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
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import * as v from 'valibot';
|
|
2
|
+
import type { McpServer } from 'tmcp';
|
|
3
|
+
import { tool } from 'tmcp/utils';
|
|
4
|
+
import {
|
|
5
|
+
createInstance,
|
|
6
|
+
deleteInstance,
|
|
7
|
+
listInstances,
|
|
8
|
+
sanitizeInstance,
|
|
9
|
+
subscribeLogs
|
|
10
|
+
} from '../lib/instances.server.ts';
|
|
11
|
+
import { getInstance, getRun, listRuns, type AgentRunRow } from '../lib/db.server.ts';
|
|
12
|
+
import {
|
|
13
|
+
PROMPT_MAX_BYTES,
|
|
14
|
+
pollRunNow,
|
|
15
|
+
readRunLog,
|
|
16
|
+
runDetail,
|
|
17
|
+
startRun,
|
|
18
|
+
stopRun
|
|
19
|
+
} from '../lib/agent-runs.server.ts';
|
|
20
|
+
import {
|
|
21
|
+
WRITE_FILE_MAX_BYTES,
|
|
22
|
+
createPr,
|
|
23
|
+
execCommand,
|
|
24
|
+
gitDiff,
|
|
25
|
+
gitPush,
|
|
26
|
+
readWorkspaceFile,
|
|
27
|
+
writeWorkspaceFile
|
|
28
|
+
} from '../lib/sandbox-ops.server.ts';
|
|
29
|
+
import { currentHealthSnapshots } from '../lib/health.server.ts';
|
|
30
|
+
import { proxyPathFor } from '../lib/proxy.server.ts';
|
|
31
|
+
import { PUBLIC_ORIGIN } from '../lib/config.server.ts';
|
|
32
|
+
import { CLAUDE_PERMISSION_MODES, normalizeMode } from '../types.ts';
|
|
33
|
+
|
|
34
|
+
/** Long-polling `get_run` past this would start tripping MCP clients' own request timeouts. */
|
|
35
|
+
const MAX_WAIT_SECONDS = 60;
|
|
36
|
+
|
|
37
|
+
const sandboxId = v.pipe(v.string(), v.minLength(1), v.description('The sandbox id.'));
|
|
38
|
+
|
|
39
|
+
function requireInstance(id: string) {
|
|
40
|
+
const row = getInstance(id);
|
|
41
|
+
if (!row) throw new Error(`No sandbox with id ${id}`);
|
|
42
|
+
return row;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every tool that touches the container needs one that is actually up. */
|
|
46
|
+
function requireRunning(id: string) {
|
|
47
|
+
const row = requireInstance(id);
|
|
48
|
+
if (!row.container_id || row.status !== 'running') {
|
|
49
|
+
throw new Error(
|
|
50
|
+
row.status === 'creating'
|
|
51
|
+
? 'the sandbox is still building — poll get_sandbox until status is "running"'
|
|
52
|
+
: `the sandbox is ${row.status}, not running`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return row;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The IDE link is what makes a sandbox inspectable by a human, so every sandbox payload carries it. */
|
|
59
|
+
const ideUrl = (id: string) => `${PUBLIC_ORIGIN}${proxyPathFor(id)}`;
|
|
60
|
+
|
|
61
|
+
function sandboxPayload(row: ReturnType<typeof requireInstance>) {
|
|
62
|
+
return { ...sanitizeInstance(row), ide_url: ideUrl(row.id) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The prompt is left out: the caller wrote it, and it can be as large as the carrier allows. */
|
|
66
|
+
function runPayload(run: AgentRunRow) {
|
|
67
|
+
const { id, instance_id, prompt: _prompt, ...detail } = runDetail(run);
|
|
68
|
+
return { run_id: id, sandbox_id: instance_id, ...detail };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A failed call reaches the model as readable content rather than a JSON-RPC protocol error: the
|
|
73
|
+
* caller is an agent, and "that sandbox is still building" is information it can act on.
|
|
74
|
+
*/
|
|
75
|
+
function safe<T>(run: (input: T) => unknown) {
|
|
76
|
+
return async (input: T) => {
|
|
77
|
+
try {
|
|
78
|
+
return tool.text(JSON.stringify(await run(input), null, 2));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return tool.error((err as Error).message);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const runOptions = {
|
|
86
|
+
resume_session_id: v.optional(
|
|
87
|
+
v.pipe(
|
|
88
|
+
v.string(),
|
|
89
|
+
v.description('Continue a previous run’s Claude session instead of starting fresh.')
|
|
90
|
+
)
|
|
91
|
+
),
|
|
92
|
+
model: v.optional(
|
|
93
|
+
v.pipe(
|
|
94
|
+
v.string(),
|
|
95
|
+
v.description(
|
|
96
|
+
'Model alias or id, e.g. "sonnet". Omit for the sandbox default. The run reports the id ' +
|
|
97
|
+
'Claude actually used as `model` once it starts.'
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
),
|
|
101
|
+
max_turns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
|
|
102
|
+
json_schema: v.optional(
|
|
103
|
+
v.pipe(
|
|
104
|
+
v.string(),
|
|
105
|
+
v.description(
|
|
106
|
+
'A JSON Schema (as a string). When set, the run’s structured_output matches it.'
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
),
|
|
110
|
+
permission_mode: v.optional(
|
|
111
|
+
v.pipe(
|
|
112
|
+
v.picklist(CLAUDE_PERMISSION_MODES),
|
|
113
|
+
v.description(
|
|
114
|
+
'Tightens what Claude may do. Omit for full autonomy, which is the default for MCP runs.'
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
),
|
|
118
|
+
timeout_minutes: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(240)))
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type RunOptionInput = v.InferInput<v.ObjectSchema<typeof runOptions, undefined>>;
|
|
122
|
+
|
|
123
|
+
// The byte limits are enforced at runtime (startRun, writeWorkspaceFile) and only documented here:
|
|
124
|
+
// a `v.check` can't be rendered into the JSON Schema an MCP client reads.
|
|
125
|
+
const promptSchema = (description: string) =>
|
|
126
|
+
v.pipe(
|
|
127
|
+
v.string(),
|
|
128
|
+
v.minLength(1),
|
|
129
|
+
v.description(
|
|
130
|
+
`${description} At most ${PROMPT_MAX_BYTES} bytes of UTF-8; refer to a workspace file for anything larger.`
|
|
131
|
+
)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
function toStartOptions(input: RunOptionInput) {
|
|
135
|
+
return {
|
|
136
|
+
resumeSessionId: input.resume_session_id,
|
|
137
|
+
model: input.model,
|
|
138
|
+
maxTurns: input.max_turns,
|
|
139
|
+
jsonSchema: input.json_schema,
|
|
140
|
+
permissionMode: input.permission_mode,
|
|
141
|
+
timeoutMs: input.timeout_minutes ? input.timeout_minutes * 60_000 : undefined
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
146
|
+
|
|
147
|
+
export function registerTools(server: McpServer<v.GenericSchema>): void {
|
|
148
|
+
/** Registers a tool whose handler returns a plain payload; `safe` serialises it and traps errors. */
|
|
149
|
+
function define<E extends v.ObjectEntries>(
|
|
150
|
+
options: { name: string; description: string; schema: v.ObjectSchema<E, undefined> },
|
|
151
|
+
run: (input: v.InferInput<v.ObjectSchema<E, undefined>>) => unknown
|
|
152
|
+
): void {
|
|
153
|
+
server.tool(options, safe(run));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
define(
|
|
157
|
+
{
|
|
158
|
+
name: 'create_sandbox',
|
|
159
|
+
description:
|
|
160
|
+
'Create an isolated devcontainer sandbox from a Git repo URL or a local folder path on ' +
|
|
161
|
+
'the Codebay host. Returns immediately while the container builds in the background; poll ' +
|
|
162
|
+
'get_sandbox until status is "running". Pass a prompt to queue an agent run that starts ' +
|
|
163
|
+
'as soon as the sandbox is up.',
|
|
164
|
+
schema: v.object({
|
|
165
|
+
source: v.pipe(
|
|
166
|
+
v.string(),
|
|
167
|
+
v.minLength(1),
|
|
168
|
+
v.description('A Git repo URL (cloned) or an absolute local folder path (copied).')
|
|
169
|
+
),
|
|
170
|
+
name: v.optional(v.string()),
|
|
171
|
+
branch: v.optional(v.pipe(v.string(), v.description('Branch to check out after cloning.'))),
|
|
172
|
+
mode: v.optional(v.picklist(['ide', 'terminal'] as const)),
|
|
173
|
+
prompt: v.optional(promptSchema('Queues an agent run once the sandbox is running.')),
|
|
174
|
+
...runOptions
|
|
175
|
+
})
|
|
176
|
+
},
|
|
177
|
+
async (input) => {
|
|
178
|
+
const instance = await createInstance(input.source, input.name, {
|
|
179
|
+
branch: input.branch,
|
|
180
|
+
mode: input.mode ? normalizeMode(input.mode) : undefined
|
|
181
|
+
});
|
|
182
|
+
// The sandbox exists once createInstance returns, so a failed run must not swallow its id —
|
|
183
|
+
// the caller would retry and leak the first one.
|
|
184
|
+
let run: AgentRunRow | null = null;
|
|
185
|
+
let runError: string | undefined;
|
|
186
|
+
try {
|
|
187
|
+
if (input.prompt) run = startRun(instance, input.prompt, toStartOptions(input));
|
|
188
|
+
} catch (err) {
|
|
189
|
+
runError = err instanceof Error ? err.message : String(err);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
sandbox: sandboxPayload(instance),
|
|
193
|
+
run: run ? runPayload(run) : null,
|
|
194
|
+
...(runError ? { run_error: runError } : {}),
|
|
195
|
+
note: 'The sandbox is building. Poll get_sandbox until status is "running".'
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
define(
|
|
201
|
+
{
|
|
202
|
+
name: 'list_sandboxes',
|
|
203
|
+
description:
|
|
204
|
+
'List every sandbox Codebay is managing, with its status and any run currently in flight.',
|
|
205
|
+
schema: v.object({
|
|
206
|
+
status: v.optional(v.picklist(['creating', 'running', 'stopped', 'error'] as const))
|
|
207
|
+
})
|
|
208
|
+
},
|
|
209
|
+
async (input) => {
|
|
210
|
+
const all = await listInstances();
|
|
211
|
+
const rows = input.status ? all.filter((i) => i.status === input.status) : all;
|
|
212
|
+
return {
|
|
213
|
+
sandboxes: rows.map((row) => ({ ...row, ide_url: ideUrl(row.id) }))
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
define(
|
|
219
|
+
{
|
|
220
|
+
name: 'get_sandbox',
|
|
221
|
+
description:
|
|
222
|
+
'Full detail for one sandbox: status, health probes, forwarded ports and its active run.',
|
|
223
|
+
schema: v.object({ sandbox_id: sandboxId })
|
|
224
|
+
},
|
|
225
|
+
async (input) => {
|
|
226
|
+
const row = requireInstance(input.sandbox_id);
|
|
227
|
+
const health = currentHealthSnapshots().find((s) => s.id === row.id)?.health ?? null;
|
|
228
|
+
const runs = listRuns(row.id, 5);
|
|
229
|
+
return {
|
|
230
|
+
sandbox: sandboxPayload(row),
|
|
231
|
+
health,
|
|
232
|
+
runs: runs.map(runPayload)
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
define(
|
|
238
|
+
{
|
|
239
|
+
name: 'delete_sandbox',
|
|
240
|
+
description:
|
|
241
|
+
'Destroy a sandbox: cancels any active run, removes the container and its workspace copy. ' +
|
|
242
|
+
'Mirrored run transcripts are kept.',
|
|
243
|
+
schema: v.object({ sandbox_id: sandboxId })
|
|
244
|
+
},
|
|
245
|
+
async (input) => {
|
|
246
|
+
requireInstance(input.sandbox_id);
|
|
247
|
+
await deleteInstance(input.sandbox_id);
|
|
248
|
+
return { deleted: input.sandbox_id };
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
define(
|
|
253
|
+
{
|
|
254
|
+
name: 'run_agent',
|
|
255
|
+
description:
|
|
256
|
+
'Run Claude Code non-interactively against the sandbox and return a run handle. The run ' +
|
|
257
|
+
'continues in the background; poll get_run for progress and the final result. Only one ' +
|
|
258
|
+
'run at a time per sandbox.',
|
|
259
|
+
schema: v.object({
|
|
260
|
+
sandbox_id: sandboxId,
|
|
261
|
+
prompt: promptSchema('What Claude should do.'),
|
|
262
|
+
...runOptions
|
|
263
|
+
})
|
|
264
|
+
},
|
|
265
|
+
(input) => {
|
|
266
|
+
const row = requireInstance(input.sandbox_id);
|
|
267
|
+
return { run: runPayload(startRun(row, input.prompt, toStartOptions(input))) };
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
define(
|
|
272
|
+
{
|
|
273
|
+
name: 'get_run',
|
|
274
|
+
description:
|
|
275
|
+
'Status and result of an agent run. While it is still going you get the live session id, ' +
|
|
276
|
+
'model, turn count, cost and what Claude is doing right now. Set wait_seconds to block until it ' +
|
|
277
|
+
'finishes instead of polling.',
|
|
278
|
+
schema: v.object({
|
|
279
|
+
run_id: v.pipe(v.string(), v.minLength(1)),
|
|
280
|
+
wait_seconds: v.optional(
|
|
281
|
+
v.pipe(
|
|
282
|
+
v.number(),
|
|
283
|
+
v.integer(),
|
|
284
|
+
v.minValue(0),
|
|
285
|
+
v.maxValue(MAX_WAIT_SECONDS),
|
|
286
|
+
v.description('Block up to this long for the run to finish before answering.')
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
},
|
|
291
|
+
async (input) => {
|
|
292
|
+
if (!getRun(input.run_id)) throw new Error(`No run with id ${input.run_id}`);
|
|
293
|
+
const deadline = Date.now() + (input.wait_seconds ?? 0) * 1000;
|
|
294
|
+
let run = await pollRunNow(input.run_id);
|
|
295
|
+
while (
|
|
296
|
+
run &&
|
|
297
|
+
(run.status === 'running' || run.status === 'queued') &&
|
|
298
|
+
Date.now() < deadline
|
|
299
|
+
) {
|
|
300
|
+
await sleep(2000);
|
|
301
|
+
run = await pollRunNow(input.run_id);
|
|
302
|
+
}
|
|
303
|
+
return { run: runPayload(run!) };
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
define(
|
|
308
|
+
{
|
|
309
|
+
name: 'list_runs',
|
|
310
|
+
description: 'Run history for a sandbox, newest first.',
|
|
311
|
+
schema: v.object({
|
|
312
|
+
sandbox_id: sandboxId,
|
|
313
|
+
limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)))
|
|
314
|
+
})
|
|
315
|
+
},
|
|
316
|
+
(input) => {
|
|
317
|
+
requireInstance(input.sandbox_id);
|
|
318
|
+
return { runs: listRuns(input.sandbox_id, input.limit ?? 20).map(runPayload) };
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
define(
|
|
323
|
+
{
|
|
324
|
+
name: 'stop_run',
|
|
325
|
+
description:
|
|
326
|
+
'Cancel an agent run. Sends SIGINT first so Claude can end its turn cleanly, then ' +
|
|
327
|
+
'escalates if it does not exit.',
|
|
328
|
+
schema: v.object({ run_id: v.pipe(v.string(), v.minLength(1)) })
|
|
329
|
+
},
|
|
330
|
+
async (input) => ({ run: runPayload(await stopRun(input.run_id)) })
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
define(
|
|
334
|
+
{
|
|
335
|
+
name: 'get_diff',
|
|
336
|
+
description:
|
|
337
|
+
'The sandbox workspace’s uncommitted changes as a unified diff, plus a porcelain ' +
|
|
338
|
+
'status summary and the current branch. This is how you see what an agent run actually did.',
|
|
339
|
+
schema: v.object({
|
|
340
|
+
sandbox_id: sandboxId,
|
|
341
|
+
base: v.optional(
|
|
342
|
+
v.pipe(v.string(), v.description('Diff against this ref instead of the working tree.'))
|
|
343
|
+
),
|
|
344
|
+
staged: v.optional(v.boolean())
|
|
345
|
+
})
|
|
346
|
+
},
|
|
347
|
+
async (input) =>
|
|
348
|
+
await gitDiff(requireRunning(input.sandbox_id), {
|
|
349
|
+
base: input.base,
|
|
350
|
+
staged: input.staged
|
|
351
|
+
})
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
define(
|
|
355
|
+
{
|
|
356
|
+
name: 'read_file',
|
|
357
|
+
description:
|
|
358
|
+
'Read a file from the sandbox workspace, by path relative to the workspace root.',
|
|
359
|
+
schema: v.object({ sandbox_id: sandboxId, path: v.pipe(v.string(), v.minLength(1)) })
|
|
360
|
+
},
|
|
361
|
+
async (input) => ({
|
|
362
|
+
path: input.path,
|
|
363
|
+
content: await readWorkspaceFile(requireRunning(input.sandbox_id), input.path)
|
|
364
|
+
})
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
define(
|
|
368
|
+
{
|
|
369
|
+
name: 'write_file',
|
|
370
|
+
description:
|
|
371
|
+
'Write a file into the sandbox workspace, creating parent directories as needed. Useful ' +
|
|
372
|
+
`for seeding config or fixtures before prompting Claude. Content is capped at ${WRITE_FILE_MAX_BYTES} ` +
|
|
373
|
+
'bytes per call; build anything larger inside the sandbox with exec_command.',
|
|
374
|
+
schema: v.object({
|
|
375
|
+
sandbox_id: sandboxId,
|
|
376
|
+
path: v.pipe(v.string(), v.minLength(1)),
|
|
377
|
+
content: v.pipe(
|
|
378
|
+
v.string(),
|
|
379
|
+
v.description(`The file's full UTF-8 text, at most ${WRITE_FILE_MAX_BYTES} bytes.`)
|
|
380
|
+
)
|
|
381
|
+
})
|
|
382
|
+
},
|
|
383
|
+
async (input) => {
|
|
384
|
+
await writeWorkspaceFile(requireRunning(input.sandbox_id), input.path, input.content);
|
|
385
|
+
return { path: input.path, written: true };
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
define(
|
|
390
|
+
{
|
|
391
|
+
name: 'exec_command',
|
|
392
|
+
description:
|
|
393
|
+
'Run a shell command in the sandbox, from the workspace root, and capture its output. ' +
|
|
394
|
+
'Bounded by timeout_seconds (default 120).',
|
|
395
|
+
schema: v.object({
|
|
396
|
+
sandbox_id: sandboxId,
|
|
397
|
+
command: v.pipe(v.string(), v.minLength(1)),
|
|
398
|
+
cwd: v.optional(
|
|
399
|
+
v.pipe(v.string(), v.description('Run from here instead of the workspace root.'))
|
|
400
|
+
),
|
|
401
|
+
timeout_seconds: v.optional(
|
|
402
|
+
v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(3600))
|
|
403
|
+
)
|
|
404
|
+
})
|
|
405
|
+
},
|
|
406
|
+
async (input) =>
|
|
407
|
+
await execCommand(requireRunning(input.sandbox_id), input.command, {
|
|
408
|
+
timeoutSeconds: input.timeout_seconds,
|
|
409
|
+
cwd: input.cwd
|
|
410
|
+
})
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
define(
|
|
414
|
+
{
|
|
415
|
+
name: 'git_push',
|
|
416
|
+
description:
|
|
417
|
+
'Commit any pending changes and push the sandbox branch to origin. Git and gh are already ' +
|
|
418
|
+
'authenticated inside the container, so no credentials are needed here.',
|
|
419
|
+
schema: v.object({
|
|
420
|
+
sandbox_id: sandboxId,
|
|
421
|
+
branch: v.optional(
|
|
422
|
+
v.pipe(v.string(), v.description('Branch to push to; defaults to the current one.'))
|
|
423
|
+
),
|
|
424
|
+
commit_message: v.optional(
|
|
425
|
+
v.pipe(
|
|
426
|
+
v.string(),
|
|
427
|
+
v.description('Commit everything outstanding under this message first.')
|
|
428
|
+
)
|
|
429
|
+
),
|
|
430
|
+
force: v.optional(v.pipe(v.boolean(), v.description('Push with --force-with-lease.')))
|
|
431
|
+
})
|
|
432
|
+
},
|
|
433
|
+
async (input) =>
|
|
434
|
+
await gitPush(requireRunning(input.sandbox_id), {
|
|
435
|
+
branch: input.branch,
|
|
436
|
+
commitMessage: input.commit_message,
|
|
437
|
+
force: input.force
|
|
438
|
+
})
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
define(
|
|
442
|
+
{
|
|
443
|
+
name: 'create_pr',
|
|
444
|
+
description: 'Open a pull request from the sandbox branch using the container’s gh CLI.',
|
|
445
|
+
schema: v.object({
|
|
446
|
+
sandbox_id: sandboxId,
|
|
447
|
+
title: v.pipe(v.string(), v.minLength(1)),
|
|
448
|
+
body: v.optional(v.string()),
|
|
449
|
+
base: v.optional(v.string()),
|
|
450
|
+
draft: v.optional(v.boolean())
|
|
451
|
+
})
|
|
452
|
+
},
|
|
453
|
+
async (input) =>
|
|
454
|
+
await createPr(requireRunning(input.sandbox_id), {
|
|
455
|
+
title: input.title,
|
|
456
|
+
body: input.body,
|
|
457
|
+
base: input.base,
|
|
458
|
+
draft: input.draft
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
define(
|
|
463
|
+
{
|
|
464
|
+
name: 'get_logs',
|
|
465
|
+
description:
|
|
466
|
+
'Logs for a sandbox. kind "boot" is the provisioning log (why a build failed); kind "run" ' +
|
|
467
|
+
'is the mirrored event stream of an agent run.',
|
|
468
|
+
schema: v.object({
|
|
469
|
+
sandbox_id: sandboxId,
|
|
470
|
+
kind: v.picklist(['boot', 'run'] as const),
|
|
471
|
+
run_id: v.optional(v.string()),
|
|
472
|
+
tail: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(2000)))
|
|
473
|
+
})
|
|
474
|
+
},
|
|
475
|
+
(input) => {
|
|
476
|
+
requireInstance(input.sandbox_id);
|
|
477
|
+
const tail = input.tail ?? 200;
|
|
478
|
+
if (input.kind === 'run') {
|
|
479
|
+
if (!input.run_id) throw new Error('run_id is required for kind "run"');
|
|
480
|
+
// Resolve through the DB so the id is a known token, never a path fragment.
|
|
481
|
+
const run = getRun(input.run_id);
|
|
482
|
+
if (!run || run.instance_id !== input.sandbox_id) {
|
|
483
|
+
throw new Error(`No run ${input.run_id} in sandbox ${input.sandbox_id}`);
|
|
484
|
+
}
|
|
485
|
+
return { kind: 'run', run_id: run.id, log: readRunLog(run.id, tail) };
|
|
486
|
+
}
|
|
487
|
+
// subscribeLogs replays the buffer synchronously before it starts streaming, so
|
|
488
|
+
// unsubscribing straight away leaves us with exactly the captured boot log.
|
|
489
|
+
const lines: string[] = [];
|
|
490
|
+
subscribeLogs(input.sandbox_id, (chunk) => lines.push(chunk))();
|
|
491
|
+
return { kind: 'boot', log: lines.join('').split('\n').slice(-tail).join('\n') };
|
|
492
|
+
}
|
|
493
|
+
);
|
|
494
|
+
}
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
disableBuildCache,
|
|
22
22
|
copyIgnorePatterns,
|
|
23
23
|
builtinCopyIgnore,
|
|
24
|
+
claudeConfigDir,
|
|
25
|
+
mcpEnabled,
|
|
26
|
+
mcpToken,
|
|
27
|
+
mcpUrl,
|
|
28
|
+
mcpPrAttribution,
|
|
24
29
|
gitIdentityEnabled,
|
|
25
30
|
gitIdentityName,
|
|
26
31
|
gitIdentityEmail,
|
|
@@ -62,6 +67,11 @@
|
|
|
62
67
|
disableBuildCache: boolean;
|
|
63
68
|
copyIgnorePatterns: string;
|
|
64
69
|
builtinCopyIgnore: string;
|
|
70
|
+
claudeConfigDir: string;
|
|
71
|
+
mcpEnabled: boolean;
|
|
72
|
+
mcpToken: string;
|
|
73
|
+
mcpUrl: string;
|
|
74
|
+
mcpPrAttribution: boolean;
|
|
65
75
|
gitIdentityEnabled: boolean;
|
|
66
76
|
gitIdentityName: string;
|
|
67
77
|
gitIdentityEmail: string;
|
|
@@ -106,6 +116,11 @@
|
|
|
106
116
|
{disableBuildCache}
|
|
107
117
|
{copyIgnorePatterns}
|
|
108
118
|
{builtinCopyIgnore}
|
|
119
|
+
{claudeConfigDir}
|
|
120
|
+
{mcpEnabled}
|
|
121
|
+
{mcpToken}
|
|
122
|
+
{mcpUrl}
|
|
123
|
+
{mcpPrAttribution}
|
|
109
124
|
{gitIdentityEnabled}
|
|
110
125
|
{gitIdentityName}
|
|
111
126
|
{gitIdentityEmail}
|
package/src/routes.ts
CHANGED
|
@@ -60,14 +60,30 @@ import {
|
|
|
60
60
|
deleteFolderHistory,
|
|
61
61
|
getInstance,
|
|
62
62
|
getOption,
|
|
63
|
+
listRuns,
|
|
63
64
|
listFolderHistory,
|
|
64
65
|
setOption
|
|
65
66
|
} from './lib/db.server.ts';
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
APP_VERSION,
|
|
69
|
+
DEFAULT_COPY_IGNORE,
|
|
70
|
+
DEFAULT_IMAGE,
|
|
71
|
+
PUBLIC_ORIGIN
|
|
72
|
+
} from './lib/config.server.ts';
|
|
67
73
|
import { wsUpgradeAllowed } from './lib/auth.server.ts';
|
|
74
|
+
import {
|
|
75
|
+
MCP_PATH,
|
|
76
|
+
getMcpToken,
|
|
77
|
+
mcpEnabled,
|
|
78
|
+
regenerateMcpToken,
|
|
79
|
+
setMcpEnabled
|
|
80
|
+
} from './lib/mcp-auth.server.ts';
|
|
68
81
|
import { clearAttention, setAttention } from './lib/bridge.server.ts';
|
|
69
82
|
import { timingSafeEqualStr } from './lib/crypto.server.ts';
|
|
70
83
|
import { proxyRoutes } from './lib/proxy.server.ts';
|
|
84
|
+
import { runDetail, runTimeline } from './lib/agent-runs.server.ts';
|
|
85
|
+
import { PR_ATTRIBUTION_KEY, prAttributionEnabled } from './lib/sandbox-ops.server.ts';
|
|
86
|
+
import { mcpRoutes } from './mcp/routes.server.ts';
|
|
71
87
|
import {
|
|
72
88
|
isInstanceFilter,
|
|
73
89
|
isTheme,
|
|
@@ -112,6 +128,9 @@ function currentFilter(): InstanceFilter {
|
|
|
112
128
|
return isInstanceFilter(v) ? v : 'all';
|
|
113
129
|
}
|
|
114
130
|
|
|
131
|
+
/** A ceiling on how many run timelines one Agent log request can pull off disk. */
|
|
132
|
+
const MAX_OPEN_TIMELINES = 10;
|
|
133
|
+
|
|
115
134
|
/** Lets a route handler just `throw` for both validation and business-logic failures. */
|
|
116
135
|
async function mutate(fn: () => Promise<unknown> | unknown): Promise<Response> {
|
|
117
136
|
try {
|
|
@@ -214,8 +233,16 @@ export const routes: Record<string, MochiRouteValue> = {
|
|
|
214
233
|
// An explicit empty string (as opposed to unset) means "copy everything".
|
|
215
234
|
copyIgnorePatterns: getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE,
|
|
216
235
|
builtinCopyIgnore: DEFAULT_COPY_IGNORE,
|
|
236
|
+
// Blank means "use ~/.claude"; the host dir credentials/skills/statusLine read from.
|
|
237
|
+
claudeConfigDir: getOption('claude_config_dir') ?? '',
|
|
217
238
|
// Not secrets, so the actual values (not just a "set" flag) go to the client.
|
|
218
239
|
// Blank means "no override" — fall back to the host's git config.
|
|
240
|
+
mcpEnabled: mcpEnabled(),
|
|
241
|
+
// The one secret here that IS sent to the client: copying it into an MCP client is
|
|
242
|
+
// the whole point of it existing.
|
|
243
|
+
mcpToken: mcpEnabled() ? getMcpToken() : '',
|
|
244
|
+
mcpUrl: `${PUBLIC_ORIGIN}${MCP_PATH}`,
|
|
245
|
+
mcpPrAttribution: prAttributionEnabled(),
|
|
219
246
|
gitIdentityEnabled: gitIdentityEnabled(),
|
|
220
247
|
gitIdentityName: getOption('git_identity_name') ?? '',
|
|
221
248
|
gitIdentityEmail: getOption('git_identity_email') ?? '',
|
|
@@ -334,6 +361,27 @@ export const routes: Record<string, MochiRouteValue> = {
|
|
|
334
361
|
return success({ patterns });
|
|
335
362
|
},
|
|
336
363
|
|
|
364
|
+
// The host directory Claude config injections read from; blank falls back to ~/.claude.
|
|
365
|
+
claudeConfigDir: ({ formData }) => {
|
|
366
|
+
const dir = str(formData, 'dir').trim();
|
|
367
|
+
setOption('claude_config_dir', dir);
|
|
368
|
+
return success({ dir });
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
mcpToggle: ({ formData }) => {
|
|
372
|
+
const enabled = onChecked(formData, 'enabled');
|
|
373
|
+
setMcpEnabled(enabled);
|
|
374
|
+
return success({ enabled, token: enabled ? getMcpToken() : '' });
|
|
375
|
+
},
|
|
376
|
+
// Rotating breaks every client already configured with the old token, so the UI confirms.
|
|
377
|
+
mcpRegenerateToken: () => success({ token: regenerateMcpToken() }),
|
|
378
|
+
|
|
379
|
+
mcpPrAttributionToggle: ({ formData }) => {
|
|
380
|
+
const enabled = onChecked(formData, 'enabled');
|
|
381
|
+
setOption(PR_ATTRIBUTION_KEY, enabled ? '1' : '0');
|
|
382
|
+
return success({ enabled });
|
|
383
|
+
},
|
|
384
|
+
|
|
337
385
|
gitIdentityToggle: ({ formData }) => {
|
|
338
386
|
const enabled = onChecked(formData, 'enabled');
|
|
339
387
|
setOption('git_identity_enabled', enabled ? '1' : '0');
|
|
@@ -630,6 +678,27 @@ export const routes: Record<string, MochiRouteValue> = {
|
|
|
630
678
|
return { ok: true };
|
|
631
679
|
}),
|
|
632
680
|
|
|
681
|
+
// GET-only, so the CSRF header guard doesn't apply; the Basic Auth gate still does.
|
|
682
|
+
'/api/instances/:id/agent-log': Mochi.api(({ method, params, url }) => {
|
|
683
|
+
if (method !== 'GET') return apiError(405, 'Method Not Allowed');
|
|
684
|
+
if (!getInstance(params.id!)) return apiError(404, 'Instance not found');
|
|
685
|
+
const runs = listRuns(params.id!, 20);
|
|
686
|
+
const known = new Set(runs.map((r) => r.id));
|
|
687
|
+
// The panel asks for whichever runs it has expanded; with none named it opens on the newest.
|
|
688
|
+
const wanted = (url.searchParams.get('run_ids') ?? '')
|
|
689
|
+
.split(',')
|
|
690
|
+
.filter((rid) => known.has(rid));
|
|
691
|
+
const ids = (wanted.length ? wanted : runs.slice(0, 1).map((r) => r.id)).slice(
|
|
692
|
+
0,
|
|
693
|
+
MAX_OPEN_TIMELINES
|
|
694
|
+
);
|
|
695
|
+
return json({
|
|
696
|
+
runs: runs.map(runDetail),
|
|
697
|
+
// Keyed by run id: one request refreshes every expanded box at once.
|
|
698
|
+
timelines: Object.fromEntries(ids.map((rid) => [rid, runTimeline(rid)]))
|
|
699
|
+
});
|
|
700
|
+
}),
|
|
701
|
+
|
|
633
702
|
'/api/instances/:id/attention/clear': mutationRoute('POST', ({ params }) => {
|
|
634
703
|
clearAttention(params.id!);
|
|
635
704
|
return { ok: true };
|
|
@@ -686,6 +755,8 @@ export const routes: Record<string, MochiRouteValue> = {
|
|
|
686
755
|
|
|
687
756
|
...proxyRoutes,
|
|
688
757
|
|
|
758
|
+
...mcpRoutes,
|
|
759
|
+
|
|
689
760
|
...(process.env.MODE === 'development'
|
|
690
761
|
? {
|
|
691
762
|
'/debug': Mochi.page('./src/pages/UI.svelte', {
|
package/src/shell.html
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
7
|
<title>Codebay</title>
|
|
7
8
|
<style>
|
|
8
9
|
/* Each themed variable is defined once as light-dark(light, dark). With no
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,21 @@ export interface Instance {
|
|
|
29
29
|
git_branch: string | null;
|
|
30
30
|
attention: 'done' | 'waiting' | null;
|
|
31
31
|
forwarded_ports: PortForward[];
|
|
32
|
+
/** The newest unfinished MCP-driven run, or null when nothing is running in this sandbox. */
|
|
33
|
+
active_run: AgentRunSummary | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AgentRunStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled';
|
|
37
|
+
|
|
38
|
+
/** The slice of an agent run the dashboard renders; the prompt and result never ride the stream. */
|
|
39
|
+
export interface AgentRunSummary {
|
|
40
|
+
id: string;
|
|
41
|
+
instance_id: string;
|
|
42
|
+
status: AgentRunStatus;
|
|
43
|
+
last_activity: string | null;
|
|
44
|
+
started_at: number | null;
|
|
45
|
+
finished_at: number | null;
|
|
46
|
+
is_error: boolean;
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
/** `'ide'` serves full code-server; `'terminal'` serves only ttyd + Claude Code. */
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{mb as a}from"/_mochi/client/chunk-phf7v5jj.js";import"/_mochi/client/chunk-0mc09cyb.js";export{a as registerComponent};
|