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/supervise.mjs CHANGED
@@ -4,9 +4,15 @@ import { constants } from "node:os";
4
4
  import { realpath } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
+ import { randomUUID } from "node:crypto";
7
8
  import { prepareDeployment } from "./distribution.mjs";
8
9
  import { normalizeThreadId } from "./thread-id.mjs";
9
10
  import { codexHome } from "./runtime.mjs";
11
+ import { validateSupervisionPolicy } from "./prompt.mjs";
12
+ import { registerSupervisor, controlSupervisor } from "./supervision.mjs";
13
+ import { connectionMode } from "./connection.mjs";
14
+
15
+ export const SUPERVISOR_AGENTS = ["claude", "codex"];
10
16
 
11
17
  function shellArgument(value) {
12
18
  if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
@@ -15,28 +21,43 @@ function shellArgument(value) {
15
21
  return `'${value.replaceAll("'", "'\\''")}'`;
16
22
  }
17
23
 
18
- export async function prepareSupervisorPrompt(threadInput) {
24
+ export async function prepareSupervisorPrompt(threadInput, policy, { agent = "claude", connection = "shared" } = {}) {
19
25
  const threadId = normalizeThreadId(threadInput);
26
+ validateSupervisionPolicy(policy);
27
+ connectionMode(connection);
28
+ if (!SUPERVISOR_AGENTS.includes(agent)) throw new Error("--agent must be claude or codex.");
20
29
  const node = { path: await realpath(process.execPath), version: process.version };
21
30
  const home = codexHome();
22
31
  shellArgument(node.path); shellArgument(home);
23
32
  const deployment = await prepareDeployment(home);
24
33
  // Bind the validated canonical profile as well as the saved executable.
25
- const command = `CODEX_HOME=${shellArgument(deployment.codex_home)} ${shellArgument(node.path)} ${shellArgument(path.join(deployment.directory, "bin/codexteer.mjs"))} --require-node-version ${shellArgument(node.version)}`;
34
+ const supervisor = { session_id: randomUUID(), owner: agent, connection };
35
+ const command = `CODEX_HOME=${shellArgument(deployment.codex_home)} ${shellArgument(node.path)} ${shellArgument(path.join(deployment.directory, "bin/codexteer.mjs"))} --require-node-version ${shellArgument(node.version)} --supervisor ${shellArgument(supervisor.session_id)} --connection ${connection}`;
26
36
  // Render with the saved distribution's implementation as well as its CLI.
27
37
  // An update to the source/cache after placement cannot mix prompt and code.
28
38
  const { supervisorPrompt } = await import(pathToFileURL(path.join(deployment.directory, "src/prompt.mjs")).href);
29
- return { ...supervisorPrompt(threadId, command), deployment, node };
39
+ return { ...supervisorPrompt(threadId, command, policy, supervisor), deployment, node, supervisor };
30
40
  }
31
41
 
32
42
  function launchFailure(agent, error) {
33
43
  return Object.assign(new Error(`Could not start ${agent} (${error.code ?? "START_FAILED"}). Check that it is installed and executable on PATH.`), { code: error.code ?? "START_FAILED" });
34
44
  }
35
45
 
36
- export async function superviseAgent(threadInput, { agent, agentArgs = [] } = {}) {
37
- if (agent !== "claude") throw new Error("supervise requires --agent claude. See codexteer help supervise.");
38
- const { prompt } = await prepareSupervisorPrompt(threadInput);
46
+ export async function superviseAgent(threadInput, { agent = "claude", agentArgs = [], policy, connection = "shared" } = {}) {
47
+ const { prompt, thread_id: threadId, supervisor, deployment } = await prepareSupervisorPrompt(threadInput, policy, { agent, connection });
48
+ const storage = { home: deployment.codex_home };
49
+ await registerSupervisor(threadId, supervisor.session_id, { ...storage, owner: agent, launcherPid: process.pid, connection });
50
+ try { return await launchSupervisorAgent(agent, agentArgs, prompt); }
51
+ finally {
52
+ try { await controlSupervisor(threadId, "stop", { ...storage, expectedSession: supervisor.session_id }); }
53
+ catch (error) {
54
+ // A user may have explicitly stopped this session and started another.
55
+ if (error.code !== "SUPERVISOR_MISMATCH") process.stderr.write("codexteer: Supervisor cleanup failed; inspect supervise status before restarting.\n");
56
+ }
57
+ }
58
+ }
39
59
 
60
+ async function launchSupervisorAgent(agent, agentArgs, prompt) {
40
61
  let child;
41
62
  try {
42
63
  // Keep forwarded argv intact. The agent's own terminator keeps its variadic
@@ -0,0 +1,134 @@
1
+ import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
2
+ import { normalizeThreadId } from "./thread-id.mjs";
3
+ import { isAlive } from "./runtime.mjs";
4
+
5
+ const failure = (message, code) => Object.assign(new Error(message), { code });
6
+ const states = new Set(["active", "paused", "stopped"]);
7
+
8
+ export function supervisorId(value) {
9
+ if (typeof value !== "string" || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value)) throw failure("Invalid supervisor session ID.", "SUPERVISOR_INVALID");
10
+ return value.toLowerCase();
11
+ }
12
+
13
+ export async function getSupervisor(threadInput, storage = {}) {
14
+ const threadId = normalizeThreadId(threadInput), record = await readRecord("supervisors", threadId, storage);
15
+ if (!record) return null;
16
+ if (record.schema !== 1 || record.thread_id !== threadId || !states.has(record.state)
17
+ || !["shared", "desktop"].includes(record.connection)
18
+ || supervisorId(record.session_id) !== record.session_id || typeof record.owner !== "string"
19
+ || (record.launcher_pid !== null && (!Number.isSafeInteger(record.launcher_pid) || record.launcher_pid <= 0))) {
20
+ throw failure("Invalid supervisor state. Inspect the local record before continuing.", "SUPERVISOR_INVALID");
21
+ }
22
+ return record;
23
+ }
24
+
25
+ export async function assertSupervisor(threadInput, sessionInput, { sending = false, connection, ...storage } = {}) {
26
+ const sessionId = supervisorId(sessionInput), record = await getSupervisor(threadInput, storage);
27
+ if (!record) throw failure("Register this supervisor session before observing or sending.", "SUPERVISOR_NOT_REGISTERED");
28
+ if (record.session_id !== sessionId) throw failure("Another supervisor session owns this task. This command belongs to an older or different session.", "SUPERVISOR_MISMATCH");
29
+ if (connection !== undefined && record.connection !== connection) throw failure("The supervisor is registered for another connection. Keep the original command intact.", "SUPERVISOR_MISMATCH");
30
+ if (record.state === "stopped") throw failure("This supervisor session has stopped. Start a new supervisor when needed.", "SUPERVISOR_STOPPED");
31
+ if (record.launcher_pid !== null && !isAlive(record.launcher_pid)) throw failure("The supervisor launcher exited. Stop the stale session before starting another supervisor.", "SUPERVISOR_ORPHANED");
32
+ if (sending && record.state === "paused") throw failure("Automatic intervention is paused. Observation can continue; only the user should resume intervention.", "SUPERVISOR_PAUSED");
33
+ return record;
34
+ }
35
+
36
+ export async function registerSupervisor(threadInput, sessionInput, { owner = "external", launcherPid = null, connection = "shared", ...storage } = {}) {
37
+ const threadId = normalizeThreadId(threadInput), sessionId = supervisorId(sessionInput);
38
+ 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");
39
+ if (launcherPid !== null && (!Number.isSafeInteger(launcherPid) || launcherPid <= 0)) throw failure("Invalid supervisor launcher PID.", "SUPERVISOR_INVALID");
40
+ if (!["shared", "desktop"].includes(connection)) throw failure("Invalid supervisor connection.", "SUPERVISOR_INVALID");
41
+ return withStoreLock(`supervisor-${threadId}`, async () => {
42
+ const current = await getSupervisor(threadId, storage);
43
+ if (current?.session_id === sessionId) {
44
+ if (current.connection !== connection) throw failure("The supervisor is registered for another connection. Keep the original command intact.", "SUPERVISOR_MISMATCH");
45
+ return assertSupervisor(threadId, sessionId, storage);
46
+ }
47
+ 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");
48
+ const now = new Date().toISOString();
49
+ const record = { schema: 1, thread_id: threadId, session_id: sessionId, owner, launcher_pid: launcherPid, connection,
50
+ state: "active", registered_at: now, updated_at: now, last_delivery: null };
51
+ await writeRecord("supervisors", threadId, record, storage);
52
+ return record;
53
+ }, storage);
54
+ }
55
+
56
+ export async function controlSupervisor(threadInput, action, { expectedSession, ...storage } = {}) {
57
+ const threadId = normalizeThreadId(threadInput);
58
+ if (!["pause", "resume", "stop"].includes(action)) throw failure("Expected pause, resume or stop.", "SUPERVISOR_INVALID");
59
+ return withStoreLock(`supervisor-${threadId}`, async () => {
60
+ const record = await getSupervisor(threadId, storage);
61
+ if (!record) throw failure("No supervisor is registered for this task.", "SUPERVISOR_NOT_REGISTERED");
62
+ if (expectedSession && record.session_id !== supervisorId(expectedSession)) throw failure("The supervisor session changed.", "SUPERVISOR_MISMATCH");
63
+ if (record.state === "stopped" && action !== "stop") throw failure("A stopped supervisor cannot be resumed. Start a new supervisor.", "SUPERVISOR_STOPPED");
64
+ if (action === "resume") await assertSupervisor(threadId, record.session_id, storage);
65
+ const updated = { ...record, state: { pause: "paused", resume: "active", stop: "stopped" }[action], updated_at: new Date().toISOString() };
66
+ await writeRecord("supervisors", threadId, updated, storage);
67
+ return updated;
68
+ }, storage);
69
+ }
70
+
71
+ export async function supervisorStatus(threadInput, storage = {}) {
72
+ const threadId = normalizeThreadId(threadInput), record = await getSupervisor(threadId, storage);
73
+ if (!record) return { thread_id: threadId, state: "unregistered", observation: null };
74
+ const observation = await readRecord("supervisor-observations", record.session_id, storage);
75
+ if (observation && (observation.schema !== 1 || observation.thread_id !== threadId || observation.session_id !== record.session_id)) throw failure("Invalid supervisor observation record.", "SUPERVISOR_INVALID");
76
+ return { ...record, launcher_alive: record.launcher_pid === null ? null : isAlive(record.launcher_pid),
77
+ observation, observation_age_ms: observation?.last_observed_at ? Math.max(0, Date.now() - Date.parse(observation.last_observed_at)) : null };
78
+ }
79
+
80
+ export async function listSupervisors(storage = {}) {
81
+ const records = await listRecords("supervisors", storage);
82
+ return Promise.all(records.filter(Boolean).map(record => supervisorStatus(record.thread_id, storage)));
83
+ }
84
+
85
+ // Observation has its own record so a concurrent read never overwrites pause/stop.
86
+ // This timestamp records CLI observation, not acknowledgement by the supervising AI.
87
+ export async function recordSupervisorObservation(threadInput, sessionInput, data, storage = {}) {
88
+ const threadId = normalizeThreadId(threadInput), sessionId = supervisorId(sessionInput);
89
+ await assertSupervisor(threadId, sessionId, storage);
90
+ const key = `supervisor-observe-${sessionId}`;
91
+ try {
92
+ return await withStoreLock(key, async () => {
93
+ const old = await readRecord("supervisor-observations", sessionId, storage);
94
+ const now = new Date().toISOString(), connected = data.type !== "connection" || ["watching", "recovered"].includes(data.state);
95
+ const observed = connected ? data.last_observed_at ?? data.observed_at ?? now : old?.last_observed_at ?? null;
96
+ const observation = { schema: 1, thread_id: threadId, session_id: sessionId, updated_at: now,
97
+ connection: data.type === "connection" ? data.state : "observed",
98
+ last_observed_at: old?.last_observed_at && old.last_observed_at > observed ? old.last_observed_at : observed,
99
+ cause_code: data.cause_code ?? null };
100
+ await writeRecord("supervisor-observations", sessionId, observation, storage);
101
+ return true;
102
+ }, storage);
103
+ } catch (error) { if (error.code === "RECORD_BUSY") return false; throw error; }
104
+ }
105
+
106
+ // Diagnostics must not mask the original read/transport failure or revive a
107
+ // stopped/replaced session. State remains separate from the last observation.
108
+ export async function recordSupervisorFailure(threadId, sessionId, error, storage = {}) {
109
+ try { await recordSupervisorObservation(threadId, sessionId, { type: "connection", state: error.watch?.state ?? "failed", cause_code: error.code ?? "OBSERVATION_FAILED" }, storage); }
110
+ catch { /* The original failure is returned to the consumer. */ }
111
+ }
112
+
113
+ export async function withSupervisedSend(threadInput, sessionInput, send, { connection, ...storage } = {}) {
114
+ if (sessionInput === undefined) return send();
115
+ const threadId = normalizeThreadId(threadInput), sessionId = supervisorId(sessionInput);
116
+ // Holding this lock through the receipt serializes pause/stop with the actual
117
+ // mutation. A busy pause is an error, never a false acknowledgement of a stop.
118
+ return withStoreLock(`supervisor-${threadId}`, async () => {
119
+ const record = await assertSupervisor(threadId, sessionId, { ...storage, sending: true, connection });
120
+ try {
121
+ const receipt = await send();
122
+ try { await writeRecord("supervisors", threadId, { ...record, updated_at: new Date().toISOString(), last_error: null,
123
+ last_delivery: { message_id: receipt.message_id ?? null, status: receipt.delivery_status, at: new Date().toISOString() } }, storage); }
124
+ catch { receipt.supervision_update_required = true; }
125
+ return receipt;
126
+ } catch (error) {
127
+ try { await writeRecord("supervisors", threadId, { ...record, updated_at: new Date().toISOString(),
128
+ last_error: { code: error.code ?? "SEND_FAILED", existing_message_id: error.existing_message_id ?? null, at: new Date().toISOString() },
129
+ ...(error.message_id ? { last_delivery: { message_id: error.message_id, status: error.delivery_status ?? "not_sent", code: error.code, at: new Date().toISOString() } } : {}) }, storage); }
130
+ catch { error.supervision_update_required = true; }
131
+ throw error;
132
+ }
133
+ }, storage);
134
+ }