pi-crew 0.2.24 → 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,331 @@
1
+ /**
2
+ * Shared rendering for pi-crew's tool TUI display.
3
+ * Ports logic from pi-subagent4 adapted for pi-crew's data model.
4
+ * Uses @mariozechner/pi-tui Components (Container, Text, Spacer) directly.
5
+ */
6
+ import { Container, Spacer, Text, visibleWidth } from "@mariozechner/pi-tui";
7
+ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
8
+
9
+ // ── Types ──────────────────────────────────────────────────────────────
10
+ export interface Theme {
11
+ fg(color: ThemeColor, text: string): string;
12
+ bold(text: string): string;
13
+ }
14
+ export type ThemeColor = "success" | "error" | "warning" | "dim" | "toolTitle" | "accent" | "muted" | "text";
15
+ export interface ToolRenderContext { expanded: boolean; lastComponent?: Container }
16
+ export type Component = Container | Text;
17
+
18
+ export interface TeamToolResultDetails {
19
+ action?: string; status?: string; runId?: string; goal?: string;
20
+ team?: string; workflow?: string; error?: string; agentRecords?: CrewAgentRecord[];
21
+ }
22
+ export interface AgentToolResultDetails {
23
+ results?: Array<{ agentId?: string; status?: string; output?: string; error?: string }>;
24
+ }
25
+
26
+ // ── Helpers ─────────────────────────────────────────────────────────
27
+
28
+ export function formatTokens(n: number): string {
29
+ if (n < 1000) return String(n);
30
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
31
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
32
+ return `${(n / 1_000_000).toFixed(1)}M`;
33
+ }
34
+
35
+ export function formatDuration(ms: number): string {
36
+ if (ms < 1000) return `${ms}ms`;
37
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
38
+ const m = Math.floor(ms / 60_000), s = Math.floor((ms % 60_000) / 1000);
39
+ return s > 0 ? `${m}m${s}s` : `${m}m`;
40
+ }
41
+
42
+ export function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
43
+ if (!contextWindow) return `${formatTokens(tokens)} ctx`;
44
+ const pct = (tokens / contextWindow) * 100;
45
+ const maxStr = contextWindow >= 1_000_000 ? `${(contextWindow / 1_000_000).toFixed(1)}M` : `${Math.round(contextWindow / 1000)}k`;
46
+ return `${pct.toFixed(1)}%/${maxStr}`;
47
+ }
48
+
49
+ export function truncLine(text: string, maxWidth: number): string {
50
+ if (text.includes("\n") || text.includes("\r")) text = text.replace(/\r?\n/g, "↵ ");
51
+ if (visibleWidth(text) <= maxWidth) return text;
52
+ let result = "", width = 0;
53
+ for (let i = 0; i < text.length; i++) {
54
+ if (text[i] === "\x1b") {
55
+ const m = text.slice(i).match(/^\x1b\[[0-9;]*m/);
56
+ if (m) { result += m[0]; i += m[0].length - 1; continue; }
57
+ }
58
+ if (width >= maxWidth - 1) return result + "…";
59
+ result += text[i]; width++;
60
+ }
61
+ return result;
62
+ }
63
+
64
+ export function formatToolPreview(name: string, args: Record<string, unknown>): string {
65
+ switch (name) {
66
+ case "bash": case "safe_bash": return `$ ${((args.command as string) || "").slice(0, 80)}`;
67
+ case "read": return `read ${(args.path as string) || ""}`;
68
+ case "write": return `write ${(args.path as string) || ""}`;
69
+ case "edit": return `edit ${(args.path as string) || ""}`;
70
+ case "grep": case "find": return `${name} ${((args.pattern || args.path) as string) || ""}`;
71
+ case "ls": return `ls ${(args.path as string) || "."}`;
72
+ case "web_search": case "search": return `search "${((args.query as string) || "").slice(0, 60)}"`;
73
+ case "web_fetch": case "fetch": return `fetch ${(args.url as string) || ""}`;
74
+ case "team": return `team action=${(args.action as string) || ""}`;
75
+ case "agent": return `agent ${(args.agent as string) || ""}`;
76
+ default: { const s = JSON.stringify(args); return `${name} ${s.slice(0, 60)}`; }
77
+ }
78
+ }
79
+
80
+ // ── Tool Call Renderers ─────────────────────────────────────────────
81
+
82
+ /** team tool call: collapsed "team action='run' impl..." / expanded: header + goal */
83
+ export function renderTeamToolCall(
84
+ args: { action?: string; goal?: string; team?: string; workflow?: string },
85
+ theme: Theme, context: ToolRenderContext,
86
+ ): Component {
87
+ const action = args.action || "", goal = args.goal || "";
88
+ const team = args.team ? ` ${theme.fg("dim", `(${args.team})`)}` : "";
89
+
90
+ if (!context.expanded) {
91
+ const preview = goal.length > 60 ? goal.slice(0, 60) + "…" : goal;
92
+ return new Text(
93
+ `${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
94
+ 0, 0,
95
+ );
96
+ }
97
+ const c = context.lastComponent instanceof Container ? (context.lastComponent.clear(), context.lastComponent) : new Container();
98
+ c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}`, 0, 0));
99
+ if (goal) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("text", goal), 0, 0)); }
100
+ return c;
101
+ }
102
+
103
+ /** agent tool call: collapsed "Agent explorer..." / expanded: header + prompt */
104
+ export function renderAgentToolCall(
105
+ args: { agent?: string; prompt?: string; task?: string; cwd?: string },
106
+ theme: Theme, context: ToolRenderContext,
107
+ ): Component {
108
+ const agentName = args.agent || "", prompt = args.prompt || args.task || "";
109
+
110
+ if (!context.expanded) {
111
+ const preview = prompt.length > 60 ? prompt.slice(0, 60) + "…" : prompt;
112
+ return new Text(
113
+ `${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
114
+ 0, 0,
115
+ );
116
+ }
117
+ const c = context.lastComponent instanceof Container ? (context.lastComponent.clear(), context.lastComponent) : new Container();
118
+ const cwdLabel = args.cwd ? theme.fg("dim", ` (cwd: ${args.cwd})`) : "";
119
+ c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${cwdLabel}`, 0, 0));
120
+ if (prompt) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("text", prompt), 0, 0)); }
121
+ return c;
122
+ }
123
+
124
+ // ── Agent Progress Renderer ──────────────────────────────────────────
125
+
126
+ /**
127
+ * Render a single crew agent's progress block.
128
+ * Icon: ⟳ running ○ pending ✓ completed ✗ failed
129
+ * Header: "✓ executor (model) — 5 tools · 12.3s"
130
+ * Tool log: "▸ bash: $ npm test" / " read: src/index.ts"
131
+ * Usage: "↑12k ↓3k R45k W0 $0.023"
132
+ */
133
+ export function renderAgentProgress(
134
+ record: CrewAgentRecord, theme: Theme, expanded: boolean, w: number,
135
+ ): Container {
136
+ const c = new Container();
137
+ const prog = record.progress;
138
+ const isRunning = record.status === "running";
139
+ const isPending = record.status === "queued" || record.status === "waiting";
140
+ const innerW = Math.max(20, w);
141
+
142
+ const addLine = (content: string) =>
143
+ c.addChild(new Text(expanded ? content : truncLine(content, innerW), 0, 0));
144
+
145
+ // Status icon
146
+ const icon = isRunning ? theme.fg("warning", "⟳")
147
+ : isPending ? theme.fg("dim", "○")
148
+ : record.status === "completed" ? theme.fg("success", "✓")
149
+ : theme.fg("error", "✗");
150
+
151
+ // Duration
152
+ const durationMs = prog?.durationMs ?? computeDurationMs(record.startedAt, record.completedAt);
153
+ const stats = `${prog?.toolCount ?? record.toolUses ?? 0} tools · ${formatDuration(durationMs)}`;
154
+ const modelStr = record.model ? ` (${record.model})` : "";
155
+ const roleLabel = record.role || record.agent || "agent";
156
+ addLine(`${icon} ${theme.fg("toolTitle", theme.bold(roleLabel))}${theme.fg("dim", modelStr)} — ${theme.fg("dim", stats)}`);
157
+
158
+ // Current tool (running)
159
+ if (isRunning && prog?.currentTool) {
160
+ const toolLabel = formatToolPreview(prog.currentTool, parseArgs(prog.currentToolArgs));
161
+ addLine(theme.fg("warning", `▸ ${prog.currentTool}: ${toolLabel}`));
162
+ }
163
+
164
+ // Recent tools log
165
+ if (prog?.recentTools?.length) {
166
+ for (const tool of prog.recentTools) {
167
+ const detail = tool.args ? `: ${tool.args}` : "";
168
+ const line = tool.endedAt
169
+ ? theme.fg("muted", ` ${tool.tool}${detail}`)
170
+ : theme.fg("warning", `▸ ${tool.tool}${detail}`);
171
+ addLine(line);
172
+ }
173
+ }
174
+
175
+ // Last assistant message (prose)
176
+ const lastOutput = prog?.recentOutput?.slice(-1)[0];
177
+ if (lastOutput?.trim()) {
178
+ c.addChild(new Spacer(1));
179
+ addLine(theme.fg("text", truncLine(lastOutput.replace(/\s+/g, " ").trim(), innerW)));
180
+ }
181
+
182
+ // Error
183
+ if (record.error) addLine(theme.fg("error", `Error: ${record.error}`));
184
+
185
+ // Usage line
186
+ const usage = record.usage;
187
+ const parts: string[] = [];
188
+ if (usage?.input) parts.push(theme.fg("dim", `↑${formatTokens(usage.input)}`));
189
+ if (usage?.output) parts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
190
+ if (usage?.cacheRead) parts.push(theme.fg("dim", `R${formatTokens(usage.cacheRead)}`));
191
+ if (usage?.cacheWrite) parts.push(theme.fg("dim", `W${formatTokens(usage.cacheWrite)}`));
192
+ if (usage?.cost) parts.push(theme.fg("dim", `$${usage.cost.toFixed(3)}`));
193
+ const tokens = prog?.tokens ?? 0;
194
+ if (tokens > 0) parts.push(theme.fg("dim", `${formatTokens(tokens)} ctx`));
195
+ if (parts.length) { c.addChild(new Spacer(1)); addLine(parts.join(" ")); }
196
+
197
+ return c;
198
+ }
199
+
200
+ // ── Tool Result Renderers ──────────────────────────────────────────────
201
+
202
+ /** team tool result: 'run' shows agent progress rows, else compact summary */
203
+ export function renderTeamToolResult(
204
+ result: { details?: TeamToolResultDetails; content?: unknown[] } & Record<string, unknown>,
205
+ _options: unknown, theme: Theme, _context: unknown,
206
+ ): Component {
207
+ // Handle both nested details (result.details) and flattened result shape (details at root level)
208
+ const d = (result as any).details;
209
+
210
+ // If details is explicitly undefined/null, check if result itself looks like details (flattened)
211
+ // This handles cases where the result object has details properties at root level
212
+ if (d === undefined || d === null) {
213
+ // Check if result has detail-like properties to treat as flattened details
214
+ if ("action" in result || "status" in result || "runId" in result || "agentRecords" in result) {
215
+ // Use result as the details object
216
+ const c = new Container();
217
+ const records = (result as any).agentRecords ?? (result as any).results;
218
+ if ((result as any).action === "run" && records?.length) {
219
+ for (const r of records) c.addChild(renderAgentProgress(r, theme, false, 116));
220
+ return c;
221
+ }
222
+ // For 'run' action without records: show goal prominently with status badge
223
+ if ((result as any).action === "run") {
224
+ const goalText = (result as any).goal || "";
225
+ const statusBadge = (result as any).status ? theme.fg((result as any).status === "completed" ? "success" : (result as any).status === "failed" ? "error" : "warning", `[${(result as any).status}]`) + " " : "";
226
+ return new Text(statusBadge + theme.fg("text", truncLine(goalText, 116)), 0, 0);
227
+ }
228
+ // For other actions: compact info line
229
+ const parts: string[] = [];
230
+ if ((result as any).status) parts.push(`status=${(result as any).status}`);
231
+ if ((result as any).runId) parts.push(`runId=${(result as any).runId}`);
232
+ if ((result as any).error) parts.push(theme.fg("error", `error`));
233
+ if ((result as any).goal && parts.length === 0) parts.push(theme.fg("dim", truncLine((result as any).goal, 116)));
234
+ return new Text(parts.join(" "), 0, 0);
235
+ }
236
+ // No details found, fall back to content
237
+ const text = extractText(result?.content).slice(0, 200);
238
+ return new Text(text, 0, 0);
239
+ }
240
+
241
+ const c = new Container();
242
+ // Support both 'results' array from subagents and direct agentRecords
243
+ const records = d.agentRecords ?? d.results;
244
+ if (d.action === "run" && records?.length) {
245
+ for (const r of records) c.addChild(renderAgentProgress(r, theme, false, 116));
246
+ return c;
247
+ }
248
+ const parts: string[] = [];
249
+ if (d.status) parts.push(`status=${d.status}`);
250
+ if (d.runId) parts.push(`runId=${d.runId}`);
251
+ if (d.team) parts.push(`team=${d.team}`);
252
+ if (d.workflow) parts.push(`workflow=${d.workflow}`);
253
+ if (d.error) parts.push(theme.fg("error", `error=${d.error}`));
254
+ if (d.goal) parts.push(theme.fg("dim", truncLine(d.goal, 116)));
255
+ if (parts.length === 0) return new Text(theme.fg("muted", "(no output)"), 0, 0);
256
+ return new Text(parts.join(" · "), 0, 0);
257
+ }
258
+
259
+ /** agent tool result: shows agent output rows with status icons */
260
+ export function renderAgentToolResult(
261
+ result: { details?: AgentToolResultDetails; content?: unknown[] } & Record<string, unknown>,
262
+ _options: unknown, theme: Theme, _context: unknown,
263
+ ): Component {
264
+ // Handle both nested details and flattened result shape
265
+ const d = (result as any).details ?? result;
266
+ const c = new Container();
267
+ const w = 116;
268
+
269
+ // Check for results array (from subagent) OR single agent properties (agentId, status)
270
+ const results = d?.results;
271
+ if (results?.length) {
272
+ for (const item of results) {
273
+ const icon = item.status === "completed" ? theme.fg("success", "✓")
274
+ : item.status === "failed" ? theme.fg("error", "✗")
275
+ : item.status === "running" ? theme.fg("warning", "⟳")
276
+ : theme.fg("dim", "○");
277
+ const label = item.agentId || "agent";
278
+ c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
279
+ if (item.error) {
280
+ c.addChild(new Text(theme.fg("error", ` Error: ${item.error}`), 0, 0));
281
+ } else if (item.output) {
282
+ for (const line of item.output.split("\n").slice(0, 5))
283
+ c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
284
+ }
285
+ }
286
+ return c;
287
+ }
288
+
289
+ // Handle single agent result shape: { agentId, runId, status, output }
290
+ if (d?.agentId) {
291
+ const icon = d.status === "completed" ? theme.fg("success", "✓")
292
+ : d.status === "failed" ? theme.fg("error", "✗")
293
+ : d.status === "running" ? theme.fg("warning", "⟳")
294
+ : theme.fg("dim", "○");
295
+ const label = d.agentId;
296
+ c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
297
+ if (d.error) {
298
+ c.addChild(new Text(theme.fg("error", ` Error: ${d.error}`), 0, 0));
299
+ } else if (d.output) {
300
+ for (const line of d.output.split("\n").slice(0, 5))
301
+ c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
302
+ }
303
+ return c;
304
+ }
305
+
306
+ return new Text(extractText(result?.content).slice(0, 200), 0, 0);
307
+ }
308
+
309
+ // ── Utilities ─────────────────────────────────────────────────────────
310
+
311
+ function extractText(content: unknown[] | undefined): string {
312
+ if (!content) return "(no output)";
313
+ if (!Array.isArray(content)) return String(content);
314
+ return content.filter((c: any) => c?.type === "text").map((c: any) => c?.text ?? "").join("\n") || "(no output)";
315
+ }
316
+
317
+ function parseArgs(argsStr: string | undefined): Record<string, unknown> {
318
+ if (!argsStr) return {};
319
+ try {
320
+ const p = JSON.parse(argsStr);
321
+ return typeof p === "object" && p !== null ? p as Record<string, unknown> : {};
322
+ } catch { return {}; }
323
+ }
324
+
325
+ function computeDurationMs(startedAt: string, completedAt?: string): number {
326
+ if (!startedAt) return 0;
327
+ const start = new Date(startedAt).getTime();
328
+ if (isNaN(start)) return 0;
329
+ if (completedAt) { const end = new Date(completedAt).getTime(); return isNaN(end) ? 0 : Math.max(0, end - start); }
330
+ return Math.max(0, Date.now() - start);
331
+ }
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ console.log("=== PI-CREW BUG FIXES VERIFICATION ===\n");
5
+
6
+ let allPassed = true;
7
+
8
+ // Bug #17: Check killAsync is commented out
9
+ console.log("Bug #17: Background runner session shutdown fix");
10
+ const registerContent = fs.readFileSync("src/extension/register.ts", "utf-8");
11
+ const killAsyncMatch = registerContent.match(/\/\/\s*for\s*\(\s*const\s+manifest\s+of\s+manifestCache\.list\(50\)/);
12
+ if (killAsyncMatch) {
13
+ console.log(" ✅ killAsync loop is commented out");
14
+ } else if (registerContent.includes("for (const manifest of manifestCache.list(50))") && !registerContent.includes("// for (const manifest")) {
15
+ console.log(" ❌ killAsync loop is NOT commented out - BUG NOT FIXED");
16
+ allPassed = false;
17
+ } else {
18
+ console.log(" ✅ killAsync pattern not found (may have been refactored)");
19
+ }
20
+
21
+ // Bug #18: Check stdio is ["ignore", "pipe", "pipe"]
22
+ console.log("\nBug #18: Child-pi stdin fix");
23
+ const childPiContent = fs.readFileSync("src/runtime/child-pi.ts", "utf-8");
24
+ const stdioMatch = childPiContent.match(/stdio:\s*\[\s*"ignore"\s*,\s*"pipe"\s*,\s*"pipe"\s*\]/);
25
+ if (stdioMatch) {
26
+ console.log(" ✅ stdio is ['ignore', 'pipe', 'pipe']");
27
+ } else if (childPiContent.includes('stdio: ["pipe", "pipe", "pipe"]')) {
28
+ console.log(" ❌ stdio is still ['pipe', 'pipe', 'pipe'] - BUG NOT FIXED");
29
+ allPassed = false;
30
+ } else {
31
+ console.log(" ⚠️ stdio pattern not found in expected format");
32
+ }
33
+
34
+ // Bug #19: Check temp workspace cleanup
35
+ console.log("\nBug #19: Phantom runs temp workspace fix");
36
+ const runIndexContent = fs.readFileSync("src/extension/run-index.ts", "utf-8");
37
+ const tempDirCheck = runIndexContent.includes("isTempRoot") || runIndexContent.includes("tmpdir") || runIndexContent.includes("tmpDir");
38
+ const activeRunContent = fs.readFileSync("src/state/active-run-registry.ts", "utf-8");
39
+ const timeoutCheck = activeRunContent.includes("30 * 60 * 1000") || activeRunContent.includes("30*60*1000");
40
+ if (tempDirCheck && timeoutCheck) {
41
+ console.log(" ✅ Temp workspace detection and 30-min timeout present");
42
+ } else if (!tempDirCheck) {
43
+ console.log(" ❌ Temp workspace detection NOT found - BUG NOT FIXED");
44
+ allPassed = false;
45
+ } else if (!timeoutCheck) {
46
+ console.log(" ❌ 30-min timeout NOT found - BUG NOT FIXED");
47
+ allPassed = false;
48
+ }
49
+
50
+ // Bug #20: Check needs_attention in completedIds
51
+ console.log("\nBug #20: Infinite retry loop fix");
52
+ const teamRunnerContent = fs.readFileSync("src/runtime/team-runner.ts", "utf-8");
53
+ const needsAttentionMatch = teamRunnerContent.match(/status\s*===\s*"needs_attention"/g);
54
+ if (needsAttentionMatch && needsAttentionMatch.length >= 3) {
55
+ console.log(" ✅ needs_attention status checks found (" + needsAttentionMatch.length + " places)");
56
+ } else {
57
+ console.log(" ❌ needs_attention status check NOT found or insufficient - BUG NOT FIXED");
58
+ allPassed = false;
59
+ }
60
+
61
+ // Check the specific completedIds fix
62
+ const completedIdsFix = teamRunnerContent.includes('status === "completed" || t.status === "needs_attention"');
63
+ if (completedIdsFix) {
64
+ console.log(" ✅ completedIds includes needs_attention");
65
+ } else {
66
+ console.log(" ❌ completedIds does NOT include needs_attention - BUG NOT FIXED");
67
+ allPassed = false;
68
+ }
69
+
70
+ // Check dist file
71
+ console.log("\n=== Checking dist/index.mjs ===");
72
+ const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
73
+ const distNeedsAttention = distContent.includes('status === "completed" || t.status === "needs_attention"');
74
+ if (distNeedsAttention) {
75
+ console.log(" ✅ Bug #20 fix is in dist/index.mjs");
76
+ } else {
77
+ console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
78
+ allPassed = false;
79
+ }
80
+
81
+ console.log("\n" + "=".repeat(40));
82
+ console.log(allPassed ? "✅ ALL BUGS ARE FIXED" : "❌ SOME BUGS ARE NOT FIXED");
83
+ console.log("=".repeat(40));
84
+
85
+ process.exit(allPassed ? 0 : 1);
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Test for lastActivityAt fallback in heartbeat-watcher
3
+ * Verifies that tasks with stale heartbeat but recent lastActivityAt are not marked dead
4
+ */
5
+
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import { createMetricRegistry } from "./src/observability/metric-registry.ts";
12
+ import { HeartbeatWatcher } from "./src/runtime/heartbeat-watcher.ts";
13
+ import { createRunManifest, saveRunTasks, updateRunStatus } from "./src/state/state-store.ts";
14
+ import { createManifestCache } from "./src/runtime/manifest-cache.ts";
15
+
16
+ const team = { name: "t", description: "", source: "test", filePath: "t", roles: [{ name: "r", agent: "a" }] };
17
+ const workflow = { name: "w", description: "", source: "test", filePath: "w", steps: [{ id: "s", role: "r", task: "x" }] };
18
+
19
+ test("HeartbeatWatcher uses lastActivityAt fallback - task NOT dead when heartbeat stale but activity recent", () => {
20
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-lastactivity-at-"));
21
+ try {
22
+ fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
23
+ const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
24
+ const manifest = updateRunStatus(created.manifest, "running", "running");
25
+
26
+ // Create task with STALE heartbeat (old lastSeenAt) but RECENT lastActivityAt
27
+ // Heartbeat is from Jan 1, 2026 (stale - 10 minutes old)
28
+ // lastActivityAt is from Jan 1, 2026 00:08:00 (2 minutes old - within dead threshold of 5 minutes)
29
+ const tasksWithHeartbeat = created.tasks.map((task) => ({
30
+ ...task,
31
+ status: "running",
32
+ heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
33
+ // Agent is still active - lastActivityAt is recent (within dead threshold)
34
+ agentProgress: {
35
+ lastActivityAt: "2026-01-01T00:08:00.000Z", // 2 minutes ago
36
+ currentTool: "working",
37
+ toolCount: 5,
38
+ tokens: 1000,
39
+ turns: 2
40
+ }
41
+ }));
42
+
43
+ saveRunTasks(manifest, tasksWithHeartbeat);
44
+ const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
45
+ const notifications = [];
46
+ let deadletters = 0;
47
+ const watcher = new HeartbeatWatcher({
48
+ cwd,
49
+ manifestCache: cache,
50
+ registry: createMetricRegistry(),
51
+ router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
52
+ deadletterTickThreshold: 3,
53
+ onDeadletterTrigger: () => { deadletters += 1; }
54
+ });
55
+
56
+ // Simulate time at 00:10:00 - 10 minutes after heartbeat, 2 minutes after activity
57
+ // With fallback: activity age = 2 minutes < dead threshold (5 minutes) -> should be warn/stale, not dead
58
+ watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
59
+ watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
60
+ watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
61
+
62
+ // Should NOT have any dead notifications because lastActivityAt is recent
63
+ assert.equal(notifications.length, 0, "Should NOT mark task dead when lastActivityAt is recent (within dead threshold)");
64
+ assert.equal(deadletters, 0, "Should NOT trigger deadletter when lastActivityAt is recent");
65
+
66
+ watcher.dispose();
67
+ cache.dispose();
68
+ } finally {
69
+ fs.rmSync(cwd, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ test("HeartbeatWatcher marks task dead when BOTH heartbeat and lastActivityAt are stale", () => {
74
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-both-stale-"));
75
+ try {
76
+ fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
77
+ const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
78
+ const manifest = updateRunStatus(created.manifest, "running", "running");
79
+
80
+ // Create task with BOTH stale heartbeat AND stale lastActivityAt
81
+ // Heartbeat is from Jan 1, 2026 00:00:00 (10 minutes old)
82
+ // lastActivityAt is also from Jan 1, 2026 00:00:00 (also 10 minutes old - beyond dead threshold)
83
+ const tasksWithHeartbeat = created.tasks.map((task) => ({
84
+ ...task,
85
+ status: "running",
86
+ heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
87
+ agentProgress: {
88
+ lastActivityAt: "2026-01-01T00:00:00.000Z", // 10 minutes old - beyond dead threshold
89
+ currentTool: "done",
90
+ toolCount: 5,
91
+ tokens: 1000,
92
+ turns: 2
93
+ }
94
+ }));
95
+
96
+ saveRunTasks(manifest, tasksWithHeartbeat);
97
+ const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
98
+ const notifications = [];
99
+ let deadletters = 0;
100
+ const watcher = new HeartbeatWatcher({
101
+ cwd,
102
+ manifestCache: cache,
103
+ registry: createMetricRegistry(),
104
+ router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
105
+ deadletterTickThreshold: 3,
106
+ onDeadletterTrigger: () => { deadletters += 1; }
107
+ });
108
+
109
+ // Simulate time at 00:10:00 - both heartbeat and activity are 10 minutes old
110
+ watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
111
+ watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
112
+ watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
113
+
114
+ // SHOULD have dead notifications because BOTH are stale (> 5 minutes)
115
+ assert.ok(notifications.length > 0, "Should mark task dead when BOTH heartbeat and lastActivityAt are stale");
116
+ assert.ok(deadletters > 0, "Should trigger deadletter when BOTH are stale");
117
+
118
+ watcher.dispose();
119
+ cache.dispose();
120
+ } finally {
121
+ fs.rmSync(cwd, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test("HeartbeatWatcher without lastActivityAt still marks stale heartbeat as dead", () => {
126
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-no-activity-"));
127
+ try {
128
+ fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
129
+ const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
130
+ const manifest = updateRunStatus(created.manifest, "running", "running");
131
+
132
+ // Create task with stale heartbeat but NO lastActivityAt
133
+ const tasksWithHeartbeat = created.tasks.map((task) => ({
134
+ ...task,
135
+ status: "running",
136
+ heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
137
+ // No agentProgress at all
138
+ }));
139
+
140
+ saveRunTasks(manifest, tasksWithHeartbeat);
141
+ const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
142
+ const notifications = [];
143
+ let deadletters = 0;
144
+ const watcher = new HeartbeatWatcher({
145
+ cwd,
146
+ manifestCache: cache,
147
+ registry: createMetricRegistry(),
148
+ router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
149
+ deadletterTickThreshold: 3,
150
+ onDeadletterTrigger: () => { deadletters += 1; }
151
+ });
152
+
153
+ // Simulate time at 00:10:00 - heartbeat is 10 minutes old
154
+ watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
155
+ watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
156
+ watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
157
+
158
+ // SHOULD have dead notifications because heartbeat is stale and no fallback
159
+ assert.ok(notifications.length > 0, "Should mark task dead when heartbeat stale and no lastActivityAt");
160
+ assert.ok(deadletters > 0, "Should trigger deadletter when no fallback available");
161
+
162
+ watcher.dispose();
163
+ cache.dispose();
164
+ } finally {
165
+ fs.rmSync(cwd, { recursive: true, force: true });
166
+ }
167
+ });
package/test-tp.mjs ADDED
@@ -0,0 +1,12 @@
1
+ import { formatToolProgress, formatCurrentToolLine } from "./src/runtime/tool-progress.ts";
2
+
3
+ const progress = {
4
+ recentTools: [{ tool: "bash", args: "ls", endedAt: "2024-01-01T00:00:00.000Z" }],
5
+ toolCount: 1,
6
+ activityState: "active"
7
+ };
8
+
9
+ const display = formatToolProgress(progress);
10
+ console.log("currentTool:", display.currentTool);
11
+ console.log("toolCount:", display.toolCount);
12
+ console.log("TEST PASSED if no errors above");