codebay 0.14.0 → 0.16.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 (108) hide show
  1. package/.mochi/manifest.json +778 -646
  2. package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
  7. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
  8. package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
  9. package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
  10. package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
  11. package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
  12. package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
  13. package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
  14. package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
  15. package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
  16. package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
  17. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
  18. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
  21. package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
  22. package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
  23. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
  24. package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
  25. package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
  26. package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
  27. package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
  28. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
  29. package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
  30. package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
  31. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
  32. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  33. package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
  34. package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
  35. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  36. package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
  37. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  38. package/README.md +43 -3
  39. package/migrations/0009_agent_runs.sql +24 -0
  40. package/migrations/0010_agent_run_model.sql +2 -0
  41. package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
  42. package/package.json +13 -9
  43. package/src/__fixtures__/IdeBarStrip.svelte +41 -0
  44. package/src/avatars/dog.ts +1 -1
  45. package/src/avatars/lightning.ts +8 -9
  46. package/src/avatars/rocket.ts +4 -5
  47. package/src/components/AgentLogBox.svelte +458 -0
  48. package/src/components/AppShell.svelte +112 -1
  49. package/src/components/DropZone.svelte +77 -0
  50. package/src/components/FolderBrowser.svelte +13 -5
  51. package/src/components/HealthBox.svelte +6 -5
  52. package/src/components/IdeBar.svelte +222 -76
  53. package/src/components/InstanceCard.svelte +38 -0
  54. package/src/components/InstanceView.svelte +83 -2
  55. package/src/components/SettingsView.svelte +273 -0
  56. package/src/components/TerminalPane.svelte +54 -0
  57. package/src/components/UiShowcase.svelte +23 -3
  58. package/src/container-injections/claude-code-install.ts +1 -1
  59. package/src/container-injections/claude-history.ts +52 -0
  60. package/src/container-injections/claude-statusline.ts +4 -1
  61. package/src/container-injections/github-credentials.ts +12 -1
  62. package/src/container-injections/tmux.ts +6 -2
  63. package/src/index.ts +35 -7
  64. package/src/lib/agent-run-stream.ts +286 -0
  65. package/src/lib/agent-runs.server.ts +735 -0
  66. package/src/lib/auth.server.ts +11 -2
  67. package/src/lib/build-freshness.server.ts +71 -0
  68. package/src/lib/config.server.ts +36 -7
  69. package/src/lib/container-files.server.ts +1 -1
  70. package/src/lib/data-dir-lock.server.ts +78 -0
  71. package/src/lib/db.server.ts +124 -1
  72. package/src/lib/devcontainer.server.ts +93 -16
  73. package/src/lib/docker-client.server.ts +11 -5
  74. package/src/lib/drives.ts +11 -0
  75. package/src/lib/exec.server.ts +66 -5
  76. package/src/lib/injections.server.ts +5 -1
  77. package/src/lib/instances.server.ts +66 -7
  78. package/src/lib/log-capture.server.ts +63 -23
  79. package/src/lib/mcp-auth.server.ts +52 -0
  80. package/src/lib/osc52.ts +18 -0
  81. package/src/lib/picker.server.ts +23 -2
  82. package/src/lib/sandbox-ops.server.ts +263 -0
  83. package/src/lib/upload.ts +32 -0
  84. package/src/lib/uploads.server.ts +164 -0
  85. package/src/mcp/routes.server.ts +25 -0
  86. package/src/mcp/server.server.ts +55 -0
  87. package/src/mcp/tools.server.ts +660 -0
  88. package/src/pages/Instance.svelte +6 -2
  89. package/src/pages/Settings.svelte +15 -0
  90. package/src/routes.ts +79 -3
  91. package/src/types.ts +17 -0
  92. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  93. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  94. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  95. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  96. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  97. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  98. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  99. package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
  100. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  101. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  102. package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
  103. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  104. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
  105. package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
  106. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
  107. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
  108. package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
@@ -0,0 +1,52 @@
1
+ import type { Injection } from '../lib/injections.server.ts';
2
+ import { CLAUDE_RESTORE_MOUNT } from '../lib/devcontainer.server.ts';
3
+ import { afterMarker, checkPresence, execInContainer } from '../lib/exec.server.ts';
4
+
5
+ const RESTORE_MARKER = '__CODEBAY_RESTORE__';
6
+
7
+ /**
8
+ * `cp -n` isn't in busybox, so missing files are selected explicitly; the count rides a marker
9
+ * because the login shell's profile output precedes anything the script prints.
10
+ */
11
+ export function restoreScript(mountDir: string = CLAUDE_RESTORE_MOUNT): string {
12
+ return (
13
+ // The host mirror's modes don't survive the mount, so match the 0600 Claude Code writes.
14
+ `umask 077; ` +
15
+ `h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
16
+ `cd ${mountDir} 2>/dev/null || { printf '%s0\\n' '${RESTORE_MARKER}'; exit 0; }; ` +
17
+ // Listed to a file, not piped: a pipeline would run the loop in a subshell the count dies with.
18
+ // Redirecting the loop (rather than word-splitting `find`) also survives a path with spaces.
19
+ `l="\${TMPDIR:-/tmp}/codebay-restore.$$"; find . -type f > "$l"; n=0; ` +
20
+ `while IFS= read -r f; do ` +
21
+ `d="$cfg/\${f#./}"; ` +
22
+ // A file the container already has is live state; the mirror is only ever a floor.
23
+ `[ -e "$d" ] && continue; ` +
24
+ `mkdir -p "$(dirname "$d")" && cp "$f" "$d" && n=$((n + 1)); ` +
25
+ `done < "$l"; rm -f "$l"; printf '%s%s\\n' '${RESTORE_MARKER}' "$n"`
26
+ );
27
+ }
28
+
29
+ /**
30
+ * A rebuild recreates the container filesystem, so `~/.claude` — and with it every transcript
31
+ * `/resume` offers — starts empty. The host mirror is bind-mounted back in read-only; this copies
32
+ * whatever the fresh container is missing out of it.
33
+ */
34
+ export const claudeHistory: Injection = {
35
+ id: 'claude-history',
36
+ label: 'Claude history',
37
+
38
+ async apply(target, log) {
39
+ const res = await execInContainer(target, { script: restoreScript(), capture: true });
40
+ if (!res.ok) {
41
+ log(`⚠ Claude history restore failed: ${res.error}\n`);
42
+ return;
43
+ }
44
+ const count = Number(afterMarker(res.stdout, RESTORE_MARKER)?.trim());
45
+ if (count > 0) log(`✓ Restored ${count} Claude conversation file(s) from a previous build\n`);
46
+ },
47
+
48
+ // The mount, not the copied files, is what the next rebuild restores from; compose configs drop it.
49
+ async check(target) {
50
+ return checkPresence(target, `[ -d ${CLAUDE_RESTORE_MOUNT} ] && echo 1 || echo 0`);
51
+ }
52
+ };
@@ -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');
@@ -15,13 +15,17 @@ export const INSTALL_SCRIPT =
15
15
  'command -v tmux >/dev/null 2>&1';
16
16
 
17
17
  /**
18
- * `mouse on` buys wheel-scroll at the cost of code-server's native drag-select, since tmux
19
- * takes the mouse — hence `bind m` to toggle it, shadowing mark-pane, unused in a single pane.
18
+ * `mouse on` buys wheel-scroll and makes tmux — not the browser — own the drag, which is what
19
+ * survives Claude's screen repaints; `set-clipboard on` then hands the copy to the host via OSC 52,
20
+ * which `TerminalPane` decodes. `bind m` shadows mark-pane, unused in a single pane.
20
21
  */
21
22
  export const TMUX_CONF_LINES = [
22
23
  'set -g mouse on',
23
24
  'set -g history-limit 50000',
24
25
  'set -g set-clipboard on',
26
+ // Only `xterm*` gets the clipboard feature by default, so an image whose TERM is anything
27
+ // else silently turns `set-clipboard on` into a no-op.
28
+ "set -as terminal-overrides ',*:Ms=\\E]52;%p1%s;%p2%s\\007'",
25
29
  'set -g allow-passthrough on',
26
30
  'set -g status off',
27
31
  'bind m set -g mouse \\; display-message "mouse: #{?mouse,on,off}"'
package/src/index.ts CHANGED
@@ -1,11 +1,16 @@
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';
9
+ import { UPLOAD_MAX_BYTES } from './lib/uploads.server.ts';
6
10
  import {
7
11
  APP_VERSION,
8
12
  BASIC_AUTH_PASSWORD,
13
+ DATA_DIR,
9
14
  HOST,
10
15
  PORT,
11
16
  PUBLIC_ORIGIN,
@@ -21,15 +26,39 @@ if (HOST !== '127.0.0.1' && HOST !== 'localhost' && !BASIC_AUTH_PASSWORD) {
21
26
  `⚠ Binding to ${HOST} (non-loopback) without a password — anyone on the network can reach this server.`
22
27
  );
23
28
  }
29
+ warnIfBuildStale();
30
+
31
+ const lock = acquireDataDirLock(DATA_DIR);
32
+ if (!lock.ok) {
33
+ const who = lock.holder
34
+ ? `pid ${lock.holder.pid} on ${lock.holder.host}, started ${new Date(lock.holder.startedAt).toLocaleString()}`
35
+ : 'an unknown process';
36
+ console.error(
37
+ `✖ Another codebay manager (${who}) is already using ${DATA_DIR}. Two managers on one data dir ` +
38
+ "poll the same runs and overwrite each other's rows — stop it, or start this one with a " +
39
+ `different DATA_DIR. A crashed manager's lock (${lock.path}) clears itself after ${LOCK_STALE_MS / 1000}s.`
40
+ );
41
+ process.exit(1);
42
+ }
43
+ // A default Ctrl-C skips the exit hooks, and an unreleased lock refuses the restart that follows.
44
+ process.on('exit', () => lock.release());
45
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
46
+ process.on(signal, () => process.exit(signal === 'SIGINT' ? 130 : 143));
47
+ }
24
48
 
25
49
  // Must precede Mochi.serve(), which is where MOCHI_KEY is read.
26
50
  ensureMochiKey();
27
51
 
52
+ // Agent runs outlive the process that started them, so re-attach pollers to anything still in flight.
53
+ resumeRuns();
54
+
28
55
  await Mochi.serve({
29
56
  port: PORT,
30
57
  hostname: HOST,
31
58
  // Bun defaults to 10s and aborts slower form POSTs mid-flight; passed through to Bun.serve.
32
59
  idleTimeout: 120,
60
+ // Bun's own default (128 MiB) is below UPLOAD_MAX_BYTES; passed through to Bun.serve.
61
+ maxRequestBodySize: UPLOAD_MAX_BYTES,
33
62
  development: process.env.MODE === 'development',
34
63
  htmlShell: './src/shell.html',
35
64
  handle: sequence(basicAuth, themeHandle),
@@ -41,10 +70,9 @@ await Mochi.serve({
41
70
  trustedOrigins: TRUSTED_ORIGINS
42
71
  },
43
72
  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,
73
+ // Containers and MCP clients send no Origin header, which the check would 403; safe because
74
+ // those routes authenticate by token, not ambient browser credentials.
75
+ 'csrf:check': (decision, { url }) => (tokenAuthenticated(url.pathname) ? null : decision),
48
76
  'consoleLogger:line': (line, ctx) => {
49
77
  const kept = silenceInternalRoutes(line, ctx);
50
78
  if (kept == null) {
@@ -53,8 +81,8 @@ await Mochi.serve({
53
81
  if (ctx.source.name === 'ws:message' && ctx.path.startsWith(PROXY_PREFIX + '/')) {
54
82
  return null;
55
83
  }
56
- // These fire on every Claude hook event and carry the bridge token.
57
- if (ctx.path.startsWith('/api/bridge/')) {
84
+ // A line per Claude hook event or JSON-RPC frame is noise, and each would print its token.
85
+ if (tokenAuthenticated(ctx.path)) {
58
86
  return null;
59
87
  }
60
88
  return kept;
@@ -0,0 +1,286 @@
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
+ /** Claude Code's terminal subtype when every `--json-schema` attempt failed validation. */
7
+ const STRUCTURED_OUTPUT_RETRY_SUBTYPE = 'error_max_structured_output_retries';
8
+
9
+ /** The tool Claude Code calls to hand back a `--json-schema` payload. */
10
+ const STRUCTURED_OUTPUT_TOOL = 'StructuredOutput';
11
+
12
+ /** A run's `error` when the agent answered but its schema payload never validated. */
13
+ export const STRUCTURED_OUTPUT_FAILED_ERROR = 'structured_output_failed';
14
+
15
+ /** Everything the run row needs, accumulated across chunks. */
16
+ export interface RunStreamState {
17
+ sessionId: string | null;
18
+ /** The model Claude actually ran with (an id like `claude-opus-5`), from the `init` event. */
19
+ model: string | null;
20
+ lastActivity: string | null;
21
+ numTurns: number | null;
22
+ costUsd: number | null;
23
+ durationMs: number | null;
24
+ result: string | null;
25
+ /** Serialized `structured_output`, present only when the caller passed a `--json-schema`. */
26
+ structuredOutput: string | null;
27
+ /** The last untruncated main-loop text block — the answer to fall back on when the schema fails. */
28
+ lastAssistantText: string | null;
29
+ /** Serialized input of the latest `StructuredOutput` call, whether or not it was accepted. */
30
+ lastStructuredOutputInput: string | null;
31
+ /** Set on the terminal event: the payload the schema rejected, for a caller to salvage. */
32
+ rejectedStructuredOutput: string | null;
33
+ /** True when the run died on `--json-schema` retry exhaustion rather than a crash. */
34
+ structuredOutputFailed: boolean;
35
+ isError: boolean;
36
+ /** True once the terminal `result` event has been seen, whatever its subtype. */
37
+ resultSeen: boolean;
38
+ }
39
+
40
+ export function emptyRunState(): RunStreamState {
41
+ return {
42
+ sessionId: null,
43
+ model: null,
44
+ lastActivity: null,
45
+ numTurns: null,
46
+ costUsd: null,
47
+ durationMs: null,
48
+ result: null,
49
+ structuredOutput: null,
50
+ lastAssistantText: null,
51
+ lastStructuredOutputInput: null,
52
+ rejectedStructuredOutput: null,
53
+ structuredOutputFailed: false,
54
+ isError: false,
55
+ resultSeen: false
56
+ };
57
+ }
58
+
59
+ /** Long enough to identify the step, short enough to sit on one line of a caller's poll output. */
60
+ const ACTIVITY_MAX = 120;
61
+
62
+ /** The timeline has a whole panel to render into, so it keeps more of each line than the poller. */
63
+ const TIMELINE_MAX = 400;
64
+
65
+ function truncate(text: string, max = ACTIVITY_MAX): string {
66
+ const oneLine = text.replace(/\s+/g, ' ').trim();
67
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine;
68
+ }
69
+
70
+ /** The field that best identifies a call, per tool; anything unlisted falls back to the first string. */
71
+ const TOOL_SUMMARY_FIELDS = [
72
+ 'command',
73
+ 'file_path',
74
+ 'path',
75
+ 'pattern',
76
+ 'description',
77
+ 'query',
78
+ 'url',
79
+ 'prompt'
80
+ ];
81
+
82
+ function summarizeToolInput(input: unknown): string {
83
+ if (typeof input !== 'object' || input === null) return '';
84
+ const obj = input as Record<string, unknown>;
85
+ for (const field of TOOL_SUMMARY_FIELDS) {
86
+ const value = obj[field];
87
+ if (typeof value === 'string' && value.trim()) return value;
88
+ }
89
+ const first = Object.values(obj).find((v) => typeof v === 'string' && v.trim());
90
+ return typeof first === 'string' ? first : '';
91
+ }
92
+
93
+ interface ContentBlock {
94
+ type?: string;
95
+ text?: string;
96
+ name?: string;
97
+ input?: unknown;
98
+ }
99
+
100
+ /** The last block wins, so `last_activity` reflects the most recent thing Claude did in the turn. */
101
+ function activityFromBlocks(blocks: ContentBlock[]): string | null {
102
+ let activity: string | null = null;
103
+ for (const block of blocks) {
104
+ if (block.type === 'tool_use' && typeof block.name === 'string') {
105
+ const arg = summarizeToolInput(block.input);
106
+ activity = arg ? `${block.name}(${truncate(arg)})` : block.name;
107
+ } else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
108
+ activity = truncate(block.text);
109
+ }
110
+ }
111
+ return activity;
112
+ }
113
+
114
+ interface StreamEvent {
115
+ type?: string;
116
+ subtype?: string;
117
+ session_id?: string;
118
+ model?: string;
119
+ parent_tool_use_id?: string | null;
120
+ message?: { content?: ContentBlock[] };
121
+ error?: string;
122
+ attempt?: number;
123
+ result?: string;
124
+ structured_output?: unknown;
125
+ is_error?: boolean;
126
+ num_turns?: number;
127
+ total_cost_usd?: number;
128
+ duration_ms?: number;
129
+ }
130
+
131
+ function applyEvent(state: RunStreamState, event: StreamEvent): void {
132
+ if (typeof event.session_id === 'string' && !state.sessionId) state.sessionId = event.session_id;
133
+
134
+ if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') {
135
+ state.model = event.model;
136
+ return;
137
+ }
138
+
139
+ if (event.type === 'system' && event.subtype === 'api_retry') {
140
+ state.lastActivity = `Retrying (${event.error ?? 'error'}), attempt ${event.attempt ?? 1}…`;
141
+ return;
142
+ }
143
+
144
+ if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
145
+ // Subagent chatter would otherwise drown out what the main loop is doing.
146
+ if (event.parent_tool_use_id != null) return;
147
+ for (const block of event.message.content) {
148
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
149
+ state.lastAssistantText = block.text.trim();
150
+ } else if (
151
+ block.type === 'tool_use' &&
152
+ block.name === STRUCTURED_OUTPUT_TOOL &&
153
+ block.input
154
+ ) {
155
+ state.lastStructuredOutputInput = JSON.stringify(block.input);
156
+ }
157
+ }
158
+ const activity = activityFromBlocks(event.message.content);
159
+ if (activity) state.lastActivity = activity;
160
+ return;
161
+ }
162
+
163
+ if (event.type === 'result') {
164
+ state.resultSeen = true;
165
+ state.isError = event.is_error === true || event.subtype !== 'success';
166
+ if (typeof event.result === 'string') state.result = event.result;
167
+ if (event.structured_output !== undefined && event.structured_output !== null) {
168
+ state.structuredOutput = JSON.stringify(event.structured_output);
169
+ }
170
+ if (event.subtype === STRUCTURED_OUTPUT_RETRY_SUBTYPE) {
171
+ state.structuredOutputFailed = true;
172
+ // Retries only run out once every attempt was rejected, so the latest one is a rejected one.
173
+ state.rejectedStructuredOutput = state.lastStructuredOutputInput;
174
+ // Claude Code leaves `result` empty on this subtype, throwing away an answer it did give.
175
+ if (!state.result?.trim()) state.result = state.lastAssistantText;
176
+ }
177
+ if (typeof event.num_turns === 'number') state.numTurns = event.num_turns;
178
+ if (typeof event.total_cost_usd === 'number') state.costUsd = event.total_cost_usd;
179
+ if (typeof event.duration_ms === 'number') state.durationMs = event.duration_ms;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Folds a chunk of newline-delimited JSON into `state`, returning the incomplete trailing line for
185
+ * the caller to prepend next time. A line that won't parse is skipped rather than fatal — a torn
186
+ * write reappears whole on the following pass.
187
+ */
188
+ export function readRunChunk(
189
+ state: RunStreamState,
190
+ carry: string,
191
+ chunk: string
192
+ ): { state: RunStreamState; carry: string } {
193
+ const lines = (carry + chunk).split('\n');
194
+ const tail = lines.pop() ?? '';
195
+ for (const line of lines) {
196
+ const trimmed = line.trim();
197
+ if (!trimmed) continue;
198
+ try {
199
+ applyEvent(state, JSON.parse(trimmed) as StreamEvent);
200
+ } catch {
201
+ // Not a torn line but genuine garbage (a stray shell warning); ignore it.
202
+ }
203
+ }
204
+ return { state, carry: tail };
205
+ }
206
+
207
+ /** Replays a whole mirror file — how a run's state is rebuilt after the manager restarts. */
208
+ export function readRunFile(text: string): RunStreamState {
209
+ return readRunChunk(emptyRunState(), '', text).state;
210
+ }
211
+
212
+ /** One rendered row of the Agent log panel. */
213
+ export interface RunTimelineEntry {
214
+ kind: 'tool' | 'text' | 'retry' | 'result';
215
+ /** Tool name, when `kind` is `'tool'`. */
216
+ name?: string;
217
+ text: string;
218
+ /** True for a subagent's message, which the panel indents rather than hides. */
219
+ subagent?: boolean;
220
+ isError?: boolean;
221
+ }
222
+
223
+ function timelineFromEvent(event: StreamEvent): RunTimelineEntry[] {
224
+ if (event.type === 'system' && event.subtype === 'api_retry') {
225
+ return [
226
+ {
227
+ kind: 'retry',
228
+ text: `Retrying (${event.error ?? 'error'}), attempt ${event.attempt ?? 1}…`
229
+ }
230
+ ];
231
+ }
232
+ if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
233
+ const subagent = event.parent_tool_use_id != null;
234
+ const out: RunTimelineEntry[] = [];
235
+ for (const block of event.message.content) {
236
+ if (block.type === 'tool_use' && typeof block.name === 'string') {
237
+ const arg = summarizeToolInput(block.input);
238
+ out.push({
239
+ kind: 'tool',
240
+ name: block.name,
241
+ text: truncate(arg, TIMELINE_MAX),
242
+ subagent
243
+ });
244
+ } else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
245
+ out.push({ kind: 'text', text: truncate(block.text, TIMELINE_MAX), subagent });
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+ if (event.type === 'result') {
251
+ const isError = event.is_error === true || event.subtype !== 'success';
252
+ return [
253
+ {
254
+ kind: 'result',
255
+ // A failed subtype often carries an empty `result`, which would render a blank row.
256
+ text: truncate(event.result?.trim() || event.subtype || 'finished', TIMELINE_MAX),
257
+ isError
258
+ }
259
+ ];
260
+ }
261
+ return [];
262
+ }
263
+
264
+ /**
265
+ * Flattens a whole mirrored run into the rows the Agent log renders. Separate from `readRunChunk`
266
+ * because that one only keeps the *latest* of each field — a timeline needs every step.
267
+ */
268
+ export function parseRunTimeline(text: string): RunTimelineEntry[] {
269
+ const out: RunTimelineEntry[] = [];
270
+ for (const line of text.split('\n')) {
271
+ const trimmed = line.trim();
272
+ if (!trimmed) continue;
273
+ try {
274
+ for (const entry of timelineFromEvent(JSON.parse(trimmed) as StreamEvent)) {
275
+ // Claude's closing message arrives twice — once as the final assistant text block and
276
+ // again as the `result` event's text — so the result supersedes it rather than repeats it.
277
+ const prev = out.at(-1);
278
+ if (entry.kind === 'result' && prev?.kind === 'text' && prev.text === entry.text) out.pop();
279
+ out.push(entry);
280
+ }
281
+ } catch {
282
+ // A torn trailing line; it reappears whole once more bytes land.
283
+ }
284
+ }
285
+ return out;
286
+ }