codebay 0.14.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 +526 -455
- 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-a7b11v90.server.js → Settings-0b5t9kw9.server.js} +145 -25
- 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/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 +186 -0
- package/src/components/UiShowcase.svelte +4 -2
- package/src/container-injections/claude-statusline.ts +4 -1
- 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 +1 -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/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 +12 -0
- package/src/routes.ts +63 -1
- 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-4a7ann1f.js +0 -40
- 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,25 @@
|
|
|
1
|
+
import { Mochi, apiError, type MochiRouteValue } from 'mochi-framework';
|
|
2
|
+
import { MCP_PATH, mcpAuthOk, mcpEnabled } from '../lib/mcp-auth.server.ts';
|
|
3
|
+
import { mcpTransport } from './server.server.ts';
|
|
4
|
+
|
|
5
|
+
function unauthorized(): Response {
|
|
6
|
+
return new Response('Unauthorized', {
|
|
7
|
+
status: 401,
|
|
8
|
+
headers: { 'WWW-Authenticate': 'Bearer realm="Codebay MCP"' }
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mounted at `/mcp` rather than under `/api/` on purpose: `basicAuth` 403s every mutating `/api/`
|
|
14
|
+
* request without the `x-codebay-request` header, which no MCP client sends. The path is exempt
|
|
15
|
+
* from the Basic Auth gate and the CSRF filter, and authenticates by bearer token instead — the
|
|
16
|
+
* same trade the container bridge makes.
|
|
17
|
+
*/
|
|
18
|
+
export const mcpRoutes: Record<string, MochiRouteValue> = {
|
|
19
|
+
[MCP_PATH]: Mochi.api(async ({ request }) => {
|
|
20
|
+
// A 404 while disabled, so an install that never opted in looks like it has no MCP at all.
|
|
21
|
+
if (!mcpEnabled()) return apiError(404, 'Not Found');
|
|
22
|
+
if (!mcpAuthOk(request)) return unauthorized();
|
|
23
|
+
return (await mcpTransport().respond(request)) ?? apiError(404, 'Not Found');
|
|
24
|
+
})
|
|
25
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { McpServer } from 'tmcp';
|
|
2
|
+
import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
|
|
3
|
+
import { HttpTransport } from '@tmcp/transport-http';
|
|
4
|
+
import type * as v from 'valibot';
|
|
5
|
+
import { APP_VERSION } from '../lib/config.server.ts';
|
|
6
|
+
import { MCP_PATH } from '../lib/mcp-auth.server.ts';
|
|
7
|
+
import { registerTools } from './tools.server.ts';
|
|
8
|
+
|
|
9
|
+
const INSTRUCTIONS = `Codebay runs isolated devcontainer sandboxes, each with an authenticated Claude Code inside it.
|
|
10
|
+
|
|
11
|
+
The normal flow is: create_sandbox (from a Git URL or a local folder) → poll get_sandbox until its
|
|
12
|
+
status is "running" → run_agent with a prompt → poll get_run until it is done → get_diff to see what
|
|
13
|
+
changed → git_push / create_pr to land it.
|
|
14
|
+
|
|
15
|
+
Sandboxes are persistent and cost real resources, so delete_sandbox when you are finished with one.
|
|
16
|
+
A sandbox runs one agent at a time. Runs are asynchronous: run_agent returns a handle immediately and
|
|
17
|
+
the work continues in the background, so never assume a run has finished without checking get_run.`;
|
|
18
|
+
|
|
19
|
+
/** Built once and pinned, so dev-mode hot reload doesn't drop live MCP sessions on the floor. */
|
|
20
|
+
interface McpRegistry {
|
|
21
|
+
transport: HttpTransport;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const globalForMcp = globalThis as unknown as { __codebayMcp?: McpRegistry };
|
|
25
|
+
|
|
26
|
+
function build(): McpRegistry {
|
|
27
|
+
const server = new McpServer<v.GenericSchema>(
|
|
28
|
+
{
|
|
29
|
+
name: 'codebay',
|
|
30
|
+
version: APP_VERSION,
|
|
31
|
+
description: 'Create devcontainer sandboxes and run Claude Code in them.'
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
adapter: new ValibotJsonSchemaAdapter(),
|
|
35
|
+
instructions: INSTRUCTIONS,
|
|
36
|
+
capabilities: { tools: { listChanged: false } }
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
registerTools(server);
|
|
40
|
+
return {
|
|
41
|
+
transport: new HttpTransport(server, {
|
|
42
|
+
path: MCP_PATH,
|
|
43
|
+
// The bearer token is for programmatic clients; no browser page should be reaching this.
|
|
44
|
+
allowedOrigins: [],
|
|
45
|
+
cors: false
|
|
46
|
+
})
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function mcpTransport(): HttpTransport {
|
|
51
|
+
return (globalForMcp.__codebayMcp ??= build()).transport;
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
copyIgnorePatterns,
|
|
23
23
|
builtinCopyIgnore,
|
|
24
24
|
claudeConfigDir,
|
|
25
|
+
mcpEnabled,
|
|
26
|
+
mcpToken,
|
|
27
|
+
mcpUrl,
|
|
28
|
+
mcpPrAttribution,
|
|
25
29
|
gitIdentityEnabled,
|
|
26
30
|
gitIdentityName,
|
|
27
31
|
gitIdentityEmail,
|
|
@@ -64,6 +68,10 @@
|
|
|
64
68
|
copyIgnorePatterns: string;
|
|
65
69
|
builtinCopyIgnore: string;
|
|
66
70
|
claudeConfigDir: string;
|
|
71
|
+
mcpEnabled: boolean;
|
|
72
|
+
mcpToken: string;
|
|
73
|
+
mcpUrl: string;
|
|
74
|
+
mcpPrAttribution: boolean;
|
|
67
75
|
gitIdentityEnabled: boolean;
|
|
68
76
|
gitIdentityName: string;
|
|
69
77
|
gitIdentityEmail: string;
|
|
@@ -109,6 +117,10 @@
|
|
|
109
117
|
{copyIgnorePatterns}
|
|
110
118
|
{builtinCopyIgnore}
|
|
111
119
|
{claudeConfigDir}
|
|
120
|
+
{mcpEnabled}
|
|
121
|
+
{mcpToken}
|
|
122
|
+
{mcpUrl}
|
|
123
|
+
{mcpPrAttribution}
|
|
112
124
|
{gitIdentityEnabled}
|
|
113
125
|
{gitIdentityName}
|
|
114
126
|
{gitIdentityEmail}
|