codexteer 0.18.1 → 0.18.2

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 CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  公開日は日本時間です。
4
4
 
5
+ ## 0.18.2 — 2026-11-01公開
6
+
7
+ - 停止済みの監督IDが、別の監督への交代後に古い本文から再登録できる問題を修正しました。停止履歴を監督記録に保持し、登録前のdoctor・再登録・観測・送信で`SUPERVISOR_STOPPED`を返します。旧形式の停止済み記録も、交代時に履歴へ引き継ぎます。保存済み旧CLIを使う監督には、更新後のCLIでプロンプトを生成し直してください。
8
+ - 同じ監督IDの別タスクへの登録を拒否します。既存データでIDが重複している場合も`supervise list --json`は全件を返し、`session_conflict.thread_ids`と、他タスクの観測記録を除外した項目の`observation_error`で衝突を示します。各タスクの`supervise stop`で停止できます。
9
+
5
10
  ## 0.18.1 — 2026-10-31
6
11
 
7
12
  - ローカルロックの保持者と回収者が続けてクラッシュした後、`RECORD_BUSY`から自動回復できなくなる問題を修正しました。回収処理にもPIDと世代トークンを持つロックを使い、生きた保持者・回収者の排他を維持します。
package/README.md CHANGED
@@ -253,6 +253,10 @@ codexteer supervise stop <thread-id>
253
253
 
254
254
  直接起動はエージェントを起動する前に登録し、終了時に停止を記録します。本文だけの生成では未登録で、貼り付け先が本文中の`supervise register`を実行して開始します。一つのタスクに一つの監督を登録でき、競合は`SUPERVISOR_CONFLICT`になります。異常終了などで残った登録は、`status`で確認して明示的に`stop`してから開始し直してください。停止済みIDや古い本文で再開はできません。同じ本文の再登録で一時停止が解除されることもありません。
255
255
 
256
+ 停止済みIDは交代後も監督記録の履歴に保持し、登録前の`doctor`・再登録・観測・送信を`SUPERVISOR_STOPPED`で拒否します。更新前の停止済み記録も、交代時に履歴へ引き継ぎます。更新前にすでに上書きされて失われた停止履歴は復元できません。保存済み旧CLIには修正が反映されないため、更新後のCLIでプロンプトを生成し直してください。
257
+
258
+ 同じ`CODEX_HOME`・接続先ホストの中では、監督IDを別タスクへ登録できません(`SUPERVISOR_CONFLICT`。停止済みなら`SUPERVISOR_STOPPED`)。旧版で同じIDを複数タスクに登録したデータも、`supervise list --json`は全件を返し、該当項目の`session_conflict.thread_ids`に衝突先を示します。他タスクの観測記録は`observation: null`と`observation_error`で明示し、そのタスクの観測として扱いません。重複したIDでの観測・送信は拒否されます。ユーザーの端末から各タスクを`supervise stop`し、新しいIDの監督を開始してください。
259
+
256
260
  送信中の`pause`/`stop`は`RECORD_BUSY`で失敗します。配送結果を確認して制御を再実行し、成功するまでは停止したと扱わないでください。監督役が自分で`resume`したり、生成コマンドから`--supervisor`を外して回避したりしないよう、必須テンプレートにも指示します。
257
261
 
258
262
  この制御は新しい監督ID付きコマンドに適用します。以前の版で開始した監督、IDなしの手動送信、同じOSユーザーが実行する任意のコードを隔離する機能ではありません。監督IDは秘密情報ではなく、古いセッションや操作の競合を検出する識別子です。
@@ -5,6 +5,8 @@
5
5
 
6
6
  `0.18.1`は、保持者と回収者の連続クラッシュでローカルロックが永久に`RECORD_BUSY`になる問題を修正します。更新前に、同じ`CODEX_HOME`を使う旧版CLIの操作と、保存済み旧CLIで動く監督を終了してください。旧版と修正版の回収方式の混在は非対応です。旧`.recover`ディレクトリは修正版では無視されます。監督は更新後のCLIで起動し直すか、プロンプトを生成し直してください。
7
7
 
8
+ `0.18.2`は、監督が交代した後も停止済みIDを履歴に保持し、古い本文からの再登録・観測・送信を`SUPERVISOR_STOPPED`で拒否します。登録前の`doctor`にも同じ判定を適用します。同じIDの別タスクへの登録を拒否し、旧版で重複したIDを持つ記録も`supervise list --json`で全件と衝突先を返します。該当タスクはそれぞれ`supervise stop`で停止できます。更新前の停止済み記録は交代時に引き継ぎますが、すでに上書きされて失われた停止履歴は復元できません。保存済み旧CLIには修正が反映されないため、更新後のCLIで監督を起動し直すか、プロンプトを生成し直してください。runtime protocolとcapability契約はv1を維持し、Desktopの再起動は必要ありません。
9
+
8
10
  `package.json` は `private` を持たず、`publishConfig` でnpm公式レジストリへのpublic公開を指定しています。公開可能な設定であることと、実際に公開済みであることは別です。
9
11
 
10
12
  公開後の基本的な実行形式は次のとおりです。通常はバージョン指定なしで使えます。監督開始後は保存したCLIを使い続けるため、監督中の一貫性を保つために版を明示する必要はありません。起動側と操作側の製品バージョンも、必要な通信仕様が対応していれば異なっていても使えます。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexteer",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "Send steering messages to running Codex Desktop threads from a terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { acquireResource, resourceStatus, renewResource, releaseResource, listRe
15
15
  import { playSendSound } from "./sound.mjs";
16
16
  import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
17
17
  import { prepareOrchestrationPrompt, orchestrateAgent } from "./orchestrate.mjs";
18
- import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
18
+ import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, assertSupervisorRegistration, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
19
19
  import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
20
20
  import { connectionMode, observationConnection, desktopReadOnly } from "./connection.mjs";
21
21
  import { repairTurns } from "./turn-repair.mjs";
@@ -141,7 +141,7 @@ function printThreads(threads) {
141
141
  function printSupervisor(data) {
142
142
  if (Array.isArray(data)) {
143
143
  if (!data.length) console.log("No supervisor sessions registered.");
144
- for (const entry of data) console.log(`${entry.thread_id} ${entry.state} ${entry.owner} ${entry.connection} observed:${entry.observation?.last_observed_at ?? "none"}`);
144
+ for (const entry of data) console.log(`${entry.thread_id} ${entry.state} ${entry.owner} ${entry.connection} observed:${entry.observation?.last_observed_at ?? "none"}${entry.session_conflict ? ` session-conflict:${entry.session_conflict.thread_ids.join(",")}` : ""}${entry.observation_error ? ` observation-error:${entry.observation_error.code}` : ""}`);
145
145
  return;
146
146
  }
147
147
  console.log(`${data.thread_id} ${data.state}${data.owner ? ` ${data.owner} ${data.connection}` : ""}`);
@@ -459,11 +459,7 @@ async function mainOnTarget(argv) {
459
459
  const target = takeOption(args, "--thread", undefined);
460
460
  if (target !== undefined && backend !== "app-server") throw new Error("doctor --thread requires --backend app-server.");
461
461
  const threadId = target === undefined ? undefined : normalizeThreadId(target);
462
- if (supervisor && threadId) {
463
- const record = await getSupervisor(threadId);
464
- if (record && record.session_id !== supervisor && record.state !== "stopped") throw Object.assign(new Error("Another supervisor is registered for this task."), { code: "SUPERVISOR_CONFLICT" });
465
- if (record?.session_id === supervisor) await assertSupervisor(threadId, supervisor, { connection });
466
- }
462
+ if (supervisor && threadId) await assertSupervisorRegistration(threadId, supervisor, { connection });
467
463
  if (args.length) throw new Error(`Unexpected argument: ${args[0]}`);
468
464
  if (targetHost() !== "local" && backend === "ui") throw remoteReadOnly();
469
465
  const report = backend === "ui" ? desktopDoctor() : await appServerDoctor({ threadId, ...connectionDependencies });
package/src/cloud-cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { observeThread, printObservation } from "./observe.mjs";
10
10
  import { monitorCommand } from "./monitor.mjs";
11
11
  import { notificationOptions } from "./notify.mjs";
12
12
  import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
13
- import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
13
+ import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, assertSupervisorRegistration, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
14
14
  import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
15
15
  import { sendTrackedMessage, listMessages, getMessage, messageSummary, reconcileMessages, markMessage, listInstructions } from "./journal.mjs";
16
16
  import { createCloudTask, cloudTaskDiff, listCloudTasks, cloudCreationReceipt } from "./cloud.mjs";
@@ -84,9 +84,8 @@ export async function mainCloud(argv) {
84
84
  const bound = async (target, { allowUnregistered = false } = {}) => {
85
85
  const id = targetId(target);
86
86
  if (supervisor) {
87
- const record = allowUnregistered ? await getSupervisor(id) : null;
88
- if (!allowUnregistered || record?.session_id === supervisor) await assertSupervisor(id, supervisor, { connection });
89
- else if (record && record.state !== "stopped") throw failure("Another supervisor owns this cloud task.", "SUPERVISOR_CONFLICT");
87
+ if (allowUnregistered) await assertSupervisorRegistration(id, supervisor, { connection });
88
+ else await assertSupervisor(id, supervisor, { connection });
90
89
  }
91
90
  return id;
92
91
  };
@@ -21,12 +21,35 @@ export function supervisorId(value) {
21
21
  return value.toLowerCase();
22
22
  }
23
23
 
24
+ // Keep retired generations in the same atomic record as the current owner.
25
+ // A legacy stopped record contributes its ID even before its first rewrite.
26
+ function stoppedSessionIds(record) {
27
+ return [...new Set([...(record?.stopped_session_ids ?? []), ...(record?.state === "stopped" ? [record.session_id] : [])])];
28
+ }
29
+
30
+ function assertNotStopped(record, sessionId) {
31
+ if (stoppedSessionIds(record).includes(sessionId)) throw failure("This supervisor session has stopped. Start a new supervisor when needed.", "SUPERVISOR_STOPPED");
32
+ }
33
+
34
+ async function supervisorRecords(storage) {
35
+ return (await Promise.all([listRecords("supervisors", storage), listRecords("cloud-supervisors", storage)])).flat().filter(Boolean);
36
+ }
37
+
38
+ async function assertSessionBinding(threadId, sessionId, storage) {
39
+ const others = (await supervisorRecords(storage)).filter(record => record.thread_id !== threadId);
40
+ // A stopped ID stays retired even when a caller changes its target task.
41
+ for (const record of others) assertNotStopped(record, sessionId);
42
+ if (others.some(record => record.session_id === sessionId)) throw failure("This supervisor session is already registered for another task. Start a new supervisor for this task.", "SUPERVISOR_CONFLICT");
43
+ }
44
+
24
45
  export async function getSupervisor(threadInput, storage = {}) {
25
46
  const threadId = normalizeTaskId(threadInput), record = await readRecord(supervisorBucket(threadId), threadId, storage);
26
47
  if (!record) return null;
27
48
  if (record.schema !== 1 || record.thread_id !== threadId || !states.has(record.state)
28
49
  || !["shared", "desktop", "cloud", "desktop-cloud"].includes(record.connection)
29
50
  || supervisorId(record.session_id) !== record.session_id || typeof record.owner !== "string"
51
+ || (record.stopped_session_ids !== undefined && (!Array.isArray(record.stopped_session_ids)
52
+ || record.stopped_session_ids.some(id => supervisorId(id) !== id)))
30
53
  || (record.launcher_pid !== null && (!Number.isSafeInteger(record.launcher_pid) || record.launcher_pid <= 0))) {
31
54
  throw failure("Invalid supervisor state. Inspect the local record before continuing.", "SUPERVISOR_INVALID");
32
55
  }
@@ -34,35 +57,45 @@ export async function getSupervisor(threadInput, storage = {}) {
34
57
  }
35
58
 
36
59
  export async function assertSupervisor(threadInput, sessionInput, { sending = false, connection, ...storage } = {}) {
37
- const sessionId = supervisorId(sessionInput), record = await getSupervisor(threadInput, storage);
60
+ const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput), record = await getSupervisor(threadId, storage);
61
+ assertNotStopped(record, sessionId);
62
+ await assertSessionBinding(threadId, sessionId, storage);
38
63
  if (!record) throw failure("Register this supervisor session before observing or sending.", "SUPERVISOR_NOT_REGISTERED");
39
64
  if (record.session_id !== sessionId) throw failure("Another supervisor session owns this task. This command belongs to an older or different session.", "SUPERVISOR_MISMATCH");
40
65
  if (connection !== undefined && record.connection !== connection) throw failure("The supervisor is registered for another connection. Keep the original command intact.", "SUPERVISOR_MISMATCH");
41
66
  if (targetHost() !== "local" && (record.remote_transport ?? "native") !== remoteTransport()) throw failure("The supervisor belongs to a different remote transport. Keep the generated route intact.", "SUPERVISOR_MISMATCH");
42
- if (record.state === "stopped") throw failure("This supervisor session has stopped. Start a new supervisor when needed.", "SUPERVISOR_STOPPED");
43
67
  if (launcherAlive(record) === false) throw failure("The supervisor launcher exited. Stop the stale session before starting another supervisor.", "SUPERVISOR_ORPHANED");
44
68
  if (sending && record.state === "paused") throw failure("Automatic intervention is paused. Observation can continue; only the user should resume intervention.", "SUPERVISOR_PAUSED");
45
69
  return record;
46
70
  }
47
71
 
72
+ // Read-only bootstrap check shared by doctor and registration. Registration
73
+ // repeats it under the task lock; a successful doctor does not reserve an ID.
74
+ export async function assertSupervisorRegistration(threadInput, sessionInput, { connection, ...storage } = {}) {
75
+ const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput), current = await getSupervisor(threadId, storage);
76
+ assertNotStopped(current, sessionId);
77
+ await assertSessionBinding(threadId, sessionId, storage);
78
+ if (current?.session_id === sessionId) return assertSupervisor(threadInput, sessionId, { connection, ...storage });
79
+ if (current && current.state !== "stopped") throw failure("A supervisor already owns this task. Use supervise status, then stop it explicitly before replacing it.", "SUPERVISOR_CONFLICT");
80
+ return current;
81
+ }
82
+
48
83
  export async function registerSupervisor(threadInput, sessionInput, { owner = "external", launcherPid = null, connection = "shared", ...storage } = {}) {
49
84
  const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput);
50
85
  if (typeof owner !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(owner)) throw failure("Supervisor owner must be a short agent name.", "SUPERVISOR_INVALID");
51
86
  if (launcherPid !== null && (!Number.isSafeInteger(launcherPid) || launcherPid <= 0)) throw failure("Invalid supervisor launcher PID.", "SUPERVISOR_INVALID");
52
87
  if (!["shared", "desktop", "cloud", "desktop-cloud"].includes(connection)) throw failure("Invalid supervisor connection.", "SUPERVISOR_INVALID");
53
- return withStoreLock(`supervisor-${threadId}`, async () => {
54
- const current = await getSupervisor(threadId, storage);
55
- if (current?.session_id === sessionId) {
56
- if (current.connection !== connection) throw failure("The supervisor is registered for another connection. Keep the original command intact.", "SUPERVISOR_MISMATCH");
57
- return assertSupervisor(threadId, sessionId, storage);
58
- }
59
- if (current && current.state !== "stopped") throw failure("A supervisor already owns this task. Use supervise status, then stop it explicitly before replacing it.", "SUPERVISOR_CONFLICT");
88
+ // Registrations take the namespace-wide lock before the task lock. Stop,
89
+ // pause and sends only take the task lock and never acquire in reverse order.
90
+ return withStoreLock("supervisor-registration", () => withStoreLock(`supervisor-${threadId}`, async () => {
91
+ const current = await assertSupervisorRegistration(threadId, sessionId, { connection, ...storage });
92
+ if (current?.session_id === sessionId) return current;
60
93
  const now = new Date().toISOString();
61
94
  const record = { schema: 1, thread_id: threadId, session_id: sessionId, owner, launcher_pid: launcherPid, connection,
62
- state: "active", registered_at: now, updated_at: now, last_delivery: null };
95
+ state: "active", stopped_session_ids: stoppedSessionIds(current), registered_at: now, updated_at: now, last_delivery: null };
63
96
  await writeRecord(supervisorBucket(threadId), threadId, record, storage);
64
97
  return record;
65
- }, storage);
98
+ }, storage), storage);
66
99
  }
67
100
 
68
101
  export async function controlSupervisor(threadInput, action, { expectedSession, ...storage } = {}) {
@@ -76,23 +109,32 @@ export async function controlSupervisor(threadInput, action, { expectedSession,
76
109
  if (record.state === "stopped" && action !== "stop") throw failure("A stopped supervisor cannot be resumed. Start a new supervisor.", "SUPERVISOR_STOPPED");
77
110
  if (action === "resume") await assertSupervisor(threadId, record.session_id, storage);
78
111
  const updated = { ...record, state: { pause: "paused", resume: "active", stop: "stopped" }[action], updated_at: new Date().toISOString() };
112
+ updated.stopped_session_ids = stoppedSessionIds(updated);
79
113
  await writeRecord(supervisorBucket(threadId), threadId, updated, storage);
80
114
  return updated;
81
115
  }, storage);
82
116
  }
83
117
 
84
- export async function supervisorStatus(threadInput, storage = {}) {
118
+ export async function supervisorStatus(threadInput, { allowObservationConflict = false, ...storage } = {}) {
85
119
  const threadId = normalizeTaskId(threadInput), record = await getSupervisor(threadId, storage);
86
120
  if (!record) return { thread_id: threadId, state: "unregistered", observation: null };
87
- const observation = await readRecord("supervisor-observations", record.session_id, storage);
121
+ let observation = await readRecord("supervisor-observations", record.session_id, storage), observationError;
122
+ if (allowObservationConflict && observation?.schema === 1 && observation.session_id === record.session_id && observation.thread_id !== threadId) {
123
+ observationError = { code: "SUPERVISOR_INVALID", message: "The observation belongs to another task.", thread_id: observation.thread_id };
124
+ observation = null;
125
+ }
88
126
  if (observation && (observation.schema !== 1 || observation.thread_id !== threadId || observation.session_id !== record.session_id)) throw failure("Invalid supervisor observation record.", "SUPERVISOR_INVALID");
89
- return { ...record, launcher_alive: launcherAlive(record),
127
+ return { ...record, launcher_alive: launcherAlive(record), ...(observationError ? { observation_error: observationError } : {}),
90
128
  observation, observation_age_ms: observation?.last_observed_at ? Math.max(0, Date.now() - Date.parse(observation.last_observed_at)) : null };
91
129
  }
92
130
 
93
131
  export async function listSupervisors(storage = {}) {
94
- const records = (await Promise.all([listRecords("supervisors", storage), listRecords("cloud-supervisors", storage)])).flat();
95
- return Promise.all(records.filter(Boolean).map(record => supervisorStatus(record.thread_id, storage)));
132
+ const records = await supervisorRecords(storage);
133
+ return Promise.all(records.map(async record => {
134
+ const status = await supervisorStatus(record.thread_id, { ...storage, allowObservationConflict: true });
135
+ const conflicts = records.filter(other => other.session_id === record.session_id).map(other => other.thread_id);
136
+ return conflicts.length > 1 ? { ...status, session_conflict: { thread_ids: conflicts.sort() } } : status;
137
+ }));
96
138
  }
97
139
 
98
140
  // Observation has its own record so a concurrent read never overwrites pause/stop.