@sns-myagent/cli 0.2.0 → 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +7 -8
  3. package/package.json +3 -3
  4. package/scripts/apply-pi-natives-patch.js +82 -56
  5. package/src/adapters/telegram/bot.ts +41 -1
  6. package/src/adapters/telegram/bridge.ts +186 -0
  7. package/src/adapters/telegram/handler.ts +138 -13
  8. package/src/adapters/telegram/index.ts +12 -2
  9. package/src/agents/__tests__/config.test.ts +79 -0
  10. package/src/agents/__tests__/resilience.test.ts +119 -0
  11. package/src/agents/__tests__/strategies.test.ts +83 -0
  12. package/src/agents/config.ts +367 -0
  13. package/src/agents/ensemble.ts +224 -0
  14. package/src/agents/resilience.ts +332 -0
  15. package/src/agents/strategies/best-of-n.ts +108 -0
  16. package/src/agents/strategies/consensus.ts +105 -0
  17. package/src/agents/strategies/critic.ts +131 -0
  18. package/src/agents/strategies/types.ts +68 -0
  19. package/src/async/__tests__/task-runner.test.ts +162 -0
  20. package/src/async/__tests__/task-store.test.ts +146 -0
  21. package/src/async/index.ts +28 -1
  22. package/src/async/notifier.ts +133 -0
  23. package/src/async/task-runner.ts +175 -0
  24. package/src/async/task-store.ts +170 -0
  25. package/src/async/types.ts +70 -0
  26. package/src/cli/entry.ts +3 -1
  27. package/src/cli/index.ts +69 -54
  28. package/src/config/index.ts +1 -1
  29. package/src/debug/index.ts +1 -1
  30. package/src/modes/components/welcome.ts +13 -6
  31. package/src/modes/controllers/event-controller.ts +1 -1
  32. package/src/modes/setup-wizard/scenes/splash.ts +1 -1
  33. package/src/session/agent-session.ts +1 -1
  34. package/src/slash-commands/builtin-registry.ts +63 -0
  35. package/src/slash-commands/helpers/task.ts +181 -0
  36. package/src/tbm/__tests__/tbm.test.ts +660 -0
  37. package/src/tbm/comm-modes.ts +165 -0
  38. package/src/tbm/config.ts +136 -0
  39. package/src/tbm/context-delta.ts +146 -0
  40. package/src/tbm/context-pyramid.ts +202 -0
  41. package/src/tbm/dashboard.ts +131 -0
  42. package/src/tbm/index.ts +247 -0
  43. package/src/tbm/lazy-skills.ts +182 -0
  44. package/src/tbm/response-cache.ts +220 -0
  45. package/src/tbm/tombstone.ts +189 -0
  46. package/src/tbm/tool-compress.ts +230 -0
  47. package/src/tools/ask.ts +1 -1
  48. package/src/tui/chat-blocks.ts +205 -0
  49. package/src/tui/chat-ui.ts +270 -0
  50. package/src/tui/code-cell.ts +90 -1
  51. package/src/tui/command-palette.ts +189 -0
  52. package/src/tui/index.ts +4 -0
  53. package/src/tui/splash.ts +130 -0
  54. package/src/ui/banner.ts +69 -29
  55. package/src/ui/colors.ts +24 -0
  56. package/src/ui/error-display.ts +130 -0
  57. package/src/ui/gradient.ts +104 -0
  58. package/src/ui/index.ts +15 -0
  59. package/src/ui/memory-toast.ts +102 -0
  60. package/src/ui/status-bar.ts +36 -30
  61. package/bin/snscoder.js +0 -98
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Ensemble base types — SNS-MyAgent Phase 5.3
3
+ *
4
+ * Shared types for consensus, critic, and best-of-N strategies.
5
+ */
6
+
7
+ export interface AgentResponse {
8
+ /** Agent role that produced this */
9
+ role: string;
10
+ /** Model used */
11
+ model: string;
12
+ /** Response text */
13
+ content: string;
14
+ /** Token usage */
15
+ tokens?: { input: number; output: number };
16
+ /** Time taken in ms */
17
+ timeMs: number;
18
+ /** Quality score 0-1 (assigned by judge/critic) */
19
+ score?: number;
20
+ }
21
+
22
+ export interface EnsembleResult {
23
+ /** Final selected/merged response */
24
+ final: string;
25
+ /** All individual responses */
26
+ responses: AgentResponse[];
27
+ /** Strategy used */
28
+ strategy: string;
29
+ /** Total rounds executed */
30
+ rounds: number;
31
+ /** Total time in ms */
32
+ totalTimeMs: number;
33
+ /** Total tokens across all agents */
34
+ totalTokens: { input: number; output: number };
35
+ /** Agent that won/produced the final (for best_of_n/consensus) */
36
+ winner?: string;
37
+ }
38
+
39
+ export interface EnsembleStrategy {
40
+ /** Strategy name */
41
+ readonly name: string;
42
+ /**
43
+ * Execute the ensemble strategy.
44
+ *
45
+ * @param prompt - The user prompt to send to all agents
46
+ * @param agents - Available agent role configs
47
+ * @param executeAgent - Function to spawn an agent and get its response
48
+ * @returns Final ensemble result
49
+ */
50
+ execute(
51
+ prompt: string,
52
+ agents: string[],
53
+ executeAgent: (role: string, prompt: string) => Promise<AgentResponse>,
54
+ ): Promise<EnsembleResult>;
55
+ }
56
+
57
+ /**
58
+ * Aggregate token usage across responses.
59
+ */
60
+ export function aggregateTokens(responses: AgentResponse[]): { input: number; output: number } {
61
+ return responses.reduce(
62
+ (acc, r) => ({
63
+ input: acc.input + (r.tokens?.input ?? 0),
64
+ output: acc.output + (r.tokens?.output ?? 0),
65
+ }),
66
+ { input: 0, output: 0 },
67
+ );
68
+ }
@@ -0,0 +1,162 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Async Task Runner — Tests
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
6
+ import { TaskStore } from "../task-store";
7
+ import { TaskRunner } from "../task-runner";
8
+ import type { AsyncTask, TaskExecutionResult } from "../types";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+
13
+ let store: TaskStore;
14
+ let runner: TaskRunner;
15
+ let tempDir: string;
16
+
17
+ beforeEach(() => {
18
+ tempDir = mkdtempSync(join(tmpdir(), "async-runner-test-"));
19
+ store = new TaskStore(join(tempDir, "test.db"));
20
+ runner = new TaskRunner(store, { maxConcurrent: 2, defaultTimeoutMs: 5000 });
21
+ });
22
+
23
+ afterEach(() => {
24
+ runner.destroy();
25
+ store.close();
26
+ rmSync(tempDir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe("TaskRunner", () => {
30
+ it("executes task with registered executor", async () => {
31
+ runner.registerExecutor("prompt", async (task: AsyncTask): Promise<TaskExecutionResult> => {
32
+ return { success: true, result: `Done: ${task.description}` };
33
+ });
34
+
35
+ const task = store.create({ description: "test exec" });
36
+ let notified = false;
37
+ runner.onNotify((t) => {
38
+ if (t.id === task.id && t.status === "completed") notified = true;
39
+ });
40
+
41
+ await runner.submit(task);
42
+ // Wait for async execution
43
+ await new Promise((r) => setTimeout(r, 100));
44
+
45
+ const updated = store.getById(task.id);
46
+ expect(updated!.status).toBe("completed");
47
+ expect(updated!.result).toBe("Done: test exec");
48
+ expect(notified).toBe(true);
49
+ });
50
+
51
+ it("fails task when executor throws", async () => {
52
+ runner.registerExecutor("prompt", async () => {
53
+ throw new Error("boom");
54
+ });
55
+
56
+ const task = store.create({ description: "fail exec" });
57
+ await runner.submit(task);
58
+ await new Promise((r) => setTimeout(r, 100));
59
+
60
+ const updated = store.getById(task.id);
61
+ expect(updated!.status).toBe("failed");
62
+ expect(updated!.error).toBe("boom");
63
+ });
64
+
65
+ it("fails task when no executor registered", async () => {
66
+ const task = store.create({ description: "no executor", taskType: "shell" });
67
+ await runner.submit(task);
68
+ await new Promise((r) => setTimeout(r, 100));
69
+
70
+ const updated = store.getById(task.id);
71
+ expect(updated!.status).toBe("failed");
72
+ expect(updated!.error).toContain("No executor registered");
73
+ });
74
+
75
+ it("cancels running task", async () => {
76
+ runner.registerExecutor("prompt", async () => {
77
+ await new Promise((r) => setTimeout(r, 10000));
78
+ return { success: true };
79
+ });
80
+
81
+ const task = store.create({ description: "long task" });
82
+ await runner.submit(task);
83
+ await new Promise((r) => setTimeout(r, 50));
84
+
85
+ expect(runner.isRunning(task.id)).toBe(true);
86
+ const cancelled = runner.cancel(task.id);
87
+ expect(cancelled).toBe(true);
88
+
89
+ const updated = store.getById(task.id);
90
+ expect(updated!.status).toBe("cancelled");
91
+ });
92
+
93
+ it("cancels pending task", () => {
94
+ const task = store.create({ description: "pending" });
95
+ const cancelled = runner.cancel(task.id);
96
+ expect(cancelled).toBe(true);
97
+
98
+ const updated = store.getById(task.id);
99
+ expect(updated!.status).toBe("cancelled");
100
+ });
101
+
102
+ it("respects maxConcurrent limit", async () => {
103
+ const executionOrder: number[] = [];
104
+ runner.registerExecutor("prompt", async (task) => {
105
+ const num = parseInt(task.description);
106
+ executionOrder.push(num);
107
+ await new Promise((r) => setTimeout(r, 50));
108
+ return { success: true, result: `${num}` };
109
+ });
110
+
111
+ const tasks = [
112
+ store.create({ description: "1" }),
113
+ store.create({ description: "2" }),
114
+ store.create({ description: "3" }),
115
+ ];
116
+
117
+ // Submit all at once — first 2 run immediately, 3rd waits for slot
118
+ for (const t of tasks) {
119
+ await runner.submit(t);
120
+ }
121
+ // Start poll to pick up the 3rd task after a slot opens
122
+ runner.start(50);
123
+ await new Promise((r) => setTimeout(r, 500));
124
+ runner.stop();
125
+
126
+ expect(runner.runningCount).toBe(0);
127
+ for (const t of tasks) {
128
+ expect(store.getById(t.id)!.status).toBe("completed");
129
+ }
130
+ });
131
+
132
+ it("destroy cancels all running tasks", async () => {
133
+ runner.registerExecutor("prompt", async () => {
134
+ await new Promise((r) => setTimeout(r, 10000));
135
+ return { success: true };
136
+ });
137
+
138
+ const task = store.create({ description: "destroy me" });
139
+ await runner.submit(task);
140
+ await new Promise((r) => setTimeout(r, 50));
141
+
142
+ expect(runner.runningCount).toBe(1);
143
+ runner.destroy();
144
+ expect(runner.runningCount).toBe(0);
145
+ });
146
+
147
+ it("polling picks up pending tasks", async () => {
148
+ runner.registerExecutor("prompt", async (task) => {
149
+ return { success: true, result: `polled: ${task.description}` };
150
+ });
151
+
152
+ const task = store.create({ description: "polled task" });
153
+ // Don't submit — let poll pick it up
154
+ runner.start(50);
155
+ await new Promise((r) => setTimeout(r, 200));
156
+ runner.stop();
157
+
158
+ const updated = store.getById(task.id);
159
+ expect(updated!.status).toBe("completed");
160
+ expect(updated!.result).toBe("polled: polled task");
161
+ });
162
+ });
@@ -0,0 +1,146 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Async Task Store — Tests
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
6
+ import { TaskStore } from "../task-store";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+
11
+ let store: TaskStore;
12
+ let tempDir: string;
13
+
14
+ beforeEach(() => {
15
+ tempDir = mkdtempSync(join(tmpdir(), "async-test-"));
16
+ store = new TaskStore(join(tempDir, "test.db"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ store.close();
21
+ rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("TaskStore", () => {
25
+ it("creates a task with defaults", () => {
26
+ const task = store.create({ description: "test task" });
27
+ expect(task.id).toBeTruthy();
28
+ expect(task.status).toBe("pending");
29
+ expect(task.taskType).toBe("prompt");
30
+ expect(task.description).toBe("test task");
31
+ expect(task.result).toBeNull();
32
+ expect(task.error).toBeNull();
33
+ expect(task.createdAt).toBeGreaterThan(0);
34
+ expect(task.startedAt).toBeNull();
35
+ expect(task.completedAt).toBeNull();
36
+ });
37
+
38
+ it("creates a task with custom type and metadata", () => {
39
+ const task = store.create({
40
+ description: "shell task",
41
+ taskType: "shell",
42
+ metadata: { key: "value" },
43
+ });
44
+ expect(task.taskType).toBe("shell");
45
+ expect(task.metadata).toEqual({ key: "value" });
46
+ });
47
+
48
+ it("getById returns task", () => {
49
+ const created = store.create({ description: "findable" });
50
+ const found = store.getById(created.id);
51
+ expect(found).toBeTruthy();
52
+ expect(found!.description).toBe("findable");
53
+ });
54
+
55
+ it("getById returns null for missing", () => {
56
+ expect(store.getById("nonexistent")).toBeNull();
57
+ });
58
+
59
+ it("lists tasks by status", () => {
60
+ store.create({ description: "a" });
61
+ store.create({ description: "b" });
62
+ const task = store.create({ description: "c" });
63
+ store.updateStatus(task.id, "completed", { result: "done" });
64
+
65
+ expect(store.list("pending")).toHaveLength(2);
66
+ expect(store.list("completed")).toHaveLength(1);
67
+ expect(store.list()).toHaveLength(3);
68
+ });
69
+
70
+ it("transitions to running", () => {
71
+ const task = store.create({ description: "run me" });
72
+ const ok = store.updateStatus(task.id, "running");
73
+ expect(ok).toBe(true);
74
+
75
+ const updated = store.getById(task.id);
76
+ expect(updated!.status).toBe("running");
77
+ expect(updated!.startedAt).toBeGreaterThan(0);
78
+ });
79
+
80
+ it("transitions to completed with result", () => {
81
+ const task = store.create({ description: "complete me" });
82
+ store.updateStatus(task.id, "running");
83
+ store.updateStatus(task.id, "completed", { result: "all done" });
84
+
85
+ const updated = store.getById(task.id);
86
+ expect(updated!.status).toBe("completed");
87
+ expect(updated!.result).toBe("all done");
88
+ expect(updated!.completedAt).toBeGreaterThan(0);
89
+ });
90
+
91
+ it("transitions to failed with error", () => {
92
+ const task = store.create({ description: "fail me" });
93
+ store.updateStatus(task.id, "running");
94
+ store.updateStatus(task.id, "failed", { error: "boom" });
95
+
96
+ const updated = store.getById(task.id);
97
+ expect(updated!.status).toBe("failed");
98
+ expect(updated!.error).toBe("boom");
99
+ });
100
+
101
+ it("transitions to cancelled", () => {
102
+ const task = store.create({ description: "cancel me" });
103
+ store.updateStatus(task.id, "cancelled");
104
+
105
+ const updated = store.getById(task.id);
106
+ expect(updated!.status).toBe("cancelled");
107
+ });
108
+
109
+ it("delete removes task", () => {
110
+ const task = store.create({ description: "delete me" });
111
+ expect(store.delete(task.id)).toBe(true);
112
+ expect(store.getById(task.id)).toBeNull();
113
+ });
114
+
115
+ it("count returns correct totals", () => {
116
+ store.create({ description: "a" });
117
+ store.create({ description: "b" });
118
+ const c = store.create({ description: "c" });
119
+ store.updateStatus(c.id, "completed", { result: "ok" });
120
+
121
+ const counts = store.count();
122
+ expect(counts.total).toBe(3);
123
+ expect(counts.pending).toBe(2);
124
+ expect(counts.completed).toBe(1);
125
+ expect(counts.running).toBe(0);
126
+ expect(counts.failed).toBe(0);
127
+ });
128
+
129
+ it("listPending returns only pending tasks", () => {
130
+ store.create({ description: "a" });
131
+ const b = store.create({ description: "b" });
132
+ store.updateStatus(b.id, "running");
133
+
134
+ expect(store.listPending()).toHaveLength(1);
135
+ });
136
+
137
+ it("cleanup removes old completed tasks", () => {
138
+ const task = store.create({ description: "old" });
139
+ store.updateStatus(task.id, "completed", { result: "done" });
140
+
141
+ // With maxAge=0, everything should be cleaned
142
+ const cleaned = store.cleanup(0);
143
+ expect(cleaned).toBe(1);
144
+ expect(store.getById(task.id)).toBeNull();
145
+ });
146
+ });
@@ -1 +1,28 @@
1
- export * from "./job-manager";
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Async Workflow — barrel export
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+
5
+ export { TaskStore, getTaskStore } from "./task-store";
6
+ export { TaskRunner, getTaskRunner } from "./task-runner";
7
+ export type { TaskExecutor } from "./task-runner";
8
+ export { AsyncJobManager } from "./job-manager";
9
+ export type {
10
+ AsyncJob,
11
+ AsyncJobManagerOptions,
12
+ AsyncJobDeliveryState,
13
+ AsyncJobRegisterOptions,
14
+ AsyncJobFilter,
15
+ } from "./job-manager";
16
+ export { cliNotify, formatTelegramNotify, formatTaskList, formatTaskStatus } from "./notifier";
17
+ export type {
18
+ AsyncTask,
19
+ AsyncTaskRow,
20
+ TaskStatus,
21
+ TaskActionType,
22
+ TaskAction,
23
+ CreateTaskOptions,
24
+ TaskExecutionResult,
25
+ NotifyChannel,
26
+ NotifyCallback,
27
+ TaskRunnerOptions,
28
+ } from "./types";
@@ -0,0 +1,133 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Async Task Notifier — notification dispatch (CLI + Telegram)
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+
5
+ import type { AsyncTask, NotifyCallback, NotifyChannel } from "./types";
6
+ import chalk from "chalk";
7
+ import gradient from "gradient-string";
8
+
9
+ const STATUS_COLORS: Record<string, (text: string) => string> = {
10
+ completed: (t) => chalk.green(t),
11
+ failed: (t) => chalk.red(t),
12
+ cancelled: (t) => chalk.yellow(t),
13
+ running: (t) => chalk.cyan(t),
14
+ pending: (t) => chalk.dim(t),
15
+ };
16
+
17
+ const STATUS_EMOJI: Record<string, string> = {
18
+ completed: "✅",
19
+ failed: "❌",
20
+ cancelled: "🚫",
21
+ running: "⏳",
22
+ pending: "⏸️",
23
+ };
24
+
25
+ function truncate(text: string | null, maxLen = 200): string {
26
+ if (!text) return "(no output)";
27
+ return text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
28
+ }
29
+
30
+ function formatDuration(startMs: number | null, endMs: number | null): string {
31
+ if (!startMs) return "";
32
+ const end = endMs ?? Date.now();
33
+ const duration = end - startMs;
34
+ if (duration < 1000) return `${duration}ms`;
35
+ if (duration < 60000) return `${(duration / 1000).toFixed(1)}s`;
36
+ return `${Math.floor(duration / 60000)}m ${Math.round((duration % 60000) / 1000)}s`;
37
+ }
38
+
39
+ /** CLI notification — renders styled block in terminal */
40
+ export const cliNotify: NotifyCallback = (task: AsyncTask, _channel: NotifyChannel) => {
41
+ const emoji = STATUS_EMOJI[task.status] ?? "❓";
42
+ const colorFn = STATUS_COLORS[task.status] ?? chalk.white;
43
+ const duration = formatDuration(task.startedAt, task.completedAt);
44
+
45
+ const header = `${emoji} Task ${task.id.slice(0, 8)} — ${colorFn(task.status.toUpperCase())}`;
46
+ const desc = ` ${chalk.dim("Description:")} ${task.description}`;
47
+ const time = duration ? ` ${chalk.dim("Duration:")} ${duration}` : "";
48
+
49
+ console.log("");
50
+ console.log(chalk.bold(header));
51
+ console.log(desc);
52
+ if (time) console.log(time);
53
+
54
+ if (task.status === "completed" && task.result) {
55
+ console.log(` ${chalk.dim("Result:")} ${truncate(task.result)}`);
56
+ }
57
+ if (task.status === "failed" && task.error) {
58
+ console.log(` ${chalk.dim("Error:")} ${chalk.red(truncate(task.error))}`);
59
+ }
60
+ console.log("");
61
+ };
62
+
63
+ /** Telegram notification — formats message for MarkdownV2 */
64
+ export function formatTelegramNotify(task: AsyncTask): string {
65
+ const emoji = STATUS_EMOJI[task.status] ?? "❓";
66
+ const duration = formatDuration(task.startedAt, task.completedAt);
67
+
68
+ let msg = `${emoji} *Task ${task.id.slice(0, 8)}* — ${task.status.toUpperCase()}\n`;
69
+ msg += `📋 ${task.description}\n`;
70
+ if (duration) msg += `⏱ ${duration}\n`;
71
+
72
+ if (task.status === "completed" && task.result) {
73
+ msg += `\n📄 Result:\n\`\`\`\n${truncate(task.result, 500)}\n\`\`\``;
74
+ }
75
+ if (task.status === "failed" && task.error) {
76
+ msg += `\n❌ Error: ${truncate(task.error, 300)}`;
77
+ }
78
+
79
+ return msg;
80
+ }
81
+
82
+ /** Format task list for display */
83
+ export function formatTaskList(tasks: AsyncTask[]): string {
84
+ if (tasks.length === 0) return chalk.dim("No tasks found.");
85
+
86
+ const lines = [chalk.bold("Async Tasks:"), ""];
87
+
88
+ for (const task of tasks) {
89
+ const emoji = STATUS_EMOJI[task.status] ?? "❓";
90
+ const colorFn = STATUS_COLORS[task.status] ?? chalk.white;
91
+ const duration = formatDuration(task.startedAt, task.completedAt);
92
+ const age = formatAge(task.createdAt);
93
+
94
+ lines.push(
95
+ ` ${emoji} ${chalk.bold(task.id.slice(0, 8))} ${colorFn(task.status.padEnd(10))} ${age} ${duration ? chalk.dim(`(${duration})`) : ""}`,
96
+ );
97
+ lines.push(` ${task.description}`);
98
+ lines.push("");
99
+ }
100
+
101
+ return lines.join("\n");
102
+ }
103
+
104
+ /** Format single task status */
105
+ export function formatTaskStatus(task: AsyncTask): string {
106
+ const emoji = STATUS_EMOJI[task.status] ?? "❓";
107
+ const colorFn = STATUS_COLORS[task.status] ?? chalk.white;
108
+ const duration = formatDuration(task.startedAt, task.completedAt);
109
+
110
+ const lines = [
111
+ chalk.bold(`${emoji} Task ${task.id}`),
112
+ ` Status: ${colorFn(task.status)}`,
113
+ ` Description: ${task.description}`,
114
+ ` Type: ${task.taskType}`,
115
+ ` Created: ${new Date(task.createdAt).toISOString()}`,
116
+ ];
117
+
118
+ if (task.startedAt) lines.push(` Started: ${new Date(task.startedAt).toISOString()}`);
119
+ if (task.completedAt) lines.push(` Completed: ${new Date(task.completedAt).toISOString()}`);
120
+ if (duration) lines.push(` Duration: ${duration}`);
121
+ if (task.result) lines.push(` Result: ${truncate(task.result, 300)}`);
122
+ if (task.error) lines.push(` Error: ${chalk.red(truncate(task.error, 300))}`);
123
+
124
+ return lines.join("\n");
125
+ }
126
+
127
+ function formatAge(createdAt: number): string {
128
+ const diff = Date.now() - createdAt;
129
+ if (diff < 60000) return "just now";
130
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
131
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
132
+ return `${Math.floor(diff / 86400000)}d ago`;
133
+ }