@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.
- package/README.md +92 -43
- package/dist/cli.js +265 -77
- 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;
|
|
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
|
-
|
|
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,27 +499,90 @@ 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 }))
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
662
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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`);
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
20
|
+
"antigravity",
|
|
21
21
|
"opencode"
|
|
22
22
|
],
|
|
23
23
|
"homepage": "https://github.com/croffasia/itsaplan/tree/main/packages/runner#readme",
|