@hraness/dawg 0.0.0-stage → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@hraness/dawg",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/hraness/dawg.git"
8
+ },
9
+ "homepage": "https://dawg.sh",
10
+ "bugs": "https://github.com/hraness/dawg/issues",
11
+ "description": "A local-first terminal music workstation with an agent-driven piano-roll interface.",
12
+ "type": "module",
13
+ "files": [
14
+ "src/**/*.ts",
15
+ "!src/**/*.test.ts",
16
+ "!src/**/fixtures/**",
17
+ "!src/**/*-fixtures.ts",
18
+ "core/**/*.ts",
19
+ "!core/**/*.test.ts",
20
+ "tui/**/*.ts",
21
+ "!tui/**/*.test.ts",
22
+ "CHANGELOG.md",
23
+ "DAWG.md"
24
+ ],
25
+ "bin": {
26
+ "dawg": "./src/main.ts"
27
+ },
28
+ "scripts": {
29
+ "dawg": "bun run src/main.ts",
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit",
32
+ "check": "bun test && bun run typecheck && bun run format",
33
+ "format": "prettier --check ."
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "24.13.3",
37
+ "bun-types": "1.3.14",
38
+ "prettier": "3.9.6",
39
+ "typescript": "6.0.3"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.3.14"
43
+ }
44
+ }
@@ -0,0 +1,587 @@
1
+ import {
2
+ applyScoreOperation,
3
+ type ScoreOperation,
4
+ type TrackScore,
5
+ } from "../../core/score.ts";
6
+ import { compositionBrief } from "./brief.ts";
7
+ import {
8
+ GatewayError,
9
+ type ChatMessage,
10
+ type ChatToolCall,
11
+ type GatewayClient,
12
+ type GatewayModel,
13
+ } from "./gateway.ts";
14
+ import { validateAgentOperation } from "./planner.ts";
15
+ import { SseBudgetError } from "./sse.ts";
16
+ import {
17
+ AGENT_TOOLS,
18
+ chatTools,
19
+ findAgentTool,
20
+ type AgentTool,
21
+ type ToolPlan,
22
+ } from "./tools.ts";
23
+
24
+ /** Hard ceilings for one agent turn. Callers may only tighten them. */
25
+ export const AGENT_LIMITS = Object.freeze({
26
+ maxSteps: 8,
27
+ maxToolCalls: 32,
28
+ maxResponseBytes: 256 * 1024,
29
+ maxToolArgumentBytes: 32 * 1024,
30
+ timeoutMs: 90_000,
31
+ maxTextChars: 4_000,
32
+ });
33
+
34
+ export type AgentBudget = Partial<{
35
+ maxSteps: number;
36
+ maxToolCalls: number;
37
+ maxResponseBytes: number;
38
+ timeoutMs: number;
39
+ }>;
40
+
41
+ /**
42
+ * Structured progress for a TUI. Events are emitted in order; exactly one of
43
+ * `done` or `error` ends every turn.
44
+ */
45
+ export type AgentEvent =
46
+ | { type: "step"; step: number }
47
+ | { type: "text-delta"; delta: string }
48
+ | { type: "tool-start"; callId: string; name: string; step: number }
49
+ | {
50
+ type: "tool-applied";
51
+ callId: string;
52
+ name: string;
53
+ summary: string;
54
+ baseRevision: number;
55
+ resultRevision: number;
56
+ trackId?: string;
57
+ }
58
+ | {
59
+ type: "tool-rejected";
60
+ callId: string;
61
+ name: string;
62
+ diagnostic: string;
63
+ }
64
+ | {
65
+ type: "done";
66
+ reason: AgentDoneReason;
67
+ text: string;
68
+ applied: number;
69
+ rejected: number;
70
+ revision: number;
71
+ }
72
+ | {
73
+ type: "error";
74
+ code: AgentErrorCode;
75
+ message: string;
76
+ applied: number;
77
+ revision: number;
78
+ };
79
+
80
+ export type AgentDoneReason = "stop" | "max-steps" | "max-tool-calls";
81
+ export type AgentErrorCode =
82
+ "aborted" | "timeout" | "budget" | "provider" | "internal";
83
+
84
+ export type AgentSnapshot = Readonly<{
85
+ score: TrackScore;
86
+ revision: number;
87
+ focusedTrackId: string;
88
+ recentOperations: readonly string[];
89
+ }>;
90
+
91
+ /** The score change a validated tool call asks the host to commit. */
92
+ export type AgentCommit = Readonly<{
93
+ next: TrackScore;
94
+ operations: readonly ScoreOperation[];
95
+ baseRevision: number;
96
+ toolName: string;
97
+ callId: string;
98
+ summary: string;
99
+ }>;
100
+
101
+ /** Thrown by a host when the session moved past `baseRevision`. */
102
+ export class StaleRevisionError extends Error {
103
+ constructor(
104
+ readonly baseRevision: number,
105
+ readonly currentRevision: number,
106
+ ) {
107
+ super(
108
+ `score changed (rev ${baseRevision} → ${currentRevision}); re-plan from the latest brief`,
109
+ );
110
+ this.name = "StaleRevisionError";
111
+ }
112
+ }
113
+
114
+ /** The narrow surface the agent needs from its host (main.ts or a daemon). */
115
+ export type AgentHost = Readonly<{
116
+ snapshot(): AgentSnapshot;
117
+ /** Atomically commit `next` if the session is still at `baseRevision`. */
118
+ commit(change: AgentCommit): Promise<{ revision: number }>;
119
+ transport?(action: "play" | "pause" | "toggle"): Promise<void>;
120
+ /** Steering messages typed during this turn, consumed between steps. */
121
+ takeSteering?(): readonly string[];
122
+ }>;
123
+
124
+ export type AgentTurnOptions = Readonly<{
125
+ prompt: string;
126
+ model: GatewayModel;
127
+ client: GatewayClient;
128
+ host: AgentHost;
129
+ onEvent?: (event: AgentEvent) => void;
130
+ signal?: AbortSignal;
131
+ budget?: AgentBudget;
132
+ tools?: readonly AgentTool[];
133
+ /** Note ID factory; defaults to `${track}-${revision}-${nonce}${index}`. */
134
+ newNoteId?: (trackId: string, revision: number, index: number) => string;
135
+ }>;
136
+
137
+ export type AgentTurnResult = Extract<AgentEvent, { type: "done" | "error" }>;
138
+
139
+ export const AGENT_SYSTEM_PROMPT = [
140
+ "You are dawg, a loop composer inside a terminal music workstation.",
141
+ "Edit the score only by calling the provided tools; every call is validated and applied immediately, and its result tells you the new revision.",
142
+ "Times are in beats from the loop start (0-based). Keep notes inside loopBeats unless you extend the loop first.",
143
+ "Prefer a few well-formed calls (one add_notes call per track part) over many tiny ones.",
144
+ 'For drums, create a track with instrument "kit" and use add_drums; drum pitches select voices, so do not use add_notes for beats.',
145
+ "Effects (set_effects, set_automation): low-pass filter cutoff 20..20000 Hz and resonance 0..1; stereo delay beats 0.0625..4, feedback 0..0.9, mix 0..1; stereo reverb mix 0..1 (0.15..0.35 is a natural room) and size 0..1; pan -1..1 is equal-power stereo. Automatable lanes: volume, pan, filter, resonance, delay-feedback, delay-mix.",
146
+ "If a call is rejected, read the diagnostic and either fix the arguments or stop.",
147
+ "When you are done, reply with one short sentence describing the musical change.",
148
+ ].join(" ");
149
+
150
+ /**
151
+ * Run one bounded, streaming, tool-calling turn. Every completed tool call is
152
+ * validated by the tool, then by the planner's operation validator, then by a
153
+ * dry run of the reducer, and only then committed through the host. A failed
154
+ * call is reported to the model and the TUI without touching the score.
155
+ */
156
+ export async function runAgentTurn(
157
+ options: AgentTurnOptions,
158
+ ): Promise<AgentTurnResult> {
159
+ const limits = {
160
+ maxSteps: tighten(options.budget?.maxSteps, AGENT_LIMITS.maxSteps),
161
+ maxToolCalls: tighten(
162
+ options.budget?.maxToolCalls,
163
+ AGENT_LIMITS.maxToolCalls,
164
+ ),
165
+ maxResponseBytes: tighten(
166
+ options.budget?.maxResponseBytes,
167
+ AGENT_LIMITS.maxResponseBytes,
168
+ ),
169
+ timeoutMs: tighten(options.budget?.timeoutMs, AGENT_LIMITS.timeoutMs),
170
+ };
171
+ const emit = (event: AgentEvent) => {
172
+ try {
173
+ options.onEvent?.(event);
174
+ } catch {
175
+ // A rendering failure must never corrupt the turn.
176
+ }
177
+ };
178
+ const tools = options.tools ?? AGENT_TOOLS;
179
+ const chatToolList = chatTools(tools);
180
+ const nonce = Math.random().toString(36).slice(2, 6);
181
+ const newNoteId =
182
+ options.newNoteId ??
183
+ ((trackId: string, revision: number, index: number) =>
184
+ `${trackId.slice(0, 40)}-${revision}-${nonce}${index}`);
185
+ const timeout = new AbortController();
186
+ const timer = setTimeout(
187
+ () => timeout.abort(new AgentTimeoutError(limits.timeoutMs)),
188
+ limits.timeoutMs,
189
+ );
190
+ const signal = options.signal
191
+ ? AbortSignal.any([options.signal, timeout.signal])
192
+ : timeout.signal;
193
+
194
+ let applied = 0;
195
+ let rejected = 0;
196
+ let toolCalls = 0;
197
+ let bytesUsed = 0;
198
+ let finalText = "";
199
+ const messages: ChatMessage[] = [
200
+ { role: "system", content: AGENT_SYSTEM_PROMPT },
201
+ { role: "user", content: options.prompt.slice(0, 8_000) },
202
+ ];
203
+ const finish = (result: AgentTurnResult): AgentTurnResult => {
204
+ clearTimeout(timer);
205
+ emit(result);
206
+ return result;
207
+ };
208
+ const currentRevision = () => options.host.snapshot().revision;
209
+
210
+ try {
211
+ for (let step = 1; step <= limits.maxSteps; step += 1) {
212
+ if (signal.aborted) throw signal.reason;
213
+ for (const steer of options.host.takeSteering?.() ?? [])
214
+ messages.push({
215
+ role: "user",
216
+ content: `Steering from the user mid-turn: ${steer.slice(0, 2_000)}`,
217
+ });
218
+ emit({ type: "step", step });
219
+ const snapshot = options.host.snapshot();
220
+ const brief = compositionBrief(snapshot);
221
+ const request: ChatMessage[] = [
222
+ messages[0]!,
223
+ { role: "system", content: `Composition brief (JSON): ${brief}` },
224
+ ...messages.slice(1),
225
+ ];
226
+ const remaining = limits.maxResponseBytes - bytesUsed;
227
+ if (remaining <= 0) throw new SseBudgetError(limits.maxResponseBytes);
228
+
229
+ let text = "";
230
+ let streamBytes = 0;
231
+ const pending = new Map<
232
+ number,
233
+ { id: string; name: string; arguments: string; started: boolean }
234
+ >();
235
+ for await (const event of options.client.stream(
236
+ {
237
+ model: options.model,
238
+ messages: request,
239
+ tools: chatToolList,
240
+ maxResponseBytes: remaining,
241
+ },
242
+ signal,
243
+ )) {
244
+ if (event.type === "text") {
245
+ streamBytes += event.delta.length;
246
+ if (text.length < AGENT_LIMITS.maxTextChars) {
247
+ const delta = event.delta.slice(
248
+ 0,
249
+ AGENT_LIMITS.maxTextChars - text.length,
250
+ );
251
+ text += delta;
252
+ emit({ type: "text-delta", delta });
253
+ }
254
+ } else if (event.type === "tool-delta") {
255
+ const call = pending.get(event.index) ?? {
256
+ id: "",
257
+ name: "",
258
+ arguments: "",
259
+ started: false,
260
+ };
261
+ if (event.id) call.id = event.id;
262
+ if (event.name) call.name += event.name;
263
+ if (event.arguments) {
264
+ call.arguments += event.arguments;
265
+ streamBytes += event.arguments.length;
266
+ if (call.arguments.length > AGENT_LIMITS.maxToolArgumentBytes)
267
+ throw new SseBudgetError(AGENT_LIMITS.maxToolArgumentBytes);
268
+ }
269
+ if (!pending.has(event.index)) {
270
+ if (pending.size + toolCalls >= limits.maxToolCalls + 1) {
271
+ // Ignore calls past the budget; the turn ends after this step.
272
+ continue;
273
+ }
274
+ pending.set(event.index, call);
275
+ }
276
+ if (!call.started && call.name) {
277
+ call.started = true;
278
+ emit({
279
+ type: "tool-start",
280
+ callId: call.id || `call-${step}-${event.index}`,
281
+ name: call.name,
282
+ step,
283
+ });
284
+ }
285
+ }
286
+ }
287
+ bytesUsed += streamBytes;
288
+ if (text.trim()) finalText = text.trim();
289
+
290
+ const calls = [...pending.entries()]
291
+ .sort(([left], [right]) => left - right)
292
+ .map(([index, call]) => ({
293
+ ...call,
294
+ id: call.id || `call-${step}-${index}`,
295
+ }));
296
+ if (calls.length === 0) {
297
+ return finish({
298
+ type: "done",
299
+ reason: "stop",
300
+ text: finalText,
301
+ applied,
302
+ rejected,
303
+ revision: currentRevision(),
304
+ });
305
+ }
306
+ const assistantCalls: ChatToolCall[] = calls.map((call) => ({
307
+ id: call.id,
308
+ type: "function",
309
+ function: { name: call.name, arguments: call.arguments || "{}" },
310
+ }));
311
+ messages.push({
312
+ role: "assistant",
313
+ content: text || null,
314
+ tool_calls: assistantCalls,
315
+ });
316
+ let overBudget = false;
317
+ for (const call of calls) {
318
+ if (signal.aborted) throw signal.reason;
319
+ let content: string;
320
+ if (toolCalls >= limits.maxToolCalls) {
321
+ overBudget = true;
322
+ content = JSON.stringify({
323
+ ok: false,
324
+ error: "tool call budget exhausted; not applied",
325
+ });
326
+ rejected += 1;
327
+ emit({
328
+ type: "tool-rejected",
329
+ callId: call.id,
330
+ name: call.name,
331
+ diagnostic: "tool call budget exhausted",
332
+ });
333
+ } else {
334
+ toolCalls += 1;
335
+ const outcome = await executeCall(call, {
336
+ tools,
337
+ host: options.host,
338
+ newNoteId,
339
+ });
340
+ if (outcome.ok) {
341
+ applied += outcome.mutated ? 1 : 0;
342
+ if (outcome.explanation) finalText = outcome.explanation;
343
+ emit(outcome.event);
344
+ } else {
345
+ rejected += 1;
346
+ emit({
347
+ type: "tool-rejected",
348
+ callId: call.id,
349
+ name: call.name,
350
+ diagnostic: outcome.diagnostic,
351
+ });
352
+ }
353
+ content = outcome.content;
354
+ }
355
+ messages.push({ role: "tool", tool_call_id: call.id, content });
356
+ }
357
+ if (overBudget || toolCalls >= limits.maxToolCalls) {
358
+ return finish({
359
+ type: "done",
360
+ reason: "max-tool-calls",
361
+ text: finalText,
362
+ applied,
363
+ rejected,
364
+ revision: currentRevision(),
365
+ });
366
+ }
367
+ }
368
+ return finish({
369
+ type: "done",
370
+ reason: "max-steps",
371
+ text: finalText,
372
+ applied,
373
+ rejected,
374
+ revision: currentRevision(),
375
+ });
376
+ } catch (error) {
377
+ const { code, message } = classifyAgentError(error, signal, options.signal);
378
+ return finish({
379
+ type: "error",
380
+ code,
381
+ message,
382
+ applied,
383
+ revision: currentRevision(),
384
+ });
385
+ } finally {
386
+ clearTimeout(timer);
387
+ }
388
+ }
389
+
390
+ export type CallOutcome =
391
+ | {
392
+ ok: true;
393
+ mutated: boolean;
394
+ content: string;
395
+ explanation?: string;
396
+ event: Extract<AgentEvent, { type: "tool-applied" }>;
397
+ }
398
+ | { ok: false; content: string; diagnostic: string };
399
+
400
+ export async function executeCall(
401
+ call: { id: string; name: string; arguments: string },
402
+ context: {
403
+ tools: readonly AgentTool[];
404
+ host: AgentHost;
405
+ newNoteId: (trackId: string, revision: number, index: number) => string;
406
+ },
407
+ ): Promise<CallOutcome> {
408
+ const reject = (diagnostic: string): CallOutcome => ({
409
+ ok: false,
410
+ diagnostic: diagnostic.slice(0, 300),
411
+ content: JSON.stringify({ ok: false, error: diagnostic.slice(0, 300) }),
412
+ });
413
+ const tool = findAgentTool(call.name, context.tools);
414
+ if (!tool) return reject(`unknown tool ${call.name.slice(0, 40)}`);
415
+ let args: unknown;
416
+ try {
417
+ args = call.arguments.trim() ? JSON.parse(call.arguments) : {};
418
+ } catch {
419
+ return reject("arguments were not valid JSON");
420
+ }
421
+ if (typeof args !== "object" || args === null || Array.isArray(args))
422
+ return reject("arguments must be a JSON object");
423
+ const snapshot = context.host.snapshot();
424
+ let plan: ToolPlan;
425
+ try {
426
+ plan = tool.plan(args as Record<string, unknown>, {
427
+ score: snapshot.score,
428
+ focusedTrackId: snapshot.focusedTrackId,
429
+ revision: snapshot.revision,
430
+ newNoteId: (trackId, index) =>
431
+ context.newNoteId(trackId, snapshot.revision + 1, index),
432
+ });
433
+ } catch (error) {
434
+ return reject(errorMessage(error));
435
+ }
436
+ const appliedEvent = (
437
+ summary: string,
438
+ resultRevision: number,
439
+ trackId?: string,
440
+ ): Extract<AgentEvent, { type: "tool-applied" }> => ({
441
+ type: "tool-applied",
442
+ callId: call.id,
443
+ name: call.name,
444
+ summary,
445
+ baseRevision: snapshot.revision,
446
+ resultRevision,
447
+ ...(trackId ? { trackId } : {}),
448
+ });
449
+ if (plan.kind === "explain") {
450
+ return {
451
+ ok: true,
452
+ mutated: false,
453
+ explanation: plan.text,
454
+ content: JSON.stringify({ ok: true, shown: true }),
455
+ event: appliedEvent(plan.summary, snapshot.revision),
456
+ };
457
+ }
458
+ if (plan.kind === "transport") {
459
+ if (!context.host.transport) return reject("transport is unavailable");
460
+ try {
461
+ await context.host.transport(plan.action);
462
+ } catch (error) {
463
+ return reject(errorMessage(error));
464
+ }
465
+ return {
466
+ ok: true,
467
+ mutated: false,
468
+ content: JSON.stringify({ ok: true, transport: plan.action }),
469
+ event: appliedEvent(plan.summary, context.host.snapshot().revision),
470
+ };
471
+ }
472
+ let next = snapshot.score;
473
+ const operations: ScoreOperation[] = [];
474
+ try {
475
+ for (const candidate of plan.operations) {
476
+ const operation = validateAgentOperation(candidate);
477
+ next = applyScoreOperation(next, operation);
478
+ operations.push(operation);
479
+ }
480
+ } catch (error) {
481
+ return reject(`rejected by score validation: ${errorMessage(error)}`);
482
+ }
483
+ if (next === snapshot.score)
484
+ return {
485
+ ok: true,
486
+ mutated: false,
487
+ content: JSON.stringify({
488
+ ok: true,
489
+ summary: "no change",
490
+ revision: snapshot.revision,
491
+ }),
492
+ event: appliedEvent("no change", snapshot.revision, plan.trackId),
493
+ };
494
+ try {
495
+ const { revision } = await context.host.commit({
496
+ next,
497
+ operations,
498
+ baseRevision: snapshot.revision,
499
+ toolName: call.name,
500
+ callId: call.id,
501
+ summary: plan.summary,
502
+ });
503
+ return {
504
+ ok: true,
505
+ mutated: true,
506
+ content: JSON.stringify({ ok: true, summary: plan.summary, revision }),
507
+ event: appliedEvent(plan.summary, revision, plan.trackId),
508
+ };
509
+ } catch (error) {
510
+ if (error instanceof StaleRevisionError)
511
+ return reject(`stale revision: ${error.message}`);
512
+ return reject(`commit failed: ${errorMessage(error)}`);
513
+ }
514
+ }
515
+
516
+ export class AgentTimeoutError extends Error {
517
+ constructor(readonly timeoutMs: number) {
518
+ super(`agent turn timed out after ${Math.round(timeoutMs / 1000)}s`);
519
+ this.name = "TimeoutError";
520
+ }
521
+ }
522
+
523
+ export function classifyAgentError(
524
+ error: unknown,
525
+ signal: AbortSignal,
526
+ userSignal: AbortSignal | undefined,
527
+ ): { code: AgentErrorCode; message: string } {
528
+ if (userSignal?.aborted) return { code: "aborted", message: "cancelled" };
529
+ if (
530
+ error instanceof AgentTimeoutError ||
531
+ signal.reason instanceof AgentTimeoutError
532
+ )
533
+ return {
534
+ code: "timeout",
535
+ message:
536
+ error instanceof AgentTimeoutError
537
+ ? error.message
538
+ : (signal.reason as AgentTimeoutError).message,
539
+ };
540
+ if (error instanceof SseBudgetError)
541
+ return { code: "budget", message: error.message };
542
+ if (error instanceof GatewayError)
543
+ return { code: "provider", message: error.message };
544
+ // XcbError (src/agent/xcb.ts); matched by name to keep this module provider-neutral.
545
+ if (error instanceof Error && error.name === "XcbError")
546
+ return { code: "provider", message: errorMessage(error) };
547
+ if (error instanceof Error && error.name === "AbortError")
548
+ return { code: "aborted", message: "cancelled" };
549
+ return { code: "internal", message: errorMessage(error) };
550
+ }
551
+
552
+ function errorMessage(error: unknown): string {
553
+ return (error instanceof Error ? error.message : String(error)).slice(0, 300);
554
+ }
555
+
556
+ export function tighten(value: number | undefined, ceiling: number): number {
557
+ return typeof value === "number" && Number.isFinite(value) && value > 0
558
+ ? Math.min(value, ceiling)
559
+ : ceiling;
560
+ }
561
+
562
+ /** Collapse agent events into one short activity line for the status bar. */
563
+ export function describeAgentEvent(event: AgentEvent): string | undefined {
564
+ switch (event.type) {
565
+ case "step":
566
+ return event.step === 1 ? "thinking…" : `thinking… step ${event.step}`;
567
+ case "text-delta":
568
+ return undefined;
569
+ case "tool-start":
570
+ return `${event.name.replace(/_/g, " ")}…`;
571
+ case "tool-applied":
572
+ return `✓ ${event.summary}`;
573
+ case "tool-rejected":
574
+ return `✗ ${event.name}: ${event.diagnostic}`;
575
+ case "done":
576
+ return (
577
+ event.text.split("\n")[0]?.slice(0, 160) ||
578
+ (event.applied > 0
579
+ ? `applied ${event.applied} change${event.applied === 1 ? "" : "s"}`
580
+ : "agent made no changes")
581
+ );
582
+ case "error":
583
+ return event.code === "aborted"
584
+ ? `cancelled · kept rev ${event.revision}`
585
+ : `agent ${event.code}: ${event.message}`;
586
+ }
587
+ }