Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Python tests
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
python: ['3.9', '3.12']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python }}
- run: python -m unittest discover -s skills/fork/scripts -p 'test_*.py' -v
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</p>

<p align="center">
Bring a previous T3, Grok, Claude Code, or Codex conversation into your current chat.<br>
Bring a previous T3 Code, Claude Code, or Codex conversation into your current chat.<br>
Keep your chosen model and workspace. No proxy, service, account, or API key.
</p>

Expand Down Expand Up @@ -57,7 +57,20 @@ $fork YOUR_SESSION_ID

Better Fork finds the local source conversation, reads its public user/assistant text, and recovers the task, constraints, decisions, and progress. The agent checks the relevant project files before editing and continues in your current model and workspace.

T3 thread IDs—including Grok threads—and native Claude Code or Codex session IDs are accepted. The source history must be accessible locally. Tool results, attachments, hidden reasoning, streaming output, and provider instructions are excluded from this attachment.
T3 Code thread IDs (including Grok threads) and native Claude Code or Codex session IDs are accepted. The source history must be accessible locally. Tool results, attachments, hidden reasoning, streaming output, and provider instructions are excluded from this attachment.

### Using a T3 Code thread ID

The thread ID shown by T3 Code identifies the conversation in T3 Code. It is distinct from the underlying Claude Code or Codex session ID. You can paste that T3 Code ID directly:

```text
$fork YOUR_T3_CODE_THREAD_ID
Continue this conversation here independently and leave the original chat untouched.
```

Your receiving chat is already a separate conversation. Better Fork reads the source history into it without changing the original thread or opening another app. If the source has a queued, running, or interrupted turn, it attaches through the last completed turn and tells you which unfinished content was excluded. All pages use that same completed boundary. A thread with no completed history reports that limitation.

Conversation separation does not isolate project files: two chats using the same workspace can still edit the same files. On multiple computers, run the skill where the source history is stored. Installing it on another node does not copy or synchronize your conversations.

## Choose how to continue

Expand All @@ -74,7 +87,7 @@ Different next steps need different context. Loading history into the current ch
| **Direct** | Explicit fresh-context opt-in | Transfers every reviewed public record, exactly and in order; no selector call |
| **Selector** | Explicit experimental selector opt-in | Builds a model-selected context packet with a recoverable archive of all reviewed records |

Attachment loads conversation history; it does not merge backend session identities. T3 thread IDs are attachment-only. Separate sessions require a native Claude Code or Codex ID and a supported host launch mechanism; otherwise the skill provides a handoff and says the session has not been created.
Attachment loads conversation history; it does not merge backend session identities. T3 Code thread IDs support attachment into your current chat. Creating an additional native session requires a native Claude Code or Codex ID and a supported host launch mechanism; otherwise the skill provides a handoff and says the session has not been created.

## Context for the next task

Expand Down
14 changes: 14 additions & 0 deletions skills/fork/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ This skill is normally discoverable automatically. In Codex, find skills with `/

## Choose the mode

“Fork this thread so I can continue here independently while the original stays
untouched” means current-chat attachment. This chat is already the separate
conversation. Run the history reader and continue here; do not launch another
session, browse for a fork button, or navigate to t3.chat (a different product
from T3 Code). Attachment preserves the source conversation but does not isolate
shared project files.

For attachment, a pending/running/interrupted source head is not a blocker.
The reader selects the last completed turn and returns `attachment_boundary`
and `attachment_note`. Follow `next_argv` until null to read all pages with the
same boundary. Disclose excluded unfinished turns. If reading fails,
report the concrete error without inventing a UI action or requiring a native
fork API.

- For `$fork ID`, “attach this session”, or ordinary continuation in a new chat, load the source conversation into the CURRENT chat. Keep the current model. Read the current-chat attachment section of the workflow and perform it; a native fork API is not required.
- Create a separate native session only when the user explicitly requests another session or a native fork.
- Use fresh context only when explicitly requested, or when the user clearly changes direction and opts into the experiment. It is not a native fork.
Expand Down
12 changes: 9 additions & 3 deletions skills/fork/references/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
## Current-chat attachment (default)

When the user provides an ID in a new chat, that chat is already the receiver.
Requests to continue here independently also use this route. The source chat
is untouched; shared workspace files are not automatically isolated. For a
pending or running source head, attach through the reader's returned
`attachment_boundary` and disclose its `attachment_note`.
Resolve the ID, then run `python3 "$SKILL_ROOT/scripts/read_history.py" "$SESSION_ID"`.
Read each page using `--offset` with the returned `next_offset` until null.
Run the returned `next_argv` for each page until null. For T3 Code it includes
`--through-turn`, pinning all pages to the same completed turn while the source
continues running. Do not drop that argument or reselect the boundary per page.
The helper accepts T3 thread IDs, including Grok threads, plus native Claude
Code and Codex session IDs. It extracts finalized user/assistant public text
only, excluding reasoning, provider instructions, streaming output, and tool
Expand Down Expand Up @@ -66,8 +72,8 @@ the native session ID and next task and retain its returned child ID. A generic
shared-workspace subagent is not a persistent interactive fork unless it returns
a user-addressable child session.

This route requires `native_session_available: true`. T3 thread IDs—including
Grok threads—are attachment-only unless the user separately provides a native
This route requires `native_session_available: true`. T3 thread IDs, including
Grok threads, are attachment-only unless the user separately provides a native
Claude Code or Codex session ID.

Without a callable API, provide an actionable handoff marked `not created`.
Expand Down
68 changes: 57 additions & 11 deletions skills/fork/scripts/read_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,64 @@
import argparse
import json
import sqlite3
import sys
from pathlib import Path

from fork_context import _reject_secrets
from fork_context import ForkContextError, _reject_secrets
from resolve_session import ResolveError, _json_records, _t3_connection, resolve_session


def _t3_messages(source):
def _t3_messages(source, through_turn=None):
path = source.get('source_record')
thread_id = source.get('t3_thread_id')
boundary = source.get('boundary')
boundary_turn_id = boundary.get('turn_id') if isinstance(boundary, dict) else None
if not path or not thread_id or not boundary_turn_id:
boundary_turn_id = through_turn or (boundary.get('turn_id') if isinstance(boundary, dict) else None)
head_row_id = boundary.get('row_id') if isinstance(boundary, dict) else None
if not path or not thread_id:
raise ValueError('t3_history_unavailable')

try:
connection = _t3_connection(Path(path))
except ResolveError as exc:
raise ValueError('t3_history_unavailable') from exc
try:
boundary_record = connection.execute(
connection.execute('BEGIN')
head_record = connection.execute(
"""
SELECT row_id
FROM projection_turns
WHERE thread_id = ? AND turn_id = ?
""",
(thread_id, boundary_turn_id),
).fetchone()
if boundary_record is None:
if boundary_turn_id and head_record is None:
raise ValueError('t3_history_unavailable')
if head_record is not None:
head_row_id = head_record['row_id']
boundary_record = connection.execute(
"""
SELECT row_id, turn_id, completed_at
FROM projection_turns
WHERE thread_id = ? AND state = 'completed'
AND completed_at IS NOT NULL AND turn_id IS NOT NULL
AND (? IS NULL OR row_id <= ?)
ORDER BY row_id DESC LIMIT 1
""",
(thread_id, head_row_id, head_row_id),
).fetchone()
if boundary_record is None:
raise ValueError('t3_no_completed_history')
if through_turn and boundary_record['turn_id'] != through_turn:
raise ValueError('t3_attachment_boundary_unavailable')
source['attachment_boundary'] = {
'status': 'completed', 'turn_id': boundary_record['turn_id'],
'completed_at': boundary_record['completed_at'],
}
source['attachment_note'] = (
'Attached through the last completed turn; later unfinished turns are excluded.'
if boundary_turn_id != boundary_record['turn_id'] else
'Attached through the requested completed turn.'
)

records = connection.execute(
"""
Expand Down Expand Up @@ -67,11 +96,14 @@ def _t3_messages(source):
connection.close()


def messages(source):
def messages(source, through_turn=None):
if source.get('input_kind') == 't3':
yield from _t3_messages(source)
yield from _t3_messages(source, through_turn)
return

if through_turn is not None:
raise ValueError('through_turn_requires_t3_source')

path = source.get('native_record')
if not path:
raise ValueError('native_history_unavailable')
Expand Down Expand Up @@ -107,17 +139,31 @@ def main():
parser.add_argument('session_id')
parser.add_argument('--offset', type=int, default=0)
parser.add_argument('--limit', type=int, default=10)
parser.add_argument('--through-turn', help='Pin T3 history to a completed turn returned by the first page')
args = parser.parse_args()
if args.offset < 0 or args.limit < 1:
parser.error('offset must be nonnegative and limit positive')
source = resolve_session(args.session_id)
records = list(messages(source))
try:
source = resolve_session(args.session_id)
if source.get('input_kind') == 't3' and args.offset and not args.through_turn:
raise ValueError('t3_pagination_requires_through_turn')
records = list(messages(source, args.through_turn))
except (ResolveError, ForkContextError, ValueError) as exc:
print(json.dumps({'ok': False, 'error': str(exc)}), file=sys.stderr)
return 2
end = min(len(records), args.offset + args.limit)
next_argv = None
if end < len(records):
next_argv = [sys.executable, str(Path(__file__).resolve()), args.session_id,
'--offset', str(end), '--limit', str(args.limit)]
if source.get('attachment_boundary'):
next_argv += ['--through-turn', source['attachment_boundary']['turn_id']]
print(json.dumps({'source': source, 'records': records[args.offset:end],
'next_offset': end if end < len(records) else None,
'next_argv': next_argv,
'total_records': len(records),
'coverage': 'public text only; tool results and attachments excluded'}, ensure_ascii=False))


if __name__ == '__main__':
main()
raise SystemExit(main())
7 changes: 4 additions & 3 deletions skills/fork/scripts/resolve_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ def _default_home(env_name: str, suffix: str) -> Path:

def _regular_file(path: Path) -> bool:
try:
info = path.stat(follow_symlinks=False)
info = path.lstat()
except OSError:
return False
return stat.S_ISREG(info.st_mode) and info.st_size <= MAX_INPUT_BYTES


def _t3_connection(path: Path) -> sqlite3.Connection:
try:
info = path.stat(follow_symlinks=False)
info = path.lstat()
except OSError as exc:
raise ResolveError("t3_state_unreadable") from exc
if not stat.S_ISREG(info.st_mode):
Expand Down Expand Up @@ -271,7 +271,7 @@ def _t3_resolution(identifier: str, root: Path) -> dict[str, Any] | None:

turn = connection.execute(
"""
SELECT turn_id, state, completed_at
SELECT row_id, turn_id, state, completed_at
FROM projection_turns
WHERE thread_id = ?
ORDER BY requested_at DESC, row_id DESC
Expand All @@ -292,6 +292,7 @@ def _t3_resolution(identifier: str, root: Path) -> dict[str, Any] | None:
or "t3"
)
boundary = {
"row_id": turn["row_id"] if turn is not None else None,
"status": _text(turn["state"]) if turn is not None else "unknown",
"turn_id": (
_text(turn["turn_id"])
Expand Down
70 changes: 70 additions & 0 deletions skills/fork/scripts/test_read_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
from __future__ import annotations

import json
import contextlib
import io
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

import read_history

Expand Down Expand Up @@ -112,6 +116,72 @@ def test_reads_only_finalized_public_t3_messages_in_order(self) -> None:
],
)

# A running head still permits the preceding completed conversation.
source['boundary'] = {'status': 'running', 'turn_id': 'turn-active'}
self.assertEqual(list(read_history.messages(source)), [
{'role': 'user', 'text': 'first'},
{'role': 'assistant', 'text': 'second'},
])
self.assertEqual(source['attachment_boundary']['turn_id'], 'turn-completed')

# Reproduce a queued head without a provider turn ID.
with sqlite3.connect(path) as connection:
connection.execute(
'INSERT INTO projection_turns VALUES (?, ?, ?, ?, ?, ?, ?)',
(4, thread_id, None, 'queued', None, 'pending', None),
)
source['boundary'] = {'status': 'pending', 'turn_id': None}
recovered = list(read_history.messages(source))
self.assertEqual([r['text'] for r in recovered],
['first', 'second', 'later prompt', 'later answer'])
self.assertEqual(source['attachment_boundary']['turn_id'], 'turn-after-boundary')

# The CLI supplies a replayable, pinned command for subsequent pages.
stdout = io.StringIO()
with patch.object(read_history, 'resolve_session', return_value=source), \
patch.object(sys, 'argv', ['read_history.py', thread_id, '--limit', '1']), \
contextlib.redirect_stdout(stdout):
self.assertIsNone(read_history.main())
first = json.loads(stdout.getvalue())
self.assertEqual(first['next_argv'][-2:], ['--through-turn', 'turn-after-boundary'])
stdout = io.StringIO()
with patch.object(read_history, 'resolve_session', return_value=source), \
patch.object(sys, 'argv', first['next_argv'][1:]), \
contextlib.redirect_stdout(stdout):
read_history.main()
self.assertEqual(json.loads(stdout.getvalue())['records'],
[{'role': 'assistant', 'text': 'second'}])
stderr = io.StringIO()
with patch.object(read_history, 'resolve_session', return_value=source), \
patch.object(sys, 'argv', ['read_history.py', thread_id, '--offset', '1']), \
contextlib.redirect_stderr(stderr):
self.assertEqual(read_history.main(), 2)
self.assertEqual(json.loads(stderr.getvalue())['error'],
't3_pagination_requires_through_turn')

# Pages explicitly pinned to the first completed turn ignore later work.
self.assertEqual(list(read_history.messages(source, 'turn-completed')), [
{'role': 'user', 'text': 'first'},
{'role': 'assistant', 'text': 'second'},
])
with self.assertRaisesRegex(ValueError, 't3_attachment_boundary_unavailable'):
list(read_history.messages(source, 'turn-active'))

# A resolver-captured queued row cannot include subsequently appended turns.
source['boundary'] = {'status': 'pending', 'turn_id': None, 'row_id': 2}
self.assertEqual([r['text'] for r in read_history.messages(source)], ['first', 'second'])

# Do not substitute another boundary when an explicit ID is missing.
source['boundary'] = {'status': 'completed', 'turn_id': 'missing'}
with self.assertRaisesRegex(ValueError, 't3_history_unavailable'):
list(read_history.messages(source))

with sqlite3.connect(path) as connection:
connection.execute("DELETE FROM projection_turns WHERE state = 'completed'")
source['boundary'] = {'status': 'pending', 'turn_id': None}
with self.assertRaisesRegex(ValueError, 't3_no_completed_history'):
list(read_history.messages(source))

def test_native_codex_history_remains_supported(self) -> None:
path = self.root / "codex.jsonl"
path.write_text(
Expand Down
1 change: 1 addition & 0 deletions skills/fork/scripts/test_resolve_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def test_resolves_t3_grok_thread_for_attachment(self) -> None:
self.assertEqual(
result["boundary"],
{
"row_id": 1,
"status": "completed",
"turn_id": "turn-1",
"completed_at": "2026-09-17T01:01:00Z",
Expand Down
Loading