@itsaplan/runner 0.1.0 → 0.2.1

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 +152 -203
  3. package/dist/cli.js +598 -77
  4. package/package.json +5 -3
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
@@ -1,267 +1,218 @@
1
1
  # @itsaplan/runner
2
2
 
3
- [It's a Plan](https://itsaplan.dev) is self-hosted, open-source project management and
4
- issue tracking where AI agents work like teammates: they have a role, permissions, and
5
- issues assigned to them.
3
+ [It's a Plan](https://itsaplan.dev) is a self-hosted, open-source issue tracker. An AI
4
+ agent in it is a project member: it has a role, permissions, and its own issues.
6
5
 
7
- This package runs the tasks of one such agent on your own machine. It takes one queued
8
- task at a time, passes it to a command you choose, and sends the result back. The
9
- command is yours: Claude Code, Codex, a shell script, anything that reads stdin.
6
+ This package runs one such agent on your own machine.
7
+
8
+ - Polls your instance for the agent's queued runs. Runs each one with a coding agent CLI.
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.
12
+ - Runs your own `command` instead, if you prefer. The command takes the task on stdin.
13
+ - Answers the agent's chat. Sends the text and the tool calls while the CLI prints them.
10
14
 
11
15
  ## Quick start
12
16
 
13
- **1. Get the agent's key.** In your project: Settings, AI agents, create one of kind
14
- External. The key is shown once, at creation.
17
+ **1. Get the agent's key.** In your project, go to Settings, AI agents. Create an agent
18
+ of kind External. The key appears one time only, at creation.
19
+
20
+ **2. Enable MCP** for the project, in Settings, MCP Server. MCP is off by default. The
21
+ agent reads the issue and writes its result through it.
15
22
 
16
- **2. Write `itsaplan-runner.json`** in the folder you want the agent to work in:
23
+ **3. Give the coding agent the address of that server.** Claude Code reads `.mcp.json`
24
+ from the folder it runs in. For the other four CLIs, see
25
+ [Coding agent setup](https://github.com/croffasia/itsaplan/blob/main/docs/runner.md).
17
26
 
18
27
  ```json
19
28
  {
20
- "url": "http://localhost:3000",
21
- "apiKey": "the key you just copied",
22
- "command": "claude -p"
29
+ "mcpServers": {
30
+ "itsaplan": {
31
+ "type": "http",
32
+ "url": "http://localhost:3000/mcp",
33
+ "headers": { "Authorization": "Bearer ${ITSAPLAN_API_KEY}" }
34
+ }
35
+ }
23
36
  }
24
37
  ```
25
38
 
26
- **3. Start it:**
27
-
28
- ```bash
29
- npx -y @itsaplan/runner
30
- ```
39
+ The runner sets `ITSAPLAN_API_KEY` for the command that it starts. The key thus stays in
40
+ one file.
31
41
 
32
- The agent goes online in the project within a few seconds. Delegate an issue to it or
33
- mention it in a comment, and the task lands in your terminal.
42
+ **4. Write `itsaplan-runner.json`** in the same folder:
34
43
 
35
- `Ctrl-C` stops the runner once the tasks in flight are done. Anything still queued
36
- waits for the next runner.
37
-
38
- ## Settings
39
-
40
- The runner reads `./itsaplan-runner.json`, or the path you give it as an argument or in
41
- `ITSAPLAN_RUNNER_CONFIG`:
42
-
43
- ```bash
44
- npx -y @itsaplan/runner /path/to/config.json
44
+ ```json
45
+ {
46
+ "url": "http://localhost:3000",
47
+ "apiKey": "the key you just copied",
48
+ "agent": "claude"
49
+ }
45
50
  ```
46
51
 
47
- Environment variables win over the file:
48
-
49
- | Field | Environment variable | Default | What it does |
50
- | ---------------- | --------------------------- | ------------------ | ----------------------------------- |
51
- | `url` | `ITSAPLAN_URL` | required | Your Itsaplan instance |
52
- | `apiKey` | `ITSAPLAN_API_KEY` | required | The external agent's key |
53
- | `command` | `ITSAPLAN_COMMAND` | required | The command that handles one task |
54
- | `cwd` | `ITSAPLAN_CWD` | where you start it | Working directory for the command |
55
- | `env` | | `{}` | Extra variables for the command |
56
- | `concurrency` | `ITSAPLAN_CONCURRENCY` | `1` | How many tasks may run at once |
57
- | `pollIntervalMs` | `ITSAPLAN_POLL_INTERVAL_MS` | `3000` | Wait after an empty queue, at least 1000 |
58
- | `timeoutMs` | `ITSAPLAN_TIMEOUT_MS` | `1800000` | When a task is killed as stuck |
59
-
60
- Which is how the key stays out of any file:
52
+ **5. Start it:**
61
53
 
62
54
  ```bash
63
- export ITSAPLAN_API_KEY=…
64
55
  npx -y @itsaplan/runner
65
56
  ```
66
57
 
67
- ## What your command receives
68
-
69
- The task text arrives on **stdin**. The rest of the run arrives in the environment:
58
+ The project shows the agent as online in a few seconds. Delegate an issue to the agent,
59
+ or mention it in a comment. The task then starts in your terminal.
70
60
 
71
- | Variable | What it holds |
72
- | ------------------------ | ------------------------------------------------------ |
73
- | `ITSAPLAN_URL` | the instance the task came from |
74
- | `ITSAPLAN_API_KEY` | the agent's key, for the API and the MCP server |
75
- | `ITSAPLAN_RUN_ID` | the run's id |
76
- | `ITSAPLAN_TRIGGER` | `mention`, `delegation`, `schedule`, or `manual` |
77
- | `ITSAPLAN_SYSTEM_PROMPT` | context about the run, for commands that take a system prompt |
78
- | `ITSAPLAN_ISSUE` | the issue key, e.g. `MKT-42` (empty when there is none) |
79
- | `ITSAPLAN_ISSUE_ID` | the issue's numeric id |
61
+ ## Which coding agent
80
62
 
81
- The task on stdin is written by the server: what happened, what to do, and to post the
82
- result as a comment on the issue. The agent reads everything else through the MCP
83
- server at `$ITSAPLAN_URL/mcp`, with `$ITSAPLAN_API_KEY` as a bearer token, acting as
84
- its own user under its role. Enable MCP in the project settings.
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.
85
65
 
86
- Exit code `0` is a success, anything else a failure with the tail of stderr as the
87
- error. Stdout becomes the run's output either way.
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 |
88
73
 
89
- ## Claude Code
74
+ Each preset needs the CLI's own MCP config.
75
+ [Coding agent setup](https://github.com/croffasia/itsaplan/blob/main/docs/runner.md) holds
76
+ that file, and the notes for each CLI.
90
77
 
91
- `mcp.json`, next to the runner config:
78
+ `--agent` on the command line has priority over the file. The runner appends the
79
+ arguments after `--` to the ones from the preset:
92
80
 
93
- ```json
94
- {
95
- "mcpServers": {
96
- "itsaplan": {
97
- "type": "http",
98
- "url": "http://localhost:3000/mcp",
99
- "headers": { "Authorization": "Bearer ${ITSAPLAN_API_KEY}" }
100
- }
101
- }
102
- }
81
+ ```bash
82
+ npx -y @itsaplan/runner --agent claude -- --model opus
103
83
  ```
104
84
 
105
- `itsaplan-runner.json`:
85
+ Write the same arguments in the file if a service manager starts the runner:
106
86
 
107
87
  ```json
108
88
  {
109
89
  "url": "http://localhost:3000",
110
90
  "apiKey": "the key you copied on creation",
111
- "command": "claude -p --mcp-config ./mcp.json --append-system-prompt \"$ITSAPLAN_SYSTEM_PROMPT\" --permission-mode auto --output-format text",
112
- "cwd": "/Users/me/work/my-repo"
91
+ "agent": "claude",
92
+ "cwd": "/Users/me/work/my-repo",
93
+ "args": ["--model", "opus"]
113
94
  }
114
95
  ```
115
96
 
116
- - `--mcp-config` adds this instance's server on top of what the machine already has,
117
- so your own MCP servers, skills, and memory stay in the run. Drop it if the server is
118
- already in the repository's `.mcp.json` or was added with `claude mcp add`.
119
- - `--permission-mode auto` covers permissions: nobody is there to answer a prompt, so a
120
- classifier reviews each action instead, MCP tool calls included.
97
+ Your arguments come after the preset's own. A repeated flag thus replaces its value.
121
98
 
122
- ## Codex
99
+ `command` replaces the preset with your own shell command. The command receives the task
100
+ on stdin. The runner keeps no session for it, because it does not know how to resume one.
123
101
 
124
- Codex reads its MCP servers from `~/.codex/config.toml`, and `bearer_token_env_var`
125
- takes the key from the environment the runner already sets:
102
+ ## Settings
103
+
104
+ The runner reads `./itsaplan-runner.json`. For a different file, give the path as an
105
+ argument or in `ITSAPLAN_RUNNER_CONFIG`:
126
106
 
127
- ```toml
128
- [mcp_servers.itsaplan]
129
- url = "http://localhost:3000/mcp"
130
- bearer_token_env_var = "ITSAPLAN_API_KEY"
107
+ ```bash
108
+ npx -y @itsaplan/runner /path/to/config.json
131
109
  ```
132
110
 
133
- `itsaplan-runner.json`:
111
+ The environment variables have priority over the file. The command-line options have
112
+ priority over both.
134
113
 
135
- ```json
136
- {
137
- "url": "http://localhost:3000",
138
- "apiKey": "the key you copied on creation",
139
- "command": "printf '%s\\n\\n%s' \"$ITSAPLAN_SYSTEM_PROMPT\" \"$(cat)\" | codex exec --sandbox workspace-write --skip-git-repo-check -",
140
- "cwd": "/path/to/working-dir"
141
- }
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 |
127
+
128
+ Set `agent` or `command`. With the environment you need no file at all:
129
+
130
+ ```bash
131
+ export ITSAPLAN_API_KEY=…
132
+ npx -y @itsaplan/runner --agent claude
142
133
  ```
143
134
 
144
- - Codex has no system-prompt flag, so `printf` puts the system prompt in front of the
145
- task, and the trailing `-` tells Codex to read all of it from stdin.
146
- - `--sandbox workspace-write` bounds what it may change: writes stay inside the working
147
- directory.
148
- - `--skip-git-repo-check` is only needed when that directory is not a git repository.
135
+ ## What the coding agent receives
149
136
 
150
- ## Gemini CLI
137
+ ### The task text
151
138
 
152
- `.gemini/settings.json` in the working directory. Gemini expands `$VARS` in it, so the
153
- key comes from the environment, and `trust` skips the tool confirmation a headless run
154
- cannot answer:
139
+ The server writes the task text. The text states what occurred, what to do, and to post
140
+ the result as a comment on the issue. Your own `command` receives the text on **stdin**. A
141
+ 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`.
155
143
 
156
- ```json
157
- {
158
- "mcpServers": {
159
- "itsaplan": {
160
- "httpUrl": "http://localhost:3000/mcp",
161
- "headers": { "Authorization": "Bearer $ITSAPLAN_API_KEY" },
162
- "trust": true
163
- }
164
- }
165
- }
166
- ```
144
+ The agent reads all other data through the MCP server at `$ITSAPLAN_URL/mcp`. The agent
145
+ sends `$ITSAPLAN_API_KEY` as a bearer token, and acts as its own user with its role.
167
146
 
168
- `itsaplan-runner.json`:
147
+ ### The environment
169
148
 
170
- ```json
171
- {
172
- "url": "http://localhost:3000",
173
- "apiKey": "the key you copied on creation",
174
- "command": "printf '%s\\n\\n%s' \"$ITSAPLAN_SYSTEM_PROMPT\" \"$(cat)\" | gemini --approval-mode yolo",
175
- "cwd": "/path/to/working-dir"
176
- }
177
- ```
149
+ Every run holds these variables:
178
150
 
179
- - Gemini has no system-prompt flag either, and reads the piped prompt on stdin, so the
180
- command is shaped like the Codex one.
181
- - `--approval-mode yolo` approves every tool call, which a headless run needs.
182
- `auto_edit` is the narrower option: file edits only.
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 |
183
157
 
184
- ## GitHub Copilot CLI
158
+ A queued run adds three more:
185
159
 
186
- `.mcp.json` in the working directory. Copilot's headers take no variable
187
- interpolation, so the key goes in as it stands:
160
+ | Variable | What it holds |
161
+ | ------------------- | --------------------------------------------------------------- |
162
+ | `ITSAPLAN_RUN_ID` | the run's id |
163
+ | `ITSAPLAN_ISSUE` | the issue key, for example `MKT-42`. Empty if there is no issue |
164
+ | `ITSAPLAN_ISSUE_ID` | the numeric id of the issue. Empty if there is no issue |
188
165
 
189
- ```json
190
- {
191
- "mcpServers": {
192
- "itsaplan": {
193
- "type": "http",
194
- "url": "http://localhost:3000/mcp",
195
- "headers": { "Authorization": "Bearer the-agent-key" },
196
- "tools": ["*"]
197
- }
198
- }
199
- }
200
- ```
166
+ A chat message adds three others in their place, and `ITSAPLAN_TRIGGER` is `chat`:
201
167
 
202
- `itsaplan-runner.json`:
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 |
203
173
 
204
- ```json
205
- {
206
- "url": "http://localhost:3000",
207
- "apiKey": "the key you copied on creation",
208
- "command": "copilot -p \"$(printf '%s\\n\\n%s' \"$ITSAPLAN_SYSTEM_PROMPT\" \"$(cat)\")\" --allow-all-tools --no-ask-user",
209
- "cwd": "/path/to/working-dir"
210
- }
211
- ```
174
+ The task text of a chat message includes the conversation, unless a session holds it. See
175
+ Sessions below.
212
176
 
213
- - Copilot takes neither a system-prompt flag nor a prompt on stdin, so both go in as
214
- one `-p` argument.
215
- - `--allow-all-tools` and `--no-ask-user` keep it from waiting for an approval it
216
- cannot get.
177
+ ### The result
217
178
 
218
- ## opencode
179
+ Exit code `0` is a success. Any other code is a failure. The end of stderr then becomes
180
+ the error. Stdout becomes the run's output in both cases.
219
181
 
220
- `opencode.json` in the working directory. opencode interpolates `{env:VAR}`, so the key
221
- stays out of the file:
182
+ ## Chat
222
183
 
223
- ```json
224
- {
225
- "$schema": "https://opencode.ai/config.json",
226
- "model": "anthropic/claude-sonnet-5",
227
- "mcp": {
228
- "itsaplan": {
229
- "type": "remote",
230
- "url": "http://localhost:3000/mcp",
231
- "enabled": true,
232
- "headers": { "Authorization": "Bearer {env:ITSAPLAN_API_KEY}" }
233
- }
234
- }
235
- }
236
- ```
184
+ The runner streams the answer to the chat while the command runs. It reports the tool
185
+ calls with the answer. `outputFormat` tells the runner how to read the output:
237
186
 
238
- `itsaplan-runner.json`:
187
+ - `text` — all the output is the answer.
188
+ - `claude-stream-json`, `codex-jsonl`, `opencode-json` — the event stream of that CLI.
239
189
 
240
- ```json
241
- {
242
- "url": "http://localhost:3000",
243
- "apiKey": "the key you copied on creation",
244
- "command": "opencode run \"$(printf '%s\\n\\n%s' \"$ITSAPLAN_SYSTEM_PROMPT\" \"$(cat)\")\"",
245
- "cwd": "/path/to/working-dir"
246
- }
247
- ```
190
+ A preset sets `outputFormat` for you. Set it yourself only with your own `command`. If the
191
+ output does not agree with the format, the runner sends the output as plain text.
248
192
 
249
- - `opencode run` takes the prompt as an argument and never reads stdin, so the task is
250
- passed the same way as with Copilot.
251
- - Pin `model` as `provider/model`. opencode is provider agnostic, and without that key
252
- a run falls back to whatever model was picked interactively last. `opencode auth
253
- login` is what makes a provider available in the first place.
193
+ ### Sessions
254
194
 
255
- ## Requirements
195
+ Each conversation resumes one session of the coding agent, which keeps its full context.
196
+ Without a session the server sends the last 20 messages of the conversation as plain text.
197
+
198
+ - The chat header shows the session id. Run `claude --resume <id>` in the runner's working
199
+ 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.
202
+ - A session stays on the machine that started it. If you delete its files or move the
203
+ runner, the answers fail. Start a new chat then.
256
204
 
257
- Node 20 or newer, an It's a Plan instance on 0.10.0 or newer, and an **external** agent in
258
- your project. Its API key is what the runner authenticates with.
205
+ ## Requirements
259
206
 
260
- The coding agent you point the runner at has to be installed and signed in on the same
261
- machine — the runner starts it as you and cannot sign in for you.
207
+ - **Node 20 or later.**
208
+ - **An It's a Plan instance 0.10.0 or later.** The chat needs 0.11.0. An older instance
209
+ gives the runner only the queued runs.
210
+ - **An external agent in your project.** The runner authenticates with its API key.
211
+ - **The coding agent, installed and signed in on the same machine.** The runner starts the
212
+ CLI as you, but it cannot sign in for you.
262
213
 
263
- On macOS and Linux there is nothing else to it. The runner hands every task to `sh -c`,
264
- so the commands in this file are POSIX shell.
214
+ macOS and Linux need nothing else. The runner starts a preset directly, so its arguments
215
+ need no quotes. Your own `command` goes to `sh -c`, so write it as POSIX shell.
265
216
 
266
217
  Windows needs WSL2, because `cmd.exe` and PowerShell have no `sh`:
267
218
 
@@ -269,16 +220,14 @@ Windows needs WSL2, because `cmd.exe` and PowerShell have no `sh`:
269
220
  wsl --install # installs Ubuntu, then reboot
270
221
  ```
271
222
 
272
- Everything after that happens inside WSL: Node, the coding agent, the config file, and
273
- `npx -y @itsaplan/runner`. Two things worth knowing there:
223
+ Do everything else in WSL: Node, the coding agent, the config file, and the runner.
274
224
 
275
- - Keep the working directory in the WSL file system (`~/work/repo`) rather than under
276
- `/mnt/c/...`. File access across that boundary is slow enough to notice on a repo.
277
- - An instance running on the Windows host is reachable from WSL2 as
278
- `http://localhost:3000`.
225
+ - Keep the working directory in the WSL file system (`~/work/repo`), not under
226
+ `/mnt/c/...`. Access between the two file systems is slow.
227
+ - An instance on the Windows host is available at `http://localhost:3000`.
279
228
 
280
229
  ## License
281
230
 
282
- Copyright © 2026 VIBE DEV SPACE LLC.
231
+ Copyright © 2026 Andrii Poluosmak.
283
232
 
284
233
  [Apache-2.0](LICENSE).
package/dist/cli.js CHANGED
@@ -3,52 +3,353 @@
3
3
  // src/cli.ts
4
4
  import { setTimeout as sleep } from "node:timers/promises";
5
5
 
6
- // src/client.ts
7
- var REQUEST_TIMEOUT_MS = 30000;
6
+ // src/agui.ts
7
+ var FLUSH_CHARS = 1500;
8
+ var DELTA_LIMIT = 12000;
9
+ var TOOL_TEXT_LIMIT = 32000;
8
10
 
9
- class RequestError extends Error {
10
- status;
11
- constructor(status, message) {
12
- super(message);
13
- this.status = status;
11
+ class AnswerStream {
12
+ format;
13
+ threadId;
14
+ runId;
15
+ send;
16
+ messageId;
17
+ queued = [];
18
+ text = "";
19
+ started = false;
20
+ line = "";
21
+ sessionId = null;
22
+ openTextPart = "";
23
+ openToolCalls = new Set;
24
+ closedToolCalls = new Set;
25
+ sawPartialText = false;
26
+ sawAnyText = false;
27
+ sending = Promise.resolve();
28
+ constructor(format, threadId, runId, send) {
29
+ this.format = format;
30
+ this.threadId = threadId;
31
+ this.runId = runId;
32
+ this.send = send;
33
+ this.messageId = `msg-${runId}`;
34
+ this.queued.push({ type: "RUN_STARTED", threadId, runId });
14
35
  }
15
- }
16
-
17
- class Client {
18
- config;
19
- constructor(config) {
20
- this.config = config;
36
+ write(chunk) {
37
+ if (this.format === "text") {
38
+ this.appendText(chunk);
39
+ return;
40
+ }
41
+ this.line += chunk;
42
+ const lines = this.line.split(`
43
+ `);
44
+ this.line = lines.pop() ?? "";
45
+ for (const line of lines)
46
+ this.readLine(line);
21
47
  }
22
- async post(path, body) {
23
- const res = await fetch(`${this.config.url}${path}`, {
24
- method: "POST",
25
- headers: {
26
- "x-api-key": this.config.apiKey,
27
- ...body === undefined ? {} : { "content-type": "application/json" }
28
- },
29
- body: body === undefined ? undefined : JSON.stringify(body),
30
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
31
- });
32
- if (!res.ok) {
33
- throw new RequestError(res.status, `POST ${path} failed with ${res.status}: ${(await res.text()).slice(0, 200)}`);
48
+ startedSession() {
49
+ return this.sessionId;
50
+ }
51
+ async flush() {
52
+ const sending = this.sending.catch(() => {}).then(() => this.sendQueued());
53
+ this.sending = sending.catch(() => {});
54
+ return sending;
55
+ }
56
+ async sendQueued() {
57
+ this.drainText();
58
+ if (this.queued.length === 0)
59
+ return;
60
+ const batch = this.queued;
61
+ this.queued = [];
62
+ try {
63
+ await this.send(batch);
64
+ } catch (err) {
65
+ this.queued.unshift(...batch);
66
+ throw err;
34
67
  }
35
- return res;
36
68
  }
37
- async claim() {
38
- const res = await this.post("/agent-runs/claim");
39
- const body = await res.json();
40
- return body.run;
69
+ async finish(fallback) {
70
+ this.closeText(fallback);
71
+ this.queued.push({ type: "RUN_FINISHED", threadId: this.threadId, runId: this.runId });
72
+ await this.flush();
41
73
  }
42
- async heartbeat(runId) {
43
- await this.post(`/agent-runs/${runId}/heartbeat`);
74
+ async fail(message, fallback = "") {
75
+ this.closeText(fallback);
76
+ this.queued.push({ type: "RUN_ERROR", message });
77
+ await this.flush();
44
78
  }
45
- async report(runId, result) {
46
- await this.post(`/agent-runs/${runId}/result`, result);
79
+ closeText(fallback) {
80
+ if (this.line) {
81
+ this.readLine(this.line);
82
+ this.line = "";
83
+ }
84
+ if (!this.sawAnyText && fallback)
85
+ this.appendText(fallback);
86
+ this.drainText();
87
+ if (this.started)
88
+ this.queued.push({ type: "TEXT_MESSAGE_END", messageId: this.messageId });
89
+ this.started = false;
90
+ }
91
+ appendText(text) {
92
+ if (!text)
93
+ return;
94
+ this.sawAnyText = true;
95
+ this.text += text;
96
+ if (this.text.length >= FLUSH_CHARS)
97
+ this.drainText();
98
+ }
99
+ drainText() {
100
+ if (this.text.length === 0)
101
+ return;
102
+ if (!this.started) {
103
+ this.started = true;
104
+ this.queued.push({
105
+ type: "TEXT_MESSAGE_START",
106
+ messageId: this.messageId,
107
+ role: "assistant"
108
+ });
109
+ }
110
+ while (this.text.length > 0) {
111
+ const delta = this.text.slice(0, DELTA_LIMIT);
112
+ this.text = this.text.slice(DELTA_LIMIT);
113
+ this.queued.push({ type: "TEXT_MESSAGE_CONTENT", messageId: this.messageId, delta });
114
+ }
115
+ }
116
+ readLine(line) {
117
+ const trimmed = line.trim();
118
+ if (!trimmed)
119
+ return;
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(trimmed);
123
+ } catch {
124
+ this.appendText(`${trimmed}
125
+ `);
126
+ return;
127
+ }
128
+ if (!parsed || typeof parsed !== "object")
129
+ return;
130
+ switch (this.format) {
131
+ case "claude-stream-json":
132
+ this.readClaudeLine(parsed);
133
+ break;
134
+ case "codex-jsonl":
135
+ this.readCodexLine(parsed);
136
+ break;
137
+ case "opencode-json":
138
+ this.readOpencodeLine(parsed);
139
+ break;
140
+ }
141
+ }
142
+ readCodexLine(message) {
143
+ if (message.type === "thread.started" && message.thread_id) {
144
+ this.sessionId ??= message.thread_id;
145
+ return;
146
+ }
147
+ const item = message.item;
148
+ if (message.type !== "item.completed" || !item)
149
+ return;
150
+ if (item.type === "agent_message" && item.text) {
151
+ this.appendText(item.text);
152
+ return;
153
+ }
154
+ if (item.type === "command_execution" && item.id) {
155
+ this.pushToolCall(item.id, "shell", item.command ?? "");
156
+ this.pushToolResult(item.id, item.aggregated_output ?? "");
157
+ }
158
+ }
159
+ readOpencodeLine(message) {
160
+ if (message.sessionID)
161
+ this.sessionId ??= message.sessionID;
162
+ const part = message.part;
163
+ if (!part)
164
+ return;
165
+ if (part.type === "text" && part.text) {
166
+ const grown = part.text.startsWith(this.openTextPart) ? part.text.slice(this.openTextPart.length) : part.text;
167
+ this.openTextPart = part.text;
168
+ this.appendText(grown);
169
+ return;
170
+ }
171
+ if (part.type !== "tool" || !part.callID)
172
+ return;
173
+ if (!this.openToolCalls.has(part.callID)) {
174
+ this.openToolCalls.add(part.callID);
175
+ this.pushToolCall(part.callID, part.tool ?? "tool", JSON.stringify(part.state?.input ?? {}));
176
+ }
177
+ if (this.closedToolCalls.has(part.callID))
178
+ return;
179
+ const state = part.state;
180
+ const result = state?.status === "completed" ? state.output : state?.status === "error" ? state.error : null;
181
+ if (typeof result !== "string")
182
+ return;
183
+ this.closedToolCalls.add(part.callID);
184
+ this.pushToolResult(part.callID, result);
185
+ }
186
+ readClaudeLine(message) {
187
+ if (message.session_id)
188
+ this.sessionId ??= message.session_id;
189
+ switch (message.type) {
190
+ case "stream_event":
191
+ this.readPartial(message.event);
192
+ break;
193
+ case "assistant":
194
+ this.readAssistant(message.message?.content ?? []);
195
+ break;
196
+ case "user":
197
+ this.readToolResults(message.message?.content ?? []);
198
+ break;
199
+ case "result":
200
+ if (!this.sawAnyText && typeof message.result === "string")
201
+ this.appendText(message.result);
202
+ break;
203
+ }
204
+ }
205
+ readPartial(event) {
206
+ if (event?.type !== "content_block_delta")
207
+ return;
208
+ if (event.delta?.type === "text_delta" && event.delta.text) {
209
+ this.sawPartialText = true;
210
+ this.appendText(event.delta.text);
211
+ }
212
+ }
213
+ readAssistant(content) {
214
+ for (const block of content) {
215
+ if (block.type === "text" && block.text && !this.sawPartialText) {
216
+ this.appendText(block.text);
217
+ }
218
+ if (block.type === "tool_use" && block.id) {
219
+ this.pushToolCall(block.id, block.name ?? "tool", JSON.stringify(block.input ?? {}));
220
+ }
221
+ }
222
+ }
223
+ readToolResults(content) {
224
+ for (const block of content) {
225
+ if (block.type !== "tool_result" || !block.tool_use_id)
226
+ continue;
227
+ this.pushToolResult(block.tool_use_id, textOfResult(block.content));
228
+ }
229
+ }
230
+ pushToolCall(toolCallId, toolCallName, args) {
231
+ this.drainText();
232
+ this.queued.push({ type: "TOOL_CALL_START", toolCallId, toolCallName, parentMessageId: this.messageId }, { type: "TOOL_CALL_ARGS", toolCallId, delta: tail(args, TOOL_TEXT_LIMIT) }, { type: "TOOL_CALL_END", toolCallId });
233
+ }
234
+ pushToolResult(toolCallId, content) {
235
+ this.drainText();
236
+ this.queued.push({
237
+ type: "TOOL_CALL_RESULT",
238
+ messageId: this.messageId,
239
+ toolCallId,
240
+ content: tail(content, TOOL_TEXT_LIMIT)
241
+ });
47
242
  }
48
243
  }
244
+ function tail(text, limit) {
245
+ return text.length <= limit ? text : `…${text.slice(-limit)}`;
246
+ }
247
+ function textOfResult(content) {
248
+ if (typeof content === "string")
249
+ return content;
250
+ if (Array.isArray(content)) {
251
+ return content.map((part) => typeof part === "object" && part && ("text" in part) ? String(part.text) : "").join("");
252
+ }
253
+ return "";
254
+ }
255
+
256
+ // src/execute.ts
257
+ import { spawn } from "node:child_process";
49
258
 
50
259
  // src/config.ts
51
260
  import { readFile } from "node:fs/promises";
261
+
262
+ // src/presets.ts
263
+ var PRESETS = {
264
+ claude: {
265
+ bin: "claude",
266
+ outputFormat: "claude-stream-json",
267
+ sessions: true,
268
+ promptVia: "stdin",
269
+ systemPromptFlag: "--append-system-prompt",
270
+ head: (sessionId) => [
271
+ "-p",
272
+ ...sessionId ? ["--resume", sessionId] : [],
273
+ "--output-format",
274
+ "stream-json",
275
+ "--include-partial-messages",
276
+ "--verbose",
277
+ "--permission-mode",
278
+ "auto"
279
+ ],
280
+ tail: []
281
+ },
282
+ codex: {
283
+ bin: "codex",
284
+ outputFormat: "codex-jsonl",
285
+ sessions: true,
286
+ promptVia: "stdin",
287
+ head: (sessionId) => [
288
+ ...sessionId ? ["exec", "resume", sessionId] : ["exec"],
289
+ "--json",
290
+ "-c",
291
+ 'sandbox_mode="workspace-write"'
292
+ ],
293
+ tail: ["-"]
294
+ },
295
+ opencode: {
296
+ bin: "opencode",
297
+ outputFormat: "opencode-json",
298
+ sessions: true,
299
+ promptVia: "arg",
300
+ head: (sessionId) => [
301
+ "run",
302
+ "--format",
303
+ "json",
304
+ ...sessionId ? ["--session", sessionId] : []
305
+ ],
306
+ tail: []
307
+ },
308
+ gemini: {
309
+ bin: "gemini",
310
+ outputFormat: "text",
311
+ sessions: false,
312
+ promptVia: "arg",
313
+ head: () => ["--approval-mode", "yolo"],
314
+ tail: ["-p"]
315
+ },
316
+ copilot: {
317
+ bin: "copilot",
318
+ outputFormat: "text",
319
+ sessions: false,
320
+ promptVia: "arg",
321
+ head: () => ["--allow-all-tools", "--no-ask-user"],
322
+ tail: ["-p"]
323
+ }
324
+ };
325
+ var PRESET_NAMES = Object.keys(PRESETS);
326
+ function isPresetName(value) {
327
+ return value in PRESETS;
328
+ }
329
+ function presetPrompt(preset, systemPrompt, prompt) {
330
+ if (preset.systemPromptFlag || !systemPrompt)
331
+ return prompt;
332
+ return `${systemPrompt}
333
+
334
+ ${prompt}`;
335
+ }
336
+ function presetArgv(preset, sessionId, systemPrompt, extraArgs, prompt) {
337
+ return [
338
+ ...preset.head(preset.sessions ? sessionId : null),
339
+ ...preset.systemPromptFlag && systemPrompt ? [preset.systemPromptFlag, systemPrompt] : [],
340
+ ...extraArgs,
341
+ ...preset.tail,
342
+ ...preset.promptVia === "arg" ? [presetPrompt(preset, systemPrompt, prompt)] : []
343
+ ];
344
+ }
345
+
346
+ // src/config.ts
347
+ var OUTPUT_FORMATS = ["text", "claude-stream-json", "codex-jsonl", "opencode-json"];
348
+ function presetOf(config) {
349
+ if (config.command || !config.agent)
350
+ return;
351
+ return PRESETS[config.agent];
352
+ }
52
353
  var DEFAULTS = {
53
354
  concurrency: 1,
54
355
  pollIntervalMs: 3000,
@@ -59,6 +360,33 @@ function intFrom(value, fallback) {
59
360
  const parsed = typeof value === "string" ? Number.parseInt(value, 10) : value;
60
361
  return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
61
362
  }
363
+ function outputFormatFrom(value, preset) {
364
+ const name = typeof value === "string" ? value.trim() : "";
365
+ if (!name)
366
+ return preset?.outputFormat ?? "text";
367
+ const format = OUTPUT_FORMATS.find((candidate) => candidate === name);
368
+ if (!format) {
369
+ throw new Error(`outputFormat must be one of ${OUTPUT_FORMATS.join(", ")}`);
370
+ }
371
+ return format;
372
+ }
373
+ function agentFrom(value) {
374
+ const name = typeof value === "string" ? value.trim() : "";
375
+ if (!name)
376
+ return;
377
+ if (!isPresetName(name)) {
378
+ throw new Error(`agent must be one of ${PRESET_NAMES.join(", ")}`);
379
+ }
380
+ return name;
381
+ }
382
+ function argsFrom(value) {
383
+ if (value === undefined)
384
+ return [];
385
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
386
+ throw new Error("args must be an array of strings");
387
+ }
388
+ return value;
389
+ }
62
390
  function required(value, field) {
63
391
  const text = typeof value === "string" ? value.trim() : "";
64
392
  if (!text)
@@ -80,39 +408,42 @@ async function readConfigFile(path) {
80
408
  throw new Error(`${path} is not valid JSON`);
81
409
  }
82
410
  }
83
- async function loadConfig(path) {
411
+ async function loadConfig(path, overrides = {}) {
84
412
  const raw = await readConfigFile(path);
85
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;
416
+ if (!agent && !command) {
417
+ throw new Error(`set either agent (one of ${PRESET_NAMES.join(", ")}) or command in the config file or the environment`);
418
+ }
86
419
  return {
87
420
  url: required(env.ITSAPLAN_URL ?? raw.url, "url").replace(/\/+$/, ""),
88
421
  apiKey: required(env.ITSAPLAN_API_KEY ?? raw.apiKey, "apiKey"),
89
- command: required(env.ITSAPLAN_COMMAND ?? raw.command, "command"),
422
+ agent,
423
+ command,
424
+ args: [...argsFrom(raw.args), ...overrides.args ?? []],
90
425
  cwd: (env.ITSAPLAN_CWD ?? raw.cwd)?.replace(/^~/, env.HOME ?? "~"),
91
426
  env: raw.env ?? {},
92
427
  concurrency: intFrom(env.ITSAPLAN_CONCURRENCY ?? raw.concurrency, DEFAULTS.concurrency),
93
428
  pollIntervalMs: Math.max(intFrom(env.ITSAPLAN_POLL_INTERVAL_MS ?? raw.pollIntervalMs, DEFAULTS.pollIntervalMs), MIN_POLL_INTERVAL_MS),
94
- timeoutMs: intFrom(env.ITSAPLAN_TIMEOUT_MS ?? raw.timeoutMs, DEFAULTS.timeoutMs)
429
+ timeoutMs: intFrom(env.ITSAPLAN_TIMEOUT_MS ?? raw.timeoutMs, DEFAULTS.timeoutMs),
430
+ outputFormat: outputFormatFrom(env.ITSAPLAN_OUTPUT_FORMAT ?? raw.outputFormat, presetOf({ agent, command }))
95
431
  };
96
432
  }
97
433
 
98
434
  // src/execute.ts
99
- import { spawn } from "node:child_process";
100
435
  var OUTPUT_LIMIT = 8000;
101
436
  var ERROR_LIMIT = 400;
102
- function tail(text, limit) {
437
+ function tail2(text, limit) {
103
438
  return text.length <= limit ? text : `…${text.slice(-limit)}`;
104
439
  }
105
- function childEnv(config, run) {
440
+ function childEnv(config, task) {
106
441
  return {
107
442
  ...process.env,
108
443
  ...config.env,
109
444
  ITSAPLAN_URL: config.url,
110
445
  ITSAPLAN_API_KEY: config.apiKey,
111
- ITSAPLAN_RUN_ID: String(run.id),
112
- ITSAPLAN_TRIGGER: run.trigger,
113
- ITSAPLAN_SYSTEM_PROMPT: run.systemPrompt,
114
- ITSAPLAN_ISSUE: run.issueIdentifier ?? "",
115
- ITSAPLAN_ISSUE_ID: run.issueId == null ? "" : String(run.issueId)
446
+ ...task.env
116
447
  };
117
448
  }
118
449
  function killGroup(pid) {
@@ -120,10 +451,27 @@ function killGroup(pid) {
120
451
  process.kill(-pid, "SIGKILL");
121
452
  } catch {}
122
453
  }
123
- async function execute(config, run) {
124
- const child = spawn("sh", ["-c", config.command], {
454
+ function spawnArgs(config, preset, task) {
455
+ if (!preset)
456
+ return ["sh", ["-c", config.command ?? ""]];
457
+ return [
458
+ preset.bin,
459
+ presetArgv(preset, task.sessionId ?? null, task.systemPrompt, config.args, task.prompt)
460
+ ];
461
+ }
462
+ function stdinText(preset, task) {
463
+ if (!preset)
464
+ return task.prompt;
465
+ if (preset.promptVia === "arg")
466
+ return "";
467
+ return presetPrompt(preset, task.systemPrompt, task.prompt);
468
+ }
469
+ async function execute(config, task, onData) {
470
+ const preset = presetOf(config);
471
+ const [bin, args] = spawnArgs(config, preset, task);
472
+ const child = spawn(bin, args, {
125
473
  cwd: config.cwd,
126
- env: childEnv(config, run),
474
+ env: childEnv(config, task),
127
475
  detached: true
128
476
  });
129
477
  let timedOut = false;
@@ -137,13 +485,14 @@ async function execute(config, run) {
137
485
  child.stdout.setEncoding("utf8");
138
486
  child.stderr.setEncoding("utf8");
139
487
  child.stdout.on("data", (chunk) => {
140
- stdout = tail(stdout + chunk, OUTPUT_LIMIT);
488
+ stdout = tail2(stdout + chunk, OUTPUT_LIMIT);
489
+ onData?.(chunk);
141
490
  });
142
491
  child.stderr.on("data", (chunk) => {
143
- stderr = tail(stderr + chunk, ERROR_LIMIT);
492
+ stderr = tail2(stderr + chunk, ERROR_LIMIT);
144
493
  });
145
494
  child.stdin.on("error", () => {});
146
- child.stdin.end(run.prompt);
495
+ child.stdin.end(stdinText(preset, task));
147
496
  const exited = new Promise((resolve, reject) => {
148
497
  child.on("error", reject);
149
498
  child.on("close", (exitCode, exitSignal) => resolve({ code: exitCode, signal: exitSignal }));
@@ -167,14 +516,112 @@ async function execute(config, run) {
167
516
  };
168
517
  }
169
518
 
519
+ // src/chat.ts
520
+ var FLUSH_MS = 500;
521
+ async function answer(config, client, message) {
522
+ let reported = message.sessionId !== null;
523
+ const stream = new AnswerStream(config.outputFormat, message.threadId, String(message.id), (events) => {
524
+ const started = reported ? undefined : stream.startedSession() ?? undefined;
525
+ if (started)
526
+ reported = true;
527
+ return client.chatEvents(message.id, events, started);
528
+ });
529
+ const flushing = setInterval(() => {
530
+ stream.flush().catch(() => {});
531
+ }, FLUSH_MS);
532
+ const outcome = await execute(config, {
533
+ prompt: message.prompt,
534
+ systemPrompt: message.systemPrompt,
535
+ sessionId: message.sessionId,
536
+ env: {
537
+ ITSAPLAN_TRIGGER: "chat",
538
+ ITSAPLAN_SYSTEM_PROMPT: message.systemPrompt,
539
+ ITSAPLAN_THREAD_ID: message.threadId,
540
+ ITSAPLAN_MESSAGE_ID: String(message.id),
541
+ ITSAPLAN_SESSION_ID: message.sessionId ?? ""
542
+ }
543
+ }, (chunk) => stream.write(chunk)).finally(() => clearInterval(flushing));
544
+ if (outcome.status === "success") {
545
+ await stream.finish(outcome.output);
546
+ await client.chatResult(message.id, { status: "success" });
547
+ return;
548
+ }
549
+ const error = outcome.error ?? "The command failed";
550
+ await stream.fail(error, outcome.output);
551
+ await client.chatResult(message.id, { status: "failed", error });
552
+ }
553
+
554
+ // src/client.ts
555
+ var REQUEST_TIMEOUT_MS = 30000;
556
+ var CHAT_CLAIM_TIMEOUT_MS = 35000;
557
+
558
+ class RequestError extends Error {
559
+ status;
560
+ constructor(status, message) {
561
+ super(message);
562
+ this.status = status;
563
+ }
564
+ }
565
+
566
+ class Client {
567
+ config;
568
+ constructor(config) {
569
+ this.config = config;
570
+ }
571
+ async post(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
572
+ const res = await fetch(`${this.config.url}${path}`, {
573
+ method: "POST",
574
+ headers: {
575
+ "x-api-key": this.config.apiKey,
576
+ ...body === undefined ? {} : { "content-type": "application/json" }
577
+ },
578
+ body: body === undefined ? undefined : JSON.stringify(body),
579
+ signal: AbortSignal.timeout(timeoutMs)
580
+ });
581
+ if (!res.ok) {
582
+ throw new RequestError(res.status, `POST ${path} failed with ${res.status}: ${(await res.text()).slice(0, 200)}`);
583
+ }
584
+ return res;
585
+ }
586
+ async claim() {
587
+ const res = await this.post("/agent-runs/claim");
588
+ const body = await res.json();
589
+ return body.run;
590
+ }
591
+ async heartbeat(runId) {
592
+ await this.post(`/agent-runs/${runId}/heartbeat`);
593
+ }
594
+ async report(runId, result) {
595
+ await this.post(`/agent-runs/${runId}/result`, result);
596
+ }
597
+ async claimChat() {
598
+ const res = await this.post("/agent-chats/claim", undefined, CHAT_CLAIM_TIMEOUT_MS);
599
+ const body = await res.json();
600
+ return body.message;
601
+ }
602
+ async chatEvents(messageId, events, sessionId) {
603
+ await this.post(`/agent-chats/${messageId}/events`, {
604
+ events,
605
+ ...sessionId && { sessionId }
606
+ });
607
+ }
608
+ async chatHeartbeat(messageId) {
609
+ await this.post(`/agent-chats/${messageId}/heartbeat`);
610
+ }
611
+ async chatResult(messageId, result) {
612
+ await this.post(`/agent-chats/${messageId}/result`, result);
613
+ }
614
+ }
615
+
170
616
  // src/cli.ts
171
617
  var HEARTBEAT_MS = 60000;
618
+ var ERROR_BACKOFF_MS = 5000;
172
619
  function log(message) {
173
620
  console.log(`[itsaplan-runner] ${message}`);
174
621
  }
175
- async function withHeartbeat(client, runId, work) {
622
+ async function withHeartbeat(beat, work) {
176
623
  const timer = setInterval(() => {
177
- client.heartbeat(runId).catch((err) => log(`heartbeat failed: ${String(err)}`));
624
+ beat().catch((err) => log(`heartbeat failed: ${String(err)}`));
178
625
  }, HEARTBEAT_MS);
179
626
  try {
180
627
  return await work;
@@ -182,11 +629,24 @@ async function withHeartbeat(client, runId, work) {
182
629
  clearInterval(timer);
183
630
  }
184
631
  }
632
+ function taskOf(run) {
633
+ return {
634
+ prompt: run.prompt,
635
+ systemPrompt: run.systemPrompt,
636
+ env: {
637
+ ITSAPLAN_RUN_ID: String(run.id),
638
+ ITSAPLAN_TRIGGER: run.trigger,
639
+ ITSAPLAN_SYSTEM_PROMPT: run.systemPrompt,
640
+ ITSAPLAN_ISSUE: run.issueIdentifier ?? "",
641
+ ITSAPLAN_ISSUE_ID: run.issueId == null ? "" : String(run.issueId)
642
+ }
643
+ };
644
+ }
185
645
  async function handle(config, client, run) {
186
646
  const label = run.issueIdentifier ?? `run ${run.id}`;
187
647
  log(`${label}: started (${run.trigger})`);
188
648
  try {
189
- const outcome = await withHeartbeat(client, run.id, execute(config, run));
649
+ const outcome = await withHeartbeat(() => client.heartbeat(run.id), execute(config, taskOf(run)));
190
650
  await client.report(run.id, outcome);
191
651
  log(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
192
652
  } catch (err) {
@@ -195,45 +655,106 @@ async function handle(config, client, run) {
195
655
  await client.report(run.id, { status: "failed", error: message }).catch(() => {});
196
656
  }
197
657
  }
198
- async function main() {
199
- const configPath = process.argv[2] ?? process.env.ITSAPLAN_RUNNER_CONFIG ?? "./itsaplan-runner.json";
200
- const config = await loadConfig(configPath);
201
- const client = new Client(config);
202
- const active = new Set;
203
- let stopping = false;
204
- for (const signal of ["SIGINT", "SIGTERM"]) {
205
- process.on(signal, () => {
206
- if (stopping) {
207
- log("quitting now — the commands in flight keep running, their runs are retried");
208
- process.exit(1);
209
- }
210
- stopping = true;
211
- log("stopping — finishing the tasks in flight, press again to quit now");
212
- });
658
+ async function handleChat(config, client, message) {
659
+ log(`chat ${message.id}: answering`);
660
+ try {
661
+ await withHeartbeat(() => client.chatHeartbeat(message.id), answer(config, client, message));
662
+ log(`chat ${message.id}: answered`);
663
+ } catch (err) {
664
+ const text = err instanceof Error ? err.message : String(err);
665
+ log(`chat ${message.id}: runner error — ${text}`);
666
+ await client.chatResult(message.id, { status: "failed", error: text }).catch(() => {});
213
667
  }
214
- log(`polling ${config.url} every ${config.pollIntervalMs}ms, up to ${config.concurrency} at once`);
215
- while (!stopping) {
216
- if (active.size >= config.concurrency) {
668
+ }
669
+ async function drain(state, concurrency, take, run, onEmpty) {
670
+ const active = new Set;
671
+ let done = false;
672
+ while (!state.stopping && !done) {
673
+ if (active.size >= concurrency) {
217
674
  await Promise.race(active);
218
675
  continue;
219
676
  }
220
- let run = null;
677
+ let item = null;
221
678
  try {
222
- run = await client.claim();
679
+ item = await take();
223
680
  } catch (err) {
224
681
  if (err instanceof RequestError && (err.status === 401 || err.status === 403))
225
682
  throw err;
226
683
  log(`claim failed: ${String(err)}`);
684
+ await sleep(ERROR_BACKOFF_MS);
685
+ continue;
227
686
  }
228
- if (!run) {
229
- await sleep(config.pollIntervalMs);
687
+ if (!item) {
688
+ done = !await onEmpty();
230
689
  continue;
231
690
  }
232
- const task = handle(config, client, run).finally(() => active.delete(task));
691
+ const task = run(item).finally(() => active.delete(task));
233
692
  active.add(task);
234
693
  }
235
694
  await Promise.all(active);
236
695
  }
696
+ function parseArgv(argv) {
697
+ const parsed = { args: [] };
698
+ for (let i = 0;i < argv.length; i++) {
699
+ const arg = argv[i];
700
+ if (arg === "--") {
701
+ parsed.args = argv.slice(i + 1);
702
+ break;
703
+ }
704
+ if (arg === "--agent") {
705
+ const value = argv[++i];
706
+ if (value === undefined)
707
+ throw new Error("--agent needs a value");
708
+ parsed.agent = value;
709
+ continue;
710
+ }
711
+ if (arg.startsWith("--agent=")) {
712
+ parsed.agent = arg.slice("--agent=".length);
713
+ continue;
714
+ }
715
+ if (arg.startsWith("-"))
716
+ throw new Error(`unknown option ${arg}`);
717
+ parsed.configPath ??= arg;
718
+ }
719
+ return parsed;
720
+ }
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 });
725
+ 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`);
738
+ let chatSupported = true;
739
+ await Promise.all([
740
+ drain(state, config.concurrency, () => client.claim(), (run) => handle(config, client, run), async () => {
741
+ await sleep(config.pollIntervalMs);
742
+ return true;
743
+ }),
744
+ drain(state, config.concurrency, async () => {
745
+ try {
746
+ return await client.claimChat();
747
+ } catch (err) {
748
+ if (err instanceof RequestError && err.status === 404) {
749
+ log("this instance has no chat feed — only queued runs will be answered");
750
+ chatSupported = false;
751
+ return null;
752
+ }
753
+ throw err;
754
+ }
755
+ }, (message) => handleChat(config, client, message), () => Promise.resolve(chatSupported))
756
+ ]);
757
+ }
237
758
  main().catch((err) => {
238
759
  console.error(`[itsaplan-runner] ${err instanceof Error ? err.message : String(err)}`);
239
760
  process.exit(1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@itsaplan/runner",
3
- "version": "0.1.0",
4
- "description": "Runs an Itsaplan external agent's queued tasks on your own machine",
3
+ "version": "0.2.1",
4
+ "description": "Runs an Itsaplan external agent's queued tasks and answers its chat on your own machine",
5
5
  "keywords": [
6
6
  "itsaplan",
7
7
  "ai-agents",
@@ -45,10 +45,12 @@
45
45
  "build": "bun build src/cli.ts --target=node --outfile dist/cli.js",
46
46
  "prepublishOnly": "bun run build",
47
47
  "lint": "eslint .",
48
- "typecheck": "tsc --noEmit"
48
+ "test": "bun test",
49
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@repo/eslint-config": "workspace:*",
53
+ "@types/bun": "latest",
52
54
  "@types/node": "^26.2.0",
53
55
  "eslint": "^9.39.0",
54
56
  "typescript": "^6.0.3"