diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0fa8472 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index 33ed37e..8f2f222 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

- Bring a previous T3, Grok, Claude Code, or Codex conversation into your current chat.
+ Bring a previous T3 Code, Claude Code, or Codex conversation into your current chat.
Keep your chosen model and workspace. No proxy, service, account, or API key.

@@ -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 @@ -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 diff --git a/skills/fork/SKILL.md b/skills/fork/SKILL.md index 6d2c3b6..5b29a5a 100644 --- a/skills/fork/SKILL.md +++ b/skills/fork/SKILL.md @@ -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. diff --git a/skills/fork/references/workflow.md b/skills/fork/references/workflow.md index cc743e3..f086bbe 100644 --- a/skills/fork/references/workflow.md +++ b/skills/fork/references/workflow.md @@ -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 @@ -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`. diff --git a/skills/fork/scripts/read_history.py b/skills/fork/scripts/read_history.py index 346e500..5a44d6c 100644 --- a/skills/fork/scripts/read_history.py +++ b/skills/fork/scripts/read_history.py @@ -3,18 +3,20 @@ 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: @@ -22,7 +24,8 @@ def _t3_messages(source): 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 @@ -30,8 +33,34 @@ def _t3_messages(source): """, (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( """ @@ -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') @@ -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()) diff --git a/skills/fork/scripts/resolve_session.py b/skills/fork/scripts/resolve_session.py index bf06adc..025b4ad 100644 --- a/skills/fork/scripts/resolve_session.py +++ b/skills/fork/scripts/resolve_session.py @@ -35,7 +35,7 @@ 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 @@ -43,7 +43,7 @@ def _regular_file(path: Path) -> bool: 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): @@ -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 @@ -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"]) diff --git a/skills/fork/scripts/test_read_history.py b/skills/fork/scripts/test_read_history.py index ded4fa1..7dd195b 100644 --- a/skills/fork/scripts/test_read_history.py +++ b/skills/fork/scripts/test_read_history.py @@ -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 @@ -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( diff --git a/skills/fork/scripts/test_resolve_session.py b/skills/fork/scripts/test_resolve_session.py index b536a26..cf5cf92 100644 --- a/skills/fork/scripts/test_resolve_session.py +++ b/skills/fork/scripts/test_resolve_session.py @@ -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",