@fitchmultz/pi-subagents 0.44.4 → 0.44.5

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.44.5] - 2026-10-08
4
+
5
+ ### Added
6
+ - 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.
7
+
8
+ ### Fixed
9
+ - 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.
10
+ - 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.
11
+
3
12
  ## [0.44.4] - 2026-10-04
4
13
 
5
14
  ### 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.5","sha256":"53f1d8cd3c71b05fdfc903bfcecd06eb5d80bb367a16a09d235199d3f93266f2"});
@@ -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);
@@ -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";
@@ -25,8 +25,12 @@ const controlQueries = [];
25
25
  function replyControls(id) {
26
26
  send({ id, value: Boolean(store.get("SELECT id FROM runs WHERE attention<4 OR state='live' LIMIT 1")) });
27
27
  }
28
- function send(response) { if (process.connected)
29
- process.send(response); }
28
+ function send(response) {
29
+ // The parent can disconnect after process.connected was read; then this and later replies are undeliverable, so stop as on disconnect.
30
+ if (process.connected)
31
+ process.send(response, undefined, undefined, (error) => { if (error)
32
+ shutdown(); });
33
+ }
30
34
  function changed() { send({ changed: true }); }
31
35
  function errorReply(id, error) {
32
36
  const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
@@ -41,10 +45,10 @@ function compactView(view) {
41
45
  return {
42
46
  ...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
47
  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 })),
48
+ questions: view.questions?.map((question) => {
49
+ const summary = summarizeSupervisorQuestion(question);
50
+ return { ...summary, message: safeText(question.message, 2048), answer: summary.answer && { ...summary.answer, message: safeText(question.answer?.message, 2048) } };
51
+ }),
48
52
  children: view.children.map((child) => ({
49
53
  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
54
  state: child.state, configuration: child.configuration, missingSession: child.missingSession, identityUnavailable: child.identityUnavailable, modelSelection: child.modelSelection,
@@ -458,4 +462,5 @@ async function handle(request) {
458
462
  }
459
463
  }
460
464
  process.on("message", (request) => { void handle(request); });
461
- process.on("disconnect", () => { closed = true; clearInterval(timer); clear(); process.exit(0); });
465
+ function shutdown() { closed = true; clearInterval(timer); clear(); process.exit(0); }
466
+ process.on("disconnect", shutdown);