@doist/doistbot-cli 1.0.10 → 1.0.11

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 (34) hide show
  1. package/dist/actions/review-usage.js +4 -4
  2. package/dist/actions/review.js +31 -21
  3. package/dist/config.js +61 -16
  4. package/dist/index.js +8 -2
  5. package/dist/terminal.js +4 -0
  6. package/package.json +3 -3
  7. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
  9. package/sandbox/dist/core/datadog-metrics.js +7 -2
  10. package/sandbox/dist/core/label-parsing.js +1 -22
  11. package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
  12. package/sandbox/dist/core/pi-progress-logging.js +4 -0
  13. package/sandbox/dist/core/pi.js +286 -67
  14. package/sandbox/dist/core/repository-map.js +1 -220
  15. package/sandbox/dist/core/repository-resolver.js +1 -166
  16. package/sandbox/dist/providers/config.js +1 -1
  17. package/sandbox/dist/providers/pi-review.js +53 -3
  18. package/sandbox/dist/tasks/chat/chat.js +95 -45
  19. package/sandbox/dist/tasks/chat/prompt.js +5 -0
  20. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -0
  21. package/sandbox/dist/tasks/issue-summarize/model.js +4 -4
  22. package/sandbox/dist/tasks/issue-triage/context.js +2 -0
  23. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -1
  24. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +4 -7
  25. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +65 -41
  26. package/sandbox/dist/tasks/issue-triage/output.js +1 -1
  27. package/sandbox/dist/tasks/issue-triage/resolution.js +1 -0
  28. package/sandbox/dist/tasks/issue-triage/shared.js +2 -12
  29. package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
  30. package/sandbox/dist/tasks/review/engines/multi-focus.js +23 -9
  31. package/sandbox/dist/tasks/review/review-summary.js +1 -1
  32. package/sandbox/dist/tasks/review/summary-model.js +2 -2
  33. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  34. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
@@ -3,6 +3,45 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
3
3
  import { tmpdir } from 'os';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
+ import { PI_EXECUTION_LIMIT_ENV } from './pi-execution-limits-extension.js';
7
+ export function toPiInvocationTelemetryFields(telemetry) {
8
+ return {
9
+ turn_count: telemetry.turnCount,
10
+ assistant_message_count: telemetry.assistantMessageCount,
11
+ tool_call_count: telemetry.toolCallCount,
12
+ tool_wall_clock_duration_ms: telemetry.toolWallClockDurationMs,
13
+ non_tool_duration_ms: telemetry.nonToolDurationMs,
14
+ ...(telemetry.timeToFirstActivityMs == null
15
+ ? {}
16
+ : { time_to_first_activity_ms: telemetry.timeToFirstActivityMs }),
17
+ ...(telemetry.timeToFirstThinkingMs == null
18
+ ? {}
19
+ : { time_to_first_thinking_ms: telemetry.timeToFirstThinkingMs }),
20
+ ...(telemetry.timeToFirstResponseMs == null
21
+ ? {}
22
+ : { time_to_first_response_ms: telemetry.timeToFirstResponseMs }),
23
+ ...(telemetry.timeToFirstToolCallMs == null
24
+ ? {}
25
+ : { time_to_first_tool_call_ms: telemetry.timeToFirstToolCallMs }),
26
+ ...(telemetry.finalizationAfterLastToolMs == null
27
+ ? {}
28
+ : { finalization_after_last_tool_ms: telemetry.finalizationAfterLastToolMs }),
29
+ soft_limit_reached: telemetry.softLimit != null,
30
+ ...(telemetry.softLimit
31
+ ? {
32
+ soft_limit_trigger: telemetry.softLimit.trigger,
33
+ soft_limit_elapsed_ms: telemetry.softLimit.elapsedMs,
34
+ }
35
+ : {}),
36
+ hard_limit_reached: telemetry.hardLimit != null,
37
+ ...(telemetry.hardLimit
38
+ ? {
39
+ hard_limit_trigger: telemetry.hardLimit.trigger,
40
+ hard_limit_elapsed_ms: telemetry.hardLimit.elapsedMs,
41
+ }
42
+ : {}),
43
+ };
44
+ }
6
45
  export const PI_PROVIDER_BY_DOISTBOT_PROVIDER = {
7
46
  deepseek: 'openrouter',
8
47
  gemini: 'google',
@@ -21,7 +60,7 @@ export const DEFAULT_OPENROUTER_MODELS = {
21
60
  deepseek: 'deepseek/deepseek-v4-pro-0813',
22
61
  gemini: 'google/gemini-3.1-pro-preview',
23
62
  grok: 'x-ai/grok-4.6',
24
- zai: 'z-ai/glm-5.2',
63
+ zai: 'z-ai/glm-5.3-flash',
25
64
  };
26
65
  // Gemini flash fallback model.
27
66
  export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.7-flash';
@@ -45,6 +84,7 @@ const PI_PROGRESS_HEARTBEAT_MS = 30_000;
45
84
  const OPENROUTER_SESSION_ID_ENV_VAR = 'DOISTBOT_OPENROUTER_SESSION_ID';
46
85
  const OPENROUTER_SESSION_ID_MAX_LENGTH = 256;
47
86
  const PI_TOOLS_BY_PRESET = {
87
+ none: [],
48
88
  'read-only': ['read', 'grep', 'find', 'ls'],
49
89
  'workspace-inspection': ['read', 'bash', 'grep', 'find', 'ls'],
50
90
  'shell-focused': ['read', 'bash', 'ls'],
@@ -54,6 +94,7 @@ function resolvePiCliPath() {
54
94
  return path.join(path.dirname(piPackageEntryPath), 'cli.js');
55
95
  }
56
96
  const PI_CLI_PATH = resolvePiCliPath();
97
+ const PI_EXECUTION_LIMIT_EXTENSION_PATH = fileURLToPath(new URL('./pi-execution-limits-extension.js', import.meta.url));
57
98
  let cachedNodeExecutable;
58
99
  let cachedOpenRouterSessionExtensionPath;
59
100
  export function resolveNodeExecutable({ execPath = process.execPath, argv0 = process.argv0, canExecute = canExecuteNode, } = {}) {
@@ -178,6 +219,47 @@ function createOpenRouterSessionExtension({ piProviderId, sessionId, }) {
178
219
  cleanup() { },
179
220
  };
180
221
  }
222
+ function createExecutionLimitExtension(limits, startedAtMs) {
223
+ if (!limits) {
224
+ return { args: [], extraEnv: {} };
225
+ }
226
+ return {
227
+ args: ['--extension', PI_EXECUTION_LIMIT_EXTENSION_PATH],
228
+ extraEnv: {
229
+ [PI_EXECUTION_LIMIT_ENV.startedAtMs]: String(startedAtMs),
230
+ ...(limits.softTimeMs == null
231
+ ? {}
232
+ : { [PI_EXECUTION_LIMIT_ENV.softTimeMs]: String(limits.softTimeMs) }),
233
+ ...(limits.softToolCalls == null
234
+ ? {}
235
+ : { [PI_EXECUTION_LIMIT_ENV.softToolCalls]: String(limits.softToolCalls) }),
236
+ ...(limits.hardTimeMs == null
237
+ ? {}
238
+ : { [PI_EXECUTION_LIMIT_ENV.hardTimeMs]: String(limits.hardTimeMs) }),
239
+ ...(limits.hardToolCalls == null
240
+ ? {}
241
+ : { [PI_EXECUTION_LIMIT_ENV.hardToolCalls]: String(limits.hardToolCalls) }),
242
+ },
243
+ };
244
+ }
245
+ function normalizePiExecutionLimits(limits) {
246
+ if (!limits)
247
+ return undefined;
248
+ function positiveInteger(value) {
249
+ return Number.isInteger(value) && value > 0 ? value : undefined;
250
+ }
251
+ const softTimeMs = positiveInteger(limits.softTimeMs);
252
+ const softToolCalls = positiveInteger(limits.softToolCalls);
253
+ const hardTimeMs = positiveInteger(limits.hardTimeMs);
254
+ const hardToolCalls = positiveInteger(limits.hardToolCalls);
255
+ const normalized = {
256
+ ...(softTimeMs == null ? {} : { softTimeMs }),
257
+ ...(softToolCalls == null ? {} : { softToolCalls }),
258
+ ...(hardTimeMs == null ? {} : { hardTimeMs }),
259
+ ...(hardToolCalls == null ? {} : { hardToolCalls }),
260
+ };
261
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
262
+ }
181
263
  function extractTextSegments(content) {
182
264
  if (!Array.isArray(content)) {
183
265
  return [];
@@ -221,9 +303,32 @@ function readStopReason(message) {
221
303
  return typeof stopReason === 'string' ? stopReason : undefined;
222
304
  }
223
305
  function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt, lastToolUpdateAt, toolStartedAt, }) {
224
- if (!onProgress) {
225
- return;
306
+ if (event.type === 'message_update') {
307
+ const assistantMessageEvent = event.assistantMessageEvent;
308
+ if (!assistantMessageEvent || typeof assistantMessageEvent !== 'object') {
309
+ return undefined;
310
+ }
311
+ const messageEventType = assistantMessageEvent.type;
312
+ if (messageEventType !== 'thinking_delta' && messageEventType !== 'text_delta') {
313
+ return undefined;
314
+ }
315
+ const now = Date.now();
316
+ if (onProgress &&
317
+ messageEventType === 'thinking_delta' &&
318
+ now - lastThinkingAt.value >= PI_PROGRESS_HEARTBEAT_MS) {
319
+ lastThinkingAt.value = now;
320
+ onProgress({ type: 'thinking' });
321
+ }
322
+ if (onProgress &&
323
+ messageEventType === 'text_delta' &&
324
+ now - lastRespondingAt.value >= PI_PROGRESS_HEARTBEAT_MS) {
325
+ lastRespondingAt.value = now;
326
+ onProgress({ type: 'responding' });
327
+ }
328
+ return { type: messageEventType, observedAt: now };
226
329
  }
330
+ if (!onProgress)
331
+ return undefined;
227
332
  switch (event.type) {
228
333
  case 'agent_start':
229
334
  onProgress({ type: 'agent_start' });
@@ -231,25 +336,6 @@ function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt
231
336
  case 'agent_end':
232
337
  onProgress({ type: 'agent_end' });
233
338
  return;
234
- case 'message_update': {
235
- const assistantMessageEvent = event.assistantMessageEvent;
236
- if (!assistantMessageEvent || typeof assistantMessageEvent !== 'object') {
237
- return;
238
- }
239
- const messageEventType = assistantMessageEvent.type;
240
- const now = Date.now();
241
- if (messageEventType === 'thinking_delta' &&
242
- now - lastThinkingAt.value >= PI_PROGRESS_HEARTBEAT_MS) {
243
- lastThinkingAt.value = now;
244
- onProgress({ type: 'thinking' });
245
- }
246
- if (messageEventType === 'text_delta' &&
247
- now - lastRespondingAt.value >= PI_PROGRESS_HEARTBEAT_MS) {
248
- lastRespondingAt.value = now;
249
- onProgress({ type: 'responding' });
250
- }
251
- return;
252
- }
253
339
  case 'tool_execution_start': {
254
340
  const toolName = event.toolName;
255
341
  if (typeof toolName === 'string' && toolName.length > 0) {
@@ -291,8 +377,10 @@ function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt
291
377
  durationMs: startedAt == null ? undefined : Math.max(0, Date.now() - startedAt),
292
378
  });
293
379
  }
380
+ return undefined;
294
381
  }
295
382
  }
383
+ return undefined;
296
384
  }
297
385
  function toSortedToolUsage(toolUsageByName) {
298
386
  return [...toolUsageByName.values()].sort((left, right) => {
@@ -302,7 +390,8 @@ function toSortedToolUsage(toolUsageByName) {
302
390
  return left.toolName.localeCompare(right.toolName);
303
391
  });
304
392
  }
305
- export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv, capabilityPreset = 'workspace-inspection', thinkingLevel, systemPrompt = DOISTBOT_PI_SYSTEM_PROMPT, noContextFiles = false, openRouterSessionId, onProgress, }) {
393
+ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv, capabilityPreset = 'workspace-inspection', thinkingLevel, systemPrompt = DOISTBOT_PI_SYSTEM_PROMPT, noContextFiles = false, openRouterSessionId, executionLimits, onProgress, }) {
394
+ const normalizedExecutionLimits = normalizePiExecutionLimits(executionLimits);
306
395
  return {
307
396
  async invoke({ prompt, modelName, timeoutMs = 5 * 60 * 1000, images, }) {
308
397
  const startedAt = Date.now();
@@ -319,6 +408,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
319
408
  piProviderId: resolvedPiProviderId,
320
409
  sessionId: openRouterSessionId,
321
410
  });
411
+ const executionLimitExtension = createExecutionLimitExtension(normalizedExecutionLimits, startedAt);
322
412
  const tools = PI_TOOLS_BY_PRESET[capabilityPreset].join(',');
323
413
  const args = [
324
414
  PI_CLI_PATH,
@@ -331,11 +421,11 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
331
421
  ...(noContextFiles ? ['--no-context-files'] : []),
332
422
  '--model',
333
423
  modelSpec,
334
- '--tools',
335
- tools,
424
+ ...(capabilityPreset === 'none' ? ['--no-tools'] : ['--tools', tools]),
336
425
  '--system-prompt',
337
426
  systemPrompt,
338
427
  ...openRouterSessionExtension.args,
428
+ ...executionLimitExtension.args,
339
429
  ...imageArgs,
340
430
  ];
341
431
  const inheritedEnv = {
@@ -351,6 +441,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
351
441
  GIT_TERMINAL_PROMPT: '0',
352
442
  ...extraEnv,
353
443
  ...openRouterSessionExtension.extraEnv,
444
+ ...executionLimitExtension.extraEnv,
354
445
  };
355
446
  const apiKeyEnvVar = PI_API_KEY_ENV_VAR_BY_PI_PROVIDER[resolvedPiProviderId];
356
447
  if (piAuthProviderId) {
@@ -380,6 +471,100 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
380
471
  const lastToolUpdateAt = new Map();
381
472
  const toolStartedAt = new Map();
382
473
  const toolUsageByName = new Map();
474
+ let turnCount = 0;
475
+ let assistantMessageCount = 0;
476
+ let toolCallCount = 0;
477
+ let activeToolCount = 0;
478
+ let toolBusyStartedAt;
479
+ let toolWallClockDurationMs = 0;
480
+ let lastToolEndedAt;
481
+ let firstThinkingAt;
482
+ let firstResponseAt;
483
+ let firstToolCallAt;
484
+ let softLimit;
485
+ let hardLimit;
486
+ function markLimit(level, trigger) {
487
+ if ((level === 'soft' && softLimit) || (level === 'hard' && hardLimit)) {
488
+ return;
489
+ }
490
+ const observation = {
491
+ level,
492
+ trigger,
493
+ elapsedMs: Math.max(0, Date.now() - startedAt),
494
+ toolCallCount,
495
+ };
496
+ if (level === 'soft')
497
+ softLimit = observation;
498
+ else
499
+ hardLimit = observation;
500
+ onProgress?.({ type: 'limit_reached', ...observation });
501
+ }
502
+ function buildTelemetry(durationMs) {
503
+ const now = startedAt + durationMs;
504
+ const activeToolDurationMs = activeToolCount > 0 && toolBusyStartedAt != null
505
+ ? Math.max(0, now - toolBusyStartedAt)
506
+ : 0;
507
+ const totalToolWallClockDurationMs = toolWallClockDurationMs + activeToolDurationMs;
508
+ const activityStarts = [
509
+ firstThinkingAt,
510
+ firstResponseAt,
511
+ firstToolCallAt,
512
+ ].filter((value) => value != null);
513
+ const firstActivityAt = activityStarts.length > 0 ? Math.min(...activityStarts) : undefined;
514
+ return {
515
+ turnCount,
516
+ assistantMessageCount,
517
+ toolCallCount,
518
+ toolWallClockDurationMs: totalToolWallClockDurationMs,
519
+ nonToolDurationMs: Math.max(0, durationMs - totalToolWallClockDurationMs),
520
+ ...(firstActivityAt == null
521
+ ? {}
522
+ : { timeToFirstActivityMs: firstActivityAt - startedAt }),
523
+ ...(firstThinkingAt == null
524
+ ? {}
525
+ : { timeToFirstThinkingMs: firstThinkingAt - startedAt }),
526
+ ...(firstResponseAt == null
527
+ ? {}
528
+ : { timeToFirstResponseMs: firstResponseAt - startedAt }),
529
+ ...(firstToolCallAt == null
530
+ ? {}
531
+ : { timeToFirstToolCallMs: firstToolCallAt - startedAt }),
532
+ ...(lastToolEndedAt == null
533
+ ? {}
534
+ : {
535
+ finalizationAfterLastToolMs: Math.max(0, now - lastToolEndedAt),
536
+ }),
537
+ ...(softLimit ? { softLimit } : {}),
538
+ ...(hardLimit ? { hardLimit } : {}),
539
+ };
540
+ }
541
+ function resolveWithTelemetry(resolve, result) {
542
+ resolve({
543
+ ...result,
544
+ telemetry: buildTelemetry(result.durationMs),
545
+ });
546
+ }
547
+ function recordAssistantMessage(message, observedAt) {
548
+ if (!message ||
549
+ typeof message !== 'object' ||
550
+ message.role !== 'assistant') {
551
+ return false;
552
+ }
553
+ const stopReason = readStopReason(message);
554
+ if (stopReason) {
555
+ lastAssistantStopReason = stopReason;
556
+ }
557
+ const result = extractAssistantMessageResult(message);
558
+ if (result?.text) {
559
+ lastAssistantText = result.text;
560
+ firstResponseAt ??= observedAt;
561
+ }
562
+ else if (result?.error) {
563
+ lastAssistantError = result.error;
564
+ firstResponseAt ??= observedAt;
565
+ }
566
+ return true;
567
+ }
383
568
  function parseStdout(chunk) {
384
569
  stdoutBuffer += chunk;
385
570
  const lines = stdoutBuffer.split(/\r?\n/);
@@ -395,7 +580,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
395
580
  sessionHeaderSeen = true;
396
581
  continue;
397
582
  }
398
- maybeEmitProgress({
583
+ const assistantMessageActivity = maybeEmitProgress({
399
584
  event: parsed,
400
585
  onProgress,
401
586
  lastThinkingAt,
@@ -403,9 +588,33 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
403
588
  lastToolUpdateAt,
404
589
  toolStartedAt,
405
590
  });
591
+ if (parsed.type === 'turn_start') {
592
+ turnCount += 1;
593
+ }
594
+ if (assistantMessageActivity?.type === 'thinking_delta') {
595
+ firstThinkingAt ??= assistantMessageActivity.observedAt;
596
+ }
597
+ else if (assistantMessageActivity?.type === 'text_delta') {
598
+ firstResponseAt ??= assistantMessageActivity.observedAt;
599
+ }
406
600
  if (parsed.type === 'tool_execution_start') {
407
601
  const toolName = typeof parsed.toolName === 'string' ? parsed.toolName : null;
408
602
  if (toolName) {
603
+ const now = Date.now();
604
+ toolCallCount += 1;
605
+ firstToolCallAt ??= now;
606
+ if (activeToolCount === 0) {
607
+ toolBusyStartedAt = now;
608
+ }
609
+ activeToolCount += 1;
610
+ if (normalizedExecutionLimits?.softToolCalls != null &&
611
+ toolCallCount >= normalizedExecutionLimits.softToolCalls) {
612
+ markLimit('soft', 'tool_calls');
613
+ }
614
+ if (normalizedExecutionLimits?.hardToolCalls != null &&
615
+ toolCallCount >= normalizedExecutionLimits.hardToolCalls) {
616
+ markLimit('hard', 'tool_calls');
617
+ }
409
618
  const toolCallId = typeof parsed.toolCallId === 'string'
410
619
  ? parsed.toolCallId
411
620
  : toolName;
@@ -439,7 +648,14 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
439
648
  const startedAt = toolStartedAt.get(toolCallId);
440
649
  toolStartedAt.delete(toolCallId);
441
650
  if (startedAt != null) {
442
- current.totalDurationMs += Math.max(0, Date.now() - startedAt);
651
+ activeToolCount = Math.max(0, activeToolCount - 1);
652
+ const now = Date.now();
653
+ lastToolEndedAt = now;
654
+ if (activeToolCount === 0 && toolBusyStartedAt != null) {
655
+ toolWallClockDurationMs += Math.max(0, now - toolBusyStartedAt);
656
+ toolBusyStartedAt = undefined;
657
+ }
658
+ current.totalDurationMs += Math.max(0, now - startedAt);
443
659
  }
444
660
  if (parsed.isError === true) {
445
661
  current.errorCount += 1;
@@ -448,37 +664,23 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
448
664
  }
449
665
  }
450
666
  if (parsed.type === 'message_end') {
451
- // Read the stop reason regardless of whether the
452
- // message carried text or an error — a terminal
453
- // "length"/"error"/"aborted" ending must surface
454
- // even when there's no usable text.
455
- const stopReason = readStopReason(parsed.message);
456
- if (stopReason) {
457
- lastAssistantStopReason = stopReason;
458
- }
459
- const result = extractAssistantMessageResult(parsed.message);
460
- if (result?.text) {
461
- lastAssistantText = result.text;
462
- }
463
- else if (result?.error) {
464
- lastAssistantError = result.error;
667
+ if (recordAssistantMessage(parsed.message, Date.now())) {
668
+ assistantMessageCount += 1;
465
669
  }
466
670
  continue;
467
671
  }
468
672
  if (parsed.type === 'agent_end' && Array.isArray(parsed.messages)) {
673
+ const observedAt = Date.now();
674
+ let finalAssistantMessageCount = 0;
469
675
  for (const message of parsed.messages) {
470
- const stopReason = readStopReason(message);
471
- if (stopReason) {
472
- lastAssistantStopReason = stopReason;
473
- }
474
- const result = extractAssistantMessageResult(message);
475
- if (result?.text) {
476
- lastAssistantText = result.text;
477
- }
478
- else if (result?.error) {
479
- lastAssistantError = result.error;
676
+ if (recordAssistantMessage(message, observedAt)) {
677
+ finalAssistantMessageCount += 1;
480
678
  }
481
679
  }
680
+ // agent_end repeats messages that were already
681
+ // delivered through message_end. Use the larger
682
+ // count so fallback-only output is captured once.
683
+ assistantMessageCount = Math.max(assistantMessageCount, finalAssistantMessageCount);
482
684
  }
483
685
  }
484
686
  catch {
@@ -489,10 +691,24 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
489
691
  }
490
692
  }
491
693
  return await new Promise((resolve) => {
694
+ const limitTimeoutHandles = [];
695
+ const softTimeMs = normalizedExecutionLimits?.softTimeMs;
696
+ if (softTimeMs != null) {
697
+ limitTimeoutHandles.push(setTimeout(() => markLimit('soft', 'time'), Math.max(0, softTimeMs - (Date.now() - startedAt))));
698
+ }
699
+ const hardTimeMs = normalizedExecutionLimits?.hardTimeMs;
700
+ if (hardTimeMs != null) {
701
+ limitTimeoutHandles.push(setTimeout(() => markLimit('hard', 'time'), Math.max(0, hardTimeMs - (Date.now() - startedAt))));
702
+ }
492
703
  const timeoutHandle = setTimeout(() => {
493
704
  timedOut = true;
494
- child.kill('SIGTERM');
705
+ child.kill(normalizedExecutionLimits ? 'SIGKILL' : 'SIGTERM');
495
706
  }, timeoutMs);
707
+ function clearTimers() {
708
+ clearTimeout(timeoutHandle);
709
+ for (const handle of limitTimeoutHandles)
710
+ clearTimeout(handle);
711
+ }
496
712
  child.stdout.setEncoding('utf8');
497
713
  child.stdout.on('data', (chunk) => {
498
714
  parseStdout(chunk);
@@ -502,26 +718,28 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
502
718
  stderr += chunk;
503
719
  });
504
720
  child.on('error', (error) => {
505
- clearTimeout(timeoutHandle);
506
- resolve({
721
+ clearTimers();
722
+ const durationMs = Date.now() - startedAt;
723
+ resolveWithTelemetry(resolve, {
507
724
  ok: false,
508
725
  error: error.message,
509
- durationMs: Date.now() - startedAt,
726
+ durationMs,
510
727
  timedOut,
511
728
  toolUsage: toSortedToolUsage(toolUsageByName),
512
729
  stopReason: lastAssistantStopReason,
513
730
  });
514
731
  });
515
732
  child.on('close', (code, signal) => {
516
- clearTimeout(timeoutHandle);
733
+ clearTimers();
734
+ const durationMs = Date.now() - startedAt;
517
735
  if (stdoutBuffer.trim()) {
518
736
  parseStdout('\n');
519
737
  }
520
738
  if (timedOut) {
521
- resolve({
739
+ resolveWithTelemetry(resolve, {
522
740
  ok: false,
523
741
  error: `Pi request timed out after ${timeoutMs}ms`,
524
- durationMs: Date.now() - startedAt,
742
+ durationMs,
525
743
  timedOut: true,
526
744
  toolUsage: toSortedToolUsage(toolUsageByName),
527
745
  stopReason: lastAssistantStopReason,
@@ -529,10 +747,10 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
529
747
  return;
530
748
  }
531
749
  if (signal) {
532
- resolve({
750
+ resolveWithTelemetry(resolve, {
533
751
  ok: false,
534
752
  error: `Pi process terminated with signal ${signal}`,
535
- durationMs: Date.now() - startedAt,
753
+ durationMs,
536
754
  timedOut: false,
537
755
  toolUsage: toSortedToolUsage(toolUsageByName),
538
756
  stopReason: lastAssistantStopReason,
@@ -540,10 +758,10 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
540
758
  return;
541
759
  }
542
760
  if (code !== 0) {
543
- resolve({
761
+ resolveWithTelemetry(resolve, {
544
762
  ok: false,
545
763
  error: stderr.trim() || `Pi exited with status ${String(code)}`,
546
- durationMs: Date.now() - startedAt,
764
+ durationMs,
547
765
  timedOut: false,
548
766
  toolUsage: toSortedToolUsage(toolUsageByName),
549
767
  stopReason: lastAssistantStopReason,
@@ -552,22 +770,22 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
552
770
  }
553
771
  const output = lastAssistantText.trim();
554
772
  if (!output) {
555
- resolve({
773
+ resolveWithTelemetry(resolve, {
556
774
  ok: false,
557
775
  error: lastAssistantError ||
558
776
  stderr.trim() ||
559
777
  'Pi session did not produce an assistant response',
560
- durationMs: Date.now() - startedAt,
778
+ durationMs,
561
779
  timedOut: false,
562
780
  toolUsage: toSortedToolUsage(toolUsageByName),
563
781
  stopReason: lastAssistantStopReason,
564
782
  });
565
783
  return;
566
784
  }
567
- resolve({
785
+ resolveWithTelemetry(resolve, {
568
786
  ok: true,
569
787
  output,
570
- durationMs: Date.now() - startedAt,
788
+ durationMs,
571
789
  toolUsage: toSortedToolUsage(toolUsageByName),
572
790
  stopReason: lastAssistantStopReason,
573
791
  });
@@ -581,7 +799,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
581
799
  },
582
800
  };
583
801
  }
584
- export async function invokePiPrompt({ provider, apiKey, piProviderId, prompt, cwd, modelName, timeoutMs = 5 * 60 * 1000, extraEnv, images, capabilityPreset, thinkingLevel, systemPrompt, noContextFiles, openRouterSessionId, onProgress, }) {
802
+ export async function invokePiPrompt({ provider, apiKey, piProviderId, prompt, cwd, modelName, timeoutMs = 5 * 60 * 1000, extraEnv, images, capabilityPreset, thinkingLevel, systemPrompt, noContextFiles, openRouterSessionId, executionLimits, onProgress, }) {
585
803
  return await createPiInvoker({
586
804
  provider,
587
805
  apiKey,
@@ -593,6 +811,7 @@ export async function invokePiPrompt({ provider, apiKey, piProviderId, prompt, c
593
811
  systemPrompt,
594
812
  noContextFiles,
595
813
  openRouterSessionId,
814
+ executionLimits,
596
815
  onProgress,
597
816
  }).invoke({
598
817
  prompt,