codexteer 0.14.1 → 0.15.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.
package/src/cli.mjs CHANGED
@@ -11,6 +11,9 @@ import { createCheckpoint, listCheckpoints, getCheckpoint, checkpointSummary, ve
11
11
  import { acquireResource, resourceStatus, renewResource, releaseResource, listResources, runWithResource } from "./resource.mjs";
12
12
  import { playSendSound } from "./sound.mjs";
13
13
  import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
14
+ import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
15
+ import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
16
+ import { connectionMode, observationConnection, desktopReadOnly } from "./connection.mjs";
14
17
 
15
18
  const DEFAULT_SEND_BACKEND = "app-server";
16
19
 
@@ -26,6 +29,8 @@ function fail(error, json) {
26
29
  operation: error.operation, capability: error.capability,
27
30
  sent: error.sent, thread_id: error.thread_id, rpc_code: error.rpc_code ?? error.rpcCode,
28
31
  message_id: error.message_id, client_message_id: error.client_message_id, journal_update_required: error.journal_update_required,
32
+ existing_message_id: error.existing_message_id,
33
+ supervision_update_required: error.supervision_update_required,
29
34
  } }));
30
35
  else console.error(`codexteer: ${message}`);
31
36
  process.exitCode = 1;
@@ -76,11 +81,33 @@ function printThreads(threads) {
76
81
  }
77
82
  }
78
83
 
84
+ function printSupervisor(data) {
85
+ if (Array.isArray(data)) {
86
+ if (!data.length) console.log("No supervisor sessions registered.");
87
+ for (const entry of data) console.log(`${entry.thread_id} ${entry.state} ${entry.owner} ${entry.connection} observed:${entry.observation?.last_observed_at ?? "none"}`);
88
+ return;
89
+ }
90
+ console.log(`${data.thread_id} ${data.state}${data.owner ? ` ${data.owner} ${data.connection}` : ""}`);
91
+ if (data.session_id) console.log(`session: ${data.session_id}`);
92
+ if (Object.hasOwn(data, "observation")) console.log(`observation: ${data.observation?.connection ?? "none"} last:${data.observation?.last_observed_at ?? "none"} age_ms:${data.observation_age_ms ?? "unknown"}`);
93
+ if (Object.hasOwn(data, "launcher_alive")) console.log(`launcher: ${data.launcher_alive === null ? "unverified (copied prompt)" : data.launcher_alive ? "alive" : "exited"}`);
94
+ if (data.last_delivery) console.log(`delivery: ${data.last_delivery.status} message:${data.last_delivery.message_id ?? "none"}`);
95
+ if (data.last_error) console.log(`last error: ${data.last_error.code}`);
96
+ if (data.unresolved_findings) {
97
+ console.log(`unresolved findings: ${data.unresolved_findings.total}`);
98
+ for (const finding of data.unresolved_findings.findings) console.log(` ${finding.id} ${finding.status} ${finding.title}`);
99
+ }
100
+ }
101
+
79
102
  export async function main(argv) {
80
103
  const args = [...argv];
81
104
  let json = takeFlag(args, "--json");
82
105
 
83
106
  try {
107
+ const sessionOption = takeOption(args, "--supervisor", undefined);
108
+ const supervisor = sessionOption === undefined ? undefined : supervisorId(sessionOption);
109
+ const connection = connectionMode(takeOption(args, "--connection", "shared"));
110
+ const connectionDependencies = observationConnection(connection);
84
111
  const requiredNode = takeOption(args, "--require-node-version", undefined);
85
112
  if (requiredNode !== undefined) {
86
113
  if (!/^v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(requiredNode)) throw new Error("--require-node-version requires a Node version such as v25.1.0.");
@@ -103,22 +130,73 @@ export async function main(argv) {
103
130
  }
104
131
 
105
132
  let command = args.shift();
133
+ // An explicit observation endpoint can never silently route a mutation to
134
+ // the shared wrapper or UI. Local records/help remain available.
135
+ if (connection === "desktop" && !["supervise", "findings", "history", "instructions", "checkpoint", "resource", "read", "status", "watch", "doctor", "threads", "thread"].includes(command)) throw desktopReadOnly();
136
+ if (command === "supervise" && ["register", "status", "list", "pause", "resume", "stop"].includes(args[0])) {
137
+ const action = args.shift(), owner = action === "register" ? takeOption(args, "--owner", "external") : undefined;
138
+ if (args.length !== (action === "list" ? 0 : 1)) throw new Error(`Expected: supervise ${action}${action === "list" ? "" : " <THREAD>"}.`);
139
+ let data;
140
+ if (action === "register") {
141
+ if (!supervisor) throw new Error("supervise register requires the --supervisor ID from a generated prompt.");
142
+ data = await registerSupervisor(args[0], supervisor, { owner, connection });
143
+ } else {
144
+ if (supervisor && action !== "status") throw Object.assign(new Error("Supervisors cannot change their own control state. Run this control command from the user's terminal without --supervisor."), { code: "SUPERVISOR_CONTROL_FORBIDDEN" });
145
+ if (supervisor) {
146
+ const record = await getSupervisor(args[0]);
147
+ if (record?.session_id !== supervisor) throw Object.assign(new Error("This supervisor belongs to a different session or task."), { code: "SUPERVISOR_MISMATCH" });
148
+ }
149
+ data = action === "list" ? await listSupervisors() : action === "status" ? await supervisorStatus(args[0]) : await controlSupervisor(args[0], action);
150
+ if (action === "status") {
151
+ const pending = await listFindings(args[0], { limit: 20 });
152
+ data.unresolved_findings = { total: pending.total, has_more: pending.has_more,
153
+ findings: pending.findings.map(finding => ({ id: finding.id, title: finding.title, status: finding.status, revision: finding.revision })) };
154
+ }
155
+ }
156
+ success(`supervise.${action}`, data, json);
157
+ if (!json) printSupervisor(data);
158
+ return;
159
+ }
160
+ if (command === "findings") {
161
+ const action = args.shift();
162
+ const flags = names => Object.fromEntries(names.map(([field, option]) => [field, takeOption(args, option, undefined)]));
163
+ let options = {}, data;
164
+ if (action === "create") options = { ...flags([["key", "--key"], ["title", "--title"], ["condition", "--condition"], ["basedOn", "--based-on"]]), evidence: takeOptions(args, "--evidence") };
165
+ else if (action === "resolve") options = flags([["checkpoint", "--checkpoint"], ["note", "--note"]]);
166
+ else if (action === "dismiss") options = flags([["reason", "--reason"]]);
167
+ else if (action === "reopen") {
168
+ options = flags([["reason", "--reason"], ["condition", "--condition"], ["basedOn", "--based-on"]]);
169
+ const evidence = takeOptions(args, "--evidence"); if (evidence.length) options.evidence = evidence;
170
+ } else if (action === "evaluate") options = flags([["rating", "--rating"], ["reason", "--reason"]]);
171
+ else if (action === "list") options = { all: takeFlag(args, "--all"), limit: Number(takeOption(args, "--limit", "50")) };
172
+ const handlers = { create: createFinding, show: findingStatus, list: listFindings, resolve: resolveFinding, dismiss: dismissFinding, reopen: reopenFinding, evaluate: evaluateFinding, stats: findingStats };
173
+ const single = ["create", "list", "stats"].includes(action);
174
+ if (!Object.hasOwn(handlers, action) || args.length !== (single ? 1 : 2) || args.some(arg => arg.startsWith("--"))) throw new Error("See codexteer help findings.");
175
+ if (supervisor) await assertSupervisor(args[0], supervisor, { connection });
176
+ data = single ? await handlers[action](args[0], options) : await handlers[action](args[0], args[1], options);
177
+ success(`findings.${action}`, data, json);
178
+ if (!json) console.log(JSON.stringify(data, null, 2));
179
+ return;
180
+ }
106
181
  if (command === "supervise" && args[0] === "prompt") {
107
182
  args.shift();
108
- if (args.length !== 1) throw new Error("Expected: supervise prompt <THREAD>. See codexteer help supervise prompt.");
109
- const data = await prepareSupervisorPrompt(args[0]);
183
+ const agent = takeOption(args, "--agent", "claude");
184
+ if (args.length < 1 || args.length > 2 || args.some(arg => arg.startsWith("--"))) throw new Error("Expected: supervise prompt <THREAD> [MESSAGE]. See codexteer help supervise prompt.");
185
+ if (supervisor) throw new Error("A supervisor cannot generate a replacement session.");
186
+ const data = await prepareSupervisorPrompt(args[0], args[1], { agent, connection });
110
187
  success("supervise.prompt", data, json);
111
188
  if (!json) console.log(data.prompt);
112
189
  return;
113
190
  }
114
191
  if (command === "supervise") {
192
+ if (supervisor) throw new Error("A supervisor cannot start a replacement session.");
115
193
  if (json) throw new Error("supervise inherits agent output and does not support --json. Use help supervise --json or supervise prompt <THREAD> --json.");
116
194
  const separator = args.indexOf("--");
117
195
  const ownArgs = separator < 0 ? args : args.slice(0, separator);
118
196
  const agentArgs = separator < 0 ? [] : args.slice(separator + 1);
119
- const agent = takeOption(ownArgs, "--agent", undefined);
120
- if (ownArgs.length !== 1 || !agent) throw new Error("Expected: supervise <THREAD> --agent claude [-- <AGENT-ARGS...>].");
121
- process.exitCode = await superviseAgent(ownArgs[0], { agent, agentArgs });
197
+ const agent = takeOption(ownArgs, "--agent", "claude");
198
+ if (ownArgs.length < 1 || ownArgs.length > 2 || ownArgs.some(arg => arg.startsWith("--"))) throw new Error("Expected: supervise <THREAD> [MESSAGE] [--agent claude|codex] [-- <AGENT-ARGS...>].");
199
+ process.exitCode = await superviseAgent(ownArgs[0], { agent, agentArgs, policy: ownArgs[1], connection });
122
200
  return;
123
201
  }
124
202
  if (command === "resource") {
@@ -148,6 +226,7 @@ export async function main(argv) {
148
226
  }
149
227
  if (command === "checkpoint") {
150
228
  const action = args.shift(), includeOutput = takeFlag(args, "--include-output");
229
+ if (supervisor) await assertSupervisor(args[0], supervisor, { connection });
151
230
  let data;
152
231
  if (action === "capture") {
153
232
  const paths = takeOptions(args, "--path"), excludes = takeOptions(args, "--exclude");
@@ -180,16 +259,18 @@ export async function main(argv) {
180
259
  if (action === "list") {
181
260
  const all = takeFlag(args, "--all");
182
261
  if (args.length !== 1) throw new Error("Expected: instructions list <THREAD> [--all].");
262
+ if (supervisor) await assertSupervisor(args[0], supervisor, { connection });
183
263
  const data = await listInstructions(args[0], { all });
184
264
  success("instructions.list", data, json);
185
265
  if (!json) { for (const entry of data.instructions) console.log(`${entry.id} ${entry.instruction_status} ${entry.metadata?.source ?? "unspecified"}\n${entry.body}`); if (data.pending_changes.length) console.log(`配送未確認: ${data.pending_changes.map(e => e.id).join(", ")}`); }
186
266
  return;
187
267
  }
188
268
  if (action !== "retract") throw new Error("See codexteer help instructions.");
269
+ if (connection === "desktop") throw desktopReadOnly();
189
270
  const reason = takeOption(args, "--reason", undefined), source = takeOption(args, "--source", undefined), basedOn = takeOption(args, "--based-on", undefined);
190
271
  const dryRun = takeFlag(args, "--dry-run"), newTurn = takeFlag(args, "--new-turn");
191
272
  if (args.length !== 2 || !reason?.trim()) throw new Error("Expected: instructions retract <THREAD> <MESSAGE-ID> --reason TEXT.");
192
- const data = await sendTrackedMessage(args[0], reason, { retracts: args[1], source, basedOn, dryRun, newTurn });
273
+ const data = await sendTrackedMessage(args[0], reason, { retracts: args[1], source, basedOn, dryRun, newTurn, supervisor, connection });
193
274
  success("instructions.retract", data, json);
194
275
  if (!json) console.log(dryRun ? "撤回の送信プレビューです。" : `撤回を受け付けました。message_id: ${data.message_id}`);
195
276
  return;
@@ -201,7 +282,8 @@ export async function main(argv) {
201
282
  const report = action === "mark" ? { status: takeOption(args, "--status", undefined), note: takeOption(args, "--note", undefined), evidence: takeOptions(args, "--evidence"), by: takeOption(args, "--by", "local") } : {};
202
283
  const [threadId, id] = args;
203
284
  if (!threadId || args.length > 2 || !["list", "show", "check", "mark"].includes(action) || (["show", "mark"].includes(action) && !id) || (action === "list" && id)) throw new Error("See codexteer help history.");
204
- const data = action === "list" ? await listMessages(threadId, { includeText, pending }) : action === "show" ? messageSummary(await getMessage(threadId, id), includeText) : action === "check" ? await reconcileMessages(threadId, id, { includeText }) : await markMessage(threadId, id, report);
285
+ if (supervisor) await assertSupervisor(threadId, supervisor, { connection });
286
+ const data = action === "list" ? await listMessages(threadId, { includeText, pending }) : action === "show" ? messageSummary(await getMessage(threadId, id), includeText) : action === "check" ? await reconcileMessages(threadId, id, { includeText }, connectionDependencies) : await markMessage(threadId, id, report);
205
287
  success(`history.${action}`, data, json);
206
288
  if (!json) for (const entry of Array.isArray(data) ? data : [data]) console.log(`${entry.id} ${entry.delivery_status} history:${entry.verification?.status ?? "unchecked"} response:${entry.response.status}${entry.body ? "\n" + entry.body : ""}`);
207
289
  return;
@@ -215,6 +297,7 @@ export async function main(argv) {
215
297
  maxChars: Number(takeOption(args, "--max-chars", "2000")),
216
298
  includeOutput: takeFlag(args, "--include-output"),
217
299
  };
300
+ options.supervisor = supervisor; options.connection = connection;
218
301
  const stream = command === "watch" && takeFlag(args, "--stream");
219
302
  if (stream) json = true;
220
303
  if (command === "watch") {
@@ -223,8 +306,9 @@ export async function main(argv) {
223
306
  Object.assign(options, { watch: true, until: until ?? "change", timeoutMs: Number(timeout ?? "30000"), pollMs: Number(takeOption(args, "--poll-ms", "1000")) });
224
307
  }
225
308
  if (args.length !== 1) throw new Error(`Expected: ${command} <THREAD>. See codexteer help ${command}.`);
226
- if (stream) { await monitorCommand(args[0], options); return; }
227
- const data = await observeThread(args[0], options);
309
+ if (stream) { await monitorCommand(args[0], options, connectionDependencies); return; }
310
+ const data = await observeThread(args[0], options, connectionDependencies);
311
+ data.connection_mode = connection;
228
312
  if (command === "status") delete data.events;
229
313
  success(command, data, json);
230
314
  if (!json) printObservation(data, command === "status");
@@ -232,11 +316,18 @@ export async function main(argv) {
232
316
  }
233
317
  if (command === "doctor") {
234
318
  const backend = backendOption(args, "app-server");
319
+ if (connection === "desktop" && backend === "ui") throw desktopReadOnly();
235
320
  const target = takeOption(args, "--thread", undefined);
236
321
  if (target !== undefined && backend !== "app-server") throw new Error("doctor --thread requires --backend app-server.");
237
322
  const threadId = target === undefined ? undefined : normalizeThreadId(target);
323
+ if (supervisor && threadId) {
324
+ const record = await getSupervisor(threadId);
325
+ if (record && record.session_id !== supervisor && record.state !== "stopped") throw Object.assign(new Error("Another supervisor is registered for this task."), { code: "SUPERVISOR_CONFLICT" });
326
+ if (record?.session_id === supervisor) await assertSupervisor(threadId, supervisor, { connection });
327
+ }
238
328
  if (args.length) throw new Error(`Unexpected argument: ${args[0]}`);
239
- const report = backend === "ui" ? desktopDoctor() : await appServerDoctor({ threadId });
329
+ const report = backend === "ui" ? desktopDoctor() : await appServerDoctor({ threadId, ...connectionDependencies });
330
+ if (report.connection) Object.assign(report.connection, { mode: connection, observation_only: connection === "desktop" });
240
331
  report.default_send_backend = DEFAULT_SEND_BACKEND;
241
332
  report.rollout_status = "enabled";
242
333
  success("doctor", report, json);
@@ -322,13 +413,14 @@ export async function main(argv) {
322
413
 
323
414
  const dryRun = takeFlag(args, "--dry-run");
324
415
  const backend = backendOption(args, DEFAULT_SEND_BACKEND);
416
+ if (supervisor && backend !== "app-server") throw new Error("Managed supervision requires the app-server backend.");
325
417
  const explicitSound = takeFlag(args, "--sound");
326
418
  const noSound = takeFlag(args, "--no-sound");
327
419
  if (explicitSound && noSound) throw new Error("--sound and --no-sound cannot be combined.");
328
420
  if (explicitSound && backend !== "app-server") throw new Error("--sound requires --backend app-server so receipt can be confirmed.");
329
421
  const newTurn = takeFlag(args, "--new-turn");
330
- const directive = { source: takeOption(args, "--source", undefined), kind: takeOption(args, "--kind", undefined), evidence: takeOptions(args, "--evidence"), basedOn: takeOption(args, "--based-on", undefined), supersedes: takeOption(args, "--supersedes", undefined), expiresAt: takeOption(args, "--expires-at", undefined), checkpoint: takeOption(args, "--checkpoint", undefined) };
331
- if (backend === "ui" && (directive.source || directive.kind || directive.evidence.length || directive.basedOn || directive.supersedes || directive.expiresAt || directive.checkpoint)) throw new Error("Directive metadata requires --backend app-server.");
422
+ const directive = { source: takeOption(args, "--source", undefined), kind: takeOption(args, "--kind", undefined), evidence: takeOptions(args, "--evidence"), basedOn: takeOption(args, "--based-on", undefined), supersedes: takeOption(args, "--supersedes", undefined), expiresAt: takeOption(args, "--expires-at", undefined), checkpoint: takeOption(args, "--checkpoint", undefined), finding: takeOption(args, "--finding", undefined) };
423
+ if (backend === "ui" && (directive.source || directive.kind || directive.evidence.length || directive.basedOn || directive.supersedes || directive.expiresAt || directive.checkpoint || directive.finding)) throw new Error("Directive metadata requires --backend app-server.");
332
424
  const keepFocus = takeFlag(args, "--keep-focus");
333
425
  const waitInput = takeOption(args, "--wait-ms", undefined);
334
426
  if (backend !== "ui" && (keepFocus || waitInput != null)) throw new Error("--keep-focus and --wait-ms require --backend ui.");
@@ -339,7 +431,7 @@ export async function main(argv) {
339
431
  if (message.trim() === "") throw new Error("send requires a non-empty message.");
340
432
  const threadId = normalizeThreadId(threadInput);
341
433
  const data = backend === "app-server"
342
- ? await sendTrackedMessage(threadId, message, { dryRun, newTurn, ...directive })
434
+ ? await sendTrackedMessage(threadId, message, { dryRun, newTurn, ...directive, supervisor, connection })
343
435
  : sendDesktopMessage(threadId, message, { dryRun, waitMs, newTurn, keepFocus });
344
436
  data.sound = noSound ? { played: false, reason: "disabled" } : playSendSound(data);
345
437
  success("send", data, json);
@@ -63,6 +63,9 @@ export function runtimeCompatibility(state, { verifiedMethods = [], unsupportedM
63
63
  }
64
64
  }
65
65
  const probes = name === "thread_read" ? ["thread/read"] : name === "history_pagination" ? ["thread/turns/list", "thread/items/list"] : [];
66
+ if (state?.native_observation_only && ["turn_steer", "turn_start", "desktop_subscribe"].includes(name)) {
67
+ status = "unsupported"; source = "observation-only"; versions = [];
68
+ }
66
69
  if (source === "probe" && probes.length) {
67
70
  // One missing required API disproves support even if another probe succeeded.
68
71
  if (probes.some(method => unsupportedMethods.includes(method))) {
@@ -0,0 +1,107 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { codexHome } from "./runtime.mjs";
4
+ import { RpcClient } from "./rpc.mjs";
5
+
6
+ const failure = (message, code) => Object.assign(new Error(message), { code });
7
+ const reads = new Set(["thread/loaded/list", "thread/read", "thread/turns/list", "thread/items/list"]);
8
+ export function connectionMode(value = "shared") {
9
+ if (!["shared", "desktop"].includes(value)) throw new Error("--connection must be shared or desktop.");
10
+ return value;
11
+ }
12
+ export function desktopReadOnly() {
13
+ return failure("The desktop connection supports observation only. Use the existing shared connection for sends; connections are never switched automatically.", "DESKTOP_READ_ONLY");
14
+ }
15
+
16
+ async function identity(home) {
17
+ const directory = path.join(home, "app-server-control"), socket = path.join(directory, "app-server-control.sock");
18
+ const fingerprint = [];
19
+ for (const [file, type] of [[home, "home"], [directory, "directory"], [socket, "socket"]]) {
20
+ const info = await lstat(file);
21
+ if (info.uid !== process.getuid() || info.isSymbolicLink() || (info.mode & (type === "home" ? 0o022 : 0o077)) !== 0
22
+ || (type === "socket" ? !info.isSocket() : !info.isDirectory())) {
23
+ throw failure("Desktop control endpoint has unsafe ownership, permissions, or file type.", "DESKTOP_CONNECTION_UNSAFE");
24
+ }
25
+ // Home directory timestamps change for unrelated profile activity.
26
+ fingerprint.push([info.dev, info.ino, type === "socket" ? info.ctimeMs : null]);
27
+ }
28
+ return { home, directory, socket, fingerprint: JSON.stringify(fingerprint) };
29
+ }
30
+
31
+ export async function discoverDesktopRuntime(home = codexHome()) {
32
+ try {
33
+ const paths = await identity(await realpath(home));
34
+ return { paths, state: { codex_home: paths.home, native_observation_only: true } };
35
+ } catch (error) {
36
+ if (error.code === "ENOENT") throw failure("No existing Desktop control socket was found in this CODEX_HOME. Direct observation requires Desktop to be using its shared local daemon. This command does not start or restart it.", "DESKTOP_CONNECTION_UNAVAILABLE");
37
+ if (["EPERM", "EACCES"].includes(error.code)) throw failure("Cannot inspect the Desktop control endpoint.", "PERMISSION_DENIED");
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async function stillCurrent(paths) {
43
+ let current;
44
+ try { current = await identity(paths.home); }
45
+ catch (error) {
46
+ if (error.code === "ENOENT") throw failure("The Desktop endpoint disappeared. Recheck the selected connection before observing again.", "DESKTOP_CONNECTION_CHANGED");
47
+ throw error;
48
+ }
49
+ if (current.fingerprint !== paths.fingerprint) throw failure("The Desktop endpoint changed. Recheck the selected connection before observing again.", "DESKTOP_CONNECTION_CHANGED");
50
+ }
51
+
52
+ async function requireLoaded(client, threadId) {
53
+ const seen = new Set(); let cursor;
54
+ for (let page = 0; page < 100; page++) {
55
+ const result = await client.request("thread/loaded/list", { limit: 100, ...(cursor ? { cursor } : {}) });
56
+ if (!Array.isArray(result?.data) || !result.data.every(id => typeof id === "string")
57
+ || (result.nextCursor != null && (typeof result.nextCursor !== "string" || !result.nextCursor || seen.has(result.nextCursor)))) {
58
+ throw failure("Invalid loaded-task list from Desktop.", "PROTOCOL_ERROR");
59
+ }
60
+ if (result.data.includes(threadId)) return;
61
+ cursor = result.nextCursor;
62
+ if (!cursor) throw failure("The target is not loaded in this Desktop endpoint. Select the matching profile and loaded task; codexteer will not resume it implicitly.", "DESKTOP_THREAD_NOT_LOADED");
63
+ seen.add(cursor);
64
+ }
65
+ throw failure("The loaded-task list exceeded the observation limit.", "PROTOCOL_ERROR");
66
+ }
67
+
68
+ export async function connectDesktop(paths, options, connect = RpcClient.connect) {
69
+ await stillCurrent(paths);
70
+ const client = await connect(paths.socket, options);
71
+ try { await stillCurrent(paths); } catch (error) { client.close(); throw error; }
72
+ return {
73
+ close: () => client.close(),
74
+ async request(method, params = {}, requestOptions = {}) {
75
+ if (!reads.has(method) || requestOptions.mutation) throw desktopReadOnly();
76
+ await stillCurrent(paths);
77
+ if (method !== "thread/loaded/list") {
78
+ if (typeof params.threadId !== "string" || !params.threadId) throw failure("A target task is required for Desktop observation.", "PROTOCOL_ERROR");
79
+ await requireLoaded(client, params.threadId);
80
+ await stillCurrent(paths);
81
+ }
82
+ const result = await client.request(method, params, requestOptions);
83
+ await stillCurrent(paths);
84
+ return result;
85
+ },
86
+ };
87
+ }
88
+
89
+ // Each operation owns its discovery result. No implicit fallback to another
90
+ // profile, daemon, or wrapper, including after connection failures.
91
+ export function observationConnection(mode) {
92
+ if (connectionMode(mode) === "shared") return {};
93
+ let selected;
94
+ return {
95
+ async discover(home) {
96
+ const discovered = await discoverDesktopRuntime(home);
97
+ if (selected && (selected.paths.home !== discovered.paths.home || selected.paths.fingerprint !== discovered.paths.fingerprint)) {
98
+ throw failure("The Desktop endpoint changed during observation. Check it before explicitly starting another watch.", "DESKTOP_CONNECTION_CHANGED");
99
+ }
100
+ selected = discovered; return selected;
101
+ },
102
+ async connect(socket, options) {
103
+ if (!selected || selected.paths.socket !== socket) throw failure("Desktop discovery is required before connection.", "DESKTOP_CONNECTION_UNAVAILABLE");
104
+ return connectDesktop(selected.paths, options);
105
+ },
106
+ };
107
+ }
package/src/directive.mjs CHANGED
@@ -2,12 +2,14 @@ import { collectEvidence, verifyEvidence } from "./evidence.mjs";
2
2
  import { decodeCursor, threadSnapshot, fetchThread } from "./observe.mjs";
3
3
  import { getCheckpoint, verifyCheckpoint } from "./checkpoint.mjs";
4
4
  import { verifyPagedFreshness } from "./paged-read.mjs";
5
+ import { assertFindingSend, getFinding } from "./findings.mjs";
5
6
 
6
7
  export const kindLabels = { decision: "ユーザー決定の伝達", review: "レビュー", hypothesis: "仮説(未確定)", suggestion: "任意提案" };
7
8
 
8
9
  export async function prepareDirective(threadId, body, options, id, storage = {}) {
9
- const { source, kind, evidence = [], basedOn, supersedes, retracts, expiresAt, checkpoint } = options;
10
- if (!source && !kind && !evidence.length && !basedOn && !supersedes && !retracts && !expiresAt && !checkpoint) return { wireText: body, metadata: null };
10
+ const { source, kind, evidence = [], basedOn, supersedes, retracts, expiresAt, checkpoint, finding, supervisor } = options;
11
+ if (!source && !kind && !evidence.length && !basedOn && !supersedes && !retracts && !expiresAt && !checkpoint && !finding && !supervisor) return { wireText: body, metadata: null };
12
+ const concern = finding ? await assertFindingSend(threadId, finding, storage) : null;
11
13
  if (checkpoint) await getCheckpoint(threadId, checkpoint, storage);
12
14
  if (supersedes && retracts) throw new Error("Use either supersedes or retracts, not both.");
13
15
  if (expiresAt && (!/(Z|[+-]\d{2}:\d{2})$/.test(expiresAt) || !Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())) throw new Error("--expires-at must be a future ISO timestamp with timezone.");
@@ -15,7 +17,8 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
15
17
  if (source != null && (!source.trim() || source.length > 80 || /[\r\n\x00-\x1f]/.test(source))) throw new Error("--source must be a non-empty single line (up to 80 characters).");
16
18
  const observed = basedOn ? decodeCursor(basedOn, threadId) : null;
17
19
  const refs = await collectEvidence(evidence);
18
- const metadata = { source: source ?? "external", kind: kind ?? "review", evidence: refs, based_on: basedOn ?? null, supersedes: supersedes ?? null, retracts: retracts ?? null, expires_at: expiresAt ?? null, checkpoint_id: checkpoint ?? null };
20
+ const metadata = { source: source ?? "external", kind: kind ?? "review", evidence: refs, based_on: basedOn ?? null, supersedes: supersedes ?? null, retracts: retracts ?? null, expires_at: expiresAt ?? null, checkpoint_id: checkpoint ?? null,
21
+ ...(concern ? { finding_id: concern.id, finding_revision: concern.revision } : {}), ...(supervisor ? { supervisor_id: supervisor } : {}) };
19
22
  const lines = [`[codexteer ${id}]`, `送信者: ${metadata.source}`, `種類: ${kindLabels[metadata.kind]}`];
20
23
  for (const ref of refs) lines.push(`根拠: ${JSON.stringify(ref.ref)}${ref.sha256 ? ` (sha256:${ref.sha256})` : " (参照URL)"}`);
21
24
  if (observed) lines.push(`観測対象ターン: ${observed.active_turn_id ?? "停止中"}`);
@@ -23,6 +26,7 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
23
26
  if (retracts) lines.push(`撤回する指示: ${retracts}。以後の方針から除外してください。`);
24
27
  if (expiresAt) lines.push(`有効期限: ${expiresAt}`);
25
28
  if (checkpoint) lines.push(`検証チェックポイント: ${checkpoint}`);
29
+ if (concern) lines.push(`指摘: ${concern.id} (revision ${concern.revision})`, `解決条件: ${JSON.stringify(concern.condition)}`);
26
30
  return { metadata, wireText: lines.join("\n") + "\n\n" + body, async beforeSend(thread, client) {
27
31
  if (expiresAt && Date.parse(expiresAt) <= Date.now()) throw Object.assign(new Error("Directive expired before sending."), { code: "EXPIRED_DIRECTIVE" });
28
32
  if (observed) {
@@ -35,5 +39,10 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
35
39
  }
36
40
  await verifyEvidence(refs);
37
41
  if (checkpoint && !(await verifyCheckpoint(threadId, checkpoint, storage)).valid) throw Object.assign(new Error("Checkpoint is no longer valid. Verify the inputs and results before sending."), { code: "STALE_CHECKPOINT" });
42
+ if (concern) {
43
+ const current = await getFinding(threadId, concern.id, storage);
44
+ if (current.revision !== concern.revision || current.disposition !== "open") throw Object.assign(new Error("The finding changed before sending. Reassess it before intervening."), { code: "STALE_FINDING" });
45
+ await verifyEvidence(concern.evidence);
46
+ }
38
47
  } };
39
48
  }
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { normalizeThreadId } from "./thread-id.mjs";
3
+ import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
4
+ import { collectEvidence } from "./evidence.mjs";
5
+ import { decodeCursor, digest } from "./observe.mjs";
6
+ import { verifyCheckpoint } from "./checkpoint.mjs";
7
+
8
+ const failure = (message, code) => Object.assign(new Error(message), { code });
9
+ const ratings = ["useful", "unnecessary", "incorrect", "unrated"];
10
+ const text = (value, label, max = 8000) => {
11
+ if (typeof value !== "string" || !value.trim() || value.length > max || value.includes("\0")) throw failure(`${label} requires non-empty text (up to ${max} characters).`, "FINDING_INVALID");
12
+ return value;
13
+ };
14
+ // A cursor can advance because our own intervention appeared as a userMessage.
15
+ // It is observation context, never sufficient evidence for another intervention.
16
+ const contextHash = record => digest({ condition: record.condition, evidence: record.evidence });
17
+
18
+ export async function getFinding(threadInput, id, storage = {}) {
19
+ const threadId = normalizeThreadId(threadInput), record = await readRecord("findings", id, storage);
20
+ if (!record || record.schema !== 1 || record.id !== id || record.thread_id !== threadId) throw failure("Finding not found for this task.", "FINDING_NOT_FOUND");
21
+ if (!Number.isSafeInteger(record.revision) || record.revision < 1 || !["open", "resolved", "dismissed"].includes(record.disposition)
22
+ || !Array.isArray(record.evidence) || typeof record.condition !== "string" || !ratings.includes(record.evaluation?.rating)) throw failure("Invalid finding record.", "FINDING_INVALID");
23
+ return record;
24
+ }
25
+
26
+ export async function findingMessages(record, storage = {}) {
27
+ return (await listRecords("messages", storage)).filter(message => message?.schema === 1 && message.thread_id === record.thread_id
28
+ && message.metadata?.finding_id === record.id && message.metadata?.finding_revision === record.revision)
29
+ .sort((a, b) => a.created_at.localeCompare(b.created_at));
30
+ }
31
+
32
+ export async function createFinding(threadInput, { key, title, condition, evidence = [], basedOn, ...storage }) {
33
+ const threadId = normalizeThreadId(threadInput);
34
+ if (typeof key !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}$/.test(key)) throw failure("--key requires a stable short identifier for this concern.", "FINDING_INVALID");
35
+ text(title, "--title", 240); text(condition, "--condition");
36
+ const refs = await collectEvidence(evidence), observed = basedOn ? decodeCursor(basedOn, threadId) : null;
37
+ return withStoreLock(`message-${threadId}`, async () => {
38
+ const existing = (await listRecords("findings", storage)).find(record => record?.thread_id === threadId && record.key === key);
39
+ if (existing) return { ...await findingStatus(threadId, existing.id, storage), reused: true };
40
+ const now = new Date().toISOString();
41
+ const record = { schema: 1, id: randomUUID(), thread_id: threadId, key, title, condition, revision: 1,
42
+ evidence: refs, based_on: basedOn ?? null, user_revision: observed?.user_revision ?? null, disposition: "open", resolution: null,
43
+ evaluation: { rating: "unrated" }, revisions: [], created_at: now, updated_at: now };
44
+ await writeRecord("findings", record.id, record, storage);
45
+ return { ...record, status: "open", messages: [], verification: null, reused: false };
46
+ }, storage);
47
+ }
48
+
49
+ export async function assertFindingSend(threadInput, id, storage = {}) {
50
+ const record = await getFinding(threadInput, id, storage);
51
+ if (record.disposition !== "open") throw failure("This finding is closed. Reopen it with new evidence or a changed requirement before sending again.", "FINDING_CLOSED");
52
+ // The journal is the reservation: even a crash before writing a receipt leaves
53
+ // an unknown attempt here. Never infer not-sent from a missing response.
54
+ const previous = (await findingMessages(record, storage)).find(message => message.delivery_status !== "not_sent");
55
+ if (previous) throw Object.assign(failure("This finding revision already has a pending or accepted intervention. Check its history; do not resend it.", "DUPLICATE_INTERVENTION"), { existing_message_id: previous.id });
56
+ return record;
57
+ }
58
+
59
+ export async function findingStatus(threadInput, id, storage = {}) {
60
+ const record = await getFinding(threadInput, id, storage), entries = await findingMessages(record, storage);
61
+ const messages = entries.map(entry => ({ id: entry.id, delivery_status: entry.delivery_status, attempt_state: entry.attempt_state,
62
+ response: entry.response, stored: entry.verification?.status ?? "unchecked" }));
63
+ const latest = entries.filter(entry => entry.delivery_status !== "not_sent").at(-1);
64
+ let status = record.disposition, verification = null;
65
+ if (record.disposition === "open" && latest) status = latest.delivery_status === "unknown" ? "delivery_unknown"
66
+ : latest.response?.status === "applied" ? "awaiting_verification" : "awaiting_response";
67
+ if (record.resolution?.checkpoint_id) {
68
+ try {
69
+ const current = await verifyCheckpoint(record.thread_id, record.resolution.checkpoint_id, storage);
70
+ verification = { valid: current.valid === true && current.latest_run?.id === record.resolution.run_id && current.snapshot.digest === record.resolution.input_digest,
71
+ checkpoint_id: current.id, run_id: current.latest_run?.id ?? null, inputs_current: current.inputs_current,
72
+ checked_at: new Date().toISOString(), condition_assessment: "explicit_report" };
73
+ } catch (error) { verification = { valid: false, checkpoint_id: record.resolution.checkpoint_id, error_code: error.code ?? "CHECKPOINT_UNAVAILABLE", condition_assessment: "explicit_report" }; }
74
+ if (!verification.valid && record.disposition === "resolved") status = "needs_review";
75
+ }
76
+ return { ...record, status, messages, verification };
77
+ }
78
+
79
+ export async function listFindings(threadInput, { all = false, limit = 50, ...storage } = {}) {
80
+ const threadId = normalizeThreadId(threadInput);
81
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw failure("--limit must be between 1 and 1000.", "FINDING_INVALID");
82
+ const records = (await listRecords("findings", storage)).filter(record => record?.thread_id === threadId).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
83
+ // Recheck closed resolutions too: changed inputs make them unresolved again.
84
+ const findings = [];
85
+ for (const record of records) {
86
+ const current = await findingStatus(threadId, record.id, storage);
87
+ if (all || !["resolved", "dismissed"].includes(current.status)) findings.push(current);
88
+ }
89
+ return { thread_id: threadId, findings: findings.slice(0, limit), total: findings.length, has_more: findings.length > limit };
90
+ }
91
+
92
+ async function changeFinding(threadInput, id, update, storage) {
93
+ const threadId = normalizeThreadId(threadInput);
94
+ return withStoreLock(`message-${threadId}`, async () => {
95
+ const record = await getFinding(threadId, id, storage);
96
+ const updated = { ...await update(record), updated_at: new Date().toISOString() };
97
+ await writeRecord("findings", id, updated, storage);
98
+ return findingStatus(threadId, id, storage);
99
+ }, storage);
100
+ }
101
+
102
+ async function knownDelivery(record, storage) {
103
+ if ((await findingMessages(record, storage)).some(entry => entry.delivery_status === "unknown")) throw failure("Delivery is unknown. Reconcile the existing message before closing or revising this finding.", "FINDING_DELIVERY_UNKNOWN");
104
+ }
105
+
106
+ export async function resolveFinding(threadInput, id, { checkpoint, note, ...storage }) {
107
+ text(note, "--note");
108
+ if (!checkpoint) throw failure("Resolution requires a --checkpoint and an explicit --note explaining how its result satisfies the condition.", "FINDING_INVALID");
109
+ return changeFinding(threadInput, id, async record => {
110
+ await knownDelivery(record, storage);
111
+ const verified = await verifyCheckpoint(record.thread_id, checkpoint, storage);
112
+ if (!verified.valid) throw failure("The checkpoint is not currently valid. Verify current inputs and a successful run before resolving.", "STALE_CHECKPOINT");
113
+ return { ...record, disposition: "resolved", resolution: { checkpoint_id: checkpoint, run_id: verified.latest_run.id,
114
+ input_digest: verified.snapshot.digest, note, basis: "explicit_report", at: new Date().toISOString() } };
115
+ }, storage);
116
+ }
117
+
118
+ export async function dismissFinding(threadInput, id, { reason, ...storage }) {
119
+ text(reason, "--reason");
120
+ return changeFinding(threadInput, id, async record => {
121
+ await knownDelivery(record, storage);
122
+ return { ...record, disposition: "dismissed", resolution: { reason, basis: "explicit_report", at: new Date().toISOString() } };
123
+ }, storage);
124
+ }
125
+
126
+ export async function reopenFinding(threadInput, id, { reason, evidence, basedOn, condition, ...storage }) {
127
+ const threadId = normalizeThreadId(threadInput);
128
+ text(reason, "--reason"); if (condition !== undefined) text(condition, "--condition");
129
+ const refs = evidence === undefined ? undefined : await collectEvidence(evidence);
130
+ const observed = basedOn === undefined ? undefined : decodeCursor(basedOn, threadId);
131
+ return changeFinding(threadId, id, async record => {
132
+ await knownDelivery(record, storage);
133
+ const updated = { ...record, condition: condition ?? record.condition, evidence: refs ?? record.evidence, based_on: basedOn ?? record.based_on, user_revision: observed?.user_revision ?? record.user_revision };
134
+ if (contextHash(record) === contextHash(updated)) throw failure("Reopening requires changed evidence or a changed resolution condition. A new reason or cursor alone does not authorize a duplicate intervention; reflect a changed user requirement in the condition.", "FINDING_UNCHANGED");
135
+ return { ...updated, revision: record.revision + 1, disposition: "open", resolution: null, evaluation: { rating: "unrated" },
136
+ revisions: [...record.revisions, { revision: record.revision, condition: record.condition, evidence: record.evidence,
137
+ based_on: record.based_on, user_revision: record.user_revision, resolution: record.resolution, evaluation: record.evaluation, reason, at: new Date().toISOString() }] };
138
+ }, storage);
139
+ }
140
+
141
+ export async function evaluateFinding(threadInput, id, { rating, reason, ...storage }) {
142
+ if (!ratings.includes(rating) || rating === "unrated") throw failure("--rating must be useful, unnecessary or incorrect.", "FINDING_INVALID");
143
+ text(reason, "--reason");
144
+ return changeFinding(threadInput, id, async record => ({ ...record, evaluation: { rating, reason, basis: "explicit_report", at: new Date().toISOString() } }), storage);
145
+ }
146
+
147
+ export async function findingStats(threadInput, storage = {}) {
148
+ const threadId = normalizeThreadId(threadInput), records = (await listRecords("findings", storage)).filter(record => record?.thread_id === threadId);
149
+ const counts = Object.fromEntries(ratings.map(rating => [rating, 0]));
150
+ let sent = 0;
151
+ for (const entry of records) {
152
+ const record = await getFinding(threadId, entry.id, storage);
153
+ counts[record.evaluation.rating]++;
154
+ if ((await findingMessages(record, storage)).some(message => message.delivery_status !== "not_sent")) sent++;
155
+ }
156
+ const evaluated = records.length - counts.unrated;
157
+ return { thread_id: threadId, scope: "current_finding_revisions_including_unsent_candidates", total: records.length,
158
+ sent_or_unknown: sent, unsent_candidates: records.length - sent, ratings: counts, evaluated,
159
+ useful_fraction: evaluated ? counts.useful / evaluated : null };
160
+ }