codexteer 0.14.1

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +519 -0
  3. package/assets/send-pururu.wav +0 -0
  4. package/bin/codexteer-wrapper.mjs +10 -0
  5. package/bin/codexteer.mjs +5 -0
  6. package/docs/distribution.md +186 -0
  7. package/node_modules/ws/LICENSE +20 -0
  8. package/node_modules/ws/README.md +548 -0
  9. package/node_modules/ws/browser.js +8 -0
  10. package/node_modules/ws/index.js +22 -0
  11. package/node_modules/ws/lib/buffer-util.js +131 -0
  12. package/node_modules/ws/lib/constants.js +19 -0
  13. package/node_modules/ws/lib/event-target.js +292 -0
  14. package/node_modules/ws/lib/extension.js +203 -0
  15. package/node_modules/ws/lib/limiter.js +55 -0
  16. package/node_modules/ws/lib/permessage-deflate.js +530 -0
  17. package/node_modules/ws/lib/receiver.js +743 -0
  18. package/node_modules/ws/lib/sender.js +607 -0
  19. package/node_modules/ws/lib/stream.js +161 -0
  20. package/node_modules/ws/lib/subprotocol.js +62 -0
  21. package/node_modules/ws/lib/validation.js +152 -0
  22. package/node_modules/ws/lib/websocket-server.js +562 -0
  23. package/node_modules/ws/lib/websocket.js +1407 -0
  24. package/node_modules/ws/package.json +74 -0
  25. package/node_modules/ws/wrapper.mjs +21 -0
  26. package/package.json +41 -0
  27. package/scripts/accessibility.applescript +43 -0
  28. package/scripts/inspect.applescript +72 -0
  29. package/scripts/send.applescript +45 -0
  30. package/src/app-server.mjs +117 -0
  31. package/src/bridge.mjs +79 -0
  32. package/src/checkpoint.mjs +128 -0
  33. package/src/cli.mjs +358 -0
  34. package/src/compatibility.mjs +115 -0
  35. package/src/desktop.mjs +207 -0
  36. package/src/directive.mjs +39 -0
  37. package/src/distribution.mjs +192 -0
  38. package/src/evidence.mjs +33 -0
  39. package/src/help.mjs +145 -0
  40. package/src/journal.mjs +120 -0
  41. package/src/launcher.mjs +145 -0
  42. package/src/monitor.mjs +123 -0
  43. package/src/observe.mjs +185 -0
  44. package/src/paged-read.mjs +228 -0
  45. package/src/prompt.mjs +51 -0
  46. package/src/resource.mjs +70 -0
  47. package/src/rpc.mjs +162 -0
  48. package/src/runner.mjs +32 -0
  49. package/src/runtime.mjs +130 -0
  50. package/src/settings.mjs +49 -0
  51. package/src/sound.mjs +16 -0
  52. package/src/store.mjs +78 -0
  53. package/src/subscription.mjs +118 -0
  54. package/src/supervise.mjs +57 -0
  55. package/src/thread-id.mjs +35 -0
  56. package/src/thread-store.mjs +72 -0
  57. package/src/version.mjs +19 -0
  58. package/src/wrapper.mjs +149 -0
package/src/help.mjs ADDED
@@ -0,0 +1,145 @@
1
+ import { supervisionSteps } from "./prompt.mjs";
2
+
3
+ export { VERSION } from "./version.mjs";
4
+ import { VERSION } from "./version.mjs";
5
+
6
+ const supervisionExecutionNotes = [
7
+ "CLI本体と依存をCODEX_HOME/codex-steer/runtimes/<version>-<sha256>へ検証して保存し、そのコピーへの実行コマンドを本文に埋め込みます。supervise promptもこの保存処理を行います。同じ内容は検証して再利用し、既存のコピーは上書き・自動削除しません。保存・検証に失敗した場合は本文を出力せず、Claudeも起動しません。",
8
+ "同じPCで使い、監督中は保存したコピーを保持してください。全コマンドの先頭に生成時に検証したCODEX_HOMEの実体パスを指定するため、監督役のCODEX_HOMEが未設定・別設定でも生成元の接続先と履歴を使います。元のnpxキャッシュやチェックアウトを更新・削除しても、監督は保存した内容を使います。ヘルプ中のcodexteer表記も、先頭のCODEX_HOME指定・引用・オプションを含む指定の実行コマンドに置き換えてください。",
9
+ "Node本体はコピーせず、開始時の実体への絶対パスと--require-node-versionを各コマンドに埋め込みます。Nodeの版が変われば操作前にNODE_VERSION_MISMATCHで停止します。同じ版のNodeの改変や共有ライブラリまで固定するものではないため、元のNodeは保持してください。保存先やNodeが使えなければ介入を止めて報告し、意図した環境から生成し直すよう指示します。",
10
+ ];
11
+
12
+ export const topics = {
13
+ supervise: {
14
+ title: "指定したCodexタスクの監督役としてClaudeを起動する",
15
+ when: "対象タスクのIDを指定して、Claude Codeを監督役の対話セッションとして起動するとき。",
16
+ usage: ["supervise <THREAD> --agent claude [-- <AGENT-ARGS...>]"],
17
+ json_output: false,
18
+ returns: "Claudeの標準入力・標準出力・標準エラーと終了コードを引き継ぎます。codexteerの出力は混ぜません。",
19
+ examples: ["codexteer supervise <THREAD> --agent claude", "codexteer supervise <THREAD> --agent claude -- --model <MODEL> --effort <LEVEL>"],
20
+ notes: [...supervisionExecutionNotes,
21
+ "--agentは必須で、現在はclaudeに対応します。PATH上の実行ファイルを、現在の作業ディレクトリと環境変数を引き継いで起動します。シェルのaliasやfunctionは使いません。", "最初の -- より後ろは全てClaudeの引数です。順序・空文字・空白・改行を保ち、--help/--version/--jsonもcodexteer側では解釈しません。シェルで再展開しないため、呼び出し元のシェルで必要な引用をしてください。", "渡された追加引数の後ろにClaude側の -- と対象ID入りの監督プロンプトを一つの引数として付加します。追加引数の意味・組み合わせの妥当性はClaudeが判断します。", "不正なID形式、--agentの不足・未対応値、区切り前の未知の引数では起動しません。Claudeが未導入・実行不可などの起動失敗は理由を標準エラーへ出して終了コード1です。Desktopの接続と対象タスクの存在は、起動した監督役が最初に確認します。", "SIGINT/SIGTERM/SIGHUPは起動したClaudeへ転送します。シグナルで終了した場合の終了コードは128+シグナル番号です。", "対話起動のsupervise <THREAD> --agent claudeでは--jsonは非対応です。help supervise --jsonでヘルプを取得でき、supervise prompt <THREAD> --jsonで起動せず監督役(オーケストレーター)向けの本文を取得できます。"],
22
+ },
23
+ "supervise prompt": {
24
+ title: "監督役(オーケストレーター)向けのプロンプトを出力する",
25
+ when: "Claude Codeなどの監督役へ渡す、対象タスク入りの初期プロンプト本文を取得・確認するとき。",
26
+ usage: ["supervise prompt <THREAD>"],
27
+ returns: "通常はプロンプト本文だけを標準出力へ返します。--jsonではdata.thread_id、data.prompt、元のホーム(codex_home)・保存先・版・ハッシュ・再利用の有無を示すdata.deployment、Nodeのパスと版を示すdata.nodeを返します。",
28
+ examples: ["codexteer supervise prompt <THREAD>", "codexteer supervise prompt codex://threads/<UUID> --json", "codexteer supervise <THREAD> --agent claude"],
29
+ notes: [...supervisionExecutionNotes,
30
+ "出力は監督役(オーケストレーター)へ渡す初期指示です。THREADはUUIDまたはcodex://threads/...。IDの書式を確認して正規化し、本文へ埋め込みます。Desktopへの接続・履歴取得・送信・Claudeの起動・設定変更は行いません。タスクの存在と接続は監督開始時に確認します。", "Claudeを起動する場合はsupervise <THREAD> --agent claudeを使います。追加の起動引数は -- の後ろへ渡してください。", "生成される本文はhelp monitorと同じ監督手順です。ユーザーの目的・制約の範囲内で自律介入し、Monitorが使えない場合は短いwatchで観測します。", "生成した本文を既存のClaudeセッションへ貼り付けても使えます。監督停止はそのセッションへ指示してください。プロンプトはCLIやOSの権限設定を変更しません。"],
31
+ },
32
+ monitor: {
33
+ title: "Codexタスクを観測・介入・結果確認の順で監督する",
34
+ when: "ユーザーから対象と範囲を委任され、Claudeなどが継続して進捗を観測し、必要な軌道修正を判断する運用。",
35
+ usage: ["supervise prompt <THREAD>", "watch <THREAD> --stream [--since CURSOR] [--poll-ms N] [--limit N] [--max-chars N] [--include-output]", "read <THREAD> [--since CURSOR]", "help supervise", "help send", "help checkpoint", "help history"],
36
+ returns: "--streamは作業差分(data.type=observation)と接続状態(data.type=connection)をJSON Linesで返します。差分のevents、attention、cursorを読み、必要なときだけ次の操作を選びます。",
37
+ examples: ["codexteer watch <THREAD> --stream --since <CURSOR> --include-output --json", "codexteer read <THREAD> --since <LAST-CURSOR> --include-output --json", "codexteer send <THREAD> '失敗箇所を先に確認してください' --source claude-code --kind review --based-on <CURSOR> --json", "codexteer history check <THREAD> <MESSAGE-ID> --json"],
38
+ notes: [...supervisionSteps(), "--streamは常にJSON Linesです。初回にwatchingを通知し、平常時は差分だけを出力します。観測成功後の一時切断は1→2→4→8→最大10秒間隔で、接続・初期化・読み取りを含め60秒まで復帰を試みます。初回失敗はすぐ終了します。SIGINT/SIGTERMまたはMonitorのキャンセルで停止し、--until/--timeout-msとは併用しません。", "無効cursorはneeds_review、復帰上限・権限・プロトコル等の異常はfailedを含むok:falseのJSONを返し、終了コード1です。観測の再接続は送信の再試行を行いません。", "既存のログ監視を続け、その通知後にread --sinceを呼ぶ運用も可能です。複数AIの共有リソース利用調整はhelp resourceを参照してください。"],
39
+ },
40
+ resource: {
41
+ title: "画面・Unity・Gitなどの利用予定をAI間で共有する",
42
+ when: "複数AIの画面操作、ビルド、コミットが重ならないように調整するとき。",
43
+ usage: ["resource acquire <NAME> --owner NAME [--ttl-ms N] [--thread THREAD] [--reason TEXT] [--condition TEXT]", "resource status <NAME>", "resource list", "resource renew <NAME> --token TOKEN [--ttl-ms N]", "resource release <NAME> --token TOKEN", "resource run <NAME> --owner NAME [--ttl-ms N] [--timeout-ms N] [--include-output] [--condition TEXT] -- <COMMAND> [ARGS...]"],
44
+ returns: "所有者、期限、利用条件のメモ、tokenを返します。競合時は送信やコマンド実行を始めず失敗します。",
45
+ examples: ["codexteer resource acquire screen --owner claude-code --ttl-ms 120000 --condition 'ユーザーが今回の2分間の画面利用を許可済み' --json", "codexteer resource run unity-project-a --owner codex --ttl-ms 600000 -- npm test", "codexteer resource release screen --token <TOKEN> --json"],
46
+ notes: ["同じCODEX_HOMEと同じリソース名を使う参加者間だけの協調用リースです。OSの画面やUnity自体を強制ロックしません。", "ttlの既定は10分、指定範囲は1000〜86400000ms。手動取得はプロセス終了後も期限まで残り、tokenで更新・解放します。古いtokenで新しい所有者の予約は消せません。", "runはコマンド実行中に自動更新し、終了時に解放します。更新に失敗したら自分が起動したコマンド群を終了します。コマンドは -- の後へ置き、シェル展開しません。", "--conditionは許可済み条件の記録です。入力アイドルや取得成功だけで画面利用の許可を生成しません。", "実行時間を制限する場合は--timeout-ms(最大86400000、既定0=制限なし)。runが異常終了した場合はリース期限後に再取得できます。"] },
47
+ checkpoint: {
48
+ title: "検証したソースと結果・成果物を結び付ける",
49
+ when: "テスト結果が最終ソースのものか確認するとき、またはビルド前に検証の有効性を確認するとき。",
50
+ usage: ["checkpoint capture <THREAD> <NAME> --path PATH [--path PATH ...] [--exclude PATH ...]", "checkpoint run <THREAD> <ID> [--timeout-ms N] [--include-output] -- <COMMAND> [ARGS...]", "checkpoint attach <THREAD> <ID> --artifact FILE [--artifact FILE ...]", "checkpoint verify <THREAD> <ID>", "checkpoint list <THREAD>", "checkpoint show <THREAD> <ID> [--include-output]"],
51
+ returns: "captureはIDと入力ハッシュ、runは終了コードと入力変更、verifyは入力・最新実行・成果物の一致を返します。valid=falseならrun/verifyの終了コードは1です。",
52
+ examples: ["codexteer checkpoint capture <THREAD> tests --path src --path test --path package.json --path package-lock.json --json", "codexteer checkpoint run <THREAD> <ID> -- npm test", "codexteer checkpoint attach <THREAD> <ID> --artifact test-results.xml --json", "codexteer send <THREAD> 'この検証結果で次へ進んでください' --checkpoint <ID> --json"],
53
+ notes: ["--pathはファイルかディレクトリ。配下の追加・削除も検知します。.gitは除外。--excludeは実パスで、globではありません。出力先・キャッシュは入力から除外してください。ディレクトリのシンボリックリンクは実体を明示してください。", "コマンドはcaptureした作業場所で、シェル展開せず引数どおりに実行します。timeoutの既定0は制限なし、最大86400000ms。割り込み・timeoutは自分が起動したプロセス群を終了します。", "実行前後のハッシュと実行中のファイル監視を記録します。途中編集・監視エラーは有効な検証にしません。厳密な固定には隔離した入力を使ってください。", "成果物は最新の成功実行後に明示的にattachします。自動で生成元を証明するものではありません。URL・ディレクトリは成果物にできません(アプリはZIP等のファイルで指定)。", "validは選択した入力とコマンドの結果の整合性です。テスト範囲の十分性や配布許可は認定しません。ログ末尾32K文字はローカル保存し、--include-outputで表示します。", "--checkpointをsendに付けると送信直前にも照合し、無効なら送信を止めます。"] },
54
+ instructions: {
55
+ title: "現在有効な指示を確認し、古い指示を訂正する",
56
+ when: "診断を撤回するとき、方針が変わったとき、長い会話で今の決定を確認するとき。",
57
+ usage: ["instructions list <THREAD> [--all]", "send <THREAD> <NEW-MESSAGE> --supersedes <MESSAGE-ID> [--expires-at ISO-TIMESTAMP]", "instructions retract <THREAD> <MESSAGE-ID> --reason TEXT [--source NAME] [--based-on CURSOR] [--new-turn] [--dry-run]"],
58
+ returns: "listは現在有効な指示の本文と、配送未確認の変更を返します。--allは置換済み・撤回済み・期限切れも含めます。",
59
+ examples: ["codexteer instructions list <THREAD> --json", "codexteer send <THREAD> '先ほどの診断を訂正します。原因は復帰処理です' --supersedes <MESSAGE-ID> --kind review --source claude-code --json", "codexteer instructions retract <THREAD> <MESSAGE-ID> --reason '再計測で仮説を否定できた' --json"],
60
+ notes: ["IDはhistory listで確認します。同じタスクに送った指示だけを指定できます。", "置換・撤回は相手にもメッセージを送ります。元の履歴は削除しません。--dry-runは送信・保存しません。", "受付不明なら旧指示を確定撤回しません。history checkで保存を確認するまでpending_changesへ表示し、重ねて置換しません。", "--expires-atは時差付きISO日時。期限は補助側の有効一覧に適用され、相手の処理を自動停止するものではありません。", "受付・保存確認は、相手が方針変更を理解した証明ではありません。必要ならhistory markで明示的な応答を記録してください。"],
61
+ },
62
+ history: {
63
+ title: "送った指示の配送と対応状況を追う",
64
+ when: "受付不明の送信を確認するとき、または未対応の指摘を洗い出すとき。",
65
+ usage: ["history list <THREAD> [--pending] [--include-text]", "history show <THREAD> <MESSAGE-ID> [--include-text]", "history check <THREAD> [MESSAGE-ID] [--include-text]", "history mark <THREAD> <MESSAGE-ID> --status acknowledged|applied|dismissed --note TEXT [--evidence REF ...] [--by NAME]"],
66
+ returns: "delivery_statusは受付、verification.status=storedは受信履歴との一致、responseは記録者が申告した対応状況です。これらを別々に返します。",
67
+ examples: ["codexteer history list <THREAD> --pending --json", "codexteer history check <THREAD> <MESSAGE-ID> --json", "codexteer history mark <THREAD> <MESSAGE-ID> --status applied --note '対象の回帰テストが通過' --evidence test-results.xml --by reviewer --json"],
68
+ notes: ["この版以降のapp-server送信をCODEX_HOME/codex-steerに保存します。本文はローカルの0600ファイルに保存し、表示は --include-text 指定時のみ。", "IDは送信結果のmessage_id/client_message_idです。unknownでもIDを保存します。checkは照合だけで再送しません。", "not_observedは未配送の証明ではありません。storedもモデルの理解や反映を証明しません。", "appliedの申告には根拠参照が必須です。根拠の妥当性を自動認定する機能ではありません。UI方式は追跡対象外です。"],
69
+ },
70
+ overview: {
71
+ title: "別のAIと、Codexタスクの進行を共有する",
72
+ when: "タスクを探す → 現状を読む → 指示を送る → 変化を確認する、という順で使います。",
73
+ usage: ["supervise <THREAD> --agent claude [-- <AGENT-ARGS...>]", "supervise prompt <THREAD>", "threads list [--desktop-only] [--limit N]", "status <THREAD>", "read <THREAD> [--since CURSOR] [--limit N]", "watch <THREAD> [--since CURSOR] [--until change|idle|attention]", "send <THREAD> <MESSAGE...> [--new-turn] [--dry-run] [--no-sound]", "doctor [--backend app-server|ui]", "desktop start [--dry-run]", "thread resolve <THREAD>", "open <THREAD>", "debug-ui <THREAD> [--wait-ms N]", "help <COMMAND>"],
74
+ returns: "対話起動のsupervise <THREAD> --agent claudeを除き、各コマンドに --json を付けると、AIやスクリプトが扱えるJSONを返します。supervise promptもJSONに対応します。",
75
+ examples: ["codexteer supervise <THREAD> --agent claude", "codexteer threads list --desktop-only --json", "codexteer read <THREAD> --json", "codexteer help send"],
76
+ use_cases: [
77
+ { need: "Claudeを対象タスクの監督役にする", command: "help supervise / help monitor" },
78
+ { need: "監督役(オーケストレーター)向けのプロンプトを取得", command: "help supervise prompt" },
79
+ { need: "今の状態・前回からの変化を確認", command: "help read / help status" },
80
+ { need: "Claude CodeのMonitorから使う", command: "help monitor" },
81
+ { need: "根拠や仮説を付けて指示する", command: "help send" },
82
+ { need: "届いたか・対応されたかを確認", command: "help history" },
83
+ { need: "古い診断を訂正・撤回", command: "help instructions" },
84
+ { need: "テストしたソースと成果物を照合", command: "help checkpoint" },
85
+ { need: "画面・Unityなどの利用を調整", command: "help resource" },
86
+ ],
87
+ notes: ["THREADにはUUIDまたはcodex://threads/...を指定します。", "監督役の対話起動: codexteer supervise <THREAD> --agent claude [-- <AGENT-ARGS...>]。superviseの標準入出力と終了コードはClaudeのものです。", "コマンド別のhelpには、使い所・出力・例・制約を載せています。help自体も --json に対応します。"],
88
+ },
89
+ read: {
90
+ title: "直近の発言・コマンド・変更ファイルを読む",
91
+ when: "レビューや追加指示を送る前、または前回の観測から何が変わったかを確認するとき。",
92
+ usage: ["read <THREAD> [--since CURSOR] [--limit N] [--max-chars N] [--include-output] [--full-history]"],
93
+ returns: "events、状態、次回用cursor。初回は直近50件。--sinceは未取得の新規・更新項目を古い順に返します。has_moreなら返されたcursorで続けて読めます。",
94
+ examples: ["codexteer read <THREAD> --json", "codexteer read <THREAD> --since <CURSOR> --include-output --json", "codexteer read <THREAD> --full-history --since <V1-CURSOR> --json"],
95
+ notes: ["--limitは1〜1000(既定50)、--max-charsは100〜20000(既定2000)。",
96
+ "通常は直近のページと、追跡中の実行項目だけを取得します。長文タスクでも毎回の全取得を避けられます。初回だけは実行中ターンを調べ、表示範囲より前のコマンドも追跡します。",
97
+ "観測対象は履歴APIに現れた項目です。Codexの版によっては実行開始通知の後、コマンドが完了してから履歴に現れる場合があります。",
98
+ "has_more:trueならchanged:falseでも、返されたcursorで続きを読んでください。読み終えてから判断・送信します。running_commands_complete:falseは差分の読み残しがある状態です。",
99
+ "history_scope=tail-and-tracked-itemsはターン列・末尾・追跡項目の確認です。過去全文の書き換え検出には --full-history を使います(表示件数は同じ、取得は遅くなります)。古い形式のタスクも全取得になります。",
100
+ "旧v1 cursorの未読差分を継続するには --full-history。高速方式に切り替える場合は --since を外して現状をレビューし、新しいcursorを保存します。v1とv2は混在できません。",
101
+ "omitted_older_events:nullは省略した古い項目の件数が未集計です。件数のための全取得は行いません。",
102
+ "内部推論は出力しません。コマンド出力・差分本文は --include-output 時のみ表示します。",
103
+ "観測でタスクを再開・送信・前面化しません。巻き戻しや追跡位置の不一致を検出したcursorは拒否するので、原因を確認してから --since を外して読み直してください。"],
104
+ },
105
+ status: {
106
+ title: "実行中・停止中・承認待ちを確認する",
107
+ when: "今ステアできるか、新規ターンが必要か、ユーザーの応答が必要かを判断するとき。",
108
+ usage: ["status <THREAD> [--full-history]"],
109
+ returns: "status、active_turn_id、attention、実行中コマンド、cursor。状態が取得できない場合はunknownとします。",
110
+ examples: ["codexteer status <THREAD> --json"],
111
+ notes: ["readと同じページ取得方式を使い、返したcursorから read --since へ進めます。初回の実行中コマンド探索と --full-history の詳細は help read へ。", "notLoadedは保存済みタスクが未ロードという意味です。statusはロードしません。", "承認や質問には回答しません。attentionはApp Serverが報告した状態で、本文から推測しません。"],
112
+ },
113
+ watch: {
114
+ title: "変化・完了・ユーザー対応待ちまで待つ",
115
+ when: "同じログを繰り返し読まず、次のレビューが必要なタイミングを待ちたいとき。",
116
+ usage: ["watch <THREAD> [--since CURSOR] [--until change|idle|attention] [--timeout-ms N] [--poll-ms N] [--limit N] [--max-chars N] [--include-output] [--full-history]", "watch <THREAD> --stream [--since CURSOR] [--poll-ms N] [--limit N] [--max-chars N] [--include-output] [--full-history]"],
117
+ returns: "通常は条件成立または時間切れで1回返します(reason、timed_out、events、cursor)。--streamはdata.type=observationの差分とdata.type=connectionの接続状態をJSON Linesで返します。",
118
+ examples: ["codexteer watch <THREAD> --since <CURSOR> --json", "codexteer watch <THREAD> --until idle --timeout-ms 60000 --json"],
119
+ notes: ["既定はchange、30秒待ち、1秒間隔。timeoutは0〜60000ms、pollは250〜10000ms。", "readと同じ差分取得を使います。--full-history は毎回の全取得になるため、通常の監視は既定の方式を使ってください。旧cursorの移行と確認範囲は help read へ。", "--sinceなしのchangeは現在を基準に次の変化を待ちます。idle/attentionは既に成立していればすぐ返します。", "--streamの接続状態はwatching/reconnecting/recovered/needs_review/failedです。観測成功後の一時切断だけ、最大10秒間隔・合計60秒まで同じタスクへ再接続します。通常のwatchと初回接続失敗は再試行しません。", "接続行のresume_cursorはCLIの出力完了位置または初回の基準です。AIの読了確認ではありません。再起動時は監督側が読了済みcursorを指定してください。復帰後もhas_moreなら差分を読み切ります。", "--streamは--until/--timeout-msと併用不可。平常時の定期通知はなく、SIGINT/SIGTERMで接続中・復帰待ちでも停止します。詳細はhelp monitorへ。", "読み取り専用のポーリングです。自動送信・タスク再開・承認回答・常駐登録は行いません。"],
120
+ },
121
+ send: {
122
+ title: "実行中タスクに方針やレビューを伝える",
123
+ when: "単発送信の宛先・内容を指定されたとき、または委任された監督範囲で軌道修正が必要と判断したとき。再開を依頼された停止中タスクには --new-turn を使います。",
124
+ usage: ["send <THREAD> <MESSAGE...> [--new-turn] [--dry-run] [--no-sound] [--backend app-server|ui]", "send <THREAD> <MESSAGE...> [--source NAME] [--kind decision|review|hypothesis|suggestion] [--evidence FILE-OR-URL ...] [--based-on CURSOR] [--supersedes MESSAGE-ID] [--expires-at ISO-TIMESTAMP] [--checkpoint ID]", "<THREAD> <MESSAGE...> [sendと同じオプション]"],
125
+ returns: "acceptedはサーバーの受付です。処理完了や画面への表示を保証するものではありません。",
126
+ examples: ["codexteer send <THREAD> '失敗したテストの原因を先に確認してください' --dry-run --json", "codexteer send <THREAD> '競合が原因か確認してください' --source claude --kind hypothesis --evidence results.xml --based-on <CURSOR> --json", "codexteer send <THREAD> '続けてください' --new-turn --no-sound --json", "printf '%s\\n' '理由' '変更方針' | codexteer send <THREAD> -"],
127
+ notes: ["MESSAGEに - を指定すると標準入力を読みます。本文中のオプション名は -- の後に置いてください。", "--kindはdecision=ユーザー決定の伝達、review=レビュー、hypothesis=未確定の仮説、suggestion=任意提案です。指定時は送信者・種類・根拠を本文の前に表示します。無指定なら従来の本文をそのまま送ります。", "decisionは送信者の分類で、新しいユーザー承認を作りません。元のユーザー指示を根拠として示してください。", "--based-onはread/statusのcursor。has_more:trueなら読み終えてから指定します。観測位置以降の新しいユーザー入力やターン変更があれば送信を止めます。通常の進捗報告だけでは止めません。根拠ファイルも送信直前にハッシュ照合します。URLは参照のみです。", "dry-runではファイル根拠を読めますが、接続・鮮度確認・保存・送信は行いません。鮮度確認と送信の間の変更を原子的には防げません。", "既定はapp-server。UI操作は --backend ui を明示した場合だけです。--keep-focusと--wait-msはUI専用です。", "送信音は既定でオンです。受付成功時に同梱の短い3音「プルッ」(約0.30秒)を再生します。--no-soundで今回だけ消音します。互換用の--soundはapp-server専用で、--no-soundと併用不可。dry-run・受付失敗・unknown・UI方式では鳴りません。通知設定は変更せず、音量・ミュートはMacの出力設定に従います。再生失敗でも送信成功を維持し、JSONのsound.played/reasonまたは警告で知らせます。", "unknownは受付未確認です。history checkで確認し、自動再送しないでください。"],
128
+ },
129
+ doctor: { title: "接続・実行環境・観測APIを診断する", when: "接続できないとき、監督の開始時、またはDesktop更新後。", usage: ["doctor [--backend app-server|ui]", "doctor --thread <THREAD> [--backend app-server]"], returns: "ready、製品バージョン、connection、runtime_compatibility、desktop_subscription、compatibility.api_checks、failureと復旧案内。", examples: ["codexteer doctor --json", "codexteer doctor --thread <THREAD> --json"], notes: ["codex_steer_compatibilityは製品名・版の比較で、違いだけでは停止しません。runtime_compatibilityで通信仕様・機能・操作ごとのsupported/unsupported/unverifiedを返します。新規ターンだけ未対応でも観測と通常送信は継続できます。", "宣言なしの旧環境は読み取りで検証し、未知の送信仕様は試験送信せず停止します。CAPABILITY_UNSUPPORTED/UNVERIFIEDは該当機能、RUNTIME_PROTOCOL_UNSUPPORTED/UNVERIFIEDは共通仕様を確認してください。対象未指定では接続を診断し、観測の互換性はunverifiedです。--threadで指定した対象の読み取り経路を検証します。readyは接続条件と指定された検証の結果です。", "compatibility.statusはverified/unverified/unsupported/failed。verifiedは対象の初回観測に成功したことを表し、履歴全体・送信・画面表示・承認往復の保証ではありません。呼び出していないAPIもunverifiedです。", "--threadはapp-server専用。本文を診断出力に含めず、タスクの再開・送信・承認回答・Desktop再起動・設定変更は行いません。failureにはコードと失敗したメソッドを返します。"] },
130
+ desktop: { title: "共有接続を有効にしてDesktopを起動する", when: "現在の作業を終え、Desktopを終了した後の起動時。", usage: ["desktop start [--dry-run]"], returns: "起動結果とdeployment(保存先、内容ハッシュ、版、再利用の有無)。", examples: ["codexteer desktop start --dry-run --json", "codexteer desktop start"], notes: ["呼び出した配布物をCODEX_HOME/codex-steer/runtimes/<version>-<sha256>へ検証して配置し、そのwrapperで起動します。保存先の所有者・権限・リンク・内容を検証し、既存の配置は上書きしません。npxの取得元はcodexteerです。通常はバージョン指定なしで使えます。特定の版で開始したい場合は、任意でパッケージ名に@バージョンを付けてください。起動と操作の製品版が異なっても、必要な通信・機能仕様が対応していれば使用できます。", "dry-runは接続・配置・起動を行いません。配置ロックが残っている場合は自動で奪わずDEPLOYMENT_BUSYで停止します。", "起動中のDesktopは終了させません。通常起動への復旧は、終了後にDock等から開き直します。"] },
131
+ threads: { title: "送信先のタスクを探す", when: "宛先の正確なIDを確認するとき。", usage: ["threads list [--desktop-only] [--limit N]"], returns: "ローカルタスクのID、作業場所、更新日時。", examples: ["codexteer threads list --desktop-only --limit 20 --json"], notes: ["CODEX_HOMEに従います。本文は読みません。"] },
132
+ thread: { title: "タスクURLをIDに変換する", when: "コピーしたURLをスクリプトで利用するとき。", usage: ["thread resolve <THREAD>"], returns: "正規化したthread_idとdeep_link。", examples: ["codexteer thread resolve codex://threads/<UUID> --json"], notes: ["IDの書式を確認します。タスクの存在確認はstatusを使ってください。"] },
133
+ open: { title: "タスクをDesktopで開く", when: "ユーザーが画面でタスクを見たいとき。", usage: ["open <THREAD>"], returns: "開いたタスクのURL。", examples: ["codexteer open <THREAD>"], notes: ["画面を操作する明示的なコマンドです。"] },
134
+ "debug-ui": { title: "UI送信のために画面構造を調べる", when: "明示的なUI方式を診断するとき。", usage: ["debug-ui <THREAD> [--wait-ms N]"], returns: "Accessibilityの診断情報。", examples: ["codexteer debug-ui <THREAD> --wait-ms 1500"], notes: ["画面を操作します。通常のバックグラウンド送信には不要です。"] },
135
+ };
136
+
137
+ export function helpData(topic = "overview") {
138
+ const data = topics[topic];
139
+ if (!data) throw new Error(`Unknown help topic: ${topic}. Run codexteer help.`);
140
+ return { version: VERSION, topic, ...data, ...(topic === "overview" ? { usage: [...data.usage, "history list|show|check|mark <THREAD> ...", "instructions list|retract <THREAD> ...", "checkpoint capture|run|attach|verify|list|show <THREAD> ...", "resource acquire|status|list|renew|release|run ..."] } : {}) };
141
+ }
142
+
143
+ export function renderHelp(data) {
144
+ return [`codexteer ${data.version} — ${data.title}`, "", `使い所: ${data.when}`, ...(data.use_cases ? ["", "目的から選ぶ:", ...data.use_cases.map(x => ` ${x.need} → codexteer ${x.command}`)] : []), "", "使い方:", ...data.usage.map(x => ` codexteer ${x}${data.json_output === false || x.startsWith("supervise <") ? "" : " [--json]"}`), "", `確認できること: ${data.returns}`, "", "例:", ...data.examples.map(x => ` ${x}`), "", ...data.notes.map(x => `• ${x}`)].join("\n");
145
+ }
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { sendAppServerMessage } from "./app-server.mjs";
3
+ import { normalizeThreadId } from "./thread-id.mjs";
4
+ import { digest, fetchThread } from "./observe.mjs";
5
+ import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
6
+ import { discoverRuntime } from "./runtime.mjs";
7
+ import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
8
+ import { RpcClient } from "./rpc.mjs";
9
+ import { prepareDirective } from "./directive.mjs";
10
+
11
+ export async function getMessage(threadInput, id, options = {}) {
12
+ const threadId = normalizeThreadId(threadInput);
13
+ const entry = await readRecord("messages", id, options);
14
+ if (!entry || entry.thread_id !== threadId || entry.id !== id || entry.schema !== 1) throw new Error("Message record not found for this task.");
15
+ return entry;
16
+ }
17
+
18
+ export function messageSummary(entry, includeText = false) {
19
+ const { body, wire_text, ...data } = entry;
20
+ return includeText ? { ...data, body, wire_text } : data;
21
+ }
22
+
23
+ export async function listMessages(threadInput, { includeText = false, pending = false, ...options } = {}) {
24
+ const threadId = normalizeThreadId(threadInput);
25
+ return (await listRecords("messages", options)).filter(e => e?.thread_id === threadId && (!pending || !["applied", "dismissed"].includes(e.response?.status)))
26
+ .sort((a, b) => a.created_at.localeCompare(b.created_at)).map(e => messageSummary(e, includeText));
27
+ }
28
+
29
+ export function instructionStates(entries, now = Date.now()) {
30
+ const states = new Map(entries.map(e => [e.id, { ...e, instruction_status: e.delivery_status === "not_sent" ? "not_sent" : e.delivery_status !== "accepted" ? "pending_delivery" : e.metadata?.retracts ? "withdrawal_notice" : e.metadata?.expires_at && Date.parse(e.metadata.expires_at) <= now ? "expired" : "active" }]));
31
+ for (const entry of entries) {
32
+ const target = states.get(entry.metadata?.supersedes ?? entry.metadata?.retracts);
33
+ if (!target) continue;
34
+ if (entry.delivery_status === "accepted") Object.assign(target, { instruction_status: entry.metadata.retracts ? "retracted" : "superseded", replaced_by: entry.id });
35
+ else if (entry.delivery_status !== "not_sent") target.pending_change_ids = [...target.pending_change_ids ?? [], entry.id];
36
+ }
37
+ return [...states.values()];
38
+ }
39
+
40
+ export async function listInstructions(threadInput, { all = false, ...options } = {}) {
41
+ const threadId = normalizeThreadId(threadInput), entries = instructionStates(await listMessages(threadId, { ...options, includeText: true }));
42
+ return { thread_id: threadId, instructions: entries.filter(e => all || e.instruction_status === "active"), pending_changes: entries.filter(e => e.instruction_status === "pending_delivery") };
43
+ }
44
+
45
+ async function validateReplacement(threadId, options, storage) {
46
+ const targetId = options.supersedes ?? options.retracts;
47
+ if (!targetId) return;
48
+ await getMessage(threadId, targetId, storage);
49
+ const entries = instructionStates(await listMessages(threadId, { ...storage, includeText: true }));
50
+ const target = entries.find(e => e.id === targetId);
51
+ if (!["active", "expired"].includes(target.instruction_status) || target.pending_change_ids?.length) throw new Error("Target instruction is inactive or has an unresolved delivery. Check history before replacing it.");
52
+ }
53
+
54
+ export async function sendTrackedMessage(threadInput, body, options = {}, { send = sendAppServerMessage, ...storage } = {}) {
55
+ const threadId = normalizeThreadId(threadInput);
56
+ if (options.dryRun) {
57
+ await validateReplacement(threadId, options, storage);
58
+ const prepared = await prepareDirective(threadId, body, options, "preview", storage);
59
+ return { ...await send(threadId, prepared.wireText, options), ...(prepared.metadata ? { metadata: prepared.metadata, freshness_checked: false } : {}) };
60
+ }
61
+ return withStoreLock(`message-${threadId}`, async () => {
62
+ await validateReplacement(threadId, options, storage);
63
+ const id = randomUUID(), created = new Date().toISOString();
64
+ const prepared = await prepareDirective(threadId, body, options, id, storage);
65
+ let entry = { schema: 1, id, client_message_id: id, thread_id: threadId, created_at: created, updated_at: created, body, wire_text: prepared.wireText, metadata: prepared.metadata,
66
+ message_sha256: digest(prepared.wireText), delivery_status: "unknown", sent: null, attempt_state: "prepared", response: { status: "unreported" } };
67
+ await writeRecord("messages", id, entry, storage);
68
+ try {
69
+ const receipt = await send(threadId, prepared.wireText, { ...options, clientMessageId: id, beforeSend: prepared.beforeSend });
70
+ entry = { ...entry, ...receipt, id, attempt_state: "finished", updated_at: new Date().toISOString() };
71
+ try { await writeRecord("messages", id, entry, storage); } catch { return { ...receipt, message_id: id, journal_update_required: true }; }
72
+ return { ...receipt, message_id: id };
73
+ } catch (error) {
74
+ entry = { ...entry, sent: Object.hasOwn(error, "sent") ? error.sent : error.uncertain ? null : false, delivery_status: error.delivery_status ?? (error.uncertain ? "unknown" : "not_sent"), attempt_state: "finished", updated_at: new Date().toISOString(), error_code: error.code ?? "NOT_SENT" };
75
+ try { await writeRecord("messages", id, entry, storage); } catch { error.journal_update_required = true; }
76
+ error.client_message_id = id; error.message_id = id;
77
+ throw error;
78
+ }
79
+ }, storage);
80
+ }
81
+
82
+ export async function reconcileMessages(threadInput, id, options = {}, { discover = discoverRuntime, connect = RpcClient.connect } = {}) {
83
+ const threadId = normalizeThreadId(threadInput);
84
+ // Validate the local target before connecting.
85
+ if (id) await getMessage(threadId, id, options);
86
+ const { paths, state } = await discover();
87
+ assertRuntimeOperation(state, "history_check");
88
+ const client = compatibleClient(await connect(paths.socket), state);
89
+ try {
90
+ const thread = await fetchThread(client, threadId);
91
+ return await withStoreLock(`message-${threadId}`, async () => {
92
+ const entries = id ? [await getMessage(threadId, id, options)] : await listMessages(threadId, { ...options, includeText: true });
93
+ const items = thread.turns.flatMap(t => t.items.map(i => ({ ...i, turn_id: t.id })));
94
+ const result = [];
95
+ for (const entry of entries) {
96
+ const matches = items.filter(i => i.type === "userMessage" && i.clientId === entry.client_message_id);
97
+ const matchingText = matches.length === 1 && digest(matches[0].content.filter(c => c.type === "text").map(c => c.text).join("\n")) === entry.message_sha256;
98
+ const verification = { status: matchingText ? "stored" : matches.length ? "conflict" : "not_observed", checked_at: new Date().toISOString(), matching_items: matches.length };
99
+ if (matchingText) Object.assign(verification, { item_id: matches[0].id, turn_id: matches[0].turn_id });
100
+ const updated = { ...entry, verification, updated_at: verification.checked_at, ...(matchingText ? { delivery_status: "accepted", sent: true, turn_id: matches[0].turn_id } : {}) };
101
+ await writeRecord("messages", entry.id, updated, options);
102
+ result.push(messageSummary(updated, options.includeText));
103
+ }
104
+ return result;
105
+ }, options);
106
+ } finally { client.close(); }
107
+ }
108
+
109
+ export async function markMessage(threadInput, id, { status, note, evidence = [], by = "local", ...options }) {
110
+ const threadId = normalizeThreadId(threadInput);
111
+ if (!["acknowledged", "applied", "dismissed"].includes(status) || !note?.trim() || !by?.trim()) throw new Error("mark requires --status acknowledged|applied|dismissed and a non-empty --note.");
112
+ if (status === "applied" && !evidence.length) throw new Error("applied requires --evidence (a test result, diff or artifact reference).");
113
+ return withStoreLock(`message-${threadId}`, async () => {
114
+ const entry = await getMessage(threadId, id, options);
115
+ const response = { status, note, evidence, reported_by: by, reported_at: new Date().toISOString(), basis: "explicit_report" };
116
+ const updated = { ...entry, response, reports: [...entry.reports ?? [], response], updated_at: response.reported_at };
117
+ await writeRecord("messages", id, updated, options);
118
+ return messageSummary(updated);
119
+ }, options);
120
+ }
@@ -0,0 +1,145 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { codexHome, discoverRuntime, verifyControlEndpoint } from "./runtime.mjs";
6
+ import { RpcClient } from "./rpc.mjs";
7
+ import { BUNDLED_CLI, BUNDLED_NODE } from "./wrapper.mjs";
8
+ import { normalizeThreadId } from "./thread-id.mjs";
9
+ import { readOnClient } from "./observe.mjs";
10
+ import { VERSION } from "./help.mjs";
11
+ import { versionCompatibility } from "./version.mjs";
12
+ import { assertRuntimeOperation, runtimeCompatibility, compatibleClient } from "./compatibility.mjs";
13
+ import { prepareDeployment } from "./distribution.mjs";
14
+
15
+ export const APP_PATH = "/Applications/ChatGPT.app";
16
+
17
+ export function installedVersions() {
18
+ const app = spawnSync("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", `${APP_PATH}/Contents/Info.plist`], { encoding: "utf8", timeout: 3000 });
19
+ const cli = spawnSync(BUNDLED_CLI, ["--version"], { encoding: "utf8", timeout: 5000 });
20
+ return {
21
+ desktop_version: app.status === 0 && /^[\d.]+$/.test(app.stdout.trim()) ? app.stdout.trim() : null,
22
+ cli_version: cli.status === 0 ? cli.stdout.match(/codex-cli ([\w.+-]+)/)?.[1] ?? null : null,
23
+ };
24
+ }
25
+
26
+ export async function appServerDoctor({ threadId: threadInput, versions = installedVersions(), discover = discoverRuntime, connect = path => RpcClient.connect(path), inspectControl = verifyControlEndpoint } = {}) {
27
+ const threadId = threadInput === undefined ? null : normalizeThreadId(threadInput);
28
+ const checks = { macos: process.platform === "darwin", bundled_cli: Boolean(versions.cli_version), desktop_app: Boolean(versions.desktop_version), runtime: false, initialized: false, bundled_wrapper_node: false };
29
+ if (threadId) checks.observation = false;
30
+ const compatibility = {
31
+ status: "unverified", scope: threadId ? "target-observation" : "connection-only", thread_id: threadId,
32
+ api_checks: Object.fromEntries(["initialize", "thread/loaded/list", "thread/read", "thread/turns/list", "thread/items/list"].map(method => [method, "unverified"])),
33
+ unverified_features: ["steering", "desktop_ui", "approval_roundtrip"],
34
+ };
35
+ let client;
36
+ let remediation = null;
37
+ let serverVersion = null;
38
+ let wrapperNodeVersion = null;
39
+ let connectionStatus = "unavailable", failure = null, method = null;
40
+ let steerCompatibility = versionCompatibility();
41
+ let runtimeState, runtimePaths;
42
+ try {
43
+ const { paths, state } = await discover();
44
+ runtimeState = state; runtimePaths = paths;
45
+ checks.runtime = true;
46
+ serverVersion = state.cli_version;
47
+ steerCompatibility = versionCompatibility(state);
48
+ wrapperNodeVersion = state.node_version ?? null;
49
+ checks.bundled_wrapper_node = state.node_path === BUNDLED_NODE;
50
+ assertRuntimeOperation(state, "read");
51
+ method = "initialize";
52
+ client = compatibleClient(await connect(paths.socket), state);
53
+ connectionStatus = "connected";
54
+ compatibility.api_checks.initialize = "verified";
55
+ method = "thread/loaded/list";
56
+ const loaded = await client.request(method);
57
+ if (!Array.isArray(loaded?.data) || !loaded.data.every(id => typeof id === "string")) {
58
+ throw Object.assign(new Error("App Server returned an invalid loaded-task list."), { code: "PROTOCOL_ERROR" });
59
+ }
60
+ compatibility.api_checks[method] = "verified";
61
+ checks.initialized = true;
62
+ method = null;
63
+ if (!checks.bundled_wrapper_node) throw Object.assign(new Error("The running wrapper does not confirm Desktop's bundled Node runtime. App tools may fail code-signing authorization. Finish current tasks, quit Desktop, then run: codexteer desktop start"), { code: "WRAPPER_NODE_UNVERIFIED" });
64
+ if (threadId) {
65
+ const probe = { async request(name, ...args) { method = name; const result = await client.request(name, ...args); compatibility.api_checks[name] = "verified"; return result; } };
66
+ const observation = await readOnClient(probe, threadId, { limit: 1, maxChars: 100 });
67
+ compatibility.status = "verified";
68
+ compatibility.history_scope = observation.history_scope;
69
+ checks.observation = true;
70
+ }
71
+ } catch (error) {
72
+ remediation = error.message;
73
+ failure = { code: error.code ?? "DIAGNOSTIC_FAILED", rpc_code: error.rpcCode ?? null, method, ...(error.capability ? { capability: error.capability } : {}) };
74
+ const unavailable = ["CONNECTION_FAILED", "TIMEOUT", "RUNTIME_UNAVAILABLE", "RUNTIME_NOT_READY"].includes(error.code);
75
+ if (method && !unavailable) {
76
+ if (["CAPABILITY_UNVERIFIED", "RUNTIME_PROTOCOL_UNVERIFIED"].includes(error.code)) {
77
+ // The client stopped before calling the runtime, so there is no API result to classify.
78
+ compatibility.status = "unverified";
79
+ } else {
80
+ compatibility.status = error.rpcCode === -32601 || error.code === "CAPABILITY_UNSUPPORTED" ? "unsupported" : "failed";
81
+ }
82
+ compatibility.api_checks[method] = compatibility.status;
83
+ }
84
+ if (unavailable) connectionStatus = "unavailable";
85
+ } finally { client?.close(); }
86
+ const apiChecks = Object.entries(compatibility.api_checks);
87
+ const contracts = runtimeCompatibility(runtimeState, {
88
+ verifiedMethods: apiChecks.filter(([, status]) => status === "verified").map(([name]) => name),
89
+ unsupportedMethods: apiChecks.filter(([, status]) => status === "unsupported").map(([name]) => name),
90
+ });
91
+ let desktopSubscription = { status: "unverified" };
92
+ if (runtimePaths && contracts.operations.send_new_turn.status === "supported") {
93
+ try { await inspectControl(runtimePaths); desktopSubscription = { status: "available" }; }
94
+ catch (error) {
95
+ desktopSubscription = { status: error.code === "DESKTOP_SUBSCRIPTION_UNAVAILABLE" ? "unavailable" : "failed", code: error.code ?? "DIAGNOSTIC_FAILED" };
96
+ Object.assign(contracts.operations.send_new_turn, { status: desktopSubscription.status === "unavailable" ? "unsupported" : "failed", failure: desktopSubscription.code });
97
+ }
98
+ }
99
+ return { ready: !remediation && Object.values(checks).every(Boolean), backend: "app-server", ...versions,
100
+ codex_steer_version: VERSION, cli_node_version: process.versions.node,
101
+ running_cli_version: serverVersion, running_wrapper_node_version: wrapperNodeVersion,
102
+ codex_steer_compatibility: steerCompatibility,
103
+ runtime_compatibility: contracts, desktop_subscription: desktopSubscription,
104
+ cli_version_status: !serverVersion || !versions.cli_version ? "unverified" : serverVersion === versions.cli_version ? "matched" : "mismatch",
105
+ connection: { status: connectionStatus }, compatibility, failure, checks, remediation };
106
+ }
107
+
108
+ export function desktopLaunchArgs(home, wrapper) {
109
+ if (typeof wrapper !== "string" || !wrapper.startsWith("/")) throw new Error("Desktop startup requires a verified wrapper path.");
110
+ return ["-a", APP_PATH, "--env", `CODEX_CLI_PATH=${wrapper}`, "--env", `CODEX_HOME=${home}`];
111
+ }
112
+
113
+ export async function startDesktop({ dryRun = false, run = spawnSync, prepare = prepareDeployment, discover = discoverRuntime, connect = RpcClient.connect } = {}) {
114
+ const home = codexHome();
115
+ if (dryRun) return { backend: "app-server", dry_run: true, started: false, command: "desktop.start" };
116
+ if (process.platform !== "darwin") throw new Error("Desktop startup requires macOS.");
117
+ if (process.env.CODEX_APP_SERVER_WS_URL?.trim()) throw new Error("An existing CODEX_APP_SERVER_WS_URL override conflicts with shared Desktop startup. Remove it from this launch environment first.");
118
+ await access(BUNDLED_CLI, constants.X_OK);
119
+ await access(BUNDLED_NODE, constants.X_OK);
120
+ // No Accessibility APIs, focus changes, process termination, or -n (new instance).
121
+ const processes = run("/bin/ps", ["-axo", "comm="], { encoding: "utf8", timeout: 3000 });
122
+ if (processes.status !== 0) throw new Error("Cannot check whether Desktop is running. Run desktop start from your terminal.");
123
+ if (processes.stdout.split("\n").some(line => line.trim() === `${APP_PATH}/Contents/MacOS/ChatGPT`)) {
124
+ throw new Error("Desktop is already running. Finish current tasks and quit Desktop, then run: codexteer desktop start");
125
+ }
126
+ const deployment = await prepare(home);
127
+ const launched = run("/usr/bin/open", desktopLaunchArgs(home, deployment.wrapper_path), { encoding: "utf8", timeout: 5000 });
128
+ if (launched.status !== 0) throw new Error("Desktop launch failed. Normal startup from the Dock is still available.");
129
+ let lastError;
130
+ for (let attempt = 0; attempt < 100; attempt++) {
131
+ try {
132
+ const { paths, state } = await discover(home);
133
+ assertRuntimeOperation(state, "read");
134
+ if (state.distribution_sha256 !== deployment.sha256) throw Object.assign(new Error("The running wrapper is not the distribution selected for this launch."), { code: "DEPLOYMENT_MISMATCH" });
135
+ const client = await connect(paths.socket, { timeoutMs: 1000 });
136
+ client.close();
137
+ return { backend: "app-server", dry_run: false, started: true, ...installedVersions(), deployment };
138
+ } catch (error) {
139
+ if (!["RUNTIME_UNAVAILABLE", "RUNTIME_NOT_READY", "CONNECTION_FAILED", "TIMEOUT"].includes(error.code)) throw error;
140
+ lastError = error;
141
+ }
142
+ await delay(200);
143
+ }
144
+ throw new Error(`Desktop started, but its shared App Server is not ready. ${lastError?.message ?? ""}`);
145
+ }
@@ -0,0 +1,123 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { normalizeThreadId } from "./thread-id.mjs";
3
+ import { codexHome, discoverRuntime } from "./runtime.mjs";
4
+ import { RpcClient } from "./rpc.mjs";
5
+ import { decodeCursor, readOnClient } from "./observe.mjs";
6
+ import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
7
+
8
+ const retryable = new Set(["CONNECTION_FAILED", "TIMEOUT", "RUNTIME_UNAVAILABLE", "RUNTIME_NOT_READY"]);
9
+ const needsReview = new Set(["STALE_CURSOR", "CURSOR_UPGRADE_REQUIRED", "OBSERVATION_CHANGED"]);
10
+
11
+ // The consumer owns the watch lifetime. A flushed cursor is a transport boundary,
12
+ // not an acknowledgement that a supervising AI has read or acted on the output.
13
+ export async function streamThread(threadInput, options, onChange, { discover = discoverRuntime, connect = RpcClient.connect, sleep = delay, now = () => performance.now() } = {}) {
14
+ const threadId = normalizeThreadId(threadInput), { signal, pollMs = 1000, reconnectTimeoutMs = 60000 } = options;
15
+ if (!Number.isInteger(pollMs) || pollMs < 250 || pollMs > 10000) throw new Error("--poll-ms must be between 250 and 10000.");
16
+ if (!Number.isInteger(reconnectTimeoutMs) || reconnectTimeoutMs < 1 || reconnectTimeoutMs > 60000) throw new Error("Invalid reconnect timeout.");
17
+ if (signal?.aborted) return;
18
+ let home = codexHome(), cursor = options.since, client, attempt;
19
+ let established = false, lastObservedAt = null, outageStarted = null, attempts = 0, backoff = 1000, lastFailure;
20
+ const connection = (state, extra = {}) => ({
21
+ type: "connection", thread_id: threadId, state, resume_cursor: cursor ?? null,
22
+ last_observed_at: lastObservedAt, observed_at: new Date().toISOString(), reconnect_attempts: attempts, ...extra,
23
+ });
24
+ const expired = () => Object.assign(new Error("Watch could not restore observation within the reconnect deadline. Check doctor before restarting from a reviewed cursor."), {
25
+ code: "WATCH_RECONNECT_TIMEOUT", cause_code: lastFailure?.code,
26
+ });
27
+ const cancel = () => { attempt?.abort(new Error("Watch cancelled.")); client?.close(); };
28
+ signal?.addEventListener("abort", cancel, { once: true });
29
+
30
+ async function read() {
31
+ const remaining = outageStarted === null ? null : reconnectTimeoutMs - (now() - outageStarted);
32
+ if (remaining !== null && remaining <= 0) throw expired();
33
+ const current = new AbortController(); attempt = current;
34
+ // Bound the whole reconnect attempt, including initialize and multi-page reads.
35
+ const timer = remaining === null ? null : setTimeout(() => { current.abort(expired()); client?.close(); }, remaining);
36
+ try {
37
+ if (signal?.aborted) cancel();
38
+ if (!client && !current.signal.aborted) {
39
+ const { paths, state } = await discover(home);
40
+ assertRuntimeOperation(state, "monitor");
41
+ home = paths.home ?? home;
42
+ if (current.signal.aborted) throw current.signal.reason;
43
+ client = compatibleClient(await connect(paths.socket, { signal: current.signal, timeoutMs: Math.min(8000, remaining ?? 8000) }), state);
44
+ }
45
+ if (current.signal.aborted) throw current.signal.reason;
46
+ const data = await readOnClient(client, threadId, { ...options, since: cursor });
47
+ if (current.signal.aborted) throw current.signal.reason;
48
+ return data;
49
+ } catch (error) { throw current.signal.aborted ? current.signal.reason : error; }
50
+ finally { clearTimeout(timer); attempt = null; }
51
+ }
52
+
53
+ try {
54
+ if (cursor) decodeCursor(cursor, threadId);
55
+ while (!signal?.aborted) {
56
+ let data;
57
+ try { data = await read(); }
58
+ catch (error) {
59
+ if (signal?.aborted) break;
60
+ client?.close(); client = null;
61
+ if (outageStarted !== null && now() - outageStarted >= reconnectTimeoutMs) throw expired();
62
+ if (!established || !retryable.has(error.code)) throw error;
63
+ lastFailure = error;
64
+ if (outageStarted === null) {
65
+ outageStarted = now(); attempts = 0; backoff = 1000;
66
+ await onChange(connection("reconnecting", { cause_code: error.code, retry_timeout_ms: reconnectTimeoutMs }));
67
+ }
68
+ await sleep(Math.max(0, Math.min(backoff, reconnectTimeoutMs - (now() - outageStarted))), undefined, { signal });
69
+ if (now() - outageStarted >= reconnectTimeoutMs) throw expired();
70
+ backoff = Math.min(backoff * 2, 10000); attempts++;
71
+ continue;
72
+ }
73
+ if (signal?.aborted) break;
74
+ lastObservedAt = data.observed_at;
75
+ // Only network reads are retried. Consumer/output errors always stop here.
76
+ if (cursor && data.changed) await onChange({ ...data, type: "observation", reason: "change" });
77
+ if (signal?.aborted) break;
78
+ cursor = data.cursor;
79
+ if (!established || outageStarted !== null) {
80
+ await onChange(connection(established ? "recovered" : "watching", { has_more: data.has_more }));
81
+ }
82
+ established = true; outageStarted = null;
83
+ if (!data.has_more && !signal?.aborted) await sleep(pollMs, undefined, { signal });
84
+ }
85
+ } catch (error) {
86
+ if (!signal?.aborted) {
87
+ const failure = error instanceof Error ? error : new Error("Watch consumer failed.");
88
+ failure.code ??= "WATCH_FAILED";
89
+ failure.thread_id = threadId;
90
+ failure.watch = connection(needsReview.has(failure.code) ? "needs_review" : "failed", { cause_code: failure.cause_code ?? failure.code });
91
+ throw failure;
92
+ }
93
+ } finally {
94
+ signal?.removeEventListener("abort", cancel);
95
+ client?.close();
96
+ }
97
+ }
98
+
99
+ export function writeObservationLine(output, data) {
100
+ return new Promise((resolve, reject) => {
101
+ output.write(JSON.stringify({ ok: true, command: "watch", data }) + "\n", error => error ? reject(error) : resolve());
102
+ });
103
+ }
104
+
105
+ export async function monitorCommand(threadId, options) {
106
+ const controller = new AbortController(); let outputError;
107
+ const cancel = () => controller.abort();
108
+ const failedOutput = error => { outputError = error; cancel(); };
109
+ process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
110
+ process.stdout.on("error", failedOutput);
111
+ try {
112
+ await streamThread(threadId, { ...options, signal: controller.signal }, data => writeObservationLine(process.stdout, data));
113
+ } catch (error) {
114
+ if (!outputError) throw error;
115
+ } finally {
116
+ process.off("SIGINT", cancel); process.off("SIGTERM", cancel);
117
+ process.stdout.off("error", failedOutput);
118
+ }
119
+ if (outputError && outputError.code !== "EPIPE") {
120
+ console.error(`codexteer: Monitor output failed: ${outputError.message}`);
121
+ process.exitCode = 1;
122
+ }
123
+ }