codebay 0.14.0 → 0.15.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 (78) hide show
  1. package/.mochi/manifest.json +526 -455
  2. package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
  7. package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
  8. package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
  9. package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
  10. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
  13. package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
  14. package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
  15. package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
  16. package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
  17. package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
  18. package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
  21. package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
  22. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-0b5t9kw9.server.js} +145 -25
  23. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
  24. package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
  25. package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
  26. package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
  27. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
  28. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  29. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  30. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  31. package/README.md +32 -1
  32. package/migrations/0009_agent_runs.sql +24 -0
  33. package/migrations/0010_agent_run_model.sql +2 -0
  34. package/package.json +6 -2
  35. package/src/components/AgentLogBox.svelte +458 -0
  36. package/src/components/AppShell.svelte +11 -0
  37. package/src/components/FolderBrowser.svelte +13 -5
  38. package/src/components/InstanceCard.svelte +38 -0
  39. package/src/components/InstanceView.svelte +8 -0
  40. package/src/components/SettingsView.svelte +186 -0
  41. package/src/components/UiShowcase.svelte +4 -2
  42. package/src/container-injections/claude-statusline.ts +4 -1
  43. package/src/container-injections/github-credentials.ts +12 -1
  44. package/src/index.ts +32 -7
  45. package/src/lib/agent-run-stream.ts +246 -0
  46. package/src/lib/agent-runs.server.ts +724 -0
  47. package/src/lib/auth.server.ts +11 -2
  48. package/src/lib/build-freshness.server.ts +71 -0
  49. package/src/lib/config.server.ts +29 -6
  50. package/src/lib/container-files.server.ts +1 -1
  51. package/src/lib/data-dir-lock.server.ts +78 -0
  52. package/src/lib/db.server.ts +120 -1
  53. package/src/lib/devcontainer.server.ts +60 -14
  54. package/src/lib/docker-client.server.ts +11 -5
  55. package/src/lib/drives.ts +11 -0
  56. package/src/lib/exec.server.ts +66 -5
  57. package/src/lib/instances.server.ts +45 -3
  58. package/src/lib/log-capture.server.ts +17 -5
  59. package/src/lib/mcp-auth.server.ts +52 -0
  60. package/src/lib/picker.server.ts +23 -2
  61. package/src/lib/sandbox-ops.server.ts +263 -0
  62. package/src/mcp/routes.server.ts +25 -0
  63. package/src/mcp/server.server.ts +52 -0
  64. package/src/mcp/tools.server.ts +494 -0
  65. package/src/pages/Settings.svelte +12 -0
  66. package/src/routes.ts +63 -1
  67. package/src/types.ts +15 -0
  68. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  69. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  70. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  71. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  72. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  73. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  74. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  75. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  76. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  77. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  78. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
@@ -24,6 +24,7 @@
24
24
  import ListOrdered from '@lucide/svelte/icons/list-ordered';
25
25
  import Boxes from '@lucide/svelte/icons/boxes';
26
26
  import Puzzle from '@lucide/svelte/icons/puzzle';
27
+ import Plug from '@lucide/svelte/icons/plug';
27
28
  import { Toaster } from 'svelte-french-toast';
28
29
  import { TOAST_OPTIONS } from '../toast.ts';
29
30
  import { flushSync } from 'svelte';
@@ -71,6 +72,10 @@
71
72
  copyIgnorePatterns,
72
73
  builtinCopyIgnore,
73
74
  claudeConfigDir,
75
+ mcpEnabled,
76
+ mcpToken,
77
+ mcpUrl,
78
+ mcpPrAttribution,
74
79
  gitIdentityEnabled,
75
80
  gitIdentityName,
76
81
  gitIdentityEmail,
@@ -113,6 +118,10 @@
113
118
  copyIgnorePatterns: string;
114
119
  builtinCopyIgnore: string;
115
120
  claudeConfigDir: string;
121
+ mcpEnabled: boolean;
122
+ mcpToken: string;
123
+ mcpUrl: string;
124
+ mcpPrAttribution: boolean;
116
125
  gitIdentityEnabled: boolean;
117
126
  gitIdentityName: string;
118
127
  gitIdentityEmail: string;
@@ -398,6 +407,57 @@
398
407
  claudeDirFormEl?.requestSubmit();
399
408
  }
400
409
 
410
+ // svelte-ignore state_referenced_locally
411
+ let mcp = $state(mcpEnabled);
412
+ // Unlike every other secret here this one round-trips to the client — a human has to copy it.
413
+ // svelte-ignore state_referenced_locally
414
+ let mcpTokenValue = $state(mcpToken);
415
+ let savingMcpToggle = $state(false);
416
+ let mcpToggleError = $state<string | null>(null);
417
+ let savingMcpToken = $state(false);
418
+ let mcpTokenError = $state<string | null>(null);
419
+ let mcpCopied = $state<string | null>(null);
420
+
421
+ const mcpToggleOpts = toggleOpts<{ enabled: boolean; token: string }>({
422
+ set: (v) => (mcp = v),
423
+ setSaving: (v) => (savingMcpToggle = v),
424
+ setError: (v) => (mcpToggleError = v),
425
+ onSuccess: (data) => (mcpTokenValue = data?.token ?? '')
426
+ });
427
+
428
+ const mcpTokenOpts = saveOpts<{ token: string }>({
429
+ setSaving: (v) => (savingMcpToken = v),
430
+ setError: (v) => (mcpTokenError = v),
431
+ onSuccess: (data) => (mcpTokenValue = data?.token ?? ''),
432
+ confirmMessage:
433
+ 'Regenerate the MCP token? Any agent already configured with the current token will stop working.'
434
+ });
435
+
436
+ // svelte-ignore state_referenced_locally
437
+ let mcpAttribution = $state(mcpPrAttribution);
438
+ let savingMcpAttribution = $state(false);
439
+ let mcpAttributionError = $state<string | null>(null);
440
+
441
+ const mcpAttributionOpts = toggleOpts({
442
+ set: (v) => (mcpAttribution = v),
443
+ setSaving: (v) => (savingMcpAttribution = v),
444
+ setError: (v) => (mcpAttributionError = v)
445
+ });
446
+
447
+ const mcpCommand = $derived(
448
+ `claude mcp add --transport http codebay ${mcpUrl} --header "Authorization: Bearer ${mcpTokenValue}"`
449
+ );
450
+
451
+ async function copyMcp(label: string, text: string) {
452
+ try {
453
+ await navigator.clipboard.writeText(text);
454
+ mcpCopied = label;
455
+ setTimeout(() => (mcpCopied = null), 1500);
456
+ } catch {
457
+ mcpTokenError = 'Could not copy — select the text and copy it manually.';
458
+ }
459
+ }
460
+
401
461
  // svelte-ignore state_referenced_locally
402
462
  let gitIdentity = $state(gitIdentityEnabled);
403
463
  let savingGitToggle = $state(false);
@@ -1230,6 +1290,116 @@
1230
1290
  </form>
1231
1291
  </section>
1232
1292
 
1293
+ <section class="card">
1294
+ <form class="row" method="POST" action="?/mcpToggle" {@attach enhance(mcpToggleOpts)}>
1295
+ <div class="label">
1296
+ <Plug size={20} />
1297
+ <div class="text">
1298
+ <div class="name">MCP server</div>
1299
+ <div class="desc">
1300
+ Expose Codebay to other AI agents over MCP, so they can create sandboxes, run Claude
1301
+ Code in them non-interactively and read back the result. Off by default; the endpoint
1302
+ 404s until you turn it on, then authenticates with the bearer token below.
1303
+ </div>
1304
+ </div>
1305
+ </div>
1306
+ <label class="switch">
1307
+ <input
1308
+ type="checkbox"
1309
+ name="enabled"
1310
+ checked={mcp}
1311
+ disabled={savingMcpToggle}
1312
+ onchange={(e) => {
1313
+ mcp = e.currentTarget.checked;
1314
+ e.currentTarget.form?.requestSubmit();
1315
+ }}
1316
+ />
1317
+ <span class="track"><span class="thumb"></span></span>
1318
+ </label>
1319
+ </form>
1320
+ {#if mcpToggleError}
1321
+ <div class="sub"><div class="msg error">{mcpToggleError}</div></div>
1322
+ {/if}
1323
+
1324
+ {#if mcp}
1325
+ <div class="row divided">
1326
+ <div class="label">
1327
+ <div class="text">
1328
+ <div class="name">Token</div>
1329
+ <div class="desc">
1330
+ Anything holding this token can create containers and run agents with your GitHub
1331
+ and Claude credentials. Treat it like a password.
1332
+ </div>
1333
+ </div>
1334
+ </div>
1335
+ <div class="model-fields">
1336
+ <label class="model-row">
1337
+ <span class="model-label">Bearer</span>
1338
+ <input
1339
+ type="text"
1340
+ class="image-input"
1341
+ value={mcpTokenValue}
1342
+ readonly
1343
+ spellcheck="false"
1344
+ onfocus={(e) => e.currentTarget.select()}
1345
+ />
1346
+ </label>
1347
+ <div class="mcp-actions">
1348
+ <Button type="button" onclick={() => copyMcp('token', mcpTokenValue)}>
1349
+ {mcpCopied === 'token' ? 'Copied' : 'Copy token'}
1350
+ </Button>
1351
+ <Button type="button" onclick={() => copyMcp('command', mcpCommand)}>
1352
+ {mcpCopied === 'command' ? 'Copied' : 'Copy claude mcp add'}
1353
+ </Button>
1354
+ <form method="POST" action="?/mcpRegenerateToken" {@attach enhance(mcpTokenOpts)}>
1355
+ <Button type="submit" disabled={savingMcpToken}>
1356
+ {savingMcpToken ? 'Regenerating…' : 'Regenerate'}
1357
+ </Button>
1358
+ </form>
1359
+ </div>
1360
+ <code class="mcp-command">{mcpCommand}</code>
1361
+ {#if mcpTokenError}
1362
+ <div class="msg error">{mcpTokenError}</div>
1363
+ {/if}
1364
+ </div>
1365
+ </div>
1366
+
1367
+ <form
1368
+ class="row divided"
1369
+ method="POST"
1370
+ action="?/mcpPrAttributionToggle"
1371
+ {@attach enhance(mcpAttributionOpts)}
1372
+ >
1373
+ <div class="label">
1374
+ <div class="text">
1375
+ <div class="name">Credit Codebay on pull requests</div>
1376
+ <div class="desc">
1377
+ Append a one-line footer to the body of every pull request an agent opens through
1378
+ <code>create_pr</code>. Off by default — an attribution line on your PRs is yours to
1379
+ opt into.
1380
+ </div>
1381
+ </div>
1382
+ </div>
1383
+ <label class="switch">
1384
+ <input
1385
+ type="checkbox"
1386
+ name="enabled"
1387
+ checked={mcpAttribution}
1388
+ disabled={savingMcpAttribution}
1389
+ onchange={(e) => {
1390
+ mcpAttribution = e.currentTarget.checked;
1391
+ e.currentTarget.form?.requestSubmit();
1392
+ }}
1393
+ />
1394
+ <span class="track"><span class="thumb"></span></span>
1395
+ </label>
1396
+ </form>
1397
+ {#if mcpAttributionError}
1398
+ <div class="sub"><div class="msg error">{mcpAttributionError}</div></div>
1399
+ {/if}
1400
+ {/if}
1401
+ </section>
1402
+
1233
1403
  <section class="card">
1234
1404
  <form
1235
1405
  class="row"
@@ -2693,6 +2863,22 @@
2693
2863
  flex: 1;
2694
2864
  min-width: 220px;
2695
2865
  }
2866
+ .mcp-actions {
2867
+ display: flex;
2868
+ flex-wrap: wrap;
2869
+ gap: 6px;
2870
+ }
2871
+ /* The registration line is long and unbreakable, so it scrolls rather than widening the card. */
2872
+ .mcp-command {
2873
+ display: block;
2874
+ overflow-x: auto;
2875
+ white-space: pre;
2876
+ padding: 6px 8px;
2877
+ border: 1px solid var(--edge);
2878
+ background: var(--panel);
2879
+ font-size: 11px;
2880
+ line-height: 1.5;
2881
+ }
2696
2882
  .model-row {
2697
2883
  display: flex;
2698
2884
  align-items: center;
@@ -183,7 +183,8 @@
183
183
  terminal_split: 0,
184
184
  git_branch: cardBranch ? branchName : null,
185
185
  attention: cardAttention,
186
- forwarded_ports: demoPorts
186
+ forwarded_ports: demoPorts,
187
+ active_run: null
187
188
  });
188
189
 
189
190
  const statuses: Instance['status'][] = ['creating', 'running', 'stopped', 'error'];
@@ -234,7 +235,8 @@
234
235
  terminal_split: 0,
235
236
  git_branch: 'main',
236
237
  attention: null,
237
- forwarded_ports: []
238
+ forwarded_ports: [],
239
+ active_run: null
238
240
  };
239
241
  })
240
242
  );
@@ -1,5 +1,6 @@
1
1
  import { statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { isAbsolute } from 'node:path';
3
4
  import { writeContainerFile } from '../lib/container-files.server.ts';
4
5
  import {
5
6
  claudeConfigFile,
@@ -34,7 +35,9 @@ const isExistingFile = (p: string): boolean => {
34
35
  export function extractScriptPath(command: string): string | null {
35
36
  const token = command
36
37
  .split(/\s+/)
37
- .find((t) => (t.startsWith('/') || t.startsWith('~/')) && isExistingFile(expandTilde(t)));
38
+ // isAbsolute rather than a leading slash, so a Windows `C:…` command is recognised too;
39
+ // on POSIX the two are equivalent. The file check still rejects a bare root either way.
40
+ .find((t) => (isAbsolute(t) || t.startsWith('~/')) && isExistingFile(expandTilde(t)));
38
41
  return token ?? null;
39
42
  }
40
43
 
@@ -51,8 +51,19 @@ export function ghHostBlock(raw: string, host: string): string | null {
51
51
  return rest.slice(0, end === -1 ? rest.length : end).join('\n');
52
52
  }
53
53
 
54
+ /** Mirrors `gh`'s own resolution order, which is not XDG on Windows. */
55
+ function ghConfigDir(): string {
56
+ const explicit = process.env.GH_CONFIG_DIR?.trim();
57
+ if (explicit) return explicit;
58
+ if (process.platform === 'win32') {
59
+ return join(process.env.AppData || join(homedir(), 'AppData', 'Roaming'), 'GitHub CLI');
60
+ }
61
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
62
+ return xdg ? join(xdg, 'gh') : join(homedir(), '.config', 'gh');
63
+ }
64
+
54
65
  async function readGhHostsFile(): Promise<string | null> {
55
- const file = join(homedir(), '.config', 'gh', 'hosts.yml');
66
+ const file = join(ghConfigDir(), 'hosts.yml');
56
67
  if (!existsSync(file)) return null;
57
68
  try {
58
69
  return await readFile(file, 'utf8');
package/src/index.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { Mochi, sequence, silenceInternalRoutes } from 'mochi-framework';
2
2
  import { routes } from './routes.ts';
3
- import { basicAuth } from './lib/auth.server.ts';
3
+ import { basicAuth, tokenAuthenticated } from './lib/auth.server.ts';
4
4
  import { themeHandle } from './lib/theme.server.ts';
5
5
  import { PROXY_PREFIX } from './lib/proxy.server.ts';
6
+ import { resumeRuns } from './lib/agent-runs.server.ts';
7
+ import { warnIfBuildStale } from './lib/build-freshness.server.ts';
8
+ import { LOCK_STALE_MS, acquireDataDirLock } from './lib/data-dir-lock.server.ts';
6
9
  import {
7
10
  APP_VERSION,
8
11
  BASIC_AUTH_PASSWORD,
12
+ DATA_DIR,
9
13
  HOST,
10
14
  PORT,
11
15
  PUBLIC_ORIGIN,
@@ -21,10 +25,32 @@ if (HOST !== '127.0.0.1' && HOST !== 'localhost' && !BASIC_AUTH_PASSWORD) {
21
25
  `⚠ Binding to ${HOST} (non-loopback) without a password — anyone on the network can reach this server.`
22
26
  );
23
27
  }
28
+ warnIfBuildStale();
29
+
30
+ const lock = acquireDataDirLock(DATA_DIR);
31
+ if (!lock.ok) {
32
+ const who = lock.holder
33
+ ? `pid ${lock.holder.pid} on ${lock.holder.host}, started ${new Date(lock.holder.startedAt).toLocaleString()}`
34
+ : 'an unknown process';
35
+ console.error(
36
+ `✖ Another codebay manager (${who}) is already using ${DATA_DIR}. Two managers on one data dir ` +
37
+ "poll the same runs and overwrite each other's rows — stop it, or start this one with a " +
38
+ `different DATA_DIR. A crashed manager's lock (${lock.path}) clears itself after ${LOCK_STALE_MS / 1000}s.`
39
+ );
40
+ process.exit(1);
41
+ }
42
+ // A default Ctrl-C skips the exit hooks, and an unreleased lock refuses the restart that follows.
43
+ process.on('exit', () => lock.release());
44
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
45
+ process.on(signal, () => process.exit(signal === 'SIGINT' ? 130 : 143));
46
+ }
24
47
 
25
48
  // Must precede Mochi.serve(), which is where MOCHI_KEY is read.
26
49
  ensureMochiKey();
27
50
 
51
+ // Agent runs outlive the process that started them, so re-attach pollers to anything still in flight.
52
+ resumeRuns();
53
+
28
54
  await Mochi.serve({
29
55
  port: PORT,
30
56
  hostname: HOST,
@@ -41,10 +67,9 @@ await Mochi.serve({
41
67
  trustedOrigins: TRUSTED_ORIGINS
42
68
  },
43
69
  filters: {
44
- // Containers curl the bridge with no Origin header, which the check would 403;
45
- // safe because the route authenticates by token, not ambient browser credentials.
46
- 'csrf:check': (decision, { url }) =>
47
- url.pathname.startsWith('/api/bridge/') ? null : decision,
70
+ // Containers and MCP clients send no Origin header, which the check would 403; safe because
71
+ // those routes authenticate by token, not ambient browser credentials.
72
+ 'csrf:check': (decision, { url }) => (tokenAuthenticated(url.pathname) ? null : decision),
48
73
  'consoleLogger:line': (line, ctx) => {
49
74
  const kept = silenceInternalRoutes(line, ctx);
50
75
  if (kept == null) {
@@ -53,8 +78,8 @@ await Mochi.serve({
53
78
  if (ctx.source.name === 'ws:message' && ctx.path.startsWith(PROXY_PREFIX + '/')) {
54
79
  return null;
55
80
  }
56
- // These fire on every Claude hook event and carry the bridge token.
57
- if (ctx.path.startsWith('/api/bridge/')) {
81
+ // A line per Claude hook event or JSON-RPC frame is noise, and each would print its token.
82
+ if (tokenAuthenticated(ctx.path)) {
58
83
  return null;
59
84
  }
60
85
  return kept;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Chunk-oriented because the poller tails raw bytes that usually end mid-line, and free of
3
+ * Node/Bun APIs so it can be unit-tested on its own.
4
+ */
5
+
6
+ /** Everything the run row needs, accumulated across chunks. */
7
+ export interface RunStreamState {
8
+ sessionId: string | null;
9
+ /** The model Claude actually ran with (an id like `claude-opus-5`), from the `init` event. */
10
+ model: string | null;
11
+ lastActivity: string | null;
12
+ numTurns: number | null;
13
+ costUsd: number | null;
14
+ durationMs: number | null;
15
+ result: string | null;
16
+ /** Serialized `structured_output`, present only when the caller passed a `--json-schema`. */
17
+ structuredOutput: string | null;
18
+ isError: boolean;
19
+ /** True once the terminal `result` event has been seen, whatever its subtype. */
20
+ resultSeen: boolean;
21
+ }
22
+
23
+ export function emptyRunState(): RunStreamState {
24
+ return {
25
+ sessionId: null,
26
+ model: null,
27
+ lastActivity: null,
28
+ numTurns: null,
29
+ costUsd: null,
30
+ durationMs: null,
31
+ result: null,
32
+ structuredOutput: null,
33
+ isError: false,
34
+ resultSeen: false
35
+ };
36
+ }
37
+
38
+ /** Long enough to identify the step, short enough to sit on one line of a caller's poll output. */
39
+ const ACTIVITY_MAX = 120;
40
+
41
+ /** The timeline has a whole panel to render into, so it keeps more of each line than the poller. */
42
+ const TIMELINE_MAX = 400;
43
+
44
+ function truncate(text: string, max = ACTIVITY_MAX): string {
45
+ const oneLine = text.replace(/\s+/g, ' ').trim();
46
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine;
47
+ }
48
+
49
+ /** The field that best identifies a call, per tool; anything unlisted falls back to the first string. */
50
+ const TOOL_SUMMARY_FIELDS = [
51
+ 'command',
52
+ 'file_path',
53
+ 'path',
54
+ 'pattern',
55
+ 'description',
56
+ 'query',
57
+ 'url',
58
+ 'prompt'
59
+ ];
60
+
61
+ function summarizeToolInput(input: unknown): string {
62
+ if (typeof input !== 'object' || input === null) return '';
63
+ const obj = input as Record<string, unknown>;
64
+ for (const field of TOOL_SUMMARY_FIELDS) {
65
+ const value = obj[field];
66
+ if (typeof value === 'string' && value.trim()) return value;
67
+ }
68
+ const first = Object.values(obj).find((v) => typeof v === 'string' && v.trim());
69
+ return typeof first === 'string' ? first : '';
70
+ }
71
+
72
+ interface ContentBlock {
73
+ type?: string;
74
+ text?: string;
75
+ name?: string;
76
+ input?: unknown;
77
+ }
78
+
79
+ /** The last block wins, so `last_activity` reflects the most recent thing Claude did in the turn. */
80
+ function activityFromBlocks(blocks: ContentBlock[]): string | null {
81
+ let activity: string | null = null;
82
+ for (const block of blocks) {
83
+ if (block.type === 'tool_use' && typeof block.name === 'string') {
84
+ const arg = summarizeToolInput(block.input);
85
+ activity = arg ? `${block.name}(${truncate(arg)})` : block.name;
86
+ } else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
87
+ activity = truncate(block.text);
88
+ }
89
+ }
90
+ return activity;
91
+ }
92
+
93
+ interface StreamEvent {
94
+ type?: string;
95
+ subtype?: string;
96
+ session_id?: string;
97
+ model?: string;
98
+ parent_tool_use_id?: string | null;
99
+ message?: { content?: ContentBlock[] };
100
+ error?: string;
101
+ attempt?: number;
102
+ result?: string;
103
+ structured_output?: unknown;
104
+ is_error?: boolean;
105
+ num_turns?: number;
106
+ total_cost_usd?: number;
107
+ duration_ms?: number;
108
+ }
109
+
110
+ function applyEvent(state: RunStreamState, event: StreamEvent): void {
111
+ if (typeof event.session_id === 'string' && !state.sessionId) state.sessionId = event.session_id;
112
+
113
+ if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') {
114
+ state.model = event.model;
115
+ return;
116
+ }
117
+
118
+ if (event.type === 'system' && event.subtype === 'api_retry') {
119
+ state.lastActivity = `Retrying (${event.error ?? 'error'}), attempt ${event.attempt ?? 1}…`;
120
+ return;
121
+ }
122
+
123
+ if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
124
+ // Subagent chatter would otherwise drown out what the main loop is doing.
125
+ if (event.parent_tool_use_id != null) return;
126
+ const activity = activityFromBlocks(event.message.content);
127
+ if (activity) state.lastActivity = activity;
128
+ return;
129
+ }
130
+
131
+ if (event.type === 'result') {
132
+ state.resultSeen = true;
133
+ state.isError = event.is_error === true || event.subtype !== 'success';
134
+ if (typeof event.result === 'string') state.result = event.result;
135
+ if (event.structured_output !== undefined && event.structured_output !== null) {
136
+ state.structuredOutput = JSON.stringify(event.structured_output);
137
+ }
138
+ if (typeof event.num_turns === 'number') state.numTurns = event.num_turns;
139
+ if (typeof event.total_cost_usd === 'number') state.costUsd = event.total_cost_usd;
140
+ if (typeof event.duration_ms === 'number') state.durationMs = event.duration_ms;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Folds a chunk of newline-delimited JSON into `state`, returning the incomplete trailing line for
146
+ * the caller to prepend next time. A line that won't parse is skipped rather than fatal — a torn
147
+ * write reappears whole on the following pass.
148
+ */
149
+ export function readRunChunk(
150
+ state: RunStreamState,
151
+ carry: string,
152
+ chunk: string
153
+ ): { state: RunStreamState; carry: string } {
154
+ const lines = (carry + chunk).split('\n');
155
+ const tail = lines.pop() ?? '';
156
+ for (const line of lines) {
157
+ const trimmed = line.trim();
158
+ if (!trimmed) continue;
159
+ try {
160
+ applyEvent(state, JSON.parse(trimmed) as StreamEvent);
161
+ } catch {
162
+ // Not a torn line but genuine garbage (a stray shell warning); ignore it.
163
+ }
164
+ }
165
+ return { state, carry: tail };
166
+ }
167
+
168
+ /** Replays a whole mirror file — how a run's state is rebuilt after the manager restarts. */
169
+ export function readRunFile(text: string): RunStreamState {
170
+ return readRunChunk(emptyRunState(), '', text).state;
171
+ }
172
+
173
+ /** One rendered row of the Agent log panel. */
174
+ export interface RunTimelineEntry {
175
+ kind: 'tool' | 'text' | 'retry' | 'result';
176
+ /** Tool name, when `kind` is `'tool'`. */
177
+ name?: string;
178
+ text: string;
179
+ /** True for a subagent's message, which the panel indents rather than hides. */
180
+ subagent?: boolean;
181
+ isError?: boolean;
182
+ }
183
+
184
+ function timelineFromEvent(event: StreamEvent): RunTimelineEntry[] {
185
+ if (event.type === 'system' && event.subtype === 'api_retry') {
186
+ return [
187
+ {
188
+ kind: 'retry',
189
+ text: `Retrying (${event.error ?? 'error'}), attempt ${event.attempt ?? 1}…`
190
+ }
191
+ ];
192
+ }
193
+ if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
194
+ const subagent = event.parent_tool_use_id != null;
195
+ const out: RunTimelineEntry[] = [];
196
+ for (const block of event.message.content) {
197
+ if (block.type === 'tool_use' && typeof block.name === 'string') {
198
+ const arg = summarizeToolInput(block.input);
199
+ out.push({
200
+ kind: 'tool',
201
+ name: block.name,
202
+ text: truncate(arg, TIMELINE_MAX),
203
+ subagent
204
+ });
205
+ } else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
206
+ out.push({ kind: 'text', text: truncate(block.text, TIMELINE_MAX), subagent });
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+ if (event.type === 'result') {
212
+ const isError = event.is_error === true || event.subtype !== 'success';
213
+ return [
214
+ {
215
+ kind: 'result',
216
+ text: truncate(event.result ?? event.subtype ?? 'finished', TIMELINE_MAX),
217
+ isError
218
+ }
219
+ ];
220
+ }
221
+ return [];
222
+ }
223
+
224
+ /**
225
+ * Flattens a whole mirrored run into the rows the Agent log renders. Separate from `readRunChunk`
226
+ * because that one only keeps the *latest* of each field — a timeline needs every step.
227
+ */
228
+ export function parseRunTimeline(text: string): RunTimelineEntry[] {
229
+ const out: RunTimelineEntry[] = [];
230
+ for (const line of text.split('\n')) {
231
+ const trimmed = line.trim();
232
+ if (!trimmed) continue;
233
+ try {
234
+ for (const entry of timelineFromEvent(JSON.parse(trimmed) as StreamEvent)) {
235
+ // Claude's closing message arrives twice — once as the final assistant text block and
236
+ // again as the `result` event's text — so the result supersedes it rather than repeats it.
237
+ const prev = out.at(-1);
238
+ if (entry.kind === 'result' && prev?.kind === 'text' && prev.text === entry.text) out.pop();
239
+ out.push(entry);
240
+ }
241
+ } catch {
242
+ // A torn trailing line; it reappears whole once more bytes land.
243
+ }
244
+ }
245
+ return out;
246
+ }