pwragent 1.3.10 → 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.10",
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,6 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@pwragent/federation": "workspace:*",
55
55
  "@pwragent/shared": "workspace:*",
56
+ "@pwragent/owner-core": "workspace:*",
56
57
  "esbuild": "0.28.2",
57
58
  "@pwragent/agent-tools": "workspace:*"
58
59
  },
@@ -63,7 +64,7 @@
63
64
  "node-pty"
64
65
  ],
65
66
  "pwragentRelease": {
66
- "tag": "v1.3.10",
67
- "commit": "ff9b702144786e162b3e91c78d79791761944350"
67
+ "tag": "v1.3.11",
68
+ "commit": "7b787f4a90bfff4811f1d41b04ae025f0cfe6ebf"
68
69
  }
69
70
  }
@@ -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
@@ -47,11 +46,17 @@ function textInput(input) {
47
46
  export class HeadlessBackend {
48
47
  constructor(runtime, publish) {
49
48
  this.runtime = runtime;
50
- this.publish = publish;
51
- 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
+ };
52
58
  // Same headroom as desktop's FEDERATION_RESPONSE_BYTE_BUDGET.
53
59
  this.replayByteBudget = FEDERATION_MAX_FRAME_BYTES - 64 * 1024;
54
- this.attentionViews = new Map();
55
60
  this.file = path.join(path.dirname(runtime.store.directory), "threads.json");
56
61
  try {
57
62
  const data = JSON.parse(readFileSync(this.file, "utf8"));
@@ -120,7 +125,16 @@ export class HeadlessBackend {
120
125
  }
121
126
  if (message.method === "headless/jobChanged") {
122
127
  this.syncJobs([message.params]);
123
- 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
+ }
124
138
  this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId: message.params.threadId, sourceMethod: "turn/completed" } } }, "navigation");
125
139
  return;
126
140
  }
@@ -146,7 +160,11 @@ export class HeadlessBackend {
146
160
  switch (name) {
147
161
  case "listBackends": return this.listBackends();
148
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) };
149
- 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
+ }
150
168
  case "getNavigationLaunchpadConfig": {
151
169
  if (params.protocol !== 2) throw new Error("Unsupported navigation protocol");
152
170
  if (params.directoryKey !== undefined) this.runtime.projects.resolve(params.directoryKey);
@@ -155,7 +173,7 @@ export class HeadlessBackend {
155
173
  }
156
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: [] };
157
175
  case "getNavigationQueueProjection": return { protocol: 2, ref: params.ref, revision: "no-queue", readiness: "ready", complete: true, entries: [] };
158
- case "releaseNavigationAttentionView": this.attentionViews.delete(params.viewId); return {};
176
+ case "releaseNavigationAttentionView": this.navigation.releaseAttentionView(envelope.sourceInstanceId || "local", params.viewId); return {};
159
177
  case "resolveThread": return this.threads.has(params.threadId) ? { thread: this.summary(this.owned(params.threadId)) } : {};
160
178
  case "resolveThreadAdmissionState": {
161
179
  const thread = this.summary(this.owned(params.threadId));
@@ -217,7 +235,6 @@ export class HeadlessBackend {
217
235
  const thread = this.owned(params.threadId);
218
236
  const title = params.title || params.name;
219
237
  if (typeof title !== "string" || !title.trim() || title.length > 200) throw new Error("Invalid title");
220
- thread.updatedAt = Date.now();
221
238
  this.setTitle(thread, title.trim(), "explicit");
222
239
  return { backend: "codex", threadId: thread.id, title: thread.title };
223
240
  }
@@ -232,7 +249,6 @@ export class HeadlessBackend {
232
249
  thread.seenUpdatedAt = params.seenUpdatedAt ?? thread.seenUpdatedAt ?? thread.seenAt ?? 0;
233
250
  thread.seenAt = params.seenAt ?? Date.now();
234
251
  this.persist();
235
- this.forgetSeenAttentionMember(thread.id);
236
252
  this.publish({ backend: "codex", notification: { method: "navigation/thread/seen",
237
253
  params: { threadId: thread.id, ...(params.seenUpdatedAt !== undefined ? { seenUpdatedAt: params.seenUpdatedAt } : {}) } } }, "transcript");
238
254
  this.invalidate(thread.id, "navigation/thread/seen");
@@ -400,120 +416,18 @@ export class HeadlessBackend {
400
416
  pagination: { supportsPagination: false, hasPreviousPage: false } } };
401
417
  }
402
418
 
403
- forgetSeenAttentionMember(id) {
404
- const thread = this.threads.get(id);
405
- if (!thread) return;
406
- const row = this.summary(thread);
407
- if (row.threadStatus !== "active" && !row.inbox.inInbox) {
408
- for (const view of this.attentionViews.values()) view.members.delete(id);
409
- }
410
- }
411
-
412
- attentionOrder(rows, options = { id: "default", promoteOnTurnEnd: true }) {
413
- if (typeof options.id !== "string" || !options.id || options.id.length > 256
414
- || typeof options.promoteOnTurnEnd !== "boolean") throw new Error("Invalid Attention view");
415
- let view = this.attentionViews.get(options.id);
416
- if (!view) {
417
- if (this.attentionViews.size >= 128) throw new Error("Attention view limit reached; release an unused view");
418
- view = { members: new Map(), nextRank: 1 };
419
- this.attentionViews.set(options.id, view);
420
- }
421
- const members = new Map();
422
- // Bootstrap oldest turn first. Subsequent ranks follow turn identities and
423
- // active/terminal transitions, never mutable thread or job activity stamps.
424
- const eligible = rows.filter((row) => row.threadStatus === "active" || row.inbox.inInbox)
425
- .sort((a, b) => (Date.parse(this.latest(a.id)?.createdAt) || a.createdAt)
426
- - (Date.parse(this.latest(b.id)?.createdAt) || b.createdAt) || a.id.localeCompare(b.id));
427
- for (const row of eligible) {
428
- const previous = view.members.get(row.id);
429
- const job = this.latest(row.id);
430
- const turnKey = job?.id;
431
- const active = row.threadStatus === "active";
432
- const newTurn = turnKey !== previous?.turnKey;
433
- const started = active && (!previous?.active || newTurn);
434
- const finished = !active && (previous?.active || newTurn);
435
- const rank = !previous || started || (finished && options.promoteOnTurnEnd) ? view.nextRank++ : previous.rank;
436
- members.set(row.id, { rank, active, turnKey });
437
- }
438
- view.members = members;
439
- return members;
440
- }
441
-
442
- query(request) {
443
- if (request.protocol !== 2 || !request.query || request.inventory === "viewer") throw new Error("Expected owner navigation protocol 2");
444
- let rows = [...this.threads.values()].filter((thread) => !thread.archived).map((thread) => this.summary(thread));
445
- const counts = { total: rows.length, active: rows.filter((row) => row.threadStatus === "active").length,
446
- unread: rows.filter((row) => row.inbox.inInbox).length, review: rows.filter((row) => row.inbox.inInbox && row.threadStatus !== "active").length, pinned: 0 };
447
- const directories = this.runtime.projects.list().map((project) => {
448
- const members = rows.filter((row) => row.projectKey === project.path);
449
- return { key: project.path, kind: "directory", label: project.label, path: project.path,
450
- counts: { total: members.length, active: members.filter((row) => row.threadStatus === "active").length,
451
- unread: members.filter((row) => row.inbox.inInbox).length, review: members.filter((row) => row.inbox.inInbox && row.threadStatus !== "active").length, pinned: 0 },
452
- pinnedRootCount: 0, unpinnedRootCount: members.length, launchpadPresent: true, launchpadBackend: "codex" };
453
- });
454
- const query = request.query;
455
- const attention = query.kind === "lens" && query.lens === "attention" ? this.attentionOrder(rows, request.attentionView) : undefined;
456
- 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");
457
- if (request.backend && !["codex", "all"].includes(request.backend)) rows = [];
458
- if (query.kind === "exact") rows = rows.filter((row) => query.identities?.some((ref) => ref.backend === "codex" && ref.threadId === row.id));
459
- else if (["children", "group-members"].includes(query.kind) || query.roots === "pinned") rows = [];
460
- else if (query.kind === "directory") rows = rows.filter((row) => row.projectKey === query.directoryKey);
461
- else if (query.kind === "lens" && query.lens === "attention") rows = rows.filter((row) => row.threadStatus === "active" || row.inbox.inInbox);
462
- if (query.agentOnly || (query.allowedBackends && !query.allowedBackends.includes("codex"))) rows = [];
463
- if (query.projectKey) rows = rows.filter((row) => row.projectKey === query.projectKey);
464
- if (query.directoryKey) rows = rows.filter((row) => row.projectKey === query.directoryKey);
465
- if (query.kind === "star-map") {
466
- for (const [key, selection] of Object.entries(query.filters || {})) {
467
- if (selection === "neutral") continue;
468
- rows = rows.filter((row) => {
469
- const matches = key === "attention" && (row.threadStatus === "active" || row.inbox.inInbox);
470
- return selection === "include" ? matches : !matches;
471
- });
472
- }
473
- }
474
- const filter = query.filter || query.text;
475
- if (filter) rows = rows.filter((row) => row.title.toLowerCase().includes(filter.toLowerCase()));
476
- const creationOrder = query.kind === "directory" || (query.kind === "lens" && query.lens === "recents");
477
- rows.sort((a, b) => (attention ? attention.get(b.id).rank - attention.get(a.id).rank
478
- : creationOrder ? b.createdAt - a.createdAt : b.updatedAt - a.updatedAt) || a.id.localeCompare(b.id));
479
- const queryKey = digest({ backend: request.backend, query, ...(attention ? { attentionView: request.attentionView } : {}) });
480
- const generation = digest(attention ? rows.map((row) => ({ ...row, attentionRank: attention.get(row.id).rank })) : rows);
481
- const countsRevision = digest(counts);
482
- let offset = 0;
483
- if (request.cursor) {
484
- const cursor = JSON.parse(Buffer.from(request.cursor, "base64url").toString());
485
- if (cursor.queryKey !== queryKey || cursor.generation !== generation || cursor.countsRevision !== countsRevision || cursor.epoch !== this.epoch
486
- || !Number.isInteger(cursor.offset) || cursor.offset < 0) throw new Error("Navigation cursor expired; refresh the collection");
487
- offset = cursor.offset;
488
- } else if (request.anchor?.kind === "thread") {
489
- const index = rows.findIndex((row) => row.id === request.anchor.ref.threadId && request.anchor.ref.backend === "codex");
490
- if (index >= 0) offset = index;
491
- }
492
- const limit = Math.min(100, Math.max(1, Number.isInteger(request.pageSize) ? request.pageSize : 50));
493
- const page = rows.slice(offset, offset + limit);
494
- const complete = offset + page.length >= rows.length;
495
- const result = { protocol: 2, queryKey, generation, ownerEpoch: this.epoch, countsRevision, coverage: { state: "complete" }, counts,
496
- collectionSize: rows.length, rangeStart: offset, complete,
497
- entries: page.map((row, index) => ({ row: { ...row, ref: { backend: "codex", threadId: row.id }, rowRevision: digest(row),
498
- ordinaryChildCount: 0, nativeSubAgentGroupPresent: false, queueCount: 0, queueState: "ready" }, orderKey: String(offset + index).padStart(8, "0"),
499
- ...(attention ? { attentionRank: attention.get(row.id).rank } : {}), placement: { kind: "root" } })),
500
- ...(!complete ? { nextCursor: Buffer.from(JSON.stringify({ queryKey, generation, countsRevision, epoch: this.epoch, offset: offset + page.length })).toString("base64url") } : {}) };
501
- if (["directory-index", "messaging-projects", "star-map-geometry"].includes(query.kind)) {
502
- result.directories = directories.filter((directory) => (!query.keys || query.keys.includes(directory.key)) && (!query.paths || query.paths.includes(directory.path))
503
- && (!query.directoryKey || query.directoryKey === directory.key)
504
- && (!query.allowedBackends || query.allowedBackends.includes("codex")) && !query.agentOnly
505
- && (!filter || `${directory.label} ${directory.path}`.toLowerCase().includes(filter.toLowerCase())));
506
- result.collectionSize = result.directories.length;
507
- result.entries = []; result.complete = true; delete result.nextCursor;
508
- }
509
- if (query.kind === "model-inventory") {
510
- const groups = new Map();
511
- 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 }); }
512
- result.modelGroups = [...groups.values()]; result.collectionSize = groups.size;
513
- 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));
514
426
  }
515
- if (query.kind === "directory") result.selectionDirectory = directories.find((directory) => query.directoryKey === directory.key);
516
- if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024) throw new Error("Navigation page exceeds its byte budget; request fewer rows");
517
- 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
+ })) };
518
432
  }
519
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,