pi-subagents 0.35.0 → 0.36.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 (76) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +132 -24
  3. package/agents/advisor.md +73 -0
  4. package/package.json +8 -12
  5. package/skills/pi-subagents/SKILL.md +22 -9
  6. package/src/agents/agents.ts +22 -5
  7. package/src/api/delegation.ts +125 -0
  8. package/src/extension/config.ts +7 -1
  9. package/src/extension/index.ts +50 -38
  10. package/src/extension/rpc.ts +27 -2
  11. package/src/extension/schemas.ts +22 -2
  12. package/src/extension/tool-description.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +1 -1
  14. package/src/intercom/native-supervisor-channel.ts +45 -6
  15. package/src/intercom/result-intercom.ts +7 -0
  16. package/src/runs/background/async-execution.ts +34 -4
  17. package/src/runs/background/async-job-tracker.ts +4 -0
  18. package/src/runs/background/async-resume.ts +27 -5
  19. package/src/runs/background/async-status.ts +76 -3
  20. package/src/runs/background/chain-append.ts +2 -0
  21. package/src/runs/background/completion-batcher.ts +6 -4
  22. package/src/runs/background/completion-dedupe.ts +2 -11
  23. package/src/runs/background/fleet-view.ts +9 -4
  24. package/src/runs/background/notify.ts +132 -120
  25. package/src/runs/background/result-watcher.ts +138 -78
  26. package/src/runs/background/run-status.ts +3 -1
  27. package/src/runs/background/subagent-runner.ts +225 -43
  28. package/src/runs/background/subagent-wait.ts +130 -4
  29. package/src/runs/background/wait-tool.ts +2 -2
  30. package/src/runs/foreground/chain-execution.ts +176 -111
  31. package/src/runs/foreground/execution.ts +90 -36
  32. package/src/runs/foreground/foreground-control.ts +90 -0
  33. package/src/runs/foreground/subagent-executor.ts +394 -163
  34. package/src/runs/shared/acceptance.ts +55 -13
  35. package/src/runs/shared/agent-contract.ts +38 -0
  36. package/src/runs/shared/child-protocol.ts +1 -1
  37. package/src/runs/shared/completion-guard.ts +36 -5
  38. package/src/runs/shared/context-mode.ts +44 -0
  39. package/src/runs/shared/dynamic-fanout.ts +4 -4
  40. package/src/runs/shared/long-running-guard.ts +4 -0
  41. package/src/runs/shared/nested-events.ts +27 -2
  42. package/src/runs/shared/parallel-handoff.ts +154 -0
  43. package/src/runs/shared/parallel-utils.ts +6 -0
  44. package/src/runs/shared/pi-args.ts +23 -14
  45. package/src/runs/shared/run-history.ts +90 -5
  46. package/src/runs/shared/structured-output.ts +112 -7
  47. package/src/runs/shared/subagent-control.ts +4 -0
  48. package/src/runs/shared/subagent-prompt-runtime.ts +17 -18
  49. package/src/runs/shared/task-intent.ts +10 -5
  50. package/src/runs/shared/tool-availability.ts +3 -1
  51. package/src/runs/shared/tool-budget.ts +11 -5
  52. package/src/runs/shared/turn-budget.ts +2 -1
  53. package/src/runs/shared/worktree.ts +63 -14
  54. package/src/shared/accessible-dir.ts +25 -0
  55. package/src/shared/artifacts.ts +37 -7
  56. package/src/shared/atomic-json.ts +14 -42
  57. package/src/shared/child-transcript.ts +52 -0
  58. package/src/shared/file-system-retry.ts +47 -0
  59. package/src/shared/settings.ts +9 -1
  60. package/src/shared/types.ts +211 -22
  61. package/src/slash/delegation-adapters.ts +152 -5
  62. package/src/slash/delegation-json.ts +108 -0
  63. package/src/slash/delegation-request.ts +182 -36
  64. package/src/slash/prompt-template-bridge.ts +222 -37
  65. package/src/slash/selector.ts +147 -0
  66. package/src/slash/slash-commands.ts +14 -5
  67. package/src/slash/slash-live-state.ts +2 -2
  68. package/src/slash/subagents-admin.ts +42 -42
  69. package/src/tui/fleet-status.ts +362 -0
  70. package/src/tui/fleet-transcript.ts +472 -0
  71. package/src/tui/fleet.ts +318 -59
  72. package/src/tui/render.ts +25 -15
  73. package/src/watchdog/change-signature.ts +105 -12
  74. package/src/watchdog/review.ts +7 -2
  75. package/src/watchdog/runtime.ts +5 -3
  76. package/src/slash/subagents-editor.ts +0 -86
package/src/tui/render.ts CHANGED
@@ -23,6 +23,7 @@ import { getDisplayItems, getSingleResultOutput } from "../shared/utils.ts";
23
23
  import { flatToLogicalStepIndex } from "../runs/background/parallel-groups.ts";
24
24
  import { formatNestedAggregate } from "../runs/shared/nested-render.ts";
25
25
  import { aggregateStepStatus, formatActivityLabel, formatAgentRunningLabel, formatParallelOutcome } from "../shared/status-format.ts";
26
+ import { contextModeBadge, contextModePrefix } from "../runs/shared/context-mode.ts";
26
27
 
27
28
  type Theme = ExtensionContext["ui"]["theme"];
28
29
 
@@ -902,7 +903,7 @@ function foregroundStyleWidgetStepLines(
902
903
  const status = widgetStepStatus(step.status, theme);
903
904
  const stats = widgetStepStats(theme, step);
904
905
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
905
- const lines = [` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index - 1))} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
906
+ const lines = [` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index - 1))} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
906
907
  const activity = widgetStepActivityLine(step, width, expanded, job.updatedAt);
907
908
  if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`);
908
909
  for (const nestedLine of formatNestedWidgetLines(step.children, theme, width, expanded, job.updatedAt)) {
@@ -955,7 +956,7 @@ function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number,
955
956
  const title = `async subagent ${mode}${count && count > 1 ? ` (${count})` : ""}`;
956
957
  return [
957
958
  `${theme.fg("toolTitle", themeBold(theme, title))} ${theme.fg("dim", "· background")}`,
958
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, mode)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
959
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, mode)}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
959
960
  ...foregroundStyleWidgetDetails(job, theme, expanded, width),
960
961
  ].map((line) => truncLine(line, width));
961
962
  }
@@ -973,7 +974,7 @@ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: numbe
973
974
  const stepStats = widgetStepStats(theme, step);
974
975
  const activitySuffix = activity ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` : "";
975
976
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
976
- lines.push(` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index))} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
977
+ lines.push(` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index))} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
977
978
  for (const nestedLine of formatNestedWidgetLines(step.children, theme, width, false, job.updatedAt)) lines.push(` ${nestedLine}`);
978
979
  }
979
980
  if (job.steps.some((step) => step.status === "running")) lines.push(theme.fg("accent", ` ${liveDetailHintText()}`));
@@ -1113,7 +1114,7 @@ function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number): st
1113
1114
  const activity = widgetActivity(job);
1114
1115
  const status = job.status === "complete" ? "done" : job.status;
1115
1116
  const parts = [
1116
- themeBold(theme, widgetJobName(job)),
1117
+ `${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}`,
1117
1118
  theme.fg("dim", status),
1118
1119
  stats,
1119
1120
  activity && activity.toLowerCase() !== status ? theme.fg("dim", activity) : "",
@@ -1248,7 +1249,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1248
1249
  if (slots <= 0) { hiddenRunning++; continue; }
1249
1250
  const stats = widgetStats(job, theme);
1250
1251
  items.push([
1251
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1252
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1252
1253
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1253
1254
  ...widgetParallelAgentDetails(job, theme, expanded, width),
1254
1255
  ]);
@@ -1265,7 +1266,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1265
1266
  if (slots <= 0) { hiddenFinished++; continue; }
1266
1267
  const stats = widgetStats(job, theme);
1267
1268
  items.push([
1268
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1269
+ `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1269
1270
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1270
1271
  ...widgetParallelAgentDetails(job, theme, expanded, width),
1271
1272
  ]);
@@ -1313,7 +1314,7 @@ function renderSingleCompact(d: Details, r: Details["results"][number], theme: T
1313
1314
  const output = r.truncation?.text || getSingleResultOutput(r);
1314
1315
  const progress = r.progress || r.progressSummary;
1315
1316
  const isRunning = r.progress?.status === "running";
1316
- const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
1317
+ const contextBadge = contextModeBadge(theme, r.context ?? d.context);
1317
1318
  const stats = statJoin(theme, [
1318
1319
  r.usage?.turns ? `⟳ ${r.usage.turns}` : "",
1319
1320
  formatProgressStats(theme, progress),
@@ -1387,7 +1388,7 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1387
1388
  : paused
1388
1389
  ? theme.fg("warning", "■")
1389
1390
  : theme.fg("success", "✓");
1390
- const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
1391
+ const contextBadge = contextModeBadge(theme, d.context);
1391
1392
  const c = new Container();
1392
1393
  const width = getTermWidth() - 4;
1393
1394
  c.addChild(new Text(truncLine(`${glyph} ${theme.fg("toolTitle", theme.bold(d.mode))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
@@ -1430,7 +1431,7 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1430
1431
  const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning, progressRunningSeed(rProg), frame);
1431
1432
  const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : "";
1432
1433
  const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
1433
- const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
1434
+ const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${contextModeBadge(theme, r.context)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
1434
1435
  c.addChild(new Text(truncLine(` ${line}`, width), 0, 0));
1435
1436
  if (rRunning && rProg && "status" in rProg) {
1436
1437
  const activity = compactCurrentActivity(rProg);
@@ -1468,9 +1469,17 @@ export function renderSubagentResult(
1468
1469
  if (!d || !d.results.length) {
1469
1470
  const t = result.content[0];
1470
1471
  const text = t?.type === "text" ? t.text : "(no output)";
1471
- const contextPrefix = d?.context === "fork" ? `${theme.fg("warning", "[fork]")} ` : "";
1472
+ const contextPrefix = contextModePrefix(theme, d?.context);
1472
1473
  const width = getTermWidth() - 4;
1473
1474
  if (!text.includes("\n")) return new Text(truncLine(`${contextPrefix}${text}`, width), 0, 0);
1475
+ if (d && !options.expanded && !result.isError) {
1476
+ const lines = text.split(/\r?\n/);
1477
+ const firstNonEmptyLine = lines.find((line) => line.trim())?.trim() || "(no output)";
1478
+ const c = new Container();
1479
+ c.addChild(new Text(truncLine(`${contextPrefix}${firstNonEmptyLine} · ${lines.length} lines`, width), 0, 0));
1480
+ c.addChild(new Text(truncLine(theme.fg("accent", ` Press ${liveDetailKeyText()} for full output`), width), 0, 0));
1481
+ return c;
1482
+ }
1474
1483
  const c = new Container();
1475
1484
  const wrapped = wrapPlainText(`${contextPrefix}${text}`, width);
1476
1485
  for (const line of wrapped) c.addChild(new Text(line, 0, 0));
@@ -1491,7 +1500,7 @@ export function renderSubagentResult(
1491
1500
  : r.exitCode === 0
1492
1501
  ? theme.fg("success", "ok")
1493
1502
  : theme.fg("error", "failed");
1494
- const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
1503
+ const contextBadge = contextModeBadge(theme, r.context ?? d.context);
1495
1504
  const output = r.truncation?.text || getSingleResultOutput(r);
1496
1505
 
1497
1506
  const progressInfo = isRunning && r.progress
@@ -1638,7 +1647,7 @@ export function renderSubagentResult(
1638
1647
  const summaryStr = summaryParts.length ? ` | ${summaryParts.join(", ")}` : "";
1639
1648
 
1640
1649
  const modeLabel = d.mode;
1641
- const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
1650
+ const contextBadge = contextModeBadge(theme, d.context);
1642
1651
  const multiLabel = buildMultiProgressLabel(d, hasRunning);
1643
1652
  const itemTitle = multiLabel.itemTitle;
1644
1653
 
@@ -1661,7 +1670,7 @@ export function renderSubagentResult(
1661
1670
  : isCurrent && hasRunning
1662
1671
  ? theme.fg("warning", "running")
1663
1672
  : theme.fg("dim", "pending");
1664
- return `${stepIcon} ${agent}`;
1673
+ return `${stepIcon} ${agent}${contextModeBadge(theme, result?.context)}`;
1665
1674
  })
1666
1675
  .join(theme.fg("dim", " → "))
1667
1676
  : null;
@@ -1732,9 +1741,10 @@ export function renderSubagentResult(
1732
1741
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
1733
1742
  const modelDisplay = modelThinkingBadge(theme, r.model);
1734
1743
  const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
1744
+ const contextBadge = contextModeBadge(theme, r.context);
1735
1745
  const stepHeader = rRunning
1736
- ? `${statusIcon} ${stepLabel}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
1737
- : `${statusIcon} ${stepLabel}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
1746
+ ? `${statusIcon} ${stepLabel}: ${theme.bold(theme.fg("warning", r.agent))}${contextBadge}${modelDisplay}${stats}`
1747
+ : `${statusIcon} ${stepLabel}: ${theme.bold(r.agent)}${contextBadge}${modelDisplay}${stats}`;
1738
1748
  const toolCallLines = getToolCallLines(r, expanded);
1739
1749
  c.addChild(new Text(fit(stepHeader), 0, 0));
1740
1750
 
@@ -5,6 +5,30 @@ import * as path from "node:path";
5
5
 
6
6
  const IGNORED_CHANGE_PREFIXES = [".pi-subagents/", "tmp/", "node_modules/"];
7
7
  const IGNORED_CHANGE_PATHS = new Set([".pi-subagents", "tmp", "node_modules"]);
8
+ const IGNORED_CHANGE_SEGMENTS = new Set([".git", ".pi-subagents", "node_modules"]);
9
+
10
+ const DEFAULT_MAX_HASH_FILE_BYTES = 64 * 1024 * 1024;
11
+ const DEFAULT_MAX_HASH_TOTAL_BYTES = 64 * 1024 * 1024;
12
+ const DEFAULT_MAX_HASH_ENTRIES = 2_000;
13
+
14
+ function positiveEnvNumber(name: string, fallback: number): number {
15
+ const parsed = Number(process.env[name]);
16
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
17
+ }
18
+
19
+ // Read at call time (not module load) so tests can override env guards after
20
+ // this module is imported.
21
+ function maxHashFileBytes(): number {
22
+ return positiveEnvNumber("PI_SUBAGENTS_MAX_HASH_FILE_BYTES", DEFAULT_MAX_HASH_FILE_BYTES);
23
+ }
24
+
25
+ function maxHashTotalBytes(): number {
26
+ return positiveEnvNumber("PI_SUBAGENTS_MAX_HASH_TOTAL_BYTES", DEFAULT_MAX_HASH_TOTAL_BYTES);
27
+ }
28
+
29
+ function maxHashEntries(): number {
30
+ return positiveEnvNumber("PI_SUBAGENTS_MAX_HASH_ENTRIES", DEFAULT_MAX_HASH_ENTRIES);
31
+ }
8
32
 
9
33
  export interface WatchdogRepoChangeSignature {
10
34
  root: string;
@@ -24,15 +48,69 @@ function normalizeRelPath(value: string): string {
24
48
 
25
49
  function ignoredRelPath(relPath: string): boolean {
26
50
  const normalized = normalizeRelPath(relPath);
27
- return IGNORED_CHANGE_PATHS.has(normalized) || IGNORED_CHANGE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
51
+ return IGNORED_CHANGE_PATHS.has(normalized)
52
+ || IGNORED_CHANGE_PREFIXES.some((prefix) => normalized.startsWith(prefix))
53
+ || normalized.split("/").some((segment) => IGNORED_CHANGE_SEGMENTS.has(segment));
54
+ }
55
+
56
+ interface HashBudget {
57
+ entries: number;
58
+ bytes: number;
59
+ maxEntries: number;
60
+ maxBytes: number;
61
+ }
62
+
63
+ function useHashEntryBudget(budget: HashBudget): boolean {
64
+ if (budget.entries >= budget.maxEntries) return false;
65
+ budget.entries++;
66
+ return true;
28
67
  }
29
68
 
30
69
  function hashFile(filePath: string): string {
31
70
  return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
32
71
  }
33
72
 
34
- function hashPath(root: string, relPath: string): unknown {
73
+ function largeFileHash(stat: fs.Stats): string {
74
+ return "large:" + stat.size + ":" + Math.floor(stat.mtimeMs);
75
+ }
76
+
77
+ function hashFileEntry(normalized: string, fullPath: string, stat: fs.Stats, budget: HashBudget): unknown {
78
+ let hash: string;
79
+ if (stat.size > maxHashFileBytes() || budget.bytes + stat.size > budget.maxBytes) {
80
+ hash = largeFileHash(stat);
81
+ } else {
82
+ try {
83
+ hash = hashFile(fullPath);
84
+ budget.bytes += stat.size;
85
+ } catch (error) {
86
+ const code = (error as NodeJS.ErrnoException).code;
87
+ // A file racing away between lstat and read: mirror the lstat ENOENT path.
88
+ if (code === "ENOENT") return { path: normalized, state: "deleted" };
89
+ // Any other read failure (too-large, EACCES, EISDIR, ...) degrades to the
90
+ // metadata marker so one unreadable file never discards the whole signature.
91
+ hash = largeFileHash(stat);
92
+ if (code !== "ERR_FS_FILE_TOO_LARGE") {
93
+ console.warn("[pi-subagents] watchdog hashFile fell back to metadata for", normalized + ":", (error as Error)?.message);
94
+ }
95
+ }
96
+ }
97
+ return { path: normalized, state: "file", mode: stat.mode & 0o777, size: stat.size, hash };
98
+ }
99
+
100
+ function gitWorktreeEntry(normalized: string, fullPath: string): unknown {
101
+ const status = git(fullPath, ["status", "--porcelain=v1", "-z", "--untracked-files=no"]);
102
+ return {
103
+ path: normalized,
104
+ state: "git-worktree",
105
+ head: git(fullPath, ["rev-parse", "HEAD"])?.trim(),
106
+ dirty: Boolean(status),
107
+ statusKey: status ? createHash("sha256").update(status).digest("hex") : undefined,
108
+ };
109
+ }
110
+
111
+ function hashPath(root: string, relPath: string, budget: HashBudget): unknown {
35
112
  const normalized = normalizeRelPath(relPath);
113
+ if (!useHashEntryBudget(budget)) return { path: normalized, state: "skipped", reason: "entry-limit" };
36
114
  const fullPath = path.join(root, normalized);
37
115
  let stat: fs.Stats;
38
116
  try {
@@ -45,15 +123,20 @@ function hashPath(root: string, relPath: string): unknown {
45
123
  return { path: normalized, state: "symlink", target: fs.readlinkSync(fullPath) };
46
124
  }
47
125
  if (stat.isDirectory()) {
126
+ if (fs.existsSync(path.join(fullPath, ".git"))) return gitWorktreeEntry(normalized, fullPath);
48
127
  const entries = fs.readdirSync(fullPath)
49
128
  .map((entry) => normalizeRelPath(path.posix.join(normalized, entry)))
50
129
  .filter((entry) => !ignoredRelPath(entry))
51
130
  .sort();
52
- return { path: normalized, state: "dir", entries: entries.map((entry) => hashPath(root, entry)) };
53
- }
54
- if (stat.isFile()) {
55
- return { path: normalized, state: "file", mode: stat.mode & 0o777, size: stat.size, hash: hashFile(fullPath) };
131
+ const remainingEntries = Math.max(0, budget.maxEntries - budget.entries);
132
+ const selectedEntries = entries.slice(0, remainingEntries);
133
+ const childEntries = selectedEntries.map((entry) => hashPath(root, entry, budget));
134
+ if (selectedEntries.length < entries.length) {
135
+ childEntries.push({ path: normalized, state: "skipped-children", reason: "entry-limit", count: entries.length - selectedEntries.length });
136
+ }
137
+ return { path: normalized, state: "dir", entries: childEntries };
56
138
  }
139
+ if (stat.isFile()) return hashFileEntry(normalized, fullPath, stat, budget);
57
140
  return { path: normalized, state: "other", mode: stat.mode };
58
141
  }
59
142
 
@@ -75,11 +158,7 @@ function parsePorcelainZ(raw: string): Array<{ status: string; paths: string[] }
75
158
  return entries;
76
159
  }
77
160
 
78
- export function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoChangeSignature | undefined {
79
- const root = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
80
- if (!root) return undefined;
81
- const statusOutput = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
82
- if (statusOutput === undefined) return undefined;
161
+ function buildRepoChangeSignature(root: string, statusOutput: string): WatchdogRepoChangeSignature {
83
162
  const entries = parsePorcelainZ(statusOutput)
84
163
  .map((entry) => ({
85
164
  status: entry.status,
@@ -88,15 +167,29 @@ export function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoCha
88
167
  .filter((entry) => entry.paths.length > 0)
89
168
  .sort((a, b) => `${a.status} ${a.paths.join("\0")}`.localeCompare(`${b.status} ${b.paths.join("\0")}`));
90
169
  const changedPaths = [...new Set(entries.flatMap((entry) => entry.paths))].sort();
170
+ const budget: HashBudget = { entries: 0, bytes: 0, maxEntries: maxHashEntries(), maxBytes: maxHashTotalBytes() };
91
171
  const payload = entries.map((entry) => ({
92
172
  status: entry.status,
93
173
  paths: entry.paths,
94
- content: entry.paths.map((relPath) => hashPath(root, relPath)),
174
+ content: entry.paths.map((relPath) => hashPath(root, relPath, budget)),
95
175
  }));
96
176
  const key = createHash("sha256").update(JSON.stringify(payload)).digest("hex");
97
177
  return { root, key, changedPaths };
98
178
  }
99
179
 
180
+ export function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoChangeSignature | undefined {
181
+ const root = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
182
+ if (!root) return undefined;
183
+ const statusOutput = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
184
+ if (statusOutput === undefined) return undefined;
185
+ try {
186
+ return buildRepoChangeSignature(root, statusOutput);
187
+ } catch (error) {
188
+ console.warn("[pi-subagents] watchdog repo change signature failed:", (error as Error)?.message);
189
+ return undefined;
190
+ }
191
+ }
192
+
100
193
  function toolNameFromMessage(message: Record<string, unknown>): string {
101
194
  const value = message.toolName ?? message.name;
102
195
  return typeof value === "string" ? value : "";
@@ -252,7 +252,12 @@ export function createMainWatchdogReview(provider: WatchdogContextProvider, opti
252
252
  });
253
253
  if (ctx.signal?.aborted || request.signal?.aborted) return { stopReason: "aborted" };
254
254
  const auth = selection.auth;
255
- const baseStreamFn = options.streamFn ?? streamSimple;
255
+ const registeredProvider = (ctx.modelRegistry as {
256
+ getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
257
+ }).getRegisteredProviderConfig?.(selection.model.provider);
258
+ const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
259
+ ? registeredProvider.streamSimple
260
+ : streamSimple);
256
261
  const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
257
262
  ...streamOptions,
258
263
  ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
@@ -271,7 +276,7 @@ export function createMainWatchdogReview(provider: WatchdogContextProvider, opti
271
276
  tools,
272
277
  },
273
278
  convertToLlm,
274
- streamFn,
279
+ streamFunction: streamFn,
275
280
  getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
276
281
  beforeToolCall: async ({ toolCall }) => WATCHDOG_ALLOWED_TOOL_NAMES.has(toolCall.name)
277
282
  ? undefined
@@ -79,6 +79,7 @@ interface MainWatchdogRuntimeOptions {
79
79
  displayWarning?: (warning: WatchdogWarningDetails) => void;
80
80
  reviewChangesOnly?: boolean;
81
81
  lspDiagnostics?: WatchdogLspDiagnosticsFunction;
82
+ repoChangeSignature?: typeof computeWatchdogRepoChangeSignature;
82
83
  }
83
84
 
84
85
  type ContextLike = Pick<ExtensionContext, "cwd">;
@@ -113,6 +114,7 @@ export class MainWatchdogRuntime {
113
114
  private readonly displayWarning: ((warning: WatchdogWarningDetails) => void) | undefined;
114
115
  private readonly reviewChangesOnly: boolean;
115
116
  private readonly lspDiagnostics: WatchdogLspDiagnosticsFunction;
117
+ private readonly repoChangeSignature: typeof computeWatchdogRepoChangeSignature;
116
118
  private readonly lspLedger = new WatchdogLspDiagnosticsLedger();
117
119
  private configResult: WatchdogSettingsResult;
118
120
  private sessionOverrideEnabled: boolean | undefined;
@@ -155,6 +157,7 @@ export class MainWatchdogRuntime {
155
157
  this.displayWarning = options.displayWarning;
156
158
  this.reviewChangesOnly = options.reviewChangesOnly === true;
157
159
  this.lspDiagnostics = options.lspDiagnostics ?? collectWatchdogLspDiagnostics;
160
+ this.repoChangeSignature = options.repoChangeSignature ?? computeWatchdogRepoChangeSignature;
158
161
  this.configResult = this.resolveConfig(this.cwd);
159
162
  this.guardMaxWarnings = this.configResult.config.maxWarnings;
160
163
  this.guard = new WatchdogEmissionGuard({ maxWarnings: this.guardMaxWarnings });
@@ -166,9 +169,8 @@ export class MainWatchdogRuntime {
166
169
  this.cwd = ctx.cwd;
167
170
  this.sessionOverrideEnabled = undefined;
168
171
  this.sessionModelOverride = undefined;
169
- this.reset("session_start", { clearReviewInputSignature: true, resetChangeSignature: true, clearLspLedger: true });
170
172
  this.refreshConfig(ctx.cwd);
171
- this.resetRepoChangeBaseline();
173
+ this.reset("session_start", { clearReviewInputSignature: true, resetChangeSignature: true, clearLspLedger: true });
172
174
  }
173
175
 
174
176
  refreshConfig(cwd = this.cwd): WatchdogSettingsResult {
@@ -532,7 +534,7 @@ export class MainWatchdogRuntime {
532
534
  }
533
535
 
534
536
  private currentRepoChangeSignature(cwd = this.cwd): WatchdogRepoChangeSignature | undefined {
535
- return this.reviewChangesOnly ? computeWatchdogRepoChangeSignature(cwd) : undefined;
537
+ return this.reviewChangesOnly && this.isEnabled() ? this.repoChangeSignature(cwd) : undefined;
536
538
  }
537
539
 
538
540
  private resetRepoChangeBaseline(options: { cwd?: string; reviewed?: boolean } = {}): void {
@@ -1,86 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import * as path from "node:path";
3
-
4
- export interface EditorSpec {
5
- command: string;
6
- args: string[];
7
- }
8
-
9
- /** Resolve a blocking graphical editor command. Pi owns the terminal, so terminal editors are unsupported. */
10
- export function resolveEditorCommand(): EditorSpec | undefined {
11
- const raw = (process.env.VISUAL || process.env.EDITOR || "").trim()
12
- || (process.platform === "darwin" ? "open -W -n -a MarkEdit" : "");
13
- if (!raw) return undefined;
14
-
15
- const parts: string[] = [];
16
- let current = "";
17
- let quote: "'" | '"' | undefined;
18
- for (let index = 0; index < raw.length; index++) {
19
- const char = raw[index]!;
20
- if (quote) {
21
- if (char === quote) quote = undefined;
22
- else if (char === "\\" && raw[index + 1] === quote) current += raw[++index]!;
23
- else current += char;
24
- } else if (char === "'" || char === '"') {
25
- quote = char;
26
- } else if (/\s/.test(char)) {
27
- if (current) {
28
- parts.push(current);
29
- current = "";
30
- }
31
- } else if (char === "\\" && raw[index + 1] && /[\s'"\\]/.test(raw[index + 1]!)) {
32
- current += raw[++index]!;
33
- } else {
34
- current += char;
35
- }
36
- }
37
- if (quote) throw new Error("The configured editor command contains an unmatched quote.");
38
- if (current) parts.push(current);
39
- if (parts.length === 0) return undefined;
40
- return { command: parts[0]!, args: parts.slice(1) };
41
- }
42
-
43
- export function editorLabel(editor: EditorSpec): string {
44
- const appIdx = editor.args.indexOf("-a");
45
- if (editor.command === "open" && appIdx !== -1 && editor.args[appIdx + 1]) return editor.args[appIdx + 1]!;
46
- return editor.command;
47
- }
48
-
49
- function escapeWindowsCommand(value: string): string {
50
- return value.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
51
- }
52
-
53
- function escapeWindowsArgument(value: string, doubleEscapeMetaChars: boolean): string {
54
- let escaped = value
55
- .replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
56
- .replace(/(?=(\\+?)?)\1$/, "$1$1");
57
- escaped = `"${escaped}"`.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
58
- return doubleEscapeMetaChars
59
- ? escaped.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1")
60
- : escaped;
61
- }
62
-
63
- export function runEditorAndWait(editor: EditorSpec, filePath: string): Promise<void> {
64
- return new Promise((resolve, reject) => {
65
- const editorArgs = [...editor.args, filePath];
66
- const child = process.platform === "win32" && !/\.(?:com|exe)$/i.test(editor.command)
67
- ? (() => {
68
- const doubleEscape = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(editor.command);
69
- const commandLine = [
70
- escapeWindowsCommand(path.normalize(editor.command)),
71
- ...editorArgs.map((arg) => escapeWindowsArgument(arg, doubleEscape)),
72
- ].join(" ");
73
- return spawn(process.env.ComSpec || process.env.COMSPEC || "cmd.exe", ["/d", "/s", "/c", `"${commandLine}"`], {
74
- stdio: "ignore",
75
- windowsVerbatimArguments: true,
76
- });
77
- })()
78
- : spawn(editor.command, editorArgs, { stdio: "ignore" });
79
- child.once("error", reject);
80
- child.once("close", (code, signal) => {
81
- if (code === 0) resolve();
82
- else if (signal) reject(new Error(`${editor.command} terminated by ${signal}`));
83
- else reject(new Error(`${editor.command} exited with code ${code ?? "unknown"}`));
84
- });
85
- });
86
- }