pi-squad 0.1.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,732 @@
1
+ /**
2
+ * scheduler.ts — Dependency DAG resolution, concurrency control, task lifecycle.
3
+ *
4
+ * The scheduler is the core engine. It:
5
+ * - Resolves which tasks are ready (all deps done)
6
+ * - Spawns agents up to maxConcurrency
7
+ * - Auto-unblocks dependents when tasks complete
8
+ * - Kills agents when tasks become re-blocked
9
+ * - Detects squad completion
10
+ */
11
+
12
+ import type { AgentDef, Squad, SquadConfig, Task, TaskMessage, TaskStatus } from "./types.js";
13
+ import { AgentPool, type AgentEvent } from "./agent-pool.js";
14
+ import { Monitor } from "./monitor.js";
15
+ import { Router } from "./router.js";
16
+ import * as store from "./store.js";
17
+ import { buildAgentSystemPrompt } from "./protocol.js";
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export type SchedulerEventType =
24
+ | "task_started"
25
+ | "task_completed"
26
+ | "task_failed"
27
+ | "task_blocked"
28
+ | "task_unblocked"
29
+ | "squad_completed"
30
+ | "squad_failed"
31
+ | "escalation"
32
+ | "activity";
33
+
34
+ export interface SchedulerEvent {
35
+ type: SchedulerEventType;
36
+ squadId: string;
37
+ taskId?: string;
38
+ agentName?: string;
39
+ message?: string;
40
+ data?: any;
41
+ }
42
+
43
+ export type SchedulerEventListener = (event: SchedulerEvent) => void;
44
+
45
+ // ============================================================================
46
+ // Scheduler
47
+ // ============================================================================
48
+
49
+ export class Scheduler {
50
+ private squadId: string;
51
+ private pool: AgentPool;
52
+ private monitor: Monitor;
53
+ private router: Router;
54
+ private listeners: SchedulerEventListener[] = [];
55
+ private skillPaths: string[] = [];
56
+ private running = false;
57
+
58
+ /** Get the project cwd for this squad (from squad.json) */
59
+ getProjectCwd(): string | undefined {
60
+ return store.loadSquad(this.squadId)?.cwd;
61
+ }
62
+
63
+ constructor(squadId: string, skillPaths: string[]) {
64
+ this.squadId = squadId;
65
+ this.skillPaths = skillPaths;
66
+ this.pool = new AgentPool();
67
+ this.monitor = new Monitor(this.pool, squadId);
68
+ this.router = new Router(this.pool, squadId);
69
+
70
+ // Wire up agent events
71
+ this.pool.onEvent((event) => this.handleAgentEvent(event));
72
+
73
+ // Wire up monitor events
74
+ this.monitor.onAction((action) => {
75
+ if (action.type === "steer") {
76
+ this.pool.steer(action.taskId, action.message);
77
+ } else if (action.type === "abort") {
78
+ this.handleTaskFailed(action.taskId, action.reason);
79
+ } else if (action.type === "escalate") {
80
+ this.emit({
81
+ type: "escalation",
82
+ squadId: this.squadId,
83
+ taskId: action.taskId,
84
+ message: action.reason,
85
+ });
86
+ }
87
+ });
88
+
89
+ // Wire up router events
90
+ this.router.onEscalation((taskId, agentName, message) => {
91
+ this.emit({
92
+ type: "escalation",
93
+ squadId: this.squadId,
94
+ taskId,
95
+ agentName,
96
+ message,
97
+ });
98
+ });
99
+ }
100
+
101
+ /** Subscribe to scheduler events */
102
+ onEvent(listener: SchedulerEventListener): () => void {
103
+ this.listeners.push(listener);
104
+ return () => {
105
+ const idx = this.listeners.indexOf(listener);
106
+ if (idx !== -1) this.listeners.splice(idx, 1);
107
+ };
108
+ }
109
+
110
+ private emit(event: SchedulerEvent): void {
111
+ for (const listener of this.listeners) {
112
+ try {
113
+ listener(event);
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Get references for external use */
121
+ getPool(): AgentPool {
122
+ return this.pool;
123
+ }
124
+ getRouter(): Router {
125
+ return this.router;
126
+ }
127
+ getMonitor(): Monitor {
128
+ return this.monitor;
129
+ }
130
+
131
+ // =========================================================================
132
+ // Lifecycle
133
+ // =========================================================================
134
+
135
+ /** Start the scheduler — begins scheduling ready tasks */
136
+ async start(): Promise<void> {
137
+ this.running = true;
138
+ this.monitor.start();
139
+ await this.scheduleReadyTasks();
140
+ }
141
+
142
+ /** Stop the scheduler — kills all agents, saves state */
143
+ async stop(): Promise<void> {
144
+ this.running = false;
145
+ this.monitor.stop();
146
+
147
+ // Suspend in-progress tasks
148
+ const tasks = store.loadAllTasks(this.squadId);
149
+ for (const task of tasks) {
150
+ if (task.status === "in_progress") {
151
+ store.updateTaskStatus(this.squadId, task.id, "suspended");
152
+ }
153
+ }
154
+
155
+ await this.pool.killAll();
156
+ }
157
+
158
+ /** Resume from suspended state */
159
+ async resume(): Promise<void> {
160
+ const tasks = store.loadAllTasks(this.squadId);
161
+ for (const task of tasks) {
162
+ if (task.status === "suspended") {
163
+ store.updateTaskStatus(this.squadId, task.id, "pending");
164
+ }
165
+ }
166
+
167
+ const squad = store.loadSquad(this.squadId);
168
+ if (squad && squad.status === "paused") {
169
+ squad.status = "running";
170
+ store.saveSquad(squad);
171
+ }
172
+
173
+ await this.start();
174
+ }
175
+
176
+ // =========================================================================
177
+ // Task Scheduling
178
+ // =========================================================================
179
+
180
+ /** Find and spawn ready tasks up to concurrency limit */
181
+ private async scheduleReadyTasks(): Promise<void> {
182
+ if (!this.running) {
183
+ console.error("[squad-scheduler] scheduleReadyTasks: not running, skipping");
184
+ return;
185
+ }
186
+
187
+ const squad = store.loadSquad(this.squadId);
188
+ if (!squad || squad.status !== "running") {
189
+ console.error(`[squad-scheduler] scheduleReadyTasks: squad status=${squad?.status}, skipping`);
190
+ return;
191
+ }
192
+
193
+ const tasks = store.loadAllTasks(this.squadId);
194
+ const runningCount = this.pool.getRunningAgents().length;
195
+ const available = squad.config.maxConcurrency - runningCount;
196
+
197
+ console.error(`[squad-scheduler] scheduleReadyTasks: ${tasks.length} tasks, ${runningCount} running, ${available} slots`);
198
+
199
+ if (available <= 0) {
200
+ console.error("[squad-scheduler] scheduleReadyTasks: no available slots");
201
+ return;
202
+ }
203
+
204
+ const ready = this.getReadyTasks(tasks);
205
+ console.error(`[squad-scheduler] scheduleReadyTasks: ${ready.length} ready tasks: ${ready.map(t => t.id).join(", ")}`);
206
+ const toSpawn = ready.slice(0, available);
207
+
208
+ for (const task of toSpawn) {
209
+ try {
210
+ await this.spawnAgentForTask(task, squad);
211
+ } catch (error) {
212
+ console.error(`[squad-scheduler] Failed to spawn ${task.id}: ${(error as Error).message}`);
213
+ }
214
+ }
215
+
216
+ // Check if squad is complete
217
+ this.checkSquadCompletion(tasks, squad);
218
+ }
219
+
220
+ /** Get tasks that are ready to execute (pending + all deps done) */
221
+ private getReadyTasks(tasks: Task[]): Task[] {
222
+ return tasks.filter((task) => {
223
+ if (task.status !== "pending") return false;
224
+ return task.depends.every((depId) => {
225
+ const dep = tasks.find((t) => t.id === depId);
226
+ return dep?.status === "done";
227
+ });
228
+ });
229
+ }
230
+
231
+ /** Spawn an agent for a task */
232
+ private async spawnAgentForTask(task: Task, squad: Squad): Promise<void> {
233
+ const agentDef = store.loadAgentDef(task.agent, squad.cwd);
234
+ if (!agentDef) {
235
+ this.handleTaskFailed(task.id, `Agent definition not found: ${task.agent}`);
236
+ return;
237
+ }
238
+
239
+ // Apply squad-level model override
240
+ const squadAgentEntry = squad.agents[task.agent];
241
+ if (squadAgentEntry?.model) {
242
+ agentDef.model = squadAgentEntry.model;
243
+ }
244
+
245
+ // Build modified files map from all running agents
246
+ const modifiedFiles: Record<string, string[]> = {};
247
+ for (const name of this.pool.getRunningAgents()) {
248
+ const runningTaskId = this.pool.getTaskIdForAgent(name);
249
+ if (runningTaskId) {
250
+ const activity = this.pool.getActivity(runningTaskId);
251
+ if (activity) {
252
+ modifiedFiles[name] = Array.from(activity.modifiedFiles);
253
+ }
254
+ }
255
+ }
256
+
257
+ // Update task status
258
+ store.updateTaskStatus(this.squadId, task.id, "in_progress", {
259
+ started: store.now(),
260
+ });
261
+
262
+ store.appendMessage(this.squadId, task.id, {
263
+ ts: store.now(),
264
+ from: "system",
265
+ type: "status",
266
+ text: `Agent ${task.agent} starting work`,
267
+ });
268
+
269
+ this.emit({
270
+ type: "task_started",
271
+ squadId: this.squadId,
272
+ taskId: task.id,
273
+ agentName: task.agent,
274
+ });
275
+
276
+ try {
277
+ await this.pool.spawn({
278
+ taskId: task.id,
279
+ agentDef,
280
+ protocolOptions: {
281
+ squadId: this.squadId,
282
+ squad,
283
+ task,
284
+ agentDef,
285
+ modifiedFiles,
286
+ queuedMessages: this.pool.consumeQueue(task.agent),
287
+ },
288
+ cwd: squad.cwd,
289
+ skillPaths: this.skillPaths,
290
+ });
291
+ } catch (error) {
292
+ this.handleTaskFailed(task.id, (error as Error).message);
293
+ }
294
+
295
+ this.updateContext();
296
+ }
297
+
298
+ // =========================================================================
299
+ // Event Handlers
300
+ // =========================================================================
301
+
302
+ private handleAgentEvent(event: AgentEvent): void {
303
+ switch (event.type) {
304
+ case "message_end": {
305
+ const msg = event.data;
306
+ if (msg?.role === "assistant") {
307
+ // Extract text from assistant message
308
+ const text = this.extractAssistantText(msg);
309
+ if (text) {
310
+ // Route @mentions
311
+ this.router.processMessage(event.taskId, event.agentName, text);
312
+
313
+ // Log message
314
+ store.appendMessage(this.squadId, event.taskId, {
315
+ ts: store.now(),
316
+ from: event.agentName,
317
+ type: "text",
318
+ text: text.slice(0, 2000),
319
+ });
320
+ }
321
+
322
+ // Track usage
323
+ if (msg.usage) {
324
+ store.updateTaskUsage(this.squadId, event.taskId, {
325
+ inputTokens: msg.usage.input || 0,
326
+ outputTokens: msg.usage.output || 0,
327
+ cost: msg.usage.cost?.total || 0,
328
+ turns: 1,
329
+ });
330
+ }
331
+ }
332
+ break;
333
+ }
334
+
335
+ case "tool_execution_start": {
336
+ const data = event.data;
337
+ store.appendMessage(this.squadId, event.taskId, {
338
+ ts: store.now(),
339
+ from: event.agentName,
340
+ type: "tool",
341
+ text: data.toolName || "unknown",
342
+ name: data.toolName,
343
+ args: data.args,
344
+ });
345
+
346
+ this.emit({
347
+ type: "activity",
348
+ squadId: this.squadId,
349
+ taskId: event.taskId,
350
+ agentName: event.agentName,
351
+ message: `→ ${data.toolName}`,
352
+ data,
353
+ });
354
+ break;
355
+ }
356
+
357
+ case "tool_execution_end": {
358
+ // Track file modifications
359
+ const data = event.data;
360
+ if (data.toolName === "write" || data.toolName === "edit") {
361
+ const filePath = data.args?.path || data.args?.file_path;
362
+ if (filePath) {
363
+ this.updateModifiedFiles(event.agentName, filePath);
364
+ }
365
+ }
366
+ break;
367
+ }
368
+
369
+ case "agent_end": {
370
+ const exitCode = event.data?.exitCode ?? 1;
371
+ const activity = this.pool.getActivity(event.taskId);
372
+ // Treat as success if agent did meaningful work (had turns),
373
+ // even if exit code is non-zero (common with EPIPE on parent exit)
374
+ const hadMeaningfulWork = activity && activity.turnCount > 0;
375
+ if (exitCode === 0 || hadMeaningfulWork) {
376
+ this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
377
+ } else {
378
+ const stderr = event.data?.stderr || "";
379
+ this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode}. ${stderr.slice(0, 500)}`);
380
+ this.updateContext();
381
+ }
382
+ // Skip the updateContext() below — handled in the branches above
383
+ return;
384
+ }
385
+
386
+ case "error": {
387
+ const errorMsg = event.data?.message || "Unknown error";
388
+ store.appendMessage(this.squadId, event.taskId, {
389
+ ts: store.now(),
390
+ from: "system",
391
+ type: "error",
392
+ text: errorMsg,
393
+ });
394
+ break;
395
+ }
396
+ }
397
+
398
+ this.updateContext();
399
+ }
400
+
401
+ private async handleTaskCompleted(taskId: string): Promise<void> {
402
+ const task = store.loadTask(this.squadId, taskId);
403
+ if (!task) return;
404
+
405
+ // Guard against double-completion
406
+ if (task.status === "done") return;
407
+
408
+ // Extract output from last messages
409
+ const messages = store.loadMessages(this.squadId, taskId);
410
+ const lastAgentMessages = messages
411
+ .filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"))
412
+ .slice(-3);
413
+ const output = lastAgentMessages.map((m) => m.text).join("\n");
414
+
415
+ store.updateTaskStatus(this.squadId, taskId, "done", {
416
+ output: output || "Task completed",
417
+ completed: store.now(),
418
+ });
419
+
420
+ store.appendMessage(this.squadId, taskId, {
421
+ ts: store.now(),
422
+ from: "system",
423
+ type: "done",
424
+ text: "Task completed",
425
+ });
426
+
427
+ this.emit({
428
+ type: "task_completed",
429
+ squadId: this.squadId,
430
+ taskId,
431
+ agentName: task.agent,
432
+ message: output,
433
+ });
434
+
435
+ // Auto-unblock dependents
436
+ console.error(`[squad-scheduler] handleTaskCompleted: ${taskId} done, auto-unblocking dependents`);
437
+ this.autoUnblock(taskId);
438
+
439
+ // Schedule next ready tasks (may spawn new agents)
440
+ console.error(`[squad-scheduler] handleTaskCompleted: scheduling next ready tasks`);
441
+ await this.scheduleReadyTasks();
442
+
443
+ // Re-check squad completion with fresh data AFTER scheduling
444
+ const freshTasks = store.loadAllTasks(this.squadId);
445
+ const freshSquad = store.loadSquad(this.squadId);
446
+ console.error(`[squad-scheduler] handleTaskCompleted: final check — tasks: ${freshTasks.map(t => `${t.id}:${t.status}`).join(", ")}`);
447
+ if (freshSquad) {
448
+ this.checkSquadCompletion(freshTasks, freshSquad);
449
+ }
450
+ }
451
+
452
+ private handleTaskFailed(taskId: string, error: string): void {
453
+ store.updateTaskStatus(this.squadId, taskId, "failed", {
454
+ error,
455
+ completed: store.now(),
456
+ });
457
+
458
+ store.appendMessage(this.squadId, taskId, {
459
+ ts: store.now(),
460
+ from: "system",
461
+ type: "error",
462
+ text: error,
463
+ });
464
+
465
+ this.emit({
466
+ type: "task_failed",
467
+ squadId: this.squadId,
468
+ taskId,
469
+ message: error,
470
+ });
471
+
472
+ this.pool.kill(taskId);
473
+ this.updateContext();
474
+
475
+ // Check if squad should be marked failed
476
+ const tasks = store.loadAllTasks(this.squadId);
477
+ const squad = store.loadSquad(this.squadId);
478
+ this.checkSquadCompletion(tasks, squad!);
479
+ }
480
+
481
+ /** Auto-unblock tasks that depend on the completed task */
482
+ private autoUnblock(completedTaskId: string): void {
483
+ const squad = store.loadSquad(this.squadId);
484
+ if (!squad?.config.autoUnblock) return;
485
+
486
+ const tasks = store.loadAllTasks(this.squadId);
487
+
488
+ for (const task of tasks) {
489
+ if (task.status !== "blocked" && task.status !== "pending") continue;
490
+ if (!task.depends.includes(completedTaskId)) continue;
491
+
492
+ const allDepsDone = task.depends.every((depId) => {
493
+ const dep = tasks.find((t) => t.id === depId);
494
+ return dep?.status === "done";
495
+ });
496
+
497
+ if (allDepsDone) {
498
+ store.updateTaskStatus(this.squadId, task.id, "pending");
499
+
500
+ store.appendMessage(this.squadId, task.id, {
501
+ ts: store.now(),
502
+ from: "system",
503
+ type: "status",
504
+ text: `Unblocked — all dependencies resolved`,
505
+ });
506
+
507
+ this.emit({
508
+ type: "task_unblocked",
509
+ squadId: this.squadId,
510
+ taskId: task.id,
511
+ });
512
+ }
513
+ }
514
+ }
515
+
516
+ /** Kill agents working on tasks that became re-blocked */
517
+ killBlockedAgents(): void {
518
+ const tasks = store.loadAllTasks(this.squadId);
519
+ for (const task of tasks) {
520
+ if (task.status === "blocked" && this.pool.isRunning(task.id)) {
521
+ this.pool.steer(
522
+ task.id,
523
+ "[squad] Your task has been blocked because a dependency was reopened. Stopping your work.",
524
+ );
525
+ this.pool.kill(task.id);
526
+ }
527
+ }
528
+ }
529
+
530
+ // =========================================================================
531
+ // Squad Completion
532
+ // =========================================================================
533
+
534
+ private checkSquadCompletion(tasks: Task[], squad: Squad): void {
535
+ if (tasks.length === 0) return;
536
+
537
+ const allDone = tasks.every((t) => t.status === "done");
538
+ const anyFailed = tasks.some((t) => t.status === "failed");
539
+ const anyInProgress = tasks.some(
540
+ (t) => t.status === "in_progress" || t.status === "pending",
541
+ );
542
+
543
+ if (allDone) {
544
+ squad.status = "done";
545
+ store.saveSquad(squad);
546
+ this.emit({ type: "squad_completed", squadId: this.squadId });
547
+ } else if (anyFailed && !anyInProgress) {
548
+ // All remaining tasks are blocked/failed with no way forward
549
+ const blockedCount = tasks.filter((t) => t.status === "blocked").length;
550
+ const failedCount = tasks.filter((t) => t.status === "failed").length;
551
+ if (blockedCount + failedCount === tasks.filter((t) => t.status !== "done").length) {
552
+ squad.status = "failed";
553
+ store.saveSquad(squad);
554
+ this.emit({ type: "squad_failed", squadId: this.squadId });
555
+ }
556
+ }
557
+ }
558
+
559
+ // =========================================================================
560
+ // Context Updates
561
+ // =========================================================================
562
+
563
+ private updateModifiedFiles(agentName: string, filePath: string): void {
564
+ // Context will pick this up from AgentActivity
565
+ }
566
+
567
+ /** Rebuild and save context.json */
568
+ updateContext(): void {
569
+ const squad = store.loadSquad(this.squadId);
570
+ if (!squad) return;
571
+
572
+ const tasks = store.loadAllTasks(this.squadId);
573
+ const startTime = new Date(squad.created).getTime();
574
+ const elapsed = formatElapsed(Date.now() - startTime);
575
+
576
+ // Build agent states
577
+ const agentStates: Record<string, any> = {};
578
+ for (const [name] of Object.entries(squad.agents)) {
579
+ const agentDef = store.loadAgentDef(name, squad.cwd);
580
+ const runningTaskId = this.pool.getTaskIdForAgent(name);
581
+ agentStates[name] = {
582
+ role: agentDef?.role || "Unknown",
583
+ status: runningTaskId ? "working" : "idle",
584
+ task: runningTaskId || null,
585
+ };
586
+ }
587
+
588
+ // Build task states
589
+ const taskStates: Record<string, any> = {};
590
+ for (const task of tasks) {
591
+ taskStates[task.id] = {
592
+ status: task.status,
593
+ agent: task.agent,
594
+ title: task.title,
595
+ ...(task.output ? { output: task.output.slice(0, 500) } : {}),
596
+ ...(task.status === "blocked"
597
+ ? {
598
+ blockedBy: task.depends.filter((d) => {
599
+ const dep = tasks.find((t) => t.id === d);
600
+ return dep && dep.status !== "done";
601
+ }),
602
+ }
603
+ : {}),
604
+ };
605
+ }
606
+
607
+ // Build costs
608
+ const costs = { total: 0, byAgent: {} as Record<string, number> };
609
+ for (const task of tasks) {
610
+ costs.total += task.usage.cost;
611
+ costs.byAgent[task.agent] = (costs.byAgent[task.agent] || 0) + task.usage.cost;
612
+ }
613
+
614
+ // Build modified files from activities
615
+ const modifiedFiles: Record<string, string[]> = {};
616
+ for (const agentName of this.pool.getRunningAgents()) {
617
+ const taskId = this.pool.getTaskIdForAgent(agentName);
618
+ if (taskId) {
619
+ const activity = this.pool.getActivity(taskId);
620
+ if (activity) {
621
+ modifiedFiles[agentName] = Array.from(activity.modifiedFiles);
622
+ }
623
+ }
624
+ }
625
+
626
+ // Recent activity (last 20)
627
+ const recentActivity: any[] = [];
628
+ for (const task of tasks) {
629
+ const messages = store.loadMessages(this.squadId, task.id);
630
+ for (const msg of messages.slice(-5)) {
631
+ recentActivity.push({
632
+ ts: msg.ts,
633
+ agent: msg.from,
634
+ action:
635
+ msg.type === "tool"
636
+ ? `→ ${msg.name} ${msg.args?.path || msg.args?.command || ""}`.trim()
637
+ : msg.text.slice(0, 80),
638
+ });
639
+ }
640
+ }
641
+ recentActivity.sort((a, b) => b.ts.localeCompare(a.ts));
642
+
643
+ store.saveContext(this.squadId, {
644
+ goal: squad.goal,
645
+ status: squad.status,
646
+ elapsed,
647
+ costs,
648
+ agents: agentStates,
649
+ tasks: taskStates,
650
+ recentActivity: recentActivity.slice(0, 20),
651
+ modifiedFiles,
652
+ });
653
+ }
654
+
655
+ // =========================================================================
656
+ // External Actions
657
+ // =========================================================================
658
+
659
+ /** Send a human message to a task's agent */
660
+ async sendHumanMessage(taskId: string, message: string): Promise<boolean> {
661
+ store.appendMessage(this.squadId, taskId, {
662
+ ts: store.now(),
663
+ from: "human",
664
+ type: "message",
665
+ text: message,
666
+ });
667
+
668
+ if (this.pool.isRunning(taskId)) {
669
+ return this.pool.steer(taskId, `[squad] Human: ${message}`);
670
+ }
671
+ // Queue for when agent spawns
672
+ const task = store.loadTask(this.squadId, taskId);
673
+ if (task) {
674
+ this.pool.queueMessage(task.agent, {
675
+ ts: store.now(),
676
+ from: "human",
677
+ type: "message",
678
+ text: message,
679
+ });
680
+ }
681
+ return false;
682
+ }
683
+
684
+ /** Pause a running task */
685
+ async pauseTask(taskId: string): Promise<void> {
686
+ if (this.pool.isRunning(taskId)) {
687
+ await this.pool.steer(taskId, "[squad] Task paused by user. Summarize your current state.");
688
+ // Give agent a moment to respond, then kill
689
+ setTimeout(() => this.pool.kill(taskId), 3000);
690
+ }
691
+ store.updateTaskStatus(this.squadId, taskId, "suspended");
692
+ this.updateContext();
693
+ }
694
+
695
+ /** Resume a suspended task */
696
+ async resumeTask(taskId: string): Promise<void> {
697
+ store.updateTaskStatus(this.squadId, taskId, "pending");
698
+ await this.scheduleReadyTasks();
699
+ }
700
+
701
+ /** Cancel a task */
702
+ async cancelTask(taskId: string): Promise<void> {
703
+ if (this.pool.isRunning(taskId)) {
704
+ await this.pool.kill(taskId);
705
+ }
706
+ store.updateTaskStatus(this.squadId, taskId, "failed", {
707
+ error: "Cancelled by user",
708
+ });
709
+ this.updateContext();
710
+ }
711
+
712
+ // =========================================================================
713
+ // Helpers
714
+ // =========================================================================
715
+
716
+ private extractAssistantText(msg: any): string | null {
717
+ if (!msg.content) return null;
718
+ const textParts = msg.content
719
+ .filter((p: any) => p.type === "text")
720
+ .map((p: any) => p.text);
721
+ return textParts.length > 0 ? textParts.join("\n") : null;
722
+ }
723
+ }
724
+
725
+ function formatElapsed(ms: number): string {
726
+ const seconds = Math.floor(ms / 1000);
727
+ const minutes = Math.floor(seconds / 60);
728
+ const hours = Math.floor(minutes / 60);
729
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
730
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
731
+ return `${seconds}s`;
732
+ }