codebay 0.13.0 → 0.15.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.
- package/.mochi/manifest.json +522 -443
- package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
- package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
- package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
- package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
- package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
- package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
- package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
- package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
- package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
- package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
- package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
- package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
- package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
- package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
- package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
- package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
- package/.mochi/svelte-compile/{Settings-h7gsjyz3.server.js → Settings-0b5t9kw9.server.js} +348 -151
- package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
- package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
- package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
- package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
- package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
- package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
- package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
- package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
- package/README.md +32 -1
- package/migrations/0009_agent_runs.sql +24 -0
- package/migrations/0010_agent_run_model.sql +2 -0
- package/package.json +6 -2
- package/public/favicon.svg +9 -0
- package/src/components/AgentLogBox.svelte +458 -0
- package/src/components/AppShell.svelte +11 -0
- package/src/components/FolderBrowser.svelte +13 -5
- package/src/components/InstanceCard.svelte +38 -0
- package/src/components/InstanceView.svelte +8 -0
- package/src/components/SettingsView.svelte +260 -0
- package/src/components/UiShowcase.svelte +4 -2
- package/src/container-injections/claude-code-credentials.ts +2 -3
- package/src/container-injections/claude-code-skills.ts +125 -0
- package/src/container-injections/claude-output-style.ts +2 -3
- package/src/container-injections/claude-statusline.ts +9 -17
- package/src/container-injections/github-credentials.ts +12 -1
- package/src/index.ts +32 -7
- package/src/lib/agent-run-stream.ts +246 -0
- package/src/lib/agent-runs.server.ts +724 -0
- package/src/lib/auth.server.ts +11 -2
- package/src/lib/build-freshness.server.ts +71 -0
- package/src/lib/config.server.ts +29 -6
- package/src/lib/container-files.server.ts +23 -1
- package/src/lib/data-dir-lock.server.ts +78 -0
- package/src/lib/db.server.ts +120 -1
- package/src/lib/devcontainer.server.ts +60 -14
- package/src/lib/docker-client.server.ts +11 -5
- package/src/lib/drives.ts +11 -0
- package/src/lib/exec.server.ts +66 -5
- package/src/lib/host-claude.server.ts +31 -0
- package/src/lib/injections.server.ts +2 -0
- package/src/lib/instances.server.ts +45 -3
- package/src/lib/log-capture.server.ts +17 -5
- package/src/lib/mcp-auth.server.ts +52 -0
- package/src/lib/picker.server.ts +23 -2
- package/src/lib/sandbox-ops.server.ts +263 -0
- package/src/mcp/routes.server.ts +25 -0
- package/src/mcp/server.server.ts +52 -0
- package/src/mcp/tools.server.ts +494 -0
- package/src/pages/Settings.svelte +15 -0
- package/src/routes.ts +72 -1
- package/src/shell.html +1 -0
- package/src/types.ts +15 -0
- package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-wedc9x9y.js +0 -39
- package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
- package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
- package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
- package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
- package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
package/src/lib/auth.server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Handle } from 'mochi-framework';
|
|
2
2
|
import { BASIC_AUTH_PASSWORD, BASIC_AUTH_USERNAME } from './config.server.ts';
|
|
3
3
|
import { timingSafeEqualStr } from './crypto.server.ts';
|
|
4
|
+
import { MCP_PATH } from './mcp-auth.server.ts';
|
|
4
5
|
|
|
5
6
|
const REALM = 'Codebay';
|
|
6
7
|
|
|
@@ -37,6 +38,15 @@ function wsOriginOk(request: Request): boolean {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Paths that authenticate with their own token instead of the app password: exempt from Basic
|
|
43
|
+
* Auth and the CSRF check (their clients can't carry either), and from the request log, since
|
|
44
|
+
* every hit carries that token.
|
|
45
|
+
*/
|
|
46
|
+
export function tokenAuthenticated(pathname: string): boolean {
|
|
47
|
+
return pathname.startsWith('/api/bridge/') || pathname === MCP_PATH;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
/** `Mochi.ws` routes are dispatched by Bun directly and never reach `basicAuth`. */
|
|
41
51
|
export function wsUpgradeAllowed(request: Request): boolean {
|
|
42
52
|
if (!wsOriginOk(request)) return false;
|
|
@@ -69,8 +79,7 @@ function credentialsOk(header: string | null): boolean {
|
|
|
69
79
|
export const basicAuth: Handle = async ({ event, resolve }) => {
|
|
70
80
|
const path = new URL(event.request.url).pathname;
|
|
71
81
|
|
|
72
|
-
|
|
73
|
-
if (path.startsWith('/api/bridge/')) return resolve(event);
|
|
82
|
+
if (tokenAuthenticated(path)) return resolve(event);
|
|
74
83
|
|
|
75
84
|
// Covers the `/p/:id/*` proxy relay only; `Mochi.ws` routes call `wsUpgradeAllowed` themselves.
|
|
76
85
|
if (
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
interface BuildManifest {
|
|
5
|
+
components?: Record<string, { hydratables?: { resolvedPath?: string }[] }>;
|
|
6
|
+
stats?: { outputs?: { inputs?: { path?: string }[] }[] };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Every project file the build consumed: pages, islands, and each client bundle's inputs. */
|
|
10
|
+
function buildInputs(manifest: BuildManifest): string[] {
|
|
11
|
+
const paths = new Set<string>();
|
|
12
|
+
for (const [source, component] of Object.entries(manifest.components ?? {})) {
|
|
13
|
+
paths.add(source);
|
|
14
|
+
for (const island of component.hydratables ?? []) {
|
|
15
|
+
if (island.resolvedPath) paths.add(island.resolvedPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const output of manifest.stats?.outputs ?? []) {
|
|
19
|
+
for (const input of output.inputs ?? []) {
|
|
20
|
+
if (input.path) paths.add(input.path);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Dependencies, Mochi's own templates, and virtual modules are not things anyone edits in place.
|
|
24
|
+
return [...paths].filter(
|
|
25
|
+
(p) => !p.startsWith('node_modules/') && !p.startsWith('$mochi/') && !p.includes(':')
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build inputs edited since the manifest was written, relative to `root`. Only files the manifest
|
|
31
|
+
* itself names count, so editing a `.server.ts` (imported from source at boot) never trips it.
|
|
32
|
+
*/
|
|
33
|
+
export function staleBuildInputs(
|
|
34
|
+
root: string,
|
|
35
|
+
manifestPath = join(root, '.mochi', 'manifest.json')
|
|
36
|
+
): string[] {
|
|
37
|
+
if (!existsSync(manifestPath)) return [];
|
|
38
|
+
const builtAt = statSync(manifestPath).mtimeMs;
|
|
39
|
+
let manifest: BuildManifest;
|
|
40
|
+
try {
|
|
41
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as BuildManifest;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// A truncated manifest is Mochi's problem to report at serve time; an advisory check must not be what crashes boot.
|
|
44
|
+
console.warn(
|
|
45
|
+
`⚠ could not read ${manifestPath} for the build-freshness check: ${(err as Error).message}`
|
|
46
|
+
);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return buildInputs(manifest)
|
|
50
|
+
.filter((p) => {
|
|
51
|
+
const abs = join(root, p);
|
|
52
|
+
return existsSync(abs) && statSync(abs).mtimeMs > builtAt;
|
|
53
|
+
})
|
|
54
|
+
.sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Production serves pages and islands from the prebuilt manifest but imports routes from source,
|
|
59
|
+
* so restarting after a `.svelte` edit without `bun run build` splits the app: new API, old client.
|
|
60
|
+
* Gated on a checkout because a package install stamps every file with its extraction time.
|
|
61
|
+
*/
|
|
62
|
+
export function warnIfBuildStale(root = process.cwd()): void {
|
|
63
|
+
if (process.env.MODE === 'development' || !existsSync(join(root, '.git'))) return;
|
|
64
|
+
const stale = staleBuildInputs(root);
|
|
65
|
+
if (stale.length === 0) return;
|
|
66
|
+
const listed = stale.slice(0, 5).join(', ') + (stale.length > 5 ? ', …' : '');
|
|
67
|
+
console.warn(
|
|
68
|
+
`⚠ .mochi/ is older than ${stale.length} source file(s) it was built from (${listed}) — ` +
|
|
69
|
+
'production serves the prebuilt bundle, so run `bun run build` and restart, or pages and islands will be stale.'
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/lib/config.server.ts
CHANGED
|
@@ -128,19 +128,42 @@ export function dockerEnv(): Record<string, string | undefined> {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
*
|
|
131
|
+
* Windows picks an executable by PATHEXT, which `existsSync` doesn't apply — so each shim has
|
|
132
|
+
* to be probed by full name. The bare name is deliberately not a win32 candidate: npm writes a
|
|
133
|
+
* POSIX sh script there, which Bun can't exec. Bun's `.bunx` sibling is a data file and is never
|
|
134
|
+
* a candidate on any platform — spawning it throws EFTYPE.
|
|
135
|
+
*/
|
|
136
|
+
const BIN_SHIM_EXTS = process.platform === 'win32' ? ['.exe', '.cmd'] : [''];
|
|
137
|
+
|
|
138
|
+
/** Null when no spawnable shim exists, so a caller can fall back instead of spawning a missing file. */
|
|
139
|
+
export function binShim(binDir: string, name: string): string | null {
|
|
140
|
+
for (const ext of BIN_SHIM_EXTS) {
|
|
141
|
+
const candidate = join(binDir, name + ext);
|
|
142
|
+
if (existsSync(candidate)) return candidate;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An argv rather than a path, because the `devcontainer.js` fallback leads with a
|
|
149
|
+
* `#!/usr/bin/env node` shebang that Windows doesn't honour — there it only runs when handed to
|
|
150
|
+
* an interpreter. Resolved from our own dependency tree, not cwd, because under `bunx codebay`
|
|
132
151
|
* cwd is the user's arbitrary folder.
|
|
133
152
|
*/
|
|
134
|
-
export function devcontainerBin(): string {
|
|
153
|
+
export function devcontainerBin(): string[] {
|
|
135
154
|
try {
|
|
136
155
|
const pkgJson = fileURLToPath(import.meta.resolve('@devcontainers/cli/package.json'));
|
|
137
|
-
const shim = join(dirname(pkgJson), '..', '..', '.bin', 'devcontainer');
|
|
138
|
-
if (
|
|
156
|
+
const shim = binShim(join(dirname(pkgJson), '..', '..', '.bin'), 'devcontainer');
|
|
157
|
+
if (shim) return [shim];
|
|
139
158
|
const pkg = JSON.parse(readFileSync(pkgJson, 'utf8'));
|
|
140
159
|
const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.devcontainer;
|
|
141
|
-
if (rel)
|
|
160
|
+
if (rel) {
|
|
161
|
+
const js = join(dirname(pkgJson), rel);
|
|
162
|
+
return process.platform === 'win32' ? [process.execPath, js] : [js];
|
|
163
|
+
}
|
|
142
164
|
} catch {
|
|
143
165
|
// Fall through to the dev-checkout path below.
|
|
144
166
|
}
|
|
145
|
-
|
|
167
|
+
const dir = join(process.cwd(), 'node_modules', '.bin');
|
|
168
|
+
return [binShim(dir, 'devcontainer') ?? join(dir, 'devcontainer')];
|
|
146
169
|
}
|
|
@@ -13,7 +13,7 @@ export interface ContainerFile {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** Resolves the exec user's home the same way every injection historically has. */
|
|
16
|
-
const HOME_PRELUDE = 'h=$(eval echo ~$(id -un)); ';
|
|
16
|
+
export const HOME_PRELUDE = 'h=$(eval echo ~$(id -un)); ';
|
|
17
17
|
|
|
18
18
|
const filePath = (file: ContainerFile): string => `${file.dir ?? '$h'}/${file.name}`;
|
|
19
19
|
|
|
@@ -54,6 +54,15 @@ export function writeFileScript(file: ContainerFile): string {
|
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Like `writeFileScript`, but the stdin payload is base64 so binary bytes survive the env-var carrier. */
|
|
58
|
+
export function writeBase64FileScript(file: ContainerFile): string {
|
|
59
|
+
const mode = file.mode ?? '644';
|
|
60
|
+
return (
|
|
61
|
+
`set -e; ${HOME_PRELUDE}f="${filePath(file)}"; mkdir -p "$(dirname "$f")"; ` +
|
|
62
|
+
`printf '%s' "$CODEBAY_STDIN" | base64 -d > "$f"; chmod ${mode} "$f"`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
/** Single-quotes a value for a sourced shell file so whatever it contains stays literal. */
|
|
58
67
|
export const shellSingleQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
|
|
59
68
|
|
|
@@ -98,6 +107,19 @@ export async function writeContainerFile(
|
|
|
98
107
|
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/** Writes raw `bytes` to a container file, ferrying them as base64 so binary content stays intact. */
|
|
111
|
+
export async function writeContainerFileBytes(
|
|
112
|
+
target: ExecTarget,
|
|
113
|
+
file: ContainerFile,
|
|
114
|
+
bytes: Buffer
|
|
115
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
116
|
+
const res = await execInContainer(target, {
|
|
117
|
+
script: writeBase64FileScript(file),
|
|
118
|
+
stdin: bytes.toString('base64')
|
|
119
|
+
});
|
|
120
|
+
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
/** Appends each line to every file it's missing from — the shared idempotent rc/conf write. */
|
|
102
124
|
export async function appendLinesIfAbsent(
|
|
103
125
|
target: ExecTarget,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** How often a live manager refreshes the lock, and how long a silent one keeps it. */
|
|
6
|
+
export const LOCK_HEARTBEAT_MS = 5_000;
|
|
7
|
+
export const LOCK_STALE_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
export const LOCK_FILE = 'manager.lock';
|
|
10
|
+
|
|
11
|
+
export interface LockHolder {
|
|
12
|
+
pid: number;
|
|
13
|
+
host: string;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type LockResult =
|
|
18
|
+
{ ok: true; release: () => void } | { ok: false; holder: LockHolder | null; path: string };
|
|
19
|
+
|
|
20
|
+
// Pinned so a dev-mode re-evaluation of the entry module re-uses the heartbeat instead of stacking one.
|
|
21
|
+
const globalForLock = globalThis as unknown as { __codebayDataDirLock?: LockResult };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One manager per DATA_DIR. Two of them share the DB and both poll every run, so each folds only
|
|
25
|
+
* the stream chunks it fetched itself and whichever sees the exit file finishes the row from a
|
|
26
|
+
* partial state. Liveness is the file's mtime under a heartbeat rather than a pid check: pids
|
|
27
|
+
* don't cross a container boundary and get reused after a reboot, but a stale file just ages out.
|
|
28
|
+
*/
|
|
29
|
+
export function acquireDataDirLock(dir: string, now = Date.now()): LockResult {
|
|
30
|
+
if (globalForLock.__codebayDataDirLock?.ok) return globalForLock.__codebayDataDirLock;
|
|
31
|
+
const path = join(dir, LOCK_FILE);
|
|
32
|
+
const existing = readLock(path);
|
|
33
|
+
if (existing && now - existing.mtimeMs < LOCK_STALE_MS && existing.holder?.pid !== process.pid) {
|
|
34
|
+
return { ok: false, holder: existing.holder, path };
|
|
35
|
+
}
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
const holder: LockHolder = { pid: process.pid, host: hostname(), startedAt: now };
|
|
38
|
+
writeFileSync(path, JSON.stringify(holder) + '\n');
|
|
39
|
+
const timer = setInterval(() => {
|
|
40
|
+
try {
|
|
41
|
+
const at = new Date();
|
|
42
|
+
utimesSync(path, at, at);
|
|
43
|
+
} catch {
|
|
44
|
+
// The data dir went away underneath us; there is nothing left to keep alive.
|
|
45
|
+
}
|
|
46
|
+
}, LOCK_HEARTBEAT_MS);
|
|
47
|
+
// A heartbeat must never be the reason the process stays up.
|
|
48
|
+
timer.unref?.();
|
|
49
|
+
const result: LockResult = {
|
|
50
|
+
ok: true,
|
|
51
|
+
release: () => {
|
|
52
|
+
clearInterval(timer);
|
|
53
|
+
globalForLock.__codebayDataDirLock = undefined;
|
|
54
|
+
try {
|
|
55
|
+
if (readLock(path)?.holder?.pid === process.pid) unlinkSync(path);
|
|
56
|
+
} catch {
|
|
57
|
+
// Already gone, or not ours any more.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
globalForLock.__codebayDataDirLock = result;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readLock(path: string): { holder: LockHolder | null; mtimeMs: number } | null {
|
|
66
|
+
try {
|
|
67
|
+
const mtimeMs = statSync(path).mtimeMs;
|
|
68
|
+
let holder: LockHolder | null = null;
|
|
69
|
+
try {
|
|
70
|
+
holder = JSON.parse(readFileSync(path, 'utf8')) as LockHolder;
|
|
71
|
+
} catch {
|
|
72
|
+
// Garbage in the file still counts as a lock while it is fresh.
|
|
73
|
+
}
|
|
74
|
+
return { holder, mtimeMs };
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/lib/db.server.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { migrate, getMigrations } from '@zihaolam/bun-sqlite-migrations';
|
|
|
3
3
|
import { mkdirSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { DATA_DIR, DB_PATH } from './config.server.ts';
|
|
6
|
-
import type { FolderHistoryEntry } from '../types.ts';
|
|
6
|
+
import type { AgentRunStatus, FolderHistoryEntry } from '../types.ts';
|
|
7
7
|
|
|
8
8
|
// db.server.ts lives in src/lib, so ../../migrations resolves to the repo root.
|
|
9
9
|
const MIGRATIONS_DIR = join(import.meta.dir, '../../migrations');
|
|
@@ -213,3 +213,122 @@ export function setOption(key: string, value: string): void {
|
|
|
213
213
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
214
214
|
).run({ $key: key, $value: value });
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
/** One `claude -p` invocation inside an instance, driven by the MCP server. */
|
|
218
|
+
export interface AgentRunRow {
|
|
219
|
+
id: string;
|
|
220
|
+
instance_id: string;
|
|
221
|
+
prompt: string;
|
|
222
|
+
status: AgentRunStatus;
|
|
223
|
+
/** Claude's own session id, parsed out of the stream's `init` event; resumable. */
|
|
224
|
+
session_id: string | null;
|
|
225
|
+
/** The model Claude actually ran with, from the stream `init` event; the requested alias lives in `options`. */
|
|
226
|
+
model: string | null;
|
|
227
|
+
resume_session_id: string | null;
|
|
228
|
+
/** JSON blob of the launch options, so a run queued before its container existed can still start. */
|
|
229
|
+
options: string | null;
|
|
230
|
+
result: string | null;
|
|
231
|
+
/** JSON string, only when the caller passed a `--json-schema`. */
|
|
232
|
+
structured_output: string | null;
|
|
233
|
+
/** Latest tool call or assistant line, so a poller can see progress mid-run. */
|
|
234
|
+
last_activity: string | null;
|
|
235
|
+
is_error: number;
|
|
236
|
+
exit_code: number | null;
|
|
237
|
+
cost_usd: number | null;
|
|
238
|
+
duration_ms: number | null;
|
|
239
|
+
num_turns: number | null;
|
|
240
|
+
error: string | null;
|
|
241
|
+
created_at: number;
|
|
242
|
+
started_at: number | null;
|
|
243
|
+
finished_at: number | null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Neither has finished, so both block a second run on the same instance. */
|
|
247
|
+
const OPEN_RUN_STATUSES = "('queued', 'running')";
|
|
248
|
+
|
|
249
|
+
export function insertRun(row: AgentRunRow): void {
|
|
250
|
+
db.query(
|
|
251
|
+
`INSERT INTO agent_runs
|
|
252
|
+
(id, instance_id, prompt, status, session_id, model, resume_session_id, options, result, structured_output, last_activity, is_error, exit_code, cost_usd, duration_ms, num_turns, error, created_at, started_at, finished_at)
|
|
253
|
+
VALUES ($id, $instance_id, $prompt, $status, $session_id, $model, $resume_session_id, $options, $result, $structured_output, $last_activity, $is_error, $exit_code, $cost_usd, $duration_ms, $num_turns, $error, $created_at, $started_at, $finished_at)`
|
|
254
|
+
).run({
|
|
255
|
+
$id: row.id,
|
|
256
|
+
$instance_id: row.instance_id,
|
|
257
|
+
$prompt: row.prompt,
|
|
258
|
+
$status: row.status,
|
|
259
|
+
$session_id: row.session_id,
|
|
260
|
+
$model: row.model,
|
|
261
|
+
$resume_session_id: row.resume_session_id,
|
|
262
|
+
$options: row.options,
|
|
263
|
+
$result: row.result,
|
|
264
|
+
$structured_output: row.structured_output,
|
|
265
|
+
$last_activity: row.last_activity,
|
|
266
|
+
$is_error: row.is_error,
|
|
267
|
+
$exit_code: row.exit_code,
|
|
268
|
+
$cost_usd: row.cost_usd,
|
|
269
|
+
$duration_ms: row.duration_ms,
|
|
270
|
+
$num_turns: row.num_turns,
|
|
271
|
+
$error: row.error,
|
|
272
|
+
$created_at: row.created_at,
|
|
273
|
+
$started_at: row.started_at,
|
|
274
|
+
$finished_at: row.finished_at
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function getRun(id: string): AgentRunRow | null {
|
|
279
|
+
return db.query('SELECT * FROM agent_runs WHERE id = $id').get({ $id: id }) as AgentRunRow | null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function listRuns(instanceId: string, limit = 50): AgentRunRow[] {
|
|
283
|
+
return db
|
|
284
|
+
.query('SELECT * FROM agent_runs WHERE instance_id = $id ORDER BY created_at DESC LIMIT $limit')
|
|
285
|
+
.all({ $id: instanceId, $limit: limit }) as AgentRunRow[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Every unfinished run, across all instances — what the poller's timer works from. */
|
|
289
|
+
export function openRuns(): AgentRunRow[] {
|
|
290
|
+
return db
|
|
291
|
+
.query(`SELECT * FROM agent_runs WHERE status IN ${OPEN_RUN_STATUSES} ORDER BY created_at`)
|
|
292
|
+
.all() as AgentRunRow[];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function openRunFor(instanceId: string): AgentRunRow | null {
|
|
296
|
+
return db
|
|
297
|
+
.query(
|
|
298
|
+
`SELECT * FROM agent_runs WHERE instance_id = $id AND status IN ${OPEN_RUN_STATUSES} ORDER BY created_at LIMIT 1`
|
|
299
|
+
)
|
|
300
|
+
.get({ $id: instanceId }) as AgentRunRow | null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The same allowlist discipline `updateInstance` uses — never interpolate a caller's column name. */
|
|
304
|
+
const UPDATABLE_RUN_COLUMNS = [
|
|
305
|
+
'status',
|
|
306
|
+
'session_id',
|
|
307
|
+
'model',
|
|
308
|
+
'result',
|
|
309
|
+
'structured_output',
|
|
310
|
+
'last_activity',
|
|
311
|
+
'is_error',
|
|
312
|
+
'exit_code',
|
|
313
|
+
'cost_usd',
|
|
314
|
+
'duration_ms',
|
|
315
|
+
'num_turns',
|
|
316
|
+
'error',
|
|
317
|
+
'started_at',
|
|
318
|
+
'finished_at'
|
|
319
|
+
] as const;
|
|
320
|
+
|
|
321
|
+
type UpdatableRunColumn = (typeof UPDATABLE_RUN_COLUMNS)[number];
|
|
322
|
+
|
|
323
|
+
export function updateRun(id: string, patch: Partial<Pick<AgentRunRow, UpdatableRunColumn>>): void {
|
|
324
|
+
const sets: string[] = [];
|
|
325
|
+
const params: Record<string, string | number | null> = { $id: id };
|
|
326
|
+
for (const col of UPDATABLE_RUN_COLUMNS) {
|
|
327
|
+
if (col in patch) {
|
|
328
|
+
sets.push(`${col} = $${col}`);
|
|
329
|
+
params[`$${col}`] = patch[col] ?? null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (sets.length === 0) return;
|
|
333
|
+
db.query(`UPDATE agent_runs SET ${sets.join(', ')} WHERE id = $id`).run(params);
|
|
334
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chmod, cp, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
-
import { basename, dirname, join, relative } from 'node:path';
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, relative, sep } 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
6
|
import { INSTALL_SCRIPT as TTYD_INSTALL_SCRIPT } from '../container-injections/ttyd.ts';
|
|
@@ -160,6 +160,14 @@ const TMUX_SHELL_SESSION = 'codebay-shell';
|
|
|
160
160
|
/** Written to `$HOME` when the post-up injection sequence finishes; the terminal launcher waits on it. */
|
|
161
161
|
export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Present in `$HOME` for exactly as long as an MCP-driven `claude -p` run is executing. The
|
|
165
|
+
* surface launchers read it so opening the IDE mid-run doesn't start a second Claude beside the
|
|
166
|
+
* agent's — the two would share `~/.claude.json` and one project session dir, which is the same
|
|
167
|
+
* read-modify-write race documented on `terminalTask` below.
|
|
168
|
+
*/
|
|
169
|
+
export const AGENT_RUN_MARKER = '.codebay-agent-active';
|
|
170
|
+
|
|
163
171
|
/**
|
|
164
172
|
* IDE mode's folderOpen task touches this to stay run-once across workspace reloads, but it
|
|
165
173
|
* outlives a container restart — so a relaunch must clear it or the terminal never reopens.
|
|
@@ -174,14 +182,14 @@ export const TERMINAL_LAUNCHED_MARKER = '.codebay-terminal-launched';
|
|
|
174
182
|
const INJECTIONS_WAIT_SECONDS = 600;
|
|
175
183
|
|
|
176
184
|
/** Long enough to ride out an in-flight `npm install -g` of Claude Code. */
|
|
177
|
-
const CLAUDE_BINARY_WAIT_SECONDS = 60;
|
|
185
|
+
export const CLAUDE_BINARY_WAIT_SECONDS = 60;
|
|
178
186
|
|
|
179
187
|
/**
|
|
180
188
|
* An auto-launched `claude` that starts mid-injection reads `~/.claude.json` before the trust
|
|
181
189
|
* keys land and clobbers them on its next rewrite, so hold it until the sentinel appears.
|
|
182
190
|
* Bounded so a failed boot (sentinel never written) still yields a usable terminal.
|
|
183
191
|
*/
|
|
184
|
-
const WAIT_FOR_INJECTIONS =
|
|
192
|
+
export const WAIT_FOR_INJECTIONS =
|
|
185
193
|
`[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "Waiting for codebay setup to finish…"; ` +
|
|
186
194
|
`i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge ${INJECTIONS_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; ` +
|
|
187
195
|
`[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "codebay: setup did not finish in time — starting anyway"; `;
|
|
@@ -193,10 +201,17 @@ const WAIT_FOR_INJECTIONS =
|
|
|
193
201
|
* skipped once the sentinel exists, since no injection can be mid-reinstall by then and a missing
|
|
194
202
|
* binary is permanent. No single quotes: this is spliced into the single-quoted tmux commands below.
|
|
195
203
|
*/
|
|
196
|
-
const launchClaude = (permissionMode: ClaudePermissionMode): string =>
|
|
204
|
+
export const launchClaude = (permissionMode: ClaudePermissionMode): string =>
|
|
205
|
+
// An agent run already owns this container's Claude state, so drop to a plain shell instead of
|
|
206
|
+
// racing it. No single quotes: this whole string is spliced into the single-quoted tmux commands.
|
|
207
|
+
`if [ -e "$HOME/${AGENT_RUN_MARKER}" ]; then ` +
|
|
208
|
+
`echo "codebay: an agent run is driving Claude in this container — opening a shell instead."; ` +
|
|
209
|
+
`echo "codebay: run claude yourself once it finishes, or watch it in the Agent log."; ` +
|
|
210
|
+
`else ` +
|
|
197
211
|
`if [ ! -e "$HOME/${INJECTIONS_DONE_FILE}" ]; then i=0; until command -v claude >/dev/null 2>&1 || [ "$i" -ge ${CLAUDE_BINARY_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; fi; ` +
|
|
198
212
|
`if command -v claude >/dev/null 2>&1; then claude ${claudePermissionFlags(permissionMode)}; ` +
|
|
199
|
-
`else echo "codebay: claude is not installed in this container — check the boot log, then Rebuild"; fi;
|
|
213
|
+
`else echo "codebay: claude is not installed in this container — check the boot log, then Rebuild"; fi; ` +
|
|
214
|
+
`fi; `;
|
|
200
215
|
|
|
201
216
|
/**
|
|
202
217
|
* claude scans `~/.claude/ide/*.lock` once at startup and never retries, so it must not race
|
|
@@ -215,7 +230,7 @@ const WAIT_FOR_IDE_BRIDGE = `i=0; until ls "$HOME/.claude/ide/" 2>/dev/null | gr
|
|
|
215
230
|
* An explicit list, never a glob: tmux runs this under the user's shell, and zsh would print
|
|
216
231
|
* "no matches found" when a file is absent.
|
|
217
232
|
*/
|
|
218
|
-
const SOURCE_INJECTED_ENV = `for f in ${[CUSTOM_ENV_FILE, MODELS_ENV_FILE, HOST_ENV_FILE]
|
|
233
|
+
export const SOURCE_INJECTED_ENV = `for f in ${[CUSTOM_ENV_FILE, MODELS_ENV_FILE, HOST_ENV_FILE]
|
|
219
234
|
.map((name) => `"$HOME/${name}"`)
|
|
220
235
|
.join(' ')}; do [ -r "$f" ] && . "$f"; done; `;
|
|
221
236
|
|
|
@@ -354,21 +369,49 @@ export function launchCommandFor(mode: InstanceMode): string {
|
|
|
354
369
|
: codeServerLaunch(getOption('advanced_blocking_ext_install') === '1');
|
|
355
370
|
}
|
|
356
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Latched so the 5s preflight tick can't spam the console while the setup banner is up, but
|
|
374
|
+
* still re-arms on recovery. Without this, a spawn that fails for a *platform* reason (Windows
|
|
375
|
+
* can't exec an extensionless shim) is indistinguishable from the CLI simply not being installed.
|
|
376
|
+
*/
|
|
377
|
+
let cliProbeWarned = false;
|
|
378
|
+
|
|
357
379
|
export async function devcontainerCliAvailable(): Promise<boolean> {
|
|
358
|
-
|
|
380
|
+
const argv = devcontainerBin();
|
|
381
|
+
const available = (await spawnCapture([...argv, '--version'])) !== null;
|
|
382
|
+
if (!available && !cliProbeWarned) {
|
|
383
|
+
console.warn(`⚠ devcontainer CLI probe failed — could not run: ${argv.join(' ')}`);
|
|
384
|
+
}
|
|
385
|
+
cliProbeWarned = !available;
|
|
386
|
+
return available;
|
|
359
387
|
}
|
|
360
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Windows needs Developer Mode to *create* a symlink, and one EPERM rejects the whole `cp` — so
|
|
391
|
+
* there we follow them instead, which needs no privilege. Git for Windows does the same by default
|
|
392
|
+
* (`core.symlinks=false`). The count rides back out so the caller can say so in the boot log.
|
|
393
|
+
*/
|
|
361
394
|
export async function copyWorkspace(
|
|
362
395
|
source: string,
|
|
363
396
|
dest: string,
|
|
364
|
-
ignore: Set<string
|
|
365
|
-
|
|
397
|
+
ignore: Set<string>,
|
|
398
|
+
dereference: boolean = process.platform === 'win32'
|
|
399
|
+
): Promise<{ dereferencedSymlinks: number }> {
|
|
400
|
+
let dereferencedSymlinks = 0;
|
|
366
401
|
await mkdir(dest, { recursive: true });
|
|
367
402
|
await cp(source, dest, {
|
|
368
403
|
recursive: true,
|
|
369
|
-
dereference
|
|
370
|
-
filter: (src) =>
|
|
404
|
+
dereference,
|
|
405
|
+
filter: (src) => {
|
|
406
|
+
if (ignore.has(basename(src))) return false;
|
|
407
|
+
// Counted in the filter because cp offers no per-entry hook once the walk is underway.
|
|
408
|
+
if (dereference && lstatSync(src, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
409
|
+
dereferencedSymlinks++;
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
371
413
|
});
|
|
414
|
+
return { dereferencedSymlinks };
|
|
372
415
|
}
|
|
373
416
|
|
|
374
417
|
/** Strip // and /* *\/ comments and trailing commas from JSONC, respecting string literals. */
|
|
@@ -582,7 +625,10 @@ export async function writeOverrideConfig(
|
|
|
582
625
|
// `./`-descendant feature paths), and under `.devcontainer/` for the two classic forms.
|
|
583
626
|
const featureRoot =
|
|
584
627
|
dirname(target) === workspaceDir ? join(workspaceDir, '.devcontainer') : dirname(target);
|
|
585
|
-
|
|
628
|
+
// A feature key is a config path the CLI resolves, not a host path: it must stay POSIX, or the
|
|
629
|
+
// flat-config form (whose relative path spans a directory) emits a backslash Windows-only key.
|
|
630
|
+
const featureKey = (dir: string) =>
|
|
631
|
+
`./${relative(dirname(target), join(featureRoot, dir)).replaceAll(sep, '/')}`;
|
|
586
632
|
const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
|
|
587
633
|
const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
|
|
588
634
|
const claudeFeatureKey = featureKey(CLAUDE_FEATURE_DIR);
|
|
@@ -925,7 +971,7 @@ type UpOptions = { noCache?: boolean; configPath?: string; overrideConfigPath?:
|
|
|
925
971
|
|
|
926
972
|
export function devcontainerUpArgs(workspaceDir: string, opts: UpOptions = {}) {
|
|
927
973
|
const args = [
|
|
928
|
-
devcontainerBin(),
|
|
974
|
+
...devcontainerBin(),
|
|
929
975
|
'up',
|
|
930
976
|
'--workspace-folder',
|
|
931
977
|
workspaceDir,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Docker from 'dockerode';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { DOCKER_HOST, dockerEnv } from './config.server.ts';
|
|
5
6
|
import { spawnCapture } from './spawn.server.ts';
|
|
@@ -16,9 +17,13 @@ export function resetDocker(): void {
|
|
|
16
17
|
g.__codebayDocker = undefined;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/** Docker Desktop for Windows exposes no Unix socket; its daemon listens on a named pipe. */
|
|
21
|
+
const DEFAULT_DOCKER_SOCKET =
|
|
22
|
+
process.platform === 'win32' ? '//./pipe/docker_engine' : '/var/run/docker.sock';
|
|
23
|
+
|
|
19
24
|
async function resolveDocker(): Promise<Docker> {
|
|
20
25
|
const host = DOCKER_HOST || (await dockerContextHost()) || '';
|
|
21
|
-
return new Docker(host ? parseDockerHost(host) : { socketPath:
|
|
26
|
+
return new Docker(host ? parseDockerHost(host) : { socketPath: DEFAULT_DOCKER_SOCKET });
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
/** dockerode ignores `docker context`, but the devcontainer CLI honors it — so read it here. */
|
|
@@ -33,7 +38,8 @@ async function dockerContextHost(): Promise<string> {
|
|
|
33
38
|
|
|
34
39
|
/** Missing files yield undefined so the connection fails with a clear TLS error, not here. */
|
|
35
40
|
function tlsMaterials(): { ca?: Buffer; cert?: Buffer; key?: Buffer } {
|
|
36
|
-
|
|
41
|
+
// homedir(), not $HOME, which Windows leaves unset — that would make this cwd-relative.
|
|
42
|
+
const dir = process.env.DOCKER_CERT_PATH?.trim() || join(homedir(), '.docker');
|
|
37
43
|
const read = (name: string): Buffer | undefined => {
|
|
38
44
|
try {
|
|
39
45
|
return readFileSync(join(dir, name));
|
|
@@ -44,10 +50,10 @@ function tlsMaterials(): { ca?: Buffer; cert?: Buffer; key?: Buffer } {
|
|
|
44
50
|
return { ca: read('ca.pem'), cert: read('cert.pem'), key: read('key.pem') };
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
/** Windows
|
|
53
|
+
/** dockerode takes a Windows named pipe as a `socketPath`, exactly like a Unix socket. */
|
|
48
54
|
function parseDockerHost(host: string): Docker.DockerOptions {
|
|
49
|
-
|
|
50
|
-
return { socketPath: host.slice(
|
|
55
|
+
for (const scheme of ['unix://', 'npipe://']) {
|
|
56
|
+
if (host.startsWith(scheme)) return { socketPath: host.slice(scheme.length) };
|
|
51
57
|
}
|
|
52
58
|
const tls = (process.env.DOCKER_TLS_VERIFY ?? '') !== '';
|
|
53
59
|
const url = new URL(host.includes('://') ? host : `tcp://${host}`);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows has no single filesystem root: `dirname('C:\')` is `C:\`, so walking up from the home
|
|
3
|
+
* directory dead-ends on its own drive and a project on another one is unreachable. This stands in
|
|
4
|
+
* as the parent of every drive root, and browsing it lists the drives themselves.
|
|
5
|
+
*
|
|
6
|
+
* A colon is illegal in a Windows path segment, so this can never collide with a real folder.
|
|
7
|
+
*/
|
|
8
|
+
export const DRIVES_ROOT = '::drives';
|
|
9
|
+
|
|
10
|
+
/** What the crumbs show for the virtual root, which has no path of its own. */
|
|
11
|
+
export const DRIVES_ROOT_LABEL = 'This PC';
|