codebay 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.mochi/manifest.json +214 -147
  2. package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +2 -0
  4. package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js → _hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js} +1 -1
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +1 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +38 -0
  7. package/.mochi/svelte-client/{chunk-2j95sv83.js → chunk-0fjx3z3r.js} +1 -1
  8. package/.mochi/svelte-client/chunk-8xvr02wh.js +2 -0
  9. package/.mochi/svelte-client/{chunk-m497q88s.js → chunk-9143wy3j.js} +2 -2
  10. package/.mochi/svelte-client/{chunk-v65n8th6.js → chunk-bn731rda.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-a2psjmrm.js → chunk-j72r8pkk.js} +2 -2
  12. package/.mochi/svelte-client/chunk-k37wj1jm.js +2 -0
  13. package/.mochi/svelte-client/{chunk-mpv3qx28.js → chunk-p20tkpde.js} +1 -1
  14. package/.mochi/svelte-client/chunk-pc0fm37c.js +2 -0
  15. package/.mochi/svelte-client/{chunk-6rbs0mhx.js → chunk-t0ap7q89.js} +1 -1
  16. package/.mochi/svelte-client/{chunk-rs41by6w.js → chunk-wvtkwfsh.js} +1 -1
  17. package/.mochi/svelte-compile/{App-7vjcp38v.server.js → App-59cdj86f.server.js} +167 -103
  18. package/.mochi/svelte-compile/{Avatars-e2cfc95t.server.js → Avatars-fmagfyj2.server.js} +1 -1
  19. package/.mochi/svelte-compile/{Instance-h78n2dsv.server.js → Instance-s1gqwzmx.server.js} +16 -10
  20. package/.mochi/svelte-compile/{Settings-tav2asn7.server.js → Settings-k447mt0x.server.js} +540 -88
  21. package/.mochi/svelte-compile/{chunk-brjv9pyd.js → chunk-3t7v4hk0.js} +1 -7
  22. package/.mochi/svelte-compile/chunk-gkjgybb2.js +15 -0
  23. package/.mochi/svelte-compile/{chunk-m8ygpyhr.js → chunk-th2dbqvc.js} +20 -32
  24. package/.mochi/svelte-css/Button-oharop2xz6am.css +1 -0
  25. package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +1 -0
  26. package/.mochi/svelte-css/{InstanceView-3vzow3ttxzlh3.css → InstanceView-1pd7j16bd2b16.css} +1 -1
  27. package/.mochi/svelte-css/SettingsView-n3oy75uonzp2.css +1 -0
  28. package/.mochi/svelte-css/{TopBar-2q87li6yt8ids.css → TopBar-2y76zxwehrfos.css} +1 -1
  29. package/migrations/0007_instance_avatar.sql +1 -0
  30. package/package.json +2 -2
  31. package/src/avatars/diamond.ts +5 -6
  32. package/src/avatars/index.ts +1 -16
  33. package/src/avatars/pick.server.ts +25 -0
  34. package/src/avatars/whale.ts +7 -8
  35. package/src/components/AppShell.svelte +29 -4
  36. package/src/components/Avatar.svelte +8 -8
  37. package/src/components/Button.svelte +22 -1
  38. package/src/components/DashboardView.svelte +14 -2
  39. package/src/components/FolderBrowser.svelte +6 -2
  40. package/src/components/IdeBar.svelte +56 -13
  41. package/src/components/IdeLoader.svelte +15 -3
  42. package/src/components/InstanceCard.svelte +6 -1
  43. package/src/components/InstanceView.svelte +33 -15
  44. package/src/components/SettingsView.svelte +548 -1
  45. package/src/components/TopBar.svelte +37 -10
  46. package/src/components/UiShowcase.svelte +5 -10
  47. package/src/container-injections/claude-aliases.ts +1 -1
  48. package/src/container-injections/claude-code-custom.ts +2 -1
  49. package/src/container-injections/claude-code-ide-extension.ts +5 -0
  50. package/src/container-injections/claude-code-models.ts +2 -1
  51. package/src/container-injections/claude-code-update.ts +62 -1
  52. package/src/container-injections/claude-effort-level.ts +32 -0
  53. package/src/container-injections/claude-permission-mode.ts +40 -0
  54. package/src/container-injections/custom-env-vars.ts +82 -0
  55. package/src/container-injections/host-env-vars.ts +2 -1
  56. package/src/lib/config.server.ts +3 -0
  57. package/src/lib/db.server.ts +8 -3
  58. package/src/lib/devcontainer.server.ts +155 -43
  59. package/src/lib/health.server.ts +55 -11
  60. package/src/lib/injections.server.ts +45 -38
  61. package/src/lib/instances.server.ts +246 -26
  62. package/src/lib/log-capture.server.ts +262 -0
  63. package/src/lib/ports.server.ts +31 -0
  64. package/src/lib/proxy.server.ts +2 -2
  65. package/src/pages/Settings.svelte +22 -1
  66. package/src/routes.ts +98 -2
  67. package/src/types.ts +36 -0
  68. package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +0 -1
  69. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +0 -2
  70. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +0 -1
  71. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +0 -29
  72. package/.mochi/svelte-client/chunk-7wkvn2vx.js +0 -2
  73. package/.mochi/svelte-client/chunk-m4fzsd5b.js +0 -2
  74. package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +0 -1
  75. package/.mochi/svelte-css/IdeBar-10xodudcx57qr.css +0 -1
  76. package/.mochi/svelte-css/SettingsView-2qfauz68axro1.css +0 -1
  77. package/src/container-injections/claude-skip-permissions.ts +0 -28
@@ -0,0 +1,262 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ writeFileSync
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { LOGS_DIR } from './config.server.ts';
12
+ import { getInstance, type InstanceRow } from './db.server.ts';
13
+ import { isRunning } from './docker.server.ts';
14
+ import { execInContainer } from './exec.server.ts';
15
+
16
+ /** Delta pulls are cheap, so a lazier cadence than the 5s health probe is plenty. */
17
+ const CAPTURE_MS = 10_000;
18
+
19
+ const MANIFEST_MARKER = '__CODEBAY_MANIFEST__';
20
+ const FETCH_MARKER = '__CODEBAY_FETCH__';
21
+ const FILE_MARKER = '__CODEBAY_FILE__';
22
+ const END_MARKER = '__CODEBAY_END__';
23
+
24
+ export interface ManifestEntry {
25
+ path: string;
26
+ size: number;
27
+ }
28
+
29
+ interface FetchJob {
30
+ path: string;
31
+ hostFileName: string;
32
+ /** 1-indexed byte to `tail -c +N` from; 1 re-pulls the whole file. */
33
+ startByte: number;
34
+ mode: 'append' | 'rollover';
35
+ }
36
+
37
+ /**
38
+ * Lists the transcript + history files with their byte sizes. `find`/`wc -c` (not GNU `-printf`)
39
+ * so it works on busybox containers too; the marker line lets the parser skip login-shell noise.
40
+ */
41
+ function manifestScript(): string {
42
+ return (
43
+ `h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
44
+ `printf '%s\\n' '${MANIFEST_MARKER}'; ` +
45
+ `{ find "$cfg" -maxdepth 1 -type f -name 'history.jsonl' 2>/dev/null; ` +
46
+ `find "$cfg/projects" -type f -name '*.jsonl' 2>/dev/null; } | ` +
47
+ `while IFS= read -r f; do printf '%s\\t%s\\n' "$f" "$(wc -c < "$f" | tr -d ' ')"; done; true`
48
+ );
49
+ }
50
+
51
+ /** Emits, per requested file, a framed block of base64 bytes from `startByte` to EOF. */
52
+ function fetchScript(): string {
53
+ return (
54
+ `printf '%s\\n' '${FETCH_MARKER}'; ` +
55
+ `while [ "$#" -ge 2 ]; do p="$1"; o="$2"; shift 2; ` +
56
+ `printf '${FILE_MARKER}\\t%s\\n' "$p"; ` +
57
+ `tail -c "+$o" "$p" 2>/dev/null | base64; ` +
58
+ `printf '\\n${END_MARKER}\\n'; done`
59
+ );
60
+ }
61
+
62
+ export function parseManifest(stdout: string): ManifestEntry[] {
63
+ const at = stdout.indexOf(MANIFEST_MARKER);
64
+ if (at === -1) return [];
65
+ const out: ManifestEntry[] = [];
66
+ for (const line of stdout.slice(at + MANIFEST_MARKER.length).split('\n')) {
67
+ const tab = line.indexOf('\t');
68
+ if (tab === -1) continue;
69
+ const path = line.slice(0, tab);
70
+ const size = Number(line.slice(tab + 1).trim());
71
+ if (path && Number.isFinite(size)) out.push({ path, size });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** A `/projects/` path is a session transcript; the only other file we list is `history.jsonl`. */
77
+ export function hostFileNameFor(instanceId: string, containerPath: string): string {
78
+ const base = containerPath.split('/').pop() ?? containerPath;
79
+ if (containerPath.includes('/projects/')) {
80
+ return `transcript-${instanceId}-${base.replace(/\.jsonl$/, '')}.jsonl`;
81
+ }
82
+ return `history-${instanceId}.jsonl`;
83
+ }
84
+
85
+ /** Diffs container sizes against the host mirrors: append new bytes, or re-pull a file that shrank. */
86
+ export function planFetch(
87
+ instanceId: string,
88
+ manifest: ManifestEntry[],
89
+ hostSize: (hostFileName: string) => number
90
+ ): FetchJob[] {
91
+ const jobs: FetchJob[] = [];
92
+ for (const { path, size } of manifest) {
93
+ const hostFileName = hostFileNameFor(instanceId, path);
94
+ const existing = hostSize(hostFileName);
95
+ if (size === existing) continue;
96
+ if (size < existing) jobs.push({ path, hostFileName, startByte: 1, mode: 'rollover' });
97
+ else jobs.push({ path, hostFileName, startByte: existing + 1, mode: 'append' });
98
+ }
99
+ return jobs;
100
+ }
101
+
102
+ export function parseFetchBlocks(stdout: string): { path: string; base64: string }[] {
103
+ const at = stdout.indexOf(FETCH_MARKER);
104
+ if (at === -1) return [];
105
+ const blocks: { path: string; base64: string }[] = [];
106
+ for (const part of stdout
107
+ .slice(at + FETCH_MARKER.length)
108
+ .split(FILE_MARKER)
109
+ .slice(1)) {
110
+ const end = part.indexOf(END_MARKER);
111
+ const seg = end === -1 ? part : part.slice(0, end);
112
+ const nl = seg.indexOf('\n');
113
+ if (nl === -1) continue;
114
+ const path = seg.slice(0, nl).replace(/^\t/, '');
115
+ // base64 wrapping (GNU wraps at 76 cols) is stripped so multi-line blocks decode cleanly.
116
+ blocks.push({ path, base64: seg.slice(nl + 1).replace(/\s+/g, '') });
117
+ }
118
+ return blocks;
119
+ }
120
+
121
+ function sizeOnDisk(path: string): number {
122
+ try {
123
+ return statSync(path).size;
124
+ } catch {
125
+ return 0;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * A rebuild recreates the container (`--remove-existing-container`), so its `~/.claude` starts empty
131
+ * and the next manifest reports a file smaller than our mirror. Archiving the mirror aside — rather
132
+ * than overwriting it with the new container's few hundred bytes — is what makes retention real.
133
+ */
134
+ function rollOver(dest: string): void {
135
+ if (!existsSync(dest)) return;
136
+ const dot = dest.lastIndexOf('.');
137
+ const stem = dot === -1 ? dest : dest.slice(0, dot);
138
+ const ext = dot === -1 ? '' : dest.slice(dot);
139
+ for (let n = 1; ; n++) {
140
+ const archive = `${stem}.${n}${ext}`;
141
+ if (existsSync(archive)) continue;
142
+ renameSync(dest, archive);
143
+ return;
144
+ }
145
+ }
146
+
147
+ /** Keeps `index.json` mapping the opaque UUID filenames back to a human-readable instance. */
148
+ function updateIndex(logsDir: string, row: InstanceRow): void {
149
+ const indexPath = join(logsDir, 'index.json');
150
+ let index: Record<string, unknown> = {};
151
+ try {
152
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf8'));
153
+ if (parsed && typeof parsed === 'object') index = parsed as Record<string, unknown>;
154
+ } catch {
155
+ // Absent or corrupt — start fresh rather than lose this pass.
156
+ }
157
+ index[row.id] = {
158
+ name: row.name,
159
+ source_path: row.source_path,
160
+ created_at: row.created_at,
161
+ last_captured: Date.now()
162
+ };
163
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
164
+ }
165
+
166
+ interface CaptureDeps {
167
+ exec?: typeof execInContainer;
168
+ logsDir?: string;
169
+ }
170
+
171
+ /**
172
+ * One extraction pass: manifest → plan deltas → fetch → append/roll over host mirrors → index.
173
+ * Resolves false when the container could not be read, which is how `tick` detects a dead chain.
174
+ */
175
+ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}): Promise<boolean> {
176
+ if (!row.container_id) return false;
177
+ const exec = deps.exec ?? execInContainer;
178
+ const logsDir = deps.logsDir ?? LOGS_DIR;
179
+ const target = { containerId: row.container_id, remoteUser: row.remote_user };
180
+
181
+ const manifestRes = await exec(target, { script: manifestScript(), capture: true });
182
+ if (!manifestRes.ok) return false;
183
+ const manifest = parseManifest(manifestRes.stdout);
184
+
185
+ mkdirSync(logsDir, { recursive: true });
186
+ const jobs = planFetch(row.id, manifest, (name) => sizeOnDisk(join(logsDir, name)));
187
+
188
+ if (jobs.length) {
189
+ const args = ['fetch'];
190
+ for (const j of jobs) args.push(j.path, String(j.startByte));
191
+ const fetchRes = await exec(target, { script: fetchScript(), args, capture: true });
192
+ if (fetchRes.ok) {
193
+ const byPath = new Map(jobs.map((j) => [j.path, j]));
194
+ for (const block of parseFetchBlocks(fetchRes.stdout)) {
195
+ const job = byPath.get(block.path);
196
+ if (!job) continue;
197
+ const bytes = Buffer.from(block.base64, 'base64');
198
+ const dest = join(logsDir, job.hostFileName);
199
+ if (job.mode === 'rollover') rollOver(dest);
200
+ appendFileSync(dest, bytes);
201
+ }
202
+ }
203
+ }
204
+ updateIndex(logsDir, row);
205
+ return true;
206
+ }
207
+
208
+ interface Capture {
209
+ timer: ReturnType<typeof setTimeout>;
210
+ }
211
+
212
+ // Pinned to globalThis so dev-mode hot reload doesn't orphan the interval timers.
213
+ const globalForCapture = globalThis as unknown as { __codebayLogCapture?: Map<string, Capture> };
214
+ const captures: Map<string, Capture> = (globalForCapture.__codebayLogCapture ??= new Map());
215
+
216
+ async function tick(id: string, cap: Capture): Promise<void> {
217
+ // Re-read every tick: a rename has to reach index.json, and a stale container_id would exec
218
+ // against a container that no longer exists.
219
+ const row = getInstance(id);
220
+ // Best-effort: a failed pass (container just stopped, transient exec error) must not kill the loop.
221
+ const ok = row ? await runCapturePass(row).catch(() => false) : false;
222
+ // syncLogCapture would normally stop us, but it only runs from the reconcile loop, which is idle
223
+ // whenever no client is connected — so a container that died on its own has to end the chain here.
224
+ const alive =
225
+ ok || (!!row?.container_id && (await isRunning(row.container_id).catch(() => false)));
226
+ // Stopped or replaced while the pass was in flight — rescheduling would leak a second chain.
227
+ if (captures.get(id) !== cap) return;
228
+ if (!alive) {
229
+ stopLogCapture(id);
230
+ return;
231
+ }
232
+ cap.timer = setTimeout(() => void tick(id, cap), CAPTURE_MS);
233
+ }
234
+
235
+ function startLogCapture(id: string): void {
236
+ if (captures.has(id)) return;
237
+ const cap: Capture = { timer: setTimeout(() => undefined, 0) };
238
+ captures.set(id, cap);
239
+ // Seed a pass now so a short-lived session is captured without waiting a full interval.
240
+ void tick(id, cap);
241
+ }
242
+
243
+ /** Stops the timer only — the mirrored logs are retained on disk past instance deletion. */
244
+ export function stopLogCapture(id: string): void {
245
+ const cap = captures.get(id);
246
+ if (!cap) return;
247
+ clearTimeout(cap.timer);
248
+ captures.delete(id);
249
+ }
250
+
251
+ /** Driven from the reconcile loop, so capture tracks the container lifecycle automatically. */
252
+ export function syncLogCapture(rows: InstanceRow[]): void {
253
+ const running = new Set(
254
+ rows.filter((r) => r.status === 'running' && r.container_id).map((r) => r.id)
255
+ );
256
+ for (const row of rows) {
257
+ if (running.has(row.id) && !captures.has(row.id)) startLogCapture(row.id);
258
+ }
259
+ for (const id of [...captures.keys()]) {
260
+ if (!running.has(id)) stopLogCapture(id);
261
+ }
262
+ }
@@ -7,3 +7,34 @@ export function pickFreePort(usedSets: ReadonlySet<number>[]): number {
7
7
  }
8
8
  throw new Error('No free host ports available.');
9
9
  }
10
+
11
+ /**
12
+ * A listener outside Docker's view is invisible to the DB/Docker union — and on a VM-backed daemon
13
+ * the collision never surfaces as an error, because the `-p` bind happens inside the VM where the
14
+ * port is free while the host-side forward fails (colima logs that as a warning). Claiming the port
15
+ * ourselves first is the only way the collision reaches us.
16
+ */
17
+ export async function isHostPortBindable(port: number): Promise<boolean> {
18
+ try {
19
+ // Loopback specifically: it's where code-server is always published, and a wildcard squatter
20
+ // blocks this bind too, so the one probe covers both.
21
+ const server = Bun.listen({ hostname: '127.0.0.1', port, socket: { data() {} } });
22
+ server.stop(true);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** Takes the probe as a parameter so the scan is testable without binding real sockets. */
30
+ export async function pickBindablePort(
31
+ usedSets: ReadonlySet<number>[],
32
+ isBindable: (port: number) => Promise<boolean>
33
+ ): Promise<number> {
34
+ const rejected = new Set<number>();
35
+ for (;;) {
36
+ const port = pickFreePort([...usedSets, rejected]);
37
+ if (await isBindable(port)) return port;
38
+ rejected.add(port);
39
+ }
40
+ }
@@ -74,10 +74,10 @@ async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Prom
74
74
  duplex: 'half'
75
75
  });
76
76
  } catch (err) {
77
- // Usually just code-server not having bound its port yet; letting it escape would
77
+ // Usually just the served surface not having bound its port yet; letting it escape would
78
78
  // render a 500 stack trace inside the IDE iframe.
79
79
  console.warn(`[proxy] upstream 127.0.0.1:${port}${rest} unreachable:`, (err as Error).message);
80
- return new Response('code-server is not accepting connections yet', {
80
+ return new Response('The instance is not accepting connections yet', {
81
81
  status: 503,
82
82
  headers: { 'retry-after': '1', 'cache-control': 'no-store' }
83
83
  });
@@ -3,11 +3,13 @@
3
3
  import '@fontsource-variable/jetbrains-mono';
4
4
  import SettingsView from '../components/SettingsView.svelte';
5
5
  import type { AvatarArt } from '../avatars/index.ts';
6
- import type { InstanceMode } from '../types.ts';
6
+ import type { ClaudeEffortLevel, ClaudePermissionMode, InstanceMode } from '../types.ts';
7
7
 
8
8
  let {
9
9
  pet,
10
10
  defaultMode,
11
+ claudePermissionMode,
12
+ claudeEffortLevel,
11
13
  defaultImage,
12
14
  builtinImage,
13
15
  disableBuildCache,
@@ -37,10 +39,17 @@
37
39
  hostEnvVarsEnabled,
38
40
  hostEnvVarNames,
39
41
  hostEnvVarPresence,
42
+ customEnvVarsEnabled,
43
+ customEnvVarNames,
44
+ advancedSerialInjections,
45
+ advancedNoBuildkit,
46
+ advancedBlockingExtInstall,
40
47
  version
41
48
  }: {
42
49
  pet?: AvatarArt;
43
50
  defaultMode: InstanceMode;
51
+ claudePermissionMode: ClaudePermissionMode;
52
+ claudeEffortLevel: ClaudeEffortLevel;
44
53
  defaultImage: string;
45
54
  builtinImage: string;
46
55
  disableBuildCache: boolean;
@@ -70,6 +79,11 @@
70
79
  hostEnvVarsEnabled: boolean;
71
80
  hostEnvVarNames: string[];
72
81
  hostEnvVarPresence: Record<string, boolean>;
82
+ customEnvVarsEnabled: boolean;
83
+ customEnvVarNames: string[];
84
+ advancedSerialInjections: boolean;
85
+ advancedNoBuildkit: boolean;
86
+ advancedBlockingExtInstall: boolean;
73
87
  version: string;
74
88
  } = $props();
75
89
  </script>
@@ -77,6 +91,8 @@
77
91
  <SettingsView
78
92
  {pet}
79
93
  {defaultMode}
94
+ {claudePermissionMode}
95
+ {claudeEffortLevel}
80
96
  {defaultImage}
81
97
  {builtinImage}
82
98
  {disableBuildCache}
@@ -106,6 +122,11 @@
106
122
  {hostEnvVarsEnabled}
107
123
  {hostEnvVarNames}
108
124
  {hostEnvVarPresence}
125
+ {customEnvVarsEnabled}
126
+ {customEnvVarNames}
127
+ {advancedSerialInjections}
128
+ {advancedNoBuildkit}
129
+ {advancedBlockingExtInstall}
109
130
  {version}
110
131
  mochi:hydrate
111
132
  />
package/src/routes.ts CHANGED
@@ -19,7 +19,14 @@ import {
19
19
  DEFAULT_SONNET_MODEL
20
20
  } from './container-injections/claude-code-custom.ts';
21
21
  import { hostEnvVarPresence, parseHostEnvVarNames } from './container-injections/host-env-vars.ts';
22
+ import { parseCustomEnvVars } from './container-injections/custom-env-vars.ts';
22
23
  import { gitIdentityEnabled } from './container-injections/git-identity.ts';
24
+ import {
25
+ LATEST_CHECKED_AT_KEY,
26
+ LATEST_VERSION_KEY
27
+ } from './container-injections/claude-code-update.ts';
28
+ import { getClaudePermissionMode } from './container-injections/claude-permission-mode.ts';
29
+ import { getClaudeEffortLevel } from './container-injections/claude-effort-level.ts';
23
30
  import { browse } from './lib/picker.server.ts';
24
31
  import { pickNamePrompt } from './avatars/name-prompts.ts';
25
32
  import { avatars, findAvatar } from './avatars/index.ts';
@@ -44,7 +51,8 @@ import {
44
51
  stopInstance,
45
52
  streamClose,
46
53
  streamOpen,
47
- subscribeLogs
54
+ subscribeLogs,
55
+ invalidateSecretValues
48
56
  } from './lib/instances.server.ts';
49
57
  import {
50
58
  deleteFolderHistory,
@@ -58,7 +66,13 @@ import { wsUpgradeAllowed } from './lib/auth.server.ts';
58
66
  import { clearAttention, setAttention } from './lib/bridge.server.ts';
59
67
  import { timingSafeEqualStr } from './lib/crypto.server.ts';
60
68
  import { proxyRoutes } from './lib/proxy.server.ts';
61
- import { isInstanceFilter, normalizeMode, type InstanceFilter } from './types.ts';
69
+ import {
70
+ isInstanceFilter,
71
+ normalizeMode,
72
+ normalizePermissionMode,
73
+ normalizeEffortLevel,
74
+ type InstanceFilter
75
+ } from './types.ts';
62
76
 
63
77
  async function preflight() {
64
78
  const [docker, cli, auth] = await Promise.all([
@@ -187,6 +201,8 @@ export const routes: Record<string, MochiRouteValue> = {
187
201
  return {
188
202
  pet: currentPet(),
189
203
  defaultMode: getDefaultMode(),
204
+ claudePermissionMode: getClaudePermissionMode(),
205
+ claudeEffortLevel: getClaudeEffortLevel(),
190
206
  defaultImage: getOption('default_image') ?? DEFAULT_IMAGE,
191
207
  builtinImage: DEFAULT_IMAGE,
192
208
  disableBuildCache: getOption('disable_build_cache') === '1',
@@ -227,6 +243,12 @@ export const routes: Record<string, MochiRouteValue> = {
227
243
  hostEnvVarsEnabled: getOption('host_env_vars_enabled') === '1',
228
244
  hostEnvVarNames,
229
245
  hostEnvVarPresence: hostEnvVarPresence(hostEnvVarNames),
246
+ // Freeform secrets: only the names round-trip; the values never leave the server.
247
+ customEnvVarsEnabled: getOption('custom_env_vars_enabled') === '1',
248
+ customEnvVarNames: parseCustomEnvVars(getOption('custom_env_vars')).map((v) => v.name),
249
+ advancedSerialInjections: getOption('advanced_serial_injections') === '1',
250
+ advancedNoBuildkit: getOption('advanced_no_buildkit') === '1',
251
+ advancedBlockingExtInstall: getOption('advanced_blocking_ext_install') === '1',
230
252
  version: APP_VERSION
231
253
  };
232
254
  },
@@ -239,6 +261,20 @@ export const routes: Record<string, MochiRouteValue> = {
239
261
  return success({ mode });
240
262
  },
241
263
 
264
+ // Baked into the container launcher at provision time, so it lands on create/rebuild.
265
+ claudePermissionMode: ({ formData }) => {
266
+ const mode = normalizePermissionMode(str(formData, 'mode'));
267
+ setOption('claude_permission_mode', mode);
268
+ return success({ mode });
269
+ },
270
+
271
+ // Injected into ~/.claude/settings.json at provision time, so it lands on create/rebuild.
272
+ claudeEffortLevel: ({ formData }) => {
273
+ const level = normalizeEffortLevel(str(formData, 'level'));
274
+ setOption('claude_effort_level', level);
275
+ return success({ level });
276
+ },
277
+
242
278
  // Persist the default container image used when a source folder ships no devcontainer.json.
243
279
  defaultImage: ({ formData }) => {
244
280
  const image = str(formData, 'image');
@@ -384,11 +420,71 @@ export const routes: Record<string, MochiRouteValue> = {
384
420
  return success({ presence: hostEnvVarPresence(saved) });
385
421
  },
386
422
 
423
+ // Freeform name=value secrets injected into every container via containerEnv. Values are
424
+ // stored plaintext in the options table (like the tokens above) and never sent to the client.
425
+ customEnvVarsToggle: ({ formData }) => {
426
+ const enabled = onChecked(formData, 'enabled');
427
+ setOption('custom_env_vars_enabled', enabled ? '1' : '0');
428
+ // The redaction set only masks while enabled, so it must be rebuilt on either flip.
429
+ invalidateSecretValues();
430
+ return success({ enabled });
431
+ },
432
+ // Upsert one var: the client only ever sends the single var it's changing, since it never
433
+ // holds the other secrets to resubmit (unlike host-env's whole-list-replace).
434
+ setCustomEnvVar: ({ formData }) => {
435
+ const name = str(formData, 'name');
436
+ const value = str(formData, 'value');
437
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
438
+ return fail(400, { error: `invalid variable name: ${name || '(empty)'}` });
439
+ }
440
+ if (!value) return fail(400, { error: 'value is required' });
441
+ const vars = parseCustomEnvVars(getOption('custom_env_vars')).filter(
442
+ (v) => v.name !== name
443
+ );
444
+ vars.push({ name, value });
445
+ setOption('custom_env_vars', JSON.stringify(vars));
446
+ invalidateSecretValues();
447
+ return success({ names: vars.map((v) => v.name) });
448
+ },
449
+ removeCustomEnvVar: ({ formData }) => {
450
+ const name = str(formData, 'name');
451
+ const vars = parseCustomEnvVars(getOption('custom_env_vars')).filter(
452
+ (v) => v.name !== name
453
+ );
454
+ setOption('custom_env_vars', JSON.stringify(vars));
455
+ invalidateSecretValues();
456
+ return success({ names: vars.map((v) => v.name) });
457
+ },
458
+
387
459
  clearBuildCache: async () => {
388
460
  const { spaceReclaimed } = await pruneBuildCache();
389
461
  return success({ spaceReclaimed: spaceReclaimed ?? 0 });
390
462
  },
391
463
 
464
+ serialInjectionsToggle: ({ formData }) => {
465
+ const enabled = onChecked(formData, 'enabled');
466
+ setOption('advanced_serial_injections', enabled ? '1' : '0');
467
+ return success({ enabled });
468
+ },
469
+
470
+ noBuildkitToggle: ({ formData }) => {
471
+ const enabled = onChecked(formData, 'enabled');
472
+ setOption('advanced_no_buildkit', enabled ? '1' : '0');
473
+ return success({ enabled });
474
+ },
475
+
476
+ blockingExtInstallToggle: ({ formData }) => {
477
+ const enabled = onChecked(formData, 'enabled');
478
+ setOption('advanced_blocking_ext_install', enabled ? '1' : '0');
479
+ return success({ enabled });
480
+ },
481
+
482
+ clearVersionCache: () => {
483
+ setOption(LATEST_VERSION_KEY, '');
484
+ setOption(LATEST_CHECKED_AT_KEY, '');
485
+ return success({});
486
+ },
487
+
392
488
  rebuildAllNoCache: () => success({ count: rebuildRunningInstancesNoCache() }),
393
489
 
394
490
  // The process exits mid-flight, so the client may see success or a dropped
package/src/types.ts CHANGED
@@ -19,6 +19,8 @@ export interface Instance {
19
19
  created_at: number;
20
20
  /** `'local'` when the folder shipped its own config; null until provisioned. */
21
21
  image_source: string | null;
22
+ /** Sprite name chosen collision-free at creation; null pre-dates this and falls back to the id hash. */
23
+ avatar: string | null;
22
24
  /** `'ide'` serves full code-server; `'terminal'` serves only ttyd + Claude Code. */
23
25
  mode: InstanceMode;
24
26
  /** Terminal mode only: 1 when the scratch-shell pane was left open, so a reload restores it. */
@@ -37,6 +39,40 @@ export function normalizeMode(value: unknown): InstanceMode {
37
39
  return value === 'terminal' ? 'terminal' : 'ide';
38
40
  }
39
41
 
42
+ /** The permission mode Claude Code starts in. `'default'` keeps the historical bypass behaviour. */
43
+ export type ClaudePermissionMode = 'default' | 'manual' | 'auto' | 'plan';
44
+
45
+ export const CLAUDE_PERMISSION_MODES: ClaudePermissionMode[] = [
46
+ 'default',
47
+ 'manual',
48
+ 'auto',
49
+ 'plan'
50
+ ];
51
+
52
+ /** Anything unrecognised falls back to the historical behaviour. */
53
+ export function normalizePermissionMode(value: unknown): ClaudePermissionMode {
54
+ return CLAUDE_PERMISSION_MODES.includes(value as ClaudePermissionMode)
55
+ ? (value as ClaudePermissionMode)
56
+ : 'default';
57
+ }
58
+
59
+ /** The flags every managed `claude` invocation carries — the launchers and the shell alias share them. */
60
+ export function claudePermissionFlags(mode: ClaudePermissionMode): string {
61
+ return mode === 'default' ? '--dangerously-skip-permissions' : `--permission-mode ${mode}`;
62
+ }
63
+
64
+ /** Claude Code's default reasoning-effort level for new sessions, written to `settings.json`. */
65
+ export type ClaudeEffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
66
+
67
+ export const CLAUDE_EFFORT_LEVELS: ClaudeEffortLevel[] = ['low', 'medium', 'high', 'xhigh', 'max'];
68
+
69
+ /** Anything unrecognised falls back to the balanced default. */
70
+ export function normalizeEffortLevel(value: unknown): ClaudeEffortLevel {
71
+ return CLAUDE_EFFORT_LEVELS.includes(value as ClaudeEffortLevel)
72
+ ? (value as ClaudeEffortLevel)
73
+ : 'high';
74
+ }
75
+
40
76
  /** Dashboard run-state view filter: All | Active (running/creating) | Stopped (stopped/error). */
41
77
  export type InstanceFilter = 'all' | 'active' | 'stopped';
42
78
 
@@ -1 +0,0 @@
1
- import{cb as a}from"/_mochi/client/chunk-v65n8th6.js";import"/_mochi/client/chunk-rs41by6w.js";export{a as registerComponent};