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.
- package/.mochi/manifest.json +214 -147
- package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +2 -0
- package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js → _hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js} +1 -1
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +38 -0
- package/.mochi/svelte-client/{chunk-2j95sv83.js → chunk-0fjx3z3r.js} +1 -1
- package/.mochi/svelte-client/chunk-8xvr02wh.js +2 -0
- package/.mochi/svelte-client/{chunk-m497q88s.js → chunk-9143wy3j.js} +2 -2
- package/.mochi/svelte-client/{chunk-v65n8th6.js → chunk-bn731rda.js} +1 -1
- package/.mochi/svelte-client/{chunk-a2psjmrm.js → chunk-j72r8pkk.js} +2 -2
- package/.mochi/svelte-client/chunk-k37wj1jm.js +2 -0
- package/.mochi/svelte-client/{chunk-mpv3qx28.js → chunk-p20tkpde.js} +1 -1
- package/.mochi/svelte-client/chunk-pc0fm37c.js +2 -0
- package/.mochi/svelte-client/{chunk-6rbs0mhx.js → chunk-t0ap7q89.js} +1 -1
- package/.mochi/svelte-client/{chunk-rs41by6w.js → chunk-wvtkwfsh.js} +1 -1
- package/.mochi/svelte-compile/{App-7vjcp38v.server.js → App-59cdj86f.server.js} +167 -103
- package/.mochi/svelte-compile/{Avatars-e2cfc95t.server.js → Avatars-fmagfyj2.server.js} +1 -1
- package/.mochi/svelte-compile/{Instance-h78n2dsv.server.js → Instance-s1gqwzmx.server.js} +16 -10
- package/.mochi/svelte-compile/{Settings-tav2asn7.server.js → Settings-k447mt0x.server.js} +540 -88
- package/.mochi/svelte-compile/{chunk-brjv9pyd.js → chunk-3t7v4hk0.js} +1 -7
- package/.mochi/svelte-compile/chunk-gkjgybb2.js +15 -0
- package/.mochi/svelte-compile/{chunk-m8ygpyhr.js → chunk-th2dbqvc.js} +20 -32
- package/.mochi/svelte-css/Button-oharop2xz6am.css +1 -0
- package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +1 -0
- package/.mochi/svelte-css/{InstanceView-3vzow3ttxzlh3.css → InstanceView-1pd7j16bd2b16.css} +1 -1
- package/.mochi/svelte-css/SettingsView-n3oy75uonzp2.css +1 -0
- package/.mochi/svelte-css/{TopBar-2q87li6yt8ids.css → TopBar-2y76zxwehrfos.css} +1 -1
- package/migrations/0007_instance_avatar.sql +1 -0
- package/package.json +2 -2
- package/src/avatars/diamond.ts +5 -6
- package/src/avatars/index.ts +1 -16
- package/src/avatars/pick.server.ts +25 -0
- package/src/avatars/whale.ts +7 -8
- package/src/components/AppShell.svelte +29 -4
- package/src/components/Avatar.svelte +8 -8
- package/src/components/Button.svelte +22 -1
- package/src/components/DashboardView.svelte +14 -2
- package/src/components/FolderBrowser.svelte +6 -2
- package/src/components/IdeBar.svelte +56 -13
- package/src/components/IdeLoader.svelte +15 -3
- package/src/components/InstanceCard.svelte +6 -1
- package/src/components/InstanceView.svelte +33 -15
- package/src/components/SettingsView.svelte +548 -1
- package/src/components/TopBar.svelte +37 -10
- package/src/components/UiShowcase.svelte +5 -10
- package/src/container-injections/claude-aliases.ts +1 -1
- package/src/container-injections/claude-code-custom.ts +2 -1
- package/src/container-injections/claude-code-ide-extension.ts +5 -0
- package/src/container-injections/claude-code-models.ts +2 -1
- package/src/container-injections/claude-code-update.ts +62 -1
- package/src/container-injections/claude-effort-level.ts +32 -0
- package/src/container-injections/claude-permission-mode.ts +40 -0
- package/src/container-injections/custom-env-vars.ts +82 -0
- package/src/container-injections/host-env-vars.ts +2 -1
- package/src/lib/config.server.ts +3 -0
- package/src/lib/db.server.ts +8 -3
- package/src/lib/devcontainer.server.ts +155 -43
- package/src/lib/health.server.ts +55 -11
- package/src/lib/injections.server.ts +45 -38
- package/src/lib/instances.server.ts +246 -26
- package/src/lib/log-capture.server.ts +262 -0
- package/src/lib/ports.server.ts +31 -0
- package/src/lib/proxy.server.ts +2 -2
- package/src/pages/Settings.svelte +22 -1
- package/src/routes.ts +98 -2
- package/src/types.ts +36 -0
- package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +0 -2
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +0 -29
- package/.mochi/svelte-client/chunk-7wkvn2vx.js +0 -2
- package/.mochi/svelte-client/chunk-m4fzsd5b.js +0 -2
- package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +0 -1
- package/.mochi/svelte-css/IdeBar-10xodudcx57qr.css +0 -1
- package/.mochi/svelte-css/SettingsView-2qfauz68axro1.css +0 -1
- 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
|
|
19
|
-
import type
|
|
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,22 @@ 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
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Far above the ~107s a real injection phase measures: at 60s this expired mid-phase and released
|
|
155
|
+
* the launcher into the window where `claude-code-update`'s `npm install -g` had unlinked the
|
|
156
|
+
* binary. `provision()` writes the sentinel on its failure path too, so a dead boot never waits here.
|
|
157
|
+
*/
|
|
158
|
+
const INJECTIONS_WAIT_SECONDS = 600;
|
|
159
|
+
|
|
160
|
+
/** Long enough to ride out an in-flight `npm install -g` of Claude Code. */
|
|
161
|
+
const CLAUDE_BINARY_WAIT_SECONDS = 60;
|
|
162
|
+
|
|
144
163
|
/**
|
|
145
164
|
* An auto-launched `claude` that starts mid-injection reads `~/.claude.json` before the trust
|
|
146
165
|
* keys land and clobbers them on its next rewrite, so hold it until the sentinel appears.
|
|
@@ -148,7 +167,20 @@ export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
|
|
|
148
167
|
*/
|
|
149
168
|
const WAIT_FOR_INJECTIONS =
|
|
150
169
|
`[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "Waiting for codebay setup to finish…"; ` +
|
|
151
|
-
`i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge
|
|
170
|
+
`i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge ${INJECTIONS_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; ` +
|
|
171
|
+
`[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "codebay: setup did not finish in time — starting anyway"; `;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Never exec a binary that may not be there: the update injection unlinks `claude` while it
|
|
175
|
+
* reinstalls, and an IDE-mode project image is free to ship no Claude Code at all — both used to
|
|
176
|
+
* surface as a bare `command not found` under the "Waiting for codebay setup" line. The retry is
|
|
177
|
+
* skipped once the sentinel exists, since no injection can be mid-reinstall by then and a missing
|
|
178
|
+
* binary is permanent. No single quotes: this is spliced into the single-quoted tmux commands below.
|
|
179
|
+
*/
|
|
180
|
+
const launchClaude = (permissionMode: ClaudePermissionMode): string =>
|
|
181
|
+
`if [ ! -e "$HOME/${INJECTIONS_DONE_FILE}" ]; then i=0; until command -v claude >/dev/null 2>&1 || [ "$i" -ge ${CLAUDE_BINARY_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; fi; ` +
|
|
182
|
+
`if command -v claude >/dev/null 2>&1; then claude ${claudePermissionFlags(permissionMode)}; ` +
|
|
183
|
+
`else echo "codebay: claude is not installed in this container — check the boot log, then Rebuild"; fi; `;
|
|
152
184
|
|
|
153
185
|
/**
|
|
154
186
|
* claude scans `~/.claude/ide/*.lock` once at startup and never retries, so it must not race
|
|
@@ -160,45 +192,82 @@ const WAIT_FOR_INJECTIONS =
|
|
|
160
192
|
*/
|
|
161
193
|
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
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The injections deliver `ANTHROPIC_*` (and host) env vars through `~/.bashrc`/`~/.zshrc`, which no
|
|
197
|
+
* auto-launch path reads — ttyd runs `bash <script>` and tmux runs its command via `$SHELL -c`, both
|
|
198
|
+
* non-interactive. LiteLLM first, so it wins over the manual override if both files somehow exist.
|
|
199
|
+
* An explicit list, never a glob: tmux runs this under the user's shell, and zsh would print
|
|
200
|
+
* "no matches found" when a file is absent.
|
|
201
|
+
*/
|
|
202
|
+
const SOURCE_INJECTED_ENV = `for f in ${[CUSTOM_ENV_FILE, MODELS_ENV_FILE, HOST_ENV_FILE]
|
|
203
|
+
.map((name) => `"$HOME/${name}"`)
|
|
204
|
+
.join(' ')}; do [ -r "$f" ] && . "$f"; done; `;
|
|
205
|
+
|
|
206
|
+
/** The label/`runOn` pair that identifies our task in a project's own tasks.json. */
|
|
207
|
+
const TERMINAL_TASK_LABEL = 'Terminal';
|
|
208
|
+
|
|
163
209
|
/**
|
|
164
210
|
* Runs under tmux so Claude survives the browser closing — code-server reaps the
|
|
165
211
|
* detached PTY, which only kills the tmux client. `-A` doubles as the run-once gate.
|
|
166
212
|
*/
|
|
167
|
-
const
|
|
168
|
-
label:
|
|
213
|
+
const terminalTask = (permissionMode: ClaudePermissionMode) => ({
|
|
214
|
+
label: TERMINAL_TASK_LABEL,
|
|
169
215
|
type: 'shell',
|
|
170
216
|
command:
|
|
171
217
|
// `"$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}
|
|
218
|
+
`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}${launchClaude(permissionMode)}exec "$SHELL" -l'; fi; ` +
|
|
173
219
|
// Without tmux there's no run-once gate, and folderOpen re-fires on every workspace load.
|
|
174
|
-
'MARK="$HOME
|
|
220
|
+
'MARK="$HOME/' +
|
|
221
|
+
TERMINAL_LAUNCHED_MARKER +
|
|
222
|
+
'"; [ -e "$MARK" ] && exit 0; touch "$MARK"; ' +
|
|
175
223
|
WAIT_FOR_INJECTIONS +
|
|
176
224
|
WAIT_FOR_IDE_BRIDGE +
|
|
177
|
-
|
|
225
|
+
SOURCE_INJECTED_ENV +
|
|
226
|
+
launchClaude(permissionMode) +
|
|
227
|
+
'exec ${env:SHELL} -l',
|
|
178
228
|
presentation: { reveal: 'always', panel: 'shared', focus: true },
|
|
179
229
|
runOptions: { runOn: 'folderOpen' },
|
|
180
230
|
problemMatcher: []
|
|
181
|
-
};
|
|
231
|
+
});
|
|
182
232
|
|
|
183
233
|
const CODE_SERVER_APPLY_SETTINGS =
|
|
184
234
|
`mkdir -p ~/.local/share/code-server/User && ` +
|
|
185
235
|
`cp -f \\"$PWD/.devcontainer/${CODE_SERVER_SETTINGS_FILE}\\" ` +
|
|
186
236
|
`~/.local/share/code-server/User/settings.json 2>/dev/null;`;
|
|
187
237
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
238
|
+
// Fully detached (redirects + </dev/null + &) because `devcontainer up` waits for postStart's
|
|
239
|
+
// pipes to close — an attached download would keep the whole boot on the Open VSX critical path.
|
|
190
240
|
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}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
`
|
|
199
|
-
`
|
|
200
|
-
|
|
201
|
-
|
|
241
|
+
`( ls -d ~/.local/share/code-server/extensions/${CLAUDE_CODE_EXTENSION_ID}-* >/dev/null 2>&1 || ` +
|
|
242
|
+
`code-server --install-extension ${CLAUDE_CODE_EXTENSION_ID} ) ` +
|
|
243
|
+
`>/tmp/code-server-ext.log 2>&1 </dev/null &`;
|
|
244
|
+
|
|
245
|
+
// Foreground variant for the advanced escape hatch: slower boot, but the extension is guaranteed
|
|
246
|
+
// active on the very first window.
|
|
247
|
+
const CODE_SERVER_INSTALL_EXT_BLOCKING =
|
|
248
|
+
`( ls -d ~/.local/share/code-server/extensions/${CLAUDE_CODE_EXTENSION_ID}-* >/dev/null 2>&1 || ` +
|
|
249
|
+
`code-server --install-extension ${CLAUDE_CODE_EXTENSION_ID} ) ` +
|
|
250
|
+
`>/tmp/code-server-ext.log 2>&1; `;
|
|
251
|
+
|
|
252
|
+
// By default the extension installs in the background after launch (the first window may need a
|
|
253
|
+
// reload to activate it; the claude-code-ide-extension injection waits out this install as the fallback).
|
|
254
|
+
function codeServerLaunch(blockingExtInstall: boolean): string {
|
|
255
|
+
return (
|
|
256
|
+
`bash -c "${CODE_SERVER_APPLY_SETTINGS} ` +
|
|
257
|
+
(blockingExtInstall ? CODE_SERVER_INSTALL_EXT_BLOCKING : '') +
|
|
258
|
+
// The bare default image may not export SHELL, which the Terminal task needs.
|
|
259
|
+
`export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
|
|
260
|
+
// Probes the port rather than the process list: every `pgrep -f` pattern that matches a
|
|
261
|
+
// code-server daemon also matches this very shell (its own argv carries the nohup line
|
|
262
|
+
// below), so a cmdline guard would skip the launch every time. Bracketing can't escape it
|
|
263
|
+
// either — the payload text itself matches.
|
|
264
|
+
`(exec 3<>/dev/tcp/127.0.0.1/${CODE_SERVER_PORT}) 2>/dev/null || ` +
|
|
265
|
+
`nohup code-server --bind-addr 0.0.0.0:${CODE_SERVER_PORT} --auth none ` +
|
|
266
|
+
`--disable-workspace-trust \\"$PWD\\" >/tmp/code-server.log 2>&1 &` +
|
|
267
|
+
(blockingExtInstall ? '' : ` ${CODE_SERVER_INSTALL_EXT}`) +
|
|
268
|
+
`"`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
202
271
|
|
|
203
272
|
/** Staged next to the config; ttyd runs it as its command so the nested quoting stays in a real file. */
|
|
204
273
|
const TTYD_LAUNCH_SCRIPT_FILE = 'codebay-terminal.sh';
|
|
@@ -216,7 +285,7 @@ export const TTYD_SHELL_ARG = 'shell';
|
|
|
216
285
|
* compare and never an eval. The shell branch sits above the injections wait so the split view's
|
|
217
286
|
* scratch shell opens immediately instead of blocking on Claude's boot sentinel.
|
|
218
287
|
*/
|
|
219
|
-
const
|
|
288
|
+
const ttydLaunchScript = (permissionMode: ClaudePermissionMode): string =>
|
|
220
289
|
'#!/usr/bin/env bash\n' +
|
|
221
290
|
'export SHELL="${SHELL:-/bin/bash}"\n' +
|
|
222
291
|
`if [ "$1" = "${TTYD_SHELL_ARG}" ]; then\n` +
|
|
@@ -228,9 +297,12 @@ const TTYD_LAUNCH_SCRIPT =
|
|
|
228
297
|
WAIT_FOR_INJECTIONS +
|
|
229
298
|
'\n' +
|
|
230
299
|
`if command -v tmux >/dev/null 2>&1; then\n` +
|
|
231
|
-
` exec tmux new-session -A -s ${TMUX_SESSION} '
|
|
300
|
+
` exec tmux new-session -A -s ${TMUX_SESSION} '${SOURCE_INJECTED_ENV}${launchClaude(permissionMode)}exec "$SHELL" -l'\n` +
|
|
232
301
|
'fi\n' +
|
|
233
|
-
|
|
302
|
+
SOURCE_INJECTED_ENV +
|
|
303
|
+
'\n' +
|
|
304
|
+
launchClaude(permissionMode) +
|
|
305
|
+
'\n' +
|
|
234
306
|
'exec "$SHELL" -l\n';
|
|
235
307
|
|
|
236
308
|
// ttyd defaults to read-only, so --writable is required for keyboard input. Guarded by `pgrep -x`
|
|
@@ -246,6 +318,16 @@ const TTYD_LAUNCH =
|
|
|
246
318
|
`nohup ttyd --port ${TTYD_PORT} --writable --url-arg ` +
|
|
247
319
|
`bash \\"$PWD/.devcontainer/${TTYD_LAUNCH_SCRIPT_FILE}\\" >/tmp/ttyd.log 2>&1 &"`;
|
|
248
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The launcher `postStartCommand` runs, exposed so a host-side relaunch can reuse the exact
|
|
323
|
+
* string rather than growing a second, driftable copy of "how the served surface is started".
|
|
324
|
+
*/
|
|
325
|
+
export function launchCommandFor(mode: InstanceMode): string {
|
|
326
|
+
return mode === 'terminal'
|
|
327
|
+
? TTYD_LAUNCH
|
|
328
|
+
: codeServerLaunch(getOption('advanced_blocking_ext_install') === '1');
|
|
329
|
+
}
|
|
330
|
+
|
|
249
331
|
export async function devcontainerCliAvailable(): Promise<boolean> {
|
|
250
332
|
return (await spawnCapture([devcontainerBin(), '--version'])) !== null;
|
|
251
333
|
}
|
|
@@ -357,6 +439,7 @@ type DevcontainerConfig = {
|
|
|
357
439
|
forwardPorts?: (number | string)[];
|
|
358
440
|
postStartCommand?: unknown;
|
|
359
441
|
runArgs?: string[];
|
|
442
|
+
containerEnv?: Record<string, string>;
|
|
360
443
|
[key: string]: unknown;
|
|
361
444
|
};
|
|
362
445
|
|
|
@@ -410,7 +493,9 @@ export async function writeOverrideConfig(
|
|
|
410
493
|
hostPort: number,
|
|
411
494
|
forwards: PortForward[] = [],
|
|
412
495
|
defaultImage: string = DEFAULT_IMAGE,
|
|
413
|
-
mode: InstanceMode = 'ide'
|
|
496
|
+
mode: InstanceMode = 'ide',
|
|
497
|
+
permissionMode: ClaudePermissionMode = 'default',
|
|
498
|
+
envVars: { name: string; value: string }[] = []
|
|
414
499
|
): Promise<{ imageSource: string }> {
|
|
415
500
|
const isTerminal = mode === 'terminal';
|
|
416
501
|
const target = configPath(workspaceDir);
|
|
@@ -474,26 +559,35 @@ export async function writeOverrideConfig(
|
|
|
474
559
|
runArgs.add(HOST_GATEWAY_ARG);
|
|
475
560
|
config.runArgs = [...runArgs];
|
|
476
561
|
|
|
477
|
-
const launch =
|
|
562
|
+
const launch = launchCommandFor(mode);
|
|
478
563
|
const existing = config.postStartCommand;
|
|
479
564
|
config.postStartCommand =
|
|
480
565
|
typeof existing === 'string' && existing.trim() ? `${existing} && ${launch}` : launch;
|
|
481
566
|
|
|
567
|
+
// Baked onto the container at creation, so every process inherits it (broader than remoteEnv).
|
|
568
|
+
// The user's configured names win; a project's own containerEnv keys are otherwise preserved.
|
|
569
|
+
if (envVars.length) {
|
|
570
|
+
config.containerEnv = {
|
|
571
|
+
...(config.containerEnv ?? {}),
|
|
572
|
+
...Object.fromEntries(envVars.map((v) => [v.name, v.value]))
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
482
576
|
await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
|
|
483
577
|
await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
484
578
|
|
|
485
579
|
if (isTerminal) {
|
|
486
580
|
await writeTtydFeature(workspaceDir);
|
|
487
|
-
await writeTerminalLaunchScript(workspaceDir);
|
|
581
|
+
await writeTerminalLaunchScript(workspaceDir, permissionMode);
|
|
488
582
|
} else {
|
|
489
|
-
// Staged next to the config;
|
|
583
|
+
// Staged next to the config; codeServerLaunch copies it into the user-data dir on first start.
|
|
490
584
|
await writeFile(
|
|
491
585
|
join(workspaceDir, '.devcontainer', CODE_SERVER_SETTINGS_FILE),
|
|
492
586
|
JSON.stringify(CODE_SERVER_SETTINGS, null, 2) + '\n',
|
|
493
587
|
'utf8'
|
|
494
588
|
);
|
|
495
589
|
// The VS Code Terminal task is code-server-only; ttyd runs its own launcher script instead.
|
|
496
|
-
await writeTerminalTask(workspaceDir);
|
|
590
|
+
await writeTerminalTask(workspaceDir, permissionMode);
|
|
497
591
|
}
|
|
498
592
|
|
|
499
593
|
await writeTmuxFeature(workspaceDir);
|
|
@@ -616,14 +710,20 @@ async function writeTtydFeature(workspaceDir: string): Promise<void> {
|
|
|
616
710
|
}
|
|
617
711
|
|
|
618
712
|
/** Staged next to the config; TTYD_LAUNCH runs it as ttyd's command. */
|
|
619
|
-
async function writeTerminalLaunchScript(
|
|
713
|
+
async function writeTerminalLaunchScript(
|
|
714
|
+
workspaceDir: string,
|
|
715
|
+
permissionMode: ClaudePermissionMode
|
|
716
|
+
): Promise<void> {
|
|
620
717
|
const path = join(workspaceDir, '.devcontainer', TTYD_LAUNCH_SCRIPT_FILE);
|
|
621
|
-
await writeFile(path,
|
|
718
|
+
await writeFile(path, ttydLaunchScript(permissionMode), 'utf8');
|
|
622
719
|
await chmod(path, 0o755);
|
|
623
720
|
}
|
|
624
721
|
|
|
625
722
|
/** Replaces the managed task rather than skipping it, so a rebuild picks up command changes. */
|
|
626
|
-
async function writeTerminalTask(
|
|
723
|
+
async function writeTerminalTask(
|
|
724
|
+
workspaceDir: string,
|
|
725
|
+
permissionMode: ClaudePermissionMode
|
|
726
|
+
): Promise<void> {
|
|
627
727
|
const tasksPath = join(workspaceDir, '.vscode', 'tasks.json');
|
|
628
728
|
let config: { version?: string; tasks?: unknown[] } = {};
|
|
629
729
|
|
|
@@ -641,9 +741,9 @@ async function writeTerminalTask(workspaceDir: string): Promise<void> {
|
|
|
641
741
|
const isManagedTask = (t: unknown) =>
|
|
642
742
|
typeof t === 'object' &&
|
|
643
743
|
t !== null &&
|
|
644
|
-
(t as Record<string, unknown>).label ===
|
|
744
|
+
(t as Record<string, unknown>).label === TERMINAL_TASK_LABEL &&
|
|
645
745
|
((t as Record<string, { runOn?: string }>).runOptions?.runOn ?? '') === 'folderOpen';
|
|
646
|
-
config.tasks = [...tasks.filter((t) => !isManagedTask(t)),
|
|
746
|
+
config.tasks = [...tasks.filter((t) => !isManagedTask(t)), terminalTask(permissionMode)];
|
|
647
747
|
|
|
648
748
|
await mkdir(join(workspaceDir, '.vscode'), { recursive: true }).catch(() => {});
|
|
649
749
|
await writeFile(tasksPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
@@ -658,12 +758,7 @@ export interface UpResult {
|
|
|
658
758
|
description?: string;
|
|
659
759
|
}
|
|
660
760
|
|
|
661
|
-
|
|
662
|
-
export async function devcontainerUp(
|
|
663
|
-
workspaceDir: string,
|
|
664
|
-
onLog: (chunk: string) => void,
|
|
665
|
-
opts: { noCache?: boolean } = {}
|
|
666
|
-
): Promise<UpResult> {
|
|
761
|
+
export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boolean } = {}) {
|
|
667
762
|
const args = [
|
|
668
763
|
devcontainerBin(),
|
|
669
764
|
'up',
|
|
@@ -673,11 +768,28 @@ export async function devcontainerUp(
|
|
|
673
768
|
];
|
|
674
769
|
// Only takes effect because --remove-existing-container drops the container before the build.
|
|
675
770
|
if (opts.noCache) args.push('--build-no-cache');
|
|
676
|
-
|
|
771
|
+
return args;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Defaults only — the spread lets an explicit user env win. BuildKit is standard on modern
|
|
775
|
+
* daemons; forcing it here just keeps feature-layer caching on older ones. */
|
|
776
|
+
export function devcontainerUpEnv(): Record<string, string | undefined> {
|
|
777
|
+
// Escape hatch for daemons where forcing BuildKit breaks builds (old builders, classic compose).
|
|
778
|
+
if (getOption('advanced_no_buildkit') === '1') return dockerEnv();
|
|
779
|
+
return { DOCKER_BUILDKIT: '1', COMPOSE_DOCKER_CLI_BUILD: '1', ...dockerEnv() };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Streams all output to `onLog` and returns the parsed final result line. */
|
|
783
|
+
export async function devcontainerUp(
|
|
784
|
+
workspaceDir: string,
|
|
785
|
+
onLog: (chunk: string) => void,
|
|
786
|
+
opts: { noCache?: boolean } = {}
|
|
787
|
+
): Promise<UpResult> {
|
|
788
|
+
const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
|
|
677
789
|
cwd: workspaceDir,
|
|
678
790
|
stdout: 'pipe',
|
|
679
791
|
stderr: 'pipe',
|
|
680
|
-
env:
|
|
792
|
+
env: devcontainerUpEnv()
|
|
681
793
|
});
|
|
682
794
|
|
|
683
795
|
let stdoutText = '';
|
package/src/lib/health.server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
88
|
+
export function resolveInjectionStages(mode?: InstanceMode): Injection[][] {
|
|
86
89
|
const claudeInjection =
|
|
87
90
|
getOption('custom_endpoint_enabled') === '1' ? claudeCodeCustom : claudeCodeCredentials;
|
|
88
|
-
const
|
|
89
|
-
|
|
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();
|