@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.
- package/LICENSE +1 -1
- package/README.md +152 -203
- package/dist/cli.js +598 -77
- 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
|
|
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
|
|
4
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
14
|
-
External. The key
|
|
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
|
-
**
|
|
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
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
```
|
|
128
|
-
|
|
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
|
-
|
|
111
|
+
The environment variables have priority over the file. The command-line options have
|
|
112
|
+
priority over both.
|
|
134
113
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
### The task text
|
|
151
138
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
+
### The environment
|
|
169
148
|
|
|
170
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
158
|
+
A queued run adds three more:
|
|
185
159
|
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
stays out of the file:
|
|
182
|
+
## Chat
|
|
222
183
|
|
|
223
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
your project. Its API key is what the runner authenticates with.
|
|
205
|
+
## Requirements
|
|
259
206
|
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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`)
|
|
276
|
-
`/mnt/c/...`.
|
|
277
|
-
- An instance
|
|
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
|
|
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/
|
|
7
|
-
var
|
|
6
|
+
// src/agui.ts
|
|
7
|
+
var FLUSH_CHARS = 1500;
|
|
8
|
+
var DELTA_LIMIT = 12000;
|
|
9
|
+
var TOOL_TEXT_LIMIT = 32000;
|
|
8
10
|
|
|
9
|
-
class
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
|
|
74
|
+
async fail(message, fallback = "") {
|
|
75
|
+
this.closeText(fallback);
|
|
76
|
+
this.queued.push({ type: "RUN_ERROR", message });
|
|
77
|
+
await this.flush();
|
|
44
78
|
}
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
437
|
+
function tail2(text, limit) {
|
|
103
438
|
return text.length <= limit ? text : `…${text.slice(-limit)}`;
|
|
104
439
|
}
|
|
105
|
-
function childEnv(config,
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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,
|
|
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 =
|
|
488
|
+
stdout = tail2(stdout + chunk, OUTPUT_LIMIT);
|
|
489
|
+
onData?.(chunk);
|
|
141
490
|
});
|
|
142
491
|
child.stderr.on("data", (chunk) => {
|
|
143
|
-
stderr =
|
|
492
|
+
stderr = tail2(stderr + chunk, ERROR_LIMIT);
|
|
144
493
|
});
|
|
145
494
|
child.stdin.on("error", () => {});
|
|
146
|
-
child.stdin.end(
|
|
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(
|
|
622
|
+
async function withHeartbeat(beat, work) {
|
|
176
623
|
const timer = setInterval(() => {
|
|
177
|
-
|
|
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
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
677
|
+
let item = null;
|
|
221
678
|
try {
|
|
222
|
-
|
|
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 (!
|
|
229
|
-
await
|
|
687
|
+
if (!item) {
|
|
688
|
+
done = !await onEmpty();
|
|
230
689
|
continue;
|
|
231
690
|
}
|
|
232
|
-
const 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
|
|
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
|
-
"
|
|
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"
|