@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.
Files changed (41) hide show
  1. package/dist/agent-runner.d.ts +41 -0
  2. package/dist/agent-runner.d.ts.map +1 -0
  3. package/dist/agent-runner.js +873 -0
  4. package/dist/agent-runner.js.map +1 -0
  5. package/dist/agent-skills.d.ts +4 -0
  6. package/dist/agent-skills.d.ts.map +1 -0
  7. package/dist/agent-skills.js +169 -0
  8. package/dist/agent-skills.js.map +1 -0
  9. package/dist/automation-run-tracker.js +1 -1
  10. package/dist/automation-run-tracker.js.map +1 -1
  11. package/dist/automation-store.d.ts +4 -0
  12. package/dist/automation-store.d.ts.map +1 -1
  13. package/dist/automation-store.js +178 -14
  14. package/dist/automation-store.js.map +1 -1
  15. package/dist/constants.d.ts +15 -2
  16. package/dist/constants.d.ts.map +1 -1
  17. package/dist/constants.js +53 -4
  18. package/dist/constants.js.map +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +221 -11
  22. package/dist/index.js.map +1 -1
  23. package/dist/protocol.d.ts +2 -2
  24. package/dist/protocol.d.ts.map +1 -1
  25. package/dist/protocol.js +1 -1
  26. package/dist/protocol.js.map +1 -1
  27. package/dist/schemas.d.ts +730 -8
  28. package/dist/schemas.d.ts.map +1 -1
  29. package/dist/schemas.js +199 -11
  30. package/dist/schemas.js.map +1 -1
  31. package/dist/session-manager.d.ts +3 -1
  32. package/dist/session-manager.d.ts.map +1 -1
  33. package/dist/session-manager.js +20 -2
  34. package/dist/session-manager.js.map +1 -1
  35. package/dist/types.d.ts +10 -2
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/utils/strip-ansi.d.ts +2 -0
  38. package/dist/utils/strip-ansi.d.ts.map +1 -0
  39. package/dist/utils/strip-ansi.js +16 -0
  40. package/dist/utils/strip-ansi.js.map +1 -0
  41. 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