@kolisachint/hoobot 0.0.3 → 0.0.5

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/src/session.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * One Discord thread ⇄ one app-server thread. Translates Codex app-server
2
+ * One Discord space (a channel or a thread) ⇄ one app-server thread. Translates Codex app-server
3
3
  * notifications into Discord messages and approval requests into buttons.
4
4
  * Uses only standard Codex methods, so it works against `hoocode app-server`
5
5
  * and the real `codex app-server` alike.
@@ -9,16 +9,20 @@ import {
9
9
  ButtonBuilder,
10
10
  ButtonStyle,
11
11
  ComponentType,
12
+ StringSelectMenuBuilder,
12
13
  type Message,
13
14
  } from "discord.js";
14
15
  import { config } from "./config.ts";
15
16
  import { code, describeTool, splitMessage, truncate } from "./format.ts";
16
17
  import { CodexClient, RpcError, userInput, type Notification, type RequestId, type ServerRequest } from "./codex-client.ts";
17
18
  import type { LinkStore } from "./links.ts";
19
+ import { TurnSummary } from "./summary.ts";
20
+ import { changedSince, pathsInText, pickAttachments, type Attachment } from "./attachments.ts";
18
21
 
19
22
  /** The bits of a Discord thread this file uses (tests pass a fake). */
20
23
  export interface ThreadLike {
21
24
  id: string;
25
+ /** Resolves to a message with `edit` and `delete`. */
22
26
  send(content: any): Promise<any>;
23
27
  sendTyping(): Promise<unknown>;
24
28
  }
@@ -34,10 +38,25 @@ export class ThreadSession {
34
38
  private progressMsg: Message | null = null;
35
39
  private progressDirty = false;
36
40
  private progressTimer: ReturnType<typeof setInterval>;
41
+ /** Show every tool step and in-between message (`!verbose`). Off: status line + final answer. */
42
+ private verbose = false;
43
+ /** The running turn's summary and its status-line message. */
44
+ private live: { summary: TurnSummary; msg: Message | null; at: number } | null = null;
45
+ /** Latest agent message of the running turn; posted when the turn ends. */
46
+ private answer: string | null = null;
37
47
  private typingTimer: ReturnType<typeof setInterval> | null = null;
38
48
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
49
  private approvals = new Map<RequestId, Approval>();
50
+ /** The model the server reports for the thread. */
40
51
  private model: string | null = null;
52
+ /** The model picked for this Discord thread (`!model`), saved in the link. */
53
+ private chosenModel: string | null = null;
54
+ /** Last Discord message the conversation has read; saved in the link. */
55
+ private seenId: string | null = null;
56
+ /** Whether the space had a conversation before this session opened. */
57
+ private wasLinked = false;
58
+ /** The message that started the running turn; the answer replies to it. */
59
+ private caller: { id: string } | null = null;
41
60
  /** Serialises Discord sends so replies stay in order. */
42
61
  private queue: Promise<unknown> = Promise.resolve();
43
62
  private ready: Promise<void> | null = null;
@@ -48,6 +67,8 @@ export class ThreadSession {
48
67
  private readonly client: CodexClient,
49
68
  private readonly links: LinkStore,
50
69
  private onClose: (threadId: string) => void,
70
+ /** The folder this thread works in (for `!status`, and files to attach). */
71
+ readonly workdir = config.workdir,
51
72
  ) {
52
73
  client.on("notification", this.onNotification);
53
74
  client.on("request", this.onRequest);
@@ -67,6 +88,9 @@ export class ThreadSession {
67
88
  private ensureThread(): Promise<void> {
68
89
  this.ready ??= (async () => {
69
90
  const link = this.links.get(this.linkKey);
91
+ this.chosenModel = link?.model ?? null;
92
+ this.seenId = link?.seen ?? null;
93
+ this.wasLinked = !!link;
70
94
  if (link) {
71
95
  try {
72
96
  const res = await this.client.request("thread/resume", { threadId: link.threadId });
@@ -75,6 +99,8 @@ export class ThreadSession {
75
99
  } catch (err) {
76
100
  console.error(`[${this.thread.id}] resume ${link.threadId} failed; starting fresh`, err);
77
101
  await this.post(`Couldn't reopen the earlier conversation (${code(errorText(err))}). Starting a new one.`);
102
+ this.seenId = null;
103
+ this.wasLinked = false;
78
104
  }
79
105
  }
80
106
  await this.startThread();
@@ -87,10 +113,34 @@ export class ThreadSession {
87
113
 
88
114
  private async startThread() {
89
115
  const params: Record<string, unknown> = {};
90
- if (config.model) params.model = config.model;
116
+ const model = this.chosenModel ?? config.model;
117
+ if (model) params.model = model;
91
118
  const res = await this.client.request("thread/start", params);
92
119
  this.adopt(res);
93
- this.links.set(this.linkKey, { threadId: res.thread.id });
120
+ this.saveLink();
121
+ }
122
+
123
+ private saveLink() {
124
+ if (!this.threadId) return;
125
+ this.links.set(this.linkKey, {
126
+ threadId: this.threadId,
127
+ ...(this.chosenModel ? { model: this.chosenModel } : {}),
128
+ ...(this.seenId ? { seen: this.seenId } : {}),
129
+ });
130
+ }
131
+
132
+ /** Read position for context: whether the space had a conversation, and its last read message. */
133
+ async readState(): Promise<{ linked: boolean; seen: string | null }> {
134
+ await this.ensureThread();
135
+ return { linked: this.wasLinked, seen: this.seenId };
136
+ }
137
+
138
+ /** Everything up to `messageId` has been sent to the conversation. */
139
+ markSeen(messageId: string) {
140
+ if (this.seenId && BigInt(this.seenId) >= BigInt(messageId)) return;
141
+ this.seenId = messageId;
142
+ this.wasLinked = true;
143
+ this.saveLink();
94
144
  }
95
145
 
96
146
  private adopt(res: any) {
@@ -98,13 +148,17 @@ export class ThreadSession {
98
148
  this.model = res.model ?? null;
99
149
  // A turn still running on the server (e.g. after a bot restart).
100
150
  const running = (res.thread.turns ?? []).findLast?.((t: any) => t.status === "inProgress");
101
- this.turnId = running?.id ?? null;
102
- if (this.turnId) this.startTyping();
151
+ this.turnId = null;
152
+ if (running) this.beginTurn(running.id);
103
153
  }
104
154
 
105
155
  // ── Input from Discord ─────────────────────────────────────────────────────
106
156
 
107
- async prompt(text: string, images: { data: string; mimeType: string }[] = []) {
157
+ /**
158
+ * `caller`: the Discord message this answers; the final answer replies to it.
159
+ * Resolves true when the server took the message (a new turn or a steer).
160
+ */
161
+ async prompt(text: string, images: { data: string; mimeType: string }[] = [], caller?: { id: string }): Promise<boolean> {
108
162
  this.touch();
109
163
  await this.ensureThread();
110
164
  const input = userInput(text, images);
@@ -113,7 +167,7 @@ export class ThreadSession {
113
167
  try {
114
168
  await this.client.request("turn/steer", { threadId: this.threadId, input, expectedTurnId: this.turnId });
115
169
  await this.post("Queued. It'll be read after the current step.");
116
- return;
170
+ return true;
117
171
  } catch (err) {
118
172
  // The turn ended in the meantime: start a new one below.
119
173
  if (!(err instanceof RpcError)) throw err;
@@ -121,15 +175,17 @@ export class ThreadSession {
121
175
  }
122
176
  try {
123
177
  const params: Record<string, unknown> = { threadId: this.threadId, input };
124
- if (this.pendingModel) params.model = this.pendingModel;
178
+ // Sent every turn: the server ignores it when it's already the model,
179
+ // and it survives a bot restart or a server that forgot it.
180
+ if (this.chosenModel) params.model = this.chosenModel;
125
181
  const res = await this.client.request("turn/start", params);
126
- if (this.pendingModel) {
127
- this.model = this.pendingModel;
128
- this.pendingModel = null;
129
- }
182
+ if (this.chosenModel) this.model = this.chosenModel;
130
183
  this.beginTurn(res.turn.id);
184
+ this.caller = caller ?? null;
185
+ return true;
131
186
  } catch (err) {
132
187
  await this.post(`**Not sent:** ${errorText(err)}`);
188
+ return false;
133
189
  }
134
190
  }
135
191
 
@@ -152,6 +208,9 @@ export class ThreadSession {
152
208
  }
153
209
  if (this.threadId) await this.client.request("thread/unsubscribe", { threadId: this.threadId }).catch(() => {});
154
210
  this.turnId = null;
211
+ // The new conversation knows nothing: the next call reads the last 30 again.
212
+ this.seenId = null;
213
+ this.wasLinked = false;
155
214
  this.ready = this.startThread();
156
215
  try {
157
216
  await this.ready;
@@ -166,20 +225,117 @@ export class ThreadSession {
166
225
  await this.ensureThread();
167
226
  const lines = [
168
227
  "**Status**",
169
- `- Model: ${code(this.pendingModel ?? this.model ?? "default")}`,
228
+ `- Model: ${code(this.chosenModel ?? this.model ?? "default")}${this.chosenModel && this.chosenModel !== this.model ? " (from the next message)" : ""}`,
170
229
  `- Busy: ${this.turnId ? "yes" : "no"}`,
230
+ `- Folder: ${code(this.workdir)}`,
171
231
  `- Thread: ${code(this.threadId ?? "none")}`,
172
232
  `- Server: ${code(this.client.endpoint)}`,
173
233
  ];
174
234
  await this.post(lines.join("\n"));
175
235
  }
176
236
 
177
- private pendingModel: string | null = null;
237
+ /**
238
+ * `!model` → a dropdown of the server's models (hoocode: your
239
+ * `enabledModels` scope). `!model kimi` → that model if one matches,
240
+ * else a dropdown of the matches. Takes effect from the next message;
241
+ * the conversation carries on.
242
+ */
243
+ async chooseModel(query = "") {
244
+ await this.ensureThread();
245
+ let models: ModelChoice[];
246
+ try {
247
+ models = await this.listModels(query !== "");
248
+ } catch (err) {
249
+ await this.post(`**Can't list models:** ${errorText(err)}`);
250
+ return;
251
+ }
252
+ const q = query.toLowerCase();
253
+ if (q) {
254
+ const exact = models.find((m) => m.value.toLowerCase() === q || m.value.toLowerCase().endsWith(`/${q}`));
255
+ if (exact) return this.setModel(exact.value);
256
+ const matches = models.filter((m) => m.value.toLowerCase().includes(q) || m.label.toLowerCase().includes(q));
257
+ if (matches.length === 1) return this.setModel(matches[0]!.value);
258
+ if (matches.length === 0) {
259
+ await this.post(`No model matches ${code(query)}. Send \`!model\` for the list.`);
260
+ return;
261
+ }
262
+ // Scoped models first.
263
+ models = [...matches.filter((m) => !m.hidden), ...matches.filter((m) => m.hidden)];
264
+ }
265
+ if (models.length === 0) {
266
+ await this.post("The server lists no models.");
267
+ return;
268
+ }
269
+ const current = this.chosenModel ?? this.model;
270
+ const shown = models.slice(0, 25);
271
+ const menu = new StringSelectMenuBuilder()
272
+ .setCustomId("model")
273
+ .setPlaceholder("Pick a model")
274
+ .addOptions(
275
+ shown.map((m) => ({
276
+ label: truncate(m.label, 100),
277
+ value: m.value.slice(0, 100),
278
+ description: m.label === m.value ? undefined : truncate(m.value, 100),
279
+ default: m.value === current || m.value.endsWith(`/${current}`),
280
+ })),
281
+ );
282
+ const more = models.length > shown.length ? `\nShowing ${shown.length} of ${models.length}; narrow it with \`!model <part of name>\`.` : "";
283
+ const title = `**Model:** ${code(current ?? "default")}. Pick one for this thread (from the next message):${more}`;
284
+ const msg = (await this.enqueue(() =>
285
+ this.thread.send({ content: title, components: [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)] }),
286
+ )) as Message;
287
+ try {
288
+ const pick = await msg.awaitMessageComponent({
289
+ componentType: ComponentType.StringSelect,
290
+ time: 5 * 60_000,
291
+ filter: async (i) => {
292
+ if (config.allowedUserIds.has(i.user.id)) return true;
293
+ await i.reply({ content: "Only allowed users can change the model.", ephemeral: true }).catch(() => {});
294
+ return false;
295
+ },
296
+ });
297
+ const name = pick.values[0]!;
298
+ this.applyModel(name);
299
+ await pick.update({ content: modelSetText(name), components: [] });
300
+ } catch {
301
+ await msg.edit({ content: `**Model:** ${code(this.chosenModel ?? this.model ?? "default")} (not changed)`, components: [] }).catch(() => {});
302
+ }
303
+ }
304
+
305
+ private async setModel(name: string) {
306
+ this.applyModel(name);
307
+ await this.post(modelSetText(name));
308
+ }
309
+
310
+ private applyModel(name: string) {
311
+ this.chosenModel = name;
312
+ this.saveLink();
313
+ }
178
314
 
179
- /** Takes effect on the next turn (`turn/start` `model`). */
180
- async setModel(name: string) {
181
- this.pendingModel = name;
182
- await this.post(`**Model:** ${code(name)} from the next message.`);
315
+ /** All pages of `model/list`. */
316
+ private async listModels(includeHidden: boolean): Promise<ModelChoice[]> {
317
+ const out: ModelChoice[] = [];
318
+ let cursor: string | undefined;
319
+ for (let page = 0; page < 10; page++) {
320
+ const res: any = await this.client.request("model/list", { includeHidden, ...(cursor ? { cursor } : {}) });
321
+ for (const m of res?.data ?? []) {
322
+ const value = String(m.model ?? m.id);
323
+ out.push({ value, label: String(m.displayName || value), hidden: !!m.hidden });
324
+ }
325
+ cursor = res?.nextCursor ?? undefined;
326
+ if (!cursor) break;
327
+ }
328
+ return includeHidden ? out : out.filter((m) => !m.hidden);
329
+ }
330
+
331
+ /** Toggle the step-by-step view for this thread. */
332
+ async toggleVerbose() {
333
+ this.verbose = !this.verbose;
334
+ await this.post(
335
+ this.verbose
336
+ ? "**Verbose on:** every step and message is shown. `!verbose` again to turn it off."
337
+ : "**Verbose off:** a status line while working, then the final answer.",
338
+ );
183
339
  }
184
340
 
185
341
  close() {
@@ -196,6 +352,8 @@ export class ThreadSession {
196
352
  this.turnId = turnId;
197
353
  this.tools = [];
198
354
  this.progressMsg = null;
355
+ this.live = { summary: new TurnSummary(), msg: null, at: 0 };
356
+ this.answer = null;
199
357
  this.startTyping();
200
358
  }
201
359
 
@@ -217,14 +375,13 @@ export class ThreadSession {
217
375
  this.turnId = null;
218
376
  this.stopTyping();
219
377
  await this.flushProgress();
220
- if (p.turn.status === "failed" && p.turn.error?.message) {
221
- await this.post(`**Error:**\n${"```"}\n${truncate(p.turn.error.message, 1500)}\n${"```"}`);
222
- }
378
+ await this.finishTurn(p.turn);
223
379
  this.touch();
224
380
  break;
225
381
  }
226
382
 
227
383
  case "item/started": {
384
+ this.live?.summary.started(p.item);
228
385
  const tool = toolLine(p.item);
229
386
  if (tool) {
230
387
  this.tools.push(tool);
@@ -235,6 +392,12 @@ export class ThreadSession {
235
392
 
236
393
  case "item/completed": {
237
394
  const item = p.item;
395
+ this.live?.summary.completed(item);
396
+ if (item.type === "agentMessage" && item.text?.trim() && !this.verbose) {
397
+ // Only the last message of the turn is posted (finishTurn).
398
+ this.answer = item.text;
399
+ break;
400
+ }
238
401
  if (item.type === "agentMessage" && item.text?.trim()) {
239
402
  // Text closes the current tool list; the next tools start a fresh one.
240
403
  await this.flushProgress();
@@ -257,8 +420,78 @@ export class ThreadSession {
257
420
  }
258
421
  }
259
422
 
423
+ /**
424
+ * Post the final answer with a footer (PR, commits, files, steps, time,
425
+ * model) and remove the status line. Errors are always posted in full.
426
+ */
427
+ private async finishTurn(turn: any) {
428
+ const status = this.live;
429
+ this.live = null;
430
+ const answer = this.answer;
431
+ this.answer = null;
432
+ const caller = this.caller;
433
+ this.caller = null;
434
+
435
+ const footer = status?.summary.footer(this.model) ?? "";
436
+ // Files the model wrote, named in its answer, or that changed in the work
437
+ // folder during the turn (shell output) go back as attachments.
438
+ const { files, skipped } =
439
+ turn.status === "completed" && status
440
+ ? pickAttachments(
441
+ [
442
+ ...status.summary.editedFiles,
443
+ ...pathsInText(answer ?? ""),
444
+ // Whole seconds: some filesystems store mtimes that coarse.
445
+ ...changedSince(this.workdir, Math.floor(status.summary.startedAt / 1000) * 1000),
446
+ ],
447
+ this.workdir,
448
+ )
449
+ : { files: [], skipped: [] };
450
+ const parts: string[] = [];
451
+ if (answer) parts.push(answer);
452
+ if (turn.status === "failed") {
453
+ parts.push(`**Error:**\n${"```"}\n${truncate(turn.error?.message ?? "the turn failed", 1500)}\n${"```"}`);
454
+ } else if (!answer && !this.verbose && turn.status === "completed") {
455
+ parts.push("Done.");
456
+ }
457
+ if (parts.length) {
458
+ const chunks = splitMessage(parts.join("\n\n"));
459
+ const last = chunks.length - 1;
460
+ if (footer && chunks[last]!.length + footer.length + 1 <= 2000) chunks[last] += `\n${footer}`;
461
+ else if (footer) chunks.push(footer);
462
+ // The first chunk is a Discord reply to the message that asked.
463
+ for (const [i, chunk] of chunks.entries()) await this.post(chunk, i === 0 ? caller : null, i === chunks.length - 1 ? files : []);
464
+ } else if (footer && !this.verbose && turn.status !== "interrupted") {
465
+ await this.post(footer, caller, files);
466
+ } else if (files.length) {
467
+ await this.post("Files:", caller, files);
468
+ }
469
+ if (skipped.length) {
470
+ await this.post(`-# Not attached (over Discord's 10 files / 10 MB): ${skipped.map((s) => code(s)).join(", ")}`);
471
+ }
472
+ // Queued after any in-flight status send, so that message exists by now.
473
+ if (status) await this.enqueue(async () => status.msg?.delete()).catch(() => {});
474
+ }
475
+
476
+ /** Throttled: the status line while a turn runs (non-verbose). */
477
+ private async flushStatus() {
478
+ const status = this.live;
479
+ if (!status || this.verbose) return;
480
+ const now = Date.now();
481
+ // Quick answers need no status line; then refresh every 3 s.
482
+ if (now - status.summary.startedAt < 4000 || now - status.at < 3000) return;
483
+ status.at = now;
484
+ const body = status.summary.statusLine(now);
485
+ await this.enqueue(async () => {
486
+ if (this.live !== status) return; // turn ended meanwhile
487
+ if (status.msg) await status.msg.edit(body).catch(() => {});
488
+ else status.msg = await this.thread.send(body);
489
+ }).catch(() => {});
490
+ }
491
+
260
492
  /** Tool activity as one message, edited in place (throttled). */
261
493
  private async flushProgress() {
494
+ if (!this.verbose) return this.flushStatus();
262
495
  if (!this.progressDirty || this.tools.length === 0) return;
263
496
  this.progressDirty = false;
264
497
  // Words, not just symbols, so state doesn't rely on colour or icon shape.
@@ -357,9 +590,25 @@ export class ThreadSession {
357
590
 
358
591
  // ── Helpers ────────────────────────────────────────────────────────────────
359
592
 
360
- private post(text: string) {
593
+ /** `replyTo`: post as a Discord reply to that message (no ping); plain send if it's gone. */
594
+ private post(text: string, replyTo?: { id: string } | null, files: Attachment[] = []) {
361
595
  if (!text.trim()) return Promise.resolve();
362
- return this.enqueue(() => this.thread.send(text)).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
596
+ const message = (withFiles: boolean) => ({
597
+ content: text,
598
+ ...(replyTo ? { reply: { messageReference: replyTo.id, failIfNotExists: false }, allowedMentions: { repliedUser: false } } : {}),
599
+ ...(withFiles ? { files } : {}),
600
+ });
601
+ const send = () =>
602
+ !replyTo && !files.length
603
+ ? this.thread.send(text)
604
+ : this.thread.send(message(files.length > 0)).catch(async (err: unknown) => {
605
+ if (!files.length) throw err;
606
+ // e.g. over this server's upload limit: still post the text.
607
+ console.error(`[${this.thread.id}] upload failed`, err);
608
+ await this.thread.send(message(false));
609
+ return this.thread.send(`-# Couldn't attach ${files.map((f) => code(f.name)).join(", ")}: ${errorText(err)}`);
610
+ });
611
+ return this.enqueue(send).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
363
612
  }
364
613
 
365
614
  private enqueue<T>(fn: () => Promise<T>): Promise<T> {
@@ -398,6 +647,12 @@ export class ThreadSession {
398
647
  }
399
648
  }
400
649
 
650
+ type ModelChoice = { value: string; label: string; hidden: boolean };
651
+
652
+ function modelSetText(name: string): string {
653
+ return `**Model:** ${code(name)} from the next message. The conversation carries on.`;
654
+ }
655
+
401
656
  /** A progress line for a tool-ish item, or null for messages. */
402
657
  function toolLine(item: any): ToolLine | null {
403
658
  switch (item?.type) {
package/src/summary.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * What a turn did, boiled down for Discord: a one-line status while it runs
3
+ * and a short footer under the final answer (PRs, commits, files, steps,
4
+ * time, model). Pure: fed app-server items, no Discord or network.
5
+ */
6
+ import { code, truncate } from "./format.ts";
7
+
8
+ const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
9
+ /** `git commit` prints `[branch abc1234] subject`; `git push` prints `old..new branch -> branch`. */
10
+ const COMMIT_LINE = /^\[[^\]\s]+(?: \(root-commit\))? ([0-9a-f]{7,40})\]/gm;
11
+
12
+ export class TurnSummary {
13
+ readonly startedAt: number;
14
+ private steps = new Set<string>();
15
+ private failed = 0;
16
+ private current: string | null = null;
17
+ private prs = new Set<string>();
18
+ private commits = new Set<string>();
19
+ private files = new Set<string>();
20
+
21
+ constructor(now = Date.now()) {
22
+ this.startedAt = now;
23
+ }
24
+
25
+ /** Paths of files edited or written this turn, in order. */
26
+ get editedFiles(): string[] {
27
+ return [...this.files];
28
+ }
29
+
30
+ /** An `item/started` item. */
31
+ started(item: any) {
32
+ const what = stepLabel(item);
33
+ if (!what) return;
34
+ this.steps.add(item.id);
35
+ this.current = what;
36
+ }
37
+
38
+ /** An `item/completed` item. */
39
+ completed(item: any) {
40
+ if (item?.type === "agentMessage") {
41
+ this.scan(item.text);
42
+ return;
43
+ }
44
+ if (!stepLabel(item)) return;
45
+ this.steps.add(item.id);
46
+ if (item.status === "failed" || item.status === "declined" || (item.exitCode ?? 0) !== 0) this.failed++;
47
+ if (item.type === "commandExecution") this.scan(item.aggregatedOutput);
48
+ if (item.type === "fileChange" && item.status !== "declined") {
49
+ for (const c of item.changes ?? []) if (c?.path) this.files.add(c.path);
50
+ }
51
+ if (item.type === "dynamicToolCall" && (item.tool === "edit" || item.tool === "write") && item.success !== false) {
52
+ const path = item.arguments?.path ?? item.arguments?.file_path;
53
+ if (path) this.files.add(String(path));
54
+ }
55
+ }
56
+
57
+ /** e.g. "⏳ Working · 4 steps · 1m 20s · bash `bun test`" */
58
+ statusLine(now = Date.now()): string {
59
+ const parts = ["⏳ Working", plural(this.steps.size, "step"), duration(now - this.startedAt)];
60
+ if (this.current) parts.push(this.current);
61
+ return parts.join(" · ");
62
+ }
63
+
64
+ /** e.g. "-# PR #5 · commit `abc1234` · 3 files · 12 steps · 3m 10s · `model`", or "" for a bare chat turn. */
65
+ footer(model: string | null, now = Date.now()): string {
66
+ const parts: string[] = [];
67
+ for (const url of this.prs) parts.push(`[PR #${url.split("/").pop()}](<${url}>)`);
68
+ const commits = [...this.commits];
69
+ if (commits.length === 1) parts.push(`commit ${code(commits[0]!.slice(0, 7))}`);
70
+ else if (commits.length > 1) parts.push(`${commits.length} commits`);
71
+ if (this.files.size === 1) parts.push(`edited ${code(truncate(basename([...this.files][0]!), 60))}`);
72
+ else if (this.files.size > 1) parts.push(`${this.files.size} files edited`);
73
+ if (this.steps.size === 0 && parts.length === 0) return "";
74
+ if (this.steps.size) parts.push(plural(this.steps.size, "step") + (this.failed ? ` (${this.failed} failed)` : ""));
75
+ parts.push(duration(now - this.startedAt));
76
+ if (model) parts.push(code(model));
77
+ return `-# ${parts.join(" · ")}`;
78
+ }
79
+
80
+ private scan(text: unknown) {
81
+ if (typeof text !== "string" || !text) return;
82
+ for (const m of text.matchAll(PR_URL)) this.prs.add(m[0]);
83
+ for (const m of text.matchAll(COMMIT_LINE)) this.commits.add(m[1]!);
84
+ }
85
+ }
86
+
87
+ /** Short label for a tool-ish item, or null for messages and reasoning. */
88
+ function stepLabel(item: any): string | null {
89
+ switch (item?.type) {
90
+ case "commandExecution":
91
+ return `bash ${code(truncate(String(item.command ?? ""), 60))}`;
92
+ case "fileChange":
93
+ return `edit ${code(truncate(basename(item.changes?.[0]?.path ?? "?"), 60))}`;
94
+ case "dynamicToolCall": {
95
+ const a = item.arguments ?? {};
96
+ const arg = a.command ?? a.path ?? a.file_path ?? a.query ?? a.pattern;
97
+ return arg ? `${item.tool} ${code(truncate(String(arg), 60))}` : String(item.tool);
98
+ }
99
+ case "mcpToolCall":
100
+ return `${item.server}/${item.tool}`;
101
+ case "webSearch":
102
+ return `web search ${code(truncate(String(item.query ?? ""), 60))}`;
103
+ default:
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function basename(path: string): string {
109
+ return path.split("/").pop() || path;
110
+ }
111
+
112
+ function plural(n: number, word: string): string {
113
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
114
+ }
115
+
116
+ export function duration(ms: number): string {
117
+ const s = Math.max(0, Math.round(ms / 1000));
118
+ if (s < 60) return `${s}s`;
119
+ const m = Math.floor(s / 60);
120
+ if (m < 60) return `${m}m ${s % 60}s`;
121
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
122
+ }