codebay 0.9.1 → 0.10.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 (77) hide show
  1. package/.mochi/manifest.json +214 -147
  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-models.ts +2 -1
  51. package/src/container-injections/claude-code-update.ts +62 -1
  52. package/src/container-injections/claude-effort-level.ts +32 -0
  53. package/src/container-injections/claude-permission-mode.ts +40 -0
  54. package/src/container-injections/custom-env-vars.ts +82 -0
  55. package/src/container-injections/host-env-vars.ts +2 -1
  56. package/src/lib/config.server.ts +3 -0
  57. package/src/lib/db.server.ts +8 -3
  58. package/src/lib/devcontainer.server.ts +155 -43
  59. package/src/lib/health.server.ts +55 -11
  60. package/src/lib/injections.server.ts +45 -38
  61. package/src/lib/instances.server.ts +246 -26
  62. package/src/lib/log-capture.server.ts +262 -0
  63. package/src/lib/ports.server.ts +31 -0
  64. package/src/lib/proxy.server.ts +2 -2
  65. package/src/pages/Settings.svelte +22 -1
  66. package/src/routes.ts +98 -2
  67. package/src/types.ts +36 -0
  68. package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +0 -1
  69. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +0 -2
  70. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +0 -1
  71. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +0 -29
  72. package/.mochi/svelte-client/chunk-7wkvn2vx.js +0 -2
  73. package/.mochi/svelte-client/chunk-m4fzsd5b.js +0 -2
  74. package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +0 -1
  75. package/.mochi/svelte-css/IdeBar-10xodudcx57qr.css +0 -1
  76. package/.mochi/svelte-css/SettingsView-2qfauz68axro1.css +0 -1
  77. 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, type ExecTarget } 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 {
@@ -78,6 +97,27 @@ interface LiveState {
78
97
  const globalForReg = globalThis as unknown as { __codebayRegistry?: Map<string, LiveState> };
79
98
  const registry: Map<string, LiveState> = (globalForReg.__codebayRegistry ??= new Map());
80
99
 
100
+ /**
101
+ * The ids `provision()` is currently building. A `creating` row is only left alone while its boot
102
+ * is actually in flight — otherwise a server restart mid-boot would strand it there forever, since
103
+ * reconcile skips `creating` and `rebuildInstance` refuses to touch it.
104
+ */
105
+ const globalForBoots = globalThis as unknown as { __codebayProvisioning?: Set<string> };
106
+ const inFlight: Set<string> = (globalForBoots.__codebayProvisioning ??= new Set());
107
+
108
+ /**
109
+ * Whether reconcile may re-derive a row's status from Docker. A live boot owns its own row until it
110
+ * opens the instance itself; an orphaned `creating` row (the manager restarted mid-boot) must not
111
+ * be left there, since nothing else would ever move it and Rebuild refuses to touch a booting row.
112
+ */
113
+ export function reconcilesFromDocker(
114
+ row: Pick<InstanceRow, 'id' | 'status'>,
115
+ building: ReadonlySet<string>
116
+ ): boolean {
117
+ if (row.status === 'error') return false;
118
+ return row.status !== 'creating' || !building.has(row.id);
119
+ }
120
+
81
121
  function live(id: string): LiveState {
82
122
  let state = registry.get(id);
83
123
  if (!state) {
@@ -87,7 +127,36 @@ function live(id: string): LiveState {
87
127
  return state;
88
128
  }
89
129
 
130
+ /**
131
+ * Cached so the redaction on every log chunk doesn't re-read the DB. Rebuilt lazily after the
132
+ * settings action invalidates it; values shorter than 4 chars are skipped so a trivially short
133
+ * secret can't blank out unrelated log text, and longest-first avoids a short value masking a
134
+ * substring of a longer one.
135
+ */
136
+ let secretValuesCache: string[] | null = null;
137
+
138
+ function getSecretValues(): string[] {
139
+ if (secretValuesCache === null) {
140
+ secretValuesCache = customEnvVarValues()
141
+ .filter((v) => v.length >= 4)
142
+ .sort((a, b) => b.length - a.length);
143
+ }
144
+ return secretValuesCache;
145
+ }
146
+
147
+ /** Call after the custom-env-vars setting changes so newly-added values redact from the next boot. */
148
+ export function invalidateSecretValues(): void {
149
+ secretValuesCache = null;
150
+ }
151
+
152
+ /** Exported for tests; the boot flow reaches it only through `appendLog`. */
153
+ export function redactSecrets(text: string): string {
154
+ for (const value of getSecretValues()) text = text.split(value).join('••••');
155
+ return text;
156
+ }
157
+
90
158
  function appendLog(id: string, chunk: string): void {
159
+ chunk = redactSecrets(chunk);
91
160
  const state = live(id);
92
161
  state.logs.push(chunk);
93
162
  if (state.logs.length > 2000) state.logs.splice(0, state.logs.length - 2000);
@@ -255,7 +324,10 @@ async function allocatePort(): Promise<number> {
255
324
  for (const [port, reservedAt] of reservedPorts) {
256
325
  if (dbPorts.has(port) || now - reservedAt > RESERVATION_TTL_MS) reservedPorts.delete(port);
257
326
  }
258
- const port = pickFreePort([dbPorts, dockerPorts, new Set(reservedPorts.keys())]);
327
+ const port = await pickBindablePort(
328
+ [dbPorts, dockerPorts, new Set(reservedPorts.keys())],
329
+ isHostPortBindable
330
+ );
259
331
  reservedPorts.set(port, now);
260
332
  return port;
261
333
  }
@@ -270,6 +342,10 @@ async function assertDir(path: string): Promise<void> {
270
342
  if (!info.isDirectory()) throw new Error(`Not a folder: ${path}`);
271
343
  }
272
344
 
345
+ function elapsed(startMs: number): string {
346
+ return `${((Date.now() - startMs) / 1000).toFixed(1)}s`;
347
+ }
348
+
273
349
  function failInstance(id: string, err: unknown): void {
274
350
  const message = (err as Error).message;
275
351
  updateInstance(id, { status: 'error', error: message });
@@ -278,19 +354,26 @@ function failInstance(id: string, err: unknown): void {
278
354
  }
279
355
 
280
356
  async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<void> {
357
+ // Claims the row for the copy/clone phase, which runs ahead of the build; provision() re-claims
358
+ // it and owns the release from there.
359
+ inFlight.add(row.id);
281
360
  try {
361
+ const sourceStart = Date.now();
282
362
  if (isRepoUrl(row.source_path)) {
283
363
  appendLog(row.id, `Cloning ${row.source_path} → ${row.workspace_path}\n`);
284
364
  await cloneRepo(row.source_path, row.workspace_path, (chunk) => appendLog(row.id, chunk), {
285
365
  branch: opts.branch
286
366
  });
367
+ appendLog(row.id, `⏱ clone: ${elapsed(sourceStart)}\n`);
287
368
  } else {
288
369
  appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
289
370
  const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
290
371
  await copyWorkspace(row.source_path, row.workspace_path, ignore);
372
+ appendLog(row.id, `⏱ copy: ${elapsed(sourceStart)}\n`);
291
373
  }
292
374
  await seedDeclaredPorts(row);
293
375
  } catch (err) {
376
+ inFlight.delete(row.id);
294
377
  failInstance(row.id, err);
295
378
  return;
296
379
  }
@@ -314,22 +397,55 @@ async function seedDeclaredPorts(row: InstanceRow): Promise<void> {
314
397
  }
315
398
  }
316
399
 
400
+ const surfaceLabel = (mode: InstanceMode) =>
401
+ mode === 'terminal' ? 'ttyd terminal' : 'code-server';
402
+
403
+ /**
404
+ * Moves an instance off a host port that something outside Docker has taken over, which otherwise
405
+ * strands it forever — the container publishes fine but nothing reaches it from the host. Requires
406
+ * the port to be *both* unbindable and dead, because a rebuild doesn't stop the old container
407
+ * first: a healthy instance's port is always unbindable here, and its surface still answers.
408
+ */
409
+ async function rescueHijackedPort(row: InstanceRow): Promise<void> {
410
+ if (await isHostPortBindable(row.host_port)) return;
411
+ if (await surfaceAccessible(row.host_port)) return;
412
+ const stale = row.host_port;
413
+ const port = await allocatePort();
414
+ updateInstance(row.id, { host_port: port });
415
+ row.host_port = port;
416
+ appendLog(
417
+ row.id,
418
+ `⚠ Host port ${stale} is held by another process and isn't reachable — moving to ${port}\n`
419
+ );
420
+ }
421
+
317
422
  /** Never re-copies the workspace, so in-container edits survive a rebuild. */
318
423
  async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Promise<void> {
424
+ // Held so the `catch` can release the container-side launcher's wait on a boot that dies.
425
+ let containerId: string | null = null;
426
+ inFlight.add(row.id);
319
427
  try {
428
+ // On a rebuild `devcontainer up --remove-existing-container` discards the old container's
429
+ // ~/.claude, so drain it first; a first boot has no container_id and skips straight past.
430
+ if (row.container_id) {
431
+ stopLogCapture(row.id);
432
+ await runCapturePass(row).catch(() => undefined);
433
+ }
434
+ await rescueHijackedPort(row);
320
435
  const forwards = listForwards(row.id).map((f) => ({
321
436
  container_port: f.container_port,
322
437
  host_port: f.host_port
323
438
  }));
324
- const surface = row.mode === 'terminal' ? 'ttyd terminal' : 'code-server';
325
- appendLog(row.id, `Injecting ${surface} (host port ${row.host_port})\n`);
439
+ appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
326
440
  const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
327
441
  const { imageSource } = await writeOverrideConfig(
328
442
  row.workspace_path,
329
443
  row.host_port,
330
444
  forwards,
331
445
  defaultImage,
332
- row.mode
446
+ row.mode,
447
+ getClaudePermissionMode(),
448
+ customEnvVarsConfig()?.vars ?? []
333
449
  );
334
450
  updateInstance(row.id, { image_source: imageSource });
335
451
 
@@ -337,9 +453,11 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
337
453
  if (noCache) appendLog(row.id, `Building without cache (--build-no-cache)\n`);
338
454
 
339
455
  appendLog(row.id, `Starting devcontainer…\n`);
456
+ const upStart = Date.now();
340
457
  const result = await devcontainerUp(row.workspace_path, (chunk) => appendLog(row.id, chunk), {
341
458
  noCache
342
459
  });
460
+ appendLog(row.id, `⏱ devcontainer up: ${elapsed(upStart)}\n`);
343
461
 
344
462
  if (result.outcome !== 'success' || !result.containerId) {
345
463
  throw new Error(
@@ -347,11 +465,13 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
347
465
  );
348
466
  }
349
467
 
468
+ containerId = result.containerId;
469
+ // The row deliberately stays `creating` until the injections below finish: `running` is what
470
+ // opens the IDE/terminal to the user, and a container whose setup is still landing isn't ready.
350
471
  updateInstance(row.id, {
351
472
  container_id: result.containerId,
352
473
  remote_workspace_folder: result.remoteWorkspaceFolder ?? null,
353
474
  remote_user: result.remoteUser ?? null,
354
- status: 'running',
355
475
  error: null
356
476
  });
357
477
 
@@ -360,34 +480,66 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
360
480
  row.container_id = result.containerId;
361
481
  row.remote_workspace_folder = result.remoteWorkspaceFolder ?? null;
362
482
  row.remote_user = result.remoteUser ?? null;
363
- row.status = 'running';
364
483
 
365
484
  const target = {
366
485
  containerId: result.containerId,
367
486
  remoteUser: result.remoteUser,
368
487
  instance: row
369
488
  };
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
- }
489
+ // Stages parallelize independent injections; per-injection failures are still swallowed
490
+ // so one bad injection can't abort the rest of provisioning.
491
+ const injectionsStart = Date.now();
492
+ for (const stage of resolveInjectionStages(row.mode)) {
493
+ await Promise.all(
494
+ stage.map(async (injection) => {
495
+ try {
496
+ await injection.apply(target, (msg) => appendLog(row.id, msg));
497
+ } catch (err) {
498
+ appendLog(row.id, `⚠ ${injection.label} injection failed: ${(err as Error).message}\n`);
499
+ }
500
+ })
501
+ );
377
502
  }
503
+ appendLog(row.id, `⏱ injections: ${elapsed(injectionsStart)}\n`);
378
504
 
379
505
  // Unblocks the terminal launcher's wait; written even after ⚠s — its timeout is the fallback.
380
- await writeContainerFile(target, { dir: '$HOME', name: INJECTIONS_DONE_FILE }, 'done\n').catch(
381
- () => undefined
382
- );
506
+ await writeInjectionsDone(row.id, target);
507
+
508
+ // postStartCommand ran before the injections above, and its launcher bails when the binary
509
+ // is missing — so a container whose build-time ttyd install failed got it too late to be
510
+ // started. No-ops (one accessibility probe) whenever postStart already brought the port up.
511
+ await relaunchSurface(row);
512
+
513
+ // Everything the instance needs is in place, so open it to the user: this is what lights up
514
+ // Open IDE/terminal, the proxy, and the health and log-capture monitors.
515
+ updateInstance(row.id, { status: 'running' });
516
+ row.status = 'running';
383
517
 
384
518
  appendLog(row.id, `\n✓ Instance running — open it via the proxy at ${proxyPathFor(row.id)}\n`);
385
519
  triggerReconcile();
386
520
  } catch (err) {
521
+ // The launcher waits minutes for the sentinel, so a boot that will never write it must say so.
522
+ if (containerId)
523
+ await writeInjectionsDone(row.id, { containerId, remoteUser: row.remote_user });
387
524
  failInstance(row.id, err);
525
+ } finally {
526
+ inFlight.delete(row.id);
388
527
  }
389
528
  }
390
529
 
530
+ /**
531
+ * `writeContainerFile` reports failure by return value rather than throwing, and a sentinel that
532
+ * never lands leaves the launcher waiting out its whole bound — so surface it in the boot log.
533
+ */
534
+ async function writeInjectionsDone(id: string, target: ExecTarget): Promise<void> {
535
+ const res = await writeContainerFile(
536
+ target,
537
+ { dir: '$HOME', name: INJECTIONS_DONE_FILE },
538
+ 'done\n'
539
+ ).catch((err: Error) => ({ ok: false, error: err.message }));
540
+ if (!res.ok) appendLog(id, `⚠ Could not mark setup complete in the container: ${res.error}\n`);
541
+ }
542
+
391
543
  /** The first instance keeps the bare name; later collisions get a `#2`, `#3`, … suffix. */
392
544
  function uniqueName(desired: string, excludeId?: string): string {
393
545
  const taken = new Set(
@@ -437,6 +589,8 @@ export async function createInstance(
437
589
  bridge_token: crypto.randomUUID().replace(/-/g, ''),
438
590
  remote_user: null,
439
591
  image_source: null,
592
+ // Kept synchronous through insertInstance so two concurrent creations can't claim the same free sprite.
593
+ avatar: pickUniqueAvatar(id, allInstances().map(effectiveAvatarName)).name,
440
594
  mode: opts.mode ?? getDefaultMode(),
441
595
  terminal_split: 0
442
596
  };
@@ -455,10 +609,19 @@ export async function listInstances(): Promise<Instance[]> {
455
609
  const branches = new Map<string, string | null>();
456
610
  await Promise.all(
457
611
  rows.map(async (row) => {
458
- if (!row.container_id || row.status === 'creating' || row.status === 'error') return;
612
+ if (!row.container_id || !reconcilesFromDocker(row, inFlight)) return;
459
613
  const running = await isRunning(row.container_id);
460
614
  const next: InstanceStatus = running ? 'running' : 'stopped';
461
615
  if (next !== row.status) {
616
+ // Only reachable for a `creating` row whose boot died with the manager, and its
617
+ // injections will never finish — so stop the container-side launcher waiting on them.
618
+ if (row.status === 'creating') {
619
+ appendLog(row.id, `\n⚠ Setup was interrupted — Rebuild to run it again\n`);
620
+ void writeInjectionsDone(row.id, {
621
+ containerId: row.container_id,
622
+ remoteUser: row.remote_user
623
+ });
624
+ }
462
625
  updateInstance(row.id, { status: next });
463
626
  row.status = next;
464
627
  }
@@ -468,6 +631,7 @@ export async function listInstances(): Promise<Instance[]> {
468
631
  );
469
632
  // Reconcile is the only place that knows the current running set.
470
633
  syncHealthMonitors(rows);
634
+ syncLogCapture(rows);
471
635
  // Distinguishes a forward that's live from one still pending a rebuild.
472
636
  const openPorts = new Map<string, Set<number>>();
473
637
  for (const { id, health } of currentHealthSnapshots()) {
@@ -488,6 +652,9 @@ export async function listInstances(): Promise<Instance[]> {
488
652
  }
489
653
  return rows.map((row) => ({
490
654
  ...sanitizeInstance(row),
655
+ // Resolve legacy null rows to their hashed sprite here so the client only sees an
656
+ // unresolvable name when a persisted sprite was actually removed from the catalog.
657
+ avatar: effectiveAvatarName(row),
491
658
  git_branch: branches.get(row.id) ?? null,
492
659
  attention: getAttention(row.id),
493
660
  forwarded_ports: forwards.get(row.id) ?? []
@@ -560,7 +727,10 @@ export function removeForwardedPort(id: string, containerPort: number): Instance
560
727
  export function rebuildInstance(id: string, opts: { noCache?: boolean } = {}): InstanceRow {
561
728
  const row = getInstance(id);
562
729
  if (!row) throw new Error('Instance not found');
563
- if (row.status === 'creating') return row; // a build is already in flight
730
+ if (inFlight.has(id)) return row; // a build is already in flight
731
+ // Claimed before the broadcast below, which reconciles synchronously: an unclaimed `creating`
732
+ // row reads as orphaned, and reconcile would flip it straight back off `creating`.
733
+ inFlight.add(id);
564
734
  updateInstance(id, { status: 'creating', error: null });
565
735
  triggerReconcile();
566
736
  const fresh = getInstance(id)!;
@@ -579,11 +749,51 @@ export function rebuildRunningInstancesNoCache(): number {
579
749
  return running.length;
580
750
  }
581
751
 
752
+ /** Single-quote a path for the shell, so a workspace folder with spaces survives the `cd`. */
753
+ function quote(value: string): string {
754
+ return `'${value.replaceAll("'", `'\\''`)}'`;
755
+ }
756
+
757
+ /**
758
+ * `postStartCommand` is a devcontainer-CLI lifecycle hook, not a container entrypoint, so a plain
759
+ * `docker start` never re-runs it and the ttyd daemon it backgrounded is gone. IDE mode only
760
+ * survives because the code-server feature ships an entrypoint of its own; ttyd has no equivalent.
761
+ * Both launchers are guarded (ttyd by process name, code-server by its port), so re-running one
762
+ * against a healthy container is a no-op.
763
+ */
764
+ export async function relaunchSurface(row: InstanceRow): Promise<void> {
765
+ if (!row.container_id) return;
766
+
767
+ const steps: string[] = [];
768
+ // The folderOpen task's run-once gate is meant to span one container run, but the marker file
769
+ // outlives it — left in place, a restarted IDE container never reopens the Claude terminal.
770
+ if (row.mode !== 'terminal') steps.push(`rm -f "$HOME/${TERMINAL_LAUNCHED_MARKER}"`);
771
+ // code-server comes back on its own (its feature ships a container entrypoint), so only launch
772
+ // a surface that is actually down rather than racing the entrypoint for the port.
773
+ if (!(await surfaceAccessible(row.host_port))) steps.push(launchCommandFor(row.mode));
774
+ if (!steps.length) return;
775
+
776
+ // Both launchers resolve paths off `$PWD`, which under postStartCommand is the workspace
777
+ // folder; an exec only inherits the image's WorkingDir, so re-establish it explicitly.
778
+ const cd = row.remote_workspace_folder
779
+ ? `cd ${quote(row.remote_workspace_folder)} || exit 1; `
780
+ : '';
781
+ const res = await execInContainer(
782
+ { containerId: row.container_id, remoteUser: row.remote_user },
783
+ { script: cd + steps.join('; ') }
784
+ );
785
+ if (!res.ok) {
786
+ appendLog(row.id, `⚠ Could not relaunch ${surfaceLabel(row.mode)}: ${res.error}\n`);
787
+ }
788
+ }
789
+
582
790
  export async function startInstance(id: string): Promise<InstanceRow> {
583
791
  const row = getInstance(id);
584
792
  if (!row) throw new Error('Instance not found');
585
793
  if (!row.container_id) throw new Error('Instance has no container yet');
586
794
  const ok = await startContainer(row.container_id);
795
+ // Awaited so the first health tick after the reconcile below already sees a listening port.
796
+ if (ok) await relaunchSurface(row);
587
797
  updateInstance(id, {
588
798
  status: ok ? 'running' : 'error',
589
799
  error: ok ? null : 'Failed to start container'
@@ -595,6 +805,9 @@ export async function startInstance(id: string): Promise<InstanceRow> {
595
805
  export async function stopInstance(id: string): Promise<InstanceRow> {
596
806
  const row = getInstance(id);
597
807
  if (!row) throw new Error('Instance not found');
808
+ // A later delete can't exec into a stopped container, so this is the last chance to mirror.
809
+ stopLogCapture(id);
810
+ if (row.container_id) await runCapturePass(row).catch(() => undefined);
598
811
  const ok = row.container_id ? await stopContainer(row.container_id) : true;
599
812
  updateInstance(id, {
600
813
  status: ok ? 'stopped' : 'error',
@@ -608,12 +821,19 @@ export async function stopInstance(id: string): Promise<InstanceRow> {
608
821
  export async function deleteInstance(id: string): Promise<void> {
609
822
  const row = getInstance(id);
610
823
  if (!row) return;
611
- if (row.container_id) await removeContainer(row.container_id);
824
+ stopHealthMonitor(id);
825
+ // Mirror the tail of the session before the transcripts die with the container; the periodic
826
+ // chain is stopped first so it can't race this last pass. Retention outlives the instance —
827
+ // <LOGS_DIR>/*-<id>.jsonl is deliberately left on disk.
828
+ stopLogCapture(id);
829
+ if (row.container_id) {
830
+ await runCapturePass(row).catch(() => undefined);
831
+ await removeContainer(row.container_id);
832
+ }
612
833
  await rm(join(INSTANCES_DIR, id), { recursive: true, force: true });
613
834
  deleteForwards(id);
614
835
  deleteInstanceRow(id);
615
836
  registry.delete(id);
616
- stopHealthMonitor(id);
617
837
  clearAttention(id);
618
838
  triggerReconcile();
619
839
  }