@fitchmultz/pi-subagents 0.44.4 → 0.44.6

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.44.6] - 2026-10-09
4
+
5
+ ### Fixed
6
+ - Keep full Agents pages available for long native histories by sharing bounded configuration results across rows and repeated queries. Committed publication, source generations and exact terminal/time/leaf boundaries invalidate reuse; strict errors and source integrity checks remain unchanged.
7
+ - Restrict scoped saved-text search to eligible records before aggregating matches, retaining the complete corpus for global relevance scores, cross-field word matching, phrases, attempt boundaries and pagination.
8
+ - Publish one settled-freshness notification for unchanged history census work instead of one per run/source. Real commits, canonical failures and source error/recovery observations still notify; polling, census and watches remain active.
9
+
10
+ ## [0.44.5] - 2026-10-08
11
+
12
+ ### Added
13
+ - Optional loopback-only mTLS Intercom HTTPS bridge over SSH reverse forwarding, with certificate-bound remote peer identity, bounded messaging/inbox API, metadata audit logs, remote CLI, and credential issue/rotation/revocation tooling. The same-machine broker and extension startup remain unchanged.
14
+
15
+ ### Fixed
16
+ - Keep supervisor-question tool results bounded: page compact question/answer previews without repeating frozen launch metadata or native attempt baselines. Full question, interview and immutable answer files remain available; answering, cancellation and revival retain their original durable contracts.
17
+ - Keep the history worker's cost per child append proportional to the new bytes. It used to re-hash every indexed byte of a linked session on each change, so a live child writing to a source of several hundred megabytes held the worker at full CPU for hours. Each check now re-hashes the last indexed line. Replacement, truncation and in-place rewrites of that line still start a new generation, and detail reads still verify their record digests. Indexes from earlier releases are verified in full once and then switch to the bounded check without re-indexing.
18
+
3
19
  ## [0.44.4] - 2026-10-04
4
20
 
5
21
  ### Added
package/README.md CHANGED
@@ -450,6 +450,8 @@ The files in `prompts/` document common workflows without registering additional
450
450
 
451
451
  ## Bundled intercom
452
452
 
453
+ For a remote agent on Linux, the optional [secure remote bridge](docs/intercom-bridge.md) provides a loopback-only mTLS HTTPS API over an SSH reverse forward. It registers a certificate-bound peer such as `grok-bot` and permits only messaging operations; the private Unix broker socket remains local. The guide includes setup, remote commands, credential rotation and revocation.
454
+
453
455
  The same `pi-subagents` install registers the intercom extension and skill. `load_intercom({})` exposes the full peer tool explicitly; actionable peer inbound and recovered reply needs also expose it before use. Presence, passive breadcrumbs, routine subagent notices, and human messages answered in chat do not. Broker presence and delivery continue even with the schema hidden. Managed children get a private coordination channel back to the parent Pi session unless an explicit agent extension allowlist excludes it. See [the intercom guide](docs/intercom.md) for direct peer messaging, keyboard UI, tool actions, configuration, and broker details.
454
456
 
455
457
  If you previously installed the standalone package, remove that old settings entry once to avoid loading two intercom extensions:
@@ -489,6 +491,8 @@ agent_runs({ action: "stop", id: "<run-id>" })
489
491
 
490
492
  Resume the **same saved supervisor session**, even from another cwd, to recover its questions. A different session does not silently adopt them. Ordinary `intercom` replies also save the answer while the live waiter is connected. After supervisor/broker restart, prefer `agent_runs` questions/answer; a nudge is guidance, not an answer to a blocking question.
491
493
 
494
+ `questions` returns 20 compact summaries by default (use `limit: 1–100` and `details.questionList.nextOffset` as `offset`), with unresolved questions first. Filter by run `id`, child `index`, or exact `questionId`. Question and answer previews are capped at 2,048 characters; truncation is marked. Read `questionPath` for the complete question, structured interview and saved launch contract, or `answerPath` for the exact immutable answer. These files remain unchanged by browsing; repeated tool results do not copy launch metadata or native attempt baselines. If questions change while paging, restart at offset 0 or look up the exact question ID.
495
+
492
496
  Answers are saved once. Repeating the same answer does not start duplicate work; conflicting answers are rejected without replacing the original. A live child reads the saved answer; an exited child resumes from its saved session in a new run, retaining the original acceptance contract. An answer receipt is not execution completion. `stop` cancels outstanding questions and aborts a live waiter using its saved run identity, including after a parent restart.
493
497
 
494
498
  If an answer launch was interrupted before a continuation existed, the reply gives an explicit `continue` recovery call. It refuses to restart when launch evidence is uncertain; inspect the advertised continuation first. Questions, answers, launch contracts, and results live under `${PI_CODING_AGENT_DIR:-~/.pi/agent}/sessions/subagent-runs/<run-id>/`, separate from temporary logs. Ownership and review use native parent `subagent-run` entries; pending intercom delivery is journaled in that same saved Pi session. Temporary cleanup does not erase these records, but deleting saved sessions or metadata removes their recovery data. Pending questions are not age-cleaned.
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.44.4","sha256":"70cf79892f3600ce1b581e43a1c14995924dead0a6cd30f032cb939b3a7d8d8c"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.44.6","sha256":"e9a0bfe1bf57b2b67e3b32c6f449996dadf8bc897fe2577eea3d919158b40466"});
@@ -60,7 +60,7 @@ export function registerCompactSubagentTools(pi, options) {
60
60
  name: "agent_runs",
61
61
  defaultActive: false,
62
62
  label: "Agent Runs",
63
- description: `List ${childSafe ? "only this child's directly owned" : "your delegated"} runs across working directories (questions/failures, then live work, then unreviewed results; 20 per page). Filter globally by agent/state/text, sort, and page with the returned cursor. history reads 100 bounded native-entry previews; search finds saved visible text using words or quoted phrases, not operators or prefixes. Browse freshness is not completion or delivery proof. Inspect concise results, paths and continuations; full:true includes the full task/configuration. Answer durable questions, nudge, stop, continue, or save parent-only review. Review notes are not sent to children; put actionable instructions in continue/nudge. Inspect/review/nudge never restart finished work. Continue/answer can launch a saved child; async:false waits for its actual result. Saved continuations keep settings unless agent selects a current profile; model overrides win. Live guidance never mutates model or acceptance. profiles lists roles, sources, context and model/thinking/fallback defaults. History survives reload.`,
63
+ description: `List ${childSafe ? "only this child's directly owned" : "your delegated"} runs across working directories (questions/failures, then live work, then unreviewed results; 20 per page). Filter globally by agent/state/text, sort, and page with the returned cursor. history reads 100 bounded native-entry previews; search finds saved visible text using words or quoted phrases, not operators or prefixes. questions pages 20 compact previews (offset/limit); questionId selects one, and questionPath/answerPath retain full records. Browse freshness is not completion or delivery proof. Inspect concise results, paths and continuations; full:true includes the full task/configuration. Answer durable questions, nudge, stop, continue, or save parent-only review. Review notes are not sent to children; put actionable instructions in continue/nudge. Inspect/review/nudge never restart finished work. Continue/answer can launch a saved child; async:false waits for its actual result. Saved continuations keep settings unless agent selects a current profile; model overrides win. Live guidance never mutates model or acceptance. profiles lists roles, sources, context and model/thinking/fallback defaults. History survives reload.`,
64
64
  parameters: AgentRunsParams,
65
65
  async execute(id, params, signal, onUpdate, ctx) {
66
66
  const normalized = normalizeEverydayParams(params, true);
@@ -255,13 +255,13 @@ const HistoryQueryFields = {
255
255
  export const AgentRunsValidationParams = Type.Object({
256
256
  action: Type.Enum(["list", "inspect", "history", "search", "nudge", "stop", "continue", "profiles", "questions", "answer", "review"], { type: "string" }),
257
257
  id: Type.Optional(Type.String({ minLength: 1, description: "Run ID or unambiguous prefix." })),
258
- questionId: Type.Optional(Type.String({ minLength: 1, description: "Durable supervisor question ID for answer." })),
258
+ questionId: Type.Optional(Type.String({ minLength: 1, description: "Durable supervisor question ID for exact questions lookup or answer." })),
259
259
  async: Type.Optional(Type.Boolean({ description: "Continue/answer: false waits for the actual continuation result." })),
260
260
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Child index for a multi-child run." })),
261
261
  message: Type.Optional(Type.String({ minLength: 1, description: "Guidance, follow-up, answer, or optional parent-only review note. Review notes are not sent to the child; put actionable instructions in continue/nudge. Only continue/answer can start a saved child." })),
262
262
  decision: Type.Optional(Type.Enum(["accepted", "needs_changes"], { type: "string", description: "Parent-only review outcome; not sent to the child. Separate from execution and runtime acceptance checks." })),
263
- offset: Type.Optional(Type.Integer({ minimum: 0, description: "List offset; history is retained regardless of page size." })),
264
- limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Results per list/search/history page (default 20; history 100)." })),
263
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "List/questions offset; history is retained regardless of page size." })),
264
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Results per list/questions/search/history page (default 20; history 100)." })),
265
265
  ...HistoryQueryFields,
266
266
  full: Type.Optional(Type.Boolean({ description: "Inspect only: include the full task and saved launch configuration. Default is a concise report; stored data is unchanged." })),
267
267
  agent: Type.Optional(Type.String({ minLength: 1, description: "List/search: filter by child agent. Continue/answer: adopt this current profile, including model, thinking and fallbacks; a separate model override wins. Otherwise keep saved settings. Required for old runs without a saved profile." })),
@@ -279,8 +279,8 @@ export const AgentRunsValidationParams = Type.Object({
279
279
  { if: { properties: { action: { enum: ["review"] } } }, then: requiredObject("decision") },
280
280
  { if: { anyOf: [requiredObject("async"), requiredObject("model"), requiredObject("cwd"), requiredObject("output"), requiredObject("acceptance")] }, then: { properties: { action: { enum: ["continue", "answer"] } } } },
281
281
  { if: requiredObject("agent"), then: { properties: { action: { enum: ["list", "search", "continue", "answer"] } } } },
282
- { if: requiredObject("offset"), then: { properties: { action: { enum: ["list"] } } } },
283
- { if: requiredObject("limit"), then: { properties: { action: { enum: ["list", "history", "search"] } } } },
282
+ { if: requiredObject("offset"), then: { properties: { action: { enum: ["list", "questions"] } } } },
283
+ { if: requiredObject("limit"), then: { properties: { action: { enum: ["list", "questions", "history", "search"] } } } },
284
284
  { if: requiredObject("cursor"), then: { properties: { action: { enum: ["list", "history", "search"] } } } },
285
285
  { if: requiredObject("sort"), then: { properties: { action: { enum: ["list", "search"] } } } },
286
286
  { not: { anyOf: [requiredObject("cursor", "offset"), requiredObject("cursor", "before")] } },
@@ -313,9 +313,9 @@ export const SubagentParams = Type.Object({
313
313
  runId: Type.Optional(Type.String({
314
314
  description: "Target run ID; prefer id. Defaults to the most recently active controllable run for interrupt/extend/nudge."
315
315
  })),
316
- questionId: Type.Optional(Type.String({ minLength: 1, description: "Durable supervisor question ID for answer." })),
316
+ questionId: AgentRunsValidationParams.properties.questionId,
317
317
  decision: Type.Optional(Type.Enum(["accepted", "needs_changes"], { type: "string", description: "Parent-only review decision; not sent to the child. Put actionable instructions in resume/nudge. Does not launch work or change runtime acceptance." })),
318
- offset: Type.Optional(Type.Integer({ minimum: 0, description: "Status list offset." })),
318
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Status list/questions offset." })),
319
319
  limit: AgentRunsValidationParams.properties.limit,
320
320
  ...HistoryQueryFields,
321
321
  full: Type.Optional(Type.Boolean({ description: "Exact status only: include the full task and saved launch configuration; default is concise." })),
@@ -390,8 +390,8 @@ export const SubagentParams = Type.Object({
390
390
  { if: requiredObject("decision"), then: { ...requiredObject("action"), properties: { action: { enum: ["review"] } } } },
391
391
  { if: { ...requiredObject("action"), properties: { action: { enum: ["history"] } } }, then: { anyOf: [requiredObject("id"), requiredObject("runId")] } },
392
392
  { if: { ...requiredObject("action"), properties: { action: { enum: ["search"] } } }, then: requiredObject("query") },
393
- { if: requiredObject("offset"), then: { ...requiredObject("action"), properties: { action: { enum: ["status"] } } } },
394
- { if: requiredObject("limit"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "history", "search"] } } } },
393
+ { if: requiredObject("offset"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "questions"] } } } },
394
+ { if: requiredObject("limit"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "questions", "history", "search"] } } } },
395
395
  { if: requiredObject("cursor"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "history", "search"] } } } },
396
396
  { if: requiredObject("sort"), then: { ...requiredObject("action"), properties: { action: { enum: ["status", "search"] } } } },
397
397
  { if: { allOf: [{ ...requiredObject("action"), properties: { action: { enum: ["search"] } } }, requiredObject("index")] }, then: { anyOf: [requiredObject("id"), requiredObject("runId")] } },
@@ -114,6 +114,16 @@ export class SubagentHistoryIndex {
114
114
  }
115
115
  await this.ready;
116
116
  }
117
+ /** Arms an ordinary deadline only in the continuation that saw the current admission and worker settle, never behind a replacement admission. */
118
+ async admitted(method, input, signal, timeout) {
119
+ await this.ensure();
120
+ const ready = this.ready, child = this.process;
121
+ await ready;
122
+ // setOwner() or a restart may have replaced readiness during either await; wait for the replacement.
123
+ if (!child || ready !== this.ready || child !== this.process)
124
+ return this.admitted(method, input, signal, timeout);
125
+ return this.send(method, input, signal, timeout);
126
+ }
117
127
  async setOwner(input) {
118
128
  if (this.closed)
119
129
  throw new HistoryIndexError("CLOSED", "History index is closed.");
@@ -138,21 +148,17 @@ export class SubagentHistoryIndex {
138
148
  if (run.ownerSessionId !== this.owner.ownerSessionId || foreground && foreground.runId !== run.runId)
139
149
  throw new HistoryIndexError("OWNERSHIP", "Run does not belong to the current owner.");
140
150
  const copy = structuredClone(run), fg = foreground && structuredClone(foreground);
141
- await this.send("updateRun", { run: copy, foreground: fg });
151
+ await this.admitted("updateRun", { run: copy, foreground: fg });
142
152
  this.owner.runs = [...this.owner.runs.filter((entry) => entry.runId !== run.runId), copy];
143
153
  if (fg)
144
154
  this.owner.foregroundRuns = [...(this.owner.foregroundRuns ?? []).filter((entry) => entry.runId !== run.runId), fg];
145
155
  }
146
156
  async query(method, input) {
147
- await this.ensure();
148
157
  const { signal, ...wire } = input;
149
- return this.send(method, wire, signal);
158
+ return this.admitted(method, wire, signal);
150
159
  }
151
160
  /** Tool discovery waits for run metadata only, not transcript backfill. */
152
- async needsControls() {
153
- await this.ensure();
154
- return this.send("needsControls", undefined, undefined, 15_000);
155
- }
161
+ needsControls() { return this.admitted("needsControls", undefined, undefined, 15_000); }
156
162
  listRuns(options = {}) { return this.query("listRuns", options); }
157
163
  historyPage(input) { return this.query("historyPage", input); }
158
164
  search(input) { return this.query("search", input); }
@@ -166,8 +172,7 @@ export class SubagentHistoryIndex {
166
172
  async refresh(runId, options = {}) {
167
173
  if (!this.closed)
168
174
  this.unavailable = undefined;
169
- await this.ensure();
170
- return this.send("refresh", { runId }, options.signal, 120_000);
175
+ return this.admitted("refresh", { runId }, options.signal, 120_000);
171
176
  }
172
177
  onChanged(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
173
178
  /** Hard cancellation affects this instance's outstanding work, not native sessions or other indexers. */
@@ -232,8 +232,13 @@ export class SourceIngest {
232
232
  fd;
233
233
  initial;
234
234
  source;
235
- prefix = createHash("sha256");
236
235
  recordDigest = createHash("sha256");
236
+ /** prefix_digest is "start:sha256" of the last indexed line; earlier releases hashed the whole prefix. */
237
+ legacy = false;
238
+ expected = null;
239
+ verified = createHash("sha256");
240
+ line = createHash("sha256");
241
+ lineStart = 0;
237
242
  verifyAt = 0;
238
243
  position;
239
244
  frames;
@@ -274,6 +279,11 @@ export class SourceIngest {
274
279
  this.headerSeen = source.cursor > 0;
275
280
  store.clearUnpublished(source.id);
276
281
  store.run("DELETE FROM pending_text");
282
+ // Appends re-hash only the last indexed line, so live sources cost their new bytes, not their size.
283
+ const anchor = /^(\d+):([0-9a-f]{64})$/.exec(source.prefix_digest ?? "");
284
+ this.legacy = !anchor;
285
+ this.expected = anchor ? anchor[2] : source.prefix_digest;
286
+ this.verifyAt = this.lineStart = anchor ? Number(anchor[1]) : 0;
277
287
  this.phase = !force && source.stamp === stamp(this.initial) ? "done" : source.cursor ? "verify" : "read";
278
288
  if (this.phase !== "done")
279
289
  store.run("UPDATE sources SET state='indexing',error=NULL WHERE id=?", source.id);
@@ -294,7 +304,7 @@ export class SourceIngest {
294
304
  if (!current || current.generation !== this.source.generation || current.cursor !== this.source.cursor)
295
305
  throw new HistoryIndexError("SOURCE_CHANGED", "Another writer advanced this source; reconcile before replaying.");
296
306
  work?.();
297
- this.store.run("UPDATE sources SET cursor=?,prefix_digest=?,identity=? WHERE id=?", end, this.prefix.copy().digest("hex"), identity(this.initial), this.id);
307
+ this.store.run("UPDATE sources SET cursor=?,prefix_digest=?,identity=? WHERE id=?", end, `${this.source.cursor}:${this.recordDigest.copy().digest("hex")}`, identity(this.initial), this.id);
298
308
  this.store.bump();
299
309
  });
300
310
  this.source.cursor = end;
@@ -392,13 +402,25 @@ export class SourceIngest {
392
402
  if (count !== bytes.length)
393
403
  throw new Error("Journal truncated during prefix verification.");
394
404
  this.store.operations.sourceBytesRead += count;
395
- this.prefix.update(bytes);
405
+ this.verified.update(bytes);
406
+ if (this.legacy) {
407
+ // A whole-prefix check also finds the last indexed line, which anchors every later check.
408
+ const before = this.source.cursor - 2 - this.verifyAt, lf = before < 0 ? -1 : bytes.lastIndexOf(10, before);
409
+ if (lf < 0)
410
+ this.line.update(bytes);
411
+ else {
412
+ this.lineStart = this.verifyAt + lf + 1;
413
+ this.line = createHash("sha256").update(bytes.subarray(lf + 1));
414
+ }
415
+ }
396
416
  this.verifyAt += count;
397
417
  if (this.verifyAt === this.source.cursor) {
398
- if (this.prefix.copy().digest("hex") !== this.source.prefix_digest) {
418
+ if (this.verified.digest("hex") !== this.expected) {
399
419
  this.store.resetSource(this.id);
400
420
  throw new HistoryIndexError("SOURCE_CHANGED", "Journal prefix changed; a new physical generation was scheduled.");
401
421
  }
422
+ if (this.legacy)
423
+ this.store.run("UPDATE sources SET prefix_digest=? WHERE id=?", `${this.lineStart}:${this.line.digest("hex")}`, this.id);
402
424
  this.phase = "read";
403
425
  }
404
426
  return false;
@@ -418,7 +440,6 @@ export class SourceIngest {
418
440
  const newline = this.buffer.indexOf(10);
419
441
  const bytes = newline < 0 ? this.buffer : this.buffer.subarray(0, newline + 1);
420
442
  this.recordDigest.update(bytes);
421
- this.prefix.update(bytes);
422
443
  this.frames.write(bytes);
423
444
  this.text.flush(this.store);
424
445
  this.buffer = this.buffer.subarray(bytes.length);
@@ -7,7 +7,9 @@ import { hash, HistoryStore, integer, pageLimit } from "./store.js";
7
7
  export class HistoryQueries {
8
8
  store;
9
9
  info;
10
+ configurations = new Map();
10
11
  constructor(store, info) { this.store = store; this.info = info; }
12
+ sourcesRemoved() { this.configurations.clear(); }
11
13
  cursor(query, keys, offset) {
12
14
  return Buffer.from(JSON.stringify({ generation: this.store.generation, owner: this.store.owner, version: this.info().version, query, keys, offset })).toString("base64url");
13
15
  }
@@ -120,6 +122,13 @@ export class HistoryQueries {
120
122
  return {};
121
123
  throw error;
122
124
  }
125
+ // Only committed source changes invalidate ancestry, not unrelated run projections.
126
+ // Validate boundaries before reuse; explicit null leaves returned above remain empty.
127
+ const leafKey = input.leaf === undefined ? null : [typeof input.leaf, String(input.leaf)];
128
+ const key = JSON.stringify([source.id, source.generation, source.cursor, source.stamp, input.terminalEntryId || null, input.terminalEntryId ? null : input.endedAt ?? null, leafKey, strict]);
129
+ const cached = this.configurations.get(key);
130
+ if (cached)
131
+ return { ...cached };
123
132
  const leaf = input.leaf ?? this.store.get(`SELECT id FROM entries WHERE ${boundary.clauses.join(" AND ")} ORDER BY start DESC LIMIT 1`, ...boundary.params)?.id;
124
133
  if (!leaf)
125
134
  return {};
@@ -139,7 +148,12 @@ export class HistoryQueries {
139
148
  return {};
140
149
  throw new HistoryIndexError("MALFORMED_ANCESTRY", "Selected native configuration has cyclic ancestry.");
141
150
  }
142
- return { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
151
+ const configuration = { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
152
+ // Bound memory across arbitrary sources, boundaries and pages. Eviction only recomputes.
153
+ if (this.configurations.size >= 128)
154
+ this.configurations.delete(this.configurations.keys().next().value);
155
+ this.configurations.set(key, configuration);
156
+ return { ...configuration };
143
157
  }
144
158
  historyPage(input) {
145
159
  const { source } = this.child(input.runId, input.index);
@@ -224,6 +238,12 @@ export class HistoryQueries {
224
238
  clauses.push("c.agent=?");
225
239
  params.push(input.agent);
226
240
  }
241
+ const joins = "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";
242
+ // Scope records before materializing/grouping hits, while bm25 still sees the
243
+ // complete corpus. Scan each linked source once, not once per continuation.
244
+ // Attempt boundaries, attribution and seek filtering remain in the final join.
245
+ const eligible = input.runId || input.agent ? `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 ")})),` : "";
246
+ const eligibleParams = eligible ? params.slice(expression.length) : [];
227
247
  // Ordering/filters apply globally before limit. Common terms may still require an FTS
228
248
  // scan/sort; the parent enforces a hard deadline by killing this separate process.
229
249
  const score = sort === "relevance" ? "matched.score" : "-COALESCE(e.timestamp,0)";
@@ -235,8 +255,8 @@ export class HistoryQueries {
235
255
  }
236
256
  // Match words across all visible fields/windows of one native record. A quoted
237
257
  // phrase remains a single FTS expression, so it cannot span unrelated fields.
238
- const rows = this.store.all(`WITH hits AS MATERIALIZED (
239
- ${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 ?`).join(" UNION ALL ")}
258
+ const rows = this.store.all(`WITH ${eligible} hits AS MATERIALIZED (
259
+ ${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 ?${eligible ? " AND d.entry_rowid IN (SELECT rowid FROM eligible)" : ""}`).join(" UNION ALL ")}
240
260
  ), terms AS (
241
261
  SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term
242
262
  ), matched AS (
@@ -244,7 +264,7 @@ export class HistoryQueries {
244
264
  ), excerpts AS (
245
265
  SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits
246
266
  )
247
- 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 ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...params, limit + 1);
267
+ 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 ${joins} WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...eligibleParams, ...params, limit + 1);
248
268
  const more = rows.length > limit, page = rows.slice(0, limit);
249
269
  return { ...this.info(), matches: page.map((row) => ({ id: row.document_id, runId: row.run_id, index: row.child_index, agent: row.agent, entryId: row.id, nativeId: row.native_id, sessionId: row.session_id, sessionFile: row.path, timestamp: row.timestamp,
250
270
  ref: { sourceId: row.source_id, generation: row.generation, start: row.start, end: row.end, digest: row.digest }, preview: row.document_preview, field: row.field, textStart: row.text_start, textEnd: row.text_end, score: row.score })),
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { ownedRunView } from "../runs/shared/run-records.js";
4
4
  import { acceptanceHumanAction } from "../runs/shared/acceptance-evaluation.js";
5
- import { getRunMetadataDir, listRunQuestions } from "../runs/shared/supervisor-questions.js";
5
+ import { getRunMetadataDir, listRunQuestions, summarizeSupervisorQuestion } from "../runs/shared/supervisor-questions.js";
6
6
  import { HistoryIndexError } from "./types.js";
7
7
  import { exactTextDigest, HistoryStore, safeText } from "./store.js";
8
8
  import { readCanonicalOutput, readSavedOutput } from "./canonical-result.js";
@@ -18,6 +18,8 @@ const sourceWatchers = new Map();
18
18
  const watchIdentities = new WeakMap();
19
19
  const watchedSources = new Map();
20
20
  let job, scheduled = false, closed = false;
21
+ let sourceObservation;
22
+ let notifiedVersion;
21
23
  let retryTimer;
22
24
  let runErrors = new Set();
23
25
  const barriers = [];
@@ -25,9 +27,21 @@ const controlQueries = [];
25
27
  function replyControls(id) {
26
28
  send({ id, value: Boolean(store.get("SELECT id FROM runs WHERE attention<4 OR state='live' LIMIT 1")) });
27
29
  }
28
- function send(response) { if (process.connected)
29
- process.send(response); }
30
- function changed() { send({ changed: true }); }
30
+ function send(response) {
31
+ // The parent can disconnect after process.connected was read; then this and later replies are undeliverable, so stop as on disconnect.
32
+ if (process.connected)
33
+ process.send(response, undefined, undefined, (error) => { if (error)
34
+ shutdown(); });
35
+ }
36
+ function changed(observationChanged = false) {
37
+ const version = store?.get("SELECT value FROM meta WHERE key='version'").value;
38
+ // Unchanged census projections need only one final freshness notification.
39
+ // Non-versioned errors/recovery and real commits still publish immediately.
40
+ if (observationChanged || version !== notifiedVersion || !hasPending()) {
41
+ notifiedVersion = version;
42
+ send({ changed: true });
43
+ }
44
+ }
31
45
  function errorReply(id, error) {
32
46
  const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
33
47
  // Parser errors can include fragments from the malformed body. IPC/logs must never carry them.
@@ -41,10 +55,10 @@ function compactView(view) {
41
55
  return {
42
56
  ...view, task: safeText(view.task, 2048), error: view.error && safeText(view.error), diagnosis: view.diagnosis && safeText(view.diagnosis), recoveryError: view.recoveryError && safeText(view.recoveryError),
43
57
  review: view.review && { ...view.review, message: view.review.message && safeText(view.review.message) }, continuations: [],
44
- questions: view.questions?.map((question) => ({ questionId: question.questionId, runId: question.runId, ownerSessionId: question.ownerSessionId, ownerTarget: question.ownerTarget,
45
- agent: question.agent, index: question.index, childSessionId: question.childSessionId, childTarget: question.childTarget, sessionFile: question.sessionFile,
46
- cwd: question.cwd, pid: question.pid, processIdentity: question.processIdentity, createdAt: question.createdAt, reason: question.reason, message: safeText(question.message, 2048), state: question.state,
47
- answer: question.answer && { ...question.answer, message: safeText(question.answer.message, 2048) }, delivery: question.delivery, revival: question.revival })),
58
+ questions: view.questions?.map((question) => {
59
+ const summary = summarizeSupervisorQuestion(question);
60
+ return { ...summary, message: safeText(question.message, 2048), answer: summary.answer && { ...summary.answer, message: safeText(question.answer?.message, 2048) } };
61
+ }),
48
62
  children: view.children.map((child) => ({
49
63
  agent: child.agent, index: child.index, workflowNodeId: child.workflowNodeId, sessionFile: child.sessionFile, task: child.task && safeText(child.task), label: child.label && safeText(child.label, 256),
50
64
  state: child.state, configuration: child.configuration, missingSession: child.missingSession, identityUnavailable: child.identityUnavailable, modelSelection: child.modelSelection,
@@ -187,8 +201,11 @@ function prune(previousSources) {
187
201
  store.run("DELETE FROM sources WHERE id=?", source.id);
188
202
  removed = true;
189
203
  }
190
- if (removed)
204
+ if (removed) {
191
205
  store.bump();
206
+ // A later admission of the same path can recreate source generation 1.
207
+ queries?.sourcesRemoved();
208
+ }
192
209
  });
193
210
  }
194
211
  function admit(run) {
@@ -243,6 +260,7 @@ function pump() {
243
260
  if (dirtyRuns.size) {
244
261
  const id = dirtyRuns.values().next().value;
245
262
  dirtyRuns.delete(id);
263
+ const failed = runErrors.has(id);
246
264
  installWatches(id);
247
265
  const previousSources = store.all("SELECT source_id FROM children WHERE run_id=? AND source_id IS NOT NULL", id).map((child) => child.source_id);
248
266
  try {
@@ -261,7 +279,7 @@ function pump() {
261
279
  if (child.source_id !== job?.id)
262
280
  dirtySources.set(child.source_id, dirtySources.get(child.source_id) ?? false);
263
281
  installWatches(id);
264
- changed();
282
+ changed(failed !== runErrors.has(id));
265
283
  }
266
284
  else if (job) {
267
285
  if (job.step()) {
@@ -269,7 +287,8 @@ function pump() {
269
287
  if (source)
270
288
  watchSources(path.dirname(source.path));
271
289
  job = undefined;
272
- changed();
290
+ changed(source?.state !== sourceObservation?.state || source?.error !== sourceObservation?.error);
291
+ sourceObservation = undefined;
273
292
  }
274
293
  }
275
294
  else if (dirtySources.size) {
@@ -278,6 +297,7 @@ function pump() {
278
297
  const source = store.source(id);
279
298
  if (source) {
280
299
  activeSource = id;
300
+ sourceObservation = source;
281
301
  job = new SourceIngest(store, source, force);
282
302
  }
283
303
  }
@@ -298,8 +318,11 @@ function pump() {
298
318
  else
299
319
  store.run("UPDATE sources SET state='error',error=?,checked_at=? WHERE id=?", "Linked native conversation could not be indexed.", Date.now(), id);
300
320
  }
301
- if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY"))
302
- changed();
321
+ if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY")) {
322
+ const source = id ? store.source(id) : undefined;
323
+ changed(Boolean(source && (source.state !== sourceObservation?.state || source.error !== sourceObservation?.error)));
324
+ }
325
+ sourceObservation = undefined;
303
326
  }
304
327
  activeSource = undefined;
305
328
  finishBarriers();
@@ -324,6 +347,8 @@ function clear() {
324
347
  retryTimer = undefined;
325
348
  job?.close();
326
349
  job = undefined;
350
+ sourceObservation = undefined;
351
+ notifiedVersion = undefined;
327
352
  for (const watcher of watchers.values())
328
353
  watcher.close();
329
354
  watchers.clear();
@@ -458,4 +483,5 @@ async function handle(request) {
458
483
  }
459
484
  }
460
485
  process.on("message", (request) => { void handle(request); });
461
- process.on("disconnect", () => { closed = true; clearInterval(timer); clear(); process.exit(0); });
486
+ function shutdown() { closed = true; clearInterval(timer); clear(); process.exit(0); }
487
+ process.on("disconnect", shutdown);