@itsaplan/runner 0.2.0 → 0.3.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 (4) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +93 -44
  3. package/dist/cli.js +233 -65
  4. package/package.json +2 -2
package/LICENSE CHANGED
@@ -187,7 +187,7 @@
187
187
  same "printed page" as the copyright notice for easier
188
188
  identification within third-party archives.
189
189
 
190
- Copyright 2026 VIBE DEV SPACE LLC
190
+ Copyright 2026 Andrii Poluosmak
191
191
 
192
192
  Licensed under the Apache License, Version 2.0 (the "License");
193
193
  you may not use this file except in compliance with the License.
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
 
@@ -228,6 +277,6 @@ Do everything else in WSL: Node, the coding agent, the config file, and the runn
228
277
 
229
278
  ## License
230
279
 
231
- Copyright © 2026 VIBE DEV SPACE LLC.
280
+ Copyright © 2026 Andrii Poluosmak.
232
281
 
233
282
  [Apache-2.0](LICENSE).
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;
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 ?? {}));
140
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,28 +499,91 @@ 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 }))
431
557
  };
432
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
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 ?? []));
586
+ }
433
587
 
434
588
  // src/execute.ts
435
589
  var OUTPUT_LIMIT = 8000;
@@ -616,12 +770,15 @@ class Client {
616
770
  // src/cli.ts
617
771
  var HEARTBEAT_MS = 60000;
618
772
  var ERROR_BACKOFF_MS = 5000;
773
+ function prefixOf(name) {
774
+ return name ? `[itsaplan-runner ${name}]` : "[itsaplan-runner]";
775
+ }
619
776
  function log(message) {
620
- console.log(`[itsaplan-runner] ${message}`);
777
+ console.log(`${prefixOf("")} ${message}`);
621
778
  }
622
- async function withHeartbeat(beat, work) {
779
+ async function withHeartbeat(log2, beat, work) {
623
780
  const timer = setInterval(() => {
624
- beat().catch((err) => log(`heartbeat failed: ${String(err)}`));
781
+ beat().catch((err) => log2(`heartbeat failed: ${String(err)}`));
625
782
  }, HEARTBEAT_MS);
626
783
  try {
627
784
  return await work;
@@ -642,31 +799,31 @@ function taskOf(run) {
642
799
  }
643
800
  };
644
801
  }
645
- async function handle(config, client, run) {
802
+ async function handle(config, client, log2, run) {
646
803
  const label = run.issueIdentifier ?? `run ${run.id}`;
647
- log(`${label}: started (${run.trigger})`);
804
+ log2(`${label}: started (${run.trigger})`);
648
805
  try {
649
- const outcome = await withHeartbeat(() => client.heartbeat(run.id), execute(config, taskOf(run)));
806
+ const outcome = await withHeartbeat(log2, () => client.heartbeat(run.id), execute(config, taskOf(run)));
650
807
  await client.report(run.id, outcome);
651
- log(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
808
+ log2(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
652
809
  } catch (err) {
653
810
  const message = err instanceof Error ? err.message : String(err);
654
- log(`${label}: runner error — ${message}`);
811
+ log2(`${label}: runner error — ${message}`);
655
812
  await client.report(run.id, { status: "failed", error: message }).catch(() => {});
656
813
  }
657
814
  }
658
- async function handleChat(config, client, message) {
659
- log(`chat ${message.id}: answering`);
815
+ async function handleChat(config, client, log2, message) {
816
+ log2(`chat ${message.id}: answering`);
660
817
  try {
661
- await withHeartbeat(() => client.chatHeartbeat(message.id), answer(config, client, message));
662
- log(`chat ${message.id}: answered`);
818
+ await withHeartbeat(log2, () => client.chatHeartbeat(message.id), answer(config, client, message));
819
+ log2(`chat ${message.id}: answered`);
663
820
  } catch (err) {
664
821
  const text = err instanceof Error ? err.message : String(err);
665
- log(`chat ${message.id}: runner error — ${text}`);
822
+ log2(`chat ${message.id}: runner error — ${text}`);
666
823
  await client.chatResult(message.id, { status: "failed", error: text }).catch(() => {});
667
824
  }
668
825
  }
669
- async function drain(state, concurrency, take, run, onEmpty) {
826
+ async function drain(state, log2, concurrency, take, run, onEmpty) {
670
827
  const active = new Set;
671
828
  let done = false;
672
829
  while (!state.stopping && !done) {
@@ -680,7 +837,7 @@ async function drain(state, concurrency, take, run, onEmpty) {
680
837
  } catch (err) {
681
838
  if (err instanceof RequestError && (err.status === 401 || err.status === 403))
682
839
  throw err;
683
- log(`claim failed: ${String(err)}`);
840
+ log2(`claim failed: ${String(err)}`);
684
841
  await sleep(ERROR_BACKOFF_MS);
685
842
  continue;
686
843
  }
@@ -718,43 +875,54 @@ function parseArgv(argv) {
718
875
  }
719
876
  return parsed;
720
877
  }
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 });
878
+ async function serve(state, config) {
725
879
  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`);
880
+ const prefix = prefixOf(config.name);
881
+ const log2 = (message) => console.log(`${prefix} ${message}`);
882
+ log2(`running ${config.agent ?? "the configured command"}, polling ${config.url} every ` + `${config.pollIntervalMs}ms, up to ${config.concurrency} at once`);
738
883
  let chatSupported = true;
739
884
  await Promise.all([
740
- drain(state, config.concurrency, () => client.claim(), (run) => handle(config, client, run), async () => {
885
+ drain(state, log2, config.concurrency, () => client.claim(), (run) => handle(config, client, log2, run), async () => {
741
886
  await sleep(config.pollIntervalMs);
742
887
  return true;
743
888
  }),
744
- drain(state, config.concurrency, async () => {
889
+ drain(state, log2, config.concurrency, async () => {
745
890
  try {
746
891
  return await client.claimChat();
747
892
  } catch (err) {
748
893
  if (err instanceof RequestError && err.status === 404) {
749
- log("this instance has no chat feed — only queued runs will be answered");
894
+ log2("this instance has no chat feed — only queued runs will be answered");
750
895
  chatSupported = false;
751
896
  return null;
752
897
  }
753
898
  throw err;
754
899
  }
755
- }, (message) => handleChat(config, client, message), () => Promise.resolve(chatSupported))
900
+ }, (message) => handleChat(config, client, log2, message), () => Promise.resolve(chatSupported))
756
901
  ]);
757
902
  }
903
+ async function main() {
904
+ const cli = parseArgv(process.argv.slice(2));
905
+ const configPath = cli.configPath ?? process.env.ITSAPLAN_RUNNER_CONFIG ?? "./itsaplan-runner.json";
906
+ const configs = await loadConfig(configPath, { agent: cli.agent, args: cli.args });
907
+ const state = { stopping: false };
908
+ for (const signal of ["SIGINT", "SIGTERM"]) {
909
+ process.on(signal, () => {
910
+ if (state.stopping) {
911
+ log("quitting now — the commands in flight keep running, their runs are retried");
912
+ process.exit(1);
913
+ }
914
+ state.stopping = true;
915
+ log("stopping — finishing the tasks in flight, press again to quit now");
916
+ });
917
+ }
918
+ const served = await Promise.all(configs.map((config) => serve(state, config).then(() => true, (err) => {
919
+ const message = err instanceof Error ? err.message : String(err);
920
+ console.error(`${prefixOf(config.name)} stopped — ${message}`);
921
+ return false;
922
+ })));
923
+ if (served.includes(false))
924
+ process.exit(1);
925
+ }
758
926
  main().catch((err) => {
759
927
  console.error(`[itsaplan-runner] ${err instanceof Error ? err.message : String(err)}`);
760
928
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsaplan/runner",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",