pwragent 1.3.7 → 1.3.9

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
@@ -70,7 +70,7 @@ shell's Node version-manager initialization does not run under systemd.
70
70
  After publication, install a pinned version into the service prefix:
71
71
 
72
72
  ```sh
73
- sudo npm install --prefix /opt/pwragent --omit=dev --ignore-scripts pwragent@1.3.6
73
+ sudo npm install --prefix /opt/pwragent --omit=dev --ignore-scripts pwragent@1.3.7
74
74
  sudo install -m 0644 /opt/pwragent/node_modules/pwragent/examples/pwragent-headless.service /etc/systemd/system/pwragent-headless.service
75
75
  # Review the account, paths, profile and Codex configuration before enabling:
76
76
  sudo systemctl daemon-reload
@@ -90,7 +90,7 @@ sudo -u pwragent env PWRAGENT_HOME=/var/lib/pwragent PWRAGENT_PROFILE=outpost \
90
90
 
91
91
  For a service update, finish or interrupt active work, run
92
92
  `sudo systemctl stop pwragent-headless.service`, repeat the prefix installation
93
- with the chosen new version (or `pwragent@next`), then run
93
+ with the chosen new version (or `pwragent@latest`), then run
94
94
  `sudo systemctl start pwragent-headless.service`. Compare the new example unit
95
95
  with the installed one before deliberately updating it; npm does not overwrite
96
96
  `/etc/systemd/system`. Stopping during a turn can leave its outcome `unknown`.
@@ -280,6 +280,15 @@ its `directoryKey`/`cwd`; omitting it selects the configured workspace root. Sel
280
280
  the defaults remain local and read-only. Launchpad requests for worktrees,
281
281
  grouping, environments or provider extensions are rejected before creation.
282
282
 
283
+ A new thread is named from its first user message, shortened the way desktop
284
+ names a thread before any helper-model title. The outpost has no helper model.
285
+ The name is stored in `threads.json`, and subscribed viewers receive
286
+ `thread/name/updated` with `titleSource: derived`, followed by a navigation
287
+ invalidation. A later turn never renames a thread. An explicit `renameThread`
288
+ always wins and is never replaced. A thread that still has a placeholder name,
289
+ such as one created before this release, takes Codex's `preview` the next time
290
+ it is read. The outpost does not set the Codex thread name.
291
+
283
292
  ## Identity, compute lifetime and remaining scope
284
293
 
285
294
  The private `federation.json` stores the retained peer ID, signing/Noise keys,
@@ -349,6 +358,7 @@ register a project. Enrollment and Git intake do not import the sender's Git or
349
358
 
350
359
  | Service | Required grants | Supported behavior |
351
360
  | --- | --- | --- |
361
+ | `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
362
  | `outpost.projects.list` | `thread_navigation` | Owner project inventory |
353
363
  | `outpost.projects.create/add/remove` | `environment_actions` | Create/clone, register and unregister projects |
354
364
  | `pty.*` | `remote_pty` | Input, resize, ack flow control, snapshots, history and close |
@@ -465,6 +475,18 @@ against the daemon. External providers and Codex are mocked; no live peers,
465
475
  operator projects, credentials or compute machines are used. Ubuntu CI runs the
466
476
  Node suite; the desktop contract test runs on POSIX lanes.
467
477
 
478
+ The owner conformance suite
479
+ (`apps/desktop/src/main/__tests__/owner-conformance.test.ts`) runs one thread's
480
+ life through the owner methods both hosts implement. It runs on this daemon and
481
+ on desktop's real owner, and compares each response and event. Each known
482
+ difference is a line in `owner-conformance-baseline.json` beside it, so the
483
+ suite fails on any new difference and names its method. After fixing a
484
+ difference, or changing the contract on purpose, record the result:
485
+
486
+ ```sh
487
+ UPDATE_OWNER_CONFORMANCE=1 pnpm test apps/desktop/src/main/__tests__/owner-conformance.test.ts
488
+ ```
489
+
468
490
  A separate live smoke test installed the packed tarball and official Codex
469
491
  0.159.2 in a Node 24 Debian Linux arm64 Docker image. It used an unprivileged
470
492
  user, a read-only authentication-file mount, container-owned state and workspace,
@@ -9200,6 +9200,7 @@ var FEDERATION_CAPABILITIES = [
9200
9200
  "navigation_snapshot_deltas",
9201
9201
  "navigation_group_invalidations",
9202
9202
  "thread_grouping",
9203
+ "archive_subthread_promotion_v1",
9203
9204
  "thread_parent_mutation_v1",
9204
9205
  "thread_detail",
9205
9206
  "turn_control",
@@ -9254,6 +9255,8 @@ var FEDERATION_CAPABILITIES = [
9254
9255
  * card with its model chip only.
9255
9256
  */
9256
9257
  "thread_todo_dispatch_target",
9258
+ /** The owner accepts messageTarget on a handoff instead of starting a thread. */
9259
+ "thread_todo_message",
9257
9260
  /**
9258
9261
  * The owner answers `threadTodos.raisePlan`, raising a handoff card's
9259
9262
  * steps as the started thread's own `plan` card. A viewer starting a
@@ -9281,7 +9284,21 @@ var FEDERATION_CAPABILITIES = [
9281
9284
  * turn's reply, its images materialized. A messaging gateway without it
9282
9285
  * reads a transcript page at every bound turn end.
9283
9286
  */
9284
- "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"
9285
9302
  ];
9286
9303
  var FEDERATION_ENDPOINT_SCHEMES = ["ws", "wss", "ssh"];
9287
9304
  function parseFederationGatewayEndpoint(value) {
@@ -9355,6 +9372,19 @@ var PWRAGENT_MESSAGING_CALLABLE_OPERATION_NAMES = [
9355
9372
  ...PWRAGENT_MESSAGING_LEGACY_OPERATION_NAMES
9356
9373
  ];
9357
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
+
9358
9388
  // ../shared/src/directory-navigation.ts
9359
9389
  function normalizeGitOriginUrl(value) {
9360
9390
  const trimmed = value?.trim();
@@ -9456,6 +9486,61 @@ async function readStatusWithoutNestedWorktrees(options) {
9456
9486
  return await options.git([...options.statusArgs, ...nestedWorktreePathspecArgs(excludes)]);
9457
9487
  }
9458
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
+
9459
9544
  // ../shared/src/messaging-causal-context.ts
9460
9545
  function mergeCausalMessagingContext(origins) {
9461
9546
  const sources = origins.flatMap((origin) => origin?.causalMessagingSource ? [origin.causalMessagingSource] : []);
@@ -9465,6 +9550,215 @@ function mergeCausalMessagingContext(origins) {
9465
9550
  return ambiguous ? { causalMessagingSource: void 0, causalMessagingAmbiguous: true } : { causalMessagingSource: sources[sources.length - 1], causalMessagingAmbiguous: void 0 };
9466
9551
  }
9467
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
+
9468
9762
  // ../federation/src/federation-identity.ts
9469
9763
  import {
9470
9764
  createPrivateKey,
@@ -12424,6 +12718,9 @@ var TerminalReplay = class {
12424
12718
  parsedTail = "";
12425
12719
  seq = 0;
12426
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;
12427
12724
  pendingCharacters = 0;
12428
12725
  whenDrained() {
12429
12726
  return this.queue;
@@ -12453,6 +12750,7 @@ var TerminalReplay = class {
12453
12750
  }
12454
12751
  this.queue = this.queue.then(() => new Promise((resolve) => {
12455
12752
  this.terminal.write(data, () => {
12753
+ this.serialized = void 0;
12456
12754
  this.pendingCharacters -= data.length;
12457
12755
  this.parsedTail = tail;
12458
12756
  this.parsedSeq = frame.seq;
@@ -12464,34 +12762,42 @@ var TerminalReplay = class {
12464
12762
  }
12465
12763
  resize(cols, rows) {
12466
12764
  this.queue = this.queue.then(() => {
12765
+ const { cols: beforeCols, rows: beforeRows } = this.terminal;
12467
12766
  this.terminal.resize(clampTerminalColumns(cols), clampTerminalRows(rows));
12767
+ if (this.terminal.cols !== beforeCols || this.terminal.rows !== beforeRows) this.serialized = void 0;
12468
12768
  });
12469
12769
  }
12470
12770
  snapshot() {
12471
12771
  const result = this.queue.then(() => {
12472
- let scrollback = 2e3;
12473
- let data = this.serializer.serialize({ scrollback });
12474
- while (Buffer.byteLength(data) > TERMINAL_SNAPSHOT_LIMIT && scrollback > 0) {
12475
- scrollback = Math.floor(scrollback / 2);
12476
- data = this.serializer.serialize({ scrollback });
12477
- }
12478
- const truncated = scrollback < 2e3;
12479
- if (Buffer.byteLength(data) > TERMINAL_SNAPSHOT_LIMIT) {
12480
- data = this.plainText().slice(-64 * 1024).replace(/\n/g, "\r\n");
12481
- }
12482
- return {
12483
- data,
12484
- pendingEscape: this.parsedTail || void 0,
12485
- seq: this.parsedSeq,
12486
- timestamp: this.updatedAt,
12487
- cols: this.terminal.cols,
12488
- rows: this.terminal.rows,
12489
- truncated
12490
- };
12772
+ this.serialized ??= this.serialize();
12773
+ return { ...this.serialized };
12491
12774
  });
12492
- this.queue = result.then(() => void 0);
12775
+ this.queue = result.then(() => void 0, () => void 0);
12493
12776
  return result;
12494
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
+ }
12495
12801
  /** Raw reads are event deltas. Plain reads are screen snapshots, not deltas. */
12496
12802
  read(afterSeq = 0, maxChars = 16384) {
12497
12803
  const limit = Math.max(1, Math.min(64 * 1024, Math.floor(maxChars) || 16384));
@@ -12520,7 +12826,7 @@ var TerminalReplay = class {
12520
12826
  seq: this.parsedSeq,
12521
12827
  timestamp: this.updatedAt
12522
12828
  }));
12523
- this.queue = result.then(() => void 0);
12829
+ this.queue = result.then(() => void 0, () => void 0);
12524
12830
  return result;
12525
12831
  }
12526
12832
  plainText() {
@@ -13518,7 +13824,8 @@ function agentToolFailure(params) {
13518
13824
  }
13519
13825
 
13520
13826
  // ../agent-tools/src/agent-tool-router.ts
13521
- var PWRAGENT_AGENT_TOOL_NAMESPACE_DESCRIPTION = "PwrAgent tools manage threads, delegated work, messaging, apps, automations, and connected instances. Prefer these tools to backend tools when both can do the task.";
13827
+ var PWRAGENT_IMAGE_REPLY_GUIDANCE = "Show images as ![Preview](/absolute/path/image.png) or image tool results. Approved worktree/temp images preview. Code paths do not. Avoid shell open commands.";
13828
+ var PWRAGENT_AGENT_TOOL_NAMESPACE_DESCRIPTION = "PwrAgent manages threads, delegation, messaging, apps, automations, and instances. Prefer its tools to backend tools. " + PWRAGENT_IMAGE_REPLY_GUIDANCE;
13522
13829
  var AgentToolRouter = class {
13523
13830
  definitions;
13524
13831
  unsupportedMessage;
@@ -18941,15 +19248,20 @@ var FederationFilePushReceipts = class {
18941
19248
  function authenticateMessageOrigin(params) {
18942
19249
  const sourceThread = params.messageOrigin?.sourceThread;
18943
19250
  if (!params.messageOrigin || !sourceThread) {
18944
- return params.messageOrigin;
19251
+ if (params.messageOrigin?.threadTodo === void 0) return params.messageOrigin;
19252
+ const { threadTodo: _threadTodo, ...messageOrigin2 } = params.messageOrigin;
19253
+ return messageOrigin2;
18945
19254
  }
18946
19255
  let sourceInstance;
18947
19256
  try {
18948
19257
  sourceInstance = params.resolveSourceInstance?.(params.sourceInstanceId);
18949
19258
  } catch {
18950
19259
  }
19260
+ const { threadTodo: rawThreadTodo, ...messageOrigin } = params.messageOrigin;
19261
+ const threadTodo = messageOrigin.kind === "agent" ? threadTodoMessageOriginFromUnknown(rawThreadTodo) : void 0;
18951
19262
  return {
18952
- ...params.messageOrigin,
19263
+ ...messageOrigin,
19264
+ ...threadTodo ? { threadTodo } : {},
18953
19265
  sourceThread: {
18954
19266
  backend: sourceThread.backend,
18955
19267
  instanceId: params.sourceInstanceId,
@@ -19482,8 +19794,318 @@ var FederationReplacementReceiver = class {
19482
19794
  return pending.entries;
19483
19795
  }
19484
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
+ };
19485
20106
  export {
19486
20107
  AgentToolRouter,
20108
+ FEDERATION_BACKEND_METHOD_CAPABILITIES,
19487
20109
  FEDERATION_CLOSE_REPLACED_CODE,
19488
20110
  FEDERATION_CLOSE_REVOKED_CODE,
19489
20111
  FEDERATION_INVITE_VERSION,
@@ -19543,6 +20165,7 @@ export {
19543
20165
  decodeFederationInvite,
19544
20166
  encodeFederationInvite,
19545
20167
  federationTransportCodecForTest,
20168
+ fitThreadReplayWithinByteBudget,
19546
20169
  generateFederationIdentityKeyPair,
19547
20170
  generateFederationNoiseStaticKeyPair,
19548
20171
  generateNoiseStaticKeyPair,
@@ -19554,6 +20177,7 @@ export {
19554
20177
  mergeCausalMessagingContext,
19555
20178
  noiseKeyPairFromRawPrivate,
19556
20179
  noisePublicKeyBase64FromPrivateBase64,
20180
+ pageThreadReplay,
19557
20181
  publicKeyPemFromPrivateKey,
19558
20182
  pushFederationFile,
19559
20183
  rawPrivateKey,
@@ -19562,6 +20186,7 @@ export {
19562
20186
  registerFederationPtyHandlers,
19563
20187
  registerFilePushHandlers,
19564
20188
  resolveMessagingOutboundFile,
20189
+ shortenDerivedThreadTitle,
19565
20190
  signFederationMessage,
19566
20191
  validateMessagingFileMetadata,
19567
20192
  verifyFederationMessageSignature,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pwragent",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Experimental headless PwrAgent daemon and command-line client.",
@@ -63,7 +63,7 @@
63
63
  "node-pty"
64
64
  ],
65
65
  "pwragentRelease": {
66
- "tag": "v1.3.7",
67
- "commit": "e5a9fab9c90daa2f0bed661768969dc5c5301415"
66
+ "tag": "v1.3.9",
67
+ "commit": "0fc28acd4318e55e172427b5a694c1a1dd096c15"
68
68
  }
69
69
  }
@@ -1,16 +1,27 @@
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);
14
25
 
15
26
  function mode(sandbox) { return sandbox === "workspace-write" ? "auto" : "default"; }
16
27
  function sandboxFor(params, fallback = "read-only") {
@@ -33,6 +44,8 @@ export class HeadlessBackend {
33
44
  this.runtime = runtime;
34
45
  this.publish = publish;
35
46
  this.epoch = randomUUID();
47
+ // Same headroom as desktop's FEDERATION_RESPONSE_BYTE_BUDGET.
48
+ this.replayByteBudget = FEDERATION_MAX_FRAME_BYTES - 64 * 1024;
36
49
  this.attentionViews = new Map();
37
50
  this.file = path.join(path.dirname(runtime.store.directory), "threads.json");
38
51
  try {
@@ -94,6 +107,10 @@ export class HeadlessBackend {
94
107
  }
95
108
 
96
109
  event(message) {
110
+ if (message.method === "headless/turnStarted") {
111
+ this.titleFromFirstPrompt(message.params);
112
+ return;
113
+ }
97
114
  if (message.method === "headless/jobChanged") {
98
115
  this.syncJobs([message.params]);
99
116
  if (message.params.threadId) this.forgetSeenAttentionMember(message.params.threadId);
@@ -149,7 +166,14 @@ export class HeadlessBackend {
149
166
  const messages = history.replay.messages.filter((message) => message.role === "assistant" && message.turn?.id === params.turnId);
150
167
  return { message: messages.find((message) => params.itemId ? message.id === params.itemId : message.text === params.text) || null };
151
168
  }
152
- case "readThread": return this.readThread(params.threadId);
169
+ case "readThread": {
170
+ // Viewers take the newest page as returned. Page and fit it to one
171
+ // frame here, as desktop owners do.
172
+ const history = await this.readThread(params.threadId);
173
+ return { ...history, replay: fitThreadReplayWithinByteBudget({ cursorIdSpace: "entry-id",
174
+ replay: pageThreadReplay(history.replay, params), maxBytes: this.replayByteBudget,
175
+ measureBytes: (replay) => Buffer.byteLength(JSON.stringify({ ...history, replay }), "utf8") }) };
176
+ }
153
177
  case "supportsMessagingPdfTools": return params.backend === "codex" && this.owned(params.threadId).messagingFileTools === true;
154
178
  case "startThread": return this.startThread(params);
155
179
  case "materializeDirectoryLaunchpad": return this.materializeLaunchpad(params, envelope);
@@ -186,9 +210,8 @@ export class HeadlessBackend {
186
210
  const thread = this.owned(params.threadId);
187
211
  const title = params.title || params.name;
188
212
  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);
213
+ thread.updatedAt = Date.now();
214
+ this.setTitle(thread, title.trim(), "explicit");
192
215
  return { backend: "codex", threadId: thread.id, title: thread.title };
193
216
  }
194
217
  case "markThreadSeen": {
@@ -226,7 +249,35 @@ export class HeadlessBackend {
226
249
  }
227
250
  }
228
251
 
229
- invalidate(threadId) { this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId } } }, "navigation"); }
252
+ invalidate(threadId, sourceMethod) {
253
+ this.publish({ backend: "codex", notification: { method: "navigation/invalidated", params: { threadId, ...(sourceMethod ? { sourceMethod } : {}) } } }, "navigation");
254
+ }
255
+
256
+ // threads.json is the only store for a title. The outpost never sets the
257
+ // Codex thread name.
258
+ setTitle(thread, title, titleSource) {
259
+ Object.assign(thread, { title, titleSource });
260
+ this.persist();
261
+ this.publish({ backend: "codex", notification: { method: "thread/name/updated",
262
+ params: { threadId: thread.id, threadName: title, titleSource } } }, "transcript");
263
+ this.invalidate(thread.id, "thread/name/updated");
264
+ }
265
+
266
+ // The outpost has no helper model, so a thread is named as desktop first
267
+ // names one: the shortened first user message. Any rename wins over it.
268
+ deriveTitle(id, text) {
269
+ const thread = this.threads.get(id);
270
+ if (!thread || thread.archived || thread.titleSource !== "fallback" || typeof text !== "string") return;
271
+ const title = shortenDerivedThreadTitle(text)?.slice(0, 200);
272
+ if (title) this.setTitle(thread, title, "derived");
273
+ }
274
+
275
+ titleFromFirstPrompt({ threadId, jobId, prompt }) {
276
+ // A later turn is not the first message. readThread names that thread
277
+ // from Codex's preview instead.
278
+ const earlier = [...this.runtime.store.jobs.values()].some((job) => job.threadId === threadId && job.id !== jobId && job.turnId);
279
+ if (!earlier) this.deriveTitle(threadId, prompt);
280
+ }
230
281
 
231
282
  async materializeLaunchpad(params, envelope) {
232
283
  const cwd = this.runtime.projects.resolve(params.directoryKey);
@@ -328,6 +379,9 @@ export class HeadlessBackend {
328
379
  }
329
380
  }
330
381
  const messages = entries.filter((entry) => entry.type === "message");
382
+ // Names threads created before derived titles, and imported threads.
383
+ this.deriveTitle(id, typeof result.thread?.preview === "string" && result.thread.preview.trim()
384
+ ? result.thread.preview : messages.find((item) => item.role === "user")?.text);
331
385
  return { backend: "codex", threadId: id, fetchedAt: Date.now(), threadStatus: this.summary(this.owned(id)).threadStatus,
332
386
  replay: { entries, messages, lastUserMessage: messages.filter((item) => item.role === "user").at(-1)?.text,
333
387
  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
@@ -151,6 +151,8 @@ export class Runtime {
151
151
  return;
152
152
  }
153
153
  this.save({ ...this.store.get(job.id), turnId: turn.turn.id, status: "running" });
154
+ // The prompt stays in memory: job records never store it.
155
+ this.onEvent?.({ method: "headless/turnStarted", params: { threadId: result.thread.id, jobId: job.id, prompt } });
154
156
  if (this.earlyCompletion?.turn?.id === turn.turn.id) {
155
157
  this.complete(this.earlyCompletion);
156
158
  } else if (["completed", "failed", "interrupted"].includes(turn.turn.status)) {