@itsaplan/runner 0.2.1 → 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.
- package/README.md +92 -43
- package/dist/cli.js +233 -65
- 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
|
|
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,
|
|
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
|
|
67
|
-
|
|
|
68
|
-
| `claude`
|
|
69
|
-
| `codex`
|
|
70
|
-
| `opencode`
|
|
71
|
-
| `
|
|
72
|
-
| `copilot`
|
|
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
|
|
79
|
-
arguments after `--` to the ones from the
|
|
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
|
-
| `
|
|
119
|
-
| `
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
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 `
|
|
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
|
|
169
|
-
|
|
|
170
|
-
| `ITSAPLAN_THREAD_ID`
|
|
171
|
-
| `ITSAPLAN_MESSAGE_ID`
|
|
172
|
-
| `ITSAPLAN_SESSION_ID`
|
|
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`
|
|
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
|
-
-
|
|
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;
|
|
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
|
-
|
|
309
|
-
bin: "
|
|
310
|
-
outputFormat: "
|
|
311
|
-
sessions: false,
|
|
377
|
+
antigravity: {
|
|
378
|
+
bin: "agy",
|
|
379
|
+
outputFormat: "antigravity-stream-json",
|
|
312
380
|
promptVia: "arg",
|
|
313
|
-
head: () => [
|
|
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: "
|
|
319
|
-
sessions: false,
|
|
393
|
+
outputFormat: "copilot-json",
|
|
320
394
|
promptVia: "arg",
|
|
321
|
-
head: () => [
|
|
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(
|
|
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 = [
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
421
|
-
|
|
545
|
+
name,
|
|
546
|
+
url: required(fields.url, "url").replace(/\/+$/, ""),
|
|
547
|
+
apiKey: required(fields.apiKey, "apiKey"),
|
|
422
548
|
agent,
|
|
423
549
|
command,
|
|
424
|
-
args: [...argsFrom(
|
|
425
|
-
cwd: (
|
|
426
|
-
env:
|
|
427
|
-
concurrency: intFrom(
|
|
428
|
-
pollIntervalMs: Math.max(intFrom(
|
|
429
|
-
timeoutMs: intFrom(
|
|
430
|
-
outputFormat: outputFormatFrom(
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
20
|
+
"antigravity",
|
|
21
21
|
"opencode"
|
|
22
22
|
],
|
|
23
23
|
"homepage": "https://github.com/croffasia/itsaplan/tree/main/packages/runner#readme",
|