pum-agent 0.2.10-beta.1 → 0.2.12-beta.1

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
@@ -47,7 +47,7 @@ The following screens are real OpenTUI renders captured through `tmux`. A local
47
47
  - **External triggers:** Supervise background commands such as `gh run watch` and automatically wake the exact target agent when they exit.
48
48
  - **Provider choice:** Search the providers exposed by pi, or add an OpenAI-compatible custom endpoint.
49
49
  - **Filesystem boundary:** `read`, `write`, and `edit` stay inside the project and configured allowed roots. `apply_patch` stays project-local.
50
- - **Optional safeguards:** Use strict, balanced, or ask Check mode for `bash`, `edit`, `apply_patch`, and external-trigger process proposals. Supported hosts can also use native Bash sandboxing through Bubblewrap or Windows CreateProcessInSandbox.
50
+ - **Optional safeguards:** Turn on Check mode to gate `bash`, `edit`, `apply_patch`, and external-trigger process proposals. Supported hosts can also use native Bash sandboxing through Bubblewrap or Windows CreateProcessInSandbox.
51
51
  - **Terminal-first appearance:** Nine themes, semantic color overrides, Unicode glyphs, and optional animation.
52
52
 
53
53
  PUM uses [pi](https://github.com/earendil-works/pi) for the agent loop and [OpenTUI](https://github.com/anomalyco/opentui) for rendering.
@@ -250,21 +250,22 @@ This boundary does not isolate `bash`, package scripts, extensions, or trigger p
250
250
 
251
251
  ### Check mode
252
252
 
253
- Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
253
+ Select Check mode in `Ctrl+P` — either **Off** or **On**. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
254
254
 
255
- - **Strict:** Run deterministic hard rules, then require a clear verifier approval.
256
- - **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls, explicit non-sensitive external reads, and narrow lifecycle-disabled npm release verification operations whose writes stay in approved roots. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
257
- - **Ask:** Show the approval popup for every checked call that passes hard rules, unless an exact session or project approval already matches. A verifier `SAFE`, unclear, error, or unavailable result still requires approval.
255
+ - **Off:** Checked tools run without the deterministic policy or the advisory verifier.
256
+ - **On:** The deterministic policy runs first, then an advisory verifier reviews the complete proposal.
258
257
 
259
- Every active profile hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. Balanced permits explicit, deterministically classified, non-sensitive external reads. It accepts one direct `npm pack` only when lifecycle scripts are disabled, an explicit cache stays in an approved root, output stays in an approved root, and any package operand is one exact registry version. Balanced also accepts one direct `npm install` of one exact registry version only when `--ignore-scripts`, an approved `--prefix`, and an approved `--cache` are explicit. File, Git, URL, tag, range, composed, general install, and global-install forms remain blocked. These hard blocks cannot be overridden and do not open the popup. An explicit verifier `UNSAFE` verdict also blocks without a popup. The only publication exception is a deterministic match for direct main-agent `npm publish` or `npm dist-tag add`. The verifier category does not control this exception. The exception still requires explicit popup approval. Managed subagents cannot use it.
258
+ On allows ordinary complete project-local calls, explicit on-mode external reads (read-only), and project-local edits. It accepts one direct `npm pack` only when lifecycle scripts are disabled, an explicit cache stays in an approved root, output stays in an approved root, and any package operand is one exact registry version. On also accepts one direct `npm install` of one exact registry version only when `--ignore-scripts`, an approved `--prefix`, and an approved `--cache` are explicit. File, Git, URL, tag, range, composed, general install, and global-install forms remain blocked. On hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links or junctions, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. These hard blocks cannot be overridden and never open a popup.
260
259
 
261
- Use `/check-path list`, `/check-path add <directory>`, `/check-path remove <directory>`, or `/check-path clear` to manage up to 16 additional directory roots for the current launch project. The filesystem sandbox applies these roots to `read`, `write`, and `edit`. Bash, edit, and external-trigger checks also use these roots; `apply_patch` remains project-local. Added roots are canonicalized and remain subject to credential, traversal, symlink or junction, broad-deletion, and other hard blocks.
260
+ On blocks an explicit verifier verdict of `UNSAFE`. An unclear, unavailable, failed, or timed-out review does not block a fully validated call. The only exception outside the verifier verdict is a deterministic match for a direct main-agent `npm publish` or `npm dist-tag add ... latest`, which On allows outright. The verifier category does not control this exception. Managed subagents cannot use it.
261
+
262
+ There is no approval popup and no approval store. Check mode is off by default.
262
263
 
263
- For `edit` and `apply_patch`, PUM validates the complete proposed change before any mutation. Review data includes the unified diff, changed paths, line counts, sensitivity flags, project containment, and full-content SHA-256. Invalid, stale, malformed, escaping, or incompletely analyzed input blocks the call. Patch length alone does not block a valid Balanced call.
264
+ Use `/check-path list`, `/check-path add <directory>`, `/check-path remove <directory>`, or `/check-path clear` to manage up to 16 additional directory roots for the current launch project. The filesystem sandbox applies these roots to `read`, `write`, and `edit`. Bash, edit, and external-trigger checks also use these roots; `apply_patch` remains project-local. Added roots are canonicalized and remain subject to credential, traversal, symlink or junction, broad-deletion, and other hard blocks.
264
265
 
265
- Ask mode can allow an exact call once, for the current session, or for the current project. Approvals match the authoritative main or child identity, tool, verifier model, project, and canonical complete input. Chat text is not approval. Use **Clear approvals** in Settings to remove project approvals.
266
+ For `edit` and `apply_patch`, PUM validates the complete proposed change before any mutation. Review data includes the unified diff, changed paths, line counts, sensitivity flags, project containment, and full-content SHA-256. Invalid, stale, malformed, escaping, or incompletely analyzed input blocks the call. Patch length alone does not block a complete validated on-mode call.
266
267
 
267
- The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict blocks malformed replies, errors, aborts, and timeouts. Balanced allows a fully validated call after an unclear, unavailable, failed, or timed-out review. Balanced still blocks explicit verifier `UNSAFE`, aborts, deterministic suspicious findings, malformed structures, and incomplete analysis. Ask requires the popup after hard rules for verifier `SAFE`, unclear, error, and unavailable results. Check mode is off by default.
268
+ The verifier uses a structured decision schema. One unclear response can receive one bounded adjudication under the shared 15-second watchdog. On blocks aborts, malformed verifier replies, and incomplete analysis, and it blocks any explicit `UNSAFE` verdict. Check mode is off by default.
268
269
 
269
270
  #### Native Bash sandbox
270
271
 
@@ -276,7 +277,7 @@ The **Sandbox** setting has three modes:
276
277
 
277
278
  Check mode **Off** always uses pi's normal unsandboxed Bash backend. For an active Check mode, PUM recomputes the sandbox policy from the exact approved command, authoritative working directory, configured additional roots, and deterministic access analysis. Model input cannot supply policy fields.
278
279
 
279
- The project and configured additional roots are writable. Explicit Balanced external reads are mounted read-only. PUM configuration and common credential paths are denied, and credential-shaped or process-injection environment variables are removed. A private temporary directory is supplied for the command. Safe pi metadata such as `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` remains available; session paths and identifiers are withheld.
280
+ The project and configured additional roots are writable. Explicit on-mode external reads are mounted read-only. PUM configuration and common credential paths are denied, and credential-shaped or process-injection environment variables are removed. A private temporary directory is supplied for the command. Safe pi metadata such as `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` remains available; session paths and identifiers are withheld.
280
281
 
281
282
  Network access is denied unless deterministic analysis recognizes an approved network operation. Bubblewrap's host-network mode is all-or-nothing and is **not domain-filtered**. Windows similarly grants or withholds the SDK's broad network capabilities; it does not provide hostname allowlists.
282
283
 
@@ -286,7 +287,7 @@ External triggers preserve direct executable/argument boundaries and continue to
286
287
 
287
288
  The filesystem sandbox is a process-local path guard for `read`, `write`, `edit`, and `apply_patch`. It does not replace native Bash isolation and does not cover scripts, extensions, or trigger processes.
288
289
 
289
- Verifier prompts stay bounded. For an oversized Balanced review, PUM sends complete validation metadata, counts, findings, and SHA-256 digests. PUM does not send a raw prefix or suffix as if it were complete. Strict and Ask keep their fail-closed oversized-input behavior.
290
+ Verifier prompts stay bounded. For an oversized On-mode review, PUM sends complete validation metadata, counts, findings, and SHA-256 digests. PUM does not send a raw prefix or suffix as if it were complete.
290
291
 
291
292
  ### Hosted web search
292
293
 
@@ -328,8 +329,6 @@ Set `PUM_DIR` to override the complete PUM data directory.
328
329
  | `prompt-stash.json` | Stashed prompts by working directory |
329
330
  | `sessions/` | Main conversation sessions |
330
331
  | `subagents/` | Persistent subagent sessions |
331
- | `check-mode-cache.json` | Accepted checks for eligible read-only Git commands |
332
- | `check-mode-approvals.json` | Exact project approvals created in Ask mode |
333
332
 
334
333
  PUM preserves all stashed prompt occurrences. PUM also keeps the 100 most recent additional sent-history occurrences for each working directory.
335
334
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.10-beta.1",
3
+ "version": "0.2.12-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -1037,9 +1037,15 @@ export function App({
1037
1037
  else if (event.type === "main-pending-add") addPending(event.pending);
1038
1038
  else if (event.type === "main-pending-resolve") resolvePending(event.id);
1039
1039
  else if (event.type === "main-pending-drop") dropPending(event.id);
1040
+ else if (event.type === "news-changed") {
1041
+ const loaded = loadNewsItems(session.sessionFile);
1042
+ newsRef.current = loaded;
1043
+ setNews(loaded);
1044
+ setTx((value) => ({ ...value, lines: tagNewsLines(value.lines, loaded) }));
1045
+ }
1040
1046
  setAgentRevision((revision) => revision + 1);
1041
1047
  }),
1042
- [subagentManager],
1048
+ [subagentManager, session],
1043
1049
  );
1044
1050
 
1045
1051
  useEffect(() => {
@@ -1506,29 +1512,47 @@ export function App({
1506
1512
  const jumpFromNews = (target: "answer" | "prompt") => {
1507
1513
  const item = newsRef.current[newsCursorRef.current];
1508
1514
  if (!item) return;
1509
- const lines = txRef.current.lines;
1510
- const answerIndex = lines.findIndex((line) =>
1511
- line.kind === "text" && line.role === "assistant" && line.newsId === item.id,
1512
- );
1515
+ const requesterAgentId = item.completion?.requesterAgentId ?? null;
1516
+ const lines = requesterAgentId === null
1517
+ ? txRef.current.lines
1518
+ : subagentManager.getAgent(requesterAgentId)?.transcript.lines;
1519
+ if (!lines) return;
1520
+ const completionPromptIndex = item.completion
1521
+ ? lines.findIndex((line) =>
1522
+ line.kind === "agent-message" && line.messageId === item.completion?.messageId,
1523
+ )
1524
+ : -1;
1525
+ const answerIndex = item.completion
1526
+ ? lines.findIndex((line, index) =>
1527
+ index > completionPromptIndex
1528
+ && line.kind === "text"
1529
+ && line.role === "assistant"
1530
+ && line.text === item.text,
1531
+ )
1532
+ : lines.findIndex((line) =>
1533
+ line.kind === "text" && line.role === "assistant" && line.newsId === item.id,
1534
+ );
1513
1535
  let targetIndex = answerIndex;
1514
1536
  if (target === "prompt") {
1515
- const promptText = item.prompts?.find((prompt) => !prompt.steer)?.text
1516
- ?? item.prompts?.[0]?.text;
1517
- targetIndex = -1;
1518
- if (promptText) {
1519
- const end = answerIndex >= 0 ? answerIndex : lines.length;
1520
- for (let index = end - 1; index >= 0; index--) {
1521
- const line = lines[index];
1522
- if (line?.kind === "text" && line.role === "user" && line.text === promptText) {
1523
- targetIndex = index;
1524
- break;
1537
+ targetIndex = completionPromptIndex;
1538
+ if (!item.completion) {
1539
+ const promptText = item.prompts?.find((prompt) => !prompt.steer)?.text
1540
+ ?? item.prompts?.[0]?.text;
1541
+ if (promptText) {
1542
+ const end = answerIndex >= 0 ? answerIndex : lines.length;
1543
+ for (let index = end - 1; index >= 0; index--) {
1544
+ const line = lines[index];
1545
+ if (line?.kind === "text" && line.role === "user" && line.text === promptText) {
1546
+ targetIndex = index;
1547
+ break;
1548
+ }
1525
1549
  }
1526
1550
  }
1527
1551
  }
1528
1552
  }
1529
1553
  if (targetIndex < 0) return;
1530
1554
 
1531
- if (activeAgentIdRef.current !== null && !selectAgentView(null)) return;
1555
+ if (activeAgentIdRef.current !== requesterAgentId && !selectAgentView(requesterAgentId)) return;
1532
1556
  newsOpenRef.current = false;
1533
1557
  setNewsOpen(false);
1534
1558
  const scrollToTarget = () => {
@@ -1590,8 +1614,12 @@ export function App({
1590
1614
  const firstLine = item.text.split("\n")[0] ?? item.text;
1591
1615
  const preview = firstLine.length > 240 ? `${firstLine.slice(0, 240)} …` : firstLine;
1592
1616
  const quote = `> ${preview}\n\n`;
1593
- viewDrafts.current.set("main", quote);
1594
- if (activeAgentIdRef.current !== null) selectAgentView(null);
1617
+ const requesterAgentId = item.completion?.requesterAgentId ?? null;
1618
+ const targetAgentId = requesterAgentId && subagentManager.getAgent(requesterAgentId)
1619
+ ? requesterAgentId
1620
+ : null;
1621
+ viewDrafts.current.set(targetAgentId ?? "main", quote);
1622
+ if (activeAgentIdRef.current !== targetAgentId) selectAgentView(targetAgentId);
1595
1623
  else setEditorText(quote);
1596
1624
  queueMicrotask(() => {
1597
1625
  inputRef.current?.focus();
@@ -549,6 +549,12 @@ export function analyzeBashCommand(command: string, requestedLimits?: Partial<Ch
549
549
  let pipeline = 0;
550
550
  let annotations = 0;
551
551
  let truncated = false;
552
+ let commandStart = true;
553
+ const caseContexts: Array<{
554
+ phase: "subject" | "pattern" | "body";
555
+ parenDepth: number;
556
+ braceDepth: number;
557
+ }> = [];
552
558
 
553
559
  const addAnnotation = <T>(list: T[], value: T) => {
554
560
  annotations++;
@@ -607,9 +613,37 @@ export function analyzeBashCommand(command: string, requestedLimits?: Partial<Ch
607
613
  index++;
608
614
  continue;
609
615
  }
610
- if (char === "'" || char === "\"") { quote = char; index++; continue; }
611
- if (char === "(") { parenDepth++; index++; continue; }
612
- if (char === ")") { if (parenDepth === 0) errors.push(`unexpected ) at ${index}`); else parenDepth--; index++; continue; }
616
+ if (char === "'" || char === "\"") { quote = char; commandStart = false; index++; continue; }
617
+
618
+ const word = command.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/)?.[0];
619
+ if (word) {
620
+ const next = command[index + word.length];
621
+ const reservedBoundary = next === undefined || /[\s;&|(){}<>]/.test(next);
622
+ const activeCase = caseContexts.at(-1);
623
+ const atCaseLevel = activeCase?.parenDepth === parenDepth && activeCase.braceDepth === braceDepth;
624
+ if (commandStart && word === "case" && reservedBoundary) {
625
+ caseContexts.push({ phase: "subject", parenDepth, braceDepth });
626
+ } else if (activeCase?.phase === "subject" && atCaseLevel && word === "in" && reservedBoundary) {
627
+ activeCase.phase = "pattern";
628
+ } else if (commandStart && activeCase && atCaseLevel && word === "esac" && reservedBoundary) {
629
+ caseContexts.pop();
630
+ }
631
+ commandStart = commandStart && reservedBoundary && new Set(["do", "then", "else", "elif"]).has(word);
632
+ index += word.length;
633
+ continue;
634
+ }
635
+
636
+ if (char === "(") { parenDepth++; commandStart = true; index++; continue; }
637
+ if (char === ")") {
638
+ const activeCase = caseContexts.at(-1);
639
+ if (activeCase?.phase === "pattern" && activeCase.parenDepth === parenDepth && activeCase.braceDepth === braceDepth) {
640
+ activeCase.phase = "body";
641
+ commandStart = true;
642
+ } else if (parenDepth === 0) errors.push(`unexpected ) at ${index}`);
643
+ else parenDepth--;
644
+ index++;
645
+ continue;
646
+ }
613
647
  if (char === "{") { braceDepth++; index++; continue; }
614
648
  if (char === "}") { if (braceDepth === 0) errors.push(`unexpected } at ${index}`); else braceDepth--; index++; continue; }
615
649
 
@@ -632,6 +666,15 @@ export function analyzeBashCommand(command: string, requestedLimits?: Partial<Ch
632
666
  index = newline + 1;
633
667
  continue;
634
668
  }
669
+ const activeCase = caseContexts.at(-1);
670
+ const inCasePattern = activeCase?.phase === "pattern"
671
+ && activeCase.parenDepth === parenDepth
672
+ && activeCase.braceDepth === braceDepth;
673
+ if (char === "|" && inCasePattern && !command.startsWith("||", index) && !command.startsWith("|&", index)) {
674
+ commandStart = false;
675
+ index++;
676
+ continue;
677
+ }
635
678
  const rawOperator = [";;&", "&&", "||", "|&", ";;", ";&", ";", "|", "&", "\n"].find((item) => command.startsWith(item, index));
636
679
  if (rawOperator) {
637
680
  const normalized = rawOperator === "\n" ? "newline" : rawOperator as BashOperator["operator"];
@@ -643,9 +686,15 @@ export function analyzeBashCommand(command: string, requestedLimits?: Partial<Ch
643
686
  stageStart = index + rawOperator.length;
644
687
  operatorBefore = normalized;
645
688
  }
689
+ if (activeCase?.phase === "body" && activeCase.parenDepth === parenDepth && activeCase.braceDepth === braceDepth
690
+ && (rawOperator === ";;" || rawOperator === ";&" || rawOperator === ";;&")) {
691
+ activeCase.phase = "pattern";
692
+ }
693
+ commandStart = true;
646
694
  index += rawOperator.length;
647
695
  continue;
648
696
  }
697
+ if (!/\s/.test(char)) commandStart = false;
649
698
  index++;
650
699
  }
651
700
  addStage(command.length);
@@ -655,6 +704,7 @@ export function analyzeBashCommand(command: string, requestedLimits?: Partial<Ch
655
704
  if (backtickStart !== undefined) errors.push(`unterminated backtick substitution at ${backtickStart}`);
656
705
  if (parenDepth) errors.push("unbalanced parentheses");
657
706
  if (braceDepth) errors.push("unbalanced braces");
707
+ if (caseContexts.length) errors.push("unterminated case statement");
658
708
  if (truncated) errors.push("analysis annotation limit exceeded");
659
709
 
660
710
  const stages: BashStage[] = stageRanges.map((range, index) => {
@@ -69,6 +69,15 @@ export function NewsPopup({
69
69
  <box style={{ flexGrow: 1, width: 1 }} />
70
70
  <text content={formatAge(current.at)} fg={theme.dim} bg={theme.popupBg} wrapMode="none" />
71
71
  </box>
72
+ {current.completion ? (
73
+ <text
74
+ content={`${current.completion.agentName} → ${current.completion.requesterName} · finish_subagent`}
75
+ fg={theme.dim}
76
+ bg={theme.popupBg}
77
+ wrapMode="none"
78
+ style={{ height: 1, flexShrink: 0 }}
79
+ />
80
+ ) : null}
72
81
  <box style={{ height: 1, flexShrink: 0 }} />
73
82
  <scrollbox
74
83
  id="news-scrollbox"
package/src/news.ts CHANGED
@@ -8,6 +8,16 @@ export type NewsPrompt = {
8
8
  steer: boolean;
9
9
  };
10
10
 
11
+ export type NewsCompletion = {
12
+ settlementId: string;
13
+ messageId: string;
14
+ agentId: string;
15
+ agentName: string;
16
+ requesterAgentId: string | null;
17
+ requesterName: string;
18
+ summary: string;
19
+ };
20
+
11
21
  export type NewsItem = {
12
22
  /** Stable identifier used to tag the matching transcript line. */
13
23
  id: string;
@@ -21,6 +31,8 @@ export type NewsItem = {
21
31
  answered: boolean;
22
32
  /** User prompt and steers that produced this answer, oldest first. */
23
33
  prompts?: NewsPrompt[];
34
+ /** Managed completion identity for a finish_subagent-triggered answer. */
35
+ completion?: NewsCompletion;
24
36
  };
25
37
 
26
38
  /** The list never holds more than this many answers. */
@@ -48,10 +60,23 @@ function isNewsItem(value: unknown): value is NewsItem {
48
60
  Boolean(prompt) &&
49
61
  typeof (prompt as Record<string, unknown>).text === "string" &&
50
62
  typeof (prompt as Record<string, unknown>).steer === "boolean",
51
- )))
63
+ ))) &&
64
+ (item.completion === undefined || isNewsCompletion(item.completion))
52
65
  );
53
66
  }
54
67
 
68
+ function isNewsCompletion(value: unknown): value is NewsCompletion {
69
+ if (!value || typeof value !== "object") return false;
70
+ const completion = value as Record<string, unknown>;
71
+ return typeof completion.settlementId === "string"
72
+ && typeof completion.messageId === "string"
73
+ && typeof completion.agentId === "string"
74
+ && typeof completion.agentName === "string"
75
+ && (completion.requesterAgentId === null || typeof completion.requesterAgentId === "string")
76
+ && typeof completion.requesterName === "string"
77
+ && typeof completion.summary === "string";
78
+ }
79
+
55
80
  /** Load the persisted news list for a session. Never throws. */
56
81
  export function loadNewsItems(sessionFile: string | undefined): NewsItem[] {
57
82
  if (!sessionFile) return [];
@@ -81,16 +106,79 @@ export function saveNewsItems(
81
106
  }
82
107
  }
83
108
 
109
+ export type FinishNewsSettlement = {
110
+ id: string;
111
+ messageId: string;
112
+ agentId: string;
113
+ agentName: string;
114
+ parentAgentId: string | null;
115
+ requesterName: string;
116
+ status: "idle" | "completed" | "failed";
117
+ summary?: string;
118
+ content: string;
119
+ createdAt: number;
120
+ response?: string;
121
+ };
122
+
123
+ /** Project one completed finish settlement into the persisted News model. */
124
+ export function newsItemFromFinishSettlement(
125
+ settlement: FinishNewsSettlement,
126
+ ): NewsItem | undefined {
127
+ if (settlement.status !== "completed" || !settlement.response?.trim()) return undefined;
128
+ if (/^(?:ack(?:nowledged)?|got it|noted|ok(?:ay)?|thanks|thank you|understood)[.!\s]*$/i.test(
129
+ settlement.response.trim(),
130
+ )) return undefined;
131
+ return {
132
+ id: `subagent-finish:${settlement.messageId}`,
133
+ text: settlement.response.trim(),
134
+ at: settlement.createdAt,
135
+ read: false,
136
+ answered: false,
137
+ prompts: [{ text: settlement.content, steer: false }],
138
+ completion: {
139
+ settlementId: settlement.id,
140
+ messageId: settlement.messageId,
141
+ agentId: settlement.agentId,
142
+ agentName: settlement.agentName,
143
+ requesterAgentId: settlement.parentAgentId,
144
+ requesterName: settlement.requesterName,
145
+ summary: settlement.summary ?? "",
146
+ },
147
+ };
148
+ }
149
+
150
+ /** Upsert stable News identities while preserving local read and reply state. */
151
+ export function mergeNewsItems(
152
+ existing: readonly NewsItem[],
153
+ incoming: readonly NewsItem[],
154
+ ): NewsItem[] {
155
+ const byId = new Map(existing.map((item) => [item.id, item]));
156
+ for (const item of incoming) {
157
+ const current = byId.get(item.id);
158
+ byId.set(item.id, current
159
+ ? { ...item, read: current.read, answered: current.answered }
160
+ : item);
161
+ }
162
+ return [...byId.values()]
163
+ .sort((a, b) => b.at - a.at)
164
+ .slice(0, NEWS_CAPACITY);
165
+ }
166
+
84
167
  /**
85
168
  * Attach news identifiers to replayed assistant lines that exactly match a
86
169
  * stored answer. Each stored answer claims the first later unmatched line with
87
170
  * the same text, so resumed transcripts keep their circle/checkmark markers.
88
171
  */
89
- export function tagNewsLines<T>(lines: readonly T[], items: readonly NewsItem[]): T[] {
172
+ export function tagNewsLines<T>(
173
+ lines: readonly T[],
174
+ items: readonly NewsItem[],
175
+ requesterAgentId: string | null = null,
176
+ ): T[] {
90
177
  if (items.length === 0 || lines.length === 0) return lines.map((line) => line);
91
178
  const claimed = new Set<number>();
92
179
  const out = lines.map((line) => line);
93
180
  for (const item of items) {
181
+ if ((item.completion?.requesterAgentId ?? null) !== requesterAgentId) continue;
94
182
  const maybe = (value: T) =>
95
183
  value as unknown as { kind?: unknown; role?: unknown; text?: unknown; newsId?: unknown };
96
184
  const index = out.findIndex((line, i) =>
package/src/replay.ts CHANGED
@@ -120,6 +120,7 @@ export function replayEntries(
120
120
  sender: agentMessage.sender,
121
121
  recipient: agentMessage.recipient,
122
122
  text: agentMessage.text,
123
+ messageId: agentMessage.id,
123
124
  });
124
125
  }
125
126
  continue;
@@ -19,6 +19,13 @@ import {
19
19
  usageFromEntries,
20
20
  } from "../agent-usage";
21
21
  import { replayEntries } from "../replay";
22
+ import {
23
+ loadNewsItems,
24
+ mergeNewsItems,
25
+ newsItemFromFinishSettlement,
26
+ saveNewsItems,
27
+ tagNewsLines,
28
+ } from "../news";
22
29
  import { isRejectedToolResult, rejectedToolReason } from "../check-mode";
23
30
  import {
24
31
  observeSearchCalls,
@@ -185,6 +192,8 @@ type RuntimeRecord = {
185
192
  * it to idle and blocking its managed merge.
186
193
  */
187
194
  statusBeforeTurn?: SubagentStatus;
195
+ completionMessageIds?: Set<string>;
196
+ completionResponse?: string;
188
197
  };
189
198
 
190
199
  type IdleOpenReminderState = {
@@ -289,6 +298,8 @@ export class SubagentManager {
289
298
  private mainCwd = process.cwd();
290
299
  private parentSessionId = "detached";
291
300
  private mainRunning = false;
301
+ private readonly mainCompletionMessageIds = new Set<string>();
302
+ private mainCompletionResponse = "";
292
303
  private maxActiveSubagents: number;
293
304
  private worktreeQueue: Promise<void> = Promise.resolve();
294
305
  private readonly messageTimes = new Map<string, number[]>();
@@ -365,6 +376,8 @@ export class SubagentManager {
365
376
  this.mainCwd = cwd;
366
377
  this.parentSessionId = sessionId;
367
378
  this.mainRunning = false;
379
+ this.mainCompletionMessageIds.clear();
380
+ this.mainCompletionResponse = "";
368
381
  this.emit({
369
382
  type: "trigger-target",
370
383
  sessionId,
@@ -374,6 +387,7 @@ export class SubagentManager {
374
387
  });
375
388
 
376
389
  const restored = new Map<string, Omit<SubagentSnapshot, "transcript">>();
390
+ const restoredEntries = new Map<string, readonly any[]>();
377
391
  const restoredActivity = new Map<string, { activityGeneration: number; idleNotifiedGeneration: number }>();
378
392
  const restoredFinish = new Map<string, string>();
379
393
  for (const entry of sessionManager.getEntries()) {
@@ -421,6 +435,7 @@ export class SubagentManager {
421
435
  const childManager = (await import("@earendil-works/pi-coding-agent")).SessionManager.open(
422
436
  snapshot.sessionFile,
423
437
  );
438
+ restoredEntries.set(snapshot.id, childManager.getEntries());
424
439
  transcript = {
425
440
  lines: replayEntries(childManager.buildContextEntries(), snapshot.worktree.path, true),
426
441
  stream: null,
@@ -463,6 +478,8 @@ export class SubagentManager {
463
478
  this.persistActivity(this.records.get(snapshot.id)!);
464
479
  }
465
480
  }
481
+ this.restoreSettlementResponses(sessionManager.getEntries(), restoredEntries);
482
+ this.reconcileFinishNews();
466
483
  await this.retrySettlementsForParent(null);
467
484
  this.emit();
468
485
  }
@@ -733,7 +750,14 @@ export class SubagentManager {
733
750
  if (typeof id === "string") {
734
751
  this.resolvePending(record, id);
735
752
  this.acknowledgeSettlementMessage(id);
753
+ if (message.customType === AGENT_MESSAGE_CUSTOM_TYPE
754
+ && message.details?.kind === "completion") {
755
+ record.completionMessageIds ??= new Set<string>();
756
+ record.completionMessageIds.add(id);
757
+ }
736
758
  }
759
+ } else if (message?.role === "assistant" && record.completionMessageIds?.size) {
760
+ record.completionResponse = "";
737
761
  } else if (message?.role === "user") {
738
762
  const text = typeof message.content === "string"
739
763
  ? message.content
@@ -766,6 +790,9 @@ export class SubagentManager {
766
790
  const update = event.assistantMessageEvent;
767
791
  const kind = update.type === "text_delta" ? "assistant" : update.type === "thinking_delta" ? "thinking" : null;
768
792
  if (!kind) return;
793
+ if (kind === "assistant" && record.completionMessageIds?.size) {
794
+ record.completionResponse = (record.completionResponse ?? "") + update.delta;
795
+ }
769
796
  this.updateTranscript(record, (value) => {
770
797
  if (value.stream?.kind === kind) {
771
798
  return { ...value, stream: { kind, text: value.stream.text + update.delta } };
@@ -833,6 +860,13 @@ export class SubagentManager {
833
860
  case "agent_settled": {
834
861
  this.messageCacheController?.releaseRequester({ kind: "subagent", id: record.snapshot.id });
835
862
  this.updateTranscript(record, flushTranscript);
863
+ if (record.completionMessageIds?.size) {
864
+ for (const messageId of record.completionMessageIds) {
865
+ this.recordSettlementResponse(messageId, record.completionResponse ?? "");
866
+ }
867
+ record.completionMessageIds.clear();
868
+ record.completionResponse = "";
869
+ }
836
870
  if (record.session) {
837
871
  void this.triggerManager?.markTargetSettled(
838
872
  record.session.sessionId,
@@ -1112,6 +1146,13 @@ export class SubagentManager {
1112
1146
  });
1113
1147
  pi.on("agent_settled", () => {
1114
1148
  this.mainRunning = false;
1149
+ if (this.mainCompletionMessageIds.size > 0) {
1150
+ for (const messageId of this.mainCompletionMessageIds) {
1151
+ this.recordSettlementResponse(messageId, this.mainCompletionResponse);
1152
+ }
1153
+ this.mainCompletionMessageIds.clear();
1154
+ this.mainCompletionResponse = "";
1155
+ }
1115
1156
  this.messageCacheController?.releaseRequester({ kind: "main", id: this.parentSessionId });
1116
1157
  void this.triggerManager?.markTargetSettled(this.parentSessionId, null);
1117
1158
  this.emit({
@@ -1126,12 +1167,26 @@ export class SubagentManager {
1126
1167
  pi.on("message_start", (event) => {
1127
1168
  const message = event.message;
1128
1169
  this.acceptIdleOpenReminder(null, message);
1170
+ if (message.role === "assistant" && this.mainCompletionMessageIds.size > 0) {
1171
+ this.mainCompletionResponse = "";
1172
+ return;
1173
+ }
1129
1174
  if (message.role !== "custom"
1130
1175
  || ![AGENT_MESSAGE_CUSTOM_TYPE, TRIGGER_EVENT_CUSTOM_TYPE].includes(message.customType)) return;
1131
- const id = (message.details as AgentMessageData | undefined)?.id;
1176
+ const details = message.details as AgentMessageData | undefined;
1177
+ const id = details?.id;
1132
1178
  if (typeof id === "string") {
1133
1179
  this.acknowledgeSettlementMessage(id);
1134
1180
  this.emit({ type: "main-pending-resolve", id });
1181
+ if (message.customType === AGENT_MESSAGE_CUSTOM_TYPE && details?.kind === "completion") {
1182
+ this.mainCompletionMessageIds.add(id);
1183
+ }
1184
+ }
1185
+ });
1186
+ pi.on("message_update", (event) => {
1187
+ const update = event.assistantMessageEvent;
1188
+ if (this.mainCompletionMessageIds.size > 0 && update.type === "text_delta") {
1189
+ this.mainCompletionResponse += update.delta;
1135
1190
  }
1136
1191
  });
1137
1192
  pi.on("session_start", async (_event, ctx) => {
@@ -1719,6 +1774,7 @@ export class SubagentManager {
1719
1774
  sender: data.sender,
1720
1775
  recipient: data.recipient,
1721
1776
  text: data.text,
1777
+ messageId: data.id,
1722
1778
  };
1723
1779
  }
1724
1780
 
@@ -1893,6 +1949,89 @@ export class SubagentManager {
1893
1949
  if (!delivered) this.emit({ type: "main-pending-drop", id: pending.id });
1894
1950
  }
1895
1951
 
1952
+ private responseAfterSettlement(entries: readonly any[], messageId: string): string {
1953
+ let active = false;
1954
+ let response = "";
1955
+ for (const entry of entries) {
1956
+ const message = entry?.type === "message"
1957
+ ? entry.message
1958
+ : entry?.type === "custom_message"
1959
+ ? { ...entry, role: "custom" }
1960
+ : entry;
1961
+ if (message?.role === "custom") {
1962
+ if (message.customType === AGENT_MESSAGE_CUSTOM_TYPE && message.details?.id === messageId) {
1963
+ active = true;
1964
+ }
1965
+ continue;
1966
+ }
1967
+ if (!active) continue;
1968
+ if (message?.role === "user") break;
1969
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
1970
+ const text = message.content
1971
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
1972
+ .map((block: any) => block.text)
1973
+ .join("")
1974
+ .trim();
1975
+ if (text) response = text;
1976
+ }
1977
+ return response;
1978
+ }
1979
+
1980
+ private restoreSettlementResponses(
1981
+ mainEntries: readonly any[],
1982
+ childEntries: ReadonlyMap<string, readonly any[]>,
1983
+ ): void {
1984
+ for (const settlement of this.settlements.values()) {
1985
+ if (settlement.status !== "completed" || settlement.response?.trim()) continue;
1986
+ const entries = settlement.parentAgentId === null
1987
+ ? mainEntries
1988
+ : childEntries.get(settlement.parentAgentId) ?? [];
1989
+ const response = this.responseAfterSettlement(entries, settlement.messageId);
1990
+ if (response) this.recordSettlementResponse(settlement.messageId, response, false);
1991
+ }
1992
+ }
1993
+
1994
+ private recordSettlementResponse(messageId: string, response: string, reconcile = true): void {
1995
+ const settlement = [...this.settlements.values()].find((item) => item.messageId === messageId);
1996
+ if (!settlement || settlement.status !== "completed" || !response.trim()) return;
1997
+ if (settlement.response === response.trim()) return;
1998
+ settlement.response = response.trim();
1999
+ settlement.respondedAt = Date.now();
2000
+ this.persist({
2001
+ event: "settlement",
2002
+ id: settlement.agentId,
2003
+ at: settlement.respondedAt,
2004
+ settlement: { ...settlement },
2005
+ });
2006
+ if (reconcile) this.reconcileFinishNews();
2007
+ }
2008
+
2009
+ private reconcileFinishNews(): void {
2010
+ const sessionFile = this.mainSessionManager?.getSessionFile?.();
2011
+ if (!sessionFile) return;
2012
+ const incoming = [...this.settlements.values()]
2013
+ .map((settlement) => newsItemFromFinishSettlement({
2014
+ ...settlement,
2015
+ agentName: settlement.agentName
2016
+ ?? this.records.get(settlement.agentId)?.snapshot.name
2017
+ ?? settlement.agentId,
2018
+ requesterName: settlement.requesterName
2019
+ ?? (settlement.parentAgentId
2020
+ ? this.records.get(settlement.parentAgentId)?.snapshot.name ?? settlement.parentAgentId
2021
+ : "main"),
2022
+ }))
2023
+ .filter((item): item is NonNullable<typeof item> => item !== undefined);
2024
+ const next = mergeNewsItems(loadNewsItems(sessionFile), incoming);
2025
+ saveNewsItems(sessionFile, next);
2026
+ for (const record of this.records.values()) {
2027
+ record.snapshot.transcript = {
2028
+ ...record.snapshot.transcript,
2029
+ lines: tagNewsLines(record.snapshot.transcript.lines, next, record.snapshot.id),
2030
+ };
2031
+ }
2032
+ this.emit({ type: "news-changed" });
2033
+ }
2034
+
1896
2035
  private settlementId(record: RuntimeRecord, status: "idle" | "completed" | "failed"): string {
1897
2036
  return `${record.snapshot.id}:${record.activityGeneration}:${status}`;
1898
2037
  }
@@ -1912,11 +2051,16 @@ export class SubagentManager {
1912
2051
  `worktree: ${record.snapshot.worktree.path}`,
1913
2052
  summary ? `summary: ${summary}` : "",
1914
2053
  ].filter(Boolean).join("\n");
2054
+ const requester = record.snapshot.parentAgentId
2055
+ ? this.records.get(record.snapshot.parentAgentId)?.snapshot.name ?? record.snapshot.parentAgentId
2056
+ : "main";
1915
2057
  settlement = {
1916
2058
  id,
1917
2059
  messageId: `settlement-${id}`,
1918
2060
  agentId: record.snapshot.id,
2061
+ agentName: record.snapshot.name,
1919
2062
  parentAgentId: record.snapshot.parentAgentId,
2063
+ requesterName: requester,
1920
2064
  status,
1921
2065
  summary,
1922
2066
  activityGeneration: record.activityGeneration,
@@ -60,9 +60,13 @@ export type SubagentSettlement = {
60
60
  parentAgentId: string | null;
61
61
  status: "idle" | "completed" | "failed";
62
62
  summary?: string;
63
+ agentName?: string;
64
+ requesterName?: string;
63
65
  activityGeneration: number;
64
66
  content: string;
65
67
  createdAt: number;
68
+ response?: string;
69
+ respondedAt?: number;
66
70
  acknowledgedAt?: number;
67
71
  };
68
72
 
@@ -96,6 +100,7 @@ export type TriggerEventData = ExternalTriggerEventData & {
96
100
 
97
101
  export type SubagentManagerEvent =
98
102
  | { type: "changed" }
103
+ | { type: "news-changed" }
99
104
  | { type: "main-line"; line: Line }
100
105
  | { type: "main-pending-add"; pending: PendingLine }
101
106
  | { type: "main-pending-resolve"; id: string }
@@ -20,7 +20,7 @@ export type Role = "user" | "assistant" | "thinking" | "system" | "error";
20
20
  export type Line =
21
21
  | { kind: "text"; role: Role; text: string; newsId?: string }
22
22
  | { kind: "tool"; call: ToolCall }
23
- | { kind: "agent-message"; sender: string; recipient: string; text: string };
23
+ | { kind: "agent-message"; sender: string; recipient: string; text: string; messageId?: string };
24
24
 
25
25
  export type PendingLine = {
26
26
  id: string;
@@ -232,7 +232,7 @@ export function TextLine({
232
232
  <Row glyph={GUTTER} glyphColor={color}>
233
233
  <text
234
234
  ref={workingCaret ? textCaret : undefined}
235
- content={workingCaret ? undefined : displayText}
235
+ content={workingCaret ? "" : displayText}
236
236
  fg={color}
237
237
  selectable
238
238
  wrapMode="word"
@@ -424,7 +424,7 @@ export function ToolLine({
424
424
  {prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
425
425
  <text
426
426
  ref={workingCaret ? caret : undefined}
427
- content={workingCaret ? undefined : new StyledText(bodyChunks)}
427
+ content={workingCaret ? "" : new StyledText(bodyChunks)}
428
428
  selectable
429
429
  wrapMode="word"
430
430
  style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}