pi-subagents 0.81.0 → 0.82.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.
Files changed (141) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bundle/agents.js +3 -3
  3. package/bundle/child-tool-plan.js +2 -2
  4. package/bundle/{chunk-LSRUTHT7.js → chunk-43SCHIMD.js} +1 -1
  5. package/bundle/{chunk-G5DKFLLI.js → chunk-4SDU2BZX.js} +15 -10
  6. package/bundle/{chunk-V2JWDB5U.js → chunk-52UBALJL.js} +2 -2
  7. package/bundle/{chunk-3DC5KBVP.js → chunk-5TYMSZX3.js} +1 -1
  8. package/bundle/{chunk-CEKS3TFO.js → chunk-7IVSUMQQ.js} +6 -6
  9. package/bundle/{chunk-KSWQVGDW.js → chunk-7PCKZHR5.js} +225 -18
  10. package/bundle/{chunk-563BHAZ5.js → chunk-7YMXOYEU.js} +33 -25
  11. package/bundle/{chunk-XMFLCKMQ.js → chunk-B5EKXDHE.js} +336 -130
  12. package/bundle/{chunk-ZBC44JJ3.js → chunk-CIZQXJ5Q.js} +22 -9
  13. package/bundle/{chunk-L5ZHE4L4.js → chunk-GC4DSPMH.js} +4 -4
  14. package/bundle/{chunk-ARRKBB2B.js → chunk-GJECHSAR.js} +2 -2
  15. package/bundle/{chunk-2ZSNH26H.js → chunk-M2CTFYT3.js} +7 -7
  16. package/bundle/{chunk-TUWLXTAM.js → chunk-MHZ4G23A.js} +681 -320
  17. package/bundle/{chunk-WSBNENFS.js → chunk-NGGAFDL6.js} +14 -4
  18. package/bundle/{chunk-VJHOELQP.js → chunk-NRQ75MGH.js} +160 -78
  19. package/bundle/{chunk-BJN5LUQY.js → chunk-OYF6AFDY.js} +1 -1
  20. package/bundle/{chunk-EYNJ6VL6.js → chunk-PLCCBLPY.js} +2 -2
  21. package/bundle/{chunk-74RT5YQE.js → chunk-RMQFMMAT.js} +123 -33
  22. package/bundle/{chunk-V6MR5TOQ.js → chunk-SIJ4S3EN.js} +25 -19
  23. package/bundle/{chunk-NNLASTQE.js → chunk-VH5OAWVG.js} +19 -5
  24. package/bundle/{chunk-BPS2AGBZ.js → chunk-W2SZ22EM.js} +93 -52
  25. package/bundle/{chunk-V3BIUPLU.js → chunk-WVD4UEL4.js} +256 -52
  26. package/bundle/{chunk-ZP3HODFH.js → chunk-XSWOTIA3.js} +1 -1
  27. package/bundle/{chunk-3IF2PGW7.js → chunk-YKEWIB4M.js} +1 -1
  28. package/bundle/{chunk-26C3WLYQ.js → chunk-ZLINIYTP.js} +497 -272
  29. package/bundle/{chunk-2XYWN7QJ.js → chunk-ZQGSDHL6.js} +2 -2
  30. package/bundle/control-channel.js +2 -2
  31. package/bundle/index.js +1 -1
  32. package/bundle/intercom-bridge.js +2 -2
  33. package/bundle/preflight.js +8 -8
  34. package/bundle/project-panes.js +3 -3
  35. package/docs/agents.md +31 -0
  36. package/docs/configuration.md +9 -1
  37. package/docs/tool-reference.md +3 -1
  38. package/docs/workflows.md +16 -0
  39. package/package.json +2 -2
  40. package/skills/pi-subagents/references/execution-controls.md +5 -4
  41. package/src/agents/agent-management.js +3 -3
  42. package/src/agents/agent-memory.js +3 -1
  43. package/src/agents/agents.js +6 -6
  44. package/src/agents/frontmatter.d.ts +2 -0
  45. package/src/agents/frontmatter.js +15 -1
  46. package/src/agents/skills.js +2 -1
  47. package/src/extension/config.js +3 -0
  48. package/src/extension/herdr-pi-bridge.js +17 -10
  49. package/src/extension/index.js +82 -2
  50. package/src/extension/schemas.js +3 -3
  51. package/src/intercom/native-supervisor-channel.js +20 -0
  52. package/src/missions/lifecycle.js +7 -1
  53. package/src/runs/background/active-async-capacity.d.ts +1 -0
  54. package/src/runs/background/active-async-capacity.js +9 -3
  55. package/src/runs/background/async-execution.js +20 -10
  56. package/src/runs/background/async-job-tracker.d.ts +6 -1
  57. package/src/runs/background/async-job-tracker.js +9 -4
  58. package/src/runs/background/async-status.d.ts +2 -0
  59. package/src/runs/background/async-status.js +21 -2
  60. package/src/runs/background/await-async-run.js +3 -7
  61. package/src/runs/background/boot-id.d.ts +12 -0
  62. package/src/runs/background/boot-id.js +47 -0
  63. package/src/runs/background/interrupted-elsewhere.d.ts +22 -0
  64. package/src/runs/background/interrupted-elsewhere.js +68 -0
  65. package/src/runs/background/notify.d.ts +2 -0
  66. package/src/runs/background/notify.js +13 -0
  67. package/src/runs/background/result-delivery-ownership.d.ts +6 -0
  68. package/src/runs/background/result-delivery-ownership.js +19 -1
  69. package/src/runs/background/result-watcher.d.ts +3 -1
  70. package/src/runs/background/result-watcher.js +24 -8
  71. package/src/runs/background/run-child-session.d.ts +1 -6
  72. package/src/runs/background/run-child-session.js +1 -1
  73. package/src/runs/background/run-status.js +2 -1
  74. package/src/runs/background/stale-run-reconciler.d.ts +25 -0
  75. package/src/runs/background/stale-run-reconciler.js +52 -13
  76. package/src/runs/background/subagent-runner-bootstrap.js +3 -0
  77. package/src/runs/background/subagent-runner.d.ts +4 -0
  78. package/src/runs/background/subagent-runner.js +13 -5
  79. package/src/runs/background/subagent-wait.d.ts +1 -1
  80. package/src/runs/background/subagent-wait.js +6 -1
  81. package/src/runs/background/terminal-run-index.d.ts +1 -0
  82. package/src/runs/background/terminal-run-index.js +4 -2
  83. package/src/runs/foreground/async-stop-action.js +3 -1
  84. package/src/runs/foreground/execution.js +6 -3
  85. package/src/runs/foreground/foreground-history.d.ts +16 -2
  86. package/src/runs/foreground/foreground-history.js +133 -31
  87. package/src/runs/foreground/subagent-executor.d.ts +11 -0
  88. package/src/runs/foreground/subagent-executor.js +277 -74
  89. package/src/runs/shared/child-launch.d.ts +2 -2
  90. package/src/runs/shared/child-session.d.ts +3 -2
  91. package/src/runs/shared/child-session.js +51 -8
  92. package/src/runs/shared/child-tool-plan.d.ts +7 -0
  93. package/src/runs/shared/child-tool-plan.js +21 -6
  94. package/src/runs/shared/herdr-connection.d.ts +17 -8
  95. package/src/runs/shared/herdr-connection.js +19 -7
  96. package/src/runs/shared/herdr-external-adapters.d.ts +3 -2
  97. package/src/runs/shared/herdr-external-adapters.js +1 -1
  98. package/src/runs/shared/herdr-machine.d.ts +12 -2
  99. package/src/runs/shared/herdr-machine.js +55 -9
  100. package/src/runs/shared/herdr-pane-host.d.ts +88 -0
  101. package/src/runs/shared/herdr-pane-host.js +155 -0
  102. package/src/runs/shared/herdr-placed-run.d.ts +27 -150
  103. package/src/runs/shared/herdr-placed-run.js +48 -395
  104. package/src/runs/shared/local-machine-access.d.ts +5 -0
  105. package/src/runs/shared/local-machine-access.js +28 -0
  106. package/src/runs/shared/orca-progress-tabs.d.ts +0 -1
  107. package/src/runs/shared/orca-progress-tabs.js +1 -24
  108. package/src/runs/shared/parallel-utils.d.ts +1 -1
  109. package/src/runs/shared/placed-run-owner.d.ts +49 -0
  110. package/src/runs/shared/placed-run-owner.js +93 -0
  111. package/src/runs/shared/placement-host.d.ts +99 -0
  112. package/src/runs/shared/placement-host.js +157 -0
  113. package/src/runs/shared/rex-machine.d.ts +25 -0
  114. package/src/runs/shared/rex-machine.js +83 -0
  115. package/src/runs/shared/rex-pane-host.d.ts +62 -0
  116. package/src/runs/shared/rex-pane-host.js +327 -0
  117. package/src/shared/completion-owner.d.ts +5 -0
  118. package/src/shared/completion-owner.js +5 -0
  119. package/src/shared/session-identity.d.ts +2 -0
  120. package/src/shared/session-identity.js +26 -0
  121. package/src/shared/types.d.ts +45 -6
  122. package/src/tui/fleet.d.ts +1 -0
  123. package/src/tui/fleet.js +24 -15
  124. package/src/tui/render.d.ts +6 -0
  125. package/src/tui/render.js +3 -3
  126. package/src/tui/subagent-messages.js +1 -0
  127. package/src/workflows/scripted-workflow.d.ts +1 -1
  128. package/src/workflows/workflow-auto-resume.d.ts +32 -0
  129. package/src/workflows/workflow-auto-resume.js +102 -0
  130. package/src/workflows/workflow-launch-record.d.ts +27 -0
  131. package/src/workflows/workflow-launch-record.js +48 -0
  132. package/src/workflows/workflow-resume-claim.d.ts +82 -0
  133. package/src/workflows/workflow-resume-claim.js +210 -0
  134. package/src/workflows/workflow-resume.d.ts +56 -0
  135. package/src/workflows/workflow-resume.js +106 -0
  136. package/src/workflows/workflow-reuse.d.ts +15 -9
  137. package/src/workflows/workflow-reuse.js +77 -14
  138. package/src/workflows/workflow-revival.d.ts +2 -0
  139. package/src/workflows/workflow-revival.js +2 -1
  140. package/src/workflows/workflow-stop-cause.d.ts +6 -0
  141. package/src/workflows/workflow-stop-cause.js +8 -0
@@ -73,10 +73,14 @@ import { loadConfig, resolveAsyncByDefault, resolveScheduledStoreRoot } from "./
73
73
  import { buildSubagentToolDescription, buildSubagentToolPromptMetadata } from "./tool-description.js";
74
74
  import { formatWorkflowPreflightSummary, normalizeWorkflowPreflight } from "../workflows/workflow-preflight.js";
75
75
  import { runtimeReplacedAbortReason } from "../workflows/workflow-reuse.js";
76
+ import { autoResumeFailureText, cancelPendingWorkflowAutoResumes, finishWorkflowAutoResume, planWorkflowAutoResumes, WORKFLOW_AUTO_RESUME_MESSAGE_TYPE } from "../workflows/workflow-auto-resume.js";
77
+ import { recordWorkflowResumeRefusal, releaseWorkflowResumeClaim, resumedRunStarted } from "../workflows/workflow-resume-claim.js";
78
+ import { workflowResumeRefusal } from "../workflows/workflow-resume.js";
76
79
  import { finalizeToolResult } from "./tool-result.js";
77
80
  import { flattenSubagentToolOptions } from "./subagent-options.js";
78
81
  import { collectGoalContinuationNotices } from "../missions/goal-driver.js";
79
82
  import { restoreForegroundRunHistory } from "../runs/foreground/foreground-history.js";
83
+ import { createInterruptedElsewhereCollector } from "../runs/background/interrupted-elsewhere.js";
80
84
  import { resolveMissionStoreLocation } from "../missions/store.js";
81
85
  import { listRetainedChildren } from "../runs/background/retained-children.js";
82
86
  import { DIRS, DEFAULT_ARTIFACT_CONFIG, SLASH_RESULT_TYPE, SLASH_TEXT_RESULT_TYPE, SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_ASYNC_STARTED_EVENT, SUBAGENT_PROCESS_TERMINAL_EVENT, SUBAGENT_CONTROL_EVENT, SUBAGENT_STEERING_NOTICE_EVENT, WIDGET_KEY, resolveMaxSubagentSpawnsPerSession, } from "../shared/types.js";
@@ -434,6 +438,7 @@ export default function registerSubagentExtension(pi) {
434
438
  refreshResultDelivery();
435
439
  },
436
440
  onJobCleanup: (asyncId) => deliveredRunIds.delete(asyncId),
441
+ deliveryOwnerId: resultDeliveryOwnership.deliveryOwnerId,
437
442
  supervisorRequestState: supervisorChannel.getSupervisorRequestState,
438
443
  onJobsChanged: programStatus.sync,
439
444
  });
@@ -597,6 +602,60 @@ export default function registerSubagentExtension(pi) {
597
602
  await waitForAdvertisement();
598
603
  return (await getExecutor()).executePublic(id, params, signal, onUpdate, ctx);
599
604
  };
605
+ // Names this runtime's planned auto-resumes in the process-global registry.
606
+ const autoResumeOwner = {};
607
+ const postAutoResumeFailure = (sourceRunId, refusal) => {
608
+ pi.sendMessage({ customType: WORKFLOW_AUTO_RESUME_MESSAGE_TYPE, content: autoResumeFailureText(sourceRunId, refusal), display: true }, { triggerTurn: false });
609
+ };
610
+ // Each session start claims its interrupted workflows synchronously, then launches them here, off
611
+ // session start. Cleanup and the next session start cancel the launches that have not started.
612
+ const launchWorkflowAutoResume = async (entry, ctx) => {
613
+ const { prepared } = entry;
614
+ try {
615
+ // Let the rest of session start (agent advertisement, other handlers) run first.
616
+ await new Promise((resolve) => setImmediate(resolve));
617
+ await waitForAdvertisement();
618
+ const executor = await getExecutor();
619
+ if (entry.state !== "pending")
620
+ return;
621
+ entry.state = "started";
622
+ const outcome = await executor.executeWorkflowResume(randomUUID(), prepared, new AbortController().signal, ctx);
623
+ // The executor has given back the claim and recorded any refusal; nothing below may replace them.
624
+ // A run launched by a runtime that was cleaned up meanwhile would keep running through its stale context.
625
+ if (outcome.launched && runtimeCleaned)
626
+ state.workflowControllers?.get(prepared.newRunId)?.abort(runtimeReplacedAbortReason());
627
+ // A cleaned-up runtime's pi can no longer post; the refusal stays in the run for its notice.
628
+ if (outcome.launched || runtimeCleaned)
629
+ return;
630
+ try {
631
+ postAutoResumeFailure(prepared.sourceRunId, outcome.refusal);
632
+ }
633
+ catch (error) {
634
+ console.error(`Failed to post the auto-resume failure of workflow ${prepared.sourceRunId}:`, error);
635
+ }
636
+ }
637
+ catch (error) {
638
+ // A cancelled launch already gave its claim back.
639
+ if (entry.state === "cancelled")
640
+ return;
641
+ const message = error instanceof Error ? error.message : String(error);
642
+ console.error(`Failed to auto-resume workflow ${prepared.sourceRunId}:`, error);
643
+ try {
644
+ if (resumedRunStarted(prepared.sourceAsyncDir, prepared.newRunId))
645
+ return;
646
+ releaseWorkflowResumeClaim(prepared.sourceAsyncDir, prepared.newRunId);
647
+ const refusal = workflowResumeRefusal("launch-failed", `launch failed: ${message}`, prepared.sourceRunId);
648
+ recordWorkflowResumeRefusal(prepared.sourceAsyncDir, { reason: refusal.reason, byHand: refusal.byHand, newRunId: prepared.newRunId });
649
+ postAutoResumeFailure(prepared.sourceRunId, refusal);
650
+ }
651
+ catch (cleanupError) {
652
+ console.error(`Failed to give back the auto-resume claim of workflow ${prepared.sourceRunId}:`, cleanupError);
653
+ }
654
+ }
655
+ finally {
656
+ finishWorkflowAutoResume(entry);
657
+ }
658
+ };
600
659
  const executeSubagentCollapsed = (id, params, signal, onUpdate, ctx) => {
601
660
  if (ctx.hasUI)
602
661
  ctx.ui.setToolsExpanded(false);
@@ -873,12 +932,31 @@ export default function registerSubagentExtension(pi) {
873
932
  resetJobs(ctx);
874
933
  logSlowPhase("reset-jobs", phaseStartedAt);
875
934
  phaseStartedAt = Date.now();
876
- restoreForegroundRunHistory(state, { resultsDir: DIRS.results });
935
+ // Runs other sessions in this folder left when their Pi died, from what this restore reads anyway.
936
+ const interruptedElsewhere = ctx.hasUI ? createInterruptedElsewhereCollector({ currentSessionId: state.currentSessionId, cwd: ctx.cwd, now: Date.now() }) : undefined;
937
+ restoreForegroundRunHistory(state, { resultsDir: DIRS.results, observeForeign: interruptedElsewhere?.observeForegroundRun });
877
938
  logSlowPhase("foreground-history", phaseStartedAt);
878
939
  phaseStartedAt = Date.now();
879
- restoreActiveJobs(ctx);
940
+ restoreActiveJobs(ctx, interruptedElsewhere?.observeStatus);
880
941
  logSlowPhase("active-job-restore", phaseStartedAt);
881
942
  phaseStartedAt = Date.now();
943
+ // Read-only and UI-only: never sent to the model, so prompts and history are unchanged.
944
+ if (interruptedElsewhere)
945
+ for (const line of interruptedElsewhere.lines())
946
+ ctx.ui.notify(line, "info");
947
+ logSlowPhase("cross-session-notice", phaseStartedAt);
948
+ // Before any stored result is delivered, so the interrupted run's notice can name its resume.
949
+ phaseStartedAt = Date.now();
950
+ // Launches an earlier session start of this runtime planned and has not started belong to that session.
951
+ cancelPendingWorkflowAutoResumes((entry) => entry.owner === autoResumeOwner);
952
+ // Not in a one-shot print run: its turn would then wait on the resumed workflow at settle.
953
+ if (recovering && ctx.hasUI && config.autoResume !== false) {
954
+ for (const entry of planWorkflowAutoResumes({ asyncDirRoot: DIRS.async, currentSessionId: state.currentSessionId, now: Date.now(), owner: autoResumeOwner })) {
955
+ void launchWorkflowAutoResume(entry, ctx);
956
+ }
957
+ }
958
+ logSlowPhase("workflow-auto-resume", phaseStartedAt);
959
+ phaseStartedAt = Date.now();
882
960
  scheduledRunManager.bindSession(ctx);
883
961
  logSlowPhase("scheduled-runs", phaseStartedAt);
884
962
  phaseStartedAt = Date.now();
@@ -903,6 +981,8 @@ export default function registerSubagentExtension(pi) {
903
981
  if (runtimeCleaned)
904
982
  return;
905
983
  runtimeCleaned = true;
984
+ // Synchronously, so a reload's new runtime finds these runs unclaimed when it scans.
985
+ cancelPendingWorkflowAutoResumes((entry) => entry.owner === autoResumeOwner);
906
986
  programStatus.dispose(shutdownReason);
907
987
  releaseHostSessionLiveness();
908
988
  releaseHostSessionLiveness = () => { };
@@ -205,7 +205,7 @@ const SubagentParamProperties = {
205
205
  description: "true: run the one ```js workflow block written in this same reply (false invalid). String with '/': script file read from request cwd. Other string: named workflow resource. Raw scripts (true or path) have no runs.host.",
206
206
  })),
207
207
  args: Type.Optional(Type.Unsafe({ type: "object", maxProperties: MAX_ARGS_FIELDS, additionalProperties: true, description: `Plain-JSON args for workflow; ${MAX_ARGS_FIELDS} fields/object, ${MAX_ARGS_ITEMS} items/array, depth ${MAX_ARGS_DEPTH}, ${MAX_ARGS_BYTES / 1024} KiB total; raw-script args are exposed deeply frozen and persisted, so do not include secrets.` })),
208
- reuseFrom: Type.Optional(Type.String({ minLength: 1, description: "Exact id of a workflow run stopped by a session handoff or reload; this async workflow reuses its finished children and re-attaches to its running ones." })),
208
+ reuseFrom: Type.Optional(Type.String({ minLength: 1, description: "Exact id of a workflow run ended by a reload, handoff or Pi exit; this async workflow reuses its finished children and re-attaches to its running ones." })),
209
209
  globalConcurrencyLimit: Type.Optional(Type.Integer({ minimum: 1 })),
210
210
  maxSubagentSpawnsPerRun: Type.Optional(Type.Integer({ minimum: 1 })),
211
211
  preflight: Type.Optional(WorkflowPreflightOverride),
@@ -227,7 +227,7 @@ const SubagentParamProperties = {
227
227
  usageBudget: Type.Optional(UsageBudgetOverride),
228
228
  agentScope: Type.Optional(Type.String({ description: "user/project/both (default); project wins collisions." })),
229
229
  cwd: Type.Optional(Type.String({ description: "Execution/project-pane directory." })),
230
- machine: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Herdr saved machine id or label; runs an external CLI agent there. cwd then means the directory on that machine." })),
230
+ machine: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Herdr saved machine id or label (runs an external CLI agent), a Rex host named in settings (runs a Pi child), or \"local\" (a Pi child in a Rex pane here). On another machine, cwd is a directory there." })),
231
231
  artifacts: Type.Optional(Type.Boolean({ description: "Debug artifacts; default true." })),
232
232
  includeProgress: Type.Optional(Type.Boolean({ description: "Full result progress; default false." })),
233
233
  share: Type.Optional(Type.Boolean({ description: "Upload session to GitHub Gist; default false." })),
@@ -306,6 +306,6 @@ const SubagentWaitParamsSchema = Type.Object({
306
306
  nonBlocking: Type.Optional(Type.Boolean({ description: "Subscribe to id's wake and return immediately; requires id, not with all." })),
307
307
  all: Type.Optional(Type.Boolean({ description: "Wait for all work active at call time." })),
308
308
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Max wait; default waitTool.defaultTimeoutMs, then 30 min." })),
309
- stopOnAttention: Type.Optional(Type.Boolean({ description: "false: keep waiting through idle or long-thinking attention." })),
309
+ stopOnAttention: Type.Optional(Type.Boolean({ description: "false: keep waiting through all attention except supervisor requests." })),
310
310
  });
311
311
  export const SubagentWaitParams = keepTopLevelParameterDescriptions(SubagentWaitParamsSchema);
@@ -113,6 +113,8 @@ async function waitForReply(channelDir, requestId, initialDeadline, signal) {
113
113
  if (fs.existsSync(file)) {
114
114
  const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
115
115
  if (parsed.type === "subagent.supervisor.reply" && parsed.requestId === requestId && typeof parsed.message === "string") {
116
+ if (typeof parsed.error === "string")
117
+ throw new Error(parsed.error);
116
118
  return parsed;
117
119
  }
118
120
  }
@@ -761,6 +763,24 @@ export function createNativeSupervisorChannel(pi, state, deps = {}) {
761
763
  cleanupRequestLifecycle(request, lifecycle);
762
764
  continue;
763
765
  }
766
+ if (request.expectsReply && runState(request).blockingWorkflowChildren?.has(request.runId)) {
767
+ // Pi cannot deliver an ask behind the workflow tool call that awaits this child.
768
+ // Return a tool error instead of renewing an unreachable ask until the run deadline.
769
+ try {
770
+ writePrivateAtomicJson(replyPath(channelDir, request.id), {
771
+ type: "subagent.supervisor.reply", requestId: request.id, createdAt: now, message: "",
772
+ error: "Supervisor unavailable: the parent is blocked in an async:false workflow and cannot answer this request. Report the blocker and any partial results, then stop. Relaunch the workflow with async:true to allow supervisor replies.",
773
+ });
774
+ }
775
+ catch (error) {
776
+ console.error(`Failed to report unreachable supervisor request ${request.id}; will retry:`, error);
777
+ continue;
778
+ }
779
+ seenFiles.add(file);
780
+ observeRequestLifecycle(request, "resolved");
781
+ cleanupRequestLifecycle(request, "resolved");
782
+ continue;
783
+ }
764
784
  seenFiles.add(file);
765
785
  if (!request.expectsReply) {
766
786
  // Progress is already visible through child activity; do not inject a
@@ -253,7 +253,13 @@ export function readMissionBinding(asyncDir) {
253
253
  const bindingPath = path.join(asyncDir, MISSION_BINDING_FILE);
254
254
  if (!fs.existsSync(bindingPath))
255
255
  return undefined;
256
- return parsePersistedBinding(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), bindingPath);
256
+ const raw = fs.readFileSync(bindingPath, "utf-8");
257
+ try {
258
+ return parsePersistedBinding(JSON.parse(raw), bindingPath);
259
+ }
260
+ catch (error) {
261
+ throw new Error(`Invalid mission binding file '${bindingPath}': ${error instanceof Error ? error.message : String(error)}`, { cause: error });
262
+ }
257
263
  }
258
264
  export function syncMissionFromAsyncCompletion(value) {
259
265
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -34,6 +34,7 @@ interface CapacityOptions {
34
34
  abandonedSlotReleaseAfterMs?: number | false;
35
35
  pidLiveness?: (pid: number) => PidLiveness;
36
36
  pidNamespaceScope?: () => string | undefined;
37
+ bootId?: () => string | undefined;
37
38
  afterSlotRename?: (releasedDir: string) => void;
38
39
  writeOwner?: (filePath: string, owner: ActiveAsyncCapacityOwner) => void;
39
40
  }
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  import { writePrivateAtomicJson } from "../../shared/atomic-json.js";
5
5
  import { TEMP_ROOT_DIR } from "../../shared/types.js";
6
6
  import { readStatus } from "../../shared/utils.js";
7
- import { checkPidLiveness } from "./stale-run-reconciler.js";
7
+ import { checkPidLiveness, checkRecordedPidLiveness } from "./stale-run-reconciler.js";
8
+ import { currentBootId } from "./boot-id.js";
8
9
  import { currentPidNamespaceScope } from "./pid-namespace.js";
9
10
  import { readProcessTerminal } from "./process-terminal.js";
10
11
  import { isTerminalAsyncState as terminalState, readWorkflowChildEvidence, readWorkflowChildProcessEvidence } from "./workflow-terminal-proof.js";
@@ -201,9 +202,14 @@ function abandonedRunnerReleaseVerdict(status, proofState, options, anyTerminalS
201
202
  if (typeof status.pid !== "number" || !Number.isInteger(status.pid) || status.pid < 1)
202
203
  return { state: "retained", reason: `${proofReason}; runner PID is missing or invalid` };
203
204
  // A PID from another namespace can look dead here while the runner is alive.
204
- if (status.pidNamespaceScope !== undefined && status.pidNamespaceScope !== (options.pidNamespaceScope ?? currentPidNamespaceScope)())
205
+ const observedScope = (options.pidNamespaceScope ?? currentPidNamespaceScope)();
206
+ if (status.pidNamespaceScope !== undefined && status.pidNamespaceScope !== observedScope)
205
207
  return { state: "retained", reason: `${proofReason}; runner PID namespace differs from this process, so liveness is unknown` };
206
- const liveness = (options.pidLiveness ?? checkPidLiveness)(status.pid);
208
+ const liveness = checkRecordedPidLiveness({ pid: status.pid, pidNamespaceScope: status.pidNamespaceScope, bootId: status.bootId }, {
209
+ observedScope,
210
+ observedBootId: (options.bootId ?? currentBootId)(),
211
+ probe: options.pidLiveness ?? ((pid) => checkPidLiveness(pid)),
212
+ });
207
213
  if (liveness !== "dead")
208
214
  return { state: "retained", reason: `${proofReason}; runner PID liveness is ${liveness}` };
209
215
  const lastActivityAt = status.lastActivityAt ?? status.lastUpdate ?? status.endedAt;
@@ -13,7 +13,7 @@ import { childCacheRetentionEnv } from "../../shared/child-cache-retention.js";
13
13
  import { buildEffectiveSystemPrompt } from "../shared/effective-system-prompt.js";
14
14
  import { currentCompletionOwnerId } from "../../shared/completion-owner.js";
15
15
  import { planChildLaunch, projectChainOutputSchemas, resolveStepBehavior, suppressProgressForReadOnlyTask } from "../shared/child-launch-plan.js";
16
- import { formatHerdrMachineRunnerUnsupported, resolveHerdrMachinePlacement } from "../shared/herdr-machine.js";
16
+ import { formatHerdrMachineRunnerUnsupported, resolveMachinePlacement } from "../shared/herdr-machine.js";
17
17
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/child-tool-plan.js";
18
18
  import { assertClaudeCodeModelScope, isClaudeCodeAdapterId, resolveClaudeCodeOverride } from "../shared/claude-code-adapter.js";
19
19
  import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.js";
@@ -51,8 +51,9 @@ import { usageBudgetState } from "../shared/usage-budget.js";
51
51
  import { finalizeProcessTerminal, initializeProcessTerminal, readProcessTerminal } from "./process-terminal.js";
52
52
  import { persistRunnerStartupFailure } from "./runner-startup-failure.js";
53
53
  import { statusStepDescription } from "./chain-append.js";
54
+ import { currentBootId, currentRecordedPid } from "./boot-id.js";
54
55
  import { currentPidNamespaceScope } from "./pid-namespace.js";
55
- import { checkPidLiveness } from "./stale-run-reconciler.js";
56
+ import { checkRecordedPidLiveness } from "./stale-run-reconciler.js";
56
57
  import { lookupRunnerLauncher, resolveAgentRunnerLauncher, runnerLauncherPlacementError } from "../shared/runner-launcher.js";
57
58
  import { SUBAGENT_PROCESS_TERMINAL_EVENT } from "../../shared/types.js";
58
59
  import { assertAgentAllowedByCapabilityCeiling, intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling } from "../shared/capability-ceiling.js";
@@ -432,7 +433,12 @@ function spawnRunner(cfg, suffix, cwd, initialStatus, initialStatusPath, launchP
432
433
  const runnerProcessInstanceId = randomUUID();
433
434
  const hasRevivalLease = typeof cfg.revivalLease === "object";
434
435
  const launchBarrierToken = hasRevivalLease ? undefined : runnerProcessInstanceId;
435
- const launchConfig = { ...cfg, runnerProcessInstanceId, ...(launchBarrierToken ? { launchBarrierToken } : {}) };
436
+ // A broker launcher may start the runner outside its own process, even under another kernel, so the
437
+ // spawned pid is only the wrapper's and only a direct runner shares Pi's boot id. The runner reports
438
+ // its own identity before it is allowed to proceed.
439
+ const launcher = cfg.launcher;
440
+ const bootId = launcher ? undefined : currentBootId();
441
+ const launchConfig = { ...cfg, runnerProcessInstanceId, ...(launchBarrierToken ? { launchBarrierToken } : {}), ...(bootId ? { bootId } : {}) };
436
442
  writePrivateAtomicJson(cfgPath, launchConfig);
437
443
  const command = binaryHost ?? nodeExecutable;
438
444
  const launchForStartup = launchConfig;
@@ -456,9 +462,6 @@ function spawnRunner(cfg, suffix, cwd, initialStatus, initialStatusPath, launchP
456
462
  fs.rmSync(startupConfirmPath, { force: true });
457
463
  if (startupProceedPath)
458
464
  fs.rmSync(startupProceedPath, { force: true });
459
- // A broker launcher may start the runner outside its own process, so the spawned pid is only the
460
- // wrapper's. The runner reports its own identity before it is allowed to proceed.
461
- const launcher = launchConfig.launcher;
462
465
  const identityPath = launcher && launchBarrierToken && launchAsyncDir ? path.join(launchAsyncDir, "runner-startup.json") : undefined;
463
466
  if (identityPath)
464
467
  fs.rmSync(identityPath, { force: true });
@@ -550,7 +553,8 @@ function spawnRunner(cfg, suffix, cwd, initialStatus, initialStatusPath, launchP
550
553
  }
551
554
  // The runner may outlive its wrapper; only a confirmed-dead runner in this pid namespace finalizes here,
552
555
  // otherwise status-pid reconciliation does it later.
553
- if (!runnerIdentity || runnerIdentity.pidNamespaceScope !== currentPidNamespaceScope() || checkPidLiveness(runnerIdentity.pid, process.kill, runnerIdentity.pidNamespaceScope !== undefined) !== "dead")
556
+ if (!runnerIdentity || runnerIdentity.pidNamespaceScope !== currentPidNamespaceScope()
557
+ || checkRecordedPidLiveness(runnerIdentity) !== "dead")
554
558
  return;
555
559
  }
556
560
  finalizeProcessTerminal(asyncDir, runId, {
@@ -612,7 +616,7 @@ function spawnRunner(cfg, suffix, cwd, initialStatus, initialStatusPath, launchP
612
616
  try {
613
617
  writePrivateAtomicJson(initialStatusPath, {
614
618
  ...initialStatus,
615
- ...(launcher ? {} : { pid: proc.pid, pidNamespaceScope: currentPidNamespaceScope() }),
619
+ ...(launcher ? {} : { pid: proc.pid, pidNamespaceScope: currentPidNamespaceScope(), ...(bootId ? { bootId } : {}) }),
616
620
  processTerminal: { version: 1, state: "pending", runId: initialStatus.runId, runnerProcessInstanceId },
617
621
  });
618
622
  // Aggregate waits must see the launch before the runner's first status update.
@@ -834,7 +838,7 @@ export function buildAsyncRunnerSteps(id, params) {
834
838
  let machineEnv;
835
839
  if (requestedMachine) {
836
840
  try {
837
- const placement = resolveHerdrMachinePlacement({ machine: requestedMachine, cwd: runnerCwd, stepCwd: s.cwd ?? params.machineCwd });
841
+ const placement = resolveMachinePlacement({ machine: requestedMachine, cwd: runnerCwd, stepCwd: s.cwd ?? params.machineCwd });
838
842
  machine = placement.machine;
839
843
  machineEnv = placement.env;
840
844
  }
@@ -1331,6 +1335,7 @@ export function executeAsyncChain(id, params) {
1331
1335
  }
1332
1336
  const initialStatusAt = Date.now();
1333
1337
  const initialCompletionOwnerId = ctx.completionOwnerId ?? currentCompletionOwnerId();
1338
+ const completionOwnerProcess = currentRecordedPid();
1334
1339
  const launchParentSessionId = ctx.parentSessionId ?? ctx.currentSessionId;
1335
1340
  try {
1336
1341
  writeRetainedRequiredChildExtensions(asyncDir, params.requiredExtensions ?? ctx.childRuntime?.requiredExtensions ?? resolveRequiredChildExtensions(launchParentSessionId ?? undefined));
@@ -1355,6 +1360,7 @@ export function executeAsyncChain(id, params) {
1355
1360
  asyncDir,
1356
1361
  sessionId: ctx.currentSessionId,
1357
1362
  completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1363
+ completionOwnerProcess,
1358
1364
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1359
1365
  piPackageRoot,
1360
1366
  childSessionFactoryModule: childSessionFactoryModule(),
@@ -1393,6 +1399,7 @@ export function executeAsyncChain(id, params) {
1393
1399
  runId: id,
1394
1400
  ...(ctx.currentSessionId ? { sessionId: ctx.currentSessionId } : {}),
1395
1401
  ...(initialCompletionOwnerId ? { completionOwnerId: initialCompletionOwnerId } : {}),
1402
+ completionOwnerProcess,
1396
1403
  mode: resultMode,
1397
1404
  state: "running",
1398
1405
  startedAt: initialStatusAt,
@@ -1620,7 +1627,7 @@ export function executeAsyncSingle(id, params) {
1620
1627
  let machineEnv;
1621
1628
  if (requestedMachine) {
1622
1629
  try {
1623
- const placement = resolveHerdrMachinePlacement({ machine: requestedMachine, cwd: runnerCwd, stepCwd: params.machineCwd });
1630
+ const placement = resolveMachinePlacement({ machine: requestedMachine, cwd: runnerCwd, stepCwd: params.machineCwd });
1624
1631
  machine = placement.machine;
1625
1632
  machineEnv = placement.env;
1626
1633
  }
@@ -1895,6 +1902,7 @@ export function executeAsyncSingle(id, params) {
1895
1902
  let spawnResultOrPromise = {};
1896
1903
  const initialStatusAt = Date.now();
1897
1904
  const initialCompletionOwnerId = ctx.completionOwnerId ?? currentCompletionOwnerId();
1905
+ const completionOwnerProcess = currentRecordedPid();
1898
1906
  const launchParentSessionId = ctx.parentSessionId ?? ctx.currentSessionId;
1899
1907
  try {
1900
1908
  spawnResultOrPromise = spawnRunner({
@@ -1977,6 +1985,7 @@ export function executeAsyncSingle(id, params) {
1977
1985
  asyncDir,
1978
1986
  sessionId: ctx.currentSessionId,
1979
1987
  completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1988
+ completionOwnerProcess,
1980
1989
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1981
1990
  piPackageRoot,
1982
1991
  childSessionFactoryModule: childSessionFactoryModule(),
@@ -2018,6 +2027,7 @@ export function executeAsyncSingle(id, params) {
2018
2027
  runId: id,
2019
2028
  ...(ctx.currentSessionId ? { sessionId: ctx.currentSessionId } : {}),
2020
2029
  ...(initialCompletionOwnerId ? { completionOwnerId: initialCompletionOwnerId } : {}),
2030
+ completionOwnerProcess,
2021
2031
  mode: "single",
2022
2032
  ...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
2023
2033
  state: "running",
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import { type AsyncJobState, type AsyncWidgetLayout, type ControlEvent, type SubagentState } from "../../shared/types.ts";
4
+ import type { RawDrainStatusObserver } from "../shared/readonly-drain-observation.ts";
5
+ import type { ResultDeliveryOwnership } from "./result-delivery-ownership.ts";
4
6
  interface AsyncJobTrackerOptions {
5
7
  completionRetentionMs?: number;
6
8
  /** Slow safety sweep for liveness repair when filesystem events are missed. */
@@ -12,6 +14,8 @@ interface AsyncJobTrackerOptions {
12
14
  platform?: NodeJS.Platform;
13
15
  onJobTerminal?: (job: AsyncJobState) => void;
14
16
  onJobCleanup?: (asyncId: string) => void;
17
+ /** Lets a run whose launching Pi exited hold its publication in the session that owns it. */
18
+ deliveryOwnerId?: ResultDeliveryOwnership["deliveryOwnerId"];
15
19
  watch?: typeof fs.watch;
16
20
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
17
21
  now?: () => number;
@@ -26,7 +30,8 @@ export declare function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">,
26
30
  handleStarted: (data: unknown) => void;
27
31
  handleComplete: (data: unknown) => void;
28
32
  resetJobs: (ctx?: ExtensionContext) => void;
29
- restoreActiveJobs: (ctx?: ExtensionContext) => void;
33
+ /** `observeStatus` also sees the statuses of other sessions' runs that the listing skips. */
34
+ restoreActiveJobs: (ctx?: ExtensionContext, observeStatus?: RawDrainStatusObserver) => void;
30
35
  dispose: () => void;
31
36
  };
32
37
  export {};
@@ -15,6 +15,7 @@ import { withWorkflowChildRunModel } from "./workflow-terminal-proof.js";
15
15
  import { validHostStepNodes } from "../shared/host-step-status.js";
16
16
  import { readProcessTerminal } from "./process-terminal.js";
17
17
  import { withCachedUiContext } from "../../shared/extension-context.js";
18
+ import { OWNING_SESSION_COMPLETION_OWNER } from "../../shared/completion-owner.js";
18
19
  const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
19
20
  const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
20
21
  const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
@@ -560,8 +561,11 @@ export function createAsyncJobTracker(pi, state, asyncDirRoot, options = {}) {
560
561
  if (isTerminalJobStatus(job.status)) {
561
562
  let publication = terminalPublications.get(job.asyncId);
562
563
  if (!publication && status.mode !== "workflow" && status.processTerminal?.state === "pending"
563
- && status.runId === job.asyncId && status.sessionId === state.currentSessionId
564
- && status.completionOwnerId && status.completionOwnerId === state.completionOwnerId) {
564
+ && status.runId === job.asyncId && status.completionOwnerId
565
+ && ((status.sessionId === state.currentSessionId && status.completionOwnerId === state.completionOwnerId)
566
+ // Also covers a session inherited after /new or /resume: deliveryOwnerId checks run ownership.
567
+ || (typeof status.sessionId === "string"
568
+ && options.deliveryOwnerId?.(status.sessionId, status.completionOwnerId, status.completionOwnerProcess, status.parentWorkflowRunId) === OWNING_SESSION_COMPLETION_OWNER))) {
565
569
  publication = { instanceId: status.processTerminal.runnerProcessInstanceId, pending: true };
566
570
  terminalPublications.set(job.asyncId, publication);
567
571
  }
@@ -883,7 +887,7 @@ export function createAsyncJobTracker(pi, state, asyncDirRoot, options = {}) {
883
887
  rerenderWidget(ctx, []);
884
888
  }
885
889
  };
886
- const restoreActiveJobs = (ctx) => {
890
+ const restoreActiveJobs = (ctx, observeStatus) => {
887
891
  if (ctx?.hasUI)
888
892
  state.lastUiContext = ctx;
889
893
  state.statusProjectionSessionId = null;
@@ -891,7 +895,8 @@ export function createAsyncJobTracker(pi, state, asyncDirRoot, options = {}) {
891
895
  return;
892
896
  let runs;
893
897
  try {
894
- runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], sessionId: state.currentSessionId, resultsDir, kill: options.kill, now: options.now });
898
+ // A workflow whose Pi exited is repaired here, in the session that owns it, which then delivers its notice.
899
+ runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], sessionId: state.currentSessionId, resultsDir, kill: options.kill, now: options.now, adoptCompletionOwnerId: state.completionOwnerId }, observeStatus);
895
900
  }
896
901
  catch (error) {
897
902
  console.error(`Failed to restore active async jobs from '${asyncDirRoot}':`, error);
@@ -142,6 +142,8 @@ interface AsyncRunListOptions {
142
142
  includeNested?: boolean;
143
143
  /** Explicit repair/debug escape hatch. Normal runtime paths must not set this. */
144
144
  repairScan?: boolean;
145
+ /** This process's completion owner, adopted by an owner-exited workflow repair of a run the session owns; needs `sessionId`. */
146
+ adoptCompletionOwnerId?: string;
145
147
  }
146
148
  type TargetedAsyncRunResolution = {
147
149
  kind: "exact";
@@ -37,6 +37,9 @@ function isAsyncStatusIsolationError(asyncDir, error) {
37
37
  || message.startsWith(`Invalid host step '${statusPath}`)
38
38
  || /^(workflowChildren|Invalid workflowChildren)/.test(message);
39
39
  }
40
+ // Status path -> last logged signature. A corrupt run is re-scanned on every
41
+ // status poll, so the line repeats only when the file or the outcome changes.
42
+ const reportedCorruptStatus = new Map();
40
43
  function isolateCorruptActiveRun(asyncDir, runId, error, now) {
41
44
  const statusPath = path.join(asyncDir, "status.json");
42
45
  const processTerminal = readProcessTerminal(asyncDir, { runId });
@@ -61,7 +64,20 @@ function isolateCorruptActiveRun(asyncDir, runId, error, now) {
61
64
  markerAction = `failed to release active marker: ${getErrorMessage(releaseError)}`;
62
65
  }
63
66
  }
64
- console.error(`[pi-subagents] Skipping corrupt active async run '${runId}' at '${statusPath}': ${getErrorMessage(error)}; ${markerAction}.`);
67
+ const line = `[pi-subagents] Skipping corrupt active async run '${runId}' at '${statusPath}': ${getErrorMessage(error)}; ${markerAction}.`;
68
+ let fileSignature = "";
69
+ try {
70
+ const stat = fs.statSync(statusPath);
71
+ fileSignature = `${stat.size}:${stat.mtimeMs}`;
72
+ }
73
+ catch {
74
+ // A missing file still gets the line once; its message carries the cause.
75
+ }
76
+ const signature = `${fileSignature}\n${line}`;
77
+ if (reportedCorruptStatus.get(statusPath) === signature)
78
+ return;
79
+ reportedCorruptStatus.set(statusPath, signature);
80
+ console.error(line);
65
81
  }
66
82
  function isNotFoundError(error) {
67
83
  return typeof error === "object"
@@ -425,16 +441,19 @@ export function listAsyncRuns(asyncDirRoot, options = {}, observeStatus) {
425
441
  let status;
426
442
  try {
427
443
  // Reconciliation can rewrite state; session-scoped discovery does not own foreign runs.
444
+ let owned = false;
428
445
  if (owners) {
429
446
  const stored = readStatus(asyncDir);
430
447
  if (stored && !owners.has(stored.sessionId)) {
431
448
  observeStatus?.(stored);
432
449
  continue;
433
450
  }
451
+ owned = stored !== null;
434
452
  }
453
+ // Only a run this session owns may adopt the caller's completion owner.
435
454
  const reconciliation = options.reconcile === false
436
455
  ? undefined
437
- : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now }, observeStatus);
456
+ : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now, adoptCompletionOwnerId: owned ? options.adoptCompletionOwnerId : undefined }, observeStatus);
438
457
  status = (reconciliation?.status ?? readStatus(asyncDir));
439
458
  if (options.reconcile === false)
440
459
  observeStatus?.(status);
@@ -2,9 +2,8 @@ import * as fs from "node:fs";
2
2
  import { readStatus } from "../../shared/utils.js";
3
3
  import { workflowAwaitedAsyncResultPath } from "./async-execution.js";
4
4
  import { waitForImportedAsyncRoot } from "./chain-root-attachment.js";
5
- import { currentPidNamespaceScope } from "./pid-namespace.js";
6
5
  import { resultPayloadPathForSessionRun, resultSnapshotReplaced, withResultRunLease } from "./result-files.js";
7
- import { checkPidLiveness } from "./stale-run-reconciler.js";
6
+ import { checkRecordedPidLiveness } from "./stale-run-reconciler.js";
8
7
  function errorMessage(error) {
9
8
  return error instanceof Error ? error.message : String(error);
10
9
  }
@@ -13,11 +12,8 @@ function runnerExitedWithoutResult(asyncDir, runId, resultPath) {
13
12
  const status = readStatus(asyncDir);
14
13
  if (!status || (status.state !== "running" && status.state !== "queued") || typeof status.pid !== "number")
15
14
  return false;
16
- const observedScope = currentPidNamespaceScope();
17
- if (status.pidNamespaceScope !== undefined && status.pidNamespaceScope !== observedScope)
18
- return false;
19
- const pidScopeVerified = status.pidNamespaceScope !== undefined && status.pidNamespaceScope === observedScope;
20
- if (checkPidLiveness(status.pid, undefined, pidScopeVerified) !== "dead")
15
+ // A pid from another namespace never reads as dead here, so the runner is not presumed gone.
16
+ if (checkRecordedPidLiveness({ pid: status.pid, pidNamespaceScope: status.pidNamespaceScope, bootId: status.bootId }) !== "dead")
21
17
  return false;
22
18
  return !fs.existsSync(resultPath) && !(status.sessionId && resultPayloadPathForSessionRun(asyncDir, status.sessionId, runId));
23
19
  }
@@ -0,0 +1,12 @@
1
+ import type { RecordedPid } from "./stale-run-reconciler.ts";
2
+ /**
3
+ * Identity of the current machine boot, read once per process. A pid recorded under a
4
+ * different boot id cannot still be running. Undefined when the platform has none.
5
+ */
6
+ export declare function currentBootId(): string | undefined;
7
+ /** This process as a recorded pid, without the fields the platform does not have. */
8
+ export declare function currentRecordedPid(): RecordedPid;
9
+ /** Fixes the boot id without reading the OS; runners take Pi's value from their config. */
10
+ export declare function primeBootId(value: string | undefined): void;
11
+ /** Clears the cache so the next read calls `nextReader` (default: the OS reader). */
12
+ export declare function resetBootIdForTesting(nextReader?: () => string | undefined): void;
@@ -0,0 +1,47 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import { currentPidNamespaceScope } from "./pid-namespace.js";
4
+ function readBootIdFromOs() {
5
+ try {
6
+ if (process.platform === "linux")
7
+ return fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || undefined;
8
+ if (process.platform === "darwin") {
9
+ return execFileSync("sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf-8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
10
+ }
11
+ }
12
+ catch {
13
+ // An unreadable boot id only means liveness falls back to probing the pid.
14
+ }
15
+ return undefined;
16
+ }
17
+ // Process-wide, so the bundled extension and the compiled runner modules share one value.
18
+ const BOOT_ID_KEY = Symbol.for("pi-subagents.boot-id.v1");
19
+ const runtime = globalThis;
20
+ let reader = readBootIdFromOs;
21
+ /**
22
+ * Identity of the current machine boot, read once per process. A pid recorded under a
23
+ * different boot id cannot still be running. Undefined when the platform has none.
24
+ */
25
+ export function currentBootId() {
26
+ const cached = runtime[BOOT_ID_KEY];
27
+ if (cached)
28
+ return cached.value;
29
+ const value = reader();
30
+ runtime[BOOT_ID_KEY] = { value };
31
+ return value;
32
+ }
33
+ /** This process as a recorded pid, without the fields the platform does not have. */
34
+ export function currentRecordedPid() {
35
+ const pidNamespaceScope = currentPidNamespaceScope();
36
+ const bootId = currentBootId();
37
+ return { pid: process.pid, ...(pidNamespaceScope ? { pidNamespaceScope } : {}), ...(bootId ? { bootId } : {}) };
38
+ }
39
+ /** Fixes the boot id without reading the OS; runners take Pi's value from their config. */
40
+ export function primeBootId(value) {
41
+ runtime[BOOT_ID_KEY] = { value };
42
+ }
43
+ /** Clears the cache so the next read calls `nextReader` (default: the OS reader). */
44
+ export function resetBootIdForTesting(nextReader) {
45
+ reader = nextReader ?? readBootIdFromOs;
46
+ delete runtime[BOOT_ID_KEY];
47
+ }
@@ -0,0 +1,22 @@
1
+ import type { ForegroundResumeRun } from "../../shared/types.ts";
2
+ import type { RawDrainStatusObserver } from "../shared/readonly-drain-observation.ts";
3
+ interface InterruptedElsewhereCollector {
4
+ /** Observer for the async statuses session start already reads; keeps foreign ones that may be interrupted. */
5
+ observeStatus: RawDrainStatusObserver;
6
+ /** Receives the foreground records of other sessions that session start already reads. */
7
+ observeForegroundRun: (run: ForegroundResumeRun) => void;
8
+ /** One line per other session that can be reopened and has runs whose Pi is dead. */
9
+ lines: () => string[];
10
+ }
11
+ /**
12
+ * Collects runs of other sessions in this folder that were cut off because their Pi died, from what
13
+ * session start reads anyway. Read-only: nothing is written to those runs or sessions. The cheap
14
+ * filters run as records arrive; `lines` reads each session file's header once, then probes each
15
+ * candidate's pid once.
16
+ */
17
+ export declare function createInterruptedElsewhereCollector(input: {
18
+ currentSessionId: string;
19
+ cwd: string;
20
+ now: number;
21
+ }): InterruptedElsewhereCollector;
22
+ export {};