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
@@ -22,6 +22,37 @@ function collector(): { stream: Writable; text: () => string } {
22
22
  const STDIN_ENV = '__CODEBAY_EXEC_STDIN';
23
23
  const STDIN_VAR = 'CODEBAY_STDIN';
24
24
 
25
+ /**
26
+ * Linux rejects any single argv/env string over 128 KiB (`MAX_ARG_STRLEN`), and the stdin carrier
27
+ * is one env entry — so this, minus headroom for the name, is the hard ceiling on any payload.
28
+ */
29
+ export const EXEC_STDIN_MAX_BYTES = 128 * 1024 - 1024;
30
+
31
+ /** The largest UTF-8 payload that still fits the carrier once base64 has inflated it by 4/3. */
32
+ export const EXEC_STDIN_MAX_BYTES_BASE64 = Math.floor((EXEC_STDIN_MAX_BYTES / 4) * 3);
33
+
34
+ /** Builds the exec target from an instance row; `container_id` must already be known non-null. */
35
+ export function execTargetFor(row: {
36
+ container_id: string | null;
37
+ remote_user: string | null;
38
+ }): ExecTarget {
39
+ return { containerId: row.container_id!, remoteUser: row.remote_user };
40
+ }
41
+
42
+ /** Everything after the last `marker`, or null when absent — login-shell profile noise precedes every script's own output. */
43
+ export function afterMarker(stdout: string, marker: string): string | null {
44
+ const at = stdout.lastIndexOf(marker);
45
+ return at === -1 ? null : stdout.slice(at + marker.length);
46
+ }
47
+
48
+ /** Just the marker's own line, untrimmed — an empty leading field is meaningful to some callers. */
49
+ export function markerLine(stdout: string, marker: string): string | null {
50
+ const rest = afterMarker(stdout, marker);
51
+ if (rest === null) return null;
52
+ const nl = rest.indexOf('\n');
53
+ return (nl === -1 ? rest : rest.slice(0, nl)).replace(/\r$/, '');
54
+ }
55
+
25
56
  /**
26
57
  * Real exec stdin is unusable on Bun — dockerode's hijack path needs an `upgrade` event
27
58
  * Bun never emits, and `openStdin` needs full-duplex client HTTP Bun also lacks.
@@ -31,16 +62,41 @@ function wrapWithStdin(script: string): string {
31
62
  return `${STDIN_VAR}="$${STDIN_ENV}"; unset ${STDIN_ENV}\n${script}`;
32
63
  }
33
64
 
65
+ /**
66
+ * Resolves false on timeout. A host-side backstop only: the container process keeps running, so a
67
+ * caller that needs the command itself killed must also wrap it in the container's own `timeout`.
68
+ */
69
+ function streamEnded(stream: NodeJS.ReadableStream, timeoutMs?: number): Promise<boolean> {
70
+ return new Promise((resolve, reject) => {
71
+ const timer = timeoutMs ? setTimeout(() => resolve(false), timeoutMs) : null;
72
+ const settle = (fn: () => void) => {
73
+ if (timer) clearTimeout(timer);
74
+ fn();
75
+ };
76
+ stream.on('end', () => settle(() => resolve(true)));
77
+ stream.on('error', (e) => settle(() => reject(e)));
78
+ });
79
+ }
80
+
34
81
  /**
35
82
  * The one place the `bash -lc` container-exec pattern lives; every injection goes through it.
36
83
  * `stdin` reaches the script as `$CODEBAY_STDIN`, and `args` as `$0`, `$1`, ….
37
84
  */
38
85
  export async function execInContainer(
39
86
  target: ExecTarget,
40
- opts: { script: string; stdin?: string; args?: string[]; capture?: boolean }
87
+ opts: { script: string; stdin?: string; args?: string[]; capture?: boolean; timeoutMs?: number }
41
88
  ): Promise<{ ok: boolean; stdout: string; error?: string }> {
42
89
  const user = target.remoteUser?.trim() || 'root';
43
90
  const hasStdin = opts.stdin !== undefined;
91
+ const stdinBytes = hasStdin ? Buffer.byteLength(opts.stdin!, 'utf8') : 0;
92
+ if (stdinBytes > EXEC_STDIN_MAX_BYTES) {
93
+ // The kernel would fail the exec with an opaque E2BIG; say what the limit actually is.
94
+ return {
95
+ ok: false,
96
+ stdout: '',
97
+ error: `payload of ${stdinBytes} bytes exceeds the ${EXEC_STDIN_MAX_BYTES}-byte limit a container exec can carry`
98
+ };
99
+ }
44
100
  try {
45
101
  const exec = await (await getDocker()).getContainer(target.containerId).exec({
46
102
  Cmd: [
@@ -60,10 +116,15 @@ export async function execInContainer(
60
116
  const out = collector();
61
117
  const err = collector();
62
118
  exec.modem.demuxStream(stream, out.stream, err.stream);
63
- await new Promise<void>((resolve, reject) => {
64
- stream.on('end', resolve);
65
- stream.on('error', reject);
66
- });
119
+ if (!(await streamEnded(stream, opts.timeoutMs))) {
120
+ // Otherwise the exec socket and its demux collectors live on for as long as the command does.
121
+ stream.destroy();
122
+ return {
123
+ ok: false,
124
+ stdout: opts.capture ? out.text().trim() : '',
125
+ error: `timed out after ${opts.timeoutMs}ms`
126
+ };
127
+ }
67
128
 
68
129
  const code = (await exec.inspect()).ExitCode ?? 1;
69
130
  const stdout = opts.capture ? out.text().trim() : '';
@@ -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,
@@ -18,6 +20,9 @@ import {
18
20
  deleteInstanceRow,
19
21
  getInstance,
20
22
  getOption,
23
+ getRun,
24
+ openRunFor,
25
+ openRuns,
21
26
  insertForward,
22
27
  insertInstance,
23
28
  listForwards,
@@ -38,6 +43,7 @@ import {
38
43
  stopContainer
39
44
  } from './docker.server.ts';
40
45
  import {
46
+ AGENT_RUN_MARKER,
41
47
  copyWorkspace,
42
48
  devcontainerCliAvailable,
43
49
  devcontainerUp,
@@ -67,12 +73,15 @@ import {
67
73
  syncHealthMonitors
68
74
  } from './health.server.ts';
69
75
  import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.server.ts';
76
+ import { currentRunSummaries, runSummary, setRunChangeHook, stopRun } from './agent-runs.server.ts';
70
77
  import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
71
78
  import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
79
+ import { uploadEnabled } from './uploads.server.ts';
72
80
  import type { ServerWebSocket } from 'bun';
73
81
  import {
74
82
  isInstanceFilter,
75
83
  normalizeMode,
84
+ type AgentRunSummary,
76
85
  type Instance,
77
86
  type InstanceFilter,
78
87
  type InstanceHealth,
@@ -186,6 +195,9 @@ export function subscribeLogs(id: string, onChunk: (chunk: string) => void): ()
186
195
  export type StreamEvent =
187
196
  | { type: 'instances'; data: Instance[] }
188
197
  | { type: 'health'; data: { id: string; health: InstanceHealth } }
198
+ // Its own frame rather than a list reconcile: run progress ticks every few seconds, and
199
+ // `reconcileInstances` runs a `docker inspect` per instance.
200
+ | { type: 'run'; data: AgentRunSummary }
189
201
  | { type: 'preflight'; data: { docker: boolean; cli: boolean } }
190
202
  // `name` is the chosen sprite, or null for the default box logo. Lets the header swap live.
191
203
  | { type: 'pet'; data: { name: string | null } }
@@ -194,7 +206,9 @@ export type StreamEvent =
194
206
  // The global default editor surface, so the picker's toggle follows a settings change.
195
207
  | { type: 'default-mode'; data: { mode: InstanceMode } }
196
208
  // The colour scheme, so a change in the settings popup repaints the window behind it.
197
- | { 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 } };
198
212
 
199
213
  interface StreamHub {
200
214
  sockets: Set<ServerWebSocket<unknown>>;
@@ -240,6 +254,11 @@ export function broadcastHealth(id: string, health: InstanceHealth): void {
240
254
  broadcast({ type: 'health', data: { id, health } });
241
255
  }
242
256
 
257
+ setRunChangeHook((runId) => {
258
+ const row = getRun(runId);
259
+ if (row) broadcast({ type: 'run', data: runSummary(row) });
260
+ });
261
+
243
262
  /** Called from the settings action so every open dashboard swaps its header logo without a reload. */
244
263
  export function broadcastPet(name: string | null): void {
245
264
  broadcast({ type: 'pet', data: { name } });
@@ -263,6 +282,11 @@ export function broadcastTheme(value: Theme): void {
263
282
  broadcast({ type: 'theme', data: { value } });
264
283
  }
265
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
+
266
290
  async function reconcileInstances(force = false): Promise<void> {
267
291
  const list = await listInstances();
268
292
  const listJson = JSON.stringify(list);
@@ -301,6 +325,7 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
301
325
  // Seed everything up front so a fresh or reconnected client is correct without waiting a tick.
302
326
  void listInstances().then((list) => sendTo(ws, { type: 'instances', data: list }));
303
327
  for (const snap of currentHealthSnapshots()) sendTo(ws, { type: 'health', data: snap });
328
+ for (const run of currentRunSummaries()) sendTo(ws, { type: 'run', data: run });
304
329
  void backgroundPreflight().then((pf) => sendTo(ws, { type: 'preflight', data: pf }));
305
330
  // Keeps a reconnecting client correct even if the pet changed while it was away.
306
331
  sendTo(ws, { type: 'pet', data: { name: getOption('pet') || null } });
@@ -312,6 +337,8 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
312
337
  });
313
338
  // Same, for the default mode the picker's toggle seeds from.
314
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() } });
315
342
  }
316
343
 
317
344
  export function streamClose(ws: ServerWebSocket<unknown>): void {
@@ -381,7 +408,18 @@ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<v
381
408
  } else {
382
409
  appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
383
410
  const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
384
- await copyWorkspace(row.source_path, row.workspace_path, ignore);
411
+ const { dereferencedSymlinks } = await copyWorkspace(
412
+ row.source_path,
413
+ row.workspace_path,
414
+ ignore
415
+ );
416
+ if (dereferencedSymlinks > 0) {
417
+ appendLog(
418
+ row.id,
419
+ `⚠ ${dereferencedSymlinks} symlink(s) copied as regular files — Windows needs Developer Mode (or admin rights) to recreate symlinks, so edits to them no longer write through to their targets
420
+ `
421
+ );
422
+ }
385
423
  appendLog(row.id, `⏱ copy: ${elapsed(sourceStart)}\n`);
386
424
  }
387
425
  await seedDeclaredPorts(row);
@@ -467,6 +505,10 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
467
505
  }));
468
506
  appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
469
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 });
470
512
  const { imageSource, configPath, overrideConfigPath } = await writeOverrideConfig(
471
513
  row.workspace_path,
472
514
  row.host_port,
@@ -474,7 +516,8 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
474
516
  defaultImage,
475
517
  row.mode,
476
518
  getClaudePermissionMode(),
477
- customEnvVarsConfig()?.vars ?? []
519
+ customEnvVarsConfig()?.vars ?? [],
520
+ mirrorDir
478
521
  );
479
522
  updateInstance(row.id, { image_source: imageSource });
480
523
 
@@ -680,6 +723,8 @@ export async function listInstances(): Promise<Instance[]> {
680
723
  for (const { id, health } of currentHealthSnapshots()) {
681
724
  openPorts.set(id, new Set(health.openPorts));
682
725
  }
726
+ // One query for every sandbox rather than one per row, since this runs on every reconcile tick.
727
+ const runs = new Map(openRuns().map((run) => [run.instance_id, runSummary(run)]));
683
728
  const forwards = new Map<
684
729
  string,
685
730
  { container_port: number; host_port: number; open: boolean }[]
@@ -700,7 +745,8 @@ export async function listInstances(): Promise<Instance[]> {
700
745
  avatar: effectiveAvatarName(row),
701
746
  git_branch: branches.get(row.id) ?? null,
702
747
  attention: getAttention(row.id),
703
- forwarded_ports: forwards.get(row.id) ?? []
748
+ forwarded_ports: forwards.get(row.id) ?? [],
749
+ active_run: runs.get(row.id) ?? null
704
750
  }));
705
751
  }
706
752
 
@@ -771,6 +817,9 @@ export function rebuildInstance(id: string, opts: { noCache?: boolean } = {}): I
771
817
  const row = getInstance(id);
772
818
  if (!row) throw new Error('Instance not found');
773
819
  if (inFlight.has(id)) return row; // a build is already in flight
820
+ // The rebuild replaces the container the run's detached process lives in, so it can never
821
+ // report back — fail it now rather than leaving the poller to time it out.
822
+ void cancelActiveRun(id, 'the sandbox was rebuilt');
774
823
  // Claimed before the broadcast below, which reconciles synchronously: an unclaimed `creating`
775
824
  // row reads as orphaned, and reconcile would flip it straight back off `creating`.
776
825
  inFlight.add(id);
@@ -807,7 +856,9 @@ function quote(value: string): string {
807
856
  export async function relaunchSurface(row: InstanceRow): Promise<void> {
808
857
  if (!row.container_id) return;
809
858
 
810
- const steps: string[] = [];
859
+ // No run survives a container restart, but its marker does when the container was stopped out
860
+ // from under it — left in place, every launcher would keep opening a plain shell instead of Claude.
861
+ const steps: string[] = [`rm -f "$HOME/${AGENT_RUN_MARKER}"`];
811
862
  // The folderOpen task's run-once gate is meant to span one container run, but the marker file
812
863
  // outlives it — left in place, a restarted IDE container never reopens the Claude terminal.
813
864
  if (row.mode !== 'terminal') steps.push(`rm -f "$HOME/${TERMINAL_LAUNCHED_MARKER}"`);
@@ -845,9 +896,16 @@ export async function startInstance(id: string): Promise<InstanceRow> {
845
896
  return getInstance(id)!;
846
897
  }
847
898
 
899
+ /** Runs are per-container, so any transition that replaces or stops one has to settle them first. */
900
+ async function cancelActiveRun(id: string, reason: string): Promise<void> {
901
+ const open = openRunFor(id);
902
+ if (open) await stopRun(open.id, reason).catch(() => undefined);
903
+ }
904
+
848
905
  export async function stopInstance(id: string): Promise<InstanceRow> {
849
906
  const row = getInstance(id);
850
907
  if (!row) throw new Error('Instance not found');
908
+ await cancelActiveRun(id, 'the sandbox was stopped');
851
909
  // A later delete can't exec into a stopped container, so this is the last chance to mirror.
852
910
  stopLogCapture(id);
853
911
  if (row.container_id) await runCapturePass(row).catch(() => undefined);
@@ -864,10 +922,11 @@ export async function stopInstance(id: string): Promise<InstanceRow> {
864
922
  export async function deleteInstance(id: string): Promise<void> {
865
923
  const row = getInstance(id);
866
924
  if (!row) return;
925
+ await cancelActiveRun(id, 'the sandbox was deleted');
867
926
  stopHealthMonitor(id);
868
927
  // Mirror the tail of the session before the transcripts die with the container; the periodic
869
- // chain is stopped first so it can't race this last pass. Retention outlives the instance —
870
- // <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.
871
930
  stopLogCapture(id);
872
931
  if (row.container_id) {
873
932
  await runCapturePass(row).catch(() => undefined);
@@ -7,8 +7,8 @@ import {
7
7
  statSync,
8
8
  writeFileSync
9
9
  } from 'node:fs';
10
- import { join } from 'node:path';
11
- import { LOGS_DIR } from './config.server.ts';
10
+ import { dirname, join } from 'node:path';
11
+ import { claudeMirrorDir, LOGS_DIR } from './config.server.ts';
12
12
  import { getInstance, type InstanceRow } from './db.server.ts';
13
13
  import { isRunning } from './docker.server.ts';
14
14
  import { execInContainer } from './exec.server.ts';
@@ -17,58 +17,83 @@ import { execInContainer } from './exec.server.ts';
17
17
  const CAPTURE_MS = 10_000;
18
18
 
19
19
  const MANIFEST_MARKER = '__CODEBAY_MANIFEST__';
20
- const FETCH_MARKER = '__CODEBAY_FETCH__';
20
+ export const FETCH_MARKER = '__CODEBAY_FETCH__';
21
21
  const FILE_MARKER = '__CODEBAY_FILE__';
22
22
  const END_MARKER = '__CODEBAY_END__';
23
23
 
24
24
  export interface ManifestEntry {
25
25
  path: string;
26
+ /** Path relative to the container's Claude config dir, e.g. `projects/-workspaces-foo/x.jsonl`. */
27
+ relPath: string;
26
28
  size: number;
27
29
  }
28
30
 
29
31
  interface FetchJob {
30
32
  path: string;
33
+ relPath: string;
31
34
  hostFileName: string;
32
35
  /** 1-indexed byte to `tail -c +N` from; 1 re-pulls the whole file. */
33
36
  startByte: number;
34
- mode: 'append' | 'rollover';
35
37
  }
36
38
 
37
39
  /**
38
- * Lists the transcript + history files with their byte sizes. `find`/`wc -c` (not GNU `-printf`)
39
- * so it works on busybox containers too; the marker line lets the parser skip login-shell noise.
40
+ * Lists the transcript + history files with their byte sizes, led by the config dir itself so the
41
+ * parser can derive each file's layout-preserving relative path rather than guess at the encoding.
42
+ * `find`/`wc -c` (not GNU `-printf`) so it works on busybox containers too; the marker line lets
43
+ * the parser skip login-shell noise.
40
44
  */
41
- function manifestScript(): string {
45
+ export function manifestScript(): string {
42
46
  return (
43
47
  `h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
44
48
  `printf '%s\\n' '${MANIFEST_MARKER}'; ` +
49
+ `printf '%s\\n' "$cfg"; ` +
45
50
  `{ find "$cfg" -maxdepth 1 -type f -name 'history.jsonl' 2>/dev/null; ` +
46
51
  `find "$cfg/projects" -type f -name '*.jsonl' 2>/dev/null; } | ` +
47
52
  `while IFS= read -r f; do printf '%s\\t%s\\n' "$f" "$(wc -c < "$f" | tr -d ' ')"; done; true`
48
53
  );
49
54
  }
50
55
 
56
+ /**
57
+ * One framed base64 block of `pathExpr` from `offsetExpr` to EOF (or `maxBytes`) — the shape
58
+ * `parseFetchBlocks` reads. Both exprs are spliced into a shell script, so callers pass shell
59
+ * exprs, not values.
60
+ */
61
+ export function tailBlockScript(pathExpr: string, offsetExpr: string, maxBytes?: number): string {
62
+ const cap = maxBytes ? ` | head -c ${maxBytes}` : '';
63
+ return (
64
+ `printf '${FILE_MARKER}\\t%s\\n' "${pathExpr}"; ` +
65
+ `tail -c "+${offsetExpr}" "${pathExpr}" 2>/dev/null${cap} | base64; ` +
66
+ `printf '\\n${END_MARKER}\\n'`
67
+ );
68
+ }
69
+
51
70
  /** Emits, per requested file, a framed block of base64 bytes from `startByte` to EOF. */
52
71
  function fetchScript(): string {
53
72
  return (
54
73
  `printf '%s\\n' '${FETCH_MARKER}'; ` +
55
74
  `while [ "$#" -ge 2 ]; do p="$1"; o="$2"; shift 2; ` +
56
- `printf '${FILE_MARKER}\\t%s\\n' "$p"; ` +
57
- `tail -c "+$o" "$p" 2>/dev/null | base64; ` +
58
- `printf '\\n${END_MARKER}\\n'; done`
75
+ `${tailBlockScript('$p', '$o')}; done`
59
76
  );
60
77
  }
61
78
 
62
79
  export function parseManifest(stdout: string): ManifestEntry[] {
63
80
  const at = stdout.indexOf(MANIFEST_MARKER);
64
81
  if (at === -1) return [];
82
+ const [, cfgLine, ...lines] = stdout.slice(at + MANIFEST_MARKER.length).split('\n');
83
+ // Trailing slashes would leave the relative path leading with one, breaking the host join.
84
+ const cfg = (cfgLine ?? '').trim().replace(/\/+$/, '');
85
+ if (!cfg) return [];
65
86
  const out: ManifestEntry[] = [];
66
- for (const line of stdout.slice(at + MANIFEST_MARKER.length).split('\n')) {
87
+ for (const line of lines) {
67
88
  const tab = line.indexOf('\t');
68
89
  if (tab === -1) continue;
69
90
  const path = line.slice(0, tab);
70
91
  const size = Number(line.slice(tab + 1).trim());
71
- if (path && Number.isFinite(size)) out.push({ path, size });
92
+ if (!path || !Number.isFinite(size) || !path.startsWith(`${cfg}/`)) continue;
93
+ const relPath = path.slice(cfg.length + 1);
94
+ // `find` can't emit `..` today, but this gets joined onto a host dir, so don't rely on that.
95
+ if (relPath.split('/').includes('..')) continue;
96
+ out.push({ path, relPath, size });
72
97
  }
73
98
  return out;
74
99
  }
@@ -82,19 +107,22 @@ export function hostFileNameFor(instanceId: string, containerPath: string): stri
82
107
  return `history-${instanceId}.jsonl`;
83
108
  }
84
109
 
85
- /** Diffs container sizes against the host mirrors: append new bytes, or re-pull a file that shrank. */
110
+ /**
111
+ * Diffs container sizes against the host mirrors: append new bytes, or re-pull a file that shrank.
112
+ * Sized against the layout-preserving mirror, not the flat archive, because that one tracks the
113
+ * container 1:1 — it's what gets mounted back in, so it must stay a byte-exact copy.
114
+ */
86
115
  export function planFetch(
87
116
  instanceId: string,
88
117
  manifest: ManifestEntry[],
89
- hostSize: (hostFileName: string) => number
118
+ hostSize: (relPath: string) => number
90
119
  ): FetchJob[] {
91
120
  const jobs: FetchJob[] = [];
92
- for (const { path, size } of manifest) {
121
+ for (const { path, relPath, size } of manifest) {
93
122
  const hostFileName = hostFileNameFor(instanceId, path);
94
- const existing = hostSize(hostFileName);
123
+ const existing = hostSize(relPath);
95
124
  if (size === existing) continue;
96
- if (size < existing) jobs.push({ path, hostFileName, startByte: 1, mode: 'rollover' });
97
- else jobs.push({ path, hostFileName, startByte: existing + 1, mode: 'append' });
125
+ jobs.push({ path, relPath, hostFileName, startByte: size < existing ? 1 : existing + 1 });
98
126
  }
99
127
  return jobs;
100
128
  }
@@ -118,7 +146,7 @@ export function parseFetchBlocks(stdout: string): { path: string; base64: string
118
146
  return blocks;
119
147
  }
120
148
 
121
- function sizeOnDisk(path: string): number {
149
+ export function sizeOnDisk(path: string): number {
122
150
  try {
123
151
  return statSync(path).size;
124
152
  } catch {
@@ -166,6 +194,7 @@ function updateIndex(logsDir: string, row: InstanceRow): void {
166
194
  interface CaptureDeps {
167
195
  exec?: typeof execInContainer;
168
196
  logsDir?: string;
197
+ mirrorDir?: string;
169
198
  }
170
199
 
171
200
  /**
@@ -176,6 +205,7 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
176
205
  if (!row.container_id) return false;
177
206
  const exec = deps.exec ?? execInContainer;
178
207
  const logsDir = deps.logsDir ?? LOGS_DIR;
208
+ const mirrorDir = deps.mirrorDir ?? claudeMirrorDir(row.id);
179
209
  const target = { containerId: row.container_id, remoteUser: row.remote_user };
180
210
 
181
211
  const manifestRes = await exec(target, { script: manifestScript(), capture: true });
@@ -183,7 +213,7 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
183
213
  const manifest = parseManifest(manifestRes.stdout);
184
214
 
185
215
  mkdirSync(logsDir, { recursive: true });
186
- const jobs = planFetch(row.id, manifest, (name) => sizeOnDisk(join(logsDir, name)));
216
+ const jobs = planFetch(row.id, manifest, (rel) => sizeOnDisk(join(mirrorDir, rel)));
187
217
 
188
218
  if (jobs.length) {
189
219
  const args = ['fetch'];
@@ -195,9 +225,19 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
195
225
  const job = byPath.get(block.path);
196
226
  if (!job) continue;
197
227
  const bytes = Buffer.from(block.base64, 'base64');
198
- const dest = join(logsDir, job.hostFileName);
199
- if (job.mode === 'rollover') rollOver(dest);
200
- appendFileSync(dest, bytes);
228
+ const archive = join(logsDir, job.hostFileName);
229
+ const mirror = join(mirrorDir, job.relPath);
230
+ mkdirSync(dirname(mirror), { recursive: true });
231
+ // The archive keeps every generation; the mirror only ever holds what the container
232
+ // holds, since it gets mounted back in and a stale byte would resurface as history.
233
+ // Any whole-file pull rolls, since a pre-mirror instance's archive would otherwise double.
234
+ if (job.startByte === 1) {
235
+ rollOver(archive);
236
+ writeFileSync(mirror, bytes);
237
+ } else {
238
+ appendFileSync(mirror, bytes);
239
+ }
240
+ appendFileSync(archive, bytes);
201
241
  }
202
242
  }
203
243
  }
@@ -0,0 +1,52 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { getOption, setOption } from './db.server.ts';
3
+ import { timingSafeEqualStr } from './crypto.server.ts';
4
+
5
+ /** The MCP endpoint's path; `auth.server.ts` and the CSRF filter both key off this exact value. */
6
+ export const MCP_PATH = '/mcp';
7
+
8
+ export const MCP_ENABLED_KEY = 'mcp_enabled';
9
+ export const MCP_TOKEN_KEY = 'mcp_token';
10
+
11
+ /**
12
+ * Off until someone opts in. The token gates the endpoint, but an install that never wanted an
13
+ * agent-facing control plane shouldn't expose one at all.
14
+ */
15
+ export function mcpEnabled(): boolean {
16
+ return getOption(MCP_ENABLED_KEY) === '1';
17
+ }
18
+
19
+ export function setMcpEnabled(enabled: boolean): void {
20
+ setOption(MCP_ENABLED_KEY, enabled ? '1' : '0');
21
+ if (enabled) getMcpToken();
22
+ }
23
+
24
+ function mint(): string {
25
+ return `cb_${randomBytes(32).toString('base64url')}`;
26
+ }
27
+
28
+ /**
29
+ * Minted on first read and persisted, so the settings page and the route can't disagree. Unlike
30
+ * every other secret here this one is shown to the user in plaintext — copying it into an MCP
31
+ * client is the whole point of it existing.
32
+ */
33
+ export function getMcpToken(): string {
34
+ const existing = getOption(MCP_TOKEN_KEY);
35
+ if (existing) return existing;
36
+ const token = mint();
37
+ setOption(MCP_TOKEN_KEY, token);
38
+ return token;
39
+ }
40
+
41
+ export function regenerateMcpToken(): string {
42
+ const token = mint();
43
+ setOption(MCP_TOKEN_KEY, token);
44
+ return token;
45
+ }
46
+
47
+ /** Constant-time, like the bridge token check, so a wrong guess leaks nothing by timing. */
48
+ export function mcpAuthOk(request: Request): boolean {
49
+ const header = request.headers.get('authorization');
50
+ if (!header?.startsWith('Bearer ')) return false;
51
+ return timingSafeEqualStr(header.slice(7).trim(), getMcpToken());
52
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * OSC 52 is how a program inside the container asks the *outer* terminal to put text on the host
3
+ * clipboard — what tmux's `set-clipboard on` emits on copy. xterm.js ships no handler for it, so
4
+ * every such write was silently dropped and copy out of an instance was impossible.
5
+ */
6
+ export function decodeOsc52(data: string): string | null {
7
+ const sep = data.indexOf(';');
8
+ if (sep === -1) return null;
9
+ const payload = data.slice(sep + 1).replace(/\s+/g, '');
10
+ // `?` is a read request; answering it would hand the host clipboard to the container.
11
+ if (payload === '' || payload === '?') return null;
12
+ try {
13
+ const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
14
+ return new TextDecoder().decode(bytes);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
@@ -1,17 +1,30 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, sep } from 'node:path';
5
5
  import { getOption, setOption } from './db.server';
6
6
  import { findDevcontainerConfig } from './devcontainer.server.ts';
7
+ import { DRIVES_ROOT } from './drives.ts';
7
8
  import type { BrowseResult, DirEntry } from '../types.ts';
8
9
 
9
10
  const LAST_VIEWED_FOLDER = 'last_viewed_folder';
10
11
 
12
+ const isWindows = process.platform === 'win32';
13
+
11
14
  function hasDevcontainer(dir: string): boolean {
12
15
  return findDevcontainerConfig(dir) !== null;
13
16
  }
14
17
 
18
+ /** Probed rather than enumerated, so listing the drives needs no shell-out. */
19
+ function driveRoots(): DirEntry[] {
20
+ const roots: DirEntry[] = [];
21
+ for (let i = 0; i < 26; i++) {
22
+ const path = `${String.fromCharCode(65 + i)}:${sep}`;
23
+ if (existsSync(path)) roots.push({ name: path, path, hasDevcontainer: hasDevcontainer(path) });
24
+ }
25
+ return roots;
26
+ }
27
+
15
28
  /** Persists every folder it lists, so an argument-less call resumes where the user left off. */
16
29
  export async function browse(path?: string): Promise<BrowseResult> {
17
30
  let target: string;
@@ -22,6 +35,12 @@ export async function browse(path?: string): Promise<BrowseResult> {
22
35
  target = saved && existsSync(saved) ? saved : homedir();
23
36
  }
24
37
 
38
+ // Deliberately not persisted as the last-viewed folder: it is not a real directory, so a
39
+ // resume would fall through to the home dir anyway.
40
+ if (isWindows && target === DRIVES_ROOT) {
41
+ return { path: DRIVES_ROOT, hasDevcontainer: false, parent: null, entries: driveRoots() };
42
+ }
43
+
25
44
  const info = await stat(target);
26
45
  if (!info.isDirectory()) throw new Error(`Not a folder: ${target}`);
27
46
 
@@ -40,7 +59,9 @@ export async function browse(path?: string): Promise<BrowseResult> {
40
59
  return {
41
60
  path: target,
42
61
  hasDevcontainer: hasDevcontainer(target),
43
- parent: parent === target ? null : parent,
62
+ // A Windows drive root is its own dirname, which would otherwise strand the user on
63
+ // whichever drive their home directory sits on.
64
+ parent: parent === target ? (isWindows ? DRIVES_ROOT : null) : parent,
44
65
  entries
45
66
  };
46
67
  }