@d3ara1n/pi-subagent 2.2.0 → 3.1.0

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/src/run.test.ts CHANGED
@@ -167,7 +167,9 @@ test("spawned runs persist to history on every terminal path; pre-run failures d
167
167
  spawnImpl: async (_m, _t, options) => {
168
168
  options.onProgress?.({
169
169
  output: "partial",
170
- activityLog: [{ kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} }],
170
+ activityLog: [
171
+ { kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} },
172
+ ],
171
173
  });
172
174
  throw new Error("Subagent was aborted");
173
175
  },
@@ -229,6 +231,54 @@ test("provider error on first attempt retries on the fallback role", async () =>
229
231
  assert.strictEqual(result.fallbackFrom.model, "test/model-fast");
230
232
  });
231
233
 
234
+ test("forwards an immutable inherited conversation to first and fallback spawns", async () => {
235
+ const received: Array<{
236
+ model: string;
237
+ inheritConversation?: boolean;
238
+ inheritedConversation?: string;
239
+ }> = [];
240
+ const spawnImpl: SpawnImpl = async (model, _task, options) => {
241
+ received.push({
242
+ model,
243
+ inheritConversation: options.inheritConversation,
244
+ inheritedConversation: options.inheritedConversation,
245
+ });
246
+ return received.length === 1
247
+ ? makeResult({ exitCode: 1, errorMessage: "429 quota exceeded", stderr: "HTTP 429" })
248
+ : makeResult({ output: "fallback ok" });
249
+ };
250
+
251
+ const run = startSubagentRun(
252
+ makeDeps({
253
+ roleDef: { ...roleDef, fallbackRole: "default" },
254
+ inheritConversation: true,
255
+ inheritedConversation: "[user]\\nParent requirement",
256
+ inheritedConversationTruncated: true,
257
+ spawnImpl,
258
+ }),
259
+ );
260
+ const result = await run.promise;
261
+
262
+ assert.deepEqual(received, [
263
+ {
264
+ model: "test/model-fast",
265
+ inheritConversation: true,
266
+ inheritedConversation: "[user]\\nParent requirement",
267
+ },
268
+ {
269
+ model: "test/model-default",
270
+ inheritConversation: true,
271
+ inheritedConversation: "[user]\\nParent requirement",
272
+ },
273
+ ]);
274
+ assert.equal(run.inheritConversation, true);
275
+ assert.equal(run.inheritedConversationChars, "[user]\\nParent requirement".length);
276
+ assert.equal(run.inheritedConversationTruncated, true);
277
+ assert.equal(result.inheritConversation, true);
278
+ assert.equal(result.inheritedConversationChars, "[user]\\nParent requirement".length);
279
+ assert.equal(result.inheritedConversationTruncated, true);
280
+ });
281
+
232
282
  test("prerun failure (roles api unavailable) becomes a failed run, not a throw", async () => {
233
283
  const run = startSubagentRun(
234
284
  makeDeps({
package/src/run.ts CHANGED
@@ -48,6 +48,9 @@ export interface RunHandle {
48
48
  readonly task: string;
49
49
  readonly context?: string;
50
50
  readonly files?: string[];
51
+ readonly inheritConversation?: boolean;
52
+ readonly inheritedConversationChars?: number;
53
+ readonly inheritedConversationTruncated?: boolean;
51
54
  /** Lifecycle state, kept in sync with the latest snapshot frame. */
52
55
  readonly state: RunState;
53
56
  /** Latest frame: queued placeholder, live progress, or terminal result. */
@@ -76,6 +79,12 @@ export interface StartRunOptions {
76
79
  task: string;
77
80
  context?: string;
78
81
  files?: string[];
82
+ /** Opt in to a text-only snapshot of the parent's active conversation. */
83
+ inheritConversation?: boolean;
84
+ /** Immutable serialized parent-conversation body; never persisted to history. */
85
+ inheritedConversation?: string;
86
+ /** Whether maxChars shortened the serialized parent conversation. */
87
+ inheritedConversationTruncated?: boolean;
79
88
  cwd: string;
80
89
  /** Nesting depth for the child (CURRENT_DEPTH + 1). */
81
90
  depth: number;
@@ -98,6 +107,13 @@ export interface StartRunOptions {
98
107
  export function startSubagentRun(opts: StartRunOptions): RunHandle {
99
108
  const spawn = opts.spawnImpl ?? spawnSubagent;
100
109
  const listeners = new Set<() => void>();
110
+ const inheritanceMetadata = opts.inheritConversation
111
+ ? {
112
+ inheritConversation: true as const,
113
+ inheritedConversationChars: opts.inheritedConversation?.length ?? 0,
114
+ inheritedConversationTruncated: opts.inheritedConversationTruncated ?? false,
115
+ }
116
+ : {};
101
117
 
102
118
  const inputFrame = (exitCode: number, queued: boolean): SubagentResult => ({
103
119
  role: opts.role,
@@ -110,6 +126,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
110
126
  activityLog: [],
111
127
  files: opts.files,
112
128
  context: opts.context,
129
+ ...inheritanceMetadata,
113
130
  });
114
131
 
115
132
  let currentState: RunState = "queued";
@@ -163,6 +180,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
163
180
  task: opts.task,
164
181
  context: opts.context,
165
182
  files: opts.files,
183
+ ...inheritanceMetadata,
166
184
  get state() {
167
185
  return currentState;
168
186
  },
@@ -198,8 +216,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
198
216
  try {
199
217
  await opts.gate.acquire(controller.signal);
200
218
  } catch {
201
- const msg =
202
- "still queued for a concurrency slot" + (abortReason ? ` (${abortReason})` : "");
219
+ const msg = "still queued for a concurrency slot" + (abortReason ? ` (${abortReason})` : "");
203
220
  finish(
204
221
  { ...inputFrame(1, false), stopReason: "cancelled", errorMessage: msg },
205
222
  new Error(msg),
@@ -279,9 +296,11 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
279
296
  pauseStart: partial.pauseStart,
280
297
  files: opts.files,
281
298
  context: opts.context,
299
+ ...inheritanceMetadata,
282
300
  fallbackFrom: activeFallbackFrom,
283
301
  });
284
- const emitProgress = (partial: Partial<SubagentResult>) => setFrame(liveFrame(partial), "running");
302
+ const emitProgress = (partial: Partial<SubagentResult>) =>
303
+ setFrame(liveFrame(partial), "running");
285
304
 
286
305
  // Running placeholder now that we hold a slot.
287
306
  setFrame(liveFrame({}), "running");
@@ -294,6 +313,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
294
313
  systemPrompt: opts.roleDef.systemPrompt,
295
314
  context: opts.context,
296
315
  contextFiles: opts.files,
316
+ inheritConversation: opts.inheritConversation,
317
+ inheritedConversation: opts.inheritedConversation,
297
318
  subagentRoles: opts.roleDef.subagentRoles,
298
319
  timeoutMs: timeoutBudgetMs,
299
320
  maxTurns,
@@ -330,6 +351,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
330
351
  systemPrompt: opts.roleDef.systemPrompt,
331
352
  context: opts.context,
332
353
  contextFiles: opts.files,
354
+ inheritConversation: opts.inheritConversation,
355
+ inheritedConversation: opts.inheritedConversation,
333
356
  subagentRoles: opts.roleDef.subagentRoles,
334
357
  timeoutMs: timeoutBudgetMs,
335
358
  maxTurns,
@@ -351,6 +374,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
351
374
  runResult.role = opts.role;
352
375
  runResult.files = opts.files;
353
376
  runResult.context = opts.context;
377
+ Object.assign(runResult, inheritanceMetadata);
354
378
  runResult.elapsedMs = Date.now() - startTime;
355
379
 
356
380
  // Compress/truncate oversized output before it reaches the main model or TUI.
@@ -398,9 +422,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
398
422
  activityLog: partial.activityLog,
399
423
  budgetMs: partial.budgetMs,
400
424
  elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
401
- errorMessage: wasCancelled
402
- ? abortReason || "cancelled"
403
- : err?.message || String(err),
425
+ errorMessage: wasCancelled ? abortReason || "cancelled" : err?.message || String(err),
404
426
  };
405
427
  // The run spawned before throwing — audit it like any terminal state.
406
428
  // The partial output is raw (compression never ran on it).
package/src/spawn.test.ts CHANGED
@@ -17,7 +17,7 @@ describe("composeInitialMessage", () => {
17
17
  try {
18
18
  const fileA = path.join(dir, "a.md");
19
19
  fs.writeFileSync(fileA, "alpha content");
20
- const message = await composeInitialMessage([fileA], "some ctx", "do the thing");
20
+ const message = await composeInitialMessage([fileA], undefined, "some ctx", "do the thing");
21
21
  assert.equal(
22
22
  message,
23
23
  `<file name="${fileA}">\nalpha content\n</file>\n\n<context>\nsome ctx\n</context>\n\n<task>\ndo the thing\n</task>`,
@@ -28,24 +28,40 @@ describe("composeInitialMessage", () => {
28
28
  });
29
29
 
30
30
  test("omits absent channels and preserves relative block order", async () => {
31
- const message = await composeInitialMessage(undefined, undefined, "only a task");
31
+ const message = await composeInitialMessage(undefined, undefined, undefined, "only a task");
32
32
  assert.equal(message, "<task>\nonly a task\n</task>");
33
- const ctxOnly = await composeInitialMessage(undefined, "ctx body", "");
33
+ const ctxOnly = await composeInitialMessage(undefined, undefined, "ctx body", "");
34
34
  assert.equal(ctxOnly, "<context>\nctx body\n</context>");
35
35
  });
36
36
 
37
37
  test("unreadable files degrade to a placeholder instead of failing the run", async () => {
38
38
  const missing = path.join(os.tmpdir(), "pi-sub-test-does-not-exist.md");
39
- const message = await composeInitialMessage([missing], undefined, "t");
39
+ const message = await composeInitialMessage([missing], undefined, undefined, "t");
40
40
  assert.match(message, /^\[?<file name="/);
41
41
  assert.match(message, /failed to read file/);
42
42
  assert.match(message, /\n\n<task>\nt\n<\/task>$/);
43
43
  });
44
44
 
45
45
  test("blank (whitespace-only) context is dropped", async () => {
46
- const message = await composeInitialMessage(undefined, " \n\t", "t");
46
+ const message = await composeInitialMessage(undefined, undefined, " \n\t", "t");
47
47
  assert.equal(message, "<task>\nt\n</task>");
48
48
  });
49
+
50
+ test("puts inherited conversation after files and before context/task", async () => {
51
+ const message = await composeInitialMessage(
52
+ ["missing.txt"],
53
+ "parent text",
54
+ "explicit ctx",
55
+ "delta task",
56
+ );
57
+ assert.ok(
58
+ message.indexOf('<file name="missing.txt">') < message.indexOf("<inherited_conversation>"),
59
+ );
60
+ assert.ok(message.indexOf("<inherited_conversation>") < message.indexOf("<context>"));
61
+ assert.ok(message.indexOf("<context>") < message.lastIndexOf("<task>"));
62
+ assert.match(message, /text-only background/i);
63
+ assert.match(message, /separate task block remains authoritative/);
64
+ });
49
65
  });
50
66
 
51
67
  describe("buildChildArgs", () => {
@@ -92,12 +108,20 @@ describe("buildChildArgs", () => {
92
108
  assert.ok(!args.includes("--exclude-tools"));
93
109
  });
94
110
 
111
+ test("policy is conditional on conversation inheritance", () => {
112
+ const isolatedArgs = buildChildArgs("m", {}, "/t");
113
+ assert.deepEqual(buildChildArgs("m", { inheritConversation: false }, "/t"), isolatedArgs);
114
+ const isolated = isolatedArgs.join("\n");
115
+ const inherited = buildChildArgs("m", { inheritConversation: true }, "/t").join("\n");
116
+ assert.match(isolated, /you have NO prior conversation/);
117
+ assert.ok(!isolated.includes("<inherited_conversation> block"));
118
+ assert.match(inherited, /<inherited_conversation> block/);
119
+ assert.match(inherited, /may be compacted or truncated/);
120
+ assert.ok(!inherited.includes("you have NO prior conversation"));
121
+ });
122
+
95
123
  test("thinking and role system prompt are wrapped in their blocks", () => {
96
- const args = buildChildArgs(
97
- "m",
98
- { thinking: "high", systemPrompt: " Be brief. " },
99
- "/t",
100
- );
124
+ const args = buildChildArgs("m", { thinking: "high", systemPrompt: " Be brief. " }, "/t");
101
125
  assert.equal(args[args.indexOf("--thinking") + 1], "high");
102
126
  const roleIdx = args.findIndex((a) => a.startsWith("<subagent_role>"));
103
127
  assert.ok(roleIdx > 0);
package/src/spawn.ts CHANGED
@@ -149,6 +149,7 @@ export function buildChildArgs(
149
149
  tools?: string[];
150
150
  excludeTools?: string[];
151
151
  systemPrompt?: string;
152
+ inheritConversation?: boolean;
152
153
  },
153
154
  tmpDir: string,
154
155
  ): string[] {
@@ -187,6 +188,17 @@ export function buildChildArgs(
187
188
  // does; this shapes HOW any subagent behaves when the task exceeds its
188
189
  // actual capabilities: report the gap and stop instead of improvising
189
190
  // workarounds until timeout.
191
+ const conversationPolicy = options.inheritConversation
192
+ ? [
193
+ "- You have an <inherited_conversation> block from the parent session.",
194
+ " It is text-only background and may be compacted or truncated. If needed",
195
+ " material is absent, report it as Missing — do not guess it.",
196
+ ]
197
+ : [
198
+ "- The task may reference material as 'discussed above' or 'the requirements'",
199
+ " — you have NO prior conversation; only this prompt exists. If referenced",
200
+ " material is not in this prompt, report it as Missing — do not guess it.",
201
+ ];
190
202
  args.push(
191
203
  "--append-system-prompt",
192
204
  [
@@ -194,11 +206,9 @@ export function buildChildArgs(
194
206
  "Before attempting the task, check it against your actual capabilities in this",
195
207
  "session — the tool list here is definitive.",
196
208
  "- If the task needs a capability you do not have (web access, bash, file",
197
- " writes, ...) or material that is not present locally or in the provided",
198
- " context/files, it is out of scope for you. Do NOT improvise workarounds.",
199
- "- The task may reference material as 'discussed above' or 'the requirements'",
200
- " — you have NO prior conversation; only this prompt exists. If referenced",
201
- " material is not in this prompt, report it as Missing — do not guess it.",
209
+ " writes, ...) or material that is absent from local files and the provided",
210
+ " prompt channels, it is out of scope for you. Do NOT improvise workarounds.",
211
+ ...conversationPolicy,
202
212
  '- "Cannot complete" means a capability or material gap — not "difficult" or',
203
213
  ' "uncertain". If it is merely hard, keep working within your tools.',
204
214
  "- When you hit a genuine gap, stop early and return:",
@@ -218,14 +228,15 @@ export function buildChildArgs(
218
228
 
219
229
  /**
220
230
  * Compose the initial RPC prompt message: reference files as <file> blocks,
221
- * then context and task as structured tags — the same shape the child saw in
222
- * json mode (@file arguments wrapped by pi's processFileArguments, followed by
223
- * the inline message body).
231
+ * optional inherited conversation, then context and task as structured tags —
232
+ * the same shape the child saw in json mode (@file arguments wrapped by pi's
233
+ * processFileArguments, followed by the inline message body).
224
234
  *
225
235
  * @internal — exported for testing.
226
236
  */
227
237
  export async function composeInitialMessage(
228
238
  files: string[] | undefined,
239
+ inheritedConversation: string | undefined,
229
240
  context: string | undefined,
230
241
  task: string,
231
242
  ): Promise<string> {
@@ -241,6 +252,17 @@ export async function composeInitialMessage(
241
252
  parts.push(`<file name="${f}">\n${content}\n</file>`);
242
253
  }
243
254
  }
255
+ if (inheritedConversation !== undefined) {
256
+ parts.push(
257
+ [
258
+ "<inherited_conversation>",
259
+ "Text-only background from the parent conversation; it may be compacted or truncated. The separate task block remains authoritative.",
260
+ "",
261
+ inheritedConversation,
262
+ "</inherited_conversation>",
263
+ ].join("\n"),
264
+ );
265
+ }
244
266
  if (context?.trim()) parts.push(`<context>\n${context}\n</context>`);
245
267
  if (task.trim()) parts.push(`<task>\n${task}\n</task>`);
246
268
  return parts.join("\n\n");
@@ -268,6 +290,10 @@ export async function spawnSubagent(
268
290
  systemPrompt?: string;
269
291
  /** Extra context delivered as a separate channel from the task. */
270
292
  context?: string;
293
+ /** Enables the inherited-conversation policy variant. */
294
+ inheritConversation?: boolean;
295
+ /** Immutable serialized parent-conversation body injected independently from context/task. */
296
+ inheritedConversation?: string;
271
297
  /** Reference files injected as <file> blocks in the initial prompt (child reads them directly). */
272
298
  contextFiles?: string[];
273
299
  subagentRoles?: string[];
@@ -332,7 +358,12 @@ export async function spawnSubagent(
332
358
  // (processFileArguments). Content still never enters the parent model's
333
359
  // context; this process reads the bytes off disk and pipes them straight
334
360
  // to the child.
335
- const initialMessage = await composeInitialMessage(options.contextFiles, options.context, task);
361
+ const initialMessage = await composeInitialMessage(
362
+ options.contextFiles,
363
+ options.inheritedConversation,
364
+ options.context,
365
+ task,
366
+ );
336
367
 
337
368
  // Spawn process
338
369
  const invocation = getPiInvocation(args);
@@ -384,7 +415,10 @@ export async function spawnSubagent(
384
415
  // Called after each assistant message_end (usage already accumulated).
385
416
  const checkBudget = () => {
386
417
  if (budgetExceeded || wasTimeout) return;
387
- if ((maxTurns > 0 && result.usage.turns >= maxTurns) || (maxCost > 0 && result.usage.cost >= maxCost)) {
418
+ if (
419
+ (maxTurns > 0 && result.usage.turns >= maxTurns) ||
420
+ (maxCost > 0 && result.usage.cost >= maxCost)
421
+ ) {
388
422
  budgetExceeded = true;
389
423
  killProc("budget");
390
424
  }
package/src/types.ts CHANGED
@@ -15,6 +15,8 @@ export interface SubagentConfig {
15
15
  /** Persist every spawned delegate run (finished/failed/aborted alike) to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. Pre-run failures that never spawned are not recorded. */
16
16
  history: SubagentHistoryConfig;
17
17
  summary: SubagentSummaryConfig;
18
+ /** Limits optional serialized parent-conversation inheritance. */
19
+ inheritance: SubagentInheritanceConfig;
18
20
  /**
19
21
  * Per-role overrides from settings.json. Keyed by role name.
20
22
  * - Override built-in roles: provide fields to merge.
@@ -32,6 +34,11 @@ export interface SubagentSummaryConfig {
32
34
  enabled: boolean;
33
35
  }
34
36
 
37
+ export interface SubagentInheritanceConfig {
38
+ /** Maximum characters in the inherited-conversation body. */
39
+ maxChars: number;
40
+ }
41
+
35
42
  export const DEFAULT_CONFIG: SubagentConfig = {
36
43
  maxConcurrency: 4,
37
44
  maxDepth: 3,
@@ -39,6 +46,7 @@ export const DEFAULT_CONFIG: SubagentConfig = {
39
46
  maxCost: 0,
40
47
  history: { enabled: true },
41
48
  summary: { role: "utility", enabled: true },
49
+ inheritance: { maxChars: 50_000 },
42
50
  agentOverrides: {},
43
51
  };
44
52
 
@@ -181,6 +189,12 @@ export interface SubagentResult {
181
189
  files?: string[];
182
190
  /** Extra context passed to delegate (params.context); used by the expanded view. */
183
191
  context?: string;
192
+ /** True when this run received a filtered parent-conversation snapshot. */
193
+ inheritConversation?: boolean;
194
+ /** Delivered inherited-conversation body size; safe metadata only, never the body itself. */
195
+ inheritedConversationChars?: number;
196
+ /** True when the inherited body was mechanically shortened to its configured limit. */
197
+ inheritedConversationTruncated?: boolean;
184
198
  }
185
199
 
186
200
  /** Snapshot of a failed first attempt that was retried on the fallback role. */
@@ -213,6 +227,9 @@ export interface BackgroundDelegateDetails {
213
227
  task: string;
214
228
  context?: string;
215
229
  files?: string[];
230
+ inheritConversation?: boolean;
231
+ inheritedConversationChars?: number;
232
+ inheritedConversationTruncated?: boolean;
216
233
  }
217
234
 
218
235
  /** One watched run inside a wait/check view. */
@@ -230,15 +247,6 @@ export interface WaitDetails {
230
247
  timedOut?: boolean;
231
248
  }
232
249
 
233
- /** Lightweight tombstone kept in the registry after a run's result was claimed via subagent_check — /subagent:status history without the full state machine. */
234
- export interface CollectedRun {
235
- id: string;
236
- role: string;
237
- /** First-line task preview (same 70-char cap as the inbox reminder). */
238
- task: string;
239
- state: "finished" | "failed";
240
- }
241
-
242
250
  /** Details for a check tool result — a frozen one-shot snapshot of a single run. */
243
251
  export interface CheckDetails {
244
252
  id: string;
@@ -253,6 +261,17 @@ export interface CheckDetails {
253
261
  */
254
262
  export type CancelDetails = CheckDetails;
255
263
 
264
+ /**
265
+ * Details for a steer tool result — the echoed correction. Collapsed shows
266
+ * icon + first line; expanded shows the full message plus the delivery hint
267
+ * (check is the result-fetcher for the effect, never this row).
268
+ */
269
+ export interface SteerDetails {
270
+ id: string;
271
+ role: string;
272
+ message: string;
273
+ }
274
+
256
275
  /**
257
276
  * Details for the background-run completion notice (custom message
258
277
  * `subagent-completion`). The renderer lays these out as a structured notice
package/src/utils.test.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  previewArgs,
22
22
  truncateOutput,
23
23
  formatTokens,
24
+ formatInheritedConversationInput,
24
25
  formatUsageStats,
25
26
  effectiveTimeout,
26
27
  elapsedSeconds,
@@ -44,6 +45,7 @@ import {
44
45
  completionNoticeLines,
45
46
  formatToolCall,
46
47
  briefFilesUsed,
48
+ collectDeliveredIds,
47
49
  } from "./utils.ts";
48
50
  import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
49
51
 
@@ -324,6 +326,18 @@ describe("truncateOutput", () => {
324
326
  });
325
327
  });
326
328
 
329
+ // ── inherited-conversation input metadata ──
330
+ describe("formatInheritedConversationInput", () => {
331
+ test("formats delivered chars, truncation, and empty inheritance", () => {
332
+ assert.equal(formatInheritedConversationInput(42, false), "conversation 42 chars");
333
+ assert.equal(
334
+ formatInheritedConversationInput(50_000, true),
335
+ "conversation 50000 chars · truncated",
336
+ );
337
+ assert.equal(formatInheritedConversationInput(0, false), "conversation inherited · empty");
338
+ });
339
+ });
340
+
327
341
  // ── formatTokens: boundary correctness ──
328
342
  describe("formatTokens", () => {
329
343
  test("under 1000 stays raw", () => {
@@ -853,3 +867,40 @@ describe("briefFilesUsed", () => {
853
867
  assert.equal(used.get(F1), false);
854
868
  });
855
869
  });
870
+
871
+ describe("collectDeliveredIds", () => {
872
+ const checkEntry = (id: string) => ({
873
+ type: "message",
874
+ message: { role: "toolResult", toolName: "subagent_check", details: { id, role: "worker" } },
875
+ });
876
+
877
+ test("collects ids from subagent_check tool results only", () => {
878
+ const entries = [
879
+ { type: "message", message: { role: "user", content: "hi" } },
880
+ { type: "message", message: { role: "assistant", content: [] } },
881
+ { type: "message", message: { role: "toolResult", toolName: "read", details: { id: "sub-9" } } },
882
+ { type: "message", message: { role: "toolResult", toolName: "subagent_wait", details: { entries: [] } } },
883
+ checkEntry("sub-1"),
884
+ { type: "message", message: { role: "custom", customType: "subagent-completion" } },
885
+ { type: "compaction" },
886
+ ];
887
+ assert.deepEqual(collectDeliveredIds(entries), new Set(["sub-1"]));
888
+ });
889
+
890
+ test("dedupes repeated checks of the same id", () => {
891
+ assert.deepEqual(collectDeliveredIds([checkEntry("sub-1"), checkEntry("sub-1")]), new Set(["sub-1"]));
892
+ });
893
+
894
+ test("empty path means nothing delivered (branch rewound past the check)", () => {
895
+ assert.equal(collectDeliveredIds([]).size, 0);
896
+ });
897
+
898
+ test("ignores malformed details", () => {
899
+ const entries = [
900
+ { type: "message", message: { role: "toolResult", toolName: "subagent_check" } },
901
+ { type: "message", message: { role: "toolResult", toolName: "subagent_check", details: {} } },
902
+ { type: "message", message: { role: "toolResult", toolName: "subagent_check", details: { id: 42 } } },
903
+ ];
904
+ assert.equal(collectDeliveredIds(entries).size, 0);
905
+ });
906
+ });
package/src/utils.ts CHANGED
@@ -38,6 +38,12 @@ export function formatTokens(count: number): string {
38
38
  return `${(count / 1000000).toFixed(1)}M`;
39
39
  }
40
40
 
41
+ /** Safe TUI label for inherited-conversation input; never includes transcript text. */
42
+ export function formatInheritedConversationInput(chars: number, truncated: boolean): string {
43
+ if (chars === 0) return "conversation inherited · empty";
44
+ return `conversation ${chars} chars${truncated ? " · truncated" : ""}`;
45
+ }
46
+
41
47
  /**
42
48
  * Usage parts shared by the TUI stats line and the LLM usage footer.
43
49
  * `withCache` adds the cache-read/write and peak-context figures (TUI only —
@@ -638,9 +644,9 @@ export function formatCheckText(id: string, role: string, r: SubagentResult): st
638
644
  }
639
645
 
640
646
  /**
641
- * Cancel confirmation text: short, no output dump — the partial output is
642
- * check's job to return (read-once collection). Always points at check so
643
- * the now-failed registry entry (and its inbox-reminder line) gets cleared.
647
+ * Cancel confirmation text for the /subagent:cancel command: short, no output
648
+ * dump — the partial output is check's job to return. Always points at check
649
+ * so the user knows where the partial output lives.
644
650
  */
645
651
  /**
646
652
  * Compact stop summary shared by the cancel tool text and its TUI row:
@@ -657,8 +663,8 @@ export function cancelStopSummary(r: SubagentResult): string {
657
663
 
658
664
  /**
659
665
  * Cancel confirmation text: short, no output dump — the partial output is
660
- * check's job to return (read-once collection). Always points at check so
661
- * the now-cancelled registry entry (and its inbox-reminder line) gets cleared.
666
+ * check's job to return. Always points at check so the model fetches the
667
+ * partial output it is entitled to.
662
668
  */
663
669
  export function formatCancelText(id: string, role: string, r: SubagentResult): string {
664
670
  const head = `${id} (${role})`;
@@ -819,3 +825,40 @@ export function truncateOutput(t: string): string {
819
825
  const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
820
826
  return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
821
827
  }
828
+
829
+ // ── Session-tree delivery derivation ────────────────────────
830
+
831
+ /**
832
+ * Minimal structural slice of a session entry — the only fields
833
+ * collectDeliveredIds reads. The real SessionEntry from
834
+ * ctx.sessionManager.buildContextEntries() satisfies this shape structurally;
835
+ * keeping it local preserves this module's zero pi-API-dependency rule and
836
+ * lets tests build plain fakes.
837
+ */
838
+ interface SessionEntryLike {
839
+ type: string;
840
+ message?: {
841
+ role?: string;
842
+ toolName?: string;
843
+ details?: unknown;
844
+ };
845
+ }
846
+
847
+ /**
848
+ * Derive the set of background-run ids already delivered by subagent_check
849
+ * on the given session entries. The session tree is the single source of
850
+ * truth for delivery state: it is append-only and branch navigation rebuilds
851
+ * the active path, so branching past a check entry un-delivers (the inbox
852
+ * re-arms) while branching back re-delivers — no mirrored state to sync.
853
+ */
854
+ export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<string> {
855
+ const ids = new Set<string>();
856
+ for (const entry of entries) {
857
+ if (entry.type !== "message") continue;
858
+ const message = entry.message;
859
+ if (!message || message.role !== "toolResult" || message.toolName !== "subagent_check") continue;
860
+ const id = (message.details as { id?: unknown } | undefined)?.id;
861
+ if (typeof id === "string" && id) ids.add(id);
862
+ }
863
+ return ids;
864
+ }
package/src/view.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  * (a "⋮ N earlier" marker appears), reaching the bottom again (or End)
16
16
  * re-pins.
17
17
  * - brief: the run's inputs and vitals — task and context verbatim (wrapped;
18
- * head+tail elided when huge), the reference file list annotated with ✓/·
19
- * for whether the child's tool calls touched each file, usage and time
18
+ * head+tail elided when huge), safe inherited-conversation size/truncation
19
+ * metadata, the reference file list annotated with ✓/· for whether the
20
+ * child's tool calls touched each file, usage and time
20
21
  * stats, the fallback trace, and a stderr tail on failures.
21
22
  *
22
23
  * Steer input is modal so keys never conflict with the editor: browse mode
@@ -51,6 +52,7 @@ import type { ActivityEntry } from "./types.ts";
51
52
  import {
52
53
  briefFilesUsed,
53
54
  formatFallback,
55
+ formatInheritedConversationInput,
54
56
  formatThinking,
55
57
  formatTimePart,
56
58
  formatToolCall,
@@ -339,7 +341,8 @@ export class SubagentViewPanel implements Component, Focusable {
339
341
  }
340
342
 
341
343
  /** Render the brief page's full content (pre-scroll): task/context verbatim,
342
- * annotated file list, stats, fallback trace, failure stderr tail. */
344
+ * inherited-conversation metadata, annotated files, stats, fallback trace,
345
+ * and failure stderr tail. */
343
346
  private renderBriefLines(run: RunHandle, width: number, fg: Fg): string[] {
344
347
  const snap = run.snapshot;
345
348
  const lines: string[] = [];
@@ -361,6 +364,15 @@ export class SubagentViewPanel implements Component, Focusable {
361
364
  body(run.context);
362
365
  }
363
366
 
367
+ if (run.inheritConversation) {
368
+ section(
369
+ formatInheritedConversationInput(
370
+ run.inheritedConversationChars ?? 0,
371
+ run.inheritedConversationTruncated === true,
372
+ ),
373
+ );
374
+ }
375
+
364
376
  if (run.files && run.files.length > 0) {
365
377
  section(`files · ${run.files.length}`);
366
378
  const used = briefFilesUsed(run.files, snap.activityLog);