@selesai/code 0.8.5 → 0.8.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/defaults/models.json +5 -0
  3. package/dist/extensions/agent-browser.test.ts +178 -2
  4. package/dist/extensions/agent-browser.ts +2 -0
  5. package/dist/extensions/copy-turn.test.ts +229 -0
  6. package/dist/extensions/copy-turn.ts +11 -0
  7. package/dist/extensions/grep-app/index.test.ts +356 -1
  8. package/dist/extensions/grep-app/index.ts +6 -1
  9. package/dist/extensions/handoff-new.test.ts +351 -161
  10. package/dist/extensions/handoff-new.ts +3 -0
  11. package/dist/extensions/inline-skills.test.ts +93 -0
  12. package/dist/extensions/inline-skills.ts +3 -0
  13. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  14. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  15. package/dist/extensions/pi-subagents/docs/configuration.md +8 -0
  16. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  17. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  18. package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
  19. package/dist/extensions/pi-subagents/src/extension/index.ts +83 -14
  20. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  21. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +3 -0
  22. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  23. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  24. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  25. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  26. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +7 -5
  27. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  28. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  29. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +39 -9
  30. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  31. package/dist/extensions/pi-subagents/src/shared/types.ts +16 -0
  32. package/dist/extensions/pi-subagents/src/tui/fleet.ts +1 -1
  33. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  34. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  35. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  36. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  37. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  38. package/dist/extensions/ponytail/index.js +11 -0
  39. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  40. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  41. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  42. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  43. package/dist/extensions/question/index.ts +18 -0
  44. package/dist/extensions/question/question-list.ts +22 -0
  45. package/dist/extensions/question/tests/batch.test.ts +103 -65
  46. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  47. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  48. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  49. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  50. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  51. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  52. package/dist/extensions/rtk.test.ts +180 -1
  53. package/dist/extensions/tokenin-onboarding.ts +3 -0
  54. package/dist/extensions/undo.test.ts +720 -0
  55. package/dist/extensions/undo.ts +6 -0
  56. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  57. package/dist/extensions/workflow/extension.ts +3 -3
  58. package/dist/extensions/workflow/modes.ts +41 -19
  59. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  60. package/docs/plans/workflow-autoloop-reference.md +1 -2
  61. package/docs/plans/workflow-handoff-carryover.md +108 -0
  62. package/package.json +4 -1
@@ -22,6 +22,9 @@ function getSkillName(command: SkillCommand): string {
22
22
 
23
23
  function getInlineSkillToken(textBeforeCursor: string): string | undefined {
24
24
  const match = textBeforeCursor.match(/(?:^|[^a-z0-9_-])\$([a-z0-9-]*)$/i);
25
+ // Group 1 is always present when the regex matches (it can be empty but
26
+ // never undefined), so the ?? fallback is unreachable.
27
+ /* v8 ignore next 1 */
25
28
  return match ? match[1] ?? "" : undefined;
26
29
  }
27
30
 
@@ -21,12 +21,15 @@ Rules:
21
21
  - If a required product, architecture, or scope decision is not approved: when the injected bridge instructions make `contact_supervisor` available, use it with `reason: "need_decision"` and wait; otherwise stop, do not guess, and report the exact blocking decision in your final response.
22
22
  - Do not launch subagents. Do not send routine completion handoffs.
23
23
  - Do not claim success without making the requested edits, unless you are blocked and report why.
24
+ - If the task specifies a progress file path, append a `## Round N` entry to that file before finishing (use the round number from the task; if none is given, count existing `## Round` entries and add one). The entry must list every file you changed (`Files:`), a short summary of the work (`Summary:`), and the validation you ran (`Validation:`). If the task names no progress file, skip this.
24
25
 
25
26
  Before finishing, verify the requirement, changed files, and relevant tests/checks.
26
27
 
27
28
  Final response:
28
29
 
29
30
  Implemented: ...
30
- Changed files: ...
31
- Validation: ...
31
+ Progress:
32
+ Files: ... (every file changed)
33
+ Summary: ... (one or two lines on what was done)
34
+ Validation: ... (checks run and outcome)
32
35
  Open risks/questions: ...
@@ -14,7 +14,7 @@ acceptanceRole: read-only
14
14
 
15
15
  You are a review-only subagent. Inspect and report evidence-backed findings; do not edit project files, write output files, use shell commands that mutate state, or launch subagents.
16
16
 
17
- Review the supplied target directly. For code, inspect the actual diff, callers, relevant tests, and requirements—not just another agent's summary. Use `bash` only for read-only inspection or test commands.
17
+ Review the supplied target directly. If the task names a progress file, read it first and scope your review to its latest round entry: inspect the diff restricted to the files that entry lists (`git diff -- <files>`). Older entries are already reviewed—re-inspect only files the latest entry repeats. If no progress file is named, or it is missing or empty, review the full uncommitted diff. For code, inspect the actual diff, callers, relevant tests, and requirements—not just another agent's summary. Use `bash` only for read-only inspection or test commands.
18
18
 
19
19
  Check:
20
20
  - correctness, regressions, edge cases, and plan/requirement adherence;
@@ -155,6 +155,14 @@ Caps cumulative logical child admissions in one top-level run tree. The default
155
155
 
156
156
  The budget counts single launches, expanded `tasks`/`count`, static chain steps and parallel groups, actual dynamic `expand` items, appended chain steps, workflow children, and nested child calls. Static and materialized dynamic groups are admitted atomically. Startup retries, model fallback, and retained-child resume reuse the original logical child claim. Claims are never released or refunded. This cap is independent from the session-wide cumulative spawn budget and `globalConcurrencyLimit`.
157
157
 
158
+ ## `maxWorkflowAutoRelaunches`
159
+
160
+ ```json
161
+ { "maxWorkflowAutoRelaunches": 12 }
162
+ ```
163
+
164
+ Maximum automatic relaunches when an async scripted workflow ends with a fan-out `budget` result before the goal is clean. Each relaunch re-runs the same `workflowScript` as a fresh top-level run with a new fan-out budget, the same mission, and the same progress file, so the loop keeps building until the commentator reports clean. Defaults to 12; `0` means unlimited. When the cap is reached, the workflow completes as `failed` with a note to raise the cap or relaunch manually. Foreground workflows are unaffected — their budget results surface to the parent inline.
165
+
158
166
  ## `maxActiveAsyncRunsPerSession`
159
167
 
160
168
  ```json
@@ -62,7 +62,7 @@ return runs.run("test", { agent: "worker", task });
62
62
 
63
63
  A plain workflow creates one enclosing mission by default. Its children do not create separate missions. The result exposes the id as `details.missionId`, and human-readable output ends with `Mission: <id> (<status>)`. Pass `mission:false` for an ephemeral workflow with no mission or durable `state` global.
64
64
 
65
- For watched same-repo workflows, pass `async:false` to show the live in-chat workflow card. `chatProgress` can force `off` or `live-card` when the automatic policy is not what you want. Foreground workflows default to a 30-minute timeout; async workflows have no default timeout. See the [tool reference](tool-reference.md) for the full parameter list.
65
+ For watched same-repo workflows, pass `async:false` to show the live in-chat workflow card. `chatProgress` can force `off` or `live-card` when the automatic policy is not what you want. Foreground workflows default to a 30-minute timeout; async workflows have no default timeout. Async workflows that end with a fan-out `budget` result auto-relaunch as a fresh top-level run (new fan-out budget, same mission and progress file) until the commentator reports clean or the `maxWorkflowAutoRelaunches` cap is reached (default 12; 0 = unlimited). See the [tool reference](tool-reference.md) for the full parameter list.
66
66
 
67
67
  The legacy `/chain`, `/parallel`, and `/run-chain` commands are not registered.
68
68
 
@@ -55,7 +55,7 @@ its resolved launch context as `[fresh]` or `[fork]`. Aggregate headers show
55
55
 
56
56
  `workflowScript` is the scripted orchestration surface. Use `runs.run(key, { agent, task, ... })` for one child, `runs.all([...])` for parallel children, and ordinary JavaScript for sequence, branching, filtering, retries, and aggregation. Scripts are ordinary JavaScript statement bodies, so use an explicit return such as `workflowScript: "return runs.run('main', { agent: 'worker', task: '...' })"` for a useful one-child result. Prefer a single scripted workflow whenever the parent is starting a coordinated wave, such as multiple reviews, review plus gate monitor, worker then monitor setup, cross-repo prep lanes, or a fanout that the parent will consume together; use the declarative `chain` / `tasks` modes when the shape is known up front.
57
57
 
58
- **Workflow modes as `workflowScript` auto-loops.** The bundled `workflow` extension ships its four mode shapes (task, prototype, quicktype, loop) as `workflowScript` auto-loops launched via `/workflow-*`. Each mode runs its phases as `runs.run` steps and loops the build↔review round until the commentator reports clean. There are no checkpoints (one-shot-and-sleep); durable state is the auto-created mission per launch; recover via `mission.list`/`status`.
58
+ **Workflow modes as `workflowScript` auto-loops.** The bundled `workflow` extension ships its four mode shapes (task, prototype, quicktype, loop) as `workflowScript` auto-loops launched via `/workflow-*`. Each mode runs its phases as `runs.run` steps and loops the build → review → fix round (a blocking review triggers a fix round that addresses only the findings) until the commentator reports clean. There are no checkpoints (one-shot-and-sleep); durable state is the auto-created mission per launch; recover via `mission.list`/`status`.
59
59
 
60
60
  ```js
61
61
  subagent({
@@ -59,6 +59,12 @@ function validateConfig(config: Record<string, unknown>): void {
59
59
  || config.maxActiveAsyncRunsPerSession < 0)) {
60
60
  throw new Error("config.maxActiveAsyncRunsPerSession must be a non-negative integer");
61
61
  }
62
+ if (config.maxWorkflowAutoRelaunches !== undefined
63
+ && (typeof config.maxWorkflowAutoRelaunches !== "number"
64
+ || !Number.isInteger(config.maxWorkflowAutoRelaunches)
65
+ || config.maxWorkflowAutoRelaunches < 0)) {
66
+ throw new Error("config.maxWorkflowAutoRelaunches must be a non-negative integer");
67
+ }
62
68
  validateMissionStoreConfig(config.missions);
63
69
  validateAuthorityPolicy(config.authorityPolicy);
64
70
  validatePermissionConfig(config.permissions);
@@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto";
16
16
  import * as fs from "node:fs";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
+ import { fileURLToPath } from "node:url";
19
20
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
20
21
  import { keyText, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@selesai/code";
21
22
  import { Box, Container, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
@@ -23,6 +24,7 @@ import { discoverAgents } from "../agents/agents.ts";
23
24
  import { ensureAccessibleDir } from "../shared/accessible-dir.ts";
24
25
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
25
26
  import { resolveCurrentSessionId } from "../shared/session-identity.ts";
27
+ import { resolveSessionLineage } from "../shared/session-lineage.ts";
26
28
  import { cleanupOldChainDirs } from "../shared/settings.ts";
27
29
  import { clearLegacyResultAnimationTimer, renderSubagentResult, renderSubagentSummary } from "../tui/render.ts";
28
30
  import { openSubagentFleet } from "../tui/fleet.ts";
@@ -376,6 +378,12 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
376
378
  const artifactCleanupDays = config.artifactConfig?.cleanupDays ?? DEFAULT_ARTIFACT_CONFIG.cleanupDays;
377
379
  cleanupAllArtifactDirs(artifactCleanupDays);
378
380
 
381
+ // Replay guard for lineage result adoption: accept result files written at
382
+ // most this long before the adoption window opened (covers completions that
383
+ // raced the session switch). Older files were left behind by a dead watcher
384
+ // and are ignored until their owning session is resumed.
385
+ const ADOPTED_RESULT_SLACK_MS = 60_000;
386
+
379
387
  const state: SubagentState = {
380
388
  baseCwd: "",
381
389
  currentSessionId: null,
@@ -630,19 +638,26 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
630
638
 
631
639
  pi.on("agent_end", async (_event, ctx) => {
632
640
  if (!ctx.hasUI) await drainOutstandingWork({ state, events: pi.events });
633
- const ownerSessionId = state.currentSessionId;
634
- if (!ownerSessionId) return;
641
+ const ownerSessionIds = state.sessionLineage?.length
642
+ ? state.sessionLineage
643
+ : state.currentSessionId ? [state.currentSessionId] : [];
644
+ if (ownerSessionIds.length === 0) return;
635
645
  goalTurnId += 1;
636
646
  try {
637
647
  const location = resolveMissionStoreLocation({ projectRoot: state.baseCwd, ...(config.missions ? { config: config.missions } : {}) });
638
- const retainedChildren = listRetainedChildren(DIRS.async, ownerSessionId);
639
- for (const notice of collectGoalContinuationNotices({ location, ownerSessionId, retainedChildren, turnId: goalTurnId })) {
640
- handleSubagentControlNotice({
641
- pi,
642
- state,
643
- visibleControlNotices: new Set(),
644
- details: { source: "goal", event: notice.event, noticeText: notice.message },
645
- });
648
+ const retainedChildren = listRetainedChildren(DIRS.async, ownerSessionIds);
649
+ const emittedMissions = new Set<string>();
650
+ for (const ownerSessionId of ownerSessionIds) {
651
+ for (const notice of collectGoalContinuationNotices({ location, ownerSessionId, retainedChildren, turnId: goalTurnId })) {
652
+ if (emittedMissions.has(notice.missionId)) continue;
653
+ emittedMissions.add(notice.missionId);
654
+ handleSubagentControlNotice({
655
+ pi,
656
+ state,
657
+ visibleControlNotices: new Set(),
658
+ details: { source: "goal", event: notice.event, noticeText: notice.message },
659
+ });
660
+ }
646
661
  }
647
662
  } catch (error) {
648
663
  console.error("Failed to evaluate goal missions:", error);
@@ -650,6 +665,11 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
650
665
  });
651
666
 
652
667
  registerSlashCommands(pi, state, { fleetKeybindings: config.fleetKeybindings });
668
+ pi.on("resources_discover", () => {
669
+ return {
670
+ promptPaths: [fileURLToPath(new URL("../../prompts", import.meta.url))],
671
+ };
672
+ });
653
673
 
654
674
  const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
655
675
  const controlNoticeSeenStoreKey = "__piSubagentVisibleControlNotices";
@@ -758,14 +778,47 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
758
778
  resolveMaxActiveAsyncRunsPerSession(config.maxActiveAsyncRunsPerSession),
759
779
  { liveWorkflowRunIds: new Set(state.workflowControllers?.keys() ?? []) },
760
780
  );
781
+ const adoptedActive = [...state.asyncJobs.values()].filter((job) => job.adopted && (job.status === "queued" || job.status === "running")).length;
782
+ if (adoptedActive > 0) {
783
+ state.activeAsyncCapacity = {
784
+ ...state.activeAsyncCapacity,
785
+ used: state.activeAsyncCapacity.used + adoptedActive,
786
+ };
787
+ }
761
788
  };
762
789
 
763
- const resetSessionState = (ctx: ExtensionContext, recovering: boolean) => {
790
+ const formatActiveWorkflowSummary = (target: SubagentState, intro: string): string => {
791
+ const active = [...target.asyncJobs.values()]
792
+ .filter((job) => job.status === "queued" || job.status === "running")
793
+ .sort((left, right) => (left.startedAt ?? 0) - (right.startedAt ?? 0));
794
+ const lines = active.map((job) => {
795
+ const label = job.mode === "workflow"
796
+ ? `workflow[${job.workflowKey ?? "script"}]`
797
+ : `${job.mode ?? "run"}${job.agents?.length ? ` ${job.agents.join(", ")}` : ""}`;
798
+ const step = job.currentStep !== undefined
799
+ ? ` · step ${job.currentStep + 1}/${job.chainStepCount ?? job.stepsTotal ?? "?"}`
800
+ : "";
801
+ const activity = job.currentTool ? ` · last: ${job.currentTool}` : "";
802
+ const adopted = job.adopted ? " (carried over from a previous session)" : "";
803
+ return `- ${job.asyncId} ${label}${step}${activity}${adopted}`;
804
+ });
805
+ return [
806
+ intro,
807
+ ...lines,
808
+ "Monitor with subagent status <id>; interrupt/resume/steer by run id; children.list lists completed steps. Results arrive automatically.",
809
+ ].join("\n");
810
+ };
811
+
812
+ const resetSessionState = (ctx: ExtensionContext, recovering: boolean, previousSessionFile?: string | null) => {
764
813
  state.widgetsSuspended = false;
765
814
  state.baseCwd = ctx.cwd;
766
815
  goalTurnId = 0;
767
816
  state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
768
817
  state.parentSessionFile = ctx.sessionManager.getSessionFile();
818
+ state.sessionLineage = resolveSessionLineage({ sessionManager: ctx.sessionManager }, { parentHint: previousSessionFile ?? null });
819
+ state.handoffResumePending = false;
820
+ if (state.sessionLineage.length > 1) state.adoptedResultWindowStart = Date.now() - ADOPTED_RESULT_SLACK_MS;
821
+ else state.adoptedResultWindowStart = undefined;
769
822
  state.subagentSpawns = {
770
823
  sessionId: state.currentSessionId,
771
824
  count: 0,
@@ -791,7 +844,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
791
844
  state.lastForegroundControlId = null;
792
845
  resetJobs(ctx);
793
846
  restoreForegroundRunHistory(state, { resultsDir: DIRS.results });
794
- restoreActiveJobs(ctx);
847
+ const adoptedJobs = restoreActiveJobs(ctx);
848
+ state.handoffResumePending = adoptedJobs > 0 && state.lastUiContext?.hasUI === true;
795
849
  scheduledRunManager.bindSession(ctx);
796
850
  restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
797
851
  waitSubscriptionManager.restore();
@@ -807,6 +861,17 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
807
861
 
808
862
  pi.on("agent_settled", () => {
809
863
  resumeWidgetsAfterCompaction();
864
+ if (state.handoffResumePending && state.lastUiContext?.hasUI === true) {
865
+ state.handoffResumePending = false;
866
+ pi.sendMessage(
867
+ {
868
+ customType: "subagent-handoff-resume",
869
+ content: formatActiveWorkflowSummary(state, "Session handoff detected. The previous session's background workflows were carried over:"),
870
+ display: false,
871
+ },
872
+ { triggerTurn: true },
873
+ );
874
+ }
810
875
  });
811
876
 
812
877
  pi.on("session_before_compact", (event) => {
@@ -819,7 +884,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
819
884
  pi.sendMessage(
820
885
  {
821
886
  customType: "subagent-compaction-resume",
822
- content: "Compaction is complete. Resume the parent task now; background subagent results will arrive separately when ready.",
887
+ content: formatActiveWorkflowSummary(state, "Compaction is complete. Resume the parent task now; results for the runs below arrive separately when ready:"),
823
888
  display: false,
824
889
  },
825
890
  { triggerTurn: true },
@@ -828,7 +893,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
828
893
 
829
894
  pi.on("session_start", (event, ctx) => {
830
895
  const recovering = event.reason === "startup" || event.reason === "reload" || event.reason === "resume";
831
- resetSessionState(ctx, recovering);
896
+ const previousSessionFile = event.reason === "new" || event.reason === "fork" ? event.previousSessionFile : null;
897
+ resetSessionState(ctx, recovering, previousSessionFile);
832
898
  herdrStatusBridge.sessionStarted({
833
899
  hasUI: ctx.hasUI === true,
834
900
  runs: activeHerdrRuns(),
@@ -842,6 +908,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
842
908
  stopResultWatcher();
843
909
  state.currentSessionId = null;
844
910
  state.parentSessionFile = null;
911
+ state.sessionLineage = undefined;
912
+ state.adoptedResultWindowStart = undefined;
913
+ state.handoffResumePending = false;
845
914
  completionNotifier.dispose();
846
915
  delete process.env[SUBAGENT_PARENT_SESSION_ENV];
847
916
  for (const unsubscribe of eventUnsubscribes) {
@@ -54,7 +54,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
54
54
  handleStarted: (data: unknown) => void;
55
55
  handleComplete: (data: unknown) => void;
56
56
  resetJobs: (ctx?: ExtensionContext) => void;
57
- restoreActiveJobs: (ctx?: ExtensionContext) => void;
57
+ restoreActiveJobs: (ctx?: ExtensionContext) => number;
58
58
  dispose: () => void;
59
59
  } {
60
60
  const completionRetentionMs = options.completionRetentionMs ?? 10000;
@@ -645,26 +645,32 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
645
645
  }
646
646
  };
647
647
 
648
- const restoreActiveJobs = (ctx?: ExtensionContext) => {
648
+ const restoreActiveJobs = (ctx?: ExtensionContext): number => {
649
649
  if (ctx?.hasUI) state.lastUiContext = ctx;
650
- if (!state.currentSessionId) return;
650
+ if (!state.currentSessionId) return 0;
651
+ const sessionIds = state.sessionLineage?.length ? state.sessionLineage : [state.currentSessionId];
651
652
  let runs: AsyncRunSummary[];
652
653
  try {
653
- runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], sessionId: state.currentSessionId, resultsDir, kill: options.kill, now: options.now });
654
+ runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], sessionIds, resultsDir, kill: options.kill, now: options.now });
654
655
  } catch (error) {
655
656
  console.error(`Failed to restore active async jobs from '${asyncDirRoot}':`, error);
656
- return;
657
+ return 0;
657
658
  }
659
+ let adopted = 0;
658
660
  for (const run of runs) {
661
+ if (state.asyncJobs.has(run.id)) continue;
659
662
  const job = summaryToJob(run);
663
+ job.adopted = run.sessionId !== undefined && run.sessionId !== state.currentSessionId;
660
664
  state.asyncJobs.set(run.id, job);
661
665
  if (job.status === "running") runningJobIds.add(job.asyncId);
662
666
  rememberFleetJob(state, job);
663
667
  watchJob(job);
668
+ if (job.adopted) adopted += 1;
664
669
  }
665
- if (runs.length === 0) return;
670
+ if (runs.length === 0) return 0;
666
671
  ensurePoller();
667
672
  rerenderLastWidget();
673
+ return adopted;
668
674
  };
669
675
 
670
676
  return { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs, dispose };
@@ -120,6 +120,8 @@ export interface AsyncRunSummary {
120
120
  interface AsyncRunListOptions {
121
121
  states?: Array<AsyncRunSummary["state"]>;
122
122
  sessionId?: string;
123
+ /** Accept runs owned by any of these session identities (union). */
124
+ sessionIds?: string[];
123
125
  limit?: number;
124
126
  /** Limits status-file reads after candidates are ordered by status mtime. */
125
127
  entryLimit?: number;
@@ -468,6 +470,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
468
470
  // match.
469
471
  if (allowedStates && !allowedStates.has(status.state)) continue;
470
472
  if (options.sessionId && status.sessionId !== options.sessionId) continue;
473
+ if (options.sessionIds && (status.sessionId === undefined || !options.sessionIds.includes(status.sessionId))) continue;
471
474
  const nestedWarnings: string[] = [];
472
475
  let nestedRoute: NestedRoute | undefined;
473
476
  if (options.reconcile !== false) {
@@ -239,7 +239,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
239
239
 
240
240
  export default function registerSubagentNotify(
241
241
  pi: ExtensionAPI,
242
- state: Pick<SubagentState, "currentSessionId">,
242
+ state: Pick<SubagentState, "currentSessionId" | "sessionLineage">,
243
243
  options: RegisterSubagentNotifyOptions = {},
244
244
  ): CompletionNotifier {
245
245
  const seen = new Map<string, number>();
@@ -250,6 +250,9 @@ export default function registerSubagentNotify(
250
250
  const batchers = new Map<string, CompletionBatcher<PendingCompletion>>();
251
251
  let disposed = false;
252
252
 
253
+ const ownsSession = (sessionId: string) =>
254
+ sessionId === state.currentSessionId || state.sessionLineage?.includes(sessionId) === true;
255
+
253
256
  const settle = (items: PendingCompletion[], accepted: boolean) => {
254
257
  for (const item of items) {
255
258
  pending.delete(item.key);
@@ -274,7 +277,7 @@ export default function registerSubagentNotify(
274
277
  };
275
278
 
276
279
  const deliver = (result: CompletionNotification): Promise<boolean> => {
277
- if (disposed || typeof result.sessionId !== "string" || result.sessionId !== state.currentSessionId) return Promise.resolve(false);
280
+ if (disposed || typeof result.sessionId !== "string" || !ownsSession(result.sessionId)) return Promise.resolve(false);
278
281
  if (result.intercomDelivered === true) return Promise.resolve(true);
279
282
  const key = buildCompletionKey(result, "notify");
280
283
  const seenAt = seen.get(key);
@@ -168,7 +168,24 @@ export function createResultWatcher(
168
168
  const ownsSession = (sessionId: string, epoch: number) => {
169
169
  if (!deliveryActive || epoch !== deliveryEpoch) return false;
170
170
  if (!activeSessionId && state.currentSessionId) activeSessionId = state.currentSessionId;
171
- return activeSessionId === sessionId && state.currentSessionId === sessionId;
171
+ if (activeSessionId !== state.currentSessionId) return false;
172
+ if (sessionId === state.currentSessionId) return true;
173
+ return (state.sessionLineage ?? []).includes(sessionId);
174
+ };
175
+
176
+ const lineageOwnsResult = (identity: ResultFileIdentity, file: string): boolean => {
177
+ const lineage = state.sessionLineage;
178
+ if (!identity.sessionId || !lineage || !lineage.includes(identity.sessionId)) return false;
179
+ // Replay guard: only accept lineage results written around or after the
180
+ // adoption window. The previous session's watcher deleted every result it
181
+ // delivered, so older files are stale leftovers, not missed deliveries.
182
+ const windowStart = state.adoptedResultWindowStart;
183
+ if (windowStart === undefined) return false;
184
+ try {
185
+ return fsApi.statSync(path.join(resultsDir, file)).mtimeMs >= windowStart;
186
+ } catch {
187
+ return false;
188
+ }
172
189
  };
173
190
 
174
191
  const scheduleResult = (file: string, triggerTurn: boolean, delayMs = 0) => {
@@ -210,6 +227,7 @@ export function createResultWatcher(
210
227
  // files keep their existing diagnostics and compatibility behavior.
211
228
  if (!identity.sessionId) return true;
212
229
  if (identity.sessionId === state.currentSessionId) return true;
230
+ if (lineageOwnsResult(identity, file)) return true;
213
231
  if (identity.asyncDir && fsApi.existsSync(path.join(identity.asyncDir, MISSION_BINDING_FILE))) return true;
214
232
  if (identity.runId && (observed ?? observedRunIds()).has(identity.runId)) return true;
215
233
  return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
@@ -32,8 +32,8 @@ function boundedTaskSummary(value: string | undefined): string {
32
32
  : normalized;
33
33
  }
34
34
 
35
- export function listRetainedChildren(asyncDirRoot: string, sessionId: string): RetainedChild[] {
36
- return listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete"], reconcile: false })
35
+ export function listRetainedChildren(asyncDirRoot: string, sessionIds: string[]): RetainedChild[] {
36
+ return listAsyncRuns(asyncDirRoot, { sessionIds, states: ["complete"], reconcile: false })
37
37
  .flatMap((run) => {
38
38
  if (!run.parentWorkflowRunId || run.steps.length !== 1) return [];
39
39
  const step = run.steps[0]!;
@@ -94,9 +94,10 @@ function toolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resu
94
94
 
95
95
  function foregroundIds(state: SubagentState | undefined): string[] {
96
96
  if (!state) return [];
97
+ const acceptedSessions = new Set(state.sessionLineage?.length ? state.sessionLineage : state.currentSessionId ? [state.currentSessionId] : []);
97
98
  const remembered = state.currentSessionId
98
99
  ? [...(state.foregroundRuns?.values() ?? [])]
99
- .filter((run) => run.sessionId === state.currentSessionId)
100
+ .filter((run) => run.sessionId !== undefined && acceptedSessions.has(run.sessionId))
100
101
  .map((run) => run.runId)
101
102
  : [];
102
103
  return [...new Set([...state.foregroundControls.keys(), ...remembered])];
@@ -106,7 +107,9 @@ function hasExactForegroundId(state: SubagentState | undefined, id: string): boo
106
107
  if (!state) return false;
107
108
  if (state.foregroundControls.has(id)) return true;
108
109
  const remembered = state.foregroundRuns?.get(id);
109
- return Boolean(remembered && state.currentSessionId && remembered.sessionId === state.currentSessionId);
110
+ if (!remembered || !state.currentSessionId || remembered.sessionId === undefined) return false;
111
+ const acceptedSessions = new Set(state.sessionLineage?.length ? state.sessionLineage : [state.currentSessionId]);
112
+ return acceptedSessions.has(remembered.sessionId);
110
113
  }
111
114
 
112
115
  function nestedScopeFromState(state: SubagentState | undefined): NestedRunResolutionScope | undefined {
@@ -217,6 +217,8 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
217
217
  const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
218
218
  const resultsDir = deps.resultsDir ?? DIRS.results;
219
219
  const currentSessionId = deps.state?.currentSessionId ?? undefined;
220
+ const acceptedSessions = new Set<string>(deps.state?.sessionLineage?.length ? deps.state.sessionLineage : currentSessionId ? [currentSessionId] : []);
221
+ const ownsSession = (sessionId: string | undefined) => sessionId !== undefined && acceptedSessions.has(sessionId);
220
222
  if (params.view && params.view !== "fleet" && params.view !== "transcript") {
221
223
  return {
222
224
  content: [{ type: "text", text: `Unknown status view: ${params.view}. Valid: fleet, transcript.` }],
@@ -236,7 +238,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
236
238
  };
237
239
  }
238
240
  try {
239
- const runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], sessionId: currentSessionId, resultsDir, kill: deps.kill, now: deps.now });
241
+ const runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], ...(acceptedSessions.size ? { sessionIds: [...acceptedSessions] } : {}), resultsDir, kill: deps.kill, now: deps.now });
240
242
  if (params.view === "transcript") {
241
243
  if (runs.length === 1) return inspectSubagentStatus({ ...params, id: runs[0]!.id }, deps);
242
244
  return {
@@ -332,9 +334,9 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
332
334
  const status = reconciliation.status;
333
335
  if (!status && diskStatus?.displayDismissedAt !== undefined) {
334
336
  if (params.view === "transcript") {
335
- if (currentSessionId && diskStatus.sessionId !== currentSessionId) {
337
+ if (currentSessionId && !ownsSession(diskStatus.sessionId)) {
336
338
  return {
337
- content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session." }],
339
+ content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session or its carried-over predecessors." }],
338
340
  isError: true,
339
341
  details: { mode: "single", results: [] },
340
342
  };
@@ -351,9 +353,9 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
351
353
  const eventsPath = path.join(asyncDir, "events.jsonl");
352
354
  if (status) {
353
355
  if (params.view === "transcript") {
354
- if (currentSessionId && status.sessionId !== currentSessionId) {
356
+ if (currentSessionId && !ownsSession(status.sessionId)) {
355
357
  return {
356
- content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session." }],
358
+ content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session or its carried-over predecessors." }],
357
359
  isError: true,
358
360
  details: { mode: "single", results: [] },
359
361
  };
@@ -245,7 +245,8 @@ export function createWaitSubscriptionManager(
245
245
  try {
246
246
  const record = parseRecord(JSON.parse(fs.readFileSync(path.join(subscriptionsDir, file), "utf-8")));
247
247
  if (!record || file !== `${record.token}.json`) continue;
248
- if (record.sessionId === state.currentSessionId) {
248
+ const acceptedSessions = new Set(state.sessionLineage?.length ? state.sessionLineage : [state.currentSessionId]);
249
+ if (acceptedSessions.has(record.sessionId)) {
249
250
  subscriptions.set(record.token, record);
250
251
  if (record.targetKind === "foreground" && !state.foregroundRuns?.has(record.runId)) unresolvedRestoredForegroundTokens.add(record.token);
251
252
  }
@@ -121,11 +121,14 @@ export function persistForegroundRunHistory(state: SubagentState, options: { res
121
121
  writeAtomicJson(historyPath(resultsDir), { version: HISTORY_VERSION, runs });
122
122
  }
123
123
 
124
- export function restoreForegroundRunHistory(state: SubagentState, options: { resultsDir?: string; sessionId?: string | null; limit?: number } = {}): number {
125
- const sessionId = options.sessionId ?? state.currentSessionId;
126
- if (!sessionId) return 0;
124
+ export function restoreForegroundRunHistory(state: SubagentState, options: { resultsDir?: string; sessionId?: string | null; sessionIds?: string[]; limit?: number } = {}): number {
125
+ const sessionIds = options.sessionIds
126
+ ?? (options.sessionId ? [options.sessionId] : state.sessionLineage)
127
+ ?? (state.currentSessionId ? [state.currentSessionId] : []);
128
+ if (sessionIds.length === 0) return 0;
129
+ const accepted = new Set(sessionIds);
127
130
  const index = readIndex(options.resultsDir ?? DIRS.results);
128
- const runs = sortAndBound(index.runs.filter((run) => run.sessionId === sessionId), options.limit ?? MAX_REMEMBERED_FOREGROUND_RUNS);
131
+ const runs = sortAndBound(index.runs.filter((run) => run.sessionId !== undefined && accepted.has(run.sessionId)), options.limit ?? MAX_REMEMBERED_FOREGROUND_RUNS);
129
132
  state.foregroundRuns ??= new Map();
130
133
  let restored = 0;
131
134
  for (const run of runs) {
@@ -115,6 +115,7 @@ import { resolveAuthorityDecision } from "../../policy/authority.ts";
115
115
  import { handleHerdrInspectorAction, HERDR_INSPECTOR_ACTIONS } from "../../inspectors/herdr/actions.ts";
116
116
  import { handleHerdrProjectPaneAction, HERDR_PROJECT_PANE_ACTIONS } from "../../inspectors/herdr/project-panes.ts";
117
117
  import { previewSimpleWorkflowRun, runWorkflowScript, WorkflowScriptError, type WorkflowScriptChildResult } from "../../workflows/scripted-workflow.ts";
118
+ import { resolveAutoRelaunchDecision, resolveMaxWorkflowAutoRelaunches } from "../../workflows/workflow-auto-relaunch.ts";
118
119
  import { renderWorkflowPrompt } from "../../shared/prompt-resources.ts";
119
120
  import { resolveWorkflowChatProgress, type WorkflowChatProgressProjection } from "../../workflows/chat-progress.ts";
120
121
  import {
@@ -781,7 +782,8 @@ function updateRememberedForegroundChild(state: SubagentState, input: { runId: s
781
782
  function resolveForegroundResumeTarget(params: SubagentParamsLike, state: SubagentState): { runId: string; mode: SubagentRunMode; state: "complete"; agent: string; index: number; cwd: string; sessionFile: string; model?: string; thinking?: string; launchContractDigest?: string; capabilityCeiling?: ResolvedSubagentCapabilityCeiling } | undefined {
782
783
  const requested = (params.id ?? params.runId)?.trim();
783
784
  if (!requested || !state.foregroundRuns?.size || !state.currentSessionId) return undefined;
784
- const sessionRuns = [...state.foregroundRuns.values()].filter((run) => run.sessionId === state.currentSessionId);
785
+ const acceptedSessions = new Set(state.sessionLineage?.length ? state.sessionLineage : [state.currentSessionId]);
786
+ const sessionRuns = [...state.foregroundRuns.values()].filter((run) => run.sessionId !== undefined && acceptedSessions.has(run.sessionId));
785
787
  const direct = sessionRuns.find((run) => run.runId === requested);
786
788
  const matches = direct ? [direct] : sessionRuns.filter((run) => run.runId.startsWith(requested));
787
789
  if (matches.length === 0) return undefined;
@@ -4889,8 +4891,17 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4889
4891
  appendWorkflowEvent({ type: "subagent.workflow.trace", trace });
4890
4892
  };
4891
4893
  try {
4892
- const workflow = await runWorkflowScript({
4893
- script: workflowScript,
4894
+ const autoRelaunchCap = resolveMaxWorkflowAutoRelaunches(deps.config.maxWorkflowAutoRelaunches);
4895
+ let relaunched = 0;
4896
+ let capReached = false;
4897
+ let workflow: Awaited<ReturnType<typeof runWorkflowScript>> | undefined;
4898
+ while (true) {
4899
+ if (relaunched > 0) {
4900
+ workflowFanoutBudget = createRunFanoutBudget(`${workflowRunId}#${relaunched + 1}`, workflowFanoutBudget.limit);
4901
+ status = { ...status, workflow: { trace: [], emits: [], console: [] } };
4902
+ }
4903
+ workflow = await runWorkflowScript({
4904
+ script: workflowScript,
4894
4905
  timeoutMs: timeout,
4895
4906
  signal: controller.signal,
4896
4907
  prompts: workflowPrompts,
@@ -4988,14 +4999,33 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4988
4999
  },
4989
5000
  status: async (keyOrRunId, workflowSignal) => workflowChildResult(keyOrRunId, await execute(randomUUID(), { action: "status", id: keyOrRunId }, workflowSignal, undefined, ctx, preserveActiveSession)),
4990
5001
  });
5002
+ const decision = resolveAutoRelaunchDecision(workflow?.value, relaunched, autoRelaunchCap);
5003
+ if (!decision.relaunch) {
5004
+ capReached = decision.capped;
5005
+ break;
5006
+ }
5007
+ relaunched += 1;
5008
+ status = { ...status, workflowRelaunchCount: relaunched };
5009
+ persist();
5010
+ appendWorkflowEvent({ type: "subagent.workflow.relaunched", relaunched });
5011
+ }
5012
+ if (!workflow) throw new Error("Workflow script produced no result.");
4991
5013
  const returnPreview = formatWorkflowValue(workflow.value).slice(0, 1_000);
4992
5014
  const emitPreview = workflow.emits.length > 0 ? ` Emitted: ${workflow.emits.map(formatWorkflowValue).join(", ").slice(0, 1_000)}` : "";
4993
- const summary = `Workflow completed with ${workflow.children.length} child run(s). Return: ${returnPreview}${emitPreview} Trace: ${workflow.trace.length} event(s).`;
5015
+ const relaunchNote = relaunched > 0 ? `Auto-relaunched ${relaunched} time(s) with a fresh fan-out budget. ` : "";
5016
+ const summary = `${relaunchNote}Workflow completed with ${workflow.children.length} child run(s). Return: ${returnPreview}${emitPreview} Trace: ${workflow.trace.length} event(s).`;
4994
5017
  const workflowUsage = sumResultsUsage(workflowResults);
4995
- status = { ...status, state: "complete", endedAt: Date.now(), workflow: { value: workflow.value, trace: workflow.trace, emits: workflow.emits, console: workflow.console }, totalTokens: { input: workflowUsage.input, output: workflowUsage.output, total: workflowUsage.input + workflowUsage.output }, totalCost: sumResultsCost(workflowResults) };
4996
- writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary, output: summary, results: workflow.children.map((child) => ({ workflowKey: child.key, ...(child.agent ? { agent: child.agent } : {}), ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
4997
- persist();
4998
- appendWorkflowEvent({ type: "subagent.workflow.completed", state: "complete" });
5018
+ if (capReached) {
5019
+ status = compactOptional<AsyncStatus>({ ...status, state: "failed", error: `Workflow auto-relaunch cap reached after ${relaunched} relaunch(es); the goal is not clean yet. The progress file is current; raise config.maxWorkflowAutoRelaunches or relaunch manually.`, endedAt: Date.now(), workflow: { value: workflow.value, trace: workflow.trace, emits: workflow.emits, console: workflow.console } });
5020
+ writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: false, state: "failed", summary: status.error, error: status.error, results: workflow.children.map((child) => ({ workflowKey: child.key, ...(child.agent ? { agent: child.agent } : {}), ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
5021
+ persist();
5022
+ appendWorkflowEvent({ type: "subagent.workflow.completed", state: "failed", error: status.error });
5023
+ } else {
5024
+ status = { ...status, state: "complete", endedAt: Date.now(), workflow: { value: workflow.value, trace: workflow.trace, emits: workflow.emits, console: workflow.console }, totalTokens: { input: workflowUsage.input, output: workflowUsage.output, total: workflowUsage.input + workflowUsage.output }, totalCost: sumResultsCost(workflowResults) };
5025
+ writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary, output: summary, results: workflow.children.map((child) => ({ workflowKey: child.key, ...(child.agent ? { agent: child.agent } : {}), ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
5026
+ persist();
5027
+ appendWorkflowEvent({ type: "subagent.workflow.completed", state: "complete" });
5028
+ }
4999
5029
  } catch (error) {
5000
5030
  const partial = error instanceof WorkflowScriptError ? error.partial : { trace: [], emits: [], console: [], children: [] };
5001
5031
  const stopped = controller.signal.aborted;
@@ -5326,7 +5356,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5326
5356
  }
5327
5357
  if (action === "children.list") {
5328
5358
  deps.state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
5329
- const children = listRetainedChildren(DIRS.async, deps.state.currentSessionId);
5359
+ const children = listRetainedChildren(DIRS.async, deps.state.sessionLineage?.length ? deps.state.sessionLineage : [deps.state.currentSessionId]);
5330
5360
  return {
5331
5361
  content: [{ type: "text", text: formatRetainedChildren(children) }],
5332
5362
  details: { mode: "management", results: [] },