codexteer 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +127 -14
  3. package/assets/remote-device-key.swift +94 -0
  4. package/bin/codexteer-update.mjs +3 -0
  5. package/bin/codexteer.mjs +6 -2
  6. package/docs/cloud.md +113 -0
  7. package/docs/desktop-cloud.md +36 -0
  8. package/docs/distribution.md +11 -19
  9. package/docs/orchestration.md +120 -0
  10. package/docs/remote-cli-validation.json +139 -0
  11. package/docs/remote-control.md +65 -0
  12. package/docs/remote-device-enrollment.md +91 -0
  13. package/docs/remote-observation.md +57 -0
  14. package/docs/remote-operations.md +89 -0
  15. package/docs/thread-create.md +20 -45
  16. package/package.json +11 -2
  17. package/scripts/update-desktop.applescript +51 -0
  18. package/src/agent-launch.mjs +27 -0
  19. package/src/app-server.mjs +34 -10
  20. package/src/checkpoint.mjs +52 -16
  21. package/src/cli.mjs +201 -23
  22. package/src/cloud-api.mjs +118 -0
  23. package/src/cloud-cli.mjs +319 -0
  24. package/src/cloud-connection.mjs +64 -0
  25. package/src/cloud-id.mjs +16 -0
  26. package/src/cloud-observe.mjs +178 -0
  27. package/src/cloud-send.mjs +33 -0
  28. package/src/cloud.mjs +172 -0
  29. package/src/connection.mjs +12 -1
  30. package/src/creation-verification.mjs +30 -10
  31. package/src/desktop-cloud-api.mjs +119 -0
  32. package/src/desktop-cloud-id.mjs +18 -0
  33. package/src/desktop-cloud-project.mjs +148 -0
  34. package/src/desktop-cloud.mjs +98 -0
  35. package/src/desktop.mjs +3 -1
  36. package/src/directive.mjs +5 -2
  37. package/src/distribution.mjs +2 -2
  38. package/src/evidence.mjs +3 -0
  39. package/src/findings.mjs +7 -7
  40. package/src/help.mjs +140 -28
  41. package/src/journal.mjs +37 -9
  42. package/src/launcher.mjs +16 -9
  43. package/src/monitor.mjs +18 -6
  44. package/src/native-command.mjs +10 -2
  45. package/src/native-rpc.mjs +15 -2
  46. package/src/observe.mjs +21 -7
  47. package/src/orchestrate-prompt.mjs +96 -0
  48. package/src/orchestrate.mjs +29 -0
  49. package/src/paged-read.mjs +7 -1
  50. package/src/projects.mjs +27 -2
  51. package/src/prompt-command.mjs +25 -0
  52. package/src/prompt.mjs +25 -8
  53. package/src/remote-client-info.mjs +16 -0
  54. package/src/remote-control-auth.mjs +109 -0
  55. package/src/remote-control-socket.mjs +137 -0
  56. package/src/remote-control.mjs +134 -0
  57. package/src/remote-device-key.mjs +41 -0
  58. package/src/remote-device-login.mjs +76 -0
  59. package/src/remote-device-protocol.mjs +72 -0
  60. package/src/remote-device-store.mjs +71 -0
  61. package/src/remote-device-worker.mjs +48 -0
  62. package/src/remote-device.mjs +202 -0
  63. package/src/remote-io.mjs +163 -0
  64. package/src/remote-key-access.mjs +76 -0
  65. package/src/remote-key-worker.mjs +38 -0
  66. package/src/remote-keychain.mjs +72 -0
  67. package/src/remote-management.mjs +234 -0
  68. package/src/remote.mjs +132 -0
  69. package/src/resource.mjs +8 -6
  70. package/src/runner.mjs +6 -1
  71. package/src/standalone-create.mjs +4 -3
  72. package/src/store.mjs +6 -3
  73. package/src/supervise.mjs +25 -53
  74. package/src/supervision.mjs +41 -19
  75. package/src/target.mjs +25 -0
  76. package/src/task-id.mjs +23 -0
  77. package/src/thread-create.mjs +4 -3
  78. package/src/thread-id.mjs +21 -6
  79. package/src/thread-verify.mjs +108 -0
  80. package/src/turn-repair.mjs +228 -0
  81. package/src/update.mjs +158 -0
  82. package/docs/thread-create-native-investigation.md +0 -136
@@ -0,0 +1,89 @@
1
+ # リモートのCLI操作
2
+
3
+ `--host <HOST-ID> --remote-transport paired`で、接続先のタスクをcodexteer CLIから操作できます。接続先にはCodex Desktopと公式ペアリングが必要で、codexteerやCodex CLIの追加インストールは不要です。ローカルのshared wrapperと専用鍵は従来どおり使用します。既定のnative経路は観測専用、既存Desktop鍵のcontrol経路は別機能のままです。失敗時に別ホスト・別資格情報・別backendへ自動切替しません。
4
+
5
+ ## 対応範囲
6
+
7
+ | ローカルの操作 | pairedでの動作 |
8
+ | --- | --- |
9
+ | `projects list` | 接続先の`project/list`で正式ID・全rootsを取得。Desktop登録IDも別フィールドで対応付ける |
10
+ | `threads list` | 接続先のタスクID・タイトル・所属・作業場所・hostId付きリンクを取得。ページ・検索・project指定・Desktop絞込みに対応 |
11
+ | `thread create` | Desktopの公式createで対象ホストへ作成し、pairedでcanonical所属・初期指示・全rootsを照合 |
12
+ | `thread verify` | 保存済みremote receiptを読み直して照合。作成・初期送信・所属変更は繰り返さない |
13
+ | `thread resolve` | hostIdを保持してIDを解決 |
14
+ | `read/status/watch/doctor` | 選択先の履歴・稼働状態・差分・接続を読む |
15
+ | `send` | 実行中の同じターンへ`expectedTurnId`付きで一度だけ送る |
16
+ | `send --new-turn` | idleだけを対象とし、制御側Desktopで対象チャットを開いてから同じ接続先へ`turn/start`する |
17
+ | `supervise`・findings・history・instructions | 従来のpause/stop・freshness・重複防止・受信照合を維持。記録・ロックはホストごとに分離 |
18
+ | `orchestrate` / `orchestrate prompt` | 接続先host・paired・callerを子作成/監督コマンドへ保持。エージェント自体は制御側で動く |
19
+ | `checkpoint capture/verify/attach`・ファイル根拠 | 接続先の公式`fs/*`で読む。相対入力パスは対象チャットのcwdで解決。ファイル根拠・artifactはリモート絶対パス |
20
+ | `checkpoint run`・`resource run` | defaultは接続先の`command/exec`。明示的な`thread-shell`にも対応(下記)。Macでは実行しない |
21
+ | resourceの取得・更新・解放・一覧 | ホスト別の協調記録。OSの排他制御を作るものではない |
22
+ | `open` | 制御側Desktopで指定したリモートチャットを表示 |
23
+ | `desktop start/update/update-status`・`debug-ui` | Macの起動/更新/Accessibility用で接続先へは未対応。ローカルで誤実行せず`REMOTE_HOST_OPERATION_UNSUPPORTED` |
24
+ | `thread repair` | 修復機能はローカル専用。リモート指定は`REMOTE_REPAIR_UNSUPPORTED`で明示拒否し、保存履歴やDBを変更しない |
25
+ | `--backend ui` | 接続先のUI送信は未対応。明示的に拒否する |
26
+
27
+ Macの起動・更新・Accessibilityとオフライン履歴修復は、リモート接続先へ提供していません。上表は実装範囲であり、接続先APIが全操作を許可する保証でもありません。
28
+
29
+ ## 一覧と作成
30
+
31
+ ```sh
32
+ codexteer --host remote-control:env_example --remote-transport paired projects list --json
33
+ codexteer --host remote-control:env_example --remote-transport paired threads list --limit 20 --json
34
+ codexteer --host remote-control:env_example --remote-transport paired threads list --project CANONICAL-ID --search 検証 --json
35
+ codexteer --host remote-control:env_example --remote-transport paired threads list --cursor CURSOR --json
36
+ codexteer --host remote-control:env_example --remote-transport paired --context-thread LOCAL-CALLER-ID thread create --project PROJECT-ID --title 検証専用 '初回の指示' --json
37
+ codexteer --host remote-control:env_example --remote-transport paired thread verify ATTEMPT-ID --json
38
+ ```
39
+
40
+ `project_id`は接続先App Serverの正式ID、`desktop_project_ids`は制御側Desktopの登録IDです。両者を同じIDとは扱いません。登録ID・正式ID・一意の正確な名前で作成対象を選べます。Desktopの登録とlive projectの全rootsが一意に一致しなければ作成しません。Windows/UNCをMacのパスへ変換したり、Macのrealpath/statで検査したりしません。
41
+
42
+ paired一覧の`availability: verified`は`availability_scope: project-catalog`です。ホストへ接続して正式一覧を読めた意味で、各rootの現在の存在・読み書き可否は`roots_availability: unverified`です。
43
+
44
+ 作成はDesktopの公式native APIを使うため、**実在するローカルcallerと保存済みturnが必要**です。Codex内では`CODEX_THREAD_ID`を使用し、通常の端末からは`--context-thread LOCAL-CALLER-ID`を明示します。callerの追加ターンやAIによる中継は作りません。pairedの一覧・read・通常sendにはcallerは不要です。ローカルstandalone作成は従来どおりcaller不要で、環境変数も継承しません。
45
+
46
+ `--dry-run`はnative schema・live project・登録の対応を検査するだけで、作成・送信・receipt保存をしません。作成後は実ホスト、Desktop所属とcanonical所属、タイトル、初回本文、project rootsを照合します。Desktopが付与するそのタスク固有のvisualizations rootだけは、同じprofile・履歴日・thread IDから照合して許容し、それ以外の追加rootは拒否します。
47
+
48
+ POSIXパスの大文字小文字は区別します。Windowsのドライブ付きパスとバックスラッシュのUNCパスだけは、Windowsの表記を正規化して照合します。再照合が失敗した場合はreceiptのverificationをfailedへ更新して古い認定フラグを除去し、created/acceptedは保持します。
49
+
50
+ `creation_status: created`は既存タスクがある状態です。後段の照合失敗でもそのIDとacceptedを保持します。応答不明はunknownとしてreceiptを残し、自動再作成・再送しません。異なるホストが返った場合も判明したIDを保持して停止し、そのタスクへ追加操作しません。
51
+
52
+ ## 新しいターンと監督
53
+
54
+ ```sh
55
+ codexteer --remote-transport paired --context-thread LOCAL-CALLER-ID send 'codex://threads/UUID?hostId=remote-control%3Aenv_example' '続きの指示' --new-turn --json
56
+ codexteer --remote-transport paired --context-thread LOCAL-CALLER-ID supervise prompt 'codex://threads/UUID?hostId=remote-control%3Aenv_example' --use-path-cli --json
57
+ ```
58
+
59
+ new-turnは、実在callerによるDesktop native readでthread/hostを照合し、公式navigation APIで制御側Desktopに対象チャットを表示します。通常sendと異なり画面遷移があり、そのDesktopの持続する接続で対象を開いた後、pairedでidle・freshnessを再確認して一度だけ`turn/start`します。短命なsenderだけでresumeする経路は使いません。ユーザーからの再開委任がないidleタスクを監督目的だけで再開しません。
60
+
61
+ 生成する監督・オーケストレーションコマンドはhost・transport・指定callerを保持します。停止済みの監督を別ID・unbound送信で迂回しません。承認・質問を自動回答するAPIは追加していません。画面への吹き出し表示や承認UI往復は、受付・保存・明示応答とは別の検証項目です。
62
+
63
+ ## リモートの検証コマンド
64
+
65
+ 既定の`command/exec`は接続先の設定された権限を使い、sandbox/permissionの上書きを送りません。接続先によってはこのAPIが`RPC_REJECTED`(-32600)になる場合があります。この拒否から自動で別経路を試しません。
66
+
67
+ 公式`thread/shellCommand`は**ホストのfull accessでシェルを実行**するAPIです。その意味を選べるよう、次の明示指定に限定します。
68
+
69
+ ```sh
70
+ codexteer --host remote-control:env_example --remote-transport paired resource run test-resource --owner codex --thread TEST-THREAD-ID --exec-backend thread-shell --shell powershell --cwd 'C:\Windows' --timeout-ms 10000 --include-output -- cmd.exe /d /c 'echo test'
71
+ codexteer --host remote-control:env_example --remote-transport paired checkpoint capture TEST-THREAD-ID tests --path 'C:\test\input.txt' --json
72
+ codexteer --host remote-control:env_example --remote-transport paired checkpoint run TEST-THREAD-ID CHECKPOINT-ID --exec-backend thread-shell --shell powershell --timeout-ms 10000 --include-output -- cmd.exe /d /c 'echo test'
73
+ ```
74
+
75
+ `--shell powershell|posix`が必須で、相手のshellを推測しません。resourceでは対象`--thread`も必須、checkpointは自身のthreadを使います。idle以外では実行しません。argvは選んだshellの規則で引用し、任意のshell式として連結しません。`cmd.exe /c`などに式を渡す場合は、その式自体が明示したユーザーのコマンドです。
76
+
77
+ 受け付けたという空応答だけでは成功としません。ランダムIDを含む実行項目、集約出力中の独立した1行に一度だけ現れる完了marker(stdoutのmarker後にstderrが続く場合も許容)、同じ項目のexitCodeを接続先の保存履歴で照合します。人間/モデルの「成功した」という発言では判定しません。full access・backend・実行先・終了コードを結果に表示します。制御側Macのプロセスを起動しません。
78
+
79
+ リモートのtimeout 0はthread-shellで公式APIの既定に合わせ1時間、汎用command/execで24時間として送ります(ローカルの無制限とは異なります)。明示timeoutは最大24時間です。thread-shellには対応する限定的な取消操作がないため、観測中断時に別のCodex作業を止める操作は行いません。完了を照合できない場合はunknown・再送禁止とし、resourceは期限までleaseを保持、checkpointは無効にします。承認/質問への代理回答やpermission overrideは行いません。
80
+
81
+ checkpointは接続先の`fs/watch`で途中変更を観測し、入力hashも前後照合します。symlinkのcanonical targetが分からない場合、巨大な入力、読取/監視失敗は有効な検証にしません。全体の厳密固定が必要なら隔離した入力を使います。監督ID付きthread-shellは、準備後の送信直前に同じsession・connection・transportとactive状態を再確認し、受付応答までpause/stopと同じロックを保持します。先にpause/stopが完了すれば送信しません。送信中のpause/stopは`RECORD_BUSY`で失敗し、停止済みという誤った応答を返しません。受付応答後はロックを解放するためpause/stopできますが、すでに受け付けたコマンドの取消・終了は保証しません。応答不明でも自動再送せず、観測とleaseの扱いは上記に従います。
82
+
83
+ ## 公開検証情報
84
+
85
+ 回帰テストは架空の識別子・ホスト・一時プロファイルを使います。実アカウントの登録情報、接続先一覧、チャットやプロジェクトの実ID、業務タイトル、実行ログは公開しません。テスト件数、pause競合のローカル再現、依存DB照合の要約は`remote-cli-validation.json`を参照してください。
86
+
87
+ `native`は観測専用、`control`は既存のactiveターンへの送信、`paired`は明示した管理操作を扱います。正式project IDとDesktop登録IDは別の識別子です。Desktopの承認・質問UIや表示は、RPC受付とは別に利用環境で確認してください。
88
+
89
+ 監督のpause/resume/stopでも、生成された`--host`と`--remote-transport`を保持してください。登録と異なるtransportの制御は`SUPERVISOR_MISMATCH`で拒否し、保存状態を変更しません。
@@ -1,12 +1,14 @@
1
1
  # Desktopプロジェクトにチャットを直接作成する
2
2
 
3
- `projects list`でローカルプロジェクトを確認し、`thread create`で初回指示付きのチャットを作成します。Desktopのnative RPCを使い、中継AIは実装から削除しました。「競馬」のような複数フォルダ・共有フォルダを持つプロジェクトにも対応します。
3
+ 本書のcaller不要standalone方式はローカル向けです。`--host ... --remote-transport paired thread create`のリモート作成・正式project ID・caller要件・host照合は[リモートCLI操作](remote-operations.md)を参照してください。
4
+
5
+ `projects list`でローカルプロジェクトを確認し、`thread create`で初回指示付きのチャットを作成します。Desktopのnative RPCを使い、中継AIは実装から削除しました。「サンプル」のような複数フォルダ・共有フォルダを持つプロジェクトにも対応します。
4
6
 
5
7
  ```bash
6
8
  codexteer projects list --json
7
- codexteer thread create --project '競馬' \
9
+ codexteer thread create --project 'サンプル' \
8
10
  --title '関連リポジトリの調査' '関連リポジトリを読み、結果を報告してください' --json
9
- codexteer thread create --project '競馬' \
11
+ codexteer thread create --project 'サンプル' \
10
12
  --dry-run '調査してください' --json
11
13
  ```
12
14
 
@@ -29,11 +31,11 @@ terminal CLI → App Server thread/start(選択プロジェクトの全フォ
29
31
  → Desktopの所属サービスで登録・表示更新
30
32
  ```
31
33
 
32
- App Serverはnative操作用にcodexteer同梱の固定helperだけを起動します。native引数は所有者専用の一時JSONファイルで渡し、shellやargvへ埋め込みません。helperの許可操作はcatalog、list_projects、ローカルcreate_thread、新規チャット自身のmove_thread_to_projectだけです。新規チャットの初回処理以外のモデル推論は発生しません。
34
+ App Serverはnative操作用にcodexteer同梱の固定helperだけを起動します。native引数は所有者専用の一時JSONファイルで渡し、shellやargvへ埋め込みません。helperの許可操作はcatalog、list_projects、固定上限のlist_threads、引数制限付きread_thread、ローカルcreate_thread、新規チャット自身のmove_thread_to_project、正確なthread IDだけを受け付けるnavigate_to_codex_pageです。navigate_to_codex_pageはpairedの購読確立のため制御側Desktopで対象リモートチャットを開きます。呼出し前に一覧とnative readでthread/hostを照合します。read_threadは[リモート観測](remote-observation.md)用で、作成先の範囲を広げません。新規チャットの初回処理以外のモデル推論は発生しません。
33
35
 
34
36
  作成前に、実行中サーバーと同じ同梱CLIからcommand/execとread-only sandboxのschemaを確認します。helperはDesktopのpeer認証を通過する署名付きApp Serverの正規の子として実行されます。Unix socketへ接続するためsandboxのnetworkAccessはtrueですが、helperにHTTP/DNSや外部サービスへの接続処理はありません。認証チェックや署名は変更しません。
35
37
 
36
- codexteerは選択プロジェクトからプライマリcwdと全workspace rootsを解決します。名前とcanonical所属をAPIで保存し、Desktopの購読を確立します。初回指示の前に範囲と空の履歴を確認します。初回受付後、Desktopのnative所属サービスを呼び、スケジュールと同じ所属ストアと表示へ反映します。作成応答だけを成功の証拠にせず、保存されたDesktop所属、canonical projectId、全フォルダ、userMessageとして保存された初回指示の完全一致とタイトルを照合します。関連付けを明示したnative作成の場合は、`codex_delegation`の呼出し元IDと本文を含むliteral全文を比較します。XMLとして解釈しません。
38
+ codexteerは選択プロジェクトからプライマリcwdと全workspace rootsを解決します。名前とcanonical所属をAPIで保存し、Desktopの購読を確立します。初回指示の前に範囲と空の履歴を確認します。初回受付後、Desktopのnative所属サービスを呼び、スケジュールと同じ所属ストアと表示へ反映します。作成応答だけを成功の証拠にせず、Desktop native一覧のthread ID・local host・Desktop project ID、canonical projectId、全フォルダ、userMessageとして保存された初回指示の完全一致とタイトルを照合します。旧global stateファイルの`thread-project-assignments`だけを現在の所属とはみなしません。関連付けを明示したnative作成の場合は、`codex_delegation`の呼出し元IDと本文を含むliteral全文を比較します。XMLとして解釈しません。
37
39
 
38
40
  受付が履歴の永続化より早い場合は、最大5秒間、読取りだけで反映を待ちます。作成や初回ターンを再実行しません。standaloneではcanonical所属をthread/startで指定し、購読前にmetadata APIでも保存します。関連付けを明示したnative作成では必要に応じてmetadata APIでcanonical所属を登録します。Desktopの状態ファイル・DBを直接書き換えません。
39
41
 
@@ -45,11 +47,11 @@ CLI終了後の承認はDesktopが担当します。codexteerは承認を自動
45
47
  - standaloneで`--title`を省略すると、初回指示の空白をまとめた先頭80文字を名前にします。タイトル生成用のAIは起動しません。対象サーバーでは未使用の空チャットをDesktopが購読するために、先に名前を保存して履歴を永続化する必要があります。
46
48
  - cwd・permissions・sandbox・approval policy・approvals reviewerの上書きはnative schemaにないため、指定すると作成前に拒否します。
47
49
  - `--via-thread`のAI中継経路は削除しました。自動fallbackもありません。
48
- - 対応先は登録済みローカルプロジェクトです。worktree、remote、ChatGPT cloudは未対応です。
50
+ - 本書のstandalone方式は登録済みローカルプロジェクト向けです。worktreeやクラウド実行はこの方式では扱いません。Codex Cloudには`--connection cloud thread create --project <ENV_ID>`で同じ作成操作を使えます。環境一覧・監督を含む[Cloud操作](cloud.md)を参照してください。
49
51
  - `--timeout-ms`は各RPCの待機期限で、既定120000、1000〜600000です。
50
52
  - `--dry-run`は接続・プロジェクト・native schemaを読み、チャット・指示・receiptを作りません。
51
53
 
52
- native RPCはDesktop内部のversion依存APIです。この実装の実測はDesktop `26.1007.30912`と`26.1007.71326` / 同梱CLI `0.162.0-alpha.17.2`です。未対応schema、unsafe socket、変更されたendpoint、認証拒否では止めます。
54
+ native RPCはDesktopのバージョンに依存します。未対応schema、unsafe socket、変更されたendpoint、認証拒否では止めます。
53
55
 
54
56
  wrapperはnative endpointを所有者専用runtimeに公開します。公開されていない旧wrapperでは、選択済み・所有者検証済みPIDと同梱Node実行ファイルを確認し、継承環境のnative endpointだけを取得します。環境全体を出力・保存しません。
55
57
 
@@ -67,49 +69,22 @@ wrapperはnative endpointを所有者専用runtimeに公開します。公開さ
67
69
  - `verification_status: verified`と所属・初回指示の個別verifiedが、照合完了の証拠です。
68
70
  - 最終receipt保存だけ失敗した場合は`receipt_update_required`を返します。受付済み操作を再送可能な失敗に変換しません。
69
71
 
70
- ## 実機検証 (2026-10-14 JST)
71
-
72
- - 直接作成と承認: `01a135f5-85aa-7410-a0cf-1a0eed53037f`、attempt `b7479787-a995-4566-a1d0-5f05a8bc6035`。native CLIは終了済みでもDesktopで`waitingOnApproval`が継続。Desktop一覧のprojectIdは「競馬」の`ad16728e-81d9-499f-81c8-fdf13f0f9d89`。初回commentaryに`CODEXTEER_NATIVE_INITIAL_INSTRUCTION_OK`。
73
- - ユーザーの「今回だけ承認」後、`printf CODEXTEER_NATIVE_APPROVAL_OK`がexit 0、最終応答`CODEXTEER_NATIVE_APPROVAL_ROUNDTRIP_OK`を確認。
74
- - この最初の試行は受付直後の履歴照合が早すぎ、CLIがcreated/acceptedの部分成功を返した。反映待ちを修正し、同じ子ID・同じattemptを読み取り照合してreceiptを確定。再作成・再送していない。
75
- - 修正後CLI全体: `01a135f9-3974-7983-a756-5d6b31968fd0`、attempt `daf87c13-7df3-45b0-acb6-4e7ea56f32ef`。Desktop所属・canonical所属・7フォルダ・初回指示・タイトルの照合を完了しexit 0。最終応答`CODEXTEER_NATIVE_CREATE_VERIFIED`。
76
- - contextは`01a135b4-b84b-7530-9c25-5bde83077c9e`の既存completedターン。終了後もidle、turn数1、latest turnは`01a135b4-bac5-7f62-9014-d109c5a37b92`。中継AIターンは発生していない。
77
- - 稼働中wrapperを中断しないため、実Desktopの作成試験は同梱NodeでCLIを直接実行した。当時のcontrol経路はローカルsocket試験までで、その後の実機確認で修正しました。現行経路と結果は次節を参照。
78
-
79
- これらは特定Desktop/CLIの実測で、将来のリリースや全承認種別の認定ではありません。[調査時の経路とコード根拠](thread-create-native-investigation.md)も参照してください。
80
-
81
- ## 再起動後の通常CLI検証 (2026-10-15 JST)
82
-
83
- Desktop `26.1007.71326` / 同梱CLI `0.162.0-alpha.17.2`で実測しました。
72
+ ## 作成済みreceiptの再照合
84
73
 
85
- - 最初の再起動後、旧native_tools_rpc:1のwrapper自身の接続は`missing-code-signing-identity`で拒否され、dry-runで停止。チャットは作成していません。
86
- - 接続元の起動経路を修正し、既存App Serverのcommand/execから固定の署名付きhelperをread-only sandboxで起動する方式に切り替えました。追加の再起動なしで通常CLIのdry-runが成功しました。
87
- - 通常の`codexteer thread create --project 競馬 --context-thread ...`がexit 0。作成IDは`01a13e01-2d5a-7bc2-a752-dbdddda9d1ea`、attemptは`68b4064b-eabc-444b-8607-935e66ebbb62`です。Desktop一覧の「競馬」所属、canonical所属、7フォルダ、タイトル、初回指示を全てverifiedで返しました。
88
- - 初回commentaryに`CODEXTEER_STANDARD_CLI_INITIAL_OK`、printfがexit 0、最終応答`CODEXTEER_STANDARD_CLI_ROUNDTRIP_OK`。この時のDesktop設定はauto_reviewでした。
89
- - 手動承認は同じ検証チャットだけをapprovalsReviewer:user / on-request / read-onlyへ変更した後続ターン`01a13e03-559a-7f12-9d9f-2d7650c52ae8`で確認しました。他チャットやプロジェクト設定は変更していません。
90
- - このターンの短命senderは`2026-10-15T05:22:48.359Z`に終了し、その後もDesktopのwaitingOnApprovalが継続。ユーザーの「今回だけ承認」後、`printf CODEXTEER_STANDARD_CLI_HUMAN_APPROVAL_OK`がexit 0、最終応答`CODEXTEER_STANDARD_CLI_HUMAN_ROUNDTRIP_OK`を確認しました。
91
- - 呼出し元`01a135b4-b84b-7530-9c25-5bde83077c9e`は元のcompletedターン1件のままです。中継AIや追加ターンは発生していません。
92
-
93
- ## 回帰・配布検証
94
-
95
- native直接作成の時点では`npm run check`成功、`node --test --test-concurrency=1`は320件中318成功・2skip・失敗0。配布テストもPASSで、76ファイルのpack、publish dry-run、offline install、npm/npx起動を確認しました。公開は行っていません。
74
+ ```bash
75
+ codexteer thread verify <ATTEMPT-ID> --json
76
+ ```
96
77
 
97
- 並列実行では既存checkpointのファイル監視テストが一度失敗しました。同テストの単独実行と全体の逐次実行は成功しています。今回のnative helper関連テストは全て成功しています。
78
+ `creation_status: created`、`sent: true`、`delivery_status: accepted`を持つschema 2/3のreceiptを、同じCODEX_HOMEで再照合します。作成・購読・移動・metadata更新・指示送信は行いません。新しいthread/turnやAI処理も発生しません。更新するのはcodexteer自身のreceiptだけで、以前の照合状態・エラーと今回の全チェックを`verification_history`へ残します。unknown/dispatching/未受付をこのコマンドで成功扱いにはしません。
98
79
 
99
- ## 呼出し元なし・最小引数での検証 (2026-10-15 JST)
80
+ Desktop native所属、現在のプロジェクトID対応と全フォルダ、threadのcanonical所属・cwd・全フォルダ・指定タイトルに加え、保存された初回本文のSHA-256とUnicode文字数をreceiptと比較します。standaloneでは初回turn IDも一致が必要です。初回本文を再度渡す必要はありません。関連付けを明示した場合は既知の呼出し元を含むliteral envelopeから本文を取り出し、XMLとして解釈しません。
100
81
 
101
- `env -u CODEX_THREAD_ID codexteer thread create --project 競馬 '初回指示' --json`で実測しました。`--context-thread`、`--title`、モデル・権限上書きは指定していません。
82
+ native一覧はDesktopの実schemaに従い最新50件と全pinnedです。対象が見つからない、別host/projectに所属する、重複して見える、schemaやruntimeが変わる場合は成功と推測しません。失敗の`verification_checks`を確認してください。`--timeout-ms`は各RPCの期限です。`--supervisor`と観測専用の`--connection desktop`からは実行できません。保存済みCLIは変更しないため、この機能には修正版CLIが必要です。
102
83
 
103
- - 最終CLIはexit 0。threadは`01a13e22-e9db-71a3-b0d2-703586bf84d5`、attemptは`ed2dc9ec-ac30-445c-9586-dbfcd336179e`。Desktopの「競馬」所属、canonical所属、全7フォルダ、初回指示と名前がverifiedです。
104
- - `context_thread_id` / `context_turn_id`はnull。native所属登録のcontextは新規thread自身と、その実際の初回ターン`01a13e22-ebfa-7541-b7a1-789676672435`です。既存チャットの読取りやAIターンは不要です。
105
- - 初回ターンはcompleted、最終応答`CODEXTEER_PROJECT_PROMPT_ONLY_FINAL_OK`。初回指示は通常のuserMessageとして完全一致し、作成時に開始したターンはこの1件だけです。
106
- - タイトル未指定の試験で、初回ターン前の未保存チャットはDesktop購読やmetadata保存を拒否される条件が見つかりました。名前を先にAPIで保存し、canonical所属を保存してから購読するよう修正しました。途中の2試行はcreated/not_sentのreceiptを保持しています。`95bdf33c-7580-40da-90e2-a2882f4b83af`は履歴0件を確認してアーカイブしました。`cf88c307-7e8d-4140-a074-ed25fa551916`は作成後の読取りをAPIが拒否したため状態を断定せず、receiptを保持しています。いずれも初回指示の送信前で停止し、再送していません。
107
- - 承認試験は同じ最終チャットの後続ターン`01a13e23-e670-7e60-8107-5317fbbca458`のみをuser/on-request/read-onlyにして実施。作成時のDesktop購読を再利用し、新たな購読は行っていません。短命senderは`2026-10-15T05:58:22.584Z`に終了し、その後もDesktopでwaitingOnApprovalが継続しました。ユーザーの「今回だけ承認」後、`printf CODEXTEER_STANDALONE_HUMAN_APPROVAL_OK`がexit 0、最終応答`CODEXTEER_STANDALONE_HUMAN_ROUNDTRIP_OK`、ターンcompletedを確認しました。所属・7フォルダ・初回指示も再照合し、同じattemptのreceiptをhuman_approval_roundtrip:verifiedへ更新しました。
108
- - 全体の逐次テストは327件中325成功・2skip・失敗0、`npm run check`と`git diff --check`も成功しました。sandbox内のNode 25によるUnix socket試験は実行環境のassertで終了したため、ローカルsocketを許可した環境で実行・確認しました。
109
- - 更新後の配布テストもPASS。77ファイルのpack、publish dry-run、offline install、npm exec/npx、保存CLIと混在バージョンの回帰を確認しました。公開は行っていません。
84
+ ## 検証と互換性
110
85
 
111
- ## PRレビューでの修正 (0.17.0公開候補)
86
+ 作成とreceipt照合の回帰テストは、架空のプロジェクト・識別子・一時プロファイルを使います。未知の応答、別プロジェクト、追加root、初回指示の不一致では停止し、作成や配送を繰り返しません。実環境で使う前に、その環境のDesktopとCLIでschema・所属・購読・承認動作を確認してください。
112
87
 
113
- 内部RPCエラーを実際の`RpcFailure`型で模擬すると、処理が実行された可能性があっても`not_created` / `not_sent`になる問題が再現しました。内部エラー(`-32603`など)は`unknown`を保持し、メソッド・引数エラー(`-32601` / `-32602`)だけを実行前の拒否として扱うよう修正しています。standaloneの作成・初回配送、native helperと明示的context作成の回帰テストを追加しました。未確認の操作を自動再実行する処理は追加していません。
88
+ この文書のプロジェクト名とIDは説明用の例です。実際のチャットID、プロジェクト一覧、ローカルパス、実行ログは公開資料に含めません。
114
89
 
115
- 公開前の全回帰で、既存のcheckpoint試験がmacOSの監視通知の到着順に依存して失敗することを変更前のmainでも確認しました。edit-and-restore試験は監視イベントまたはスナップショットのメタデータ差分で対象ファイルの変更が検出されたことを確認し、無効化されるという契約を維持しています。checkpointの実装は変更していません。
90
+ DesktopのCloud環境構成で作成する場合は[Desktop Cloud](desktop-cloud.md)を参照してください。環境選択とローカルDesktopプロジェクト所属は別で、通常のリモートCloud環境への初回所属登録は未対応です。Desktop自身の実行環境に限定した署名付きAPIの対応範囲は、Desktop Cloudの説明を参照してください。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexteer",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Send steering messages to running Codex Desktop threads from a terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "check": "node scripts/check-syntax.mjs",
11
11
  "test": "node --test",
12
12
  "test:protocol": "node scripts/probe-app-server.mjs",
13
+ "test:remote-control": "node scripts/probe-remote-control.mjs",
13
14
  "test:connection": "node scripts/probe-desktop-connection.mjs",
14
15
  "benchmark:read": "node scripts/benchmark-read.mjs",
15
16
  "test:package": "node scripts/test-package.mjs"
@@ -26,14 +27,22 @@
26
27
  "url": "git+https://github.com/vinhphatfsg/codexteer.git"
27
28
  },
28
29
  "files": [
30
+ "docs/cloud.md",
31
+ "docs/desktop-cloud.md",
29
32
  "bin/*.mjs",
30
33
  "src/*.mjs",
31
34
  "scripts/*.applescript",
32
35
  "assets/send-pururu.wav",
36
+ "assets/remote-device-key.swift",
33
37
  "CHANGELOG.md",
34
38
  "docs/distribution.md",
39
+ "docs/orchestration.md",
35
40
  "docs/thread-create.md",
36
- "docs/thread-create-native-investigation.md"
41
+ "docs/remote-observation.md",
42
+ "docs/remote-control.md",
43
+ "docs/remote-device-enrollment.md",
44
+ "docs/remote-operations.md",
45
+ "docs/remote-cli-validation.json"
37
46
  ],
38
47
  "bundleDependencies": [
39
48
  "ws"
@@ -0,0 +1,51 @@
1
+ -- Match known update labels inside the fixed Codex application process.
2
+ -- This does not authenticate a Sparkle window. Nonmatching controls and OS
3
+ -- permission prompts are left to the user.
4
+ on run argv
5
+ set operation to item 1 of argv
6
+ tell application "System Events"
7
+ if not (exists (first application process whose bundle identifier is "com.openai.codex")) then return "absent"
8
+ set appProcess to first application process whose bundle identifier is "com.openai.codex"
9
+ tell appProcess
10
+ if operation is "preflight" or operation is "check" then
11
+ repeat with entry in menu items of menu 1 of menu bar item 2 of menu bar 1
12
+ if name of entry is in {"Check for Updates…", "Check for Updates...", "更新を確認…", "更新を確認..."} then
13
+ if not enabled of entry then error "The update menu is disabled."
14
+ if operation is "check" then click entry
15
+ return "checking"
16
+ end if
17
+ end repeat
18
+ error "Supported Codex update menu not found (English/Japanese supported)."
19
+ end if
20
+ if operation is not "advance" and operation is not "inspect" then error "Unknown updater operation."
21
+ -- Do not retain System Events' name-based window references: multiple
22
+ -- Codex windows may have the same title. Resolve positional references
23
+ -- afresh, and tolerate only stale AX elements during this polling pass.
24
+ repeat with windowIndex from 1 to count of windows
25
+ try
26
+ repeat with buttonIndex from 1 to count of buttons of window windowIndex
27
+ try
28
+ set buttonName to name of button buttonIndex of window windowIndex
29
+ if buttonName is in {"Install and Relaunch", "インストールして再起動", "Install Update", "アップデートをインストール"} then
30
+ if enabled of button buttonIndex of window windowIndex then
31
+ if operation is "advance" then click button buttonIndex of window windowIndex
32
+ if buttonName is in {"Install and Relaunch", "インストールして再起動"} then return "installing"
33
+ return "downloading"
34
+ end if
35
+ end if
36
+ on error errorMessage number errorNumber
37
+ if errorNumber is not -1719 and errorNumber is not -1728 then error errorMessage number errorNumber
38
+ end try
39
+ end repeat
40
+ repeat with textIndex from 1 to count of static texts of window windowIndex
41
+ set messageText to value of static text textIndex of window windowIndex as text
42
+ if messageText contains "You’re up to date!" or messageText contains "currently the newest version available" or messageText contains "最新版を使用しています!" or messageText contains "は現在入手できる最新バージョンです" then return "up_to_date"
43
+ end repeat
44
+ on error errorMessage number errorNumber
45
+ if errorNumber is not -1719 and errorNumber is not -1728 then error errorMessage number errorNumber
46
+ end try
47
+ end repeat
48
+ end tell
49
+ end tell
50
+ return "waiting"
51
+ end run
@@ -0,0 +1,27 @@
1
+ import { spawn } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import { constants } from "node:os";
4
+
5
+ function launchFailure(agent, error) {
6
+ return Object.assign(new Error(`Could not start ${agent} (${error.code ?? "START_FAILED"}). Check that it is installed and executable on PATH.`), { code: error.code ?? "START_FAILED" });
7
+ }
8
+
9
+ export async function launchAgent(agent, agentArgs, prompt) {
10
+ let child;
11
+ try {
12
+ // Keep forwarded argv intact. The agent's own terminator keeps its variadic
13
+ // options from consuming the generated prompt as another option value.
14
+ child = spawn(agent, [...agentArgs, "--", prompt], { shell: false, stdio: "inherit" });
15
+ } catch (error) { throw launchFailure(agent, error); }
16
+
17
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
18
+ const handlers = signals.map(signal => () => {
19
+ if (child.exitCode === null && child.signalCode === null) child.kill(signal);
20
+ });
21
+ signals.forEach((signal, index) => process.on(signal, handlers[index]));
22
+ try {
23
+ const [code, signal] = await once(child, "exit");
24
+ return code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1);
25
+ } catch (error) { throw launchFailure(agent, error); }
26
+ finally { signals.forEach((signal, index) => process.off(signal, handlers[index])); }
27
+ }
@@ -1,3 +1,5 @@
1
+ import { isDesktopCloudTask } from "./desktop-cloud-id.mjs";
2
+ import { sendDesktopCloudInput } from "./desktop-cloud.mjs";
1
3
  import { mkdir, rm, readFile, writeFile } from "node:fs/promises";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import path from "node:path";
@@ -7,9 +9,14 @@ import { normalizeThreadId } from "./thread-id.mjs";
7
9
  import { ensureDesktopSubscription } from "./subscription.mjs";
8
10
  import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
9
11
  import { turnMetadata } from "./paged-read.mjs";
12
+ import { targetHost, remoteReadOnly, remoteTransport, hostNamespace, hostFields, isControlTransport } from "./target.mjs";
13
+ import { remoteControlConnection, remoteNewTurnUnsupported } from "./remote-control.mjs";
14
+ import { ensureRemoteDesktopSubscription } from "./remote-management.mjs";
15
+ import { isCloudTask } from "./task-id.mjs";
16
+ import { sendCloudInput } from "./cloud-send.mjs";
10
17
 
11
18
  export class SendFailure extends Error {
12
- constructor(message, { code = "NOT_SENT", uncertain = false, threadId, rpcCode, capability, operation } = {}) {
19
+ constructor(message, { code = "NOT_SENT", uncertain = false, threadId, rpcCode, capability, operation, remoteControlAuth } = {}) {
13
20
  super(message);
14
21
  this.code = code;
15
22
  this.delivery_status = uncertain ? "unknown" : "not_sent";
@@ -18,12 +25,22 @@ export class SendFailure extends Error {
18
25
  this.rpc_code = rpcCode;
19
26
  this.capability = capability;
20
27
  this.operation = operation;
28
+ this.remote_control_auth = remoteControlAuth;
21
29
  }
22
30
  }
23
31
 
24
32
  function activeTurn(thread) {
33
+ if (thread.status?.type !== "active") return null;
34
+ if (thread.historyMode === "paginated") {
35
+ // readSendState obtains every page in ascending order. Codex may also
36
+ // report orphaned historical turns as inProgress while a new turn runs.
37
+ // The latest turn is only a candidate: turn/steer.expectedTurnId must
38
+ // still match the actual runtime turn. Never fall back to an older ID.
39
+ const latest = thread.turns?.at(-1);
40
+ return latest?.status === "inProgress" ? latest : null;
41
+ }
25
42
  const turns = thread.turns?.filter(turn => turn.status === "inProgress") ?? [];
26
- return thread.status?.type === "active" && turns.length === 1 ? turns[0] : null;
43
+ return turns.length === 1 ? turns[0] : null;
27
44
  }
28
45
 
29
46
  function verifyThread(thread, threadId) {
@@ -57,7 +74,7 @@ export async function sendOnClient(client, threadId, message, { newTurn = false,
57
74
  const input = [{ type: "text", text: message }];
58
75
  if (!newTurn) {
59
76
  const turn = activeTurn(thread);
60
- if (!turn) throw new Error("The task has no unambiguous active turn. Use --new-turn for an idle task.");
77
+ if (!turn) throw new Error("The task has no eligible active turn. Read it again before sending; use --new-turn only for an idle task.");
61
78
  await beforeSend?.(thread, client);
62
79
  // Desktop uses clientId on the resulting userMessage to render external
63
80
  // steering as a user bubble instead of just an anonymous "steered" marker.
@@ -84,10 +101,17 @@ export async function sendOnClient(client, threadId, message, { newTurn = false,
84
101
  return { turn_id: accepted.turn.id, client_message_id: clientUserMessageId };
85
102
  }
86
103
 
87
- export async function sendAppServerMessage(threadInput, message, { dryRun = false, newTurn = false, clientMessageId = randomUUID(), beforeSend, connect = RpcClient.connect, discover = discoverRuntime, subscribe = ensureDesktopSubscription, inspectControl = verifyControlEndpoint } = {}) {
104
+ export async function sendAppServerMessage(threadInput, message, { dryRun = false, newTurn = false, clientMessageId = randomUUID(), beforeSend, connect, discover, subscribe = ensureDesktopSubscription, inspectControl = verifyControlEndpoint } = {}) {
105
+ if (isDesktopCloudTask(threadInput)) return sendDesktopCloudInput(threadInput, message, { dryRun, newTurn, clientMessageId, beforeSend });
106
+ if (isCloudTask(threadInput)) return sendCloudInput(threadInput, message, { dryRun, newTurn, clientMessageId, beforeSend });
107
+ const remote = targetHost() !== "local";
108
+ if (remote && !isControlTransport()) throw remoteReadOnly();
109
+ if (remote && newTurn && remoteTransport() !== "paired") throw remoteNewTurnUnsupported();
110
+ const route = remote ? remoteControlConnection() : {};
111
+ connect ??= route.connect ?? RpcClient.connect; discover ??= route.discover ?? discoverRuntime;
88
112
  const threadId = normalizeThreadId(threadInput);
89
113
  if (typeof message !== "string" || !message.trim()) throw new Error("Message must not be empty.");
90
- const plan = { thread_id: threadId, backend: "app-server", delivery_action: newTurn ? "new-turn" : "steer", message_characters: [...message].length, dry_run: dryRun };
114
+ const plan = { thread_id: threadId, ...hostFields(), backend: "app-server", delivery_action: newTurn ? "new-turn" : "steer", message_characters: [...message].length, dry_run: dryRun };
91
115
  if (dryRun) return { ...plan, sent: false, delivery_status: "not_sent" };
92
116
  let client;
93
117
  let lock;
@@ -97,8 +121,8 @@ export async function sendAppServerMessage(threadInput, message, { dryRun = fals
97
121
  try {
98
122
  const { paths, state } = await discover();
99
123
  assertRuntimeOperation(state, operation);
100
- if (newTurn) await inspectControl(paths);
101
- const candidate = path.join(paths.lease, `send-${threadId}`);
124
+ if (newTurn && !remote) await inspectControl(paths);
125
+ const candidate = path.join(paths.lease, `send-${remote ? `${hostNamespace()}-` : ""}${threadId}`);
102
126
  try { await mkdir(candidate, { mode: 0o700 }); } catch (error) {
103
127
  if (error.code === "EEXIST") throw new Error("Another send is in progress for this task, or a previous sender exited unexpectedly. Restart the shared Desktop after checking delivery before retrying.");
104
128
  throw error;
@@ -111,18 +135,18 @@ export async function sendAppServerMessage(threadInput, message, { dryRun = fals
111
135
  if (current.state.instance !== state.instance || current.paths.socket !== paths.socket || (newTurn && current.paths.control !== paths.control)) {
112
136
  throw Object.assign(new Error("Runtime changed before delivery. Read the target again before sending."), { code: "RUNTIME_CHANGED" });
113
137
  }
114
- if (newTurn) await inspectControl(current.paths);
138
+ if (newTurn && !remote) await inspectControl(current.paths);
115
139
  };
116
140
  const receipt = await sendOnClient(client, threadId, message, { newTurn, clientMessageId,
117
141
  beforeSend: async (...args) => { await beforeSend?.(...args); await checkRuntime(); },
118
- subscribe: async id => { await checkRuntime(); return subscribe(paths.control, id); },
142
+ subscribe: async id => { await checkRuntime(); return remote ? ensureRemoteDesktopSubscription(id) : subscribe(paths.control, id); },
119
143
  });
120
144
  result = { ...plan, ...receipt, sent: true, delivery_status: "accepted" };
121
145
  return result;
122
146
  } catch (error) {
123
147
  throw new SendFailure(error.uncertain
124
148
  ? "Delivery is unknown because App Server did not confirm receipt. Check the target task before retrying."
125
- : error.message, { code: error.code ?? "NOT_SENT", uncertain: error.uncertain, threadId, rpcCode: error.rpcCode, capability: error.capability, operation: error.operation });
149
+ : error.message, { code: error.code ?? "NOT_SENT", uncertain: error.uncertain, threadId, rpcCode: error.rpcCode, capability: error.capability, operation: error.operation, remoteControlAuth: error.remote_control_auth });
126
150
  } finally {
127
151
  // Cleanup failure must never turn an acknowledged send into a retryable
128
152
  // error. Keep its acceptance receipt and leave any stale lock fail-closed.
@@ -4,14 +4,17 @@ import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { fingerprintFile, collectEvidence, verifyEvidence } from "./evidence.mjs";
6
6
  import { digest } from "./observe.mjs";
7
- import { normalizeThreadId } from "./thread-id.mjs";
7
+ import { normalizeTaskId } from "./task-id.mjs";
8
8
  import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
9
9
  import { runCommand } from "./runner.mjs";
10
+ import { targetHost } from "./target.mjs";
11
+ import { remoteCaptureInputs, remoteCheckpointScope, remoteWatchInputs } from "./remote-io.mjs";
10
12
 
11
13
  const within = (file, root) => file === root || file.startsWith(root + path.sep);
12
14
  function excluded(file, excludes) { return file.split(path.sep).includes(".git") || excludes.some(x => within(file, x)); }
13
15
 
14
16
  export async function captureInputs(roots, excludes = [], { allowMissing = false } = {}) {
17
+ if (targetHost() !== "local") return remoteCaptureInputs(roots, excludes, { allowMissing });
15
18
  const records = new Map();
16
19
  async function visit(file) {
17
20
  if (excluded(file, excludes) || records.has(file)) return;
@@ -38,18 +41,20 @@ function differences(before, after) {
38
41
  }
39
42
 
40
43
  export async function getCheckpoint(threadInput, id, storage = {}) {
41
- const threadId = normalizeThreadId(threadInput), entry = await readRecord("checkpoints", id, storage);
44
+ const threadId = normalizeTaskId(threadInput), entry = await readRecord("checkpoints", id, storage);
42
45
  if (!entry || entry.thread_id !== threadId || entry.id !== id) throw new Error("Checkpoint not found for this task.");
43
46
  return entry;
44
47
  }
45
48
 
46
49
  export async function createCheckpoint(threadInput, name, { paths, excludes = [], cwd = process.cwd(), ...storage }) {
47
- const threadId = normalizeThreadId(threadInput);
50
+ const threadId = normalizeTaskId(threadInput);
48
51
  if (!name?.trim() || !paths?.length) throw new Error("capture requires a name and at least one --path.");
49
- const roots = [...new Set(paths.map(p => path.resolve(cwd, p)))], omit = excludes.map(p => path.resolve(cwd, p));
52
+ const scope = targetHost() === "local" ? { cwd: path.resolve(cwd), roots: [...new Set(paths.map(p => path.resolve(cwd, p)))], excludes: excludes.map(p => path.resolve(cwd, p)) }
53
+ : await remoteCheckpointScope(threadId, paths, excludes);
54
+ const { roots, excludes: omit } = scope;
50
55
  const snapshot = await captureInputs(roots, omit);
51
56
  if (!snapshot.files.some(f => f.type === "file")) throw new Error("No input files were selected.");
52
- const entry = { schema: 1, id: randomUUID(), thread_id: threadId, name, cwd: path.resolve(cwd), created_at: new Date().toISOString(), snapshot, runs: [], artifacts: [] };
57
+ const entry = { schema: 1, id: randomUUID(), thread_id: threadId, name, cwd: scope.cwd, created_at: new Date().toISOString(), snapshot, runs: [], artifacts: [] };
53
58
  await writeRecord("checkpoints", entry.id, entry, storage);
54
59
  return checkpointSummary(entry);
55
60
  }
@@ -72,39 +77,70 @@ export async function verifyCheckpoint(threadInput, id, storage = {}) {
72
77
  }
73
78
 
74
79
  function watchInputs(snapshot) {
75
- const touched = new Set(), watchers = []; let error = null;
80
+ const touched = new Set(), watchers = []; let error = null, pending = Promise.resolve(), dirty = false, scanning = false;
81
+ // macOS can deliver queued events for files created before watch startup,
82
+ // including parent notifications for excluded outputs. Confirm an event
83
+ // against the input snapshot rather than treating its path alone as a write.
84
+ const scan = () => {
85
+ dirty = true;
86
+ if (scanning) return;
87
+ scanning = true;
88
+ pending = pending.then(async () => {
89
+ while (dirty) {
90
+ dirty = false;
91
+ try {
92
+ const current = await captureInputs(snapshot.roots, snapshot.excludes, { allowMissing: true }), changes = differences(snapshot, current);
93
+ for (const file of [...changes.added, ...changes.removed, ...changes.changed]) if (touched.size < 100) touched.add(file);
94
+ } catch (e) { error = e.message; }
95
+ }
96
+ }).finally(() => { scanning = false; });
97
+ };
76
98
  const files = new Map(snapshot.files.map(f => [f.ref, f]));
77
99
  const dirs = [...new Set(snapshot.roots.map(r => files.get(r)?.type === "directory" ? r : path.dirname(r)))];
78
100
  try {
79
101
  for (const dir of dirs) {
80
- const watcher = watch(dir, { recursive: true }, (_, filename) => {
102
+ const watcher = watch(dir, { recursive: true }, (eventType, filename) => {
81
103
  const file = filename ? path.resolve(dir, filename.toString()) : dir;
82
- if (!excluded(file, snapshot.excludes) && (!filename || snapshot.roots.some(r => within(file, r)))) { if (touched.size < 100) touched.add(file); }
104
+ if (!excluded(file, snapshot.excludes) && (!filename || snapshot.roots.some(r => within(file, r)))) {
105
+ // A content-change event on an existing input remains evidence even
106
+ // if it was restored within the filesystem timestamp resolution.
107
+ if (eventType === "change" && files.get(file)?.type === "file" && touched.size < 100) touched.add(file);
108
+ scan();
109
+ }
83
110
  });
84
111
  watcher.on("error", e => { error = e.message; }); watchers.push(watcher);
85
112
  }
86
113
  } catch (e) { for (const w of watchers) w.close(); throw e; }
87
- return { touched, get error() { return error; }, close() { for (const w of watchers) w.close(); } };
114
+ return { touched, get error() { return error; }, settle: () => pending, async close() { for (const w of watchers) w.close(); await pending; } };
88
115
  }
89
116
 
90
- export async function runCheckpoint(threadInput, id, argv, { timeoutMs = 0, ...storage } = {}) {
117
+ export async function runCheckpoint(threadInput, id, argv, { timeoutMs = 0, execBackend, remoteShell, supervisor, connection, ...storage } = {}) {
91
118
  if (!argv.length || !argv[0] || !Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 86400000) throw new Error("A command and timeout-ms between 0 and 86400000 are required.");
92
- const threadId = normalizeThreadId(threadInput);
119
+ const threadId = normalizeTaskId(threadInput);
93
120
  return withStoreLock(`checkpoint-${id}`, async () => {
94
- const entry = await getCheckpoint(threadId, id, storage), watcher = watchInputs(entry.snapshot);
121
+ const entry = await getCheckpoint(threadId, id, storage), watcher = targetHost() === "local" ? watchInputs(entry.snapshot) : await remoteWatchInputs(entry.snapshot);
95
122
  try {
96
123
  const before = await captureInputs(entry.snapshot.roots, entry.snapshot.excludes, { allowMissing: true });
97
124
  if (before.digest !== entry.snapshot.digest) throw new Error("Inputs changed since capture. Capture a new checkpoint before running.");
98
125
  const run = { id: randomUUID(), argv, status: "running", started_at: new Date().toISOString() };
99
126
  entry.runs.push(run); await writeRecord("checkpoints", id, entry, storage);
100
- const result = await runCommand(argv, { cwd: entry.cwd, timeoutMs, onStart: async pid => { run.pid = pid; await writeRecord("checkpoints", id, entry, storage); } });
127
+ let result;
128
+ try { result = await runCommand(argv, { cwd: entry.cwd, timeoutMs, execBackend, remoteShell, supervisor, connection, threadId, onStart: async (pid, info) => { Object.assign(run, { pid }, info); await writeRecord("checkpoints", id, entry, storage); } }); }
129
+ catch (error) {
130
+ Object.assign(run, { status: error.uncertain ? "unknown" : "failed", error_code: error.code, error: error.message, inputs_unchanged: false,
131
+ remote_process_id: error.remote_process_id, touched_paths: [...watcher.touched], observation_error: watcher.error, completed_at: new Date().toISOString() });
132
+ await writeRecord("checkpoints", id, entry, storage); throw error;
133
+ }
101
134
  let after, snapshotError;
102
135
  try { after = await captureInputs(entry.snapshot.roots, entry.snapshot.excludes, { allowMissing: true }); } catch (e) { snapshotError = e.message; }
136
+ await watcher.settle?.();
137
+ const changedInputs = after ? differences(entry.snapshot, after) : null;
138
+ const touchedPaths = [...new Set([...watcher.touched, ...(changedInputs ? [...changedInputs.added, ...changedInputs.removed, ...changedInputs.changed] : [])])].slice(0, 100);
103
139
  Object.assign(run, result, { status: "completed", inputs_unchanged: after?.digest === entry.snapshot.digest && !watcher.touched.size && !watcher.error,
104
- touched_paths: [...watcher.touched], observation_error: watcher.error ?? snapshotError ?? null, differences: after ? differences(entry.snapshot, after) : null });
140
+ touched_paths: touchedPaths, observed_touched_paths: [...watcher.touched], observation_error: watcher.error ?? snapshotError ?? null, differences: changedInputs });
105
141
  await writeRecord("checkpoints", id, entry, storage);
106
142
  return { checkpoint_id: id, ...run, valid: run.exit_code === 0 && !run.error && !run.timed_out && run.inputs_unchanged };
107
- } finally { watcher.close(); }
143
+ } finally { await watcher.close(); }
108
144
  }, storage);
109
145
  }
110
146
 
@@ -123,6 +159,6 @@ export async function attachArtifacts(threadInput, id, refs, storage = {}) {
123
159
  }
124
160
 
125
161
  export async function listCheckpoints(threadInput, storage = {}) {
126
- const threadId = normalizeThreadId(threadInput);
162
+ const threadId = normalizeTaskId(threadInput);
127
163
  return (await listRecords("checkpoints", storage)).filter(c => c?.thread_id === threadId).sort((a, b) => a.created_at.localeCompare(b.created_at)).map(entry => checkpointSummary(entry));
128
164
  }