pi-subagents 0.58.0 → 0.59.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 (105) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +89 -4
  8. package/docs/workflows.md +93 -2
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +1 -1
  12. package/skills/pi-subagents/SKILL.md +1 -1
  13. package/skills/pi-subagents/references/execution-controls.md +5 -1
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -2
  15. package/skills/pi-subagents/references/prompting-and-roles.md +24 -3
  16. package/src/agents/agent-management.ts +5 -17
  17. package/src/agents/agent-serializer.ts +4 -2
  18. package/src/agents/agents.ts +67 -26
  19. package/src/agents/runtime-agent-registry.ts +9 -16
  20. package/src/api/background-work.ts +5 -1
  21. package/src/api/delegation.ts +0 -7
  22. package/src/api/preflight.ts +6 -9
  23. package/src/extension/fanout-child.ts +5 -3
  24. package/src/extension/index.ts +51 -23
  25. package/src/extension/public-execution.ts +15 -2
  26. package/src/extension/schemas.ts +35 -10
  27. package/src/extension/tool-description.ts +18 -3
  28. package/src/intercom/result-intercom.ts +2 -0
  29. package/src/profiles/profiles.ts +5 -6
  30. package/src/runs/background/active-async-capacity.ts +2 -2
  31. package/src/runs/background/async-execution.ts +50 -35
  32. package/src/runs/background/async-job-tracker.ts +14 -12
  33. package/src/runs/background/async-resume.ts +23 -25
  34. package/src/runs/background/async-status-snapshot.ts +23 -261
  35. package/src/runs/background/async-status.ts +66 -7
  36. package/src/runs/background/chain-append.ts +6 -3
  37. package/src/runs/background/chain-root-attachment.ts +60 -8
  38. package/src/runs/background/fleet-view.ts +21 -11
  39. package/src/runs/background/notify.ts +158 -6
  40. package/src/runs/background/result-files.ts +2 -1
  41. package/src/runs/background/result-watcher.ts +2 -0
  42. package/src/runs/background/resume-guidance.ts +1 -1
  43. package/src/runs/background/retained-children.ts +1 -1
  44. package/src/runs/background/run-status.ts +18 -8
  45. package/src/runs/background/scheduled-runs.ts +86 -7
  46. package/src/runs/background/stale-run-reconciler.ts +10 -4
  47. package/src/runs/background/steering.ts +4 -14
  48. package/src/runs/background/subagent-runner.ts +346 -357
  49. package/src/runs/background/subagent-wait.ts +58 -10
  50. package/src/runs/background/terminal-run-index.ts +1 -1
  51. package/src/runs/background/wait-completions.ts +22 -1
  52. package/src/runs/background/wait-config.ts +23 -9
  53. package/src/runs/background/wait-tool.ts +9 -2
  54. package/src/runs/foreground/async-steering-action.ts +2 -2
  55. package/src/runs/foreground/execution.ts +114 -120
  56. package/src/runs/foreground/foreground-control.ts +3 -0
  57. package/src/runs/foreground/foreground-history.ts +1 -0
  58. package/src/runs/foreground/subagent-executor.ts +478 -239
  59. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  60. package/src/runs/shared/abort-recovery.ts +119 -0
  61. package/src/runs/shared/async-status-projection.ts +463 -0
  62. package/src/runs/shared/child-identity.ts +19 -4
  63. package/src/runs/shared/child-launch-plan.ts +151 -0
  64. package/src/runs/shared/completion-evidence.ts +89 -0
  65. package/src/runs/shared/completion-guard.ts +1 -1
  66. package/src/runs/shared/dynamic-fanout.ts +2 -2
  67. package/src/runs/shared/host-step-status.ts +230 -0
  68. package/src/runs/shared/lane-metadata.ts +105 -0
  69. package/src/runs/shared/mcp-config-sources.ts +42 -6
  70. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  71. package/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
  72. package/src/runs/shared/model-fallback.ts +9 -2
  73. package/src/runs/shared/nested-events.ts +6 -2
  74. package/src/runs/shared/nested-render.ts +7 -3
  75. package/src/runs/shared/parallel-handoff.ts +419 -7
  76. package/src/runs/shared/parallel-utils.ts +7 -0
  77. package/src/runs/shared/pi-args.ts +20 -2
  78. package/src/runs/shared/single-output.ts +27 -4
  79. package/src/runs/shared/subagent-prompt-runtime.ts +13 -4
  80. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  81. package/src/runs/shared/worktree.ts +18 -0
  82. package/src/shared/child-session-name.ts +46 -0
  83. package/src/shared/extension-context.ts +24 -0
  84. package/src/shared/formatters.ts +5 -2
  85. package/src/shared/launch-contract.ts +1 -1
  86. package/src/shared/settings.ts +9 -103
  87. package/src/shared/types.ts +198 -29
  88. package/src/shared/utils.ts +35 -55
  89. package/src/slash/delegation-adapters.ts +1 -8
  90. package/src/slash/delegation-request.ts +0 -4
  91. package/src/slash/slash-bridge.ts +1 -2
  92. package/src/slash/slash-commands.ts +369 -90
  93. package/src/slash/slash-live-state.ts +22 -11
  94. package/src/tui/fleet-status.ts +125 -74
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +316 -27
  97. package/src/watchdog/turn-delta.ts +1 -1
  98. package/src/workflows/chat-progress.ts +6 -3
  99. package/src/workflows/host-command.ts +230 -0
  100. package/src/workflows/scripted-workflow.ts +452 -38
  101. package/src/workflows/workflow-child-summary.ts +9 -5
  102. package/src/workflows/workflow-preflight.ts +270 -0
  103. package/src/workflows/workflow-receipt.ts +43 -4
  104. package/src/workflows/workflow-settlement.ts +246 -0
  105. package/src/runs/shared/turn-budget.ts +0 -98
@@ -1,18 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { DIRS, type AcceptanceInput, type AsyncStatus, type ResolvedTurnBudget, type SteeringRecoveryDescriptor, type SubagentRunMode } from "../../shared/types.ts";
3
+ import { DIRS, type AcceptanceInput, type AsyncStatus, type SteeringRecoveryDescriptor, type SubagentRunMode } from "../../shared/types.ts";
4
4
  import type { AgentConfig } from "../../agents/agents.ts";
5
5
  import { normalizeExtensionBindings } from "../shared/extension-bindings.ts";
6
+ import { normalizeWorkflowLaneMetadata } from "../shared/lane-metadata.ts";
6
7
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
7
8
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
8
9
  import { intersectSubagentCapabilityCeilings, parseSubagentCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
9
10
  import { validateRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
10
- import { resolveTurnBudgetConfig } from "../shared/turn-budget.ts";
11
11
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
12
12
  import { resultFilePath, resultPayloadPathForIndexedRun } from "./result-files.ts";
13
13
  import { canScanAsyncRunPrefix, MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH } from "./run-id-query.ts";
14
14
  import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/parallel-handoff.ts";
15
15
  import { intersectThinkingCeilings, parseThinkingLevel, type ThinkingLevel } from "../../shared/thinking-ceiling.ts";
16
+ import { assertWorkflowGraphHostSteps } from "../shared/host-step-status.ts";
16
17
 
17
18
  export interface AsyncResumeParams {
18
19
  id?: string;
@@ -40,6 +41,8 @@ export type AsyncResumeTarget = {
40
41
  state: AsyncStatus["state"];
41
42
  mode?: SubagentRunMode;
42
43
  agent: string;
44
+ /** Human-readable display name for the child session, when derived at launch. */
45
+ sessionName?: string;
43
46
  index: number;
44
47
  cwd?: string;
45
48
  sessionFile?: string;
@@ -63,11 +66,12 @@ interface AsyncResultFile {
63
66
  cwd?: string;
64
67
  sessionId?: string;
65
68
  sessionFile?: string;
69
+ sessionName?: string;
66
70
  model?: string;
67
71
  thinking?: string;
68
72
  launchContractDigest?: string;
69
73
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
70
- results?: Array<{ agent?: string; success?: boolean; sessionFile?: string; intercomTarget?: string; model?: string; thinking?: string; launchContractDigest?: string; capabilityCeiling?: ResolvedSubagentCapabilityCeiling }>;
74
+ results?: Array<{ agent?: string; sessionName?: string; success?: boolean; sessionFile?: string; intercomTarget?: string; model?: string; thinking?: string; launchContractDigest?: string; capabilityCeiling?: ResolvedSubagentCapabilityCeiling }>;
71
75
  }
72
76
 
73
77
  export interface AsyncRunLocation {
@@ -104,6 +108,7 @@ function validateResultFile(value: unknown, resultPath: string): AsyncResultFile
104
108
  const child = ensureObject(entry, `${resultPath} results[${index}]`);
105
109
  const agent = validateOptionalString(child, "agent", resultPath, `results[${index}].agent`);
106
110
  const sessionFile = validateOptionalString(child, "sessionFile", resultPath, `results[${index}].sessionFile`);
111
+ const sessionName = validateOptionalString(child, "sessionName", resultPath, `results[${index}].sessionName`);
107
112
  const intercomTarget = validateOptionalString(child, "intercomTarget", resultPath, `results[${index}].intercomTarget`);
108
113
  const model = validateOptionalString(child, "model", resultPath, `results[${index}].model`);
109
114
  const thinking = validateOptionalString(child, "thinking", resultPath, `results[${index}].thinking`);
@@ -111,7 +116,7 @@ function validateResultFile(value: unknown, resultPath: string): AsyncResultFile
111
116
  const capabilityCeiling = child.capabilityCeiling === undefined ? undefined : parseSubagentCapabilityCeiling(child.capabilityCeiling, `async result file '${resultPath}' results[${index}].capabilityCeiling`);
112
117
  const success = child.success;
113
118
  if (success !== undefined && typeof success !== "boolean") throw new Error(`Invalid async result file '${resultPath}': results[${index}].success must be a boolean.`);
114
- return { agent, sessionFile, intercomTarget, model, thinking, launchContractDigest, ...(capabilityCeiling ? { capabilityCeiling } : {}), ...(typeof success === "boolean" ? { success } : {}) };
119
+ return { agent, sessionName, sessionFile, intercomTarget, model, thinking, launchContractDigest, ...(capabilityCeiling ? { capabilityCeiling } : {}), ...(typeof success === "boolean" ? { success } : {}) };
115
120
  });
116
121
  }
117
122
  const success = data.success;
@@ -248,7 +253,7 @@ export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot:
248
253
  }
249
254
 
250
255
  function resultState(result: AsyncResultFile): AsyncStatus["state"] {
251
- if (result.state === "complete" || result.state === "failed" || result.state === "paused" || result.state === "stopped" || result.state === "running" || result.state === "queued") {
256
+ if (result.state === "complete" || result.state === "failed" || result.state === "partial" || result.state === "paused" || result.state === "stopped" || result.state === "running" || result.state === "queued") {
252
257
  return result.state;
253
258
  }
254
259
  return result.success ? "complete" : "failed";
@@ -257,6 +262,7 @@ function resultState(result: AsyncResultFile): AsyncStatus["state"] {
257
262
  function validateStatusForResume(status: AsyncStatus | null, source: string): void {
258
263
  if (!status) return;
259
264
  if (typeof status.runId !== "string") throw new Error(`Invalid async status '${source}': runId must be a string.`);
265
+ assertWorkflowGraphHostSteps(status.workflowGraph, source, status.runId);
260
266
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") throw new Error(`Invalid async status '${source}': sessionId must be a string.`);
261
267
  if (status.cwd !== undefined && typeof status.cwd !== "string") throw new Error(`Invalid async status '${source}': cwd must be a string.`);
262
268
  if (status.sessionFile !== undefined && typeof status.sessionFile !== "string") throw new Error(`Invalid async status '${source}': sessionFile must be a string.`);
@@ -268,6 +274,7 @@ function validateStatusForResume(status: AsyncStatus | null, source: string): vo
268
274
  const stepRecord = step as Record<string, unknown>;
269
275
  if (typeof stepRecord.agent !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].agent must be a string.`);
270
276
  if (stepRecord.sessionFile !== undefined && typeof stepRecord.sessionFile !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].sessionFile must be a string.`);
277
+ if (stepRecord.sessionName !== undefined && typeof stepRecord.sessionName !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].sessionName must be a string.`);
271
278
  if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].model must be a string.`);
272
279
  if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].thinking must be a string.`);
273
280
  if (stepRecord.thinkingCeiling !== undefined) stepRecord.thinkingCeiling = parseThinkingLevel(stepRecord.thinkingCeiling, `async status '${source}' steps[${index}].thinkingCeiling`);
@@ -283,23 +290,6 @@ function normalizeRecoveryAcceptance(value: unknown, descriptorPath: string): Ac
283
290
  return value as AcceptanceInput;
284
291
  }
285
292
 
286
- function normalizeRecoveryTurnBudget(value: unknown, descriptorPath: string): ResolvedTurnBudget {
287
- if (value && typeof value === "object" && !Array.isArray(value)) {
288
- const {
289
- outcome: _outcome,
290
- turnCount: _turnCount,
291
- wrapUpRequestedAtTurn: _wrapUpRequestedAtTurn,
292
- terminationDeferredAtTurn: _terminationDeferredAtTurn,
293
- exceededAtTurn: _exceededAtTurn,
294
- ...publicTurnBudget
295
- } = value as Record<string, unknown>;
296
- value = publicTurnBudget;
297
- }
298
- const result = resolveTurnBudgetConfig(value, "recoveryDescriptor.initialTurnBudget");
299
- if (result.error || !result.turnBudget) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${result.error ?? "recoveryDescriptor.initialTurnBudget is invalid."}`);
300
- return result.turnBudget;
301
- }
302
-
303
293
  export function asyncReviveRequiresRecoveryDescriptor(target: Pick<AsyncResumeTarget, "recoveryDescriptor" | "mode" | "sessionFile">): boolean {
304
294
  if (target.recoveryDescriptor) return false;
305
295
  return !(target.mode === "workflow" && Boolean(target.sessionFile));
@@ -324,11 +314,11 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
324
314
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
325
315
  const parsed = value as Record<string, unknown>;
326
316
  const allowedFields = new Set([
327
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "fallbackModels", "thinking", "thinkingCeiling", "tools", "extensions",
317
+ "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
328
318
  "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
329
319
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
330
320
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
331
- "launchResolvedExtensions", "runFanoutBudget",
321
+ "launchResolvedExtensions", "runFanoutBudget", "lane",
332
322
  "extensionBindings",
333
323
  ]);
334
324
  for (const field of Object.keys(parsed)) {
@@ -347,6 +337,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
347
337
  if (parsed.capabilityCeiling !== undefined) parsed.capabilityCeiling = parseSubagentCapabilityCeiling(parsed.capabilityCeiling, `async recovery descriptor '${descriptorPath}' capabilityCeiling`);
348
338
  if (parsed.thinkingCeiling !== undefined) parsed.thinkingCeiling = parseThinkingLevel(parsed.thinkingCeiling, `async recovery descriptor '${descriptorPath}' thinkingCeiling`);
349
339
  if (parsed.extensionBindings !== undefined) parsed.extensionBindings = normalizeExtensionBindings(parsed.extensionBindings)!.value;
340
+ if (parsed.lane !== undefined) parsed.lane = normalizeWorkflowLaneMetadata(parsed.lane, `Invalid async recovery descriptor '${descriptorPath}': lane`);
350
341
  if (parsed.agentContract !== undefined) {
351
342
  if (!parsed.agentContract || typeof parsed.agentContract !== "object" || Array.isArray(parsed.agentContract)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': agentContract must be an object.`);
352
343
  const contract = parsed.agentContract as Record<string, unknown>;
@@ -361,6 +352,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
361
352
  }
362
353
  if (parsed.inheritGlobalContext === undefined) parsed.inheritGlobalContext = parsed.inheritProjectContext;
363
354
  else if (typeof parsed.inheritGlobalContext !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': inheritGlobalContext must be a boolean.`);
355
+ if (parsed.allowNestedSubagents !== undefined && typeof parsed.allowNestedSubagents !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': allowNestedSubagents must be a boolean.`);
364
356
  if (!Number.isInteger(parsed.maxSubagentDepth) || (parsed.maxSubagentDepth as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': maxSubagentDepth must be a non-negative integer.`);
365
357
  for (const field of ["fallbackModels", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "skills", "skillPath"] as const) {
366
358
  const item = parsed[field];
@@ -379,7 +371,9 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
379
371
  }
380
372
  if (parsed.absoluteDeadlineAt !== undefined && (!Number.isFinite(parsed.absoluteDeadlineAt) || (parsed.absoluteDeadlineAt as number) <= 0)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': absoluteDeadlineAt must be a positive timestamp.`);
381
373
  if (parsed.initialTurnBudget !== undefined) {
382
- parsed.initialTurnBudget = normalizeRecoveryTurnBudget(parsed.initialTurnBudget, descriptorPath);
374
+ // Older descriptors may contain the removed turn-budget setting. Accept it
375
+ // for recovery compatibility, but do not restore or enforce it.
376
+ delete parsed.initialTurnBudget;
383
377
  }
384
378
  if (parsed.initialToolBudget !== undefined) {
385
379
  const result = validateToolBudgetConfig(parsed.initialToolBudget, "recoveryDescriptor.initialToolBudget");
@@ -492,6 +486,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
492
486
  state,
493
487
  ...(mode ? { mode } : {}),
494
488
  agent: selectedStep.agent,
489
+ ...(selectedStep.sessionName ?? result?.results?.[requestedIndex]?.sessionName ? { sessionName: selectedStep.sessionName ?? result?.results?.[requestedIndex]?.sessionName } : {}),
495
490
  index: requestedIndex,
496
491
  cwd: status?.cwd ?? result?.cwd,
497
492
  sessionFile: selectedStep.sessionFile ?? status?.sessionFile ?? result?.sessionFile,
@@ -523,6 +518,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
523
518
  state,
524
519
  ...(mode ? { mode } : {}),
525
520
  agent: selected.step.agent,
521
+ ...(selected.step.sessionName ?? result?.results?.[selected.index]?.sessionName ? { sessionName: selected.step.sessionName ?? result?.results?.[selected.index]?.sessionName } : {}),
526
522
  index: selected.index,
527
523
  cwd: status?.cwd ?? result?.cwd,
528
524
  sessionFile: selected.step.sessionFile ?? status?.sessionFile ?? result?.sessionFile,
@@ -568,6 +564,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
568
564
  state,
569
565
  ...(mode ? { mode } : {}),
570
566
  agent,
567
+ ...(statusSteps[index]?.sessionName ?? resultSteps[index]?.sessionName ? { sessionName: statusSteps[index]?.sessionName ?? resultSteps[index]?.sessionName } : {}),
571
568
  index,
572
569
  ...(resumeCwd ? { cwd: resumeCwd } : {}),
573
570
  ...(resolvedSessionFile ? { sessionFile: resolvedSessionFile } : {}),
@@ -591,6 +588,7 @@ export function applySteeringRecoveryAgentConfig(agentConfig: AgentConfig, descr
591
588
  thinking: descriptor.thinking,
592
589
  maxThinking: intersectThinkingCeilings(descriptor.thinkingCeiling, agentConfig.maxThinking),
593
590
  tools: descriptor.tools ? [...descriptor.tools] : undefined,
591
+ allowNestedSubagents: descriptor.allowNestedSubagents,
594
592
  extensions: descriptor.extensions ? [...descriptor.extensions] : undefined,
595
593
  subagentOnlyExtensions: descriptor.subagentOnlyExtensions ? [...descriptor.subagentOnlyExtensions] : undefined,
596
594
  mcpDirectTools: descriptor.mcpDirectTools ? [...descriptor.mcpDirectTools] : undefined,
@@ -1,268 +1,30 @@
1
- import { sanitizeDisplayText, truncateDisplayText } from "../../shared/display-text.ts";
2
- import type { AsyncJobState, AsyncJobStep, NestedRunSummary, NestedStepSummary, SubagentRunMode, SubagentState } from "../../shared/types.ts";
1
+ import type { AsyncJobState, SubagentState } from "../../shared/types.ts";
2
+ import {
3
+ projectAsyncStatusSnapshot,
4
+ type AsyncStatusSnapshotOptions,
5
+ type AsyncStatusSnapshotV1,
6
+ } from "../shared/async-status-projection.ts";
7
+
8
+ export {
9
+ ASYNC_STATUS_SNAPSHOT_KIND,
10
+ ASYNC_STATUS_SNAPSHOT_VERSION,
11
+ } from "../shared/async-status-projection.ts";
12
+ export type {
13
+ AsyncStatusSnapshotActivityV1,
14
+ AsyncStatusSnapshotCapsV1,
15
+ AsyncStatusSnapshotHostStepV1,
16
+ AsyncStatusSnapshotKind,
17
+ AsyncStatusSnapshotNodeV1,
18
+ AsyncStatusSnapshotOmittedV1,
19
+ AsyncStatusSnapshotOptions,
20
+ AsyncStatusSnapshotState,
21
+ AsyncStatusSnapshotV1,
22
+ } from "../shared/async-status-projection.ts";
3
23
 
4
- export const ASYNC_STATUS_SNAPSHOT_KIND = "pi-subagents.async-status-snapshot";
5
- export const ASYNC_STATUS_SNAPSHOT_VERSION = 1;
6
24
  export const ASYNC_STATUS_SNAPSHOT_WIDGET_PREFIX = "PI_SUBAGENT_ASYNC_JSON:";
7
25
 
8
- const DEFAULT_MAX_RUNS = 20;
9
- const DEFAULT_MAX_CHILDREN_PER_NODE = 8;
10
- const DEFAULT_MAX_DEPTH = 3;
11
- const DEFAULT_MAX_STRING_LENGTH = 160;
12
- const DEFAULT_MAX_SERIALIZED_BYTES = 32 * 1024;
13
-
14
- export type AsyncStatusSnapshotState = "queued" | "running" | "complete" | "failed" | "paused" | "stopped" | "rejected";
15
- export type AsyncStatusSnapshotKind = "subagent" | "workflow" | "step";
16
-
17
- export interface AsyncStatusSnapshotActivityV1 {
18
- state?: string;
19
- currentTool?: string;
20
- lastActivityAt?: number;
21
- currentToolStartedAt?: number;
22
- turnCount?: number;
23
- toolCount?: number;
24
- }
25
-
26
- export interface AsyncStatusSnapshotNodeV1 {
27
- id: string;
28
- kind: AsyncStatusSnapshotKind;
29
- label: string;
30
- state: AsyncStatusSnapshotState;
31
- startedAt?: number;
32
- updatedAt?: number;
33
- endedAt?: number;
34
- activity?: AsyncStatusSnapshotActivityV1;
35
- children?: AsyncStatusSnapshotNodeV1[];
36
- }
37
-
38
- export interface AsyncStatusSnapshotCapsV1 {
39
- maxRuns: number;
40
- maxChildrenPerNode: number;
41
- maxDepth: number;
42
- maxStringLength: number;
43
- maxSerializedBytes: number;
44
- }
45
-
46
- export interface AsyncStatusSnapshotOmittedV1 {
47
- runs: number;
48
- children: number;
49
- byteLimitExceeded: boolean;
50
- }
51
-
52
- export interface AsyncStatusSnapshotV1 {
53
- kind: typeof ASYNC_STATUS_SNAPSHOT_KIND;
54
- version: typeof ASYNC_STATUS_SNAPSHOT_VERSION;
55
- generatedAt: number;
56
- caps: AsyncStatusSnapshotCapsV1;
57
- omitted: AsyncStatusSnapshotOmittedV1;
58
- runs: AsyncStatusSnapshotNodeV1[];
59
- }
60
-
61
- export interface AsyncStatusSnapshotOptions {
62
- generatedAt?: number;
63
- maxRuns?: number;
64
- maxChildrenPerNode?: number;
65
- maxDepth?: number;
66
- maxStringLength?: number;
67
- maxSerializedBytes?: number;
68
- }
69
-
70
- interface BuildContext {
71
- caps: AsyncStatusSnapshotCapsV1;
72
- omitted: AsyncStatusSnapshotOmittedV1;
73
- }
74
-
75
- function resolveCaps(options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshotCapsV1 {
76
- return {
77
- maxRuns: Math.max(0, Math.floor(options.maxRuns ?? DEFAULT_MAX_RUNS)),
78
- maxChildrenPerNode: Math.max(0, Math.floor(options.maxChildrenPerNode ?? DEFAULT_MAX_CHILDREN_PER_NODE)),
79
- maxDepth: Math.max(0, Math.floor(options.maxDepth ?? DEFAULT_MAX_DEPTH)),
80
- maxStringLength: Math.max(0, Math.floor(options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH)),
81
- maxSerializedBytes: Math.max(256, Math.floor(options.maxSerializedBytes ?? DEFAULT_MAX_SERIALIZED_BYTES)),
82
- };
83
- }
84
-
85
- function publicText(value: unknown, fallback: string, maxLength: number): string {
86
- if (typeof value !== "string") return fallback;
87
- const normalized = sanitizeDisplayText(value.slice(0, Math.max(0, maxLength * 4)));
88
- return truncateDisplayText(normalized || fallback, maxLength);
89
- }
90
-
91
- function publicOptionalText(value: unknown, maxLength: number): string | undefined {
92
- if (typeof value !== "string") return undefined;
93
- const normalized = sanitizeDisplayText(value.slice(0, Math.max(0, maxLength * 4)));
94
- return normalized ? truncateDisplayText(normalized, maxLength) : undefined;
95
- }
96
-
97
- function publicTime(value: unknown): number | undefined {
98
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
99
- }
100
-
101
- function publicCount(value: unknown): number | undefined {
102
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
103
- }
104
-
105
- function normalizeState(value: string): AsyncStatusSnapshotState {
106
- if (value === "completed") return "complete";
107
- if (value === "pending") return "queued";
108
- return value as AsyncStatusSnapshotState;
109
- }
110
-
111
- function terminalState(state: AsyncStatusSnapshotState): boolean {
112
- return state === "complete" || state === "failed" || state === "paused" || state === "stopped" || state === "rejected";
113
- }
114
-
115
- function kindForMode(mode: SubagentRunMode | undefined): AsyncStatusSnapshotKind {
116
- return mode === "workflow" ? "workflow" : "subagent";
117
- }
118
-
119
- function labelForAgents(agents: readonly string[] | undefined, fallback: string, maxLength: number): string {
120
- if (!agents?.length) return publicText(fallback, fallback, maxLength);
121
- const visible = agents.slice(0, 3).map((agent) => publicText(agent, "agent", maxLength)).join(", ");
122
- const suffix = agents.length > 3 ? `, +${agents.length - 3} more` : "";
123
- return truncateDisplayText(`${visible}${suffix}`, maxLength);
124
- }
125
-
126
- function activityFor(source: {
127
- activityState?: unknown;
128
- currentTool?: unknown;
129
- lastActivityAt?: unknown;
130
- currentToolStartedAt?: unknown;
131
- turnCount?: unknown;
132
- toolCount?: unknown;
133
- }, ctx: BuildContext): AsyncStatusSnapshotActivityV1 | undefined {
134
- const activity: AsyncStatusSnapshotActivityV1 = {
135
- ...(typeof source.activityState === "string" ? { state: publicText(source.activityState, "unknown", ctx.caps.maxStringLength) } : {}),
136
- ...(publicOptionalText(source.currentTool, ctx.caps.maxStringLength) ? { currentTool: publicOptionalText(source.currentTool, ctx.caps.maxStringLength) } : {}),
137
- ...(publicTime(source.lastActivityAt) !== undefined ? { lastActivityAt: publicTime(source.lastActivityAt) } : {}),
138
- ...(publicTime(source.currentToolStartedAt) !== undefined ? { currentToolStartedAt: publicTime(source.currentToolStartedAt) } : {}),
139
- ...(publicCount(source.turnCount) !== undefined ? { turnCount: publicCount(source.turnCount) } : {}),
140
- ...(publicCount(source.toolCount) !== undefined ? { toolCount: publicCount(source.toolCount) } : {}),
141
- };
142
- return Object.keys(activity).length ? activity : undefined;
143
- }
144
-
145
- function appendBoundedChildren(children: AsyncStatusSnapshotNodeV1[], source: readonly AsyncStatusSnapshotNodeV1[], ctx: BuildContext): void {
146
- const remaining = Math.max(0, ctx.caps.maxChildrenPerNode - children.length);
147
- children.push(...source.slice(0, remaining));
148
- ctx.omitted.children += Math.max(0, source.length - remaining);
149
- }
150
-
151
- function buildStepNode(step: AsyncJobStep | NestedStepSummary, index: number, depth: number, ctx: BuildContext): AsyncStatusSnapshotNodeV1 {
152
- const state = normalizeState(step.status);
153
- const updatedAt = publicTime(step.endedAt) ?? publicTime(step.lastActivityAt) ?? publicTime(step.startedAt);
154
- const node: AsyncStatusSnapshotNodeV1 = {
155
- id: publicText("workflowKey" in step && step.workflowKey ? step.workflowKey : "runId" in step && step.runId ? step.runId : `step:${index}`, `step:${index}`, ctx.caps.maxStringLength),
156
- kind: "step",
157
- label: publicText("label" in step && step.label ? step.label : step.agent, "step", ctx.caps.maxStringLength),
158
- state,
159
- ...(publicTime(step.startedAt) !== undefined ? { startedAt: publicTime(step.startedAt) } : {}),
160
- ...(updatedAt !== undefined ? { updatedAt } : {}),
161
- ...(terminalState(state) && publicTime(step.endedAt) !== undefined ? { endedAt: publicTime(step.endedAt) } : {}),
162
- ...(activityFor(step, ctx) ? { activity: activityFor(step, ctx) } : {}),
163
- };
164
- if (depth < ctx.caps.maxDepth && step.children?.length) {
165
- const nested = step.children.map((child, childIndex) => buildNestedNode(child, childIndex, depth + 1, ctx));
166
- const bounded: AsyncStatusSnapshotNodeV1[] = [];
167
- appendBoundedChildren(bounded, nested, ctx);
168
- if (bounded.length) node.children = bounded;
169
- } else if (step.children?.length) {
170
- ctx.omitted.children += step.children.length;
171
- }
172
- return node;
173
- }
174
-
175
- function buildNestedNode(child: NestedRunSummary, index: number, depth: number, ctx: BuildContext): AsyncStatusSnapshotNodeV1 {
176
- const state = normalizeState(child.state);
177
- const updatedAt = publicTime(child.lastUpdate) ?? publicTime(child.endedAt) ?? publicTime(child.lastActivityAt) ?? publicTime(child.startedAt);
178
- const node: AsyncStatusSnapshotNodeV1 = {
179
- id: publicText(child.id, `nested:${index}`, ctx.caps.maxStringLength),
180
- kind: kindForMode(child.mode),
181
- label: child.agent ? publicText(child.agent, "subagent", ctx.caps.maxStringLength) : labelForAgents(child.agents, child.mode ?? "subagent", ctx.caps.maxStringLength),
182
- state,
183
- ...(publicTime(child.startedAt) !== undefined ? { startedAt: publicTime(child.startedAt) } : {}),
184
- ...(updatedAt !== undefined ? { updatedAt } : {}),
185
- ...(terminalState(state) && publicTime(child.endedAt) !== undefined ? { endedAt: publicTime(child.endedAt) } : {}),
186
- ...(activityFor(child, ctx) ? { activity: activityFor(child, ctx) } : {}),
187
- };
188
- if (depth < ctx.caps.maxDepth) {
189
- const nestedSteps = child.steps?.map((step, stepIndex) => buildStepNode(step, stepIndex, depth + 1, ctx)) ?? [];
190
- const nestedChildren = child.children?.map((nested, childIndex) => buildNestedNode(nested, childIndex, depth + 1, ctx)) ?? [];
191
- const bounded: AsyncStatusSnapshotNodeV1[] = [];
192
- appendBoundedChildren(bounded, [...nestedSteps, ...nestedChildren], ctx);
193
- if (bounded.length) node.children = bounded;
194
- } else {
195
- ctx.omitted.children += (child.steps?.length ?? 0) + (child.children?.length ?? 0);
196
- }
197
- return node;
198
- }
199
-
200
- function buildRunNode(job: AsyncJobState, ctx: BuildContext): AsyncStatusSnapshotNodeV1 {
201
- const state = normalizeState(job.status);
202
- const updatedAt = publicTime(job.updatedAt) ?? publicTime(job.startedAt);
203
- const node: AsyncStatusSnapshotNodeV1 = {
204
- id: publicText(job.asyncId, "async", ctx.caps.maxStringLength),
205
- kind: kindForMode(job.mode),
206
- label: labelForAgents(job.agents, job.mode ?? "subagent", ctx.caps.maxStringLength),
207
- state,
208
- ...(publicTime(job.startedAt) !== undefined ? { startedAt: publicTime(job.startedAt) } : {}),
209
- ...(updatedAt !== undefined ? { updatedAt } : {}),
210
- ...(terminalState(state) && updatedAt !== undefined ? { endedAt: updatedAt } : {}),
211
- ...(activityFor(job, ctx) ? { activity: activityFor(job, ctx) } : {}),
212
- };
213
- if (ctx.caps.maxDepth > 0) {
214
- const stepChildren = job.steps?.map((step, index) => buildStepNode(step, step.index ?? index, 1, ctx)) ?? [];
215
- const nestedChildren = job.nestedChildren?.map((child, index) => buildNestedNode(child, index, 1, ctx)) ?? [];
216
- const bounded: AsyncStatusSnapshotNodeV1[] = [];
217
- appendBoundedChildren(bounded, [...stepChildren, ...nestedChildren], ctx);
218
- if (bounded.length) node.children = bounded;
219
- } else {
220
- ctx.omitted.children += (job.steps?.length ?? 0) + (job.nestedChildren?.length ?? 0);
221
- }
222
- return node;
223
- }
224
-
225
- function snapshotBytes(snapshot: AsyncStatusSnapshotV1): number {
226
- return Buffer.byteLength(JSON.stringify(snapshot), "utf8");
227
- }
228
-
229
- function enforceByteLimit(snapshot: AsyncStatusSnapshotV1): void {
230
- if (snapshotBytes(snapshot) <= snapshot.caps.maxSerializedBytes) return;
231
- snapshot.omitted.byteLimitExceeded = true;
232
- const runs = snapshot.runs;
233
- const initialOmittedRuns = snapshot.omitted.runs;
234
- let lower = 0;
235
- let upper = Math.max(0, runs.length - 1);
236
- while (lower < upper) {
237
- const retained = Math.ceil((lower + upper) / 2);
238
- snapshot.runs = runs.slice(0, retained);
239
- snapshot.omitted.runs = initialOmittedRuns + runs.length - retained;
240
- if (snapshotBytes(snapshot) <= snapshot.caps.maxSerializedBytes) lower = retained;
241
- else upper = retained - 1;
242
- }
243
- snapshot.runs = runs.slice(0, lower);
244
- snapshot.omitted.runs = initialOmittedRuns + runs.length - lower;
245
- }
246
-
247
26
  export function buildAsyncStatusSnapshot(jobs: Iterable<AsyncJobState>, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshotV1 {
248
- const caps = resolveCaps(options);
249
- const ctx: BuildContext = { caps, omitted: { runs: 0, children: 0, byteLimitExceeded: false } };
250
- const sorted = [...jobs].sort((left, right) => {
251
- const leftUpdated = left.updatedAt ?? left.startedAt ?? 0;
252
- const rightUpdated = right.updatedAt ?? right.startedAt ?? 0;
253
- return rightUpdated - leftUpdated || left.asyncId.localeCompare(right.asyncId);
254
- });
255
- ctx.omitted.runs += Math.max(0, sorted.length - caps.maxRuns);
256
- const snapshot: AsyncStatusSnapshotV1 = {
257
- kind: ASYNC_STATUS_SNAPSHOT_KIND,
258
- version: ASYNC_STATUS_SNAPSHOT_VERSION,
259
- generatedAt: options.generatedAt ?? Date.now(),
260
- caps,
261
- omitted: ctx.omitted,
262
- runs: sorted.slice(0, caps.maxRuns).map((job) => buildRunNode(job, ctx)),
263
- };
264
- enforceByteLimit(snapshot);
265
- return snapshot;
27
+ return projectAsyncStatusSnapshot(jobs, options);
266
28
  }
267
29
 
268
30
  export function asyncStatusSnapshotJobsForState(state: SubagentState | undefined, sessionId: string | null | undefined): AsyncJobState[] {