pi-crew 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/migration-v0.4-v0.5.md +19 -2
  5. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  6. package/package.json +7 -5
  7. package/src/benchmark/benchmark-runner.ts +45 -0
  8. package/src/benchmark/feedback-loop.ts +5 -0
  9. package/src/config/config.ts +38 -4
  10. package/src/config/defaults.ts +5 -0
  11. package/src/config/suggestions.ts +8 -0
  12. package/src/extension/async-notifier.ts +10 -1
  13. package/src/extension/cross-extension-rpc.ts +1 -1
  14. package/src/extension/notification-router.ts +18 -0
  15. package/src/extension/register.ts +13 -17
  16. package/src/extension/registration/subagent-tools.ts +1 -1
  17. package/src/extension/team-tool/anchor.ts +201 -0
  18. package/src/extension/team-tool/api.ts +2 -1
  19. package/src/extension/team-tool/auto-summarize.ts +154 -0
  20. package/src/extension/team-tool/run.ts +37 -2
  21. package/src/extension/team-tool.ts +44 -2
  22. package/src/hooks/registry.ts +1 -3
  23. package/src/observability/event-bus.ts +13 -4
  24. package/src/observability/event-to-metric.ts +0 -2
  25. package/src/runtime/anchor-manager.ts +473 -0
  26. package/src/runtime/async-runner.ts +8 -4
  27. package/src/runtime/auto-summarize.ts +350 -0
  28. package/src/runtime/background-runner.ts +2 -1
  29. package/src/runtime/budget-tracker.ts +354 -0
  30. package/src/runtime/chain-runner.ts +507 -0
  31. package/src/runtime/child-pi.ts +24 -6
  32. package/src/runtime/crash-recovery.ts +5 -4
  33. package/src/runtime/crew-agent-records.ts +32 -1
  34. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  35. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  36. package/src/runtime/delivery-coordinator.ts +10 -3
  37. package/src/runtime/dynamic-script-runner.ts +482 -0
  38. package/src/runtime/handoff-manager.ts +589 -0
  39. package/src/runtime/hidden-handoff.ts +424 -0
  40. package/src/runtime/live-agent-manager.ts +20 -4
  41. package/src/runtime/live-session-runtime.ts +39 -4
  42. package/src/runtime/manifest-cache.ts +2 -1
  43. package/src/runtime/model-resolver.ts +16 -4
  44. package/src/runtime/phase-tracker.ts +373 -0
  45. package/src/runtime/pipeline-runner.ts +514 -0
  46. package/src/runtime/retry-runner.ts +354 -0
  47. package/src/runtime/sandbox.ts +252 -0
  48. package/src/runtime/scheduler.ts +7 -2
  49. package/src/runtime/subagent-manager.ts +1 -1
  50. package/src/runtime/task-graph.ts +11 -1
  51. package/src/runtime/task-runner.ts +15 -1
  52. package/src/runtime/team-runner.ts +4 -3
  53. package/src/schema/team-tool-schema.ts +31 -0
  54. package/src/skills/discover-skills.ts +5 -0
  55. package/src/state/active-run-registry.ts +19 -3
  56. package/src/state/contracts.ts +9 -0
  57. package/src/state/crew-init.ts +3 -3
  58. package/src/state/decision-ledger.ts +26 -32
  59. package/src/state/event-log-rotation.ts +2 -2
  60. package/src/state/event-log.ts +17 -4
  61. package/src/state/mailbox.ts +35 -1
  62. package/src/state/run-cache.ts +18 -8
  63. package/src/tools/safe-bash-extension.ts +1 -0
  64. package/src/tools/safe-bash.ts +153 -20
  65. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  66. package/src/ui/powerbar-publisher.ts +1 -0
  67. package/src/ui/transcript-cache.ts +13 -0
  68. package/src/utils/bm25-search.ts +16 -8
  69. package/src/utils/env-filter.ts +8 -5
  70. package/src/utils/redaction.ts +169 -15
  71. package/src/utils/sse-parser.ts +10 -1
  72. package/src/worktree/cleanup.ts +6 -1
  73. package/workflows/chain.workflow.md +252 -0
  74. package/workflows/pipeline.workflow.md +27 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * HiddenHandoffService - Sends hidden boomerang-handoff messages to parent agents.
3
+ *
4
+ * Based on pi-boomerang's triggerHiddenOrchestratorHandoff() pattern:
5
+ * - Sends customType: "boomerang-handoff" messages
6
+ * - Full handoff details in hidden message to parent
7
+ * - Orchestrator immediately reads summary
8
+ *
9
+ * @see docs/pi-boomerang-integration-plan.md
10
+ */
11
+
12
+ import type { HandoffSummary } from "./handoff-manager.ts";
13
+
14
+ /**
15
+ * Type of hidden handoff message.
16
+ */
17
+ export type HiddenHandoffType = "boomerang-handoff" | "task-complete" | "context-ready";
18
+
19
+ /**
20
+ * Hidden handoff message sent to parent agent.
21
+ */
22
+ export interface HiddenHandoff {
23
+ type: HiddenHandoffType;
24
+ hidden: true;
25
+ content: HandoffContent;
26
+ metadata: HiddenHandoffMetadata;
27
+ }
28
+
29
+ /**
30
+ * Metadata for hidden handoff message.
31
+ */
32
+ export interface HiddenHandoffMetadata {
33
+ taskId: string;
34
+ runId: string;
35
+ timestamp: number;
36
+ priority: HandoffPriority;
37
+ }
38
+
39
+ /**
40
+ * Priority level for hidden handoffs.
41
+ */
42
+ export type HandoffPriority = "low" | "normal" | "high";
43
+
44
+ /**
45
+ * Content of a hidden handoff message.
46
+ */
47
+ export interface HandoffContent {
48
+ summary: string;
49
+ files: {
50
+ created: string[];
51
+ modified: string[];
52
+ deleted: string[];
53
+ };
54
+ decisions: {
55
+ rationale: string;
56
+ outcome: string;
57
+ }[];
58
+ nextSteps: string[];
59
+ metrics: {
60
+ tokens: number;
61
+ duration: number;
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Options for HiddenHandoffService.
67
+ */
68
+ export interface HiddenHandoffServiceOptions {
69
+ /** Custom mailbox service for sending messages */
70
+ mailbox?: HiddenHandoffMailbox;
71
+ /** Event emitter for handoff events */
72
+ eventEmitter?: HiddenHandoffEventEmitter;
73
+ /** Get parent agent ID callback */
74
+ getParentAgentId?: () => string;
75
+ }
76
+
77
+ /**
78
+ * Mailbox interface for sending hidden handoffs.
79
+ */
80
+ export interface HiddenHandoffMailbox {
81
+ send(recipient: string, message: HiddenHandoff): void;
82
+ }
83
+
84
+ /**
85
+ * Event emitter for hidden handoff events.
86
+ */
87
+ export interface HiddenHandoffEventEmitter {
88
+ emit(event: string, data: unknown): void;
89
+ }
90
+
91
+ /**
92
+ * Event data for hidden handoff sent event.
93
+ */
94
+ export interface HiddenHandoffSentEventData {
95
+ summary: HandoffSummary;
96
+ recipient: string;
97
+ priority: HandoffPriority;
98
+ }
99
+
100
+ /**
101
+ * HiddenHandoffService sends hidden boomerang-handoff messages to parent agents.
102
+ * This enables agents to communicate progress and context without explicit user-visible output.
103
+ */
104
+ export class HiddenHandoffService {
105
+ private mailbox: HiddenHandoffMailbox | null = null;
106
+ private eventEmitter: HiddenHandoffEventEmitter | null = null;
107
+ private getParentAgentIdFn: (() => string) | null = null;
108
+ private enabled = true;
109
+ // C7: Track rate limiting per recipient
110
+ private sendTimestamps = new Map<string, number[]>();
111
+ private readonly RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
112
+ private readonly RATE_LIMIT_MAX_SENDS = 10; // Max handoffs per window
113
+
114
+ constructor(options: HiddenHandoffServiceOptions = {}) {
115
+ if (options.mailbox) {
116
+ this.mailbox = options.mailbox;
117
+ }
118
+ if (options.eventEmitter) {
119
+ this.eventEmitter = options.eventEmitter;
120
+ }
121
+ if (options.getParentAgentId) {
122
+ this.getParentAgentIdFn = options.getParentAgentId;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Check if hidden handoff service is enabled.
128
+ */
129
+ isEnabled(): boolean {
130
+ return this.enabled;
131
+ }
132
+
133
+ /**
134
+ * Enable or disable hidden handoff service.
135
+ */
136
+ setEnabled(enabled: boolean): void {
137
+ this.enabled = enabled;
138
+ }
139
+
140
+ /**
141
+ * Set the mailbox service for sending messages.
142
+ */
143
+ setMailbox(mailbox: HiddenHandoffMailbox): void {
144
+ this.mailbox = mailbox;
145
+ }
146
+
147
+ /**
148
+ * Set the event emitter for events.
149
+ */
150
+ setEventEmitter(eventEmitter: HiddenHandoffEventEmitter): void {
151
+ this.eventEmitter = eventEmitter;
152
+ }
153
+
154
+ /**
155
+ * Set the function to get parent agent ID.
156
+ */
157
+ setGetParentAgentId(fn: () => string): void {
158
+ this.getParentAgentIdFn = fn;
159
+ }
160
+
161
+ /**
162
+ * Send a hidden handoff to the parent agent or specified recipient.
163
+ *
164
+ * @param summary - The handoff summary to send
165
+ * @param options - Send options
166
+ */
167
+ sendHandoff(
168
+ summary: HandoffSummary,
169
+ options: SendHandoffOptions = {},
170
+ ): void {
171
+ if (!this.enabled) {
172
+ return;
173
+ }
174
+
175
+ const priority = options.priority ?? this.inferPriority(summary);
176
+ const content = this.buildContent(summary);
177
+ let recipient = options.to ?? this.getParentAgentId();
178
+
179
+ if (!recipient) {
180
+ // No parent to send to, but we still emit the event
181
+ this.eventEmitter?.emit("handoff:sent_no_recipient", {
182
+ summary,
183
+ priority,
184
+ });
185
+ return;
186
+ }
187
+
188
+ // C7: Validate recipient is a reasonable agent ID
189
+ if (!this.isValidRecipient(recipient)) {
190
+ this.eventEmitter?.emit("handoff:invalid_recipient", {
191
+ recipient,
192
+ summary,
193
+ });
194
+ return;
195
+ }
196
+
197
+ // C7: Check rate limit
198
+ if (this.isRateLimited(recipient)) {
199
+ this.eventEmitter?.emit("handoff:rate_limited", {
200
+ recipient,
201
+ summary,
202
+ });
203
+ return;
204
+ }
205
+
206
+ const message: HiddenHandoff = {
207
+ type: options.customType ?? "boomerang-handoff",
208
+ hidden: true,
209
+ content,
210
+ metadata: {
211
+ taskId: summary.taskId,
212
+ runId: summary.runId,
213
+ timestamp: Date.now(),
214
+ priority,
215
+ },
216
+ };
217
+
218
+ if (this.mailbox) {
219
+ this.mailbox.send(recipient, message);
220
+ }
221
+
222
+ // C7: Record send for rate limiting
223
+ this.recordSend(recipient);
224
+
225
+ this.eventEmitter?.emit("handoff:sent", {
226
+ summary,
227
+ recipient,
228
+ priority,
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Send a hidden handoff immediately (fire and forget).
234
+ */
235
+ sendHandoffAsync(
236
+ summary: HandoffSummary,
237
+ options?: SendHandoffOptions,
238
+ ): void {
239
+ // Fire and forget - no await
240
+ try {
241
+ this.sendHandoff(summary, options);
242
+ } catch (error) {
243
+ // Log but don't throw
244
+ console.error("Hidden handoff failed:", error);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Infer priority based on summary outcome.
250
+ */
251
+ private inferPriority(summary: HandoffSummary): HandoffPriority {
252
+ if (summary.outcome === "failure") {
253
+ return "high";
254
+ }
255
+ if (summary.blockers.length > 0) {
256
+ return "normal";
257
+ }
258
+ if (summary.metrics.tokensUsed > 10000) {
259
+ return "normal";
260
+ }
261
+ return "low";
262
+ }
263
+
264
+ /**
265
+ * Build handoff content from summary.
266
+ */
267
+ private buildContent(summary: HandoffSummary): HandoffContent {
268
+ return {
269
+ summary: this.buildSummaryText(summary),
270
+ files: {
271
+ created: summary.filesCreated,
272
+ modified: summary.filesModified,
273
+ deleted: summary.filesDeleted,
274
+ },
275
+ decisions: summary.decisions.map((d) => ({
276
+ rationale: d.rationale,
277
+ outcome: d.outcome,
278
+ })),
279
+ nextSteps: summary.nextSteps,
280
+ metrics: {
281
+ tokens: summary.metrics.tokensUsed,
282
+ duration: summary.metrics.duration,
283
+ },
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Build summary text from summary.
289
+ */
290
+ private buildSummaryText(summary: HandoffSummary): string {
291
+ const parts: string[] = [
292
+ `Completed: ${summary.task}`,
293
+ `Outcome: ${summary.outcome}`,
294
+ ];
295
+
296
+ if (summary.filesCreated.length > 0) {
297
+ parts.push(`Files created: ${summary.filesCreated.join(", ")}`);
298
+ }
299
+ if (summary.filesModified.length > 0) {
300
+ parts.push(`Files modified: ${summary.filesModified.join(", ")}`);
301
+ }
302
+ if (summary.decisions.length > 0) {
303
+ parts.push(`Decisions: ${summary.decisions.length}`);
304
+ }
305
+ if (summary.blockers.length > 0) {
306
+ parts.push(`Blockers: ${summary.blockers.join("; ")}`);
307
+ }
308
+ if (summary.nextSteps.length > 0) {
309
+ parts.push(`Next steps: ${summary.nextSteps.join("; ")}`);
310
+ }
311
+
312
+ parts.push(
313
+ `Tokens: ${summary.metrics.tokensUsed}`,
314
+ `Duration: ${Math.round(summary.metrics.duration / 1000)}s`,
315
+ );
316
+
317
+ return parts.join("\n");
318
+ }
319
+
320
+ /**
321
+ * Get parent agent ID from callback or context.
322
+ */
323
+ private getParentAgentId(): string | undefined {
324
+ if (this.getParentAgentIdFn) {
325
+ return this.getParentAgentIdFn();
326
+ }
327
+ // Fallback: try to get from global context
328
+ const ctx = (globalThis as Record<string, unknown>).__piCrewContext;
329
+ if (ctx && typeof ctx === "object") {
330
+ return (ctx as Record<string, unknown>).parentAgentId as string | undefined;
331
+ }
332
+ return undefined;
333
+ }
334
+
335
+ /**
336
+ * C7: Validate recipient is a reasonable agent ID.
337
+ */
338
+ private isValidRecipient(recipient: string): boolean {
339
+ if (!recipient || typeof recipient !== "string") {
340
+ return false;
341
+ }
342
+ // Reasonable length for an agent ID
343
+ if (recipient.length < 1 || recipient.length > 256) {
344
+ return false;
345
+ }
346
+ // Only allow alphanumeric, hyphen, underscore, colon, and period
347
+ // This prevents injection in mailbox routing
348
+ if (!/^[a-zA-Z0-9_:.-]+$/.test(recipient)) {
349
+ return false;
350
+ }
351
+ return true;
352
+ }
353
+
354
+ /**
355
+ * C7: Check if recipient is rate limited.
356
+ * Also cleans up empty recipient entries to prevent unbounded Map growth.
357
+ */
358
+ private isRateLimited(recipient: string): boolean {
359
+ const now = Date.now();
360
+ const timestamps = this.sendTimestamps.get(recipient) ?? [];
361
+
362
+ // Filter out old timestamps outside the window
363
+ const recentTimestamps = timestamps.filter(
364
+ (t) => now - t < this.RATE_LIMIT_WINDOW_MS,
365
+ );
366
+
367
+ // HIGH-11: If no recent timestamps, remove the empty key to prevent unbounded growth
368
+ if (recentTimestamps.length === 0) {
369
+ this.sendTimestamps.delete(recipient);
370
+ } else {
371
+ this.sendTimestamps.set(recipient, recentTimestamps);
372
+ }
373
+
374
+ return recentTimestamps.length >= this.RATE_LIMIT_MAX_SENDS;
375
+ }
376
+
377
+ /**
378
+ * C7: Record a send for rate limiting.
379
+ */
380
+ private recordSend(recipient: string): void {
381
+ const now = Date.now();
382
+ const timestamps = this.sendTimestamps.get(recipient) ?? [];
383
+ timestamps.push(now);
384
+
385
+ // MEDIUM-14: Use RATE_LIMIT_WINDOW_MS consistently for both filter and record
386
+ const recentTimestamps = timestamps.filter(
387
+ (t) => now - t < this.RATE_LIMIT_WINDOW_MS,
388
+ );
389
+
390
+ this.sendTimestamps.set(recipient, recentTimestamps);
391
+ }
392
+
393
+ /**
394
+ * Dispose of resources.
395
+ * Call this when the service is no longer needed.
396
+ */
397
+ dispose(): void {
398
+ this.mailbox = null;
399
+ this.eventEmitter = null;
400
+ this.getParentAgentIdFn = null;
401
+ this.sendTimestamps.clear();
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Options for sending hidden handoffs.
407
+ */
408
+ export interface SendHandoffOptions {
409
+ /** Recipient agent ID (defaults to parent) */
410
+ to?: string;
411
+ /** Priority level */
412
+ priority?: HandoffPriority;
413
+ /** Custom handoff type */
414
+ customType?: HiddenHandoffType;
415
+ }
416
+
417
+ /**
418
+ * Create a HiddenHandoffService with default options.
419
+ */
420
+ export function createHiddenHandoffService(
421
+ options?: HiddenHandoffServiceOptions,
422
+ ): HiddenHandoffService {
423
+ return new HiddenHandoffService(options);
424
+ }
@@ -3,6 +3,8 @@ import type { IrcMessage } from "./live-irc.ts";
3
3
  import { logInternalError } from "../utils/internal-error.ts";
4
4
  import type { appendEvent } from "../state/event-log.ts";
5
5
 
6
+ const MAX_PENDING_MESSAGES = 1000;
7
+
6
8
  type LiveSessionHandle = {
7
9
  steer?: (text: string) => Promise<void>;
8
10
  prompt?: (text: string, options?: Record<string, unknown>) => Promise<void>;
@@ -103,12 +105,12 @@ export function registerLiveAgent(input: Omit<LiveAgentHandle, "createdAt" | "up
103
105
  if (handle.pendingSteers.length && typeof handle.session.steer === "function") {
104
106
  const pending = [...handle.pendingSteers];
105
107
  handle.pendingSteers.length = 0;
106
- for (const message of pending) void handle.session.steer(message).catch(() => {});
108
+ for (const message of pending) void handle.session.steer(message).catch((error) => logInternalError("live-agent-manager.steer", error, `agentId=${handle.agentId}`));
107
109
  }
108
110
  if (handle.pendingFollowUps.length && typeof handle.session.prompt === "function") {
109
111
  const pending = [...handle.pendingFollowUps];
110
112
  handle.pendingFollowUps.length = 0;
111
- for (const message of pending) void handle.session.prompt(message, { source: "api", expandPromptTemplates: false }).catch(() => {});
113
+ for (const message of pending) void handle.session.prompt(message, { source: "api", expandPromptTemplates: false }).catch((error) => logInternalError("live-agent-manager.prompt", error, `agentId=${handle.agentId}`));
112
114
  }
113
115
  return handle;
114
116
  }
@@ -262,6 +264,14 @@ export async function resumeLiveAgent(agentIdOrTaskId: string, prompt: string):
262
264
  export function trackLiveAgentToolStart(agentIdOrTaskId: string, toolName: string): void {
263
265
  const handle = getLiveAgent(agentIdOrTaskId);
264
266
  if (!handle) return;
267
+ // Evict oldest entries if at capacity
268
+ const MAX_TRACKED_TOOLS = 1000;
269
+ if (handle.activity.activeTools.size >= MAX_TRACKED_TOOLS) {
270
+ const firstKey = handle.activity.activeTools.keys().next().value;
271
+ if (firstKey !== undefined) {
272
+ handle.activity.activeTools.delete(firstKey);
273
+ }
274
+ }
265
275
  handle.activity.activeTools.set(toolName, toolName);
266
276
  handle.activity.toolUses++;
267
277
  handle.updatedAt = new Date().toISOString();
@@ -310,6 +320,9 @@ export function clearLiveAgentsForTest(): void {
310
320
  export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void {
311
321
  const handle = getLiveAgent(targetAgentId);
312
322
  if (!handle) return;
323
+ if (handle.pendingMessages.length >= MAX_PENDING_MESSAGES) {
324
+ handle.pendingMessages.shift();
325
+ }
313
326
  handle.pendingMessages.push(message);
314
327
  handle.updatedAt = new Date().toISOString();
315
328
  // G4: Try non-blocking delivery via sendCustomMessage
@@ -328,7 +341,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
328
341
  // Fallback: inject as prompt (blocking)
329
342
  if (typeof handle.session.prompt === "function") {
330
343
  const ircPrompt = `[Message from ${message.from}] ${message.content}`;
331
- void handle.session.prompt(ircPrompt, { source: "api", expandPromptTemplates: false }).catch(() => {});
344
+ void handle.session.prompt(ircPrompt, { source: "api", expandPromptTemplates: false }).catch((error) => logInternalError("live-agent-manager.irc-deliver", error, `agentId=${handle.agentId}`));
332
345
  }
333
346
  }
334
347
 
@@ -341,6 +354,9 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
341
354
  for (const handle of liveAgents.values()) {
342
355
  if (handle.agentId === fromAgentId) continue;
343
356
  if (handle.status !== "running" && handle.status !== "queued") continue;
357
+ if (handle.pendingMessages.length >= MAX_PENDING_MESSAGES) {
358
+ handle.pendingMessages.shift();
359
+ }
344
360
  handle.pendingMessages.push(message);
345
361
  handle.updatedAt = new Date().toISOString();
346
362
  // G4: Try non-blocking delivery
@@ -360,7 +376,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
360
376
  // Fallback: inject as prompt
361
377
  if (typeof handle.session.prompt === "function") {
362
378
  const ircPrompt = `[Broadcast from ${message.from}] ${message.content}`;
363
- void handle.session.prompt(ircPrompt, { source: "api", expandPromptTemplates: false }).catch(() => {});
379
+ void handle.session.prompt(ircPrompt, { source: "api", expandPromptTemplates: false }).catch((error) => logInternalError("live-agent-manager.irc-broadcast", error, `agentId=${handle.agentId}`));
364
380
  }
365
381
  recipients.push(handle.agentId);
366
382
  }
@@ -105,6 +105,41 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
105
105
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
106
106
  }
107
107
 
108
+ function isString(value: unknown): value is string {
109
+ return typeof value === "string";
110
+ }
111
+
112
+ /**
113
+ * Type-safe extractor for message role from streaming events.
114
+ * Handles the case where message may be a Record with a role field.
115
+ */
116
+ function extractMessageRole(obj: Record<string, unknown> | undefined): string | undefined {
117
+ const message = obj?.message ? asRecord(obj.message) : undefined;
118
+ return isString(message?.role) ? message.role : undefined;
119
+ }
120
+
121
+ /**
122
+ * Type-safe extractor for message usage from streaming events.
123
+ * Handles the case where message may be a Record with usage data.
124
+ */
125
+ function extractMessageUsage(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
126
+ const message = obj?.message ? asRecord(obj.message) : undefined;
127
+ return message?.usage && typeof message.usage === "object" ? message.usage as Record<string, unknown> : undefined;
128
+ }
129
+
130
+ /**
131
+ * Type-safe extractor for tool name from streaming events.
132
+ * Handles various event structures: { tool: { name } }, { toolName }, { name }.
133
+ */
134
+ function extractToolName(obj: Record<string, unknown> | undefined): string | undefined {
135
+ if (!obj) return undefined;
136
+ const tool = asRecord(obj.tool);
137
+ if (isString(tool?.name)) return tool.name;
138
+ if (isString(obj.toolName)) return obj.toolName;
139
+ if (isString(obj.name)) return obj.name;
140
+ return undefined;
141
+ }
142
+
108
143
  function textFromContent(content: unknown): string[] {
109
144
  if (typeof content === "string") return [content];
110
145
  if (!Array.isArray(content)) return [];
@@ -477,8 +512,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
477
512
  }
478
513
  }
479
514
  // Accumulate lifetime usage that survives compaction
480
- if (obj?.type === "message_end" && (obj as any).message?.role === "assistant") {
481
- const u = (obj as any).message?.usage;
515
+ if (obj?.type === "message_end" && extractMessageRole(obj) === "assistant") {
516
+ const u = extractMessageUsage(obj);
482
517
  if (u) {
483
518
  trackTaskUsage(input.task.id, {
484
519
  input: typeof u.input === "number" ? u.input : 0,
@@ -497,11 +532,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
497
532
  }
498
533
  // G2: Track tool start/end for activity display
499
534
  if (obj?.type === "tool_use" || obj?.type === "tool_execution_start") {
500
- const toolName = (obj as any).tool?.name ?? (obj as any).toolName ?? (obj as any).name ?? "unknown";
535
+ const toolName = extractToolName(obj) ?? "unknown";
501
536
  trackLiveAgentToolStart(agentId, toolName);
502
537
  }
503
538
  if (obj?.type === "tool_result" || obj?.type === "tool_execution_end") {
504
- const toolName = (obj as any).tool?.name ?? (obj as any).toolName ?? (obj as any).name ?? "unknown";
539
+ const toolName = extractToolName(obj) ?? "unknown";
505
540
  trackLiveAgentToolEnd(agentId, toolName);
506
541
  }
507
542
  // Phase 1: collect events for yield detection
@@ -152,10 +152,11 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
152
152
  clearTimeout(listTimer);
153
153
  }
154
154
  listTimer = setTimeout(() => {
155
+ const timer = listTimer;
155
156
  listTimer = undefined;
156
157
  listCache.clear();
158
+ timer?.unref();
157
159
  }, ttlMs);
158
- listTimer.unref();
159
160
  }
160
161
 
161
162
  function loadManifest(runId: string, rootsToCheck: string[]): CachedManifest | undefined {
@@ -4,10 +4,22 @@ export interface ModelEntry {
4
4
  provider: string;
5
5
  }
6
6
 
7
+ /**
8
+ * Core Model interface representing a resolved model instance.
9
+ * Used by resolveModel return type to ensure proper typing.
10
+ */
11
+ export interface Model {
12
+ id: string;
13
+ name?: string;
14
+ provider?: string;
15
+ // Allow additional properties from the registry
16
+ [key: string]: unknown;
17
+ }
18
+
7
19
  export interface ModelRegistry {
8
- find(provider: string, modelId: string): any;
9
- getAll(): any[];
10
- getAvailable?(): any[];
20
+ find(provider: string, modelId: string): Model | undefined;
21
+ getAll(): Model[];
22
+ getAvailable?(): Model[];
11
23
  }
12
24
 
13
25
  /**
@@ -15,7 +27,7 @@ export interface ModelRegistry {
15
27
  * Exact match first ("provider/modelId"), then fuzzy match.
16
28
  * Returns Model on success, error message string on failure.
17
29
  */
18
- export function resolveModel(input: string, registry: ModelRegistry): any | string {
30
+ export function resolveModel(input: string, registry: ModelRegistry): Model | string {
19
31
  const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
20
32
  const availableSet = new Set(all.map((m) => `${m.provider}/${m.id}`.toLowerCase()));
21
33