@fitchmultz/pi-subagents 0.46.1 → 0.46.2

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/agents/agent-management.js +8 -2
  3. package/dist/agents/agents.js +8 -8
  4. package/dist/extension/build-info.js +1 -1
  5. package/dist/extension/index.js +11 -5
  6. package/dist/extension/result-snapshot.js +0 -18
  7. package/dist/history/ingest.js +40 -31
  8. package/dist/history/prune.js +1 -1
  9. package/dist/history/run-projection.js +1 -2
  10. package/dist/history/search-queries.js +8 -2
  11. package/dist/history/store.js +20 -4
  12. package/dist/history/text-chunks.js +1 -2
  13. package/dist/history/transcript-queries.js +12 -6
  14. package/dist/intercom/result-intercom.js +0 -74
  15. package/dist/pi-intercom/broker/broker.js +33 -16
  16. package/dist/pi-intercom/broker/framing.js +67 -17
  17. package/dist/pi-intercom/broker/snapshot-reader.js +29 -8
  18. package/dist/pi-intercom/broker/snapshots.js +144 -41
  19. package/dist/pi-intercom/session-targets.js +0 -1
  20. package/dist/runs/background/async-run-discovery.js +4 -6
  21. package/dist/runs/background/completion-dedupe.js +4 -55
  22. package/dist/runs/background/notify.js +1 -9
  23. package/dist/runs/background/result-watcher.js +1 -1
  24. package/dist/runs/background/runner-child-observer.js +12 -7
  25. package/dist/runs/background/runner-monitor.js +2 -0
  26. package/dist/runs/background/runner-parallel.js +2 -3
  27. package/dist/runs/shared/child-observation-spool.js +8 -3
  28. package/dist/runs/shared/run-restoration.js +4 -5
  29. package/dist/runs/shared/supervisor-questions.js +7 -4
  30. package/dist/shared/journal-frames.js +1 -1
  31. package/dist/slash/command-discovery.js +13 -12
  32. package/dist/tui/agent-history.js +0 -14
  33. package/dist/tui/agent-view.js +0 -1
  34. package/dist/tui/conversation-history.js +10 -1
  35. package/dist/tui/history-cards.js +8 -22
  36. package/docs/intercom.md +5 -1
  37. package/docs/performance.md +72 -3
  38. package/package.json +1 -1
  39. package/src/agents/agent-management.ts +8 -4
  40. package/src/agents/agents.ts +17 -12
  41. package/src/extension/index.ts +12 -5
  42. package/src/extension/result-snapshot.ts +1 -27
  43. package/src/history/ingest.ts +57 -49
  44. package/src/history/prune.ts +1 -1
  45. package/src/history/run-projection.ts +2 -4
  46. package/src/history/search-queries.ts +9 -2
  47. package/src/history/store.ts +21 -5
  48. package/src/history/text-chunks.ts +7 -2
  49. package/src/history/transcript-queries.ts +16 -10
  50. package/src/intercom/result-intercom.ts +0 -103
  51. package/src/pi-intercom/broker/broker.ts +34 -20
  52. package/src/pi-intercom/broker/framing.ts +71 -20
  53. package/src/pi-intercom/broker/snapshot-reader.ts +47 -9
  54. package/src/pi-intercom/broker/snapshots.ts +184 -51
  55. package/src/pi-intercom/session-targets.ts +0 -2
  56. package/src/runs/background/async-run-discovery.ts +4 -10
  57. package/src/runs/background/completion-dedupe.ts +6 -69
  58. package/src/runs/background/notify.ts +2 -12
  59. package/src/runs/background/result-watcher.ts +1 -1
  60. package/src/runs/background/runner-child-observer.ts +19 -11
  61. package/src/runs/background/runner-monitor.ts +2 -0
  62. package/src/runs/background/runner-parallel.ts +2 -3
  63. package/src/runs/shared/child-observation-spool.ts +8 -3
  64. package/src/runs/shared/run-restoration.ts +4 -4
  65. package/src/runs/shared/supervisor-questions.ts +10 -10
  66. package/src/shared/journal-frames.ts +1 -1
  67. package/src/slash/command-discovery.ts +13 -12
  68. package/src/tui/agent-history.ts +1 -18
  69. package/src/tui/agent-view.ts +0 -1
  70. package/src/tui/conversation-history.ts +15 -1
  71. package/src/tui/history-cards.ts +8 -26
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.46.2] - 2026-10-12
4
+
5
+ ### Changed
6
+
7
+ - Keep parent execution, history and UI imports behind the child-mode guard. Skip empty streaming-spool replay/truncation and right-size copy buffers while preserving exact native wire logs and completed-text output.
8
+ - Reuse a bounded set of SQLite statements, prune through indexed source lookups and publish small indexed records in one transaction. Bound pending-human reply lookup and improve relevance search without changing global scores, attempt boundaries or pagination.
9
+ - Avoid irrelevant completion discovery and duplicate profile reads when listing agents. Reuse unchanged native history cards while retaining fresh detail validation, expanded state and source/theme invalidation.
10
+ - Assemble fragmented Intercom frames and restore topic snapshots incrementally. Encode socket payloads once and keep legacy lists identity-only without reconstructing discarded topic arrays; encoded limits and validation-before-commit remain unchanged.
11
+ - Aggregate workflow activity on tool transitions and coalesced status publication rather than every streaming delta. Preserve all observer updates and scheduling requests.
12
+ - Right-size saved-owner JSON scratch buffers and share supplemental-owner reads across recovery. Filter validated embedded question owners before reading foreign answer/delivery/revival/cancellation state, preserving full archive discovery, selected validation and fresh publication.
13
+ - Record reproducible operation-count baselines and qualified startup measurements in the performance guide.
14
+
15
+ ### Fixed
16
+
17
+ - Clear stale top-level tool/path activity when a parallel child exits or fails without a tool-end event, while retaining activity until a resistant process actually exits.
18
+
3
19
  ## [0.46.1] - 2026-10-11
4
20
 
5
21
  ### Fixed
@@ -1,4 +1,5 @@
1
- import { discoverAgents, discoverAgentsAll } from "./agents.js";
1
+ import { discoverAgentsAll } from "./agents.js";
2
+ import { mergeAgents } from "./agent-selection.js";
2
3
  import { formatAgentGroups, formatAgentDetail, formatChainDetail } from "./management-format.js";
3
4
  import { handleCreate, handleUpdate, handleDelete } from "./management-persistence.js";
4
5
  import { result, normalizeListScope, discoveryOptions, findEffectiveAgents, findChains, availableNamesText, } from "./management-targets.js";
@@ -9,7 +10,12 @@ export function handleList(params, ctx) {
9
10
  return result("agentScope must be 'user', 'project', or 'both'.", true);
10
11
  }
11
12
  const discovery = discoverAgentsAll(ctx.cwd, discoveryOptions(ctx), scope);
12
- const agents = discoverAgents(ctx.cwd, scope, discoveryOptions(ctx)).agents.sort((a, b) => a.name.localeCompare(b.name));
13
+ const agents = mergeAgents(discovery.user, discovery.project, [
14
+ ...discovery.builtin,
15
+ ...discovery.package,
16
+ ])
17
+ .filter((agent) => agent.disabled !== true)
18
+ .sort((a, b) => a.name.localeCompare(b.name));
13
19
  const chains = discovery.chains
14
20
  .filter((chain) => scope === "both" || chain.source === scope)
15
21
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -59,10 +59,14 @@ function loadChainsFromDir(dir, source) {
59
59
  }
60
60
  return { chains, diagnostics };
61
61
  }
62
- function discoverChains(userDir, projectDirs, scope) {
63
- const projectChains = (scope === "user" ? [] : projectDirs).map((dir) => loadChainsFromDir(dir, "project"));
64
- const userChains = scope === "project" ? { chains: [], diagnostics: [] } : loadChainsFromDir(userDir, "user");
62
+ export function discoverChains(cwd, options = {}, scope = "both") {
63
+ const userChainDir = path.join(getAgentDir(), "chains");
64
+ const project = resolveNearestProjectChainDirs(cwd, options);
65
+ const projectChains = (scope === "user" ? [] : project.readDirs).map((dir) => loadChainsFromDir(dir, "project"));
66
+ const userChains = scope === "project" ? { chains: [], diagnostics: [] } : loadChainsFromDir(userChainDir, "user");
65
67
  return {
68
+ userChainDir,
69
+ projectChainDir: project.preferredDir,
66
70
  chains: [...userChains.chains, ...projectChains.flatMap((entry) => entry.chains)],
67
71
  chainDiagnostics: [
68
72
  ...userChains.diagnostics,
@@ -88,10 +92,8 @@ export function discoverAgents(cwd, scope, options = {}) {
88
92
  }
89
93
  export function discoverAgentsAll(cwd, options = {}, scope = "both") {
90
94
  const paths = profilePaths(cwd, options);
91
- const userChainDir = path.join(getAgentDir(), "chains");
92
- const projectChainPaths = resolveNearestProjectChainDirs(cwd, options);
93
95
  const profiles = discoverLocalProfiles(paths, scope);
94
- const chains = discoverChains(userChainDir, projectChainPaths.readDirs, scope);
96
+ const chains = discoverChains(cwd, options, scope);
95
97
  const packages = loadConfiguredPackageAgents(cwd, scope, options);
96
98
  return {
97
99
  ...profiles,
@@ -103,8 +105,6 @@ export function discoverAgentsAll(cwd, options = {}, scope = "both") {
103
105
  ],
104
106
  userDir: paths.userDirs.at(0) ?? path.join(getAgentDir(), "agents"),
105
107
  projectDir: paths.projectDir,
106
- userChainDir,
107
- projectChainDir: projectChainPaths.preferredDir,
108
108
  userSettingsPath: paths.userSettingsPath,
109
109
  projectSettingsPath: paths.projectSettingsPath,
110
110
  };
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.46.1","sha256":"c04a71cfa57811b1a2d45d43b71aa2edd0f47cd86f3b8ca91730a87ffe854304"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.46.2","sha256":"3d48e9cb92ee79f898e74db19a38eecf8e61c126587a6a3d7958acfad4ee487e"});
@@ -1,15 +1,21 @@
1
- import { SUBAGENT_CHILD_ENV } from "../runs/shared/pi-args.js";
2
- import { ensureTempRoot } from "../shared/temp-root.js";
1
+ // Keep deferred imports on Pi's host-peer mapping path in production-only installs.
2
+ // oxlint-disable-next-line import/no-unassigned-import
3
+ import "@earendil-works/pi-coding-agent";
4
+ import { SUBAGENT_CHILD_ENV } from "../runs/shared/child-route-env.js";
3
5
  import { errorMessage } from "../shared/unknown.js";
4
6
  import { loadConfig } from "./config.js";
5
- import { ParentSubagentRuntime } from "./parent-runtime.js";
6
- import { cleanupStaleRuntime } from "./runtime-reload.js";
7
7
  export { loadConfig } from "./config.js";
8
- export default function registerSubagentExtension(pi) {
8
+ export default async function registerSubagentExtension(pi) {
9
9
  if (process.env[SUBAGENT_CHILD_ENV] === "1") {
10
10
  // Explicit fanout-child owns nested delegation; globally discovered parent code remains inert.
11
11
  return;
12
12
  }
13
+ // Children discover this entry too; keep the parent execution/history/UI graph behind the guard.
14
+ const [{ ensureTempRoot }, { ParentSubagentRuntime }, { cleanupStaleRuntime }] = await Promise.all([
15
+ import("../shared/temp-root.js"),
16
+ import("./parent-runtime.js"),
17
+ import("./runtime-reload.js"),
18
+ ]);
13
19
  try {
14
20
  ensureTempRoot();
15
21
  }
@@ -1,21 +1,3 @@
1
- /** A new result owns its progress buffers; native messages and accounting stay shared snapshots. */
2
- export function copyProgress(progress) {
3
- return {
4
- ...progress,
5
- recentTools: progress.recentTools.map((tool) => ({ ...tool })),
6
- recentOutput: [...progress.recentOutput],
7
- };
8
- }
9
- export function copyResult(result) {
10
- return { ...result, progress: result.progress ? copyProgress(result.progress) : undefined };
11
- }
12
- export function copyDetails(details) {
13
- return {
14
- ...details,
15
- results: details.results.map(copyResult),
16
- progress: details.progress?.map(copyProgress),
17
- };
18
- }
19
1
  /** Preserve the native result envelope while giving the receiving tool its own content array. */
20
2
  export function copyExecutionResult(result) {
21
3
  return { ...result, content: [...result.content] };
@@ -147,7 +147,6 @@ export class SourceIngest {
147
147
  });
148
148
  return;
149
149
  }
150
- this.stagedEntry = stageEntry(this.store, this.source, record);
151
150
  this.textCursor = 0;
152
151
  this.phase = "text";
153
152
  }
@@ -197,41 +196,51 @@ export class SourceIngest {
197
196
  }
198
197
  indexText() {
199
198
  const record = this.record;
200
- const rowid = this.stagedEntry;
201
- if (!record || rowid === undefined) {
202
- throw new HistoryIndexError("CORRUPT", "Missing staged entry.");
199
+ if (!record) {
200
+ throw new HistoryIndexError("CORRUPT", "Missing ingestion record.");
203
201
  }
204
- const chunks = this.store.all("SELECT rowid,* FROM pending_text WHERE rowid>? ORDER BY rowid LIMIT 32", this.textCursor);
205
- if (chunks.length > 0) {
206
- this.store.transaction(() => {
207
- for (const chunk of chunks) {
208
- if (!allowed(record.value, text(chunk, "field"))) {
209
- continue;
210
- }
211
- const body = safeText(text(chunk, "text"), 1_048_576);
212
- const id = this.store.run("INSERT INTO documents(entry_rowid,field,text_start,text_end,preview) VALUES (?,?,?,?,?)", rowid, text(chunk, "field"), number(chunk, "position"), number(chunk, "end_position"), safeText(body, 512)).lastInsertRowid;
213
- this.store.run("INSERT INTO corpus(rowid,text) VALUES (?,?)", id, body);
214
- }
215
- });
216
- const last = chunks.at(-1);
217
- if (last) {
218
- this.textCursor = number(last, "rowid");
202
+ const chunks = this.store.all("SELECT rowid,* FROM pending_text WHERE rowid>? ORDER BY rowid LIMIT 33", this.textCursor);
203
+ const complete = chunks.length <= 32;
204
+ const batch = chunks.slice(0, 32);
205
+ let rowid;
206
+ const write = () => {
207
+ rowid = this.stagedEntry ?? stageEntry(this.store, this.source, record);
208
+ this.indexChunks(rowid, record, batch);
209
+ if (complete) {
210
+ this.publishEntry(rowid, record);
219
211
  }
220
- return;
212
+ };
213
+ if (complete) {
214
+ this.published(record.end, write);
215
+ this.stagedEntry = undefined;
216
+ this.phase = "read";
217
+ }
218
+ else {
219
+ this.store.transaction(write);
220
+ this.stagedEntry = rowid;
221
+ this.textCursor = number(batch[31], "rowid");
222
+ }
223
+ }
224
+ indexChunks(rowid, record, chunks) {
225
+ for (const chunk of chunks) {
226
+ if (!allowed(record.value, text(chunk, "field"))) {
227
+ continue;
228
+ }
229
+ const body = safeText(text(chunk, "text"), 1_048_576);
230
+ const id = this.store.run("INSERT INTO documents(entry_rowid,field,text_start,text_end,preview) VALUES (?,?,?,?,?)", rowid, text(chunk, "field"), number(chunk, "position"), number(chunk, "end_position"), safeText(body, 512)).lastInsertRowid;
231
+ this.store.run("INSERT INTO corpus(rowid,text) VALUES (?,?)", id, body);
221
232
  }
233
+ }
234
+ publishEntry(rowid, record) {
222
235
  const value = record.value;
223
236
  const answers = this.answerTexts.fingerprints(value, typeof value.id === "string" ? value.id : `legacy-${record.start}`);
224
- this.published(record.end, () => {
225
- if (this.store.run("UPDATE entries SET published=1 WHERE rowid=? AND source_id=? AND generation=? AND published=0", rowid, this.id, this.source.generation).changes !== 1) {
226
- throw new HistoryIndexError("SOURCE_CHANGED", "Staged entry disappeared before publication; reconcile before advancing the source.");
227
- }
228
- for (const [digest, item] of answers) {
229
- this.store.run("INSERT OR IGNORE INTO answers VALUES (?,?,?)", rowid, digest, item);
230
- }
231
- publishTools(this.store, this.source, rowid, value);
232
- });
233
- this.stagedEntry = undefined;
234
- this.phase = "read";
237
+ if (this.store.run("UPDATE entries SET published=1 WHERE rowid=? AND source_id=? AND generation=? AND published=0", rowid, this.id, this.source.generation).changes !== 1) {
238
+ throw new HistoryIndexError("SOURCE_CHANGED", "Staged entry disappeared before publication; reconcile before advancing the source.");
239
+ }
240
+ for (const [digest, item] of answers) {
241
+ this.store.run("INSERT OR IGNORE INTO answers VALUES (?,?,?)", rowid, digest, item);
242
+ }
243
+ publishTools(this.store, this.source, rowid, value);
235
244
  }
236
245
  /** Each pump reads <=64 KiB or indexes <=32 chunks, then returns to the IPC event loop. */
237
246
  step() {
@@ -17,7 +17,7 @@ export function pruneHistory(store, runs, previousSources, release) {
17
17
  }
18
18
  return store.transaction(() => {
19
19
  let removed = previousSources === undefined && pruneRuns(store, runs);
20
- for (const source of store.all(`SELECT id,path FROM sources WHERE id NOT IN (SELECT source_id FROM children WHERE source_id IS NOT NULL)${previousSources ? ` AND id IN (${previousSources.map(() => "?").join(",")})` : ""}`, ...(previousSources ?? []))) {
20
+ for (const source of store.all(`SELECT id,path FROM sources WHERE NOT EXISTS (SELECT 1 FROM children WHERE children.source_id=sources.id)${previousSources ? ` AND id IN (${previousSources.map(() => "?").join(",")})` : ""}`, ...(previousSources ?? []))) {
21
21
  const id = text(source, "id");
22
22
  release(id, text(source, "path"));
23
23
  store.deleteDocuments(id);
@@ -123,8 +123,7 @@ export function compactView(view) {
123
123
  /** Canonical owner/filesystem projection. Native transcript presence never establishes completion. */
124
124
  export function projectRun(run, ownerSessionId, runs, foreground) {
125
125
  const state = { ownedRuns: runs, foregroundRuns: foreground };
126
- const questions = listRunQuestions(getRunMetadataDir(run.runId)).filter((question) => question.ownerSessionId === ownerSessionId &&
127
- ["awaiting_input", "answer_pending"].includes(question.state));
126
+ const questions = listRunQuestions(getRunMetadataDir(run.runId), ownerSessionId).filter((question) => ["awaiting_input", "answer_pending"].includes(question.state));
128
127
  return {
129
128
  ...ownedRunView(run, state, {
130
129
  pendingInput: questions.length > 0,
@@ -64,7 +64,8 @@ function plan(input, expression, cursor, score) {
64
64
  clauses.push("c.agent=?");
65
65
  params.push(input.agent);
66
66
  }
67
- // Scope physical records once before aggregation; bm25 still uses the whole corpus.
67
+ // Materialized IDs reject common out-of-scope postings without per-hit entry lookups.
68
+ // Each source is scoped once; bm25 still uses the whole corpus.
68
69
  const eligible = hasText(input.runId) || hasText(input.agent)
69
70
  ? `eligible AS MATERIALIZED (SELECT e.rowid FROM entries e JOIN sources s ON s.id=e.source_id WHERE e.published=1 AND e.generation=s.generation AND e.source_id IN (SELECT c.source_id FROM children c WHERE ${clauses.slice(4).join(" AND ")})),`
70
71
  : "";
@@ -122,13 +123,18 @@ export class SearchQueries {
122
123
  }
123
124
  rows(expression, filter, score, limit) {
124
125
  // Match every token across a record's visible windows; a quoted phrase cannot span fields.
126
+ // Relevance benefits from hit-driven attribution. Newest keeps the native join order:
127
+ // forcing hit order regresses newest sorting across many shared attempts.
128
+ const joined = score === "matched.score"
129
+ ? "FROM matched CROSS JOIN entries e ON e.rowid=matched.entry_rowid CROSS JOIN sources s ON s.id=e.source_id CROSS JOIN children c ON c.source_id=s.id JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id"
130
+ : "FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id";
125
131
  // Ranking/filters precede LIMIT. The parent owns the hard deadline for a costly FTS scan.
126
132
  return this.scope.store.all(`WITH ${filter.eligible} hits AS MATERIALIZED (
127
133
  ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?${filter.eligible.length > 0 ? " AND d.entry_rowid IN (SELECT rowid FROM eligible)" : ""}`).join(" UNION ALL ")}
128
134
  ), terms AS (SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term),
129
135
  matched AS (SELECT entry_rowid,SUM(score) AS score FROM terms GROUP BY entry_rowid HAVING COUNT(*)=${expression.length}),
130
136
  excerpts AS (SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits)
131
- SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${filter.clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...filter.eligibleParams, ...filter.params, limit + 1);
137
+ SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview ${joined} LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${filter.clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...filter.eligibleParams, ...filter.params, limit + 1);
132
138
  }
133
139
  search(input) {
134
140
  const expression = searchGrammar(input.query);
@@ -26,6 +26,7 @@ export class HistoryStore {
26
26
  this.counters[operation] += amount;
27
27
  }
28
28
  ingestLock;
29
+ statements = new Map();
29
30
  outputDigests = new OutputDigests();
30
31
  constructor(agentDir, owner) {
31
32
  this.owner = owner;
@@ -49,17 +50,31 @@ export class HistoryStore {
49
50
  return row;
50
51
  }
51
52
  get(sql, ...params) {
52
- const row = this.db.prepare(sql).get(...params);
53
+ const row = this.statement(sql).get(...params);
53
54
  return row === undefined ? undefined : indexRow(row);
54
55
  }
55
56
  all(sql, ...params) {
56
- return this.db
57
- .prepare(sql)
57
+ return this.statement(sql)
58
58
  .all(...params)
59
59
  .map(indexRow);
60
60
  }
61
61
  run(sql, ...params) {
62
- return this.db.prepare(sql).run(...params);
62
+ return this.statement(sql).run(...params);
63
+ }
64
+ statement(sql) {
65
+ let statement = this.statements.get(sql);
66
+ if (!statement) {
67
+ statement = this.db.prepare(sql);
68
+ // Dynamic browse/filter shapes cannot grow this connection's cache indefinitely.
69
+ if (this.statements.size >= 128) {
70
+ const oldest = this.statements.keys().next().value;
71
+ if (oldest !== undefined) {
72
+ this.statements.delete(oldest);
73
+ }
74
+ }
75
+ this.statements.set(sql, statement);
76
+ }
77
+ return statement;
63
78
  }
64
79
  deleteDocuments(sourceId, unpublishedOnly = false) {
65
80
  const where = `entry_rowid IN (SELECT rowid FROM entries WHERE source_id=?${unpublishedOnly ? " AND published=0" : ""})`;
@@ -194,6 +209,7 @@ export class HistoryStore {
194
209
  }
195
210
  }
196
211
  close() {
212
+ this.statements.clear();
197
213
  this.ingestLock?.close();
198
214
  this.db.close();
199
215
  }
@@ -159,9 +159,8 @@ export class TextChunks {
159
159
  this.fields.clear();
160
160
  }
161
161
  if (this.staged.length > 0) {
162
- const insert = store.db.prepare("INSERT INTO pending_text VALUES (?,?,?,?)");
163
162
  for (const item of this.staged) {
164
- insert.run(item.field, item.start, item.end, item.text);
163
+ store.run("INSERT INTO pending_text VALUES (?,?,?,?)", item.field, item.start, item.end, item.text);
165
164
  }
166
165
  this.staged = [];
167
166
  }
@@ -51,18 +51,24 @@ export class TranscriptQueries {
51
51
  this.scope = scope;
52
52
  }
53
53
  delivered(boundary, ids) {
54
- const delivered = [];
54
+ const humans = [];
55
55
  const where = boundary.clauses.join(" AND ");
56
56
  for (const id of new Set(ids)) {
57
57
  const human = this.scope.store.get(`SELECT start FROM entries WHERE ${where} AND human_id=? ORDER BY start DESC LIMIT 1`, ...boundary.params, id);
58
58
  if (human) {
59
- delivered.push([
60
- id,
61
- this.scope.store.get(`SELECT rowid FROM entries WHERE ${where} AND start>? AND assistant_text=1 LIMIT 1`, ...boundary.params, number(human, "start")) !== undefined,
62
- ]);
59
+ humans.push([id, number(human, "start")]);
63
60
  }
64
61
  }
65
- return delivered;
62
+ if (humans.length === 0) {
63
+ return [];
64
+ }
65
+ // One tail lookup answers every ID, without scanning before the earliest requested human.
66
+ const assistant = this.scope.store.get(`SELECT start FROM entries WHERE ${where} AND start>? AND assistant_text=1 ORDER BY start DESC LIMIT 1`, ...boundary.params, Math.min(...humans.map(([, start]) => start)));
67
+ const assistantStart = assistant ? number(assistant, "start") : undefined;
68
+ return humans.map(([id, start]) => [
69
+ id,
70
+ assistantStart !== undefined && start < assistantStart,
71
+ ]);
66
72
  }
67
73
  readMetadata(boundary, readThrough) {
68
74
  const where = boundary.clauses.join(" AND ");
@@ -1,4 +1,3 @@
1
- import { copyDetails, copyResult } from "../extension/result-snapshot.js";
2
1
  import { compactNestedResultChildren, formatNestedResultLines } from "./nested-result-tree.js";
3
2
  export { compactNestedResultChildren, attachNestedChildrenToResultChildren, } from "./nested-result-tree.js";
4
3
  export { deliverSubagentResultIntercomEvent, deliverSubagentIntercomMessageEvent, } from "./result-delivery.js";
@@ -215,76 +214,3 @@ export function buildSubagentResultIntercomPayload(input) {
215
214
  };
216
215
  return { ...payload, message: formatSubagentResultIntercomMessage(payload) };
217
216
  }
218
- function stripSingleResultOutputs(result) {
219
- return {
220
- ...copyResult(result),
221
- messages: undefined,
222
- finalOutput: undefined,
223
- truncation: undefined,
224
- };
225
- }
226
- export function stripDetailsOutputsForIntercomReceipt(details, delivery) {
227
- return {
228
- ...copyDetails(details),
229
- results: details.results.map(stripSingleResultOutputs),
230
- ...(delivery ? { intercomDelivery: delivery } : {}),
231
- };
232
- }
233
- function compactReceiptSummary(summary) {
234
- const withoutOutput = summary.split(/\n\nOutput:\n|\sOutput:\s/)[0] ?? summary;
235
- const normalized = withoutOutput.replace(/\s+/g, " ").trim();
236
- return normalized.length > 240 ? `${normalized.slice(0, 239)}…` : normalized;
237
- }
238
- function formatChildReferences(children, field, heading) {
239
- const lines = [];
240
- for (const child of children) {
241
- const value = child[field];
242
- if (typeof value === "string") {
243
- lines.push(`- ${child.agent} [${child.status}]: ${value}`);
244
- }
245
- }
246
- return lines.length > 0 ? [heading, ...lines] : [];
247
- }
248
- function formatReceiptReferences(payload) {
249
- const lines = [];
250
- if (hasText(payload.resultPath)) {
251
- lines.push(`Saved result (acceptance details when configured): ${payload.resultPath}`);
252
- }
253
- for (const child of payload.children) {
254
- if (hasText(child.metadataPath)) {
255
- lines.push(`Result metadata (${child.agent}; acceptance details when configured): ${child.metadataPath}${fs.existsSync(child.metadataPath) ? "" : " (missing)"}`);
256
- }
257
- }
258
- lines.push(...formatChildReferences(payload.children, "artifactPath", "Artifacts:"), ...formatChildReferences(payload.children, "intercomTarget", "Run intercom targets (may be inactive after completion):"), ...formatChildReferences(payload.children, "sessionPath", "Sessions:"));
259
- const nonCompleted = payload.children.filter((child) => child.status !== "completed");
260
- if (nonCompleted.length > 0) {
261
- lines.push("Non-completed children:");
262
- for (const child of nonCompleted) {
263
- lines.push(`- ${child.agent} [${child.status}]: ${compactReceiptSummary(child.summary)}`);
264
- }
265
- }
266
- return lines;
267
- }
268
- export function formatSubagentResultReceipt(input) {
269
- const counts = countStatuses(input.payload.children);
270
- const modeLabel = {
271
- single: "single subagent result",
272
- parallel: "parallel subagent results",
273
- chain: "chain subagent results",
274
- }[input.mode];
275
- const lines = [
276
- `Delivered ${modeLabel} via intercom.`,
277
- "Delivery: succeeded",
278
- `Run: ${input.runId}`,
279
- `Child outcome: ${input.payload.status}`,
280
- `Children: ${formatStatusCounts(counts)}`,
281
- ...(hasText(input.payload.error)
282
- ? [
283
- `Workflow ${input.payload.status === "paused" ? "paused" : "error"}: ${input.payload.error}`,
284
- ]
285
- : []),
286
- ];
287
- lines.push(...formatReceiptReferences(input.payload));
288
- lines.push("Full grouped output was sent over intercom.");
289
- return lines.join("\n");
290
- }
@@ -8,7 +8,7 @@ import { prepareBrokerSocketPath } from "./paths.js";
8
8
  import { brokerPidRecord } from "./pid.js";
9
9
  import { compactTopicMessage, isSessionRegistration, normalizeMessage, normalizeSessionInfo, } from "../types.js";
10
10
  import { errorMessage, isRecord } from "../../shared/unknown.js";
11
- import { messageSender, snapshotFrames, prepareTopicChange, publicationMessage, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
11
+ import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
12
12
  const INTERCOM_DIR = join(getPiAgentDir(), "intercom");
13
13
  const PID_PATH = join(INTERCOM_DIR, "broker.pid");
14
14
  const REPLACE_DELIVERY_DELAY_MS = 1500;
@@ -124,7 +124,12 @@ class IntercomBroker {
124
124
  const info = { ...msg.session, id, lastSeen: msg.session.lastSeen ?? Date.now() };
125
125
  snapshotFrames(randomUUID(), [info], true);
126
126
  const topicFrames = msg.topicFrames === true;
127
- this.sessions.set(id, { socket, info, topicFrames });
127
+ this.sessions.set(id, {
128
+ socket,
129
+ info: messageSender(info),
130
+ topics: new TopicSnapshot(info),
131
+ topicFrames,
132
+ });
128
133
  setId(id);
129
134
  if (this.shutdownTimer) {
130
135
  clearTimeout(this.shutdownTimer);
@@ -148,7 +153,7 @@ class IntercomBroker {
148
153
  return deliveries;
149
154
  }
150
155
  for (const target of this.sessions.values()) {
151
- const subscription = target.info.subscriptions?.find((entry) => entry.topic === publication.topic);
156
+ const subscription = target.topics.subscription(publication.topic);
152
157
  if (target.info.id === fromId || !subscription) {
153
158
  continue;
154
159
  }
@@ -171,22 +176,28 @@ class IntercomBroker {
171
176
  if (!session) {
172
177
  throw new Error("Sender session not found");
173
178
  }
174
- const prepared = prepareTopicChange(session.info, msg.change, session.topicFrames);
175
- const nextInfo = prepared.info;
179
+ const prepared = session.topics.prepare(msg.change, session.topicFrames);
180
+ const nextInfo = prepared.restore
181
+ ? session.info
182
+ : session.topics.snapshot(session.info, prepared);
176
183
  const from = messageSender(nextInfo);
177
184
  const deliveries = this.planPublications(currentId, from, prepared.publication);
178
- const restore = prepared.restore;
179
- const infos = [...this.sessions.values()].map((entry) => entry === session ? nextInfo : entry.info);
180
185
  // Validate every frame before committing state or delivering publications.
181
- if (restore) {
182
- snapshotFrames(requestId, [nextInfo], true);
186
+ if (prepared.restore) {
187
+ session.topics.validateRestore(session.info, prepared, requestId);
183
188
  }
184
- const frames = snapshotFrames(requestId, restore ? [] : infos, msg.stream === true);
189
+ const infos = prepared.restore
190
+ ? []
191
+ : [...this.sessions.values()].map((entry) => {
192
+ if (entry === session) {
193
+ return nextInfo;
194
+ }
195
+ return msg.stream === true ? entry.topics.snapshot(entry.info) : entry.info;
196
+ });
197
+ const frames = snapshotFrames(requestId, infos, msg.stream === true);
185
198
  const last = frames.pop();
186
- if (!last) {
187
- throw new Error("Missing final snapshot frame");
188
- }
189
- session.info = nextInfo;
199
+ session.topics.commit(prepared);
200
+ session.info = messageSender(nextInfo);
190
201
  const receipts = [];
191
202
  for (const { to, message } of deliveries) {
192
203
  receipts.push({ to, ...this.sendToSession(currentId, to, from, message) });
@@ -254,7 +265,10 @@ class IntercomBroker {
254
265
  if (!session) {
255
266
  return;
256
267
  }
257
- const update = { ...session.info, lastSeen: Date.now() };
268
+ const update = {
269
+ ...session.topics.snapshot(session.info),
270
+ lastSeen: Date.now(),
271
+ };
258
272
  for (const key of [
259
273
  "name",
260
274
  "status",
@@ -274,7 +288,10 @@ class IntercomBroker {
274
288
  throw new Error("Invalid presence update");
275
289
  }
276
290
  snapshotFrames(randomUUID(), [nextInfo], true);
277
- session.info = nextInfo;
291
+ if (msg.topics !== undefined || msg.subscriptions !== undefined) {
292
+ session.topics = new TopicSnapshot(nextInfo);
293
+ }
294
+ session.info = messageSender(nextInfo);
278
295
  }
279
296
  sendToSession(fromId, toId, from, message) {
280
297
  const target = this.sessions.get(toId);