codexteer 0.17.0 → 0.18.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 (82) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/README.md +127 -14
  3. package/assets/remote-device-key.swift +94 -0
  4. package/bin/codexteer-update.mjs +3 -0
  5. package/bin/codexteer.mjs +6 -2
  6. package/docs/cloud.md +113 -0
  7. package/docs/desktop-cloud.md +36 -0
  8. package/docs/distribution.md +15 -20
  9. package/docs/orchestration.md +120 -0
  10. package/docs/remote-cli-validation.json +139 -0
  11. package/docs/remote-control.md +65 -0
  12. package/docs/remote-device-enrollment.md +91 -0
  13. package/docs/remote-observation.md +57 -0
  14. package/docs/remote-operations.md +89 -0
  15. package/docs/thread-create.md +20 -45
  16. package/package.json +11 -2
  17. package/scripts/update-desktop.applescript +51 -0
  18. package/src/agent-launch.mjs +27 -0
  19. package/src/app-server.mjs +34 -10
  20. package/src/checkpoint.mjs +52 -16
  21. package/src/cli.mjs +201 -23
  22. package/src/cloud-api.mjs +118 -0
  23. package/src/cloud-cli.mjs +319 -0
  24. package/src/cloud-connection.mjs +64 -0
  25. package/src/cloud-id.mjs +16 -0
  26. package/src/cloud-observe.mjs +178 -0
  27. package/src/cloud-send.mjs +33 -0
  28. package/src/cloud.mjs +172 -0
  29. package/src/connection.mjs +12 -1
  30. package/src/creation-verification.mjs +30 -10
  31. package/src/desktop-cloud-api.mjs +119 -0
  32. package/src/desktop-cloud-id.mjs +18 -0
  33. package/src/desktop-cloud-project.mjs +148 -0
  34. package/src/desktop-cloud.mjs +98 -0
  35. package/src/desktop.mjs +3 -1
  36. package/src/directive.mjs +5 -2
  37. package/src/distribution.mjs +2 -2
  38. package/src/evidence.mjs +3 -0
  39. package/src/findings.mjs +7 -7
  40. package/src/help.mjs +140 -28
  41. package/src/journal.mjs +37 -9
  42. package/src/launcher.mjs +16 -9
  43. package/src/monitor.mjs +18 -6
  44. package/src/native-command.mjs +10 -2
  45. package/src/native-rpc.mjs +15 -2
  46. package/src/observe.mjs +21 -7
  47. package/src/orchestrate-prompt.mjs +96 -0
  48. package/src/orchestrate.mjs +29 -0
  49. package/src/paged-read.mjs +7 -1
  50. package/src/projects.mjs +27 -2
  51. package/src/prompt-command.mjs +25 -0
  52. package/src/prompt.mjs +25 -8
  53. package/src/remote-client-info.mjs +16 -0
  54. package/src/remote-control-auth.mjs +109 -0
  55. package/src/remote-control-socket.mjs +137 -0
  56. package/src/remote-control.mjs +134 -0
  57. package/src/remote-device-key.mjs +41 -0
  58. package/src/remote-device-login.mjs +76 -0
  59. package/src/remote-device-protocol.mjs +72 -0
  60. package/src/remote-device-store.mjs +71 -0
  61. package/src/remote-device-worker.mjs +48 -0
  62. package/src/remote-device.mjs +202 -0
  63. package/src/remote-io.mjs +163 -0
  64. package/src/remote-key-access.mjs +76 -0
  65. package/src/remote-key-worker.mjs +38 -0
  66. package/src/remote-keychain.mjs +72 -0
  67. package/src/remote-management.mjs +234 -0
  68. package/src/remote.mjs +132 -0
  69. package/src/resource.mjs +8 -6
  70. package/src/runner.mjs +6 -1
  71. package/src/standalone-create.mjs +4 -3
  72. package/src/store.mjs +25 -13
  73. package/src/supervise.mjs +25 -53
  74. package/src/supervision.mjs +41 -19
  75. package/src/target.mjs +25 -0
  76. package/src/task-id.mjs +23 -0
  77. package/src/thread-create.mjs +4 -3
  78. package/src/thread-id.mjs +21 -6
  79. package/src/thread-verify.mjs +108 -0
  80. package/src/turn-repair.mjs +228 -0
  81. package/src/update.mjs +158 -0
  82. package/docs/thread-create-native-investigation.md +0 -136
package/src/store.mjs CHANGED
@@ -1,13 +1,15 @@
1
1
  import { lstat, mkdir, readFile, writeFile, rename, rm, readdir, link } from "node:fs/promises";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { codexHome, isAlive } from "./runtime.mjs";
5
+ import { hostNamespace, hostFields } from "./target.mjs";
5
6
 
6
7
  const valid = value => typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}$/.test(value);
7
8
  // The persisted namespace predates the codexteer rename; keep existing records.
8
9
  export function storePath(bucket, id, home = codexHome()) {
9
10
  if (!valid(bucket) || (id != null && !valid(id))) throw new Error("Invalid local record identifier.");
10
- return path.join(home, "codex-steer", bucket, ...(id == null ? [] : [`${id}.json`]));
11
+ const namespace = hostNamespace();
12
+ return path.join(home, "codex-steer", ...(namespace ? ["hosts", namespace] : []), bucket, ...(id == null ? [] : [`${id}.json`]));
11
13
  }
12
14
 
13
15
  async function owned(file, directory = false) {
@@ -17,7 +19,8 @@ async function owned(file, directory = false) {
17
19
 
18
20
  async function directory(bucket, home, create = false) {
19
21
  const root = path.join(home, "codex-steer"), target = storePath(bucket, null, home);
20
- for (const p of [root, target]) {
22
+ const namespace = hostNamespace();
23
+ for (const p of [root, ...(namespace ? [path.join(root, "hosts"), path.join(root, "hosts", namespace)] : []), target]) {
21
24
  if (create) await mkdir(p, { mode: 0o700 }).catch(e => { if (e.code !== "EEXIST") throw e; });
22
25
  await owned(p, true);
23
26
  }
@@ -36,7 +39,7 @@ export async function writeRecord(bucket, id, data, { home = codexHome() } = {})
36
39
  await directory(bucket, home, true);
37
40
  const target = storePath(bucket, id, home), temp = `${target}.${randomUUID()}.tmp`;
38
41
  await owned(target).catch(e => { if (e.code !== "ENOENT") throw e; });
39
- try { await writeFile(temp, JSON.stringify(data) + "\n", { mode: 0o600, flag: "wx" }); await rename(temp, target); }
42
+ try { await writeFile(temp, JSON.stringify({ ...data, ...hostFields() }) + "\n", { mode: 0o600, flag: "wx" }); await rename(temp, target); }
40
43
  finally { await rm(temp, { force: true }); }
41
44
  }
42
45
 
@@ -48,22 +51,31 @@ export async function listRecords(bucket, options = {}) {
48
51
  }
49
52
 
50
53
  export async function withStoreLock(key, fn, { home = codexHome() } = {}) {
51
- await directory("locks", home, true);
52
- const file = storePath("locks", key, home), token = randomUUID(), temp = `${file}.${token}.tmp`;
54
+ return withRecordLock("locks", key, fn, { home });
55
+ }
56
+
57
+ async function withRecordLock(bucket, key, fn, { home }) {
58
+ await directory(bucket, home, true);
59
+ const file = storePath(bucket, key, home), token = randomUUID(), temp = `${file}.${token}.tmp`;
53
60
  await writeFile(temp, JSON.stringify({ token, pid: process.pid }), { mode: 0o600, flag: "wx" });
54
61
  let acquired = false;
55
62
  let outcome;
56
63
  try {
57
64
  for (let attempt = 0; attempt < 2; attempt++) {
58
65
  try { await link(temp, file); acquired = true; break; } catch (e) { if (e.code !== "EEXIST") throw e; }
59
- const old = await readRecord("locks", key, { home });
66
+ const old = await readRecord(bucket, key, { home });
60
67
  if (old && !isAlive(old.pid)) {
61
- const recovery = `${file}.recover`;
62
- try { await mkdir(recovery, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; break; }
63
- try {
64
- const current = await readRecord("locks", key, { home });
68
+ // Serialize deletion of this generation, including recovery of a crashed
69
+ // recoverer. Each nested lock has an atomically published PID + token.
70
+ // A finite chain of dead owners ends at an absent recovery lock.
71
+ // Legacy .recover directories have no owner and are unsupported/inert.
72
+ // Stop all old-version operations (including saved supervisor CLIs)
73
+ // sharing this CODEX_HOME before using this recovery protocol.
74
+ const recoveryKey = createHash("sha256").update(String(old.token)).digest("hex");
75
+ await withRecordLock("lock-recovery", recoveryKey, async () => {
76
+ const current = await readRecord(bucket, key, { home });
65
77
  if (current?.token === old.token && !isAlive(current.pid)) await rm(file);
66
- } finally { await rm(recovery, { recursive: true }); }
78
+ }, { home });
67
79
  } else break;
68
80
  }
69
81
  if (!acquired) throw Object.assign(new Error("Another operation owns this local record. Retry after it finishes."), { code: "RECORD_BUSY" });
@@ -71,7 +83,7 @@ export async function withStoreLock(key, fn, { home = codexHome() } = {}) {
71
83
  return outcome;
72
84
  } finally {
73
85
  try {
74
- if (acquired && (await readRecord("locks", key, { home }))?.token === token) await rm(file);
86
+ if (acquired && (await readRecord(bucket, key, { home }))?.token === token) await rm(file);
75
87
  await rm(temp, { force: true });
76
88
  } catch { if (outcome && typeof outcome === "object") outcome.state_cleanup_required = true; }
77
89
  }
package/src/supervise.mjs CHANGED
@@ -1,58 +1,50 @@
1
- import { spawn } from "node:child_process";
2
- import { once } from "node:events";
3
- import { constants } from "node:os";
4
- import { realpath } from "node:fs/promises";
5
1
  import path from "node:path";
6
2
  import { pathToFileURL } from "node:url";
7
3
  import { randomUUID } from "node:crypto";
8
- import { prepareCodexHome, prepareDeployment } from "./distribution.mjs";
9
- import { normalizeThreadId } from "./thread-id.mjs";
4
+ import { prepareCodexHome } from "./distribution.mjs";
5
+ import { parseThreadTarget } from "./thread-id.mjs";
6
+ import { normalizeTaskId, taskConnection } from "./task-id.mjs";
10
7
  import { codexHome } from "./runtime.mjs";
11
8
  import { supervisorPrompt as pathSupervisorPrompt, validateSupervisionPolicy } from "./prompt.mjs";
12
9
  import { registerSupervisor, controlSupervisor } from "./supervision.mjs";
13
10
  import { connectionMode } from "./connection.mjs";
11
+ import { preparePromptCommand, shellArgument, SUPERVISOR_AGENTS } from "./prompt-command.mjs";
12
+ import { launchAgent } from "./agent-launch.mjs";
13
+ import { targetHost, targetContext, hostFields, remoteTransport } from "./target.mjs";
14
14
 
15
- export const SUPERVISOR_AGENTS = ["claude", "codex"];
16
-
17
- function shellArgument(value) {
18
- if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
19
- throw Object.assign(new Error("Cannot generate supervision commands from a path containing control characters. Use a path without them and generate the prompt again."), { code: "SUPERVISION_PATH_UNSAFE" });
20
- }
21
- return `'${value.replaceAll("'", "'\\''")}'`;
22
- }
15
+ export { SUPERVISOR_AGENTS };
23
16
 
24
17
  export async function prepareSupervisorPrompt(threadInput, policy, { agent = "claude", connection = "shared", usePathCli = false } = {}) {
25
- const threadId = normalizeThreadId(threadInput);
18
+ const threadId = normalizeTaskId(threadInput);
19
+ connection = taskConnection(threadId, connection);
26
20
  validateSupervisionPolicy(policy);
27
21
  connectionMode(connection);
28
22
  if (!SUPERVISOR_AGENTS.includes(agent)) throw new Error("--agent must be claude or codex.");
29
- const supervisor = { session_id: randomUUID(), owner: agent, connection };
30
- const sessionOptions = `--supervisor ${shellArgument(supervisor.session_id)} --connection ${connection}`;
23
+ const supervisor = { session_id: randomUUID(), owner: agent, connection, ...hostFields() };
24
+ let context = targetContext() ?? process.env.CODEX_THREAD_ID;
25
+ if (targetHost() !== "local" && remoteTransport() === "native" && !context) throw Object.assign(new Error("Remote supervision requires --context-thread <LOCAL-THREAD-ID> or CODEX_THREAD_ID."), { code: "REMOTE_CONTEXT_REQUIRED" });
26
+ if (targetHost() !== "local" && remoteTransport() === "native") {
27
+ const source = parseThreadTarget(context);
28
+ if (source.hostId !== undefined && source.hostId !== "local") throw Object.assign(new Error("Remote supervision needs a local --context-thread."), { code: "REMOTE_CONTEXT_INVALID" });
29
+ context = source.threadId;
30
+ }
31
+ const sessionOptions = `--supervisor ${shellArgument(supervisor.session_id)} --connection ${connection}${targetHost() === "local" ? "" : ` --host ${shellArgument(targetHost())} --remote-transport ${remoteTransport()}${context ? ` --context-thread ${shellArgument(context)}` : ""}`}`;
32
+ const { command: baseCommand, ...runtime } = await preparePromptCommand(usePathCli);
33
+ const command = `${baseCommand} ${sessionOptions}`;
31
34
  if (usePathCli) {
32
- return { ...pathSupervisorPrompt(threadId, `codexteer ${sessionOptions}`, policy, supervisor, { usePathCli: true }),
33
- cli_mode: "path", deployment: null, node: null, supervisor };
35
+ return { ...pathSupervisorPrompt(threadId, command, policy, supervisor, { usePathCli: true }), ...runtime, supervisor };
34
36
  }
35
- const node = { path: await realpath(process.execPath), version: process.version };
36
- const home = codexHome();
37
- shellArgument(node.path); shellArgument(home);
38
- const deployment = await prepareDeployment(home);
39
- // Bind the validated canonical profile as well as the saved executable.
40
- 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)} ${sessionOptions}`;
41
37
  // Render with the saved distribution's implementation as well as its CLI.
42
38
  // An update to the source/cache after placement cannot mix prompt and code.
43
- const { supervisorPrompt } = await import(pathToFileURL(path.join(deployment.directory, "src/prompt.mjs")).href);
44
- return { ...supervisorPrompt(threadId, command, policy, supervisor), cli_mode: "saved", deployment, node, supervisor };
45
- }
46
-
47
- function launchFailure(agent, error) {
48
- 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" });
39
+ const { supervisorPrompt } = await import(pathToFileURL(path.join(runtime.deployment.directory, "src/prompt.mjs")).href);
40
+ return { ...supervisorPrompt(threadId, command, policy, supervisor), ...runtime, supervisor };
49
41
  }
50
42
 
51
43
  export async function superviseAgent(threadInput, { agent = "claude", agentArgs = [], policy, connection = "shared", usePathCli = false } = {}) {
52
44
  const { prompt, thread_id: threadId, supervisor, deployment } = await prepareSupervisorPrompt(threadInput, policy, { agent, connection, usePathCli });
53
45
  const storage = { home: deployment?.codex_home ?? await prepareCodexHome(codexHome()) };
54
- await registerSupervisor(threadId, supervisor.session_id, { ...storage, owner: agent, launcherPid: process.pid, connection });
55
- try { return await launchSupervisorAgent(agent, agentArgs, prompt); }
46
+ await registerSupervisor(threadId, supervisor.session_id, { ...storage, owner: agent, launcherPid: process.pid, connection: supervisor.connection });
47
+ try { return await launchAgent(agent, agentArgs, prompt); }
56
48
  finally {
57
49
  try { await controlSupervisor(threadId, "stop", { ...storage, expectedSession: supervisor.session_id }); }
58
50
  catch (error) {
@@ -61,23 +53,3 @@ export async function superviseAgent(threadInput, { agent = "claude", agentArgs
61
53
  }
62
54
  }
63
55
  }
64
-
65
- async function launchSupervisorAgent(agent, agentArgs, prompt) {
66
- let child;
67
- try {
68
- // Keep forwarded argv intact. The agent's own terminator keeps its variadic
69
- // options from consuming the generated prompt as another option value.
70
- child = spawn(agent, [...agentArgs, "--", prompt], { shell: false, stdio: "inherit" });
71
- } catch (error) { throw launchFailure(agent, error); }
72
-
73
- const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
74
- const handlers = signals.map(signal => () => {
75
- if (child.exitCode === null && child.signalCode === null) child.kill(signal);
76
- });
77
- signals.forEach((signal, index) => process.on(signal, handlers[index]));
78
- try {
79
- const [code, signal] = await once(child, "exit");
80
- return code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1);
81
- } catch (error) { throw launchFailure(agent, error); }
82
- finally { signals.forEach((signal, index) => process.off(signal, handlers[index])); }
83
- }
@@ -1,9 +1,20 @@
1
+ import { isDesktopCloudTask } from "./desktop-cloud-id.mjs";
1
2
  import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
2
- import { normalizeThreadId } from "./thread-id.mjs";
3
+ import { targetHost, remoteReadOnly, remoteTransport, isControlTransport } from "./target.mjs";
4
+ import { normalizeTaskId, isCloudTask } from "./task-id.mjs";
3
5
  import { isAlive } from "./runtime.mjs";
4
6
 
5
7
  const failure = (message, code) => Object.assign(new Error(message), { code });
6
8
  const states = new Set(["active", "paused", "stopped"]);
9
+ const supervisorBucket = id => (isCloudTask(id) || isDesktopCloudTask(id)) ? "cloud-supervisors" : "supervisors";
10
+ function launcherAlive(record) {
11
+ if (record.launcher_pid === null) return null;
12
+ try { return isAlive(record.launcher_pid); }
13
+ catch (error) {
14
+ if (error.code === "PERMISSION_DENIED") throw failure("Cannot verify the supervisor launcher process. This execution sandbox may deny process inspection; no liveness was inferred.", "PERMISSION_DENIED");
15
+ throw error;
16
+ }
17
+ }
7
18
 
8
19
  export function supervisorId(value) {
9
20
  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");
@@ -11,10 +22,10 @@ export function supervisorId(value) {
11
22
  }
12
23
 
13
24
  export async function getSupervisor(threadInput, storage = {}) {
14
- const threadId = normalizeThreadId(threadInput), record = await readRecord("supervisors", threadId, storage);
25
+ const threadId = normalizeTaskId(threadInput), record = await readRecord(supervisorBucket(threadId), threadId, storage);
15
26
  if (!record) return null;
16
27
  if (record.schema !== 1 || record.thread_id !== threadId || !states.has(record.state)
17
- || !["shared", "desktop"].includes(record.connection)
28
+ || !["shared", "desktop", "cloud", "desktop-cloud"].includes(record.connection)
18
29
  || supervisorId(record.session_id) !== record.session_id || typeof record.owner !== "string"
19
30
  || (record.launcher_pid !== null && (!Number.isSafeInteger(record.launcher_pid) || record.launcher_pid <= 0))) {
20
31
  throw failure("Invalid supervisor state. Inspect the local record before continuing.", "SUPERVISOR_INVALID");
@@ -27,17 +38,18 @@ export async function assertSupervisor(threadInput, sessionInput, { sending = fa
27
38
  if (!record) throw failure("Register this supervisor session before observing or sending.", "SUPERVISOR_NOT_REGISTERED");
28
39
  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
40
  if (connection !== undefined && record.connection !== connection) throw failure("The supervisor is registered for another connection. Keep the original command intact.", "SUPERVISOR_MISMATCH");
41
+ 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");
30
42
  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");
43
+ if (launcherAlive(record) === false) throw failure("The supervisor launcher exited. Stop the stale session before starting another supervisor.", "SUPERVISOR_ORPHANED");
32
44
  if (sending && record.state === "paused") throw failure("Automatic intervention is paused. Observation can continue; only the user should resume intervention.", "SUPERVISOR_PAUSED");
33
45
  return record;
34
46
  }
35
47
 
36
48
  export async function registerSupervisor(threadInput, sessionInput, { owner = "external", launcherPid = null, connection = "shared", ...storage } = {}) {
37
- const threadId = normalizeThreadId(threadInput), sessionId = supervisorId(sessionInput);
49
+ const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput);
38
50
  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
51
  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");
52
+ if (!["shared", "desktop", "cloud", "desktop-cloud"].includes(connection)) throw failure("Invalid supervisor connection.", "SUPERVISOR_INVALID");
41
53
  return withStoreLock(`supervisor-${threadId}`, async () => {
42
54
  const current = await getSupervisor(threadId, storage);
43
55
  if (current?.session_id === sessionId) {
@@ -48,44 +60,45 @@ export async function registerSupervisor(threadInput, sessionInput, { owner = "e
48
60
  const now = new Date().toISOString();
49
61
  const record = { schema: 1, thread_id: threadId, session_id: sessionId, owner, launcher_pid: launcherPid, connection,
50
62
  state: "active", registered_at: now, updated_at: now, last_delivery: null };
51
- await writeRecord("supervisors", threadId, record, storage);
63
+ await writeRecord(supervisorBucket(threadId), threadId, record, storage);
52
64
  return record;
53
65
  }, storage);
54
66
  }
55
67
 
56
68
  export async function controlSupervisor(threadInput, action, { expectedSession, ...storage } = {}) {
57
- const threadId = normalizeThreadId(threadInput);
69
+ const threadId = normalizeTaskId(threadInput);
58
70
  if (!["pause", "resume", "stop"].includes(action)) throw failure("Expected pause, resume or stop.", "SUPERVISOR_INVALID");
59
71
  return withStoreLock(`supervisor-${threadId}`, async () => {
60
72
  const record = await getSupervisor(threadId, storage);
61
73
  if (!record) throw failure("No supervisor is registered for this task.", "SUPERVISOR_NOT_REGISTERED");
74
+ if (targetHost() !== "local" && (record.remote_transport ?? "native") !== remoteTransport()) throw failure("Keep the registered remote transport when controlling this supervisor. No state was changed.", "SUPERVISOR_MISMATCH");
62
75
  if (expectedSession && record.session_id !== supervisorId(expectedSession)) throw failure("The supervisor session changed.", "SUPERVISOR_MISMATCH");
63
76
  if (record.state === "stopped" && action !== "stop") throw failure("A stopped supervisor cannot be resumed. Start a new supervisor.", "SUPERVISOR_STOPPED");
64
77
  if (action === "resume") await assertSupervisor(threadId, record.session_id, storage);
65
78
  const updated = { ...record, state: { pause: "paused", resume: "active", stop: "stopped" }[action], updated_at: new Date().toISOString() };
66
- await writeRecord("supervisors", threadId, updated, storage);
79
+ await writeRecord(supervisorBucket(threadId), threadId, updated, storage);
67
80
  return updated;
68
81
  }, storage);
69
82
  }
70
83
 
71
84
  export async function supervisorStatus(threadInput, storage = {}) {
72
- const threadId = normalizeThreadId(threadInput), record = await getSupervisor(threadId, storage);
85
+ const threadId = normalizeTaskId(threadInput), record = await getSupervisor(threadId, storage);
73
86
  if (!record) return { thread_id: threadId, state: "unregistered", observation: null };
74
87
  const observation = await readRecord("supervisor-observations", record.session_id, storage);
75
88
  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),
89
+ return { ...record, launcher_alive: launcherAlive(record),
77
90
  observation, observation_age_ms: observation?.last_observed_at ? Math.max(0, Date.now() - Date.parse(observation.last_observed_at)) : null };
78
91
  }
79
92
 
80
93
  export async function listSupervisors(storage = {}) {
81
- const records = await listRecords("supervisors", storage);
94
+ const records = (await Promise.all([listRecords("supervisors", storage), listRecords("cloud-supervisors", storage)])).flat();
82
95
  return Promise.all(records.filter(Boolean).map(record => supervisorStatus(record.thread_id, storage)));
83
96
  }
84
97
 
85
98
  // Observation has its own record so a concurrent read never overwrites pause/stop.
86
99
  // This timestamp records CLI observation, not acknowledgement by the supervising AI.
87
100
  export async function recordSupervisorObservation(threadInput, sessionInput, data, storage = {}) {
88
- const threadId = normalizeThreadId(threadInput), sessionId = supervisorId(sessionInput);
101
+ const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput);
89
102
  await assertSupervisor(threadId, sessionId, storage);
90
103
  const key = `supervisor-observe-${sessionId}`;
91
104
  try {
@@ -110,25 +123,34 @@ export async function recordSupervisorFailure(threadId, sessionId, error, storag
110
123
  catch { /* The original failure is returned to the consumer. */ }
111
124
  }
112
125
 
113
- export async function withSupervisedSend(threadInput, sessionInput, send, { connection, ...storage } = {}) {
114
- if (sessionInput === undefined) return send();
115
- const threadId = normalizeThreadId(threadInput), sessionId = supervisorId(sessionInput);
126
+ export async function withSupervisedMutation(threadInput, sessionInput, mutate, { connection, ...storage } = {}) {
127
+ if (targetHost() !== "local" && !isControlTransport()) throw remoteReadOnly();
128
+ if (sessionInput === undefined) return mutate();
129
+ const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput);
116
130
  // Holding this lock through the receipt serializes pause/stop with the actual
117
131
  // mutation. A busy pause is an error, never a false acknowledgement of a stop.
118
132
  return withStoreLock(`supervisor-${threadId}`, async () => {
119
133
  const record = await assertSupervisor(threadId, sessionId, { ...storage, sending: true, connection });
134
+ return mutate(record);
135
+ }, storage);
136
+ }
137
+
138
+ export async function withSupervisedSend(threadInput, sessionInput, send, { connection, ...storage } = {}) {
139
+ return withSupervisedMutation(threadInput, sessionInput, async record => {
140
+ if (!record) return send();
141
+ const threadId = record.thread_id;
120
142
  try {
121
143
  const receipt = await send();
122
- try { await writeRecord("supervisors", threadId, { ...record, updated_at: new Date().toISOString(), last_error: null,
144
+ try { await writeRecord(supervisorBucket(threadId), threadId, { ...record, updated_at: new Date().toISOString(), last_error: null,
123
145
  last_delivery: { message_id: receipt.message_id ?? null, status: receipt.delivery_status, at: new Date().toISOString() } }, storage); }
124
146
  catch { receipt.supervision_update_required = true; }
125
147
  return receipt;
126
148
  } catch (error) {
127
- try { await writeRecord("supervisors", threadId, { ...record, updated_at: new Date().toISOString(),
149
+ try { await writeRecord(supervisorBucket(threadId), threadId, { ...record, updated_at: new Date().toISOString(),
128
150
  last_error: { code: error.code ?? "SEND_FAILED", existing_message_id: error.existing_message_id ?? null, at: new Date().toISOString() },
129
151
  ...(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
152
  catch { error.supervision_update_required = true; }
131
153
  throw error;
132
154
  }
133
- }, storage);
155
+ }, { connection, ...storage });
134
156
  }
package/src/target.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash } from "node:crypto";
3
+
4
+ // One invocation selects one host, including for downstream local records.
5
+ const targets = new AsyncLocalStorage();
6
+ export const targetHost = () => targets.getStore()?.hostId ?? "local";
7
+ export const targetContext = () => targets.getStore()?.contextThread;
8
+ export const remoteTransport = () => targets.getStore()?.remoteTransport ?? "native";
9
+ export const isControlTransport = (transport = remoteTransport()) => ["control", "paired"].includes(transport);
10
+ export function validateRemoteTransport(value) {
11
+ if (!["native", "control", "paired"].includes(value)) throw Object.assign(new Error("--remote-transport must be native, control or paired."), { code: "INVALID_REMOTE_TRANSPORT" });
12
+ return value;
13
+ }
14
+ export function validateHostId(hostId) {
15
+ if (typeof hostId !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,239}$/.test(hostId)) {
16
+ throw Object.assign(new Error("Invalid host ID."), { code: "INVALID_HOST" });
17
+ }
18
+ return hostId;
19
+ }
20
+ export const withTarget = (hostId, contextThread, operation, { transport = "native" } = {}) => targets.run({ hostId: validateHostId(hostId), contextThread, remoteTransport: validateRemoteTransport(transport) }, operation);
21
+ export const hostNamespace = () => targetHost() === "local" ? null : createHash("sha256").update(targetHost()).digest("hex");
22
+ export const hostFields = () => targetHost() === "local" ? {} : { host_id: targetHost(), remote_transport: remoteTransport() };
23
+ export function remoteReadOnly() {
24
+ return Object.assign(new Error("The native remote route supports observation only. Select --remote-transport paired for remote task creation, listing and new-turn operations, or a configured control route for active-turn steering. No route was switched automatically."), { code: "REMOTE_READ_ONLY", sent: false, delivery_status: "not_sent" });
25
+ }
@@ -0,0 +1,23 @@
1
+ import { isDesktopCloudTask, desktopCloudId } from "./desktop-cloud-id.mjs";
2
+ import { normalizeThreadId } from "./thread-id.mjs";
3
+ import { isCloudTask, cloudTaskId } from "./cloud-id.mjs";
4
+
5
+ export { isCloudTask } from "./cloud-id.mjs";
6
+
7
+ // Shared records use the actual backend identifier. Desktop RPC boundaries
8
+ // retain their strict UUID validator, so a cloud ID cannot reach a local task.
9
+ export function normalizeTaskId(value) {
10
+ return isDesktopCloudTask(value) ? desktopCloudId(value) : isCloudTask(value) ? cloudTaskId(value) : normalizeThreadId(value);
11
+ }
12
+
13
+ export function taskConnection(value, selected = "shared") {
14
+ if (isDesktopCloudTask(value)) {
15
+ if (!["shared", "desktop-cloud"].includes(selected)) throw Object.assign(new Error("Desktop Cloud cannot use another connection."), { code: "CLOUD_TARGET_CONFLICT" });
16
+ return "desktop-cloud";
17
+ }
18
+ if (selected === "desktop-cloud") throw Object.assign(new Error("Use a durable_ ID or hostId=durable link for Desktop Cloud."), { code: "CLOUD_TARGET_CONFLICT" });
19
+ const cloud = isCloudTask(value);
20
+ if (cloud && selected === "desktop") throw Object.assign(new Error("A cloud task cannot use a Desktop observation endpoint."), { code: "CLOUD_TARGET_CONFLICT" });
21
+ if (!cloud && selected === "cloud") throw Object.assign(new Error("The cloud connection requires a cloud task ID or link."), { code: "CLOUD_TARGET_CONFLICT" });
22
+ return cloud ? "cloud" : selected;
23
+ }
@@ -10,7 +10,7 @@ import { readSendState } from "./app-server.mjs";
10
10
  import { fetchThread } from "./observe.mjs";
11
11
  import { discoverNativeEndpoint, inspectNativeEndpoint, nativeToolJson } from "./native-rpc.mjs";
12
12
  import { nativeOnAppServer, inspectNativeCommandProtocol, mutationOutcomeUnknown } from "./native-command.mjs";
13
- import { desktopMembership, initialInstructionMatches } from "./creation-verification.mjs";
13
+ import { desktopMembership, initialInstructionMatches, validateMembershipCatalog } from "./creation-verification.mjs";
14
14
  import { writeRecord, storePath } from "./store.mjs";
15
15
  import { createStandaloneProjectThread } from "./standalone-create.mjs";
16
16
 
@@ -26,11 +26,12 @@ export function creationOptions(options = {}) {
26
26
  }
27
27
 
28
28
  export function validateNativeCreationCatalog(catalog) {
29
+ const membership = validateMembershipCatalog(catalog);
29
30
  const schemas = ["list_projects", "create_thread"].map(name => catalog?.tools?.find(tool => tool.name === name && tool.namespace === "codex_app")?.inputSchema);
30
31
  const creation = schemas[1], project = creation?.properties?.target?.anyOf?.find(target => target.properties?.type?.enum?.includes("project"));
31
32
  const local = project?.properties?.environment?.anyOf?.find(environment => environment.properties?.type?.enum?.includes("local"));
32
33
  if (schemas[0]?.type !== "object" || creation?.properties?.prompt?.type !== "string" || !creation.required?.includes("prompt") || !creation.required.includes("target") || project?.properties?.projectId?.type !== "string" || !project.required?.includes("projectId") || !local) throw failure("Desktop native tools do not expose the required local creation schema.", "NATIVE_CREATION_UNSUPPORTED");
33
- return { status: "verified", source: "desktop-native-schema", schema_sha256: createHash("sha256").update(JSON.stringify(schemas)).digest("hex") };
34
+ return { status: "verified", source: "desktop-native-schema", schema_sha256: createHash("sha256").update(JSON.stringify([...schemas, membership])).digest("hex") };
34
35
  }
35
36
 
36
37
  export async function createProjectThread(selector, prompt, options = {}, dependencies = {}) {
@@ -99,7 +100,7 @@ export async function createProjectThread(selector, prompt, options = {}, depend
99
100
  await write("creations", attemptId, receipt, storage);
100
101
  if (!accepted) throw failure("Child exists but its initial turn was not confirmed. Inspect the child before resending.", "INITIAL_TURN_UNCONFIRMED");
101
102
  const thread = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
102
- if (thread?.id !== threadId || thread.cwd !== scope.cwd || !await membership(paths.home, threadId, selected.projectId)) throw failure("Created child did not confirm the selected Desktop project.", "DESKTOP_MEMBERSHIP_MISMATCH");
103
+ if (thread?.id !== threadId || thread.cwd !== scope.cwd || !await membership(invokeNative, nativeEndpoint, threadId, selected.projectId, context, nativeOptions)) throw failure("Created child did not confirm the selected Desktop project.", "DESKTOP_MEMBERSHIP_MISMATCH");
103
104
  const roots = thread.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
104
105
  if (!Array.isArray(roots) || roots.length !== scope.runtimeWorkspaceRoots.length || !scope.runtimeWorkspaceRoots.every(root => roots.includes(root))) throw failure("Created child did not confirm all project source folders.", "CREATION_SCOPE_MISMATCH");
105
106
  let saved = await history(client, threadId), named = thread;
package/src/thread-id.mjs CHANGED
@@ -1,11 +1,12 @@
1
+ import { targetHost, validateHostId } from "./target.mjs";
1
2
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
3
 
3
- export function normalizeThreadId(input) {
4
+ export function parseThreadTarget(input) {
4
5
  if (typeof input !== "string" || input.trim() === "") {
5
6
  throw new Error("Thread ID is required.");
6
7
  }
7
8
 
8
- let value = input.trim();
9
+ let value = input.trim(), hostId;
9
10
  if (value.startsWith("codex://")) {
10
11
  let url;
11
12
  try {
@@ -16,20 +17,34 @@ export function normalizeThreadId(input) {
16
17
  if (url.hostname !== "threads") {
17
18
  throw new Error(`Expected codex://threads/<thread-id>: ${input}`);
18
19
  }
20
+ const hosts = url.searchParams.getAll("hostId");
21
+ if (hosts.length > 1) throw new Error("A thread link must have only one hostId.");
22
+ if (hosts.length) hostId = validateHostId(hosts[0]);
19
23
  value = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
20
24
  }
21
25
 
22
26
  if (!UUID_PATTERN.test(value)) {
23
27
  throw new Error(`Invalid local Codex thread ID: ${input}`);
24
28
  }
25
- return value.toLowerCase();
29
+ return { threadId: value.toLowerCase(), hostId };
30
+ }
31
+
32
+ export function normalizeThreadId(input) {
33
+ const { threadId, hostId } = parseThreadTarget(input);
34
+ if (hostId !== undefined && hostId !== targetHost()) {
35
+ throw Object.assign(new Error("Thread link belongs to another host. Select its host with --host or the host-aware CLI entry point."), { code: "HOST_MISMATCH" });
36
+ }
37
+ return threadId;
26
38
  }
27
39
 
28
40
  export function threadDeepLink(threadId) {
29
- return `codex://threads/${normalizeThreadId(threadId)}`;
41
+ const target = parseThreadTarget(threadId), host = target.hostId ?? targetHost();
42
+ const link = `codex://threads/${target.threadId}`;
43
+ return host === "local" ? link : `${link}?${new URLSearchParams({ hostId: host })}`;
30
44
  }
31
45
 
32
46
  export function threadComposerDeepLink(threadId, prompt = "") {
33
- const query = new URLSearchParams({ prompt });
34
- return `${threadDeepLink(threadId)}?${query}`;
47
+ const link = new URL(threadDeepLink(threadId));
48
+ link.searchParams.set("prompt", prompt);
49
+ return link.toString();
35
50
  }
@@ -0,0 +1,108 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { discoverRuntime } from "./runtime.mjs";
4
+ import { RpcClient } from "./rpc.mjs";
5
+ import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
6
+ import { normalizeThreadId } from "./thread-id.mjs";
7
+ import { projectAliases, projectWorkspace, validateProject } from "./projects.mjs";
8
+ import { fetchThread } from "./observe.mjs";
9
+ import { discoverNativeEndpoint, inspectNativeEndpoint } from "./native-rpc.mjs";
10
+ import { nativeOnAppServer, inspectNativeCommandProtocol } from "./native-command.mjs";
11
+ import { desktopMembership, initialInstructionText, validateMembershipCatalog } from "./creation-verification.mjs";
12
+ import { readRecord, writeRecord, storePath, withStoreLock } from "./store.mjs";
13
+
14
+ const failure = (message, code = "CREATION_VERIFICATION_FAILED") => Object.assign(new Error(message), { code });
15
+ const sameRoots = (actual, expected) => Array.isArray(actual) && actual.length === expected.length && expected.every(root => actual.includes(root));
16
+ const text = value => typeof value === "string" && !!value.trim();
17
+
18
+ function validateReceipt(receipt, attemptId) {
19
+ if (!receipt || receipt.attempt_id !== attemptId || receipt.dry_run
20
+ || !((receipt.schema === 3 && receipt.creation_mode === "standalone") || (receipt.schema === 2 && receipt.creation_mode === "delegated"))
21
+ || receipt.creation_status !== "created" || receipt.sent !== true || receipt.delivery_status !== "accepted"
22
+ || !text(receipt.thread_id) || normalizeThreadId(receipt.thread_id) !== receipt.thread_id
23
+ || !text(receipt.project_id) || !text(receipt.desktop_project_id) || !text(receipt.cwd) || !path.isAbsolute(receipt.cwd)
24
+ || !Array.isArray(receipt.runtime_workspace_roots) || !receipt.runtime_workspace_roots.length
25
+ || receipt.runtime_workspace_roots.some(root => !text(root) || !path.isAbsolute(root))
26
+ || new Set(receipt.runtime_workspace_roots).size !== receipt.runtime_workspace_roots.length
27
+ || !/^[a-f0-9]{64}$/.test(receipt.prompt_sha256 ?? "") || !Number.isSafeInteger(receipt.message_characters) || receipt.message_characters < 1
28
+ || (receipt.creation_mode === "standalone" ? !text(receipt.turn_id) || !text(receipt.title) : !text(receipt.context_thread_id))) {
29
+ throw failure("A valid created/accepted receipt is required. Inspect the existing task; this command never creates or resends.", "CREATION_RECEIPT_UNVERIFIED");
30
+ }
31
+ }
32
+
33
+ // Recovery of a known created/accepted attempt. Only the codexteer receipt is
34
+ // written. No create, subscribe, move, metadata update, start or steer API is
35
+ // available in this operation; native access is restricted to two read tools.
36
+ export async function verifyProjectThread(attempt, { timeoutMs = 120000 } = {}, {
37
+ discover = discoverRuntime, connect = RpcClient.connect, aliases = projectAliases,
38
+ endpoint = discoverNativeEndpoint, inspectEndpoint = inspectNativeEndpoint, inspectCommand = inspectNativeCommandProtocol, native,
39
+ workspace = projectWorkspace, history = fetchThread, membership = desktopMembership,
40
+ read = readRecord, write = writeRecord, lock = withStoreLock,
41
+ } = {}) {
42
+ const attemptId = normalizeThreadId(attempt);
43
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) throw failure("--timeout-ms must be 1000..600000.", "INVALID_CREATION_OPTIONS");
44
+ const { paths, state } = await discover(), storage = { home: paths.home };
45
+ assertRuntimeOperation(state, "read");
46
+ return lock(`creation-${attemptId}`, async () => {
47
+ const receipt = await read("creations", attemptId, storage);
48
+ validateReceipt(receipt, attemptId);
49
+ let client;
50
+ try {
51
+ const nativeEndpoint = await endpoint(state);
52
+ await inspectCommand(state);
53
+ client = compatibleClient(await connect(paths.socket, { timeoutMs: timeoutMs + 5000 }), state);
54
+ const invoke = native ?? ((...args) => nativeOnAppServer(client, ...args));
55
+ const readNative = (...args) => {
56
+ if (!["catalog", "list_threads"].includes(args[1])) throw failure("Verification permits only native reads.");
57
+ return invoke(...args);
58
+ };
59
+ validateMembershipCatalog(await readNative(nativeEndpoint, "catalog", undefined, undefined, { timeoutMs }));
60
+ const project = validateProject((await client.request("project/read", { projectId: receipt.project_id })).project);
61
+ const scope = await workspace(project, receipt.cwd);
62
+ const thread = (await client.request("thread/read", { threadId: receipt.thread_id, includeTurns: false })).thread;
63
+ const saved = await history(client, receipt.thread_id), first = saved.turns?.[0];
64
+ const instruction = initialInstructionText(saved, receipt.creation_mode === "delegated" ? receipt.context_thread_id : undefined);
65
+ const roots = thread?.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
66
+ const checks = {
67
+ thread_id: thread?.id === receipt.thread_id,
68
+ project_mapping: (await aliases(paths.home))[receipt.desktop_project_id] === receipt.project_id,
69
+ project_roots: project.id === receipt.project_id && sameRoots(scope.runtimeWorkspaceRoots, receipt.runtime_workspace_roots),
70
+ canonical_project_id: thread?.projectId === receipt.project_id,
71
+ cwd: thread?.cwd === receipt.cwd && scope.cwd === receipt.cwd,
72
+ roots: sameRoots(roots, receipt.runtime_workspace_roots),
73
+ title: receipt.title == null || thread?.name === receipt.title,
74
+ initial_turn: text(first?.id) && (receipt.creation_mode !== "standalone" || first.id === receipt.turn_id),
75
+ initial_instruction: typeof instruction === "string" && [...instruction].length === receipt.message_characters
76
+ && createHash("sha256").update(instruction).digest("hex") === receipt.prompt_sha256,
77
+ desktop_membership: text(first?.id) && await membership(readNative, nativeEndpoint, receipt.thread_id, receipt.desktop_project_id,
78
+ { threadId: receipt.thread_id, turnId: first.id }, { timeoutMs }),
79
+ };
80
+ const current = await discover(); assertRuntimeOperation(current.state, "read");
81
+ if (current.state.instance !== state.instance || current.paths.socket !== paths.socket || current.paths.home !== paths.home || current.state.cli_version !== state.cli_version) {
82
+ throw failure("Runtime changed during verification. No task was changed.", "RUNTIME_CHANGED");
83
+ }
84
+ await inspectEndpoint(nativeEndpoint.socketPath, nativeEndpoint.fingerprint);
85
+ const verified = Object.values(checks).every(value => value === true), status = verified ? "verified" : "failed";
86
+ const updated = { ...receipt, receipt_path: storePath("creations", attemptId, paths.home), verification_status: status,
87
+ verification_history: [...(receipt.verification_history ?? []), { checked_at: new Date().toISOString(), status, checks,
88
+ source: "desktop-native-list_threads", desktop_reads_only: true,
89
+ previous_status: receipt.verification_status, ...(receipt.error_code ? { previous_error_code: receipt.error_code } : {}) }] };
90
+ if (verified) {
91
+ Object.assign(updated, { desktop_project_membership: "verified", app_server_project_membership: "verified", initial_instruction: "verified" });
92
+ delete updated.error_code;
93
+ } else {
94
+ updated.error_code = "CREATION_VERIFICATION_FAILED";
95
+ // Never leave earlier certification flags on a failed fresh check.
96
+ delete updated.desktop_project_membership; delete updated.app_server_project_membership; delete updated.initial_instruction;
97
+ }
98
+ try { await write("creations", attemptId, updated, storage); }
99
+ catch { throw Object.assign(failure("Verification finished but its receipt could not be saved. Do not recreate or resend.", "CREATION_RECEIPT_UPDATE_FAILED"), { receipt_update_required: true, verification_checks: checks }); }
100
+ if (!verified) throw Object.assign(failure("Existing task did not pass all creation checks. No task was changed; do not recreate or resend."), { verification_checks: checks });
101
+ return updated;
102
+ } catch (error) {
103
+ Object.assign(error, { attempt_id: attemptId, thread_id: receipt.thread_id, creation_status: receipt.creation_status,
104
+ sent: receipt.sent, delivery_status: receipt.delivery_status, receipt_path: storePath("creations", attemptId, paths.home) });
105
+ throw error;
106
+ } finally { client?.close(); }
107
+ }, storage);
108
+ }