codebay 0.9.1 → 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 (77) hide show
  1. package/.mochi/manifest.json +213 -146
  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 +129 -42
  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 +181 -19
  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
@@ -6,6 +6,9 @@ import { INSTALL_SCRIPT as TMUX_INSTALL_SCRIPT } from '../container-injections/t
6
6
  import { INSTALL_SCRIPT as TTYD_INSTALL_SCRIPT } from '../container-injections/ttyd.ts';
7
7
  import { INSTALL_SCRIPT as CLAUDE_INSTALL_SCRIPT } from '../container-injections/claude-code-install.ts';
8
8
  import { EXTENSION_ID as CLAUDE_CODE_EXTENSION_ID } from '../container-injections/claude-code-ide-extension.ts';
9
+ import { ENV_FILE_NAME as CUSTOM_ENV_FILE } from '../container-injections/claude-code-custom.ts';
10
+ import { ENV_FILE_NAME as MODELS_ENV_FILE } from '../container-injections/claude-code-models.ts';
11
+ import { ENV_FILE_NAME as HOST_ENV_FILE } from '../container-injections/host-env-vars.ts';
9
12
  import {
10
13
  CODE_SERVER_PORT,
11
14
  DEFAULT_IMAGE,
@@ -15,8 +18,8 @@ import {
15
18
  dockerEnv
16
19
  } from './config.server.ts';
17
20
  import { spawnCapture } from './spawn.server.ts';
18
- import type { InstanceMode } from './db.server.ts';
19
- import type { PortForward } from '../types.ts';
21
+ import { getOption, type InstanceMode } from './db.server.ts';
22
+ import { claudePermissionFlags, type ClaudePermissionMode, type PortForward } from '../types.ts';
20
23
 
21
24
  const CODE_SERVER_FEATURE = 'ghcr.io/coder/devcontainer-features/code-server:1';
22
25
 
@@ -141,6 +144,12 @@ const TMUX_SHELL_SESSION = 'codebay-shell';
141
144
  /** Written to `$HOME` when the post-up injection sequence finishes; the terminal launcher waits on it. */
142
145
  export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
143
146
 
147
+ /**
148
+ * IDE mode's folderOpen task touches this to stay run-once across workspace reloads, but it
149
+ * outlives a container restart — so a relaunch must clear it or the terminal never reopens.
150
+ */
151
+ export const TERMINAL_LAUNCHED_MARKER = '.codebay-terminal-launched';
152
+
144
153
  /**
145
154
  * An auto-launched `claude` that starts mid-injection reads `~/.claude.json` before the trust
146
155
  * keys land and clobbers them on its next rewrite, so hold it until the sentinel appears.
@@ -160,45 +169,81 @@ const WAIT_FOR_INJECTIONS =
160
169
  */
161
170
  const WAIT_FOR_IDE_BRIDGE = `i=0; until ls "$HOME/.claude/ide/" 2>/dev/null | grep -q "\\.lock$" || [ "$i" -ge 30 ]; do sleep 1; i=$((i + 1)); done; `;
162
171
 
172
+ /**
173
+ * The injections deliver `ANTHROPIC_*` (and host) env vars through `~/.bashrc`/`~/.zshrc`, which no
174
+ * auto-launch path reads — ttyd runs `bash <script>` and tmux runs its command via `$SHELL -c`, both
175
+ * non-interactive. LiteLLM first, so it wins over the manual override if both files somehow exist.
176
+ * An explicit list, never a glob: tmux runs this under the user's shell, and zsh would print
177
+ * "no matches found" when a file is absent.
178
+ */
179
+ const SOURCE_INJECTED_ENV = `for f in ${[CUSTOM_ENV_FILE, MODELS_ENV_FILE, HOST_ENV_FILE]
180
+ .map((name) => `"$HOME/${name}"`)
181
+ .join(' ')}; do [ -r "$f" ] && . "$f"; done; `;
182
+
183
+ /** The label/`runOn` pair that identifies our task in a project's own tasks.json. */
184
+ const TERMINAL_TASK_LABEL = 'Terminal';
185
+
163
186
  /**
164
187
  * Runs under tmux so Claude survives the browser closing — code-server reaps the
165
188
  * detached PTY, which only kills the tmux client. `-A` doubles as the run-once gate.
166
189
  */
167
- const TERMINAL_TASK = {
168
- label: 'Terminal',
190
+ const terminalTask = (permissionMode: ClaudePermissionMode) => ({
191
+ label: TERMINAL_TASK_LABEL,
169
192
  type: 'shell',
170
193
  command:
171
194
  // `"$SHELL"` must reach tmux unexpanded; VS Code would substitute a `${…}` form first.
172
- `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} '${WAIT_FOR_INJECTIONS}${WAIT_FOR_IDE_BRIDGE}claude --dangerously-skip-permissions; exec "$SHELL" -l'; fi; ` +
195
+ `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} '${WAIT_FOR_INJECTIONS}${WAIT_FOR_IDE_BRIDGE}${SOURCE_INJECTED_ENV}claude ${claudePermissionFlags(permissionMode)}; exec "$SHELL" -l'; fi; ` +
173
196
  // Without tmux there's no run-once gate, and folderOpen re-fires on every workspace load.
174
- 'MARK="$HOME/.codebay-terminal-launched"; [ -e "$MARK" ] && exit 0; touch "$MARK"; ' +
197
+ 'MARK="$HOME/' +
198
+ TERMINAL_LAUNCHED_MARKER +
199
+ '"; [ -e "$MARK" ] && exit 0; touch "$MARK"; ' +
175
200
  WAIT_FOR_INJECTIONS +
176
201
  WAIT_FOR_IDE_BRIDGE +
177
- 'claude --dangerously-skip-permissions; exec ${env:SHELL} -l',
202
+ SOURCE_INJECTED_ENV +
203
+ `claude ${claudePermissionFlags(permissionMode)}; exec \${env:SHELL} -l`,
178
204
  presentation: { reveal: 'always', panel: 'shared', focus: true },
179
205
  runOptions: { runOn: 'folderOpen' },
180
206
  problemMatcher: []
181
- };
207
+ });
182
208
 
183
209
  const CODE_SERVER_APPLY_SETTINGS =
184
210
  `mkdir -p ~/.local/share/code-server/User && ` +
185
211
  `cp -f \\"$PWD/.devcontainer/${CODE_SERVER_SETTINGS_FILE}\\" ` +
186
212
  `~/.local/share/code-server/User/settings.json 2>/dev/null;`;
187
213
 
188
- // Runs before code-server first launches so the extension host activates it on the first window
189
- // (the /ide bridge, in-container over localhost); best-effort, offline-tolerant, skipped if present.
214
+ // Fully detached (redirects + </dev/null + &) because `devcontainer up` waits for postStart's
215
+ // pipes to close — an attached download would keep the whole boot on the Open VSX critical path.
190
216
  const CODE_SERVER_INSTALL_EXT =
191
- `ls -d ~/.local/share/code-server/extensions/${CLAUDE_CODE_EXTENSION_ID}-* >/dev/null 2>&1 || ` +
192
- `code-server --install-extension ${CLAUDE_CODE_EXTENSION_ID} >/tmp/code-server-ext.log 2>&1 || true; `;
193
-
194
- const CODE_SERVER_LAUNCH =
195
- `bash -c "${CODE_SERVER_APPLY_SETTINGS} ` +
196
- `${CODE_SERVER_INSTALL_EXT} ` +
197
- // The bare default image may not export SHELL, which the Terminal task needs.
198
- `export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
199
- `pgrep -f 'code-server.*${CODE_SERVER_PORT}' >/dev/null 2>&1 || ` +
200
- `nohup code-server --bind-addr 0.0.0.0:${CODE_SERVER_PORT} --auth none ` +
201
- `--disable-workspace-trust \\"$PWD\\" >/tmp/code-server.log 2>&1 &"`;
217
+ `( ls -d ~/.local/share/code-server/extensions/${CLAUDE_CODE_EXTENSION_ID}-* >/dev/null 2>&1 || ` +
218
+ `code-server --install-extension ${CLAUDE_CODE_EXTENSION_ID} ) ` +
219
+ `>/tmp/code-server-ext.log 2>&1 </dev/null &`;
220
+
221
+ // Foreground variant for the advanced escape hatch: slower boot, but the extension is guaranteed
222
+ // active on the very first window.
223
+ const CODE_SERVER_INSTALL_EXT_BLOCKING =
224
+ `( ls -d ~/.local/share/code-server/extensions/${CLAUDE_CODE_EXTENSION_ID}-* >/dev/null 2>&1 || ` +
225
+ `code-server --install-extension ${CLAUDE_CODE_EXTENSION_ID} ) ` +
226
+ `>/tmp/code-server-ext.log 2>&1; `;
227
+
228
+ // By default the extension installs in the background after launch (the first window may need a
229
+ // reload to activate it; the claude-code-ide-extension injection waits out this install as the fallback).
230
+ function codeServerLaunch(blockingExtInstall: boolean): string {
231
+ return (
232
+ `bash -c "${CODE_SERVER_APPLY_SETTINGS} ` +
233
+ (blockingExtInstall ? CODE_SERVER_INSTALL_EXT_BLOCKING : '') +
234
+ // The bare default image may not export SHELL, which the Terminal task needs.
235
+ `export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
236
+ // Probes the port rather than the process list: every `pgrep -f` pattern that matches a
237
+ // code-server daemon also matches this very shell (its own argv carries the nohup line
238
+ // below), so a cmdline guard would skip the launch every time. Bracketing can't escape it
239
+ // either — the payload text itself matches.
240
+ `(exec 3<>/dev/tcp/127.0.0.1/${CODE_SERVER_PORT}) 2>/dev/null || ` +
241
+ `nohup code-server --bind-addr 0.0.0.0:${CODE_SERVER_PORT} --auth none ` +
242
+ `--disable-workspace-trust \\"$PWD\\" >/tmp/code-server.log 2>&1 &` +
243
+ (blockingExtInstall ? '' : ` ${CODE_SERVER_INSTALL_EXT}`) +
244
+ `"`
245
+ );
246
+ }
202
247
 
203
248
  /** Staged next to the config; ttyd runs it as its command so the nested quoting stays in a real file. */
204
249
  const TTYD_LAUNCH_SCRIPT_FILE = 'codebay-terminal.sh';
@@ -216,7 +261,7 @@ export const TTYD_SHELL_ARG = 'shell';
216
261
  * compare and never an eval. The shell branch sits above the injections wait so the split view's
217
262
  * scratch shell opens immediately instead of blocking on Claude's boot sentinel.
218
263
  */
219
- const TTYD_LAUNCH_SCRIPT =
264
+ const ttydLaunchScript = (permissionMode: ClaudePermissionMode): string =>
220
265
  '#!/usr/bin/env bash\n' +
221
266
  'export SHELL="${SHELL:-/bin/bash}"\n' +
222
267
  `if [ "$1" = "${TTYD_SHELL_ARG}" ]; then\n` +
@@ -228,9 +273,11 @@ const TTYD_LAUNCH_SCRIPT =
228
273
  WAIT_FOR_INJECTIONS +
229
274
  '\n' +
230
275
  `if command -v tmux >/dev/null 2>&1; then\n` +
231
- ` exec tmux new-session -A -s ${TMUX_SESSION} 'claude --dangerously-skip-permissions; exec "$SHELL" -l'\n` +
276
+ ` exec tmux new-session -A -s ${TMUX_SESSION} '${SOURCE_INJECTED_ENV}claude ${claudePermissionFlags(permissionMode)}; exec "$SHELL" -l'\n` +
232
277
  'fi\n' +
233
- 'claude --dangerously-skip-permissions\n' +
278
+ SOURCE_INJECTED_ENV +
279
+ '\n' +
280
+ `claude ${claudePermissionFlags(permissionMode)}\n` +
234
281
  'exec "$SHELL" -l\n';
235
282
 
236
283
  // ttyd defaults to read-only, so --writable is required for keyboard input. Guarded by `pgrep -x`
@@ -246,6 +293,16 @@ const TTYD_LAUNCH =
246
293
  `nohup ttyd --port ${TTYD_PORT} --writable --url-arg ` +
247
294
  `bash \\"$PWD/.devcontainer/${TTYD_LAUNCH_SCRIPT_FILE}\\" >/tmp/ttyd.log 2>&1 &"`;
248
295
 
296
+ /**
297
+ * The launcher `postStartCommand` runs, exposed so a host-side relaunch can reuse the exact
298
+ * string rather than growing a second, driftable copy of "how the served surface is started".
299
+ */
300
+ export function launchCommandFor(mode: InstanceMode): string {
301
+ return mode === 'terminal'
302
+ ? TTYD_LAUNCH
303
+ : codeServerLaunch(getOption('advanced_blocking_ext_install') === '1');
304
+ }
305
+
249
306
  export async function devcontainerCliAvailable(): Promise<boolean> {
250
307
  return (await spawnCapture([devcontainerBin(), '--version'])) !== null;
251
308
  }
@@ -357,6 +414,7 @@ type DevcontainerConfig = {
357
414
  forwardPorts?: (number | string)[];
358
415
  postStartCommand?: unknown;
359
416
  runArgs?: string[];
417
+ containerEnv?: Record<string, string>;
360
418
  [key: string]: unknown;
361
419
  };
362
420
 
@@ -410,7 +468,9 @@ export async function writeOverrideConfig(
410
468
  hostPort: number,
411
469
  forwards: PortForward[] = [],
412
470
  defaultImage: string = DEFAULT_IMAGE,
413
- mode: InstanceMode = 'ide'
471
+ mode: InstanceMode = 'ide',
472
+ permissionMode: ClaudePermissionMode = 'default',
473
+ envVars: { name: string; value: string }[] = []
414
474
  ): Promise<{ imageSource: string }> {
415
475
  const isTerminal = mode === 'terminal';
416
476
  const target = configPath(workspaceDir);
@@ -474,26 +534,35 @@ export async function writeOverrideConfig(
474
534
  runArgs.add(HOST_GATEWAY_ARG);
475
535
  config.runArgs = [...runArgs];
476
536
 
477
- const launch = isTerminal ? TTYD_LAUNCH : CODE_SERVER_LAUNCH;
537
+ const launch = launchCommandFor(mode);
478
538
  const existing = config.postStartCommand;
479
539
  config.postStartCommand =
480
540
  typeof existing === 'string' && existing.trim() ? `${existing} && ${launch}` : launch;
481
541
 
542
+ // Baked onto the container at creation, so every process inherits it (broader than remoteEnv).
543
+ // The user's configured names win; a project's own containerEnv keys are otherwise preserved.
544
+ if (envVars.length) {
545
+ config.containerEnv = {
546
+ ...(config.containerEnv ?? {}),
547
+ ...Object.fromEntries(envVars.map((v) => [v.name, v.value]))
548
+ };
549
+ }
550
+
482
551
  await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
483
552
  await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
484
553
 
485
554
  if (isTerminal) {
486
555
  await writeTtydFeature(workspaceDir);
487
- await writeTerminalLaunchScript(workspaceDir);
556
+ await writeTerminalLaunchScript(workspaceDir, permissionMode);
488
557
  } else {
489
- // Staged next to the config; CODE_SERVER_LAUNCH copies it into the user-data dir on first start.
558
+ // Staged next to the config; codeServerLaunch copies it into the user-data dir on first start.
490
559
  await writeFile(
491
560
  join(workspaceDir, '.devcontainer', CODE_SERVER_SETTINGS_FILE),
492
561
  JSON.stringify(CODE_SERVER_SETTINGS, null, 2) + '\n',
493
562
  'utf8'
494
563
  );
495
564
  // The VS Code Terminal task is code-server-only; ttyd runs its own launcher script instead.
496
- await writeTerminalTask(workspaceDir);
565
+ await writeTerminalTask(workspaceDir, permissionMode);
497
566
  }
498
567
 
499
568
  await writeTmuxFeature(workspaceDir);
@@ -616,14 +685,20 @@ async function writeTtydFeature(workspaceDir: string): Promise<void> {
616
685
  }
617
686
 
618
687
  /** Staged next to the config; TTYD_LAUNCH runs it as ttyd's command. */
619
- async function writeTerminalLaunchScript(workspaceDir: string): Promise<void> {
688
+ async function writeTerminalLaunchScript(
689
+ workspaceDir: string,
690
+ permissionMode: ClaudePermissionMode
691
+ ): Promise<void> {
620
692
  const path = join(workspaceDir, '.devcontainer', TTYD_LAUNCH_SCRIPT_FILE);
621
- await writeFile(path, TTYD_LAUNCH_SCRIPT, 'utf8');
693
+ await writeFile(path, ttydLaunchScript(permissionMode), 'utf8');
622
694
  await chmod(path, 0o755);
623
695
  }
624
696
 
625
697
  /** Replaces the managed task rather than skipping it, so a rebuild picks up command changes. */
626
- async function writeTerminalTask(workspaceDir: string): Promise<void> {
698
+ async function writeTerminalTask(
699
+ workspaceDir: string,
700
+ permissionMode: ClaudePermissionMode
701
+ ): Promise<void> {
627
702
  const tasksPath = join(workspaceDir, '.vscode', 'tasks.json');
628
703
  let config: { version?: string; tasks?: unknown[] } = {};
629
704
 
@@ -641,9 +716,9 @@ async function writeTerminalTask(workspaceDir: string): Promise<void> {
641
716
  const isManagedTask = (t: unknown) =>
642
717
  typeof t === 'object' &&
643
718
  t !== null &&
644
- (t as Record<string, unknown>).label === TERMINAL_TASK.label &&
719
+ (t as Record<string, unknown>).label === TERMINAL_TASK_LABEL &&
645
720
  ((t as Record<string, { runOn?: string }>).runOptions?.runOn ?? '') === 'folderOpen';
646
- config.tasks = [...tasks.filter((t) => !isManagedTask(t)), TERMINAL_TASK];
721
+ config.tasks = [...tasks.filter((t) => !isManagedTask(t)), terminalTask(permissionMode)];
647
722
 
648
723
  await mkdir(join(workspaceDir, '.vscode'), { recursive: true }).catch(() => {});
649
724
  await writeFile(tasksPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
@@ -658,12 +733,7 @@ export interface UpResult {
658
733
  description?: string;
659
734
  }
660
735
 
661
- /** Streams all output to `onLog` and returns the parsed final result line. */
662
- export async function devcontainerUp(
663
- workspaceDir: string,
664
- onLog: (chunk: string) => void,
665
- opts: { noCache?: boolean } = {}
666
- ): Promise<UpResult> {
736
+ export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boolean } = {}) {
667
737
  const args = [
668
738
  devcontainerBin(),
669
739
  'up',
@@ -673,11 +743,28 @@ export async function devcontainerUp(
673
743
  ];
674
744
  // Only takes effect because --remove-existing-container drops the container before the build.
675
745
  if (opts.noCache) args.push('--build-no-cache');
676
- const proc = Bun.spawn(args, {
746
+ return args;
747
+ }
748
+
749
+ /** Defaults only — the spread lets an explicit user env win. BuildKit is standard on modern
750
+ * daemons; forcing it here just keeps feature-layer caching on older ones. */
751
+ export function devcontainerUpEnv(): Record<string, string | undefined> {
752
+ // Escape hatch for daemons where forcing BuildKit breaks builds (old builders, classic compose).
753
+ if (getOption('advanced_no_buildkit') === '1') return dockerEnv();
754
+ return { DOCKER_BUILDKIT: '1', COMPOSE_DOCKER_CLI_BUILD: '1', ...dockerEnv() };
755
+ }
756
+
757
+ /** Streams all output to `onLog` and returns the parsed final result line. */
758
+ export async function devcontainerUp(
759
+ workspaceDir: string,
760
+ onLog: (chunk: string) => void,
761
+ opts: { noCache?: boolean } = {}
762
+ ): Promise<UpResult> {
763
+ const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
677
764
  cwd: workspaceDir,
678
765
  stdout: 'pipe',
679
766
  stderr: 'pipe',
680
- env: dockerEnv()
767
+ env: devcontainerUpEnv()
681
768
  });
682
769
 
683
770
  let stdoutText = '';
@@ -1,21 +1,26 @@
1
1
  import { type InstanceRow } from './db.server.ts';
2
2
  import { isRunning, publishedContainerPorts } from './docker.server.ts';
3
- import { broadcastHealth } from './instances.server.ts';
3
+ import { broadcastHealth, relaunchSurface } from './instances.server.ts';
4
4
  import { resolveInjections } from './injections.server.ts';
5
5
  import type { InstanceHealth } from '../types.ts';
6
6
 
7
7
  const REFRESH_MS = 5000;
8
+ const FAST_REFRESH_MS = 1000;
9
+ const FAST_WINDOW_MS = 120_000;
8
10
 
9
11
  interface Monitor {
10
12
  snapshot: InstanceHealth | null;
11
- timer: ReturnType<typeof setInterval>;
13
+ timer: ReturnType<typeof setTimeout>;
14
+ startedAt: number;
15
+ everAccessible: boolean;
12
16
  }
13
17
 
14
18
  // Pin to globalThis so dev-mode hot reload doesn't orphan the interval timers.
15
19
  const globalForHealth = globalThis as unknown as { __codebayHealth?: Map<string, Monitor> };
16
20
  const monitors: Map<string, Monitor> = (globalForHealth.__codebayHealth ??= new Map());
17
21
 
18
- async function codeServerAccessible(port: number): Promise<boolean> {
22
+ /** Named for what it measures — the served surface is ttyd in terminal mode, code-server in IDE. */
23
+ export async function surfaceAccessible(port: number): Promise<boolean> {
19
24
  try {
20
25
  // Any HTTP response at all (200/302/401/…) means the server is listening.
21
26
  await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2000) });
@@ -38,7 +43,7 @@ async function check(row: InstanceRow): Promise<InstanceHealth> {
38
43
  // Probes as the recorded remote user, so `$HOME` resolves to the one the injection wrote to.
39
44
  const target = { containerId: row.container_id, remoteUser: row.remote_user, instance: row };
40
45
  const [accessible, openPorts, injectionResults] = await Promise.all([
41
- codeServerAccessible(row.host_port),
46
+ surfaceAccessible(row.host_port),
42
47
  publishedContainerPorts(row.container_id),
43
48
  Promise.all(
44
49
  resolveInjections(row.mode)
@@ -59,27 +64,66 @@ async function check(row: InstanceRow): Promise<InstanceHealth> {
59
64
  };
60
65
  }
61
66
 
62
- async function tick(row: InstanceRow): Promise<void> {
67
+ /** Probes at 1s until code-server first answers, so the IDE iframe can mount without waiting out
68
+ * the 5s cadence; the window cap keeps a permanently-dead code-server off the fast path. */
69
+ export function nextProbeDelay(
70
+ health: InstanceHealth,
71
+ everAccessible: boolean,
72
+ elapsedMs: number
73
+ ): number {
74
+ const waitingForFirstResponse =
75
+ health.containerRunning && !health.codeServerAccessible && !everAccessible;
76
+ return waitingForFirstResponse && elapsedMs < FAST_WINDOW_MS ? FAST_REFRESH_MS : REFRESH_MS;
77
+ }
78
+
79
+ async function tick(row: InstanceRow, mon: Monitor): Promise<void> {
63
80
  const snapshot = await check(row);
64
- const mon = monitors.get(row.id);
65
- if (mon) mon.snapshot = snapshot;
66
81
  broadcastHealth(row.id, snapshot);
67
- if (!snapshot.containerRunning) stopHealthMonitor(row.id);
82
+ // Replaced or stopped while the probe was in flight — rescheduling would leak a second chain.
83
+ if (monitors.get(row.id) !== mon) return;
84
+ if (!snapshot.containerRunning) {
85
+ stopHealthMonitor(row.id);
86
+ return;
87
+ }
88
+ mon.snapshot = snapshot;
89
+ mon.everAccessible ||= snapshot.codeServerAccessible;
90
+ const delay = nextProbeDelay(snapshot, mon.everAccessible, Date.now() - mon.startedAt);
91
+ mon.timer = setTimeout(() => void tick(row, mon), delay);
68
92
  }
69
93
 
70
94
  function startHealthMonitor(row: InstanceRow): Monitor {
71
95
  const existing = monitors.get(row.id);
72
96
  if (existing) return existing;
73
- const mon: Monitor = { snapshot: null, timer: setInterval(() => void tick(row), REFRESH_MS) };
97
+ const mon: Monitor = {
98
+ snapshot: null,
99
+ // Placeholder until the seed tick completes; it self-reschedules from then on.
100
+ timer: setTimeout(() => undefined, 0),
101
+ startedAt: Date.now(),
102
+ everAccessible: false
103
+ };
74
104
  monitors.set(row.id, mon);
75
- void tick(row); // seed the first snapshot rather than making the UI wait a full interval
105
+ // Seed a snapshot rather than making the UI wait a full interval (the tick self-reschedules
106
+ // the fast/slow probe chain from here), then use the seed to decide whether to relaunch.
107
+ void tick(row, mon).then(async () => {
108
+ if (monitors.get(row.id) !== mon) return;
109
+ const seeded = mon.snapshot;
110
+ if (!seeded?.containerRunning || seeded.codeServerAccessible) return;
111
+ // A live container with a dead port means postStartCommand never re-ran — it only fires
112
+ // under `devcontainer up`. Covers starts codebay didn't perform (a daemon restart, a bare
113
+ // `docker start`); once per monitor, so a genuinely wedged container isn't exec'd in a loop.
114
+ await relaunchSurface(row).catch(() => undefined);
115
+ if (monitors.get(row.id) !== mon) return;
116
+ // Drop the seed tick's already-scheduled probe and re-probe now, so a single chain survives.
117
+ clearTimeout(mon.timer);
118
+ void tick(row, mon);
119
+ });
76
120
  return mon;
77
121
  }
78
122
 
79
123
  export function stopHealthMonitor(id: string): void {
80
124
  const mon = monitors.get(id);
81
125
  if (!mon) return;
82
- clearInterval(mon.timer);
126
+ clearTimeout(mon.timer);
83
127
  monitors.delete(id);
84
128
  }
85
129
 
@@ -15,11 +15,13 @@ import { githubCredentials } from '../container-injections/github-credentials.ts
15
15
  import { attentionHooks } from '../container-injections/attention-hooks.ts';
16
16
  import { claudeStatusline } from '../container-injections/claude-statusline.ts';
17
17
  import { claudeModel } from '../container-injections/claude-model.ts';
18
- import { claudeSkipPermissions } from '../container-injections/claude-skip-permissions.ts';
18
+ import { claudeEffortLevel } from '../container-injections/claude-effort-level.ts';
19
+ import { claudePermissionMode } from '../container-injections/claude-permission-mode.ts';
19
20
  import { claudeTrust } from '../container-injections/claude-trust.ts';
20
21
  import { claudeAliases } from '../container-injections/claude-aliases.ts';
21
22
  import { claudeNoCoauthor } from '../container-injections/claude-no-coauthor.ts';
22
23
  import { hostEnvVars } from '../container-injections/host-env-vars.ts';
24
+ import { customEnvVars } from '../container-injections/custom-env-vars.ts';
23
25
 
24
26
  /** Extends `ExecTarget` so exec-user semantics have a single source of truth. */
25
27
  export interface ContainerTarget extends ExecTarget {
@@ -47,51 +49,56 @@ export interface Injection {
47
49
  check?(target: ContainerTarget): Promise<boolean>;
48
50
  }
49
51
 
50
- /** Split around the Claude slot, which `resolveInjections()` fills at call time. */
51
- const BASE_INJECTIONS_HEAD: Injection[] = [
52
- // Must stay first — every later git-touching injection depends on safe.directory.
53
- gitSafeDirectory,
54
- // The package install is the slowest injection, so start it before a folderOpen can find tmux missing.
55
- tmux,
56
- // Terminal-mode only (filtered by resolveInjections); a download, so run it early like tmux.
57
- ttyd,
58
- // Terminal-mode only, and ahead of every claude-* step in the tail, which all assume a binary.
59
- claudeCodeInstall,
60
- gitIdentity
61
- ];
62
-
63
- const BASE_INJECTIONS_TAIL: Injection[] = [
64
- // Refresh the binary to latest before the other claude-config steps run against it.
65
- claudeCodeUpdate,
66
- // Self-skips unless manual override is on and LiteLLM off, so it's safe in the always-run tail.
67
- claudeCodeModels,
68
- claudeCodeIdeExtension,
69
- githubCredentials,
70
- attentionHooks,
71
- claudeStatusline,
72
- claudeModel,
73
- claudeSkipPermissions,
74
- claudeTrust,
75
- claudeAliases,
76
- claudeNoCoauthor,
77
- hostEnvVars
78
- ];
52
+ /**
53
+ * Injections within a stage apply in parallel, so no two of them may write the same container
54
+ * resource; the stage boundaries serialize each shared resource in the same relative order the
55
+ * old flat list ran it (gitconfig, ~/.claude/settings.json, rc files, ~/.claude.json, apt/dpkg).
56
+ */
57
+ function buildStages(claudeInjection: Injection): Injection[][] {
58
+ return [
59
+ // Disjoint resources — and the slow network installs (apt tmux, npm/standalone claude,
60
+ // Open VSX extension) start immediately instead of queueing behind one another.
61
+ // claude-code-install (terminal-only) must precede claude-code-update: both are the sole
62
+ // npm-global writer of their stage, and the update no-ops until a binary exists.
63
+ [
64
+ gitSafeDirectory,
65
+ tmux,
66
+ claudeCodeInstall,
67
+ claudeInjection,
68
+ claudeCodeIdeExtension,
69
+ claudeEffortLevel
70
+ ],
71
+ // git-identity needs stage 1's safe.directory; ttyd shares the apt/dpkg lock with tmux and
72
+ // the /usr/local/bin symlink with claude-code-install, so it trails both.
73
+ [gitIdentity, attentionHooks, claudeCodeModels, ttyd, claudeCodeUpdate],
74
+ [githubCredentials, claudeStatusline, claudePermissionMode],
75
+ [claudeModel, claudeAliases],
76
+ // claude-trust edits ~/.claude.json, which the stage-1 Claude slot also writes.
77
+ // custom-env-vars writes nothing (values ride containerEnv) — it only verifies + health-checks.
78
+ [claudeTrust, hostEnvVars, customEnvVars],
79
+ [claudeNoCoauthor]
80
+ ];
81
+ }
79
82
 
80
83
  /**
81
84
  * Resolved per call so toggling the custom-endpoint setting takes effect without a restart.
82
85
  * `mode` drops injections that don't apply to it (e.g. ttyd off IDE instances, the IDE
83
86
  * extension off terminal ones); omit it in mode-agnostic contexts to keep every injection.
84
87
  */
85
- export function resolveInjections(mode?: InstanceMode): Injection[] {
88
+ export function resolveInjectionStages(mode?: InstanceMode): Injection[][] {
86
89
  const claudeInjection =
87
90
  getOption('custom_endpoint_enabled') === '1' ? claudeCodeCustom : claudeCodeCredentials;
88
- const all = [...BASE_INJECTIONS_HEAD, claudeInjection, ...BASE_INJECTIONS_TAIL];
89
- return mode ? all.filter((i) => !i.modes || i.modes.includes(mode)) : all;
91
+ const stages = buildStages(claudeInjection)
92
+ .map((stage) => (mode ? stage.filter((i) => !i.modes || i.modes.includes(mode)) : stage))
93
+ .filter((stage) => stage.length > 0);
94
+ // One injection per stage restores the fully serial boot for diagnosing injection interference.
95
+ return getOption('advanced_serial_injections') === '1' ? stages.flat().map((i) => [i]) : stages;
96
+ }
97
+
98
+ /** Flat view of `resolveInjectionStages()` for order-insensitive consumers (health, auth chips). */
99
+ export function resolveInjections(mode?: InstanceMode): Injection[] {
100
+ return resolveInjectionStages(mode).flat();
90
101
  }
91
102
 
92
103
  /** @deprecated Use `resolveInjections()`; this ignores settings/mode and is kept for the tests. */
93
- export const injections: Injection[] = [
94
- ...BASE_INJECTIONS_HEAD,
95
- claudeCodeCredentials,
96
- ...BASE_INJECTIONS_TAIL
97
- ];
104
+ export const injections: Injection[] = buildStages(claudeCodeCredentials).flat();