hammerkit 1.26.0 → 1.27.0-next.10

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 (128) hide show
  1. package/build.schema.json +62 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/backends/registry-cache-backend.js +2 -2
  4. package/dist/cache/explain.js +13 -11
  5. package/dist/cli.js +3 -0
  6. package/dist/docker/item-network.js +471 -0
  7. package/dist/docker/pull.js +4 -2
  8. package/dist/docker/remove-orphaned-containers.js +13 -6
  9. package/dist/environment/replace-env-variables.js +8 -6
  10. package/dist/executer/await-completed-dependencies.js +2 -1
  11. package/dist/executer/check-readiness.js +2 -2
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +24 -2
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +411 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +42 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/claim-layout.js +71 -0
  34. package/dist/kubernetes/ensure-http-route.js +1 -1
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
  36. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
  38. package/dist/kubernetes/ensure-persistent-data.js +75 -24
  39. package/dist/kubernetes/get-service-ip.js +16 -2
  40. package/dist/kubernetes/job-placement.js +99 -0
  41. package/dist/kubernetes/job-secrets.js +320 -0
  42. package/dist/kubernetes/kept-claims.js +203 -0
  43. package/dist/kubernetes/kubernetes-instance.js +10 -2
  44. package/dist/kubernetes/network-gate.js +71 -0
  45. package/dist/kubernetes/network-policy.js +583 -0
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/resources.js +37 -4
  48. package/dist/kubernetes/restore-kubernetes-data.js +28 -7
  49. package/dist/kubernetes/secrets.js +7 -9
  50. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  51. package/dist/kubernetes/volumes.js +104 -12
  52. package/dist/log.js +1 -1
  53. package/dist/main.js +41 -0
  54. package/dist/network/egress-proxy.js +407 -0
  55. package/dist/network/port-relay.js +35 -0
  56. package/dist/optimizer/get-work-cache-stats.js +16 -0
  57. package/dist/optimizer/work-task-cache-description.js +5 -2
  58. package/dist/planner/service-definition.js +5 -0
  59. package/dist/planner/utils/append-work-dependencies.js +5 -2
  60. package/dist/planner/utils/append-work-environment.js +2 -0
  61. package/dist/planner/utils/append-work-service.js +5 -0
  62. package/dist/planner/utils/append-work-task.js +20 -1
  63. package/dist/planner/utils/assert-item-paths.js +50 -0
  64. package/dist/planner/utils/hosted-volumes.js +35 -0
  65. package/dist/planner/utils/task-arguments.js +3 -0
  66. package/dist/planner/work-network.js +193 -0
  67. package/dist/planner/work-runtime-docker.js +43 -1
  68. package/dist/planner/work-runtime-kubernetes.js +370 -111
  69. package/dist/planner/work-runtime-local.js +2 -1
  70. package/dist/program.js +33 -5
  71. package/dist/remote/annotations.js +115 -0
  72. package/dist/remote/bundle-text.js +24 -0
  73. package/dist/remote/bundle.js +9 -1
  74. package/dist/remote/remote-commands.js +15 -3
  75. package/dist/remote/remote-work-commands.js +272 -71
  76. package/dist/remote/server-client.js +18 -5
  77. package/dist/remote/source-manifest.js +15 -2
  78. package/dist/remote/token-source.js +67 -0
  79. package/dist/schema/build-file-base-task-schema.js +7 -0
  80. package/dist/schema/build-file-container-service-schema.js +8 -0
  81. package/dist/schema/build-file-container-task-schema.js +3 -0
  82. package/dist/schema/build-file-environment-schema.js +9 -0
  83. package/dist/schema/network-schema.js +78 -0
  84. package/dist/schema/reference-parser.js +2 -0
  85. package/dist/schema/schema-parser.js +16 -3
  86. package/dist/schema/work-scope-parser.js +5 -0
  87. package/dist/secrets/command-secret-provider.js +73 -10
  88. package/dist/secrets/github-app-token-command.js +26 -0
  89. package/dist/secrets/github-app-token.js +191 -0
  90. package/dist/server/accounts-api.js +9 -2
  91. package/dist/server/definitions-api.js +69 -47
  92. package/dist/server/definitions.js +76 -51
  93. package/dist/server/executor.js +156 -41
  94. package/dist/server/git-checkout.js +72 -8
  95. package/dist/server/kubernetes-backend.js +603 -0
  96. package/dist/server/kubernetes-sandbox-storage.js +997 -0
  97. package/dist/server/oauth.js +12 -0
  98. package/dist/server/plan.js +88 -2
  99. package/dist/server/policy.js +104 -15
  100. package/dist/server/proposals-api.js +24 -40
  101. package/dist/server/proposals.js +12 -25
  102. package/dist/server/repo-access.js +37 -6
  103. package/dist/server/run-logs.js +217 -0
  104. package/dist/server/run-plan.js +48 -2
  105. package/dist/server/run-request.js +29 -10
  106. package/dist/server/run-store.js +5 -0
  107. package/dist/server/run-worker-command.js +13 -1
  108. package/dist/server/run-worker.js +121 -24
  109. package/dist/server/runs.js +191 -10
  110. package/dist/server/sandbox-docker.js +62 -0
  111. package/dist/server/sandbox-files-api.js +269 -0
  112. package/dist/server/sandbox-files.js +1054 -0
  113. package/dist/server/sandbox-patch.js +481 -0
  114. package/dist/server/sandbox-pod-command.js +15 -0
  115. package/dist/server/sandbox-pod.js +146 -0
  116. package/dist/server/sandbox-storage.js +462 -0
  117. package/dist/server/sandboxes-api.js +216 -0
  118. package/dist/server/sandboxes.js +519 -0
  119. package/dist/server/server-command.js +39 -20
  120. package/dist/server/server-config.js +443 -22
  121. package/dist/server/server.js +219 -40
  122. package/dist/utils/abort-on-signals.js +46 -6
  123. package/dist/utils/run-id.js +12 -0
  124. package/dist/utils/tar.js +25 -0
  125. package/harness/opencode.ts +11 -0
  126. package/harness/pi.ts +7 -0
  127. package/package.json +8 -2
  128. package/dist/remote/text-diff.js +0 -89
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getHarnessEnvironment = exports.connectSandbox = exports.harnessOptionsFromEnv = exports.DEFAULT_SHELL_TASK = void 0;
4
+ const get_file_context_1 = require("../file/get-file-context");
5
+ const log_1 = require("../log");
6
+ const work_item_status_1 = require("../planner/work-item-status");
7
+ const host_config_1 = require("../remote/host-config");
8
+ const redact_1 = require("../utils/redact");
9
+ const empty_writable_1 = require("../utils/empty-writable");
10
+ const token_source_1 = require("../remote/token-source");
11
+ const bundle_1 = require("../remote/bundle");
12
+ const sandbox_client_1 = require("./sandbox-client");
13
+ exports.DEFAULT_SHELL_TASK = 'sh';
14
+ // The same settings as variables, for harnesses that load hammerkit as a plugin.
15
+ function harnessOptionsFromEnv(env) {
16
+ const value = (name) => (env[name] && env[name]?.trim()) || undefined;
17
+ return {
18
+ server: value('HAMMERKIT_SERVER'),
19
+ sandbox: value('HAMMERKIT_SANDBOX'),
20
+ repo: value('HAMMERKIT_REPO'),
21
+ ref: value('HAMMERKIT_REF'),
22
+ build: value('HAMMERKIT_BUILD'),
23
+ shell: value('HAMMERKIT_SHELL'),
24
+ };
25
+ }
26
+ exports.harnessOptionsFromEnv = harnessOptionsFromEnv;
27
+ async function connectSandbox(environment, options, log = () => undefined) {
28
+ const config = await (0, host_config_1.readHostConfig)(environment);
29
+ const name = options.server ?? config.run.on;
30
+ if (!name || !(0, host_config_1.hasServer)(config.servers, name)) {
31
+ throw new Error(options.server
32
+ ? `server ${options.server} is not registered, add it with: hammerkit remote add ${options.server} <url> --issuer <url>`
33
+ : 'name a server (HAMMERKIT_SERVER or --server), or set a default with: hammerkit remote use <name>');
34
+ }
35
+ if (!options.build) {
36
+ throw new Error('name the published version of the build file whose tasks run on the sandbox by its hash (HAMMERKIT_BUILD or --build), as hammerkit remote publish printed it');
37
+ }
38
+ // a run names exactly the version it runs, there is no name that follows the latest one
39
+ if (!bundle_1.HASH_PATTERN.test(options.build)) {
40
+ throw new Error(`${options.build} is not the hash of a published version (sha256:...), name it as hammerkit remote publish printed it (HAMMERKIT_BUILD or --build)`);
41
+ }
42
+ const server = config.servers[name];
43
+ const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, name, server) };
44
+ let sandbox;
45
+ if (options.sandbox) {
46
+ sandbox = await sandbox_client_1.SandboxClient.get(connection, options.sandbox);
47
+ }
48
+ else if (options.repo && options.ref) {
49
+ sandbox = await reuseSandbox(connection, options.repo, options.ref);
50
+ if (sandbox) {
51
+ log(`took sandbox ${sandbox.id} of ${sandbox.repo} at ${sandbox.commit} on ${name}, it had no changes`);
52
+ }
53
+ else {
54
+ sandbox = await sandbox_client_1.SandboxClient.create(connection, options.repo, options.ref);
55
+ log(`created sandbox ${sandbox.id} of ${sandbox.repo} at ${sandbox.commit} on ${name}, ` +
56
+ `set HAMMERKIT_SANDBOX=${sandbox.id} to come back to it`);
57
+ }
58
+ }
59
+ else {
60
+ throw new Error('name a sandbox (HAMMERKIT_SANDBOX or --sandbox), or a repository and ref to create one from (HAMMERKIT_REPO and HAMMERKIT_REF, or --repo and --ref)');
61
+ }
62
+ return new sandbox_client_1.SandboxClient(connection, sandbox, { hash: options.build, shell: options.shell ?? exports.DEFAULT_SHELL_TASK });
63
+ }
64
+ exports.connectSandbox = connectSandbox;
65
+ // A client that starts with a repository and ref takes a sandbox of the caller's of the same
66
+ // that nobody changed and no run holds, checked out at the ref again, rather than creating
67
+ // one on every start: sandboxes stay until they are deleted, and only a few are allowed.
68
+ async function reuseSandbox(connection, repo, ref) {
69
+ const build = { hash: '', shell: exports.DEFAULT_SHELL_TASK };
70
+ for (const candidate of await sandbox_client_1.SandboxClient.list(connection)) {
71
+ if (candidate.repo !== repo || candidate.ref !== ref || candidate.busy) {
72
+ continue;
73
+ }
74
+ const changes = await new sandbox_client_1.SandboxClient(connection, candidate, build).changes();
75
+ if (changes.files.length === 0) {
76
+ return sandbox_client_1.SandboxClient.fetch(connection, candidate.id, ref);
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+ // What hammerkit needs of its environment to read the host config and the sign-in, for a
82
+ // harness that loads it as a plugin rather than through the command line.
83
+ function getHarnessEnvironment(cwd = process.cwd()) {
84
+ const secrets = (0, redact_1.createSecretRegistry)();
85
+ return {
86
+ cwd,
87
+ abortCtrl: new AbortController(),
88
+ processEnvs: process.env,
89
+ file: (0, get_file_context_1.getFileContext)(cwd),
90
+ console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()),
91
+ status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
92
+ secrets,
93
+ stdout: (0, empty_writable_1.emptyWritable)(),
94
+ stderr: (0, empty_writable_1.emptyWritable)(),
95
+ stdoutColumns: 80,
96
+ };
97
+ }
98
+ exports.getHarnessEnvironment = getHarnessEnvironment;
99
+ //# sourceMappingURL=connect.js.map
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.capOutput = exports.toColonNumbers = exports.formatTree = exports.formatSearch = void 0;
4
+ // Lines as grep tools print them: `path:line: text` for a match, `path-line- text` around
5
+ // one, the file names alone or `path:count`, with a note when the server answered a part.
6
+ function formatSearch(result) {
7
+ let lines;
8
+ if (result.output_mode === 'files_with_matches') {
9
+ lines = result.files ?? [];
10
+ }
11
+ else if (result.output_mode === 'count') {
12
+ lines = (result.counts ?? []).map((count) => `${count.path}:${count.count}`);
13
+ }
14
+ else {
15
+ lines = (result.matches ?? []).map((match) => match.context ? `${match.path}-${match.line}- ${match.text}` : `${match.path}:${match.line}: ${match.text}`);
16
+ }
17
+ if (lines.length === 0) {
18
+ return 'No matches found';
19
+ }
20
+ if (result.truncated) {
21
+ lines.push('', `(${lines.length} of ${result.exact ? '' : 'at least '}${result.total} shown, narrow the search or ask with a higher limit)`);
22
+ }
23
+ return lines.join('\n');
24
+ }
25
+ exports.formatSearch = formatSearch;
26
+ // The paths of a listing, a directory with a trailing `/`.
27
+ function formatTree(result, prefix = '') {
28
+ const lines = result.entries.map((entry) => {
29
+ const path = prefix && entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path;
30
+ return entry.type === 'directory' ? `${path}/` : path;
31
+ });
32
+ if (lines.length === 0) {
33
+ return 'No files found';
34
+ }
35
+ if (result.truncated) {
36
+ lines.push('', `(${result.entries.length} of ${result.total} shown, narrow the pattern)`);
37
+ }
38
+ return lines.join('\n');
39
+ }
40
+ exports.formatTree = formatTree;
41
+ // The server numbers lines like `cat -n` (` 1\tline`); some harnesses print `1: line`.
42
+ function toColonNumbers(content) {
43
+ return content
44
+ .split('\n')
45
+ .map((line) => {
46
+ const match = /^\s*(\d+)\t(.*)$/s.exec(line);
47
+ return match ? `${match[1]}: ${match[2]}` : line;
48
+ })
49
+ .join('\n');
50
+ }
51
+ exports.toColonNumbers = toColonNumbers;
52
+ // The most of a command's output a tool answers: its first and last lines, up to about 30 KB.
53
+ // One `cat` of a big file or a verbose install would otherwise fill the model's context.
54
+ const HEAD_LINES = 50;
55
+ const TAIL_LINES = 200;
56
+ const MAX_CHARS = 30000;
57
+ /**
58
+ * The head and the tail of a long output, with a line in between that says how much is cut
59
+ * and how to read it (`readMore`, for example the run_logs call for the run).
60
+ */
61
+ function capOutput(output, readMore) {
62
+ const lines = output.split('\n');
63
+ if (lines.length <= HEAD_LINES + TAIL_LINES && output.length <= MAX_CHARS) {
64
+ return output;
65
+ }
66
+ const many = lines.length > HEAD_LINES + TAIL_LINES;
67
+ // the end tells most of how a command went: it gets the larger part
68
+ const head = (many ? lines.slice(0, HEAD_LINES).join('\n') : output).slice(0, MAX_CHARS / 4);
69
+ const tail = (many ? lines.slice(-TAIL_LINES).join('\n') : output).slice(-(MAX_CHARS * 3) / 4);
70
+ return `${head}\n… the output is cut here, it has ${lines.length} lines in all: ${readMore}\n${tail}`;
71
+ }
72
+ exports.capOutput = capOutput;
73
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerMcpCommand = void 0;
4
+ const commander_1 = require("commander");
5
+ const log_1 = require("../log");
6
+ const version_1 = require("../version");
7
+ const connect_1 = require("./connect");
8
+ const mcp_1 = require("./mcp");
9
+ // `hammerkit remote mcp`: a Model Context Protocol server on stdio for one sandbox of a
10
+ // server. It lives under `remote`, which already is a command: a top-level `mcp` would hide a
11
+ // task named `mcp`, since `hammerkit <task>` runs a task. Like the other `remote` commands it
12
+ // needs no build file of its own: the tasks are the published ones.
13
+ function registerMcpCommand(remote, environment, input = process.stdin) {
14
+ // errors go through the program, like those of the other remote commands: it carries the exit handling
15
+ const program = remote.parent ?? remote;
16
+ remote
17
+ .command('mcp')
18
+ .description('serve the files, shell and tasks of a sandbox on a server as MCP tools over stdio (experimental)')
19
+ .addOption(new commander_1.Option('--server <name>', 'registered server (default: hammerkit remote use)').env('HAMMERKIT_SERVER'))
20
+ .addOption(new commander_1.Option('--sandbox <id>', 'a sandbox of yours on the server').env('HAMMERKIT_SANDBOX'))
21
+ .addOption(new commander_1.Option('--repo <repo>', 'create a sandbox of this repository').env('HAMMERKIT_REPO'))
22
+ .addOption(new commander_1.Option('--ref <ref>', 'at this branch').env('HAMMERKIT_REF'))
23
+ .addOption(new commander_1.Option('--build <hash>', 'hash of the published version of the build file whose tasks run on it').env('HAMMERKIT_BUILD'))
24
+ .addOption(new commander_1.Option('--shell <task>', 'its task whose last command is a shell, like sh -c')
25
+ .env('HAMMERKIT_SHELL')
26
+ .default(connect_1.DEFAULT_SHELL_TASK))
27
+ .action(async (options) => {
28
+ try {
29
+ // stdout carries the protocol only, everything else goes to stderr
30
+ const log = (line) => environment.stderr.write(`hammerkit remote mcp: ${line}\n`);
31
+ const sandbox = await (0, connect_1.connectSandbox)(environment, options, log);
32
+ log(`sandbox ${sandbox.id} of ${sandbox.sandbox.repo} at ${sandbox.sandbox.commit}`);
33
+ const instructions = `The tools work on hammerkit sandbox ${sandbox.id}, a checkout of ${sandbox.sandbox.repo} at ` +
34
+ `${sandbox.sandbox.commit} on a server. Paths are relative to its root. bash runs a command as the ` +
35
+ `task ${sandbox.build.shell} of the published build file ${sandbox.build.hash}, run_task runs its other ` +
36
+ 'tasks; only the files are kept between commands. changes shows what changed since the fetch.';
37
+ const served = (0, mcp_1.serveMcp)(input, environment.stdout, (0, mcp_1.createMcpTools)(async () => sandbox), {
38
+ name: 'hammerkit',
39
+ version: (0, version_1.getVersion)(),
40
+ instructions,
41
+ });
42
+ const stopped = new Promise((resolve) => {
43
+ if (environment.abortCtrl.signal.aborted) {
44
+ resolve();
45
+ }
46
+ environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
47
+ });
48
+ await Promise.race([served, stopped]);
49
+ }
50
+ catch (e) {
51
+ if (e instanceof commander_1.CommanderError) {
52
+ throw e;
53
+ }
54
+ program.error(`Mcp was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
55
+ }
56
+ });
57
+ }
58
+ exports.registerMcpCommand = registerMcpCommand;
59
+ //# sourceMappingURL=mcp-command.js.map
@@ -0,0 +1,432 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serveMcp = exports.createMcpTools = void 0;
4
+ const readline_1 = require("readline");
5
+ const format_1 = require("./format");
6
+ const sandbox_client_1 = require("./sandbox-client");
7
+ // the newest first; a client asking for an older one gets it
8
+ const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
9
+ const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
10
+ const EDITS = {
11
+ readOnlyHint: false,
12
+ destructiveHint: true,
13
+ idempotentHint: false,
14
+ openWorldHint: false,
15
+ };
16
+ const text = (description) => ({ type: 'string', description });
17
+ const count = (description, minimum = 0, maximum) => ({
18
+ type: 'integer',
19
+ description,
20
+ minimum,
21
+ ...(maximum !== undefined ? { maximum } : {}),
22
+ });
23
+ const flag = (description) => ({ type: 'boolean', description });
24
+ // MCP clients send paths as they like them: `/src/a.ts` is `src/a.ts` of the sandbox
25
+ function sandboxPath(path) {
26
+ return (path ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
27
+ }
28
+ function readLog(run, task) {
29
+ return `read more of the log with run_logs { "run": "${run}", "task": "${task}" }`;
30
+ }
31
+ function describeRun(task, ran) {
32
+ const result = ran.result;
33
+ const lines = [`run ${ran.run.id}: ${ran.run.state}`];
34
+ if (result) {
35
+ lines.push(`task ${task}: ${result.status}` +
36
+ (result.exitCode !== undefined ? `, exit code ${result.exitCode}` : '') +
37
+ (result.durationMs !== undefined ? `, ${result.durationMs} ms` : '') +
38
+ (result.cache?.hit ? ', from the cache' : ''));
39
+ if (result.error) {
40
+ lines.push(`error: ${result.error}`);
41
+ }
42
+ if (result.cache?.causes?.length) {
43
+ lines.push(`ran because: ${result.cache.causes.join('; ')}`);
44
+ }
45
+ }
46
+ else if (ran.run.error) {
47
+ lines.push(`error: ${ran.run.error}`);
48
+ }
49
+ if (result && result.lastLog.length > 0) {
50
+ lines.push('last lines:', ...result.lastLog);
51
+ }
52
+ lines.push(readLog(ran.run.id, task));
53
+ return lines.join('\n');
54
+ }
55
+ /**
56
+ * The tools of `hammerkit remote mcp`, for chat clients and agents without file and shell tools of
57
+ * their own. They take the shapes models are trained on: numbered reads with offset and
58
+ * limit, exact-string replace, `apply_patch` or a git diff, ripgrep's parameters, a shell
59
+ * command. Paths are relative to the root of the sandbox.
60
+ */
61
+ function createMcpTools(client) {
62
+ return inOrder([
63
+ {
64
+ name: 'read',
65
+ description: 'Read a file of the sandbox. Lines come back numbered like `cat -n`: the number, a tab, the line. ' +
66
+ 'Long files come in parts; offset is the first line (1-based), limit the number of lines.',
67
+ inputSchema: {
68
+ type: 'object',
69
+ properties: {
70
+ path: text('Path of the file, relative to the root of the sandbox'),
71
+ offset: count('The first line to read, 1 for the start', 1),
72
+ limit: count('How many lines to read', 1),
73
+ },
74
+ required: ['path'],
75
+ },
76
+ annotations: { title: 'Read a file', ...READ_ONLY },
77
+ async call(args) {
78
+ const read = await (await client()).read(sandboxPath(args.path), args.offset, args.limit);
79
+ const footer = read.nextOffset
80
+ ? `(lines ${read.offset} to ${read.nextOffset - 1} of ${read.lines}, read on with offset ${read.nextOffset})`
81
+ : `(end of file, ${read.lines} lines)`;
82
+ return `${read.content.replace(/\n$/, '')}\n\n${footer}`;
83
+ },
84
+ },
85
+ {
86
+ name: 'write',
87
+ description: 'Write a file of the sandbox, replacing what it held. Directories are made as needed.',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: { path: text('Path of the file'), content: text('The whole new content of the file') },
91
+ required: ['path', 'content'],
92
+ },
93
+ annotations: { title: 'Write a file', ...EDITS, idempotentHint: true },
94
+ async call(args) {
95
+ const written = await (await client()).writeRaw(sandboxPath(args.path), args.content);
96
+ return `${written.change} ${written.path} (${written.size ?? Buffer.byteLength(args.content)} bytes)`;
97
+ },
98
+ },
99
+ {
100
+ name: 'edit',
101
+ description: 'Replace an exact string in a file of the sandbox. old_string has to match exactly once, with its ' +
102
+ 'whitespace and indentation, unless replace_all is set; give enough lines around the change to make it unique.',
103
+ inputSchema: {
104
+ type: 'object',
105
+ properties: {
106
+ path: text('Path of the file'),
107
+ old_string: text('The text to replace, exactly as it is in the file'),
108
+ new_string: text('The text to put in its place'),
109
+ replace_all: flag('Replace every occurrence of old_string'),
110
+ },
111
+ required: ['path', 'old_string', 'new_string'],
112
+ },
113
+ annotations: { title: 'Edit a file', ...EDITS },
114
+ async call(args) {
115
+ const files = await (await client()).edit([
116
+ {
117
+ op: 'replace',
118
+ path: sandboxPath(args.path),
119
+ old_string: args.old_string,
120
+ new_string: args.new_string,
121
+ replace_all: args.replace_all,
122
+ },
123
+ ]);
124
+ return files.map((file) => `${file.change} ${file.path}`).join('\n') || 'nothing changed';
125
+ },
126
+ },
127
+ {
128
+ name: 'apply_patch',
129
+ description: 'Change several files at once with a patch: the `*** Begin Patch` format (`*** Add File:`, ' +
130
+ '`*** Update File:` with `@@` context lines, `*** Delete File:`, `*** Move to:`) or a unified git diff. ' +
131
+ 'Every file changes, or none does.',
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: { patch: text('The patch, from `*** Begin Patch` to `*** End Patch`, or a git diff') },
135
+ required: ['patch'],
136
+ },
137
+ annotations: { title: 'Apply a patch', ...EDITS },
138
+ async call(args) {
139
+ const files = await (await client()).edit([{ op: 'patch', patch: args.patch }]);
140
+ return files.map((file) => `${file.change} ${file.path}`).join('\n') || 'nothing changed';
141
+ },
142
+ },
143
+ {
144
+ name: 'search',
145
+ description: 'Search the files of the sandbox with ripgrep. pattern is a regular expression (Rust syntax); output_mode ' +
146
+ 'is files_with_matches (the default), content (matching lines, with -A/-B/-C context) or count.',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ pattern: text('The regular expression to look for'),
151
+ path: text('A file or directory to search in, the whole sandbox by default'),
152
+ glob: text('Only files matching this glob, like "*.ts" or "src/**/*.tsx"'),
153
+ type: text('Only files of this ripgrep type, like ts, py or rust'),
154
+ '-i': flag('Ignore case'),
155
+ fixed_strings: flag('Take the pattern as a literal string'),
156
+ multiline: flag('Let the pattern match across lines'),
157
+ '-A': count('Lines after each match (content mode)', 0, 100),
158
+ '-B': count('Lines before each match (content mode)', 0, 100),
159
+ '-C': count('Lines before and after each match (content mode)', 0, 100),
160
+ output_mode: { type: 'string', enum: ['files_with_matches', 'content', 'count'] },
161
+ head_limit: count('The most results to answer', 1, 5000),
162
+ },
163
+ required: ['pattern'],
164
+ },
165
+ annotations: { title: 'Search files', ...READ_ONLY },
166
+ async call(args) {
167
+ const query = { ...args, path: args.path ? sandboxPath(args.path) || undefined : undefined };
168
+ return (0, format_1.formatSearch)(await (await client()).search(query));
169
+ },
170
+ },
171
+ {
172
+ name: 'glob',
173
+ description: 'List the files of the sandbox that match a glob, like "**/*.ts" or "src/**/package.json".',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ pattern: text('The glob, relative to path'),
178
+ path: text('The directory to look in, the root by default'),
179
+ },
180
+ required: ['pattern'],
181
+ },
182
+ annotations: { title: 'Find files', ...READ_ONLY },
183
+ async call(args) {
184
+ const path = sandboxPath(args.path) || undefined;
185
+ return (0, format_1.formatTree)(await (await client()).tree({ path, glob: args.pattern, limit: 500 }));
186
+ },
187
+ },
188
+ {
189
+ name: 'bash',
190
+ description: 'Run a shell command on the sandbox and answer its output and exit code. It runs as the shell task of ' +
191
+ 'the published build file, in its container and with its network; commands run one at a time, and ' +
192
+ 'only the files are kept between them.',
193
+ inputSchema: {
194
+ type: 'object',
195
+ properties: {
196
+ command: text('The command'),
197
+ workdir: text('The directory to run it in, relative to the root of the sandbox'),
198
+ timeout: count('Stop the command after this many seconds', 1),
199
+ },
200
+ required: ['command'],
201
+ },
202
+ annotations: { title: 'Run a command' },
203
+ async call(args, signal) {
204
+ const sandbox = await client();
205
+ const ran = await sandbox.exec(args.command, sandboxPath(args.workdir), {
206
+ signal,
207
+ timeoutSeconds: args.timeout,
208
+ });
209
+ const output = (0, format_1.capOutput)(ran.output, readLog(ran.run.id, sandbox.build.shell));
210
+ const lines = output ? [output, ''] : [];
211
+ if (ran.exitCode === null) {
212
+ lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
213
+ }
214
+ else {
215
+ lines.push(`exit code ${ran.exitCode}`);
216
+ }
217
+ return lines.join('\n');
218
+ },
219
+ },
220
+ {
221
+ name: 'run_task',
222
+ description: 'Run a task of the published build file on the sandbox, such as test or build, and answer how it went: ' +
223
+ 'its status, exit code, why it was not taken from the cache, and its last lines. args are appended to ' +
224
+ 'its last command, for example a test file.',
225
+ inputSchema: {
226
+ type: 'object',
227
+ properties: {
228
+ task: text('The name of the task'),
229
+ args: { type: 'array', items: { type: 'string' }, description: 'Arguments for its last command' },
230
+ },
231
+ required: ['task'],
232
+ },
233
+ annotations: { title: 'Run a task' },
234
+ async call(args, signal) {
235
+ const ran = await (await client()).runTask(args.task, { args: args.args, signal });
236
+ return describeRun(args.task, ran);
237
+ },
238
+ },
239
+ {
240
+ name: 'run_logs',
241
+ description: 'Read the log of a run as numbered lines: one task, the last lines (tail), a range (from, limit) or ' +
242
+ 'the lines that contain a text (grep, a fixed string) with lines of context around them.',
243
+ inputSchema: {
244
+ type: 'object',
245
+ properties: {
246
+ run: text('The id of the run'),
247
+ task: text('Only the lines of this task'),
248
+ tail: count('The last lines, or with grep the last matches', 1, 1000),
249
+ from: count('The first line to read', 1),
250
+ limit: count('How many lines to read', 1, 1000),
251
+ grep: text('Only lines that contain this text'),
252
+ ignore_case: flag('Ignore case in grep'),
253
+ context: count('Lines before and after each match', 0, 20),
254
+ },
255
+ required: ['run'],
256
+ },
257
+ annotations: { title: 'Read the log of a run', ...READ_ONLY },
258
+ async call(args) {
259
+ const page = await (await client()).logs(args.run, {
260
+ task: args.task,
261
+ tail: args.tail,
262
+ from: args.from,
263
+ limit: args.limit,
264
+ grep: args.grep,
265
+ i: args.ignore_case,
266
+ context: args.context,
267
+ });
268
+ const lines = page.lines.map((line) => `${line.line}${line.match === false ? '-' : ':'} ${args.task ? '' : `[${line.task}] `}${line.text}`);
269
+ lines.push('', `(${page.total} lines${args.grep ? ` searched, ${page.lines.filter((line) => line.match !== false).length} match here` : ''}${page.next ? `, read on with from ${page.next}` : ''}${page.truncated ? ', the server cut the log off at its limit' : ''})`);
270
+ return lines.join('\n');
271
+ },
272
+ },
273
+ {
274
+ name: 'changes',
275
+ description: 'What changed in the sandbox since the commit it was fetched at: a summary of the files, a diff, or a ' +
276
+ 'patch to apply elsewhere with git am.',
277
+ inputSchema: {
278
+ type: 'object',
279
+ properties: {
280
+ format: { type: 'string', enum: ['summary', 'diff', 'patch'], description: 'summary by default' },
281
+ },
282
+ },
283
+ annotations: { title: 'Show the changes', ...READ_ONLY },
284
+ async call(args) {
285
+ const sandbox = await client();
286
+ if (args.format === 'diff' || args.format === 'patch') {
287
+ return (await sandbox.diff(args.format)) || 'no changes';
288
+ }
289
+ const changes = await sandbox.changes();
290
+ if (changes.files.length === 0) {
291
+ return `no changes since ${changes.commit}`;
292
+ }
293
+ return [
294
+ `changes since ${changes.commit}:`,
295
+ ...changes.files.map((file) => `${file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}` +
296
+ (file.additions !== null ? ` +${file.additions} -${file.deletions}` : ' (binary)')),
297
+ ].join('\n');
298
+ },
299
+ },
300
+ {
301
+ name: 'push',
302
+ description: 'Commit the changes of the sandbox and push them to a branch of its repository, for review. The server ' +
303
+ 'pushes only to the branches its operator allows; the sandbox is at the pushed commit afterwards.',
304
+ inputSchema: {
305
+ type: 'object',
306
+ properties: {
307
+ branch: text('the branch to push to, created when it does not exist'),
308
+ message: text('the commit message'),
309
+ },
310
+ required: ['branch', 'message'],
311
+ },
312
+ annotations: { title: 'Push a branch', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
313
+ async call(args) {
314
+ const pushed = await (await client()).push(args.branch, args.message);
315
+ return `pushed ${pushed.commit} to ${pushed.branch}`;
316
+ },
317
+ },
318
+ ]);
319
+ }
320
+ exports.createMcpTools = createMcpTools;
321
+ // the calls of a client go to the sandbox one at a time, in the order the client sent them
322
+ function inOrder(tools) {
323
+ const inTurn = (0, sandbox_client_1.oneAtATime)();
324
+ return tools.map((tool) => ({ ...tool, call: (args, signal) => inTurn(() => tool.call(args, signal)) }));
325
+ }
326
+ /**
327
+ * A Model Context Protocol server over stdio: one JSON-RPC message per line on `input`, the
328
+ * answers on `output`. It answers `initialize`, `ping`, `tools/list` and `tools/call`, and
329
+ * stops a call when the client cancels it. Resolves when the input ends.
330
+ */
331
+ async function serveMcp(input, output, tools, server) {
332
+ const running = new Map();
333
+ const pending = new Set();
334
+ const send = (message) => {
335
+ output.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
336
+ };
337
+ const reply = (id, result) => send({ id, result });
338
+ const fail = (id, code, message) => send({ id, error: { code, message } });
339
+ const handle = async (message) => {
340
+ const id = message.id;
341
+ if (message.method === 'notifications/cancelled') {
342
+ running.get(message.params?.requestId)?.abort();
343
+ return;
344
+ }
345
+ if (id === undefined || id === null || message.method === undefined) {
346
+ // a notification, or an answer of the client to a request this server never sends
347
+ return;
348
+ }
349
+ if (message.method === 'initialize') {
350
+ const asked = message.params?.protocolVersion;
351
+ reply(id, {
352
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
353
+ capabilities: { tools: { listChanged: false } },
354
+ serverInfo: { name: server.name, version: server.version },
355
+ ...(server.instructions ? { instructions: server.instructions } : {}),
356
+ });
357
+ }
358
+ else if (message.method === 'ping') {
359
+ reply(id, {});
360
+ }
361
+ else if (message.method === 'tools/list') {
362
+ reply(id, {
363
+ tools: tools.map((tool) => ({
364
+ name: tool.name,
365
+ description: tool.description,
366
+ inputSchema: tool.inputSchema,
367
+ annotations: tool.annotations,
368
+ })),
369
+ });
370
+ }
371
+ else if (message.method === 'tools/call') {
372
+ const tool = tools.find((candidate) => candidate.name === message.params?.name);
373
+ if (!tool) {
374
+ return fail(id, -32602, `unknown tool ${String(message.params?.name)}`);
375
+ }
376
+ const args = message.params?.arguments ?? {};
377
+ const missing = (tool.inputSchema.required ?? []).filter((name) => args[name] === undefined);
378
+ if (missing.length > 0) {
379
+ return reply(id, { content: [{ type: 'text', text: `missing ${missing.join(', ')}` }], isError: true });
380
+ }
381
+ const abort = new AbortController();
382
+ running.set(id, abort);
383
+ try {
384
+ const text = await tool.call(args, abort.signal);
385
+ if (!abort.signal.aborted) {
386
+ reply(id, { content: [{ type: 'text', text }] });
387
+ }
388
+ }
389
+ catch (e) {
390
+ if (!abort.signal.aborted) {
391
+ // a failed call is an answer the model reads, not a protocol error
392
+ reply(id, { content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }], isError: true });
393
+ }
394
+ }
395
+ finally {
396
+ running.delete(id);
397
+ }
398
+ }
399
+ else {
400
+ fail(id, -32601, `method not found: ${String(message.method)}`);
401
+ }
402
+ };
403
+ const lines = (0, readline_1.createInterface)({ input, crlfDelay: Infinity });
404
+ for await (const line of lines) {
405
+ if (line.trim().length === 0) {
406
+ continue;
407
+ }
408
+ let message;
409
+ try {
410
+ message = JSON.parse(line);
411
+ }
412
+ catch {
413
+ fail(null, -32700, 'parse error');
414
+ continue;
415
+ }
416
+ if (typeof message !== 'object' || message === null || Array.isArray(message)) {
417
+ fail(null, -32600, 'invalid request');
418
+ continue;
419
+ }
420
+ const work = handle(message).catch((e) => message.id !== undefined && message.id !== null
421
+ ? fail(message.id, -32603, e instanceof Error ? e.message : String(e))
422
+ : undefined);
423
+ pending.add(work);
424
+ work.finally(() => pending.delete(work));
425
+ }
426
+ for (const abort of running.values()) {
427
+ abort.abort();
428
+ }
429
+ await Promise.all(pending);
430
+ }
431
+ exports.serveMcp = serveMcp;
432
+ //# sourceMappingURL=mcp.js.map