@tea-agent/loop-agent 0.26.3 → 0.26.4

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.
@@ -1,10 +1,10 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import path from 'node:path';
5
- import { PI_REUSE_RUNTIME_ENV, isPiReuseRuntimeEffective, resolvePiReuseRuntimeMode as resolveConfiguredPiReuseRuntimeMode, } from './pi-runtime-reuse.js';
6
- import { cleanupPiPromptTransport, createPiPromptTransport, } from './pi-prompt-transport.js';
7
- import { processTreeSpawnOptions, terminateProcessTree } from './process-tree.js';
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { PI_REUSE_RUNTIME_ENV, isPiReuseRuntimeEffective, resolvePiReuseRuntimeMode as resolveConfiguredPiReuseRuntimeMode, } from "./pi-runtime-reuse.js";
6
+ import { cleanupPiPromptTransport, createPiPromptTransport, } from "./pi-prompt-transport.js";
7
+ import { processTreeSpawnOptions, terminateProcessTree, } from "./process-tree.js";
8
8
  export function extractSubagentStats(stdout) {
9
9
  let totalCalls = 0;
10
10
  let failedCalls = 0;
@@ -16,20 +16,22 @@ export function extractSubagentStats(stdout) {
16
16
  continue;
17
17
  try {
18
18
  const event = JSON.parse(trimmed);
19
- if (event.type === 'tool_execution_start' && event.toolName === 'subagent') {
19
+ if (event.type === "tool_execution_start" &&
20
+ event.toolName === "subagent") {
20
21
  totalCalls += 1;
21
22
  if (isRecord(event.input)) {
22
23
  if (event.input.agent)
23
24
  agents.add(String(event.input.agent));
24
25
  if (event.input.tasks)
25
- modes.add('parallel');
26
+ modes.add("parallel");
26
27
  if (event.input.chain)
27
- modes.add('chain');
28
+ modes.add("chain");
28
29
  if (event.input.agent && !event.input.tasks && !event.input.chain)
29
- modes.add('single');
30
+ modes.add("single");
30
31
  }
31
32
  }
32
- if (event.type === 'tool_execution_end' && event.toolName === 'subagent') {
33
+ if (event.type === "tool_execution_end" &&
34
+ event.toolName === "subagent") {
33
35
  if (event.isError === true)
34
36
  failedCalls += 1;
35
37
  }
@@ -39,15 +41,15 @@ export function extractSubagentStats(stdout) {
39
41
  }
40
42
  }
41
43
  if (totalCalls === 0)
42
- return 'calls=0';
44
+ return "calls=0";
43
45
  const parts = [`calls=${totalCalls}`];
44
46
  if (failedCalls > 0)
45
47
  parts.push(`${failedCalls} failed`);
46
48
  if (modes.size > 0)
47
- parts.push(Array.from(modes).sort().join('+'));
49
+ parts.push(Array.from(modes).sort().join("+"));
48
50
  if (agents.size > 0)
49
- parts.push(`agents=[${Array.from(agents).sort().join(',')}]`);
50
- return parts.join(' | ');
51
+ parts.push(`agents=[${Array.from(agents).sort().join(",")}]`);
52
+ return parts.join(" | ");
51
53
  }
52
54
  /** Absolute max wall clock for a single Pi attempt (4h). Not renewed by empty heartbeats. */
53
55
  export const DEFAULT_TIMEOUT_MS = 14_400_000;
@@ -56,14 +58,14 @@ export const MAX_ALLOWED_TIMEOUT_MS = 14_400_000;
56
58
  export const DEFAULT_STALL_TIMEOUT_MS = 900_000;
57
59
  /** Grace after SIGTERM before SIGKILL on absolute max. */
58
60
  export const DEFAULT_ABORT_GRACE_MS = 30_000;
59
- const PI_BACKEND_ENV = 'CODE_AGENT_PI_BACKEND';
61
+ const PI_BACKEND_ENV = "CODE_AGENT_PI_BACKEND";
60
62
  const OUTPUT_PREVIEW_HEAD_CHARS = 64_000;
61
63
  const OUTPUT_PREVIEW_TAIL_CHARS = 64_000;
62
64
  const MAX_STREAM_JSONL_LINE_CHARS = 16_000_000;
63
65
  const MAX_ASSISTANT_TEXT_CHARS = 16_000_000;
64
66
  const PI_CLI_PACKAGE_NAMES = [
65
- '@earendil-works/pi-coding-agent',
66
- '@mariozechner/pi-coding-agent',
67
+ "@earendil-works/pi-coding-agent",
68
+ "@mariozechner/pi-coding-agent",
67
69
  ];
68
70
  export { PI_REUSE_RUNTIME_ENV };
69
71
  /** Resolve configured Pi SDK runtime reuse mode. Default off; unknown values fall back to off. */
@@ -82,7 +84,7 @@ export async function withPiReuseRuntimeScope(fn) {
82
84
  if (!isPiReuseRuntimeEnabled()) {
83
85
  return fn();
84
86
  }
85
- const { beginPiSdkReuseScope, endPiSdkReuseScope } = await import('./pi-sdk-executor.js');
87
+ const { beginPiSdkReuseScope, endPiSdkReuseScope } = await import("./pi-sdk-executor.js");
86
88
  beginPiSdkReuseScope();
87
89
  try {
88
90
  return await fn();
@@ -94,14 +96,14 @@ export async function withPiReuseRuntimeScope(fn) {
94
96
  /** Resolve configured Pi backend mode. Default sdk-first; cli-only preserves pure CLI path. */
95
97
  export function resolvePiBackend() {
96
98
  const raw = process.env[PI_BACKEND_ENV]?.trim().toLowerCase();
97
- if (raw === 'cli-only')
98
- return 'cli-only';
99
- if (raw === 'sdk-first' || !raw)
100
- return 'sdk-first';
101
- return 'sdk-first';
99
+ if (raw === "cli-only")
100
+ return "cli-only";
101
+ if (raw === "sdk-first" || !raw)
102
+ return "sdk-first";
103
+ return "sdk-first";
102
104
  }
103
105
  function piCliPathFromPackageRoot(nodeModulesRoot, packageName) {
104
- return path.join(nodeModulesRoot, ...packageName.split('/'), 'dist', 'cli.js');
106
+ return path.join(nodeModulesRoot, ...packageName.split("/"), "dist", "cli.js");
105
107
  }
106
108
  function findExistingPiCliUnderNodeModules(nodeModulesRoot) {
107
109
  for (const packageName of PI_CLI_PACKAGE_NAMES) {
@@ -113,7 +115,7 @@ function findExistingPiCliUnderNodeModules(nodeModulesRoot) {
113
115
  }
114
116
  function resolveGlobalNpmPiCliPath() {
115
117
  try {
116
- const npmRoot = spawnSync('npm', ['root', '-g'], { encoding: 'utf-8' });
118
+ const npmRoot = spawnSync("npm", ["root", "-g"], { encoding: "utf-8" });
117
119
  if (npmRoot.status === 0 && npmRoot.stdout.trim()) {
118
120
  return findExistingPiCliUnderNodeModules(npmRoot.stdout.trim());
119
121
  }
@@ -127,23 +129,25 @@ function resolvePiCliNearShim(shimPath) {
127
129
  if (!existsSync(shimPath))
128
130
  return undefined;
129
131
  const shimDir = path.dirname(shimPath);
130
- const nearGlobalShim = findExistingPiCliUnderNodeModules(path.join(shimDir, 'node_modules'));
132
+ const nearGlobalShim = findExistingPiCliUnderNodeModules(path.join(shimDir, "node_modules"));
131
133
  if (nearGlobalShim)
132
134
  return nearGlobalShim;
133
135
  if (shimDir.endsWith(`${path.sep}.bin`)) {
134
- const nearLocalBin = findExistingPiCliUnderNodeModules(path.join(shimDir, '..'));
136
+ const nearLocalBin = findExistingPiCliUnderNodeModules(path.join(shimDir, ".."));
135
137
  if (nearLocalBin)
136
138
  return nearLocalBin;
137
139
  }
138
140
  return undefined;
139
141
  }
140
142
  function resolvePiCliFromPathLookup() {
141
- const lookupCommands = process.platform === 'win32'
142
- ? [{ command: 'where.exe', args: ['pi'] }]
143
- : [{ command: 'which', args: ['pi'] }];
143
+ const lookupCommands = process.platform === "win32"
144
+ ? [{ command: "where.exe", args: ["pi"] }]
145
+ : [{ command: "which", args: ["pi"] }];
144
146
  for (const lookup of lookupCommands) {
145
147
  try {
146
- const result = spawnSync(lookup.command, lookup.args, { encoding: 'utf-8' });
148
+ const result = spawnSync(lookup.command, lookup.args, {
149
+ encoding: "utf-8",
150
+ });
147
151
  if (result.status !== 0 || !result.stdout.trim())
148
152
  continue;
149
153
  for (const rawPath of result.stdout.split(/\r?\n/)) {
@@ -153,10 +157,10 @@ function resolvePiCliFromPathLookup() {
153
157
  const nearShim = resolvePiCliNearShim(lookupPath);
154
158
  if (nearShim)
155
159
  return nearShim;
156
- if (process.platform !== 'win32') {
160
+ if (process.platform !== "win32") {
157
161
  try {
158
- const realPathResult = spawnSync('readlink', ['-f', lookupPath], {
159
- encoding: 'utf-8',
162
+ const realPathResult = spawnSync("readlink", ["-f", lookupPath], {
163
+ encoding: "utf-8",
160
164
  });
161
165
  if (realPathResult.status === 0 && realPathResult.stdout.trim()) {
162
166
  const resolved = realPathResult.stdout.trim();
@@ -191,20 +195,20 @@ function resolvePiCliPath() {
191
195
  const home = homedir();
192
196
  // Try nvm paths with both "v"-prefixed and non-prefixed version directories
193
197
  const nodeVersion = process.versions.node; // e.g. "22.19.0"
194
- const nvmBase = path.join(home, '.nvm', 'versions', 'node');
198
+ const nvmBase = path.join(home, ".nvm", "versions", "node");
195
199
  const nvmCandidates = [
196
- path.join(nvmBase, `v${nodeVersion}`, 'lib', 'node_modules'),
197
- path.join(nvmBase, nodeVersion, 'lib', 'node_modules'),
200
+ path.join(nvmBase, `v${nodeVersion}`, "lib", "node_modules"),
201
+ path.join(nvmBase, nodeVersion, "lib", "node_modules"),
198
202
  ].flatMap((nodeModulesRoot) => PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(nodeModulesRoot, packageName)));
199
203
  const otherCandidates = [
200
204
  // Prefer the project-local Pi dependency. `npm run` adds node_modules/.bin
201
205
  // to PATH, but direct invocations from the DAG runner may not, so resolve
202
206
  // the package from the current workspace explicitly.
203
- ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(path.resolve(process.cwd(), 'node_modules'), packageName)),
207
+ ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(path.resolve(process.cwd(), "node_modules"), packageName)),
204
208
  // Global npm installation (non-nvm)
205
- ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot('/usr/local/lib/node_modules', packageName)),
209
+ ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot("/usr/local/lib/node_modules", packageName)),
206
210
  // Bun installation
207
- ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(path.join(home, '.bun', 'install', 'global', 'node_modules'), packageName)),
211
+ ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(path.join(home, ".bun", "install", "global", "node_modules"), packageName)),
208
212
  ];
209
213
  const allCandidates = [...nvmCandidates, ...otherCandidates];
210
214
  for (const candidate of allCandidates) {
@@ -218,7 +222,7 @@ function resolvePiCliPath() {
218
222
  const pathLookupPi = resolvePiCliFromPathLookup();
219
223
  if (pathLookupPi)
220
224
  return pathLookupPi;
221
- throw new Error('Could not resolve pi CLI path. Ensure pi is installed.');
225
+ throw new Error("Could not resolve pi CLI path. Ensure pi is installed.");
222
226
  }
223
227
  let cachedPiCliPath;
224
228
  /** Lazily resolve and cache the pi CLI script path (avoids import-time sync I/O). */
@@ -235,17 +239,19 @@ export function resetPiCliPathCacheForTests() {
235
239
  export function checkPiAvailability() {
236
240
  // Use process.execPath to run pi CLI directly, avoiding shebang-based
237
241
  // resolution that can drop built-in providers when spawned from tsx.
238
- const result = spawnSync(process.execPath, [getPiCliPath(), '--help'], { encoding: 'utf-8' });
242
+ const result = spawnSync(process.execPath, [getPiCliPath(), "--help"], {
243
+ encoding: "utf-8",
244
+ });
239
245
  if (result.error) {
240
246
  return { ok: false, detail: result.error.message };
241
247
  }
242
248
  if (result.status !== 0) {
243
249
  return { ok: false, detail: result.stderr || `exit ${result.status}` };
244
250
  }
245
- return { ok: true, detail: 'pi available' };
251
+ return { ok: true, detail: "pi available" };
246
252
  }
247
253
  export function extractAssistantTextFromPiJson(stdout) {
248
- let assistantText = '';
254
+ let assistantText = "";
249
255
  let parsedEvents = 0;
250
256
  for (const line of stdout.split(/\r?\n/)) {
251
257
  const trimmed = line.trim();
@@ -292,9 +298,22 @@ function extractUsageFromEvent(event) {
292
298
  for (const candidate of candidates) {
293
299
  if (!isRecord(candidate))
294
300
  continue;
295
- const input = readNumericUsageField(candidate, ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens']);
296
- const output = readNumericUsageField(candidate, ['output_tokens', 'outputTokens', 'completion_tokens', 'completionTokens']);
297
- const total = readNumericUsageField(candidate, ['total_tokens', 'totalTokens']);
301
+ const input = readNumericUsageField(candidate, [
302
+ "input_tokens",
303
+ "inputTokens",
304
+ "prompt_tokens",
305
+ "promptTokens",
306
+ ]);
307
+ const output = readNumericUsageField(candidate, [
308
+ "output_tokens",
309
+ "outputTokens",
310
+ "completion_tokens",
311
+ "completionTokens",
312
+ ]);
313
+ const total = readNumericUsageField(candidate, [
314
+ "total_tokens",
315
+ "totalTokens",
316
+ ]);
298
317
  if (input !== null && output !== null)
299
318
  return input + output;
300
319
  if (total !== null)
@@ -305,20 +324,20 @@ function extractUsageFromEvent(event) {
305
324
  function readNumericUsageField(record, keys) {
306
325
  for (const key of keys) {
307
326
  const value = record[key];
308
- if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
327
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
309
328
  return Math.trunc(value);
310
329
  }
311
330
  }
312
331
  return null;
313
332
  }
314
333
  function extractAssistantTextFromEvent(event) {
315
- if (event.type === 'turn_end' && isRecord(event.message)) {
334
+ if (event.type === "turn_end" && isRecord(event.message)) {
316
335
  return extractAssistantTextFromMessage(event.message);
317
336
  }
318
- if (event.type === 'message_end' && isRecord(event.message)) {
337
+ if (event.type === "message_end" && isRecord(event.message)) {
319
338
  return extractAssistantTextFromMessage(event.message);
320
339
  }
321
- if (event.type === 'agent_end' && Array.isArray(event.messages)) {
340
+ if (event.type === "agent_end" && Array.isArray(event.messages)) {
322
341
  for (let i = event.messages.length - 1; i >= 0; i -= 1) {
323
342
  const message = event.messages[i];
324
343
  if (isRecord(message)) {
@@ -328,27 +347,27 @@ function extractAssistantTextFromEvent(event) {
328
347
  }
329
348
  }
330
349
  }
331
- return '';
350
+ return "";
332
351
  }
333
352
  function extractAssistantTextFromMessage(message) {
334
- if (message.role !== 'assistant' || !Array.isArray(message.content))
335
- return '';
353
+ if (message.role !== "assistant" || !Array.isArray(message.content))
354
+ return "";
336
355
  const textParts = message.content
337
356
  .filter(isRecord)
338
- .filter((item) => item.type === 'text' && typeof item.text === 'string')
357
+ .filter((item) => item.type === "text" && typeof item.text === "string")
339
358
  .map((item) => item.text);
340
- return textParts.join('\n').trim();
359
+ return textParts.join("\n").trim();
341
360
  }
342
361
  function isRecord(value) {
343
- return typeof value === 'object' && value !== null;
362
+ return typeof value === "object" && value !== null;
344
363
  }
345
364
  export class BoundedTextPreview {
346
365
  label;
347
366
  headMax;
348
367
  tailMax;
349
368
  parts = [];
350
- head = '';
351
- tail = '';
369
+ head = "";
370
+ tail = "";
352
371
  bufferedChars = 0;
353
372
  totalChars = 0;
354
373
  truncated = false;
@@ -367,7 +386,7 @@ export class BoundedTextPreview {
367
386
  this.bufferedChars += chunk.length;
368
387
  return;
369
388
  }
370
- const combined = this.parts.join('') + chunk;
389
+ const combined = this.parts.join("") + chunk;
371
390
  this.head = combined.slice(0, this.headMax);
372
391
  this.tail = combined.slice(-this.tailMax);
373
392
  this.parts = [];
@@ -379,19 +398,19 @@ export class BoundedTextPreview {
379
398
  }
380
399
  text() {
381
400
  if (!this.truncated)
382
- return this.parts.join('');
401
+ return this.parts.join("");
383
402
  const omitted = Math.max(0, this.totalChars - this.head.length - this.tail.length);
384
403
  return [
385
404
  this.head,
386
405
  `[${this.label} truncated: omitted ${omitted} chars; kept first ${this.head.length} and last ${this.tail.length}]`,
387
406
  this.tail,
388
- ].join('\n');
407
+ ].join("\n");
389
408
  }
390
409
  }
391
410
  export class PiJsonlStreamCollector {
392
- lineBuffer = '';
411
+ lineBuffer = "";
393
412
  discardUntilNewline = false;
394
- assistantText = '';
413
+ assistantText = "";
395
414
  parsedEvents = 0;
396
415
  tokensUsed = 0;
397
416
  outputTooLarge = false;
@@ -404,7 +423,7 @@ export class PiJsonlStreamCollector {
404
423
  return;
405
424
  const lines = chunk.split(/\r?\n/);
406
425
  for (let index = 0; index < lines.length; index += 1) {
407
- const part = lines[index] ?? '';
426
+ const part = lines[index] ?? "";
408
427
  const isLast = index === lines.length - 1;
409
428
  if (this.discardUntilNewline) {
410
429
  if (!isLast)
@@ -413,7 +432,7 @@ export class PiJsonlStreamCollector {
413
432
  }
414
433
  if (this.lineBuffer.length + part.length > MAX_STREAM_JSONL_LINE_CHARS) {
415
434
  this.outputTooLarge = true;
416
- this.lineBuffer = '';
435
+ this.lineBuffer = "";
417
436
  if (isLast)
418
437
  this.discardUntilNewline = true;
419
438
  continue;
@@ -421,7 +440,7 @@ export class PiJsonlStreamCollector {
421
440
  this.lineBuffer += part;
422
441
  if (!isLast) {
423
442
  this.consumeLine(this.lineBuffer);
424
- this.lineBuffer = '';
443
+ this.lineBuffer = "";
425
444
  }
426
445
  }
427
446
  }
@@ -429,7 +448,7 @@ export class PiJsonlStreamCollector {
429
448
  if (!this.discardUntilNewline && this.lineBuffer.trim()) {
430
449
  this.consumeLine(this.lineBuffer);
431
450
  }
432
- this.lineBuffer = '';
451
+ this.lineBuffer = "";
433
452
  return {
434
453
  assistantText: this.assistantText.trim(),
435
454
  outputTooLarge: this.outputTooLarge,
@@ -465,47 +484,51 @@ export class PiJsonlStreamCollector {
465
484
  const usage = extractUsageFromEvent(event);
466
485
  if (usage !== null)
467
486
  this.tokensUsed += usage;
468
- if (event.type === 'tool_execution_start' && event.toolName === 'subagent') {
487
+ if (event.type === "tool_execution_start" &&
488
+ event.toolName === "subagent") {
469
489
  this.subagentTotalCalls += 1;
470
490
  if (isRecord(event.input)) {
471
491
  if (event.input.agent)
472
492
  this.subagentAgents.add(String(event.input.agent));
473
493
  if (event.input.tasks)
474
- this.subagentModes.add('parallel');
494
+ this.subagentModes.add("parallel");
475
495
  if (event.input.chain)
476
- this.subagentModes.add('chain');
496
+ this.subagentModes.add("chain");
477
497
  if (event.input.agent && !event.input.tasks && !event.input.chain) {
478
- this.subagentModes.add('single');
498
+ this.subagentModes.add("single");
479
499
  }
480
500
  }
481
501
  }
482
- if (event.type === 'tool_execution_end' && event.toolName === 'subagent') {
502
+ if (event.type === "tool_execution_end" && event.toolName === "subagent") {
483
503
  if (event.isError === true)
484
504
  this.subagentFailedCalls += 1;
485
505
  }
486
506
  }
487
507
  formatSubagentStats() {
488
508
  if (this.subagentTotalCalls === 0)
489
- return 'calls=0';
509
+ return "calls=0";
490
510
  const parts = [`calls=${this.subagentTotalCalls}`];
491
511
  if (this.subagentFailedCalls > 0)
492
512
  parts.push(`${this.subagentFailedCalls} failed`);
493
513
  if (this.subagentModes.size > 0)
494
- parts.push(Array.from(this.subagentModes).sort().join('+'));
514
+ parts.push(Array.from(this.subagentModes).sort().join("+"));
495
515
  if (this.subagentAgents.size > 0) {
496
- parts.push(`agents=[${Array.from(this.subagentAgents).sort().join(',')}]`);
516
+ parts.push(`agents=[${Array.from(this.subagentAgents).sort().join(",")}]`);
497
517
  }
498
- return parts.join(' | ');
518
+ return parts.join(" | ");
499
519
  }
500
520
  }
501
521
  export function createPiJsonlStreamCollector() {
502
522
  return new PiJsonlStreamCollector();
503
523
  }
504
524
  function withSubagentStats(result) {
505
- return { ...result, subagentStats: result.subagentStats ?? extractSubagentStats(result.stdout) };
525
+ return {
526
+ ...result,
527
+ subagentStats: result.subagentStats ?? extractSubagentStats(result.stdout),
528
+ };
506
529
  }
507
530
  function withCliBackend(result) {
508
- return { ...result, backend: 'cli' };
531
+ return { ...result, backend: "cli" };
509
532
  }
510
533
  /** CLI-only orchestration: primary attempt + optional fallback model retry. */
511
534
  async function runCliOrchestration(options, meta) {
@@ -534,15 +557,20 @@ async function runCliOrchestrationWithPromptFile(options, promptFilePath, meta)
534
557
  }
535
558
  const primaryHardFail = !primaryResult.ok;
536
559
  const primaryInvalidOutput = primaryResult.ok && primaryIssues.length > 0;
537
- const shouldFallback = primary.fallback && ((primaryHardFail
538
- && primaryResult.failureCategory !== 'termination-unconfirmed'
539
- && shouldRetryWithFallback(primaryResult.stderr, primaryResult.stdout))
540
- || primaryInvalidOutput);
560
+ const shouldFallback = primary.fallback &&
561
+ ((primaryHardFail &&
562
+ primaryResult.failureCategory !== "termination-unconfirmed" &&
563
+ shouldRetryWithFallback(primaryResult.stderr, primaryResult.stdout)) ||
564
+ primaryInvalidOutput);
541
565
  if (shouldFallback) {
542
566
  const fallbackResult = await executeSingleCliAttempt(options, primary.fallback, promptFilePath);
543
567
  attemptedModels.push(fallbackResult.modelDisplay);
544
- const combinedStderr = [primaryResult.stderr, fallbackResult.stderr].filter(Boolean).join('\n--- fallback ---\n');
545
- const combinedStdout = [primaryResult.stdout, fallbackResult.stdout].filter(Boolean).join('\n--- fallback ---\n');
568
+ const combinedStderr = [primaryResult.stderr, fallbackResult.stderr]
569
+ .filter(Boolean)
570
+ .join("\n--- fallback ---\n");
571
+ const combinedStdout = [primaryResult.stdout, fallbackResult.stdout]
572
+ .filter(Boolean)
573
+ .join("\n--- fallback ---\n");
546
574
  const combinedStdoutTruncated = primaryResult.stdoutTruncated || fallbackResult.stdoutTruncated;
547
575
  const combinedStderrTruncated = primaryResult.stderrTruncated || fallbackResult.stderrTruncated;
548
576
  const combinedOutputTooLarge = primaryResult.outputTooLarge || fallbackResult.outputTooLarge;
@@ -567,11 +595,11 @@ async function runCliOrchestrationWithPromptFile(options, promptFilePath, meta)
567
595
  return withSubagentStats(withCliBackend({
568
596
  ...fallbackResult,
569
597
  ok: false,
570
- failureCategory: 'invalid-output',
598
+ failureCategory: "invalid-output",
571
599
  attemptedModels,
572
600
  fallbackUsed: true,
573
601
  sdkAttempted: meta?.sdkAttempted,
574
- stderr: combinedStderr + `\n[invalid-output] ${fallbackIssues.join('; ')}`,
602
+ stderr: combinedStderr + `\n[invalid-output] ${fallbackIssues.join("; ")}`,
575
603
  stdout: combinedStdout,
576
604
  stdoutTruncated: combinedStdoutTruncated,
577
605
  stderrTruncated: combinedStderrTruncated,
@@ -594,11 +622,12 @@ async function runCliOrchestrationWithPromptFile(options, promptFilePath, meta)
594
622
  return withSubagentStats(withCliBackend({
595
623
  ...primaryResult,
596
624
  ok: false,
597
- failureCategory: 'invalid-output',
625
+ failureCategory: "invalid-output",
598
626
  attemptedModels,
599
627
  fallbackUsed: false,
600
628
  sdkAttempted: meta?.sdkAttempted,
601
- stderr: primaryResult.stderr + `\n[invalid-output] ${primaryIssues.join('; ')}`,
629
+ stderr: primaryResult.stderr +
630
+ `\n[invalid-output] ${primaryIssues.join("; ")}`,
602
631
  }));
603
632
  }
604
633
  return withSubagentStats(withCliBackend({
@@ -608,29 +637,71 @@ async function runCliOrchestrationWithPromptFile(options, promptFilePath, meta)
608
637
  sdkAttempted: meta?.sdkAttempted,
609
638
  }));
610
639
  }
611
- function shouldFallbackSdkToCli(sdkResult, validationIssues) {
640
+ function shouldFallbackSdkToCli(sdkResult, validationIssues, options) {
641
+ // Writers must never fall back to unrestricted CLI mutation tools.
642
+ if (options?.writerToolPolicy?.requireSdk)
643
+ return false;
612
644
  if (sdkResult.ok && validationIssues.length > 0)
613
645
  return true;
614
- if (!sdkResult.ok && (sdkResult.failureCategory === 'auth'
615
- || sdkResult.failureCategory === 'timeout'
616
- || sdkResult.failureCategory === 'termination-unconfirmed')) {
646
+ if (!sdkResult.ok &&
647
+ (sdkResult.failureCategory === "auth" ||
648
+ sdkResult.failureCategory === "timeout" ||
649
+ sdkResult.failureCategory === "termination-unconfirmed")) {
617
650
  return false;
618
651
  }
619
- if (!sdkResult.ok && shouldRetryWithFallback(sdkResult.stderr, sdkResult.stdout))
652
+ if (!sdkResult.ok &&
653
+ shouldRetryWithFallback(sdkResult.stderr, sdkResult.stdout))
620
654
  return true;
621
655
  return false;
622
656
  }
657
+ function writerPolicyFailureResult(options, message, extras) {
658
+ return {
659
+ assistantText: "",
660
+ command: [],
661
+ durationMs: 0,
662
+ exitCode: 1,
663
+ failureCategory: "tool-policy",
664
+ modelDisplay: options.modelConfig?.model
665
+ ? `${options.modelConfig.provider ?? "default"}/${options.modelConfig.model}`
666
+ : "default",
667
+ ok: false,
668
+ parsedEvents: 0,
669
+ stderr: message,
670
+ stdout: "",
671
+ timedOut: false,
672
+ attemptedModels: [],
673
+ fallbackUsed: false,
674
+ tokensUsed: 0,
675
+ backend: extras?.backend,
676
+ sdkAttempted: extras?.sdkAttempted ?? false,
677
+ };
678
+ }
623
679
  export async function executePiStep(options) {
624
680
  const backendMode = resolvePiBackend();
625
- if (backendMode === 'cli-only') {
681
+ const writerRequiresSdk = options.writerToolPolicy?.requireSdk === true;
682
+ if (backendMode === "cli-only") {
683
+ if (writerRequiresSdk) {
684
+ return writerPolicyFailureResult(options, "pi writer tool policy requires SDK backend; CODE_AGENT_PI_BACKEND=cli-only is fail-closed for writers", { sdkAttempted: false, backend: "cli" });
685
+ }
626
686
  return runCliOrchestration(options, { sdkAttempted: false });
627
687
  }
628
- const { checkPiSdkAvailability, executeSingleSdkAttempt, getActivePiSdkReuseScope, } = await import('./pi-sdk-executor.js');
688
+ if (writerRequiresSdk) {
689
+ const customTools = options.writerToolPolicy?.customTools;
690
+ if (!Array.isArray(customTools) || customTools.length === 0) {
691
+ return writerPolicyFailureResult(options, "pi writer tool policy missing customTools; refusing uncontrolled writer session", { sdkAttempted: false });
692
+ }
693
+ }
694
+ const { checkPiSdkAvailability, executeSingleSdkAttempt, getActivePiSdkReuseScope, } = await import("./pi-sdk-executor.js");
629
695
  const sdkCheck = await checkPiSdkAvailability(options.repoRoot);
630
696
  if (!sdkCheck.ok) {
697
+ if (writerRequiresSdk) {
698
+ return writerPolicyFailureResult(options, `pi writer tool policy requires SDK; ${sdkCheck.detail}`, { sdkAttempted: false });
699
+ }
631
700
  return runCliOrchestration(options, { sdkAttempted: false });
632
701
  }
633
- const reuseScope = isPiReuseRuntimeEnabled() ? getActivePiSdkReuseScope() : undefined;
702
+ const reuseScope = isPiReuseRuntimeEnabled()
703
+ ? getActivePiSdkReuseScope()
704
+ : undefined;
634
705
  const reuseRuntimeActive = Boolean(reuseScope);
635
706
  const primary = options.modelConfig ?? {};
636
707
  const sdkResult = await executeSingleSdkAttempt({
@@ -651,10 +722,12 @@ export async function executePiStep(options) {
651
722
  reuseRuntimeActive,
652
723
  });
653
724
  }
654
- if (!shouldFallbackSdkToCli(sdkResult, validationIssues)) {
655
- const failureCategory = validationIssues.length > 0 ? 'invalid-output' : sdkResult.failureCategory;
725
+ if (!shouldFallbackSdkToCli(sdkResult, validationIssues, options)) {
726
+ const failureCategory = validationIssues.length > 0
727
+ ? "invalid-output"
728
+ : sdkResult.failureCategory;
656
729
  const stderr = validationIssues.length > 0
657
- ? sdkResult.stderr + `\n[invalid-output] ${validationIssues.join('; ')}`
730
+ ? sdkResult.stderr + `\n[invalid-output] ${validationIssues.join("; ")}`
658
731
  : sdkResult.stderr;
659
732
  return withSubagentStats({
660
733
  ...sdkResult,
@@ -676,13 +749,13 @@ export async function executePiStep(options) {
676
749
  async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
677
750
  const modelDisplay = modelConfig.provider && modelConfig.model
678
751
  ? `${modelConfig.provider}/${modelConfig.model}`
679
- : modelConfig.model ?? 'default';
752
+ : (modelConfig.model ?? "default");
680
753
  const timeoutMs = options.timeoutMs ?? modelConfig.timeoutMs ?? DEFAULT_TIMEOUT_MS;
681
754
  const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
682
755
  const abortGraceMs = options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
683
756
  let timedOut = false;
684
757
  let terminationUnconfirmed = false;
685
- let supervisionStderr = '';
758
+ let supervisionStderr = "";
686
759
  let timeoutHandle;
687
760
  let stallHandle;
688
761
  let sigkillHandle;
@@ -690,22 +763,30 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
690
763
  // Build pi CLI arguments (same as before).
691
764
  // We invoke pi via `node <pi-cli-path>` instead of relying on `#!/usr/bin/env node`
692
765
  // to avoid provider resolution issues when spawned from tsx-managed processes.
693
- const piArgs = ['-p', '--mode', 'json', '--no-session', '--no-context-files', '--no-skills'];
766
+ const piArgs = [
767
+ "-p",
768
+ "--mode",
769
+ "json",
770
+ "--no-session",
771
+ "--no-context-files",
772
+ "--no-skills",
773
+ "--no-extensions",
774
+ ];
694
775
  if (modelConfig.provider) {
695
- piArgs.push('--provider', modelConfig.provider);
776
+ piArgs.push("--provider", modelConfig.provider);
696
777
  }
697
778
  if (modelConfig.model) {
698
- piArgs.push('--model', modelConfig.model);
779
+ piArgs.push("--model", modelConfig.model);
699
780
  }
700
781
  if (modelConfig.thinking) {
701
- piArgs.push('--thinking', modelConfig.thinking);
782
+ piArgs.push("--thinking", modelConfig.thinking);
702
783
  }
703
- piArgs.push('--tools', options.toolNames.join(','));
704
- piArgs.push('--append-system-prompt', promptFilePath);
784
+ piArgs.push("--tools", options.toolNames.join(","));
785
+ piArgs.push("--append-system-prompt", promptFilePath);
705
786
  piArgs.push(...options.attachedFiles.map((file) => `@${file}`));
706
787
  piArgs.push(options.userMessage);
707
- const recordedPiArgs = piArgs.map((arg, index) => index > 0 && piArgs[index - 1] === '--append-system-prompt'
708
- ? '<system-prompt-file>'
788
+ const recordedPiArgs = piArgs.map((arg, index) => index > 0 && piArgs[index - 1] === "--append-system-prompt"
789
+ ? "<system-prompt-file>"
709
790
  : arg);
710
791
  const startedAt = Date.now();
711
792
  return await new Promise((resolve, reject) => {
@@ -718,22 +799,22 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
718
799
  // When tsx runs inside a non-login shell, some vars set in ~/.zshrc
719
800
  // may not be in process.env; this ensures pi can resolve providers.
720
801
  HOME: process.env.HOME,
721
- DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? '',
722
- AIMAX_API_KEY: process.env.AIMAX_API_KEY ?? '',
723
- OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY ?? '',
724
- NODE_PATH: process.env.NODE_PATH ?? '',
802
+ DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? "",
803
+ AIMAX_API_KEY: process.env.AIMAX_API_KEY ?? "",
804
+ OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY ?? "",
805
+ NODE_PATH: process.env.NODE_PATH ?? "",
725
806
  // Prevent upstream Node loader hooks (tsx/preload/import) from leaking into pi.
726
- NODE_OPTIONS: '',
727
- PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin',
807
+ NODE_OPTIONS: "",
808
+ PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
728
809
  },
729
- stdio: ['ignore', 'pipe', 'pipe'],
810
+ stdio: ["ignore", "pipe", "pipe"],
730
811
  });
731
- const stdoutPreview = new BoundedTextPreview('stdout');
732
- const stderrPreview = new BoundedTextPreview('stderr', 16_000, 16_000);
812
+ const stdoutPreview = new BoundedTextPreview("stdout");
813
+ const stderrPreview = new BoundedTextPreview("stderr", 16_000, 16_000);
733
814
  const stdoutCollector = new PiJsonlStreamCollector();
734
815
  let settled = false;
735
- child.stdout.setEncoding('utf-8');
736
- child.stderr.setEncoding('utf-8');
816
+ child.stdout.setEncoding("utf-8");
817
+ child.stderr.setEncoding("utf-8");
737
818
  const clearSupervisionTimers = () => {
738
819
  if (timeoutHandle)
739
820
  clearTimeout(timeoutHandle);
@@ -752,10 +833,12 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
752
833
  const durationMs = Date.now() - startedAt;
753
834
  const stdout = stdoutPreview.text();
754
835
  const capturedStderr = stderrPreview.text();
755
- const stderr = [supervisionStderr, capturedStderr].filter(Boolean).join('\n');
836
+ const stderr = [supervisionStderr, capturedStderr]
837
+ .filter(Boolean)
838
+ .join("\n");
756
839
  const collected = stdoutCollector.finish();
757
840
  const failureCategory = terminationUnconfirmed
758
- ? 'termination-unconfirmed'
841
+ ? "termination-unconfirmed"
759
842
  : classifyPiFailure({
760
843
  assistantText: collected.assistantText,
761
844
  exitCode: code,
@@ -766,12 +849,15 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
766
849
  });
767
850
  resolve({
768
851
  assistantText: collected.assistantText,
769
- command: ['pi', ...recordedPiArgs],
852
+ command: ["pi", ...recordedPiArgs],
770
853
  durationMs,
771
854
  exitCode: code,
772
855
  failureCategory,
773
856
  modelDisplay,
774
- ok: !timedOut && code === 0 && collected.assistantText.length > 0 && !collected.outputTooLarge,
857
+ ok: !timedOut &&
858
+ code === 0 &&
859
+ collected.assistantText.length > 0 &&
860
+ !collected.outputTooLarge,
775
861
  parsedEvents: collected.parsedEvents,
776
862
  stderr,
777
863
  stdout,
@@ -789,7 +875,7 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
789
875
  return;
790
876
  timedOut = true;
791
877
  supervisionStderr = reason;
792
- terminateProcessTree(child, 'SIGTERM');
878
+ terminateProcessTree(child, "SIGTERM");
793
879
  sigkillHandle = setTimeout(() => {
794
880
  if (settled)
795
881
  return;
@@ -797,7 +883,7 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
797
883
  finish(child.exitCode);
798
884
  return;
799
885
  }
800
- terminateProcessTree(child, 'SIGKILL');
886
+ terminateProcessTree(child, "SIGKILL");
801
887
  exitConfirmationHandle = setTimeout(() => {
802
888
  if (settled)
803
889
  return;
@@ -823,29 +909,29 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
823
909
  const reportOutputActivity = () => {
824
910
  armStallWatchdog();
825
911
  try {
826
- options.onActivity?.({ kind: 'output', at: new Date().toISOString() });
912
+ options.onActivity?.({ kind: "output", at: new Date().toISOString() });
827
913
  }
828
914
  catch {
829
915
  // best-effort: never fail the pi step
830
916
  }
831
917
  };
832
- child.stdout.on('data', (chunk) => {
918
+ child.stdout.on("data", (chunk) => {
833
919
  stdoutPreview.append(chunk);
834
920
  stdoutCollector.append(chunk);
835
921
  if (chunk.length > 0)
836
922
  reportOutputActivity();
837
923
  });
838
- child.stderr.on('data', (chunk) => {
924
+ child.stderr.on("data", (chunk) => {
839
925
  stderrPreview.append(chunk);
840
926
  if (chunk.length > 0)
841
927
  reportOutputActivity();
842
928
  });
843
- child.on('error', (err) => {
929
+ child.on("error", (err) => {
844
930
  clearSupervisionTimers();
845
931
  settled = true;
846
932
  reject(err);
847
933
  });
848
- child.on('close', (code) => {
934
+ child.on("close", (code) => {
849
935
  finish(code);
850
936
  });
851
937
  armStallWatchdog();
@@ -858,35 +944,35 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
858
944
  }
859
945
  export function shouldRetryWithFallback(stderr, stdout) {
860
946
  const category = classifyPiFailure({
861
- assistantText: '',
947
+ assistantText: "",
862
948
  exitCode: null,
863
949
  stderr,
864
950
  stdout,
865
951
  timedOut: false,
866
952
  });
867
- return ['quota', 'rate-limit', 'unavailable', 'network'].includes(category);
953
+ return ["quota", "rate-limit", "unavailable", "network"].includes(category);
868
954
  }
869
955
  export function classifyPiFailure(input) {
870
956
  if (input.timedOut)
871
- return 'timeout';
957
+ return "timeout";
872
958
  if (input.outputTooLarge)
873
- return 'output-too-large';
959
+ return "output-too-large";
874
960
  if (input.exitCode === 0 && input.assistantText.trim())
875
- return 'success';
961
+ return "success";
876
962
  const combined = `${input.stderr}\n${input.stdout}`.toLowerCase();
877
963
  if (/quota|usage limit|reached.*limit|5\s*小时|5小时/.test(combined))
878
- return 'quota';
964
+ return "quota";
879
965
  if (/rate.?limit/.test(combined))
880
- return 'rate-limit';
966
+ return "rate-limit";
881
967
  if (/unauthorized|forbidden|invalid api key|authentication|auth failed/.test(combined))
882
- return 'auth';
968
+ return "auth";
883
969
  if (/unknown provider|unknown model|model.*unavailable|provider.*unavailable|\bunavailable\b|overloaded|capacity|temporarily unavailable/.test(combined))
884
- return 'unavailable';
970
+ return "unavailable";
885
971
  if (/network|econnreset|etimedout|socket hang up|connection reset|dns|fetch failed/.test(combined))
886
- return 'network';
972
+ return "network";
887
973
  if (input.exitCode === 0 && !input.assistantText.trim())
888
- return 'empty-output';
974
+ return "empty-output";
889
975
  if (input.exitCode !== 0)
890
- return 'nonzero-exit';
891
- return 'unknown';
976
+ return "nonzero-exit";
977
+ return "unknown";
892
978
  }