pi-subagents 0.22.0 → 0.23.1

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.
@@ -9,9 +9,8 @@ import type { Theme } from "@mariozechner/pi-coding-agent";
9
9
  import type { Component, TUI } from "@mariozechner/pi-tui";
10
10
  import { matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
11
11
  import * as fs from "node:fs";
12
- import * as os from "node:os";
13
12
  import * as path from "node:path";
14
- import type { AgentConfig, ChainConfig, ChainStepConfig } from "../../agents/agents.ts";
13
+ import { getUserChainDir, type AgentConfig, type ChainConfig, type ChainStepConfig } from "../../agents/agents.ts";
15
14
  import type { ResolvedStepBehavior } from "../../shared/settings.ts";
16
15
  import type { TextEditorState } from "../../tui/text-editor.ts";
17
16
  import { createEditorState, ensureCursorVisible, getCursorDisplayPos, handleEditorInput, renderEditor, wrapText } from "../../tui/text-editor.ts";
@@ -317,7 +316,7 @@ export class ChainClarifyComponent implements Component {
317
316
  return;
318
317
  }
319
318
  try {
320
- const dir = path.join(os.homedir(), ".pi", "agent", "agents");
319
+ const dir = getUserChainDir();
321
320
  fs.mkdirSync(dir, { recursive: true });
322
321
  const filePath = path.join(dir, `${name}.chain.md`);
323
322
  const config = this.buildChainConfig(name);
@@ -18,6 +18,7 @@ import {
18
18
  buildChainInstructions,
19
19
  writeInitialProgressFile,
20
20
  createParallelDirs,
21
+ suppressProgressForReadOnlyTask,
21
22
  aggregateParallelOutputs,
22
23
  isParallelStep,
23
24
  type StepOverrides,
@@ -178,8 +179,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
178
179
  } as SingleResult;
179
180
  }
180
181
 
181
- const behavior = input.parallelBehaviors[taskIndex]!;
182
182
  const taskTemplate = input.parallelTemplates[taskIndex] ?? "{previous}";
183
+ const behavior = suppressProgressForReadOnlyTask(input.parallelBehaviors[taskIndex]!, taskTemplate, input.originalTask);
183
184
  const templateHasPrevious = taskTemplate.includes("{previous}");
184
185
  const { prefix, suffix } = buildChainInstructions(
185
186
  behavior,
@@ -537,7 +538,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
537
538
 
538
539
  try {
539
540
  const agentNames = step.parallel.map((task) => task.agent);
540
- const parallelBehaviors = resolveParallelBehaviors(step.parallel, agents, stepIndex, chainSkills);
541
+ const parallelBehaviors = resolveParallelBehaviors(step.parallel, agents, stepIndex, chainSkills)
542
+ .map((behavior, taskIndex) => suppressProgressForReadOnlyTask(behavior, parallelTemplates[taskIndex] ?? step.parallel[taskIndex]?.task, originalTask));
541
543
  for (let taskIndex = 0; taskIndex < step.parallel.length; taskIndex++) {
542
544
  const behavior = parallelBehaviors[taskIndex]!;
543
545
  const outputPath = typeof behavior.output === "string"
@@ -616,6 +618,23 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
616
618
  }),
617
619
  };
618
620
  }
621
+ const detachedIndexInStep = parallelResults.findIndex((result) => result.detached);
622
+ const detached = detachedIndexInStep >= 0 ? parallelResults[detachedIndexInStep] : undefined;
623
+ if (detached) {
624
+ return {
625
+ content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
626
+ details: buildChainExecutionDetails({
627
+ results,
628
+ includeProgress,
629
+ allProgress,
630
+ allArtifactPaths,
631
+ artifactsDir,
632
+ chainAgents,
633
+ totalSteps,
634
+ currentStepIndex: stepIndex,
635
+ }),
636
+ };
637
+ }
619
638
 
620
639
  const failures = parallelResults
621
640
  .map((result, originalIndex) => ({ ...result, originalIndex }))
@@ -695,7 +714,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
695
714
  ? tuiOverride.skills
696
715
  : normalizeSkillInput(seqStep.skill),
697
716
  };
698
- const behavior = resolveStepBehavior(agentConfig, stepOverride, chainSkills);
717
+ const behavior = suppressProgressForReadOnlyTask(resolveStepBehavior(agentConfig, stepOverride, chainSkills), stepTemplate, originalTask);
699
718
 
700
719
  const isFirstProgress = behavior.progress && !progressCreated;
701
720
  if (isFirstProgress) {
@@ -822,24 +841,6 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
822
841
  if (r.progress) allProgress.push(r.progress);
823
842
  if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
824
843
 
825
- if (behavior.output && r.exitCode === 0) {
826
- try {
827
- const expectedPath = path.isAbsolute(behavior.output)
828
- ? behavior.output
829
- : path.join(chainDir, behavior.output);
830
- if (!fs.existsSync(expectedPath)) {
831
- const dirFiles = fs.readdirSync(chainDir);
832
- const mdFiles = dirFiles.filter((file) => file.endsWith(".md") && file !== "progress.md");
833
- const warning = mdFiles.length > 0
834
- ? `Agent wrote to different file(s): ${mdFiles.join(", ")} instead of ${behavior.output}`
835
- : `Agent did not create expected output file: ${behavior.output}`;
836
- r.error = r.error ? `${r.error}\n${warning}` : warning;
837
- }
838
- } catch {
839
- // Ignore validation errors - this is just a diagnostic
840
- }
841
- }
842
-
843
844
  if (r.interrupted) {
844
845
  return {
845
846
  content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${r.agent}). Waiting for explicit next action.` }],
@@ -855,6 +856,21 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
855
856
  }),
856
857
  };
857
858
  }
859
+ if (r.detached) {
860
+ return {
861
+ content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${r.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
862
+ details: buildChainExecutionDetails({
863
+ results,
864
+ includeProgress,
865
+ allProgress,
866
+ allArtifactPaths,
867
+ artifactsDir,
868
+ chainAgents,
869
+ totalSteps,
870
+ currentStepIndex: stepIndex,
871
+ }),
872
+ };
873
+ }
858
874
 
859
875
  if (r.exitCode !== 0) {
860
876
  const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
@@ -877,6 +893,24 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
877
893
  };
878
894
  }
879
895
 
896
+ if (behavior.output) {
897
+ try {
898
+ const expectedPath = path.isAbsolute(behavior.output)
899
+ ? behavior.output
900
+ : path.join(chainDir, behavior.output);
901
+ if (!fs.existsSync(expectedPath)) {
902
+ const dirFiles = fs.readdirSync(chainDir);
903
+ const mdFiles = dirFiles.filter((file) => file.endsWith(".md") && file !== "progress.md");
904
+ const warning = mdFiles.length > 0
905
+ ? `Agent wrote to different file(s): ${mdFiles.join(", ")} instead of ${behavior.output}`
906
+ : `Agent did not create expected output file: ${behavior.output}`;
907
+ r.error = r.error ? `${r.error}\n${warning}` : warning;
908
+ }
909
+ } catch {
910
+ // Ignore validation errors; this diagnostic should not mask successful chain output.
911
+ }
912
+ }
913
+
880
914
  prev = getSingleResultOutput(r);
881
915
  }
882
916
  }
@@ -421,7 +421,7 @@ async function runSingleAttempt(
421
421
  const toolArgs = evt.args && typeof evt.args === "object" && !Array.isArray(evt.args)
422
422
  ? evt.args as Record<string, unknown>
423
423
  : {};
424
- if (options.allowIntercomDetach && evt.toolName === "intercom") {
424
+ if (options.allowIntercomDetach && (evt.toolName === "intercom" || evt.toolName === "contact_supervisor")) {
425
425
  intercomStarted = true;
426
426
  }
427
427
  progress.toolCount++;
@@ -633,7 +633,10 @@ async function runSingleAttempt(
633
633
  return result;
634
634
  }
635
635
 
636
- if (exitCode === 0 && !result.error) {
636
+ if (result.error && result.exitCode === 0) {
637
+ result.exitCode = 1;
638
+ }
639
+ if (result.exitCode === 0 && !result.error) {
637
640
  const errInfo = detectSubagentError(result.messages);
638
641
  if (errInfo.hasError) {
639
642
  result.exitCode = errInfo.exitCode ?? 1;
@@ -746,7 +749,6 @@ export async function runSync(
746
749
 
747
750
  const shareEnabled = options.share === true;
748
751
  const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
749
- const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
750
752
  const skillNames = options.skills ?? agent.skills ?? [];
751
753
  const skillCwd = options.cwd ?? runtimeCwd;
752
754
  const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(skillNames, skillCwd, runtimeCwd);
@@ -797,6 +799,7 @@ export async function runSync(
797
799
  for (let i = 0; i < modelsToTry.length; i++) {
798
800
  const candidate = modelsToTry[i];
799
801
  if (candidate) attemptedModels.push(candidate);
802
+ const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
800
803
  const result = await runSingleAttempt(runtimeCwd, agent, task, candidate, options, {
801
804
  sessionEnabled,
802
805
  systemPrompt,
@@ -811,15 +814,16 @@ export async function runSync(
811
814
  sumUsage(aggregateUsage, result.usage);
812
815
  totalToolCount += result.progressSummary?.toolCount ?? 0;
813
816
  totalDurationMs += result.progressSummary?.durationMs ?? 0;
817
+ const attemptSucceeded = result.exitCode === 0 && !result.error;
814
818
  const attempt: ModelAttempt = {
815
819
  model: candidate ?? result.model ?? agent.model ?? "default",
816
- success: result.exitCode === 0,
820
+ success: attemptSucceeded,
817
821
  exitCode: result.exitCode,
818
822
  error: result.error,
819
823
  usage: { ...result.usage },
820
824
  };
821
825
  modelAttempts.push(attempt);
822
- if (result.exitCode === 0) {
826
+ if (attemptSucceeded) {
823
827
  break;
824
828
  }
825
829
  if (!isRetryableModelFailure(result.error) || i === modelsToTry.length - 1) {
@@ -22,13 +22,15 @@ import {
22
22
  getStepAgents,
23
23
  isParallelStep,
24
24
  resolveStepBehavior,
25
+ suppressProgressForReadOnlyTask,
26
+ taskDisallowsFileUpdates,
25
27
  type ChainStep,
26
28
  type ResolvedStepBehavior,
27
29
  type SequentialStep,
28
30
  type StepOverrides,
29
31
  } from "../../shared/settings.ts";
30
32
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
31
- import { executeAsyncChain, executeAsyncSingle, isAsyncAvailable } from "../background/async-execution.ts";
33
+ import { executeAsyncChain, executeAsyncSingle, formatAsyncStartedMessage, isAsyncAvailable } from "../background/async-execution.ts";
32
34
  import { createForkContextResolver } from "../../shared/fork-context.ts";
33
35
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
34
36
  import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
@@ -206,6 +208,115 @@ function foregroundStatusResult(control: SubagentState["foregroundControls"] ext
206
208
  return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "management", results: [] } };
207
209
  }
208
210
 
211
+ function rememberForegroundRun(state: SubagentState, input: { runId: string; mode: "single" | "parallel" | "chain"; cwd: string; results: SingleResult[] }): void {
212
+ state.foregroundRuns ??= new Map();
213
+ state.foregroundRuns.set(input.runId, {
214
+ runId: input.runId,
215
+ mode: input.mode,
216
+ cwd: input.cwd,
217
+ updatedAt: Date.now(),
218
+ children: input.results.map((result, index) => ({
219
+ agent: result.agent,
220
+ index,
221
+ status: resolveSubagentResultStatus({ exitCode: result.exitCode, interrupted: result.interrupted, detached: result.detached }),
222
+ ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}),
223
+ })),
224
+ });
225
+ while (state.foregroundRuns.size > 50) {
226
+ const oldest = [...state.foregroundRuns.values()].sort((left, right) => left.updatedAt - right.updatedAt)[0];
227
+ if (!oldest) break;
228
+ state.foregroundRuns.delete(oldest.runId);
229
+ }
230
+ }
231
+
232
+ function resolveForegroundResumeTarget(params: SubagentParamsLike, state: SubagentState): { runId: string; mode: "single" | "parallel" | "chain"; state: "complete"; agent: string; index: number; intercomTarget: string; cwd: string; sessionFile: string } | undefined {
233
+ const requested = (params.id ?? params.runId)?.trim();
234
+ if (!requested || !state.foregroundRuns?.size) return undefined;
235
+ const direct = state.foregroundRuns.get(requested);
236
+ const matches = direct ? [direct] : [...state.foregroundRuns.values()].filter((run) => run.runId.startsWith(requested));
237
+ if (matches.length === 0) return undefined;
238
+ if (matches.length > 1) throw new Error(`Ambiguous foreground run id prefix '${requested}' matched: ${matches.map((run) => run.runId).join(", ")}. Provide a longer id.`);
239
+ const run = matches[0]!;
240
+ if (run.children.length > 1 && params.index === undefined) throw new Error(`Foreground run '${run.runId}' has ${run.children.length} children. Provide index to choose one.`);
241
+ const index = params.index ?? 0;
242
+ if (!Number.isInteger(index)) throw new Error(`Foreground run '${run.runId}' index must be an integer.`);
243
+ if (index < 0 || index >= run.children.length) throw new Error(`Foreground run '${run.runId}' has ${run.children.length} children. Index ${index} is out of range.`);
244
+ const child = run.children[index]!;
245
+ if (child.status === "detached") throw new Error(`Foreground run '${run.runId}' child ${index} is detached for intercom coordination and cannot be revived safely from the remembered foreground state. Reply to the supervisor request first; after the child exits, start a fresh follow-up if needed.`);
246
+ if (!child.sessionFile) throw new Error(`Foreground run '${run.runId}' child ${index} does not have a persisted session file to resume from.`);
247
+ if (path.extname(child.sessionFile) !== ".jsonl") throw new Error(`Foreground run '${run.runId}' child ${index} session file must be a .jsonl file: ${child.sessionFile}`);
248
+ const sessionFile = path.resolve(child.sessionFile);
249
+ if (!fs.existsSync(sessionFile)) throw new Error(`Foreground run '${run.runId}' child ${index} session file does not exist: ${child.sessionFile}`);
250
+ return { runId: run.runId, mode: run.mode, state: "complete", agent: child.agent, index, intercomTarget: resolveSubagentIntercomTarget(run.runId, child.agent, index), cwd: run.cwd, sessionFile };
251
+ }
252
+
253
+ type AsyncResumeSourceTarget = ReturnType<typeof resolveAsyncResumeTarget> & { source: "async" };
254
+ type ForegroundResumeSourceTarget = NonNullable<ReturnType<typeof resolveForegroundResumeTarget>> & { kind: "revive"; source: "foreground" };
255
+ type ResumeSourceTarget = AsyncResumeSourceTarget | ForegroundResumeSourceTarget;
256
+
257
+ function isAsyncRunNotFound(error: unknown): boolean {
258
+ return error instanceof Error && error.message.startsWith("Async run not found.");
259
+ }
260
+
261
+ function isResumeAmbiguity(error: unknown): boolean {
262
+ return error instanceof Error && /Ambiguous .*run id prefix/.test(error.message);
263
+ }
264
+
265
+ function resumeTargetExact(target: { runId: string } | undefined, requested: string): boolean {
266
+ return target?.runId === requested;
267
+ }
268
+
269
+ function escapeRegExp(value: string): string {
270
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
271
+ }
272
+
273
+ function isExactResumeError(error: unknown, source: "async" | "foreground", requested: string): boolean {
274
+ if (!(error instanceof Error) || !requested) return false;
275
+ return new RegExp(`\\b${source} run '${escapeRegExp(requested)}'`, "i").test(error.message);
276
+ }
277
+
278
+ function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState): ResumeSourceTarget {
279
+ const requested = (params.id ?? params.runId)?.trim() ?? "";
280
+ let foregroundTarget: ForegroundResumeSourceTarget | undefined;
281
+ let foregroundError: unknown;
282
+ let asyncTarget: AsyncResumeSourceTarget | undefined;
283
+ let asyncError: unknown;
284
+
285
+ try {
286
+ const target = resolveForegroundResumeTarget(params, state);
287
+ if (target) foregroundTarget = { kind: "revive", source: "foreground", ...target };
288
+ } catch (error) {
289
+ foregroundError = error;
290
+ }
291
+ try {
292
+ asyncTarget = { source: "async", ...resolveAsyncResumeTarget(params) };
293
+ } catch (error) {
294
+ asyncError = error;
295
+ }
296
+
297
+ if (foregroundTarget && asyncTarget) {
298
+ const foregroundExact = resumeTargetExact(foregroundTarget, requested);
299
+ const asyncExact = resumeTargetExact(asyncTarget, requested);
300
+ if (foregroundExact && !asyncExact) return foregroundTarget;
301
+ if (asyncExact && !foregroundExact) return asyncTarget;
302
+ throw new Error(`Resume id '${requested}' is ambiguous between foreground run '${foregroundTarget.runId}' and async run '${asyncTarget.runId}'. Provide a full run id.`);
303
+ }
304
+ if (foregroundTarget) {
305
+ if (isExactResumeError(asyncError, "async", requested)) throw asyncError;
306
+ if (isResumeAmbiguity(asyncError) && !resumeTargetExact(foregroundTarget, requested)) throw asyncError;
307
+ return foregroundTarget;
308
+ }
309
+ if (asyncTarget) {
310
+ if (isExactResumeError(foregroundError, "foreground", requested)) throw foregroundError;
311
+ if (isResumeAmbiguity(foregroundError) && !resumeTargetExact(asyncTarget, requested)) throw foregroundError;
312
+ return asyncTarget;
313
+ }
314
+ if (foregroundError && !isAsyncRunNotFound(asyncError)) throw foregroundError;
315
+ if (foregroundError) throw foregroundError;
316
+ if (asyncError) throw asyncError;
317
+ throw new Error("Run not found. Provide id or runId.");
318
+ }
319
+
209
320
  function getAsyncInterruptTarget(state: SubagentState, runId: string | undefined): { asyncId: string; asyncDir: string } | undefined {
210
321
  if (runId) {
211
322
  const direct = state.asyncJobs.get(runId);
@@ -296,9 +407,9 @@ async function resumeAsyncRun(input: {
296
407
  };
297
408
  }
298
409
 
299
- let target: ReturnType<typeof resolveAsyncResumeTarget>;
410
+ let target: ResumeSourceTarget;
300
411
  try {
301
- target = resolveAsyncResumeTarget(input.params);
412
+ target = resolveResumeTarget(input.params, input.deps.state);
302
413
  } catch (error) {
303
414
  const message = error instanceof Error ? error.message : String(error);
304
415
  return { content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } };
@@ -344,6 +455,7 @@ async function resumeAsyncRun(input: {
344
455
  config: input.deps.config.intercomBridge,
345
456
  context: input.params.context,
346
457
  orchestratorTarget: sessionName,
458
+ cwd: effectiveCwd,
347
459
  });
348
460
  const agents = intercomBridge.active
349
461
  ? discoveredAgents.map((agent) => applyIntercomBridgeToAgent(agent, intercomBridge))
@@ -351,7 +463,7 @@ async function resumeAsyncRun(input: {
351
463
  const agentConfig = agents.find((agent) => agent.name === target.agent);
352
464
  if (!agentConfig) {
353
465
  return {
354
- content: [{ type: "text", text: `Unknown agent for async resume: ${target.agent}` }],
466
+ content: [{ type: "text", text: `Unknown agent for resume: ${target.agent}` }],
355
467
  isError: true,
356
468
  details: { mode: "management", results: [] },
357
469
  };
@@ -389,16 +501,17 @@ async function resumeAsyncRun(input: {
389
501
 
390
502
  const revivedId = result.details.asyncId ?? runId;
391
503
  const revivedTarget = intercomBridge.active ? resolveSubagentIntercomTarget(revivedId, target.agent, 0) : undefined;
504
+ const sourceLabel = target.source === "foreground" ? "foreground" : "async";
392
505
  const lines = [
393
- `Revived async subagent from ${target.runId}.`,
506
+ `Revived ${sourceLabel} subagent from ${target.runId}.`,
394
507
  `Revived run: ${revivedId}`,
395
508
  `Agent: ${target.agent}`,
396
509
  `Session: ${target.sessionFile}`,
397
510
  result.details.asyncDir ? `Async dir: ${result.details.asyncDir}` : undefined,
398
511
  revivedTarget ? `Intercom target: ${revivedTarget} (if registered)` : undefined,
399
- `Follow: subagent({ action: "status", id: "${revivedId}" })`,
512
+ `Status if needed: subagent({ action: "status", id: "${revivedId}" })`,
400
513
  ].filter((line): line is string => Boolean(line));
401
- return { content: [{ type: "text", text: lines.join("\n") }], details: result.details };
514
+ return { content: [{ type: "text", text: formatAsyncStartedMessage(lines.join("\n")) }], details: result.details };
402
515
  }
403
516
 
404
517
  function resultSummaryForIntercom(result: SingleResult): string {
@@ -835,6 +948,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
835
948
  const chain = wrapChainTasksForFork(params.chain as ChainStep[], params.context);
836
949
  return executeAsyncChain(id, {
837
950
  chain,
951
+ task: params.task,
838
952
  agents,
839
953
  ctx: asyncCtx,
840
954
  availableModels,
@@ -971,6 +1085,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
971
1085
  const asyncChain = wrapChainTasksForFork(chainResult.requestedAsync.chain, params.context);
972
1086
  return executeAsyncChain(id, {
973
1087
  chain: asyncChain,
1088
+ task: params.task,
974
1089
  agents,
975
1090
  ctx: asyncCtx,
976
1091
  availableModels: ctx.modelRegistry.getAvailable().map(toModelInfo),
@@ -991,8 +1106,9 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
991
1106
  });
992
1107
  }
993
1108
 
994
- const chainDetails = chainResult.details ? compactForegroundDetails(chainResult.details) : undefined;
995
- const intercomReceipt = chainDetails && !chainDetails.results.some((result) => result.interrupted)
1109
+ const chainDetails = chainResult.details ? compactForegroundDetails({ ...chainResult.details, runId }) : undefined;
1110
+ if (chainDetails) rememberForegroundRun(deps.state, { runId, mode: "chain", cwd: effectiveCwd, results: chainDetails.results });
1111
+ const intercomReceipt = chainDetails && !chainDetails.results.some((result) => result.interrupted || result.detached)
996
1112
  ? await maybeBuildForegroundIntercomReceipt({
997
1113
  pi: deps.pi,
998
1114
  intercomBridge: data.intercomBridge,
@@ -1009,7 +1125,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1009
1125
  };
1010
1126
  }
1011
1127
 
1012
- return chainResult;
1128
+ return chainDetails ? { ...chainResult, details: chainDetails } : chainResult;
1013
1129
  }
1014
1130
 
1015
1131
  interface ForegroundParallelRunInput {
@@ -1375,17 +1491,21 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1375
1491
  currentSessionId: deps.state.currentSessionId!,
1376
1492
  currentModelProvider: ctx.model?.provider,
1377
1493
  };
1378
- const parallelTasks = tasks.map((t, i) => ({
1379
- agent: t.agent,
1380
- task: params.context === "fork" ? wrapForkTask(taskTexts[i]!) : taskTexts[i]!,
1381
- cwd: t.cwd,
1382
- ...(modelOverrides[i] ? { model: modelOverrides[i] } : {}),
1383
- ...(skillOverrides[i] !== undefined ? { skill: skillOverrides[i] } : {}),
1384
- ...(behaviorOverrides[i]?.output !== undefined ? { output: behaviorOverrides[i]!.output } : {}),
1385
- ...(behaviorOverrides[i]?.outputMode !== undefined ? { outputMode: behaviorOverrides[i]!.outputMode } : {}),
1386
- ...(behaviorOverrides[i]?.reads !== undefined ? { reads: behaviorOverrides[i]!.reads } : {}),
1387
- ...(behaviorOverrides[i]?.progress !== undefined ? { progress: behaviorOverrides[i]!.progress } : {}),
1388
- }));
1494
+ const parallelTasks = tasks.map((t, i) => {
1495
+ const taskText = params.context === "fork" ? wrapForkTask(taskTexts[i]!) : taskTexts[i]!;
1496
+ const progress = taskDisallowsFileUpdates(taskText) ? false : behaviorOverrides[i]?.progress;
1497
+ return {
1498
+ agent: t.agent,
1499
+ task: taskText,
1500
+ cwd: t.cwd,
1501
+ ...(modelOverrides[i] ? { model: modelOverrides[i] } : {}),
1502
+ ...(skillOverrides[i] !== undefined ? { skill: skillOverrides[i] } : {}),
1503
+ ...(behaviorOverrides[i]?.output !== undefined ? { output: behaviorOverrides[i]!.output } : {}),
1504
+ ...(behaviorOverrides[i]?.outputMode !== undefined ? { outputMode: behaviorOverrides[i]!.outputMode } : {}),
1505
+ ...(behaviorOverrides[i]?.reads !== undefined ? { reads: behaviorOverrides[i]!.reads } : {}),
1506
+ ...(progress !== undefined ? { progress } : {}),
1507
+ };
1508
+ });
1389
1509
  return executeAsyncChain(id, {
1390
1510
  chain: [{ parallel: parallelTasks, concurrency: parallelConcurrency, worktree: params.worktree }],
1391
1511
  resultMode: "parallel",
@@ -1410,7 +1530,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1410
1530
  }
1411
1531
  }
1412
1532
 
1413
- const behaviors = agentConfigs.map((config, index) => resolveStepBehavior(config, behaviorOverrides[index]!));
1533
+ const behaviors = agentConfigs.map((config, index) => suppressProgressForReadOnlyTask(resolveStepBehavior(config, behaviorOverrides[index]!), taskTexts[index]));
1414
1534
  const firstProgressIndex = behaviors.findIndex((behavior) => behavior.progress);
1415
1535
  const liveResults: (SingleResult | undefined)[] = new Array(tasks.length).fill(undefined);
1416
1536
  const liveProgress: (AgentProgress | undefined)[] = new Array(tasks.length).fill(undefined);
@@ -1494,16 +1614,26 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1494
1614
  const interrupted = results.find((result) => result.interrupted);
1495
1615
  const details = compactForegroundDetails({
1496
1616
  mode: "parallel",
1617
+ runId,
1497
1618
  results,
1498
1619
  progress: params.includeProgress ? allProgress : undefined,
1499
1620
  artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1500
1621
  });
1622
+ rememberForegroundRun(deps.state, { runId, mode: "parallel", cwd: effectiveCwd, results: details.results });
1501
1623
  if (interrupted) {
1502
1624
  return {
1503
1625
  content: [{ type: "text", text: `Parallel run paused after interrupt (${interrupted.agent}). Waiting for explicit next action.` }],
1504
1626
  details,
1505
1627
  };
1506
1628
  }
1629
+ const detachedIndex = results.findIndex((result) => result.detached);
1630
+ const detached = detachedIndex >= 0 ? results[detachedIndex] : undefined;
1631
+ if (detached) {
1632
+ return {
1633
+ content: [{ type: "text", text: `Parallel run detached for intercom coordination (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
1634
+ details,
1635
+ };
1636
+ }
1507
1637
 
1508
1638
  const intercomReceipt = await maybeBuildForegroundIntercomReceipt({
1509
1639
  pi: deps.pi,
@@ -1775,11 +1905,13 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1775
1905
  });
1776
1906
  const details = compactForegroundDetails({
1777
1907
  mode: "single",
1908
+ runId,
1778
1909
  results: [r],
1779
1910
  progress: params.includeProgress ? allProgress : undefined,
1780
1911
  artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1781
1912
  truncation: r.truncation,
1782
1913
  });
1914
+ rememberForegroundRun(deps.state, { runId, mode: "single", cwd: effectiveCwd, results: details.results });
1783
1915
 
1784
1916
  if (!r.detached && !r.interrupted) {
1785
1917
  const intercomReceipt = await maybeBuildForegroundIntercomReceipt({
@@ -1800,7 +1932,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1800
1932
 
1801
1933
  if (r.detached) {
1802
1934
  return {
1803
- content: [{ type: "text", text: `Detached for intercom coordination: ${params.agent}` }],
1935
+ content: [{ type: "text", text: `Detached for intercom coordination: ${params.agent}. Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
1804
1936
  details,
1805
1937
  };
1806
1938
  }
@@ -1841,6 +1973,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1841
1973
  ctx: ExtensionContext,
1842
1974
  ): Promise<AgentToolResult<Details>> => {
1843
1975
  deps.state.baseCwd = ctx.cwd;
1976
+ deps.state.foregroundRuns ??= new Map();
1844
1977
  deps.state.foregroundControls ??= new Map();
1845
1978
  deps.state.lastForegroundControlId ??= null;
1846
1979
  const requestCwd = resolveRequestedCwd(ctx.cwd, params.cwd);
@@ -1962,6 +2095,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1962
2095
  config: deps.config.intercomBridge,
1963
2096
  context: effectiveParams.context,
1964
2097
  orchestratorTarget: sessionName,
2098
+ cwd: effectiveCwd,
1965
2099
  });
1966
2100
  const agents = intercomBridge.active
1967
2101
  ? discoveredAgents.map((agent) => applyIntercomBridgeToAgent(agent, intercomBridge))
@@ -69,6 +69,7 @@ export function captureSingleOutputSnapshot(outputPath: string | undefined): Sin
69
69
  const stat = fs.statSync(outputPath);
70
70
  return { exists: true, mtimeMs: stat.mtimeMs, size: stat.size };
71
71
  } catch {
72
+ // The snapshot is advisory; resolveSingleOutput reports concrete read/write failures.
72
73
  return { exists: false };
73
74
  }
74
75
  }
@@ -94,18 +95,32 @@ export function resolveSingleOutput(
94
95
  ): { fullOutput: string; savedPath?: string; saveError?: string } {
95
96
  if (!outputPath) return { fullOutput: fallbackOutput };
96
97
 
98
+ let changedSinceStart = false;
97
99
  try {
98
100
  const stat = fs.statSync(outputPath);
99
- const changedSinceStart = !beforeRun?.exists
101
+ changedSinceStart = !beforeRun?.exists
100
102
  || stat.mtimeMs !== beforeRun.mtimeMs
101
103
  || stat.size !== beforeRun.size;
102
- if (changedSinceStart) {
104
+ } catch (error) {
105
+ const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined;
106
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
103
107
  return {
104
- fullOutput: fs.readFileSync(outputPath, "utf-8"),
105
- savedPath: outputPath,
108
+ fullOutput: fallbackOutput,
109
+ saveError: `Failed to inspect output file: ${error instanceof Error ? error.message : String(error)}`,
106
110
  };
107
111
  }
108
- } catch {}
112
+ }
113
+
114
+ if (changedSinceStart) {
115
+ try {
116
+ return { fullOutput: fs.readFileSync(outputPath, "utf-8"), savedPath: outputPath };
117
+ } catch (error) {
118
+ return {
119
+ fullOutput: fallbackOutput,
120
+ saveError: `Failed to read changed output file: ${error instanceof Error ? error.message : String(error)}`,
121
+ };
122
+ }
123
+ }
109
124
 
110
125
  const save = persistSingleOutput(outputPath, fallbackOutput);
111
126
  if (save.savedPath) return { fullOutput: fallbackOutput, savedPath: save.savedPath };
@@ -132,7 +147,7 @@ export function finalizeSingleOutput(params: {
132
147
  return { displayOutput, savedPath: params.savedPath, outputReference };
133
148
  }
134
149
  if (params.exitCode === 0 && params.saveError && params.outputPath) {
135
- displayOutput += `\n\nFailed to save output to: ${params.outputPath}\n${params.saveError}`;
150
+ displayOutput += `\n\nOutput file error: ${params.outputPath}\n${params.saveError}`;
136
151
  return { displayOutput, saveError: params.saveError };
137
152
  }
138
153
  return { displayOutput };
@@ -220,6 +220,25 @@ export function resolveStepBehavior(
220
220
  return { output, outputMode, reads, progress, skills, model };
221
221
  }
222
222
 
223
+ export function resolveTaskTextForFileUpdatePolicy(task: string | undefined, originalTask?: string): string | undefined {
224
+ if (!task) return originalTask;
225
+ return originalTask ? task.replaceAll("{task}", originalTask) : task;
226
+ }
227
+
228
+ export function taskDisallowsFileUpdates(task: string | undefined): boolean {
229
+ if (!task) return false;
230
+ return /\breview[- ]only\b/i.test(task)
231
+ || /\bread[- ]only\s+(?:review|audit|inspection|pass)\b/i.test(task)
232
+ || /\b(?:no|without)\s+(?:file\s+)?edits?\b/i.test(task)
233
+ || /\b(?:do not|don't|must not)\s+(?:edit|modify|write|touch)\b/i.test(task)
234
+ || /\bleave\s+files?\s+unchanged\b/i.test(task);
235
+ }
236
+
237
+ export function suppressProgressForReadOnlyTask(behavior: ResolvedStepBehavior, task: string | undefined, originalTask?: string): ResolvedStepBehavior {
238
+ const policyTask = resolveTaskTextForFileUpdatePolicy(task, originalTask);
239
+ return behavior.progress && taskDisallowsFileUpdates(policyTask) ? { ...behavior, progress: false } : behavior;
240
+ }
241
+
223
242
  // =============================================================================
224
243
  // Chain Instruction Injection
225
244
  // =============================================================================