pwragent 1.3.12 → 1.3.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pwragent",
3
- "version": "1.3.12",
3
+ "version": "1.3.15",
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.12",
68
- "commit": "7654f26215b4b5e47f70cc3fe682fd6e3d39c7f8"
67
+ "tag": "v1.3.15",
68
+ "commit": "bea7442e08c8eecf09926b8956e898efb72569b8"
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
- // This adapter deliberately supports only the stable App Server job surface.
5
- // It never reads Codex's storage or imports Electron/desktop modules.
6
- export class Codex {
7
- constructor({ command, home, workspace, onNotification, onRequest, onFailure, timeout = 30000 }) {
8
- this.pending = new Map();
9
- this.sequence = 0;
10
- this.timeout = timeout;
11
- this.onFailure = onFailure;
12
- const env = { ...process.env, CODEX_HOME: home };
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
- this.child.on("error", () => this.fail(new Error("Could not start Codex App Server")));
25
- this.child.on("exit", () => this.fail(new Error("Codex App Server exited")));
26
- this.child.stdin.on("error", () => this.fail(new Error("Codex App Server input closed")));
27
- // Drain diagnostics without copying potentially sensitive provider text to
28
- // the journal. Request errors are returned to the private local client.
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, (message) => {
31
- if (typeof message.method === "string") {
32
- if (message.id !== undefined) onRequest(message);
33
- else onNotification(message);
34
- return;
35
- }
36
- const request = this.pending.get(message.id);
37
- if (!request) return;
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.failure || this.stopping) throw this.failure || new Error("Codex is stopping");
62
- this.child.stdin.write(JSON.stringify(message) + "\n");
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 stop() {
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
- process.kill(-this.child.pid, name);
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.messages.filter((message) => message.role === "assistant" && message.turn?.id === params.turnId);
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.request("turn/steer", { threadId: params.threadId, expectedTurnId: params.expectedTurnId || job.turnId, input });
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 is the only store for a title. The outpost never sets the
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.request("thread/start", { cwd, dynamicTools: this.runtime.messagingTools.buildDynamicToolSpecs(), approvalPolicy: "never", sandbox, ...(params.model ? { model: params.model } : {}), ephemeral: false });
344
- if (!result?.thread?.id || typeof result.thread.id !== "string" || result.thread.id.length > 256) throw new Error("Invalid thread/start response");
345
- this.runtime.loadedThreads.add(result.thread.id);
346
- const thread = { id: result.thread.id, messagingFileTools: true, title: PLACEHOLDER_THREAD_TITLE, titleSource: "fallback", createdAt: Date.now(), cwd, sandbox,
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 result = await this.runtime.codex.request("model/list", {});
373
- const models = (result.data || []).map((model) => ({ id: model.id, label: model.displayName || model.id,
374
- current: model.isDefault, reasoningEfforts: (model.supportedReasoningEfforts || []).map((entry) => entry.reasoningEffort),
375
- defaultReasoningEffort: model.defaultReasoningEffort, supportsSteering: true, supportsImage: false, supportsFast: false }));
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 result = await this.runtime.codex.request("thread/read", { threadId: id, includeTurns: true });
388
- const entries = [];
389
- for (const turn of result.thread?.turns || []) {
390
- const status = turn.status === "inProgress" ? "in_progress" : turn.status;
391
- const job = [...this.runtime.store.jobs.values()].find((job) => job.threadId === id && job.turnId === turn.id);
392
- const metadata = { id: turn.id,
393
- ...(["in_progress", "completed", "failed", "cancelled", "interrupted"].includes(status) ? { status } : {}) };
394
- for (const [index, item] of (turn.items || []).entries()) {
395
- const itemId = item.id || `${turn.id}-${index}`;
396
- if (item.type === "agentMessage" || item.type === "userMessage") {
397
- entries.push({ type: "message", id: itemId, role: item.type === "agentMessage" ? "assistant" : "user",
398
- text: item.text || (item.content || []).filter((part) => part.type === "text").map((part) => part.text).join("\n"),
399
- turn: metadata, ...(item.type === "userMessage" && job?.messageOrigin ? { origin: job.messageOrigin } : {}) });
400
- } else {
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
- const messages = entries.filter((entry) => entry.type === "message");
410
- // Names threads created before derived titles, and imported threads.
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() {
@@ -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 result = await runtime.codex.request("thread/read", { threadId: id, includeTurns: false });
76
- if (result.thread?.id !== id || typeof result.thread.path !== "string") throw new Error("Codex did not provide a persisted thread for protocol export");
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.request("thread/fork", { threadId: request.sourceThreadId, path: request.sourceThreadPath,
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.thread?.id;
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.request("thread/archive", { threadId });
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.request("thread/read", { threadId: job.threadId, includeTurns: true });
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.request("turn/interrupt", { threadId: job.threadId, turnId: job.turnId });
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.codex.failure || this.stopping ? "unknown" : "failed", error.message);
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 && this.loadedThreads.has(job.threadId) ? { thread: { id: job.threadId } }
130
- : await this.codex.request(job.threadId ? "thread/resume" : "thread/start", {
131
- ...params,
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.thread.id);
139
- this.save({ ...this.store.get(job.id), threadId: result.thread.id,
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.request("turn/start", {
142
- threadId: result.thread.id,
143
- input: [{ type: "text", text: prompt, text_elements: [] }],
144
- cwd,
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?.turn?.id !== "string" || !turn.turn.id || turn.turn.id.length > 256) {
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.turn.id, status: "running" });
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.thread.id, jobId: job.id, prompt } });
160
- if (this.earlyCompletion?.turn?.id === turn.turn.id) {
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 (method !== "turn/completed" || !this.active) return;
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
- void this.dispatchTool(call).then((result) => this.codex.send({ id: message.id, result }),
243
- (error) => this.codex.send({ id: message.id, result: { success: false, contentItems: [{ type: "inputText", text: error.message }] } }));
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.codex.send(result
252
- ? { id: message.id, result }
253
- : { id: message.id, error: { code: -32601, message: "Interactive requests are not supported by PwrAgent Headless" } });
254
- if (!this.active) return;
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
- const directory = openSync(path.dirname(file), "r");
30
- try { fsyncSync(directory); } finally { closeSync(directory); }
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. */ }