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.
- package/CHANGELOG.md +59 -0
- package/README.md +132 -24
- package/agents/advisor.md +73 -0
- package/package.json +8 -12
- package/skills/pi-subagents/SKILL.md +22 -9
- package/src/agents/agents.ts +22 -5
- package/src/api/delegation.ts +125 -0
- package/src/extension/config.ts +7 -1
- package/src/extension/index.ts +50 -38
- package/src/extension/rpc.ts +27 -2
- package/src/extension/schemas.ts +22 -2
- package/src/extension/tool-description.ts +2 -2
- package/src/intercom/intercom-bridge.ts +1 -1
- package/src/intercom/native-supervisor-channel.ts +45 -6
- package/src/intercom/result-intercom.ts +7 -0
- package/src/runs/background/async-execution.ts +34 -4
- package/src/runs/background/async-job-tracker.ts +4 -0
- package/src/runs/background/async-resume.ts +27 -5
- package/src/runs/background/async-status.ts +76 -3
- package/src/runs/background/chain-append.ts +2 -0
- package/src/runs/background/completion-batcher.ts +6 -4
- package/src/runs/background/completion-dedupe.ts +2 -11
- package/src/runs/background/fleet-view.ts +9 -4
- package/src/runs/background/notify.ts +132 -120
- package/src/runs/background/result-watcher.ts +138 -78
- package/src/runs/background/run-status.ts +3 -1
- package/src/runs/background/subagent-runner.ts +225 -43
- package/src/runs/background/subagent-wait.ts +130 -4
- package/src/runs/background/wait-tool.ts +2 -2
- package/src/runs/foreground/chain-execution.ts +176 -111
- package/src/runs/foreground/execution.ts +90 -36
- package/src/runs/foreground/foreground-control.ts +90 -0
- package/src/runs/foreground/subagent-executor.ts +394 -163
- package/src/runs/shared/acceptance.ts +55 -13
- package/src/runs/shared/agent-contract.ts +38 -0
- package/src/runs/shared/child-protocol.ts +1 -1
- package/src/runs/shared/completion-guard.ts +36 -5
- package/src/runs/shared/context-mode.ts +44 -0
- package/src/runs/shared/dynamic-fanout.ts +4 -4
- package/src/runs/shared/long-running-guard.ts +4 -0
- package/src/runs/shared/nested-events.ts +27 -2
- package/src/runs/shared/parallel-handoff.ts +154 -0
- package/src/runs/shared/parallel-utils.ts +6 -0
- package/src/runs/shared/pi-args.ts +23 -14
- package/src/runs/shared/run-history.ts +90 -5
- package/src/runs/shared/structured-output.ts +112 -7
- package/src/runs/shared/subagent-control.ts +4 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +17 -18
- package/src/runs/shared/task-intent.ts +10 -5
- package/src/runs/shared/tool-availability.ts +3 -1
- package/src/runs/shared/tool-budget.ts +11 -5
- package/src/runs/shared/turn-budget.ts +2 -1
- package/src/runs/shared/worktree.ts +63 -14
- package/src/shared/accessible-dir.ts +25 -0
- package/src/shared/artifacts.ts +37 -7
- package/src/shared/atomic-json.ts +14 -42
- package/src/shared/child-transcript.ts +52 -0
- package/src/shared/file-system-retry.ts +47 -0
- package/src/shared/settings.ts +9 -1
- package/src/shared/types.ts +211 -22
- package/src/slash/delegation-adapters.ts +152 -5
- package/src/slash/delegation-json.ts +108 -0
- package/src/slash/delegation-request.ts +182 -36
- package/src/slash/prompt-template-bridge.ts +222 -37
- package/src/slash/selector.ts +147 -0
- package/src/slash/slash-commands.ts +14 -5
- package/src/slash/slash-live-state.ts +2 -2
- package/src/slash/subagents-admin.ts +42 -42
- package/src/tui/fleet-status.ts +362 -0
- package/src/tui/fleet-transcript.ts +472 -0
- package/src/tui/fleet.ts +318 -59
- package/src/tui/render.ts +25 -15
- package/src/watchdog/change-signature.ts +105 -12
- package/src/watchdog/review.ts +7 -2
- package/src/watchdog/runtime.ts +5 -3
- 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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 : "";
|
package/src/watchdog/review.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/watchdog/runtime.ts
CHANGED
|
@@ -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.
|
|
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 ?
|
|
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
|
-
}
|