@itsaplan/runner 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -7
- package/dist/cli.js +927 -730
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -3,225 +3,739 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
var
|
|
8
|
-
var
|
|
9
|
-
var TOOL_TEXT_LIMIT = 32000;
|
|
6
|
+
// src/client.ts
|
|
7
|
+
var REQUEST_TIMEOUT_MS = 30000;
|
|
8
|
+
var CHAT_CLAIM_TIMEOUT_MS = 35000;
|
|
10
9
|
|
|
11
|
-
class
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
usage;
|
|
29
|
-
constructor(format, threadId, runId, send) {
|
|
30
|
-
this.format = format;
|
|
31
|
-
this.threadId = threadId;
|
|
32
|
-
this.runId = runId;
|
|
33
|
-
this.send = send;
|
|
34
|
-
this.usage = new UsageReader(format);
|
|
35
|
-
this.messageId = `msg-${runId}`;
|
|
36
|
-
this.queued.push({ type: "RUN_STARTED", threadId, runId });
|
|
37
|
-
}
|
|
38
|
-
write(chunk) {
|
|
39
|
-
if (this.format === "text") {
|
|
40
|
-
this.appendText(chunk);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
this.line += chunk;
|
|
44
|
-
const lines = this.line.split(`
|
|
45
|
-
`);
|
|
46
|
-
this.line = lines.pop() ?? "";
|
|
47
|
-
for (const line of lines)
|
|
48
|
-
this.readLine(line);
|
|
49
|
-
}
|
|
50
|
-
startedSession() {
|
|
51
|
-
return this.sessionId;
|
|
52
|
-
}
|
|
53
|
-
contextUsage() {
|
|
54
|
-
return this.usage.value();
|
|
10
|
+
class RequestError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(status, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
55
15
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class Client {
|
|
19
|
+
config;
|
|
20
|
+
constructor(config) {
|
|
21
|
+
this.config = config;
|
|
60
22
|
}
|
|
61
|
-
async
|
|
62
|
-
this.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
23
|
+
async post(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
24
|
+
const res = await fetch(`${this.config.url}${path}`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"x-api-key": this.config.apiKey,
|
|
28
|
+
...body === undefined ? {} : { "content-type": "application/json" }
|
|
29
|
+
},
|
|
30
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
31
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
throw new RequestError(res.status, `POST ${path} failed with ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
72
35
|
}
|
|
36
|
+
return res;
|
|
73
37
|
}
|
|
74
|
-
async
|
|
75
|
-
this.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
async fail(message, fallback = "") {
|
|
80
|
-
this.closeText(fallback);
|
|
81
|
-
this.queued.push({ type: "RUN_ERROR", message });
|
|
82
|
-
await this.flush();
|
|
38
|
+
async claim() {
|
|
39
|
+
const res = await this.post("/agent-runs/claim");
|
|
40
|
+
const body = await res.json();
|
|
41
|
+
return body.run;
|
|
83
42
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.readLine(this.line);
|
|
87
|
-
this.line = "";
|
|
88
|
-
}
|
|
89
|
-
if (!this.sawAnyText && fallback)
|
|
90
|
-
this.appendText(fallback);
|
|
91
|
-
this.drainText();
|
|
92
|
-
if (this.started)
|
|
93
|
-
this.queued.push({ type: "TEXT_MESSAGE_END", messageId: this.messageId });
|
|
94
|
-
this.started = false;
|
|
43
|
+
async heartbeat(runId) {
|
|
44
|
+
await this.post(`/agent-runs/${runId}/heartbeat`);
|
|
95
45
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return;
|
|
99
|
-
this.sawAnyText = true;
|
|
100
|
-
this.text += text;
|
|
101
|
-
if (this.text.length >= FLUSH_CHARS)
|
|
102
|
-
this.drainText();
|
|
46
|
+
async report(runId, result) {
|
|
47
|
+
await this.post(`/agent-runs/${runId}/result`, result);
|
|
103
48
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this.started = true;
|
|
109
|
-
this.queued.push({
|
|
110
|
-
type: "TEXT_MESSAGE_START",
|
|
111
|
-
messageId: this.messageId,
|
|
112
|
-
role: "assistant"
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
while (this.text.length > 0) {
|
|
116
|
-
const delta = this.text.slice(0, DELTA_LIMIT);
|
|
117
|
-
this.text = this.text.slice(DELTA_LIMIT);
|
|
118
|
-
this.queued.push({ type: "TEXT_MESSAGE_CONTENT", messageId: this.messageId, delta });
|
|
119
|
-
}
|
|
49
|
+
async claimChat() {
|
|
50
|
+
const res = await this.post("/agent-chats/claim", undefined, CHAT_CLAIM_TIMEOUT_MS);
|
|
51
|
+
const body = await res.json();
|
|
52
|
+
return body.message;
|
|
120
53
|
}
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
parsed = JSON.parse(trimmed);
|
|
128
|
-
} catch {
|
|
129
|
-
this.appendText(`${trimmed}
|
|
130
|
-
`);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
if (!parsed || typeof parsed !== "object")
|
|
134
|
-
return;
|
|
135
|
-
this.usage.readLine(parsed);
|
|
136
|
-
switch (this.format) {
|
|
137
|
-
case "claude-stream-json":
|
|
138
|
-
this.readClaudeLine(parsed);
|
|
139
|
-
break;
|
|
140
|
-
case "codex-jsonl":
|
|
141
|
-
this.readCodexLine(parsed);
|
|
142
|
-
break;
|
|
143
|
-
case "opencode-json":
|
|
144
|
-
this.readOpencodeLine(parsed);
|
|
145
|
-
break;
|
|
146
|
-
case "antigravity-stream-json":
|
|
147
|
-
this.readAntigravityLine(parsed);
|
|
148
|
-
break;
|
|
149
|
-
case "copilot-json":
|
|
150
|
-
this.readCopilotLine(parsed);
|
|
151
|
-
break;
|
|
152
|
-
case "pi-json":
|
|
153
|
-
this.readPiLine(parsed);
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
54
|
+
async chatEvents(messageId, events, sessionId) {
|
|
55
|
+
const res = await this.post(`/agent-chats/${messageId}/events`, {
|
|
56
|
+
events,
|
|
57
|
+
...sessionId && { sessionId }
|
|
58
|
+
});
|
|
59
|
+
return canceled(res);
|
|
156
60
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
this.sessionId ??= message.id;
|
|
160
|
-
switch (message.type) {
|
|
161
|
-
case "message_update": {
|
|
162
|
-
const event = message.assistantMessageEvent;
|
|
163
|
-
if (event?.type === "text_delta" && event.delta) {
|
|
164
|
-
this.sawPartialText = true;
|
|
165
|
-
this.appendText(event.delta);
|
|
166
|
-
}
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
case "tool_execution_start":
|
|
170
|
-
if (message.toolCallId && !this.openToolCalls.has(message.toolCallId)) {
|
|
171
|
-
this.openToolCalls.add(message.toolCallId);
|
|
172
|
-
this.pushToolCall(message.toolCallId, message.toolName ?? "tool", JSON.stringify(message.args ?? {}));
|
|
173
|
-
}
|
|
174
|
-
return;
|
|
175
|
-
case "tool_execution_end": {
|
|
176
|
-
if (!message.toolCallId || this.closedToolCalls.has(message.toolCallId))
|
|
177
|
-
return;
|
|
178
|
-
this.closedToolCalls.add(message.toolCallId);
|
|
179
|
-
this.pushToolResult(message.toolCallId, piResultText(message.result));
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
case "message_end":
|
|
183
|
-
if (message.message?.role === "assistant" && !this.sawAnyText && message.message.content) {
|
|
184
|
-
const body = textOfResult(message.message.content);
|
|
185
|
-
if (body)
|
|
186
|
-
this.appendText(body);
|
|
187
|
-
}
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
61
|
+
async chatHeartbeat(messageId) {
|
|
62
|
+
return canceled(await this.post(`/agent-chats/${messageId}/heartbeat`));
|
|
190
63
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
switch (message.type) {
|
|
194
|
-
case "result":
|
|
195
|
-
if (message.sessionId)
|
|
196
|
-
this.sessionId ??= message.sessionId;
|
|
197
|
-
return;
|
|
198
|
-
case "assistant.message_delta":
|
|
199
|
-
if (data?.deltaContent) {
|
|
200
|
-
this.sawPartialText = true;
|
|
201
|
-
this.appendText(data.deltaContent);
|
|
202
|
-
}
|
|
203
|
-
return;
|
|
204
|
-
case "assistant.message":
|
|
205
|
-
if (data?.content && !this.sawPartialText)
|
|
206
|
-
this.appendText(data.content);
|
|
207
|
-
return;
|
|
208
|
-
case "tool.execution_start":
|
|
209
|
-
if (data?.toolCallId) {
|
|
210
|
-
this.pushToolCall(data.toolCallId, data.toolName ?? "tool", JSON.stringify(data.arguments ?? {}));
|
|
211
|
-
}
|
|
212
|
-
return;
|
|
213
|
-
case "tool.execution_complete": {
|
|
214
|
-
if (!data?.toolCallId)
|
|
215
|
-
return;
|
|
216
|
-
const result = data.success ? data.result?.content : data.error?.message;
|
|
217
|
-
if (typeof result === "string")
|
|
218
|
-
this.pushToolResult(data.toolCallId, result);
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
64
|
+
async chatResult(messageId, result) {
|
|
65
|
+
await this.post(`/agent-chats/${messageId}/result`, result);
|
|
222
66
|
}
|
|
223
|
-
|
|
224
|
-
|
|
67
|
+
}
|
|
68
|
+
async function canceled(res) {
|
|
69
|
+
const body = await res.json().catch(() => ({}));
|
|
70
|
+
return body.canceled === true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/config.ts
|
|
74
|
+
import { readFile } from "node:fs/promises";
|
|
75
|
+
|
|
76
|
+
// src/presets.ts
|
|
77
|
+
var PRESETS = {
|
|
78
|
+
claude: {
|
|
79
|
+
bin: "claude",
|
|
80
|
+
outputFormat: "claude-stream-json",
|
|
81
|
+
promptVia: "stdin",
|
|
82
|
+
systemPromptFlag: "--append-system-prompt",
|
|
83
|
+
head: (sessionId) => [
|
|
84
|
+
"-p",
|
|
85
|
+
...sessionId ? ["--resume", sessionId] : [],
|
|
86
|
+
"--output-format",
|
|
87
|
+
"stream-json",
|
|
88
|
+
"--include-partial-messages",
|
|
89
|
+
"--verbose",
|
|
90
|
+
"--permission-mode",
|
|
91
|
+
"auto"
|
|
92
|
+
],
|
|
93
|
+
tail: []
|
|
94
|
+
},
|
|
95
|
+
codex: {
|
|
96
|
+
bin: "codex",
|
|
97
|
+
outputFormat: "codex-jsonl",
|
|
98
|
+
promptVia: "stdin",
|
|
99
|
+
head: (sessionId) => [
|
|
100
|
+
...sessionId ? ["exec", "resume", sessionId] : ["exec"],
|
|
101
|
+
"--json",
|
|
102
|
+
"-c",
|
|
103
|
+
'sandbox_mode="workspace-write"'
|
|
104
|
+
],
|
|
105
|
+
tail: ["-"]
|
|
106
|
+
},
|
|
107
|
+
opencode: {
|
|
108
|
+
bin: "opencode",
|
|
109
|
+
outputFormat: "opencode-json",
|
|
110
|
+
promptVia: "arg",
|
|
111
|
+
head: (sessionId) => [
|
|
112
|
+
"run",
|
|
113
|
+
"--format",
|
|
114
|
+
"json",
|
|
115
|
+
...sessionId ? ["--session", sessionId] : []
|
|
116
|
+
],
|
|
117
|
+
tail: []
|
|
118
|
+
},
|
|
119
|
+
antigravity: {
|
|
120
|
+
bin: "agy",
|
|
121
|
+
outputFormat: "antigravity-stream-json",
|
|
122
|
+
promptVia: "arg",
|
|
123
|
+
head: (sessionId) => [
|
|
124
|
+
...sessionId ? ["--conversation", sessionId] : [],
|
|
125
|
+
"--output-format",
|
|
126
|
+
"stream-json",
|
|
127
|
+
"--dangerously-skip-permissions",
|
|
128
|
+
"--print-timeout",
|
|
129
|
+
"24h"
|
|
130
|
+
],
|
|
131
|
+
tail: ["-p"]
|
|
132
|
+
},
|
|
133
|
+
copilot: {
|
|
134
|
+
bin: "copilot",
|
|
135
|
+
outputFormat: "copilot-json",
|
|
136
|
+
promptVia: "arg",
|
|
137
|
+
head: (sessionId) => [
|
|
138
|
+
"--allow-all-tools",
|
|
139
|
+
"--no-ask-user",
|
|
140
|
+
"--output-format",
|
|
141
|
+
"json",
|
|
142
|
+
...sessionId ? ["--session-id", sessionId] : []
|
|
143
|
+
],
|
|
144
|
+
tail: ["-p"]
|
|
145
|
+
},
|
|
146
|
+
pi: {
|
|
147
|
+
bin: "pi",
|
|
148
|
+
outputFormat: "pi-json",
|
|
149
|
+
promptVia: "arg",
|
|
150
|
+
systemPromptFlag: "--append-system-prompt",
|
|
151
|
+
head: (sessionId) => [
|
|
152
|
+
"-p",
|
|
153
|
+
"--mode",
|
|
154
|
+
"json",
|
|
155
|
+
...sessionId ? ["--session-id", sessionId] : []
|
|
156
|
+
],
|
|
157
|
+
tail: []
|
|
158
|
+
},
|
|
159
|
+
omp: {
|
|
160
|
+
bin: "omp",
|
|
161
|
+
outputFormat: "pi-json",
|
|
162
|
+
promptVia: "arg",
|
|
163
|
+
systemPromptFlag: "--append-system-prompt",
|
|
164
|
+
head: (sessionId) => [
|
|
165
|
+
"-p",
|
|
166
|
+
"--mode",
|
|
167
|
+
"json",
|
|
168
|
+
"--auto-approve",
|
|
169
|
+
...sessionId ? ["--resume", sessionId] : []
|
|
170
|
+
],
|
|
171
|
+
tail: []
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
var PRESET_NAMES = Object.keys(PRESETS);
|
|
175
|
+
function isPresetName(value) {
|
|
176
|
+
return value in PRESETS;
|
|
177
|
+
}
|
|
178
|
+
function presetPrompt(preset, systemPrompt, prompt) {
|
|
179
|
+
if (preset.systemPromptFlag || !systemPrompt)
|
|
180
|
+
return prompt;
|
|
181
|
+
return `${systemPrompt}
|
|
182
|
+
|
|
183
|
+
${prompt}`;
|
|
184
|
+
}
|
|
185
|
+
function presetArgv(preset, sessionId, systemPrompt, extraArgs, prompt) {
|
|
186
|
+
return [
|
|
187
|
+
...preset.head(sessionId),
|
|
188
|
+
...preset.systemPromptFlag && systemPrompt ? [preset.systemPromptFlag, systemPrompt] : [],
|
|
189
|
+
...extraArgs,
|
|
190
|
+
...preset.tail,
|
|
191
|
+
...preset.promptVia === "arg" ? [presetPrompt(preset, systemPrompt, prompt)] : []
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/config.ts
|
|
196
|
+
var OUTPUT_FORMATS = [
|
|
197
|
+
"text",
|
|
198
|
+
"claude-stream-json",
|
|
199
|
+
"codex-jsonl",
|
|
200
|
+
"opencode-json",
|
|
201
|
+
"antigravity-stream-json",
|
|
202
|
+
"copilot-json",
|
|
203
|
+
"pi-json"
|
|
204
|
+
];
|
|
205
|
+
function presetOf(config) {
|
|
206
|
+
if (config.command || !config.agent)
|
|
207
|
+
return;
|
|
208
|
+
return PRESETS[config.agent];
|
|
209
|
+
}
|
|
210
|
+
var DEFAULTS = {
|
|
211
|
+
concurrency: 1,
|
|
212
|
+
pollIntervalMs: 3000,
|
|
213
|
+
timeoutMs: 30 * 60 * 1000
|
|
214
|
+
};
|
|
215
|
+
var MIN_POLL_INTERVAL_MS = 1000;
|
|
216
|
+
function textOf(value) {
|
|
217
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
218
|
+
return text || undefined;
|
|
219
|
+
}
|
|
220
|
+
function intFrom(value, fallback) {
|
|
221
|
+
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : value;
|
|
222
|
+
return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
223
|
+
}
|
|
224
|
+
function outputFormatFrom(value, preset) {
|
|
225
|
+
const name = textOf(value);
|
|
226
|
+
if (!name)
|
|
227
|
+
return preset?.outputFormat ?? "text";
|
|
228
|
+
const format = OUTPUT_FORMATS.find((candidate) => candidate === name);
|
|
229
|
+
if (!format) {
|
|
230
|
+
throw new Error(`outputFormat must be one of ${OUTPUT_FORMATS.join(", ")}`);
|
|
231
|
+
}
|
|
232
|
+
return format;
|
|
233
|
+
}
|
|
234
|
+
function agentFrom(value) {
|
|
235
|
+
const name = textOf(value);
|
|
236
|
+
if (!name)
|
|
237
|
+
return;
|
|
238
|
+
if (!isPresetName(name)) {
|
|
239
|
+
throw new Error(`agent must be one of ${PRESET_NAMES.join(", ")}`);
|
|
240
|
+
}
|
|
241
|
+
return name;
|
|
242
|
+
}
|
|
243
|
+
function portFrom(value) {
|
|
244
|
+
if (value === undefined || value === "")
|
|
245
|
+
return;
|
|
246
|
+
const port = intFrom(value, 0);
|
|
247
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
248
|
+
throw new Error("listen must be a port number from 1 to 65535");
|
|
249
|
+
}
|
|
250
|
+
return port;
|
|
251
|
+
}
|
|
252
|
+
function argsFrom(value) {
|
|
253
|
+
if (value === undefined)
|
|
254
|
+
return [];
|
|
255
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
256
|
+
throw new Error("args must be an array of strings");
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
function required(value, field) {
|
|
261
|
+
const text = textOf(value);
|
|
262
|
+
if (!text)
|
|
263
|
+
throw new Error(`${field} is required — set it in the config file or the environment`);
|
|
264
|
+
return text;
|
|
265
|
+
}
|
|
266
|
+
async function readConfigFile(path) {
|
|
267
|
+
let text;
|
|
268
|
+
try {
|
|
269
|
+
text = await readFile(path, "utf8");
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err.code === "ENOENT")
|
|
272
|
+
return {};
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
return JSON.parse(text);
|
|
277
|
+
} catch {
|
|
278
|
+
throw new Error(`${path} is not valid JSON`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function agentFieldsFrom(file) {
|
|
282
|
+
if (file.apiKeys !== undefined && file.agents !== undefined) {
|
|
283
|
+
throw new Error("set either apiKeys or agents, not both");
|
|
284
|
+
}
|
|
285
|
+
if (file.apiKeys !== undefined) {
|
|
286
|
+
const keys = file.apiKeys;
|
|
287
|
+
if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
|
|
288
|
+
throw new Error("apiKeys must be an array of strings");
|
|
289
|
+
}
|
|
290
|
+
return keys.map((apiKey) => ({ apiKey }));
|
|
291
|
+
}
|
|
292
|
+
if (file.agents === undefined)
|
|
293
|
+
return [{}];
|
|
294
|
+
const agents = file.agents;
|
|
295
|
+
if (!Array.isArray(agents) || agents.some((entry) => typeof entry !== "object" || entry === null || Array.isArray(entry) || !textOf(entry.apiKey))) {
|
|
296
|
+
throw new Error("agents must be an array of objects, each with its own apiKey");
|
|
297
|
+
}
|
|
298
|
+
return agents;
|
|
299
|
+
}
|
|
300
|
+
function merge(shared, own) {
|
|
301
|
+
const merged = { ...shared, ...own };
|
|
302
|
+
const mode = textOf(own.agent) ?? textOf(own.command);
|
|
303
|
+
const changesMode = mode !== undefined && mode !== (textOf(shared.agent) ?? textOf(shared.command));
|
|
304
|
+
if (textOf(own.agent))
|
|
305
|
+
delete merged.command;
|
|
306
|
+
else if (textOf(own.command))
|
|
307
|
+
delete merged.agent;
|
|
308
|
+
if (changesMode && !textOf(own.outputFormat))
|
|
309
|
+
delete merged.outputFormat;
|
|
310
|
+
return merged;
|
|
311
|
+
}
|
|
312
|
+
function nameOf(entry, index, total) {
|
|
313
|
+
if (total === 1)
|
|
314
|
+
return "";
|
|
315
|
+
return textOf(entry.name) ?? `#${index + 1}`;
|
|
316
|
+
}
|
|
317
|
+
function configFrom(fields, name, extraArgs) {
|
|
318
|
+
const agent = agentFrom(fields.agent);
|
|
319
|
+
const command = textOf(fields.command);
|
|
320
|
+
const listen = portFrom(fields.listen);
|
|
321
|
+
const signingSecret = textOf(fields.signingSecret);
|
|
322
|
+
if (listen !== undefined && !signingSecret) {
|
|
323
|
+
throw new Error("signingSecret is required with listen — copy it from the agent settings");
|
|
324
|
+
}
|
|
325
|
+
if (!agent && !command) {
|
|
326
|
+
throw new Error(`set either agent (one of ${PRESET_NAMES.join(", ")}) or command in the config file or the environment`);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
name,
|
|
330
|
+
url: required(fields.url, "url").replace(/\/+$/, ""),
|
|
331
|
+
apiKey: required(fields.apiKey, "apiKey"),
|
|
332
|
+
agent,
|
|
333
|
+
command,
|
|
334
|
+
args: [...argsFrom(fields.args), ...extraArgs],
|
|
335
|
+
cwd: textOf(fields.cwd)?.replace(/^~/, process.env.HOME ?? "~"),
|
|
336
|
+
env: fields.env ?? {},
|
|
337
|
+
concurrency: intFrom(fields.concurrency, DEFAULTS.concurrency),
|
|
338
|
+
pollIntervalMs: Math.max(intFrom(fields.pollIntervalMs, DEFAULTS.pollIntervalMs), MIN_POLL_INTERVAL_MS),
|
|
339
|
+
timeoutMs: intFrom(fields.timeoutMs, DEFAULTS.timeoutMs),
|
|
340
|
+
outputFormat: outputFormatFrom(fields.outputFormat, presetOf({ agent, command })),
|
|
341
|
+
listen,
|
|
342
|
+
signingSecret
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function assertSecretsApart(configs) {
|
|
346
|
+
const seen = new Set;
|
|
347
|
+
for (const config of configs) {
|
|
348
|
+
if (config.listen === undefined)
|
|
349
|
+
continue;
|
|
350
|
+
const key = `${config.listen} ${config.signingSecret}`;
|
|
351
|
+
if (seen.has(key)) {
|
|
352
|
+
throw new Error("each agent on a listen port needs its own signingSecret — list them in agents");
|
|
353
|
+
}
|
|
354
|
+
seen.add(key);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function sharedFields(file, overrides) {
|
|
358
|
+
const env = process.env;
|
|
359
|
+
const outside = {
|
|
360
|
+
url: env.ITSAPLAN_URL,
|
|
361
|
+
apiKey: env.ITSAPLAN_API_KEY,
|
|
362
|
+
agent: overrides.agent ?? env.ITSAPLAN_AGENT,
|
|
363
|
+
command: env.ITSAPLAN_COMMAND,
|
|
364
|
+
cwd: env.ITSAPLAN_CWD,
|
|
365
|
+
concurrency: env.ITSAPLAN_CONCURRENCY,
|
|
366
|
+
pollIntervalMs: env.ITSAPLAN_POLL_INTERVAL_MS,
|
|
367
|
+
timeoutMs: env.ITSAPLAN_TIMEOUT_MS,
|
|
368
|
+
outputFormat: env.ITSAPLAN_OUTPUT_FORMAT,
|
|
369
|
+
listen: env.ITSAPLAN_LISTEN,
|
|
370
|
+
signingSecret: env.ITSAPLAN_SIGNING_SECRET
|
|
371
|
+
};
|
|
372
|
+
for (const [field, value] of Object.entries(outside)) {
|
|
373
|
+
if (!textOf(value))
|
|
374
|
+
delete outside[field];
|
|
375
|
+
}
|
|
376
|
+
return merge(file, outside);
|
|
377
|
+
}
|
|
378
|
+
async function loadConfig(path, overrides = {}) {
|
|
379
|
+
const file = await readConfigFile(path);
|
|
380
|
+
const entries = agentFieldsFrom(file);
|
|
381
|
+
if (entries.length === 0) {
|
|
382
|
+
throw new Error("no agents configured — apiKeys and agents cannot be empty");
|
|
383
|
+
}
|
|
384
|
+
const shared = sharedFields(file, overrides);
|
|
385
|
+
const configs = entries.map((entry, index) => configFrom(merge(shared, entry), nameOf(entry, index, entries.length), overrides.args ?? []));
|
|
386
|
+
assertSecretsApart(configs);
|
|
387
|
+
return configs;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/listen.ts
|
|
391
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
392
|
+
import { createServer } from "node:http";
|
|
393
|
+
var SIGNATURE_TOLERANCE_S = 300;
|
|
394
|
+
var MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
395
|
+
function verifySignature(secret, header, body, nowSeconds = Date.now() / 1000) {
|
|
396
|
+
const fields = new Map(header.split(",").map((part) => {
|
|
397
|
+
const at = part.indexOf("=");
|
|
398
|
+
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
|
|
399
|
+
}));
|
|
400
|
+
const t = fields.get("t");
|
|
401
|
+
const v1 = fields.get("v1");
|
|
402
|
+
if (!t || !v1 || !Number.isFinite(Number(t)))
|
|
403
|
+
return false;
|
|
404
|
+
if (Math.abs(nowSeconds - Number(t)) > SIGNATURE_TOLERANCE_S)
|
|
405
|
+
return false;
|
|
406
|
+
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
|
|
407
|
+
const given = Buffer.from(v1);
|
|
408
|
+
const wanted = Buffer.from(expected);
|
|
409
|
+
return given.length === wanted.length && timingSafeEqual(given, wanted);
|
|
410
|
+
}
|
|
411
|
+
function feed(limit, work, inFlight) {
|
|
412
|
+
let active = 0;
|
|
413
|
+
const waiting = [];
|
|
414
|
+
const held = new Set;
|
|
415
|
+
const acquire = () => {
|
|
416
|
+
if (active < limit) {
|
|
417
|
+
active++;
|
|
418
|
+
return Promise.resolve();
|
|
419
|
+
}
|
|
420
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
421
|
+
};
|
|
422
|
+
const release = () => {
|
|
423
|
+
const next = waiting.shift();
|
|
424
|
+
if (next)
|
|
425
|
+
next();
|
|
426
|
+
else
|
|
427
|
+
active--;
|
|
428
|
+
};
|
|
429
|
+
return (item) => {
|
|
430
|
+
if (held.has(item.id))
|
|
431
|
+
return;
|
|
432
|
+
held.add(item.id);
|
|
433
|
+
const task = acquire().then(() => work(item)).finally(() => {
|
|
434
|
+
held.delete(item.id);
|
|
435
|
+
release();
|
|
436
|
+
});
|
|
437
|
+
inFlight.add(task);
|
|
438
|
+
task.finally(() => inFlight.delete(task)).catch(() => {});
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
class TooLargeError extends Error {
|
|
443
|
+
}
|
|
444
|
+
function readBody(req) {
|
|
445
|
+
return new Promise((resolve, reject) => {
|
|
446
|
+
const chunks = [];
|
|
447
|
+
let size = 0;
|
|
448
|
+
req.on("data", (chunk) => {
|
|
449
|
+
size += chunk.length;
|
|
450
|
+
if (size > MAX_BODY_BYTES) {
|
|
451
|
+
reject(new TooLargeError);
|
|
452
|
+
req.destroy();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
chunks.push(chunk);
|
|
456
|
+
});
|
|
457
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
458
|
+
req.on("error", reject);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
function startListener(port, agents, log) {
|
|
462
|
+
const inFlight = new Set;
|
|
463
|
+
const served = agents.map((agent) => ({
|
|
464
|
+
secret: agent.secret,
|
|
465
|
+
run: feed(agent.concurrency, agent.run, inFlight),
|
|
466
|
+
chat: feed(agent.concurrency, agent.chat, inFlight)
|
|
467
|
+
}));
|
|
468
|
+
const server = createServer((req, res) => {
|
|
469
|
+
if (req.method !== "POST") {
|
|
470
|
+
res.writeHead(405, { allow: "POST" }).end();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
readBody(req).then((body) => {
|
|
474
|
+
const header = req.headers["x-itsaplan-signature"];
|
|
475
|
+
const agent = typeof header === "string" ? served.find((candidate) => verifySignature(candidate.secret, header, body)) : undefined;
|
|
476
|
+
if (!agent) {
|
|
477
|
+
log("refused a request that no configured agent signed");
|
|
478
|
+
res.writeHead(401).end("bad signature");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
let payload;
|
|
482
|
+
try {
|
|
483
|
+
payload = JSON.parse(body);
|
|
484
|
+
} catch {
|
|
485
|
+
res.writeHead(400).end("body is not JSON");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (payload.event === "agent.run" && payload.run)
|
|
489
|
+
agent.run(payload.run);
|
|
490
|
+
else if (payload.event === "agent.chat" && payload.message)
|
|
491
|
+
agent.chat(payload.message);
|
|
492
|
+
else if (payload.event !== "ping") {
|
|
493
|
+
res.writeHead(400).end(`unknown event ${String(payload.event)}`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
res.writeHead(200).end("ok");
|
|
497
|
+
}, (err) => {
|
|
498
|
+
res.writeHead(err instanceof TooLargeError ? 413 : 400).end();
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
return new Promise((resolve, reject) => {
|
|
502
|
+
server.once("error", reject);
|
|
503
|
+
server.listen(port, () => {
|
|
504
|
+
server.off("error", reject);
|
|
505
|
+
const address = server.address();
|
|
506
|
+
resolve({
|
|
507
|
+
port: typeof address === "object" && address ? address.port : port,
|
|
508
|
+
close: async () => {
|
|
509
|
+
await new Promise((done) => {
|
|
510
|
+
server.close(() => done());
|
|
511
|
+
server.closeIdleConnections();
|
|
512
|
+
});
|
|
513
|
+
await Promise.allSettled([...inFlight]);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/agui.ts
|
|
521
|
+
var FLUSH_CHARS = 1500;
|
|
522
|
+
var DELTA_LIMIT = 12000;
|
|
523
|
+
var TOOL_TEXT_LIMIT = 32000;
|
|
524
|
+
|
|
525
|
+
class AnswerStream {
|
|
526
|
+
format;
|
|
527
|
+
threadId;
|
|
528
|
+
runId;
|
|
529
|
+
send;
|
|
530
|
+
messageId;
|
|
531
|
+
queued = [];
|
|
532
|
+
text = "";
|
|
533
|
+
started = false;
|
|
534
|
+
line = "";
|
|
535
|
+
sessionId = null;
|
|
536
|
+
openTextPart = "";
|
|
537
|
+
openToolCalls = new Set;
|
|
538
|
+
closedToolCalls = new Set;
|
|
539
|
+
sawPartialText = false;
|
|
540
|
+
sawAnyText = false;
|
|
541
|
+
sending = Promise.resolve();
|
|
542
|
+
usage;
|
|
543
|
+
constructor(format, threadId, runId, send) {
|
|
544
|
+
this.format = format;
|
|
545
|
+
this.threadId = threadId;
|
|
546
|
+
this.runId = runId;
|
|
547
|
+
this.send = send;
|
|
548
|
+
this.usage = new UsageReader(format);
|
|
549
|
+
this.messageId = `msg-${runId}`;
|
|
550
|
+
this.queued.push({ type: "RUN_STARTED", threadId, runId });
|
|
551
|
+
}
|
|
552
|
+
write(chunk) {
|
|
553
|
+
if (this.format === "text") {
|
|
554
|
+
this.appendText(chunk);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.line += chunk;
|
|
558
|
+
const lines = this.line.split(`
|
|
559
|
+
`);
|
|
560
|
+
this.line = lines.pop() ?? "";
|
|
561
|
+
for (const line of lines)
|
|
562
|
+
this.readLine(line);
|
|
563
|
+
}
|
|
564
|
+
startedSession() {
|
|
565
|
+
return this.sessionId;
|
|
566
|
+
}
|
|
567
|
+
contextUsage() {
|
|
568
|
+
return this.usage.value();
|
|
569
|
+
}
|
|
570
|
+
async flush() {
|
|
571
|
+
const sending = this.sending.catch(() => {}).then(() => this.sendQueued());
|
|
572
|
+
this.sending = sending.catch(() => {});
|
|
573
|
+
return sending;
|
|
574
|
+
}
|
|
575
|
+
async sendQueued() {
|
|
576
|
+
this.drainText();
|
|
577
|
+
if (this.queued.length === 0)
|
|
578
|
+
return;
|
|
579
|
+
const batch = this.queued;
|
|
580
|
+
this.queued = [];
|
|
581
|
+
try {
|
|
582
|
+
await this.send(batch);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
this.queued.unshift(...batch);
|
|
585
|
+
throw err;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async finish(fallback) {
|
|
589
|
+
this.closeText(fallback);
|
|
590
|
+
this.queued.push({ type: "RUN_FINISHED", threadId: this.threadId, runId: this.runId });
|
|
591
|
+
await this.flush();
|
|
592
|
+
}
|
|
593
|
+
async fail(message, fallback = "") {
|
|
594
|
+
this.closeText(fallback);
|
|
595
|
+
this.queued.push({ type: "RUN_ERROR", message });
|
|
596
|
+
await this.flush();
|
|
597
|
+
}
|
|
598
|
+
closeText(fallback) {
|
|
599
|
+
if (this.line) {
|
|
600
|
+
this.readLine(this.line);
|
|
601
|
+
this.line = "";
|
|
602
|
+
}
|
|
603
|
+
if (!this.sawAnyText && fallback)
|
|
604
|
+
this.appendText(fallback);
|
|
605
|
+
this.drainText();
|
|
606
|
+
if (this.started)
|
|
607
|
+
this.queued.push({ type: "TEXT_MESSAGE_END", messageId: this.messageId });
|
|
608
|
+
this.started = false;
|
|
609
|
+
}
|
|
610
|
+
appendText(text) {
|
|
611
|
+
if (!text)
|
|
612
|
+
return;
|
|
613
|
+
this.sawAnyText = true;
|
|
614
|
+
this.text += text;
|
|
615
|
+
if (this.text.length >= FLUSH_CHARS)
|
|
616
|
+
this.drainText();
|
|
617
|
+
}
|
|
618
|
+
drainText() {
|
|
619
|
+
if (this.text.length === 0)
|
|
620
|
+
return;
|
|
621
|
+
if (!this.started) {
|
|
622
|
+
this.started = true;
|
|
623
|
+
this.queued.push({
|
|
624
|
+
type: "TEXT_MESSAGE_START",
|
|
625
|
+
messageId: this.messageId,
|
|
626
|
+
role: "assistant"
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
while (this.text.length > 0) {
|
|
630
|
+
const delta = this.text.slice(0, DELTA_LIMIT);
|
|
631
|
+
this.text = this.text.slice(DELTA_LIMIT);
|
|
632
|
+
this.queued.push({ type: "TEXT_MESSAGE_CONTENT", messageId: this.messageId, delta });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
readLine(line) {
|
|
636
|
+
const trimmed = line.trim();
|
|
637
|
+
if (!trimmed)
|
|
638
|
+
return;
|
|
639
|
+
let parsed;
|
|
640
|
+
try {
|
|
641
|
+
parsed = JSON.parse(trimmed);
|
|
642
|
+
} catch {
|
|
643
|
+
this.appendText(`${trimmed}
|
|
644
|
+
`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (!parsed || typeof parsed !== "object")
|
|
648
|
+
return;
|
|
649
|
+
this.usage.readLine(parsed);
|
|
650
|
+
switch (this.format) {
|
|
651
|
+
case "claude-stream-json":
|
|
652
|
+
this.readClaudeLine(parsed);
|
|
653
|
+
break;
|
|
654
|
+
case "codex-jsonl":
|
|
655
|
+
this.readCodexLine(parsed);
|
|
656
|
+
break;
|
|
657
|
+
case "opencode-json":
|
|
658
|
+
this.readOpencodeLine(parsed);
|
|
659
|
+
break;
|
|
660
|
+
case "antigravity-stream-json":
|
|
661
|
+
this.readAntigravityLine(parsed);
|
|
662
|
+
break;
|
|
663
|
+
case "copilot-json":
|
|
664
|
+
this.readCopilotLine(parsed);
|
|
665
|
+
break;
|
|
666
|
+
case "pi-json":
|
|
667
|
+
this.readPiLine(parsed);
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
readPiLine(message) {
|
|
672
|
+
if (message.type === "session" && message.id)
|
|
673
|
+
this.sessionId ??= message.id;
|
|
674
|
+
switch (message.type) {
|
|
675
|
+
case "message_update": {
|
|
676
|
+
const event = message.assistantMessageEvent;
|
|
677
|
+
if (event?.type === "text_delta" && event.delta) {
|
|
678
|
+
this.sawPartialText = true;
|
|
679
|
+
this.appendText(event.delta);
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
case "tool_execution_start":
|
|
684
|
+
if (message.toolCallId && !this.openToolCalls.has(message.toolCallId)) {
|
|
685
|
+
this.openToolCalls.add(message.toolCallId);
|
|
686
|
+
this.pushToolCall(message.toolCallId, message.toolName ?? "tool", JSON.stringify(message.args ?? {}));
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
case "tool_execution_end": {
|
|
690
|
+
if (!message.toolCallId || this.closedToolCalls.has(message.toolCallId))
|
|
691
|
+
return;
|
|
692
|
+
this.closedToolCalls.add(message.toolCallId);
|
|
693
|
+
this.pushToolResult(message.toolCallId, piResultText(message.result));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
case "message_end":
|
|
697
|
+
if (message.message?.role === "assistant" && !this.sawAnyText && message.message.content) {
|
|
698
|
+
const body = textOfResult(message.message.content);
|
|
699
|
+
if (body)
|
|
700
|
+
this.appendText(body);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
readCopilotLine(message) {
|
|
706
|
+
const data = message.data;
|
|
707
|
+
switch (message.type) {
|
|
708
|
+
case "result":
|
|
709
|
+
if (message.sessionId)
|
|
710
|
+
this.sessionId ??= message.sessionId;
|
|
711
|
+
return;
|
|
712
|
+
case "assistant.message_delta":
|
|
713
|
+
if (data?.deltaContent) {
|
|
714
|
+
this.sawPartialText = true;
|
|
715
|
+
this.appendText(data.deltaContent);
|
|
716
|
+
}
|
|
717
|
+
return;
|
|
718
|
+
case "assistant.message":
|
|
719
|
+
if (data?.content && !this.sawPartialText)
|
|
720
|
+
this.appendText(data.content);
|
|
721
|
+
return;
|
|
722
|
+
case "tool.execution_start":
|
|
723
|
+
if (data?.toolCallId) {
|
|
724
|
+
this.pushToolCall(data.toolCallId, data.toolName ?? "tool", JSON.stringify(data.arguments ?? {}));
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
case "tool.execution_complete": {
|
|
728
|
+
if (!data?.toolCallId)
|
|
729
|
+
return;
|
|
730
|
+
const result = data.success ? data.result?.content : data.error?.message;
|
|
731
|
+
if (typeof result === "string")
|
|
732
|
+
this.pushToolResult(data.toolCallId, result);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
readAntigravityLine(message) {
|
|
738
|
+
const step = message.step_update;
|
|
225
739
|
const conversationId = message.conversation_id ?? step?.conversation_id ?? message.result?.conversation_id;
|
|
226
740
|
if (conversationId)
|
|
227
741
|
this.sessionId ??= conversationId;
|
|
@@ -342,447 +856,160 @@ class AnswerStream {
|
|
|
342
856
|
if (block.type !== "tool_result" || !block.tool_use_id)
|
|
343
857
|
continue;
|
|
344
858
|
this.pushToolResult(block.tool_use_id, textOfResult(block.content));
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
pushToolCall(toolCallId, toolCallName, args) {
|
|
348
|
-
this.drainText();
|
|
349
|
-
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 });
|
|
350
|
-
}
|
|
351
|
-
pushToolResult(toolCallId, content) {
|
|
352
|
-
this.drainText();
|
|
353
|
-
this.queued.push({
|
|
354
|
-
type: "TOOL_CALL_RESULT",
|
|
355
|
-
messageId: this.messageId,
|
|
356
|
-
toolCallId,
|
|
357
|
-
content: tail(content, TOOL_TEXT_LIMIT)
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
class UsageReader {
|
|
363
|
-
format;
|
|
364
|
-
last = null;
|
|
365
|
-
buffered = "";
|
|
366
|
-
constructor(format) {
|
|
367
|
-
this.format = format;
|
|
368
|
-
}
|
|
369
|
-
write(chunk) {
|
|
370
|
-
if (this.format === "text")
|
|
371
|
-
return;
|
|
372
|
-
this.buffered += chunk;
|
|
373
|
-
const lines = this.buffered.split(`
|
|
374
|
-
`);
|
|
375
|
-
this.buffered = lines.pop() ?? "";
|
|
376
|
-
for (const line of lines)
|
|
377
|
-
this.readText(line);
|
|
378
|
-
}
|
|
379
|
-
end() {
|
|
380
|
-
if (!this.buffered)
|
|
381
|
-
return;
|
|
382
|
-
this.readText(this.buffered);
|
|
383
|
-
this.buffered = "";
|
|
384
|
-
}
|
|
385
|
-
value() {
|
|
386
|
-
if (this.format === "copilot-json")
|
|
387
|
-
return null;
|
|
388
|
-
return this.last ?? undefined;
|
|
389
|
-
}
|
|
390
|
-
readText(line) {
|
|
391
|
-
const trimmed = line.trim();
|
|
392
|
-
if (!trimmed)
|
|
393
|
-
return;
|
|
394
|
-
try {
|
|
395
|
-
const parsed = JSON.parse(trimmed);
|
|
396
|
-
if (parsed && typeof parsed === "object")
|
|
397
|
-
this.readLine(parsed);
|
|
398
|
-
} catch {}
|
|
399
|
-
}
|
|
400
|
-
readLine(parsed) {
|
|
401
|
-
switch (this.format) {
|
|
402
|
-
case "claude-stream-json":
|
|
403
|
-
this.readClaude(parsed);
|
|
404
|
-
return;
|
|
405
|
-
case "codex-jsonl":
|
|
406
|
-
this.readCodex(parsed);
|
|
407
|
-
return;
|
|
408
|
-
case "opencode-json":
|
|
409
|
-
this.readOpencode(parsed);
|
|
410
|
-
return;
|
|
411
|
-
case "antigravity-stream-json":
|
|
412
|
-
this.readAntigravity(parsed);
|
|
413
|
-
return;
|
|
414
|
-
case "pi-json":
|
|
415
|
-
this.readPi(parsed);
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
readClaude(message) {
|
|
420
|
-
const event = message.type === "stream_event" ? message.event : undefined;
|
|
421
|
-
if (!event)
|
|
422
|
-
return;
|
|
423
|
-
const usage = event.type === "message_start" ? event.message?.usage : event.usage;
|
|
424
|
-
if (!usage)
|
|
425
|
-
return;
|
|
426
|
-
const read = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
427
|
-
const written = usage.output_tokens ?? 0;
|
|
428
|
-
if (event.type === "message_start") {
|
|
429
|
-
this.last = { inputTokens: read, outputTokens: written };
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
if (event.type !== "message_delta")
|
|
433
|
-
return;
|
|
434
|
-
this.last = { inputTokens: read || (this.last?.inputTokens ?? 0), outputTokens: written };
|
|
435
|
-
}
|
|
436
|
-
readCodex(message) {
|
|
437
|
-
if (message.type !== "turn.completed" || !message.usage)
|
|
438
|
-
return;
|
|
439
|
-
this.last = {
|
|
440
|
-
inputTokens: message.usage.input_tokens ?? 0,
|
|
441
|
-
outputTokens: message.usage.output_tokens ?? 0
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
readOpencode(message) {
|
|
445
|
-
const tokens = message.part?.type === "step-finish" ? message.part.tokens : undefined;
|
|
446
|
-
if (!tokens)
|
|
447
|
-
return;
|
|
448
|
-
this.last = {
|
|
449
|
-
inputTokens: (tokens.input ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0),
|
|
450
|
-
outputTokens: (tokens.output ?? 0) + (tokens.reasoning ?? 0)
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
readAntigravity(message) {
|
|
454
|
-
const step = message.step_update;
|
|
455
|
-
if (!step)
|
|
456
|
-
return;
|
|
457
|
-
const usage = step.usage ?? message.usage;
|
|
458
|
-
if (!usage)
|
|
459
|
-
return;
|
|
460
|
-
this.last = {
|
|
461
|
-
inputTokens: (usage.input_tokens ?? 0) + (usage.cache_read_tokens ?? 0),
|
|
462
|
-
outputTokens: (usage.output_tokens ?? 0) + (usage.thinking_tokens ?? 0)
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
readPi(message) {
|
|
466
|
-
const usage = message.usage;
|
|
467
|
-
if (!usage)
|
|
468
|
-
return;
|
|
469
|
-
this.last = {
|
|
470
|
-
inputTokens: (usage.input ?? usage.input_tokens ?? 0) + (usage.cacheRead ?? 0),
|
|
471
|
-
outputTokens: usage.output ?? usage.output_tokens ?? 0
|
|
472
|
-
};
|
|
859
|
+
}
|
|
473
860
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
return text;
|
|
478
|
-
const mark = "…";
|
|
479
|
-
return mark + text.slice(-(limit - mark.length));
|
|
480
|
-
}
|
|
481
|
-
function textOfResult(content) {
|
|
482
|
-
if (typeof content === "string")
|
|
483
|
-
return content;
|
|
484
|
-
if (Array.isArray(content)) {
|
|
485
|
-
return content.map((part) => typeof part === "object" && part && ("text" in part) ? String(part.text) : "").join("");
|
|
861
|
+
pushToolCall(toolCallId, toolCallName, args) {
|
|
862
|
+
this.drainText();
|
|
863
|
+
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 });
|
|
486
864
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
865
|
+
pushToolResult(toolCallId, content) {
|
|
866
|
+
this.drainText();
|
|
867
|
+
this.queued.push({
|
|
868
|
+
type: "TOOL_CALL_RESULT",
|
|
869
|
+
messageId: this.messageId,
|
|
870
|
+
toolCallId,
|
|
871
|
+
content: tail(content, TOOL_TEXT_LIMIT)
|
|
872
|
+
});
|
|
492
873
|
}
|
|
493
|
-
const text = textOfResult(result);
|
|
494
|
-
return text || (result == null ? "" : JSON.stringify(result));
|
|
495
874
|
}
|
|
496
875
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
// src/presets.ts
|
|
504
|
-
var PRESETS = {
|
|
505
|
-
claude: {
|
|
506
|
-
bin: "claude",
|
|
507
|
-
outputFormat: "claude-stream-json",
|
|
508
|
-
promptVia: "stdin",
|
|
509
|
-
systemPromptFlag: "--append-system-prompt",
|
|
510
|
-
head: (sessionId) => [
|
|
511
|
-
"-p",
|
|
512
|
-
...sessionId ? ["--resume", sessionId] : [],
|
|
513
|
-
"--output-format",
|
|
514
|
-
"stream-json",
|
|
515
|
-
"--include-partial-messages",
|
|
516
|
-
"--verbose",
|
|
517
|
-
"--permission-mode",
|
|
518
|
-
"auto"
|
|
519
|
-
],
|
|
520
|
-
tail: []
|
|
521
|
-
},
|
|
522
|
-
codex: {
|
|
523
|
-
bin: "codex",
|
|
524
|
-
outputFormat: "codex-jsonl",
|
|
525
|
-
promptVia: "stdin",
|
|
526
|
-
head: (sessionId) => [
|
|
527
|
-
...sessionId ? ["exec", "resume", sessionId] : ["exec"],
|
|
528
|
-
"--json",
|
|
529
|
-
"-c",
|
|
530
|
-
'sandbox_mode="workspace-write"'
|
|
531
|
-
],
|
|
532
|
-
tail: ["-"]
|
|
533
|
-
},
|
|
534
|
-
opencode: {
|
|
535
|
-
bin: "opencode",
|
|
536
|
-
outputFormat: "opencode-json",
|
|
537
|
-
promptVia: "arg",
|
|
538
|
-
head: (sessionId) => [
|
|
539
|
-
"run",
|
|
540
|
-
"--format",
|
|
541
|
-
"json",
|
|
542
|
-
...sessionId ? ["--session", sessionId] : []
|
|
543
|
-
],
|
|
544
|
-
tail: []
|
|
545
|
-
},
|
|
546
|
-
antigravity: {
|
|
547
|
-
bin: "agy",
|
|
548
|
-
outputFormat: "antigravity-stream-json",
|
|
549
|
-
promptVia: "arg",
|
|
550
|
-
head: (sessionId) => [
|
|
551
|
-
...sessionId ? ["--conversation", sessionId] : [],
|
|
552
|
-
"--output-format",
|
|
553
|
-
"stream-json",
|
|
554
|
-
"--dangerously-skip-permissions",
|
|
555
|
-
"--print-timeout",
|
|
556
|
-
"24h"
|
|
557
|
-
],
|
|
558
|
-
tail: ["-p"]
|
|
559
|
-
},
|
|
560
|
-
copilot: {
|
|
561
|
-
bin: "copilot",
|
|
562
|
-
outputFormat: "copilot-json",
|
|
563
|
-
promptVia: "arg",
|
|
564
|
-
head: (sessionId) => [
|
|
565
|
-
"--allow-all-tools",
|
|
566
|
-
"--no-ask-user",
|
|
567
|
-
"--output-format",
|
|
568
|
-
"json",
|
|
569
|
-
...sessionId ? ["--session-id", sessionId] : []
|
|
570
|
-
],
|
|
571
|
-
tail: ["-p"]
|
|
572
|
-
},
|
|
573
|
-
pi: {
|
|
574
|
-
bin: "pi",
|
|
575
|
-
outputFormat: "pi-json",
|
|
576
|
-
promptVia: "arg",
|
|
577
|
-
systemPromptFlag: "--append-system-prompt",
|
|
578
|
-
head: (sessionId) => [
|
|
579
|
-
"-p",
|
|
580
|
-
"--mode",
|
|
581
|
-
"json",
|
|
582
|
-
...sessionId ? ["--session-id", sessionId] : []
|
|
583
|
-
],
|
|
584
|
-
tail: []
|
|
585
|
-
},
|
|
586
|
-
omp: {
|
|
587
|
-
bin: "omp",
|
|
588
|
-
outputFormat: "pi-json",
|
|
589
|
-
promptVia: "arg",
|
|
590
|
-
systemPromptFlag: "--append-system-prompt",
|
|
591
|
-
head: (sessionId) => [
|
|
592
|
-
"-p",
|
|
593
|
-
"--mode",
|
|
594
|
-
"json",
|
|
595
|
-
"--auto-approve",
|
|
596
|
-
...sessionId ? ["--resume", sessionId] : []
|
|
597
|
-
],
|
|
598
|
-
tail: []
|
|
876
|
+
class UsageReader {
|
|
877
|
+
format;
|
|
878
|
+
last = null;
|
|
879
|
+
buffered = "";
|
|
880
|
+
constructor(format) {
|
|
881
|
+
this.format = format;
|
|
599
882
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
${prompt}`;
|
|
611
|
-
}
|
|
612
|
-
function presetArgv(preset, sessionId, systemPrompt, extraArgs, prompt) {
|
|
613
|
-
return [
|
|
614
|
-
...preset.head(sessionId),
|
|
615
|
-
...preset.systemPromptFlag && systemPrompt ? [preset.systemPromptFlag, systemPrompt] : [],
|
|
616
|
-
...extraArgs,
|
|
617
|
-
...preset.tail,
|
|
618
|
-
...preset.promptVia === "arg" ? [presetPrompt(preset, systemPrompt, prompt)] : []
|
|
619
|
-
];
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// src/config.ts
|
|
623
|
-
var OUTPUT_FORMATS = [
|
|
624
|
-
"text",
|
|
625
|
-
"claude-stream-json",
|
|
626
|
-
"codex-jsonl",
|
|
627
|
-
"opencode-json",
|
|
628
|
-
"antigravity-stream-json",
|
|
629
|
-
"copilot-json",
|
|
630
|
-
"pi-json"
|
|
631
|
-
];
|
|
632
|
-
function presetOf(config) {
|
|
633
|
-
if (config.command || !config.agent)
|
|
634
|
-
return;
|
|
635
|
-
return PRESETS[config.agent];
|
|
636
|
-
}
|
|
637
|
-
var DEFAULTS = {
|
|
638
|
-
concurrency: 1,
|
|
639
|
-
pollIntervalMs: 3000,
|
|
640
|
-
timeoutMs: 30 * 60 * 1000
|
|
641
|
-
};
|
|
642
|
-
var MIN_POLL_INTERVAL_MS = 1000;
|
|
643
|
-
function textOf(value) {
|
|
644
|
-
const text = typeof value === "string" ? value.trim() : "";
|
|
645
|
-
return text || undefined;
|
|
646
|
-
}
|
|
647
|
-
function intFrom(value, fallback) {
|
|
648
|
-
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : value;
|
|
649
|
-
return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
650
|
-
}
|
|
651
|
-
function outputFormatFrom(value, preset) {
|
|
652
|
-
const name = textOf(value);
|
|
653
|
-
if (!name)
|
|
654
|
-
return preset?.outputFormat ?? "text";
|
|
655
|
-
const format = OUTPUT_FORMATS.find((candidate) => candidate === name);
|
|
656
|
-
if (!format) {
|
|
657
|
-
throw new Error(`outputFormat must be one of ${OUTPUT_FORMATS.join(", ")}`);
|
|
883
|
+
write(chunk) {
|
|
884
|
+
if (this.format === "text")
|
|
885
|
+
return;
|
|
886
|
+
this.buffered += chunk;
|
|
887
|
+
const lines = this.buffered.split(`
|
|
888
|
+
`);
|
|
889
|
+
this.buffered = lines.pop() ?? "";
|
|
890
|
+
for (const line of lines)
|
|
891
|
+
this.readText(line);
|
|
658
892
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
return;
|
|
665
|
-
if (!isPresetName(name)) {
|
|
666
|
-
throw new Error(`agent must be one of ${PRESET_NAMES.join(", ")}`);
|
|
893
|
+
end() {
|
|
894
|
+
if (!this.buffered)
|
|
895
|
+
return;
|
|
896
|
+
this.readText(this.buffered);
|
|
897
|
+
this.buffered = "";
|
|
667
898
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
899
|
+
value() {
|
|
900
|
+
if (this.format === "copilot-json")
|
|
901
|
+
return null;
|
|
902
|
+
return this.last ?? undefined;
|
|
903
|
+
}
|
|
904
|
+
readText(line) {
|
|
905
|
+
const trimmed = line.trim();
|
|
906
|
+
if (!trimmed)
|
|
907
|
+
return;
|
|
908
|
+
try {
|
|
909
|
+
const parsed = JSON.parse(trimmed);
|
|
910
|
+
if (parsed && typeof parsed === "object")
|
|
911
|
+
this.readLine(parsed);
|
|
912
|
+
} catch {}
|
|
913
|
+
}
|
|
914
|
+
readLine(parsed) {
|
|
915
|
+
switch (this.format) {
|
|
916
|
+
case "claude-stream-json":
|
|
917
|
+
this.readClaude(parsed);
|
|
918
|
+
return;
|
|
919
|
+
case "codex-jsonl":
|
|
920
|
+
this.readCodex(parsed);
|
|
921
|
+
return;
|
|
922
|
+
case "opencode-json":
|
|
923
|
+
this.readOpencode(parsed);
|
|
924
|
+
return;
|
|
925
|
+
case "antigravity-stream-json":
|
|
926
|
+
this.readAntigravity(parsed);
|
|
927
|
+
return;
|
|
928
|
+
case "pi-json":
|
|
929
|
+
this.readPi(parsed);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
675
932
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if (
|
|
690
|
-
return
|
|
691
|
-
|
|
933
|
+
readClaude(message) {
|
|
934
|
+
const event = message.type === "stream_event" ? message.event : undefined;
|
|
935
|
+
if (!event)
|
|
936
|
+
return;
|
|
937
|
+
const usage = event.type === "message_start" ? event.message?.usage : event.usage;
|
|
938
|
+
if (!usage)
|
|
939
|
+
return;
|
|
940
|
+
const read = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
941
|
+
const written = usage.output_tokens ?? 0;
|
|
942
|
+
if (event.type === "message_start") {
|
|
943
|
+
this.last = { inputTokens: read, outputTokens: written };
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (event.type !== "message_delta")
|
|
947
|
+
return;
|
|
948
|
+
this.last = { inputTokens: read || (this.last?.inputTokens ?? 0), outputTokens: written };
|
|
692
949
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
950
|
+
readCodex(message) {
|
|
951
|
+
if (message.type !== "turn.completed" || !message.usage)
|
|
952
|
+
return;
|
|
953
|
+
this.last = {
|
|
954
|
+
inputTokens: message.usage.input_tokens ?? 0,
|
|
955
|
+
outputTokens: message.usage.output_tokens ?? 0
|
|
956
|
+
};
|
|
697
957
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
958
|
+
readOpencode(message) {
|
|
959
|
+
const tokens = message.part?.type === "step-finish" ? message.part.tokens : undefined;
|
|
960
|
+
if (!tokens)
|
|
961
|
+
return;
|
|
962
|
+
this.last = {
|
|
963
|
+
inputTokens: (tokens.input ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0),
|
|
964
|
+
outputTokens: (tokens.output ?? 0) + (tokens.reasoning ?? 0)
|
|
965
|
+
};
|
|
702
966
|
}
|
|
703
|
-
|
|
704
|
-
const
|
|
705
|
-
if (!
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
967
|
+
readAntigravity(message) {
|
|
968
|
+
const step = message.step_update;
|
|
969
|
+
if (!step)
|
|
970
|
+
return;
|
|
971
|
+
const usage = step.usage ?? message.usage;
|
|
972
|
+
if (!usage)
|
|
973
|
+
return;
|
|
974
|
+
this.last = {
|
|
975
|
+
inputTokens: (usage.input_tokens ?? 0) + (usage.cache_read_tokens ?? 0),
|
|
976
|
+
outputTokens: (usage.output_tokens ?? 0) + (usage.thinking_tokens ?? 0)
|
|
977
|
+
};
|
|
709
978
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
979
|
+
readPi(message) {
|
|
980
|
+
const usage = message.usage;
|
|
981
|
+
if (!usage)
|
|
982
|
+
return;
|
|
983
|
+
this.last = {
|
|
984
|
+
inputTokens: (usage.input ?? usage.input_tokens ?? 0) + (usage.cacheRead ?? 0),
|
|
985
|
+
outputTokens: usage.output ?? usage.output_tokens ?? 0
|
|
986
|
+
};
|
|
715
987
|
}
|
|
716
|
-
return agents;
|
|
717
|
-
}
|
|
718
|
-
function merge(shared, own) {
|
|
719
|
-
const merged = { ...shared, ...own };
|
|
720
|
-
const mode = textOf(own.agent) ?? textOf(own.command);
|
|
721
|
-
const changesMode = mode !== undefined && mode !== (textOf(shared.agent) ?? textOf(shared.command));
|
|
722
|
-
if (textOf(own.agent))
|
|
723
|
-
delete merged.command;
|
|
724
|
-
else if (textOf(own.command))
|
|
725
|
-
delete merged.agent;
|
|
726
|
-
if (changesMode && !textOf(own.outputFormat))
|
|
727
|
-
delete merged.outputFormat;
|
|
728
|
-
return merged;
|
|
729
|
-
}
|
|
730
|
-
function nameOf(entry, index, total) {
|
|
731
|
-
if (total === 1)
|
|
732
|
-
return "";
|
|
733
|
-
return textOf(entry.name) ?? `#${index + 1}`;
|
|
734
988
|
}
|
|
735
|
-
function
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
741
|
-
return {
|
|
742
|
-
name,
|
|
743
|
-
url: required(fields.url, "url").replace(/\/+$/, ""),
|
|
744
|
-
apiKey: required(fields.apiKey, "apiKey"),
|
|
745
|
-
agent,
|
|
746
|
-
command,
|
|
747
|
-
args: [...argsFrom(fields.args), ...extraArgs],
|
|
748
|
-
cwd: textOf(fields.cwd)?.replace(/^~/, process.env.HOME ?? "~"),
|
|
749
|
-
env: fields.env ?? {},
|
|
750
|
-
concurrency: intFrom(fields.concurrency, DEFAULTS.concurrency),
|
|
751
|
-
pollIntervalMs: Math.max(intFrom(fields.pollIntervalMs, DEFAULTS.pollIntervalMs), MIN_POLL_INTERVAL_MS),
|
|
752
|
-
timeoutMs: intFrom(fields.timeoutMs, DEFAULTS.timeoutMs),
|
|
753
|
-
outputFormat: outputFormatFrom(fields.outputFormat, presetOf({ agent, command }))
|
|
754
|
-
};
|
|
989
|
+
function tail(text, limit) {
|
|
990
|
+
if (text.length <= limit)
|
|
991
|
+
return text;
|
|
992
|
+
const mark = "…";
|
|
993
|
+
return mark + text.slice(-(limit - mark.length));
|
|
755
994
|
}
|
|
756
|
-
function
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
agent: overrides.agent ?? env.ITSAPLAN_AGENT,
|
|
762
|
-
command: env.ITSAPLAN_COMMAND,
|
|
763
|
-
cwd: env.ITSAPLAN_CWD,
|
|
764
|
-
concurrency: env.ITSAPLAN_CONCURRENCY,
|
|
765
|
-
pollIntervalMs: env.ITSAPLAN_POLL_INTERVAL_MS,
|
|
766
|
-
timeoutMs: env.ITSAPLAN_TIMEOUT_MS,
|
|
767
|
-
outputFormat: env.ITSAPLAN_OUTPUT_FORMAT
|
|
768
|
-
};
|
|
769
|
-
for (const [field, value] of Object.entries(outside)) {
|
|
770
|
-
if (!textOf(value))
|
|
771
|
-
delete outside[field];
|
|
995
|
+
function textOfResult(content) {
|
|
996
|
+
if (typeof content === "string")
|
|
997
|
+
return content;
|
|
998
|
+
if (Array.isArray(content)) {
|
|
999
|
+
return content.map((part) => typeof part === "object" && part && ("text" in part) ? String(part.text) : "").join("");
|
|
772
1000
|
}
|
|
773
|
-
return
|
|
1001
|
+
return "";
|
|
774
1002
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
if (entries.length === 0) {
|
|
779
|
-
throw new Error("no agents configured — apiKeys and agents cannot be empty");
|
|
1003
|
+
function piResultText(result) {
|
|
1004
|
+
if (result && typeof result === "object" && !Array.isArray(result) && "content" in result) {
|
|
1005
|
+
return textOfResult(result.content);
|
|
780
1006
|
}
|
|
781
|
-
const
|
|
782
|
-
return
|
|
1007
|
+
const text = textOfResult(result);
|
|
1008
|
+
return text || (result == null ? "" : JSON.stringify(result));
|
|
783
1009
|
}
|
|
784
1010
|
|
|
785
1011
|
// src/execute.ts
|
|
1012
|
+
import { spawn } from "node:child_process";
|
|
786
1013
|
var OUTPUT_LIMIT = 8000;
|
|
787
1014
|
var ERROR_LIMIT = 400;
|
|
788
1015
|
function tail2(text, limit) {
|
|
@@ -794,7 +1021,8 @@ function childEnv(config, task) {
|
|
|
794
1021
|
...config.env,
|
|
795
1022
|
ITSAPLAN_URL: config.url,
|
|
796
1023
|
ITSAPLAN_API_KEY: config.apiKey,
|
|
797
|
-
...task.env
|
|
1024
|
+
...task.env,
|
|
1025
|
+
ITSAPLAN_TIMEOUT_MS: String(config.timeoutMs)
|
|
798
1026
|
};
|
|
799
1027
|
}
|
|
800
1028
|
function killGroup(pid) {
|
|
@@ -910,85 +1138,11 @@ async function answer(config, client, message, stop) {
|
|
|
910
1138
|
await client.chatResult(message.id, { status: "failed", error, usage: stream.contextUsage() });
|
|
911
1139
|
}
|
|
912
1140
|
|
|
913
|
-
// src/
|
|
914
|
-
var REQUEST_TIMEOUT_MS = 30000;
|
|
915
|
-
var CHAT_CLAIM_TIMEOUT_MS = 35000;
|
|
916
|
-
|
|
917
|
-
class RequestError extends Error {
|
|
918
|
-
status;
|
|
919
|
-
constructor(status, message) {
|
|
920
|
-
super(message);
|
|
921
|
-
this.status = status;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
class Client {
|
|
926
|
-
config;
|
|
927
|
-
constructor(config) {
|
|
928
|
-
this.config = config;
|
|
929
|
-
}
|
|
930
|
-
async post(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
931
|
-
const res = await fetch(`${this.config.url}${path}`, {
|
|
932
|
-
method: "POST",
|
|
933
|
-
headers: {
|
|
934
|
-
"x-api-key": this.config.apiKey,
|
|
935
|
-
...body === undefined ? {} : { "content-type": "application/json" }
|
|
936
|
-
},
|
|
937
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
938
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
939
|
-
});
|
|
940
|
-
if (!res.ok) {
|
|
941
|
-
throw new RequestError(res.status, `POST ${path} failed with ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
942
|
-
}
|
|
943
|
-
return res;
|
|
944
|
-
}
|
|
945
|
-
async claim() {
|
|
946
|
-
const res = await this.post("/agent-runs/claim");
|
|
947
|
-
const body = await res.json();
|
|
948
|
-
return body.run;
|
|
949
|
-
}
|
|
950
|
-
async heartbeat(runId) {
|
|
951
|
-
await this.post(`/agent-runs/${runId}/heartbeat`);
|
|
952
|
-
}
|
|
953
|
-
async report(runId, result) {
|
|
954
|
-
await this.post(`/agent-runs/${runId}/result`, result);
|
|
955
|
-
}
|
|
956
|
-
async claimChat() {
|
|
957
|
-
const res = await this.post("/agent-chats/claim", undefined, CHAT_CLAIM_TIMEOUT_MS);
|
|
958
|
-
const body = await res.json();
|
|
959
|
-
return body.message;
|
|
960
|
-
}
|
|
961
|
-
async chatEvents(messageId, events, sessionId) {
|
|
962
|
-
const res = await this.post(`/agent-chats/${messageId}/events`, {
|
|
963
|
-
events,
|
|
964
|
-
...sessionId && { sessionId }
|
|
965
|
-
});
|
|
966
|
-
return canceled(res);
|
|
967
|
-
}
|
|
968
|
-
async chatHeartbeat(messageId) {
|
|
969
|
-
return canceled(await this.post(`/agent-chats/${messageId}/heartbeat`));
|
|
970
|
-
}
|
|
971
|
-
async chatResult(messageId, result) {
|
|
972
|
-
await this.post(`/agent-chats/${messageId}/result`, result);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
async function canceled(res) {
|
|
976
|
-
const body = await res.json().catch(() => ({}));
|
|
977
|
-
return body.canceled === true;
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
// src/cli.ts
|
|
1141
|
+
// src/tasks.ts
|
|
981
1142
|
var HEARTBEAT_MS = 60000;
|
|
982
|
-
|
|
983
|
-
function prefixOf(name) {
|
|
984
|
-
return name ? `[itsaplan-runner ${name}]` : "[itsaplan-runner]";
|
|
985
|
-
}
|
|
986
|
-
function log(message) {
|
|
987
|
-
console.log(`${prefixOf("")} ${message}`);
|
|
988
|
-
}
|
|
989
|
-
async function withHeartbeat(log2, beat, work) {
|
|
1143
|
+
async function withHeartbeat(log, beat, work) {
|
|
990
1144
|
const timer = setInterval(() => {
|
|
991
|
-
beat().catch((err) =>
|
|
1145
|
+
beat().catch((err) => log(`heartbeat failed: ${String(err)}`));
|
|
992
1146
|
}, HEARTBEAT_MS);
|
|
993
1147
|
try {
|
|
994
1148
|
return await work;
|
|
@@ -1009,40 +1163,51 @@ function taskOf(run) {
|
|
|
1009
1163
|
}
|
|
1010
1164
|
};
|
|
1011
1165
|
}
|
|
1012
|
-
async function
|
|
1166
|
+
async function handleRun(config, client, log, run) {
|
|
1013
1167
|
const label = run.issueIdentifier ?? `run ${run.id}`;
|
|
1014
|
-
|
|
1168
|
+
log(`${label}: started (${run.trigger})`);
|
|
1015
1169
|
try {
|
|
1016
1170
|
const usage = new UsageReader(config.outputFormat);
|
|
1017
|
-
const outcome = await withHeartbeat(
|
|
1171
|
+
const outcome = await withHeartbeat(log, () => client.heartbeat(run.id), execute(config, taskOf(run), { onData: (chunk) => usage.write(chunk) }));
|
|
1018
1172
|
usage.end();
|
|
1019
1173
|
await client.report(run.id, { ...outcome, usage: usage.value() });
|
|
1020
|
-
|
|
1174
|
+
log(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
|
|
1021
1175
|
} catch (err) {
|
|
1022
1176
|
const message = err instanceof Error ? err.message : String(err);
|
|
1023
|
-
|
|
1177
|
+
log(`${label}: runner error — ${message}`);
|
|
1024
1178
|
await client.report(run.id, { status: "failed", error: message }).catch(() => {});
|
|
1025
1179
|
}
|
|
1026
1180
|
}
|
|
1027
|
-
async function handleChat(config, client,
|
|
1028
|
-
|
|
1181
|
+
async function handleChat(config, client, log, message) {
|
|
1182
|
+
log(`chat ${message.id}: answering`);
|
|
1029
1183
|
const stop = new AbortController;
|
|
1030
1184
|
try {
|
|
1031
|
-
await withHeartbeat(
|
|
1185
|
+
await withHeartbeat(log, async () => {
|
|
1032
1186
|
if (await client.chatHeartbeat(message.id))
|
|
1033
1187
|
stop.abort();
|
|
1034
1188
|
}, answer(config, client, message, stop));
|
|
1035
1189
|
} catch (err) {
|
|
1036
1190
|
if (!stop.signal.aborted) {
|
|
1037
1191
|
const text = err instanceof Error ? err.message : String(err);
|
|
1038
|
-
|
|
1192
|
+
log(`chat ${message.id}: runner error — ${text}`);
|
|
1039
1193
|
await client.chatResult(message.id, { status: "failed", error: text }).catch(() => {});
|
|
1040
1194
|
return;
|
|
1041
1195
|
}
|
|
1042
1196
|
}
|
|
1043
|
-
|
|
1197
|
+
log(`chat ${message.id}: ${stop.signal.aborted ? "stopped" : "answered"}`);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// src/cli.ts
|
|
1201
|
+
var ERROR_BACKOFF_MS = 5000;
|
|
1202
|
+
function prefixOf(name) {
|
|
1203
|
+
return name ? `[itsaplan-runner ${name}]` : "[itsaplan-runner]";
|
|
1204
|
+
}
|
|
1205
|
+
function logFor(name) {
|
|
1206
|
+
const prefix = prefixOf(name);
|
|
1207
|
+
return (message) => console.log(`${prefix} ${message}`);
|
|
1044
1208
|
}
|
|
1045
|
-
|
|
1209
|
+
var log = logFor("");
|
|
1210
|
+
async function drain(state, log, concurrency, take, run, onEmpty) {
|
|
1046
1211
|
const active = new Set;
|
|
1047
1212
|
let done = false;
|
|
1048
1213
|
while (!state.stopping && !done) {
|
|
@@ -1056,7 +1221,7 @@ async function drain(state, log2, concurrency, take, run, onEmpty) {
|
|
|
1056
1221
|
} catch (err) {
|
|
1057
1222
|
if (err instanceof RequestError && (err.status === 401 || err.status === 403))
|
|
1058
1223
|
throw err;
|
|
1059
|
-
|
|
1224
|
+
log(`claim failed: ${String(err)}`);
|
|
1060
1225
|
await sleep(ERROR_BACKOFF_MS);
|
|
1061
1226
|
continue;
|
|
1062
1227
|
}
|
|
@@ -1094,36 +1259,68 @@ function parseArgv(argv) {
|
|
|
1094
1259
|
}
|
|
1095
1260
|
return parsed;
|
|
1096
1261
|
}
|
|
1097
|
-
async function
|
|
1262
|
+
async function poll(state, config) {
|
|
1098
1263
|
const client = new Client(config);
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
log2(`running ${config.agent ?? "the configured command"}, polling ${config.url} every ` + `${config.pollIntervalMs}ms, up to ${config.concurrency} at once`);
|
|
1264
|
+
const log = logFor(config.name);
|
|
1265
|
+
log(`running ${config.agent ?? "the configured command"}, polling ${config.url} every ` + `${config.pollIntervalMs}ms, up to ${config.concurrency} at once`);
|
|
1102
1266
|
let chatSupported = true;
|
|
1103
1267
|
await Promise.all([
|
|
1104
|
-
drain(state,
|
|
1268
|
+
drain(state, log, config.concurrency, () => client.claim(), (run) => handleRun(config, client, log, run), async () => {
|
|
1105
1269
|
await sleep(config.pollIntervalMs);
|
|
1106
1270
|
return true;
|
|
1107
1271
|
}),
|
|
1108
|
-
drain(state,
|
|
1272
|
+
drain(state, log, config.concurrency, async () => {
|
|
1109
1273
|
try {
|
|
1110
1274
|
return await client.claimChat();
|
|
1111
1275
|
} catch (err) {
|
|
1112
1276
|
if (err instanceof RequestError && err.status === 404) {
|
|
1113
|
-
|
|
1277
|
+
log("this instance has no chat feed — only queued runs will be answered");
|
|
1114
1278
|
chatSupported = false;
|
|
1115
1279
|
return null;
|
|
1116
1280
|
}
|
|
1117
1281
|
throw err;
|
|
1118
1282
|
}
|
|
1119
|
-
}, (message) => handleChat(config, client,
|
|
1283
|
+
}, (message) => handleChat(config, client, log, message), () => Promise.resolve(chatSupported))
|
|
1120
1284
|
]);
|
|
1121
1285
|
}
|
|
1286
|
+
async function listenOn(state, port, configs) {
|
|
1287
|
+
const listener = await startListener(port, configs.map((config) => {
|
|
1288
|
+
const client = new Client(config);
|
|
1289
|
+
const log = logFor(config.name);
|
|
1290
|
+
log(`running ${config.agent ?? "the configured command"} for the tasks ${config.url} ` + `sends to port ${port}, up to ${config.concurrency} at once`);
|
|
1291
|
+
return {
|
|
1292
|
+
secret: config.signingSecret,
|
|
1293
|
+
concurrency: config.concurrency,
|
|
1294
|
+
run: (run) => handleRun(config, client, log, run),
|
|
1295
|
+
chat: (message) => handleChat(config, client, log, message)
|
|
1296
|
+
};
|
|
1297
|
+
}), log);
|
|
1298
|
+
log(`listening on port ${listener.port}`);
|
|
1299
|
+
await state.stopped;
|
|
1300
|
+
await listener.close();
|
|
1301
|
+
}
|
|
1302
|
+
function byPort(configs) {
|
|
1303
|
+
const ports = new Map;
|
|
1304
|
+
for (const config of configs) {
|
|
1305
|
+
if (config.listen === undefined)
|
|
1306
|
+
continue;
|
|
1307
|
+
ports.set(config.listen, [...ports.get(config.listen) ?? [], config]);
|
|
1308
|
+
}
|
|
1309
|
+
return ports;
|
|
1310
|
+
}
|
|
1311
|
+
function settle(name, work) {
|
|
1312
|
+
return work.then(() => true, (err) => {
|
|
1313
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1314
|
+
console.error(`${prefixOf(name)} stopped — ${message}`);
|
|
1315
|
+
return false;
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1122
1318
|
async function main() {
|
|
1123
1319
|
const cli = parseArgv(process.argv.slice(2));
|
|
1124
1320
|
const configPath = cli.configPath ?? process.env.ITSAPLAN_RUNNER_CONFIG ?? "./itsaplan-runner.json";
|
|
1125
1321
|
const configs = await loadConfig(configPath, { agent: cli.agent, args: cli.args });
|
|
1126
|
-
|
|
1322
|
+
let stop;
|
|
1323
|
+
const state = { stopping: false, stopped: new Promise((resolve) => stop = resolve) };
|
|
1127
1324
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1128
1325
|
process.on(signal, () => {
|
|
1129
1326
|
if (state.stopping) {
|
|
@@ -1131,14 +1328,14 @@ async function main() {
|
|
|
1131
1328
|
process.exit(1);
|
|
1132
1329
|
}
|
|
1133
1330
|
state.stopping = true;
|
|
1331
|
+
stop();
|
|
1134
1332
|
log("stopping — finishing the tasks in flight, press again to quit now");
|
|
1135
1333
|
});
|
|
1136
1334
|
}
|
|
1137
|
-
const served = await Promise.all(
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
})));
|
|
1335
|
+
const served = await Promise.all([
|
|
1336
|
+
...configs.filter((config) => config.listen === undefined).map((config) => settle(config.name, poll(state, config))),
|
|
1337
|
+
...[...byPort(configs)].map(([port, listening]) => settle(listening.length === 1 ? listening[0].name : "", listenOn(state, port, listening)))
|
|
1338
|
+
]);
|
|
1142
1339
|
if (served.includes(false))
|
|
1143
1340
|
process.exit(1);
|
|
1144
1341
|
}
|