codebay 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.mochi/manifest.json +216 -149
  2. package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +2 -0
  4. package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js → _hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js} +1 -1
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +1 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +38 -0
  7. package/.mochi/svelte-client/{chunk-2j95sv83.js → chunk-0fjx3z3r.js} +1 -1
  8. package/.mochi/svelte-client/chunk-8xvr02wh.js +2 -0
  9. package/.mochi/svelte-client/{chunk-m497q88s.js → chunk-9143wy3j.js} +2 -2
  10. package/.mochi/svelte-client/{chunk-v65n8th6.js → chunk-bn731rda.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-a2psjmrm.js → chunk-j72r8pkk.js} +2 -2
  12. package/.mochi/svelte-client/chunk-k37wj1jm.js +2 -0
  13. package/.mochi/svelte-client/{chunk-mpv3qx28.js → chunk-p20tkpde.js} +1 -1
  14. package/.mochi/svelte-client/chunk-pc0fm37c.js +2 -0
  15. package/.mochi/svelte-client/{chunk-6rbs0mhx.js → chunk-t0ap7q89.js} +1 -1
  16. package/.mochi/svelte-client/{chunk-rs41by6w.js → chunk-wvtkwfsh.js} +1 -1
  17. package/.mochi/svelte-compile/{App-7vjcp38v.server.js → App-59cdj86f.server.js} +167 -103
  18. package/.mochi/svelte-compile/{Avatars-e2cfc95t.server.js → Avatars-fmagfyj2.server.js} +1 -1
  19. package/.mochi/svelte-compile/{Instance-h78n2dsv.server.js → Instance-s1gqwzmx.server.js} +16 -10
  20. package/.mochi/svelte-compile/{Settings-tav2asn7.server.js → Settings-k447mt0x.server.js} +540 -88
  21. package/.mochi/svelte-compile/{chunk-brjv9pyd.js → chunk-3t7v4hk0.js} +1 -7
  22. package/.mochi/svelte-compile/chunk-gkjgybb2.js +15 -0
  23. package/.mochi/svelte-compile/{chunk-m8ygpyhr.js → chunk-th2dbqvc.js} +20 -32
  24. package/.mochi/svelte-css/Button-oharop2xz6am.css +1 -0
  25. package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +1 -0
  26. package/.mochi/svelte-css/{InstanceView-3vzow3ttxzlh3.css → InstanceView-1pd7j16bd2b16.css} +1 -1
  27. package/.mochi/svelte-css/SettingsView-n3oy75uonzp2.css +1 -0
  28. package/.mochi/svelte-css/{TopBar-2q87li6yt8ids.css → TopBar-2y76zxwehrfos.css} +1 -1
  29. package/migrations/0007_instance_avatar.sql +1 -0
  30. package/package.json +2 -2
  31. package/src/avatars/diamond.ts +5 -6
  32. package/src/avatars/index.ts +1 -16
  33. package/src/avatars/pick.server.ts +25 -0
  34. package/src/avatars/whale.ts +7 -8
  35. package/src/components/AppShell.svelte +29 -4
  36. package/src/components/Avatar.svelte +8 -8
  37. package/src/components/Button.svelte +22 -1
  38. package/src/components/DashboardView.svelte +14 -2
  39. package/src/components/FolderBrowser.svelte +6 -2
  40. package/src/components/IdeBar.svelte +56 -13
  41. package/src/components/IdeLoader.svelte +15 -3
  42. package/src/components/InstanceCard.svelte +6 -1
  43. package/src/components/InstanceView.svelte +33 -15
  44. package/src/components/SettingsView.svelte +548 -1
  45. package/src/components/TopBar.svelte +37 -10
  46. package/src/components/UiShowcase.svelte +5 -10
  47. package/src/container-injections/claude-aliases.ts +1 -1
  48. package/src/container-injections/claude-code-custom.ts +2 -1
  49. package/src/container-injections/claude-code-ide-extension.ts +5 -0
  50. package/src/container-injections/claude-code-install.ts +67 -0
  51. package/src/container-injections/claude-code-models.ts +2 -1
  52. package/src/container-injections/claude-code-update.ts +62 -1
  53. package/src/container-injections/claude-effort-level.ts +32 -0
  54. package/src/container-injections/claude-permission-mode.ts +40 -0
  55. package/src/container-injections/custom-env-vars.ts +82 -0
  56. package/src/container-injections/host-env-vars.ts +2 -1
  57. package/src/lib/config.server.ts +3 -0
  58. package/src/lib/db.server.ts +8 -3
  59. package/src/lib/devcontainer.server.ts +182 -47
  60. package/src/lib/health.server.ts +55 -11
  61. package/src/lib/injections.server.ts +46 -36
  62. package/src/lib/instances.server.ts +185 -19
  63. package/src/lib/log-capture.server.ts +262 -0
  64. package/src/lib/ports.server.ts +31 -0
  65. package/src/lib/proxy.server.ts +21 -76
  66. package/src/pages/Settings.svelte +22 -1
  67. package/src/routes.ts +98 -2
  68. package/src/types.ts +36 -0
  69. package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +0 -1
  70. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +0 -2
  71. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +0 -1
  72. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +0 -29
  73. package/.mochi/svelte-client/chunk-7wkvn2vx.js +0 -2
  74. package/.mochi/svelte-client/chunk-m4fzsd5b.js +0 -2
  75. package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +0 -1
  76. package/.mochi/svelte-css/IdeBar-10xodudcx57qr.css +0 -1
  77. package/.mochi/svelte-css/SettingsView-2qfauz68axro1.css +0 -1
  78. package/src/container-injections/claude-skip-permissions.ts +0 -28
@@ -41,17 +41,32 @@ import {
41
41
  devcontainerCliAvailable,
42
42
  devcontainerUp,
43
43
  INJECTIONS_DONE_FILE,
44
+ launchCommandFor,
44
45
  readDeclaredContainerPorts,
46
+ TERMINAL_LAUNCHED_MARKER,
45
47
  writeOverrideConfig
46
48
  } from './devcontainer.server.ts';
47
49
  import { writeContainerFile } from './container-files.server.ts';
50
+ import { execInContainer } from './exec.server.ts';
48
51
  import { clearAttention, getAttention } from './bridge.server.ts';
49
52
  import { proxyPathFor } from './proxy.server.ts';
50
- import { resolveInjections } from './injections.server.ts';
53
+ import { resolveInjectionStages } from './injections.server.ts';
51
54
  import { cloneRepo, readGitBranch } from './git.server.ts';
52
55
  import { isRepoUrl, parseRepoUrl } from './repo-url.ts';
53
- import { currentHealthSnapshots, stopHealthMonitor, syncHealthMonitors } from './health.server.ts';
54
- import { pickFreePort } from './ports.server.ts';
56
+ import { getClaudePermissionMode } from '../container-injections/claude-permission-mode.ts';
57
+ import {
58
+ customEnvVarsConfig,
59
+ customEnvVarValues
60
+ } from '../container-injections/custom-env-vars.ts';
61
+ import {
62
+ currentHealthSnapshots,
63
+ stopHealthMonitor,
64
+ surfaceAccessible,
65
+ syncHealthMonitors
66
+ } from './health.server.ts';
67
+ import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.server.ts';
68
+ import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
69
+ import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
55
70
  import type { ServerWebSocket } from 'bun';
56
71
  import {
57
72
  isInstanceFilter,
@@ -61,13 +76,17 @@ import {
61
76
  type InstanceHealth
62
77
  } from '../types.ts';
63
78
 
79
+ /** The sprite a row shows: its persisted choice, or the id hash for rows created before avatars were stored. */
80
+ const effectiveAvatarName = (row: InstanceRow): string => row.avatar ?? pickAvatar(row.id).name;
81
+
64
82
  /**
65
- * `bridge_token` authenticates the no-Basic-Auth `/api/bridge/` endpoint, so it
66
- * must never reach the browser — every row handed to a route goes through here.
83
+ * `bridge_token` authenticates the no-Basic-Auth `/api/bridge/` endpoint, so it must never reach the
84
+ * browser — every row handed to a route goes through here. Also resolves the avatar name so the
85
+ * client renders by name (`findAvatar`) and never needs a hash function of its own.
67
86
  */
68
87
  export function sanitizeInstance(row: InstanceRow): Omit<InstanceRow, 'bridge_token'> {
69
88
  const { bridge_token: _token, ...rest } = row;
70
- return rest;
89
+ return { ...rest, avatar: effectiveAvatarName(row) };
71
90
  }
72
91
 
73
92
  interface LiveState {
@@ -87,7 +106,36 @@ function live(id: string): LiveState {
87
106
  return state;
88
107
  }
89
108
 
109
+ /**
110
+ * Cached so the redaction on every log chunk doesn't re-read the DB. Rebuilt lazily after the
111
+ * settings action invalidates it; values shorter than 4 chars are skipped so a trivially short
112
+ * secret can't blank out unrelated log text, and longest-first avoids a short value masking a
113
+ * substring of a longer one.
114
+ */
115
+ let secretValuesCache: string[] | null = null;
116
+
117
+ function getSecretValues(): string[] {
118
+ if (secretValuesCache === null) {
119
+ secretValuesCache = customEnvVarValues()
120
+ .filter((v) => v.length >= 4)
121
+ .sort((a, b) => b.length - a.length);
122
+ }
123
+ return secretValuesCache;
124
+ }
125
+
126
+ /** Call after the custom-env-vars setting changes so newly-added values redact from the next boot. */
127
+ export function invalidateSecretValues(): void {
128
+ secretValuesCache = null;
129
+ }
130
+
131
+ /** Exported for tests; the boot flow reaches it only through `appendLog`. */
132
+ export function redactSecrets(text: string): string {
133
+ for (const value of getSecretValues()) text = text.split(value).join('••••');
134
+ return text;
135
+ }
136
+
90
137
  function appendLog(id: string, chunk: string): void {
138
+ chunk = redactSecrets(chunk);
91
139
  const state = live(id);
92
140
  state.logs.push(chunk);
93
141
  if (state.logs.length > 2000) state.logs.splice(0, state.logs.length - 2000);
@@ -255,7 +303,10 @@ async function allocatePort(): Promise<number> {
255
303
  for (const [port, reservedAt] of reservedPorts) {
256
304
  if (dbPorts.has(port) || now - reservedAt > RESERVATION_TTL_MS) reservedPorts.delete(port);
257
305
  }
258
- const port = pickFreePort([dbPorts, dockerPorts, new Set(reservedPorts.keys())]);
306
+ const port = await pickBindablePort(
307
+ [dbPorts, dockerPorts, new Set(reservedPorts.keys())],
308
+ isHostPortBindable
309
+ );
259
310
  reservedPorts.set(port, now);
260
311
  return port;
261
312
  }
@@ -270,6 +321,10 @@ async function assertDir(path: string): Promise<void> {
270
321
  if (!info.isDirectory()) throw new Error(`Not a folder: ${path}`);
271
322
  }
272
323
 
324
+ function elapsed(startMs: number): string {
325
+ return `${((Date.now() - startMs) / 1000).toFixed(1)}s`;
326
+ }
327
+
273
328
  function failInstance(id: string, err: unknown): void {
274
329
  const message = (err as Error).message;
275
330
  updateInstance(id, { status: 'error', error: message });
@@ -279,15 +334,18 @@ function failInstance(id: string, err: unknown): void {
279
334
 
280
335
  async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<void> {
281
336
  try {
337
+ const sourceStart = Date.now();
282
338
  if (isRepoUrl(row.source_path)) {
283
339
  appendLog(row.id, `Cloning ${row.source_path} → ${row.workspace_path}\n`);
284
340
  await cloneRepo(row.source_path, row.workspace_path, (chunk) => appendLog(row.id, chunk), {
285
341
  branch: opts.branch
286
342
  });
343
+ appendLog(row.id, `⏱ clone: ${elapsed(sourceStart)}\n`);
287
344
  } else {
288
345
  appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
289
346
  const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
290
347
  await copyWorkspace(row.source_path, row.workspace_path, ignore);
348
+ appendLog(row.id, `⏱ copy: ${elapsed(sourceStart)}\n`);
291
349
  }
292
350
  await seedDeclaredPorts(row);
293
351
  } catch (err) {
@@ -314,22 +372,52 @@ async function seedDeclaredPorts(row: InstanceRow): Promise<void> {
314
372
  }
315
373
  }
316
374
 
375
+ const surfaceLabel = (mode: InstanceMode) =>
376
+ mode === 'terminal' ? 'ttyd terminal' : 'code-server';
377
+
378
+ /**
379
+ * Moves an instance off a host port that something outside Docker has taken over, which otherwise
380
+ * strands it forever — the container publishes fine but nothing reaches it from the host. Requires
381
+ * the port to be *both* unbindable and dead, because a rebuild doesn't stop the old container
382
+ * first: a healthy instance's port is always unbindable here, and its surface still answers.
383
+ */
384
+ async function rescueHijackedPort(row: InstanceRow): Promise<void> {
385
+ if (await isHostPortBindable(row.host_port)) return;
386
+ if (await surfaceAccessible(row.host_port)) return;
387
+ const stale = row.host_port;
388
+ const port = await allocatePort();
389
+ updateInstance(row.id, { host_port: port });
390
+ row.host_port = port;
391
+ appendLog(
392
+ row.id,
393
+ `⚠ Host port ${stale} is held by another process and isn't reachable — moving to ${port}\n`
394
+ );
395
+ }
396
+
317
397
  /** Never re-copies the workspace, so in-container edits survive a rebuild. */
318
398
  async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Promise<void> {
319
399
  try {
400
+ // On a rebuild `devcontainer up --remove-existing-container` discards the old container's
401
+ // ~/.claude, so drain it first; a first boot has no container_id and skips straight past.
402
+ if (row.container_id) {
403
+ stopLogCapture(row.id);
404
+ await runCapturePass(row).catch(() => undefined);
405
+ }
406
+ await rescueHijackedPort(row);
320
407
  const forwards = listForwards(row.id).map((f) => ({
321
408
  container_port: f.container_port,
322
409
  host_port: f.host_port
323
410
  }));
324
- const surface = row.mode === 'terminal' ? 'ttyd terminal' : 'code-server';
325
- appendLog(row.id, `Injecting ${surface} (host port ${row.host_port})\n`);
411
+ appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
326
412
  const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
327
413
  const { imageSource } = await writeOverrideConfig(
328
414
  row.workspace_path,
329
415
  row.host_port,
330
416
  forwards,
331
417
  defaultImage,
332
- row.mode
418
+ row.mode,
419
+ getClaudePermissionMode(),
420
+ customEnvVarsConfig()?.vars ?? []
333
421
  );
334
422
  updateInstance(row.id, { image_source: imageSource });
335
423
 
@@ -337,9 +425,11 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
337
425
  if (noCache) appendLog(row.id, `Building without cache (--build-no-cache)\n`);
338
426
 
339
427
  appendLog(row.id, `Starting devcontainer…\n`);
428
+ const upStart = Date.now();
340
429
  const result = await devcontainerUp(row.workspace_path, (chunk) => appendLog(row.id, chunk), {
341
430
  noCache
342
431
  });
432
+ appendLog(row.id, `⏱ devcontainer up: ${elapsed(upStart)}\n`);
343
433
 
344
434
  if (result.outcome !== 'success' || !result.containerId) {
345
435
  throw new Error(
@@ -362,25 +452,41 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
362
452
  row.remote_user = result.remoteUser ?? null;
363
453
  row.status = 'running';
364
454
 
455
+ // Start the health monitor now so the IDE mounts as soon as code-server answers,
456
+ // instead of after every injection plus a full reconcile/health poll cycle.
457
+ triggerReconcile();
458
+
365
459
  const target = {
366
460
  containerId: result.containerId,
367
461
  remoteUser: result.remoteUser,
368
462
  instance: row
369
463
  };
370
- // Swallow per-injection failures so one bad injection can't abort the rest of provisioning.
371
- for (const injection of resolveInjections(row.mode)) {
372
- try {
373
- await injection.apply(target, (msg) => appendLog(row.id, msg));
374
- } catch (err) {
375
- appendLog(row.id, `⚠ ${injection.label} injection failed: ${(err as Error).message}\n`);
376
- }
464
+ // Stages parallelize independent injections; per-injection failures are still swallowed
465
+ // so one bad injection can't abort the rest of provisioning.
466
+ const injectionsStart = Date.now();
467
+ for (const stage of resolveInjectionStages(row.mode)) {
468
+ await Promise.all(
469
+ stage.map(async (injection) => {
470
+ try {
471
+ await injection.apply(target, (msg) => appendLog(row.id, msg));
472
+ } catch (err) {
473
+ appendLog(row.id, `⚠ ${injection.label} injection failed: ${(err as Error).message}\n`);
474
+ }
475
+ })
476
+ );
377
477
  }
478
+ appendLog(row.id, `⏱ injections: ${elapsed(injectionsStart)}\n`);
378
479
 
379
480
  // Unblocks the terminal launcher's wait; written even after ⚠s — its timeout is the fallback.
380
481
  await writeContainerFile(target, { dir: '$HOME', name: INJECTIONS_DONE_FILE }, 'done\n').catch(
381
482
  () => undefined
382
483
  );
383
484
 
485
+ // postStartCommand ran before the injections above, and its launcher bails when the binary
486
+ // is missing — so a container whose build-time ttyd install failed got it too late to be
487
+ // started. No-ops (one accessibility probe) whenever postStart already brought the port up.
488
+ await relaunchSurface(row);
489
+
384
490
  appendLog(row.id, `\n✓ Instance running — open it via the proxy at ${proxyPathFor(row.id)}\n`);
385
491
  triggerReconcile();
386
492
  } catch (err) {
@@ -437,6 +543,8 @@ export async function createInstance(
437
543
  bridge_token: crypto.randomUUID().replace(/-/g, ''),
438
544
  remote_user: null,
439
545
  image_source: null,
546
+ // Kept synchronous through insertInstance so two concurrent creations can't claim the same free sprite.
547
+ avatar: pickUniqueAvatar(id, allInstances().map(effectiveAvatarName)).name,
440
548
  mode: opts.mode ?? getDefaultMode(),
441
549
  terminal_split: 0
442
550
  };
@@ -468,6 +576,7 @@ export async function listInstances(): Promise<Instance[]> {
468
576
  );
469
577
  // Reconcile is the only place that knows the current running set.
470
578
  syncHealthMonitors(rows);
579
+ syncLogCapture(rows);
471
580
  // Distinguishes a forward that's live from one still pending a rebuild.
472
581
  const openPorts = new Map<string, Set<number>>();
473
582
  for (const { id, health } of currentHealthSnapshots()) {
@@ -488,6 +597,9 @@ export async function listInstances(): Promise<Instance[]> {
488
597
  }
489
598
  return rows.map((row) => ({
490
599
  ...sanitizeInstance(row),
600
+ // Resolve legacy null rows to their hashed sprite here so the client only sees an
601
+ // unresolvable name when a persisted sprite was actually removed from the catalog.
602
+ avatar: effectiveAvatarName(row),
491
603
  git_branch: branches.get(row.id) ?? null,
492
604
  attention: getAttention(row.id),
493
605
  forwarded_ports: forwards.get(row.id) ?? []
@@ -524,6 +636,10 @@ export async function addForwardedPort(id: string, containerPort: number): Promi
524
636
  if (!Number.isInteger(containerPort) || containerPort < 1 || containerPort > 65535) {
525
637
  throw new Error('Port must be an integer between 1 and 65535');
526
638
  }
639
+ // Forwards publish on PUBLISH_HOST (0.0.0.0 under HOST=0.0.0.0), so forwarding the port the
640
+ // instance is actually served on would republish code-server/ttyd — neither of which has auth
641
+ // of its own — outside the Basic-Auth-gated proxy. Scoped to the live surface on purpose: in
642
+ // terminal mode nothing listens on 8080, and it's far too common an app port to reserve.
527
643
  const reserved = row.mode === 'terminal' ? TTYD_PORT : CODE_SERVER_PORT;
528
644
  if (containerPort === reserved) {
529
645
  throw new Error(
@@ -575,11 +691,51 @@ export function rebuildRunningInstancesNoCache(): number {
575
691
  return running.length;
576
692
  }
577
693
 
694
+ /** Single-quote a path for the shell, so a workspace folder with spaces survives the `cd`. */
695
+ function quote(value: string): string {
696
+ return `'${value.replaceAll("'", `'\\''`)}'`;
697
+ }
698
+
699
+ /**
700
+ * `postStartCommand` is a devcontainer-CLI lifecycle hook, not a container entrypoint, so a plain
701
+ * `docker start` never re-runs it and the ttyd daemon it backgrounded is gone. IDE mode only
702
+ * survives because the code-server feature ships an entrypoint of its own; ttyd has no equivalent.
703
+ * Both launchers are guarded (ttyd by process name, code-server by its port), so re-running one
704
+ * against a healthy container is a no-op.
705
+ */
706
+ export async function relaunchSurface(row: InstanceRow): Promise<void> {
707
+ if (!row.container_id) return;
708
+
709
+ const steps: string[] = [];
710
+ // The folderOpen task's run-once gate is meant to span one container run, but the marker file
711
+ // outlives it — left in place, a restarted IDE container never reopens the Claude terminal.
712
+ if (row.mode !== 'terminal') steps.push(`rm -f "$HOME/${TERMINAL_LAUNCHED_MARKER}"`);
713
+ // code-server comes back on its own (its feature ships a container entrypoint), so only launch
714
+ // a surface that is actually down rather than racing the entrypoint for the port.
715
+ if (!(await surfaceAccessible(row.host_port))) steps.push(launchCommandFor(row.mode));
716
+ if (!steps.length) return;
717
+
718
+ // Both launchers resolve paths off `$PWD`, which under postStartCommand is the workspace
719
+ // folder; an exec only inherits the image's WorkingDir, so re-establish it explicitly.
720
+ const cd = row.remote_workspace_folder
721
+ ? `cd ${quote(row.remote_workspace_folder)} || exit 1; `
722
+ : '';
723
+ const res = await execInContainer(
724
+ { containerId: row.container_id, remoteUser: row.remote_user },
725
+ { script: cd + steps.join('; ') }
726
+ );
727
+ if (!res.ok) {
728
+ appendLog(row.id, `⚠ Could not relaunch ${surfaceLabel(row.mode)}: ${res.error}\n`);
729
+ }
730
+ }
731
+
578
732
  export async function startInstance(id: string): Promise<InstanceRow> {
579
733
  const row = getInstance(id);
580
734
  if (!row) throw new Error('Instance not found');
581
735
  if (!row.container_id) throw new Error('Instance has no container yet');
582
736
  const ok = await startContainer(row.container_id);
737
+ // Awaited so the first health tick after the reconcile below already sees a listening port.
738
+ if (ok) await relaunchSurface(row);
583
739
  updateInstance(id, {
584
740
  status: ok ? 'running' : 'error',
585
741
  error: ok ? null : 'Failed to start container'
@@ -591,6 +747,9 @@ export async function startInstance(id: string): Promise<InstanceRow> {
591
747
  export async function stopInstance(id: string): Promise<InstanceRow> {
592
748
  const row = getInstance(id);
593
749
  if (!row) throw new Error('Instance not found');
750
+ // A later delete can't exec into a stopped container, so this is the last chance to mirror.
751
+ stopLogCapture(id);
752
+ if (row.container_id) await runCapturePass(row).catch(() => undefined);
594
753
  const ok = row.container_id ? await stopContainer(row.container_id) : true;
595
754
  updateInstance(id, {
596
755
  status: ok ? 'stopped' : 'error',
@@ -604,12 +763,19 @@ export async function stopInstance(id: string): Promise<InstanceRow> {
604
763
  export async function deleteInstance(id: string): Promise<void> {
605
764
  const row = getInstance(id);
606
765
  if (!row) return;
607
- if (row.container_id) await removeContainer(row.container_id);
766
+ stopHealthMonitor(id);
767
+ // Mirror the tail of the session before the transcripts die with the container; the periodic
768
+ // chain is stopped first so it can't race this last pass. Retention outlives the instance —
769
+ // <LOGS_DIR>/*-<id>.jsonl is deliberately left on disk.
770
+ stopLogCapture(id);
771
+ if (row.container_id) {
772
+ await runCapturePass(row).catch(() => undefined);
773
+ await removeContainer(row.container_id);
774
+ }
608
775
  await rm(join(INSTANCES_DIR, id), { recursive: true, force: true });
609
776
  deleteForwards(id);
610
777
  deleteInstanceRow(id);
611
778
  registry.delete(id);
612
- stopHealthMonitor(id);
613
779
  clearAttention(id);
614
780
  triggerReconcile();
615
781
  }
@@ -0,0 +1,262 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ writeFileSync
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { LOGS_DIR } from './config.server.ts';
12
+ import { getInstance, type InstanceRow } from './db.server.ts';
13
+ import { isRunning } from './docker.server.ts';
14
+ import { execInContainer } from './exec.server.ts';
15
+
16
+ /** Delta pulls are cheap, so a lazier cadence than the 5s health probe is plenty. */
17
+ const CAPTURE_MS = 10_000;
18
+
19
+ const MANIFEST_MARKER = '__CODEBAY_MANIFEST__';
20
+ const FETCH_MARKER = '__CODEBAY_FETCH__';
21
+ const FILE_MARKER = '__CODEBAY_FILE__';
22
+ const END_MARKER = '__CODEBAY_END__';
23
+
24
+ export interface ManifestEntry {
25
+ path: string;
26
+ size: number;
27
+ }
28
+
29
+ interface FetchJob {
30
+ path: string;
31
+ hostFileName: string;
32
+ /** 1-indexed byte to `tail -c +N` from; 1 re-pulls the whole file. */
33
+ startByte: number;
34
+ mode: 'append' | 'rollover';
35
+ }
36
+
37
+ /**
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
+ */
41
+ function manifestScript(): string {
42
+ return (
43
+ `h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
44
+ `printf '%s\\n' '${MANIFEST_MARKER}'; ` +
45
+ `{ find "$cfg" -maxdepth 1 -type f -name 'history.jsonl' 2>/dev/null; ` +
46
+ `find "$cfg/projects" -type f -name '*.jsonl' 2>/dev/null; } | ` +
47
+ `while IFS= read -r f; do printf '%s\\t%s\\n' "$f" "$(wc -c < "$f" | tr -d ' ')"; done; true`
48
+ );
49
+ }
50
+
51
+ /** Emits, per requested file, a framed block of base64 bytes from `startByte` to EOF. */
52
+ function fetchScript(): string {
53
+ return (
54
+ `printf '%s\\n' '${FETCH_MARKER}'; ` +
55
+ `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`
59
+ );
60
+ }
61
+
62
+ export function parseManifest(stdout: string): ManifestEntry[] {
63
+ const at = stdout.indexOf(MANIFEST_MARKER);
64
+ if (at === -1) return [];
65
+ const out: ManifestEntry[] = [];
66
+ for (const line of stdout.slice(at + MANIFEST_MARKER.length).split('\n')) {
67
+ const tab = line.indexOf('\t');
68
+ if (tab === -1) continue;
69
+ const path = line.slice(0, tab);
70
+ const size = Number(line.slice(tab + 1).trim());
71
+ if (path && Number.isFinite(size)) out.push({ path, size });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** A `/projects/` path is a session transcript; the only other file we list is `history.jsonl`. */
77
+ export function hostFileNameFor(instanceId: string, containerPath: string): string {
78
+ const base = containerPath.split('/').pop() ?? containerPath;
79
+ if (containerPath.includes('/projects/')) {
80
+ return `transcript-${instanceId}-${base.replace(/\.jsonl$/, '')}.jsonl`;
81
+ }
82
+ return `history-${instanceId}.jsonl`;
83
+ }
84
+
85
+ /** Diffs container sizes against the host mirrors: append new bytes, or re-pull a file that shrank. */
86
+ export function planFetch(
87
+ instanceId: string,
88
+ manifest: ManifestEntry[],
89
+ hostSize: (hostFileName: string) => number
90
+ ): FetchJob[] {
91
+ const jobs: FetchJob[] = [];
92
+ for (const { path, size } of manifest) {
93
+ const hostFileName = hostFileNameFor(instanceId, path);
94
+ const existing = hostSize(hostFileName);
95
+ 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' });
98
+ }
99
+ return jobs;
100
+ }
101
+
102
+ export function parseFetchBlocks(stdout: string): { path: string; base64: string }[] {
103
+ const at = stdout.indexOf(FETCH_MARKER);
104
+ if (at === -1) return [];
105
+ const blocks: { path: string; base64: string }[] = [];
106
+ for (const part of stdout
107
+ .slice(at + FETCH_MARKER.length)
108
+ .split(FILE_MARKER)
109
+ .slice(1)) {
110
+ const end = part.indexOf(END_MARKER);
111
+ const seg = end === -1 ? part : part.slice(0, end);
112
+ const nl = seg.indexOf('\n');
113
+ if (nl === -1) continue;
114
+ const path = seg.slice(0, nl).replace(/^\t/, '');
115
+ // base64 wrapping (GNU wraps at 76 cols) is stripped so multi-line blocks decode cleanly.
116
+ blocks.push({ path, base64: seg.slice(nl + 1).replace(/\s+/g, '') });
117
+ }
118
+ return blocks;
119
+ }
120
+
121
+ function sizeOnDisk(path: string): number {
122
+ try {
123
+ return statSync(path).size;
124
+ } catch {
125
+ return 0;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * A rebuild recreates the container (`--remove-existing-container`), so its `~/.claude` starts empty
131
+ * and the next manifest reports a file smaller than our mirror. Archiving the mirror aside — rather
132
+ * than overwriting it with the new container's few hundred bytes — is what makes retention real.
133
+ */
134
+ function rollOver(dest: string): void {
135
+ if (!existsSync(dest)) return;
136
+ const dot = dest.lastIndexOf('.');
137
+ const stem = dot === -1 ? dest : dest.slice(0, dot);
138
+ const ext = dot === -1 ? '' : dest.slice(dot);
139
+ for (let n = 1; ; n++) {
140
+ const archive = `${stem}.${n}${ext}`;
141
+ if (existsSync(archive)) continue;
142
+ renameSync(dest, archive);
143
+ return;
144
+ }
145
+ }
146
+
147
+ /** Keeps `index.json` mapping the opaque UUID filenames back to a human-readable instance. */
148
+ function updateIndex(logsDir: string, row: InstanceRow): void {
149
+ const indexPath = join(logsDir, 'index.json');
150
+ let index: Record<string, unknown> = {};
151
+ try {
152
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf8'));
153
+ if (parsed && typeof parsed === 'object') index = parsed as Record<string, unknown>;
154
+ } catch {
155
+ // Absent or corrupt — start fresh rather than lose this pass.
156
+ }
157
+ index[row.id] = {
158
+ name: row.name,
159
+ source_path: row.source_path,
160
+ created_at: row.created_at,
161
+ last_captured: Date.now()
162
+ };
163
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
164
+ }
165
+
166
+ interface CaptureDeps {
167
+ exec?: typeof execInContainer;
168
+ logsDir?: string;
169
+ }
170
+
171
+ /**
172
+ * One extraction pass: manifest → plan deltas → fetch → append/roll over host mirrors → index.
173
+ * Resolves false when the container could not be read, which is how `tick` detects a dead chain.
174
+ */
175
+ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}): Promise<boolean> {
176
+ if (!row.container_id) return false;
177
+ const exec = deps.exec ?? execInContainer;
178
+ const logsDir = deps.logsDir ?? LOGS_DIR;
179
+ const target = { containerId: row.container_id, remoteUser: row.remote_user };
180
+
181
+ const manifestRes = await exec(target, { script: manifestScript(), capture: true });
182
+ if (!manifestRes.ok) return false;
183
+ const manifest = parseManifest(manifestRes.stdout);
184
+
185
+ mkdirSync(logsDir, { recursive: true });
186
+ const jobs = planFetch(row.id, manifest, (name) => sizeOnDisk(join(logsDir, name)));
187
+
188
+ if (jobs.length) {
189
+ const args = ['fetch'];
190
+ for (const j of jobs) args.push(j.path, String(j.startByte));
191
+ const fetchRes = await exec(target, { script: fetchScript(), args, capture: true });
192
+ if (fetchRes.ok) {
193
+ const byPath = new Map(jobs.map((j) => [j.path, j]));
194
+ for (const block of parseFetchBlocks(fetchRes.stdout)) {
195
+ const job = byPath.get(block.path);
196
+ if (!job) continue;
197
+ 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);
201
+ }
202
+ }
203
+ }
204
+ updateIndex(logsDir, row);
205
+ return true;
206
+ }
207
+
208
+ interface Capture {
209
+ timer: ReturnType<typeof setTimeout>;
210
+ }
211
+
212
+ // Pinned to globalThis so dev-mode hot reload doesn't orphan the interval timers.
213
+ const globalForCapture = globalThis as unknown as { __codebayLogCapture?: Map<string, Capture> };
214
+ const captures: Map<string, Capture> = (globalForCapture.__codebayLogCapture ??= new Map());
215
+
216
+ async function tick(id: string, cap: Capture): Promise<void> {
217
+ // Re-read every tick: a rename has to reach index.json, and a stale container_id would exec
218
+ // against a container that no longer exists.
219
+ const row = getInstance(id);
220
+ // Best-effort: a failed pass (container just stopped, transient exec error) must not kill the loop.
221
+ const ok = row ? await runCapturePass(row).catch(() => false) : false;
222
+ // syncLogCapture would normally stop us, but it only runs from the reconcile loop, which is idle
223
+ // whenever no client is connected — so a container that died on its own has to end the chain here.
224
+ const alive =
225
+ ok || (!!row?.container_id && (await isRunning(row.container_id).catch(() => false)));
226
+ // Stopped or replaced while the pass was in flight — rescheduling would leak a second chain.
227
+ if (captures.get(id) !== cap) return;
228
+ if (!alive) {
229
+ stopLogCapture(id);
230
+ return;
231
+ }
232
+ cap.timer = setTimeout(() => void tick(id, cap), CAPTURE_MS);
233
+ }
234
+
235
+ function startLogCapture(id: string): void {
236
+ if (captures.has(id)) return;
237
+ const cap: Capture = { timer: setTimeout(() => undefined, 0) };
238
+ captures.set(id, cap);
239
+ // Seed a pass now so a short-lived session is captured without waiting a full interval.
240
+ void tick(id, cap);
241
+ }
242
+
243
+ /** Stops the timer only — the mirrored logs are retained on disk past instance deletion. */
244
+ export function stopLogCapture(id: string): void {
245
+ const cap = captures.get(id);
246
+ if (!cap) return;
247
+ clearTimeout(cap.timer);
248
+ captures.delete(id);
249
+ }
250
+
251
+ /** Driven from the reconcile loop, so capture tracks the container lifecycle automatically. */
252
+ export function syncLogCapture(rows: InstanceRow[]): void {
253
+ const running = new Set(
254
+ rows.filter((r) => r.status === 'running' && r.container_id).map((r) => r.id)
255
+ );
256
+ for (const row of rows) {
257
+ if (running.has(row.id) && !captures.has(row.id)) startLogCapture(row.id);
258
+ }
259
+ for (const id of [...captures.keys()]) {
260
+ if (!running.has(id)) stopLogCapture(id);
261
+ }
262
+ }
@@ -7,3 +7,34 @@ export function pickFreePort(usedSets: ReadonlySet<number>[]): number {
7
7
  }
8
8
  throw new Error('No free host ports available.');
9
9
  }
10
+
11
+ /**
12
+ * A listener outside Docker's view is invisible to the DB/Docker union — and on a VM-backed daemon
13
+ * the collision never surfaces as an error, because the `-p` bind happens inside the VM where the
14
+ * port is free while the host-side forward fails (colima logs that as a warning). Claiming the port
15
+ * ourselves first is the only way the collision reaches us.
16
+ */
17
+ export async function isHostPortBindable(port: number): Promise<boolean> {
18
+ try {
19
+ // Loopback specifically: it's where code-server is always published, and a wildcard squatter
20
+ // blocks this bind too, so the one probe covers both.
21
+ const server = Bun.listen({ hostname: '127.0.0.1', port, socket: { data() {} } });
22
+ server.stop(true);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** Takes the probe as a parameter so the scan is testable without binding real sockets. */
30
+ export async function pickBindablePort(
31
+ usedSets: ReadonlySet<number>[],
32
+ isBindable: (port: number) => Promise<boolean>
33
+ ): Promise<number> {
34
+ const rejected = new Set<number>();
35
+ for (;;) {
36
+ const port = pickFreePort([...usedSets, rejected]);
37
+ if (await isBindable(port)) return port;
38
+ rejected.add(port);
39
+ }
40
+ }