pi-crew 0.2.25 → 0.3.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.
@@ -0,0 +1,261 @@
1
+ # pi-subagent4 vs pi-crew: Comparative Analysis
2
+
3
+ ## Overview
4
+
5
+ | Aspect | pi-subagent4 | pi-crew |
6
+ |--------|--------------|---------|
7
+ | **Size** | ~560 lines (single file) | ~50+ files, 10K+ lines |
8
+ | **Architecture** | Single `subagent` tool | Full team orchestration system |
9
+ | **Agent Model** | 3 built-in (scout, researcher, worker) | Configurable, extensible |
10
+ | **Concurrency** | Semaphore (default 4) | DAG scheduler with phases |
11
+ | **Context** | No inheritance (must be in task) | Full context preservation |
12
+
13
+ ---
14
+
15
+ ## 1. Extension & Registration
16
+
17
+ ### pi-subagent4
18
+ ```typescript
19
+ // Dynamic agent registration via globalThis bridge
20
+ (globalThis as any).__pi_subagents = { registerAgent, unregisterAgent };
21
+
22
+ export function registerAgent(config: AgentConfig): void {
23
+ agents.push(config);
24
+ }
25
+ ```
26
+ - **File-based**: Loads agents from `.md` files at startup
27
+ - **Global bridge**: Uses `globalThis.__pi_subagents` for cross-module registration
28
+ - **Frontmatter config**: YAML frontmatter in `.md` files define agents
29
+
30
+ ### pi-crew
31
+ - **Manifest-based**: Teams/workflows defined in `.team.md`/`.workflow.md` files
32
+ - **Skills system**: Extensible skill system for agents
33
+ - **No dynamic registration API**: Static configuration
34
+
35
+ ---
36
+
37
+ ## 2. Child Process Spawning
38
+
39
+ ### pi-subagent4
40
+ ```typescript
41
+ const args = [
42
+ ...piBin.baseArgs,
43
+ "--mode", "json",
44
+ "-p",
45
+ "--no-session",
46
+ "--no-skills",
47
+ "--no-extensions",
48
+ "--tools", allowlist.join(","),
49
+ // ... custom tools, model, thinking level
50
+ ];
51
+
52
+ const child = spawn(command, spawnArgs, { stdio: ["ignore", "pipe", "pipe"] });
53
+ ```
54
+ - **JSON mode**: `--mode json` for structured output
55
+ - **Heavy isolation**: `--no-session --no-skills --no-extensions`
56
+ - **Tool allowlist**: `--tools` for fine-grained control
57
+ - **PI_SUBAGENT_ALLOWED**: Env var restricts nested subagents
58
+
59
+ ### pi-crew
60
+ ```typescript
61
+ const child = spawn(spawnSpec.command, spawnSpec.args, buildChildPiSpawnOptions(...));
62
+ ```
63
+ - **Similar isolation**: Filters env vars, preserves essentials
64
+ - **More complex args**: Based on task config
65
+ - **No direct env restriction**: Uses runtime mode instead
66
+
67
+ ---
68
+
69
+ ## 3. Concurrency Control
70
+
71
+ ### pi-subagent4
72
+ ```typescript
73
+ class Semaphore {
74
+ constructor(private readonly max: number) {}
75
+
76
+ async run<T>(fn: () => Promise<T>): Promise<T> {
77
+ // Simple acquire/release pattern
78
+ }
79
+ }
80
+
81
+ const semaphore = new Semaphore(config.maxConcurrency ?? 4);
82
+ ```
83
+ - **Per-parent semaphore**: Default 4, configurable via `config.json`
84
+ - **Promise.all fan-out**: Parallel subagent calls in one turn
85
+
86
+ ### pi-crew
87
+ ```typescript
88
+ // DAG scheduler with phase-based concurrency
89
+ resolveBatchConcurrency({
90
+ workflowMaxConcurrency,
91
+ teamMaxConcurrency,
92
+ maxConcurrentWorkers,
93
+ workspaceMode,
94
+ });
95
+
96
+ // Tasks in same phase run concurrently
97
+ ```
98
+ - **Phase-based**: Tasks grouped by workflow phase
99
+ - **DAG dependency**: Respects task dependencies
100
+ - **Configurable limits**: Per-workflow and per-team
101
+
102
+ ---
103
+
104
+ ## 4. Input Handling
105
+
106
+ ### pi-subagent4
107
+ ```typescript
108
+ // Long tasks written to temp file
109
+ if (task.length > 8000) {
110
+ const tempFile = createTempFile(task);
111
+ args.push("@" + tempFile);
112
+ } else {
113
+ args.push("--task", task);
114
+ }
115
+ ```
116
+ - **8K char threshold**: Uses temp file for large tasks
117
+ - **Single task format**: `--task <text>` or `@<file>`
118
+
119
+ ### pi-crew
120
+ ```typescript
121
+ // Prompt builder with system prompt, context, task
122
+ const built = await buildPrompt({ task, role, goal, cwd, ... });
123
+ // Args built from task configuration
124
+ ```
125
+ - **Prompt builder**: Constructs full prompt with context
126
+ - **File-based context**: Can read from workspace files
127
+
128
+ ---
129
+
130
+ ## 5. Output Handling
131
+
132
+ ### pi-subagent4
133
+ ```typescript
134
+ // JSON event stream on stdout
135
+ child.stdout.on("data", (data) => {
136
+ const lines = data.toString().split("\n");
137
+ for (const line of lines) {
138
+ if (line.startsWith("{")) {
139
+ const event = JSON.parse(line);
140
+ // tool_execution_start/end, message_end
141
+ }
142
+ }
143
+ });
144
+ ```
145
+ - **JSON event stream**: Structured events from child process
146
+ - **Event types**: `tool_execution_start`, `tool_execution_end`, `message_end`
147
+ - **Streaming**: Real-time event processing
148
+
149
+ ### pi-crew
150
+ ```typescript
151
+ // JSON output mode + structured response
152
+ const output = await runChildPi({
153
+ onLifecycleEvent: (event) => { ... },
154
+ // ...
155
+ });
156
+ ```
157
+ - **Lifecycle events**: spawn, spawn_error, response_timeout, etc.
158
+ - **Structured result**: `{ content, details, usage }`
159
+
160
+ ---
161
+
162
+ ## 6. Safety Features
163
+
164
+ ### pi-subagent4
165
+ ```typescript
166
+ // tools/safe-bash.ts
167
+ const DANGEROUS_PATTERNS = [
168
+ /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
169
+ /\bsudo\b/,
170
+ /\bmkfs\b/,
171
+ // ... 15+ patterns
172
+ ];
173
+ ```
174
+ - **Regex blocklist**: 15+ dangerous command patterns
175
+ - **Safe bash wrapper**: Wraps built-in bash tool
176
+
177
+ ### pi-crew
178
+ - **Env var filtering**: Strips secrets before spawning
179
+ - **No built-in safe bash**: Trust-based (user config required)
180
+ - **Sandbox modes**: scaffold, child-process, live-session
181
+
182
+ ---
183
+
184
+ ## 7. UI/Rendering
185
+
186
+ ### pi-subagent4
187
+ ```typescript
188
+ // Throttled live rendering
189
+ const updateThrottle = 150; // ms
190
+ // Context window meter for depth >= 1 subagents
191
+ // Tool preview extraction
192
+ ```
193
+ - **150ms throttle**: Prevents UI thrashing
194
+ - **Context gauge**: Shows token usage
195
+ - **Tool preview**: Single-line argument preview
196
+
197
+ ### pi-crew
198
+ - **Rich UI widget**: Live status, progress, model/token display
199
+ - **Dashboard**: Full run dashboard
200
+ - **Event bus**: Real-time updates
201
+
202
+ ---
203
+
204
+ ## 8. Agent Hierarchy
205
+
206
+ ### pi-subagent4
207
+ ```
208
+ worker (depth 2)
209
+ ├─ scout (depth 1)
210
+ └─ researcher (depth 1)
211
+ ```
212
+ - **Depth-2 cap**: Worker can spawn scout/researcher
213
+ - **PI_SUBAGENT_ALLOWED**: Enforces restriction
214
+
215
+ ### pi-crew
216
+ - **No nested subagent**: Each task is independent
217
+ - **Team roles**: explorer, planner, executor, etc.
218
+ - **Phase-based**: Sequential phases with parallel within
219
+
220
+ ---
221
+
222
+ ## Key Insights
223
+
224
+ ### What pi-subagent4 does better:
225
+ 1. **Simpler API**: Single tool, minimal config
226
+ 2. **Dynamic registration**: `registerAgent()` for runtime changes
227
+ 3. **JSON event stream**: Real-time structured events
228
+ 4. **Safe bash**: Built-in dangerous command blocking
229
+ 5. **Context gauge**: Token monitoring per turn
230
+
231
+ ### What pi-crew does better:
232
+ 1. **Complex workflows**: DAG scheduler, phases, dependencies
233
+ 2. **Durable state**: Manifest, events, artifacts persisted
234
+ 3. **Worktree isolation**: Safe parallel edits
235
+ 4. **Async runs**: Background execution with notifications
236
+ 5. **Rich UI**: Full dashboard and widget system
237
+ 6. **Multiple teams**: Built-in teams for different use cases
238
+
239
+ ---
240
+
241
+ ## Potential Improvements for pi-crew
242
+
243
+ 1. **Dynamic agent registration API**
244
+ - Add `registerAgent(config)` similar to subagent4
245
+ - Allow runtime agent creation
246
+
247
+ 2. **Safe bash tool**
248
+ - Port dangerous pattern blocklist from subagent4
249
+ - Configurable via project config
250
+
251
+ 3. **JSON event stream parsing**
252
+ - Extract real-time tool events from child process
253
+ - Display tool progress in UI
254
+
255
+ 4. **Context window monitoring**
256
+ - Show token usage per task
257
+ - Alert when approaching limits
258
+
259
+ 5. **Simpler single-agent mode**
260
+ - Maybe a `subagent` tool for simple delegation?
261
+ - Current API is team/workflow based, could be heavy for simple cases
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.2.25",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -100,22 +100,92 @@ function applyAgentOverrides(agents: AgentConfig[], cwd: string, loadedConfig?:
100
100
  });
101
101
  }
102
102
 
103
+ // ─── Agent Discovery Cache (Phase 3a) ────────────────────────────────────
104
+ // Caches discoverAgents results by cwd with a short TTL to avoid repeated
105
+ // disk I/O when multiple callers request agents for the same project.
106
+
107
+ const DISCOVERY_CACHE_TTL_MS = 500;
108
+ const discoveryCache = new Map<string, { result: AgentDiscoveryResult; expiresAt: number }>();
109
+ const DISCOVERY_CACHE_MAX_ENTRIES = 32;
110
+
111
+ function pruneDiscoveryCache(): void {
112
+ const now = Date.now();
113
+ for (const [key, entry] of discoveryCache) {
114
+ if (entry.expiresAt <= now) discoveryCache.delete(key);
115
+ }
116
+ }
117
+
118
+ /** Invalidate cached discovery result for a given cwd (or all if omitted). */
119
+ export function invalidateAgentDiscoveryCache(cwd?: string): void {
120
+ if (cwd) {
121
+ discoveryCache.delete(cwd);
122
+ } else {
123
+ discoveryCache.clear();
124
+ }
125
+ }
126
+
103
127
  export function discoverAgents(cwd: string): AgentDiscoveryResult {
128
+ pruneDiscoveryCache();
129
+ const cached = discoveryCache.get(cwd);
130
+ if (cached && cached.expiresAt > Date.now()) {
131
+ return cached.result;
132
+ }
104
133
  const loaded = loadConfig(cwd);
105
- return {
134
+ const result: AgentDiscoveryResult = {
106
135
  builtin: applyAgentOverrides(readAgentDir(path.join(packageRoot(), "agents"), "builtin"), cwd, loaded),
107
136
  user: applyAgentOverrides(readAgentDir(path.join(userPiRoot(), "agents"), "user"), cwd, loaded),
108
137
  project: applyAgentOverrides(readAgentDir(path.join(projectCrewRoot(cwd), "agents"), "project"), cwd, loaded),
109
138
  };
139
+ discoveryCache.set(cwd, { result, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS });
140
+ while (discoveryCache.size > DISCOVERY_CACHE_MAX_ENTRIES) {
141
+ const oldest = discoveryCache.keys().next().value;
142
+ if (oldest !== undefined) discoveryCache.delete(oldest);
143
+ }
144
+ return result;
145
+ }
146
+
147
+ // ─── Dynamic Agent Registry (Phase 3b) ───────────────────────────────────
148
+ // In-memory store for runtime-registered agents. Merged into discovery results
149
+ // with highest priority (after project agents).
150
+
151
+ const dynamicAgents = new Map<string, AgentConfig>();
152
+
153
+ /** Register a dynamic agent at runtime. Throws if already registered. */
154
+ export function registerDynamicAgent(config: AgentConfig): void {
155
+ const key = config.name.toLowerCase();
156
+ if (dynamicAgents.has(key)) {
157
+ throw new Error(`Agent already registered: ${config.name}`);
158
+ }
159
+ dynamicAgents.set(key, { ...config, source: config.source ?? "project" });
160
+ invalidateAgentDiscoveryCache();
161
+ }
162
+
163
+ /** Unregister a previously registered dynamic agent. Throws if not found. */
164
+ export function unregisterDynamicAgent(name: string): void {
165
+ const removed = dynamicAgents.delete(name.toLowerCase());
166
+ if (!removed) {
167
+ throw new Error(`Agent not found: ${name}`);
168
+ }
169
+ invalidateAgentDiscoveryCache();
170
+ }
171
+
172
+ /** List all currently registered dynamic agents. */
173
+ export function listDynamicAgents(): AgentConfig[] {
174
+ return [...dynamicAgents.values()];
110
175
  }
111
176
 
112
177
  export function allAgents(discovery: AgentDiscoveryResult): AgentConfig[] {
113
178
  const byName = new Map<string, AgentConfig>();
114
- // Priority: project > builtin > user for disambiguation.
115
- // This means a project agent with the same name as a builtin/user agent is shadowed
116
- // (security: project config cannot override trusted builtins).
179
+ // Priority for disambiguation (security): project < builtin < user.
180
+ // Project config cannot override trusted builtins (security-hardening).
181
+ // Later entries in the loop overwrite earlier ones, so user wins.
117
182
  for (const agent of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
118
183
  byName.set(agent.name.toLowerCase(), agent);
119
184
  }
185
+ // Dynamic agents (registered at runtime) take highest precedence.
186
+ // They can override any discovered agent (project/builtin/user).
187
+ for (const agent of dynamicAgents.values()) {
188
+ byName.set(agent.name.toLowerCase(), agent);
189
+ }
120
190
  return [...byName.values()].filter((agent) => !agent.disabled).sort((a, b) => a.name.localeCompare(b.name));
121
191
  }
@@ -445,35 +445,36 @@ export function registerPiTeams(pi: ExtensionAPI): void {
445
445
  // Register global RPC registry for cross-extension access (mirrors pi-subagents3's Symbol.for pattern)
446
446
  // Uses lazy import to avoid pulling team-tool.ts into module load.
447
447
  // Other extensions access via: const reg = globalThis[Symbol.for("pi-crew:registry")];
448
- void import("./team-tool.ts").then(({ registerCrewGlobalRegistry }) => {
448
+ void import("./team-tool.ts").then(({ registerCrewGlobalRegistry, installCrewGlobalRegistry }) => {
449
+ // Phase 3b: installCrewGlobalRegistry creates a v2 registry with agent registration API.
450
+ // We then patch the manifest-backed methods with real implementations below.
449
451
  const manifestCacheForRegistry = getManifestCache(currentCtx?.cwd ?? process.cwd());
450
- registerCrewGlobalRegistry({
451
- version: 1,
452
- getRecord: (runId) => manifestCacheForRegistry.get(runId),
453
- listRuns: () => manifestCacheForRegistry.list(100).map((m) => ({ runId: m.runId, status: m.status, goal: m.goal })),
454
- appendEvent: (runId, event) => {
455
- const manifest = manifestCacheForRegistry.get(runId);
456
- if (manifest) void import("../state/event-log.ts").then(({ appendEventFireAndForget }) => appendEventFireAndForget(manifest.eventsPath, event as Parameters<typeof appendEventFireAndForget>[1]));
457
- },
458
- waitForAll: async (runId) => {
459
- // LAZY: loadRunManifestById is already imported at top of file, but kept here for consistency
460
- const { loadRunManifestById } = await import("../state/state-store.ts");
461
- const check = (): boolean => {
462
- const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
463
- if (!loaded) return true;
464
- return !loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
465
- };
466
- while (!check()) await new Promise((resolve) => setTimeout(resolve, 500));
467
- },
468
- hasRunning: (runId) => {
469
- const manifest = manifestCacheForRegistry.get(runId);
470
- if (!manifest) return false;
471
- const { loadRunManifestById } = require("../state/state-store.ts");
452
+ installCrewGlobalRegistry();
453
+ const CREW_REGISTRY_KEY = Symbol.for("pi-crew:registry");
454
+ const registry = (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] as Record<string, unknown>;
455
+ registry.getRecord = (runId: string) => manifestCacheForRegistry.get(runId);
456
+ registry.listRuns = () => manifestCacheForRegistry.list(100).map((m: { runId: string; status: string; goal: string }) => ({ runId: m.runId, status: m.status, goal: m.goal }));
457
+ registry.appendEvent = (runId: string, event: Record<string, unknown>) => {
458
+ const manifest = manifestCacheForRegistry.get(runId);
459
+ if (manifest) void import("../state/event-log.ts").then(({ appendEventFireAndForget }) => appendEventFireAndForget(manifest.eventsPath, event as Parameters<typeof appendEventFireAndForget>[1]));
460
+ };
461
+ registry.waitForAll = async (runId: string) => {
462
+ const { loadRunManifestById } = await import("../state/state-store.ts");
463
+ const check = (): boolean => {
472
464
  const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
473
- if (!loaded) return false;
474
- return loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
475
- },
476
- });
465
+ if (!loaded) return true;
466
+ return !loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
467
+ };
468
+ while (!check()) await new Promise((resolve) => setTimeout(resolve, 500));
469
+ };
470
+ registry.hasRunning = (runId: string) => {
471
+ const manifest = manifestCacheForRegistry.get(runId);
472
+ if (!manifest) return false;
473
+ const { loadRunManifestById } = require("../state/state-store.ts");
474
+ const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
475
+ if (!loaded) return false;
476
+ return loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
477
+ };
477
478
  });
478
479
 
479
480
  const cleanupRuntime = (): void => {
@@ -22,6 +22,7 @@ import { t } from "../../i18n.ts";
22
22
  import { loadRunManifestById } from "../../state/state-store.ts";
23
23
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
24
24
  import { formatCompactToolProgress } from "../../ui/tool-progress-formatter.ts";
25
+ import { renderAgentToolCall, renderAgentToolResult } from "../../ui/tool-render.ts";
25
26
 
26
27
  const TOOL_PROGRESS_TICK_MS = 1000;
27
28
 
@@ -95,6 +96,12 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
95
96
  }
96
97
  return foregroundResult;
97
98
  },
99
+ renderCall(args: any, theme: any, context: any): any {
100
+ return renderAgentToolCall(args, theme, context);
101
+ },
102
+ renderResult(result: any, options: any, theme: any, context: any): any {
103
+ return renderAgentToolResult(result, options, theme, context);
104
+ },
98
105
  };
99
106
 
100
107
  const getSubagentResultTool: ToolDefinition = {
@@ -9,6 +9,7 @@ import type { createManifestCache } from "../../runtime/manifest-cache.ts";
9
9
  import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
10
10
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
11
11
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
12
+ import { renderTeamToolCall, renderTeamToolResult } from "../../ui/tool-render.ts";
12
13
  // Team tool handler — lazy-loaded because team-tool.ts imports many modules
13
14
  import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts";
14
15
  let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
@@ -104,6 +105,12 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
104
105
  stopProgress.stop();
105
106
  }
106
107
  },
108
+ renderCall(args: any, theme: any, context: any): any {
109
+ return renderTeamToolCall(args, theme, context);
110
+ },
111
+ renderResult(result: any, options: any, theme: any, context: any): any {
112
+ return renderTeamToolResult(result, options, theme, context);
113
+ },
107
114
  };
108
115
  pi.registerTool(tool);
109
116
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { allAgents, discoverAgents } from "../agents/discover-agents.ts";
3
+ import { allAgents, discoverAgents, invalidateAgentDiscoveryCache, registerDynamicAgent, unregisterDynamicAgent, listDynamicAgents } from "../agents/discover-agents.ts";
4
+ import type { AgentConfig } from "../agents/agent-config.ts";
4
5
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
5
6
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
6
7
  import { loadConfig, updateAutonomousConfig, updateConfig } from "../config/config.ts";
@@ -383,14 +384,25 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
383
384
  */
384
385
  const CREW_REGISTRY_KEY = Symbol.for("pi-crew:registry");
385
386
  interface CrewRegistry {
386
- version: 1;
387
+ version: 2;
387
388
  getRecord: (runId: string) => TeamRunManifest | undefined;
388
389
  listRuns: () => Array<{ runId: string; status: string; goal: string }>;
389
390
  appendEvent: (runId: string, event: Record<string, unknown>) => void;
390
391
  waitForAll: (runId: string) => Promise<void>;
391
392
  hasRunning: (runId: string) => boolean;
393
+ /** Register a dynamic agent at runtime. Invalidates the discovery cache. */
394
+ registerAgent: (config: AgentConfig) => void;
395
+ /** Unregister a previously registered dynamic agent. Invalidates the discovery cache. */
396
+ unregisterAgent: (name: string) => void;
397
+ /** List all currently registered dynamic agents. */
398
+ listDynamicAgents: () => AgentConfig[];
392
399
  }
393
400
 
401
+ // ─── Dynamic Agent Registry (Phase 3b) ───────────────────────────────────
402
+ // The dynamic agent store lives in discover-agents.ts and is merged into
403
+ // discovery results with highest priority. The CrewRegistry interface exposes
404
+ // registerAgent/unregisterAgent/listDynamicAgents for cross-extension access.
405
+
394
406
  export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
395
407
  (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] = registry;
396
408
  }
@@ -398,3 +410,18 @@ export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
398
410
  export function getCrewGlobalRegistry(): CrewRegistry | undefined {
399
411
  return (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] as CrewRegistry | undefined;
400
412
  }
413
+
414
+ /** Create and install the global CrewRegistry singleton. Call once at extension init. */
415
+ export function installCrewGlobalRegistry(): void {
416
+ registerCrewGlobalRegistry({
417
+ version: 2,
418
+ getRecord: (runId: string) => undefined as unknown as TeamRunManifest,
419
+ listRuns: () => [],
420
+ appendEvent: () => {},
421
+ waitForAll: async () => {},
422
+ hasRunning: () => false,
423
+ registerAgent: registerDynamicAgent,
424
+ unregisterAgent: unregisterDynamicAgent,
425
+ listDynamicAgents,
426
+ });
427
+ }
@@ -96,8 +96,23 @@ export class HeartbeatWatcher {
96
96
  activeKeys.add(key);
97
97
  this.lastSeen.set(key, now);
98
98
 
99
- const elapsed = heartbeatAgeMs(task.heartbeat, now);
100
- const level = classifyHeartbeat(task.heartbeat, thresholds, now);
99
+ // Check heartbeat staleness with lastActivityAt fallback
100
+ let elapsed = heartbeatAgeMs(task.heartbeat, now);
101
+ // PR #6 partial: use lastActivityAt as fallback when heartbeat is stale
102
+ // If heartbeat is stale but lastActivityAt is fresher, use activity age instead.
103
+ // This prevents false-positive dead detection for live-session tasks during long operations.
104
+ if (task.agentProgress?.lastActivityAt) {
105
+ const activityAt = new Date(task.agentProgress.lastActivityAt).getTime();
106
+ if (Number.isFinite(activityAt)) {
107
+ const activityAge = now - activityAt;
108
+ // Use activity age if it's fresher than heartbeat age
109
+ // (no upper bound - if agent has recent activity, trust it even if old)
110
+ if (activityAge < elapsed) {
111
+ elapsed = activityAge;
112
+ }
113
+ }
114
+ }
115
+ const level = elapsed > thresholds.deadMs ? "dead" : elapsed > thresholds.staleMs ? "stale" : elapsed > thresholds.warnMs ? "warn" : "healthy";
101
116
  this.opts.registry.gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds").set({ runId: run.runId, taskId: task.id }, Number.isFinite(elapsed) ? elapsed : thresholds.deadMs);
102
117
  this.opts.registry.counter("crew.heartbeat.level_total", "Heartbeat classifications by level").inc({ runId: run.runId, level });
103
118
  const previous = this.lastLevel.get(key);