bosun 0.41.0 → 0.41.2

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 (64) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-event-bus.mjs +248 -6
  4. package/agent/agent-pool.mjs +125 -28
  5. package/agent/agent-work-analyzer.mjs +8 -16
  6. package/agent/retry-queue.mjs +164 -0
  7. package/bosun.config.example.json +25 -0
  8. package/bosun.schema.json +825 -183
  9. package/cli.mjs +59 -5
  10. package/config/config.mjs +130 -3
  11. package/infra/monitor.mjs +693 -67
  12. package/infra/runtime-accumulator.mjs +376 -84
  13. package/infra/session-tracker.mjs +82 -25
  14. package/lib/codebase-audit.mjs +133 -18
  15. package/package.json +23 -4
  16. package/server/setup-web-server.mjs +25 -0
  17. package/server/ui-server.mjs +248 -29
  18. package/setup.mjs +27 -24
  19. package/shell/codex-shell.mjs +34 -3
  20. package/shell/copilot-shell.mjs +50 -8
  21. package/task/msg-hub.mjs +193 -0
  22. package/task/pipeline.mjs +544 -0
  23. package/task/task-cli.mjs +38 -2
  24. package/task/task-executor-pipeline.mjs +143 -0
  25. package/task/task-executor.mjs +36 -27
  26. package/telegram/get-telegram-chat-id.mjs +57 -47
  27. package/ui/components/workspace-switcher.js +7 -7
  28. package/ui/demo-defaults.js +15694 -10573
  29. package/ui/modules/settings-schema.js +2 -0
  30. package/ui/modules/state.js +54 -57
  31. package/ui/modules/voice-client-sdk.js +375 -36
  32. package/ui/modules/voice-client.js +140 -31
  33. package/ui/setup.html +68 -2
  34. package/ui/styles/components.css +57 -0
  35. package/ui/styles.css +201 -1
  36. package/ui/tabs/dashboard.js +74 -0
  37. package/ui/tabs/logs.js +10 -0
  38. package/ui/tabs/settings.js +178 -99
  39. package/ui/tabs/tasks.js +31 -1
  40. package/ui/tabs/telemetry.js +34 -0
  41. package/ui/tabs/workflow-canvas-utils.mjs +8 -1
  42. package/ui/tabs/workflows.js +532 -275
  43. package/voice/voice-agents-sdk.mjs +1 -1
  44. package/voice/voice-relay.mjs +6 -6
  45. package/workflow/declarative-workflows.mjs +145 -0
  46. package/workflow/msg-hub.mjs +237 -0
  47. package/workflow/pipeline-workflows.mjs +287 -0
  48. package/workflow/pipeline.mjs +828 -315
  49. package/workflow/workflow-cli.mjs +128 -0
  50. package/workflow/workflow-engine.mjs +329 -17
  51. package/workflow/workflow-nodes/custom-loader.mjs +250 -0
  52. package/workflow/workflow-nodes.mjs +1955 -223
  53. package/workflow/workflow-templates.mjs +26 -8
  54. package/workflow-templates/agents.mjs +0 -1
  55. package/workflow-templates/bosun-native.mjs +212 -2
  56. package/workflow-templates/continuation-loop.mjs +339 -0
  57. package/workflow-templates/github.mjs +516 -40
  58. package/workflow-templates/planning.mjs +446 -17
  59. package/workflow-templates/reliability.mjs +65 -12
  60. package/workflow-templates/task-batch.mjs +24 -8
  61. package/workflow-templates/task-lifecycle.mjs +83 -6
  62. package/workspace/context-cache.mjs +66 -18
  63. package/workspace/workspace-manager.mjs +2 -1
  64. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -0,0 +1,544 @@
1
+ /**
2
+ * @module task/pipeline
3
+ * @description Declarative multi-agent pipeline primitives with fresh-context stage isolation.
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+
8
+ function normalizeStage(stage, index) {
9
+ if (typeof stage === "function") {
10
+ return {
11
+ id: `stage-${index + 1}`,
12
+ name: stage.name || `stage-${index + 1}`,
13
+ run: stage,
14
+ meta: {},
15
+ };
16
+ }
17
+
18
+ if (stage && typeof stage === "object") {
19
+ const runner =
20
+ typeof stage.run === "function"
21
+ ? stage.run.bind(stage)
22
+ : typeof stage.execute === "function"
23
+ ? stage.execute.bind(stage)
24
+ : null;
25
+ if (!runner) {
26
+ throw new TypeError(`Pipeline stage ${index + 1} is missing run/execute()`);
27
+ }
28
+ return {
29
+ id: String(stage.id || stage.name || `stage-${index + 1}`),
30
+ name: String(stage.name || stage.id || `stage-${index + 1}`),
31
+ run: runner,
32
+ meta: { ...stage },
33
+ };
34
+ }
35
+
36
+ throw new TypeError(`Unsupported pipeline stage at index ${index}`);
37
+ }
38
+
39
+ function normalizeStages(stages) {
40
+ if (!Array.isArray(stages) || stages.length === 0) {
41
+ throw new TypeError("Pipeline requires at least one stage");
42
+ }
43
+ return stages.map((stage, index) => normalizeStage(stage, index));
44
+ }
45
+
46
+ function normalizeError(error) {
47
+ if (error instanceof Error) {
48
+ return {
49
+ message: error.message,
50
+ name: error.name,
51
+ stack: error.stack || "",
52
+ };
53
+ }
54
+ return {
55
+ message: String(error || "Unknown pipeline error"),
56
+ name: "Error",
57
+ stack: "",
58
+ };
59
+ }
60
+
61
+ function coerceText(value) {
62
+ if (value == null) return "";
63
+ if (typeof value === "string") return value;
64
+ try {
65
+ return JSON.stringify(value);
66
+ } catch {
67
+ return String(value);
68
+ }
69
+ }
70
+
71
+ function pickDescriptorFields(value) {
72
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
73
+ const descriptor = {};
74
+ const mappings = [
75
+ ["taskId", ["taskId", "id"]],
76
+ ["title", ["title", "taskTitle", "name"]],
77
+ ["summary", ["summary", "message", "result"]],
78
+ ["branch", ["branch", "branchName"]],
79
+ ["baseBranch", ["baseBranch"]],
80
+ ["repoRoot", ["repoRoot", "cwd"]],
81
+ ["repoSlug", ["repoSlug"]],
82
+ ["workspace", ["workspace"]],
83
+ ["repository", ["repository", "repo"]],
84
+ ["status", ["status"]],
85
+ ];
86
+
87
+ for (const [targetKey, candidateKeys] of mappings) {
88
+ for (const key of candidateKeys) {
89
+ const candidate = value[key];
90
+ if (candidate == null || candidate === "") continue;
91
+ descriptor[targetKey] = candidate;
92
+ break;
93
+ }
94
+ }
95
+
96
+ const paths = value.paths || value.filePaths || value.files;
97
+ if (Array.isArray(paths) && paths.length > 0) {
98
+ descriptor.paths = paths.filter(Boolean).map((entry) => String(entry)).slice(0, 50);
99
+ }
100
+
101
+ return Object.keys(descriptor).length > 0 ? descriptor : null;
102
+ }
103
+
104
+ export function toMinimalDescriptor(value) {
105
+ const descriptor = pickDescriptorFields(value);
106
+ if (descriptor) return descriptor;
107
+ if (Array.isArray(value)) {
108
+ return {
109
+ items: value.slice(0, 10).map((entry) => toMinimalDescriptor(entry)),
110
+ };
111
+ }
112
+ return {
113
+ summary: coerceText(value).slice(0, 4000),
114
+ };
115
+ }
116
+
117
+ function defaultPrepareStageInput(previousRecord, initialInput) {
118
+ if (!previousRecord) return toMinimalDescriptor(initialInput);
119
+ return previousRecord.descriptor || toMinimalDescriptor(previousRecord.output);
120
+ }
121
+
122
+ function defaultGetTokensUsed(result) {
123
+ const candidates = [
124
+ result?.tokensUsed,
125
+ result?.usage?.totalTokens,
126
+ result?.usage?.total_tokens,
127
+ result?.tokenUsage?.total,
128
+ result?.metrics?.tokensUsed,
129
+ ];
130
+ for (const candidate of candidates) {
131
+ const parsed = Number(candidate);
132
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed;
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ function createBaseContext({
138
+ pipelineType,
139
+ runId,
140
+ stage,
141
+ index,
142
+ initialInput,
143
+ stageInput,
144
+ previousRecord,
145
+ signal,
146
+ options,
147
+ }) {
148
+ return {
149
+ runId,
150
+ pipelineType,
151
+ stageId: stage.id,
152
+ stageName: stage.name,
153
+ stageIndex: index,
154
+ initialInput: toMinimalDescriptor(initialInput),
155
+ input: toMinimalDescriptor(stageInput),
156
+ previousOutput: previousRecord ? previousRecord.descriptor : null,
157
+ freshContext: true,
158
+ signal,
159
+ options,
160
+ };
161
+ }
162
+
163
+ function createStageRecord({ stage, index, input, result, startedAt, endedAt, successOverride }) {
164
+ const rawOutput = result && typeof result === "object" && Object.hasOwn(result, "output")
165
+ ? result.output
166
+ : result;
167
+ const success =
168
+ typeof successOverride === "boolean"
169
+ ? successOverride
170
+ : !(result && typeof result === "object" && result.success === false);
171
+ return {
172
+ stageId: stage.id,
173
+ stageName: stage.name,
174
+ stageIndex: index,
175
+ input: toMinimalDescriptor(input),
176
+ output: rawOutput,
177
+ descriptor: toMinimalDescriptor(
178
+ result && typeof result === "object" && result.descriptor
179
+ ? result.descriptor
180
+ : rawOutput,
181
+ ),
182
+ success,
183
+ tokensUsed: defaultGetTokensUsed(result),
184
+ meta: result && typeof result === "object" ? { ...result } : {},
185
+ startedAt,
186
+ endedAt,
187
+ durationMs: Math.max(0, endedAt - startedAt),
188
+ };
189
+ }
190
+
191
+ function createCancelledRecord(stage, index, input, reason) {
192
+ const now = Date.now();
193
+ return {
194
+ stageId: stage.id,
195
+ stageName: stage.name,
196
+ stageIndex: index,
197
+ input: toMinimalDescriptor(input),
198
+ output: null,
199
+ descriptor: { summary: reason },
200
+ success: false,
201
+ tokensUsed: 0,
202
+ meta: { cancelled: true, reason },
203
+ startedAt: now,
204
+ endedAt: now,
205
+ durationMs: 0,
206
+ };
207
+ }
208
+
209
+ function finalizePipelineResult(type, startedAt, outputs, errors, extra = {}) {
210
+ const endedAt = Date.now();
211
+ const tokensUsed = outputs.reduce(
212
+ (sum, record) => sum + (Number(record?.tokensUsed) || 0),
213
+ 0,
214
+ );
215
+ return {
216
+ ok: errors.length === 0 && outputs.some((record) => record?.success !== false),
217
+ type,
218
+ outputs,
219
+ timing: {
220
+ startedAt,
221
+ endedAt,
222
+ durationMs: Math.max(0, endedAt - startedAt),
223
+ stages: outputs.map((record) => ({
224
+ stageId: record.stageId,
225
+ stageName: record.stageName,
226
+ startedAt: record.startedAt,
227
+ endedAt: record.endedAt,
228
+ durationMs: record.durationMs,
229
+ })),
230
+ },
231
+ tokensUsed,
232
+ errors,
233
+ ...extra,
234
+ };
235
+ }
236
+
237
+ function createPipeline(type, stages, options = {}, runner) {
238
+ const normalizedStages = normalizeStages(stages);
239
+ const pipelineOptions = { ...options };
240
+ return Object.freeze({
241
+ type,
242
+ stages: normalizedStages.map((stage) => ({ ...stage.meta, id: stage.id, name: stage.name })),
243
+ options: pipelineOptions,
244
+ async run(initialInput, runtimeOptions = {}) {
245
+ return runner(normalizedStages, initialInput, { ...pipelineOptions, ...runtimeOptions });
246
+ },
247
+ });
248
+ }
249
+
250
+ async function executeStage(stage, input, baseContext, options) {
251
+ const createContext =
252
+ typeof options.createContext === "function"
253
+ ? options.createContext
254
+ : (ctx) => ctx;
255
+ const context = createContext(baseContext);
256
+ if (context?.signal?.aborted) {
257
+ const error = new Error(String(context.signal.reason || "aborted"));
258
+ error.name = "AbortError";
259
+ throw error;
260
+ }
261
+ return stage.run(input, context);
262
+ }
263
+
264
+ export function SequentialPipeline(stages, options = {}) {
265
+ return createPipeline("sequential", stages, options, async (normalizedStages, initialInput, runtimeOptions) => {
266
+ const startedAt = Date.now();
267
+ const runId = String(runtimeOptions.runId || randomUUID());
268
+ const outputs = [];
269
+ const errors = [];
270
+ const prepareStageInput =
271
+ typeof runtimeOptions.prepareStageInput === "function"
272
+ ? runtimeOptions.prepareStageInput
273
+ : defaultPrepareStageInput;
274
+
275
+ let previousRecord = null;
276
+ for (const [index, stage] of normalizedStages.entries()) {
277
+ const stageInput =
278
+ index === 0
279
+ ? initialInput
280
+ : prepareStageInput(previousRecord, initialInput, stage, index, outputs.slice());
281
+ const started = Date.now();
282
+ try {
283
+ runtimeOptions.onStageStart?.(stage, stageInput, index);
284
+ const result = await executeStage(
285
+ stage,
286
+ stageInput,
287
+ createBaseContext({
288
+ pipelineType: "sequential",
289
+ runId,
290
+ stage,
291
+ index,
292
+ initialInput,
293
+ stageInput,
294
+ previousRecord,
295
+ signal: runtimeOptions.signal || null,
296
+ options: runtimeOptions,
297
+ }),
298
+ runtimeOptions,
299
+ );
300
+ const record = createStageRecord({
301
+ stage,
302
+ index,
303
+ input: stageInput,
304
+ result,
305
+ startedAt: started,
306
+ endedAt: Date.now(),
307
+ });
308
+ outputs.push(record);
309
+ previousRecord = record;
310
+ runtimeOptions.onStageComplete?.(record, index);
311
+ if (record.success === false) {
312
+ errors.push({ stageId: stage.id, stageName: stage.name, error: normalizeError(record.meta?.error || "Stage returned success=false") });
313
+ break;
314
+ }
315
+ } catch (error) {
316
+ const normalized = normalizeError(error);
317
+ outputs.push(
318
+ createStageRecord({
319
+ stage,
320
+ index,
321
+ input: stageInput,
322
+ result: { output: null, error: normalized.message, success: false },
323
+ startedAt: started,
324
+ endedAt: Date.now(),
325
+ successOverride: false,
326
+ }),
327
+ );
328
+ errors.push({ stageId: stage.id, stageName: stage.name, error: normalized });
329
+ runtimeOptions.onStageError?.(normalized, stage, index);
330
+ break;
331
+ }
332
+ }
333
+
334
+ return finalizePipelineResult("sequential", startedAt, outputs, errors, {
335
+ finalOutput: outputs.at(-1)?.output ?? null,
336
+ runId,
337
+ });
338
+ });
339
+ }
340
+
341
+ export function FanoutPipeline(stages, options = {}) {
342
+ return createPipeline("fanout", stages, options, async (normalizedStages, initialInput, runtimeOptions) => {
343
+ const startedAt = Date.now();
344
+ const runId = String(runtimeOptions.runId || randomUUID());
345
+ const outputs = new Array(normalizedStages.length);
346
+ const errors = [];
347
+ const prepareStageInput =
348
+ typeof runtimeOptions.prepareStageInput === "function"
349
+ ? runtimeOptions.prepareStageInput
350
+ : (_previousRecord, seed) => toMinimalDescriptor(seed);
351
+
352
+ await Promise.allSettled(
353
+ normalizedStages.map(async (stage, index) => {
354
+ const stageInput = prepareStageInput(null, initialInput, stage, index, []);
355
+ const started = Date.now();
356
+ try {
357
+ runtimeOptions.onStageStart?.(stage, stageInput, index);
358
+ const result = await executeStage(
359
+ stage,
360
+ stageInput,
361
+ createBaseContext({
362
+ pipelineType: "fanout",
363
+ runId,
364
+ stage,
365
+ index,
366
+ initialInput,
367
+ stageInput,
368
+ previousRecord: null,
369
+ signal: runtimeOptions.signal || null,
370
+ options: runtimeOptions,
371
+ }),
372
+ runtimeOptions,
373
+ );
374
+ outputs[index] = createStageRecord({
375
+ stage,
376
+ index,
377
+ input: stageInput,
378
+ result,
379
+ startedAt: started,
380
+ endedAt: Date.now(),
381
+ });
382
+ runtimeOptions.onStageComplete?.(outputs[index], index);
383
+ if (outputs[index].success === false) {
384
+ errors.push({ stageId: stage.id, stageName: stage.name, error: normalizeError(outputs[index].meta?.error || "Stage returned success=false") });
385
+ }
386
+ } catch (error) {
387
+ const normalized = normalizeError(error);
388
+ outputs[index] = createStageRecord({
389
+ stage,
390
+ index,
391
+ input: stageInput,
392
+ result: { output: null, error: normalized.message, success: false },
393
+ startedAt: started,
394
+ endedAt: Date.now(),
395
+ successOverride: false,
396
+ });
397
+ errors.push({ stageId: stage.id, stageName: stage.name, error: normalized });
398
+ runtimeOptions.onStageError?.(normalized, stage, index);
399
+ }
400
+ }),
401
+ );
402
+
403
+ return finalizePipelineResult(
404
+ "fanout",
405
+ startedAt,
406
+ outputs.filter(Boolean),
407
+ errors,
408
+ { runId },
409
+ );
410
+ });
411
+ }
412
+
413
+ export function RacePipeline(stages, options = {}) {
414
+ return createPipeline("race", stages, options, async (normalizedStages, initialInput, runtimeOptions) => {
415
+ const startedAt = Date.now();
416
+ const runId = String(runtimeOptions.runId || randomUUID());
417
+ const outputs = new Array(normalizedStages.length);
418
+ const errors = [];
419
+ const prepareStageInput =
420
+ typeof runtimeOptions.prepareStageInput === "function"
421
+ ? runtimeOptions.prepareStageInput
422
+ : (_previousRecord, seed) => toMinimalDescriptor(seed);
423
+
424
+ const controllers = normalizedStages.map(() => new AbortController());
425
+ if (runtimeOptions.signal) {
426
+ const propagateAbort = () => {
427
+ for (const controller of controllers) {
428
+ if (!controller.signal.aborted) {
429
+ controller.abort(runtimeOptions.signal.reason || "aborted");
430
+ }
431
+ }
432
+ };
433
+ if (runtimeOptions.signal.aborted) {
434
+ propagateAbort();
435
+ } else {
436
+ runtimeOptions.signal.addEventListener("abort", propagateAbort, { once: true });
437
+ }
438
+ }
439
+
440
+ let winner = null;
441
+ let resolved = false;
442
+
443
+ const settleWinner = (record, index) => {
444
+ winner = { ...record, stageIndex: index };
445
+ for (const [controllerIndex, controller] of controllers.entries()) {
446
+ if (controllerIndex === index || controller.signal.aborted) continue;
447
+ controller.abort("race_won");
448
+ if (!outputs[controllerIndex]) {
449
+ outputs[controllerIndex] = createCancelledRecord(
450
+ normalizedStages[controllerIndex],
451
+ controllerIndex,
452
+ prepareStageInput(null, initialInput, normalizedStages[controllerIndex], controllerIndex, []),
453
+ "Cancelled after another stage won the race",
454
+ );
455
+ }
456
+ }
457
+ resolved = true;
458
+ return finalizePipelineResult(
459
+ "race",
460
+ startedAt,
461
+ outputs.filter(Boolean),
462
+ errors,
463
+ { winner, finalOutput: winner.output, runId },
464
+ );
465
+ };
466
+
467
+ const pending = normalizedStages.map((stage, index) => (async () => {
468
+ const stageInput = prepareStageInput(null, initialInput, stage, index, []);
469
+ const started = Date.now();
470
+ try {
471
+ runtimeOptions.onStageStart?.(stage, stageInput, index);
472
+ const result = await executeStage(
473
+ stage,
474
+ stageInput,
475
+ createBaseContext({
476
+ pipelineType: "race",
477
+ runId,
478
+ stage,
479
+ index,
480
+ initialInput,
481
+ stageInput,
482
+ previousRecord: null,
483
+ signal: controllers[index].signal,
484
+ options: runtimeOptions,
485
+ }),
486
+ runtimeOptions,
487
+ );
488
+ const record = createStageRecord({
489
+ stage,
490
+ index,
491
+ input: stageInput,
492
+ result,
493
+ startedAt: started,
494
+ endedAt: Date.now(),
495
+ });
496
+ outputs[index] = record;
497
+ runtimeOptions.onStageComplete?.(record, index);
498
+ if (record.success !== false && !resolved) {
499
+ return settleWinner(record, index);
500
+ }
501
+ if (record.success === false) {
502
+ errors.push({ stageId: stage.id, stageName: stage.name, error: normalizeError(record.meta?.error || "Stage returned success=false") });
503
+ }
504
+ return null;
505
+ } catch (error) {
506
+ const normalized = normalizeError(error);
507
+ const isAbort = normalized.name === "AbortError" || /aborted|race_won/i.test(normalized.message);
508
+ outputs[index] = createStageRecord({
509
+ stage,
510
+ index,
511
+ input: stageInput,
512
+ result: { output: null, error: normalized.message, success: false },
513
+ startedAt: started,
514
+ endedAt: Date.now(),
515
+ successOverride: false,
516
+ });
517
+ if (!isAbort || !resolved) {
518
+ errors.push({ stageId: stage.id, stageName: stage.name, error: normalized });
519
+ runtimeOptions.onStageError?.(normalized, stage, index);
520
+ }
521
+ return null;
522
+ }
523
+ })());
524
+
525
+ const firstResult = await Promise.race(pending);
526
+ if (firstResult) return firstResult;
527
+
528
+ await Promise.allSettled(pending);
529
+ return finalizePipelineResult(
530
+ "race",
531
+ startedAt,
532
+ outputs.filter(Boolean),
533
+ errors,
534
+ { winner: null, finalOutput: null, runId },
535
+ );
536
+ });
537
+ }
538
+
539
+ export default {
540
+ SequentialPipeline,
541
+ FanoutPipeline,
542
+ RacePipeline,
543
+ toMinimalDescriptor,
544
+ };
package/task/task-cli.mjs CHANGED
@@ -30,6 +30,7 @@ import { homedir } from "node:os";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { readFileSync, existsSync, statSync } from "node:fs";
32
32
  import { randomUUID } from "node:crypto";
33
+ import { getTaskLifetimeTotals } from "../infra/runtime-accumulator.mjs";
33
34
 
34
35
  const __filename = fileURLToPath(import.meta.url);
35
36
  const __dirname = dirname(__filename);
@@ -359,6 +360,34 @@ export async function taskList(filters = {}) {
359
360
  return tasks;
360
361
  }
361
362
 
363
+ function withTaskLifetimeTotals(task) {
364
+ if (!task || typeof task !== "object") return task;
365
+ const taskId = String(task.id || task.taskId || "").trim();
366
+ const lifetimeTotals = taskId ? getTaskLifetimeTotals(taskId) : null;
367
+ return {
368
+ ...task,
369
+ lifetimeTotals,
370
+ meta: {
371
+ ...(task.meta || {}),
372
+ lifetimeTotals,
373
+ },
374
+ };
375
+ }
376
+
377
+ function formatDurationMs(ms) {
378
+ const value = Number(ms || 0);
379
+ if (!Number.isFinite(value) || value <= 0) return "0s";
380
+ if (value < 1000) return `${Math.round(value)}ms`;
381
+ const seconds = Math.round(value / 1000);
382
+ if (seconds < 60) return `${seconds}s`;
383
+ const minutes = Math.floor(seconds / 60);
384
+ const remSeconds = seconds % 60;
385
+ if (minutes < 60) return remSeconds > 0 ? `${minutes}m ${remSeconds}s` : `${minutes}m`;
386
+ const hours = Math.floor(minutes / 60);
387
+ const remMinutes = minutes % 60;
388
+ return remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`;
389
+ }
390
+
362
391
  /**
363
392
  * Get a single task by ID.
364
393
  * @param {string} id - Task ID (UUID or partial prefix)
@@ -369,12 +398,12 @@ export async function taskGet(id) {
369
398
 
370
399
  // Try exact match first
371
400
  let task = store.getTask(id);
372
- if (task) return task;
401
+ if (task) return withTaskLifetimeTotals(task);
373
402
 
374
403
  // Try prefix match
375
404
  const all = store.getAllTasks();
376
405
  const matches = all.filter((t) => t.id?.startsWith(id));
377
- if (matches.length === 1) return matches[0];
406
+ if (matches.length === 1) return withTaskLifetimeTotals(matches[0]);
378
407
  if (matches.length > 1) {
379
408
  throw new Error(
380
409
  `Ambiguous task ID prefix "${id}" — matches ${matches.length} tasks. Use a longer prefix.`,
@@ -1234,6 +1263,12 @@ async function cliGet(args) {
1234
1263
  console.log(` Branch: ${task.baseBranch || "main"}`);
1235
1264
  console.log(` Created: ${task.createdAt || "?"}`);
1236
1265
  console.log(` Updated: ${task.updatedAt || "?"}`);
1266
+ const lifetimeTotals = task.lifetimeTotals || task.meta?.lifetimeTotals || null;
1267
+ if (lifetimeTotals) {
1268
+ console.log(` Attempts count: ${lifetimeTotals.attemptsCount || 0}`);
1269
+ console.log(` Total tokens across all attempts: ${lifetimeTotals.tokenCount || 0}`);
1270
+ console.log(` Total runtime across all attempts: ${formatDurationMs(lifetimeTotals.durationMs || 0)}`);
1271
+ }
1237
1272
  if (task.workspace) console.log(` Workspace: ${task.workspace}`);
1238
1273
  if (task.repository) console.log(` Repository: ${task.repository}`);
1239
1274
  if (task.description) {
@@ -1662,3 +1697,4 @@ if (process.argv[1] && resolve(process.argv[1]) === __filename) {
1662
1697
  process.exit(1);
1663
1698
  });
1664
1699
  }
1700
+