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 +13 -14
- package/package.json +1 -1
- package/src/app.tsx +46 -18
- package/src/check-policy.ts +53 -3
- package/src/news-popup.tsx +9 -0
- package/src/news.ts +90 -2
- package/src/replay.ts +1 -0
- package/src/subagents/manager.ts +145 -1
- package/src/subagents/types.ts +5 -0
- package/src/transcript.tsx +3 -3
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:**
|
|
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
|
|
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
|
-
- **
|
|
256
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
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
|
|
1510
|
-
const
|
|
1511
|
-
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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 !==
|
|
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
|
-
|
|
1594
|
-
|
|
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();
|
package/src/check-policy.ts
CHANGED
|
@@ -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
|
-
|
|
612
|
-
|
|
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) => {
|
package/src/news-popup.tsx
CHANGED
|
@@ -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>(
|
|
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
package/src/subagents/manager.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/subagents/types.ts
CHANGED
|
@@ -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 }
|
package/src/transcript.tsx
CHANGED
|
@@ -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 ?
|
|
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 ?
|
|
427
|
+
content={workingCaret ? "" : new StyledText(bodyChunks)}
|
|
428
428
|
selectable
|
|
429
429
|
wrapMode="word"
|
|
430
430
|
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|