@itsaplan/runner 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +92 -43
  2. package/dist/cli.js +265 -77
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -3,12 +3,12 @@
3
3
  [It's a Plan](https://itsaplan.dev) is a self-hosted, open-source issue tracker. An AI
4
4
  agent in it is a project member: it has a role, permissions, and its own issues.
5
5
 
6
- This package runs one such agent on your own machine.
6
+ This package runs such agents on your own machine — one, or several at once.
7
7
 
8
8
  - Polls your instance for the agent's queued runs. Runs each one with a coding agent CLI.
9
9
  Reports the result.
10
- - Has presets for Claude Code, Codex, opencode, Gemini CLI and GitHub Copilot CLI. Each
11
- preset sets the unattended flags and the session resume.
10
+ - Has presets for Claude Code, Codex, opencode, Antigravity CLI and GitHub Copilot CLI.
11
+ Each preset sets the unattended flags and the session resume.
12
12
  - Runs your own `command` instead, if you prefer. The command takes the task on stdin.
13
13
  - Answers the agent's chat. Sends the text and the tool calls while the CLI prints them.
14
14
 
@@ -61,22 +61,23 @@ or mention it in a comment. The task then starts in your terminal.
61
61
  ## Which coding agent
62
62
 
63
63
  `agent` names a CLI that the runner knows. The runner then builds the command itself: the
64
- flags for an unattended run, and the session resume.
64
+ flags for an unattended run, and the session resume. Every preset keeps a chat session.
65
65
 
66
- | Agent | Runs | Keeps a chat session |
67
- | ---------- | ------------------------ | -------------------- |
68
- | `claude` | Claude Code | yes |
69
- | `codex` | Codex CLI | yes |
70
- | `opencode` | opencode | yes |
71
- | `gemini` | Gemini CLI | no |
72
- | `copilot` | GitHub Copilot CLI | no |
66
+ | Agent | Runs |
67
+ | ------------- | ------------------ |
68
+ | `claude` | Claude Code |
69
+ | `codex` | Codex CLI |
70
+ | `opencode` | opencode |
71
+ | `antigravity` | Antigravity CLI |
72
+ | `copilot` | GitHub Copilot CLI |
73
73
 
74
74
  Each preset needs the CLI's own MCP config.
75
75
  [Coding agent setup](https://github.com/croffasia/itsaplan/blob/main/docs/runner.md) holds
76
76
  that file, and the notes for each CLI.
77
77
 
78
- `--agent` on the command line has priority over the file. The runner appends the
79
- arguments after `--` to the ones from the preset:
78
+ `--agent` on the command line has priority over the file, except over an `agents` entry
79
+ that names its own. The runner appends the arguments after `--` to the ones from the
80
+ preset:
80
81
 
81
82
  ```bash
82
83
  npx -y @itsaplan/runner --agent claude -- --model opus
@@ -99,6 +100,52 @@ Your arguments come after the preset's own. A repeated flag thus replaces its va
99
100
  `command` replaces the preset with your own shell command. The command receives the task
100
101
  on stdin. The runner keeps no session for it, because it does not know how to resume one.
101
102
 
103
+ ## Several agents on one runner
104
+
105
+ One key is one agent. A runner set up the way you want it can serve several of them at
106
+ once: it polls for each key on its own, and every agent uses the coding agent, the working
107
+ directory and the arguments the file already gives.
108
+
109
+ ```json
110
+ {
111
+ "url": "http://localhost:3000",
112
+ "agent": "claude",
113
+ "cwd": "/Users/me/work/my-repo",
114
+ "apiKeys": ["the first agent's key", "the second agent's key"]
115
+ }
116
+ ```
117
+
118
+ Nothing else changes: the runner gives each command its own `ITSAPLAN_API_KEY`, so the one
119
+ MCP config in that folder works for all of them.
120
+
121
+ Use `agents` instead of `apiKeys` where an agent needs something of its own. A field an
122
+ entry sets replaces the shared one, and it inherits every field it leaves out, except its
123
+ `apiKey`, which every entry sets itself. `name` is what the runner's log lines call that
124
+ agent; without it they number the agents `#1`, `#2` in the order of the file.
125
+
126
+ ```json
127
+ {
128
+ "url": "http://localhost:3000",
129
+ "agent": "claude",
130
+ "cwd": "/Users/me/work/my-repo",
131
+ "agents": [
132
+ { "apiKey": "the first agent's key", "name": "planner" },
133
+ {
134
+ "apiKey": "the second agent's key",
135
+ "cwd": "/Users/me/work/other-repo",
136
+ "args": ["--model", "opus"]
137
+ }
138
+ ]
139
+ }
140
+ ```
141
+
142
+ `concurrency` counts per agent and per feed, so a runner with three agents at
143
+ `concurrency: 2` can have six queued runs in flight and six chat answers alongside them,
144
+ each one a coding agent CLI of its own. The environment variables and the command-line
145
+ options describe every agent at once; an entry has priority over them.
146
+
147
+ An agent whose key the instance refuses stops on its own, and the others keep working.
148
+
102
149
  ## Settings
103
150
 
104
151
  The runner reads `./itsaplan-runner.json`. For a different file, give the path as an
@@ -109,21 +156,23 @@ npx -y @itsaplan/runner /path/to/config.json
109
156
  ```
110
157
 
111
158
  The environment variables have priority over the file. The command-line options have
112
- priority over both.
113
-
114
- | Field | Environment variable | Default | What it does |
115
- | ---------------- | --------------------------- | ------------------ | ----------------------------------- |
116
- | `url` | `ITSAPLAN_URL` | required | Your Itsaplan instance |
117
- | `apiKey` | `ITSAPLAN_API_KEY` | required | The external agent's key |
118
- | `agent` | `ITSAPLAN_AGENT` | — | The coding agent to run |
119
- | `args` | | `[]` | Arguments added after the preset's |
120
- | `command` | `ITSAPLAN_COMMAND` | — | Your own command, instead of `agent` |
121
- | `cwd` | `ITSAPLAN_CWD` | where you start it | Working directory for the command |
122
- | `env` | | `{}` | Extra variables for the command |
123
- | `concurrency` | `ITSAPLAN_CONCURRENCY` | `1` | Tasks at once. Queued runs and chat answers count apart |
124
- | `pollIntervalMs` | `ITSAPLAN_POLL_INTERVAL_MS` | `3000` | Wait after an empty queue. Minimum 1000 |
125
- | `timeoutMs` | `ITSAPLAN_TIMEOUT_MS` | `1800000` | Time before the runner stops a task |
126
- | `outputFormat` | `ITSAPLAN_OUTPUT_FORMAT` | the preset's | How the runner reads a chat answer |
159
+ priority over both. An `agents` entry has priority over all three, for the fields it sets.
160
+
161
+ | Field | Environment variable | Default | What it does |
162
+ | ---------------- | --------------------------- | ------------------ | ------------------------------------------------------------------ |
163
+ | `url` | `ITSAPLAN_URL` | required | Your Itsaplan instance |
164
+ | `apiKey` | `ITSAPLAN_API_KEY` | required | The external agent's key. Replaced by `apiKeys` or `agents` |
165
+ | `apiKeys` | | — | Several keys, all with these settings. Not with `agents` |
166
+ | `agents` | | — | Several agents, each with its own key and settings. Not with `apiKeys` |
167
+ | `agent` | `ITSAPLAN_AGENT` | — | The coding agent to run |
168
+ | `args` | | `[]` | Arguments added after the preset's |
169
+ | `command` | `ITSAPLAN_COMMAND` | — | Your own command, instead of `agent` |
170
+ | `cwd` | `ITSAPLAN_CWD` | where you start it | Working directory for the command |
171
+ | `env` | | `{}` | Extra variables for the command |
172
+ | `concurrency` | `ITSAPLAN_CONCURRENCY` | `1` | Tasks at once, per agent. Queued runs and chat answers count apart |
173
+ | `pollIntervalMs` | `ITSAPLAN_POLL_INTERVAL_MS` | `3000` | Wait after an empty queue. Minimum 1000 |
174
+ | `timeoutMs` | `ITSAPLAN_TIMEOUT_MS` | `1800000` | Time before the runner stops a task |
175
+ | `outputFormat` | `ITSAPLAN_OUTPUT_FORMAT` | the preset's | How the runner reads a chat answer |
127
176
 
128
177
  Set `agent` or `command`. With the environment you need no file at all:
129
178
 
@@ -139,7 +188,7 @@ npx -y @itsaplan/runner --agent claude
139
188
  The server writes the task text. The text states what occurred, what to do, and to post
140
189
  the result as a comment on the issue. Your own `command` receives the text on **stdin**. A
141
190
  preset gives it to its CLI in the way that CLI accepts it: on stdin for `claude` and
142
- `codex`, as an argument for `gemini`, `copilot` and `opencode`.
191
+ `codex`, as an argument for `antigravity`, `copilot` and `opencode`.
143
192
 
144
193
  The agent reads all other data through the MCP server at `$ITSAPLAN_URL/mcp`. The agent
145
194
  sends `$ITSAPLAN_API_KEY` as a bearer token, and acts as its own user with its role.
@@ -148,12 +197,12 @@ sends `$ITSAPLAN_API_KEY` as a bearer token, and acts as its own user with its r
148
197
 
149
198
  Every run holds these variables:
150
199
 
151
- | Variable | What it holds |
152
- | ------------------------ | ---------------------------------------------------------------- |
153
- | `ITSAPLAN_URL` | the instance that sent the task |
154
- | `ITSAPLAN_API_KEY` | the agent's key, for the API and the MCP server |
155
- | `ITSAPLAN_TRIGGER` | `mention`, `delegation`, `schedule`, `manual`, or `chat` |
156
- | `ITSAPLAN_SYSTEM_PROMPT` | the context of the run, for a command that takes a system prompt |
200
+ | Variable | What it holds |
201
+ | ------------------------ | ----------------------------------------------------------------- |
202
+ | `ITSAPLAN_URL` | the instance that sent the task |
203
+ | `ITSAPLAN_API_KEY` | the agent's key, for the API and the MCP server |
204
+ | `ITSAPLAN_TRIGGER` | `mention`, `delegation`, `field`, `schedule`, `manual`, or `chat` |
205
+ | `ITSAPLAN_SYSTEM_PROMPT` | the context of the run, for a command that takes a system prompt |
157
206
 
158
207
  A queued run adds three more:
159
208
 
@@ -165,11 +214,11 @@ A queued run adds three more:
165
214
 
166
215
  A chat message adds three others in their place, and `ITSAPLAN_TRIGGER` is `chat`:
167
216
 
168
- | Variable | What it holds |
169
- | ---------------------- | ---------------------------------------------------------------- |
170
- | `ITSAPLAN_THREAD_ID` | the conversation's id |
171
- | `ITSAPLAN_MESSAGE_ID` | the id of the message to answer |
172
- | `ITSAPLAN_SESSION_ID` | the coding agent's session to resume. Empty for the first message |
217
+ | Variable | What it holds |
218
+ | --------------------- | ----------------------------------------------------------------- |
219
+ | `ITSAPLAN_THREAD_ID` | the conversation's id |
220
+ | `ITSAPLAN_MESSAGE_ID` | the id of the message to answer |
221
+ | `ITSAPLAN_SESSION_ID` | the coding agent's session to resume. Empty for the first message |
173
222
 
174
223
  The task text of a chat message includes the conversation, unless a session holds it. See
175
224
  Sessions below.
@@ -185,7 +234,8 @@ The runner streams the answer to the chat while the command runs. It reports the
185
234
  calls with the answer. `outputFormat` tells the runner how to read the output:
186
235
 
187
236
  - `text` — all the output is the answer.
188
- - `claude-stream-json`, `codex-jsonl`, `opencode-json` — the event stream of that CLI.
237
+ - `claude-stream-json`, `codex-jsonl`, `opencode-json`, `antigravity-stream-json`,
238
+ `copilot-json` — the event stream of that CLI.
189
239
 
190
240
  A preset sets `outputFormat` for you. Set it yourself only with your own `command`. If the
191
241
  output does not agree with the format, the runner sends the output as plain text.
@@ -197,8 +247,7 @@ Without a session the server sends the last 20 messages of the conversation as p
197
247
 
198
248
  - The chat header shows the session id. Run `claude --resume <id>` in the runner's working
199
249
  directory to open the same session in your terminal.
200
- - `gemini` and `copilot` keep no session: neither one reports the id of a session that
201
- starts non-interactively.
250
+ - Your own `command` keeps no session. Each chat message then includes the conversation.
202
251
  - A session stays on the machine that started it. If you delete its files or move the
203
252
  runner, the answers fail. Start a new chat then.
204
253
 
package/dist/cli.js CHANGED
@@ -137,7 +137,79 @@ class AnswerStream {
137
137
  case "opencode-json":
138
138
  this.readOpencodeLine(parsed);
139
139
  break;
140
+ case "antigravity-stream-json":
141
+ this.readAntigravityLine(parsed);
142
+ break;
143
+ case "copilot-json":
144
+ this.readCopilotLine(parsed);
145
+ break;
146
+ }
147
+ }
148
+ readCopilotLine(message) {
149
+ const data = message.data;
150
+ switch (message.type) {
151
+ case "result":
152
+ if (message.sessionId)
153
+ this.sessionId ??= message.sessionId;
154
+ return;
155
+ case "assistant.message_delta":
156
+ if (data?.deltaContent) {
157
+ this.sawPartialText = true;
158
+ this.appendText(data.deltaContent);
159
+ }
160
+ return;
161
+ case "assistant.message":
162
+ if (data?.content && !this.sawPartialText)
163
+ this.appendText(data.content);
164
+ return;
165
+ case "tool.execution_start":
166
+ if (data?.toolCallId) {
167
+ this.pushToolCall(data.toolCallId, data.toolName ?? "tool", JSON.stringify(data.arguments ?? {}));
168
+ }
169
+ return;
170
+ case "tool.execution_complete": {
171
+ if (!data?.toolCallId)
172
+ return;
173
+ const result = data.success ? data.result?.content : data.error?.message;
174
+ if (typeof result === "string")
175
+ this.pushToolResult(data.toolCallId, result);
176
+ return;
177
+ }
178
+ }
179
+ }
180
+ readAntigravityLine(message) {
181
+ const step = message.step_update;
182
+ const conversationId = message.conversation_id ?? step?.conversation_id ?? message.result?.conversation_id;
183
+ if (conversationId)
184
+ this.sessionId ??= conversationId;
185
+ if (message.event === "result") {
186
+ if (!this.sawAnyText && typeof message.result?.response === "string") {
187
+ this.appendText(message.result.response);
188
+ }
189
+ return;
190
+ }
191
+ if (!step)
192
+ return;
193
+ if (step.step_type === "agent_response") {
194
+ if (step.text_delta)
195
+ this.appendText(step.text_delta);
196
+ return;
140
197
  }
198
+ if (step.step_type !== "tool" || step.step_index === undefined)
199
+ return;
200
+ const id = `step-${step.step_index}`;
201
+ const tool = step.tool_info;
202
+ if (!this.openToolCalls.has(id)) {
203
+ this.openToolCalls.add(id);
204
+ this.pushToolCall(id, step.tool_name ?? tool?.name ?? "tool", JSON.stringify(tool?.parameters ?? {}));
205
+ }
206
+ if (this.closedToolCalls.has(id))
207
+ return;
208
+ const result = tool?.error?.message ?? tool?.output;
209
+ if (typeof result !== "string")
210
+ return;
211
+ this.closedToolCalls.add(id);
212
+ this.pushToolResult(id, result);
141
213
  }
142
214
  readCodexLine(message) {
143
215
  if (message.type === "thread.started" && message.thread_id) {
@@ -264,7 +336,6 @@ var PRESETS = {
264
336
  claude: {
265
337
  bin: "claude",
266
338
  outputFormat: "claude-stream-json",
267
- sessions: true,
268
339
  promptVia: "stdin",
269
340
  systemPromptFlag: "--append-system-prompt",
270
341
  head: (sessionId) => [
@@ -282,7 +353,6 @@ var PRESETS = {
282
353
  codex: {
283
354
  bin: "codex",
284
355
  outputFormat: "codex-jsonl",
285
- sessions: true,
286
356
  promptVia: "stdin",
287
357
  head: (sessionId) => [
288
358
  ...sessionId ? ["exec", "resume", sessionId] : ["exec"],
@@ -295,7 +365,6 @@ var PRESETS = {
295
365
  opencode: {
296
366
  bin: "opencode",
297
367
  outputFormat: "opencode-json",
298
- sessions: true,
299
368
  promptVia: "arg",
300
369
  head: (sessionId) => [
301
370
  "run",
@@ -305,20 +374,31 @@ var PRESETS = {
305
374
  ],
306
375
  tail: []
307
376
  },
308
- gemini: {
309
- bin: "gemini",
310
- outputFormat: "text",
311
- sessions: false,
377
+ antigravity: {
378
+ bin: "agy",
379
+ outputFormat: "antigravity-stream-json",
312
380
  promptVia: "arg",
313
- head: () => ["--approval-mode", "yolo"],
381
+ head: (sessionId) => [
382
+ ...sessionId ? ["--conversation", sessionId] : [],
383
+ "--output-format",
384
+ "stream-json",
385
+ "--dangerously-skip-permissions",
386
+ "--print-timeout",
387
+ "24h"
388
+ ],
314
389
  tail: ["-p"]
315
390
  },
316
391
  copilot: {
317
392
  bin: "copilot",
318
- outputFormat: "text",
319
- sessions: false,
393
+ outputFormat: "copilot-json",
320
394
  promptVia: "arg",
321
- head: () => ["--allow-all-tools", "--no-ask-user"],
395
+ head: (sessionId) => [
396
+ "--allow-all-tools",
397
+ "--no-ask-user",
398
+ "--output-format",
399
+ "json",
400
+ ...sessionId ? ["--session-id", sessionId] : []
401
+ ],
322
402
  tail: ["-p"]
323
403
  }
324
404
  };
@@ -335,7 +415,7 @@ ${prompt}`;
335
415
  }
336
416
  function presetArgv(preset, sessionId, systemPrompt, extraArgs, prompt) {
337
417
  return [
338
- ...preset.head(preset.sessions ? sessionId : null),
418
+ ...preset.head(sessionId),
339
419
  ...preset.systemPromptFlag && systemPrompt ? [preset.systemPromptFlag, systemPrompt] : [],
340
420
  ...extraArgs,
341
421
  ...preset.tail,
@@ -344,7 +424,14 @@ function presetArgv(preset, sessionId, systemPrompt, extraArgs, prompt) {
344
424
  }
345
425
 
346
426
  // src/config.ts
347
- var OUTPUT_FORMATS = ["text", "claude-stream-json", "codex-jsonl", "opencode-json"];
427
+ var OUTPUT_FORMATS = [
428
+ "text",
429
+ "claude-stream-json",
430
+ "codex-jsonl",
431
+ "opencode-json",
432
+ "antigravity-stream-json",
433
+ "copilot-json"
434
+ ];
348
435
  function presetOf(config) {
349
436
  if (config.command || !config.agent)
350
437
  return;
@@ -356,12 +443,16 @@ var DEFAULTS = {
356
443
  timeoutMs: 30 * 60 * 1000
357
444
  };
358
445
  var MIN_POLL_INTERVAL_MS = 1000;
446
+ function textOf(value) {
447
+ const text = typeof value === "string" ? value.trim() : "";
448
+ return text || undefined;
449
+ }
359
450
  function intFrom(value, fallback) {
360
451
  const parsed = typeof value === "string" ? Number.parseInt(value, 10) : value;
361
452
  return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
362
453
  }
363
454
  function outputFormatFrom(value, preset) {
364
- const name = typeof value === "string" ? value.trim() : "";
455
+ const name = textOf(value);
365
456
  if (!name)
366
457
  return preset?.outputFormat ?? "text";
367
458
  const format = OUTPUT_FORMATS.find((candidate) => candidate === name);
@@ -371,7 +462,7 @@ function outputFormatFrom(value, preset) {
371
462
  return format;
372
463
  }
373
464
  function agentFrom(value) {
374
- const name = typeof value === "string" ? value.trim() : "";
465
+ const name = textOf(value);
375
466
  if (!name)
376
467
  return;
377
468
  if (!isPresetName(name)) {
@@ -388,7 +479,7 @@ function argsFrom(value) {
388
479
  return value;
389
480
  }
390
481
  function required(value, field) {
391
- const text = typeof value === "string" ? value.trim() : "";
482
+ const text = textOf(value);
392
483
  if (!text)
393
484
  throw new Error(`${field} is required — set it in the config file or the environment`);
394
485
  return text;
@@ -408,27 +499,90 @@ async function readConfigFile(path) {
408
499
  throw new Error(`${path} is not valid JSON`);
409
500
  }
410
501
  }
411
- async function loadConfig(path, overrides = {}) {
412
- const raw = await readConfigFile(path);
413
- const env = process.env;
414
- const agent = agentFrom(overrides.agent ?? env.ITSAPLAN_AGENT ?? raw.agent);
415
- const command = (env.ITSAPLAN_COMMAND ?? raw.command)?.trim() || undefined;
502
+ function agentFieldsFrom(file) {
503
+ if (file.apiKeys !== undefined && file.agents !== undefined) {
504
+ throw new Error("set either apiKeys or agents, not both");
505
+ }
506
+ if (file.apiKeys !== undefined) {
507
+ const keys = file.apiKeys;
508
+ if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
509
+ throw new Error("apiKeys must be an array of strings");
510
+ }
511
+ return keys.map((apiKey) => ({ apiKey }));
512
+ }
513
+ if (file.agents === undefined)
514
+ return [{}];
515
+ const agents = file.agents;
516
+ if (!Array.isArray(agents) || agents.some((entry) => typeof entry !== "object" || entry === null || Array.isArray(entry) || !textOf(entry.apiKey))) {
517
+ throw new Error("agents must be an array of objects, each with its own apiKey");
518
+ }
519
+ return agents;
520
+ }
521
+ function merge(shared, own) {
522
+ const merged = { ...shared, ...own };
523
+ const mode = textOf(own.agent) ?? textOf(own.command);
524
+ const changesMode = mode !== undefined && mode !== (textOf(shared.agent) ?? textOf(shared.command));
525
+ if (textOf(own.agent))
526
+ delete merged.command;
527
+ else if (textOf(own.command))
528
+ delete merged.agent;
529
+ if (changesMode && !textOf(own.outputFormat))
530
+ delete merged.outputFormat;
531
+ return merged;
532
+ }
533
+ function nameOf(entry, index, total) {
534
+ if (total === 1)
535
+ return "";
536
+ return textOf(entry.name) ?? `#${index + 1}`;
537
+ }
538
+ function configFrom(fields, name, extraArgs) {
539
+ const agent = agentFrom(fields.agent);
540
+ const command = textOf(fields.command);
416
541
  if (!agent && !command) {
417
542
  throw new Error(`set either agent (one of ${PRESET_NAMES.join(", ")}) or command in the config file or the environment`);
418
543
  }
419
544
  return {
420
- url: required(env.ITSAPLAN_URL ?? raw.url, "url").replace(/\/+$/, ""),
421
- apiKey: required(env.ITSAPLAN_API_KEY ?? raw.apiKey, "apiKey"),
545
+ name,
546
+ url: required(fields.url, "url").replace(/\/+$/, ""),
547
+ apiKey: required(fields.apiKey, "apiKey"),
422
548
  agent,
423
549
  command,
424
- args: [...argsFrom(raw.args), ...overrides.args ?? []],
425
- cwd: (env.ITSAPLAN_CWD ?? raw.cwd)?.replace(/^~/, env.HOME ?? "~"),
426
- env: raw.env ?? {},
427
- concurrency: intFrom(env.ITSAPLAN_CONCURRENCY ?? raw.concurrency, DEFAULTS.concurrency),
428
- pollIntervalMs: Math.max(intFrom(env.ITSAPLAN_POLL_INTERVAL_MS ?? raw.pollIntervalMs, DEFAULTS.pollIntervalMs), MIN_POLL_INTERVAL_MS),
429
- timeoutMs: intFrom(env.ITSAPLAN_TIMEOUT_MS ?? raw.timeoutMs, DEFAULTS.timeoutMs),
430
- outputFormat: outputFormatFrom(env.ITSAPLAN_OUTPUT_FORMAT ?? raw.outputFormat, presetOf({ agent, command }))
550
+ args: [...argsFrom(fields.args), ...extraArgs],
551
+ cwd: textOf(fields.cwd)?.replace(/^~/, process.env.HOME ?? "~"),
552
+ env: fields.env ?? {},
553
+ concurrency: intFrom(fields.concurrency, DEFAULTS.concurrency),
554
+ pollIntervalMs: Math.max(intFrom(fields.pollIntervalMs, DEFAULTS.pollIntervalMs), MIN_POLL_INTERVAL_MS),
555
+ timeoutMs: intFrom(fields.timeoutMs, DEFAULTS.timeoutMs),
556
+ outputFormat: outputFormatFrom(fields.outputFormat, presetOf({ agent, command }))
557
+ };
558
+ }
559
+ function sharedFields(file, overrides) {
560
+ const env = process.env;
561
+ const outside = {
562
+ url: env.ITSAPLAN_URL,
563
+ apiKey: env.ITSAPLAN_API_KEY,
564
+ agent: overrides.agent ?? env.ITSAPLAN_AGENT,
565
+ command: env.ITSAPLAN_COMMAND,
566
+ cwd: env.ITSAPLAN_CWD,
567
+ concurrency: env.ITSAPLAN_CONCURRENCY,
568
+ pollIntervalMs: env.ITSAPLAN_POLL_INTERVAL_MS,
569
+ timeoutMs: env.ITSAPLAN_TIMEOUT_MS,
570
+ outputFormat: env.ITSAPLAN_OUTPUT_FORMAT
431
571
  };
572
+ for (const [field, value] of Object.entries(outside)) {
573
+ if (!textOf(value))
574
+ delete outside[field];
575
+ }
576
+ return merge(file, outside);
577
+ }
578
+ async function loadConfig(path, overrides = {}) {
579
+ const file = await readConfigFile(path);
580
+ const entries = agentFieldsFrom(file);
581
+ if (entries.length === 0) {
582
+ throw new Error("no agents configured — apiKeys and agents cannot be empty");
583
+ }
584
+ const shared = sharedFields(file, overrides);
585
+ return entries.map((entry, index) => configFrom(merge(shared, entry), nameOf(entry, index, entries.length), overrides.args ?? []));
432
586
  }
433
587
 
434
588
  // src/execute.ts
@@ -466,7 +620,7 @@ function stdinText(preset, task) {
466
620
  return "";
467
621
  return presetPrompt(preset, task.systemPrompt, task.prompt);
468
622
  }
469
- async function execute(config, task, onData) {
623
+ async function execute(config, task, opts = {}) {
470
624
  const preset = presetOf(config);
471
625
  const [bin, args] = spawnArgs(config, preset, task);
472
626
  const child = spawn(bin, args, {
@@ -474,19 +628,23 @@ async function execute(config, task, onData) {
474
628
  env: childEnv(config, task),
475
629
  detached: true
476
630
  });
631
+ const kill = () => {
632
+ if (child.pid !== undefined)
633
+ killGroup(child.pid);
634
+ };
477
635
  let timedOut = false;
478
636
  const timer = setTimeout(() => {
479
637
  timedOut = true;
480
- if (child.pid !== undefined)
481
- killGroup(child.pid);
638
+ kill();
482
639
  }, config.timeoutMs);
640
+ opts.signal?.addEventListener("abort", kill, { once: true });
483
641
  let stdout = "";
484
642
  let stderr = "";
485
643
  child.stdout.setEncoding("utf8");
486
644
  child.stderr.setEncoding("utf8");
487
645
  child.stdout.on("data", (chunk) => {
488
646
  stdout = tail2(stdout + chunk, OUTPUT_LIMIT);
489
- onData?.(chunk);
647
+ opts.onData?.(chunk);
490
648
  });
491
649
  child.stderr.on("data", (chunk) => {
492
650
  stderr = tail2(stderr + chunk, ERROR_LIMIT);
@@ -503,6 +661,7 @@ async function execute(config, task, onData) {
503
661
  ({ code, signal } = await exited);
504
662
  } finally {
505
663
  clearTimeout(timer);
664
+ opts.signal?.removeEventListener("abort", kill);
506
665
  }
507
666
  const output = stdout.trim();
508
667
  if (code === 0)
@@ -518,13 +677,14 @@ async function execute(config, task, onData) {
518
677
 
519
678
  // src/chat.ts
520
679
  var FLUSH_MS = 500;
521
- async function answer(config, client, message) {
680
+ async function answer(config, client, message, stop) {
522
681
  let reported = message.sessionId !== null;
523
- const stream = new AnswerStream(config.outputFormat, message.threadId, String(message.id), (events) => {
682
+ const stream = new AnswerStream(config.outputFormat, message.threadId, String(message.id), async (events) => {
524
683
  const started = reported ? undefined : stream.startedSession() ?? undefined;
525
684
  if (started)
526
685
  reported = true;
527
- return client.chatEvents(message.id, events, started);
686
+ if (await client.chatEvents(message.id, events, started))
687
+ stop.abort();
528
688
  });
529
689
  const flushing = setInterval(() => {
530
690
  stream.flush().catch(() => {});
@@ -540,7 +700,9 @@ async function answer(config, client, message) {
540
700
  ITSAPLAN_MESSAGE_ID: String(message.id),
541
701
  ITSAPLAN_SESSION_ID: message.sessionId ?? ""
542
702
  }
543
- }, (chunk) => stream.write(chunk)).finally(() => clearInterval(flushing));
703
+ }, { onData: (chunk) => stream.write(chunk), signal: stop.signal }).finally(() => clearInterval(flushing));
704
+ if (stop.signal.aborted)
705
+ return;
544
706
  if (outcome.status === "success") {
545
707
  await stream.finish(outcome.output);
546
708
  await client.chatResult(message.id, { status: "success" });
@@ -600,28 +762,36 @@ class Client {
600
762
  return body.message;
601
763
  }
602
764
  async chatEvents(messageId, events, sessionId) {
603
- await this.post(`/agent-chats/${messageId}/events`, {
765
+ const res = await this.post(`/agent-chats/${messageId}/events`, {
604
766
  events,
605
767
  ...sessionId && { sessionId }
606
768
  });
769
+ return canceled(res);
607
770
  }
608
771
  async chatHeartbeat(messageId) {
609
- await this.post(`/agent-chats/${messageId}/heartbeat`);
772
+ return canceled(await this.post(`/agent-chats/${messageId}/heartbeat`));
610
773
  }
611
774
  async chatResult(messageId, result) {
612
775
  await this.post(`/agent-chats/${messageId}/result`, result);
613
776
  }
614
777
  }
778
+ async function canceled(res) {
779
+ const body = await res.json().catch(() => ({}));
780
+ return body.canceled === true;
781
+ }
615
782
 
616
783
  // src/cli.ts
617
784
  var HEARTBEAT_MS = 60000;
618
785
  var ERROR_BACKOFF_MS = 5000;
786
+ function prefixOf(name) {
787
+ return name ? `[itsaplan-runner ${name}]` : "[itsaplan-runner]";
788
+ }
619
789
  function log(message) {
620
- console.log(`[itsaplan-runner] ${message}`);
790
+ console.log(`${prefixOf("")} ${message}`);
621
791
  }
622
- async function withHeartbeat(beat, work) {
792
+ async function withHeartbeat(log2, beat, work) {
623
793
  const timer = setInterval(() => {
624
- beat().catch((err) => log(`heartbeat failed: ${String(err)}`));
794
+ beat().catch((err) => log2(`heartbeat failed: ${String(err)}`));
625
795
  }, HEARTBEAT_MS);
626
796
  try {
627
797
  return await work;
@@ -642,31 +812,38 @@ function taskOf(run) {
642
812
  }
643
813
  };
644
814
  }
645
- async function handle(config, client, run) {
815
+ async function handle(config, client, log2, run) {
646
816
  const label = run.issueIdentifier ?? `run ${run.id}`;
647
- log(`${label}: started (${run.trigger})`);
817
+ log2(`${label}: started (${run.trigger})`);
648
818
  try {
649
- const outcome = await withHeartbeat(() => client.heartbeat(run.id), execute(config, taskOf(run)));
819
+ const outcome = await withHeartbeat(log2, () => client.heartbeat(run.id), execute(config, taskOf(run)));
650
820
  await client.report(run.id, outcome);
651
- log(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
821
+ log2(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
652
822
  } catch (err) {
653
823
  const message = err instanceof Error ? err.message : String(err);
654
- log(`${label}: runner error — ${message}`);
824
+ log2(`${label}: runner error — ${message}`);
655
825
  await client.report(run.id, { status: "failed", error: message }).catch(() => {});
656
826
  }
657
827
  }
658
- async function handleChat(config, client, message) {
659
- log(`chat ${message.id}: answering`);
828
+ async function handleChat(config, client, log2, message) {
829
+ log2(`chat ${message.id}: answering`);
830
+ const stop = new AbortController;
660
831
  try {
661
- await withHeartbeat(() => client.chatHeartbeat(message.id), answer(config, client, message));
662
- log(`chat ${message.id}: answered`);
832
+ await withHeartbeat(log2, async () => {
833
+ if (await client.chatHeartbeat(message.id))
834
+ stop.abort();
835
+ }, answer(config, client, message, stop));
663
836
  } catch (err) {
664
- const text = err instanceof Error ? err.message : String(err);
665
- log(`chat ${message.id}: runner error — ${text}`);
666
- await client.chatResult(message.id, { status: "failed", error: text }).catch(() => {});
837
+ if (!stop.signal.aborted) {
838
+ const text = err instanceof Error ? err.message : String(err);
839
+ log2(`chat ${message.id}: runner error — ${text}`);
840
+ await client.chatResult(message.id, { status: "failed", error: text }).catch(() => {});
841
+ return;
842
+ }
667
843
  }
844
+ log2(`chat ${message.id}: ${stop.signal.aborted ? "stopped" : "answered"}`);
668
845
  }
669
- async function drain(state, concurrency, take, run, onEmpty) {
846
+ async function drain(state, log2, concurrency, take, run, onEmpty) {
670
847
  const active = new Set;
671
848
  let done = false;
672
849
  while (!state.stopping && !done) {
@@ -680,7 +857,7 @@ async function drain(state, concurrency, take, run, onEmpty) {
680
857
  } catch (err) {
681
858
  if (err instanceof RequestError && (err.status === 401 || err.status === 403))
682
859
  throw err;
683
- log(`claim failed: ${String(err)}`);
860
+ log2(`claim failed: ${String(err)}`);
684
861
  await sleep(ERROR_BACKOFF_MS);
685
862
  continue;
686
863
  }
@@ -718,43 +895,54 @@ function parseArgv(argv) {
718
895
  }
719
896
  return parsed;
720
897
  }
721
- async function main() {
722
- const cli = parseArgv(process.argv.slice(2));
723
- const configPath = cli.configPath ?? process.env.ITSAPLAN_RUNNER_CONFIG ?? "./itsaplan-runner.json";
724
- const config = await loadConfig(configPath, { agent: cli.agent, args: cli.args });
898
+ async function serve(state, config) {
725
899
  const client = new Client(config);
726
- const state = { stopping: false };
727
- for (const signal of ["SIGINT", "SIGTERM"]) {
728
- process.on(signal, () => {
729
- if (state.stopping) {
730
- log("quitting now — the commands in flight keep running, their runs are retried");
731
- process.exit(1);
732
- }
733
- state.stopping = true;
734
- log("stopping — finishing the tasks in flight, press again to quit now");
735
- });
736
- }
737
- log(`running ${config.agent ?? "the configured command"}, polling ${config.url} every ` + `${config.pollIntervalMs}ms, up to ${config.concurrency} at once`);
900
+ const prefix = prefixOf(config.name);
901
+ const log2 = (message) => console.log(`${prefix} ${message}`);
902
+ log2(`running ${config.agent ?? "the configured command"}, polling ${config.url} every ` + `${config.pollIntervalMs}ms, up to ${config.concurrency} at once`);
738
903
  let chatSupported = true;
739
904
  await Promise.all([
740
- drain(state, config.concurrency, () => client.claim(), (run) => handle(config, client, run), async () => {
905
+ drain(state, log2, config.concurrency, () => client.claim(), (run) => handle(config, client, log2, run), async () => {
741
906
  await sleep(config.pollIntervalMs);
742
907
  return true;
743
908
  }),
744
- drain(state, config.concurrency, async () => {
909
+ drain(state, log2, config.concurrency, async () => {
745
910
  try {
746
911
  return await client.claimChat();
747
912
  } catch (err) {
748
913
  if (err instanceof RequestError && err.status === 404) {
749
- log("this instance has no chat feed — only queued runs will be answered");
914
+ log2("this instance has no chat feed — only queued runs will be answered");
750
915
  chatSupported = false;
751
916
  return null;
752
917
  }
753
918
  throw err;
754
919
  }
755
- }, (message) => handleChat(config, client, message), () => Promise.resolve(chatSupported))
920
+ }, (message) => handleChat(config, client, log2, message), () => Promise.resolve(chatSupported))
756
921
  ]);
757
922
  }
923
+ async function main() {
924
+ const cli = parseArgv(process.argv.slice(2));
925
+ const configPath = cli.configPath ?? process.env.ITSAPLAN_RUNNER_CONFIG ?? "./itsaplan-runner.json";
926
+ const configs = await loadConfig(configPath, { agent: cli.agent, args: cli.args });
927
+ const state = { stopping: false };
928
+ for (const signal of ["SIGINT", "SIGTERM"]) {
929
+ process.on(signal, () => {
930
+ if (state.stopping) {
931
+ log("quitting now — the commands in flight keep running, their runs are retried");
932
+ process.exit(1);
933
+ }
934
+ state.stopping = true;
935
+ log("stopping — finishing the tasks in flight, press again to quit now");
936
+ });
937
+ }
938
+ const served = await Promise.all(configs.map((config) => serve(state, config).then(() => true, (err) => {
939
+ const message = err instanceof Error ? err.message : String(err);
940
+ console.error(`${prefixOf(config.name)} stopped — ${message}`);
941
+ return false;
942
+ })));
943
+ if (served.includes(false))
944
+ process.exit(1);
945
+ }
758
946
  main().catch((err) => {
759
947
  console.error(`[itsaplan-runner] ${err instanceof Error ? err.message : String(err)}`);
760
948
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsaplan/runner",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Runs an Itsaplan external agent's queued tasks and answers its chat on your own machine",
5
5
  "keywords": [
6
6
  "itsaplan",
@@ -17,7 +17,7 @@
17
17
  "linear-alternative",
18
18
  "claude-code",
19
19
  "codex",
20
- "gemini-cli",
20
+ "antigravity",
21
21
  "opencode"
22
22
  ],
23
23
  "homepage": "https://github.com/croffasia/itsaplan/tree/main/packages/runner#readme",