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
|
@@ -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
|
+
}
|
package/src/lib/ports.server.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/proxy.server.ts
CHANGED
|
@@ -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
|
|
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('
|
|
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 {
|
|
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};
|