pwragent 1.3.8 → 1.3.10

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/README.md CHANGED
@@ -26,12 +26,12 @@ Use an exact version to match a desktop installation. See the
26
26
  and publication verification.
27
27
 
28
28
  ```sh
29
- npm install --global pwragent@next
29
+ npm install --global pwragent@latest
30
30
  pwragent --help
31
- # Update to the newest headless prerelease:
31
+ # Or choose a reproducible version that matches your desktop:
32
+ npm install --global pwragent@1.3.7
33
+ # Prerelease desktop versions publish to next, once one has shipped:
32
34
  npm install --global pwragent@next
33
- # Or choose a reproducible version:
34
- npm install --global pwragent@1.3.6
35
35
  ```
36
36
 
37
37
  Provide **Node >=22 and npm separately**, on Linux (macOS is also supported for
@@ -50,6 +50,19 @@ stop the daemon, install the selected version, and start it again. Do not remove
50
50
  the PwrAgent root, Codex home or workspace during updates. There is no automatic
51
51
  CLI updater or automatic systemd enablement.
52
52
 
53
+ `pwragent --version` prints the installed package version. A published package
54
+ also prints its release tag and source commit, for example
55
+ `pwragent 1.3.8 (v1.3.8, commit 0123…)`. `pwragent version` prints the same
56
+ fields as JSON. Neither needs a running daemon.
57
+
58
+ `pwragent status` reports the daemon's version as `running`, read when that
59
+ daemon started, and the package files as `installed`. `restartRequired` is
60
+ `true` when they differ, so the update has not taken effect yet. A daemon
61
+ started before version reporting has no `running` field and also reports
62
+ `true`. Run `status` from the same installation as the daemon, such as the
63
+ `/opt/pwragent` path in the systemd example; another installation compares its
64
+ own files.
65
+
53
66
  ## systemd installation example
54
67
 
55
68
  On a Linux host with systemd, an administrator must first create the dedicated
@@ -70,7 +83,7 @@ shell's Node version-manager initialization does not run under systemd.
70
83
  After publication, install a pinned version into the service prefix:
71
84
 
72
85
  ```sh
73
- sudo npm install --prefix /opt/pwragent --omit=dev --ignore-scripts pwragent@1.3.6
86
+ sudo npm install --prefix /opt/pwragent --omit=dev --ignore-scripts pwragent@1.3.7
74
87
  sudo install -m 0644 /opt/pwragent/node_modules/pwragent/examples/pwragent-headless.service /etc/systemd/system/pwragent-headless.service
75
88
  # Review the account, paths, profile and Codex configuration before enabling:
76
89
  sudo systemctl daemon-reload
@@ -90,8 +103,9 @@ sudo -u pwragent env PWRAGENT_HOME=/var/lib/pwragent PWRAGENT_PROFILE=outpost \
90
103
 
91
104
  For a service update, finish or interrupt active work, run
92
105
  `sudo systemctl stop pwragent-headless.service`, repeat the prefix installation
93
- with the chosen new version (or `pwragent@next`), then run
94
- `sudo systemctl start pwragent-headless.service`. Compare the new example unit
106
+ with the chosen new version (or `pwragent@latest`), then run
107
+ `sudo systemctl start pwragent-headless.service`. Run the `status` command above
108
+ and check that `restartRequired` is `false`. Compare the new example unit
95
109
  with the installed one before deliberately updating it; npm does not overwrite
96
110
  `/etc/systemd/system`. Stopping during a turn can leave its outcome `unknown`.
97
111
 
@@ -280,6 +294,15 @@ its `directoryKey`/`cwd`; omitting it selects the configured workspace root. Sel
280
294
  the defaults remain local and read-only. Launchpad requests for worktrees,
281
295
  grouping, environments or provider extensions are rejected before creation.
282
296
 
297
+ A new thread is named from its first user message, shortened the way desktop
298
+ names a thread before any helper-model title. The outpost has no helper model.
299
+ The name is stored in `threads.json`, and subscribed viewers receive
300
+ `thread/name/updated` with `titleSource: derived`, followed by a navigation
301
+ invalidation. A later turn never renames a thread. An explicit `renameThread`
302
+ always wins and is never replaced. A thread that still has a placeholder name,
303
+ such as one created before this release, takes Codex's `preview` the next time
304
+ it is read. The outpost does not set the Codex thread name.
305
+
283
306
  ## Identity, compute lifetime and remaining scope
284
307
 
285
308
  The private `federation.json` stores the retained peer ID, signing/Noise keys,
@@ -349,6 +372,7 @@ register a project. Enrollment and Git intake do not import the sender's Git or
349
372
 
350
373
  | Service | Required grants | Supported behavior |
351
374
  | --- | --- | --- |
375
+ | `backend.*` | Per method, from desktop's owner table in `@pwragent/federation` | Navigation reads need `thread_navigation`; thread detail, queue projection, launchpad config and `listBackends` need `thread_detail`; turns need `turn_control`; launchpads need `environment_actions` |
352
376
  | `outpost.projects.list` | `thread_navigation` | Owner project inventory |
353
377
  | `outpost.projects.create/add/remove` | `environment_actions` | Create/clone, register and unregister projects |
354
378
  | `pty.*` | `remote_pty` | Input, resize, ack flow control, snapshots, history and close |
@@ -465,6 +489,18 @@ against the daemon. External providers and Codex are mocked; no live peers,
465
489
  operator projects, credentials or compute machines are used. Ubuntu CI runs the
466
490
  Node suite; the desktop contract test runs on POSIX lanes.
467
491
 
492
+ The owner conformance suite
493
+ (`apps/desktop/src/main/__tests__/owner-conformance.test.ts`) runs one thread's
494
+ life through the owner methods both hosts implement. It runs on this daemon and
495
+ on desktop's real owner, and compares each response and event. Each known
496
+ difference is a line in `owner-conformance-baseline.json` beside it, so the
497
+ suite fails on any new difference and names its method. After fixing a
498
+ difference, or changing the contract on purpose, record the result:
499
+
500
+ ```sh
501
+ UPDATE_OWNER_CONFORMANCE=1 pnpm test apps/desktop/src/main/__tests__/owner-conformance.test.ts
502
+ ```
503
+
468
504
  A separate live smoke test installed the packed tarball and official Codex
469
505
  0.159.2 in a Node 24 Debian Linux arm64 Docker image. It used an unprivileged
470
506
  user, a read-only authentication-file mount, container-owned state and workspace,
@@ -9284,7 +9284,21 @@ var FEDERATION_CAPABILITIES = [
9284
9284
  * turn's reply, its images materialized. A messaging gateway without it
9285
9285
  * reads a transcript page at every bound turn end.
9286
9286
  */
9287
- "turn_assistant_reply"
9287
+ "turn_assistant_reply",
9288
+ /**
9289
+ * The owner answers `backend.manageThreadPrWatchesV1`: a viewer reads a
9290
+ * thread's watch history, stops a watch, and arms Watch CI there. Its
9291
+ * navigation rows also carry `prCiWatches`. A viewer shows an older
9292
+ * owner's watches read-only.
9293
+ */
9294
+ "thread_pr_watch_control_v1",
9295
+ /**
9296
+ * The owner sends each of its Directories projects' Forge Automation on
9297
+ * its rows and answers `backend.setProjectForgeAutomation`,
9298
+ * `backend.listProjectHeldWakeups` and `backend.resolveProjectHeldWakeups`.
9299
+ * A viewer offers no Forge Automation menu items without it.
9300
+ */
9301
+ "project_forge_automation"
9288
9302
  ];
9289
9303
  var FEDERATION_ENDPOINT_SCHEMES = ["ws", "wss", "ssh"];
9290
9304
  function parseFederationGatewayEndpoint(value) {
@@ -9358,6 +9372,19 @@ var PWRAGENT_MESSAGING_CALLABLE_OPERATION_NAMES = [
9358
9372
  ...PWRAGENT_MESSAGING_LEGACY_OPERATION_NAMES
9359
9373
  ];
9360
9374
 
9375
+ // ../shared/src/contracts/thread-todos.ts
9376
+ var THREAD_TODO_TITLE_MAX_LENGTH = 120;
9377
+ var THREAD_TODO_ORIGIN_ID_MAX_LENGTH = 200;
9378
+ function threadTodoMessageOriginFromUnknown(value) {
9379
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
9380
+ const record = value;
9381
+ if (typeof record.id !== "string") return void 0;
9382
+ const id = record.id.trim();
9383
+ if (!id || id.length > THREAD_TODO_ORIGIN_ID_MAX_LENGTH) return void 0;
9384
+ const title = typeof record.title === "string" ? record.title.trim().slice(0, THREAD_TODO_TITLE_MAX_LENGTH).trim() : "";
9385
+ return { id, ...title ? { title } : {} };
9386
+ }
9387
+
9361
9388
  // ../shared/src/directory-navigation.ts
9362
9389
  function normalizeGitOriginUrl(value) {
9363
9390
  const trimmed = value?.trim();
@@ -9459,6 +9486,61 @@ async function readStatusWithoutNestedWorktrees(options) {
9459
9486
  return await options.git([...options.statusArgs, ...nestedWorktreePathspecArgs(excludes)]);
9460
9487
  }
9461
9488
 
9489
+ // ../shared/src/thread-titles.ts
9490
+ var MAX_DERIVED_THREAD_TITLE_LENGTH = 72;
9491
+ var MIN_PREFERRED_BREAK_INDEX = 36;
9492
+ var LEADING_SKILL_LINK_RE = /^(?:\[\$[^\]]+\]\([^)]+\)\s*)+/i;
9493
+ var LEADING_SKILL_TOKEN_RE = /^(?:\$[\w:-]+\s+)+/i;
9494
+ var LEADING_REQUEST_PREFIX_RE = /^(?:i need(?: you to)?|can you|could you|would you|please|help me|let's|show me|tell me)\s+/i;
9495
+ function trimTrailingPunctuation(value) {
9496
+ return value.replace(/[\s,;:.-]+$/g, "").trim();
9497
+ }
9498
+ function uppercaseFirstLetter(value) {
9499
+ if (!value) {
9500
+ return value;
9501
+ }
9502
+ return value[0].toUpperCase() + value.slice(1);
9503
+ }
9504
+ function normalizeTitleInput(value) {
9505
+ const trimmed = value?.replace(/\s+/g, " ").trim();
9506
+ if (!trimmed) {
9507
+ return void 0;
9508
+ }
9509
+ const withoutSkillLinks = trimmed.replace(LEADING_SKILL_LINK_RE, "");
9510
+ const withoutSkillTokens = withoutSkillLinks.replace(LEADING_SKILL_TOKEN_RE, "");
9511
+ return withoutSkillTokens || trimmed;
9512
+ }
9513
+ function shortenDerivedThreadTitle(value) {
9514
+ const normalized = normalizeTitleInput(value);
9515
+ if (!normalized) {
9516
+ return void 0;
9517
+ }
9518
+ const requestTrimmed = normalized.replace(LEADING_REQUEST_PREFIX_RE, "");
9519
+ const candidateBase = requestTrimmed && requestTrimmed.length < normalized.length ? uppercaseFirstLetter(requestTrimmed) : normalized;
9520
+ const candidate = trimTrailingPunctuation(candidateBase);
9521
+ if (candidate.length <= MAX_DERIVED_THREAD_TITLE_LENGTH) {
9522
+ return candidate;
9523
+ }
9524
+ const breakpointWindow = candidate.slice(0, MAX_DERIVED_THREAD_TITLE_LENGTH + 1);
9525
+ const punctuationBreaks = [
9526
+ breakpointWindow.lastIndexOf(". "),
9527
+ breakpointWindow.lastIndexOf("? "),
9528
+ breakpointWindow.lastIndexOf("! "),
9529
+ breakpointWindow.lastIndexOf(": "),
9530
+ breakpointWindow.lastIndexOf("; "),
9531
+ breakpointWindow.lastIndexOf(", ")
9532
+ ];
9533
+ const punctuationBreak = Math.max(...punctuationBreaks);
9534
+ if (punctuationBreak >= MIN_PREFERRED_BREAK_INDEX) {
9535
+ return `${trimTrailingPunctuation(candidate.slice(0, punctuationBreak))}...`;
9536
+ }
9537
+ const wordBreak = breakpointWindow.lastIndexOf(" ");
9538
+ if (wordBreak >= MIN_PREFERRED_BREAK_INDEX) {
9539
+ return `${trimTrailingPunctuation(candidate.slice(0, wordBreak))}...`;
9540
+ }
9541
+ return `${trimTrailingPunctuation(candidate.slice(0, MAX_DERIVED_THREAD_TITLE_LENGTH))}...`;
9542
+ }
9543
+
9462
9544
  // ../shared/src/messaging-causal-context.ts
9463
9545
  function mergeCausalMessagingContext(origins) {
9464
9546
  const sources = origins.flatMap((origin) => origin?.causalMessagingSource ? [origin.causalMessagingSource] : []);
@@ -9468,6 +9550,215 @@ function mergeCausalMessagingContext(origins) {
9468
9550
  return ambiguous ? { causalMessagingSource: void 0, causalMessagingAmbiguous: true } : { causalMessagingSource: sources[sources.length - 1], causalMessagingAmbiguous: void 0 };
9469
9551
  }
9470
9552
 
9553
+ // ../shared/src/thread-replay-paging.ts
9554
+ function pageThreadReplay(replay, options, hooks = {}) {
9555
+ if (options.includeTurns === false) {
9556
+ return {
9557
+ ...replay,
9558
+ entries: [],
9559
+ messages: [],
9560
+ lastUserMessage: void 0,
9561
+ lastAssistantMessage: void 0,
9562
+ pagination: {
9563
+ supportsPagination: false,
9564
+ hasPreviousPage: false
9565
+ }
9566
+ };
9567
+ }
9568
+ if (options.limit === void 0 && options.before === void 0) {
9569
+ return replay;
9570
+ }
9571
+ const beforeIndex = options.before !== void 0 ? replay.entries.findIndex((entry) => entry.id === options.before) : replay.entries.length;
9572
+ if (beforeIndex < 0) {
9573
+ hooks.onUnresolvedCursor?.();
9574
+ return {
9575
+ ...replay,
9576
+ entries: [],
9577
+ messages: [],
9578
+ lastUserMessage: void 0,
9579
+ lastAssistantMessage: void 0,
9580
+ pagination: {
9581
+ supportsPagination: true,
9582
+ hasPreviousPage: false
9583
+ }
9584
+ };
9585
+ }
9586
+ const limit = options.limit === void 0 ? void 0 : Math.max(0, Math.floor(options.limit));
9587
+ const startIndex = limit === void 0 ? 0 : findTurnPageStartIndex(replay.entries, beforeIndex, limit);
9588
+ const entries = replay.entries.slice(startIndex, beforeIndex);
9589
+ return replayWithEntries(
9590
+ replay,
9591
+ entries,
9592
+ mintEntryCursorPagination(entries, startIndex > 0, hooks.isCursorEntry)
9593
+ );
9594
+ }
9595
+ function findTurnPageStartIndex(entries, endIndex, limit) {
9596
+ if (limit === 0) {
9597
+ return endIndex;
9598
+ }
9599
+ for (let index = 0; index < endIndex; index += 1) {
9600
+ if (!entries[index]?.turn?.id) {
9601
+ return Math.max(0, endIndex - limit);
9602
+ }
9603
+ }
9604
+ const turnIds = /* @__PURE__ */ new Set();
9605
+ let oldestRetainedTurnId;
9606
+ for (let index = endIndex - 1; index >= 0; index -= 1) {
9607
+ const turnId = entries[index]?.turn?.id;
9608
+ if (!turnId || turnIds.has(turnId)) {
9609
+ continue;
9610
+ }
9611
+ if (turnIds.size >= limit) {
9612
+ break;
9613
+ }
9614
+ turnIds.add(turnId);
9615
+ oldestRetainedTurnId = turnId;
9616
+ }
9617
+ if (!oldestRetainedTurnId) {
9618
+ return Math.max(0, endIndex - limit);
9619
+ }
9620
+ if (turnIds.size < limit) {
9621
+ return 0;
9622
+ }
9623
+ const firstTurnEntryIndex = entries.findIndex(
9624
+ (entry, index) => index < endIndex && entry.turn?.id === oldestRetainedTurnId
9625
+ );
9626
+ return firstTurnEntryIndex >= 0 ? firstTurnEntryIndex : 0;
9627
+ }
9628
+ function mintEntryCursorPagination(entries, hasPreviousPage, isCursorEntry = () => true) {
9629
+ const cursorEntry = hasPreviousPage ? entries.find(isCursorEntry) : void 0;
9630
+ return {
9631
+ supportsPagination: true,
9632
+ hasPreviousPage: cursorEntry !== void 0,
9633
+ ...cursorEntry ? { previousCursor: cursorEntry.id } : {}
9634
+ };
9635
+ }
9636
+ function replayWithEntries(replay, entries, pagination) {
9637
+ const messages = entries.flatMap(
9638
+ (entry) => entry.type === "message" ? [
9639
+ {
9640
+ id: entry.id,
9641
+ role: entry.role,
9642
+ text: entry.text,
9643
+ ...entry.parts ? { parts: entry.parts } : {},
9644
+ ...entry.origin ? { origin: entry.origin } : {},
9645
+ ...entry.createdAt ? { createdAt: entry.createdAt } : {}
9646
+ }
9647
+ ] : []
9648
+ );
9649
+ const lastUserMessage = messages.findLast((message) => message.role === "user")?.text;
9650
+ const lastAssistantMessage = messages.findLast(
9651
+ (message) => message.role === "assistant"
9652
+ )?.text;
9653
+ return {
9654
+ ...replay,
9655
+ entries,
9656
+ messages,
9657
+ lastUserMessage,
9658
+ lastAssistantMessage,
9659
+ pagination
9660
+ };
9661
+ }
9662
+ var FEDERATION_OMITTED_ENTRY_TEXT = "Content omitted because this page exceeded the federation frame limit.";
9663
+ function fitThreadReplayWithinByteBudget(params) {
9664
+ if (params.measureBytes(params.replay) <= params.maxBytes) {
9665
+ return params.replay;
9666
+ }
9667
+ if (params.cursorIdSpace === "provider-cursor") {
9668
+ return compactReplayWithinByteBudget(params);
9669
+ }
9670
+ let entries = params.replay.entries;
9671
+ while (entries.length > 1) {
9672
+ entries = entries.slice(1);
9673
+ const candidate = replayWithEntries(
9674
+ params.replay,
9675
+ entries,
9676
+ mintEntryCursorPagination(entries, true, params.isCursorEntry)
9677
+ );
9678
+ if (params.measureBytes(candidate) <= params.maxBytes) {
9679
+ return candidate;
9680
+ }
9681
+ }
9682
+ const oversizedEntry = entries[0];
9683
+ if (oversizedEntry) {
9684
+ const candidate = replayWithEntries(
9685
+ params.replay,
9686
+ [compactOversizedEntry(oversizedEntry)],
9687
+ // Classify the entry as it arrived. Compaction rewrites an activity's
9688
+ // summary, and a usage row matched by summary rather than by id would
9689
+ // read as provider-owned afterwards — the overlay-id cursor this module
9690
+ // exists to prevent.
9691
+ mintEntryCursorPagination(
9692
+ [oversizedEntry],
9693
+ params.replay.pagination.hasPreviousPage || params.replay.entries.length > 1,
9694
+ params.isCursorEntry
9695
+ )
9696
+ );
9697
+ if (params.measureBytes(candidate) <= params.maxBytes) {
9698
+ return candidate;
9699
+ }
9700
+ }
9701
+ return replayWithEntries(params.replay, [], mintEntryCursorPagination([], false));
9702
+ }
9703
+ function compactReplayWithinByteBudget(params) {
9704
+ const entries = [...params.replay.entries];
9705
+ for (let index = 0; index < entries.length; index += 1) {
9706
+ entries[index] = compactOversizedEntry(entries[index]);
9707
+ const candidate = replayWithEntries(
9708
+ params.replay,
9709
+ [...entries],
9710
+ params.replay.pagination
9711
+ );
9712
+ if (params.measureBytes(candidate) <= params.maxBytes) {
9713
+ return candidate;
9714
+ }
9715
+ }
9716
+ return replayWithEntries(params.replay, [], params.replay.pagination);
9717
+ }
9718
+ function compactOversizedEntry(entry) {
9719
+ switch (entry.type) {
9720
+ case "message":
9721
+ return {
9722
+ type: "message",
9723
+ id: entry.id,
9724
+ role: entry.role,
9725
+ text: FEDERATION_OMITTED_ENTRY_TEXT,
9726
+ ...entry.createdAt ? { createdAt: entry.createdAt } : {},
9727
+ ...entry.phase ? { phase: entry.phase } : {},
9728
+ ...entry.turn ? { turn: entry.turn } : {}
9729
+ };
9730
+ case "activity":
9731
+ return {
9732
+ type: "activity",
9733
+ id: entry.id,
9734
+ summary: FEDERATION_OMITTED_ENTRY_TEXT,
9735
+ details: [],
9736
+ ...entry.createdAt ? { createdAt: entry.createdAt } : {},
9737
+ ...entry.tone ? { tone: entry.tone } : {},
9738
+ ...entry.status ? { status: entry.status } : {},
9739
+ ...entry.turn ? { turn: entry.turn } : {}
9740
+ };
9741
+ case "plan":
9742
+ return {
9743
+ type: "plan",
9744
+ id: entry.id,
9745
+ markdown: FEDERATION_OMITTED_ENTRY_TEXT,
9746
+ steps: [],
9747
+ ...entry.createdAt ? { createdAt: entry.createdAt } : {},
9748
+ ...entry.turn ? { turn: entry.turn } : {}
9749
+ };
9750
+ case "review":
9751
+ return {
9752
+ type: "review",
9753
+ id: entry.id,
9754
+ review: FEDERATION_OMITTED_ENTRY_TEXT,
9755
+ ...entry.createdAt ? { createdAt: entry.createdAt } : {},
9756
+ ...entry.status ? { status: entry.status } : {},
9757
+ ...entry.turn ? { turn: entry.turn } : {}
9758
+ };
9759
+ }
9760
+ }
9761
+
9471
9762
  // ../federation/src/federation-identity.ts
9472
9763
  import {
9473
9764
  createPrivateKey,
@@ -12427,6 +12718,9 @@ var TerminalReplay = class {
12427
12718
  parsedTail = "";
12428
12719
  seq = 0;
12429
12720
  updatedAt = 0;
12721
+ /** The last serialized screen, until a write or resize changes it. A thread
12722
+ * view remount attaches every pane at once, and most panes are idle. */
12723
+ serialized;
12430
12724
  pendingCharacters = 0;
12431
12725
  whenDrained() {
12432
12726
  return this.queue;
@@ -12456,6 +12750,7 @@ var TerminalReplay = class {
12456
12750
  }
12457
12751
  this.queue = this.queue.then(() => new Promise((resolve) => {
12458
12752
  this.terminal.write(data, () => {
12753
+ this.serialized = void 0;
12459
12754
  this.pendingCharacters -= data.length;
12460
12755
  this.parsedTail = tail;
12461
12756
  this.parsedSeq = frame.seq;
@@ -12467,34 +12762,42 @@ var TerminalReplay = class {
12467
12762
  }
12468
12763
  resize(cols, rows) {
12469
12764
  this.queue = this.queue.then(() => {
12765
+ const { cols: beforeCols, rows: beforeRows } = this.terminal;
12470
12766
  this.terminal.resize(clampTerminalColumns(cols), clampTerminalRows(rows));
12767
+ if (this.terminal.cols !== beforeCols || this.terminal.rows !== beforeRows) this.serialized = void 0;
12471
12768
  });
12472
12769
  }
12473
12770
  snapshot() {
12474
12771
  const result = this.queue.then(() => {
12475
- let scrollback = 2e3;
12476
- let data = this.serializer.serialize({ scrollback });
12477
- while (Buffer.byteLength(data) > TERMINAL_SNAPSHOT_LIMIT && scrollback > 0) {
12478
- scrollback = Math.floor(scrollback / 2);
12479
- data = this.serializer.serialize({ scrollback });
12480
- }
12481
- const truncated = scrollback < 2e3;
12482
- if (Buffer.byteLength(data) > TERMINAL_SNAPSHOT_LIMIT) {
12483
- data = this.plainText().slice(-64 * 1024).replace(/\n/g, "\r\n");
12484
- }
12485
- return {
12486
- data,
12487
- pendingEscape: this.parsedTail || void 0,
12488
- seq: this.parsedSeq,
12489
- timestamp: this.updatedAt,
12490
- cols: this.terminal.cols,
12491
- rows: this.terminal.rows,
12492
- truncated
12493
- };
12772
+ this.serialized ??= this.serialize();
12773
+ return { ...this.serialized };
12494
12774
  });
12495
- this.queue = result.then(() => void 0);
12775
+ this.queue = result.then(() => void 0, () => void 0);
12496
12776
  return result;
12497
12777
  }
12778
+ serialize() {
12779
+ let scrollback = 2e3;
12780
+ let data = this.serializer.serialize({ scrollback });
12781
+ let bytes = Buffer.byteLength(data);
12782
+ while (bytes > TERMINAL_SNAPSHOT_LIMIT && scrollback > 0) {
12783
+ scrollback = Math.floor(scrollback / 2);
12784
+ data = this.serializer.serialize({ scrollback });
12785
+ bytes = Buffer.byteLength(data);
12786
+ }
12787
+ const truncated = scrollback < 2e3;
12788
+ if (bytes > TERMINAL_SNAPSHOT_LIMIT) {
12789
+ data = this.plainText().slice(-64 * 1024).replace(/\n/g, "\r\n");
12790
+ }
12791
+ return {
12792
+ data,
12793
+ pendingEscape: this.parsedTail || void 0,
12794
+ seq: this.parsedSeq,
12795
+ timestamp: this.updatedAt,
12796
+ cols: this.terminal.cols,
12797
+ rows: this.terminal.rows,
12798
+ truncated
12799
+ };
12800
+ }
12498
12801
  /** Raw reads are event deltas. Plain reads are screen snapshots, not deltas. */
12499
12802
  read(afterSeq = 0, maxChars = 16384) {
12500
12803
  const limit = Math.max(1, Math.min(64 * 1024, Math.floor(maxChars) || 16384));
@@ -12523,7 +12826,7 @@ var TerminalReplay = class {
12523
12826
  seq: this.parsedSeq,
12524
12827
  timestamp: this.updatedAt
12525
12828
  }));
12526
- this.queue = result.then(() => void 0);
12829
+ this.queue = result.then(() => void 0, () => void 0);
12527
12830
  return result;
12528
12831
  }
12529
12832
  plainText() {
@@ -18945,15 +19248,20 @@ var FederationFilePushReceipts = class {
18945
19248
  function authenticateMessageOrigin(params) {
18946
19249
  const sourceThread = params.messageOrigin?.sourceThread;
18947
19250
  if (!params.messageOrigin || !sourceThread) {
18948
- return params.messageOrigin;
19251
+ if (params.messageOrigin?.threadTodo === void 0) return params.messageOrigin;
19252
+ const { threadTodo: _threadTodo, ...messageOrigin2 } = params.messageOrigin;
19253
+ return messageOrigin2;
18949
19254
  }
18950
19255
  let sourceInstance;
18951
19256
  try {
18952
19257
  sourceInstance = params.resolveSourceInstance?.(params.sourceInstanceId);
18953
19258
  } catch {
18954
19259
  }
19260
+ const { threadTodo: rawThreadTodo, ...messageOrigin } = params.messageOrigin;
19261
+ const threadTodo = messageOrigin.kind === "agent" ? threadTodoMessageOriginFromUnknown(rawThreadTodo) : void 0;
18955
19262
  return {
18956
- ...params.messageOrigin,
19263
+ ...messageOrigin,
19264
+ ...threadTodo ? { threadTodo } : {},
18957
19265
  sourceThread: {
18958
19266
  backend: sourceThread.backend,
18959
19267
  instanceId: params.sourceInstanceId,
@@ -19486,8 +19794,318 @@ var FederationReplacementReceiver = class {
19486
19794
  return pending.entries;
19487
19795
  }
19488
19796
  };
19797
+
19798
+ // ../federation/src/federation-backend-methods.ts
19799
+ var FEDERATION_BACKEND_METHODS = {
19800
+ getNavigationQueryPage: "backend.getNavigationQueryPage",
19801
+ removeNavigationDirectory: "backend.removeNavigationDirectory",
19802
+ archiveNavigationDirectory: "backend.archiveNavigationDirectory",
19803
+ markNavigationDirectorySeen: "backend.markNavigationDirectorySeen",
19804
+ releaseNavigationAttentionView: "backend.releaseNavigationAttentionView",
19805
+ getNavigationLaunchpadConfig: "backend.getNavigationLaunchpadConfig",
19806
+ getNavigationSelectedDetail: "backend.getNavigationSelectedDetail",
19807
+ getNavigationQueueProjection: "backend.getNavigationQueueProjection",
19808
+ getProjectPage: "backend.getProjectPage",
19809
+ getNavigationDescendantPage: "backend.getNavigationDescendantPage",
19810
+ lookupArchivedThreads: "backend.lookupArchivedThreads",
19811
+ /** @deprecated Alpha collection contract; migrate per docs/federation-navigation-v2.md before removal. */
19812
+ getNavigationSnapshot: "backend.getNavigationSnapshot",
19813
+ searchNavigationThreads: "backend.searchNavigationThreads",
19814
+ searchFederatedThreads: "backend.searchFederatedThreads",
19815
+ searchThreadContent: "backend.searchThreadContent",
19816
+ cancelThreadContentSearch: "backend.cancelThreadContentSearch",
19817
+ /** @deprecated Collection enumeration; use bounded queries or exact-ID archive lookup. */
19818
+ listThreads: "backend.listThreads",
19819
+ resolveThread: "backend.resolveThread",
19820
+ resolveThreadAdmissionState: "backend.resolveThreadAdmissionState",
19821
+ readThread: "backend.readThread",
19822
+ readTurnAssistantReply: "backend.readTurnAssistantReply",
19823
+ readUsageActivity: "backend.readUsageActivity",
19824
+ readThreadFamilyPricing: "backend.readThreadFamilyPricing",
19825
+ analyzeUsageActivity: "backend.analyzeUsageActivity",
19826
+ inspectTokenMiserOutput: "backend.inspectTokenMiserOutput",
19827
+ analyzeThreadToolHistory: "backend.analyzeThreadToolHistory",
19828
+ readTranscriptImage: "backend.readTranscriptImage",
19829
+ listSkills: "backend.listSkills",
19830
+ supportsMessagingPdfTools: "backend.supportsMessagingPdfTools",
19831
+ listBackends: "backend.listBackends",
19832
+ markThreadSeen: "backend.markThreadSeen",
19833
+ continueThreadEnvironmentStartup: "backend.continueThreadEnvironmentStartup",
19834
+ acknowledgeThreadEnvironmentFailure: "backend.acknowledgeThreadEnvironmentFailure",
19835
+ setThreadReaction: "backend.setThreadReaction",
19836
+ setThreadLock: "backend.setThreadLock",
19837
+ setThreadPin: "backend.setThreadPin",
19838
+ reorderThreadPins: "backend.reorderThreadPins",
19839
+ mountRemoteChild: "backend.mountRemoteChild",
19840
+ setThreadParent: "backend.setThreadParent",
19841
+ mutateThreadParentV1: "backend.mutateThreadParentV1",
19842
+ updateSubthreadOrder: "backend.updateSubthreadOrder",
19843
+ setSubthreadsCollapsed: "backend.setSubthreadsCollapsed",
19844
+ readForge: "backend.readForge",
19845
+ detachThreadPullRequest: "backend.detachThreadPullRequest",
19846
+ setThreadPrAutoDispatch: "backend.setThreadPrAutoDispatch",
19847
+ cancelThreadPrAutoDispatch: "backend.cancelThreadPrAutoDispatch",
19848
+ sendThreadPrAutoDispatchNow: "backend.sendThreadPrAutoDispatchNow",
19849
+ // A new method, so an owner that predates it answers method_not_found.
19850
+ manageThreadPrWatchesV1: "backend.manageThreadPrWatchesV1",
19851
+ setProjectForgeAutomation: "backend.setProjectForgeAutomation",
19852
+ listProjectHeldWakeups: "backend.listProjectHeldWakeups",
19853
+ resolveProjectHeldWakeups: "backend.resolveProjectHeldWakeups",
19854
+ archiveThread: "backend.archiveThread",
19855
+ restoreThread: "backend.restoreThread",
19856
+ startThread: "backend.startThread",
19857
+ forkThread: "backend.forkThread",
19858
+ startTurn: "backend.startTurn",
19859
+ submitTurnRequest: "backend.submitTurnRequest",
19860
+ getTurnRequest: "backend.getTurnRequest",
19861
+ submitSteerRequest: "backend.submitSteerRequest",
19862
+ getSteerRequest: "backend.getSteerRequest",
19863
+ replaceQueuedMessage: "backend.replaceQueuedMessage",
19864
+ readQueuedTurn: "backend.readQueuedTurn",
19865
+ cancelQueuedTurn: "backend.cancelQueuedTurn",
19866
+ releaseQueuedTurn: "backend.releaseQueuedTurn",
19867
+ moveQueuedTurn: "backend.moveQueuedTurn",
19868
+ setQueuedTurnPaused: "backend.setQueuedTurnPaused",
19869
+ startReview: "backend.startReview",
19870
+ startPullRequestReview: "backend.startPullRequestReview",
19871
+ listScheduledThreadActions: "backend.listScheduledThreadActions",
19872
+ createScheduledThreadAction: "backend.createScheduledThreadAction",
19873
+ createScheduledPullRequestReview: "backend.createScheduledPullRequestReview",
19874
+ updateScheduledThreadAction: "backend.updateScheduledThreadAction",
19875
+ updateScheduledPullRequestReview: "backend.updateScheduledPullRequestReview",
19876
+ cancelScheduledThreadAction: "backend.cancelScheduledThreadAction",
19877
+ sendScheduledThreadActionNow: "backend.sendScheduledThreadActionNow",
19878
+ compactThread: "backend.compactThread",
19879
+ rewindThread: "backend.rewindThread",
19880
+ previewThreadRewind: "backend.previewThreadRewind",
19881
+ undoThreadRewind: "backend.undoThreadRewind",
19882
+ listMcpConnections: "backend.listMcpConnections",
19883
+ readThreadMcpConnections: "backend.readThreadMcpConnections",
19884
+ setThreadMcpConnections: "backend.setThreadMcpConnections",
19885
+ describeThreadMcpConnections: "backend.describeThreadMcpConnections",
19886
+ listThreadMcpServers: "backend.listThreadMcpServers",
19887
+ reloadCodexMcpConfig: "backend.reloadCodexMcpConfig",
19888
+ controlActiveTurn: "backend.controlActiveTurn",
19889
+ resolveActiveTurn: "backend.resolveActiveTurn",
19890
+ interruptTurn: "backend.interruptTurn",
19891
+ stopSubAgent: "backend.stopSubAgent",
19892
+ steerTurn: "backend.steerTurn",
19893
+ setThreadExecutionMode: "backend.setThreadExecutionMode",
19894
+ queueThreadExecutionMode: "backend.queueThreadExecutionMode",
19895
+ cancelThreadExecutionModeQueue: "backend.cancelThreadExecutionModeQueue",
19896
+ setAcpSessionRuntimeOption: "backend.setAcpSessionRuntimeOption",
19897
+ setThreadAgent: "backend.setThreadAgent",
19898
+ setThreadTokenMiser: "backend.setThreadTokenMiser",
19899
+ setThreadModelSettings: "backend.setThreadModelSettings",
19900
+ checkThreadBranchDrift: "backend.checkThreadBranchDrift",
19901
+ updateThreadExpectedBranch: "backend.updateThreadExpectedBranch",
19902
+ retainThreadBranchDrift: "backend.retainThreadBranchDrift",
19903
+ submitServerRequest: "backend.submitServerRequest",
19904
+ runCodexEnvironmentAction: "backend.runCodexEnvironmentAction",
19905
+ stopCodexEnvironmentAction: "backend.stopCodexEnvironmentAction",
19906
+ listBackgroundTerminals: "backend.listBackgroundTerminals",
19907
+ terminateBackgroundTerminal: "backend.terminateBackgroundTerminal",
19908
+ setCodexThreadEnvironment: "backend.setCodexThreadEnvironment",
19909
+ refreshThreadPullRequests: "backend.refreshThreadPullRequests",
19910
+ refreshDirectoryGitStatuses: "backend.refreshDirectoryGitStatuses",
19911
+ refreshThreadGitWorkingState: "backend.refreshThreadGitWorkingState",
19912
+ ensureDirectoryLaunchpad: "backend.ensureDirectoryLaunchpad",
19913
+ listRecentFileReferences: "backend.listRecentFileReferences",
19914
+ recordRecentFileReferences: "backend.recordRecentFileReferences",
19915
+ listModelSettingsRecents: "backend.listModelSettingsRecents",
19916
+ recordModelSettingsRecent: "backend.recordModelSettingsRecent",
19917
+ attachDirectoryToThread: "backend.attachDirectoryToThread",
19918
+ detachDirectoryFromThread: "backend.detachDirectoryFromThread",
19919
+ listWorktreeUnpublishedCommits: "backend.listWorktreeUnpublishedCommits",
19920
+ getWorktreeUnpublishedCommitDiff: "backend.getWorktreeUnpublishedCommitDiff",
19921
+ materializeDirectoryLaunchpad: "backend.materializeDirectoryLaunchpad",
19922
+ manageLaunchpadCreation: "backend.manageLaunchpadCreation",
19923
+ handoffThreadWorkspace: "backend.handoffThreadWorkspace",
19924
+ // Separate so an owner that predates these moves answers method_not_found.
19925
+ // It would read an unknown direction as Handoff to Local and ignore baseRef.
19926
+ handoffThreadWorkspaceToNewWorktree: "backend.handoffThreadWorkspaceToNewWorktree",
19927
+ recreateThreadWorktree: "backend.recreateThreadWorktree",
19928
+ moveThreadToMainCheckout: "backend.moveThreadToMainCheckout",
19929
+ renameThread: "backend.renameThread",
19930
+ readApplications: "backend.readApplications",
19931
+ openApplication: "backend.openApplication",
19932
+ readMessagingPlatformStatuses: "backend.readMessagingPlatformStatuses",
19933
+ readPwrSnapConnectionStatus: "backend.readPwrSnapConnectionStatus",
19934
+ getLoadStatus: "backend.getLoadStatus",
19935
+ trustCodexProject: "backend.trustCodexProject",
19936
+ setCelestialIcon: "backend.setCelestialIcon",
19937
+ setFederationShortName: "backend.setFederationShortName",
19938
+ starMapIntake: "backend.starMapIntake"
19939
+ };
19940
+ var FEDERATION_BACKEND_METHOD_CAPABILITIES = {
19941
+ [FEDERATION_BACKEND_METHODS.getNavigationQueryPage]: "thread_navigation",
19942
+ [FEDERATION_BACKEND_METHODS.removeNavigationDirectory]: "thread_navigation",
19943
+ [FEDERATION_BACKEND_METHODS.archiveNavigationDirectory]: "turn_control",
19944
+ [FEDERATION_BACKEND_METHODS.markNavigationDirectorySeen]: "thread_navigation",
19945
+ [FEDERATION_BACKEND_METHODS.releaseNavigationAttentionView]: "thread_navigation",
19946
+ [FEDERATION_BACKEND_METHODS.getNavigationLaunchpadConfig]: "thread_detail",
19947
+ [FEDERATION_BACKEND_METHODS.getNavigationSelectedDetail]: "thread_detail",
19948
+ [FEDERATION_BACKEND_METHODS.getNavigationQueueProjection]: "thread_detail",
19949
+ [FEDERATION_BACKEND_METHODS.getProjectPage]: "thread_navigation",
19950
+ [FEDERATION_BACKEND_METHODS.getNavigationDescendantPage]: "thread_navigation",
19951
+ [FEDERATION_BACKEND_METHODS.lookupArchivedThreads]: "thread_navigation",
19952
+ [FEDERATION_BACKEND_METHODS.getNavigationSnapshot]: "thread_navigation",
19953
+ [FEDERATION_BACKEND_METHODS.searchNavigationThreads]: "thread_navigation",
19954
+ [FEDERATION_BACKEND_METHODS.searchFederatedThreads]: "federated_search",
19955
+ [FEDERATION_BACKEND_METHODS.searchThreadContent]: "thread_detail",
19956
+ [FEDERATION_BACKEND_METHODS.cancelThreadContentSearch]: "thread_detail",
19957
+ [FEDERATION_BACKEND_METHODS.listThreads]: "thread_navigation",
19958
+ [FEDERATION_BACKEND_METHODS.resolveThread]: "thread_navigation",
19959
+ [FEDERATION_BACKEND_METHODS.resolveThreadAdmissionState]: "messaging_route",
19960
+ [FEDERATION_BACKEND_METHODS.readThread]: "thread_detail",
19961
+ [FEDERATION_BACKEND_METHODS.readTurnAssistantReply]: "thread_detail",
19962
+ [FEDERATION_BACKEND_METHODS.readForge]: "thread_detail",
19963
+ /* Reads the thread's own transcript history; same data class as reading it. */
19964
+ [FEDERATION_BACKEND_METHODS.readUsageActivity]: "thread_detail",
19965
+ [FEDERATION_BACKEND_METHODS.readThreadFamilyPricing]: "thread_detail",
19966
+ // Starts a paid helper turn on the owner; transcript-read permission is insufficient.
19967
+ [FEDERATION_BACKEND_METHODS.analyzeUsageActivity]: "turn_control",
19968
+ [FEDERATION_BACKEND_METHODS.inspectTokenMiserOutput]: "thread_detail",
19969
+ [FEDERATION_BACKEND_METHODS.analyzeThreadToolHistory]: "thread_detail",
19970
+ [FEDERATION_BACKEND_METHODS.readTranscriptImage]: "thread_detail",
19971
+ [FEDERATION_BACKEND_METHODS.listSkills]: "thread_detail",
19972
+ [FEDERATION_BACKEND_METHODS.supportsMessagingPdfTools]: "messaging_route",
19973
+ [FEDERATION_BACKEND_METHODS.listBackends]: "thread_detail",
19974
+ [FEDERATION_BACKEND_METHODS.markThreadSeen]: "thread_navigation",
19975
+ [FEDERATION_BACKEND_METHODS.continueThreadEnvironmentStartup]: "turn_control",
19976
+ [FEDERATION_BACKEND_METHODS.acknowledgeThreadEnvironmentFailure]: "thread_navigation",
19977
+ [FEDERATION_BACKEND_METHODS.setThreadReaction]: "thread_navigation",
19978
+ // A lock refuses every turn on the owner's thread, so it takes the same
19979
+ // capability as starting one.
19980
+ [FEDERATION_BACKEND_METHODS.setThreadLock]: "turn_control",
19981
+ [FEDERATION_BACKEND_METHODS.setThreadPin]: "thread_navigation",
19982
+ [FEDERATION_BACKEND_METHODS.reorderThreadPins]: "thread_navigation",
19983
+ [FEDERATION_BACKEND_METHODS.mountRemoteChild]: "thread_navigation",
19984
+ [FEDERATION_BACKEND_METHODS.setThreadParent]: "thread_grouping",
19985
+ [FEDERATION_BACKEND_METHODS.mutateThreadParentV1]: "thread_grouping",
19986
+ [FEDERATION_BACKEND_METHODS.updateSubthreadOrder]: "thread_grouping",
19987
+ [FEDERATION_BACKEND_METHODS.setSubthreadsCollapsed]: "thread_grouping",
19988
+ // PR detach cancels pending auto-dispatch work and auto-dispatch arms
19989
+ // automatic repair turns, so both sit with the turn-control grants
19990
+ // (like archiveThread) rather than the browse-level navigation grants.
19991
+ [FEDERATION_BACKEND_METHODS.detachThreadPullRequest]: "turn_control",
19992
+ [FEDERATION_BACKEND_METHODS.setThreadPrAutoDispatch]: "turn_control",
19993
+ [FEDERATION_BACKEND_METHODS.cancelThreadPrAutoDispatch]: "turn_control",
19994
+ [FEDERATION_BACKEND_METHODS.sendThreadPrAutoDispatchNow]: "turn_control",
19995
+ // A watch wakes the thread with an agent turn, and stopping one cancels
19996
+ // that wake, so both sit with Auto-fix under turn control.
19997
+ [FEDERATION_BACKEND_METHODS.manageThreadPrWatchesV1]: "turn_control",
19998
+ // A project's Forge Automation decides which turns the owner starts on its
19999
+ // own, and a held wake-up's Send starts one, so all three take the
20000
+ // turn-control grant. Listing rides with them: only the resume flow asks.
20001
+ [FEDERATION_BACKEND_METHODS.setProjectForgeAutomation]: "turn_control",
20002
+ [FEDERATION_BACKEND_METHODS.listProjectHeldWakeups]: "turn_control",
20003
+ [FEDERATION_BACKEND_METHODS.resolveProjectHeldWakeups]: "turn_control",
20004
+ [FEDERATION_BACKEND_METHODS.archiveThread]: "turn_control",
20005
+ [FEDERATION_BACKEND_METHODS.restoreThread]: "turn_control",
20006
+ [FEDERATION_BACKEND_METHODS.startThread]: "turn_control",
20007
+ [FEDERATION_BACKEND_METHODS.forkThread]: "turn_control",
20008
+ [FEDERATION_BACKEND_METHODS.startTurn]: "turn_control",
20009
+ [FEDERATION_BACKEND_METHODS.submitTurnRequest]: "turn_control",
20010
+ [FEDERATION_BACKEND_METHODS.getTurnRequest]: "turn_control",
20011
+ [FEDERATION_BACKEND_METHODS.submitSteerRequest]: "turn_control",
20012
+ [FEDERATION_BACKEND_METHODS.getSteerRequest]: "turn_control",
20013
+ [FEDERATION_BACKEND_METHODS.replaceQueuedMessage]: "turn_control",
20014
+ [FEDERATION_BACKEND_METHODS.readQueuedTurn]: "thread_detail",
20015
+ [FEDERATION_BACKEND_METHODS.cancelQueuedTurn]: "turn_control",
20016
+ [FEDERATION_BACKEND_METHODS.releaseQueuedTurn]: "turn_control",
20017
+ [FEDERATION_BACKEND_METHODS.moveQueuedTurn]: "turn_control",
20018
+ [FEDERATION_BACKEND_METHODS.setQueuedTurnPaused]: "turn_control",
20019
+ [FEDERATION_BACKEND_METHODS.startReview]: "turn_control",
20020
+ [FEDERATION_BACKEND_METHODS.startPullRequestReview]: "turn_control",
20021
+ [FEDERATION_BACKEND_METHODS.listScheduledThreadActions]: "scheduled_actions",
20022
+ [FEDERATION_BACKEND_METHODS.createScheduledThreadAction]: "scheduled_actions",
20023
+ [FEDERATION_BACKEND_METHODS.createScheduledPullRequestReview]: "scheduled_actions",
20024
+ [FEDERATION_BACKEND_METHODS.updateScheduledThreadAction]: "scheduled_actions",
20025
+ [FEDERATION_BACKEND_METHODS.updateScheduledPullRequestReview]: "scheduled_actions",
20026
+ [FEDERATION_BACKEND_METHODS.cancelScheduledThreadAction]: "scheduled_actions",
20027
+ [FEDERATION_BACKEND_METHODS.sendScheduledThreadActionNow]: "scheduled_actions",
20028
+ [FEDERATION_BACKEND_METHODS.compactThread]: "turn_control",
20029
+ [FEDERATION_BACKEND_METHODS.rewindThread]: "turn_control",
20030
+ [FEDERATION_BACKEND_METHODS.previewThreadRewind]: "turn_control",
20031
+ [FEDERATION_BACKEND_METHODS.undoThreadRewind]: "turn_control",
20032
+ [FEDERATION_BACKEND_METHODS.listMcpConnections]: "thread_detail",
20033
+ [FEDERATION_BACKEND_METHODS.readThreadMcpConnections]: "thread_detail",
20034
+ [FEDERATION_BACKEND_METHODS.setThreadMcpConnections]: "turn_control",
20035
+ [FEDERATION_BACKEND_METHODS.describeThreadMcpConnections]: "thread_detail",
20036
+ [FEDERATION_BACKEND_METHODS.listThreadMcpServers]: "thread_detail",
20037
+ [FEDERATION_BACKEND_METHODS.reloadCodexMcpConfig]: "turn_control",
20038
+ [FEDERATION_BACKEND_METHODS.controlActiveTurn]: "turn_control",
20039
+ [FEDERATION_BACKEND_METHODS.resolveActiveTurn]: "turn_control",
20040
+ [FEDERATION_BACKEND_METHODS.interruptTurn]: "turn_control",
20041
+ [FEDERATION_BACKEND_METHODS.stopSubAgent]: "turn_control",
20042
+ [FEDERATION_BACKEND_METHODS.steerTurn]: "turn_control",
20043
+ [FEDERATION_BACKEND_METHODS.setThreadExecutionMode]: "turn_control",
20044
+ [FEDERATION_BACKEND_METHODS.queueThreadExecutionMode]: "turn_control",
20045
+ [FEDERATION_BACKEND_METHODS.cancelThreadExecutionModeQueue]: "turn_control",
20046
+ [FEDERATION_BACKEND_METHODS.setAcpSessionRuntimeOption]: "turn_control",
20047
+ [FEDERATION_BACKEND_METHODS.setThreadAgent]: "turn_control",
20048
+ [FEDERATION_BACKEND_METHODS.setThreadTokenMiser]: "turn_control",
20049
+ [FEDERATION_BACKEND_METHODS.setThreadModelSettings]: "turn_control",
20050
+ [FEDERATION_BACKEND_METHODS.checkThreadBranchDrift]: "thread_navigation",
20051
+ [FEDERATION_BACKEND_METHODS.updateThreadExpectedBranch]: "turn_control",
20052
+ [FEDERATION_BACKEND_METHODS.retainThreadBranchDrift]: "turn_control",
20053
+ [FEDERATION_BACKEND_METHODS.submitServerRequest]: "pending_request_control",
20054
+ [FEDERATION_BACKEND_METHODS.runCodexEnvironmentAction]: "environment_actions",
20055
+ [FEDERATION_BACKEND_METHODS.stopCodexEnvironmentAction]: "environment_actions",
20056
+ [FEDERATION_BACKEND_METHODS.listBackgroundTerminals]: "thread_detail",
20057
+ [FEDERATION_BACKEND_METHODS.terminateBackgroundTerminal]: "turn_control",
20058
+ [FEDERATION_BACKEND_METHODS.setCodexThreadEnvironment]: "environment_actions",
20059
+ [FEDERATION_BACKEND_METHODS.refreshThreadPullRequests]: "thread_navigation",
20060
+ [FEDERATION_BACKEND_METHODS.refreshDirectoryGitStatuses]: "thread_navigation",
20061
+ [FEDERATION_BACKEND_METHODS.refreshThreadGitWorkingState]: "thread_navigation",
20062
+ [FEDERATION_BACKEND_METHODS.ensureDirectoryLaunchpad]: "launchpad_metadata",
20063
+ [FEDERATION_BACKEND_METHODS.listRecentFileReferences]: "remote_window",
20064
+ [FEDERATION_BACKEND_METHODS.recordRecentFileReferences]: "remote_window",
20065
+ // Reviewer/composer model-settings history is read-only preference data at
20066
+ // the same sensitivity tier as the recent-file list it sits beside.
20067
+ [FEDERATION_BACKEND_METHODS.listModelSettingsRecents]: "remote_window",
20068
+ [FEDERATION_BACKEND_METHODS.recordModelSettingsRecent]: "remote_window",
20069
+ [FEDERATION_BACKEND_METHODS.attachDirectoryToThread]: "environment_actions",
20070
+ [FEDERATION_BACKEND_METHODS.detachDirectoryFromThread]: "environment_actions",
20071
+ [FEDERATION_BACKEND_METHODS.listWorktreeUnpublishedCommits]: "thread_detail",
20072
+ [FEDERATION_BACKEND_METHODS.getWorktreeUnpublishedCommitDiff]: "thread_detail",
20073
+ [FEDERATION_BACKEND_METHODS.materializeDirectoryLaunchpad]: "environment_actions",
20074
+ [FEDERATION_BACKEND_METHODS.manageLaunchpadCreation]: "environment_actions",
20075
+ [FEDERATION_BACKEND_METHODS.handoffThreadWorkspace]: "turn_control",
20076
+ [FEDERATION_BACKEND_METHODS.handoffThreadWorkspaceToNewWorktree]: "turn_control",
20077
+ [FEDERATION_BACKEND_METHODS.recreateThreadWorktree]: "turn_control",
20078
+ [FEDERATION_BACKEND_METHODS.moveThreadToMainCheckout]: "turn_control",
20079
+ [FEDERATION_BACKEND_METHODS.renameThread]: "turn_control",
20080
+ [FEDERATION_BACKEND_METHODS.readApplications]: "remote_window",
20081
+ [FEDERATION_BACKEND_METHODS.openApplication]: "remote_window",
20082
+ // Read-only peer messaging health for the remote window's MSG chip.
20083
+ // messaging_route stays reserved for messaging-originated remote control.
20084
+ [FEDERATION_BACKEND_METHODS.readMessagingPlatformStatuses]: "remote_window",
20085
+ [FEDERATION_BACKEND_METHODS.readPwrSnapConnectionStatus]: "pwrsnap_connection",
20086
+ // On-demand load readings (CPU load averages, available RAM, free
20087
+ // disk) are read-only health facts at the same sensitivity tier as
20088
+ // the browse-level grants, and the Star Map surface that polls them
20089
+ // already rides thread_navigation for its event class. A dedicated
20090
+ // capability would buy no isolation and cost every peer a handshake
20091
+ // re-advertisement.
20092
+ [FEDERATION_BACKEND_METHODS.getLoadStatus]: "thread_navigation",
20093
+ [FEDERATION_BACKEND_METHODS.trustCodexProject]: "environment_actions",
20094
+ // Celestial icon overrides are directed at the gateway (the assignment
20095
+ // coordinator). No dedicated capability exists for federation-level
20096
+ // cosmetic state; thread_navigation is the least-privileged grant every
20097
+ // browsing peer already holds.
20098
+ [FEDERATION_BACKEND_METHODS.setCelestialIcon]: "thread_navigation",
20099
+ // Short-name overrides go to the gateway for the same reason, under the
20100
+ // same least-privileged grant.
20101
+ [FEDERATION_BACKEND_METHODS.setFederationShortName]: "thread_navigation",
20102
+ // Intake creates a thread and starts its first turn on the owning
20103
+ // instance — the same trust the materialize-launchpad path carries.
20104
+ [FEDERATION_BACKEND_METHODS.starMapIntake]: "environment_actions"
20105
+ };
19489
20106
  export {
19490
20107
  AgentToolRouter,
20108
+ FEDERATION_BACKEND_METHOD_CAPABILITIES,
19491
20109
  FEDERATION_CLOSE_REPLACED_CODE,
19492
20110
  FEDERATION_CLOSE_REVOKED_CODE,
19493
20111
  FEDERATION_INVITE_VERSION,
@@ -19547,6 +20165,7 @@ export {
19547
20165
  decodeFederationInvite,
19548
20166
  encodeFederationInvite,
19549
20167
  federationTransportCodecForTest,
20168
+ fitThreadReplayWithinByteBudget,
19550
20169
  generateFederationIdentityKeyPair,
19551
20170
  generateFederationNoiseStaticKeyPair,
19552
20171
  generateNoiseStaticKeyPair,
@@ -19558,6 +20177,7 @@ export {
19558
20177
  mergeCausalMessagingContext,
19559
20178
  noiseKeyPairFromRawPrivate,
19560
20179
  noisePublicKeyBase64FromPrivateBase64,
20180
+ pageThreadReplay,
19561
20181
  publicKeyPemFromPrivateKey,
19562
20182
  pushFederationFile,
19563
20183
  rawPrivateKey,
@@ -19566,6 +20186,7 @@ export {
19566
20186
  registerFederationPtyHandlers,
19567
20187
  registerFilePushHandlers,
19568
20188
  resolveMessagingOutboundFile,
20189
+ shortenDerivedThreadTitle,
19569
20190
  signFederationMessage,
19570
20191
  validateMessagingFileMetadata,
19571
20192
  verifyFederationMessageSignature,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pwragent",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Experimental headless PwrAgent daemon and command-line client.",
@@ -53,7 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@pwragent/federation": "workspace:*",
55
55
  "@pwragent/shared": "workspace:*",
56
- "esbuild": "0.28.1",
56
+ "esbuild": "0.28.2",
57
57
  "@pwragent/agent-tools": "workspace:*"
58
58
  },
59
59
  "dependencies": {
@@ -63,7 +63,7 @@
63
63
  "node-pty"
64
64
  ],
65
65
  "pwragentRelease": {
66
- "tag": "v1.3.8",
67
- "commit": "76f9b95a24db2bc64d9a444805da009974bcbccb"
66
+ "tag": "v1.3.10",
67
+ "commit": "ff9b702144786e162b3e91c78d79791761944350"
68
68
  }
69
69
  }
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,16 +1,32 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { mergeCausalMessagingContext } from "../dist/federation-core.js";
4
+ import { FEDERATION_BACKEND_METHOD_CAPABILITIES, FEDERATION_MAX_FRAME_BYTES, fitThreadReplayWithinByteBudget, mergeCausalMessagingContext, pageThreadReplay, shortenDerivedThreadTitle } from "../dist/federation-core.js";
5
5
  import { ACTIVE, atomicJson, digest } from "./store.js";
6
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
- ]);
7
+ // The owner methods this outpost serves. Each one's capability comes from the
8
+ // shared owner contract, so a grant means the same here as on desktop.
9
+ const OWNER_METHODS = [
10
+ "resolveThreadAdmissionState", "supportsMessagingPdfTools",
11
+ "getNavigationQueryPage", "getNavigationLaunchpadConfig", "getNavigationSelectedDetail", "getNavigationQueueProjection",
12
+ "releaseNavigationAttentionView", "listThreads", "listBackends", "resolveThread", "markThreadSeen", "getProjectPage", "lookupArchivedThreads",
13
+ "readThread", "readTurnAssistantReply", "listSkills", "listThreadMcpServers",
14
+ "startThread", "materializeDirectoryLaunchpad", "startTurn", "interruptTurn", "steerTurn", "controlActiveTurn", "renameThread",
15
+ "setThreadModelSettings", "setThreadExecutionMode",
16
+ ];
17
+ export function ownerMethodCapabilities(methods, contract = FEDERATION_BACKEND_METHOD_CAPABILITIES) {
18
+ return Object.fromEntries(methods.map((name) => {
19
+ const method = `backend.${name}`;
20
+ if (!Object.hasOwn(contract, method)) throw new Error(`${method} is not in the shared owner contract`);
21
+ return [method, contract[method]];
22
+ }));
23
+ }
24
+ export const METHOD_CAPABILITIES = ownerMethodCapabilities(OWNER_METHODS);
25
+
26
+ // The placeholder title desktop gives a new thread. Viewers recognize this
27
+ // exact text as "no name yet".
28
+ export const PLACEHOLDER_THREAD_TITLE = "Untitled thread";
29
+ const LEGACY_PLACEHOLDER_THREAD_TITLE = "New outpost thread";
14
30
 
15
31
  function mode(sandbox) { return sandbox === "workspace-write" ? "auto" : "default"; }
16
32
  function sandboxFor(params, fallback = "read-only") {
@@ -33,12 +49,16 @@ export class HeadlessBackend {
33
49
  this.runtime = runtime;
34
50
  this.publish = publish;
35
51
  this.epoch = randomUUID();
52
+ // Same headroom as desktop's FEDERATION_RESPONSE_BYTE_BUDGET.
53
+ this.replayByteBudget = FEDERATION_MAX_FRAME_BYTES - 64 * 1024;
36
54
  this.attentionViews = new Map();
37
55
  this.file = path.join(path.dirname(runtime.store.directory), "threads.json");
38
56
  try {
39
57
  const data = JSON.parse(readFileSync(this.file, "utf8"));
40
58
  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]));
59
+ this.threads = new Map(data.threads.map((thread) => [thread.id,
60
+ thread.titleSource === "fallback" && thread.title === LEGACY_PLACEHOLDER_THREAD_TITLE
61
+ ? { ...thread, title: PLACEHOLDER_THREAD_TITLE } : thread]));
42
62
  } catch (error) {
43
63
  if (error.code !== "ENOENT") throw error;
44
64
  this.threads = new Map();
@@ -94,6 +114,10 @@ export class HeadlessBackend {
94
114
  }
95
115
 
96
116
  event(message) {
117
+ if (message.method === "headless/turnStarted") {
118
+ this.titleFromFirstPrompt(message.params);
119
+ return;
120
+ }
97
121
  if (message.method === "headless/jobChanged") {
98
122
  this.syncJobs([message.params]);
99
123
  if (message.params.threadId) this.forgetSeenAttentionMember(message.params.threadId);
@@ -149,7 +173,14 @@ export class HeadlessBackend {
149
173
  const messages = history.replay.messages.filter((message) => message.role === "assistant" && message.turn?.id === params.turnId);
150
174
  return { message: messages.find((message) => params.itemId ? message.id === params.itemId : message.text === params.text) || null };
151
175
  }
152
- case "readThread": return this.readThread(params.threadId);
176
+ case "readThread": {
177
+ // Viewers take the newest page as returned. Page and fit it to one
178
+ // frame here, as desktop owners do.
179
+ const history = await this.readThread(params.threadId);
180
+ return { ...history, replay: fitThreadReplayWithinByteBudget({ cursorIdSpace: "entry-id",
181
+ replay: pageThreadReplay(history.replay, params), maxBytes: this.replayByteBudget,
182
+ measureBytes: (replay) => Buffer.byteLength(JSON.stringify({ ...history, replay }), "utf8") }) };
183
+ }
153
184
  case "supportsMessagingPdfTools": return params.backend === "codex" && this.owned(params.threadId).messagingFileTools === true;
154
185
  case "startThread": return this.startThread(params);
155
186
  case "materializeDirectoryLaunchpad": return this.materializeLaunchpad(params, envelope);
@@ -186,9 +217,8 @@ export class HeadlessBackend {
186
217
  const thread = this.owned(params.threadId);
187
218
  const title = params.title || params.name;
188
219
  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);
220
+ thread.updatedAt = Date.now();
221
+ this.setTitle(thread, title.trim(), "explicit");
192
222
  return { backend: "codex", threadId: thread.id, title: thread.title };
193
223
  }
194
224
  case "markThreadSeen": {
@@ -196,13 +226,18 @@ export class HeadlessBackend {
196
226
  for (const key of ["seenAt", "seenUpdatedAt"]) {
197
227
  if (params[key] !== undefined && (!Number.isFinite(params[key]) || params[key] < 0)) throw new Error(`Invalid ${key}`);
198
228
  }
199
- const seenUpdatedAt = params.seenUpdatedAt ?? this.summary(thread).updatedAt;
229
+ // An omitted watermark keeps the stored one (see MarkThreadSeenRequest).
230
+ // A thread never given one is pinned to the seenAt it was judged by,
231
+ // so recording a new seenAt cannot change its unread state.
232
+ thread.seenUpdatedAt = params.seenUpdatedAt ?? thread.seenUpdatedAt ?? thread.seenAt ?? 0;
200
233
  thread.seenAt = params.seenAt ?? Date.now();
201
- thread.seenUpdatedAt = seenUpdatedAt;
202
234
  this.persist();
203
235
  this.forgetSeenAttentionMember(thread.id);
204
- this.invalidate(thread.id);
205
- return { backend: "codex", threadId: thread.id, seenAt: thread.seenAt, seenUpdatedAt };
236
+ this.publish({ backend: "codex", notification: { method: "navigation/thread/seen",
237
+ params: { threadId: thread.id, ...(params.seenUpdatedAt !== undefined ? { seenUpdatedAt: params.seenUpdatedAt } : {}) } } }, "transcript");
238
+ this.invalidate(thread.id, "navigation/thread/seen");
239
+ return { backend: "codex", threadId: thread.id, seenAt: thread.seenAt,
240
+ ...(params.seenUpdatedAt !== undefined ? { seenUpdatedAt: params.seenUpdatedAt } : {}) };
206
241
  }
207
242
  case "setThreadExecutionMode": {
208
243
  const thread = this.owned(params.threadId);
@@ -226,7 +261,35 @@ export class HeadlessBackend {
226
261
  }
227
262
  }
228
263
 
229
- invalidate(threadId) { this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId } } }, "navigation"); }
264
+ invalidate(threadId, sourceMethod) {
265
+ this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId, ...(sourceMethod ? { sourceMethod } : {}) } } }, "navigation");
266
+ }
267
+
268
+ // threads.json is the only store for a title. The outpost never sets the
269
+ // Codex thread name.
270
+ setTitle(thread, title, titleSource) {
271
+ Object.assign(thread, { title, titleSource });
272
+ this.persist();
273
+ this.publish({ backend: "codex", notification: { method: "thread/name/updated",
274
+ params: { threadId: thread.id, threadName: title, titleSource } } }, "transcript");
275
+ this.invalidate(thread.id, "thread/name/updated");
276
+ }
277
+
278
+ // The outpost has no helper model, so a thread is named as desktop first
279
+ // names one: the shortened first user message. Any rename wins over it.
280
+ deriveTitle(id, text) {
281
+ const thread = this.threads.get(id);
282
+ if (!thread || thread.archived || thread.titleSource !== "fallback" || typeof text !== "string") return;
283
+ const title = shortenDerivedThreadTitle(text)?.slice(0, 200);
284
+ if (title) this.setTitle(thread, title, "derived");
285
+ }
286
+
287
+ titleFromFirstPrompt({ threadId, jobId, prompt }) {
288
+ // A later turn is not the first message. readThread names that thread
289
+ // from Codex's preview instead.
290
+ const earlier = [...this.runtime.store.jobs.values()].some((job) => job.threadId === threadId && job.id !== jobId && job.turnId);
291
+ if (!earlier) this.deriveTitle(threadId, prompt);
292
+ }
230
293
 
231
294
  async materializeLaunchpad(params, envelope) {
232
295
  const cwd = this.runtime.projects.resolve(params.directoryKey);
@@ -264,11 +327,11 @@ export class HeadlessBackend {
264
327
  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
328
  if (!result?.thread?.id || typeof result.thread.id !== "string" || result.thread.id.length > 256) throw new Error("Invalid thread/start response");
266
329
  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,
330
+ const thread = { id: result.thread.id, messagingFileTools: true, title: PLACEHOLDER_THREAD_TITLE, titleSource: "fallback", createdAt: Date.now(), cwd, sandbox,
268
331
  model: params.model, reasoningEffort: params.reasoningEffort, serviceTier: params.serviceTier };
269
332
  this.threads.set(thread.id, thread);
270
333
  this.persist();
271
- this.invalidate(thread.id);
334
+ this.invalidate(thread.id, "thread/started");
272
335
  return { backend: "codex", threadId: thread.id, executionMode: mode(sandbox) };
273
336
  } finally { this.creating = false; }
274
337
  }
@@ -328,6 +391,9 @@ export class HeadlessBackend {
328
391
  }
329
392
  }
330
393
  const messages = entries.filter((entry) => entry.type === "message");
394
+ // Names threads created before derived titles, and imported threads.
395
+ this.deriveTitle(id, typeof result.thread?.preview === "string" && result.thread.preview.trim()
396
+ ? result.thread.preview : messages.find((item) => item.role === "user")?.text);
331
397
  return { backend: "codex", threadId: id, fetchedAt: Date.now(), threadStatus: this.summary(this.owned(id)).threadStatus,
332
398
  replay: { entries, messages, lastUserMessage: messages.filter((item) => item.role === "user").at(-1)?.text,
333
399
  lastAssistantMessage: messages.filter((item) => item.role === "assistant").at(-1)?.text,
@@ -16,9 +16,10 @@ export * from "@pwragent/federation/git-instance-handoff";
16
16
  export * from "@pwragent/federation/federation-file-push";
17
17
 
18
18
  export { AgentToolRouter } from "@pwragent/agent-tools/agent-tool-router";
19
- export { mergeCausalMessagingContext } from "@pwragent/shared";
19
+ export { fitThreadReplayWithinByteBudget, mergeCausalMessagingContext, pageThreadReplay, shortenDerivedThreadTitle } from "@pwragent/shared";
20
20
  export { authenticateMessageOrigin } from "@pwragent/federation/federation-message-origin";
21
21
 
22
22
  export { resolveMessagingOutboundFile, validateMessagingFileMetadata } from "@pwragent/agent-tools/messaging-outbound-file";
23
23
 
24
24
  export * from "@pwragent/federation/federation-replacement-receiver";
25
+ export { FEDERATION_BACKEND_METHOD_CAPABILITIES } from "@pwragent/federation/federation-backend-methods";
package/src/handoffs.js CHANGED
@@ -77,7 +77,9 @@ export function setupHandoffs(federation, directory) {
77
77
  const file = await runtime.codex.request("fs/readFile", { path: result.thread.path });
78
78
  if (typeof file.dataBase64 !== "string") throw new Error("Codex protocol thread export is unavailable");
79
79
  return { rolloutBase64: file.dataBase64, replay: (await backend.readThread(id)).replay,
80
- cwd: runtime.projects.resolve(owned.cwd || runtime.workspace), title: owned.title };
80
+ cwd: runtime.projects.resolve(owned.cwd || runtime.workspace),
81
+ // A placeholder would become the destination's explicit name.
82
+ ...(owned.titleSource === "fallback" ? {} : { title: owned.title }) };
81
83
  },
82
84
  forkThread: async (request) => {
83
85
  if (runtime.active || backend.creating) throw new Error("Outpost is busy");
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);
@@ -151,6 +155,8 @@ export class Runtime {
151
155
  return;
152
156
  }
153
157
  this.save({ ...this.store.get(job.id), turnId: turn.turn.id, status: "running" });
158
+ // The prompt stays in memory: job records never store it.
159
+ this.onEvent?.({ method: "headless/turnStarted", params: { threadId: result.thread.id, jobId: job.id, prompt } });
154
160
  if (this.earlyCompletion?.turn?.id === turn.turn.id) {
155
161
  this.complete(this.earlyCompletion);
156
162
  } else if (["completed", "failed", "interrupted"].includes(turn.turn.status)) {
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
+ }