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.
Files changed (85) hide show
  1. package/.mochi/manifest.json +522 -443
  2. package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
  7. package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
  8. package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
  9. package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
  10. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
  13. package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
  14. package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
  15. package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
  16. package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
  17. package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
  18. package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
  21. package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
  22. package/.mochi/svelte-compile/{Settings-h7gsjyz3.server.js → Settings-0b5t9kw9.server.js} +348 -151
  23. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
  24. package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
  25. package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
  26. package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
  27. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
  28. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  29. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  30. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  31. package/README.md +32 -1
  32. package/migrations/0009_agent_runs.sql +24 -0
  33. package/migrations/0010_agent_run_model.sql +2 -0
  34. package/package.json +6 -2
  35. package/public/favicon.svg +9 -0
  36. package/src/components/AgentLogBox.svelte +458 -0
  37. package/src/components/AppShell.svelte +11 -0
  38. package/src/components/FolderBrowser.svelte +13 -5
  39. package/src/components/InstanceCard.svelte +38 -0
  40. package/src/components/InstanceView.svelte +8 -0
  41. package/src/components/SettingsView.svelte +260 -0
  42. package/src/components/UiShowcase.svelte +4 -2
  43. package/src/container-injections/claude-code-credentials.ts +2 -3
  44. package/src/container-injections/claude-code-skills.ts +125 -0
  45. package/src/container-injections/claude-output-style.ts +2 -3
  46. package/src/container-injections/claude-statusline.ts +9 -17
  47. package/src/container-injections/github-credentials.ts +12 -1
  48. package/src/index.ts +32 -7
  49. package/src/lib/agent-run-stream.ts +246 -0
  50. package/src/lib/agent-runs.server.ts +724 -0
  51. package/src/lib/auth.server.ts +11 -2
  52. package/src/lib/build-freshness.server.ts +71 -0
  53. package/src/lib/config.server.ts +29 -6
  54. package/src/lib/container-files.server.ts +23 -1
  55. package/src/lib/data-dir-lock.server.ts +78 -0
  56. package/src/lib/db.server.ts +120 -1
  57. package/src/lib/devcontainer.server.ts +60 -14
  58. package/src/lib/docker-client.server.ts +11 -5
  59. package/src/lib/drives.ts +11 -0
  60. package/src/lib/exec.server.ts +66 -5
  61. package/src/lib/host-claude.server.ts +31 -0
  62. package/src/lib/injections.server.ts +2 -0
  63. package/src/lib/instances.server.ts +45 -3
  64. package/src/lib/log-capture.server.ts +17 -5
  65. package/src/lib/mcp-auth.server.ts +52 -0
  66. package/src/lib/picker.server.ts +23 -2
  67. package/src/lib/sandbox-ops.server.ts +263 -0
  68. package/src/mcp/routes.server.ts +25 -0
  69. package/src/mcp/server.server.ts +52 -0
  70. package/src/mcp/tools.server.ts +494 -0
  71. package/src/pages/Settings.svelte +15 -0
  72. package/src/routes.ts +72 -1
  73. package/src/shell.html +1 -0
  74. package/src/types.ts +15 -0
  75. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  76. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  77. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  78. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  79. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-wedc9x9y.js +0 -39
  80. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  81. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  82. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  83. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  84. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  85. 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 { APP_VERSION, DEFAULT_COPY_IGNORE, DEFAULT_IMAGE } from './lib/config.server.ts';
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};