codebay 0.15.0 → 0.16.1

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 (91) hide show
  1. package/.mochi/manifest.json +490 -429
  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-5pg0be5f.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-ds9yn9cq.js → chunk-sxcr9gzp.js} +1 -1
  18. package/.mochi/svelte-client/{chunk-a2hty99b.js → chunk-tss2473w.js} +1 -1
  19. package/.mochi/svelte-compile/{App-68f9a9fq.server.js → App-8mpgrxza.server.js} +489 -295
  20. package/.mochi/svelte-compile/{Avatars-vyh3rwxx.server.js → Avatars-y5nf6q25.server.js} +102 -86
  21. package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
  22. package/.mochi/svelte-compile/{Instance-yfw7s5mp.server.js → Instance-3sbzyp0g.server.js} +186 -118
  23. package/.mochi/svelte-compile/{Settings-0b5t9kw9.server.js → Settings-ysvh5vhh.server.js} +739 -628
  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-0y3wrae9.js → chunk-jq7jj8jd.js} +10 -6
  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-g0eravvj.js → chunk-zk4a9zms.js} +25 -17
  32. package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
  33. package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
  34. package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
  35. package/README.md +13 -4
  36. package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
  37. package/package.json +9 -9
  38. package/src/__fixtures__/IdeBarStrip.svelte +41 -0
  39. package/src/avatars/dog.ts +1 -1
  40. package/src/avatars/lightning.ts +8 -9
  41. package/src/avatars/rocket.ts +4 -5
  42. package/src/components/AppShell.svelte +101 -1
  43. package/src/components/DropZone.svelte +77 -0
  44. package/src/components/HealthBox.svelte +6 -5
  45. package/src/components/IdeBar.svelte +222 -76
  46. package/src/components/InstanceView.svelte +75 -2
  47. package/src/components/SettingsView.svelte +87 -0
  48. package/src/components/TerminalPane.svelte +54 -0
  49. package/src/components/UiShowcase.svelte +19 -1
  50. package/src/container-injections/claude-code-install.ts +1 -1
  51. package/src/container-injections/claude-history.ts +52 -0
  52. package/src/container-injections/tmux.ts +6 -2
  53. package/src/index.ts +3 -0
  54. package/src/lib/agent-run-stream.ts +41 -1
  55. package/src/lib/agent-runs.server.ts +14 -3
  56. package/src/lib/config.server.ts +16 -1
  57. package/src/lib/db.server.ts +6 -2
  58. package/src/lib/devcontainer.server.ts +37 -4
  59. package/src/lib/injections.server.ts +5 -1
  60. package/src/lib/instances.server.ts +21 -4
  61. package/src/lib/log-capture.server.ts +46 -18
  62. package/src/lib/osc52.ts +18 -0
  63. package/src/lib/spawn.server.ts +7 -2
  64. package/src/lib/upload.ts +32 -0
  65. package/src/lib/uploads.server.ts +164 -0
  66. package/src/mcp/server.server.ts +4 -1
  67. package/src/mcp/tools.server.ts +178 -12
  68. package/src/pages/Instance.svelte +6 -2
  69. package/src/pages/Settings.svelte +3 -0
  70. package/src/routes.ts +16 -2
  71. package/src/types.ts +2 -0
  72. package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +0 -1
  73. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +0 -2
  74. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +0 -14
  75. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +0 -3
  76. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +0 -44
  77. package/.mochi/svelte-client/chunk-0mstgbtr.js +0 -2
  78. package/.mochi/svelte-client/chunk-0q9g0kpg.js +0 -1
  79. package/.mochi/svelte-client/chunk-3fwjzw1r.js +0 -1
  80. package/.mochi/svelte-client/chunk-fn03gxer.js +0 -4
  81. package/.mochi/svelte-client/chunk-jhgq7f65.js +0 -2
  82. package/.mochi/svelte-client/chunk-p5a07kx1.js +0 -1
  83. package/.mochi/svelte-client/chunk-p5wpyhsf.js +0 -2
  84. package/.mochi/svelte-client/chunk-t2kfvtb6.js +0 -2
  85. package/.mochi/svelte-client/chunk-v3wg9mdy.js +0 -1
  86. package/.mochi/svelte-compile/chunk-2qzc07gt.js +0 -34
  87. package/.mochi/svelte-compile/chunk-ef3hvegw.js +0 -21
  88. package/.mochi/svelte-compile/chunk-f9td8ej2.js +0 -24
  89. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
  90. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
  91. package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
@@ -4,6 +4,7 @@
4
4
  import type { Terminal } from '@xterm/xterm';
5
5
  import type { FitAddon } from '@xterm/addon-fit';
6
6
  import RotateCw from '@lucide/svelte/icons/rotate-cw';
7
+ import { decodeOsc52 } from '../lib/osc52.ts';
7
8
 
8
9
  let {
9
10
  id,
@@ -118,6 +119,40 @@
118
119
  }
119
120
  }
120
121
 
122
+ /**
123
+ * The Clipboard API is the only path to the *host* clipboard, but it needs a secure context and
124
+ * (outside a user gesture) can still reject — hence the legacy textarea fallback behind it.
125
+ */
126
+ async function toClipboard(text: string) {
127
+ if (!text) return;
128
+ try {
129
+ await navigator.clipboard.writeText(text);
130
+ return;
131
+ } catch {
132
+ /* no Clipboard API, or a write the browser refused — fall through */
133
+ }
134
+ const ta = document.createElement('textarea');
135
+ ta.value = text;
136
+ ta.setAttribute('aria-hidden', 'true');
137
+ ta.style.position = 'fixed';
138
+ ta.style.opacity = '0';
139
+ document.body.appendChild(ta);
140
+ ta.select();
141
+ try {
142
+ document.execCommand('copy');
143
+ } catch {
144
+ /* nothing left to try */
145
+ }
146
+ ta.remove();
147
+ if (active && focus) term?.focus();
148
+ }
149
+
150
+ /** A finished drag is both the moment the selection exists and the gesture the clipboard needs. */
151
+ function copySelection() {
152
+ const sel = term?.getSelection();
153
+ if (sel) void toClipboard(sel);
154
+ }
155
+
121
156
  function fitSafe() {
122
157
  // A hidden pane (display:none) has no size, so fitting there would throw or size to 0.
123
158
  if (el?.clientWidth > 0 && el?.clientHeight > 0) {
@@ -165,6 +200,21 @@
165
200
  term.open(el);
166
201
  applyTheme();
167
202
  fitSafe();
203
+ // Without this, tmux's `set-clipboard on` writes land in a handler xterm doesn't have.
204
+ term.parser.registerOscHandler(52, (data) => {
205
+ const text = decodeOsc52(data);
206
+ if (text !== null) void toClipboard(text);
207
+ return true;
208
+ });
209
+ // Chrome keeps Ctrl+Shift+C for devtools, so a browser terminal needs its own copy key.
210
+ term.attachCustomKeyEventHandler((e) => {
211
+ if (e.type !== 'keydown' || !term?.hasSelection()) return true;
212
+ const isCopy =
213
+ e.ctrlKey && (e.key === 'Insert' || (e.altKey && e.key.toLowerCase() === 'c'));
214
+ if (!isCopy) return true;
215
+ copySelection();
216
+ return false;
217
+ });
168
218
  term.onData((d) => send(CMD_INPUT + d));
169
219
  term.onResize(({ cols, rows }) => send(CMD_RESIZE + JSON.stringify({ columns: cols, rows })));
170
220
  // The theme cookie flips data-theme on <html>; re-derive xterm's colors when it does.
@@ -182,8 +232,12 @@
182
232
  const sizeObs = new ResizeObserver(() => fitSafe());
183
233
  sizeObs.observe(el);
184
234
 
235
+ // Shift+drag selects in xterm even while tmux owns the mouse; copy it before a repaint clears it.
236
+ el.addEventListener('mouseup', copySelection);
237
+
185
238
  return () => {
186
239
  disposed = true;
240
+ el.removeEventListener('mouseup', copySelection);
187
241
  sizeObs.disconnect();
188
242
  themeObs?.disconnect();
189
243
  scheme.removeEventListener('change', onScheme);
@@ -14,6 +14,7 @@
14
14
  import SettingsCog from './SettingsCog.svelte';
15
15
  import TopBar from './TopBar.svelte';
16
16
  import IdeLoader from './IdeLoader.svelte';
17
+ import DropZone from './DropZone.svelte';
17
18
  import HealthBox from './HealthBox.svelte';
18
19
  import BranchBox from './BranchBox.svelte';
19
20
  import PortsBox from './PortsBox.svelte';
@@ -213,6 +214,8 @@
213
214
  let tabEditingName = $state('');
214
215
  // Renames are applied for real, so the inline editor is inspectable end to end.
215
216
  let tabRenames = $state<Record<string, string>>({});
217
+ // No container behind the demo, so a stopped tab just stays disabled.
218
+ let tabStopping = $state<string[]>([]);
216
219
 
217
220
  const demoTabs = $derived(
218
221
  Array.from({ length: tabCount }, (_, i): Instance => {
@@ -463,6 +466,15 @@
463
466
  {/snippet}
464
467
  </ComponentDemo>
465
468
 
469
+ <ComponentDemo
470
+ title="DropZone"
471
+ note="Shown while a file drag is over an instance; the page decides when."
472
+ >
473
+ <div class="loader-stage">
474
+ <DropZone />
475
+ </div>
476
+ </ComponentDemo>
477
+
466
478
  <ComponentDemo title="HealthBox">
467
479
  <HealthBox
468
480
  health={demoHealth}
@@ -555,6 +567,12 @@
555
567
  bind:editingName={tabEditingName}
556
568
  onreload={() => toast('Reload editor (demo)')}
557
569
  onselect={(id) => (tabActive = demoTabs.findIndex((t) => t.id === id))}
570
+ stopping={tabStopping}
571
+ onstop={(id) => {
572
+ tabStopping = [...tabStopping, id];
573
+ toast(`Stop ${id} (demo)`);
574
+ }}
575
+ ondropfiles={(id, files) => toast(`Drop ${files.length} file(s) on ${id} (demo)`)}
558
576
  onstartrename={(inst) => {
559
577
  tabEditingId = inst.id;
560
578
  tabEditingName = inst.name;
@@ -677,7 +695,7 @@
677
695
  .showcase :global(.fields input[type='checkbox']) {
678
696
  accent-color: var(--ink);
679
697
  }
680
- /* IdeLoader is an inset:0 overlay, so give it a sized, positioned stage. */
698
+ /* IdeLoader and DropZone are inset:0 overlays, so give them a sized, positioned stage. */
681
699
  .loader-stage {
682
700
  position: relative;
683
701
  width: 100%;
@@ -49,7 +49,7 @@ const CHECK_SCRIPT = 'command -v claude >/dev/null 2>&1 && echo 1 || echo 0';
49
49
  */
50
50
  export const claudeCodeInstall: Injection = {
51
51
  id: 'claude-code-install',
52
- label: 'Claude Code',
52
+ label: 'Claude Code installed',
53
53
  modes: ['terminal'],
54
54
 
55
55
  async apply(target, log) {
@@ -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
+ };
@@ -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
@@ -6,6 +6,7 @@ import { PROXY_PREFIX } from './lib/proxy.server.ts';
6
6
  import { resumeRuns } from './lib/agent-runs.server.ts';
7
7
  import { warnIfBuildStale } from './lib/build-freshness.server.ts';
8
8
  import { LOCK_STALE_MS, acquireDataDirLock } from './lib/data-dir-lock.server.ts';
9
+ import { UPLOAD_MAX_BYTES } from './lib/uploads.server.ts';
9
10
  import {
10
11
  APP_VERSION,
11
12
  BASIC_AUTH_PASSWORD,
@@ -56,6 +57,8 @@ await Mochi.serve({
56
57
  hostname: HOST,
57
58
  // Bun defaults to 10s and aborts slower form POSTs mid-flight; passed through to Bun.serve.
58
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,
59
62
  development: process.env.MODE === 'development',
60
63
  htmlShell: './src/shell.html',
61
64
  handle: sequence(basicAuth, themeHandle),
@@ -3,6 +3,15 @@
3
3
  * Node/Bun APIs so it can be unit-tested on its own.
4
4
  */
5
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
+
6
15
  /** Everything the run row needs, accumulated across chunks. */
7
16
  export interface RunStreamState {
8
17
  sessionId: string | null;
@@ -15,6 +24,14 @@ export interface RunStreamState {
15
24
  result: string | null;
16
25
  /** Serialized `structured_output`, present only when the caller passed a `--json-schema`. */
17
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;
18
35
  isError: boolean;
19
36
  /** True once the terminal `result` event has been seen, whatever its subtype. */
20
37
  resultSeen: boolean;
@@ -30,6 +47,10 @@ export function emptyRunState(): RunStreamState {
30
47
  durationMs: null,
31
48
  result: null,
32
49
  structuredOutput: null,
50
+ lastAssistantText: null,
51
+ lastStructuredOutputInput: null,
52
+ rejectedStructuredOutput: null,
53
+ structuredOutputFailed: false,
33
54
  isError: false,
34
55
  resultSeen: false
35
56
  };
@@ -123,6 +144,17 @@ function applyEvent(state: RunStreamState, event: StreamEvent): void {
123
144
  if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
124
145
  // Subagent chatter would otherwise drown out what the main loop is doing.
125
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
+ }
126
158
  const activity = activityFromBlocks(event.message.content);
127
159
  if (activity) state.lastActivity = activity;
128
160
  return;
@@ -135,6 +167,13 @@ function applyEvent(state: RunStreamState, event: StreamEvent): void {
135
167
  if (event.structured_output !== undefined && event.structured_output !== null) {
136
168
  state.structuredOutput = JSON.stringify(event.structured_output);
137
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
+ }
138
177
  if (typeof event.num_turns === 'number') state.numTurns = event.num_turns;
139
178
  if (typeof event.total_cost_usd === 'number') state.costUsd = event.total_cost_usd;
140
179
  if (typeof event.duration_ms === 'number') state.durationMs = event.duration_ms;
@@ -213,7 +252,8 @@ function timelineFromEvent(event: StreamEvent): RunTimelineEntry[] {
213
252
  return [
214
253
  {
215
254
  kind: 'result',
216
- text: truncate(event.result ?? event.subtype ?? 'finished', TIMELINE_MAX),
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),
217
257
  isError
218
258
  }
219
259
  ];
@@ -40,6 +40,7 @@ import {
40
40
  type ClaudePermissionMode
41
41
  } from '../types.ts';
42
42
  import {
43
+ STRUCTURED_OUTPUT_FAILED_ERROR,
43
44
  emptyRunState,
44
45
  parseRunTimeline,
45
46
  readRunChunk,
@@ -312,6 +313,9 @@ export function runDetail(row: AgentRunRow) {
312
313
  structured_output: row.structured_output
313
314
  ? (JSON.parse(row.structured_output) as unknown)
314
315
  : null,
316
+ rejected_structured_output: row.rejected_structured_output
317
+ ? (JSON.parse(row.rejected_structured_output) as unknown)
318
+ : null,
315
319
  is_error: row.is_error === 1,
316
320
  error: row.error,
317
321
  exit_code: row.exit_code,
@@ -537,15 +541,21 @@ function poll(row: AgentRunRow, instance: InstanceRow): Promise<void> {
537
541
  is_error: failed ? 1 : 0,
538
542
  result: state.result,
539
543
  structured_output: state.structuredOutput,
544
+ rejected_structured_output: state.rejectedStructuredOutput,
540
545
  duration_ms: state.durationMs ?? (row.started_at ? Date.now() - row.started_at : null),
541
546
  num_turns: state.numTurns,
542
547
  cost_usd: state.costUsd,
543
548
  session_id: state.sessionId,
544
549
  model: state.model,
545
550
  last_activity: state.lastActivity,
546
- // The stream's own result text is the better message when claude exited cleanly but
547
- // reported a failure; stderr only carries anything when it crashed outright.
548
- error: failed ? stderr || state.result || `claude exited ${exitCode}` : null
551
+ // A schema failure gets its own marker so a caller can tell "answered but the payload
552
+ // never validated" (result holds that answer) from "claude crashed"; otherwise the
553
+ // stream's own result text beats stderr, which only fills in on an outright crash.
554
+ error: failed
555
+ ? state.structuredOutputFailed
556
+ ? STRUCTURED_OUTPUT_FAILED_ERROR
557
+ : stderr || state.result || `claude exited ${exitCode}`
558
+ : null
549
559
  });
550
560
  return;
551
561
  }
@@ -614,6 +624,7 @@ export function startRun(
614
624
  options: JSON.stringify(opts),
615
625
  result: null,
616
626
  structured_output: null,
627
+ rejected_structured_output: null,
617
628
  last_activity: null,
618
629
  is_error: 0,
619
630
  exit_code: null,
@@ -48,12 +48,18 @@ export const APP_VERSION: string = (() => {
48
48
  }
49
49
  })();
50
50
 
51
- /** Per-instance working copies live here: <INSTANCES_DIR>/<id>/workspace. */
51
+ /** Per-instance state lives here: <INSTANCES_DIR>/<id>/<repo-or-folder-name> is the working copy. */
52
52
  export const INSTANCES_DIR = join(DATA_DIR, 'instances');
53
53
 
54
54
  /** Extracted Claude Code logs, in one flat folder that outlives the instances they came from. */
55
55
  export const LOGS_DIR = join(DATA_DIR, 'logs');
56
56
 
57
+ /**
58
+ * The container's Claude config dir mirrored at its original layout, so it can be mounted back in
59
+ * after a rebuild. Sibling of the working copy, so it dies with the instance — unlike LOGS_DIR.
60
+ */
61
+ export const claudeMirrorDir = (id: string): string => join(INSTANCES_DIR, id, 'claude');
62
+
57
63
  export const DB_PATH = join(DATA_DIR, 'app.sqlite');
58
64
 
59
65
  export const PORT_BASE = 8001;
@@ -144,6 +150,15 @@ export function binShim(binDir: string, name: string): string | null {
144
150
  return null;
145
151
  }
146
152
 
153
+ /**
154
+ * The CLI's `node` shebang resolves to Bun under Bun, and Bun loads `bunfig.toml` from cwd — so a
155
+ * workspace cwd would run the repo's `preload` on the host; not tmpdir, since that's world-writable.
156
+ */
157
+ export function devcontainerCwd(): string {
158
+ mkdirSync(DATA_DIR, { recursive: true });
159
+ return DATA_DIR;
160
+ }
161
+
147
162
  /**
148
163
  * An argv rather than a path, because the `devcontainer.js` fallback leads with a
149
164
  * `#!/usr/bin/env node` shebang that Windows doesn't honour — there it only runs when handed to
@@ -230,6 +230,8 @@ export interface AgentRunRow {
230
230
  result: string | null;
231
231
  /** JSON string, only when the caller passed a `--json-schema`. */
232
232
  structured_output: string | null;
233
+ /** JSON string of the last schema-rejected `StructuredOutput` input, so a caller can salvage it. */
234
+ rejected_structured_output: string | null;
233
235
  /** Latest tool call or assistant line, so a poller can see progress mid-run. */
234
236
  last_activity: string | null;
235
237
  is_error: number;
@@ -249,8 +251,8 @@ const OPEN_RUN_STATUSES = "('queued', 'running')";
249
251
  export function insertRun(row: AgentRunRow): void {
250
252
  db.query(
251
253
  `INSERT INTO agent_runs
252
- (id, instance_id, prompt, status, session_id, model, resume_session_id, options, result, structured_output, last_activity, is_error, exit_code, cost_usd, duration_ms, num_turns, error, created_at, started_at, finished_at)
253
- VALUES ($id, $instance_id, $prompt, $status, $session_id, $model, $resume_session_id, $options, $result, $structured_output, $last_activity, $is_error, $exit_code, $cost_usd, $duration_ms, $num_turns, $error, $created_at, $started_at, $finished_at)`
254
+ (id, instance_id, prompt, status, session_id, model, resume_session_id, options, result, structured_output, rejected_structured_output, last_activity, is_error, exit_code, cost_usd, duration_ms, num_turns, error, created_at, started_at, finished_at)
255
+ VALUES ($id, $instance_id, $prompt, $status, $session_id, $model, $resume_session_id, $options, $result, $structured_output, $rejected_structured_output, $last_activity, $is_error, $exit_code, $cost_usd, $duration_ms, $num_turns, $error, $created_at, $started_at, $finished_at)`
254
256
  ).run({
255
257
  $id: row.id,
256
258
  $instance_id: row.instance_id,
@@ -262,6 +264,7 @@ export function insertRun(row: AgentRunRow): void {
262
264
  $options: row.options,
263
265
  $result: row.result,
264
266
  $structured_output: row.structured_output,
267
+ $rejected_structured_output: row.rejected_structured_output,
265
268
  $last_activity: row.last_activity,
266
269
  $is_error: row.is_error,
267
270
  $exit_code: row.exit_code,
@@ -307,6 +310,7 @@ const UPDATABLE_RUN_COLUMNS = [
307
310
  'model',
308
311
  'result',
309
312
  'structured_output',
313
+ 'rejected_structured_output',
310
314
  'last_activity',
311
315
  'is_error',
312
316
  'exit_code',
@@ -15,6 +15,7 @@ import {
15
15
  PUBLISH_HOST,
16
16
  TTYD_PORT,
17
17
  devcontainerBin,
18
+ devcontainerCwd,
18
19
  dockerEnv
19
20
  } from './config.server.ts';
20
21
  import { spawnCapture } from './spawn.server.ts';
@@ -117,7 +118,10 @@ const MANAGER_GIT_EXCLUDES = [
117
118
  '/.devcontainer/codebay-ttyd/',
118
119
  '/.devcontainer/codebay-claude/',
119
120
  '/.devcontainer/codebay-terminal.sh',
120
- '/.vscode/tasks.json'
121
+ '/.vscode/tasks.json',
122
+ // Self-excluding on top of this (see uploads.server.ts), so an already-running instance whose
123
+ // info/exclude predates the upload feature still hides the folder until its next rebuild.
124
+ '/codebay-inbox/'
121
125
  ];
122
126
 
123
127
  /** Bound the manager-owned block so a rebuild can replace it without touching the user's own lines. */
@@ -378,7 +382,8 @@ let cliProbeWarned = false;
378
382
 
379
383
  export async function devcontainerCliAvailable(): Promise<boolean> {
380
384
  const argv = devcontainerBin();
381
- const available = (await spawnCapture([...argv, '--version'])) !== null;
385
+ const available =
386
+ (await spawnCapture([...argv, '--version'], { cwd: devcontainerCwd() })) !== null;
382
387
  if (!available && !cliProbeWarned) {
383
388
  console.warn(`⚠ devcontainer CLI probe failed — could not run: ${argv.join(' ')}`);
384
389
  }
@@ -536,6 +541,7 @@ type DevcontainerConfig = {
536
541
  postStartCommand?: unknown;
537
542
  runArgs?: string[];
538
543
  containerEnv?: Record<string, string>;
544
+ mounts?: unknown[];
539
545
  [key: string]: unknown;
540
546
  };
541
547
 
@@ -594,7 +600,8 @@ export async function writeOverrideConfig(
594
600
  defaultImage: string = DEFAULT_IMAGE,
595
601
  mode: InstanceMode = 'ide',
596
602
  permissionMode: ClaudePermissionMode = 'default',
597
- envVars: { name: string; value: string }[] = []
603
+ envVars: { name: string; value: string }[] = [],
604
+ claudeRestoreDir: string | null = null
598
605
  ): Promise<{ imageSource: string; configPath: string | null; overrideConfigPath: string }> {
599
606
  const isTerminal = mode === 'terminal';
600
607
  const canonical = findDevcontainerConfig(workspaceDir);
@@ -681,6 +688,19 @@ export async function writeOverrideConfig(
681
688
  };
682
689
  }
683
690
 
691
+ // Read-only so the container can't rewrite its own history; claude-history copies out of it.
692
+ // Our entry is re-rendered rather than merged (like appPort) so it can never accumulate, while
693
+ // the project's own mounts — string or object form — pass through untouched.
694
+ if (claudeRestoreDir) {
695
+ const existingMounts = Array.isArray(config.mounts) ? config.mounts : [];
696
+ config.mounts = [
697
+ ...existingMounts.filter((m) => !isClaudeRestoreMount(m)),
698
+ // Docker Desktop takes a Windows path with forward slashes; a backslash would have to
699
+ // survive both JSON escaping and the CLI's comma-separated --mount parser.
700
+ `source=${claudeRestoreDir.replaceAll('\\', '/')},target=${CLAUDE_RESTORE_MOUNT},type=bind,readonly`
701
+ ];
702
+ }
703
+
684
704
  await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
685
705
  await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
686
706
 
@@ -707,6 +727,19 @@ export async function writeOverrideConfig(
707
727
  return { imageSource, configPath: canonical, overrideConfigPath: target };
708
728
  }
709
729
 
730
+ /** Where the instance's host-side Claude mirror is mounted, for `claude-history` to restore from. */
731
+ export const CLAUDE_RESTORE_MOUNT = '/codebay/claude-restore';
732
+
733
+ /** Matches our own entry in either devcontainer.json mount form, so the rewrite stays idempotent. */
734
+ function isClaudeRestoreMount(mount: unknown): boolean {
735
+ if (typeof mount === 'string') return mount.split(',').includes(`target=${CLAUDE_RESTORE_MOUNT}`);
736
+ return (
737
+ typeof mount === 'object' &&
738
+ mount !== null &&
739
+ (mount as { target?: unknown }).target === CLAUDE_RESTORE_MOUNT
740
+ );
741
+ }
742
+
710
743
  /** `codebay-tmux` has been injected into every config this manager has ever written. */
711
744
  const OVERRIDE_FINGERPRINT = TMUX_FEATURE_DIR;
712
745
 
@@ -1001,7 +1034,7 @@ export async function devcontainerUp(
1001
1034
  opts: UpOptions = {}
1002
1035
  ): Promise<UpResult> {
1003
1036
  const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
1004
- cwd: workspaceDir,
1037
+ cwd: devcontainerCwd(),
1005
1038
  stdout: 'pipe',
1006
1039
  stderr: 'pipe',
1007
1040
  env: devcontainerUpEnv()
@@ -8,6 +8,7 @@ import { gitIdentity } from '../container-injections/git-identity.ts';
8
8
  import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
9
9
  import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
10
10
  import { claudeCodeSkills } from '../container-injections/claude-code-skills.ts';
11
+ import { claudeHistory } from '../container-injections/claude-history.ts';
11
12
  import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
12
13
  import { codeServerDark } from '../container-injections/code-server-dark.ts';
13
14
  import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
@@ -70,7 +71,10 @@ function buildStages(claudeInjection: Injection): Injection[][] {
70
71
  claudeInjection,
71
72
  claudeCodeSkills,
72
73
  claudeCodeIdeExtension,
73
- claudeEffortLevel
74
+ claudeEffortLevel,
75
+ // Only writes `projects/**` + `history.jsonl`, which no other injection touches, and
76
+ // lands before the injections-done sentinel releases the launcher's `claude`.
77
+ claudeHistory
74
78
  ],
75
79
  // git-identity needs stage 1's safe.directory; ttyd shares the apt/dpkg lock with tmux and
76
80
  // the /usr/local/bin symlink with claude-code-install, so it trails both. code-server-dark
@@ -1,6 +1,8 @@
1
+ import { mkdirSync } from 'node:fs';
1
2
  import { rm, stat } from 'node:fs/promises';
2
3
  import { basename, join } from 'node:path';
3
4
  import {
5
+ claudeMirrorDir,
4
6
  CODE_SERVER_PORT,
5
7
  DATA_DIR,
6
8
  DEFAULT_COPY_IGNORE,
@@ -74,6 +76,7 @@ import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.se
74
76
  import { currentRunSummaries, runSummary, setRunChangeHook, stopRun } from './agent-runs.server.ts';
75
77
  import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
76
78
  import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
79
+ import { uploadEnabled } from './uploads.server.ts';
77
80
  import type { ServerWebSocket } from 'bun';
78
81
  import {
79
82
  isInstanceFilter,
@@ -203,7 +206,9 @@ export type StreamEvent =
203
206
  // The global default editor surface, so the picker's toggle follows a settings change.
204
207
  | { type: 'default-mode'; data: { mode: InstanceMode } }
205
208
  // The colour scheme, so a change in the settings popup repaints the window behind it.
206
- | { type: 'theme'; data: { value: Theme } };
209
+ | { type: 'theme'; data: { value: Theme } }
210
+ // Arms/disarms the drop zones on every open page when the setting flips.
211
+ | { type: 'upload-enabled'; data: { enabled: boolean } };
207
212
 
208
213
  interface StreamHub {
209
214
  sockets: Set<ServerWebSocket<unknown>>;
@@ -277,6 +282,11 @@ export function broadcastTheme(value: Theme): void {
277
282
  broadcast({ type: 'theme', data: { value } });
278
283
  }
279
284
 
285
+ /** Settings opens in its own popup, so every open dashboard/IDE tab needs the new toggle pushed. */
286
+ export function broadcastUploadEnabled(enabled: boolean): void {
287
+ broadcast({ type: 'upload-enabled', data: { enabled } });
288
+ }
289
+
280
290
  async function reconcileInstances(force = false): Promise<void> {
281
291
  const list = await listInstances();
282
292
  const listJson = JSON.stringify(list);
@@ -327,6 +337,8 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
327
337
  });
328
338
  // Same, for the default mode the picker's toggle seeds from.
329
339
  sendTo(ws, { type: 'default-mode', data: { mode: getDefaultMode() } });
340
+ // Same, for the upload toggle, so a reconnecting client's drop zones match the current setting.
341
+ sendTo(ws, { type: 'upload-enabled', data: { enabled: uploadEnabled() } });
330
342
  }
331
343
 
332
344
  export function streamClose(ws: ServerWebSocket<unknown>): void {
@@ -493,6 +505,10 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
493
505
  }));
494
506
  appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
495
507
  const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
508
+ // Mounted read-only for claude-history to restore from, so it has to exist before the boot —
509
+ // and it has to be mounted *after* the drain above, or a rebuild would restore a stale mirror.
510
+ const mirrorDir = claudeMirrorDir(row.id);
511
+ mkdirSync(mirrorDir, { recursive: true });
496
512
  const { imageSource, configPath, overrideConfigPath } = await writeOverrideConfig(
497
513
  row.workspace_path,
498
514
  row.host_port,
@@ -500,7 +516,8 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
500
516
  defaultImage,
501
517
  row.mode,
502
518
  getClaudePermissionMode(),
503
- customEnvVarsConfig()?.vars ?? []
519
+ customEnvVarsConfig()?.vars ?? [],
520
+ mirrorDir
504
521
  );
505
522
  updateInstance(row.id, { image_source: imageSource });
506
523
 
@@ -908,8 +925,8 @@ export async function deleteInstance(id: string): Promise<void> {
908
925
  await cancelActiveRun(id, 'the sandbox was deleted');
909
926
  stopHealthMonitor(id);
910
927
  // Mirror the tail of the session before the transcripts die with the container; the periodic
911
- // chain is stopped first so it can't race this last pass. Retention outlives the instance —
912
- // <LOGS_DIR>/*-<id>.jsonl is deliberately left on disk.
928
+ // chain is stopped first so it can't race this last pass. The flat <LOGS_DIR>/*-<id>.jsonl
929
+ // archive is deliberately left on disk; the per-instance mirror goes with the instance below.
913
930
  stopLogCapture(id);
914
931
  if (row.container_id) {
915
932
  await runCapturePass(row).catch(() => undefined);