codebay 0.4.0 → 0.6.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 (110) hide show
  1. package/.mochi/manifest.json +272 -220
  2. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-1y0r9tae.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +13 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +1 -0
  6. package/.mochi/svelte-client/{_hydrate-SettingsView_8hmarwmf8onq-r9vvc7rx.js → _hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js} +3 -3
  7. package/.mochi/svelte-client/chunk-330mrspw.js +2 -0
  8. package/.mochi/svelte-client/{chunk-dz1019cy.js → chunk-4jgkak13.js} +1 -1
  9. package/.mochi/svelte-client/chunk-fqg2qzj8.js +2 -0
  10. package/.mochi/svelte-client/chunk-k5m4kt20.js +2 -0
  11. package/.mochi/svelte-client/{chunk-nmf4dq86.js → chunk-qj7zy2d9.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-ssa0ad2f.js → chunk-rpdphch7.js} +1 -1
  13. package/.mochi/svelte-compile/{App-9qjy14bc.server.js → App-v5mwmv6t.server.js} +26 -2
  14. package/.mochi/svelte-compile/{Avatars-sq8mjn6k.server.js → Avatars-pzyz0tv3.server.js} +75 -15
  15. package/.mochi/svelte-compile/{Instance-13j59daw.server.js → Instance-49ksrp11.server.js} +15 -3
  16. package/.mochi/svelte-compile/{Settings-p0s2awg1.server.js → Settings-sz5d6qj7.server.js} +3 -3
  17. package/.mochi/svelte-compile/{chunk-dfe2km6a.js → chunk-q8rbesaw.js} +324 -123
  18. package/.mochi/svelte-css/Avatar-3pi30db6ozvf0.css +1 -0
  19. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +1 -0
  20. package/.mochi/svelte-css/{AvatarsView-3hq37u4dgtljn.css → AvatarsView-2ughbzc26luyb.css} +1 -1
  21. package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +1 -0
  22. package/.mochi/svelte-css/{InstanceView-wq85bxtusont.css → InstanceView-3vzow3ttxzlh3.css} +1 -1
  23. package/README.md +2 -0
  24. package/package.json +2 -2
  25. package/src/avatars/anchor.ts +1 -0
  26. package/src/avatars/ant-head.ts +17 -0
  27. package/src/avatars/apple.ts +17 -0
  28. package/src/avatars/bear.ts +1 -0
  29. package/src/avatars/bee.ts +1 -0
  30. package/src/avatars/bodybuilder.ts +17 -0
  31. package/src/avatars/cat.ts +1 -0
  32. package/src/avatars/cherry.ts +1 -0
  33. package/src/avatars/crab.ts +1 -0
  34. package/src/avatars/crown.ts +1 -0
  35. package/src/avatars/diamond.ts +1 -0
  36. package/src/avatars/dog.ts +1 -0
  37. package/src/avatars/editor.ts +17 -0
  38. package/src/avatars/fish.ts +1 -0
  39. package/src/avatars/flower.ts +1 -0
  40. package/src/avatars/fox.ts +1 -0
  41. package/src/avatars/frog.ts +1 -0
  42. package/src/avatars/ghost.ts +1 -0
  43. package/src/avatars/heart.ts +8 -8
  44. package/src/avatars/horse.ts +17 -0
  45. package/src/avatars/index.ts +20 -0
  46. package/src/avatars/invader.ts +1 -0
  47. package/src/avatars/key.ts +1 -0
  48. package/src/avatars/lightning.ts +1 -0
  49. package/src/avatars/mushroom.ts +1 -0
  50. package/src/avatars/octopus.ts +1 -0
  51. package/src/avatars/owl.ts +1 -0
  52. package/src/avatars/penguin.ts +1 -0
  53. package/src/avatars/planet.ts +1 -0
  54. package/src/avatars/praying-mantis.ts +17 -0
  55. package/src/avatars/rabbit.ts +1 -0
  56. package/src/avatars/robot.ts +1 -0
  57. package/src/avatars/rocket.ts +1 -0
  58. package/src/avatars/serialize.ts +4 -3
  59. package/src/avatars/skull.ts +6 -6
  60. package/src/avatars/snail.ts +1 -0
  61. package/src/avatars/snowflake.ts +17 -0
  62. package/src/avatars/star.ts +1 -0
  63. package/src/avatars/strawberry.ts +17 -0
  64. package/src/avatars/target.ts +1 -0
  65. package/src/avatars/tree.ts +1 -0
  66. package/src/avatars/types.ts +6 -3
  67. package/src/avatars/vangogh-almond-blossoms.ts +17 -0
  68. package/src/avatars/wasp.ts +17 -0
  69. package/src/avatars/whale.ts +1 -0
  70. package/src/avatars/windmill.ts +17 -0
  71. package/src/components/AppShell.svelte +13 -0
  72. package/src/components/Avatar.svelte +4 -1
  73. package/src/components/AvatarEditor.svelte +83 -5
  74. package/src/components/AvatarsView.svelte +14 -1
  75. package/src/components/DashboardView.svelte +24 -2
  76. package/src/components/InstanceCard.svelte +55 -6
  77. package/src/components/InstanceView.svelte +41 -8
  78. package/src/components/SettingsView.svelte +2 -2
  79. package/src/container-injections/attention-hooks.ts +19 -38
  80. package/src/container-injections/claude-aliases.ts +7 -23
  81. package/src/container-injections/claude-code-credentials.ts +12 -8
  82. package/src/container-injections/claude-code-custom.ts +35 -48
  83. package/src/container-injections/claude-code-ide-extension.ts +39 -0
  84. package/src/container-injections/claude-code-models.ts +8 -24
  85. package/src/container-injections/claude-model.ts +43 -0
  86. package/src/container-injections/claude-no-coauthor.ts +14 -16
  87. package/src/container-injections/claude-skip-permissions.ts +7 -17
  88. package/src/container-injections/claude-statusline.ts +40 -27
  89. package/src/container-injections/claude-trust.ts +73 -0
  90. package/src/container-injections/host-env-vars.ts +13 -24
  91. package/src/container-injections/tmux.ts +2 -11
  92. package/src/index.ts +2 -0
  93. package/src/lib/claude-settings.server.ts +35 -0
  94. package/src/lib/container-files.server.ts +219 -0
  95. package/src/lib/devcontainer.server.ts +38 -1
  96. package/src/lib/exec.server.ts +2 -7
  97. package/src/lib/injections.server.ts +6 -0
  98. package/src/lib/instances.server.ts +14 -0
  99. package/src/lib/theme.server.ts +7 -3
  100. package/src/shell.html +2 -0
  101. package/.mochi/svelte-client/HydratableIsland-79z9xq4b.js +0 -1
  102. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-f5rzyjm8.js +0 -2
  103. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-fxyd5qx4.js +0 -13
  104. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-nv31ptdp.js +0 -1
  105. package/.mochi/svelte-client/chunk-6eexkr6p.js +0 -2
  106. package/.mochi/svelte-client/chunk-7znwaf7t.js +0 -2
  107. package/.mochi/svelte-client/chunk-zmmb1c4b.js +0 -2
  108. package/.mochi/svelte-css/Avatar-24th63awm9mna.css +0 -1
  109. package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +0 -1
  110. package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +0 -1
@@ -0,0 +1,73 @@
1
+ import { deepMerge, editJsonFile, readJsonFile } from '../lib/container-files.server.ts';
2
+ import {
3
+ CLAUDE_JSON_FILE,
4
+ mergeClaudeSettings,
5
+ readClaudeSettings
6
+ } from '../lib/claude-settings.server.ts';
7
+ import type { Injection } from '../lib/injections.server.ts';
8
+
9
+ /**
10
+ * Pre-accepts the three prompts `claude` shows on first launch, so a throwaway instance
11
+ * opens straight into a session: the folder-trust dialog and the MCP-server approval
12
+ * (both per-project, keyed by the path `claude` runs in), and the one-time Bypass
13
+ * Permissions acknowledgement (`--dangerously-skip-permissions` still trips it without this).
14
+ * Key names verified against the shipped `claude` binary.
15
+ */
16
+ export function claudeTrustConfig(workspacePath: string | null): Record<string, unknown> {
17
+ const cfg: Record<string, unknown> = {
18
+ // Idempotent with the credentials injection; keeps this self-sufficient when auth is skipped.
19
+ hasCompletedOnboarding: true,
20
+ bypassPermissionsModeAccepted: true
21
+ };
22
+ if (workspacePath) {
23
+ cfg.projects = {
24
+ [workspacePath]: {
25
+ hasTrustDialogAccepted: true,
26
+ hasCompletedProjectOnboarding: true,
27
+ enableAllProjectMcpServers: true
28
+ }
29
+ };
30
+ }
31
+ return cfg;
32
+ }
33
+
34
+ /** claude ≥2.1.220 keeps the bypass acknowledgement here; the `.claude.json` key stays for older versions. */
35
+ export const CLAUDE_TRUST_SETTINGS: Record<string, unknown> = {
36
+ skipDangerousModePermissionPrompt: true
37
+ };
38
+
39
+ /** Safe here only because instances are throwaway, single-tenant sandboxes. */
40
+ export const claudeTrust: Injection = {
41
+ id: 'claude-trust',
42
+ label: 'Claude trust & MCP auto-accept',
43
+
44
+ async apply(target, log) {
45
+ log('Pre-accepting Claude trust, MCP, and bypass-permissions prompts…\n');
46
+ // deepMerge recurses into `projects`, so the per-project keys layer onto any existing entries.
47
+ const res = await editJsonFile(target, CLAUDE_JSON_FILE, (cur) =>
48
+ deepMerge(cur, claudeTrustConfig(target.instance.remote_workspace_folder))
49
+ );
50
+ if (!res.ok) {
51
+ log(`⚠ Claude trust injection failed: ${res.error}\n`);
52
+ return;
53
+ }
54
+ const settings = await mergeClaudeSettings(target, CLAUDE_TRUST_SETTINGS);
55
+ log(
56
+ settings.ok
57
+ ? '✓ Claude startup prompts pre-accepted\n'
58
+ : `⚠ Claude trust injection failed: ${settings.error}\n`
59
+ );
60
+ },
61
+
62
+ // Not keyed on `.claude.json`'s `bypassPermissionsModeAccepted`: claude ≥2.1.220 drops
63
+ // that key (and `enableAllProjectMcpServers`) when it rewrites the file after startup.
64
+ async check(target) {
65
+ const settings = await readClaudeSettings(target);
66
+ if (settings?.skipDangerousModePermissionPrompt !== true) return false;
67
+ const workspace = target.instance.remote_workspace_folder;
68
+ if (!workspace) return true;
69
+ const cfg = await readJsonFile(target, CLAUDE_JSON_FILE);
70
+ const projects = cfg?.projects as Record<string, Record<string, unknown>> | undefined;
71
+ return projects?.[workspace]?.hasTrustDialogAccepted === true;
72
+ }
73
+ };
@@ -1,8 +1,13 @@
1
1
  import { getOption } from '../lib/db.server.ts';
2
- import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import {
3
+ containerFileExists,
4
+ shellEnvFile,
5
+ installShellEnvFile,
6
+ shellSingleQuote
7
+ } from '../lib/container-files.server.ts';
3
8
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
4
9
 
5
- const ENV_FILE = '~/.codebay-host-env';
10
+ const ENV_FILE_NAME = '.codebay-host-env';
6
11
 
7
12
  interface ResolvedVar {
8
13
  name: string;
@@ -47,31 +52,15 @@ export function hostEnvVarsConfig(): {
47
52
  }
48
53
 
49
54
  /** Values ride `stdin` as one payload, so they reach neither argv nor `docker inspect`. */
50
- async function injectHostEnvVars(
55
+ function injectHostEnvVars(
51
56
  target: ContainerTarget,
52
57
  resolved: ResolvedVar[]
53
58
  ): Promise<{ ok: boolean; error?: string }> {
54
59
  // Every interactive shell sources this file, so values must be single-quoted to stay literal.
55
- const payload = resolved
56
- .map(({ name, value }) => `export ${name}='${value.replaceAll("'", "'\\''")}'`)
57
- .join('\n');
58
- const script =
59
- 'set -e; f=$(eval echo "' +
60
- ENV_FILE +
61
- '"); ' +
62
- 'printf \'%s\\n\' "$CODEBAY_STDIN" > "$f"; chmod 600 "$f"; ' +
63
- // The grep guard keeps a re-apply from stacking duplicate source lines.
64
- 'h=$(eval echo ~$(id -un)); src="[ -f \\"$f\\" ] && . \\"$f\\""; ' +
65
- 'for rc in "$h/.bashrc" "$h/.zshrc"; do ' +
66
- 'grep -qF "$src" "$rc" 2>/dev/null || printf \'%s\\n\' "$src" >> "$rc"; ' +
67
- 'done';
68
-
69
- const res = await execInContainer(target, {
70
- script,
71
- stdin: payload,
72
- args: ['host-env-vars']
73
- });
74
- return res.ok ? { ok: true } : { ok: false, error: res.error };
60
+ const content =
61
+ resolved.map(({ name, value }) => `export ${name}=${shellSingleQuote(value)}`).join('\n') +
62
+ '\n';
63
+ return installShellEnvFile(target, ENV_FILE_NAME, content);
75
64
  }
76
65
 
77
66
  /**
@@ -103,6 +92,6 @@ export const hostEnvVars: Injection = {
103
92
  },
104
93
 
105
94
  async check(target) {
106
- return checkPresence(target, `f=$(eval echo "${ENV_FILE}"); [ -s "$f" ] && echo 1 || echo 0`);
95
+ return containerFileExists(target, shellEnvFile(ENV_FILE_NAME));
107
96
  }
108
97
  };
@@ -1,4 +1,5 @@
1
1
  import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import { appendLinesIfAbsent } from '../lib/container-files.server.ts';
2
3
  import type { Injection } from '../lib/injections.server.ts';
3
4
 
4
5
  /** apt-get is the real path (the default image is Debian); the rest cover project images. */
@@ -26,13 +27,6 @@ export const TMUX_CONF_LINES = [
26
27
  'bind m set -g mouse \\; display-message "mouse: #{?mouse,on,off}"'
27
28
  ];
28
29
 
29
- /** Lines arrive as `$@` so no conf text is interpolated into the loop body. */
30
- const CONF_SCRIPT =
31
- 'h=$(eval echo ~$(id -un)); f="$h/.tmux.conf"; ' +
32
- 'for line in "$@"; do ' +
33
- 'grep -qF "$line" "$f" 2>/dev/null || printf \'%s\\n\' "$line" >> "$f"; ' +
34
- 'done';
35
-
36
30
  const CHECK_SCRIPT = 'command -v tmux >/dev/null 2>&1 && echo 1 || echo 0';
37
31
 
38
32
  /**
@@ -54,10 +48,7 @@ export const tmux: Injection = {
54
48
  log(`⚠ tmux install failed: ${install.error} — terminal falls back to non-persistent mode\n`);
55
49
  return;
56
50
  }
57
- const conf = await execInContainer(target, {
58
- script: CONF_SCRIPT,
59
- args: ['tmux-conf', ...TMUX_CONF_LINES]
60
- });
51
+ const conf = await appendLinesIfAbsent(target, [{ name: '.tmux.conf' }], TMUX_CONF_LINES);
61
52
  log(conf.ok ? '✓ tmux installed\n' : `⚠ tmux conf setup failed: ${conf.error}\n`);
62
53
  },
63
54
 
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ ensureMochiKey();
27
27
  await Mochi.serve({
28
28
  port: PORT,
29
29
  hostname: HOST,
30
+ // Bun defaults to 10s and aborts slower form POSTs mid-flight; passed through to Bun.serve.
31
+ idleTimeout: 120,
30
32
  development: process.env.MODE === 'development',
31
33
  htmlShell: './src/shell.html',
32
34
  handle: sequence(basicAuth, themeHandle),
@@ -0,0 +1,35 @@
1
+ import type { ExecTarget } from './exec.server.ts';
2
+ import {
3
+ deepMerge,
4
+ editJsonFile,
5
+ readJsonFile,
6
+ type ContainerFile
7
+ } from './container-files.server.ts';
8
+
9
+ /** A file in `~/.claude` (or wherever `CLAUDE_CONFIG_DIR` points), e.g. settings.json or .bridge-header. */
10
+ export const claudeConfigFile = (name: string, mode?: string): ContainerFile => ({
11
+ dir: '${CLAUDE_CONFIG_DIR:-$h/.claude}',
12
+ name,
13
+ mode
14
+ });
15
+
16
+ /** `~/.claude/settings.json`, honoring a `CLAUDE_CONFIG_DIR` override. */
17
+ export const CLAUDE_SETTINGS_FILE: ContainerFile = claudeConfigFile('settings.json');
18
+
19
+ /** `~/.claude.json` sits beside the config dir, not inside it — hence a different dir expr. */
20
+ export const CLAUDE_JSON_FILE: ContainerFile = {
21
+ dir: '${CLAUDE_CONFIG_DIR:-$h}',
22
+ name: '.claude.json'
23
+ };
24
+
25
+ /** Deep-merges a patch into settings.json, letting the settings.json injections compose in any order. */
26
+ export function mergeClaudeSettings(
27
+ target: ExecTarget,
28
+ patch: Record<string, unknown>
29
+ ): Promise<{ ok: boolean; error?: string }> {
30
+ return editJsonFile(target, CLAUDE_SETTINGS_FILE, (current) => deepMerge(current, patch));
31
+ }
32
+
33
+ export function readClaudeSettings(target: ExecTarget): Promise<Record<string, unknown> | null> {
34
+ return readJsonFile<Record<string, unknown>>(target, CLAUDE_SETTINGS_FILE);
35
+ }
@@ -0,0 +1,219 @@
1
+ import { checkPresence, execInContainer, type ExecTarget } from './exec.server.ts';
2
+
3
+ /**
4
+ * A file inside a container, addressed by a shell expr for its dir (`$h` = the exec user's home) + name.
5
+ * Every field is interpolated into a bash script, so callers must pass literal, trusted values only.
6
+ */
7
+ export interface ContainerFile {
8
+ /** Shell expr for the containing dir; `$h` is bound to the resolved home. Defaults to `$h`. */
9
+ dir?: string;
10
+ name: string;
11
+ /** chmod mode applied on write. */
12
+ mode?: string;
13
+ }
14
+
15
+ /** Resolves the exec user's home the same way every injection historically has. */
16
+ const HOME_PRELUDE = 'h=$(eval echo ~$(id -un)); ';
17
+
18
+ const filePath = (file: ContainerFile): string => `${file.dir ?? '$h'}/${file.name}`;
19
+
20
+ /** The two rc files either shell may open, so an interactive-shell tweak must reach both. */
21
+ export const SHELL_RC_FILES: ContainerFile[] = [{ name: '.bashrc' }, { name: '.zshrc' }];
22
+
23
+ /** `bash -lc` runs profile scripts whose stdout precedes ours, so reads mark where real output starts. */
24
+ const READ_MARKER = '__CODEBAY_READ__';
25
+
26
+ export function readFileScript(file: ContainerFile): string {
27
+ // A status char follows the marker: A(bsent), O(k)+content, E (exists but unreadable).
28
+ return (
29
+ `${HOME_PRELUDE}f="${filePath(file)}"; ` +
30
+ `if [ ! -e "$f" ]; then printf '%sA' '${READ_MARKER}'; ` +
31
+ `elif c=$(cat "$f" 2>/dev/null); then printf '%sO%s' '${READ_MARKER}' "$c"; ` +
32
+ `else printf '%sE' '${READ_MARKER}'; fi`
33
+ );
34
+ }
35
+
36
+ type ContainerRead = { ok: true; content: string | null } | { ok: false; error: string };
37
+
38
+ /** Slices `readFileScript` output at the marker, so profile noise can't masquerade as file content. */
39
+ export function parseReadScriptOutput(stdout: string, name: string): ContainerRead {
40
+ const at = stdout.indexOf(READ_MARKER);
41
+ if (at === -1) return { ok: false, error: `read of ${name} produced no marker` };
42
+ const payload = stdout.slice(at + READ_MARKER.length);
43
+ if (payload[0] === 'A') return { ok: true, content: null };
44
+ if (payload[0] === 'O') return { ok: true, content: payload.slice(1) };
45
+ return { ok: false, error: `${name} exists but is unreadable` };
46
+ }
47
+
48
+ export function writeFileScript(file: ContainerFile): string {
49
+ const mode = file.mode ?? '644';
50
+ // `set -e` so a partial write can't exit 0 and get reported as a success.
51
+ return (
52
+ `set -e; ${HOME_PRELUDE}f="${filePath(file)}"; mkdir -p "$(dirname "$f")"; ` +
53
+ `printf '%s' "$CODEBAY_STDIN" > "$f"; chmod ${mode} "$f"`
54
+ );
55
+ }
56
+
57
+ /** Single-quotes a value for a sourced shell file so whatever it contains stays literal. */
58
+ export const shellSingleQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
59
+
60
+ /** Lines arrive as `$@` so none is interpolated into the loop body; each file gets a `grep -qF` guard. */
61
+ export function appendLinesScript(files: ContainerFile[]): string {
62
+ const paths = files.map((f) => `"${filePath(f)}"`).join(' ');
63
+ // `set -e` so a failed append (readonly fs, perms) can't exit 0 and get reported as a success.
64
+ return (
65
+ `set -e; ${HOME_PRELUDE}for line in "$@"; do ` +
66
+ `for f in ${paths}; do ` +
67
+ `mkdir -p "$(dirname "$f")"; ` +
68
+ `grep -qF "$line" "$f" 2>/dev/null || printf '%s\\n' "$line" >> "$f"; ` +
69
+ `done; done`
70
+ );
71
+ }
72
+
73
+ /** Read that distinguishes "absent" (`content: null`) from "couldn't read" (`ok: false`). */
74
+ export async function readContainerFileResult(
75
+ target: ExecTarget,
76
+ file: ContainerFile
77
+ ): Promise<ContainerRead> {
78
+ const res = await execInContainer(target, { script: readFileScript(file), capture: true });
79
+ if (!res.ok) return { ok: false, error: res.error ?? `read of ${file.name} failed` };
80
+ return parseReadScriptOutput(res.stdout, file.name);
81
+ }
82
+
83
+ /** Returns the file's contents, or null when it's absent, empty, or unreadable. Note: output capture trims trailing whitespace (incl. trailing newlines), so a whitespace-only file reads as null — fine for JSON callers, but don't use this where exact bytes matter. */
84
+ export async function readContainerFile(
85
+ target: ExecTarget,
86
+ file: ContainerFile
87
+ ): Promise<string | null> {
88
+ const res = await readContainerFileResult(target, file);
89
+ return res.ok && res.content ? res.content : null;
90
+ }
91
+
92
+ export async function writeContainerFile(
93
+ target: ExecTarget,
94
+ file: ContainerFile,
95
+ content: string
96
+ ): Promise<{ ok: boolean; error?: string }> {
97
+ const res = await execInContainer(target, { script: writeFileScript(file), stdin: content });
98
+ return res.ok ? { ok: true } : { ok: false, error: res.error };
99
+ }
100
+
101
+ /** Appends each line to every file it's missing from — the shared idempotent rc/conf write. */
102
+ export async function appendLinesIfAbsent(
103
+ target: ExecTarget,
104
+ files: ContainerFile[],
105
+ lines: string[]
106
+ ): Promise<{ ok: boolean; error?: string }> {
107
+ if (!lines.length) return { ok: true };
108
+ const res = await execInContainer(target, {
109
+ script: appendLinesScript(files),
110
+ args: ['append-lines', ...lines]
111
+ });
112
+ return res.ok ? { ok: true } : { ok: false, error: res.error };
113
+ }
114
+
115
+ /** A line counts as present when any listed file carries it — the either-rc-file check the injections have always used. */
116
+ export function linesPresentScript(files: ContainerFile[]): string {
117
+ const greps = files.map((f) => `grep -qF "$line" "${filePath(f)}" 2>/dev/null`).join(' || ');
118
+ return `${HOME_PRELUDE}for line in "$@"; do ${greps} || { echo 0; exit 0; }; done; echo 1`;
119
+ }
120
+
121
+ /** The `check()` counterpart of `appendLinesIfAbsent`. */
122
+ export function linesPresent(
123
+ target: ExecTarget,
124
+ files: ContainerFile[],
125
+ lines: string[]
126
+ ): Promise<boolean> {
127
+ return checkPresence(target, linesPresentScript(files), ['lines-present', ...lines]);
128
+ }
129
+
130
+ /** True when the file exists and is non-empty — a content-free probe for `check()`. */
131
+ export function containerFileExists(target: ExecTarget, file: ContainerFile): Promise<boolean> {
132
+ return checkPresence(
133
+ target,
134
+ `${HOME_PRELUDE}f="${filePath(file)}"; [ -s "$f" ] && echo 1 || echo 0`
135
+ );
136
+ }
137
+
138
+ /** `$HOME`-addressed so the write, the `check()` probe, and the sourced rc line all resolve one path. */
139
+ export const shellEnvFile = (name: string): ContainerFile => ({ dir: '$HOME', name, mode: '600' });
140
+
141
+ /** Writes a mode-600 env file in the home dir and guards a source line into both rc files. */
142
+ export async function installShellEnvFile(
143
+ target: ExecTarget,
144
+ name: string,
145
+ content: string
146
+ ): Promise<{ ok: boolean; error?: string }> {
147
+ const wrote = await writeContainerFile(target, shellEnvFile(name), content);
148
+ if (!wrote.ok) return wrote;
149
+ const path = `$HOME/${name}`;
150
+ return appendLinesIfAbsent(target, SHELL_RC_FILES, [`[ -f "${path}" ] && . "${path}"`]);
151
+ }
152
+
153
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
154
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
155
+ }
156
+
157
+ /** Mirrors jq's `.[0] * .[1]`: recurse into plain objects, replace arrays and scalars with the patch. */
158
+ export function deepMerge<T extends Record<string, unknown> = Record<string, unknown>>(
159
+ base: Record<string, unknown>,
160
+ patch: Record<string, unknown>
161
+ ): T {
162
+ const out: Record<string, unknown> = { ...base };
163
+ for (const [key, value] of Object.entries(patch)) {
164
+ // A `__proto__` key in parsed JSON would pollute Object.prototype via plain assignment.
165
+ if (key === '__proto__') continue;
166
+ const current = out[key];
167
+ out[key] = isPlainObject(value) && isPlainObject(current) ? deepMerge(current, value) : value;
168
+ }
169
+ return out as T;
170
+ }
171
+
172
+ type JsonRead = { ok: true; data: Record<string, unknown> | null } | { ok: false; error: string };
173
+
174
+ /** Absent/empty means "start fresh"; anything else must parse to an object, so callers can refuse destructive fallbacks. */
175
+ export function parseJsonRead(raw: string | null, name: string): JsonRead {
176
+ if (raw === null || raw === '') return { ok: true, data: null };
177
+ try {
178
+ const data: unknown = JSON.parse(raw);
179
+ if (!isPlainObject(data)) return { ok: false, error: `${name} is not a JSON object` };
180
+ return { ok: true, data };
181
+ } catch {
182
+ return { ok: false, error: `${name} exists but is not valid JSON` };
183
+ }
184
+ }
185
+
186
+ /** Parses the container file, treating an absent or invalid file as "no JSON" (null). */
187
+ export async function readJsonFile<T = Record<string, unknown>>(
188
+ target: ExecTarget,
189
+ file: ContainerFile
190
+ ): Promise<T | null> {
191
+ const parsed = parseJsonRead(await readContainerFile(target, file), file.name);
192
+ return parsed.ok ? (parsed.data as T | null) : null;
193
+ }
194
+
195
+ export async function writeJsonFile(
196
+ target: ExecTarget,
197
+ file: ContainerFile,
198
+ data: unknown
199
+ ): Promise<{ ok: boolean; error?: string }> {
200
+ return writeContainerFile(target, file, JSON.stringify(data, null, 2));
201
+ }
202
+
203
+ /**
204
+ * Read-modify-write: an absent/empty file starts from `{}`, but a failed read or unparseable
205
+ * existing content aborts the edit — overwriting what we couldn't read would silently destroy it.
206
+ */
207
+ export async function editJsonFile(
208
+ target: ExecTarget,
209
+ file: ContainerFile,
210
+ edit: (current: Record<string, unknown>) => Record<string, unknown> | void
211
+ ): Promise<{ ok: boolean; error?: string }> {
212
+ const read = await readContainerFileResult(target, file);
213
+ if (!read.ok) return { ok: false, error: `read before edit failed: ${read.error}` };
214
+ const parsed = parseJsonRead(read.content, file.name);
215
+ if (!parsed.ok) return { ok: false, error: parsed.error };
216
+ const current = parsed.data ?? {};
217
+ const next = edit(current) ?? current;
218
+ return writeJsonFile(target, file, next);
219
+ }
@@ -3,6 +3,7 @@ import { existsSync, statSync } from 'node:fs';
3
3
  import { basename, dirname, join, relative } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { INSTALL_SCRIPT as TMUX_INSTALL_SCRIPT } from '../container-injections/tmux.ts';
6
+ import { EXTENSION_ID as CLAUDE_CODE_EXTENSION_ID } from '../container-injections/claude-code-ide-extension.ts';
6
7
  import {
7
8
  CODE_SERVER_PORT,
8
9
  DEFAULT_IMAGE,
@@ -62,6 +63,13 @@ const EXCLUDE_MARKER_END = '# <<< codebay <<<';
62
63
 
63
64
  const CODE_SERVER_SETTINGS = {
64
65
  'workbench.colorTheme': 'Default Dark Modern',
66
+ // Stop VS Code re-resolving the theme from the OS/browser color scheme at runtime —
67
+ // that re-resolution (fired on focus/tab-switch) is what randomly flips the editor to
68
+ // light. Pinning both preferred themes to dark keeps either branch dark even if it does.
69
+ 'window.autoDetectColorScheme': false,
70
+ 'window.autoDetectHighContrast': false,
71
+ 'workbench.preferredDarkColorTheme': 'Default Dark Modern',
72
+ 'workbench.preferredLightColorTheme': 'Default Dark Modern',
65
73
  'workbench.secondarySideBar.defaultVisibility': 'hidden',
66
74
  'chat.commandCenter.enabled': false,
67
75
  // Instances are throwaway sandboxes — never nag to install recommended extensions.
@@ -78,6 +86,26 @@ const CODE_SERVER_SETTINGS = {
78
86
 
79
87
  const TMUX_SESSION = 'codebay';
80
88
 
89
+ /** Written to `$HOME` when the post-up injection sequence finishes; the terminal launcher waits on it. */
90
+ export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
91
+
92
+ /**
93
+ * An auto-launched `claude` that starts mid-injection reads `~/.claude.json` before the trust
94
+ * keys land and clobbers them on its next rewrite, so hold it until the sentinel appears.
95
+ * Bounded so a failed boot (sentinel never written) still yields a usable terminal.
96
+ */
97
+ const WAIT_FOR_INJECTIONS =
98
+ `[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "Waiting for codebay setup to finish…"; ` +
99
+ `i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge 60 ]; do sleep 1; i=$((i + 1)); done; `;
100
+
101
+ /**
102
+ * claude scans `~/.claude/ide/*.lock` once at startup and never retries, so it must not race
103
+ * the code-server extension host writing that lock — hold it briefly until the bridge appears.
104
+ * Bounded so an offline/uninstalled instance (lock never written) still yields a usable terminal.
105
+ * No single quotes: this is spliced into the single-quoted tmux command string below.
106
+ */
107
+ const WAIT_FOR_IDE_BRIDGE = `i=0; until ls "$HOME/.claude/ide/"*.lock >/dev/null 2>&1 || [ "$i" -ge 30 ]; do sleep 1; i=$((i + 1)); done; `;
108
+
81
109
  /**
82
110
  * Runs under tmux so Claude survives the browser closing — code-server reaps the
83
111
  * detached PTY, which only kills the tmux client. `-A` doubles as the run-once gate.
@@ -87,9 +115,11 @@ const TERMINAL_TASK = {
87
115
  type: 'shell',
88
116
  command:
89
117
  // `"$SHELL"` must reach tmux unexpanded; VS Code would substitute a `${…}` form first.
90
- `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} 'claude --dangerously-skip-permissions; exec "$SHELL" -l'; fi; ` +
118
+ `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; ` +
91
119
  // Without tmux there's no run-once gate, and folderOpen re-fires on every workspace load.
92
120
  'MARK="$HOME/.codebay-terminal-launched"; [ -e "$MARK" ] && exit 0; touch "$MARK"; ' +
121
+ WAIT_FOR_INJECTIONS +
122
+ WAIT_FOR_IDE_BRIDGE +
93
123
  'claude --dangerously-skip-permissions; exec ${env:SHELL} -l',
94
124
  presentation: { reveal: 'always', panel: 'shared', focus: true },
95
125
  runOptions: { runOn: 'folderOpen' },
@@ -101,8 +131,15 @@ const CODE_SERVER_APPLY_SETTINGS =
101
131
  `cp -f \\"$PWD/.devcontainer/${CODE_SERVER_SETTINGS_FILE}\\" ` +
102
132
  `~/.local/share/code-server/User/settings.json 2>/dev/null;`;
103
133
 
134
+ // Runs before code-server first launches so the extension host activates it on the first window
135
+ // (the /ide bridge, in-container over localhost); best-effort, offline-tolerant, skipped if present.
136
+ const CODE_SERVER_INSTALL_EXT =
137
+ `ls -d ~/.local/share/code-server/extensions/${CLAUDE_CODE_EXTENSION_ID}-* >/dev/null 2>&1 || ` +
138
+ `code-server --install-extension ${CLAUDE_CODE_EXTENSION_ID} >/tmp/code-server-ext.log 2>&1 || true; `;
139
+
104
140
  const CODE_SERVER_LAUNCH =
105
141
  `bash -c "${CODE_SERVER_APPLY_SETTINGS} ` +
142
+ `${CODE_SERVER_INSTALL_EXT} ` +
106
143
  // The bare default image may not export SHELL, which the Terminal task needs.
107
144
  `export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
108
145
  `pgrep -f 'code-server.*${CODE_SERVER_PORT}' >/dev/null 2>&1 || ` +
@@ -82,11 +82,6 @@ export async function checkPresence(
82
82
  args?: string[]
83
83
  ): Promise<boolean> {
84
84
  const res = await execInContainer(target, { script, args, capture: true });
85
- return res.ok && res.stdout === '1';
86
- }
87
-
88
- /** Only for secret-only files; injections mixing secret and non-secret content roll their own. */
89
- export function writeSecretFileScript(dirExpr: string, filename: string, mode = '600'): string {
90
- const path = `${dirExpr}/${filename}`;
91
- return `mkdir -p "${dirExpr}"; printf '%s' "$CODEBAY_STDIN" > "${path}"; chmod ${mode} "${path}";`;
85
+ // The login shell's profile noise precedes the probe's echo, so only the last line counts.
86
+ return res.ok && res.stdout.split('\n').at(-1)?.trim() === '1';
92
87
  }
@@ -6,11 +6,14 @@ import { tmux } from '../container-injections/tmux.ts';
6
6
  import { gitIdentity } from '../container-injections/git-identity.ts';
7
7
  import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
8
8
  import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
9
+ import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
9
10
  import { claudeCodeModels } from '../container-injections/claude-code-models.ts';
10
11
  import { githubCredentials } from '../container-injections/github-credentials.ts';
11
12
  import { attentionHooks } from '../container-injections/attention-hooks.ts';
12
13
  import { claudeStatusline } from '../container-injections/claude-statusline.ts';
14
+ import { claudeModel } from '../container-injections/claude-model.ts';
13
15
  import { claudeSkipPermissions } from '../container-injections/claude-skip-permissions.ts';
16
+ import { claudeTrust } from '../container-injections/claude-trust.ts';
14
17
  import { claudeAliases } from '../container-injections/claude-aliases.ts';
15
18
  import { claudeNoCoauthor } from '../container-injections/claude-no-coauthor.ts';
16
19
  import { hostEnvVars } from '../container-injections/host-env-vars.ts';
@@ -51,10 +54,13 @@ const BASE_INJECTIONS_HEAD: Injection[] = [
51
54
  const BASE_INJECTIONS_TAIL: Injection[] = [
52
55
  // Self-skips unless manual override is on and LiteLLM off, so it's safe in the always-run tail.
53
56
  claudeCodeModels,
57
+ claudeCodeIdeExtension,
54
58
  githubCredentials,
55
59
  attentionHooks,
56
60
  claudeStatusline,
61
+ claudeModel,
57
62
  claudeSkipPermissions,
63
+ claudeTrust,
58
64
  claudeAliases,
59
65
  claudeNoCoauthor,
60
66
  hostEnvVars
@@ -38,9 +38,11 @@ import {
38
38
  copyWorkspace,
39
39
  devcontainerCliAvailable,
40
40
  devcontainerUp,
41
+ INJECTIONS_DONE_FILE,
41
42
  readDeclaredContainerPorts,
42
43
  writeOverrideConfig
43
44
  } from './devcontainer.server.ts';
45
+ import { writeContainerFile } from './container-files.server.ts';
44
46
  import { clearAttention, getAttention } from './bridge.server.ts';
45
47
  import { proxyPathFor } from './proxy.server.ts';
46
48
  import { resolveInjections } from './injections.server.ts';
@@ -321,6 +323,13 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
321
323
  error: null
322
324
  });
323
325
 
326
+ // updateInstance writes the DB, not `row`; mirror the fields so `target.instance`
327
+ // (e.g. remote_workspace_folder, which claude-trust keys on) isn't the stale pre-boot row.
328
+ row.container_id = result.containerId;
329
+ row.remote_workspace_folder = result.remoteWorkspaceFolder ?? null;
330
+ row.remote_user = result.remoteUser ?? null;
331
+ row.status = 'running';
332
+
324
333
  const target = {
325
334
  containerId: result.containerId,
326
335
  remoteUser: result.remoteUser,
@@ -335,6 +344,11 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
335
344
  }
336
345
  }
337
346
 
347
+ // Unblocks the terminal launcher's wait; written even after ⚠s — its timeout is the fallback.
348
+ await writeContainerFile(target, { dir: '$HOME', name: INJECTIONS_DONE_FILE }, 'done\n').catch(
349
+ () => undefined
350
+ );
351
+
338
352
  appendLog(row.id, `\n✓ Instance running — open it via the proxy at ${proxyPathFor(row.id)}\n`);
339
353
  triggerReconcile();
340
354
  } catch (err) {
@@ -1,4 +1,5 @@
1
1
  import { getRequestContext, type Handle } from 'mochi-framework';
2
+ import { PROXY_PREFIX } from './proxy.server.ts';
2
3
 
3
4
  /** Anything but an explicit choice is left untouched, so the CSS falls through to `prefers-color-scheme`. */
4
5
  export function injectThemeAttribute(html: string, cookieValue: string | undefined): string {
@@ -11,12 +12,15 @@ export const themeHandle: Handle = ({ event, resolve }) =>
11
12
  resolve(event, {
12
13
  transformPage: ({ html }) => {
13
14
  // Mochi's fetch-fallback path also lands here, and it runs outside requestContext.
14
- let cookieValue: string | undefined;
15
+ let ctx: ReturnType<typeof getRequestContext>;
15
16
  try {
16
- cookieValue = getRequestContext().cookies.get('theme');
17
+ ctx = getRequestContext();
17
18
  } catch {
18
19
  return html;
19
20
  }
20
- return injectThemeAttribute(html, cookieValue);
21
+ // `data-theme` is only meaningful to Codebay's own stylesheet; injecting it into a
22
+ // proxied code-server document would rewrite HTML that isn't ours, so leave it alone.
23
+ if (ctx.url.pathname.startsWith(PROXY_PREFIX + '/')) return html;
24
+ return injectThemeAttribute(html, ctx.cookies.get('theme'));
21
25
  }
22
26
  });
package/src/shell.html CHANGED
@@ -16,6 +16,8 @@
16
16
  --ink: light-dark(#14150f, #e8e9df);
17
17
  --ink-soft: light-dark(#45463e, #b7b8a9);
18
18
  --ink-faint: light-dark(#7d7e74, #85867a);
19
+ /* Half-lit avatar LED — a true mid-tone sitting evenly between the off and on cells, so all three levels stay distinct even at tiny sizes. */
20
+ --led-gray: light-dark(#6b6c62, #8d8e83);
19
21
  --rule: light-dark(#20211a, #46473a);
20
22
  --rule-soft: light-dark(#b9bab2, #34352a);
21
23
  --warn-bg: light-dark(#e4d98f, #4a3d12);
@@ -1 +0,0 @@
1
- import{Ya as a}from"/_mochi/client/chunk-nmf4dq86.js";export{a as registerComponent};