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 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 = ledger.find(e => e.type === JT.created && e.wid === wid) ?? entries.find(e => e.type === JT.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
- /** P15, V7: Rebuild session-wide receipts, including entries hidden by compaction. */
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
- return ctx.sessionManager.getEntries().flatMap(entry => entry.type === "custom_message" && entry.customType === CT.attention
42
- ? entry.details?.items ?? [] : []);
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 entry of workflow.entries) {
51
- if (entry.type !== JT.attention)
52
- continue;
53
- const item = entry.item;
54
- if (workflow.entries.some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev))
55
- continue;
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, previous ? "continue" : "task", { message: previous ? `Continue the task. Tool calls whose outcomes are unknown: ${dangling.join(", ") || "none"}.` : t.opening?.message ?? t.spec.task }, undefined, { rid: contentHash([exec, "dispatch"]) });
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 wf = snapshotFromEntries(wid, readJournalSnapshot(journalPath(home, wid)));
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
- return { invalidate() { }, render: (width) => items.flatMap(item => {
54
- const h = HEAD[item.kind] ?? HEAD.unknown, closed = item.kind === "question" && isResolved(item);
55
- const heading = `${h.icon} ${item.kind === "finished" && !item.call ? "Workflow" : `Subagent ${keyOf(item)}`} ${closed ? "— answered" : h.title}`;
56
- // v12 §3: a finished digest is first line + dim per-agent lines, clipped; old single-line items read exactly as before.
57
- const inner = Math.max(1, Math.max(3, Math.floor(width)) - 4);
58
- const body = item.kind === "finished" && !closed
59
- ? digestLines(item.text, line => theme.fg("dim", line), inner)
60
- : [closed ? theme.fg("dim", item.text) : item.text];
61
- return card(theme, closed ? "muted" : h.tone, heading, body, width, options.expanded);
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
- return { invalidate() { }, render: (width) => card(theme, "muted", "you, in the subagent view", text.split("\n"), width, options.expanded) };
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), facts = new Map(), sessions = new Map();
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 journal = readJournalSnapshot(journalPath(this.home, w.wid));
70
+ const index = journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
36
71
  for (const c of w.calls) {
37
- let tail = this.tails.get(c.callId);
38
- if (!tail) {
39
- tail = new SessionTail();
40
- this.tails.set(c.callId, tail);
41
- }
42
- const entries = sessionBranch(tail.read(callSession(this.home, w.wid, c.key, c.gen)));
43
- const value = sessionFacts(entries, c.callId);
44
- const proposal = journal.findLast(e => e.type === "call" && e.key === c.key && e.gen === c.gen);
45
- value.task ||= String(proposal?.spec?.task ?? "");
46
- const loss = journal.findLastIndex(e => e.type === "loss" && e.exec === c.exec);
47
- const launch = journal.findLastIndex(e => e.type === JT.exec && e.exec === c.exec);
48
- if (c.phase !== "sealed" && loss > launch)
49
- value.activity = "connection dropped, retrying";
50
- if (c.phase !== "sealed")
51
- value.live = readLive(join(callDir(this.home, w.wid, c.key, c.gen), LIVE_FILE));
52
- sessions.set(c.callId, entries);
53
- facts.set(c.callId, value);
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 (!sessions.has(key))
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;
@@ -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.match(/[^.!?。!?]+[.!?。!?](?=\s|$)/gu)?.at(-1)?.trim() ?? "";
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.2",
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",