pwragent 1.3.9 → 1.3.11

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.9",
3
+ "version": "1.3.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Experimental headless PwrAgent daemon and command-line client.",
@@ -53,7 +53,8 @@
53
53
  "devDependencies": {
54
54
  "@pwragent/federation": "workspace:*",
55
55
  "@pwragent/shared": "workspace:*",
56
- "esbuild": "0.28.1",
56
+ "@pwragent/owner-core": "workspace:*",
57
+ "esbuild": "0.28.2",
57
58
  "@pwragent/agent-tools": "workspace:*"
58
59
  },
59
60
  "dependencies": {
@@ -63,7 +64,7 @@
63
64
  "node-pty"
64
65
  ],
65
66
  "pwragentRelease": {
66
- "tag": "v1.3.9",
67
- "commit": "0fc28acd4318e55e172427b5a694c1a1dd096c15"
67
+ "tag": "v1.3.11",
68
+ "commit": "7b787f4a90bfff4811f1d41b04ae025f0cfe6ebf"
68
69
  }
69
70
  }
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import { setTimeout as delay } from "node:timers/promises";
6
6
  import { call } from "./client.js";
7
7
  import { serve } from "./daemon.js";
8
8
  import { ACTIVE } from "./store.js";
9
+ import { formatVersion, installedVersion, packageVersion, restartRequired } from "./version.js";
9
10
 
10
11
  const help = `PwrAgent Headless (experimental, Linux/macOS, Node >=22)
11
12
 
@@ -16,6 +17,7 @@ const help = `PwrAgent Headless (experimental, Linux/macOS, Node >=22)
16
17
  pwragent project-add|project-remove --path /workspace/project
17
18
  pwragent git-auth-configure --config-file /outpost/git-auth-settings.json
18
19
  pwragent status | list
20
+ pwragent --version | version
19
21
  pwragent submit --id JOB --prompt-file /task.txt [--sandbox read-only|workspace-write]
20
22
  [--model MODEL] [--thread THREAD_ID] [--cwd /workspace/project]
21
23
  pwragent show|read|interrupt|wait --id JOB
@@ -36,6 +38,7 @@ No provisioning, login, queue, automatic replay, or local messaging providers.
36
38
  export async function main(args) {
37
39
  const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
38
40
  help: { type: "boolean", short: "h" },
41
+ version: { type: "boolean" },
39
42
  profile: { type: "string" },
40
43
  workspace: { type: "string" },
41
44
  "codex-home": { type: "string" },
@@ -52,7 +55,9 @@ export async function main(args) {
52
55
  peer: { type: "string" },
53
56
  name: { type: "string" }, remote: { type: "string" }, path: { type: "string" }, cwd: { type: "string" }, "config-file": { type: "string" },
54
57
  } });
55
- if (values.help || !positionals.length) { console.log(help); return 0; }
58
+ if (values.help) { console.log(help); return 0; }
59
+ if (values.version) { console.log(formatVersion(packageVersion())); return 0; }
60
+ if (!positionals.length) { console.log(help); return 0; }
56
61
  if (positionals.length !== 1) throw new Error("Expected one command; see --help");
57
62
  const command = positionals[0];
58
63
  const allowed = {
@@ -60,7 +65,7 @@ export async function main(args) {
60
65
  submit: ["id", "prompt-file", "thread", "model", "sandbox", "cwd"],
61
66
  "project-list": [], "project-create": ["name", "label"], "project-clone": ["name", "remote", "label"],
62
67
  "project-add": ["path", "label"], "project-remove": ["path"], "git-auth-configure": ["config-file"],
63
- show: ["id"], read: ["id"], interrupt: ["id"], wait: ["id"], status: [], list: [],
68
+ show: ["id"], read: ["id"], interrupt: ["id"], wait: ["id"], status: [], list: [], version: [],
64
69
  "federation-join": ["invite-file", "gateway", "label"],
65
70
  "federation-invite": ["port", "label"],
66
71
  "federation-status": [], "federation-leave": [], "federation-revoke": ["peer"],
@@ -69,6 +74,7 @@ export async function main(args) {
69
74
  for (const option of Object.keys(values)) {
70
75
  if (option !== "profile" && !allowed[command].includes(option)) throw new Error(`--${option} does not apply to ${command}`);
71
76
  }
77
+ if (command === "version") { console.log(JSON.stringify(packageVersion())); return 0; }
72
78
  const profile = values.profile || process.env.PWRAGENT_PROFILE || "default";
73
79
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(profile)) throw new Error("Invalid profile name");
74
80
  const root = process.env.PWRAGENT_HOME || path.join(homedir(), ".pwragent");
@@ -118,6 +124,12 @@ export async function main(args) {
118
124
  await delay(1000);
119
125
  }
120
126
  }
127
+ if (command === "status") {
128
+ const status = await call(directory, "status", {});
129
+ const installed = installedVersion();
130
+ console.log(JSON.stringify({ ...status, installed, restartRequired: restartRequired(status.running, installed) }));
131
+ return 0;
132
+ }
121
133
  console.log(JSON.stringify(await call(directory, command, { id: values.id })));
122
134
  return 0;
123
135
  }
package/src/daemon.js CHANGED
@@ -116,7 +116,7 @@ export async function serve({ directory, workspace, home, command, profile }) {
116
116
  });
117
117
  if (shutdown) { server.close(); return await done; }
118
118
  server.on("error", (error) => { void stop(error); });
119
- console.log(JSON.stringify({ ready: true, socket: socketPath, pid: process.pid }));
119
+ console.log(JSON.stringify({ ready: true, socket: socketPath, pid: process.pid, running: runtime.running }));
120
120
  return await done;
121
121
  } catch (error) {
122
122
  await stop(error);
@@ -1,7 +1,6 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { readFileSync } from "node:fs";
3
2
  import path from "node:path";
4
- import { FEDERATION_BACKEND_METHOD_CAPABILITIES, FEDERATION_MAX_FRAME_BYTES, fitThreadReplayWithinByteBudget, mergeCausalMessagingContext, pageThreadReplay, shortenDerivedThreadTitle } from "../dist/federation-core.js";
3
+ import { buildThreadIdentityKey, NavigationQueryStore, FEDERATION_BACKEND_METHOD_CAPABILITIES, FEDERATION_MAX_FRAME_BYTES, fitThreadReplayWithinByteBudget, mergeCausalMessagingContext, pageThreadReplay, shortenDerivedThreadTitle } from "../dist/federation-core.js";
5
4
  import { ACTIVE, atomicJson, digest } from "./store.js";
6
5
 
7
6
  // The owner methods this outpost serves. Each one's capability comes from the
@@ -23,6 +22,11 @@ export function ownerMethodCapabilities(methods, contract = FEDERATION_BACKEND_M
23
22
  }
24
23
  export const METHOD_CAPABILITIES = ownerMethodCapabilities(OWNER_METHODS);
25
24
 
25
+ // The placeholder title desktop gives a new thread. Viewers recognize this
26
+ // exact text as "no name yet".
27
+ export const PLACEHOLDER_THREAD_TITLE = "Untitled thread";
28
+ const LEGACY_PLACEHOLDER_THREAD_TITLE = "New outpost thread";
29
+
26
30
  function mode(sandbox) { return sandbox === "workspace-write" ? "auto" : "default"; }
27
31
  function sandboxFor(params, fallback = "read-only") {
28
32
  if (params.executionMode && !["default", "auto", "full-access"].includes(params.executionMode)) throw new Error("Unsupported execution mode");
@@ -42,16 +46,24 @@ function textInput(input) {
42
46
  export class HeadlessBackend {
43
47
  constructor(runtime, publish) {
44
48
  this.runtime = runtime;
45
- this.publish = publish;
46
- this.epoch = randomUUID();
49
+ this.navigation = new NavigationQueryStore();
50
+ this.publish = (event, eventClass) => {
51
+ const thread = this.threads.get(event.notification.params.threadId);
52
+ // Acknowledging an older displayed update does not mark newer work read.
53
+ if (event.notification.method !== "navigation/thread/seen" || (thread && !this.summary(thread).inbox.inInbox)) {
54
+ this.navigation.observeAttentionEvent(event);
55
+ }
56
+ publish(event, eventClass);
57
+ };
47
58
  // Same headroom as desktop's FEDERATION_RESPONSE_BYTE_BUDGET.
48
59
  this.replayByteBudget = FEDERATION_MAX_FRAME_BYTES - 64 * 1024;
49
- this.attentionViews = new Map();
50
60
  this.file = path.join(path.dirname(runtime.store.directory), "threads.json");
51
61
  try {
52
62
  const data = JSON.parse(readFileSync(this.file, "utf8"));
53
63
  if (data.version !== 1 || !Array.isArray(data.threads)) throw new Error("Invalid headless thread registry");
54
- this.threads = new Map(data.threads.map((thread) => [thread.id, thread]));
64
+ this.threads = new Map(data.threads.map((thread) => [thread.id,
65
+ thread.titleSource === "fallback" && thread.title === LEGACY_PLACEHOLDER_THREAD_TITLE
66
+ ? { ...thread, title: PLACEHOLDER_THREAD_TITLE } : thread]));
55
67
  } catch (error) {
56
68
  if (error.code !== "ENOENT") throw error;
57
69
  this.threads = new Map();
@@ -113,7 +125,16 @@ export class HeadlessBackend {
113
125
  }
114
126
  if (message.method === "headless/jobChanged") {
115
127
  this.syncJobs([message.params]);
116
- if (message.params.threadId) this.forgetSeenAttentionMember(message.params.threadId);
128
+ const job = message.params;
129
+ if (job.threadId && this.latest(job.threadId) === job) {
130
+ const active = ACTIVE.has(job.status);
131
+ // An accepted job starts before Codex assigns its turn ID. Let the
132
+ // shared reducer adopt that ID without allocating another start rank.
133
+ this.navigation.observeAttentionEvent({ backend: "codex", notification: {
134
+ method: active ? "turn/started" : "turn/completed",
135
+ params: { threadId: job.threadId, turnId: job.turnId || (active ? undefined : job.id) },
136
+ } });
137
+ }
117
138
  this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId: message.params.threadId, sourceMethod: "turn/completed" } } }, "navigation");
118
139
  return;
119
140
  }
@@ -139,7 +160,11 @@ export class HeadlessBackend {
139
160
  switch (name) {
140
161
  case "listBackends": return this.listBackends();
141
162
  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) };
142
- case "getNavigationQueryPage": return this.query(params);
163
+ case "getNavigationQueryPage": {
164
+ if (params.inventory === "viewer") throw new Error("Expected owner navigation protocol 2");
165
+ return this.navigation.readPage({ request: params, scopeKey: envelope.sourceInstanceId || "local",
166
+ loadIndex: async () => this.navigationIndex() });
167
+ }
143
168
  case "getNavigationLaunchpadConfig": {
144
169
  if (params.protocol !== 2) throw new Error("Unsupported navigation protocol");
145
170
  if (params.directoryKey !== undefined) this.runtime.projects.resolve(params.directoryKey);
@@ -148,7 +173,7 @@ export class HeadlessBackend {
148
173
  }
149
174
  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: [] };
150
175
  case "getNavigationQueueProjection": return { protocol: 2, ref: params.ref, revision: "no-queue", readiness: "ready", complete: true, entries: [] };
151
- case "releaseNavigationAttentionView": this.attentionViews.delete(params.viewId); return {};
176
+ case "releaseNavigationAttentionView": this.navigation.releaseAttentionView(envelope.sourceInstanceId || "local", params.viewId); return {};
152
177
  case "resolveThread": return this.threads.has(params.threadId) ? { thread: this.summary(this.owned(params.threadId)) } : {};
153
178
  case "resolveThreadAdmissionState": {
154
179
  const thread = this.summary(this.owned(params.threadId));
@@ -210,7 +235,6 @@ export class HeadlessBackend {
210
235
  const thread = this.owned(params.threadId);
211
236
  const title = params.title || params.name;
212
237
  if (typeof title !== "string" || !title.trim() || title.length > 200) throw new Error("Invalid title");
213
- thread.updatedAt = Date.now();
214
238
  this.setTitle(thread, title.trim(), "explicit");
215
239
  return { backend: "codex", threadId: thread.id, title: thread.title };
216
240
  }
@@ -219,13 +243,17 @@ export class HeadlessBackend {
219
243
  for (const key of ["seenAt", "seenUpdatedAt"]) {
220
244
  if (params[key] !== undefined && (!Number.isFinite(params[key]) || params[key] < 0)) throw new Error(`Invalid ${key}`);
221
245
  }
222
- const seenUpdatedAt = params.seenUpdatedAt ?? this.summary(thread).updatedAt;
246
+ // An omitted watermark keeps the stored one (see MarkThreadSeenRequest).
247
+ // A thread never given one is pinned to the seenAt it was judged by,
248
+ // so recording a new seenAt cannot change its unread state.
249
+ thread.seenUpdatedAt = params.seenUpdatedAt ?? thread.seenUpdatedAt ?? thread.seenAt ?? 0;
223
250
  thread.seenAt = params.seenAt ?? Date.now();
224
- thread.seenUpdatedAt = seenUpdatedAt;
225
251
  this.persist();
226
- this.forgetSeenAttentionMember(thread.id);
227
- this.invalidate(thread.id);
228
- return { backend: "codex", threadId: thread.id, seenAt: thread.seenAt, seenUpdatedAt };
252
+ this.publish({ backend: "codex", notification: { method: "navigation/thread/seen",
253
+ params: { threadId: thread.id, ...(params.seenUpdatedAt !== undefined ? { seenUpdatedAt: params.seenUpdatedAt } : {}) } } }, "transcript");
254
+ this.invalidate(thread.id, "navigation/thread/seen");
255
+ return { backend: "codex", threadId: thread.id, seenAt: thread.seenAt,
256
+ ...(params.seenUpdatedAt !== undefined ? { seenUpdatedAt: params.seenUpdatedAt } : {}) };
229
257
  }
230
258
  case "setThreadExecutionMode": {
231
259
  const thread = this.owned(params.threadId);
@@ -315,11 +343,11 @@ export class HeadlessBackend {
315
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 });
316
344
  if (!result?.thread?.id || typeof result.thread.id !== "string" || result.thread.id.length > 256) throw new Error("Invalid thread/start response");
317
345
  this.runtime.loadedThreads.add(result.thread.id);
318
- const thread = { id: result.thread.id, messagingFileTools: true, title: "New outpost thread", titleSource: "fallback", createdAt: Date.now(), cwd, sandbox,
346
+ const thread = { id: result.thread.id, messagingFileTools: true, title: PLACEHOLDER_THREAD_TITLE, titleSource: "fallback", createdAt: Date.now(), cwd, sandbox,
319
347
  model: params.model, reasoningEffort: params.reasoningEffort, serviceTier: params.serviceTier };
320
348
  this.threads.set(thread.id, thread);
321
349
  this.persist();
322
- this.invalidate(thread.id);
350
+ this.invalidate(thread.id, "thread/started");
323
351
  return { backend: "codex", threadId: thread.id, executionMode: mode(sandbox) };
324
352
  } finally { this.creating = false; }
325
353
  }
@@ -388,120 +416,18 @@ export class HeadlessBackend {
388
416
  pagination: { supportsPagination: false, hasPreviousPage: false } } };
389
417
  }
390
418
 
391
- forgetSeenAttentionMember(id) {
392
- const thread = this.threads.get(id);
393
- if (!thread) return;
394
- const row = this.summary(thread);
395
- if (row.threadStatus !== "active" && !row.inbox.inInbox) {
396
- for (const view of this.attentionViews.values()) view.members.delete(id);
397
- }
398
- }
399
-
400
- attentionOrder(rows, options = { id: "default", promoteOnTurnEnd: true }) {
401
- if (typeof options.id !== "string" || !options.id || options.id.length > 256
402
- || typeof options.promoteOnTurnEnd !== "boolean") throw new Error("Invalid Attention view");
403
- let view = this.attentionViews.get(options.id);
404
- if (!view) {
405
- if (this.attentionViews.size >= 128) throw new Error("Attention view limit reached; release an unused view");
406
- view = { members: new Map(), nextRank: 1 };
407
- this.attentionViews.set(options.id, view);
408
- }
409
- const members = new Map();
410
- // Bootstrap oldest turn first. Subsequent ranks follow turn identities and
411
- // active/terminal transitions, never mutable thread or job activity stamps.
412
- const eligible = rows.filter((row) => row.threadStatus === "active" || row.inbox.inInbox)
413
- .sort((a, b) => (Date.parse(this.latest(a.id)?.createdAt) || a.createdAt)
414
- - (Date.parse(this.latest(b.id)?.createdAt) || b.createdAt) || a.id.localeCompare(b.id));
415
- for (const row of eligible) {
416
- const previous = view.members.get(row.id);
417
- const job = this.latest(row.id);
418
- const turnKey = job?.id;
419
- const active = row.threadStatus === "active";
420
- const newTurn = turnKey !== previous?.turnKey;
421
- const started = active && (!previous?.active || newTurn);
422
- const finished = !active && (previous?.active || newTurn);
423
- const rank = !previous || started || (finished && options.promoteOnTurnEnd) ? view.nextRank++ : previous.rank;
424
- members.set(row.id, { rank, active, turnKey });
425
- }
426
- view.members = members;
427
- return members;
428
- }
429
-
430
- query(request) {
431
- if (request.protocol !== 2 || !request.query || request.inventory === "viewer") throw new Error("Expected owner navigation protocol 2");
432
- let rows = [...this.threads.values()].filter((thread) => !thread.archived).map((thread) => this.summary(thread));
433
- const counts = { total: rows.length, active: rows.filter((row) => row.threadStatus === "active").length,
434
- unread: rows.filter((row) => row.inbox.inInbox).length, review: rows.filter((row) => row.inbox.inInbox && row.threadStatus !== "active").length, pinned: 0 };
435
- const directories = this.runtime.projects.list().map((project) => {
436
- const members = rows.filter((row) => row.projectKey === project.path);
437
- return { key: project.path, kind: "directory", label: project.label, path: project.path,
438
- counts: { total: members.length, active: members.filter((row) => row.threadStatus === "active").length,
439
- unread: members.filter((row) => row.inbox.inInbox).length, review: members.filter((row) => row.inbox.inInbox && row.threadStatus !== "active").length, pinned: 0 },
440
- pinnedRootCount: 0, unpinnedRootCount: members.length, launchpadPresent: true, launchpadBackend: "codex" };
441
- });
442
- const query = request.query;
443
- const attention = query.kind === "lens" && query.lens === "attention" ? this.attentionOrder(rows, request.attentionView) : undefined;
444
- 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");
445
- if (request.backend && !["codex", "all"].includes(request.backend)) rows = [];
446
- if (query.kind === "exact") rows = rows.filter((row) => query.identities?.some((ref) => ref.backend === "codex" && ref.threadId === row.id));
447
- else if (["children", "group-members"].includes(query.kind) || query.roots === "pinned") rows = [];
448
- else if (query.kind === "directory") rows = rows.filter((row) => row.projectKey === query.directoryKey);
449
- else if (query.kind === "lens" && query.lens === "attention") rows = rows.filter((row) => row.threadStatus === "active" || row.inbox.inInbox);
450
- if (query.agentOnly || (query.allowedBackends && !query.allowedBackends.includes("codex"))) rows = [];
451
- if (query.projectKey) rows = rows.filter((row) => row.projectKey === query.projectKey);
452
- if (query.directoryKey) rows = rows.filter((row) => row.projectKey === query.directoryKey);
453
- if (query.kind === "star-map") {
454
- for (const [key, selection] of Object.entries(query.filters || {})) {
455
- if (selection === "neutral") continue;
456
- rows = rows.filter((row) => {
457
- const matches = key === "attention" && (row.threadStatus === "active" || row.inbox.inInbox);
458
- return selection === "include" ? matches : !matches;
459
- });
460
- }
461
- }
462
- const filter = query.filter || query.text;
463
- if (filter) rows = rows.filter((row) => row.title.toLowerCase().includes(filter.toLowerCase()));
464
- const creationOrder = query.kind === "directory" || (query.kind === "lens" && query.lens === "recents");
465
- rows.sort((a, b) => (attention ? attention.get(b.id).rank - attention.get(a.id).rank
466
- : creationOrder ? b.createdAt - a.createdAt : b.updatedAt - a.updatedAt) || a.id.localeCompare(b.id));
467
- const queryKey = digest({ backend: request.backend, query, ...(attention ? { attentionView: request.attentionView } : {}) });
468
- const generation = digest(attention ? rows.map((row) => ({ ...row, attentionRank: attention.get(row.id).rank })) : rows);
469
- const countsRevision = digest(counts);
470
- let offset = 0;
471
- if (request.cursor) {
472
- const cursor = JSON.parse(Buffer.from(request.cursor, "base64url").toString());
473
- if (cursor.queryKey !== queryKey || cursor.generation !== generation || cursor.countsRevision !== countsRevision || cursor.epoch !== this.epoch
474
- || !Number.isInteger(cursor.offset) || cursor.offset < 0) throw new Error("Navigation cursor expired; refresh the collection");
475
- offset = cursor.offset;
476
- } else if (request.anchor?.kind === "thread") {
477
- const index = rows.findIndex((row) => row.id === request.anchor.ref.threadId && request.anchor.ref.backend === "codex");
478
- if (index >= 0) offset = index;
479
- }
480
- const limit = Math.min(100, Math.max(1, Number.isInteger(request.pageSize) ? request.pageSize : 50));
481
- const page = rows.slice(offset, offset + limit);
482
- const complete = offset + page.length >= rows.length;
483
- const result = { protocol: 2, queryKey, generation, ownerEpoch: this.epoch, countsRevision, coverage: { state: "complete" }, counts,
484
- collectionSize: rows.length, rangeStart: offset, complete,
485
- entries: page.map((row, index) => ({ row: { ...row, ref: { backend: "codex", threadId: row.id }, rowRevision: digest(row),
486
- ordinaryChildCount: 0, nativeSubAgentGroupPresent: false, queueCount: 0, queueState: "ready" }, orderKey: String(offset + index).padStart(8, "0"),
487
- ...(attention ? { attentionRank: attention.get(row.id).rank } : {}), placement: { kind: "root" } })),
488
- ...(!complete ? { nextCursor: Buffer.from(JSON.stringify({ queryKey, generation, countsRevision, epoch: this.epoch, offset: offset + page.length })).toString("base64url") } : {}) };
489
- if (["directory-index", "messaging-projects", "star-map-geometry"].includes(query.kind)) {
490
- result.directories = directories.filter((directory) => (!query.keys || query.keys.includes(directory.key)) && (!query.paths || query.paths.includes(directory.path))
491
- && (!query.directoryKey || query.directoryKey === directory.key)
492
- && (!query.allowedBackends || query.allowedBackends.includes("codex")) && !query.agentOnly
493
- && (!filter || `${directory.label} ${directory.path}`.toLowerCase().includes(filter.toLowerCase())));
494
- result.collectionSize = result.directories.length;
495
- result.entries = []; result.complete = true; delete result.nextCursor;
496
- }
497
- if (query.kind === "model-inventory") {
498
- const groups = new Map();
499
- 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 }); }
500
- result.modelGroups = [...groups.values()]; result.collectionSize = groups.size;
501
- result.entries = []; result.complete = true; delete result.nextCursor;
419
+ navigationIndex() {
420
+ const threads = [...this.threads.values()].filter((thread) => !thread.archived).map((thread) => this.summary(thread));
421
+ const byProject = new Map();
422
+ for (const thread of threads) {
423
+ let keys = byProject.get(thread.projectKey);
424
+ if (!keys) { keys = []; byProject.set(thread.projectKey, keys); }
425
+ keys.push(buildThreadIdentityKey(thread.source, thread.id));
502
426
  }
503
- if (query.kind === "directory") result.selectionDirectory = directories.find((directory) => query.directoryKey === directory.key);
504
- if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024) throw new Error("Navigation page exceeds its byte budget; request fewer rows");
505
- return result;
427
+ return { threads, directories: this.runtime.projects.list().map((project) => ({
428
+ key: project.path, kind: "directory", label: project.label, path: project.path,
429
+ threadKeys: byProject.get(project.path) || [], needsAttentionCount: 0,
430
+ launchpad: { backend: "codex" },
431
+ })) };
506
432
  }
507
433
  }
@@ -23,3 +23,6 @@ export { resolveMessagingOutboundFile, validateMessagingFileMetadata } from "@pw
23
23
 
24
24
  export * from "@pwragent/federation/federation-replacement-receiver";
25
25
  export { FEDERATION_BACKEND_METHOD_CAPABILITIES } from "@pwragent/federation/federation-backend-methods";
26
+
27
+ export { NavigationQueryStore } from "@pwragent/owner-core/navigation-query-store";
28
+ export { buildThreadIdentityKey, NAVIGATION_QUERY_MAX_RESULT_BYTES } from "@pwragent/shared";
package/src/federation.js CHANGED
@@ -142,12 +142,12 @@ export class HeadlessFederation {
142
142
  }
143
143
 
144
144
  async invite({ port = 47831, label }) {
145
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Federation port must be between 1 and 65535");
145
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("Federation port must be between 0 and 65535 (0 chooses an available port)");
146
146
  this.ensureIdentity(label);
147
147
  if (!this.router) this.setupRouter();
148
- if (this.state.listenPort !== undefined && this.state.listenPort !== port) throw new Error("Restart with the existing listener port; it cannot change while serving");
148
+ if (port !== 0 && this.state.listenPort !== undefined && this.state.listenPort !== port) throw new Error("Restart with the existing listener port; it cannot change while serving");
149
149
  await this.listen(port);
150
- this.state.listenPort = port;
150
+ this.state.listenPort = this.listenerAddress.port;
151
151
  const token = randomBytes(32).toString("base64url");
152
152
  const now = Date.now();
153
153
  const entry = { id: randomUUID(), status: "pending", generatedAt: now, expiresAt: now + 15 * 60_000,
package/src/runtime.js CHANGED
@@ -3,12 +3,16 @@ import path from "node:path";
3
3
  import { readFileSync, readdirSync } from "node:fs";
4
4
  import { AgentToolRouter, buildPwrAgentMessagingToolDefinitions, readPwrAgentMessagingDynamicToolCall, resolveMessagingOutboundFile, validateMessagingFileMetadata, FILE_PUSH_MAX_BYTES } from "../dist/federation-core.js";
5
5
  import { Codex } from "./codex.js";
6
+ import { installedVersion } from "./version.js";
6
7
  import { ACTIVE, atomicJson, digest, privateDirectory, validId } from "./store.js";
7
8
 
8
9
  export class Runtime {
9
10
  constructor({ store, workspace, home, command, onFailure }) {
10
11
  this.store = store;
11
12
  this.workspace = workspace;
13
+ // Snapshot at startup: upgrading the package files does not change the
14
+ // code this process is running.
15
+ this.running = installedVersion();
12
16
  this.projects = new OutpostProjects(path.dirname(store.directory), workspace);
13
17
  this.messagingTools = new AgentToolRouter(buildPwrAgentMessagingToolDefinitions((request) => this.messagingRequest(request), { nativeDynamicPdfImages: true }));
14
18
  this.fileAccess = { privateStorageRoots: [home, process.env.PWRAGENT_HOME].filter(Boolean),
@@ -41,7 +45,7 @@ export class Runtime {
41
45
  async handle(method, params = {}) {
42
46
  if (this.stopping) throw new Error("Daemon is stopping");
43
47
  switch (method) {
44
- case "status": return { version: 1, ready: true, pid: process.pid, activeJob: this.active || null, workspace: this.workspace };
48
+ case "status": return { version: 1, ready: true, pid: process.pid, running: this.running, activeJob: this.active || null, workspace: this.workspace };
45
49
  case "project-list": return this.projects.list();
46
50
  case "project-create": case "project-clone": return this.projects.create(params);
47
51
  case "project-add": return this.projects.add(params);
package/src/version.js ADDED
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ // The release writes pwragentRelease: { tag, commit } into the published
4
+ // manifest. A checkout has none, so only string fields are reported.
5
+ export function versionInfo(pkg) {
6
+ const source = pkg.pwragentRelease;
7
+ const release = {};
8
+ if (typeof source?.tag === "string") release.tag = source.tag;
9
+ if (typeof source?.commit === "string") release.commit = source.commit;
10
+ return { name: pkg.name, version: pkg.version, ...(Object.keys(release).length ? { release } : {}) };
11
+ }
12
+
13
+ // Read on each call from this package's own manifest, so the CLI reports the
14
+ // files currently installed. The daemon reads it once at startup instead.
15
+ export function packageVersion() {
16
+ return versionInfo(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")));
17
+ }
18
+
19
+ // The version fields that status compares, without the package name.
20
+ export function installedVersion() {
21
+ const { name: _name, ...fields } = packageVersion();
22
+ return fields;
23
+ }
24
+
25
+ export function formatVersion({ name, version, release }) {
26
+ const details = [release?.tag, release?.commit && `commit ${release.commit}`].filter(Boolean);
27
+ return details.length ? `${name} ${version} (${details.join(", ")})` : `${name} ${version}`;
28
+ }
29
+
30
+ // A daemon that predates version reporting returns no running version. It is
31
+ // older than this installation, so it needs a restart too.
32
+ export function restartRequired(running, installed) {
33
+ return !running
34
+ || running.version !== installed.version
35
+ || running.release?.commit !== installed.release?.commit;
36
+ }