@monotykamary/localterm-server 2.41.0 → 2.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-runner.d.ts +41 -0
- package/dist/agent-runner.d.ts.map +1 -0
- package/dist/agent-runner.js +873 -0
- package/dist/agent-runner.js.map +1 -0
- package/dist/agent-skills.d.ts +4 -0
- package/dist/agent-skills.d.ts.map +1 -0
- package/dist/agent-skills.js +169 -0
- package/dist/agent-skills.js.map +1 -0
- package/dist/automation-run-tracker.js +1 -1
- package/dist/automation-run-tracker.js.map +1 -1
- package/dist/automation-store.d.ts +4 -0
- package/dist/automation-store.d.ts.map +1 -1
- package/dist/automation-store.js +178 -14
- package/dist/automation-store.js.map +1 -1
- package/dist/constants.d.ts +15 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +53 -4
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +221 -11
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/schemas.d.ts +730 -8
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +199 -11
- package/dist/schemas.js.map +1 -1
- package/dist/session-manager.d.ts +3 -1
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +20 -2
- package/dist/session-manager.js.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/strip-ansi.d.ts +2 -0
- package/dist/utils/strip-ansi.d.ts.map +1 -0
- package/dist/utils/strip-ansi.js +16 -0
- package/dist/utils/strip-ansi.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AUTOMATION_AGENT_RUN_TIMEOUT_MS, AUTOMATION_SESSION_TOOL_MAX_BYTES, AUTOMATION_SESSION_TOOL_MAX_LINES, MAX_AUTOMATION_CHANGED_FILES, MAX_AUTOMATION_FINDINGS_LENGTH, MAX_AUTOMATION_LOG_ENTRIES, MAX_AUTOMATION_LOG_LENGTH, MAX_AUTOMATION_TOOL_INPUT_LENGTH, MAX_AUTOMATION_TOOL_RESULT_LENGTH, } from "./constants.js";
|
|
6
|
+
const FINDINGS_TRUNCATION_MARKER = "\n…[truncated]";
|
|
7
|
+
const LOG_TRUNCATION_MARKER = "\n…[log truncated]";
|
|
8
|
+
let cachedPi;
|
|
9
|
+
const pathWithoutShims = (pathVar, shimsDir) => pathVar
|
|
10
|
+
.split(path.delimiter)
|
|
11
|
+
.filter((dir) => dir.length > 0 && path.resolve(dir) !== path.resolve(shimsDir))
|
|
12
|
+
.join(path.delimiter);
|
|
13
|
+
const scanPathForPi = (pathVar, shimsDir) => {
|
|
14
|
+
for (const dir of pathVar.split(path.delimiter)) {
|
|
15
|
+
if (dir.length === 0 || path.resolve(dir) === path.resolve(shimsDir))
|
|
16
|
+
continue;
|
|
17
|
+
const candidate = path.join(dir, "pi");
|
|
18
|
+
try {
|
|
19
|
+
if (fs.statSync(candidate).isFile()) {
|
|
20
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// not present or not executable in this dir
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
// Fallback: the user's login interactive shell PATH, which sources the RC
|
|
31
|
+
// that adds pi's directory (e.g. ~/.npm-global/bin via ~/.zshrc). The localterm
|
|
32
|
+
// shims dir is typically first in the login PATH, so the caller scans the
|
|
33
|
+
// result minus the shims dir to land on the real binary, not the
|
|
34
|
+
// secret-injecting shim. The PATH is printed with delimiters to survive shell
|
|
35
|
+
// hooks like OSC-7 working-directory reports that write to stdout. stdin is
|
|
36
|
+
// empty so an interactive shell with `-c` runs the command and exits.
|
|
37
|
+
const resolveLoginPath = () => {
|
|
38
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
39
|
+
try {
|
|
40
|
+
const result = spawnSync(shell, ["-l", "-i", "-c", "printf 'PIPATHBEGIN%sPIPATHEND' \"$PATH\""], {
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
input: "",
|
|
43
|
+
timeout: 10_000,
|
|
44
|
+
});
|
|
45
|
+
const stdout = result.stdout || "";
|
|
46
|
+
const start = stdout.indexOf("PIPATHBEGIN");
|
|
47
|
+
const end = stdout.indexOf("PIPATHEND", start === -1 ? 0 : start);
|
|
48
|
+
if (start === -1 || end === -1)
|
|
49
|
+
return "";
|
|
50
|
+
return stdout.slice(start + "PIPATHBEGIN".length, end);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const resolvePiAndPath = (shimsDir, override) => {
|
|
57
|
+
if (override)
|
|
58
|
+
return { binary: override, pathEnv: process.env.PATH ?? "" };
|
|
59
|
+
if (cachedPi)
|
|
60
|
+
return cachedPi;
|
|
61
|
+
const daemonPath = process.env.PATH ?? "";
|
|
62
|
+
const fromDaemon = scanPathForPi(daemonPath, shimsDir);
|
|
63
|
+
if (fromDaemon) {
|
|
64
|
+
cachedPi = { binary: fromDaemon, pathEnv: pathWithoutShims(daemonPath, shimsDir) };
|
|
65
|
+
return cachedPi;
|
|
66
|
+
}
|
|
67
|
+
const loginPath = resolveLoginPath();
|
|
68
|
+
const fromLogin = scanPathForPi(loginPath, shimsDir);
|
|
69
|
+
const pathEnv = pathWithoutShims(loginPath || daemonPath, shimsDir);
|
|
70
|
+
if (fromLogin)
|
|
71
|
+
cachedPi = { binary: fromLogin, pathEnv };
|
|
72
|
+
return { binary: fromLogin, pathEnv };
|
|
73
|
+
};
|
|
74
|
+
const parseGitStatus = (output) => {
|
|
75
|
+
const set = new Set();
|
|
76
|
+
for (const line of output.split("\n")) {
|
|
77
|
+
if (line.length < 3)
|
|
78
|
+
continue;
|
|
79
|
+
let filePath = line.slice(3);
|
|
80
|
+
const arrow = filePath.indexOf(" -> ");
|
|
81
|
+
if (arrow !== -1)
|
|
82
|
+
filePath = filePath.slice(arrow + 4);
|
|
83
|
+
if (filePath.startsWith('"') && filePath.endsWith('"')) {
|
|
84
|
+
filePath = filePath.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
if (filePath.length > 0)
|
|
87
|
+
set.add(filePath);
|
|
88
|
+
}
|
|
89
|
+
return set;
|
|
90
|
+
};
|
|
91
|
+
const gitStatusSet = (cwd) => {
|
|
92
|
+
try {
|
|
93
|
+
const result = spawnSync("git", ["-C", cwd, "status", "--porcelain"], {
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
timeout: 5000,
|
|
96
|
+
});
|
|
97
|
+
if (result.error || result.status !== 0)
|
|
98
|
+
return new Set();
|
|
99
|
+
return parseGitStatus(result.stdout);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return new Set();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const computeChangedFiles = (before, cwd) => {
|
|
106
|
+
const after = gitStatusSet(cwd);
|
|
107
|
+
const changed = [];
|
|
108
|
+
for (const filePath of after)
|
|
109
|
+
if (!before.has(filePath))
|
|
110
|
+
changed.push(filePath);
|
|
111
|
+
for (const filePath of before)
|
|
112
|
+
if (!after.has(filePath))
|
|
113
|
+
changed.push(filePath);
|
|
114
|
+
changed.sort();
|
|
115
|
+
return changed.slice(0, MAX_AUTOMATION_CHANGED_FILES);
|
|
116
|
+
};
|
|
117
|
+
const truncate = (raw, max, marker) => {
|
|
118
|
+
if (raw.length === 0)
|
|
119
|
+
return null;
|
|
120
|
+
// Slice below `max` by the marker length so the result (text + marker) fits
|
|
121
|
+
// the schema's `.max(max)` — otherwise the stored value exceeds the cap and
|
|
122
|
+
// the file fails to load next time.
|
|
123
|
+
return raw.length > max ? raw.slice(0, Math.max(0, max - marker.length)) + marker : raw;
|
|
124
|
+
};
|
|
125
|
+
const truncateFindings = (raw) => truncate(raw, MAX_AUTOMATION_FINDINGS_LENGTH, FINDINGS_TRUNCATION_MARKER);
|
|
126
|
+
const truncateLog = (raw) => truncate(raw, MAX_AUTOMATION_LOG_LENGTH, LOG_TRUNCATION_MARKER);
|
|
127
|
+
const piFlagsFor = (config) => {
|
|
128
|
+
const flags = [];
|
|
129
|
+
if (!config.extensions)
|
|
130
|
+
flags.push("--no-extensions");
|
|
131
|
+
if (!config.skills)
|
|
132
|
+
flags.push("--no-skills");
|
|
133
|
+
if (!config.contextFiles)
|
|
134
|
+
flags.push("--no-context-files");
|
|
135
|
+
return flags;
|
|
136
|
+
};
|
|
137
|
+
// JSONL line reader over a child's stdout. Splits on `\n` only (RPC mode uses
|
|
138
|
+
// LF as the record delimiter; readline is non-compliant because it also splits
|
|
139
|
+
// on U+2028/U+2029, which are valid inside JSON strings). Resolves each line to
|
|
140
|
+
// the next waiter, or null on close/timeout.
|
|
141
|
+
class RpcClient {
|
|
142
|
+
child;
|
|
143
|
+
buffer = "";
|
|
144
|
+
lineQueue = [];
|
|
145
|
+
lineWaiters = [];
|
|
146
|
+
closed = false;
|
|
147
|
+
constructor(binary, args, cwd, env) {
|
|
148
|
+
this.child = spawn(binary, args, {
|
|
149
|
+
cwd,
|
|
150
|
+
env,
|
|
151
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
152
|
+
windowsHide: true,
|
|
153
|
+
});
|
|
154
|
+
this.child.stdout?.on("data", (chunk) => this.onData(chunk));
|
|
155
|
+
this.child.on("close", () => {
|
|
156
|
+
this.closed = true;
|
|
157
|
+
while (this.lineWaiters.length > 0)
|
|
158
|
+
this.lineWaiters.shift()?.(null);
|
|
159
|
+
});
|
|
160
|
+
this.child.on("error", () => {
|
|
161
|
+
this.closed = true;
|
|
162
|
+
while (this.lineWaiters.length > 0)
|
|
163
|
+
this.lineWaiters.shift()?.(null);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
onData(chunk) {
|
|
167
|
+
this.buffer += chunk.toString("utf8");
|
|
168
|
+
let index;
|
|
169
|
+
while ((index = this.buffer.indexOf("\n")) !== -1) {
|
|
170
|
+
let line = this.buffer.slice(0, index);
|
|
171
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
172
|
+
if (line.endsWith("\r"))
|
|
173
|
+
line = line.slice(0, -1);
|
|
174
|
+
const waiter = this.lineWaiters.shift();
|
|
175
|
+
if (waiter)
|
|
176
|
+
waiter(line);
|
|
177
|
+
else
|
|
178
|
+
this.lineQueue.push(line);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
nextLine(timeoutMs) {
|
|
182
|
+
if (this.lineQueue.length > 0)
|
|
183
|
+
return Promise.resolve(this.lineQueue.shift() ?? null);
|
|
184
|
+
if (this.closed)
|
|
185
|
+
return Promise.resolve(null);
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
let settled = false;
|
|
188
|
+
const waiter = (line) => {
|
|
189
|
+
if (settled)
|
|
190
|
+
return;
|
|
191
|
+
settled = true;
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
const idx = this.lineWaiters.indexOf(waiter);
|
|
194
|
+
if (idx !== -1)
|
|
195
|
+
this.lineWaiters.splice(idx, 1);
|
|
196
|
+
resolve(line);
|
|
197
|
+
};
|
|
198
|
+
const timer = setTimeout(() => waiter(null), Math.max(0, timeoutMs));
|
|
199
|
+
this.lineWaiters.push(waiter);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
send(command) {
|
|
203
|
+
this.child.stdin?.write(`${JSON.stringify(command)}\n`);
|
|
204
|
+
}
|
|
205
|
+
close() {
|
|
206
|
+
try {
|
|
207
|
+
this.child.stdin?.end();
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// already closed
|
|
211
|
+
}
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
try {
|
|
214
|
+
this.child.kill("SIGKILL");
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// already dead
|
|
218
|
+
}
|
|
219
|
+
}, 2000).unref?.();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const extractAssistantText = (message) => {
|
|
223
|
+
if (!message || !Array.isArray(message.content))
|
|
224
|
+
return "";
|
|
225
|
+
return message.content
|
|
226
|
+
.filter((block) => {
|
|
227
|
+
const blockType = block?.type;
|
|
228
|
+
return blockType === "text";
|
|
229
|
+
})
|
|
230
|
+
.map((block) => block.text)
|
|
231
|
+
.join("");
|
|
232
|
+
};
|
|
233
|
+
// The assistant's reasoning blocks, concatenated. Hidden by default in the UI
|
|
234
|
+
// (behind a "show thinking" toggle) since it's noisy but sometimes useful.
|
|
235
|
+
const extractAssistantThinking = (message) => {
|
|
236
|
+
if (!message || !Array.isArray(message.content))
|
|
237
|
+
return "";
|
|
238
|
+
return message.content
|
|
239
|
+
.filter((block) => {
|
|
240
|
+
const blockType = block?.type;
|
|
241
|
+
return blockType === "thinking";
|
|
242
|
+
})
|
|
243
|
+
.map((block) => block.thinking)
|
|
244
|
+
.join("");
|
|
245
|
+
};
|
|
246
|
+
const TOOL_RESULT_TRUNCATION_MARKER = "…[truncated]";
|
|
247
|
+
const TOOL_INPUT_TRUNCATION_MARKER = "…";
|
|
248
|
+
const SESSION_TOOL_TRUNCATION_MARKER = "\n…[output truncated]";
|
|
249
|
+
// Format a tool call's arguments as a short header string (the path a read
|
|
250
|
+
// took, the command bash ran, the pattern grep searched). Mirrors pi's per-tool
|
|
251
|
+
// render (read shows the path, bash the command) with a generic fallback.
|
|
252
|
+
const formatToolInput = (args) => {
|
|
253
|
+
if (!args || typeof args !== "object")
|
|
254
|
+
return "";
|
|
255
|
+
const record = args;
|
|
256
|
+
const command = record.command;
|
|
257
|
+
if (typeof command === "string")
|
|
258
|
+
return command;
|
|
259
|
+
const filePath = record.file_path ?? record.path;
|
|
260
|
+
const pattern = record.pattern;
|
|
261
|
+
if (typeof pattern === "string")
|
|
262
|
+
return typeof filePath === "string" ? `${pattern} · ${filePath}` : pattern;
|
|
263
|
+
if (typeof filePath === "string")
|
|
264
|
+
return filePath;
|
|
265
|
+
try {
|
|
266
|
+
const json = JSON.stringify(args);
|
|
267
|
+
return json === "{}" ? "" : json;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const truncateToolInput = (raw) => raw.length > MAX_AUTOMATION_TOOL_INPUT_LENGTH
|
|
274
|
+
? raw.slice(0, Math.max(0, MAX_AUTOMATION_TOOL_INPUT_LENGTH - TOOL_INPUT_TRUNCATION_MARKER.length)) + TOOL_INPUT_TRUNCATION_MARKER
|
|
275
|
+
: raw;
|
|
276
|
+
// Safety-net cap for a session-transcript tool result, matching pi core's
|
|
277
|
+
// tool-output truncation (2000 lines or 50 KB, head kept). The session file is
|
|
278
|
+
// already pi-truncated, so this rarely fires.
|
|
279
|
+
const capSessionToolResult = (raw) => {
|
|
280
|
+
const lines = raw.split("\n");
|
|
281
|
+
let out = raw;
|
|
282
|
+
if (lines.length > AUTOMATION_SESSION_TOOL_MAX_LINES)
|
|
283
|
+
out = lines.slice(0, AUTOMATION_SESSION_TOOL_MAX_LINES).join("\n");
|
|
284
|
+
if (out.length > AUTOMATION_SESSION_TOOL_MAX_BYTES)
|
|
285
|
+
out = out.slice(0, AUTOMATION_SESSION_TOOL_MAX_BYTES);
|
|
286
|
+
return out === raw ? raw : out + SESSION_TOOL_TRUNCATION_MARKER;
|
|
287
|
+
};
|
|
288
|
+
const truncateToolResult = (raw) => raw.length > MAX_AUTOMATION_TOOL_RESULT_LENGTH
|
|
289
|
+
? raw.slice(0, Math.max(0, MAX_AUTOMATION_TOOL_RESULT_LENGTH - TOOL_RESULT_TRUNCATION_MARKER.length)) + TOOL_RESULT_TRUNCATION_MARKER
|
|
290
|
+
: raw;
|
|
291
|
+
const entrySize = (entry) => {
|
|
292
|
+
let size = entry.text.length;
|
|
293
|
+
if (entry.type === "assistant" && entry.thinking)
|
|
294
|
+
size += entry.thinking.length;
|
|
295
|
+
if (entry.type === "tool")
|
|
296
|
+
size += entry.name.length;
|
|
297
|
+
return size;
|
|
298
|
+
};
|
|
299
|
+
// Bound the structured log: cap the entry count, then drop oldest entries
|
|
300
|
+
// until the total is under the byte cap (keeps the recent turns, which hold
|
|
301
|
+
// the final answer). User/assistant text is kept full per entry; only the
|
|
302
|
+
// total is bounded for storage.
|
|
303
|
+
const capLogEntries = (entries) => {
|
|
304
|
+
let trimmed = entries.length > MAX_AUTOMATION_LOG_ENTRIES
|
|
305
|
+
? entries.slice(entries.length - MAX_AUTOMATION_LOG_ENTRIES)
|
|
306
|
+
: entries;
|
|
307
|
+
let total = trimmed.reduce((sum, entry) => sum + entrySize(entry), 0);
|
|
308
|
+
while (total > MAX_AUTOMATION_LOG_LENGTH && trimmed.length > 1) {
|
|
309
|
+
total -= entrySize(trimmed[0]);
|
|
310
|
+
trimmed = trimmed.slice(1);
|
|
311
|
+
}
|
|
312
|
+
return trimmed;
|
|
313
|
+
};
|
|
314
|
+
// Run one agent fire through a `pi --mode rpc` subprocess. Fresh runs use
|
|
315
|
+
// --no-session; thread runs resume --session <file>. Auto-compaction is left to
|
|
316
|
+
// the harness default (pi: on). Findings come from the last assistant message;
|
|
317
|
+
// the log is the formatted event transcript (+ stderr tail if pi writes any).
|
|
318
|
+
// The run status is derived from the event stream so a headless API failure
|
|
319
|
+
// (stopReason "error", a crash) is "failed" even if the
|
|
320
|
+
// process exits 0.
|
|
321
|
+
const runPi = async (request, piBinaryPath) => {
|
|
322
|
+
const { binary: piBinary, pathEnv } = resolvePiAndPath(request.shimsDir, piBinaryPath);
|
|
323
|
+
if (!piBinary) {
|
|
324
|
+
return {
|
|
325
|
+
exitCode: 1,
|
|
326
|
+
findings: "pi not found on PATH (excluding the localterm shims dir). Install pi or add it to PATH so the agent runner can spawn it.",
|
|
327
|
+
log: null,
|
|
328
|
+
changedFiles: [],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (request.sessionFile) {
|
|
332
|
+
try {
|
|
333
|
+
fs.mkdirSync(path.dirname(request.sessionFile), { recursive: true });
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// pi will surface the write failure itself
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const { runner } = request;
|
|
340
|
+
const harness = runner.harness;
|
|
341
|
+
const piHarness = harness.kind === "pi"
|
|
342
|
+
? harness
|
|
343
|
+
: { kind: "pi", extensions: true, skills: true, contextFiles: true };
|
|
344
|
+
const args = ["--mode", "rpc"];
|
|
345
|
+
if (runner.sessionMode === "fresh")
|
|
346
|
+
args.push("--no-session");
|
|
347
|
+
else if (request.sessionFile)
|
|
348
|
+
args.push("--session", request.sessionFile);
|
|
349
|
+
if (runner.model)
|
|
350
|
+
args.push("--model", runner.model);
|
|
351
|
+
if (runner.thinking)
|
|
352
|
+
args.push("--thinking", runner.thinking);
|
|
353
|
+
args.push(...piFlagsFor(piHarness));
|
|
354
|
+
const before = gitStatusSet(request.cwd);
|
|
355
|
+
// Spawn pi with the resolved full PATH (minus the shims dir) so pi and its
|
|
356
|
+
// tools find their dependencies — the daemon's own minimal PATH would leave
|
|
357
|
+
// pi unable to spawn node/git/etc. The shim dir is stripped so pi's tools
|
|
358
|
+
// don't double-inject secrets (the automation injects its requestedSecrets
|
|
359
|
+
// as env directly).
|
|
360
|
+
const client = new RpcClient(piBinary, args, request.cwd, {
|
|
361
|
+
...process.env,
|
|
362
|
+
PATH: pathEnv || process.env.PATH,
|
|
363
|
+
...request.env,
|
|
364
|
+
});
|
|
365
|
+
const logEntries = [{ type: "user", text: runner.prompt }];
|
|
366
|
+
let lastAssistantText = "";
|
|
367
|
+
let lastErrorMessage = "";
|
|
368
|
+
let errored = false;
|
|
369
|
+
let agentEnded = false;
|
|
370
|
+
// Tool-call inputs (the path/command), recovered from a message_end's
|
|
371
|
+
// tool_use blocks by call id, then attached to the matching tool_execution_end
|
|
372
|
+
// entry so the per-run log shows what a tool was invoked with.
|
|
373
|
+
const toolInputById = new Map();
|
|
374
|
+
client.send({ type: "prompt", message: runner.prompt, id: "prompt" });
|
|
375
|
+
const deadline = Date.now() + AUTOMATION_AGENT_RUN_TIMEOUT_MS;
|
|
376
|
+
while (Date.now() < deadline) {
|
|
377
|
+
const line = await client.nextLine(Math.min(1000, deadline - Date.now()));
|
|
378
|
+
if (line === null) {
|
|
379
|
+
if (client.closed) {
|
|
380
|
+
if (!agentEnded)
|
|
381
|
+
errored = true;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
let event;
|
|
387
|
+
try {
|
|
388
|
+
event = JSON.parse(line);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (event.type === "response") {
|
|
394
|
+
if (event.id === "prompt" && event.success === false) {
|
|
395
|
+
errored = true;
|
|
396
|
+
lastErrorMessage = String(event.error ?? "prompt rejected");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (event.type === "message_end") {
|
|
400
|
+
const message = event.message;
|
|
401
|
+
if (message?.role === "assistant") {
|
|
402
|
+
const text = extractAssistantText(event.message);
|
|
403
|
+
if (text) {
|
|
404
|
+
const thinking = extractAssistantThinking(event.message);
|
|
405
|
+
lastAssistantText = text;
|
|
406
|
+
logEntries.push(thinking ? { type: "assistant", text, thinking } : { type: "assistant", text });
|
|
407
|
+
}
|
|
408
|
+
else if (message.errorMessage) {
|
|
409
|
+
lastErrorMessage = message.errorMessage;
|
|
410
|
+
logEntries.push({ type: "assistant", text: message.errorMessage });
|
|
411
|
+
}
|
|
412
|
+
if (Array.isArray(message.content)) {
|
|
413
|
+
for (const part of message.content) {
|
|
414
|
+
const block = part;
|
|
415
|
+
if (block.type === "tool_use" || block.type === "toolCall") {
|
|
416
|
+
const formatted = truncateToolInput(formatToolInput(block.arguments ?? block.input));
|
|
417
|
+
if (formatted)
|
|
418
|
+
toolInputById.set(String(block.id ?? ""), formatted);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (message.stopReason === "error" || message.errorMessage) {
|
|
423
|
+
errored = true;
|
|
424
|
+
lastErrorMessage = message.errorMessage ?? lastErrorMessage;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else if (event.type === "tool_execution_end") {
|
|
429
|
+
const result = event.result;
|
|
430
|
+
const text = truncateToolResult(extractAssistantText(result));
|
|
431
|
+
const input = toolInputById.get(String(event.toolCallId ?? ""));
|
|
432
|
+
logEntries.push({
|
|
433
|
+
type: "tool",
|
|
434
|
+
name: String(event.toolName ?? "tool"),
|
|
435
|
+
...(input !== undefined ? { input } : {}),
|
|
436
|
+
text,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
else if (event.type === "turn_end") {
|
|
440
|
+
const message = event.message;
|
|
441
|
+
if (message?.stopReason === "error")
|
|
442
|
+
errored = true;
|
|
443
|
+
}
|
|
444
|
+
else if (event.type === "agent_end") {
|
|
445
|
+
agentEnded = true;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// If the run errored without an assistant message carrying the error (a
|
|
450
|
+
// crash or a rejected prompt), surface it as a final assistant entry so the
|
|
451
|
+
// log explains the failure instead of ending on the prompt.
|
|
452
|
+
if (errored && lastErrorMessage) {
|
|
453
|
+
const last = logEntries[logEntries.length - 1];
|
|
454
|
+
if (!last || last.type !== "assistant" || last.text !== lastErrorMessage) {
|
|
455
|
+
logEntries.push({ type: "assistant", text: lastErrorMessage });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
client.close();
|
|
459
|
+
const findingsSource = lastAssistantText || (errored ? lastErrorMessage : "");
|
|
460
|
+
const findings = truncateFindings(findingsSource);
|
|
461
|
+
const log = capLogEntries(logEntries);
|
|
462
|
+
const changedFiles = computeChangedFiles(before, request.cwd);
|
|
463
|
+
const exitCode = errored || !agentEnded ? 1 : 0;
|
|
464
|
+
return { exitCode, findings, log, changedFiles };
|
|
465
|
+
};
|
|
466
|
+
// Compact a thread session in place via a short-lived `pi --mode rpc` session:
|
|
467
|
+
// send `compact`, wait for `compaction_end`, close. The session file is
|
|
468
|
+
// updated on disk by pi.
|
|
469
|
+
const compactPi = async (request, piBinaryPath) => {
|
|
470
|
+
const { binary: piBinary, pathEnv } = resolvePiAndPath(request.shimsDir, piBinaryPath);
|
|
471
|
+
if (!piBinary)
|
|
472
|
+
return { ok: false, message: "pi not found on PATH" };
|
|
473
|
+
try {
|
|
474
|
+
fs.mkdirSync(path.dirname(request.sessionFile), { recursive: true });
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// pi will surface the write failure
|
|
478
|
+
}
|
|
479
|
+
const piHarness = request.harness.kind === "pi"
|
|
480
|
+
? request.harness
|
|
481
|
+
: { kind: "pi", extensions: true, skills: true, contextFiles: true };
|
|
482
|
+
const args = ["--mode", "rpc", "--session", request.sessionFile, ...piFlagsFor(piHarness)];
|
|
483
|
+
const client = new RpcClient(piBinary, args, request.cwd, {
|
|
484
|
+
...process.env,
|
|
485
|
+
PATH: pathEnv || process.env.PATH,
|
|
486
|
+
...request.env,
|
|
487
|
+
});
|
|
488
|
+
client.send({ type: "compact", id: "compact" });
|
|
489
|
+
let ok = false;
|
|
490
|
+
let message;
|
|
491
|
+
const deadline = Date.now() + 60_000;
|
|
492
|
+
while (Date.now() < deadline) {
|
|
493
|
+
const line = await client.nextLine(Math.min(1000, deadline - Date.now()));
|
|
494
|
+
if (line === null) {
|
|
495
|
+
if (client.closed)
|
|
496
|
+
break;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
let event;
|
|
500
|
+
try {
|
|
501
|
+
event = JSON.parse(line);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (event.type === "response" && event.id === "compact") {
|
|
507
|
+
ok = Boolean(event.success);
|
|
508
|
+
if (!ok)
|
|
509
|
+
message = String(event.error ?? "compact rejected");
|
|
510
|
+
}
|
|
511
|
+
else if (event.type === "compaction_end") {
|
|
512
|
+
if (event.aborted) {
|
|
513
|
+
ok = false;
|
|
514
|
+
message = "compaction aborted";
|
|
515
|
+
}
|
|
516
|
+
else if (event.errorMessage) {
|
|
517
|
+
ok = false;
|
|
518
|
+
message = String(event.errorMessage);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
ok = true;
|
|
522
|
+
}
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
client.close();
|
|
527
|
+
return { ok, message };
|
|
528
|
+
};
|
|
529
|
+
const PiHarness = (piBinaryPath) => ({
|
|
530
|
+
run: (request) => runPi(request, piBinaryPath),
|
|
531
|
+
compact: (request) => compactPi(request, piBinaryPath),
|
|
532
|
+
});
|
|
533
|
+
// A user-supplied harness: runs `command` as a shell command with the request
|
|
534
|
+
// passed as LOCALTERM_AGENT_* env vars (the prompt is in env, never argv, so a
|
|
535
|
+
// prompt with shell metacharacters is safe). stdout is findings, stdout+stderr
|
|
536
|
+
// is the log. `compactCommand` (optional) compacts a thread session in place.
|
|
537
|
+
const runCustom = async (request, config) => {
|
|
538
|
+
const before = gitStatusSet(request.cwd);
|
|
539
|
+
if (request.sessionFile) {
|
|
540
|
+
try {
|
|
541
|
+
fs.mkdirSync(path.dirname(request.sessionFile), { recursive: true });
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
// the harness will surface the write failure
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const env = {
|
|
548
|
+
...process.env,
|
|
549
|
+
...request.env,
|
|
550
|
+
LOCALTERM_AGENT_PROMPT: request.runner.prompt,
|
|
551
|
+
LOCALTERM_AGENT_SESSION_MODE: request.runner.sessionMode,
|
|
552
|
+
LOCALTERM_AGENT_SESSION_FILE: request.sessionFile ?? "",
|
|
553
|
+
LOCALTERM_AGENT_MODEL: request.runner.model ?? "",
|
|
554
|
+
LOCALTERM_AGENT_THINKING: request.runner.thinking ?? "",
|
|
555
|
+
};
|
|
556
|
+
let stdout = "";
|
|
557
|
+
let stderr = "";
|
|
558
|
+
let killed = false;
|
|
559
|
+
let exitCode = 0;
|
|
560
|
+
let spawnFailed = false;
|
|
561
|
+
await new Promise((resolve) => {
|
|
562
|
+
const child = spawn(config.command, {
|
|
563
|
+
cwd: request.cwd,
|
|
564
|
+
env,
|
|
565
|
+
shell: true,
|
|
566
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
567
|
+
windowsHide: true,
|
|
568
|
+
});
|
|
569
|
+
child.stdout?.on("data", (chunk) => {
|
|
570
|
+
stdout += chunk.toString("utf8");
|
|
571
|
+
});
|
|
572
|
+
child.stderr?.on("data", (chunk) => {
|
|
573
|
+
stderr += chunk.toString("utf8");
|
|
574
|
+
});
|
|
575
|
+
const timer = setTimeout(() => {
|
|
576
|
+
killed = true;
|
|
577
|
+
child.kill("SIGTERM");
|
|
578
|
+
setTimeout(() => {
|
|
579
|
+
if (!child.killed)
|
|
580
|
+
child.kill("SIGKILL");
|
|
581
|
+
}, 3000).unref?.();
|
|
582
|
+
}, AUTOMATION_AGENT_RUN_TIMEOUT_MS);
|
|
583
|
+
timer.unref?.();
|
|
584
|
+
child.on("close", (code, signal) => {
|
|
585
|
+
clearTimeout(timer);
|
|
586
|
+
if (killed || signal)
|
|
587
|
+
exitCode = null;
|
|
588
|
+
else
|
|
589
|
+
exitCode = code ?? 0;
|
|
590
|
+
resolve();
|
|
591
|
+
});
|
|
592
|
+
child.on("error", () => {
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
spawnFailed = true;
|
|
595
|
+
stderr += `\nfailed to spawn harness: ${config.command}`;
|
|
596
|
+
resolve();
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
const changedFiles = computeChangedFiles(before, request.cwd);
|
|
600
|
+
const findings = truncateFindings(stdout.length > 0 ? stdout : stderr);
|
|
601
|
+
const log = truncateLog(stdout + (stderr.length > 0 ? `\n--- stderr ---\n${stderr}` : ""));
|
|
602
|
+
if (spawnFailed)
|
|
603
|
+
exitCode = 1;
|
|
604
|
+
return { exitCode, findings, log, changedFiles };
|
|
605
|
+
};
|
|
606
|
+
const compactCustom = async (request, config) => {
|
|
607
|
+
if (!config.compactCommand) {
|
|
608
|
+
return { ok: false, message: "custom harness has no compact command" };
|
|
609
|
+
}
|
|
610
|
+
const env = {
|
|
611
|
+
...process.env,
|
|
612
|
+
...request.env,
|
|
613
|
+
LOCALTERM_AGENT_SESSION_FILE: request.sessionFile,
|
|
614
|
+
};
|
|
615
|
+
const compactCommand = config.compactCommand;
|
|
616
|
+
return new Promise((resolve) => {
|
|
617
|
+
const child = spawn(compactCommand, {
|
|
618
|
+
cwd: request.cwd,
|
|
619
|
+
env,
|
|
620
|
+
shell: true,
|
|
621
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
622
|
+
windowsHide: true,
|
|
623
|
+
});
|
|
624
|
+
let stderr = "";
|
|
625
|
+
child.stderr?.on("data", (chunk) => {
|
|
626
|
+
stderr += chunk.toString("utf8");
|
|
627
|
+
});
|
|
628
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 60_000);
|
|
629
|
+
timer.unref?.();
|
|
630
|
+
child.on("close", (code) => {
|
|
631
|
+
clearTimeout(timer);
|
|
632
|
+
resolve({
|
|
633
|
+
ok: code === 0,
|
|
634
|
+
message: code === 0
|
|
635
|
+
? undefined
|
|
636
|
+
: `compact command exited ${String(code)}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`,
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
child.on("error", (error) => {
|
|
640
|
+
clearTimeout(timer);
|
|
641
|
+
resolve({ ok: false, message: `failed to spawn compact command: ${error.message}` });
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
};
|
|
645
|
+
const CustomHarness = (config) => ({
|
|
646
|
+
run: (request) => runCustom(request, config),
|
|
647
|
+
compact: (request) => compactCustom(request, config),
|
|
648
|
+
});
|
|
649
|
+
const resolveHarness = (harness, piBinaryPath) => harness.kind === "pi" ? PiHarness(piBinaryPath) : CustomHarness(harness);
|
|
650
|
+
export const runAgent = (request) => resolveHarness(request.runner.harness, request.piBinaryPath).run(request);
|
|
651
|
+
export const compactAgent = (request) => resolveHarness(request.harness, request.piBinaryPath).compact(request);
|
|
652
|
+
// Cache of the available-models list (pi's RPC get_available_models). The list
|
|
653
|
+
// rarely changes, so cache it for a few minutes; the first call spawns pi
|
|
654
|
+
// (slow, ~1-5s), later calls reuse the cache.
|
|
655
|
+
const MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
656
|
+
let cachedModels = null;
|
|
657
|
+
const listModelsViaRpcWith = async (binary, pathEnv, extraFlags) => {
|
|
658
|
+
const args = ["--mode", "rpc", "--no-session", ...extraFlags];
|
|
659
|
+
const client = new RpcClient(binary, args, os.tmpdir(), {
|
|
660
|
+
...process.env,
|
|
661
|
+
PATH: pathEnv || process.env.PATH,
|
|
662
|
+
});
|
|
663
|
+
client.send({ type: "get_available_models", id: "models" });
|
|
664
|
+
let models = [];
|
|
665
|
+
const deadline = Date.now() + 15_000;
|
|
666
|
+
while (Date.now() < deadline) {
|
|
667
|
+
const line = await client.nextLine(Math.min(1000, deadline - Date.now()));
|
|
668
|
+
if (line === null) {
|
|
669
|
+
if (client.closed)
|
|
670
|
+
break;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
let event;
|
|
674
|
+
try {
|
|
675
|
+
event = JSON.parse(line);
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (event.type === "response" && event.id === "models" && event.success) {
|
|
681
|
+
const raw = event.data?.models;
|
|
682
|
+
if (Array.isArray(raw)) {
|
|
683
|
+
models = raw
|
|
684
|
+
.map((model) => {
|
|
685
|
+
const entry = model;
|
|
686
|
+
return {
|
|
687
|
+
id: String(entry.id ?? ""),
|
|
688
|
+
name: String(entry.name ?? entry.id ?? ""),
|
|
689
|
+
provider: String(entry.provider ?? ""),
|
|
690
|
+
...(typeof entry.contextWindow === "number"
|
|
691
|
+
? { contextWindow: entry.contextWindow }
|
|
692
|
+
: {}),
|
|
693
|
+
...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}),
|
|
694
|
+
};
|
|
695
|
+
})
|
|
696
|
+
.filter((model) => model.id.length > 0);
|
|
697
|
+
}
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
client.close();
|
|
702
|
+
return models;
|
|
703
|
+
};
|
|
704
|
+
const listModelsViaRpc = async (shimsDir, extraFlags) => {
|
|
705
|
+
const { binary: realPi, pathEnv } = resolvePiAndPath(shimsDir);
|
|
706
|
+
// Prefer the localterm shim for the model list: it injects the pi-process
|
|
707
|
+
// secrets (so every provider with a key registers its models) then execs the
|
|
708
|
+
// real pi. The bare real pi has none of those keys, so most providers don't
|
|
709
|
+
// register and the list is nearly empty.
|
|
710
|
+
let binary = realPi;
|
|
711
|
+
const shimPi = path.join(shimsDir, "pi");
|
|
712
|
+
try {
|
|
713
|
+
if (fs.statSync(shimPi).isFile()) {
|
|
714
|
+
fs.accessSync(shimPi, fs.constants.X_OK);
|
|
715
|
+
binary = shimPi;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
// no shim; fall back to the real pi
|
|
720
|
+
}
|
|
721
|
+
if (!binary)
|
|
722
|
+
return [];
|
|
723
|
+
return listModelsViaRpcWith(binary, pathEnv, extraFlags);
|
|
724
|
+
};
|
|
725
|
+
// List models available to the pi harness. Tries with extensions on (the
|
|
726
|
+
// default, so custom-provider models appear); if that yields nothing (e.g. the
|
|
727
|
+
// provider extensions crash headless), retries with --no-extensions for the
|
|
728
|
+
// built-in providers. Cached for a few minutes. A `piBinaryPath` override
|
|
729
|
+
// (tests) bypasses the shim + cache.
|
|
730
|
+
export const listAgentModels = async (shimsDir, piBinaryPath) => {
|
|
731
|
+
if (piBinaryPath)
|
|
732
|
+
return listModelsViaRpcWith(piBinaryPath, process.env.PATH ?? "", []);
|
|
733
|
+
if (cachedModels && Date.now() - cachedModels.at < MODEL_CACHE_TTL_MS)
|
|
734
|
+
return cachedModels.models;
|
|
735
|
+
let models = await listModelsViaRpc(shimsDir, []);
|
|
736
|
+
if (models.length === 0)
|
|
737
|
+
models = await listModelsViaRpc(shimsDir, ["--no-extensions"]);
|
|
738
|
+
cachedModels = { at: Date.now(), models };
|
|
739
|
+
return models;
|
|
740
|
+
};
|
|
741
|
+
// Test-only: reset the model-list cache so a case never sees another case's
|
|
742
|
+
// (or another file's) cached result.
|
|
743
|
+
export const __resetAgentModelCache = () => {
|
|
744
|
+
cachedModels = null;
|
|
745
|
+
};
|
|
746
|
+
// Extract the text of a tool_result content block: it's either a plain string
|
|
747
|
+
// or an array of content blocks (Anthropic's tool_result.content shape).
|
|
748
|
+
const extractToolResultText = (part) => {
|
|
749
|
+
const content = part.content;
|
|
750
|
+
if (typeof content === "string")
|
|
751
|
+
return content;
|
|
752
|
+
if (Array.isArray(content)) {
|
|
753
|
+
return content
|
|
754
|
+
.filter((block) => {
|
|
755
|
+
const blockType = block?.type;
|
|
756
|
+
return blockType === "text";
|
|
757
|
+
})
|
|
758
|
+
.map((block) => block.text)
|
|
759
|
+
.join("");
|
|
760
|
+
}
|
|
761
|
+
return "";
|
|
762
|
+
};
|
|
763
|
+
// Read a thread-mode pi session file (JSONL) and flatten it into the same
|
|
764
|
+
// user/assistant/tool entry shape as a run log, plus a `compaction` entry for
|
|
765
|
+
// each compaction the branch went through. Tool calls are tracked by id so a
|
|
766
|
+
// tool result's name + input (the path/command) can be recovered from the
|
|
767
|
+
// preceding call. Session-transcript tool results are capped at pi core's
|
|
768
|
+
// limits (2000 lines / 50 KB), not the stored-log preview cap. `untilMs`
|
|
769
|
+
// truncates the transcript at a point in time (a run's finishedAt) so an older
|
|
770
|
+
// run shows the branch as it was then, not the latest state. Returns [] if the
|
|
771
|
+
// file is missing (fresh mode, or no runs yet).
|
|
772
|
+
export const readAgentSession = async (sessionFile, untilMs) => {
|
|
773
|
+
let raw;
|
|
774
|
+
try {
|
|
775
|
+
raw = await fs.promises.readFile(sessionFile, "utf8");
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
const toolNameById = new Map();
|
|
781
|
+
const toolInputById = new Map();
|
|
782
|
+
const entries = [];
|
|
783
|
+
for (const line of raw.split("\n")) {
|
|
784
|
+
const trimmed = line.trim();
|
|
785
|
+
if (trimmed.length === 0)
|
|
786
|
+
continue;
|
|
787
|
+
let event;
|
|
788
|
+
try {
|
|
789
|
+
event = JSON.parse(trimmed);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (untilMs !== undefined &&
|
|
795
|
+
typeof event.timestamp === "string" &&
|
|
796
|
+
Date.parse(event.timestamp) > untilMs) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (event.type === "message") {
|
|
800
|
+
const message = event.message;
|
|
801
|
+
if (!message)
|
|
802
|
+
continue;
|
|
803
|
+
const role = message.role;
|
|
804
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
805
|
+
if (role === "user") {
|
|
806
|
+
for (const part of content) {
|
|
807
|
+
const block = part;
|
|
808
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
809
|
+
entries.push({ type: "user", text: block.text });
|
|
810
|
+
}
|
|
811
|
+
else if (block.type === "tool_result") {
|
|
812
|
+
const id = String(block.tool_use_id ?? "");
|
|
813
|
+
const name = toolNameById.get(id) ?? "tool";
|
|
814
|
+
const input = toolInputById.get(id);
|
|
815
|
+
entries.push({
|
|
816
|
+
type: "tool",
|
|
817
|
+
name,
|
|
818
|
+
...(input !== undefined ? { input } : {}),
|
|
819
|
+
text: capSessionToolResult(extractToolResultText(block)),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else if (role === "toolResult") {
|
|
825
|
+
const message2 = event.message;
|
|
826
|
+
const id = String(message2?.toolCallId ?? "");
|
|
827
|
+
const name = (typeof message2?.toolName === "string" && message2.toolName) ||
|
|
828
|
+
toolNameById.get(id) ||
|
|
829
|
+
"tool";
|
|
830
|
+
const input = toolInputById.get(id);
|
|
831
|
+
entries.push({
|
|
832
|
+
type: "tool",
|
|
833
|
+
name,
|
|
834
|
+
...(input !== undefined ? { input } : {}),
|
|
835
|
+
text: capSessionToolResult(extractToolResultText(message2 ?? {})),
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
else if (role === "assistant") {
|
|
839
|
+
let text = "";
|
|
840
|
+
let thinking = "";
|
|
841
|
+
for (const part of content) {
|
|
842
|
+
const block = part;
|
|
843
|
+
if (block.type === "text" && typeof block.text === "string")
|
|
844
|
+
text += block.text;
|
|
845
|
+
else if (block.type === "thinking" && typeof block.thinking === "string")
|
|
846
|
+
thinking += block.thinking;
|
|
847
|
+
else if (block.type === "tool_use" || block.type === "toolCall") {
|
|
848
|
+
const callId = String(block.id ?? "");
|
|
849
|
+
const callName = String(block.name ?? "tool");
|
|
850
|
+
toolNameById.set(callId, callName);
|
|
851
|
+
const formatted = truncateToolInput(formatToolInput(block.arguments ?? block.input));
|
|
852
|
+
if (formatted)
|
|
853
|
+
toolInputById.set(callId, formatted);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (text.length > 0 || thinking.length > 0) {
|
|
857
|
+
entries.push(thinking.length > 0
|
|
858
|
+
? { type: "assistant", text, thinking }
|
|
859
|
+
: { type: "assistant", text });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
else if (event.type === "compaction") {
|
|
864
|
+
const summary = String(event.summary ?? "");
|
|
865
|
+
const tokensBefore = typeof event.tokensBefore === "number" ? event.tokensBefore : undefined;
|
|
866
|
+
entries.push(tokensBefore !== undefined
|
|
867
|
+
? { type: "compaction", summary, tokensBefore }
|
|
868
|
+
: { type: "compaction", summary });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return entries;
|
|
872
|
+
};
|
|
873
|
+
//# sourceMappingURL=agent-runner.js.map
|