pi-crew 0.11.1 → 0.11.4
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 +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { getBuiltinTemplates, instantiateTemplate, listTemplates } from "../../../skills/skill-templates.ts";
|
|
5
6
|
import { atomicWriteFile } from "../../../state/atomic-write.ts";
|
|
@@ -9,6 +10,29 @@ import { commandText, notifyCommandResult, parseScalar, pushUnset, setNestedConf
|
|
|
9
10
|
import type { RegisterTeamCommandsDeps } from "./shared.ts";
|
|
10
11
|
import { handleTeamTool, openTeamSettingsOverlay, teamCommandContext } from "./shared.ts";
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the user-level skills directory (<pi-crew package root>/skills).
|
|
15
|
+
*
|
|
16
|
+
* ESM-safe replacement for the former `require.resolve("../../../../package.json",
|
|
17
|
+
* { paths: [__dirname] })`: neither `require` nor `__dirname` exists under
|
|
18
|
+
* strip-types source loading or inside the esbuild ESM bundle (dist/index.mjs).
|
|
19
|
+
* Anchoring on `import.meta.url` and walking up to the nearest package.json
|
|
20
|
+
* yields the package root under BOTH layouts — src/.../commands/manage.ts and
|
|
21
|
+
* dist/index.mjs sit at different depths, so a fixed relative hop count cannot
|
|
22
|
+
* serve both.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveUserSkillsDir(): string {
|
|
25
|
+
let dir = fileURLToPath(new URL(".", import.meta.url));
|
|
26
|
+
for (;;) {
|
|
27
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return path.join(dir, "skills");
|
|
28
|
+
const parent = path.dirname(dir);
|
|
29
|
+
if (parent === dir) {
|
|
30
|
+
throw new Error(`pi-crew: cannot locate package root (no package.json above ${dir})`);
|
|
31
|
+
}
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
|
|
13
37
|
pi.registerCommand("team-prune", {
|
|
14
38
|
description: "Prune old finished pi-crew runs, keeping the newest N",
|
|
@@ -210,16 +234,20 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
|
|
|
210
234
|
return idx === -1 ? [s, ""] : [s.slice(0, idx), s.slice(idx + 1)];
|
|
211
235
|
});
|
|
212
236
|
const templateId = tokens.find((t) => !t.startsWith("--") && !t.includes("="));
|
|
237
|
+
const availableTemplateIds = listTemplates().map((t) => t.id);
|
|
213
238
|
if (!templateId) {
|
|
214
239
|
await notifyCommandResult(
|
|
215
240
|
ctx,
|
|
216
|
-
|
|
241
|
+
`Usage: /skill-create <template-id> [--var key=value...] [--project]\nAvailable templates: ${availableTemplateIds.join(", ")}`,
|
|
217
242
|
);
|
|
218
243
|
return;
|
|
219
244
|
}
|
|
220
245
|
const template = getBuiltinTemplates().find((t) => t.id === templateId);
|
|
221
246
|
if (!template) {
|
|
222
|
-
await notifyCommandResult(
|
|
247
|
+
await notifyCommandResult(
|
|
248
|
+
ctx,
|
|
249
|
+
`Unknown template '${templateId}'.\nUsage: /skill-create <template-id> [--var key=value...] [--project]\nAvailable templates: ${availableTemplateIds.join(", ")}`,
|
|
250
|
+
);
|
|
223
251
|
return;
|
|
224
252
|
}
|
|
225
253
|
const variables: Record<string, string> = {};
|
|
@@ -249,19 +277,7 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
|
|
|
249
277
|
await notifyCommandResult(ctx, error instanceof Error ? error.message : String(error));
|
|
250
278
|
return;
|
|
251
279
|
}
|
|
252
|
-
const skillsDir = path.resolve(
|
|
253
|
-
cwd,
|
|
254
|
-
useProject
|
|
255
|
-
? "skills"
|
|
256
|
-
: path.join(
|
|
257
|
-
path.dirname(
|
|
258
|
-
require.resolve("../../../../package.json", {
|
|
259
|
-
paths: [__dirname],
|
|
260
|
-
}),
|
|
261
|
-
),
|
|
262
|
-
"skills",
|
|
263
|
-
),
|
|
264
|
-
);
|
|
280
|
+
const skillsDir = useProject ? path.resolve(cwd, "skills") : resolveUserSkillsDir();
|
|
265
281
|
const skillDir = path.join(skillsDir, template.id);
|
|
266
282
|
const skillPath = path.join(skillDir, "SKILL.md");
|
|
267
283
|
try {
|
|
@@ -109,6 +109,10 @@ export function registerRunCommands(pi: ExtensionAPI, deps: RegisterTeamCommands
|
|
|
109
109
|
const taskToken = tokens[0] === "--all" ? tokens.shift() : tokens.shift();
|
|
110
110
|
const taskId = taskToken === "--all" ? undefined : taskToken;
|
|
111
111
|
const message = tokens.join(" ") || undefined;
|
|
112
|
+
if (!runId || !taskToken || !message) {
|
|
113
|
+
await notifyCommandResult(ctx, "Usage: /team-respond <runId> <taskId|--all> <message>…");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
112
116
|
const result = await handleTeamTool({ action: "respond", runId, taskId, message }, teamCommandContext(ctx));
|
|
113
117
|
await notifyCommandResult(ctx, commandText(result));
|
|
114
118
|
},
|
|
@@ -163,7 +167,19 @@ export function registerRunCommands(pi: ExtensionAPI, deps: RegisterTeamCommands
|
|
|
163
167
|
|
|
164
168
|
pi.registerCommand("team-goal", {
|
|
165
169
|
description:
|
|
166
|
-
"Autonomous goal loop control: [start|status|pause|resume|stop|step|clear] [goalId] [--objective=...] [--evaluatorModel=...] [--maxTurns=N]",
|
|
170
|
+
"Autonomous goal loop control (defaults to status): [start|status|pause|resume|stop|cancel|reset|step|clear] [goalId] [--objective=...] [--evaluatorModel=...] [--maxTurns=N]",
|
|
171
|
+
// Suggest the stop-aliases only while completing the FIRST argument:
|
|
172
|
+
// pi hands `getArgumentCompletions` the whole argument text (there is no
|
|
173
|
+
// argument-index parameter), so any whitespace in it means the cursor is
|
|
174
|
+
// already past arg 1 — nothing useful to suggest for goalId/flags.
|
|
175
|
+
getArgumentCompletions: (argumentPrefix: string) => {
|
|
176
|
+
if (argumentPrefix.includes(" ")) return [];
|
|
177
|
+
const prefix = argumentPrefix.trim();
|
|
178
|
+
return [
|
|
179
|
+
{ value: "cancel", label: "cancel", description: "stop the goal loop (alias of stop)" },
|
|
180
|
+
{ value: "reset", label: "reset", description: "stop the goal loop (alias of stop)" },
|
|
181
|
+
].filter((item) => item.value.startsWith(prefix));
|
|
182
|
+
},
|
|
167
183
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
168
184
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
169
185
|
const knownSubs = new Set(["start", "status", "pause", "resume", "stop", "step", "clear", "cancel", "reset"]);
|
|
@@ -203,7 +219,13 @@ export function registerRunCommands(pi: ExtensionAPI, deps: RegisterTeamCommands
|
|
|
203
219
|
metricRegistry: deps.getMetricRegistry?.(),
|
|
204
220
|
},
|
|
205
221
|
);
|
|
206
|
-
|
|
222
|
+
const text = commandText(result);
|
|
223
|
+
const trimmed = text.trim();
|
|
224
|
+
const hint =
|
|
225
|
+
!trimmed || trimmed === "[]"
|
|
226
|
+
? "\nNo metrics yet — observability may be disabled for this team. Set `observability: true` in the team frontmatter to enable."
|
|
227
|
+
: "";
|
|
228
|
+
await notifyCommandResult(ctx, text + hint);
|
|
207
229
|
},
|
|
208
230
|
});
|
|
209
231
|
|
|
@@ -46,6 +46,7 @@ export async function handleTeamTool(
|
|
|
46
46
|
if (!_cachedHandleTeamTool) {
|
|
47
47
|
if (!_handleTeamToolPromise) {
|
|
48
48
|
_handleTeamToolPromise = import("../../team-tool.ts").then((mod) => {
|
|
49
|
+
// LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
49
50
|
_cachedHandleTeamTool = mod.handleTeamTool;
|
|
50
51
|
return mod.handleTeamTool;
|
|
51
52
|
});
|
|
@@ -109,13 +110,13 @@ export async function ui(): Promise<NonNullable<typeof _uiCache>> {
|
|
|
109
110
|
if (!_uiCachePromise) {
|
|
110
111
|
_uiCachePromise = (async () => {
|
|
111
112
|
const [rd, tv, co, md, mc, ap, ma] = await Promise.all([
|
|
112
|
-
import("../../../ui/run-dashboard.ts"),
|
|
113
|
-
import("../../../ui/transcript-viewer.ts"),
|
|
114
|
-
import("../../../ui/overlays/confirm-overlay.ts"),
|
|
115
|
-
import("../../../ui/overlays/mailbox-detail-overlay.ts"),
|
|
116
|
-
import("../../../ui/overlays/mailbox-compose-overlay.ts"),
|
|
117
|
-
import("../../../ui/overlays/agent-picker-overlay.ts"),
|
|
118
|
-
import("../../../ui/mascot.ts"),
|
|
113
|
+
import("../../../ui/run-dashboard.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
114
|
+
import("../../../ui/transcript-viewer.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
115
|
+
import("../../../ui/overlays/confirm-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
116
|
+
import("../../../ui/overlays/mailbox-detail-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
117
|
+
import("../../../ui/overlays/mailbox-compose-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
118
|
+
import("../../../ui/overlays/agent-picker-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
119
|
+
import("../../../ui/mascot.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
119
120
|
]);
|
|
120
121
|
const cache = {
|
|
121
122
|
RunDashboard: rd.RunDashboard,
|
|
@@ -249,9 +250,9 @@ export function teamCommandContext(ctx: ExtensionCommandContext): ExtensionComma
|
|
|
249
250
|
export async function openTeamSettingsOverlay(ctx: ExtensionContext): Promise<void> {
|
|
250
251
|
if (!ctx.hasUI) return;
|
|
251
252
|
const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([
|
|
252
|
-
import("../../../config/config.ts"),
|
|
253
|
-
import("../../../ui/theme-adapter.ts"),
|
|
254
|
-
import("../../../ui/settings-overlay.ts"),
|
|
253
|
+
import("../../../config/config.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
254
|
+
import("../../../ui/theme-adapter.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
255
|
+
import("../../../ui/settings-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
|
|
255
256
|
]);
|
|
256
257
|
const loaded = loadConfig(ctx.cwd);
|
|
257
258
|
const config = loaded.config as Record<string, unknown>;
|
|
@@ -576,6 +577,29 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
|
|
|
576
577
|
deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
|
|
577
578
|
continue;
|
|
578
579
|
}
|
|
580
|
+
// US-020: dashboard `x` cancel — dispatches the EXISTING cancel channel
|
|
581
|
+
// (control-domain handleCancel). The 2-step dashboard gate is the user's
|
|
582
|
+
// explicit intent; `intent` satisfies requireIntentForDestructiveActions
|
|
583
|
+
// when that policy is on. Dashboard reopens on continue (selection kept).
|
|
584
|
+
if (selection.action === "cancel" && selection.runId) {
|
|
585
|
+
// F4 pattern: surface backend rejections at error level, truncate long text.
|
|
586
|
+
const cancelResult = await handleTeamTool(
|
|
587
|
+
{
|
|
588
|
+
action: "cancel",
|
|
589
|
+
runId: selection.runId,
|
|
590
|
+
config: { intent: "dashboard cancel keystroke (2-step confirm)" },
|
|
591
|
+
},
|
|
592
|
+
teamCommandContext(cmdCtx),
|
|
593
|
+
);
|
|
594
|
+
const cancelText = commandText(cancelResult);
|
|
595
|
+
depsNotify(
|
|
596
|
+
cmdCtx,
|
|
597
|
+
cancelText.length > 800 ? `${cancelText.slice(0, 797)}...` : cancelText,
|
|
598
|
+
cancelResult.isError ? "error" : "info",
|
|
599
|
+
);
|
|
600
|
+
deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
579
603
|
if (selection.action === "plan-approve" || selection.action === "plan-deny") {
|
|
580
604
|
await handlePlanDashboardAction(cmdCtx, selection);
|
|
581
605
|
deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadRunManifestById } from "../../../state/stores/state-store.ts";
|
|
2
3
|
import { suggestRunIds } from "../../command-completions.ts";
|
|
3
4
|
import { piTeamsHelp } from "../../help.ts";
|
|
4
|
-
import { commandText, notifyCommandResult } from "../command-utils.ts";
|
|
5
|
+
import { commandText, NOTIFY_TEXT_CAP, notifyCommandResult } from "../command-utils.ts";
|
|
5
6
|
import type { RegisterTeamCommandsDeps } from "./shared.ts";
|
|
6
7
|
import { handleTeamTool, teamCommandContext } from "./shared.ts";
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* W5 (slash-commands fix spec): `/team-events` pointer. When the events
|
|
11
|
+
* listing is about to be clipped by the 800-char notification cap, resolve
|
|
12
|
+
* the run's on-disk events log so the message can end with a reachable path.
|
|
13
|
+
* Derived from the SAME source the `events` team action reads
|
|
14
|
+
* (inspect.ts): locateRunCwd + loadRunManifestById → manifest.eventsPath.
|
|
15
|
+
* The dynamic team-tool import is free here — the awaited handleTeamTool
|
|
16
|
+
* call above it already loaded that module into the ESM cache.
|
|
17
|
+
*/
|
|
18
|
+
async function eventsLogFooter(runId: string | undefined, text: string, cwd: string): Promise<string | undefined> {
|
|
19
|
+
if (!runId || text.length <= NOTIFY_TEXT_CAP) return undefined;
|
|
20
|
+
const { locateRunCwd } = await import("../../team-tool.ts"); // LAZY: defer team-tool chain to call site (handleTeamTool already cached it).
|
|
21
|
+
const runCwd = locateRunCwd(runId, cwd);
|
|
22
|
+
const manifest = runCwd ? loadRunManifestById(runCwd, runId)?.manifest : undefined;
|
|
23
|
+
return manifest ? ` Full log: ${manifest.eventsPath}` : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
export function registerStatusCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
|
|
9
27
|
pi.registerCommand("teams", {
|
|
10
28
|
description: "List pi-crew teams, workflows, and agents",
|
|
@@ -34,7 +52,12 @@ export function registerStatusCommands(pi: ExtensionAPI, deps: RegisterTeamComma
|
|
|
34
52
|
getRunSnapshotCache: deps.getRunSnapshotCache,
|
|
35
53
|
},
|
|
36
54
|
);
|
|
37
|
-
|
|
55
|
+
const text = commandText(result);
|
|
56
|
+
await notifyCommandResult(ctx, text, {
|
|
57
|
+
// W5: only `team-events` output is a truncatable on-disk log —
|
|
58
|
+
// point at the full file when the listing gets clipped.
|
|
59
|
+
truncatedFooter: action === "events" ? await eventsLogFooter(runId, text, ctx.cwd) : undefined,
|
|
60
|
+
});
|
|
38
61
|
},
|
|
39
62
|
});
|
|
40
63
|
}
|
|
@@ -58,6 +58,7 @@ export function buildRegistrationContext(pi: ExtensionAPI): RegistrationContext
|
|
|
58
58
|
autoRepairTimer: undefined,
|
|
59
59
|
tempReconcileTimer: undefined,
|
|
60
60
|
otlpExporter: undefined,
|
|
61
|
+
initPromise: undefined,
|
|
61
62
|
} as ObservabilityState,
|
|
62
63
|
lifecycleState: {
|
|
63
64
|
notifierStarted: false,
|
|
@@ -121,7 +122,12 @@ export function buildRegistrationContext(pi: ExtensionAPI): RegistrationContext
|
|
|
121
122
|
};
|
|
122
123
|
|
|
123
124
|
ctx.getManifestCache = (cwd: string) => {
|
|
124
|
-
|
|
125
|
+
// F13 (RR-018): a disposed cache pair must be recreated EVEN WHEN the cwd
|
|
126
|
+
// is unchanged. Session cleanup disposes the caches but used to leave
|
|
127
|
+
// `cacheCwd` pointing at them, so same-cwd access returned the disposed
|
|
128
|
+
// instance (no disposed flag existed). isDisposed() makes the pair's
|
|
129
|
+
// liveness part of the swap check.
|
|
130
|
+
if (ctx.manifestCache && ctx.cacheCwd === cwd && !ctx.runSnapshotCache.isDisposed()) return ctx.manifestCache;
|
|
125
131
|
if (ctx.manifestCache) ctx.manifestCache.dispose();
|
|
126
132
|
if (ctx.runSnapshotCache) ctx.runSnapshotCache.dispose?.();
|
|
127
133
|
ctx.cacheCwd = cwd;
|
|
@@ -130,7 +136,7 @@ export function buildRegistrationContext(pi: ExtensionAPI): RegistrationContext
|
|
|
130
136
|
return ctx.manifestCache;
|
|
131
137
|
};
|
|
132
138
|
ctx.getRunSnapshotCache = (cwd: string) => {
|
|
133
|
-
if (ctx.cacheCwd !== cwd) ctx.getManifestCache(cwd);
|
|
139
|
+
if (ctx.cacheCwd !== cwd || ctx.runSnapshotCache.isDisposed()) ctx.getManifestCache(cwd);
|
|
134
140
|
return ctx.runSnapshotCache;
|
|
135
141
|
};
|
|
136
142
|
|
|
@@ -103,7 +103,7 @@ function startForegroundRunImpl(
|
|
|
103
103
|
extensionCtx.ui.setWorkingMessage(runId ? `pi-crew foreground run ${runId}...` : "pi-crew foreground run...");
|
|
104
104
|
}
|
|
105
105
|
if (runId) {
|
|
106
|
-
void import("../../runtime/foreground-watchdog.ts")
|
|
106
|
+
void import("../../runtime/foreground-watchdog.ts") // LAZY: defer watchdog module until first foreground run
|
|
107
107
|
.then(({ startForegroundWatchdog }) => {
|
|
108
108
|
startForegroundWatchdog({ pi, cwd: extensionCtx.cwd, runId });
|
|
109
109
|
})
|
|
@@ -139,7 +139,7 @@ function startForegroundRunImpl(
|
|
|
139
139
|
.finally(() => {
|
|
140
140
|
ctx.foregroundTeamRunControllers.delete(key);
|
|
141
141
|
if (runId) {
|
|
142
|
-
void import("../../runtime/foreground-watchdog.ts")
|
|
142
|
+
void import("../../runtime/foreground-watchdog.ts") // LAZY: defer watchdog module until first foreground run
|
|
143
143
|
.then(({ stopWatchdog }) => {
|
|
144
144
|
stopWatchdog(runId);
|
|
145
145
|
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-notify-policy.ts — FINDING 6 (2026-09-23 battery): ambient
|
|
3
|
+
* notification re-delivery storms.
|
|
4
|
+
*
|
|
5
|
+
* Observed live: a zombie-run leak made the health monitor re-fire the same
|
|
6
|
+
* `recovery_missing_heartbeat` notification every cooldown window (5 min) for
|
|
7
|
+
* hours. Each fire queued a follow-up message on the host session while the
|
|
8
|
+
* parent was busy; the host drains follow-ups ONE PER TURN BOUNDARY
|
|
9
|
+
* (followUpMode "one-at-a-time"), so the queued duplicates kept dripping into
|
|
10
|
+
* the parent conversation for ~5 hours AFTER the runs were deleted and the
|
|
11
|
+
* monitor had written its `clear` entries.
|
|
12
|
+
*
|
|
13
|
+
* Policy fixes (both sides of the storm):
|
|
14
|
+
* 1. BOUNDED RE-FIRE — a given notification key may fire at most
|
|
15
|
+
* MAX_HEALTH_NOTIFY_FIRES times unless the underlying fingerprint changes
|
|
16
|
+
* (a different dead/missing/task-count shape is a NEW situation and gets a
|
|
17
|
+
* fresh budget). Previously the 5-min cooldown re-armed forever.
|
|
18
|
+
* 2. RESET-ON-CLEAR — the clear path resets the fire budget, so a genuine
|
|
19
|
+
* recurrence after a resolved incident notifies again (up to the cap).
|
|
20
|
+
*
|
|
21
|
+
* The host-queue purge of already-queued copies is handled separately
|
|
22
|
+
* (purgeQueuedAmbientNotifications in subagent-helpers.ts) — this module only
|
|
23
|
+
* stops pi-crew from FEEDING the queue.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Maximum notifications emitted per key before a fingerprint change. */
|
|
27
|
+
export const MAX_HEALTH_NOTIFY_FIRES = 3;
|
|
28
|
+
|
|
29
|
+
export interface AutoRecoveryEntry {
|
|
30
|
+
insertedAt: number;
|
|
31
|
+
lastAccessAt: number;
|
|
32
|
+
/** Times this key has fired under the current fingerprint. */
|
|
33
|
+
fires?: number;
|
|
34
|
+
/** Shape of the situation last notified (see healthNotifyFingerprint). */
|
|
35
|
+
fingerprint?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HealthNotifyState {
|
|
39
|
+
/** Map keyed `${kind}_${runId}` — typically ctx.autoRecoveryLast. */
|
|
40
|
+
entries: Map<string, AutoRecoveryEntry>;
|
|
41
|
+
/** LRU cap — typically ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES. */
|
|
42
|
+
maxEntries: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Compact fingerprint of the health situation: same counts = same situation. */
|
|
46
|
+
export function healthNotifyFingerprint(summary: { dead: number; missing: number }, totalTasks: number): string {
|
|
47
|
+
return `${summary.dead}/${summary.missing}/${totalTasks}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decide whether a health notification may fire now, and record the decision.
|
|
52
|
+
*
|
|
53
|
+
* Fires when: (a) never fired before, (b) cooldown elapsed AND the same
|
|
54
|
+
* fingerprint still has budget, or (c) the fingerprint CHANGED (new situation
|
|
55
|
+
* — fresh budget). Blocks within the cooldown window and after the budget is
|
|
56
|
+
* exhausted. Evicts oldest-access entries at the cap (LRU, mirrors the
|
|
57
|
+
* previous inline behavior).
|
|
58
|
+
*/
|
|
59
|
+
export function recordHealthNotifyDecision(
|
|
60
|
+
state: HealthNotifyState,
|
|
61
|
+
key: string,
|
|
62
|
+
fingerprint: string,
|
|
63
|
+
now: number,
|
|
64
|
+
cooldownMs = 5 * 60_000,
|
|
65
|
+
maxFires = MAX_HEALTH_NOTIFY_FIRES,
|
|
66
|
+
): boolean {
|
|
67
|
+
const previous = state.entries.get(key);
|
|
68
|
+
if (previous !== undefined && now - previous.lastAccessAt < cooldownMs) return false;
|
|
69
|
+
if (previous !== undefined && previous.fires !== undefined && previous.fires >= maxFires && previous.fingerprint === fingerprint) {
|
|
70
|
+
// Budget exhausted for an unchanged situation. Still refresh access so
|
|
71
|
+
// LRU ordering reflects interest in this key.
|
|
72
|
+
previous.lastAccessAt = now;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
while (state.entries.size >= state.maxEntries) {
|
|
76
|
+
let oldestKey: string | undefined;
|
|
77
|
+
let oldestAccess = Infinity;
|
|
78
|
+
for (const [k, v] of state.entries) {
|
|
79
|
+
if (v.lastAccessAt < oldestAccess) {
|
|
80
|
+
oldestAccess = v.lastAccessAt;
|
|
81
|
+
oldestKey = k;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (oldestKey === undefined) break;
|
|
85
|
+
state.entries.delete(oldestKey);
|
|
86
|
+
}
|
|
87
|
+
const fires = previous?.fingerprint === fingerprint ? (previous.fires ?? 0) + 1 : 1;
|
|
88
|
+
state.entries.set(key, {
|
|
89
|
+
insertedAt: previous?.insertedAt ?? now,
|
|
90
|
+
lastAccessAt: now,
|
|
91
|
+
fires,
|
|
92
|
+
fingerprint,
|
|
93
|
+
});
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Reset a key's fire budget (clear path: resolved incident gets fresh budget). */
|
|
98
|
+
export function resetHealthNotifyEntry(state: HealthNotifyState, key: string): void {
|
|
99
|
+
state.entries.delete(key);
|
|
100
|
+
}
|
|
@@ -24,6 +24,7 @@ import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts"
|
|
|
24
24
|
import { reconcileOrphanedTempWorkspaces } from "../../runtime/stale-reconciler.ts";
|
|
25
25
|
import { requestPowerbarUpdate } from "../../ui/powerbar-publisher.ts";
|
|
26
26
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
27
|
+
import { extractSessionId } from "../../utils/session-utils.ts";
|
|
27
28
|
import type { RegistrationContext } from "./registration-types.ts";
|
|
28
29
|
import { sendAgentWakeUp, sendFollowUp } from "./subagent-helpers.ts";
|
|
29
30
|
|
|
@@ -48,6 +49,35 @@ export function installLazyConfigurers(pi: ExtensionAPI, ctx: RegistrationContex
|
|
|
48
49
|
ctx.configureDeliveryCoordinator = (): void => {
|
|
49
50
|
void configureDeliveryCoordinatorImpl(pi, ctx);
|
|
50
51
|
};
|
|
52
|
+
installTurnReconcileHook(pi, ctx);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* F12 (RR-018): register the before_agent_start reconcile hook EXACTLY ONCE
|
|
57
|
+
* for the extension lifetime. The callback resolves the CURRENT session
|
|
58
|
+
* (`ctx.currentCtx`) at fire time instead of capturing the configuring
|
|
59
|
+
* session's ExtensionContext, so a stale session's hook can never re-activate,
|
|
60
|
+
* double-reconcile a turn, or flip the shared manifest-cache cwd back to an
|
|
61
|
+
* old session's directory. (There is no pi.off unregister surface, so the old
|
|
62
|
+
* per-session registration accumulated one dead hook per session switch.)
|
|
63
|
+
*/
|
|
64
|
+
function installTurnReconcileHook(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
65
|
+
try {
|
|
66
|
+
pi.on?.("before_agent_start", () => {
|
|
67
|
+
const current = ctx.currentCtx;
|
|
68
|
+
if (!current || ctx.cleanedUp) return;
|
|
69
|
+
try {
|
|
70
|
+
// RR-021 WI-1.5: async reconcile — fire-and-forget from the sync hook.
|
|
71
|
+
void reconcileAllStaleRuns(current.cwd, ctx.getManifestCache(current.cwd), undefined, extractSessionId(current)).catch(
|
|
72
|
+
(error) => logInternalError("register.autoRepair.turnHook", error),
|
|
73
|
+
);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logInternalError("register.autoRepair.turnHook", error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
/* older Pi without before_agent_start — rely on the interval alone */
|
|
80
|
+
}
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
/**
|
|
@@ -58,6 +88,9 @@ export function installLazyConfigurers(pi: ExtensionAPI, ctx: RegistrationContex
|
|
|
58
88
|
* `ctx.currentCtx` and caches.
|
|
59
89
|
*/
|
|
60
90
|
async function configureNotificationsImpl(pi: ExtensionAPI, ctx: RegistrationContext, extCtx: ExtensionContext): Promise<void> {
|
|
91
|
+
// F11: capture the owning generation BEFORE the first await — the deps
|
|
92
|
+
// closure below re-checks it after lifecycle.ts's lazy imports resolve.
|
|
93
|
+
const ownerGeneration = ctx.sessionGeneration;
|
|
61
94
|
try {
|
|
62
95
|
// LAZY: registration/lifecycle is heavy (notification-router + sink)
|
|
63
96
|
const lifecycleModule = await import("./lifecycle.ts");
|
|
@@ -68,6 +101,7 @@ async function configureNotificationsImpl(pi: ExtensionAPI, ctx: RegistrationCon
|
|
|
68
101
|
getManifestCache: ctx.getManifestCache,
|
|
69
102
|
getRunSnapshotCache: ctx.getRunSnapshotCache,
|
|
70
103
|
requestPowerbarUpdate,
|
|
104
|
+
isOwnerSession: () => !ctx.cleanedUp && ctx.sessionGeneration === ownerGeneration,
|
|
71
105
|
});
|
|
72
106
|
} catch (error) {
|
|
73
107
|
logInternalError("register.configureNotifications", error);
|
|
@@ -90,6 +124,8 @@ async function configureObservabilityImpl(pi: ExtensionAPI, ctx: RegistrationCon
|
|
|
90
124
|
getManifestCache: ctx.getManifestCache,
|
|
91
125
|
notifyOperator: ctx.notifyOperator,
|
|
92
126
|
isCleanedUp: () => ctx.cleanedUp,
|
|
127
|
+
// F11: reuse the existing sessionGeneration counter — never a parallel one.
|
|
128
|
+
getSessionGeneration: () => ctx.sessionGeneration,
|
|
93
129
|
reconcileStaleRuns: (cwd, cache, currentSessionId) => reconcileAllStaleRuns(cwd, cache, undefined, currentSessionId),
|
|
94
130
|
reconcileOrphanedTempWorkspaces: (now, opts) => reconcileOrphanedTempWorkspaces(now, opts),
|
|
95
131
|
cleanupOrphanTempDirs,
|
|
@@ -108,6 +144,7 @@ async function configureObservabilityImpl(pi: ExtensionAPI, ctx: RegistrationCon
|
|
|
108
144
|
* No dynamic import needed — lifecycle is already loaded by configureNotifications.
|
|
109
145
|
*/
|
|
110
146
|
async function configureDeliveryCoordinatorImpl(pi: ExtensionAPI, ctx: RegistrationContext): Promise<void> {
|
|
147
|
+
const ownerGeneration = ctx.sessionGeneration;
|
|
111
148
|
try {
|
|
112
149
|
// LAZY: lifecycle.ts may not be loaded yet if configureNotifications hasn't fired.
|
|
113
150
|
const lifecycleModule = await import("./lifecycle.ts");
|
|
@@ -117,6 +154,7 @@ async function configureDeliveryCoordinatorImpl(pi: ExtensionAPI, ctx: Registrat
|
|
|
117
154
|
notifyOperator: ctx.notifyOperator,
|
|
118
155
|
sendFollowUp,
|
|
119
156
|
sendAgentWakeUp,
|
|
157
|
+
isOwnerSession: () => !ctx.cleanedUp && ctx.sessionGeneration === ownerGeneration,
|
|
120
158
|
});
|
|
121
159
|
} catch (error) {
|
|
122
160
|
logInternalError("register.configureDeliveryCoordinator", error);
|