pwragent 1.3.12 → 1.3.14
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/dist/THIRD_PARTY_LICENSES +113 -0
- package/dist/federation-core.js +13021 -270
- package/package.json +3 -3
- package/src/codex.js +85 -86
- package/src/daemon.js +1 -1
- package/src/federation-backend.js +38 -41
- package/src/federation-core.ts +3 -0
- package/src/handoffs.js +5 -8
- package/src/owner-thread-registry.js +68 -0
- package/src/runtime.js +29 -37
- package/src/store.js +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pwragent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Experimental headless PwrAgent daemon and command-line client.",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"node-pty"
|
|
65
65
|
],
|
|
66
66
|
"pwragentRelease": {
|
|
67
|
-
"tag": "v1.3.
|
|
68
|
-
"commit": "
|
|
67
|
+
"tag": "v1.3.14",
|
|
68
|
+
"commit": "83e5f182822aa59fb38cf9f86fad806e3027a007"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/codex.js
CHANGED
|
@@ -1,112 +1,111 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { CodexAppServerClient } from "../dist/federation-core.js";
|
|
2
3
|
import { readMessages, MAX_RESPONSE_BYTES } from "./wire.js";
|
|
3
4
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
constructor({ command, home, workspace,
|
|
8
|
-
this
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
// The Outpost owns launch settings and process-group shutdown. RPC state and
|
|
6
|
+
// Codex normalization belong to the shared owner client.
|
|
7
|
+
class OutpostCodexTransport {
|
|
8
|
+
constructor({ command, home, workspace, onFailure, onUnexpectedExit }) {
|
|
9
|
+
Object.assign(this, { command, home, workspace, onFailure, onUnexpectedExit });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
setMessageHandler(handler) { this.messageHandler = handler; }
|
|
13
|
+
setCloseHandler(handler) { this.closeHandler = handler; }
|
|
14
|
+
|
|
15
|
+
async connect() {
|
|
16
|
+
if (this.child) return;
|
|
17
|
+
this.stopping = false;
|
|
18
|
+
const env = { ...process.env, CODEX_HOME: this.home };
|
|
13
19
|
for (const key of Object.keys(env)) {
|
|
14
20
|
if (key.toUpperCase() === "ELECTRON_RENDERER_URL") delete env[key];
|
|
15
21
|
}
|
|
16
|
-
this.child = spawn(command, ["app-server", "--listen", "stdio://"], {
|
|
17
|
-
cwd: workspace,
|
|
18
|
-
env,
|
|
19
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
-
// A dedicated group lets shutdown reap ordinary descendants on POSIX.
|
|
21
|
-
detached: true,
|
|
22
|
+
this.child = spawn(this.command, ["app-server", "--listen", "stdio://"], {
|
|
23
|
+
cwd: this.workspace, env, stdio: ["pipe", "pipe", "pipe"], detached: true,
|
|
22
24
|
});
|
|
23
25
|
this.closed = new Promise((resolve) => this.child.once("close", resolve));
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const fail = (error) => {
|
|
27
|
+
this.closeHandler(error);
|
|
28
|
+
if (!this.stopping) this.onFailure(error);
|
|
29
|
+
};
|
|
30
|
+
this.child.on("error", () => fail(new Error("Could not start Codex App Server")));
|
|
31
|
+
this.child.on("exit", (code, signal) => {
|
|
32
|
+
const unexpected = !this.stopping;
|
|
33
|
+
// Record the host's uncertain outcome before the shared client emits
|
|
34
|
+
// synthetic turn failures for a lost process.
|
|
35
|
+
fail(new Error("Codex App Server exited"));
|
|
36
|
+
if (unexpected) this.onUnexpectedExit({ code, signal, stderrPreview: [] });
|
|
37
|
+
});
|
|
38
|
+
this.child.stdin.on("error", () => fail(new Error("Codex App Server input closed")));
|
|
39
|
+
// Provider diagnostics can contain private text; drain without journaling.
|
|
29
40
|
this.child.stderr.resume();
|
|
30
|
-
readMessages(this.child.stdout, MAX_RESPONSE_BYTES,
|
|
31
|
-
|
|
32
|
-
if (message.id !== undefined)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!("error" in message) && !("result" in message)) {
|
|
39
|
-
this.fail(new Error("Invalid Codex response"));
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
this.pending.delete(message.id);
|
|
43
|
-
clearTimeout(request.timer);
|
|
44
|
-
if (message.error) request.reject(new Error(message.error.message || "Codex request failed"));
|
|
45
|
-
else if ("result" in message) request.resolve(message.result);
|
|
46
|
-
}, (error) => this.fail(error));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
fail(error) {
|
|
50
|
-
if (this.failure) return;
|
|
51
|
-
this.failure = error;
|
|
52
|
-
for (const request of this.pending.values()) {
|
|
53
|
-
clearTimeout(request.timer);
|
|
54
|
-
request.reject(error);
|
|
55
|
-
}
|
|
56
|
-
this.pending.clear();
|
|
57
|
-
if (!this.stopping) this.onFailure(error);
|
|
41
|
+
readMessages(this.child.stdout, MAX_RESPONSE_BYTES,
|
|
42
|
+
(message) => {
|
|
43
|
+
if (message.id !== undefined && typeof message.method !== "string" && !("result" in message) && !("error" in message)) {
|
|
44
|
+
fail(new Error("Invalid Codex response"));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.messageHandler(JSON.stringify(message));
|
|
48
|
+
}, fail);
|
|
58
49
|
}
|
|
59
50
|
|
|
60
51
|
send(message) {
|
|
61
|
-
if (this.
|
|
62
|
-
this.child.stdin.write(
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
request(method, params) {
|
|
66
|
-
if (this.failure || this.stopping) return Promise.reject(this.failure || new Error("Codex is stopping"));
|
|
67
|
-
if (this.pending.size >= 32) return Promise.reject(new Error("Too many pending Codex requests"));
|
|
68
|
-
const id = ++this.sequence;
|
|
69
|
-
return new Promise((resolve, reject) => {
|
|
70
|
-
const timer = setTimeout(() => {
|
|
71
|
-
// A timeout may mean a mutation executed but its reply was lost.
|
|
72
|
-
// Stop the runtime; never retry an uncertain turn/start.
|
|
73
|
-
this.fail(new Error(`Codex request timed out: ${method}`));
|
|
74
|
-
}, this.timeout);
|
|
75
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
76
|
-
try {
|
|
77
|
-
this.send({ id, method, params });
|
|
78
|
-
} catch (error) {
|
|
79
|
-
this.fail(error);
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async initialize() {
|
|
85
|
-
await this.request("initialize", {
|
|
86
|
-
clientInfo: { name: "pwragent-headless", title: "PwrAgent Headless", version: "0.0.0" },
|
|
87
|
-
capabilities: { experimentalApi: true },
|
|
88
|
-
});
|
|
89
|
-
this.send({ method: "initialized", params: {} });
|
|
52
|
+
if (this.stopping || !this.child) throw new Error("Codex is stopping");
|
|
53
|
+
this.child.stdin.write(message + "\n");
|
|
90
54
|
}
|
|
91
55
|
|
|
92
|
-
async
|
|
56
|
+
async close() {
|
|
57
|
+
if (!this.child) return;
|
|
93
58
|
this.stopping = true;
|
|
94
|
-
this.fail(new Error("Codex stopped"));
|
|
95
59
|
const signal = (name) => {
|
|
96
60
|
if (!this.child.pid) return;
|
|
97
|
-
try {
|
|
98
|
-
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (error.code !== "ESRCH") throw error;
|
|
101
|
-
}
|
|
61
|
+
try { process.kill(-this.child.pid, name); }
|
|
62
|
+
catch (error) { if (error.code !== "ESRCH") throw error; }
|
|
102
63
|
};
|
|
103
64
|
signal("SIGTERM");
|
|
104
65
|
let timer;
|
|
105
66
|
await Promise.race([this.closed, new Promise((resolve) => { timer = setTimeout(resolve, 2000); })]);
|
|
106
67
|
clearTimeout(timer);
|
|
107
|
-
// Also reap descendants that outlived the group leader. systemd's cgroup
|
|
108
|
-
// owns processes that deliberately leave this group.
|
|
109
68
|
signal("SIGKILL");
|
|
110
69
|
await this.closed;
|
|
70
|
+
this.child = null;
|
|
111
71
|
}
|
|
112
72
|
}
|
|
73
|
+
|
|
74
|
+
const quietLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
75
|
+
|
|
76
|
+
export class Codex extends CodexAppServerClient {
|
|
77
|
+
constructor({ command, home, workspace, onNotification, onRequest, onFailure, timeout = 30000 }) {
|
|
78
|
+
const transport = new OutpostCodexTransport({ command, home, workspace, onFailure,
|
|
79
|
+
onUnexpectedExit: () => {} });
|
|
80
|
+
super({ env: { ...process.env, CODEX_HOME: home }, requestTimeoutMs: timeout }, {
|
|
81
|
+
createTransport: (callbacks) => { transport.onUnexpectedExit = callbacks.onUnexpectedExit; return transport; },
|
|
82
|
+
clientInfo: { name: "pwragent-headless", title: "PwrAgent Headless" },
|
|
83
|
+
onRequestFailure: (error) => {
|
|
84
|
+
// A lost mutation response leaves an uncertain outcome; stop this host.
|
|
85
|
+
if (error instanceof Error && error.message.startsWith("json-rpc timeout:")) onFailure(error);
|
|
86
|
+
},
|
|
87
|
+
logger: quietLogger,
|
|
88
|
+
rpcLogger: quietLogger,
|
|
89
|
+
listingDiagnostics: { trace: (_stage, _fields, work) => work(), link() {} },
|
|
90
|
+
authState: { isBlocked: () => false, assertAvailable() {} },
|
|
91
|
+
signInRequiredMessage: "Codex is logged out. Please sign in again to resume operations.",
|
|
92
|
+
buildShellEnvironment: (env) => env,
|
|
93
|
+
buildSandboxPolicy: (sandbox, cwd = workspace) => {
|
|
94
|
+
if (sandbox === undefined) return undefined;
|
|
95
|
+
if (sandbox === "read-only") return { type: "readOnly", networkAccess: false };
|
|
96
|
+
if (sandbox !== "workspace-write") throw new Error("Unsupported Outpost sandbox policy");
|
|
97
|
+
return { type: "workspaceWrite", writableRoots: [cwd], networkAccess: false,
|
|
98
|
+
excludeTmpdirEnvVar: true, excludeSlashTmp: true };
|
|
99
|
+
},
|
|
100
|
+
enrichThreadDirectory: async () => ({ linkedDirectories: [] }),
|
|
101
|
+
sampleAppServerProcessTree: async () => ({}),
|
|
102
|
+
});
|
|
103
|
+
this.hostTransport = transport;
|
|
104
|
+
this.onNotification(onNotification);
|
|
105
|
+
this.onRequest(onRequest);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get child() { return this.hostTransport.child; }
|
|
109
|
+
initialize() { return this.getInitializeResult(); }
|
|
110
|
+
stop() { return this.close(); }
|
|
111
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -68,10 +68,10 @@ export async function serve({ directory, workspace, home, command, profile }) {
|
|
|
68
68
|
const store = new JobStore(directory, { version: 1, workspace, codexHome: home });
|
|
69
69
|
runtime = new Runtime({ store, workspace, home, command,
|
|
70
70
|
onFailure: (error) => { console.error(`Headless runtime stopped: ${error.message}`); void stop(error); } });
|
|
71
|
-
writeFileSync(lockPath, JSON.stringify({ pid: process.pid, codexPid: runtime.codex.child.pid }) + "\n");
|
|
72
71
|
process.on("SIGTERM", signalStop);
|
|
73
72
|
process.on("SIGINT", signalStop);
|
|
74
73
|
await runtime.codex.initialize();
|
|
74
|
+
writeFileSync(lockPath, JSON.stringify({ pid: process.pid, codexPid: runtime.codex.child.pid }) + "\n");
|
|
75
75
|
federation = new HeadlessFederation({ directory, runtime, profile, onFailure: (error) => { void stop(error); } });
|
|
76
76
|
await federation.start();
|
|
77
77
|
if (shutdown) return done;
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { buildThreadIdentityKey, NavigationQueryStore, FEDERATION_BACKEND_METHOD_CAPABILITIES, FEDERATION_MAX_FRAME_BYTES, fitThreadReplayWithinByteBudget, mergeCausalMessagingContext, pageThreadReplay, shortenDerivedThreadTitle } from "../dist/federation-core.js";
|
|
4
4
|
import { ACTIVE, atomicJson, digest } from "./store.js";
|
|
5
|
+
import { OutpostThreadRegistry } from "./owner-thread-registry.js";
|
|
5
6
|
|
|
6
7
|
// The owner methods this outpost serves. Each one's capability comes from the
|
|
7
8
|
// shared owner contract, so a grant means the same here as on desktop.
|
|
@@ -73,6 +74,7 @@ export class HeadlessBackend {
|
|
|
73
74
|
runtime.listOwnedThreads = () => [...this.threads.values()].filter((thread) => !thread.archived);
|
|
74
75
|
this.latestJobs = new Map();
|
|
75
76
|
this.syncJobs();
|
|
77
|
+
this.threadRegistry = new OutpostThreadRegistry(this);
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
persist() {
|
|
@@ -140,6 +142,13 @@ export class HeadlessBackend {
|
|
|
140
142
|
}
|
|
141
143
|
const id = message.params?.threadId || message.params?.thread?.id;
|
|
142
144
|
if (!this.threads.has(id)) return;
|
|
145
|
+
if (message.method === "thread/name/updated") {
|
|
146
|
+
const thread = this.threads.get(id);
|
|
147
|
+
const source = message.params.titleSource || "explicit";
|
|
148
|
+
if (thread.titleSource === "explicit" && source !== "explicit") return;
|
|
149
|
+
this.setTitle(thread, message.params.threadName, source);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
143
152
|
// The desktop adapter represents failed completions as turn/failed and
|
|
144
153
|
// supplies turnId alongside the provider's nested turn object.
|
|
145
154
|
const turnId = message.params?.turnId || message.params?.turn?.id;
|
|
@@ -188,7 +197,7 @@ export class HeadlessBackend {
|
|
|
188
197
|
&& (!params.afterKey || project.path > params.afterKey)).map((project) => ({ key: project.path, kind: "directory", path: project.path, label: project.label, threadKeys: [] })) };
|
|
189
198
|
case "readTurnAssistantReply": {
|
|
190
199
|
const history = await this.readThread(params.threadId);
|
|
191
|
-
const messages = history.replay.
|
|
200
|
+
const messages = history.replay.entries.filter((entry) => entry.type === "message" && entry.role === "assistant" && entry.turn?.id === params.turnId);
|
|
192
201
|
return { message: messages.find((message) => params.itemId ? message.id === params.itemId : message.text === params.text) || null };
|
|
193
202
|
}
|
|
194
203
|
case "readThread": {
|
|
@@ -227,7 +236,7 @@ export class HeadlessBackend {
|
|
|
227
236
|
const original = job.messageOrigin;
|
|
228
237
|
this.runtime.save({ ...job, messageOrigin: { ...incoming, ...mergeCausalMessagingContext([original, incoming]) } });
|
|
229
238
|
}
|
|
230
|
-
const result = await this.runtime.codex.
|
|
239
|
+
const result = await this.runtime.codex.steerTurn({ threadId: params.threadId, expectedTurnId: params.expectedTurnId || job.turnId, input });
|
|
231
240
|
return { backend: "codex", threadId: params.threadId, turnId: result.turnId || job.turnId, disposition: "steered",
|
|
232
241
|
...(name === "controlActiveTurn" ? { ok: true, requestId: params.requestId } : {}) };
|
|
233
242
|
}
|
|
@@ -281,8 +290,7 @@ export class HeadlessBackend {
|
|
|
281
290
|
this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId, ...(sourceMethod ? { sourceMethod } : {}) } } }, "navigation");
|
|
282
291
|
}
|
|
283
292
|
|
|
284
|
-
// threads.json
|
|
285
|
-
// Codex thread name.
|
|
293
|
+
// threads.json retains owner title provenance, including explicit renames.
|
|
286
294
|
setTitle(thread, title, titleSource) {
|
|
287
295
|
Object.assign(thread, { title, titleSource });
|
|
288
296
|
this.persist();
|
|
@@ -340,10 +348,10 @@ export class HeadlessBackend {
|
|
|
340
348
|
const cwd = this.runtime.projects.resolve(params.cwd || params.directoryPath || params.projectKey);
|
|
341
349
|
this.creating = true;
|
|
342
350
|
try {
|
|
343
|
-
const result = await this.runtime.codex.
|
|
344
|
-
if (!result?.
|
|
345
|
-
this.runtime.loadedThreads.add(result.
|
|
346
|
-
const thread = { id: result.
|
|
351
|
+
const result = await this.runtime.codex.startThread({ cwd, dynamicTools: this.runtime.messagingTools.buildDynamicToolSpecs(), approvalPolicy: "never", sandbox, ...(params.model ? { model: params.model } : {}), ephemeral: false });
|
|
352
|
+
if (!result?.threadId || typeof result.threadId !== "string" || result.threadId.length > 256) throw new Error("Invalid thread/start response");
|
|
353
|
+
this.runtime.loadedThreads.add(result.threadId);
|
|
354
|
+
const thread = { id: result.threadId, messagingFileTools: true, title: PLACEHOLDER_THREAD_TITLE, titleSource: "fallback", createdAt: Date.now(), cwd, sandbox,
|
|
347
355
|
model: params.model, reasoningEffort: params.reasoningEffort, serviceTier: params.serviceTier };
|
|
348
356
|
this.threads.set(thread.id, thread);
|
|
349
357
|
this.persist();
|
|
@@ -369,10 +377,12 @@ export class HeadlessBackend {
|
|
|
369
377
|
}
|
|
370
378
|
|
|
371
379
|
async listBackends() {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
380
|
+
const models = (await this.runtime.codex.listModels()).map((model) => ({
|
|
381
|
+
id: model.id, label: model.label, current: model.current,
|
|
382
|
+
reasoningEfforts: model.reasoningEfforts, defaultReasoningEffort: model.defaultReasoningEffort,
|
|
383
|
+
supportsReasoning: model.supportsReasoning, supportsSteering: true,
|
|
384
|
+
supportsImage: false, supportsFast: false,
|
|
385
|
+
}));
|
|
376
386
|
return { fetchedAt: Date.now(), backends: [{ kind: "codex", label: "Codex (headless outpost)", available: true,
|
|
377
387
|
methods: Object.keys(METHOD_CAPABILITIES), capabilities: { listThreads: true, createThread: true, resumeThread: true,
|
|
378
388
|
renameThread: true, readThread: true, startTurn: true, interruptTurn: true, steerTurn: true,
|
|
@@ -384,36 +394,23 @@ export class HeadlessBackend {
|
|
|
384
394
|
|
|
385
395
|
async readThread(id) {
|
|
386
396
|
this.owned(id);
|
|
387
|
-
const
|
|
388
|
-
const entries =
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const details = item.type === "commandExecution" ? [{ id: itemId, kind: "command", label: item.command,
|
|
402
|
-
command: { displayCommand: item.command, cwd: item.cwd, output: item.aggregatedOutput || "", exitCode: item.exitCode, durationMs: item.durationMs } }]
|
|
403
|
-
: [{ id: itemId, kind: item.type === "fileChange" ? "write" : "read", label: item.type, markdown: JSON.stringify(item, null, 2) }];
|
|
404
|
-
entries.push({ type: "activity", id: itemId, summary: item.command || item.type,
|
|
405
|
-
status: item.status === "inProgress" ? "in_progress" : item.status === "failed" ? "failed" : "completed", details, turn: metadata });
|
|
406
|
-
}
|
|
407
|
-
}
|
|
397
|
+
const replay = await this.runtime.codex.readThread({ threadId: id });
|
|
398
|
+
const entries = replay.entries.map((entry) => {
|
|
399
|
+
if (entry.type !== "message" || entry.role !== "user") return entry;
|
|
400
|
+
const job = [...this.runtime.store.jobs.values()].find((job) => job.threadId === id && job.turnId === entry.turn?.id);
|
|
401
|
+
return job?.messageOrigin ? { ...entry, origin: job.messageOrigin } : entry;
|
|
402
|
+
});
|
|
403
|
+
const origins = new Map(entries.filter((entry) => entry.type === "message" && entry.origin).map((entry) => [entry.id, entry.origin]));
|
|
404
|
+
const messages = replay.messages.map((message) => origins.has(message.id) ? { ...message, origin: origins.get(message.id) } : message);
|
|
405
|
+
const summary = await this.runtime.codex.readThreadSummary(id);
|
|
406
|
+
const thread = this.owned(id);
|
|
407
|
+
if (thread.titleSource === "fallback" && summary.titleSource !== "fallback") {
|
|
408
|
+
this.setTitle(thread, summary.title, summary.titleSource);
|
|
409
|
+
} else if (thread.titleSource === "fallback") {
|
|
410
|
+
this.deriveTitle(id, replay.messages.find((message) => message.role === "user")?.text);
|
|
408
411
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
this.deriveTitle(id, typeof result.thread?.preview === "string" && result.thread.preview.trim()
|
|
412
|
-
? result.thread.preview : messages.find((item) => item.role === "user")?.text);
|
|
413
|
-
return { backend: "codex", threadId: id, fetchedAt: Date.now(), threadStatus: this.summary(this.owned(id)).threadStatus,
|
|
414
|
-
replay: { entries, messages, lastUserMessage: messages.filter((item) => item.role === "user").at(-1)?.text,
|
|
415
|
-
lastAssistantMessage: messages.filter((item) => item.role === "assistant").at(-1)?.text,
|
|
416
|
-
pagination: { supportsPagination: false, hasPreviousPage: false } } };
|
|
412
|
+
return { backend: "codex", threadId: id, fetchedAt: Date.now(), threadStatus: this.summary(thread).threadStatus,
|
|
413
|
+
replay: { ...replay, entries, messages } };
|
|
417
414
|
}
|
|
418
415
|
|
|
419
416
|
navigationIndex() {
|
package/src/federation-core.ts
CHANGED
|
@@ -26,3 +26,6 @@ export { FEDERATION_BACKEND_METHOD_CAPABILITIES } from "@pwragent/federation/fed
|
|
|
26
26
|
|
|
27
27
|
export { NavigationQueryStore } from "@pwragent/owner-core/navigation-query-store";
|
|
28
28
|
export { buildThreadIdentityKey, NAVIGATION_QUERY_MAX_RESULT_BYTES } from "@pwragent/shared";
|
|
29
|
+
|
|
30
|
+
export { CodexAppServerClient } from "@pwragent/owner-core/codex-app-server/client";
|
|
31
|
+
export { patchOwnerThreadMetadata } from "@pwragent/owner-core/thread-storage";
|
package/src/handoffs.js
CHANGED
|
@@ -72,11 +72,8 @@ export function setupHandoffs(federation, directory) {
|
|
|
72
72
|
exportThreadForHandoff: async (id) => {
|
|
73
73
|
const owned = backend.owned(id);
|
|
74
74
|
if (runtime.active) throw new Error("Wait for the source turn to finish");
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const file = await runtime.codex.request("fs/readFile", { path: result.thread.path });
|
|
78
|
-
if (typeof file.dataBase64 !== "string") throw new Error("Codex protocol thread export is unavailable");
|
|
79
|
-
return { rolloutBase64: file.dataBase64, replay: (await backend.readThread(id)).replay,
|
|
75
|
+
const exported = await runtime.codex.exportThreadForHandoff(id);
|
|
76
|
+
return { ...exported, replay: (await backend.readThread(id)).replay,
|
|
80
77
|
cwd: runtime.projects.resolve(owned.cwd || runtime.workspace),
|
|
81
78
|
// A placeholder would become the destination's explicit name.
|
|
82
79
|
...(owned.titleSource === "fallback" ? {} : { title: owned.title }) };
|
|
@@ -87,9 +84,9 @@ export function setupHandoffs(federation, directory) {
|
|
|
87
84
|
try {
|
|
88
85
|
const cwd = runtime.projects.contained(await realpath(request.directoryPath));
|
|
89
86
|
if (backend.threads.size >= 10000) throw new Error("Thread retention limit reached");
|
|
90
|
-
const result = await runtime.codex.
|
|
87
|
+
const result = await runtime.codex.forkThread({ threadId: request.sourceThreadId, path: request.sourceThreadPath,
|
|
91
88
|
cwd, approvalPolicy: "never", sandbox: "read-only" });
|
|
92
|
-
const id = result.
|
|
89
|
+
const id = result.threadId;
|
|
93
90
|
if (typeof id !== "string" || !id || backend.threads.has(id)) throw new Error("Invalid forked thread identity");
|
|
94
91
|
backend.threads.set(id, { id, cwd, title: "Imported thread", titleSource: "fallback", createdAt: Date.now(), sandbox: "read-only" });
|
|
95
92
|
runtime.loadedThreads.add(id);
|
|
@@ -101,7 +98,7 @@ export function setupHandoffs(federation, directory) {
|
|
|
101
98
|
readThread: async ({ threadId }) => ({ replay: (await backend.readThread(threadId)).replay }),
|
|
102
99
|
archiveThread: async ({ threadId }) => {
|
|
103
100
|
backend.owned(threadId);
|
|
104
|
-
await runtime.codex.
|
|
101
|
+
await runtime.codex.archiveThread({ threadId });
|
|
105
102
|
backend.owned(threadId).archived = true;
|
|
106
103
|
backend.persist();
|
|
107
104
|
backend.invalidate(threadId);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { patchOwnerThreadMetadata } from "../dist/federation-core.js";
|
|
2
|
+
|
|
3
|
+
const FIELDS = [
|
|
4
|
+
["archived", "archived"],
|
|
5
|
+
["model", "model"], ["reasoningEffort", "reasoningEffort"], ["serviceTier", "serviceTier"], ["fastMode", "fastMode"],
|
|
6
|
+
["executionModeUpdatedAt", "executionModeUpdatedAt"], ["reasoningEffortsByModel", "reasoningEffortsByModel"],
|
|
7
|
+
["modelMigrationRevision", "modelMigrationRevision"], ["modelSettingsManuallyUpdatedAt", "modelSettingsManuallyUpdatedAt"],
|
|
8
|
+
["createdAt", "createdAt"], ["updatedAt", "updatedAt"], ["cwd", "cwd"],
|
|
9
|
+
["pinnedRank", "pinnedRank"], ["seenAt", "seenAt"], ["seenUpdatedAt", "seenUpdatedAt"],
|
|
10
|
+
["archivedAt", "archivedAt"], ["restoredAt", "restoredAt"],
|
|
11
|
+
["archiveRetentionStartedAt", "archiveRetentionStartedAt"],
|
|
12
|
+
["subthreadOrder", "subthreadOrder"], ["subthreadsCollapsed", "subthreadsCollapsed"],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** Storage view over the backend's live threads.json map; no lifecycle calls. */
|
|
16
|
+
export class OutpostThreadRegistry {
|
|
17
|
+
constructor(backend) {
|
|
18
|
+
this.backend = backend;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
identity(identity) {
|
|
22
|
+
if (identity.backend !== "codex") throw new Error("This outpost stores Codex threads only");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
record(thread) {
|
|
26
|
+
const patch = {
|
|
27
|
+
executionMode: thread.sandbox === "workspace-write" ? "auto" : thread.sandbox === "danger-full-access" ? "full-access" : "default",
|
|
28
|
+
title: thread.title === undefined ? undefined : { text: thread.title, source: thread.titleSource ?? "fallback" },
|
|
29
|
+
parent: thread.parentThreadId !== undefined ? { backend: thread.parentThreadBackend ?? "codex", threadId: thread.parentThreadId,
|
|
30
|
+
...(thread.parentThreadInstanceId !== undefined ? { instanceId: thread.parentThreadInstanceId } : {}) } : undefined,
|
|
31
|
+
};
|
|
32
|
+
for (const [field, stored] of FIELDS) patch[field] = thread[stored];
|
|
33
|
+
return patchOwnerThreadMetadata(undefined, { backend: "codex", threadId: thread.id }, patch);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getThreadMetadata(identity) {
|
|
37
|
+
this.identity(identity);
|
|
38
|
+
const thread = this.backend.threads.get(identity.threadId);
|
|
39
|
+
return thread ? this.record(thread) : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async listThreadMetadata() {
|
|
43
|
+
return [...this.backend.threads.values()].map((thread) => this.record(thread));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async updateThreadMetadata(identity, patch) {
|
|
47
|
+
this.identity(identity);
|
|
48
|
+
const thread = this.backend.threads.get(identity.threadId);
|
|
49
|
+
const current = thread ? this.record(thread) : undefined;
|
|
50
|
+
const next = patchOwnerThreadMetadata(current, identity, patch);
|
|
51
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(next)) {
|
|
52
|
+
const stored = { ...thread, id: identity.threadId, title: next.title?.text, titleSource: next.title?.source,
|
|
53
|
+
sandbox: next.executionMode === "auto" ? "workspace-write" : next.executionMode === "full-access" ? "danger-full-access" : "read-only",
|
|
54
|
+
parentThreadId: next.parent?.threadId, parentThreadBackend: next.parent?.backend,
|
|
55
|
+
parentThreadInstanceId: next.parent?.instanceId };
|
|
56
|
+
for (const [field, key] of FIELDS) stored[key] = structuredClone(next[field]);
|
|
57
|
+
// Keep the live registry unchanged if the durable write fails.
|
|
58
|
+
this.backend.threads.set(identity.threadId, stored);
|
|
59
|
+
try { this.backend.persist(); }
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (thread) this.backend.threads.set(identity.threadId, thread);
|
|
62
|
+
else this.backend.threads.delete(identity.threadId);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return next;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -27,7 +27,7 @@ export class Runtime {
|
|
|
27
27
|
command, home, workspace,
|
|
28
28
|
onNotification: (message) => { this.notification(message); this.onEvent?.(message); },
|
|
29
29
|
onRequest: (message) => this.serverRequest(message),
|
|
30
|
-
onFailure,
|
|
30
|
+
onFailure: (error) => { this.codexFailure = error; onFailure(error); },
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -57,7 +57,7 @@ export class Runtime {
|
|
|
57
57
|
case "read": {
|
|
58
58
|
const job = this.store.get(params.id);
|
|
59
59
|
if (!job.threadId) throw new Error("This job has no confirmed thread ID");
|
|
60
|
-
return this.codex.
|
|
60
|
+
return this.codex.readThreadProtocolResult({ threadId: job.threadId, includeTurns: true });
|
|
61
61
|
}
|
|
62
62
|
case "interrupt": {
|
|
63
63
|
const job = this.store.get(params.id);
|
|
@@ -66,7 +66,7 @@ export class Runtime {
|
|
|
66
66
|
if (job.status === "interrupting") return job;
|
|
67
67
|
this.save({ ...job, status: "interrupting" });
|
|
68
68
|
try {
|
|
69
|
-
await this.codex.
|
|
69
|
+
await this.codex.interruptTurn({ threadId: job.threadId, turnId: job.turnId });
|
|
70
70
|
} catch (error) {
|
|
71
71
|
const current = this.store.get(job.id);
|
|
72
72
|
if (current.status === "interrupting" && current.turnId === job.turnId) {
|
|
@@ -115,7 +115,7 @@ export class Runtime {
|
|
|
115
115
|
this.work = this.start(job, prompt).catch((error) => {
|
|
116
116
|
const current = this.store.get(id);
|
|
117
117
|
if (ACTIVE.has(current.status)) {
|
|
118
|
-
this.finish(id, this.
|
|
118
|
+
this.finish(id, this.codexFailure || this.stopping ? "unknown" : "failed", error.message);
|
|
119
119
|
}
|
|
120
120
|
});
|
|
121
121
|
// The durable acceptance precedes inference. Socket loss cannot cancel it.
|
|
@@ -126,41 +126,29 @@ export class Runtime {
|
|
|
126
126
|
const cwd = this.projects.resolve(job.cwd || this.workspace);
|
|
127
127
|
const params = { cwd, approvalPolicy: "never", sandbox: job.sandbox,
|
|
128
128
|
...(job.model ? { model: job.model } : {}) };
|
|
129
|
-
const result = job.threadId
|
|
130
|
-
: await this.codex.
|
|
131
|
-
|
|
132
|
-
...(job.threadId ? { threadId: job.threadId } : { ephemeral: false, dynamicTools: this.messagingTools.buildDynamicToolSpecs() }),
|
|
133
|
-
});
|
|
134
|
-
if (typeof result?.thread?.id !== "string" || !result.thread.id || result.thread.id.length > 256
|
|
135
|
-
|| (job.threadId && result.thread.id !== job.threadId)) {
|
|
129
|
+
const result = job.threadId ? { threadId: job.threadId }
|
|
130
|
+
: await this.codex.startThread({ ...params, ephemeral: false, dynamicTools: this.messagingTools.buildDynamicToolSpecs() });
|
|
131
|
+
if (typeof result.threadId !== "string" || !result.threadId || result.threadId.length > 256) {
|
|
136
132
|
throw new Error("Codex returned an invalid thread ID");
|
|
137
133
|
}
|
|
138
|
-
this.loadedThreads.add(result.
|
|
139
|
-
this.save({ ...this.store.get(job.id), threadId: result.
|
|
134
|
+
this.loadedThreads.add(result.threadId);
|
|
135
|
+
this.save({ ...this.store.get(job.id), threadId: result.threadId,
|
|
140
136
|
...(!job.threadId ? { messagingFileTools: true } : {}) });
|
|
141
|
-
const turn = await this.codex.
|
|
142
|
-
|
|
143
|
-
input: [{ type: "text", text: prompt
|
|
144
|
-
|
|
145
|
-
approvalPolicy: "never",
|
|
146
|
-
sandboxPolicy: job.sandbox === "workspace-write"
|
|
147
|
-
? { type: "workspaceWrite", writableRoots: [cwd], networkAccess: false, excludeTmpdirEnvVar: true, excludeSlashTmp: true }
|
|
148
|
-
: { type: "readOnly", networkAccess: false },
|
|
149
|
-
...(job.model ? { model: job.model } : {}),
|
|
150
|
-
...(job.reasoningEffort ? { effort: job.reasoningEffort } : {}),
|
|
137
|
+
const turn = await this.codex.startTurn({ ...params, threadId: result.threadId,
|
|
138
|
+
onTerminalResult: (params) => this.notification({ method: "turn/completed", params }),
|
|
139
|
+
input: [{ type: "text", text: prompt }],
|
|
140
|
+
...(job.reasoningEffort ? { reasoningEffort: job.reasoningEffort } : {}),
|
|
151
141
|
...(job.serviceTier ? { serviceTier: job.serviceTier } : {}),
|
|
152
142
|
});
|
|
153
|
-
if (typeof turn
|
|
143
|
+
if (typeof turn.turnId !== "string" || !turn.turnId || turn.turnId.length > 256 || turn.turnId.startsWith("pending:")) {
|
|
154
144
|
this.onFailure(new Error("Codex returned an invalid turn ID; execution outcome is unknown"));
|
|
155
145
|
return;
|
|
156
146
|
}
|
|
157
|
-
this.save({ ...this.store.get(job.id), turnId: turn.
|
|
147
|
+
this.save({ ...this.store.get(job.id), turnId: turn.turnId, status: "running" });
|
|
158
148
|
// The prompt stays in memory: job records never store it.
|
|
159
|
-
this.onEvent?.({ method: "headless/turnStarted", params: { threadId: result.
|
|
160
|
-
if (this.earlyCompletion?.turn?.id === turn.
|
|
149
|
+
this.onEvent?.({ method: "headless/turnStarted", params: { threadId: result.threadId, jobId: job.id, prompt } });
|
|
150
|
+
if (this.earlyCompletion?.turn?.id === turn.turnId) {
|
|
161
151
|
this.complete(this.earlyCompletion);
|
|
162
|
-
} else if (["completed", "failed", "interrupted"].includes(turn.turn.status)) {
|
|
163
|
-
this.complete({ threadId: result.thread.id, turn: turn.turn });
|
|
164
152
|
} else if (this.blockedReason) {
|
|
165
153
|
await this.handle("interrupt", { id: job.id });
|
|
166
154
|
}
|
|
@@ -168,7 +156,10 @@ export class Runtime {
|
|
|
168
156
|
|
|
169
157
|
notification({ method, params }) {
|
|
170
158
|
if (method === "thread/closed") this.loadedThreads.delete(params?.threadId);
|
|
171
|
-
if (
|
|
159
|
+
if (!["turn/completed", "turn/failed"].includes(method) || !this.active) return;
|
|
160
|
+
// A process-loss failure is synthesized by the client, not a confirmed
|
|
161
|
+
// provider result. Shutdown preserves this job as unknown.
|
|
162
|
+
if (this.codexFailure) return;
|
|
172
163
|
const job = this.store.get(this.active);
|
|
173
164
|
if (params?.threadId !== job.threadId) return;
|
|
174
165
|
if (!job.turnId) this.earlyCompletion = params;
|
|
@@ -239,22 +230,23 @@ export class Runtime {
|
|
|
239
230
|
serverRequest(message) {
|
|
240
231
|
const call = readPwrAgentMessagingDynamicToolCall(message);
|
|
241
232
|
if (call) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return;
|
|
233
|
+
return this.dispatchTool(call).catch((error) => ({ success: false,
|
|
234
|
+
contentItems: [{ type: "inputText", text: error.message }] }));
|
|
245
235
|
}
|
|
246
236
|
// Never grant unattended approvals or invent answers to user questions.
|
|
247
237
|
const method = message.method;
|
|
248
238
|
let result;
|
|
249
239
|
if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval"].includes(method)) result = { decision: "decline" };
|
|
250
240
|
else if (method === "item/permissions/requestApproval") result = { permissions: {}, scope: "turn" };
|
|
251
|
-
this.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
241
|
+
if (!this.active) {
|
|
242
|
+
if (result) return result;
|
|
243
|
+
throw new Error("Interactive requests are not supported by PwrAgent Headless");
|
|
244
|
+
}
|
|
255
245
|
this.blockedReason = `Interactive request requires an operator: ${method}`;
|
|
256
246
|
const job = this.store.get(this.active);
|
|
257
247
|
if (job.turnId) void this.handle("interrupt", { id: job.id }).catch(this.onFailure);
|
|
248
|
+
if (result) return result;
|
|
249
|
+
throw new Error("Interactive requests are not supported by PwrAgent Headless");
|
|
258
250
|
}
|
|
259
251
|
|
|
260
252
|
finish(id, status, reason) {
|
package/src/store.js
CHANGED
|
@@ -26,8 +26,11 @@ export function atomicJson(file, value) {
|
|
|
26
26
|
closeSync(fd);
|
|
27
27
|
}
|
|
28
28
|
renameSync(temporary, file);
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Windows rejects directory fsync. The file is synced before rename on every host.
|
|
30
|
+
if (process.platform !== "win32") {
|
|
31
|
+
const directory = openSync(path.dirname(file), "r");
|
|
32
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
33
|
+
}
|
|
31
34
|
} catch (error) {
|
|
32
35
|
// Preserve the original storage failure even if cleanup also fails.
|
|
33
36
|
try { unlinkSync(temporary); } catch { /* A renamed file is already gone. */ }
|