pwragent 1.3.10 → 1.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/federation-core.js +1827 -31
- package/package.json +4 -3
- package/src/federation-backend.js +38 -124
- package/src/federation-core.ts +3 -0
- package/src/federation.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pwragent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.12",
|
|
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.
|
|
67
|
-
"commit": "
|
|
67
|
+
"tag": "v1.3.12",
|
|
68
|
+
"commit": "7654f26215b4b5e47f70cc3fe682fd6e3d39c7f8"
|
|
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.
|
|
51
|
-
this.
|
|
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
|
-
|
|
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":
|
|
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.
|
|
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
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
}
|
package/src/federation-core.ts
CHANGED
|
@@ -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 <
|
|
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,
|