hammerkit 1.27.0-next.2 → 1.27.0-next.21

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 (103) hide show
  1. package/build.schema.json +27 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/cache/explain.js +17 -0
  4. package/dist/cli.js +1 -0
  5. package/dist/docker/item-network.js +13 -5
  6. package/dist/executer/deadline.js +17 -3
  7. package/dist/executer/docker-service.js +30 -8
  8. package/dist/executer/event-cache.js +20 -2
  9. package/dist/executer/execute-work-task.js +5 -3
  10. package/dist/executer/execution-steps.js +25 -4
  11. package/dist/executer/reset-work-tree.js +4 -0
  12. package/dist/executer/scheduler/enqueue-next.js +7 -0
  13. package/dist/executer/scheduler/state-key.js +42 -5
  14. package/dist/harness/connect.js +20 -6
  15. package/dist/harness/mcp-command.js +22 -6
  16. package/dist/harness/mcp.js +63 -114
  17. package/dist/harness/opencode.js +158 -50
  18. package/dist/harness/pi.js +29 -0
  19. package/dist/harness/sandbox-client.js +207 -65
  20. package/dist/harness/service-tools.js +141 -0
  21. package/dist/harness/task-tools.js +221 -0
  22. package/dist/kubernetes/apply.js +27 -16
  23. package/dist/kubernetes/claim-layout.js +71 -0
  24. package/dist/kubernetes/ensure-http-route.js +1 -1
  25. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  26. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  27. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  28. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  29. package/dist/kubernetes/get-service-ip.js +2 -2
  30. package/dist/kubernetes/job-secrets.js +33 -19
  31. package/dist/kubernetes/kept-claims.js +22 -10
  32. package/dist/kubernetes/network-policy.js +85 -27
  33. package/dist/kubernetes/remove-persistent-data.js +3 -1
  34. package/dist/kubernetes/resources.js +25 -3
  35. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  36. package/dist/kubernetes/sandbox-runner.js +626 -0
  37. package/dist/kubernetes/secrets.js +7 -11
  38. package/dist/kubernetes/volumes.js +6 -3
  39. package/dist/network/egress-proxy.js +45 -6
  40. package/dist/optimizer/calculate-checksum.js +3 -2
  41. package/dist/optimizer/check-key.js +94 -0
  42. package/dist/optimizer/check-keys.js +64 -0
  43. package/dist/optimizer/get-work-cache-stats.js +17 -3
  44. package/dist/optimizer/work-task-cache-description.js +51 -1
  45. package/dist/planner/utils/append-work-dependencies.js +10 -5
  46. package/dist/planner/utils/append-work-service.js +1 -0
  47. package/dist/planner/utils/append-work-task.js +15 -2
  48. package/dist/planner/utils/get-work-runtime.js +6 -1
  49. package/dist/planner/utils/normalize-path.js +19 -1
  50. package/dist/planner/utils/parse-work-mount.js +7 -1
  51. package/dist/planner/utils/parse-work-port.js +4 -1
  52. package/dist/planner/utils/task-arguments.js +3 -0
  53. package/dist/planner/validate.js +22 -0
  54. package/dist/planner/work-network.js +3 -1
  55. package/dist/planner/work-runtime-aggregate.js +32 -0
  56. package/dist/planner/work-runtime-docker.js +2 -1
  57. package/dist/planner/work-runtime-kubernetes.js +97 -48
  58. package/dist/planner/work-runtime-local.js +2 -1
  59. package/dist/planner/work-task.js +6 -1
  60. package/dist/program.js +35 -13
  61. package/dist/remote/annotations.js +13 -1
  62. package/dist/remote/device-login.js +7 -2
  63. package/dist/remote/host-config.js +64 -3
  64. package/dist/remote/remote-commands.js +41 -9
  65. package/dist/remote/remote-work-commands.js +246 -1
  66. package/dist/remote/server-client.js +15 -2
  67. package/dist/remote/token-source.js +64 -26
  68. package/dist/run-program.js +9 -0
  69. package/dist/schema/build-file-base-task-schema.js +5 -1
  70. package/dist/schema/build-file-container-service-schema.js +2 -0
  71. package/dist/schema/build-file-include-schema.js +10 -4
  72. package/dist/schema/reference-parser.js +9 -1
  73. package/dist/schema/schema-parser.js +36 -4
  74. package/dist/schema/work-scope-parser.js +33 -10
  75. package/dist/secrets/github-app-token-command.js +3 -1
  76. package/dist/server/accounts-api.js +3 -1
  77. package/dist/server/approvals-api.js +1 -1
  78. package/dist/server/checks-api.js +83 -0
  79. package/dist/server/checks.js +116 -0
  80. package/dist/server/definitions-api.js +4 -2
  81. package/dist/server/endpoints.js +612 -0
  82. package/dist/server/executor.js +56 -5
  83. package/dist/server/kubernetes-backend.js +303 -64
  84. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  85. package/dist/server/oauth.js +12 -1
  86. package/dist/server/plan.js +126 -62
  87. package/dist/server/policy.js +32 -2
  88. package/dist/server/repo-access.js +5 -0
  89. package/dist/server/run-plan.js +50 -19
  90. package/dist/server/run-request.js +64 -3
  91. package/dist/server/run-worker.js +146 -10
  92. package/dist/server/runs.js +347 -27
  93. package/dist/server/sandbox-pod.js +28 -0
  94. package/dist/server/sandbox-storage.js +23 -0
  95. package/dist/server/sandboxes-api.js +10 -4
  96. package/dist/server/sandboxes.js +8 -0
  97. package/dist/server/server-command.js +39 -20
  98. package/dist/server/server-config.js +213 -14
  99. package/dist/server/server.js +268 -15
  100. package/dist/server/service-runs.js +82 -0
  101. package/dist/utils/abort-on-signals.js +46 -6
  102. package/dist/utils/tar.js +36 -0
  103. package/package.json +1 -1
@@ -4,6 +4,8 @@ exports.serveMcp = exports.createMcpTools = void 0;
4
4
  const readline_1 = require("readline");
5
5
  const format_1 = require("./format");
6
6
  const sandbox_client_1 = require("./sandbox-client");
7
+ const task_tools_1 = require("./task-tools");
8
+ const service_tools_1 = require("./service-tools");
7
9
  // the newest first; a client asking for an older one gets it
8
10
  const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
9
11
  const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
@@ -25,40 +27,47 @@ const flag = (description) => ({ type: 'boolean', description });
25
27
  function sandboxPath(path) {
26
28
  return (path ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
27
29
  }
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
30
  /**
56
31
  * The tools of `hammerkit remote mcp`, for chat clients and agents without file and shell tools of
57
32
  * their own. They take the shapes models are trained on: numbered reads with offset and
58
33
  * 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.
34
+ * command. Paths are relative to the root of the sandbox. `tasks` are the tasks the description
35
+ * of `run_task` lists; without `shell` there is no `bash`.
60
36
  */
61
- function createMcpTools(client) {
37
+ function createMcpTools(client, options = {}) {
38
+ const [runTask, runLogs, check] = (0, task_tools_1.createTaskTools)(options.tasks, options.shell || undefined).map((tool) => fromTaskTool(tool, client));
39
+ const bash = {
40
+ name: 'bash',
41
+ description: 'Run a shell command on the sandbox and answer its output and exit code. It runs as the shell task of ' +
42
+ 'the published build file, in its container and with its network; commands run one at a time, and ' +
43
+ 'only the files are kept between them.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {
47
+ command: text('The command'),
48
+ workdir: text('The directory to run it in, relative to the root of the sandbox'),
49
+ timeout: count('Stop the command after this many seconds', 1),
50
+ },
51
+ required: ['command'],
52
+ },
53
+ annotations: { title: 'Run a command' },
54
+ async call(args, signal) {
55
+ const sandbox = await client();
56
+ const ran = await sandbox.exec(args.command, sandboxPath(args.workdir), {
57
+ signal,
58
+ timeoutSeconds: args.timeout,
59
+ });
60
+ const output = (0, format_1.capOutput)(ran.output, (0, task_tools_1.readLogHint)(ran.run.id, sandbox.build.shell));
61
+ const lines = output ? [output, ''] : [];
62
+ if (ran.exitCode === null) {
63
+ lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
64
+ }
65
+ else {
66
+ lines.push(`exit code ${ran.exitCode}`);
67
+ }
68
+ return lines.join('\n');
69
+ },
70
+ };
62
71
  return inOrder([
63
72
  {
64
73
  name: 'read',
@@ -185,91 +194,10 @@ function createMcpTools(client) {
185
194
  return (0, format_1.formatTree)(await (await client()).tree({ path, glob: args.pattern, limit: 500 }));
186
195
  },
187
196
  },
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
- },
197
+ ...(options.shell === false ? [] : [bash]),
198
+ runTask,
199
+ check,
200
+ { ...runLogs, annotations: { ...runLogs.annotations, ...READ_ONLY } },
273
201
  {
274
202
  name: 'changes',
275
203
  description: 'What changed in the sandbox since the commit it was fetched at: a summary of the files, a diff, or a ' +
@@ -315,9 +243,30 @@ function createMcpTools(client) {
315
243
  return `pushed ${pushed.commit} to ${pushed.branch}`;
316
244
  },
317
245
  },
246
+ ...service_tools_1.SERVICE_TOOLS.map((tool) => ({
247
+ name: tool.name,
248
+ description: tool.description,
249
+ inputSchema: (0, service_tools_1.serviceToolSchema)(tool),
250
+ annotations: { title: tool.title, readOnlyHint: tool.name === 'endpoint_get', openWorldHint: false },
251
+ async call(args, signal) {
252
+ return tool.call(await client(), args, signal);
253
+ },
254
+ })),
318
255
  ]);
319
256
  }
320
257
  exports.createMcpTools = createMcpTools;
258
+ // a tool shared with the other harnesses, as an MCP tool
259
+ function fromTaskTool(tool, client) {
260
+ return {
261
+ name: tool.name,
262
+ description: tool.description,
263
+ inputSchema: (0, task_tools_1.taskToolSchema)(tool),
264
+ annotations: { title: tool.title },
265
+ async call(args, signal) {
266
+ return tool.call(await client(), args, signal);
267
+ },
268
+ };
269
+ }
321
270
  // the calls of a client go to the sandbox one at a time, in the order the client sent them
322
271
  function inOrder(tools) {
323
272
  const inTurn = (0, sandbox_client_1.oneAtATime)();
@@ -9,35 +9,69 @@ const url_1 = require("url");
9
9
  const connect_1 = require("./connect");
10
10
  const format_1 = require("./format");
11
11
  const sandbox_client_1 = require("./sandbox-client");
12
+ const task_tools_1 = require("./task-tools");
13
+ const service_tools_1 = require("./service-tools");
12
14
  const DEFAULT_READ_LINES = 2000;
13
15
  const LIST_LIMIT = 100;
14
16
  const SEARCH_LIMIT = 100;
15
17
  /**
16
18
  * The tools of opencode on a hammerkit sandbox: `read`, `write`, `edit`, `apply_patch`,
17
19
  * `bash`, `grep`, `glob` and `list` with the parameters of opencode's own, which a plugin replaces
18
- * by registering tools of the same name, and `push`, which takes the work out as a branch. opencode resolves paths against its directory,
19
- * which stands for the root of the sandbox. `schema` is the zod of opencode
20
+ * by registering tools of the same name, `run_task` and `run_logs` for the tasks of the build
21
+ * file, and `push`, which takes the work out as a branch. opencode resolves paths against its
22
+ * directory, which stands for the root of the sandbox. `schema` is the zod of opencode
20
23
  * (`tool.schema` of `@opencode-ai/plugin`), so it reads the parameters as its own.
21
24
  */
22
25
  function hammerkitOpencodePlugin(schema, settings = {}) {
23
- return async (input) => ({ tool: createOpencodeTools(schema, lazySandbox(input.directory, settings)) });
26
+ return async (input) => {
27
+ const harness = connectHarness(input.directory, settings);
28
+ const tasks = await harness.tasks();
29
+ return { tool: createOpencodeTools(schema, harness.client, { tasks, shell: harness.shell }) };
30
+ };
24
31
  }
25
32
  exports.hammerkitOpencodePlugin = hammerkitOpencodePlugin;
26
- // the sandbox, connected on the first call of a tool and again after a failed connect
27
- function lazySandbox(directory, settings) {
33
+ function connectHarness(directory, settings) {
28
34
  const environment = settings.environment ?? (0, connect_1.getHarnessEnvironment)(directory);
29
35
  const options = settings.options ?? (0, connect_1.harnessOptionsFromEnv)(environment.processEnvs);
30
36
  let connecting;
31
- return () => {
37
+ const client = () => {
32
38
  if (!connecting) {
33
39
  connecting = settings.client ? settings.client() : (0, connect_1.connectSandbox)(environment, options);
34
40
  connecting.catch(() => (connecting = undefined));
35
41
  }
36
42
  return connecting;
37
43
  };
44
+ return {
45
+ client,
46
+ // the version is read without a sandbox, which the first call of a tool takes
47
+ tasks: () => (0, task_tools_1.readTaskListing)(async () => {
48
+ if (settings.client) {
49
+ return (await client()).tasks();
50
+ }
51
+ const server = await (0, connect_1.connectServer)(environment, options);
52
+ return (0, sandbox_client_1.listPublishedTasks)(server.connection, server.build);
53
+ }),
54
+ shell: options.shell ?? connect_1.DEFAULT_SHELL_TASK,
55
+ };
38
56
  }
39
57
  // the names of the tools of the plugin, which stand in for opencode's own
40
- exports.OPENCODE_TOOLS = ['read', 'write', 'edit', 'apply_patch', 'bash', 'grep', 'glob', 'push', 'list'];
58
+ exports.OPENCODE_TOOLS = [
59
+ 'read',
60
+ 'write',
61
+ 'edit',
62
+ 'apply_patch',
63
+ 'bash',
64
+ 'grep',
65
+ 'glob',
66
+ 'run_task',
67
+ 'run_logs',
68
+ 'check',
69
+ 'push',
70
+ 'list',
71
+ 'services_up',
72
+ 'endpoint_get',
73
+ 'services_stop',
74
+ ];
41
75
  /**
42
76
  * The plugin as the file `harness/opencode.ts` exports it: it takes the zod of opencode from
43
77
  * `@opencode-ai/plugin`, found next to hammerkit or else in the opencode directories of the
@@ -111,7 +145,22 @@ function packageEntry(manifest) {
111
145
  }
112
146
  }
113
147
  // the names of the tools of the plugin on opencode 2, which stand in for its own
114
- exports.OPENCODE_V2_TOOLS = ['read', 'write', 'edit', 'patch', 'shell', 'glob', 'grep', 'push'];
148
+ exports.OPENCODE_V2_TOOLS = [
149
+ 'read',
150
+ 'write',
151
+ 'edit',
152
+ 'patch',
153
+ 'shell',
154
+ 'glob',
155
+ 'grep',
156
+ 'run_task',
157
+ 'run_logs',
158
+ 'check',
159
+ 'push',
160
+ 'services_up',
161
+ 'endpoint_get',
162
+ 'services_stop',
163
+ ];
115
164
  // the tools of opencode 2 that work on this machine and have no stand-in on a sandbox
116
165
  exports.OPENCODE_V2_LOCAL_TOOLS = [
117
166
  'opencode_worktree_create',
@@ -120,25 +169,31 @@ exports.OPENCODE_V2_LOCAL_TOOLS = [
120
169
  ];
121
170
  /**
122
171
  * The plugin for opencode 2: the tools of {@link createOpencodeV2Tools} in place of opencode's own,
123
- * and none of the worktree tools, which would make worktrees of this machine. opencode 2 takes a
124
- * plugin as an object with an id and a setup, it needs no package of opencode.
172
+ * and none of the worktree tools, which would make worktrees of this machine, nor its shell
173
+ * when there is no shell task. opencode 2 takes a plugin as an object with an id and a setup, it
174
+ * needs no package of opencode.
125
175
  */
126
176
  function hammerkitOpencodeV2Plugin(settings = {}) {
127
177
  return {
128
178
  id: 'hammerkit',
129
179
  async setup(context) {
130
180
  const directory = context.location.directory;
131
- const tools = createOpencodeV2Tools(lazySandbox(directory, settings), directory);
181
+ const harness = connectHarness(directory, settings);
182
+ const tasks = await harness.tasks();
183
+ const tools = createOpencodeV2Tools(harness.client, directory, { tasks, shell: harness.shell });
132
184
  await context.tool.transform((editor) => {
133
185
  for (const tool of tools) {
134
186
  editor.add(tool);
135
187
  }
136
- for (const name of exports.OPENCODE_V2_LOCAL_TOOLS) {
188
+ for (const name of [...exports.OPENCODE_V2_LOCAL_TOOLS, ...(harness.shell ? [] : ['shell'])]) {
137
189
  editor.remove(name);
138
190
  }
139
191
  });
140
192
  // opencode describes the tool named shell anew for each request, as the shell of this machine
141
193
  const shell = tools.find((tool) => tool.name === 'shell');
194
+ if (!shell) {
195
+ return;
196
+ }
142
197
  for (const name of ['context', 'compaction', 'generate']) {
143
198
  await context.session.hook(name, (request) => {
144
199
  if (request.tools.shell) {
@@ -163,8 +218,9 @@ exports.hammerkitOpencodeEntry = hammerkitOpencodeEntry;
163
218
  * opencode 1 under their new names (`patch`, `shell`), paths as `path`, relative to `directory`
164
219
  * or absolute below it. opencode 2 has no `list`, `read` lists a directory.
165
220
  */
166
- function createOpencodeV2Tools(client, directory) {
167
- const tools = opencodeTools(client);
221
+ function createOpencodeV2Tools(client, directory, options = {}) {
222
+ const tools = opencodeTools(client, options);
223
+ const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
168
224
  const tool = (name, of, input, args, permission) => ({
169
225
  name,
170
226
  description: tools[of].description,
@@ -176,6 +232,7 @@ function createOpencodeV2Tools(client, directory) {
176
232
  },
177
233
  });
178
234
  const same = (input) => input;
235
+ const taskTools = Object.fromEntries((0, task_tools_1.createTaskTools)().map((task) => [task.name, task]));
179
236
  return [
180
237
  tool('read', 'read', object({
181
238
  path: text('The file or directory to read, relative to the project or absolute'),
@@ -193,11 +250,15 @@ function createOpencodeV2Tools(client, directory) {
193
250
  replaceAll: flag('Replace all occurrences of oldString (default false)'),
194
251
  }, ['path', 'oldString', 'newString']), ({ path, ...rest }) => ({ filePath: path, ...rest }), 'edit'),
195
252
  tool('patch', 'apply_patch', object({ patchText: text('The full patch text that describes all changes to be made') }, ['patchText']), same, 'edit'),
196
- tool('shell', 'bash', object({
197
- command: text('The command to execute'),
198
- workdir: text('The directory to run the command in, relative to the project. Use this instead of cd.'),
199
- timeout: count('Timeout in milliseconds, 0 for the default of the shell task'),
200
- }, ['command']), ({ timeout, ...rest }) => ({ ...rest, timeout: timeout || undefined })),
253
+ ...(shell
254
+ ? [
255
+ tool('shell', 'bash', object({
256
+ command: text('The command to execute'),
257
+ workdir: text('The directory to run the command in, relative to the project. Use this instead of cd.'),
258
+ timeout: count('Timeout in milliseconds, 0 for the default of the shell task'),
259
+ }, ['command']), ({ timeout, ...rest }) => ({ ...rest, timeout: timeout || undefined })),
260
+ ]
261
+ : []),
201
262
  tool('glob', 'glob', object({
202
263
  pattern: text('The glob pattern to match files against, like "**/*.ts"'),
203
264
  path: text('The directory to search in, the project by default'),
@@ -211,10 +272,12 @@ function createOpencodeV2Tools(client, directory) {
211
272
  caseSensitive: flag('Match case (default true)'),
212
273
  limit: count(`The maximum number of matching lines (default ${SEARCH_LIMIT})`),
213
274
  }, ['pattern']), same),
275
+ ...['run_task', 'run_logs', 'check'].map((name) => tool(name, name, { ...(0, task_tools_1.taskToolSchema)(taskTools[name]), additionalProperties: false }, same)),
214
276
  tool('push', 'push', object({
215
277
  branch: text('The branch to push to, created when it does not exist'),
216
278
  message: text('The commit message'),
217
279
  }, ['branch', 'message']), same),
280
+ ...service_tools_1.SERVICE_TOOLS.map((service) => tool(service.name, service.name, { ...(0, service_tools_1.serviceToolSchema)(service), additionalProperties: false }, same)),
218
281
  ];
219
282
  }
220
283
  exports.createOpencodeV2Tools = createOpencodeV2Tools;
@@ -227,11 +290,46 @@ const object = (properties, required) => ({
227
290
  required,
228
291
  additionalProperties: false,
229
292
  });
230
- function createOpencodeTools(z, client) {
231
- return Object.fromEntries(Object.entries(opencodeTools(client)).map(([name, { args, ...tool }]) => [name, { ...tool, args: args(z) }]));
293
+ function createOpencodeTools(z, client, options = {}) {
294
+ return Object.fromEntries(Object.entries(opencodeTools(client, options)).map(([name, { args, ...tool }]) => [
295
+ name,
296
+ { ...tool, args: args(z) },
297
+ ]));
232
298
  }
233
299
  exports.createOpencodeTools = createOpencodeTools;
234
- function opencodeTools(client) {
300
+ // services_up, endpoint_get and services_stop, with their parameters for the zod of opencode 1
301
+ function opencodeServiceTools(client) {
302
+ return Object.fromEntries(service_tools_1.SERVICE_TOOLS.map((tool) => [
303
+ tool.name,
304
+ {
305
+ description: tool.description,
306
+ args: (z) => (0, service_tools_1.serviceToolArgs)(tool, z),
307
+ async execute(args, context) {
308
+ return { title: tool.title, output: await tool.call(await client(), args, context.abort) };
309
+ },
310
+ },
311
+ ]));
312
+ }
313
+ function opencodeTools(client, options = {}) {
314
+ const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
315
+ const tasks = Object.fromEntries((0, task_tools_1.createTaskTools)(options.tasks, shell || undefined).map((task) => [
316
+ task.name,
317
+ {
318
+ description: task.description,
319
+ args: (z) => (0, task_tools_1.taskToolArgs)(task, z),
320
+ async execute(args, context) {
321
+ const output = await task.call(await client(), args, context.abort);
322
+ return {
323
+ title: task.name === 'run_logs'
324
+ ? `log of ${args.run}`
325
+ : `${task.name === 'check' ? 'check' : 'run'} ${args.task}`,
326
+ output,
327
+ };
328
+ },
329
+ },
330
+ ]));
331
+ // without a shell task bash still stands in for opencode's, which would run on this machine
332
+ const noShell = 'There is no shell on this hammerkit sandbox: run the tasks of the build file with run_task.';
235
333
  const where = (context, path) => (0, sandbox_client_1.toSandboxPath)(context.directory, path ?? context.directory);
236
334
  return inOrder({
237
335
  read: {
@@ -337,35 +435,43 @@ function opencodeTools(client) {
337
435
  ].join('\n');
338
436
  },
339
437
  },
340
- bash: {
341
- description: 'Run a shell command on the hammerkit sandbox, as the shell task of the published build file, in its ' +
342
- 'container. Commands run one at a time and nothing is kept between them but the files. They start in ' +
343
- 'the root of the sandbox, where the paths of this machine do not exist: give them relative paths.',
344
- args: (z) => ({
345
- command: z.string().describe('The command to execute'),
346
- timeout: z.number().int().min(1).optional().describe('Optional timeout in milliseconds'),
347
- workdir: z
348
- .string()
349
- .optional()
350
- .describe("The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands."),
351
- }),
352
- async execute(args, context) {
353
- const sandbox = await client();
354
- const ran = await sandbox.exec(args.command, where(context, args.workdir), {
355
- signal: context.abort,
356
- timeoutSeconds: args.timeout ? args.timeout / 1000 : undefined,
357
- });
358
- const lines = [(0, format_1.capOutput)(ran.output, 'narrow the command down, for example with grep, head or tail')];
359
- if (ran.exitCode === null) {
360
- lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
361
- }
362
- else if (ran.exitCode !== 0) {
363
- lines.push(`Exit code: ${ran.exitCode}`);
364
- }
365
- context.metadata?.({ metadata: { exit: ran.exitCode, run: ran.run.id } });
366
- return lines.filter((line) => line.length > 0).join('\n\n') || '(no output)';
438
+ bash: shell
439
+ ? {
440
+ description: 'Run a shell command on the hammerkit sandbox, as the shell task of the published build file, in its ' +
441
+ 'container. Commands run one at a time and nothing is kept between them but the files. They start in ' +
442
+ 'the root of the sandbox, where the paths of this machine do not exist: give them relative paths.',
443
+ args: (z) => ({
444
+ command: z.string().describe('The command to execute'),
445
+ timeout: z.number().int().min(1).optional().describe('Optional timeout in milliseconds'),
446
+ workdir: z
447
+ .string()
448
+ .optional()
449
+ .describe("The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands."),
450
+ }),
451
+ async execute(args, context) {
452
+ const sandbox = await client();
453
+ const ran = await sandbox.exec(args.command, where(context, args.workdir), {
454
+ signal: context.abort,
455
+ timeoutSeconds: args.timeout ? args.timeout / 1000 : undefined,
456
+ });
457
+ const lines = [(0, format_1.capOutput)(ran.output, (0, task_tools_1.readLogHint)(ran.run.id, shell))];
458
+ if (ran.exitCode === null) {
459
+ lines.push(`hammerkit: ${ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`}`);
460
+ }
461
+ else if (ran.exitCode !== 0) {
462
+ lines.push(`Exit code: ${ran.exitCode}`);
463
+ }
464
+ context.metadata?.({ metadata: { exit: ran.exitCode, run: ran.run.id } });
465
+ return lines.filter((line) => line.length > 0).join('\n\n') || '(no output)';
466
+ },
467
+ }
468
+ : {
469
+ description: noShell,
470
+ args: (z) => ({ command: z.string().describe('The command to execute') }),
471
+ async execute() {
472
+ throw new Error(noShell);
473
+ },
367
474
  },
368
- },
369
475
  grep: {
370
476
  description: 'Search the files of the hammerkit sandbox with a regular expression (ripgrep). Returns the matching ' +
371
477
  'lines with their file and line number.',
@@ -430,6 +536,7 @@ function opencodeTools(client) {
430
536
  return lines.join('\n');
431
537
  },
432
538
  },
539
+ ...tasks,
433
540
  push: {
434
541
  description: 'Commit the changes of the hammerkit sandbox and push them to a branch of its repository, for review. ' +
435
542
  'The server pushes only to the branches its operator allows; the sandbox is at the pushed commit afterwards.',
@@ -474,6 +581,7 @@ function opencodeTools(client) {
474
581
  return lines.join('\n');
475
582
  },
476
583
  },
584
+ ...opencodeServiceTools(client),
477
585
  });
478
586
  }
479
587
  // opencode runs the tool calls of a step in parallel, they reach the sandbox one at a time in their order
@@ -4,6 +4,7 @@ exports.hammerkitPiExtension = exports.createPiOperations = void 0;
4
4
  const path_1 = require("path");
5
5
  const connect_1 = require("./connect");
6
6
  const format_1 = require("./format");
7
+ const service_tools_1 = require("./service-tools");
7
8
  const sandbox_client_1 = require("./sandbox-client");
8
9
  const IMAGE_TYPES = {
9
10
  '.png': 'image/png',
@@ -178,6 +179,34 @@ function hammerkitPiExtension(library, settings = {}) {
178
179
  return { content: [{ type: 'text', text: `pushed ${pushed.commit} to ${pushed.branch}` }], details: pushed };
179
180
  },
180
181
  });
182
+ for (const tool of service_tools_1.SERVICE_TOOLS) {
183
+ register({
184
+ name: tool.name,
185
+ label: tool.name,
186
+ description: tool.description,
187
+ promptSnippet: tool.title,
188
+ parameters: (0, service_tools_1.serviceToolSchema)(tool),
189
+ execute: async (_id, params, signal) => {
190
+ const text = await tool.call(await client(), params, signal ?? new AbortController().signal);
191
+ return { content: [{ type: 'text', text }], details: undefined };
192
+ },
193
+ });
194
+ }
195
+ register({
196
+ name: 'check',
197
+ label: 'check',
198
+ description: sandbox_client_1.CHECK_DESCRIPTION,
199
+ promptSnippet: 'Run a task as a check that CI verifies after the push',
200
+ parameters: {
201
+ type: 'object',
202
+ properties: { task: { type: 'string', description: 'The name of the task' } },
203
+ required: ['task'],
204
+ },
205
+ execute: async (_id, params, signal) => {
206
+ const text = await (0, sandbox_client_1.runCheck)(await client(), params.task, signal);
207
+ return { content: [{ type: 'text', text }], details: { task: params.task } };
208
+ },
209
+ });
181
210
  pi.on('session_start', async (_event, ctx) => {
182
211
  try {
183
212
  const sandbox = await client();