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.
- package/CHANGELOG.md +36 -2
- package/README.md +127 -14
- 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 +15 -20
- 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 +20 -45
- package/package.json +11 -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 +201 -23
- 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 +12 -1
- package/src/creation-verification.mjs +30 -10
- 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 +2 -2
- package/src/evidence.mjs +3 -0
- package/src/findings.mjs +7 -7
- package/src/help.mjs +140 -28
- package/src/journal.mjs +37 -9
- package/src/launcher.mjs +16 -9
- package/src/monitor.mjs +18 -6
- package/src/native-command.mjs +10 -2
- package/src/native-rpc.mjs +15 -2
- 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 +27 -2
- package/src/prompt-command.mjs +25 -0
- package/src/prompt.mjs +25 -8
- 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/standalone-create.mjs +4 -3
- package/src/store.mjs +25 -13
- package/src/supervise.mjs +25 -53
- package/src/supervision.mjs +41 -19
- package/src/target.mjs +25 -0
- package/src/task-id.mjs +23 -0
- package/src/thread-create.mjs +4 -3
- 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/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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
52
|
-
|
|
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(
|
|
66
|
+
const old = await readRecord(bucket, key, { home });
|
|
60
67
|
if (old && !isAlive(old.pid)) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
}
|
|
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(
|
|
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
|
|
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
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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),
|
|
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
|
|
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
|
-
}
|
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
|
+
}
|
package/src/thread-create.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
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
|
+
}
|