pi-durable-subagents 1.0.2 → 1.0.4
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/CHANGELOG.md +21 -0
- package/dist/agent/main/snapshots.js +33 -14
- package/dist/orchestrator/executor/index.js +10 -1
- package/dist/orchestrator/snapshot.js +7 -1
- package/dist/ui/cards.js +31 -11
- package/dist/ui/data.js +80 -20
- package/dist/ui/session.js +16 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.4
|
|
4
|
+
|
|
5
|
+
- The TUI no longer stalls pi with a long subagent history. Its 500 ms refresh
|
|
6
|
+
re-read and re-derived every historical call's session and scanned journals
|
|
7
|
+
once per call, taking over a second per refresh with a few hundred finished
|
|
8
|
+
calls; it now reuses per-call facts and a journal index (about 5 ms).
|
|
9
|
+
- Main-session polling, attention cards and transcript cards reuse derived
|
|
10
|
+
state per journal and per width instead of rescanning the session every
|
|
11
|
+
200 ms, so typing stays responsive in long sessions.
|
|
12
|
+
- Faster startup: thinking summaries no longer use a regex that went
|
|
13
|
+
quadratic on long unpunctuated thoughts (200 KB took 43 s), and finished
|
|
14
|
+
calls' sessions are read when first shown or in small idle slices rather
|
|
15
|
+
than all at startup.
|
|
16
|
+
|
|
17
|
+
## 1.0.3
|
|
18
|
+
|
|
19
|
+
- Recovery delivers a call's task when an earlier execution ended before its
|
|
20
|
+
subagent received it (for example, an orchestrator restart while the call
|
|
21
|
+
waited for a provider slot). It used to send only "Continue the task", and
|
|
22
|
+
the fresh subagent had to guess what the task was.
|
|
23
|
+
|
|
3
24
|
## 1.0.2
|
|
4
25
|
|
|
5
26
|
The first published release (1.0.0 and 1.0.1 were prepared but never
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readJournalSnapshot } from "../../kernel/journal.js";
|
|
4
|
-
import { singlePresentation } from "../../kernel/guards.js";
|
|
5
4
|
import { journalPath, orchLedger } from "../../paths.js";
|
|
6
5
|
import { CT, JT } from "../../types.js";
|
|
7
6
|
import { holdOf } from "../../orchestrator/snapshot.js";
|
|
@@ -20,9 +19,13 @@ export function workflows(home) {
|
|
|
20
19
|
}
|
|
21
20
|
const pruned = new Set(ledger.filter(e => e.type === "pruned").map(e => String(e.wid)));
|
|
22
21
|
const ids = new Set([...ledger.filter(e => e.type === JT.created).map(e => String(e.wid)), ...names].filter(wid => !pruned.has(wid)));
|
|
22
|
+
const createdBy = new Map();
|
|
23
|
+
for (const e of ledger)
|
|
24
|
+
if (e.type === JT.created && !createdBy.has(e.wid))
|
|
25
|
+
createdBy.set(e.wid, e);
|
|
23
26
|
return [...ids].sort().map(wid => {
|
|
24
27
|
const entries = readJournalSnapshot(journalPath(home, wid));
|
|
25
|
-
const created =
|
|
28
|
+
const created = createdBy.get(wid) ?? entries.find(e => e.type === JT.created);
|
|
26
29
|
return { wid, origin: created?.origin, entries };
|
|
27
30
|
});
|
|
28
31
|
}
|
|
@@ -36,25 +39,41 @@ export function unfinishedWorkflow(home) {
|
|
|
36
39
|
const ledger = readJournalSnapshot(orchLedger(home));
|
|
37
40
|
return workflows(home).some(w => (!w.entries.some(e => e.type === JT.done) || openGeneration(w.entries)) && !holdOf(ledger, w.wid, w.origin));
|
|
38
41
|
}
|
|
39
|
-
|
|
42
|
+
const receipts = new WeakMap();
|
|
43
|
+
const receiptsOf = (entry) => entry.type === "custom_message" && entry.customType === CT.attention
|
|
44
|
+
? entry.details?.items ?? [] : [];
|
|
40
45
|
export function presented(ctx) {
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
// The main session is append-only between session switches: scan only the entries added since the last poll.
|
|
47
|
+
const entries = ctx.sessionManager.getEntries();
|
|
48
|
+
const owner = ctx.sessionManager, prior = receipts.get(owner);
|
|
49
|
+
const appended = prior && prior.length <= entries.length && prior.first === entries[0] && prior.last === entries[prior.length - 1];
|
|
50
|
+
const items = appended ? prior.items.concat(entries.slice(prior.length).flatMap(receiptsOf)) : entries.flatMap(receiptsOf);
|
|
51
|
+
receipts.set(owner, { first: entries[0], last: entries.at(-1), length: entries.length, items });
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
const open = new WeakMap();
|
|
55
|
+
/** The unresolved attention items of one immutable journal snapshot, computed once per snapshot. */
|
|
56
|
+
function unresolved(entries) {
|
|
57
|
+
let items = open.get(entries);
|
|
58
|
+
if (items)
|
|
59
|
+
return items;
|
|
60
|
+
const done = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}\0${e.rev}`));
|
|
61
|
+
items = entries.filter(e => e.type === JT.attention).map(e => e.item).filter(item => !done.has(`${item.id}\0${item.rev}`));
|
|
62
|
+
open.set(entries, items);
|
|
63
|
+
return items;
|
|
43
64
|
}
|
|
44
65
|
/** P15, V7: Select only unresolved, unpresented items owned by this session. */
|
|
45
66
|
export function attention(home, sender, seen) {
|
|
46
|
-
const items = [];
|
|
67
|
+
const items = [], shown = new Set(seen.map(s => `${s.id}\0${s.rev}`));
|
|
47
68
|
for (const workflow of workflows(home)) {
|
|
48
69
|
if (workflow.origin !== sender)
|
|
49
70
|
continue;
|
|
50
|
-
for (const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (singlePresentation({ presented: [...seen, ...items] }, item))
|
|
57
|
-
items.push(item);
|
|
71
|
+
for (const item of unresolved(workflow.entries)) {
|
|
72
|
+
const key = `${item.id}\0${item.rev}`;
|
|
73
|
+
if (shown.has(key))
|
|
74
|
+
continue; // singlePresentation: one presentation per id/rev
|
|
75
|
+
shown.add(key);
|
|
76
|
+
items.push(item);
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
79
|
return items;
|
|
@@ -625,12 +625,21 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
625
625
|
const receipt = unresolved && entries.some(e => receiptId(e) === unresolved.rid2);
|
|
626
626
|
const openingRid = t.opening && contentHash([t.opening.rid, "dispatch"]);
|
|
627
627
|
const openingReceived = openingRid && entries.some(e => receiptId(e) === openingRid);
|
|
628
|
+
// The task has one identity: the dispatch of the call's first execution. A later execution continues only once
|
|
629
|
+
// the session holds that task; an execution interrupted before its child received it (still waiting for a
|
|
630
|
+
// provider slot, say) leaves a session without a task, and "continue" would ask a fresh model to guess.
|
|
631
|
+
const taskRid = contentHash([`${t.callId}#1.1`, "dispatch"]);
|
|
632
|
+
const taskReceived = !openingRid && entries.some(e => receiptId(e) === taskRid);
|
|
628
633
|
if (openingRid && !openingReceived)
|
|
629
634
|
await sender.send(t.callId, "task", { message: t.opening.message }, undefined, { rid: openingRid });
|
|
635
|
+
else if (!openingRid && !taskReceived)
|
|
636
|
+
await sender.send(t.callId, "task", { message: t.spec.task }, undefined, { rid: taskRid });
|
|
630
637
|
else if (unresolved && !receipt)
|
|
631
638
|
await sender.send(t.callId, "continue", { message: String(unresolved.message) }, { qid: String(unresolved.qid), rev: Number(unresolved.rev) }, { rid: String(unresolved.rid2) });
|
|
639
|
+
else if (previous)
|
|
640
|
+
await sender.send(t.callId, "continue", { message: `Continue the task. Tool calls whose outcomes are unknown: ${dangling.join(", ") || "none"}.` }, undefined, { rid: contentHash([exec, "dispatch"]) });
|
|
632
641
|
else
|
|
633
|
-
await sender.send(t.callId,
|
|
642
|
+
await sender.send(t.callId, "task", { message: t.opening?.message ?? t.spec.task }, undefined, { rid: contentHash([exec, "dispatch"]) });
|
|
634
643
|
if (interrupted(a)) {
|
|
635
644
|
await fence(journal, exec, { park: a });
|
|
636
645
|
return finish(journal, t.callId, exec, makeResult("stopped"));
|
|
@@ -214,8 +214,14 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
216
|
/** P25: Snapshot one workflow from its durable journal (v12 §4: plus the planned total of its pinned run body). */
|
|
217
|
+
// A journal snapshot is immutable and replaced on every append, so its derived workflow snapshot is reused until then:
|
|
218
|
+
// re-deriving every historical workflow on each UI refresh dominated pi's main thread.
|
|
219
|
+
const derived = new WeakMap();
|
|
217
220
|
export function workflowSnapshot(home, wid) {
|
|
218
|
-
const
|
|
221
|
+
const entries = readJournalSnapshot(journalPath(home, wid)), hit = derived.get(entries);
|
|
222
|
+
const wf = hit?.wid === wid ? hit.snapshot : snapshotFromEntries(wid, entries);
|
|
223
|
+
if (hit?.wid !== wid && entries.length)
|
|
224
|
+
derived.set(entries, { wid, snapshot: wf });
|
|
219
225
|
const planned = plannedTotal(home, wid, wf.rev);
|
|
220
226
|
return planned === undefined ? wf : { ...wf, planned };
|
|
221
227
|
}
|
package/dist/ui/cards.js
CHANGED
|
@@ -33,13 +33,20 @@ export function card(theme, tone, heading, body, width, expanded = false) {
|
|
|
33
33
|
/** P15, P16: Register renderers for attention presentations and watch-view notes in the main session. */
|
|
34
34
|
export function registerCards(pi, home) {
|
|
35
35
|
const done = new Set(); // resolution is monotone: once resolved, never re-read
|
|
36
|
+
// pi renders every visible message on each frame; an open question re-reads its child session at most once a second.
|
|
37
|
+
const checked = new Map();
|
|
36
38
|
const isResolved = (item) => {
|
|
37
39
|
const id = `${item.id}@${item.rev}`;
|
|
38
40
|
if (done.has(id))
|
|
39
41
|
return true;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
if (now - (checked.get(id) ?? -Infinity) < 1000)
|
|
44
|
+
return false;
|
|
45
|
+
checked.set(id, now);
|
|
40
46
|
try {
|
|
41
47
|
if (resolved(home, item)) {
|
|
42
48
|
done.add(id);
|
|
49
|
+
checked.delete(id);
|
|
43
50
|
return true;
|
|
44
51
|
}
|
|
45
52
|
}
|
|
@@ -50,19 +57,32 @@ export function registerCards(pi, home) {
|
|
|
50
57
|
const items = message.details?.items;
|
|
51
58
|
if (!items?.length)
|
|
52
59
|
return undefined;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// The lines depend only on width, expansion, theme and which questions are answered: reuse them across frames.
|
|
61
|
+
let last;
|
|
62
|
+
const draw = (width) => items.flatMap(item => {
|
|
63
|
+
const h = HEAD[item.kind] ?? HEAD.unknown, closed = item.kind === "question" && isResolved(item);
|
|
64
|
+
const heading = `${h.icon} ${item.kind === "finished" && !item.call ? "Workflow" : `Subagent ${keyOf(item)}`} ${closed ? "— answered" : h.title}`;
|
|
65
|
+
// v12 §3: a finished digest is first line + dim per-agent lines, clipped; old single-line items read exactly as before.
|
|
66
|
+
const inner = Math.max(1, Math.max(3, Math.floor(width)) - 4);
|
|
67
|
+
const body = item.kind === "finished" && !closed
|
|
68
|
+
? digestLines(item.text, line => theme.fg("dim", line), inner)
|
|
69
|
+
: [closed ? theme.fg("dim", item.text) : item.text];
|
|
70
|
+
return card(theme, closed ? "muted" : h.tone, heading, body, width, options.expanded);
|
|
71
|
+
});
|
|
72
|
+
return { invalidate() { last = undefined; }, render: (width) => {
|
|
73
|
+
const key = `${width}|${items.map(item => item.kind === "question" && isResolved(item) ? 1 : 0).join("")}`;
|
|
74
|
+
if (last?.key !== key)
|
|
75
|
+
last = { key, lines: draw(width) };
|
|
76
|
+
return last.lines;
|
|
77
|
+
} };
|
|
63
78
|
});
|
|
64
79
|
pi.registerMessageRenderer(CT.note, (message, options, theme) => {
|
|
65
80
|
const text = typeof message.content === "string" ? message.content : "";
|
|
66
|
-
|
|
81
|
+
let last;
|
|
82
|
+
return { invalidate() { last = undefined; }, render: (width) => {
|
|
83
|
+
if (last?.width !== width)
|
|
84
|
+
last = { width, lines: card(theme, "muted", "you, in the subagent view", text.split("\n"), width, options.expanded) };
|
|
85
|
+
return last.lines;
|
|
86
|
+
} };
|
|
67
87
|
});
|
|
68
88
|
}
|
package/dist/ui/data.js
CHANGED
|
@@ -16,9 +16,38 @@ function readLive(path) {
|
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
const indexes = new WeakMap();
|
|
20
|
+
/** The last call proposal per key/gen and the last loss and launch per execution, built once per journal snapshot
|
|
21
|
+
* instead of scanning the whole journal for every call on every refresh. */
|
|
22
|
+
function journalIndex(journal) {
|
|
23
|
+
const hit = indexes.get(journal);
|
|
24
|
+
if (hit && hit.length === journal.length)
|
|
25
|
+
return hit;
|
|
26
|
+
const index = { length: journal.length, calls: new Map(), losses: new Map(), execs: new Map() };
|
|
27
|
+
journal.forEach((e, i) => {
|
|
28
|
+
if (e.type === "call")
|
|
29
|
+
index.calls.set(`${e.key}\0${e.gen}`, e);
|
|
30
|
+
else if (e.type === "loss")
|
|
31
|
+
index.losses.set(String(e.exec), i);
|
|
32
|
+
else if (e.type === JT.exec)
|
|
33
|
+
index.execs.set(String(e.exec), i);
|
|
34
|
+
});
|
|
35
|
+
indexes.set(journal, index);
|
|
36
|
+
return index;
|
|
37
|
+
}
|
|
38
|
+
/** A map whose missing entries are produced on first `get`/`has`; iteration and `size` cover only produced entries. */
|
|
39
|
+
export class LazyMap extends Map {
|
|
40
|
+
produce;
|
|
41
|
+
constructor(produce) { super(); this.produce = produce; }
|
|
42
|
+
put(key, value) { super.set(key, value); }
|
|
43
|
+
get(key) { if (!super.has(key))
|
|
44
|
+
this.produce(key); return super.get(key); }
|
|
45
|
+
has(key) { return this.get(key) !== undefined; }
|
|
46
|
+
}
|
|
19
47
|
/** A1, P25: Read the UI's data from durable workflow snapshots and native session tails only. */
|
|
20
48
|
export class UiData {
|
|
21
49
|
workflows = [];
|
|
50
|
+
/** Facts and session branch per call id; a finished call's are read on first `get` (see refresh). */
|
|
22
51
|
facts = new Map();
|
|
23
52
|
sessions = new Map();
|
|
24
53
|
aliases = {};
|
|
@@ -26,36 +55,67 @@ export class UiData {
|
|
|
26
55
|
dock = "auto";
|
|
27
56
|
/** Where the dock sits: below the editor (default; never splits an editor header such as a powerline bar) or above. */
|
|
28
57
|
dockAt = "above";
|
|
58
|
+
/** Per-refresh budget (ms) for reading not yet read finished sessions (history) in the background. */
|
|
59
|
+
warmMs = 15;
|
|
29
60
|
tails = new Map();
|
|
61
|
+
/** Branch and facts per call, reused while its session tail is unchanged: a finished call's session never grows, and
|
|
62
|
+
* re-deriving every historical call on each refresh blocked pi's main thread for over a second. */
|
|
63
|
+
derived = new Map();
|
|
30
64
|
home;
|
|
31
65
|
constructor(home) { this.home = home; }
|
|
32
66
|
refresh() {
|
|
33
|
-
const workflows = allWorkflows(this.home),
|
|
67
|
+
const workflows = allWorkflows(this.home), known = new Set(), deferred = new Map();
|
|
68
|
+
const facts = new LazyMap(key => deferred.get(key)?.()), sessions = new LazyMap(key => deferred.get(key)?.());
|
|
34
69
|
for (const w of workflows) {
|
|
35
|
-
const
|
|
70
|
+
const index = journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
|
|
36
71
|
for (const c of w.calls) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.tails.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
known.add(c.callId);
|
|
73
|
+
const load = () => {
|
|
74
|
+
deferred.delete(c.callId);
|
|
75
|
+
let tail = this.tails.get(c.callId);
|
|
76
|
+
if (!tail) {
|
|
77
|
+
tail = new SessionTail();
|
|
78
|
+
this.tails.set(c.callId, tail);
|
|
79
|
+
}
|
|
80
|
+
const source = tail.read(callSession(this.home, w.wid, c.key, c.gen));
|
|
81
|
+
let cached = this.derived.get(c.callId);
|
|
82
|
+
if (!cached || cached.source !== source || cached.length !== source.length) {
|
|
83
|
+
const entries = sessionBranch(source);
|
|
84
|
+
cached = { source, length: source.length, entries, facts: sessionFacts(entries, c.callId) };
|
|
85
|
+
this.derived.set(c.callId, cached);
|
|
86
|
+
}
|
|
87
|
+
const value = { ...cached.facts, live: undefined };
|
|
88
|
+
const proposal = index.calls.get(`${c.key}\0${c.gen}`);
|
|
89
|
+
value.task ||= String(proposal?.spec?.task ?? "");
|
|
90
|
+
const loss = index.losses.get(String(c.exec)) ?? -1;
|
|
91
|
+
const launch = index.execs.get(String(c.exec)) ?? -1;
|
|
92
|
+
if (c.phase !== "sealed" && loss > launch)
|
|
93
|
+
value.activity = "connection dropped, retrying";
|
|
94
|
+
if (c.phase !== "sealed")
|
|
95
|
+
value.live = readLive(join(callDir(this.home, w.wid, c.key, c.gen), LIVE_FILE));
|
|
96
|
+
sessions.put(c.callId, cached.entries);
|
|
97
|
+
facts.put(c.callId, value);
|
|
98
|
+
};
|
|
99
|
+
// A finished call never read before (history at startup) is read when first shown or by the idle warm-up below;
|
|
100
|
+
// reading every historical session up front blocked pi's startup for seconds.
|
|
101
|
+
if (c.phase === "sealed" && !this.tails.has(c.callId))
|
|
102
|
+
deferred.set(c.callId, load);
|
|
103
|
+
else
|
|
104
|
+
load();
|
|
54
105
|
}
|
|
55
106
|
}
|
|
56
107
|
for (const key of this.tails.keys())
|
|
57
|
-
if (!
|
|
108
|
+
if (!known.has(key)) {
|
|
58
109
|
this.tails.delete(key);
|
|
110
|
+
this.derived.delete(key);
|
|
111
|
+
}
|
|
112
|
+
// Warm the history a slice at a time, so opening the list later finds it read without one long stall now.
|
|
113
|
+
const until = performance.now() + this.warmMs;
|
|
114
|
+
for (const load of [...deferred.values()]) {
|
|
115
|
+
if (performance.now() >= until)
|
|
116
|
+
break;
|
|
117
|
+
load();
|
|
118
|
+
}
|
|
59
119
|
this.workflows = workflows;
|
|
60
120
|
this.facts = facts;
|
|
61
121
|
this.sessions = sessions;
|
package/dist/ui/session.js
CHANGED
|
@@ -84,7 +84,22 @@ export function thoughtSummary(text) {
|
|
|
84
84
|
const headings = [...text.matchAll(/\*\*([^*\n]+)\*\*|^#{1,6}\s+(.+)$/gm)];
|
|
85
85
|
if (headings.length)
|
|
86
86
|
return (headings.at(-1)[1] ?? headings.at(-1)[2]).trim();
|
|
87
|
-
return text
|
|
87
|
+
return lastSentence(text);
|
|
88
|
+
}
|
|
89
|
+
const STOPS = new Set([...".!?。!?"]);
|
|
90
|
+
/** The last match of /[^.!?。!?]+[.!?。!?](?=\s|$)/gu without the regex: a long thought with no sentence end made
|
|
91
|
+
* that regex retry from every position (quadratic), which stalled pi's startup on long histories. Such a match is a
|
|
92
|
+
* whole run of non-stop characters followed by one stop that ends the text or precedes whitespace. */
|
|
93
|
+
function lastSentence(text) {
|
|
94
|
+
for (let end = text.length - 1; end > 0; end--) {
|
|
95
|
+
if (!STOPS.has(text[end]) || STOPS.has(text[end - 1]) || (end + 1 < text.length && !/\s/u.test(String.fromCodePoint(text.codePointAt(end + 1)))))
|
|
96
|
+
continue;
|
|
97
|
+
let start = end - 1;
|
|
98
|
+
while (start > 0 && !STOPS.has(text[start - 1]))
|
|
99
|
+
start--;
|
|
100
|
+
return text.slice(start, end + 1).trim();
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
88
103
|
}
|
|
89
104
|
/** UI §2–3, P31: Derive model, thinking, tool activity and the call's own tool-call count solely from committed session entries. */
|
|
90
105
|
export function sessionFacts(entries, call) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-durable-subagents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|