codexteer 0.16.0 → 0.18.0

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 (85) hide show
  1. package/CHANGELOG.md +37 -2
  2. package/README.md +154 -16
  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 +21 -9
  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 +90 -0
  16. package/package.json +13 -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 +233 -19
  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 +23 -5
  30. package/src/creation-verification.mjs +36 -0
  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 +6 -6
  38. package/src/evidence.mjs +3 -0
  39. package/src/findings.mjs +7 -7
  40. package/src/help.mjs +155 -26
  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 +100 -0
  45. package/src/native-rpc.mjs +116 -0
  46. package/src/native-worker.mjs +25 -0
  47. package/src/observe.mjs +21 -7
  48. package/src/orchestrate-prompt.mjs +96 -0
  49. package/src/orchestrate.mjs +29 -0
  50. package/src/paged-read.mjs +7 -1
  51. package/src/projects.mjs +104 -0
  52. package/src/prompt-command.mjs +25 -0
  53. package/src/prompt.mjs +35 -11
  54. package/src/remote-client-info.mjs +16 -0
  55. package/src/remote-control-auth.mjs +109 -0
  56. package/src/remote-control-socket.mjs +137 -0
  57. package/src/remote-control.mjs +134 -0
  58. package/src/remote-device-key.mjs +41 -0
  59. package/src/remote-device-login.mjs +76 -0
  60. package/src/remote-device-protocol.mjs +72 -0
  61. package/src/remote-device-store.mjs +71 -0
  62. package/src/remote-device-worker.mjs +48 -0
  63. package/src/remote-device.mjs +202 -0
  64. package/src/remote-io.mjs +163 -0
  65. package/src/remote-key-access.mjs +76 -0
  66. package/src/remote-key-worker.mjs +38 -0
  67. package/src/remote-keychain.mjs +72 -0
  68. package/src/remote-management.mjs +234 -0
  69. package/src/remote.mjs +132 -0
  70. package/src/resource.mjs +8 -6
  71. package/src/runner.mjs +6 -1
  72. package/src/runtime.mjs +4 -2
  73. package/src/socket-endpoint.mjs +40 -0
  74. package/src/standalone-create.mjs +135 -0
  75. package/src/store.mjs +6 -3
  76. package/src/supervise.mjs +32 -55
  77. package/src/supervision.mjs +41 -19
  78. package/src/target.mjs +25 -0
  79. package/src/task-id.mjs +23 -0
  80. package/src/thread-create.mjs +128 -0
  81. package/src/thread-id.mjs +21 -6
  82. package/src/thread-verify.mjs +108 -0
  83. package/src/turn-repair.mjs +228 -0
  84. package/src/update.mjs +158 -0
  85. package/src/wrapper.mjs +68 -5
@@ -0,0 +1,135 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { discoverRuntime, verifyControlEndpoint } from "./runtime.mjs";
5
+ import { RpcClient } from "./rpc.mjs";
6
+ import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
7
+ import { normalizeThreadId, threadDeepLink } from "./thread-id.mjs";
8
+ import { projectsOnClient, projectAliases, resolveProject, projectWorkspace, validateProject } from "./projects.mjs";
9
+ import { fetchThread } from "./observe.mjs";
10
+ import { ensureDesktopSubscription } from "./subscription.mjs";
11
+ import { discoverNativeEndpoint, inspectNativeEndpoint, nativeToolJson } from "./native-rpc.mjs";
12
+ import { nativeOnAppServer, inspectNativeCommandProtocol, mutationOutcomeUnknown } from "./native-command.mjs";
13
+ import { desktopMembership, initialInstructionMatches, validateMembershipCatalog } from "./creation-verification.mjs";
14
+ import { writeRecord, storePath } from "./store.mjs";
15
+
16
+ const failure = (message, code = "CREATION_FAILED") => Object.assign(new Error(message), { code });
17
+
18
+ export function validateStandaloneCreationCatalog(catalog) {
19
+ const membership = validateMembershipCatalog(catalog);
20
+ const schema = catalog?.tools?.find(tool => tool.name === "move_thread_to_project" && tool.namespace === "codex_app")?.inputSchema;
21
+ const list = catalog?.tools?.find(tool => tool.name === "list_projects" && tool.namespace === "codex_app")?.inputSchema;
22
+ const project = schema?.properties?.projectId;
23
+ if (list?.type !== "object" || schema?.properties?.threadId?.type !== "string"
24
+ || !schema.required?.includes("threadId") || !schema.required.includes("projectId")
25
+ || !(project?.type === "string" || project?.anyOf?.some(type => type.type === "string"))) {
26
+ throw failure("Desktop native tools do not expose local project assignment.", "NATIVE_CREATION_UNSUPPORTED");
27
+ }
28
+ return { status: "verified", source: "desktop-native-assignment-schema", schema_sha256: createHash("sha256").update(JSON.stringify([list, schema, membership])).digest("hex") };
29
+ }
30
+
31
+ // No caller chat is required. The initial instruction starts exactly one real
32
+ // task/turn; only that new task supplies the native self-assignment context.
33
+ export async function createStandaloneProjectThread(selector, prompt, settings, {
34
+ discover = discoverRuntime, connect = RpcClient.connect, aliases = projectAliases,
35
+ endpoint = discoverNativeEndpoint, inspectEndpoint = inspectNativeEndpoint, inspectCommand = inspectNativeCommandProtocol, native,
36
+ workspace = projectWorkspace, history = fetchThread, membership = desktopMembership,
37
+ inspectControl = verifyControlEndpoint, subscribe = ensureDesktopSubscription, write = writeRecord, sleep = delay,
38
+ } = {}) {
39
+ const attemptId = randomUUID();
40
+ const title = settings.title ?? [...prompt.trim().replace(/\s+/gu, " ")].slice(0, 80).join("");
41
+ let client, receipt, storage, creating = false, sending = false, accepted = false;
42
+ try {
43
+ const { paths, state } = await discover(); storage = { home: paths.home };
44
+ assertRuntimeOperation(state, "send_new_turn");
45
+ await inspectControl(paths);
46
+ const nativeEndpoint = await endpoint(state), command = await inspectCommand(state, { standalone: true });
47
+ client = compatibleClient(await connect(paths.socket, { timeoutMs: settings.timeoutMs + 5000 }), state);
48
+ const invokeNative = native ?? ((...args) => nativeOnAppServer(client, ...args));
49
+ const nativeOptions = { timeoutMs: settings.timeoutMs };
50
+ const protocol = { ...validateStandaloneCreationCatalog(await invokeNative(nativeEndpoint, "catalog", undefined, undefined, nativeOptions)), command };
51
+ const mapping = await aliases(paths.home), project = resolveProject(await projectsOnClient(client), selector, mapping), scope = await workspace(project);
52
+ const matches = Object.keys(mapping).filter(id => mapping[id] === project.id);
53
+ const desktopId = mapping[selector] === project.id ? selector : matches.length === 1 ? matches[0] : undefined;
54
+ if (!desktopId) throw failure("Cannot uniquely map this local project to Desktop. No chat was created.", "DESKTOP_PROJECT_MAPPING_UNVERIFIED");
55
+ const check = async () => {
56
+ const current = await discover(); assertRuntimeOperation(current.state, "send_new_turn");
57
+ if (current.state.instance !== state.instance || current.paths.socket !== paths.socket || current.paths.control !== paths.control || current.state.cli_version !== state.cli_version) throw failure("Runtime changed. Inspect the receipt before retrying.", "RUNTIME_CHANGED");
58
+ await inspectControl(current.paths); await inspectEndpoint(nativeEndpoint.socketPath, nativeEndpoint.fingerprint);
59
+ const live = validateProject((await client.request("project/read", { projectId: project.id })).project);
60
+ if (!isDeepStrictEqual(live.roots, project.roots) || live.name !== project.name || (await aliases(paths.home))[desktopId] !== project.id) throw failure("Project source folders or Desktop mapping changed.", "PROJECT_CHANGED");
61
+ };
62
+ await check();
63
+ receipt = { schema: 3, attempt_id: attemptId, native_call_id: attemptId, creation_mode: "standalone", project_id: project.id, desktop_project_id: desktopId, project_name: project.name,
64
+ context_thread_id: null, context_turn_id: null, cwd: scope.cwd, runtime_workspace_roots: scope.runtimeWorkspaceRoots,
65
+ title, title_source: settings.title === undefined ? "initial-instruction" : "explicit", backend: "app-server-native-assignment", connection_mode: "shared", protocol,
66
+ creation_status: "not_created", sent: false, delivery_status: "not_sent", verification_status: "not_started",
67
+ message_characters: [...prompt].length, prompt_sha256: createHash("sha256").update(prompt).digest("hex"), dry_run: !!settings.dryRun };
68
+ if (settings.dryRun) return receipt;
69
+ receipt.receipt_path = storePath("creations", attemptId, paths.home);
70
+ await write("creations", attemptId, { ...receipt, creation_status: "dispatching" }, storage);
71
+ await check(); creating = true;
72
+ const started = await client.request("thread/start", { cwd: scope.cwd, projectId: project.id, runtimeWorkspaceRoots: scope.runtimeWorkspaceRoots,
73
+ ephemeral: false, ...(settings.model === undefined ? {} : { model: settings.model }) }, { mutation: true });
74
+ if (!started?.thread?.id) throw Object.assign(failure("No created task ID was confirmed. Do not retry automatically.", "NATIVE_CREATION_UNKNOWN"), { uncertain: true });
75
+ const threadId = normalizeThreadId(started.thread.id);
76
+ Object.assign(receipt, { thread_id: threadId, deep_link: threadDeepLink(threadId), creation_status: "created" });
77
+ await write("creations", attemptId, receipt, storage);
78
+ await check();
79
+ // Naming also materializes the turnless rollout on supported servers;
80
+ // without it both metadata/update and Desktop's resume can fail.
81
+ await client.request("thread/name/set", { threadId, name: title }, { mutation: true });
82
+ // An untouched, turnless thread can have no durable rollout yet. Persist
83
+ // its canonical assignment through the API before Desktop resumes it.
84
+ await client.request("thread/metadata/update", { threadId, projectId: project.id }, { mutation: true });
85
+ // Subscribe on Desktop's long-lived connection before starting any work.
86
+ await subscribe(paths.control, threadId); receipt.desktop_subscription = "verified";
87
+ await check();
88
+ const prepared = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
89
+ const preparedRoots = prepared?.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
90
+ if (prepared?.id !== threadId || prepared.projectId !== project.id || prepared.cwd !== scope.cwd
91
+ || !Array.isArray(preparedRoots) || preparedRoots.length !== scope.runtimeWorkspaceRoots.length || !scope.runtimeWorkspaceRoots.every(root => preparedRoots.includes(root))
92
+ || prepared.canAcceptDirectInput === false || (await history(client, threadId)).turns?.length !== 0) {
93
+ throw failure("New task's scope or empty initial history changed before delivery. No instruction was sent.", "CREATION_SCOPE_MISMATCH");
94
+ }
95
+ receipt.client_message_id = randomUUID();
96
+ await write("creations", attemptId, { ...receipt, delivery_status: "dispatching" }, storage);
97
+ sending = true;
98
+ const delivery = await client.request("turn/start", { threadId, input: [{ type: "text", text: prompt }], clientUserMessageId: receipt.client_message_id,
99
+ ...(settings.thinking === undefined ? {} : { effort: settings.thinking }) }, { mutation: true });
100
+ if (typeof delivery?.turn?.id !== "string" || !delivery.turn.id.trim()) throw Object.assign(failure("No initial turn ID was confirmed. Inspect the task before resending.", "INITIAL_TURN_UNCONFIRMED"), { uncertain: true });
101
+ accepted = true;
102
+ Object.assign(receipt, { turn_id: delivery.turn.id, sent: true, delivery_status: "accepted", native_context_thread_id: threadId, native_context_turn_id: delivery.turn.id });
103
+ await write("creations", attemptId, receipt, storage);
104
+ const context = { threadId, turnId: delivery.turn.id };
105
+ const inventory = nativeToolJson(await invokeNative(nativeEndpoint, "list_projects", {}, context, nativeOptions));
106
+ const selected = inventory.projects?.filter(item => item.projectKind === "local" && item.hostId === "local" && item.projectId === desktopId);
107
+ if (selected?.length !== 1 || selected[0].label !== project.name || selected[0].path !== project.roots[0].path) throw failure("Desktop project mapping was not confirmed. Inspect the created task.", "DESKTOP_PROJECT_MAPPING_UNVERIFIED");
108
+ await check();
109
+ const assigned = await invokeNative(nativeEndpoint, "move_thread_to_project", { threadId, projectId: desktopId }, context, { ...nativeOptions, callId: attemptId });
110
+ nativeToolJson(assigned);
111
+ if (assigned.native_request_cleanup_required) receipt.native_request_cleanup_required = true;
112
+ const verify = async () => {
113
+ const thread = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
114
+ const roots = thread?.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
115
+ return thread?.id === threadId && thread.projectId === project.id && thread.cwd === scope.cwd
116
+ && Array.isArray(roots) && roots.length === scope.runtimeWorkspaceRoots.length && scope.runtimeWorkspaceRoots.every(root => roots.includes(root))
117
+ && thread.name === title
118
+ && await membership(invokeNative, nativeEndpoint, threadId, desktopId, context, nativeOptions) && initialInstructionMatches(await history(client, threadId), prompt);
119
+ };
120
+ let verified = await verify();
121
+ for (let retry = 0; retry < 20 && !verified; retry++) { await sleep(250); verified = await verify(); }
122
+ if (!verified) throw failure("Created task's assignment, roots, title or initial instruction could not be verified. Do not recreate it.", "CREATION_VERIFICATION_FAILED");
123
+ Object.assign(receipt, { verification_status: "verified", desktop_project_membership: "verified", app_server_project_membership: "verified", initial_instruction: "verified" });
124
+ try { await write("creations", attemptId, receipt, storage); } catch { receipt.receipt_update_required = true; }
125
+ return receipt;
126
+ } catch (error) {
127
+ const created = !!receipt?.thread_id, unknownCreation = creating && !created && mutationOutcomeUnknown(error);
128
+ const unknownDelivery = sending && !accepted && mutationOutcomeUnknown(error);
129
+ Object.assign(error, { attempt_id: attemptId, native_call_id: attemptId, thread_id: receipt?.thread_id, receipt_path: receipt?.receipt_path,
130
+ creation_status: created ? "created" : unknownCreation ? "unknown" : "not_created", sent: accepted ? true : unknownDelivery ? null : false,
131
+ delivery_status: accepted ? "accepted" : unknownDelivery ? "unknown" : "not_sent" });
132
+ if (receipt?.receipt_path) try { await write("creations", attemptId, { ...receipt, creation_status: error.creation_status, sent: error.sent, delivery_status: error.delivery_status, verification_status: "failed", error_code: error.code ?? "CREATION_FAILED" }, storage); } catch { error.receipt_update_required = true; }
133
+ throw error;
134
+ } finally { try { client?.close(); } catch { if (accepted && receipt) receipt.runtime_cleanup_required = true; } }
135
+ }
package/src/store.mjs CHANGED
@@ -2,12 +2,14 @@ import { lstat, mkdir, readFile, writeFile, rename, rm, readdir, link } from "no
2
2
  import { 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
 
package/src/supervise.mjs CHANGED
@@ -1,53 +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 { 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
- import { validateSupervisionPolicy } from "./prompt.mjs";
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"];
15
+ export { SUPERVISOR_AGENTS };
16
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
- }
23
-
24
- export async function prepareSupervisorPrompt(threadInput, policy, { agent = "claude", connection = "shared" } = {}) {
25
- const threadId = normalizeThreadId(threadInput);
17
+ export async function prepareSupervisorPrompt(threadInput, policy, { agent = "claude", connection = "shared", usePathCli = false } = {}) {
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 node = { path: await realpath(process.execPath), version: process.version };
30
- const home = codexHome();
31
- shellArgument(node.path); shellArgument(home);
32
- const deployment = await prepareDeployment(home);
33
- // Bind the validated canonical profile as well as the saved executable.
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}`;
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}`;
34
+ if (usePathCli) {
35
+ return { ...pathSupervisorPrompt(threadId, command, policy, supervisor, { usePathCli: true }), ...runtime, supervisor };
36
+ }
36
37
  // Render with the saved distribution's implementation as well as its CLI.
37
38
  // An update to the source/cache after placement cannot mix prompt and code.
38
- const { supervisorPrompt } = await import(pathToFileURL(path.join(deployment.directory, "src/prompt.mjs")).href);
39
- return { ...supervisorPrompt(threadId, command, policy, supervisor), deployment, node, supervisor };
39
+ const { supervisorPrompt } = await import(pathToFileURL(path.join(runtime.deployment.directory, "src/prompt.mjs")).href);
40
+ return { ...supervisorPrompt(threadId, command, policy, supervisor), ...runtime, supervisor };
40
41
  }
41
42
 
42
- function launchFailure(agent, error) {
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" });
44
- }
45
-
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); }
43
+ export async function superviseAgent(threadInput, { agent = "claude", agentArgs = [], policy, connection = "shared", usePathCli = false } = {}) {
44
+ const { prompt, thread_id: threadId, supervisor, deployment } = await prepareSupervisorPrompt(threadInput, policy, { agent, connection, usePathCli });
45
+ const storage = { home: deployment?.codex_home ?? await prepareCodexHome(codexHome()) };
46
+ await registerSupervisor(threadId, supervisor.session_id, { ...storage, owner: agent, launcherPid: process.pid, connection: supervisor.connection });
47
+ try { return await launchAgent(agent, agentArgs, prompt); }
51
48
  finally {
52
49
  try { await controlSupervisor(threadId, "stop", { ...storage, expectedSession: supervisor.session_id }); }
53
50
  catch (error) {
@@ -56,23 +53,3 @@ export async function superviseAgent(threadInput, { agent = "claude", agentArgs
56
53
  }
57
54
  }
58
55
  }
59
-
60
- async function launchSupervisorAgent(agent, agentArgs, prompt) {
61
- let child;
62
- try {
63
- // Keep forwarded argv intact. The agent's own terminator keeps its variadic
64
- // options from consuming the generated prompt as another option value.
65
- child = spawn(agent, [...agentArgs, "--", prompt], { shell: false, stdio: "inherit" });
66
- } catch (error) { throw launchFailure(agent, error); }
67
-
68
- const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
69
- const handlers = signals.map(signal => () => {
70
- if (child.exitCode === null && child.signalCode === null) child.kill(signal);
71
- });
72
- signals.forEach((signal, index) => process.on(signal, handlers[index]));
73
- try {
74
- const [code, signal] = await once(child, "exit");
75
- return code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1);
76
- } catch (error) { throw launchFailure(agent, error); }
77
- finally { signals.forEach((signal, index) => process.off(signal, handlers[index])); }
78
- }
@@ -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
+ }