@itsaplan/runner 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +86 -66
  2. package/dist/cli.js +196 -32
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -8,9 +8,11 @@ This package runs such agents on your own machine — one, or several at once.
8
8
  - Polls your instance for the agent's queued runs. Runs each one with a coding agent CLI.
9
9
  Reports the result.
10
10
  - Has presets for Claude Code, Codex, opencode, Antigravity CLI, GitHub Copilot CLI,
11
- Pi, and Oh My Pi (`omp`). Each preset sets the unattended flags and the session resume.
11
+ Pi, Oh My Pi (`omp`), and Mastra Code. Each preset sets the unattended flags and the
12
+ session resume.
12
13
  - Runs your own `command` instead, if you prefer. The command takes the task on stdin.
13
14
  - Answers the agent's chat. Sends the text and the tool calls while the CLI prints them.
15
+ - Sends the messages and the tool calls of a queued run to the issue while the run works.
14
16
  - Receives the tasks on a port instead of polling, for an agent whose Connection is
15
17
  Endpoint. See [Receiving tasks on a port](#receiving-tasks-on-a-port).
16
18
 
@@ -22,9 +24,9 @@ of kind External. The key appears one time only, at creation.
22
24
  **2. Enable MCP** for the project, in Settings, MCP Server. MCP is off by default. The
23
25
  agent reads the issue and writes its result through it.
24
26
 
25
- **3. Give the coding agent the address of that server.** Claude Code, Oh My Pi, and Pi
26
- (through `pi-mcp-adapter`) read `.mcp.json` from the folder they run in. For the other
27
- CLIs, see
27
+ **3. Give the coding agent the address of that server.** Claude Code, Oh My Pi, Mastra
28
+ Code, and Pi (through `pi-mcp-adapter`) read `.mcp.json` from the folder they run in. For
29
+ the other CLIs, see
28
30
  [Coding agent setup](https://github.com/croffasia/itsaplan/blob/main/docs/runner.md).
29
31
 
30
32
  ```json
@@ -66,15 +68,16 @@ or mention it in a comment. The task then starts in your terminal.
66
68
  `agent` names a CLI that the runner knows. The runner then builds the command itself: the
67
69
  flags for an unattended run, and the session resume. Every preset keeps a chat session.
68
70
 
69
- | Agent | Runs |
70
- | ------------- | ------------------ |
71
- | `claude` | Claude Code |
72
- | `codex` | Codex CLI |
73
- | `opencode` | opencode |
74
- | `antigravity` | Antigravity CLI |
75
- | `copilot` | GitHub Copilot CLI |
76
- | `pi` | Pi |
77
- | `omp` | Oh My Pi |
71
+ | Agent | Runs |
72
+ | ------------- | ---------------------------------------------------------------------------- |
73
+ | `claude` | [Claude Code](https://code.claude.com/docs/en/overview) |
74
+ | `codex` | [Codex CLI](https://github.com/openai/codex) |
75
+ | `opencode` | [opencode](https://opencode.ai) |
76
+ | `antigravity` | [Antigravity CLI](https://antigravity.google/docs/getting-started?tab=cli) |
77
+ | `copilot` | [GitHub Copilot CLI](https://github.com/github/copilot-cli) |
78
+ | `pi` | [Pi](https://pi.dev) |
79
+ | `omp` | [Oh My Pi](https://omp.sh) |
80
+ | `mastracode` | [Mastra Code](https://code.mastra.ai) |
78
81
 
79
82
  Each preset needs the CLI's own MCP config.
80
83
  [Coding agent setup](https://github.com/croffasia/itsaplan/blob/main/docs/runner.md) holds
@@ -107,9 +110,8 @@ on stdin. The runner keeps no session for it, because it does not know how to re
107
110
 
108
111
  ## Several agents on one runner
109
112
 
110
- One key is one agent. A runner set up the way you want it can serve several of them at
111
- once: it polls for each key on its own, and every agent uses the coding agent, the working
112
- directory and the arguments the file already gives.
113
+ One key is one agent. One runner can serve several agents. It polls for each key
114
+ separately, and every agent uses the `agent`, `cwd` and `args` from the file.
113
115
 
114
116
  ```json
115
117
  {
@@ -120,13 +122,13 @@ directory and the arguments the file already gives.
120
122
  }
121
123
  ```
122
124
 
123
- Nothing else changes: the runner gives each command its own `ITSAPLAN_API_KEY`, so the one
124
- MCP config in that folder works for all of them.
125
+ The runner gives each command its own `ITSAPLAN_API_KEY`, so one MCP config in that
126
+ folder works for all agents.
125
127
 
126
- Use `agents` instead of `apiKeys` where an agent needs something of its own. A field an
127
- entry sets replaces the shared one, and it inherits every field it leaves out, except its
128
- `apiKey`, which every entry sets itself. `name` is what the runner's log lines call that
129
- agent; without it they number the agents `#1`, `#2` in the order of the file.
128
+ Use `agents` instead of `apiKeys` when an agent needs its own settings. Each entry sets
129
+ its own `apiKey`. The other fields that an entry sets replace the shared ones, and the
130
+ entry inherits the rest. `name` is the agent's label in the log. Without it, the log
131
+ numbers the agents `#1`, `#2` in the order of the file.
130
132
 
131
133
  ```json
132
134
  {
@@ -144,12 +146,11 @@ agent; without it they number the agents `#1`, `#2` in the order of the file.
144
146
  }
145
147
  ```
146
148
 
147
- `concurrency` counts per agent and per feed, so a runner with three agents at
148
- `concurrency: 2` can have six queued runs in flight and six chat answers alongside them,
149
- each one a coding agent CLI of its own. The environment variables and the command-line
150
- options describe every agent at once; an entry has priority over them.
149
+ `concurrency` counts per agent and per feed. A runner with three agents at
150
+ `concurrency: 2` can run six queued runs and six chat answers at once, each in its own
151
+ CLI process.
151
152
 
152
- An agent whose key the instance refuses stops on its own, and the others keep working.
153
+ If the instance refuses the key of an agent, that agent stops. The other agents continue.
153
154
 
154
155
  ## Receiving tasks on a port
155
156
 
@@ -157,8 +158,8 @@ By default the runner polls the instance for tasks. With `listen`, the instance
157
158
  task to the runner when the task is created. The runner then does not poll.
158
159
 
159
160
  Use `listen` for a runner on a server that the instance can reach. On a laptop behind a
160
- router, use polling. Polling needs no setup, and the chat is equally fast with it: a chat
161
- claim waits on the server until a message comes.
161
+ router, use polling. Polling needs no setup. The chat is as fast with polling, because a
162
+ chat claim waits on the server until a message comes.
162
163
 
163
164
  **1.** In the agent's settings, open Connection and select Endpoint. Enter the public
164
165
  HTTPS address of the runner's port (see [Reaching the port](#reaching-the-port)), and
@@ -243,23 +244,23 @@ npx -y @itsaplan/runner /path/to/config.json
243
244
  The environment variables have priority over the file. The command-line options have
244
245
  priority over both. An `agents` entry has priority over all three, for the fields it sets.
245
246
 
246
- | Field | Environment variable | Default | What it does |
247
- | ---------------- | --------------------------- | ------------------ | ------------------------------------------------------------------ |
248
- | `url` | `ITSAPLAN_URL` | required | Your Itsaplan instance |
249
- | `apiKey` | `ITSAPLAN_API_KEY` | required | The external agent's key. Replaced by `apiKeys` or `agents` |
250
- | `apiKeys` | | — | Several keys, all with these settings. Not with `agents` |
247
+ | Field | Environment variable | Default | What it does |
248
+ | ---------------- | --------------------------- | ------------------ | ---------------------------------------------------------------------- |
249
+ | `url` | `ITSAPLAN_URL` | required | Your Itsaplan instance |
250
+ | `apiKey` | `ITSAPLAN_API_KEY` | required | The external agent's key. Replaced by `apiKeys` or `agents` |
251
+ | `apiKeys` | | — | Several keys, all with these settings. Not with `agents` |
251
252
  | `agents` | | — | Several agents, each with its own key and settings. Not with `apiKeys` |
252
- | `agent` | `ITSAPLAN_AGENT` | — | The coding agent to run |
253
- | `args` | | `[]` | Arguments added after the preset's |
254
- | `command` | `ITSAPLAN_COMMAND` | — | Your own command, instead of `agent` |
255
- | `cwd` | `ITSAPLAN_CWD` | where you start it | Working directory for the command |
256
- | `env` | | `{}` | Extra variables for the command |
257
- | `concurrency` | `ITSAPLAN_CONCURRENCY` | `1` | Tasks at once, per agent. Queued runs and chat answers count apart |
258
- | `pollIntervalMs` | `ITSAPLAN_POLL_INTERVAL_MS` | `3000` | Wait after an empty queue. Minimum 1000 |
259
- | `timeoutMs` | `ITSAPLAN_TIMEOUT_MS` | `1800000` | Time before the runner stops a task |
260
- | `outputFormat` | `ITSAPLAN_OUTPUT_FORMAT` | the preset's | How the runner reads a chat answer |
261
- | `listen` | `ITSAPLAN_LISTEN` | — | Port that receives the tasks. The runner does not poll for the agent |
262
- | `signingSecret` | `ITSAPLAN_SIGNING_SECRET` | — | The agent's signing secret. Required with `listen` |
253
+ | `agent` | `ITSAPLAN_AGENT` | — | The coding agent to run |
254
+ | `args` | | `[]` | Arguments added after the preset's |
255
+ | `command` | `ITSAPLAN_COMMAND` | — | Your own command, instead of `agent` |
256
+ | `cwd` | `ITSAPLAN_CWD` | where you start it | Working directory for the command |
257
+ | `env` | | `{}` | Extra variables for the command |
258
+ | `concurrency` | `ITSAPLAN_CONCURRENCY` | `1` | Tasks at once, per agent. Queued runs and chat answers count apart |
259
+ | `pollIntervalMs` | `ITSAPLAN_POLL_INTERVAL_MS` | `3000` | Wait after an empty queue. Minimum 1000 |
260
+ | `timeoutMs` | `ITSAPLAN_TIMEOUT_MS` | `1800000` | Time before the runner stops a task |
261
+ | `outputFormat` | `ITSAPLAN_OUTPUT_FORMAT` | the preset's | How the runner reads the output. See [The output](#the-output) |
262
+ | `listen` | `ITSAPLAN_LISTEN` | — | Port that receives the tasks. The runner does not poll for the agent |
263
+ | `signingSecret` | `ITSAPLAN_SIGNING_SECRET` | — | The agent's signing secret. Required with `listen` |
263
264
 
264
265
  Set `agent` or `command`. With the environment you need no file at all:
265
266
 
@@ -274,8 +275,8 @@ npx -y @itsaplan/runner --agent claude
274
275
 
275
276
  The server writes the task text. The text states what occurred, what to do, and to post
276
277
  the result as a comment on the issue. Your own `command` receives the text on **stdin**. A
277
- preset gives it to its CLI in the way that CLI accepts it: on stdin for `claude` and
278
- `codex`, as an argument for `antigravity`, `copilot`, `opencode`, `pi` and `omp`.
278
+ preset gives it to its CLI in the way that CLI accepts it: on stdin for `claude`, `codex`,
279
+ `pi` and `mastracode`, as an argument for `antigravity`, `copilot`, `opencode` and `omp`.
279
280
 
280
281
  The agent reads all other data through the MCP server at `$ITSAPLAN_URL/mcp`. The agent
281
282
  sends `$ITSAPLAN_API_KEY` as a bearer token, and acts as its own user with its role.
@@ -290,12 +291,11 @@ Every run holds these variables:
290
291
  | `ITSAPLAN_API_KEY` | the agent's key, for the API and the MCP server |
291
292
  | `ITSAPLAN_TRIGGER` | `mention`, `delegation`, `field`, `schedule`, `manual`, `status`, or `chat` |
292
293
  | `ITSAPLAN_SYSTEM_PROMPT` | the context of the run, for a command that takes a system prompt |
293
- | `ITSAPLAN_TIMEOUT_MS` | the effective task timeout in milliseconds, including per-agent overrides |
294
+ | `ITSAPLAN_TIMEOUT_MS` | the task timeout in milliseconds |
294
295
 
295
- Custom commands can read `ITSAPLAN_TIMEOUT_MS` to configure their own execution timeout.
296
- The runner supplies the resolved `timeoutMs`, overriding any inherited value or value in
297
- `env`. Its timer starts when the command is spawned, so startup and waiting inside the
298
- command count toward the same limit.
296
+ A command can read `ITSAPLAN_TIMEOUT_MS` to set its own timeout. The value is the
297
+ `timeoutMs` of that agent, and it replaces a value from the environment or from `env`.
298
+ The time starts when the command starts.
299
299
 
300
300
  A queued run adds three more:
301
301
 
@@ -314,25 +314,46 @@ A chat message adds three others in their place, and `ITSAPLAN_TRIGGER` is `chat
314
314
  | `ITSAPLAN_SESSION_ID` | the coding agent's session to resume. Empty for the first message |
315
315
 
316
316
  The task text of a chat message includes the conversation, unless a session holds it. See
317
- Sessions below.
317
+ [Sessions](#sessions).
318
318
 
319
- ### The result
319
+ ## The output
320
320
 
321
- Exit code `0` is a success. Any other code is a failure. The end of stderr then becomes
322
- the error. Stdout becomes the run's output in both cases.
321
+ Exit code `0` is a success. Any other code is a failure, and the end of stderr becomes the
322
+ error.
323
323
 
324
- ## Chat
325
-
326
- The runner streams the answer to the chat while the command runs. It reports the tool
327
- calls with the answer. `outputFormat` tells the runner how to read the output:
324
+ `outputFormat` tells the runner how to read stdout:
328
325
 
329
- - `text` — all the output is the answer.
326
+ - `text` — all of stdout is the answer.
330
327
  - `claude-stream-json`, `codex-jsonl`, `opencode-json`, `antigravity-stream-json`,
331
- `copilot-json`, `pi-json` — the event stream of that CLI. `pi` and `omp` both use
332
- `pi-json`.
328
+ `copilot-json`, `pi-json`, `mastracode-jsonl` — the event stream of that CLI. `pi` and
329
+ `omp` both use `pi-json`. The runner reads the text and the tool calls from it.
333
330
 
334
331
  A preset sets `outputFormat` for you. Set it yourself only with your own `command`. If the
335
- output does not agree with the format, the runner sends the output as plain text.
332
+ output does not agree with the format, the runner uses the output as plain text.
333
+
334
+ A queued run reports one answer as its output. With an event stream format, the answer is
335
+ the text that the agent wrote after its last tool call.
336
+
337
+ ### Run transcripts
338
+
339
+ While a queued run works, the runner sends the agent's messages and tool calls to the
340
+ instance. The issue shows them as the run's transcript.
341
+
342
+ - The tool calls need an event stream format. With `text`, the transcript holds only the
343
+ output.
344
+ - With `codex-jsonl`, the run shows an **Open in Codex** link. The link opens the session
345
+ only where that session is: in the Codex app of the viewer, or on a connected host. The
346
+ link does not copy the session.
347
+ - The instance keeps up to 2,000 events or 1 MB for each attempt, and marks a transcript
348
+ that it cut. The cut does not change the result of the run.
349
+ - If the last upload fails with a network or server error, the runner tries it one more
350
+ time. Then it reports the result of the command, even with an incomplete transcript.
351
+ Any other refusal fails the run.
352
+
353
+ ## Chat
354
+
355
+ The runner streams the answer to the chat while the command runs. It sends the tool calls
356
+ with the answer.
336
357
 
337
358
  ### Sessions
338
359
 
@@ -348,8 +369,7 @@ Without a session the server sends the last 20 messages of the conversation as p
348
369
  ## Requirements
349
370
 
350
371
  - **Node 20 or later.**
351
- - **An It's a Plan instance 0.10.0 or later.** The chat needs 0.11.0. An older instance
352
- gives the runner only the queued runs. `listen` needs 1.6.0.
372
+ - **An It's a Plan instance 1.7.0 or later.**
353
373
  - **An external agent in your project.** The runner authenticates with its API key.
354
374
  - **The coding agent, installed and signed in on the same machine.** The runner starts the
355
375
  CLI as you, but it cannot sign in for you.
package/dist/cli.js CHANGED
@@ -40,12 +40,20 @@ class Client {
40
40
  const body = await res.json();
41
41
  return body.run;
42
42
  }
43
- async heartbeat(runId) {
44
- await this.post(`/agent-runs/${runId}/heartbeat`);
43
+ async heartbeat(runId, attempt) {
44
+ await this.post(`/agent-runs/${runId}/heartbeat`, attempt == null ? undefined : { attempt });
45
45
  }
46
46
  async report(runId, result) {
47
47
  await this.post(`/agent-runs/${runId}/result`, result);
48
48
  }
49
+ async runEvents(run, offset, events, sessionId) {
50
+ await this.post(`/agent-runs/${run.id}/events`, {
51
+ attempt: run.attempts,
52
+ offset,
53
+ events,
54
+ ...sessionId && { sessionId }
55
+ });
56
+ }
49
57
  async claimChat() {
50
58
  const res = await this.post("/agent-chats/claim", undefined, CHAT_CLAIM_TIMEOUT_MS);
51
59
  const body = await res.json();
@@ -114,7 +122,7 @@ var PRESETS = {
114
122
  "json",
115
123
  ...sessionId ? ["--session", sessionId] : []
116
124
  ],
117
- tail: []
125
+ tail: ["--"]
118
126
  },
119
127
  antigravity: {
120
128
  bin: "agy",
@@ -146,7 +154,7 @@ var PRESETS = {
146
154
  pi: {
147
155
  bin: "pi",
148
156
  outputFormat: "pi-json",
149
- promptVia: "arg",
157
+ promptVia: "stdin",
150
158
  systemPromptFlag: "--append-system-prompt",
151
159
  head: (sessionId) => [
152
160
  "-p",
@@ -168,7 +176,20 @@ var PRESETS = {
168
176
  "--auto-approve",
169
177
  ...sessionId ? ["--resume", sessionId] : []
170
178
  ],
171
- tail: []
179
+ tail: ["--"]
180
+ },
181
+ mastracode: {
182
+ bin: "mastracode",
183
+ outputFormat: "mastracode-jsonl",
184
+ promptVia: "stdin",
185
+ head: (sessionId) => [
186
+ "--output",
187
+ "jsonl",
188
+ "--permission-mode",
189
+ "auto",
190
+ ...sessionId ? ["--thread", sessionId] : []
191
+ ],
192
+ tail: ["--prompt", "-"]
172
193
  }
173
194
  };
174
195
  var PRESET_NAMES = Object.keys(PRESETS);
@@ -200,7 +221,8 @@ var OUTPUT_FORMATS = [
200
221
  "opencode-json",
201
222
  "antigravity-stream-json",
202
223
  "copilot-json",
203
- "pi-json"
224
+ "pi-json",
225
+ "mastracode-jsonl"
204
226
  ];
205
227
  function presetOf(config) {
206
228
  if (config.command || !config.agent)
@@ -521,6 +543,7 @@ function startListener(port, agents, log) {
521
543
  var FLUSH_CHARS = 1500;
522
544
  var DELTA_LIMIT = 12000;
523
545
  var TOOL_TEXT_LIMIT = 32000;
546
+ var TERMINAL_CODES = /\x1b\[[0-?]*[ -/]*[@-~]/g;
524
547
 
525
548
  class AnswerStream {
526
549
  format;
@@ -536,8 +559,11 @@ class AnswerStream {
536
559
  openTextPart = "";
537
560
  openToolCalls = new Set;
538
561
  closedToolCalls = new Set;
562
+ mastraMessageId;
539
563
  sawPartialText = false;
540
564
  sawAnyText = false;
565
+ segment = "";
566
+ lastSegment = "";
541
567
  sending = Promise.resolve();
542
568
  usage;
543
569
  constructor(format, threadId, runId, send) {
@@ -564,6 +590,9 @@ class AnswerStream {
564
590
  startedSession() {
565
591
  return this.sessionId;
566
592
  }
593
+ answer() {
594
+ return (this.segment.trim() ? this.segment : this.lastSegment).trim();
595
+ }
567
596
  contextUsage() {
568
597
  return this.usage.value();
569
598
  }
@@ -612,6 +641,7 @@ class AnswerStream {
612
641
  return;
613
642
  this.sawAnyText = true;
614
643
  this.text += text;
644
+ this.segment += text;
615
645
  if (this.text.length >= FLUSH_CHARS)
616
646
  this.drainText();
617
647
  }
@@ -640,7 +670,9 @@ class AnswerStream {
640
670
  try {
641
671
  parsed = JSON.parse(trimmed);
642
672
  } catch {
643
- this.appendText(`${trimmed}
673
+ const plain = trimmed.replace(TERMINAL_CODES, "").trim();
674
+ if (plain)
675
+ this.appendText(`${plain}
644
676
  `);
645
677
  return;
646
678
  }
@@ -666,6 +698,39 @@ class AnswerStream {
666
698
  case "pi-json":
667
699
  this.readPiLine(parsed);
668
700
  break;
701
+ case "mastracode-jsonl":
702
+ this.readMastraLine(parsed);
703
+ break;
704
+ }
705
+ }
706
+ readMastraLine(message) {
707
+ switch (message.type) {
708
+ case "thread_created":
709
+ if (message.thread?.id)
710
+ this.sessionId ??= message.thread.id;
711
+ return;
712
+ case "message_start":
713
+ if (message.message?.role === "assistant")
714
+ this.mastraMessageId = message.message.id;
715
+ return;
716
+ case "message_update":
717
+ if (message.id === this.mastraMessageId && message.event?.type === "text-delta") {
718
+ this.appendText(message.event.delta ?? "");
719
+ }
720
+ return;
721
+ case "tool_start":
722
+ if (message.toolCallId) {
723
+ this.pushToolCall(message.toolCallId, message.toolName ?? "tool", JSON.stringify(message.args ?? {}));
724
+ }
725
+ return;
726
+ case "tool_end":
727
+ if (message.toolCallId)
728
+ this.pushToolResult(message.toolCallId, toolResultText(message.result));
729
+ return;
730
+ case "result":
731
+ if (!this.sawAnyText)
732
+ this.appendText(message.text || message.error?.message || "");
733
+ return;
669
734
  }
670
735
  }
671
736
  readPiLine(message) {
@@ -690,7 +755,7 @@ class AnswerStream {
690
755
  if (!message.toolCallId || this.closedToolCalls.has(message.toolCallId))
691
756
  return;
692
757
  this.closedToolCalls.add(message.toolCallId);
693
- this.pushToolResult(message.toolCallId, piResultText(message.result));
758
+ this.pushToolResult(message.toolCallId, toolResultText(message.result));
694
759
  return;
695
760
  }
696
761
  case "message_end":
@@ -774,15 +839,36 @@ class AnswerStream {
774
839
  return;
775
840
  }
776
841
  const item = message.item;
777
- if (message.type !== "item.completed" || !item)
842
+ if (!item)
778
843
  return;
779
- if (item.type === "agent_message" && item.text) {
844
+ if (message.type === "item.completed" && item.type === "agent_message" && item.text) {
780
845
  this.appendText(item.text);
781
846
  return;
782
847
  }
783
- if (item.type === "command_execution" && item.id) {
784
- this.pushToolCall(item.id, "shell", item.command ?? "");
785
- this.pushToolResult(item.id, item.aggregated_output ?? "");
848
+ if (!item.id || !["command_execution", "mcp_tool_call", "file_change", "web_search"].includes(item.type ?? ""))
849
+ return;
850
+ if (!["item.started", "item.completed"].includes(message.type ?? ""))
851
+ return;
852
+ if (!this.openToolCalls.has(item.id)) {
853
+ this.openToolCalls.add(item.id);
854
+ const name = item.type === "command_execution" ? "shell" : item.tool ?? item.type ?? "tool";
855
+ const args = item.command ?? JSON.stringify(item.arguments ?? item.changes ?? { query: item.query });
856
+ this.pushToolCall(item.id, name, args);
857
+ }
858
+ if (message.type === "item.completed" && !this.closedToolCalls.has(item.id)) {
859
+ this.closedToolCalls.add(item.id);
860
+ let result = item.aggregated_output;
861
+ if (result == null || item.status === "failed" || item.exit_code != null && item.exit_code !== 0) {
862
+ result = JSON.stringify({
863
+ status: item.status,
864
+ exitCode: item.exit_code,
865
+ output: item.aggregated_output,
866
+ result: item.result,
867
+ error: item.error,
868
+ changes: item.changes
869
+ });
870
+ }
871
+ this.pushToolResult(item.id, result);
786
872
  }
787
873
  }
788
874
  readOpencodeLine(message) {
@@ -860,10 +946,17 @@ class AnswerStream {
860
946
  }
861
947
  pushToolCall(toolCallId, toolCallName, args) {
862
948
  this.drainText();
949
+ this.closeSegment();
863
950
  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 });
864
951
  }
952
+ closeSegment() {
953
+ if (this.segment.trim())
954
+ this.lastSegment = this.segment;
955
+ this.segment = "";
956
+ }
865
957
  pushToolResult(toolCallId, content) {
866
958
  this.drainText();
959
+ this.closeSegment();
867
960
  this.queued.push({
868
961
  type: "TOOL_CALL_RESULT",
869
962
  messageId: this.messageId,
@@ -928,8 +1021,19 @@ class UsageReader {
928
1021
  case "pi-json":
929
1022
  this.readPi(parsed);
930
1023
  return;
1024
+ case "mastracode-jsonl":
1025
+ this.readMastra(parsed);
1026
+ return;
931
1027
  }
932
1028
  }
1029
+ readMastra(message) {
1030
+ if (message.type !== "usage_update" || !message.usage)
1031
+ return;
1032
+ this.last = {
1033
+ inputTokens: message.usage.promptTokens ?? 0,
1034
+ outputTokens: message.usage.completionTokens ?? 0
1035
+ };
1036
+ }
933
1037
  readClaude(message) {
934
1038
  const event = message.type === "stream_event" ? message.event : undefined;
935
1039
  if (!event)
@@ -1000,7 +1104,7 @@ function textOfResult(content) {
1000
1104
  }
1001
1105
  return "";
1002
1106
  }
1003
- function piResultText(result) {
1107
+ function toolResultText(result) {
1004
1108
  if (result && typeof result === "object" && !Array.isArray(result) && "content" in result) {
1005
1109
  return textOfResult(result.content);
1006
1110
  }
@@ -1138,6 +1242,78 @@ async function answer(config, client, message, stop) {
1138
1242
  await client.chatResult(message.id, { status: "failed", error, usage: stream.contextUsage() });
1139
1243
  }
1140
1244
 
1245
+ // src/run.ts
1246
+ async function runTask(config, client, run) {
1247
+ let offset = 0;
1248
+ const stream = new AnswerStream(config.outputFormat, `run-${run.id}`, String(run.id), async (events) => {
1249
+ if (!run.traceEvents)
1250
+ return;
1251
+ const session = config.outputFormat === "codex-jsonl" ? stream.startedSession() : null;
1252
+ const sessionId = session && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(session) ? session : undefined;
1253
+ for (let start = 0;start < events.length; start += 200) {
1254
+ const batch = events.slice(start, start + 200);
1255
+ await client.runEvents(run, offset + start, batch, sessionId);
1256
+ }
1257
+ offset += events.length;
1258
+ });
1259
+ const usage = new UsageReader(config.outputFormat);
1260
+ const timer = setInterval(() => {
1261
+ stream.flush().catch(() => {});
1262
+ }, 500);
1263
+ try {
1264
+ const outcome = await execute(config, {
1265
+ prompt: run.prompt,
1266
+ systemPrompt: run.systemPrompt,
1267
+ env: {
1268
+ ITSAPLAN_RUN_ID: String(run.id),
1269
+ ITSAPLAN_TRIGGER: run.trigger,
1270
+ ITSAPLAN_SYSTEM_PROMPT: run.systemPrompt,
1271
+ ITSAPLAN_ISSUE: run.issueIdentifier ?? "",
1272
+ ITSAPLAN_ISSUE_ID: run.issueId == null ? "" : String(run.issueId)
1273
+ }
1274
+ }, {
1275
+ onData: (chunk) => {
1276
+ usage.write(chunk);
1277
+ stream.write(chunk);
1278
+ }
1279
+ });
1280
+ clearInterval(timer);
1281
+ usage.end();
1282
+ try {
1283
+ if (outcome.status === "success")
1284
+ await stream.finish(outcome.output);
1285
+ else
1286
+ await stream.fail(outcome.error ?? "The command failed", outcome.output);
1287
+ } catch (error) {
1288
+ if (!isTransientEventError(error))
1289
+ throw error;
1290
+ try {
1291
+ await stream.flush();
1292
+ } catch (retryError) {
1293
+ if (!isTransientEventError(retryError))
1294
+ throw retryError;
1295
+ console.error(`Run ${run.id}: trace upload failed; reporting the command result: ${String(retryError)}`);
1296
+ }
1297
+ }
1298
+ const output = config.outputFormat === "text" ? outcome.output : stream.answer();
1299
+ await client.report(run.id, {
1300
+ ...outcome,
1301
+ output,
1302
+ ...run.traceEvents && { attempt: run.attempts },
1303
+ usage: usage.value()
1304
+ });
1305
+ return outcome;
1306
+ } finally {
1307
+ clearInterval(timer);
1308
+ }
1309
+ }
1310
+ function isTransientEventError(error) {
1311
+ if (error instanceof RequestError) {
1312
+ return error.status === 408 || error.status === 429 || error.status >= 500;
1313
+ }
1314
+ return error instanceof TypeError || error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name);
1315
+ }
1316
+
1141
1317
  // src/tasks.ts
1142
1318
  var HEARTBEAT_MS = 60000;
1143
1319
  async function withHeartbeat(log, beat, work) {
@@ -1150,32 +1326,20 @@ async function withHeartbeat(log, beat, work) {
1150
1326
  clearInterval(timer);
1151
1327
  }
1152
1328
  }
1153
- function taskOf(run) {
1154
- return {
1155
- prompt: run.prompt,
1156
- systemPrompt: run.systemPrompt,
1157
- env: {
1158
- ITSAPLAN_RUN_ID: String(run.id),
1159
- ITSAPLAN_TRIGGER: run.trigger,
1160
- ITSAPLAN_SYSTEM_PROMPT: run.systemPrompt,
1161
- ITSAPLAN_ISSUE: run.issueIdentifier ?? "",
1162
- ITSAPLAN_ISSUE_ID: run.issueId == null ? "" : String(run.issueId)
1163
- }
1164
- };
1165
- }
1166
1329
  async function handleRun(config, client, log, run) {
1167
1330
  const label = run.issueIdentifier ?? `run ${run.id}`;
1168
1331
  log(`${label}: started (${run.trigger})`);
1169
1332
  try {
1170
- const usage = new UsageReader(config.outputFormat);
1171
- const outcome = await withHeartbeat(log, () => client.heartbeat(run.id), execute(config, taskOf(run), { onData: (chunk) => usage.write(chunk) }));
1172
- usage.end();
1173
- await client.report(run.id, { ...outcome, usage: usage.value() });
1333
+ const outcome = await withHeartbeat(log, () => client.heartbeat(run.id, run.traceEvents ? run.attempts : undefined), runTask(config, client, run));
1174
1334
  log(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
1175
1335
  } catch (err) {
1176
1336
  const message = err instanceof Error ? err.message : String(err);
1177
1337
  log(`${label}: runner error — ${message}`);
1178
- await client.report(run.id, { status: "failed", error: message }).catch(() => {});
1338
+ await client.report(run.id, {
1339
+ status: "failed",
1340
+ error: message,
1341
+ ...run.traceEvents && { attempt: run.attempts }
1342
+ }).catch(() => {});
1179
1343
  }
1180
1344
  }
1181
1345
  async function handleChat(config, client, log, message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsaplan/runner",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Runs an Itsaplan external agent's queued tasks and answers its chat on your own machine",
5
5
  "keywords": [
6
6
  "itsaplan",
@@ -20,7 +20,8 @@
20
20
  "antigravity",
21
21
  "opencode",
22
22
  "pi",
23
- "omp"
23
+ "omp",
24
+ "mastracode"
24
25
  ],
25
26
  "homepage": "https://github.com/croffasia/itsaplan/tree/main/packages/runner#readme",
26
27
  "repository": {