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.
- package/CHANGELOG.md +28 -1
- package/README.md +127 -14
- package/assets/remote-device-key.swift +94 -0
- package/bin/codexteer-update.mjs +3 -0
- package/bin/codexteer.mjs +6 -2
- package/docs/cloud.md +113 -0
- package/docs/desktop-cloud.md +36 -0
- package/docs/distribution.md +11 -19
- package/docs/orchestration.md +120 -0
- package/docs/remote-cli-validation.json +139 -0
- package/docs/remote-control.md +65 -0
- package/docs/remote-device-enrollment.md +91 -0
- package/docs/remote-observation.md +57 -0
- package/docs/remote-operations.md +89 -0
- package/docs/thread-create.md +20 -45
- package/package.json +11 -2
- package/scripts/update-desktop.applescript +51 -0
- package/src/agent-launch.mjs +27 -0
- package/src/app-server.mjs +34 -10
- package/src/checkpoint.mjs +52 -16
- package/src/cli.mjs +201 -23
- package/src/cloud-api.mjs +118 -0
- package/src/cloud-cli.mjs +319 -0
- package/src/cloud-connection.mjs +64 -0
- package/src/cloud-id.mjs +16 -0
- package/src/cloud-observe.mjs +178 -0
- package/src/cloud-send.mjs +33 -0
- package/src/cloud.mjs +172 -0
- package/src/connection.mjs +12 -1
- package/src/creation-verification.mjs +30 -10
- package/src/desktop-cloud-api.mjs +119 -0
- package/src/desktop-cloud-id.mjs +18 -0
- package/src/desktop-cloud-project.mjs +148 -0
- package/src/desktop-cloud.mjs +98 -0
- package/src/desktop.mjs +3 -1
- package/src/directive.mjs +5 -2
- package/src/distribution.mjs +2 -2
- package/src/evidence.mjs +3 -0
- package/src/findings.mjs +7 -7
- package/src/help.mjs +140 -28
- package/src/journal.mjs +37 -9
- package/src/launcher.mjs +16 -9
- package/src/monitor.mjs +18 -6
- package/src/native-command.mjs +10 -2
- package/src/native-rpc.mjs +15 -2
- package/src/observe.mjs +21 -7
- package/src/orchestrate-prompt.mjs +96 -0
- package/src/orchestrate.mjs +29 -0
- package/src/paged-read.mjs +7 -1
- package/src/projects.mjs +27 -2
- package/src/prompt-command.mjs +25 -0
- package/src/prompt.mjs +25 -8
- package/src/remote-client-info.mjs +16 -0
- package/src/remote-control-auth.mjs +109 -0
- package/src/remote-control-socket.mjs +137 -0
- package/src/remote-control.mjs +134 -0
- package/src/remote-device-key.mjs +41 -0
- package/src/remote-device-login.mjs +76 -0
- package/src/remote-device-protocol.mjs +72 -0
- package/src/remote-device-store.mjs +71 -0
- package/src/remote-device-worker.mjs +48 -0
- package/src/remote-device.mjs +202 -0
- package/src/remote-io.mjs +163 -0
- package/src/remote-key-access.mjs +76 -0
- package/src/remote-key-worker.mjs +38 -0
- package/src/remote-keychain.mjs +72 -0
- package/src/remote-management.mjs +234 -0
- package/src/remote.mjs +132 -0
- package/src/resource.mjs +8 -6
- package/src/runner.mjs +6 -1
- package/src/standalone-create.mjs +4 -3
- package/src/store.mjs +6 -3
- package/src/supervise.mjs +25 -53
- package/src/supervision.mjs +41 -19
- package/src/target.mjs +25 -0
- package/src/task-id.mjs +23 -0
- package/src/thread-create.mjs +4 -3
- package/src/thread-id.mjs +21 -6
- package/src/thread-verify.mjs +108 -0
- package/src/turn-repair.mjs +228 -0
- package/src/update.mjs +158 -0
- 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`で拒否し、保存状態を変更しません。
|
package/docs/thread-create.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Desktopプロジェクトにチャットを直接作成する
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
Desktop native所属、現在のプロジェクトID対応と全フォルダ、threadのcanonical所属・cwd・全フォルダ・指定タイトルに加え、保存された初回本文のSHA-256とUnicode文字数をreceiptと比較します。standaloneでは初回turn IDも一致が必要です。初回本文を再度渡す必要はありません。関連付けを明示した場合は既知の呼出し元を含むliteral envelopeから本文を取り出し、XMLとして解釈しません。
|
|
100
81
|
|
|
101
|
-
|
|
82
|
+
native一覧はDesktopの実schemaに従い最新50件と全pinnedです。対象が見つからない、別host/projectに所属する、重複して見える、schemaやruntimeが変わる場合は成功と推測しません。失敗の`verification_checks`を確認してください。`--timeout-ms`は各RPCの期限です。`--supervisor`と観測専用の`--connection desktop`からは実行できません。保存済みCLIは変更しないため、この機能には修正版CLIが必要です。
|
|
102
83
|
|
|
103
|
-
|
|
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
|
-
|
|
86
|
+
作成とreceipt照合の回帰テストは、架空のプロジェクト・識別子・一時プロファイルを使います。未知の応答、別プロジェクト、追加root、初回指示の不一致では停止し、作成や配送を繰り返しません。実環境で使う前に、その環境のDesktopとCLIでschema・所属・購読・承認動作を確認してください。
|
|
112
87
|
|
|
113
|
-
|
|
88
|
+
この文書のプロジェクト名とIDは説明用の例です。実際のチャットID、プロジェクト一覧、ローカルパス、実行ログは公開資料に含めません。
|
|
114
89
|
|
|
115
|
-
|
|
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.
|
|
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/
|
|
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
|
+
}
|
package/src/app-server.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
package/src/checkpoint.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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 }, (
|
|
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)))) {
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
}
|