pwragent 0.0.0 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +494 -2
  2. package/bin/pwragent.js +10 -1
  3. package/dist/THIRD_PARTY_LICENSES +165 -0
  4. package/dist/federation-core.js +19573 -0
  5. package/dist/ws-LICENSE +20 -0
  6. package/examples/pwragent-headless.service +25 -0
  7. package/licenses/xterm-LICENSE +26 -0
  8. package/node_modules/node-addon-api/LICENSE.md +9 -0
  9. package/node_modules/node-addon-api/README.md +319 -0
  10. package/node_modules/node-addon-api/common.gypi +20 -0
  11. package/node_modules/node-addon-api/except.gypi +25 -0
  12. package/node_modules/node-addon-api/index.js +12 -0
  13. package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  14. package/node_modules/node-addon-api/napi-inl.h +6607 -0
  15. package/node_modules/node-addon-api/napi.h +3201 -0
  16. package/node_modules/node-addon-api/node_addon_api.gyp +32 -0
  17. package/node_modules/node-addon-api/node_api.gyp +9 -0
  18. package/node_modules/node-addon-api/noexcept.gypi +26 -0
  19. package/node_modules/node-addon-api/nothing.c +0 -0
  20. package/node_modules/node-addon-api/package-support.json +21 -0
  21. package/node_modules/node-addon-api/package.json +480 -0
  22. package/node_modules/node-addon-api/tools/README.md +73 -0
  23. package/node_modules/node-addon-api/tools/check-napi.js +99 -0
  24. package/node_modules/node-addon-api/tools/clang-format.js +71 -0
  25. package/node_modules/node-addon-api/tools/conversion.js +301 -0
  26. package/node_modules/node-addon-api/tools/eslint-format.js +79 -0
  27. package/node_modules/node-pty/LICENSE +69 -0
  28. package/node_modules/node-pty/README.md +164 -0
  29. package/node_modules/node-pty/binding.gyp +150 -0
  30. package/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  31. package/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  32. package/node_modules/node-pty/lib/index.js +52 -0
  33. package/node_modules/node-pty/lib/interfaces.js +7 -0
  34. package/node_modules/node-pty/lib/shared/conout.js +10 -0
  35. package/node_modules/node-pty/lib/terminal.js +190 -0
  36. package/node_modules/node-pty/lib/types.js +7 -0
  37. package/node_modules/node-pty/lib/unixTerminal.js +351 -0
  38. package/node_modules/node-pty/lib/utils.js +38 -0
  39. package/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  40. package/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  41. package/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  42. package/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  43. package/node_modules/node-pty/package.json +66 -0
  44. package/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  45. package/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  46. package/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  47. package/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  48. package/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  49. package/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  50. package/node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  51. package/node_modules/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  52. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  53. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.pdb +0 -0
  54. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  55. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.pdb +0 -0
  56. package/node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  57. package/node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  58. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  59. package/node_modules/node-pty/prebuilds/win32-x64/conpty.pdb +0 -0
  60. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  61. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.pdb +0 -0
  62. package/node_modules/node-pty/scripts/post-install.js +76 -0
  63. package/node_modules/node-pty/scripts/prebuild.js +34 -0
  64. package/node_modules/node-pty/src/unix/pty.cc +876 -0
  65. package/node_modules/node-pty/src/unix/spawn-helper.cc +23 -0
  66. package/node_modules/node-pty/src/win/conpty.cc +616 -0
  67. package/node_modules/node-pty/src/win/conpty.h +41 -0
  68. package/node_modules/node-pty/src/win/conpty_console_list.cc +44 -0
  69. package/node_modules/node-pty/src/win/path_util.cc +95 -0
  70. package/node_modules/node-pty/src/win/path_util.h +26 -0
  71. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-arm64/OpenConsole.exe +0 -0
  72. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-arm64/conpty.dll +0 -0
  73. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-x64/OpenConsole.exe +0 -0
  74. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-x64/conpty.dll +0 -0
  75. package/node_modules/node-pty/typings/node-pty.d.ts +215 -0
  76. package/package.json +32 -5
  77. package/src/cli.js +123 -0
  78. package/src/client.js +27 -0
  79. package/src/codex.js +112 -0
  80. package/src/daemon.js +125 -0
  81. package/src/federation-backend.js +453 -0
  82. package/src/federation-core.ts +24 -0
  83. package/src/federation.js +454 -0
  84. package/src/handoffs.js +141 -0
  85. package/src/projects.js +140 -0
  86. package/src/runtime.js +265 -0
  87. package/src/store.js +80 -0
  88. package/src/wire.js +33 -0
@@ -0,0 +1,453 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { mergeCausalMessagingContext } from "../dist/federation-core.js";
5
+ import { ACTIVE, atomicJson, digest } from "./store.js";
6
+
7
+ export const METHOD_CAPABILITIES = Object.fromEntries([
8
+ ["backend.resolveThreadAdmissionState", "messaging_route"],
9
+ ["backend.supportsMessagingPdfTools", "messaging_route"],
10
+ ...["getNavigationQueryPage", "getNavigationLaunchpadConfig", "getNavigationSelectedDetail", "getNavigationQueueProjection", "releaseNavigationAttentionView", "listThreads", "listBackends", "resolveThread", "markThreadSeen", "getProjectPage", "lookupArchivedThreads"].map((method) => [`backend.${method}`, "thread_navigation"]),
11
+ ...["readThread", "readTurnAssistantReply", "listSkills", "listThreadMcpServers"].map((method) => [`backend.${method}`, "thread_detail"]),
12
+ ...["startThread", "materializeDirectoryLaunchpad", "startTurn", "interruptTurn", "steerTurn", "controlActiveTurn", "renameThread", "setThreadModelSettings", "setThreadExecutionMode"].map((method) => [`backend.${method}`, "turn_control"]),
13
+ ]);
14
+
15
+ function mode(sandbox) { return sandbox === "workspace-write" ? "auto" : "default"; }
16
+ function sandboxFor(params, fallback = "read-only") {
17
+ if (params.executionMode && !["default", "auto", "full-access"].includes(params.executionMode)) throw new Error("Unsupported execution mode");
18
+ if (params.executionMode === "full-access" || params.sandbox === "danger-full-access") throw new Error("Full access is unavailable on this outpost");
19
+ const value = params.sandbox || (params.executionMode === "auto" ? "workspace-write" : params.executionMode === "default" ? "read-only" : fallback);
20
+ if (!["read-only", "workspace-write"].includes(value)) throw new Error("Unsupported sandbox policy");
21
+ if (params.approvalPolicy && params.approvalPolicy !== "never") throw new Error("This unattended outpost requires approvalPolicy never");
22
+ return value;
23
+ }
24
+ function textInput(input) {
25
+ if (!Array.isArray(input) || !input.length || input.some((item) => item?.type !== "text" || typeof item.text !== "string")) {
26
+ throw new Error("Headless federation currently accepts text input only");
27
+ }
28
+ return input.map((item) => item.text).join("\n");
29
+ }
30
+
31
+ export class HeadlessBackend {
32
+ constructor(runtime, publish) {
33
+ this.runtime = runtime;
34
+ this.publish = publish;
35
+ this.epoch = randomUUID();
36
+ this.attentionViews = new Map();
37
+ this.file = path.join(path.dirname(runtime.store.directory), "threads.json");
38
+ try {
39
+ const data = JSON.parse(readFileSync(this.file, "utf8"));
40
+ if (data.version !== 1 || !Array.isArray(data.threads)) throw new Error("Invalid headless thread registry");
41
+ this.threads = new Map(data.threads.map((thread) => [thread.id, thread]));
42
+ } catch (error) {
43
+ if (error.code !== "ENOENT") throw error;
44
+ this.threads = new Map();
45
+ }
46
+ runtime.ownsThread = (id) => this.threads.has(id) && !this.threads.get(id).archived;
47
+ runtime.threadCwd = (id) => this.owned(id).cwd || runtime.workspace;
48
+ runtime.listOwnedThreads = () => [...this.threads.values()].filter((thread) => !thread.archived);
49
+ this.latestJobs = new Map();
50
+ this.syncJobs();
51
+ }
52
+
53
+ persist() {
54
+ try { atomicJson(this.file, { version: 1, threads: [...this.threads.values()] }); }
55
+ catch (error) { this.runtime.onFailure(error); throw error; }
56
+ }
57
+
58
+ syncJobs(jobs = this.runtime.store.jobs.values()) {
59
+ let changed = false;
60
+ for (const job of jobs) {
61
+ if (!job.threadId) continue;
62
+ const previous = this.latestJobs.get(job.threadId);
63
+ if (!previous || previous.id === job.id || job.createdAt > previous.createdAt) this.latestJobs.set(job.threadId, job);
64
+ if (this.threads.has(job.threadId)) continue;
65
+ this.threads.set(job.threadId, { id: job.threadId, title: `Thread ${job.threadId.slice(0, 8)}`, titleSource: "fallback",
66
+ createdAt: Date.parse(job.createdAt), messagingFileTools: job.messagingFileTools === true, cwd: job.cwd || this.runtime.workspace, sandbox: job.sandbox, model: job.model });
67
+ changed = true;
68
+ }
69
+ if (changed) this.persist();
70
+ }
71
+
72
+ owned(id) {
73
+ const thread = this.threads.get(id);
74
+ if (!thread || thread.archived) throw new Error("Thread is not owned by this outpost");
75
+ return thread;
76
+ }
77
+
78
+ latest(id) {
79
+ return this.latestJobs.get(id);
80
+ }
81
+
82
+ summary(thread) {
83
+ const cwd = thread.cwd || this.runtime.workspace;
84
+ const job = this.latest(thread.id);
85
+ const updatedAt = Math.max(thread.updatedAt || thread.createdAt, job ? Date.parse(job.updatedAt) : 0);
86
+ return { id: thread.id, title: thread.title, titleSource: thread.titleSource, source: "codex",
87
+ createdAt: thread.createdAt, updatedAt, projectKey: cwd,
88
+ threadStatus: job && ACTIVE.has(job.status) ? "active" : "idle",
89
+ linkedDirectories: [{ id: cwd, kind: "local", path: cwd, label: path.basename(cwd) }],
90
+ executionMode: mode(thread.sandbox), model: thread.model || undefined,
91
+ reasoningEffort: thread.reasoningEffort, serviceTier: thread.serviceTier,
92
+ inbox: { inInbox: updatedAt > (thread.seenUpdatedAt ?? thread.seenAt ?? 0), reason: "updated-since-seen" },
93
+ workspaceHandoff: { available: false, unavailableReason: "Workspace handoff is unavailable on this outpost" } };
94
+ }
95
+
96
+ event(message) {
97
+ if (message.method === "headless/jobChanged") {
98
+ this.syncJobs([message.params]);
99
+ if (message.params.threadId) this.forgetSeenAttentionMember(message.params.threadId);
100
+ this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId: message.params.threadId, sourceMethod: "turn/completed" } } }, "navigation");
101
+ return;
102
+ }
103
+ const id = message.params?.threadId || message.params?.thread?.id;
104
+ if (!this.threads.has(id)) return;
105
+ // The desktop adapter represents failed completions as turn/failed and
106
+ // supplies turnId alongside the provider's nested turn object.
107
+ const turnId = message.params?.turnId || message.params?.turn?.id;
108
+ const notification = { method: message.method === "turn/completed" && message.params.turn?.status === "failed" ? "turn/failed" : message.method,
109
+ params: { ...message.params, ...(turnId ? { turnId } : {}) } };
110
+ this.publish({ backend: "codex", notification }, "transcript");
111
+ if (["thread/started", "turn/started", "turn/completed", "thread/status/changed"].includes(message.method)) {
112
+ this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId: id, sourceMethod: message.method } } }, "navigation");
113
+ }
114
+ }
115
+
116
+ async handle(method, params = {}, envelope = {}) {
117
+ if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error("Expected request parameters");
118
+ if (params.backend && !["codex", "all"].includes(params.backend)) throw new Error("This outpost serves Codex only");
119
+ if (params.ref && params.ref.backend !== "codex") throw new Error("This outpost serves Codex only");
120
+ if (params.cwd) this.runtime.projects.resolve(params.cwd);
121
+ const name = method.replace(/^backend\./, "");
122
+ switch (name) {
123
+ case "listBackends": return this.listBackends();
124
+ case "listThreads": return { backend: "codex", fetchedAt: Date.now(), threads: [...this.threads.values()].filter((thread) => !thread.archived).map((thread) => this.summary(thread)), workspaceRoots: this.runtime.projects.list().map((project) => project.path) };
125
+ case "getNavigationQueryPage": return this.query(params);
126
+ case "getNavigationLaunchpadConfig": {
127
+ if (params.protocol !== 2) throw new Error("Unsupported navigation protocol");
128
+ if (params.directoryKey !== undefined) this.runtime.projects.resolve(params.directoryKey);
129
+ return { protocol: 2, revision: "headless-v1", ...(params.directoryKey ? { directoryKey: params.directoryKey } : {}),
130
+ ...(params.knownRevision === "headless-v1" ? { unchanged: true } : { defaults: { backend: "codex", executionMode: "default", workMode: "local" } }) };
131
+ }
132
+ case "getNavigationSelectedDetail": return { protocol: 2, ref: params.ref, revision: digest(this.summary(this.owned(params.ref.threadId))), readiness: "ready", identity: "present", thread: this.summary(this.owned(params.ref.threadId)), collections: [] };
133
+ case "getNavigationQueueProjection": return { protocol: 2, ref: params.ref, revision: "no-queue", readiness: "ready", complete: true, entries: [] };
134
+ case "releaseNavigationAttentionView": this.attentionViews.delete(params.viewId); return {};
135
+ case "resolveThread": return this.threads.has(params.threadId) ? { thread: this.summary(this.owned(params.threadId)) } : {};
136
+ case "resolveThreadAdmissionState": {
137
+ const thread = this.summary(this.owned(params.threadId));
138
+ const job = this.latest(params.threadId);
139
+ return { thread, threadStatus: thread.threadStatus,
140
+ ...(job?.turnId && ACTIVE.has(job.status) ? { activeTurn: { backend: "codex", threadId: thread.id, turnId: job.turnId } } : {}) };
141
+ }
142
+ case "lookupArchivedThreads": return { threads: [] };
143
+ case "getProjectPage": return { backend: "codex", fetchedAt: Date.now(),
144
+ launchpadDefaults: { backend: "codex", executionMode: "default", workMode: "local" },
145
+ directories: this.runtime.projects.list().filter((project) => (!params.projectKey || params.projectKey === project.path)
146
+ && (!params.afterKey || project.path > params.afterKey)).map((project) => ({ key: project.path, kind: "directory", path: project.path, label: project.label, threadKeys: [] })) };
147
+ case "readTurnAssistantReply": {
148
+ const history = await this.readThread(params.threadId);
149
+ const messages = history.replay.messages.filter((message) => message.role === "assistant" && message.turn?.id === params.turnId);
150
+ return { message: messages.find((message) => params.itemId ? message.id === params.itemId : message.text === params.text) || null };
151
+ }
152
+ case "readThread": return this.readThread(params.threadId);
153
+ case "supportsMessagingPdfTools": return params.backend === "codex" && this.owned(params.threadId).messagingFileTools === true;
154
+ case "startThread": return this.startThread(params);
155
+ case "materializeDirectoryLaunchpad": return this.materializeLaunchpad(params, envelope);
156
+ case "startTurn": return this.startTurn(params, envelope);
157
+ case "interruptTurn": {
158
+ this.owned(params.threadId);
159
+ const job = this.latest(params.threadId);
160
+ if (!job || !ACTIVE.has(job.status)) throw new Error("Thread has no active turn");
161
+ if (params.turnId && params.turnId !== job.turnId) throw new Error("Active turn changed");
162
+ await this.runtime.handle("interrupt", { id: job.id });
163
+ return { backend: "codex", threadId: params.threadId, turnId: job.turnId };
164
+ }
165
+ case "steerTurn": case "controlActiveTurn": {
166
+ this.owned(params.threadId);
167
+ const job = this.latest(params.threadId);
168
+ if (!job || !ACTIVE.has(job.status) || !job.turnId) throw new Error("Thread has no active turn");
169
+ if (params.expectedTurnId && params.expectedTurnId !== job.turnId) throw new Error("Active turn changed");
170
+ if (name === "controlActiveTurn" && params.operation === "stop") {
171
+ await this.runtime.handle("interrupt", { id: job.id });
172
+ return { ok: true, backend: "codex", threadId: params.threadId, requestId: params.requestId, turnId: job.turnId, disposition: "interrupted" };
173
+ }
174
+ if (name === "controlActiveTurn" && params.operation !== "steer") throw new Error("Unsupported active-turn operation");
175
+ const input = [{ type: "text", text: textInput(params.input), text_elements: [] }];
176
+ const incoming = this.authenticateOrigin?.(params.messageOrigin, envelope);
177
+ if (incoming) {
178
+ const original = job.messageOrigin;
179
+ this.runtime.save({ ...job, messageOrigin: { ...incoming, ...mergeCausalMessagingContext([original, incoming]) } });
180
+ }
181
+ const result = await this.runtime.codex.request("turn/steer", { threadId: params.threadId, expectedTurnId: params.expectedTurnId || job.turnId, input });
182
+ return { backend: "codex", threadId: params.threadId, turnId: result.turnId || job.turnId, disposition: "steered",
183
+ ...(name === "controlActiveTurn" ? { ok: true, requestId: params.requestId } : {}) };
184
+ }
185
+ case "renameThread": {
186
+ const thread = this.owned(params.threadId);
187
+ const title = params.title || params.name;
188
+ if (typeof title !== "string" || !title.trim() || title.length > 200) throw new Error("Invalid title");
189
+ Object.assign(thread, { title: title.trim(), titleSource: "explicit", updatedAt: Date.now() });
190
+ this.persist();
191
+ this.invalidate(thread.id);
192
+ return { backend: "codex", threadId: thread.id, title: thread.title };
193
+ }
194
+ case "markThreadSeen": {
195
+ const thread = this.owned(params.threadId);
196
+ for (const key of ["seenAt", "seenUpdatedAt"]) {
197
+ if (params[key] !== undefined && (!Number.isFinite(params[key]) || params[key] < 0)) throw new Error(`Invalid ${key}`);
198
+ }
199
+ const seenUpdatedAt = params.seenUpdatedAt ?? this.summary(thread).updatedAt;
200
+ thread.seenAt = params.seenAt ?? Date.now();
201
+ thread.seenUpdatedAt = seenUpdatedAt;
202
+ this.persist();
203
+ this.forgetSeenAttentionMember(thread.id);
204
+ this.invalidate(thread.id);
205
+ return { backend: "codex", threadId: thread.id, seenAt: thread.seenAt, seenUpdatedAt };
206
+ }
207
+ case "setThreadExecutionMode": {
208
+ const thread = this.owned(params.threadId);
209
+ thread.sandbox = sandboxFor({ executionMode: params.executionMode });
210
+ this.persist();
211
+ this.invalidate(thread.id);
212
+ return { backend: "codex", threadId: thread.id, executionMode: mode(thread.sandbox) };
213
+ }
214
+ case "setThreadModelSettings": {
215
+ const thread = this.owned(params.threadId);
216
+ for (const key of ["model", "reasoningEffort", "serviceTier"]) {
217
+ if (params[key] !== undefined && (typeof params[key] !== "string" || params[key].length > 200)) throw new Error(`Invalid ${key}`);
218
+ if (params[key] !== undefined) thread[key] = params[key];
219
+ }
220
+ this.persist();
221
+ this.invalidate(thread.id);
222
+ return { backend: "codex", threadId: thread.id, model: thread.model, reasoningEffort: thread.reasoningEffort, serviceTier: thread.serviceTier };
223
+ }
224
+ case "listSkills": case "listThreadMcpServers": throw new Error("This inventory is not exposed by the headless adapter");
225
+ default: throw new Error(`Unsupported outpost operation: ${method}`);
226
+ }
227
+ }
228
+
229
+ invalidate(threadId) { this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId } } }, "navigation"); }
230
+
231
+ async materializeLaunchpad(params, envelope) {
232
+ const cwd = this.runtime.projects.resolve(params.directoryKey);
233
+ const draft = params.launchpad || {};
234
+ if (draft.backend && draft.backend !== "codex") throw new Error("This outpost serves Codex only");
235
+ if (draft.directoryPath && draft.directoryPath !== cwd) throw new Error("Requested workspace is not owned by this outpost");
236
+ if (params.parentThreadId || params.agent || params.reviewTarget || params.scheduledFor || params.collaborationMode
237
+ || (params.codexEnvironmentSelector && params.codexEnvironmentSelector !== "none") || draft.codexEnvironmentId
238
+ || draft.branchName || draft.acpRuntime || draft.fastMode || draft.tokenMiserEnabled || draft.mcpConnectionIds?.length) {
239
+ throw new Error("This outpost supports local workspace launchpads without grouping, environments or provider extensions");
240
+ }
241
+ const input = params.input?.length ? params.input : draft.prompt ? [{ type: "text", text: draft.prompt }] : undefined;
242
+ if (input) textInput(input);
243
+ const created = { ...await this.startThread({ ...draft, cwd }), workMode: "local" };
244
+ if (!input) return created;
245
+ try {
246
+ const turn = await this.startTurn({ ...draft, threadId: created.threadId, input }, envelope);
247
+ return { ...created, turnId: turn.turnId };
248
+ } catch (error) {
249
+ return { ...created, turnStartFailure: { message: error.message, phase: "turn" } };
250
+ }
251
+ }
252
+
253
+ async startThread(params) {
254
+ if (this.runtime.active || this.runtime.intakeBusy || this.creating) throw new Error("Outpost is busy");
255
+ if (params.workMode && params.workMode !== "local") throw new Error("Outpost threads use the configured workspace; worktree creation is unavailable");
256
+ if (this.threads.size >= 10000) throw new Error("Thread retention limit reached");
257
+ const sandbox = sandboxFor(params);
258
+ for (const key of ["model", "reasoningEffort", "serviceTier"]) {
259
+ if (params[key] !== undefined && (typeof params[key] !== "string" || !params[key] || params[key].length > 200)) throw new Error(`Invalid ${key}`);
260
+ }
261
+ const cwd = this.runtime.projects.resolve(params.cwd || params.directoryPath || params.projectKey);
262
+ this.creating = true;
263
+ try {
264
+ 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 });
265
+ if (!result?.thread?.id || typeof result.thread.id !== "string" || result.thread.id.length > 256) throw new Error("Invalid thread/start response");
266
+ this.runtime.loadedThreads.add(result.thread.id);
267
+ const thread = { id: result.thread.id, messagingFileTools: true, title: "New outpost thread", titleSource: "fallback", createdAt: Date.now(), cwd, sandbox,
268
+ model: params.model, reasoningEffort: params.reasoningEffort, serviceTier: params.serviceTier };
269
+ this.threads.set(thread.id, thread);
270
+ this.persist();
271
+ this.invalidate(thread.id);
272
+ return { backend: "codex", threadId: thread.id, executionMode: mode(sandbox) };
273
+ } finally { this.creating = false; }
274
+ }
275
+
276
+ async startTurn(params, envelope) {
277
+ if (params.collaborationMode || params.fastMode || params.acpRuntime) throw new Error("Collaboration modes, fast mode and ACP settings are unavailable on this outpost");
278
+ const thread = this.owned(params.threadId);
279
+ if (params.cwd && params.cwd !== (thread.cwd || this.runtime.workspace)) throw new Error("Turn cwd differs from its owner thread project");
280
+ const sandbox = sandboxFor(params, thread.sandbox);
281
+ const prompt = textInput(params.input);
282
+ if (params.queueEntryId !== undefined && (typeof params.queueEntryId !== "string" || !params.queueEntryId || params.queueEntryId.length > 256)) throw new Error("Invalid submission identity");
283
+ const job = this.runtime.submit({ id: `fed-${digest([envelope.sourceInstanceId, params.queueEntryId || envelope.id])}`, prompt,
284
+ threadId: thread.id, cwd: thread.cwd || this.runtime.workspace, messageOrigin: this.authenticateOrigin?.(params.messageOrigin, envelope), sandbox, model: params.model || thread.model,
285
+ reasoningEffort: params.reasoningEffort || thread.reasoningEffort, serviceTier: params.serviceTier || thread.serviceTier });
286
+ await this.runtime.work;
287
+ const started = this.runtime.store.get(job.id);
288
+ if (!started.turnId) throw new Error(started.reason || "Turn was not started");
289
+ return { backend: "codex", threadId: thread.id, turnId: started.turnId, queueStatus: "started" };
290
+ }
291
+
292
+ async listBackends() {
293
+ const result = await this.runtime.codex.request("model/list", {});
294
+ const models = (result.data || []).map((model) => ({ id: model.id, label: model.displayName || model.id,
295
+ current: model.isDefault, reasoningEfforts: (model.supportedReasoningEfforts || []).map((entry) => entry.reasoningEffort),
296
+ defaultReasoningEffort: model.defaultReasoningEffort, supportsSteering: true, supportsImage: false, supportsFast: false }));
297
+ return { fetchedAt: Date.now(), backends: [{ kind: "codex", label: "Codex (headless outpost)", available: true,
298
+ methods: Object.keys(METHOD_CAPABILITIES), capabilities: { listThreads: true, createThread: true, resumeThread: true,
299
+ renameThread: true, readThread: true, startTurn: true, interruptTurn: true, steerTurn: true,
300
+ transcriptPagination: false, toolUse: true, approvalRequests: false, multiDirectoryThreads: false },
301
+ executionModes: [{ mode: "default", label: "Read-only", available: true, isDefault: true },
302
+ { mode: "auto", label: "Workspace write", available: true }, { mode: "full-access", label: "Full access", available: false }],
303
+ launchpadOptions: { models, supportsFastMode: false } }] };
304
+ }
305
+
306
+ async readThread(id) {
307
+ this.owned(id);
308
+ const result = await this.runtime.codex.request("thread/read", { threadId: id, includeTurns: true });
309
+ const entries = [];
310
+ for (const turn of result.thread?.turns || []) {
311
+ const status = turn.status === "inProgress" ? "in_progress" : turn.status;
312
+ const job = [...this.runtime.store.jobs.values()].find((job) => job.threadId === id && job.turnId === turn.id);
313
+ const metadata = { id: turn.id,
314
+ ...(["in_progress", "completed", "failed", "cancelled", "interrupted"].includes(status) ? { status } : {}) };
315
+ for (const [index, item] of (turn.items || []).entries()) {
316
+ const itemId = item.id || `${turn.id}-${index}`;
317
+ if (item.type === "agentMessage" || item.type === "userMessage") {
318
+ entries.push({ type: "message", id: itemId, role: item.type === "agentMessage" ? "assistant" : "user",
319
+ text: item.text || (item.content || []).filter((part) => part.type === "text").map((part) => part.text).join("\n"),
320
+ turn: metadata, ...(item.type === "userMessage" && job?.messageOrigin ? { origin: job.messageOrigin } : {}) });
321
+ } else {
322
+ const details = item.type === "commandExecution" ? [{ id: itemId, kind: "command", label: item.command,
323
+ command: { displayCommand: item.command, cwd: item.cwd, output: item.aggregatedOutput || "", exitCode: item.exitCode, durationMs: item.durationMs } }]
324
+ : [{ id: itemId, kind: item.type === "fileChange" ? "write" : "read", label: item.type, markdown: JSON.stringify(item, null, 2) }];
325
+ entries.push({ type: "activity", id: itemId, summary: item.command || item.type,
326
+ status: item.status === "inProgress" ? "in_progress" : item.status === "failed" ? "failed" : "completed", details, turn: metadata });
327
+ }
328
+ }
329
+ }
330
+ const messages = entries.filter((entry) => entry.type === "message");
331
+ return { backend: "codex", threadId: id, fetchedAt: Date.now(), threadStatus: this.summary(this.owned(id)).threadStatus,
332
+ replay: { entries, messages, lastUserMessage: messages.filter((item) => item.role === "user").at(-1)?.text,
333
+ lastAssistantMessage: messages.filter((item) => item.role === "assistant").at(-1)?.text,
334
+ pagination: { supportsPagination: false, hasPreviousPage: false } } };
335
+ }
336
+
337
+ forgetSeenAttentionMember(id) {
338
+ const thread = this.threads.get(id);
339
+ if (!thread) return;
340
+ const row = this.summary(thread);
341
+ if (row.threadStatus !== "active" && !row.inbox.inInbox) {
342
+ for (const view of this.attentionViews.values()) view.members.delete(id);
343
+ }
344
+ }
345
+
346
+ attentionOrder(rows, options = { id: "default", promoteOnTurnEnd: true }) {
347
+ if (typeof options.id !== "string" || !options.id || options.id.length > 256
348
+ || typeof options.promoteOnTurnEnd !== "boolean") throw new Error("Invalid Attention view");
349
+ let view = this.attentionViews.get(options.id);
350
+ if (!view) {
351
+ if (this.attentionViews.size >= 128) throw new Error("Attention view limit reached; release an unused view");
352
+ view = { members: new Map(), nextRank: 1 };
353
+ this.attentionViews.set(options.id, view);
354
+ }
355
+ const members = new Map();
356
+ // Bootstrap oldest turn first. Subsequent ranks follow turn identities and
357
+ // active/terminal transitions, never mutable thread or job activity stamps.
358
+ const eligible = rows.filter((row) => row.threadStatus === "active" || row.inbox.inInbox)
359
+ .sort((a, b) => (Date.parse(this.latest(a.id)?.createdAt) || a.createdAt)
360
+ - (Date.parse(this.latest(b.id)?.createdAt) || b.createdAt) || a.id.localeCompare(b.id));
361
+ for (const row of eligible) {
362
+ const previous = view.members.get(row.id);
363
+ const job = this.latest(row.id);
364
+ const turnKey = job?.id;
365
+ const active = row.threadStatus === "active";
366
+ const newTurn = turnKey !== previous?.turnKey;
367
+ const started = active && (!previous?.active || newTurn);
368
+ const finished = !active && (previous?.active || newTurn);
369
+ const rank = !previous || started || (finished && options.promoteOnTurnEnd) ? view.nextRank++ : previous.rank;
370
+ members.set(row.id, { rank, active, turnKey });
371
+ }
372
+ view.members = members;
373
+ return members;
374
+ }
375
+
376
+ query(request) {
377
+ if (request.protocol !== 2 || !request.query || request.inventory === "viewer") throw new Error("Expected owner navigation protocol 2");
378
+ let rows = [...this.threads.values()].filter((thread) => !thread.archived).map((thread) => this.summary(thread));
379
+ const counts = { total: rows.length, active: rows.filter((row) => row.threadStatus === "active").length,
380
+ unread: rows.filter((row) => row.inbox.inInbox).length, review: rows.filter((row) => row.inbox.inInbox && row.threadStatus !== "active").length, pinned: 0 };
381
+ const directories = this.runtime.projects.list().map((project) => {
382
+ const members = rows.filter((row) => row.projectKey === project.path);
383
+ return { key: project.path, kind: "directory", label: project.label, path: project.path,
384
+ counts: { total: members.length, active: members.filter((row) => row.threadStatus === "active").length,
385
+ unread: members.filter((row) => row.inbox.inInbox).length, review: members.filter((row) => row.inbox.inInbox && row.threadStatus !== "active").length, pinned: 0 },
386
+ pinnedRootCount: 0, unpinnedRootCount: members.length, launchpadPresent: true, launchpadBackend: "codex" };
387
+ });
388
+ const query = request.query;
389
+ const attention = query.kind === "lens" && query.lens === "attention" ? this.attentionOrder(rows, request.attentionView) : undefined;
390
+ if (!["exact", "children", "group-members", "directory", "lens", "search", "directory-index", "messaging-projects", "messaging-threads", "model-inventory", "star-map", "star-map-geometry"].includes(query.kind)) throw new Error("Unsupported navigation query");
391
+ if (request.backend && !["codex", "all"].includes(request.backend)) rows = [];
392
+ if (query.kind === "exact") rows = rows.filter((row) => query.identities?.some((ref) => ref.backend === "codex" && ref.threadId === row.id));
393
+ else if (["children", "group-members"].includes(query.kind) || query.roots === "pinned") rows = [];
394
+ else if (query.kind === "directory") rows = rows.filter((row) => row.projectKey === query.directoryKey);
395
+ else if (query.kind === "lens" && query.lens === "attention") rows = rows.filter((row) => row.threadStatus === "active" || row.inbox.inInbox);
396
+ if (query.agentOnly || (query.allowedBackends && !query.allowedBackends.includes("codex"))) rows = [];
397
+ if (query.projectKey) rows = rows.filter((row) => row.projectKey === query.projectKey);
398
+ if (query.directoryKey) rows = rows.filter((row) => row.projectKey === query.directoryKey);
399
+ if (query.kind === "star-map") {
400
+ for (const [key, selection] of Object.entries(query.filters || {})) {
401
+ if (selection === "neutral") continue;
402
+ rows = rows.filter((row) => {
403
+ const matches = key === "attention" && (row.threadStatus === "active" || row.inbox.inInbox);
404
+ return selection === "include" ? matches : !matches;
405
+ });
406
+ }
407
+ }
408
+ const filter = query.filter || query.text;
409
+ if (filter) rows = rows.filter((row) => row.title.toLowerCase().includes(filter.toLowerCase()));
410
+ const creationOrder = query.kind === "directory" || (query.kind === "lens" && query.lens === "recents");
411
+ rows.sort((a, b) => (attention ? attention.get(b.id).rank - attention.get(a.id).rank
412
+ : creationOrder ? b.createdAt - a.createdAt : b.updatedAt - a.updatedAt) || a.id.localeCompare(b.id));
413
+ const queryKey = digest({ backend: request.backend, query, ...(attention ? { attentionView: request.attentionView } : {}) });
414
+ const generation = digest(attention ? rows.map((row) => ({ ...row, attentionRank: attention.get(row.id).rank })) : rows);
415
+ const countsRevision = digest(counts);
416
+ let offset = 0;
417
+ if (request.cursor) {
418
+ const cursor = JSON.parse(Buffer.from(request.cursor, "base64url").toString());
419
+ if (cursor.queryKey !== queryKey || cursor.generation !== generation || cursor.countsRevision !== countsRevision || cursor.epoch !== this.epoch
420
+ || !Number.isInteger(cursor.offset) || cursor.offset < 0) throw new Error("Navigation cursor expired; refresh the collection");
421
+ offset = cursor.offset;
422
+ } else if (request.anchor?.kind === "thread") {
423
+ const index = rows.findIndex((row) => row.id === request.anchor.ref.threadId && request.anchor.ref.backend === "codex");
424
+ if (index >= 0) offset = index;
425
+ }
426
+ const limit = Math.min(100, Math.max(1, Number.isInteger(request.pageSize) ? request.pageSize : 50));
427
+ const page = rows.slice(offset, offset + limit);
428
+ const complete = offset + page.length >= rows.length;
429
+ const result = { protocol: 2, queryKey, generation, ownerEpoch: this.epoch, countsRevision, coverage: { state: "complete" }, counts,
430
+ collectionSize: rows.length, rangeStart: offset, complete,
431
+ entries: page.map((row, index) => ({ row: { ...row, ref: { backend: "codex", threadId: row.id }, rowRevision: digest(row),
432
+ ordinaryChildCount: 0, nativeSubAgentGroupPresent: false, queueCount: 0, queueState: "ready" }, orderKey: String(offset + index).padStart(8, "0"),
433
+ ...(attention ? { attentionRank: attention.get(row.id).rank } : {}), placement: { kind: "root" } })),
434
+ ...(!complete ? { nextCursor: Buffer.from(JSON.stringify({ queryKey, generation, countsRevision, epoch: this.epoch, offset: offset + page.length })).toString("base64url") } : {}) };
435
+ if (["directory-index", "messaging-projects", "star-map-geometry"].includes(query.kind)) {
436
+ result.directories = directories.filter((directory) => (!query.keys || query.keys.includes(directory.key)) && (!query.paths || query.paths.includes(directory.path))
437
+ && (!query.directoryKey || query.directoryKey === directory.key)
438
+ && (!query.allowedBackends || query.allowedBackends.includes("codex")) && !query.agentOnly
439
+ && (!filter || `${directory.label} ${directory.path}`.toLowerCase().includes(filter.toLowerCase())));
440
+ result.collectionSize = result.directories.length;
441
+ result.entries = []; result.complete = true; delete result.nextCursor;
442
+ }
443
+ if (query.kind === "model-inventory") {
444
+ const groups = new Map();
445
+ for (const row of rows) { const key = row.model || ""; groups.set(key, { backend: "codex", model: row.model, threadCount: (groups.get(key)?.threadCount || 0) + 1, fastThreadCount: 0 }); }
446
+ result.modelGroups = [...groups.values()]; result.collectionSize = groups.size;
447
+ result.entries = []; result.complete = true; delete result.nextCursor;
448
+ }
449
+ if (query.kind === "directory") result.selectionDirectory = directories.find((directory) => query.directoryKey === directory.key);
450
+ if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024) throw new Error("Navigation page exceeds its byte budget; request fewer rows");
451
+ return result;
452
+ }
453
+ }
@@ -0,0 +1,24 @@
1
+ export * from "@pwragent/federation/federation-transport";
2
+ export * from "@pwragent/federation/federation-identity";
3
+ export * from "@pwragent/federation/federation-noise";
4
+ export * from "@pwragent/federation/federation-enrollment";
5
+ export * from "@pwragent/federation/federation-router";
6
+ export * from "@pwragent/federation/federation-rpc";
7
+ export { FEDERATION_PROTOCOL_VERSION, FEDERATION_INVITE_VERSION } from "@pwragent/shared";
8
+
9
+ export * from "@pwragent/federation/federation-pty-service";
10
+ export * from "@pwragent/federation/terminal-history-store";
11
+ export * from "@pwragent/agent-tools/pwragent-messaging-agent-tools";
12
+ export * from "@pwragent/agent-tools/pwragent-messaging-codex-tools";
13
+
14
+ export * from "@pwragent/federation/thread-instance-handoff-service";
15
+ export * from "@pwragent/federation/git-instance-handoff";
16
+ export * from "@pwragent/federation/federation-file-push";
17
+
18
+ export { AgentToolRouter } from "@pwragent/agent-tools/agent-tool-router";
19
+ export { mergeCausalMessagingContext } from "@pwragent/shared";
20
+ export { authenticateMessageOrigin } from "@pwragent/federation/federation-message-origin";
21
+
22
+ export { resolveMessagingOutboundFile, validateMessagingFileMetadata } from "@pwragent/agent-tools/messaging-outbound-file";
23
+
24
+ export * from "@pwragent/federation/federation-replacement-receiver";