pi-cc-loop 0.1.1 → 0.2.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.
package/schedule.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Job store and dispatch scheduler for the pi-cc-loop extension.
3
3
  *
4
- * Modelled on Claude Code's scheduler (v2.1.306):
4
+ * Modelled on Claude Code's scheduler:
5
5
  *
6
6
  * - "The scheduler checks every second for due tasks and enqueues them at low
7
7
  * priority." -> one scan per second, not one timer per job.
@@ -10,9 +10,18 @@
10
10
  * - "If a task's scheduled time passes while Claude is busy on a long-running
11
11
  * request, it fires once when Claude becomes idle, not once per missed
12
12
  * interval." -> at most one pending tick per job, no backlog.
13
+ * - "Recurring tasks automatically expire 7 days after creation. The task
14
+ * fires one final time, then deletes itself." -> a recurring job past its
15
+ * maximum age is deleted right after its next fire. One-shot jobs do not
16
+ * expire.
17
+ * - Run times carry Claude Code's fixed per-job offset (see nextRunTime).
18
+ *
19
+ * There is no delivery confirmation or retry: Pi marks the session busy as
20
+ * soon as a tick starts a turn, so the idle check alone keeps ticks one at a
21
+ * time, and a tick that fails to start is dropped (Claude Code drops it too).
13
22
  */
14
23
 
15
- import { nextFireAfter, parseCron, type CronFields } from "./cron.ts";
24
+ import { nextFireAfter, parseCron } from "./cron.ts";
16
25
 
17
26
  export type JobKind = "cron" | "wakeup";
18
27
 
@@ -25,67 +34,137 @@ export interface LoopJob {
25
34
  prompt: string;
26
35
  recurring: boolean;
27
36
  createdAt: number;
28
- /** Epoch ms of the next cron slot. */
37
+ /** Epoch ms of the next cron run, offset included. */
29
38
  nextFireAt?: number;
30
- /** Epoch ms of the next dynamic wakeup. */
39
+ /** Epoch ms of the next self-paced wakeup. */
31
40
  scheduledFor?: number;
32
- /** Epoch ms when the dynamic chain started; used for the age-out check. */
41
+ /** Epoch ms when the self-paced chain started; used for the 7-day limit. */
33
42
  chainStartedAt?: number;
34
43
  /** True once the keepalive fallback has been used for this chain. */
35
44
  keepaliveUsed?: boolean;
36
45
  /** True while the current turn is the keepalive turn (the model's last chance). */
37
46
  keepaliveTurn?: boolean;
38
- /** True after a tick was dispatched, until the model reschedules. */
47
+ /** True after a wakeup fired, until the model reschedules. */
39
48
  awaitingReschedule?: boolean;
40
49
  /** True when the job is due and waiting for the agent to go idle. */
41
50
  pending?: boolean;
42
51
  /** Epoch ms when the job became pending. */
43
52
  dueAt?: number;
44
- /** Epoch ms when the job was last dispatched. */
45
- dispatchedAt?: number;
46
- /** Turn sequence number at dispatch; set once a turn confirms delivery. */
47
- dispatchedTurnSeq?: number;
48
53
  /** How many times the job has fired. */
49
54
  fireCount?: number;
50
55
  /** How many ticks the model has seen. */
51
56
  tickCount?: number;
52
57
  /** Epoch ms of the last fire. */
53
- lastTickAt?: number;
58
+ lastFiredAt?: number;
54
59
  /** Optional cap on the number of fires. */
55
60
  maxFires?: number;
56
- /** True when the job is written to disk and survives a restart. */
61
+ /** True when the job lives in .pi/scheduled_tasks.json and survives restarts. */
57
62
  durable: boolean;
63
+ /** The project folder a durable job was created in; it only runs there. */
64
+ createdInProject?: string;
58
65
  /** Free-form label shown in `/loop status`. */
59
66
  label?: string;
60
- /** Why the delay was chosen (dynamic jobs). */
67
+ /** Why the delay was chosen (self-paced chains). */
61
68
  reason?: string;
62
69
  /** Where the job came from: `loop_command`, `cron_create`, `schedule_wakeup`. */
63
70
  source: string;
64
71
  }
65
72
 
73
+ /** Fields that only mean something while the job lives in this process. */
74
+ const TRANSIENT_FIELDS = ["pending", "dueAt", "awaitingReschedule", "keepaliveTurn"] as const;
75
+
76
+ /** A copy of the job without its in-process state, for persisting. */
77
+ export function storedJob(job: LoopJob): LoopJob {
78
+ const copy = { ...job };
79
+ for (const field of TRANSIENT_FIELDS) delete copy[field];
80
+ return copy;
81
+ }
82
+
83
+ /**
84
+ * Claude Code's offsets (v2.1.302 defaults, also in its docs): "a recurring
85
+ * task starts each run late by up to half the gap to the next run, and by at
86
+ * most 30 minutes"; "a one-shot task scheduled for :00 or :30 runs up to 90
87
+ * seconds early".
88
+ *
89
+ * Claude Code also starts `*\/5` jobs 15 seconds early, so that consecutive runs
90
+ * stay inside its 5 minute prompt cache. That is dropped here: it makes the
91
+ * interval 4m45s instead of 5m, and caches now last far longer than 5 minutes.
92
+ */
93
+ export interface TimingConfig {
94
+ recurringFrac: number;
95
+ recurringCapMs: number;
96
+ oneShotMaxMs: number;
97
+ oneShotMinuteMod: number;
98
+ }
99
+
100
+ export const DEFAULT_TIMING: TimingConfig = {
101
+ recurringFrac: 0.5,
102
+ recurringCapMs: 30 * 60_000,
103
+ oneShotMaxMs: 90_000,
104
+ oneShotMinuteMod: 30,
105
+ };
106
+
107
+ /** A fraction in [0, 1) derived from the job id, so a job always gets the same offset. */
108
+ export function idFraction(id: string): number {
109
+ let hash = 0x811c9dc5;
110
+ for (let i = 0; i < id.length; i++) {
111
+ hash ^= id.charCodeAt(i);
112
+ hash = Math.imul(hash, 0x01000193) >>> 0;
113
+ }
114
+ return hash / 4294967296;
115
+ }
116
+
117
+ /**
118
+ * When a cron job runs next, measured from `from`, with Claude Code's
119
+ * offsets. Recurring jobs measure from their last fire (or creation); a
120
+ * one-shot measures from its creation, so its run time never moves.
121
+ */
122
+ export function nextRunTime(
123
+ job: Pick<LoopJob, "id" | "cron" | "recurring">,
124
+ from: number,
125
+ timing: TimingConfig = DEFAULT_TIMING,
126
+ ): number | null {
127
+ if (!job.cron) return null;
128
+ const fields = parseCron(job.cron);
129
+ if (!fields) return null;
130
+ const first = nextFireAfter(fields, new Date(from));
131
+ if (!first) return null;
132
+ const slot = first.getTime();
133
+
134
+ if (!job.recurring) {
135
+ if (new Date(slot).getMinutes() % timing.oneShotMinuteMod !== 0) return slot;
136
+ return Math.max(slot - idFraction(job.id) * timing.oneShotMaxMs, from);
137
+ }
138
+
139
+ const second = nextFireAfter(fields, first);
140
+ if (!second) return slot;
141
+ const period = second.getTime() - slot;
142
+ return slot + Math.min(idFraction(job.id) * timing.recurringFrac * period, timing.recurringCapMs);
143
+ }
144
+
66
145
  export interface SchedulerHooks {
67
146
  /** Deliver the job's prompt to the agent. */
68
147
  dispatch: (job: LoopJob) => void;
148
+ /** Called after a fire; `finished` when the job was deleted (one-shot, cap, final run). */
149
+ afterDispatch?: (job: LoopJob, finished: boolean) => void;
69
150
  /** Called whenever the job set changes, so state can be persisted. */
70
151
  onChange?: (jobs: LoopJob[]) => void;
71
- /** Max age of a recurring job in ms; 0 disables the check. */
72
- maxAgeMs?: () => number;
73
- /** Fraction of its period a recurring job may be delayed by; 0 disables jitter. */
74
- jitterFraction?: () => number;
75
- /** Upper bound for the jitter delay in ms. */
76
- jitterCapMs?: () => number;
77
152
  /** True when the agent is idle and has nothing queued: a tick may go out. */
78
153
  canDispatch?: () => boolean;
79
- /** Called when a job passes its maximum age and is dropped. */
154
+ /** The offsets to apply to run times. */
155
+ timing?: () => TimingConfig;
156
+ /** Max age of a recurring job in ms; 0 disables the limit. */
157
+ maxAgeMs?: () => number;
158
+ /** A recurring job ran its final time and was deleted. */
80
159
  onExpire?: (job: LoopJob) => void;
160
+ /** Runs at the start of every scan (housekeeping such as durable jobs). */
161
+ onScan?: (now: number) => void;
81
162
  }
82
163
 
83
164
  /** Claude Code checks for due tasks once a second. */
84
165
  export const SCAN_INTERVAL_MS = 1000;
85
166
 
86
- /**
87
- * The scan interval can be raised for tests, where the scan is driven by hand.
88
- */
167
+ /** The scan interval can be raised for tests, where the scan is driven by hand. */
89
168
  export function scanIntervalMs(): number {
90
169
  const raw = process.env.PI_LOOP_SCAN_MS;
91
170
  if (raw === undefined) return SCAN_INTERVAL_MS;
@@ -93,12 +172,6 @@ export function scanIntervalMs(): number {
93
172
  return Number.isFinite(value) && value > 0 ? value : SCAN_INTERVAL_MS;
94
173
  }
95
174
 
96
- /**
97
- * How long a dispatched tick may sit without a turn starting before it is
98
- * treated as undelivered and requeued.
99
- */
100
- export const DISPATCH_CONFIRM_MS = 30_000;
101
-
102
175
  const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
103
176
 
104
177
  export function generateId(length = 8): string {
@@ -112,13 +185,8 @@ export function generateId(length = 8): string {
112
185
  export class LoopScheduler {
113
186
  private jobs = new Map<string, LoopJob>();
114
187
  private scanTimer: ReturnType<typeof setInterval> | null = null;
115
- private hooks: SchedulerHooks;
116
- private inFlightId: string | null = null;
117
- private turnSeq = 0;
118
188
 
119
- constructor(hooks: SchedulerHooks) {
120
- this.hooks = hooks;
121
- }
189
+ constructor(private readonly hooks: SchedulerHooks) {}
122
190
 
123
191
  get size(): number {
124
192
  return this.jobs.size;
@@ -136,7 +204,7 @@ export class LoopScheduler {
136
204
  return this.list().filter((job) => job.kind === kind);
137
205
  }
138
206
 
139
- /** The dynamic chain that owns the given wakeup prompt, if any. */
207
+ /** The self-paced chain that owns the given prompt, if any. */
140
208
  findChain(prompt: string): LoopJob | undefined {
141
209
  return this.list().find((job) => job.kind === "wakeup" && job.prompt === prompt);
142
210
  }
@@ -148,41 +216,46 @@ export class LoopScheduler {
148
216
  return job;
149
217
  }
150
218
 
219
+ /** Update a job in place, so references held elsewhere stay current. */
151
220
  update(id: string, patch: Partial<LoopJob>): LoopJob | undefined {
152
221
  const job = this.jobs.get(id);
153
222
  if (!job) return undefined;
154
- const updated = { ...job, ...patch };
155
- this.jobs.set(id, updated);
223
+ Object.assign(job, patch);
156
224
  this.changed();
157
- return updated;
225
+ return job;
158
226
  }
159
227
 
160
228
  remove(id: string): boolean {
161
229
  const existed = this.jobs.delete(id);
162
- if (existed && this.inFlightId === id) this.inFlightId = null;
163
230
  if (existed) this.changed();
164
231
  return existed;
165
232
  }
166
233
 
167
- /** Remove every job of a kind (used by `/loop stop`). */
168
- removeKind(kind: JobKind): number {
169
- const ids = this.byKind(kind).map((job) => job.id);
170
- for (const id of ids) this.jobs.delete(id);
171
- if (ids.length) this.changed();
172
- return ids.length;
173
- }
174
-
175
234
  clear(notify = true): void {
176
235
  this.jobs.clear();
177
- this.inFlightId = null;
178
236
  if (notify) this.changed();
179
237
  }
180
238
 
181
- /** Restore a job without arming it (used while loading persisted state). */
239
+ /** Put a job back without arming it or announcing a change. */
182
240
  restore(job: LoopJob): void {
183
241
  this.jobs.set(job.id, job);
184
242
  }
185
243
 
244
+ /** Announce the current job set (after a series of restores). */
245
+ refresh(): void {
246
+ this.changed();
247
+ }
248
+
249
+ /**
250
+ * Compute a cron job's next run. Recurring jobs measure from `from`
251
+ * (default now); a one-shot always measures from its creation.
252
+ */
253
+ arm(job: LoopJob, from = Date.now()): void {
254
+ if (job.kind !== "cron") return;
255
+ const next = nextRunTime(job, job.recurring ? from : job.createdAt, this.timing());
256
+ job.nextFireAt = next ?? undefined;
257
+ }
258
+
186
259
  // ------------------------------------------------------------- lifecycle
187
260
 
188
261
  /** Start the once-per-second scan. */
@@ -200,46 +273,20 @@ export class LoopScheduler {
200
273
  }
201
274
  }
202
275
 
203
- /** Compute the first fire time of a cron job. */
204
- arm(job: LoopJob, from = Date.now()): void {
205
- if (job.kind !== "cron" || !job.cron) return;
206
- const fields = parseCron(job.cron);
207
- if (!fields) return;
208
- const next = nextFireAfter(fields, new Date(from));
209
- if (!next) return;
210
- job.nextFireAt = next.getTime() + this.jitterFor(job, fields, next);
211
- }
212
-
213
276
  // ------------------------------------------------------------- dispatch
214
277
 
215
- /**
216
- * Run one scan: expire old jobs, requeue unconfirmed ticks, mark due jobs
217
- * pending, and dispatch the oldest pending job when the agent is idle.
218
- */
278
+ /** Run one scan: housekeeping, mark due jobs pending, dispatch the oldest when idle. */
219
279
  tick(now = Date.now()): void {
220
- this.expire(now);
221
- this.retryUnconfirmed(now);
280
+ this.hooks.onScan?.(now);
222
281
  this.markDue(now);
223
282
  this.tryDispatch(now);
224
283
  }
225
284
 
226
- /**
227
- * The agent settled: a dispatched tick has finished its turn, so the next
228
- * pending job may go out.
229
- */
285
+ /** The agent settled: a pending tick may go out now. */
230
286
  notifyIdle(): void {
231
- this.releaseInFlight();
232
287
  this.tick();
233
288
  }
234
289
 
235
- /** A turn started: any dispatched tick is confirmed delivered. */
236
- noteTurnStarted(): void {
237
- this.turnSeq += 1;
238
- if (!this.inFlightId) return;
239
- const job = this.jobs.get(this.inFlightId);
240
- if (job) job.dispatchedTurnSeq = this.turnSeq;
241
- }
242
-
243
290
  /**
244
291
  * Mark jobs whose time has come as pending. A job is pending at most once:
245
292
  * missed intervals coalesce into a single tick, like Claude Code.
@@ -261,112 +308,43 @@ export class LoopScheduler {
261
308
  if (changed) this.changed();
262
309
  }
263
310
 
264
- /** Dispatch the oldest pending job when nothing is in flight and the agent is idle. */
265
- private tryDispatch(now: number): boolean {
266
- if (this.inFlightId) return false;
267
- const job = this.oldestPending();
268
- if (!job) return false;
269
- if (this.hooks.canDispatch && !this.hooks.canDispatch()) return false;
311
+ /** Dispatch the oldest pending job when the agent is idle. */
312
+ private tryDispatch(now: number): void {
313
+ const job = this.list()
314
+ .filter((candidate) => candidate.pending)
315
+ .sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0))[0];
316
+ if (!job) return;
317
+ if (this.hooks.canDispatch && !this.hooks.canDispatch()) return;
270
318
 
271
319
  job.pending = false;
272
320
  job.dueAt = undefined;
273
- job.dispatchedAt = now;
274
- job.dispatchedTurnSeq = undefined;
275
321
  job.fireCount = (job.fireCount ?? 0) + 1;
276
- job.lastTickAt = now;
277
-
278
- this.inFlightId = job.id;
322
+ job.lastFiredAt = now;
279
323
 
324
+ let finished = false;
325
+ let expired = false;
280
326
  if (job.kind === "wakeup") {
281
327
  // A wakeup that fired from the keepalive is the model's last chance.
282
328
  if (job.keepaliveUsed) job.keepaliveTurn = true;
283
329
  job.awaitingReschedule = true;
284
330
  } else {
285
- this.advanceCron(job, now);
331
+ const capped = job.maxFires !== undefined && job.fireCount >= job.maxFires;
332
+ const maxAge = this.hooks.maxAgeMs?.() ?? 0;
333
+ expired = job.recurring && maxAge > 0 && now - job.createdAt >= maxAge;
334
+ const next = job.recurring && !capped && !expired ? nextRunTime(job, now, this.timing()) : null;
335
+ job.nextFireAt = next ?? undefined;
336
+ finished = next === null;
286
337
  }
287
- this.changed();
288
338
 
289
339
  this.hooks.dispatch(job);
290
-
291
- // A one-shot cron job and a capped cron job are done after their last
292
- // fire. A wakeup chain is never deleted here: the model re-arms it.
293
- if (job.kind === "cron" && (!job.recurring || (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires))) {
294
- this.jobs.delete(job.id);
295
- this.inFlightId = null;
296
- this.changed();
297
- }
298
- return true;
299
- }
300
-
301
- /** Recompute the next cron slot after a fire. */
302
- private advanceCron(job: LoopJob, now: number): void {
303
- if (!job.cron) return;
304
- const fields = parseCron(job.cron);
305
- if (!fields) return;
306
- const next = nextFireAfter(fields, new Date(now));
307
- if (!next) return;
308
- job.nextFireAt = next.getTime() + this.jitterFor(job, fields, next);
309
- }
310
-
311
- /**
312
- * A tick that was dispatched but never started a turn is treated as
313
- * undelivered: the job goes back to pending so it fires again.
314
- */
315
- private retryUnconfirmed(now: number): void {
316
- if (!this.inFlightId) return;
317
- const job = this.jobs.get(this.inFlightId);
318
- if (!job) {
319
- this.inFlightId = null;
320
- return;
321
- }
322
- if (job.dispatchedTurnSeq !== undefined) return;
323
- if (now - (job.dispatchedAt ?? 0) < DISPATCH_CONFIRM_MS) return;
324
- job.pending = true;
325
- job.dueAt = now;
326
- this.inFlightId = null;
340
+ if (finished) this.jobs.delete(job.id);
341
+ this.hooks.afterDispatch?.(job, finished);
342
+ if (expired) this.hooks.onExpire?.(job);
327
343
  this.changed();
328
344
  }
329
345
 
330
- /** Called when the agent settles: the in-flight tick has finished its turn. */
331
- releaseInFlight(): void {
332
- this.inFlightId = null;
333
- }
334
-
335
- private oldestPending(): LoopJob | undefined {
336
- return this.list()
337
- .filter((job) => job.pending)
338
- .sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0))[0];
339
- }
340
-
341
- // --------------------------------------------------------------- expiry
342
-
343
- private expire(now: number): void {
344
- const limit = this.hooks.maxAgeMs?.() ?? 0;
345
- if (limit <= 0) return;
346
- let changed = false;
347
- for (const job of this.list()) {
348
- if (now - job.createdAt < limit) continue;
349
- this.jobs.delete(job.id);
350
- if (this.inFlightId === job.id) this.inFlightId = null;
351
- changed = true;
352
- this.hooks.onExpire?.(job);
353
- }
354
- if (changed) this.changed();
355
- }
356
-
357
- /**
358
- * Randomised delay for recurring jobs, so many sessions polling the same
359
- * target do not all fire on the same second.
360
- */
361
- private jitterFor(job: LoopJob, fields: CronFields, next: Date): number {
362
- if (!job.recurring) return 0;
363
- const fraction = this.hooks.jitterFraction?.() ?? 0;
364
- if (!(fraction > 0)) return 0;
365
- const following = nextFireAfter(fields, next);
366
- if (!following) return 0;
367
- const period = following.getTime() - next.getTime();
368
- const cap = this.hooks.jitterCapMs?.() ?? 30 * 60 * 1000;
369
- return Math.round(Math.random() * Math.min(fraction * period, cap));
346
+ private timing(): TimingConfig {
347
+ return this.hooks.timing?.() ?? DEFAULT_TIMING;
370
348
  }
371
349
 
372
350
  private changed(): void {
package/tool-rows.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Compact one-line tool rows and small formatting helpers shared by the pi-cc
3
+ * extensions.
4
+ *
5
+ * This file is identical in pi-cc-loop and pi-cc-monitor; tests/shared.test.mjs
6
+ * fails when the two copies drift apart.
7
+ */
8
+
9
+ import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
10
+ import { Container, Text } from "@earendil-works/pi-tui";
11
+
12
+ /** Collapse whitespace and cut the text to `max` characters. */
13
+ export function truncate(text: string, max: number): string {
14
+ const clean = text.replace(/\s+/g, " ").trim();
15
+ return clean.length > max ? `${clean.slice(0, max - 1)}\u2026` : clean;
16
+ }
17
+
18
+ /** "20s", "5m", "1m 30s", "1h", "1h 5m", "2d 3h". */
19
+ export function formatDuration(ms: number): string {
20
+ if (!Number.isFinite(ms)) return "unknown";
21
+ const seconds = Math.max(0, Math.round(ms / 1000));
22
+ if (seconds < 60) return `${seconds}s`;
23
+ const minutes = Math.floor(seconds / 60);
24
+ const restSeconds = seconds % 60;
25
+ if (minutes < 60) return restSeconds ? `${minutes}m ${restSeconds}s` : `${minutes}m`;
26
+ const hours = Math.floor(minutes / 60);
27
+ const restMinutes = minutes % 60;
28
+ if (hours < 24) return restMinutes ? `${hours}h ${restMinutes}m` : `${hours}h`;
29
+ const days = Math.floor(hours / 24);
30
+ const restHours = hours % 24;
31
+ return restHours ? `${days}d ${restHours}h` : `${days}d`;
32
+ }
33
+
34
+ /** The first text line of a tool result. */
35
+ export function resultText(result: { content?: Array<{ type?: string; text?: string }> }): string {
36
+ for (const block of result.content ?? []) {
37
+ if (block?.type === "text" && typeof block.text === "string") {
38
+ return block.text.split("\n")[0] ?? "";
39
+ }
40
+ }
41
+ return "";
42
+ }
43
+
44
+ export interface CompactRow {
45
+ /** The call row, shown while the call runs. It disappears once the result arrives. */
46
+ call: (args: any) => { label: string; detail?: string };
47
+ /** The one-line summary of a successful result. */
48
+ summary: (details: any, args: any) => string;
49
+ /** Extra lines shown when the row is expanded. */
50
+ detail?: (details: any, args: any) => string | undefined;
51
+ }
52
+
53
+ /**
54
+ * Renderers that draw a tool call as a single result line, for example
55
+ * `✓ Scheduled hu2389lz · Every minute · next 04:55 PM`. Errors show the first
56
+ * line of the error text.
57
+ */
58
+ export function compactRenderers(row: CompactRow): ToolRenderers {
59
+ return {
60
+ renderCall(args: any, theme: Theme, context: any) {
61
+ if (!context?.isPartial && context?.executionStarted) return new Container();
62
+ const { label, detail } = row.call(args ?? {});
63
+ const title = theme.fg("toolTitle", theme.bold(label));
64
+ return new Text(detail ? `${title} ${theme.fg("muted", detail)}` : title, 0, 0);
65
+ },
66
+ renderResult(result: any, options: any, theme: Theme, context: any) {
67
+ if (options?.isPartial) return new Text(theme.fg("warning", "Working\u2026"), 0, 0);
68
+ if (context?.isError) {
69
+ return new Text(`${theme.fg("error", "\u2717 ")}${theme.fg("error", resultText(result) || "Failed")}`, 0, 0);
70
+ }
71
+ const details = result?.details ?? {};
72
+ const args = context?.args ?? {};
73
+ const summary = row.summary(details, args) || resultText(result);
74
+ const line = `${theme.fg("success", "\u2713 ")}${theme.fg("toolOutput", summary)}`;
75
+ const detail = options?.expanded ? row.detail?.(details, args) : undefined;
76
+ return new Text(detail ? `${line}\n${theme.fg("dim", detail)}` : line, 0, 0);
77
+ },
78
+ };
79
+ }