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.
- package/CHANGELOG.md +37 -2
- package/README.md +154 -16
- package/assets/remote-device-key.swift +94 -0
- package/bin/codexteer-update.mjs +3 -0
- package/bin/codexteer.mjs +6 -2
- package/docs/cloud.md +113 -0
- package/docs/desktop-cloud.md +36 -0
- package/docs/distribution.md +21 -9
- package/docs/orchestration.md +120 -0
- package/docs/remote-cli-validation.json +139 -0
- package/docs/remote-control.md +65 -0
- package/docs/remote-device-enrollment.md +91 -0
- package/docs/remote-observation.md +57 -0
- package/docs/remote-operations.md +89 -0
- package/docs/thread-create.md +90 -0
- package/package.json +13 -2
- package/scripts/update-desktop.applescript +51 -0
- package/src/agent-launch.mjs +27 -0
- package/src/app-server.mjs +34 -10
- package/src/checkpoint.mjs +52 -16
- package/src/cli.mjs +233 -19
- package/src/cloud-api.mjs +118 -0
- package/src/cloud-cli.mjs +319 -0
- package/src/cloud-connection.mjs +64 -0
- package/src/cloud-id.mjs +16 -0
- package/src/cloud-observe.mjs +178 -0
- package/src/cloud-send.mjs +33 -0
- package/src/cloud.mjs +172 -0
- package/src/connection.mjs +23 -5
- package/src/creation-verification.mjs +36 -0
- package/src/desktop-cloud-api.mjs +119 -0
- package/src/desktop-cloud-id.mjs +18 -0
- package/src/desktop-cloud-project.mjs +148 -0
- package/src/desktop-cloud.mjs +98 -0
- package/src/desktop.mjs +3 -1
- package/src/directive.mjs +5 -2
- package/src/distribution.mjs +6 -6
- package/src/evidence.mjs +3 -0
- package/src/findings.mjs +7 -7
- package/src/help.mjs +155 -26
- package/src/journal.mjs +37 -9
- package/src/launcher.mjs +16 -9
- package/src/monitor.mjs +18 -6
- package/src/native-command.mjs +100 -0
- package/src/native-rpc.mjs +116 -0
- package/src/native-worker.mjs +25 -0
- package/src/observe.mjs +21 -7
- package/src/orchestrate-prompt.mjs +96 -0
- package/src/orchestrate.mjs +29 -0
- package/src/paged-read.mjs +7 -1
- package/src/projects.mjs +104 -0
- package/src/prompt-command.mjs +25 -0
- package/src/prompt.mjs +35 -11
- package/src/remote-client-info.mjs +16 -0
- package/src/remote-control-auth.mjs +109 -0
- package/src/remote-control-socket.mjs +137 -0
- package/src/remote-control.mjs +134 -0
- package/src/remote-device-key.mjs +41 -0
- package/src/remote-device-login.mjs +76 -0
- package/src/remote-device-protocol.mjs +72 -0
- package/src/remote-device-store.mjs +71 -0
- package/src/remote-device-worker.mjs +48 -0
- package/src/remote-device.mjs +202 -0
- package/src/remote-io.mjs +163 -0
- package/src/remote-key-access.mjs +76 -0
- package/src/remote-key-worker.mjs +38 -0
- package/src/remote-keychain.mjs +72 -0
- package/src/remote-management.mjs +234 -0
- package/src/remote.mjs +132 -0
- package/src/resource.mjs +8 -6
- package/src/runner.mjs +6 -1
- package/src/runtime.mjs +4 -2
- package/src/socket-endpoint.mjs +40 -0
- package/src/standalone-create.mjs +135 -0
- package/src/store.mjs +6 -3
- package/src/supervise.mjs +32 -55
- package/src/supervision.mjs +41 -19
- package/src/target.mjs +25 -0
- package/src/task-id.mjs +23 -0
- package/src/thread-create.mjs +128 -0
- package/src/thread-id.mjs +21 -6
- package/src/thread-verify.mjs +108 -0
- package/src/turn-repair.mjs +228 -0
- package/src/update.mjs +158 -0
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
15
|
+
export { SUPERVISOR_AGENTS };
|
|
16
16
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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),
|
|
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
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
}
|
package/src/supervision.mjs
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
import { isDesktopCloudTask } from "./desktop-cloud-id.mjs";
|
|
1
2
|
import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
114
|
-
if (
|
|
115
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/task-id.mjs
ADDED
|
@@ -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
|
+
}
|