pi-subagents 0.63.0 → 0.64.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.
@@ -27,6 +27,7 @@ import { normalizePublicSubagentExecution, validateWorkflowCapacityOverrides } f
27
27
  import { runSync } from "./execution.ts";
28
28
  import { handleWatchdogToolAction, WATCHDOG_TOOL_ACTIONS } from "../../watchdog/tool-actions.ts";
29
29
  import type { MainWatchdogRuntime } from "../../watchdog/runtime.ts";
30
+ import { applyWatchdogLaunchRules } from "../../watchdog/rules.ts";
30
31
  import { buildModelCandidates, normalizeParentModel, resolveEffectiveSubagentModel, resolveModelOrigin, type ModelOrigin, type ParentModel } from "../shared/model-fallback.ts";
31
32
  import { formatRetainedChildren, listRetainedChildren } from "../background/retained-children.ts";
32
33
  import { resolveModelScopesForAgent, type ModelScopeConfig } from "../shared/model-scope.ts";
@@ -2069,7 +2070,7 @@ async function resumeAsyncRun(input: {
2069
2070
  // place rather than allocating a second provider worktree around it.
2070
2071
  worktree: input.params.worktree === true && !("managedWorktree" in target && target.managedWorktree === true),
2071
2072
  lane: input.params.lane ?? recoveryDescriptor?.lane,
2072
- controlConfig: recoveryDescriptor?.controlConfig ?? resolveControlConfig(input.deps.config.control, input.params.control),
2073
+ controlConfig: resolveRevivalControlConfig({ globalConfig: input.deps.config.control, requestedControl: input.params.control, recoveryControlConfig: recoveryDescriptor?.controlConfig }),
2073
2074
  intercomBridge: input.params.intercomBridge ?? recoveryDescriptor?.intercomBridge,
2074
2075
  controlIntercomTarget: intercomBridge.active ? intercomBridge.orchestratorTarget : undefined,
2075
2076
  childIntercomTarget: intercomBridge.active ? (agent, index) => resolveSubagentIntercomTarget(runId, agent, index) : undefined,
@@ -3229,6 +3230,8 @@ async function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
3229
3230
  source: modelOrigin === "explicit" ? "explicit" : "inherited",
3230
3231
  });
3231
3232
  const modelOverrideFromParent = modelOrigin === "inherited";
3233
+ const launchRuleError = applyWatchdogLaunchRules({ cwd: effectiveCwd, agent: a.name, model: modelOverride ?? (parentModel && `${parentModel.provider}/${parentModel.id}`), warn: (violation) => deps.watchdog?.displayRuleWarning(violation) });
3234
+ if (launchRuleError) return toExecutionErrorResult(params, new Error(launchRuleError), data.contextPolicy.contextSummary);
3232
3235
  const asyncResult = executeAsyncSingle(id, compactOptional<Parameters<typeof executeAsyncSingle>[1]>({
3233
3236
  agent: params.agent!,
3234
3237
  task: shouldForkAgent(contextPolicy, params.agent!) ? wrapForkTask(params.task ?? "") : (params.task ?? ""),
@@ -3664,6 +3667,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3664
3667
  },
3665
3668
  );
3666
3669
  const modelOverrideFromParent = modelOrigin === "inherited";
3670
+ const launchRuleError = applyWatchdogLaunchRules({ cwd: effectiveCwd, agent: agentConfig.name, model: modelOverride ?? (parentModel && `${parentModel.provider}/${parentModel.id}`), warn: (violation) => deps.watchdog?.displayRuleWarning(violation) });
3671
+ if (launchRuleError) return toExecutionErrorResult(params, new Error(launchRuleError), data.contextPolicy.contextSummary);
3667
3672
  let skillOverride: string[] | false | undefined = normalizeSkillInput(params.skill);
3668
3673
  let readsOverride: string[] | false | undefined = params.reads;
3669
3674
  const rawOutput = params.output !== undefined ? params.output : agentConfig.output;
@@ -4438,6 +4443,7 @@ export function prepareWorkflowLaunchParams(
4438
4443
  if (output !== undefined && typeof output !== "string" && typeof output !== "boolean") throw new Error("output must be a path string or boolean.");
4439
4444
  const outputMode = Object.hasOwn(childParams, "outputMode") ? childParams.outputMode : workflowDefaults.outputMode;
4440
4445
  if (outputMode !== undefined && outputMode !== "inline" && outputMode !== "file-only") throw new Error("outputMode must be 'inline' or 'file-only'.");
4446
+ const control = mergeWorkflowControlOverrides(workflowDefaults.control, childParams.control as ControlConfig | undefined);
4441
4447
  return {
4442
4448
  action: "resume",
4443
4449
  id: childParams.resume.trim(),
@@ -4456,14 +4462,17 @@ export function prepareWorkflowLaunchParams(
4456
4462
  ...(options.missionDetached ? { mission: false } : {}),
4457
4463
  ...(timeoutMs !== undefined ? { timeoutMs: timeoutMs as number } : {}),
4458
4464
  ...(toolBudget !== undefined ? { toolBudget: toolBudget as ToolBudgetConfig } : {}),
4465
+ ...(control !== undefined ? { control } : {}),
4459
4466
  ...(intercomBridge !== undefined ? { intercomBridge: intercomBridge as IntercomBridgeConfig } : {}),
4460
4467
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
4461
4468
  };
4462
4469
  }
4470
+ const control = mergeWorkflowControlOverrides(workflowDefaults.control, childParams.control as ControlConfig | undefined);
4463
4471
  const launchParams = {
4464
4472
  ...workflowDefaults,
4465
4473
  async: options.externalAsyncRequired === true && childParams.async === undefined && workflowDefaults.async === undefined ? true : false,
4466
4474
  ...childParams,
4475
+ ...(control !== undefined ? { control } : {}),
4467
4476
  ...(options.externalAsyncRequired === true && childParams.async === undefined && workflowDefaults.async === undefined ? { workflowAwaitAsync: true } : {}),
4468
4477
  ...(options.missionDetached ? { mission: false } : {}),
4469
4478
  workflowParentRunId: parentWorkflowRunId,
@@ -4482,6 +4491,17 @@ export function prepareWorkflowLaunchParams(
4482
4491
  return normalizedGate.params;
4483
4492
  }
4484
4493
 
4494
+ function mergeWorkflowControlOverrides(workflowControl: ControlConfig | undefined, childControl: ControlConfig | undefined): ControlConfig | undefined {
4495
+ if (childControl === undefined) return workflowControl;
4496
+ if (workflowControl === undefined) return childControl;
4497
+ return { ...workflowControl, ...childControl };
4498
+ }
4499
+
4500
+ export function resolveRevivalControlConfig(input: { globalConfig?: ControlConfig; requestedControl?: ControlConfig; recoveryControlConfig?: ResolvedControlConfig }): ResolvedControlConfig {
4501
+ if (input.requestedControl === undefined) return input.recoveryControlConfig ?? resolveControlConfig(input.globalConfig, undefined);
4502
+ return resolveControlConfig(input.recoveryControlConfig ?? input.globalConfig, input.requestedControl);
4503
+ }
4504
+
4485
4505
  type GateParamsNormalizationResult =
4486
4506
  | { ok: true; params: SubagentParamsLike }
4487
4507
  | { ok: false; error: string };
@@ -21,7 +21,9 @@ import type {
21
21
  ResolvedAcceptanceGate,
22
22
  SingleResult,
23
23
  SubagentRunMode,
24
+ ChildWatchdogProgress,
24
25
  } from "../../shared/types.ts";
26
+ import { unresolvedChildWatchdogBlockers } from "../../watchdog/child-status.ts";
25
27
  import { isAgentContractV1 } from "./agent-contract.ts";
26
28
  import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
27
29
 
@@ -1360,6 +1362,7 @@ export async function evaluateAcceptance(input: {
1360
1362
  reportOptional?: boolean;
1361
1363
  artifactsDir?: string;
1362
1364
  runId?: string;
1365
+ watchdog?: ChildWatchdogProgress;
1363
1366
  }): Promise<AcceptanceLedger> {
1364
1367
  const acceptance = input.acceptance;
1365
1368
  const initialStatus = acceptance.level === "none" ? "not-required" : "claimed";
@@ -1375,6 +1378,13 @@ export async function evaluateAcceptance(input: {
1375
1378
  };
1376
1379
  if (acceptance.level === "none") return ledger;
1377
1380
 
1381
+ if (input.watchdog) {
1382
+ const unresolved = unresolvedChildWatchdogBlockers(input.watchdog);
1383
+ ledger.runtimeChecks.push(unresolved.length
1384
+ ? { id: "watchdog-blocker", status: "failed", message: `Unresolved watchdog blocker: ${unresolved[0]!.summary}` }
1385
+ : { id: "watchdog-blocker", status: "passed", message: "No unresolved watchdog blockers." });
1386
+ }
1387
+
1378
1388
  const parsed: AcceptanceReportParseResult = input.reportError
1379
1389
  ? { error: input.reportError }
1380
1390
  : input.report !== undefined
@@ -2,6 +2,7 @@ import { sanitizeDisplayText, truncateDisplayText } from "../../shared/display-t
2
2
  import { formatModelThinking } from "../../shared/formatters.ts";
3
3
  import type { AsyncJobState, AsyncJobStep, HostStepFreshnessV1, HostStepMonitorKind, HostStepNodeV1, HostStepState, HostStepVerdict, NestedRunSummary, NestedStepSummary, SubagentRunMode, WorkflowGraphSnapshot, WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../../shared/types.ts";
4
4
  import { HOST_STEP_MAX_COUNT, HOST_STEP_MAX_DETAIL_CHARS, HOST_STEP_MAX_LABEL_CHARS, HOST_STEP_MAX_PROVIDER_CHARS, HOST_STEP_MAX_REASON_CHARS, HOST_STEP_MAX_REF_CHARS, HOST_STEP_MAX_ROLE_CHARS, HOST_STEP_MAX_TARGET_CHARS, hostStepReportName, parseHostStepNode, validHostStepNodes } from "./host-step-status.ts";
5
+ import { workflowPreflightLaneForRuntimeKey } from "../../workflows/workflow-preflight.ts";
5
6
  import { workflowGraphStageNodes } from "./workflow-graph.ts";
6
7
 
7
8
  export const ASYNC_STATUS_SNAPSHOT_KIND = "pi-subagents.async-status-snapshot";
@@ -472,7 +473,7 @@ function projectWorkflowGraphRow(node: WorkflowGraphSnapshot["nodes"][number], p
472
473
  };
473
474
  }
474
475
 
475
- /** Project loaded workflow child facts plus stored preflight hints into compact rows. */
476
+ /** Project authoritative workflow facts into compact rows, annotated by preflight hints. */
476
477
  export function projectAsyncWorkflowRows(
477
478
  steps: readonly AsyncJobStep[] | undefined,
478
479
  hostStepsOrPreflight?: readonly HostStepNodeV1[] | WorkflowGraphSnapshot | WorkflowPreflightV1,
@@ -482,7 +483,8 @@ export function projectAsyncWorkflowRows(
482
483
  const graph = isWorkflowGraph(hostStepsOrPreflight) ? hostStepsOrPreflight : undefined;
483
484
  const hostSteps = isWorkflowPreflight(hostStepsOrPreflight) || graph ? undefined : hostStepsOrPreflight;
484
485
  const loaded = steps ?? [];
485
- const declared = new Map<string, WorkflowPreflightLaneV1>();
486
+ const preflightForKey = (key: string, groupKeys: readonly (string | undefined)[] = []): WorkflowPreflightLaneV1 | undefined =>
487
+ workflowPreflightLaneForRuntimeKey(preflight, key, groupKeys);
486
488
  if (graph) {
487
489
  const loadedIndexesByKey = new Map<string, number[]>();
488
490
  for (const [index, step] of loaded.entries()) {
@@ -493,7 +495,6 @@ export function projectAsyncWorkflowRows(
493
495
  }
494
496
  const consumed = new Set<number>();
495
497
  const childRows: AsyncStatusWorkflowRow[] = [];
496
- for (const lane of preflight?.lanes ?? []) declared.set(lane.key, lane);
497
498
  const graphStages = workflowGraphStageNodes(graph);
498
499
  const graphKeys = new Set(graphStages.map((node) => node.id));
499
500
  const graphPhaseByNodeId = new Map<string, string>();
@@ -502,60 +503,27 @@ export function projectAsyncWorkflowRows(
502
503
  if (graphKeys.has(nodeId) && !graphPhaseByNodeId.has(nodeId)) graphPhaseByNodeId.set(nodeId, phase.title);
503
504
  }
504
505
  }
505
- const graphLaneKeys = new Set(graphPhaseByNodeId.values());
506
- const preflightForNode = (node: WorkflowGraphSnapshot["nodes"][number]): WorkflowPreflightLaneV1 | undefined => {
507
- const phaseTitle = graphPhaseByNodeId.get(node.id);
508
- return declared.get(node.id) ?? (node.phase ? declared.get(node.phase) : undefined) ?? (phaseTitle ? declared.get(phaseTitle) : undefined);
509
- };
510
506
  for (const node of graphStages) {
507
+ const lane = preflightForKey(node.id, [node.phase, graphPhaseByNodeId.get(node.id)]);
511
508
  const indexes = (loadedIndexesByKey.get(node.id) ?? []).filter((index) => !consumed.has(index));
512
- if (indexes.length > 0) {
513
- for (const index of indexes) {
514
- consumed.add(index);
515
- childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, preflightForNode(node)));
516
- }
517
- } else {
518
- childRows.push(projectWorkflowGraphRow(node, preflightForNode(node)));
519
- }
520
- }
521
- for (const lane of preflight?.lanes ?? []) {
522
- if (graphKeys.has(lane.key) || graphLaneKeys.has(lane.key)) continue;
523
- const indexes = (loadedIndexesByKey.get(lane.key) ?? []).filter((index) => !consumed.has(index));
524
509
  if (indexes.length > 0) {
525
510
  for (const index of indexes) {
526
511
  consumed.add(index);
527
512
  childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, lane));
528
513
  }
529
514
  } else {
530
- childRows.push({ name: lane.key, state: "planned", preflight: lane });
515
+ childRows.push(projectWorkflowGraphRow(node, lane));
531
516
  }
532
517
  }
533
518
  for (const [index, step] of loaded.entries()) {
534
- if (!consumed.has(index)) childRows.push(projectLoadedWorkflowRow(step, index, step.workflowKey ? declared.get(step.workflowKey) : undefined));
519
+ if (!consumed.has(index)) childRows.push(projectLoadedWorkflowRow(step, index, step.workflowKey ? preflightForKey(step.workflowKey, [step.phase]) : undefined));
535
520
  }
536
521
  return [...childRows, ...validHostStepList(graph).map(hostStepRow)];
537
522
  }
538
- const loadedIndexByKey = new Map<string, number>();
539
- for (const [index, step] of loaded.entries()) {
540
- if (step.workflowKey !== undefined && !loadedIndexByKey.has(step.workflowKey)) loadedIndexByKey.set(step.workflowKey, index);
541
- }
542
- const consumed = new Set<number>();
543
- const childRows: AsyncStatusWorkflowRow[] = [];
544
- for (const lane of preflight?.lanes ?? []) {
545
- declared.set(lane.key, lane);
546
- const index = loadedIndexByKey.get(lane.key);
547
- if (index === undefined) {
548
- childRows.push({ name: lane.key, state: "planned", preflight: lane });
549
- continue;
550
- }
551
- consumed.add(index);
552
- childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, lane));
553
- }
554
- for (const [index, step] of loaded.entries()) {
555
- if (consumed.has(index)) continue;
556
- childRows.push(projectLoadedWorkflowRow(step, index, step.workflowKey ? declared.get(step.workflowKey) : undefined));
557
- }
558
- return [...childRows, ...validHostStepList(hostSteps).map(hostStepRow)];
523
+ return [
524
+ ...loaded.map((step, index) => projectLoadedWorkflowRow(step, index, step.workflowKey ? preflightForKey(step.workflowKey, [step.phase]) : undefined)),
525
+ ...validHostStepList(hostSteps).map(hostStepRow),
526
+ ];
559
527
  }
560
528
 
561
529
  function projectLoadedWorkflowRow(step: AsyncJobStep, index: number, preflight?: WorkflowPreflightLaneV1): AsyncStatusWorkflowRow {
@@ -47,7 +47,6 @@ import {
47
47
  } from "./tool-availability.ts";
48
48
  import {
49
49
  CHILD_WATCHDOG_CONFIG_ENV,
50
- encodeChildWatchdogConfig,
51
50
  type ChildWatchdogConfig,
52
51
  } from "../../watchdog/child-status.ts";
53
52
  import { WAIT_TOOL_DEFAULT_TIMEOUT_MS_ENV, WAIT_TOOL_ENABLED_ENV } from "../background/wait-config.ts";
@@ -1031,9 +1030,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
1031
1030
  const encodedToolBudget = encodeToolBudgetEnv(input.toolBudget);
1032
1031
  if (encodedToolBudget) env[TOOL_BUDGET_ENV] = encodedToolBudget;
1033
1032
  env[TOOL_BUDGET_ZERO_AUTH_ENV] = input.allowZeroToolBudget ? "1" : undefined;
1034
- const encodedChildWatchdog = encodeChildWatchdogConfig(input.childWatchdog);
1035
- if (encodedChildWatchdog)
1036
- env[CHILD_WATCHDOG_CONFIG_ENV] = encodedChildWatchdog;
1033
+ if (input.childWatchdog) env[CHILD_WATCHDOG_CONFIG_ENV] = JSON.stringify(input.childWatchdog);
1037
1034
 
1038
1035
  env[SUBAGENT_PARENT_SESSION_ENV] =
1039
1036
  input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
@@ -45,11 +45,12 @@ export function resolveControlConfig(
45
45
  const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
46
46
  const overrideNeedsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs);
47
47
  const globalNeedsAttentionAfterMs = parsePositiveInt(globalConfig?.needsAttentionAfterMs);
48
+ const globalNeedsAttentionAfterMsIsExplicit = (globalConfig as Partial<ResolvedControlConfig> | undefined)?.needsAttentionAfterMsIsExplicit ?? globalNeedsAttentionAfterMs !== undefined;
48
49
  const needsAttentionAfterMs = overrideNeedsAttentionAfterMs
49
50
  ?? globalNeedsAttentionAfterMs
50
51
  ?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
51
52
  const needsAttentionAfterMsIsExplicit = overrideNeedsAttentionAfterMs !== undefined
52
- || globalNeedsAttentionAfterMs !== undefined;
53
+ || globalNeedsAttentionAfterMsIsExplicit;
53
54
  const activeNoticeAfterMs = parsePositiveInt(override?.activeNoticeAfterMs)
54
55
  ?? parsePositiveInt(globalConfig?.activeNoticeAfterMs)
55
56
  ?? DEFAULT_CONTROL_CONFIG.activeNoticeAfterMs;
@@ -98,11 +99,12 @@ export function deriveActivityState(input: {
98
99
  config: ResolvedControlConfig;
99
100
  startedAt: number;
100
101
  lastActivityAt?: number;
102
+ turnCount?: number;
101
103
  currentTool?: string;
102
104
  thinking?: string | false;
103
105
  now?: number;
104
106
  }): ActivityState | undefined {
105
- if (!input.config.enabled || input.currentTool) return undefined;
107
+ if (!input.config.enabled || input.currentTool || (input.turnCount ?? 0) === 0) return undefined;
106
108
  const now = input.now ?? Date.now();
107
109
  const lastActivity = input.lastActivityAt ?? input.startedAt;
108
110
  const ageMs = Math.max(0, now - lastActivity);
@@ -15,6 +15,7 @@ import type { ThinkingLevel } from "./model-info.ts";
15
15
  import type { GlobalMissionIndexRecord, MissionRecord, MissionStoreConfig } from "../missions/types.ts";
16
16
  import type { ExtensionBindings } from "../runs/shared/extension-bindings.ts";
17
17
  import type { WorkflowChildPermitContext } from "./workflow-child-permit.ts";
18
+ import type { WatchdogWarningDetails } from "../watchdog/types.ts";
18
19
 
19
20
  // ============================================================================
20
21
  // Basic Types
@@ -894,13 +895,19 @@ export interface SubagentResultIntercomPayload {
894
895
  // Progress Tracking
895
896
  // ============================================================================
896
897
 
898
+ export interface ChildWatchdogWarningSummary extends Pick<WatchdogWarningDetails, "severity" | "category" | "summary" | "evidence" | "recommendedAction" | "displayedAt"> {
899
+ /** True when a later assistant turn in the child followed the warning. */
900
+ addressed: boolean;
901
+ stalemate: boolean;
902
+ }
903
+
897
904
  export interface ChildWatchdogProgress {
898
- phase: "idle" | "reviewing" | "autofollow" | "settling" | "stale" | "failed";
905
+ phase: "idle" | "reviewing" | "stale" | "failed";
899
906
  seq: number;
900
907
  lastUpdate: number;
901
- followUpPending: boolean;
902
908
  reason?: string;
903
909
  timedOut?: boolean;
910
+ warnings?: ChildWatchdogWarningSummary[];
904
911
  }
905
912
 
906
913
  export interface AgentProgress {
package/src/tui/render.ts CHANGED
@@ -7,6 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
8
8
  import { getMarkdownTheme, keyText, type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
10
+ import { unresolvedChildWatchdogBlockers } from "../watchdog/child-status.ts";
10
11
  import {
11
12
  type AgentProgress,
12
13
  type AsyncJobState,
@@ -360,6 +361,7 @@ function workflowStepPriority(step: AsyncJobStep, currentNodeId?: string): numbe
360
361
  || step.turnBudgetExceeded === true
361
362
  || step.activityState === "needs_attention"
362
363
  || step.watchdog?.phase === "stale"
364
+ || unresolvedChildWatchdogBlockers(step.watchdog).length > 0
363
365
  || gate !== undefined
364
366
  ) return 1;
365
367
  if (step.status === "pending") return 2;
@@ -505,6 +507,7 @@ function laneGate(step: AsyncJobStep | undefined): string | undefined {
505
507
  }
506
508
 
507
509
  function laneNextAction(state: AsyncLaneProjection["state"], step: AsyncJobStep | undefined, output: string | undefined, gate: string | undefined): string | undefined {
510
+ if (unresolvedChildWatchdogBlockers(step?.watchdog).length > 0) return "resolve watchdog blockers";
508
511
  if (step?.watchdog?.phase === "stale") return "inspect stale state";
509
512
  if (step?.toolBudgetBlocked === true || step?.turnBudgetExceeded === true) return "inspect blocked state";
510
513
  if (gate === "review blockers") return "resolve review blockers";
@@ -543,6 +546,7 @@ export function projectAsyncLane(job: AsyncJobState, ...args: [selectedStep?: As
543
546
  selectedStep?.activityState === "active_long_running" ? "long-running" : undefined,
544
547
  selectedStep?.activityState === "needs_attention" ? "attention" : undefined,
545
548
  selectedStep?.watchdog?.phase === "stale" ? "stale" : undefined,
549
+ unresolvedChildWatchdogBlockers(selectedStep?.watchdog).length > 0 ? `wd:${unresolvedChildWatchdogBlockers(selectedStep?.watchdog).length}` : undefined,
546
550
  selectedStep?.toolBudgetBlocked === true || selectedStep?.turnBudgetExceeded === true ? "blocked" : undefined,
547
551
  ].filter((chip): chip is string => Boolean(chip));
548
552
  const state = isTerminalLaneState(job.status) ? job.status : selectedStep?.status ?? job.status;
@@ -1001,6 +1005,7 @@ function widgetStepRenderKey(step: AsyncJobStep, index: number, expanded = false
1001
1005
  step.execution?.stopped,
1002
1006
  step.execution?.detached,
1003
1007
  step.watchdog?.phase,
1008
+ unresolvedChildWatchdogBlockers(step.watchdog).length,
1004
1009
  step.error,
1005
1010
  expanded ? expandedStepActivityRenderKey(step) : undefined,
1006
1011
  nestedRenderKey(step.children, expanded),
@@ -1,13 +1,15 @@
1
- import type { ResolvedWatchdogConfig, WatchdogLspConfig } from "./types.ts";
1
+ import type { ChildWatchdogProgress, ChildWatchdogWarningSummary } from "../shared/types.ts";
2
+ import { SUBAGENT_WATCHDOG_WARNING_TYPE, type ResolvedWatchdogConfig, type WatchdogCadenceConfig, type WatchdogCategory, type WatchdogLspConfig } from "./types.ts";
3
+
4
+ export const CHILD_WATCHDOG_WARNING_LIMIT = 20;
2
5
 
3
6
  export const CHILD_WATCHDOG_CONFIG_ENV = "PI_SUBAGENT_WATCHDOG_CHILD_CONFIG";
4
7
  export const CHILD_WATCHDOG_STATUS_EVENT = "subagent.watchdog.status";
5
8
 
6
- export const CHILD_WATCHDOG_PHASES = ["idle", "reviewing", "autofollow", "settling", "stale", "failed"] as const;
9
+ export const CHILD_WATCHDOG_PHASES = ["idle", "reviewing", "stale", "failed"] as const;
7
10
  export type ChildWatchdogPhase = typeof CHILD_WATCHDOG_PHASES[number];
8
11
 
9
12
  export interface ChildWatchdogConfig {
10
- enabled: boolean;
11
13
  runId?: string;
12
14
  agent?: string;
13
15
  childIndex?: number;
@@ -17,9 +19,9 @@ export interface ChildWatchdogConfig {
17
19
  model?: string;
18
20
  thinking?: string | false;
19
21
  lsp: WatchdogLspConfig;
20
- autoFollowBlockers: boolean;
21
- autoFollowMaxAttempts: number | null;
22
22
  stalemateRepeats: number;
23
+ /** Mid-run review cadence; everyNTools null means boundary reviews only. */
24
+ cadence: WatchdogCadenceConfig;
23
25
  }
24
26
 
25
27
  export interface ChildWatchdogStatusEvent {
@@ -31,18 +33,10 @@ export interface ChildWatchdogStatusEvent {
31
33
  seq: number;
32
34
  phase: ChildWatchdogPhase;
33
35
  ts: number;
34
- followUpPending: boolean;
35
36
  reason?: string;
36
37
  }
37
38
 
38
- export interface ChildWatchdogStateSnapshot {
39
- phase: ChildWatchdogPhase;
40
- seq: number;
41
- lastUpdate: number;
42
- followUpPending: boolean;
43
- reason?: string;
44
- timedOut?: boolean;
45
- }
39
+ export type ChildWatchdogStateSnapshot = ChildWatchdogProgress;
46
40
 
47
41
  export function resolveChildWatchdogConfig(input: {
48
42
  config: ResolvedWatchdogConfig;
@@ -55,8 +49,8 @@ export function resolveChildWatchdogConfig(input: {
55
49
  if (!enabled) return undefined;
56
50
  const model = override?.model ?? input.config.children.model;
57
51
  const thinking = override?.thinking ?? input.config.children.thinking;
52
+ const cadence = override?.cadence ?? input.config.children.cadence ?? input.config.cadence;
58
53
  return {
59
- enabled: true,
60
54
  ...(input.runId ? { runId: input.runId } : {}),
61
55
  ...(input.agent ? { agent: input.agent } : {}),
62
56
  ...(input.childIndex !== undefined ? { childIndex: input.childIndex } : {}),
@@ -66,16 +60,11 @@ export function resolveChildWatchdogConfig(input: {
66
60
  ...(model ? { model } : {}),
67
61
  ...(thinking !== undefined ? { thinking } : {}),
68
62
  lsp: { ...input.config.lsp },
69
- autoFollowBlockers: input.config.children.autoFollow.blockers,
70
- autoFollowMaxAttempts: input.config.children.autoFollow.maxAttempts,
71
- stalemateRepeats: input.config.children.autoFollow.stalemateRepeats,
63
+ stalemateRepeats: input.config.stalemateRepeats,
64
+ cadence: { everyNTools: cadence.everyNTools ?? null },
72
65
  };
73
66
  }
74
67
 
75
- export function encodeChildWatchdogConfig(config: ChildWatchdogConfig | undefined): string | undefined {
76
- return config ? JSON.stringify(config) : undefined;
77
- }
78
-
79
68
  function childConfigObject(value: unknown, field: string): Record<string, unknown> {
80
69
  if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
81
70
  throw new Error(`Invalid child watchdog config: ${field} must be an object.`);
@@ -108,10 +97,12 @@ function childConfigNullableNonNegativeInteger(input: Record<string, unknown>, f
108
97
  throw new Error(`Invalid child watchdog config: ${field} must be null or a non-negative integer.`);
109
98
  }
110
99
 
111
- function childConfigBoolean(input: Record<string, unknown>, field: string): boolean {
112
- const value = input[field];
113
- if (typeof value === "boolean") return value;
114
- throw new Error(`Invalid child watchdog config: ${field} must be a boolean.`);
100
+ function childConfigCadence(value: unknown): WatchdogCadenceConfig {
101
+ const input = childConfigObject(value, "cadence");
102
+ const everyNTools = input.everyNTools;
103
+ if (everyNTools === null) return { everyNTools: null };
104
+ if (typeof everyNTools === "number" && Number.isInteger(everyNTools) && everyNTools >= 5) return { everyNTools };
105
+ throw new Error("Invalid child watchdog config: cadence.everyNTools must be null or an integer >= 5.");
115
106
  }
116
107
 
117
108
  function childConfigLsp(value: unknown): WatchdogLspConfig {
@@ -138,7 +129,7 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
138
129
  if (!raw) return undefined;
139
130
  const parsed = childConfigObject(JSON.parse(raw), "root");
140
131
  if (parsed.enabled === false) return undefined;
141
- if (parsed.enabled !== true) throw new Error("Invalid child watchdog config: enabled must be true or false.");
132
+ if ("enabled" in parsed && parsed.enabled !== true) throw new Error("Invalid child watchdog config: enabled must be true or false.");
142
133
  const thinking = parsed.thinking;
143
134
  if (thinking !== undefined && typeof thinking !== "string" && thinking !== false) {
144
135
  throw new Error("Invalid child watchdog config: thinking must be a string or false.");
@@ -148,7 +139,6 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
148
139
  const childIndex = childConfigOptionalIndex(parsed, "childIndex");
149
140
  const model = childConfigOptionalString(parsed, "model");
150
141
  return {
151
- enabled: true,
152
142
  ...(runId ? { runId } : {}),
153
143
  ...(agent ? { agent } : {}),
154
144
  ...(childIndex !== undefined ? { childIndex } : {}),
@@ -158,9 +148,8 @@ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdo
158
148
  ...(model ? { model } : {}),
159
149
  ...(thinking !== undefined ? { thinking: thinking as string | false } : {}),
160
150
  lsp: childConfigLsp(parsed.lsp),
161
- autoFollowBlockers: childConfigBoolean(parsed, "autoFollowBlockers"),
162
- autoFollowMaxAttempts: childConfigNullableNonNegativeInteger(parsed, "autoFollowMaxAttempts"),
163
151
  stalemateRepeats: childConfigPositiveInteger(parsed, "stalemateRepeats"),
152
+ cadence: childConfigCadence(parsed.cadence),
164
153
  };
165
154
  }
166
155
 
@@ -173,14 +162,13 @@ export function isChildWatchdogStatusEvent(value: unknown): value is ChildWatchd
173
162
  && event.seq >= 0
174
163
  && typeof event.ts === "number"
175
164
  && Number.isFinite(event.ts)
176
- && typeof event.followUpPending === "boolean"
177
165
  && typeof event.phase === "string"
178
166
  && (CHILD_WATCHDOG_PHASES as readonly string[]).includes(event.phase);
179
167
  }
180
168
 
181
169
  export function childWatchdogIsActive(snapshot: ChildWatchdogStateSnapshot | undefined): boolean {
182
170
  if (!snapshot) return false;
183
- return snapshot.followUpPending || snapshot.phase === "reviewing" || snapshot.phase === "autofollow" || snapshot.phase === "settling";
171
+ return snapshot.phase === "reviewing";
184
172
  }
185
173
 
186
174
  export function acceptChildWatchdogEvent(input: {
@@ -199,7 +187,40 @@ export function acceptChildWatchdogEvent(input: {
199
187
  phase: input.event.phase,
200
188
  seq: input.event.seq,
201
189
  lastUpdate: input.event.ts,
202
- followUpPending: input.event.followUpPending,
203
190
  ...(input.event.reason ? { reason: input.event.reason } : {}),
191
+ ...(input.current?.warnings?.length ? { warnings: input.current.warnings } : {}),
204
192
  };
205
193
  }
194
+
195
+ function childWatchdogWarningFromMessage(message: unknown): ChildWatchdogWarningSummary | undefined {
196
+ const candidate = message as { role?: unknown; customType?: unknown; details?: Record<string, unknown> } | undefined;
197
+ if (candidate?.role !== "custom" || candidate.customType !== SUBAGENT_WATCHDOG_WARNING_TYPE) return undefined;
198
+ const details = candidate.details ?? {};
199
+ if (details.severity !== "concern" && details.severity !== "blocker") return undefined;
200
+ if (typeof details.summary !== "string" || typeof details.evidence !== "string" || typeof details.recommendedAction !== "string") return undefined;
201
+ return {
202
+ severity: details.severity,
203
+ category: details.category as WatchdogCategory,
204
+ summary: details.summary,
205
+ evidence: details.evidence,
206
+ recommendedAction: details.recommendedAction,
207
+ ...(typeof details.displayedAt === "string" ? { displayedAt: details.displayedAt } : {}),
208
+ addressed: false,
209
+ stalemate: details.state === "stalemate",
210
+ };
211
+ }
212
+
213
+ /** A watchdog warning message is appended; an assistant turn marks earlier warnings addressed. Undefined when unchanged. */
214
+ export function applyChildWatchdogMessage(current: ChildWatchdogStateSnapshot | undefined, message: unknown, now = Date.now()): ChildWatchdogStateSnapshot | undefined {
215
+ const warning = childWatchdogWarningFromMessage(message);
216
+ if (warning) {
217
+ const warnings = [...(current?.warnings ?? []), warning].slice(-CHILD_WATCHDOG_WARNING_LIMIT);
218
+ return current ? { ...current, warnings } : { phase: "idle", seq: 0, lastUpdate: now, warnings };
219
+ }
220
+ if ((message as { role?: unknown } | undefined)?.role !== "assistant" || !current?.warnings?.some((entry) => !entry.addressed)) return undefined;
221
+ return { ...current, warnings: current.warnings.map((entry) => entry.addressed ? entry : { ...entry, addressed: true }) };
222
+ }
223
+
224
+ export function unresolvedChildWatchdogBlockers(progress: Pick<ChildWatchdogProgress, "warnings"> | undefined): ChildWatchdogWarningSummary[] {
225
+ return (progress?.warnings ?? []).filter((warning) => warning.severity === "blocker" && (!warning.addressed || warning.stalemate));
226
+ }
@@ -0,0 +1,77 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as path from "node:path";
3
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
4
+ import { Type, type Static } from "typebox";
5
+
6
+ export const WATCHDOG_DIFF_TOOL_NAME = "watchdog_diff";
7
+ export const WATCHDOG_DIFF_MAX_CHARS = 24_000;
8
+ const MAX_UNTRACKED_FILES = 50;
9
+
10
+ export interface WatchdogDiffBaseline {
11
+ root: string;
12
+ ref: string;
13
+ }
14
+
15
+ const WatchdogDiffParams = Type.Object({
16
+ path: Type.Optional(Type.String({ description: "Restrict the diff to one file or directory, relative to the repo root." })),
17
+ stat: Type.Optional(Type.Boolean({ description: "Return per-file change counts instead of the full diff." })),
18
+ }, { additionalProperties: false });
19
+
20
+ type WatchdogDiffParams = Static<typeof WatchdogDiffParams>;
21
+
22
+ function runGit(root: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
23
+ const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf-8", maxBuffer: 16 * 1024 * 1024, windowsHide: true });
24
+ return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: (result.stderr ?? "").trim() };
25
+ }
26
+
27
+ /** HEAD at session start, so later child commits still show in the diff. */
28
+ export function captureWatchdogDiffBaseline(cwd: string): WatchdogDiffBaseline | undefined {
29
+ const toplevel = runGit(cwd, ["rev-parse", "--show-toplevel"]);
30
+ if (!toplevel.ok) return undefined;
31
+ const head = runGit(cwd, ["rev-parse", "HEAD"]);
32
+ if (!head.ok) return undefined;
33
+ const root = toplevel.stdout.trim();
34
+ const ref = head.stdout.trim();
35
+ return root && ref ? { root, ref } : undefined;
36
+ }
37
+
38
+ function validatePath(value: string | undefined): string | undefined {
39
+ const trimmed = value?.trim();
40
+ if (!trimmed) return undefined;
41
+ if (trimmed.startsWith("-")) throw new Error("watchdog_diff path must not start with '-'.");
42
+ if (path.isAbsolute(trimmed)) throw new Error("watchdog_diff path must be relative to the repo root.");
43
+ if (trimmed.split(/[\\/]/).includes("..")) throw new Error("watchdog_diff path must not contain '..'.");
44
+ return trimmed;
45
+ }
46
+
47
+ function bound(text: string): string {
48
+ if (text.length <= WATCHDOG_DIFF_MAX_CHARS) return text;
49
+ const marker = `\n\n[... ${text.length - WATCHDOG_DIFF_MAX_CHARS} characters omitted; call again with a narrower path ...]`;
50
+ return `${text.slice(0, WATCHDOG_DIFF_MAX_CHARS - marker.length)}${marker}`;
51
+ }
52
+
53
+ /** In a shared cwd, changes already pending when the session started also appear. */
54
+ export function createWatchdogDiffTool(baseline: WatchdogDiffBaseline): AgentTool<typeof WatchdogDiffParams, { chars: number }> {
55
+ return {
56
+ name: WATCHDOG_DIFF_TOOL_NAME,
57
+ label: "Watchdog diff",
58
+ description: "Show the repository diff since the review baseline, plus untracked file paths. Optional path narrows it; stat:true returns per-file counts only.",
59
+ parameters: WatchdogDiffParams,
60
+ executionMode: "sequential",
61
+ async execute(_toolCallId, params: WatchdogDiffParams) {
62
+ const pathFilter = validatePath(params.path);
63
+ const diff = runGit(baseline.root, ["diff", "--no-color", "--no-ext-diff", ...(params.stat === true ? ["--stat"] : []), baseline.ref, "--", ...(pathFilter ? [pathFilter] : [])]);
64
+ if (!diff.ok) throw new Error(`git diff failed: ${diff.stderr || "unknown error"}`);
65
+ const untrackedResult = runGit(baseline.root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...(pathFilter ? [pathFilter] : [])]);
66
+ const untracked = untrackedResult.ok ? untrackedResult.stdout.split("\0").filter(Boolean) : [];
67
+ const sections = [diff.stdout.trimEnd()];
68
+ if (untracked.length) {
69
+ const shown = untracked.slice(0, MAX_UNTRACKED_FILES);
70
+ sections.push(["Untracked files (use read to inspect):", ...shown.map((file) => ` ${file}`)].join("\n"));
71
+ if (untracked.length > shown.length) sections.push(`... ${untracked.length - shown.length} more untracked files`);
72
+ }
73
+ const text = bound(sections.filter(Boolean).join("\n\n")) || `No changes since baseline ${baseline.ref.slice(0, 12)}.`;
74
+ return { content: [{ type: "text", text }], details: { chars: text.length } };
75
+ },
76
+ };
77
+ }
@@ -88,7 +88,8 @@ export class WatchdogEmissionGuard {
88
88
  this.startModelUpdate();
89
89
  }
90
90
 
91
- evaluate(warning: WatchdogWarning): WatchdogEmissionDecision {
91
+ /** `allowRepeatOf`: one already-accepted identity that may repeat (boundary re-findings before stalemate). */
92
+ evaluate(warning: WatchdogWarning, options: { allowRepeatOf?: string } = {}): WatchdogEmissionDecision {
92
93
  if (isContentFree(warning.summary) || isContentFree(warning.evidence) || isContentFree(warning.recommendedAction)) {
93
94
  return { accepted: false, reason: "content-free" };
94
95
  }
@@ -102,8 +103,9 @@ export class WatchdogEmissionGuard {
102
103
  && warning.severity === "blocker";
103
104
  if (!updateEscalation) return { accepted: false, reason: "update-budget", identity, underlyingIdentity };
104
105
  }
105
- if (priorSeverity !== undefined && !escalation) return { accepted: false, reason: "duplicate", identity, underlyingIdentity };
106
- if (this.maxWarnings !== null && this.acceptedCount >= this.maxWarnings && !escalation) {
106
+ const repeat = priorSeverity !== undefined && options.allowRepeatOf === identity;
107
+ if (priorSeverity !== undefined && !escalation && !repeat) return { accepted: false, reason: "duplicate", identity, underlyingIdentity };
108
+ if (this.maxWarnings !== null && this.acceptedCount >= this.maxWarnings && !escalation && !repeat) {
107
109
  return { accepted: false, reason: "max-warnings", identity, underlyingIdentity };
108
110
  }
109
111
 
@@ -0,0 +1,20 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, getProjectConfigDir } from "../shared/utils.ts";
4
+
5
+ export const WATCHDOG_GUIDANCE_MAX_CHARS = 8_000;
6
+
7
+ function readOptional(filePath: string): string {
8
+ try {
9
+ return fs.readFileSync(filePath, "utf-8").trim();
10
+ } catch {
11
+ return "";
12
+ }
13
+ }
14
+
15
+ /** Read fresh on every review; project file first, then user file. */
16
+ export function loadWatchdogGuidance(cwd: string, enabled: boolean): string {
17
+ if (!enabled) return "";
18
+ const sections = [getProjectConfigDir(cwd), getAgentDir()].map((dir) => readOptional(path.join(dir, "WATCHDOG.md"))).filter(Boolean);
19
+ return sections.join("\n\n").slice(0, WATCHDOG_GUIDANCE_MAX_CHARS);
20
+ }