@doist/doistbot-cli 1.0.9 → 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 (35) hide show
  1. package/dist/actions/auth.js +1 -1
  2. package/dist/actions/review-usage.js +4 -4
  3. package/dist/actions/review.js +32 -21
  4. package/dist/config.js +59 -13
  5. package/dist/index.js +8 -2
  6. package/dist/terminal.js +5 -0
  7. package/package.json +4 -4
  8. package/sandbox/dist/core/datadog-metrics.js +9 -2
  9. package/sandbox/dist/core/label-parsing.js +1 -22
  10. package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
  11. package/sandbox/dist/core/pi-progress-logging.js +4 -0
  12. package/sandbox/dist/core/pi.js +290 -67
  13. package/sandbox/dist/core/repository-map.js +1 -220
  14. package/sandbox/dist/core/repository-resolver.js +1 -166
  15. package/sandbox/dist/core/tracing.js +1 -1
  16. package/sandbox/dist/providers/config.js +1 -1
  17. package/sandbox/dist/providers/index.js +1 -0
  18. package/sandbox/dist/providers/pi-review.js +53 -3
  19. package/sandbox/dist/tasks/chat/chat.js +95 -45
  20. package/sandbox/dist/tasks/chat/prompt.js +5 -0
  21. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -0
  22. package/sandbox/dist/tasks/issue-summarize/model.js +1 -1
  23. package/sandbox/dist/tasks/issue-triage/context.js +2 -0
  24. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -1
  25. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +4 -7
  26. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +65 -41
  27. package/sandbox/dist/tasks/issue-triage/output.js +1 -1
  28. package/sandbox/dist/tasks/issue-triage/resolution.js +1 -0
  29. package/sandbox/dist/tasks/issue-triage/shared.js +2 -12
  30. package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
  31. package/sandbox/dist/tasks/review/engines/multi-focus.js +14 -0
  32. package/sandbox/dist/tasks/review/engines/shared.js +2 -0
  33. package/sandbox/dist/tasks/review/review.js +5 -3
  34. package/sandbox/dist/tasks/review/summary-model.js +4 -3
  35. package/sandbox/dist/tasks/review/thinking-level.js +2 -0
@@ -3,28 +3,71 @@ 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',
48
+ grok: 'openrouter',
9
49
  zai: 'openrouter',
10
50
  openai: 'openai',
11
51
  };
12
52
  export const DEFAULT_PI_MODELS = {
13
53
  deepseek: 'deepseek-v4-pro',
14
54
  gemini: 'gemini-3.1-pro-preview',
55
+ grok: 'grok-4.6',
15
56
  zai: 'glm5.2',
16
57
  openai: 'gpt-5.6-terra',
17
58
  };
18
59
  export const DEFAULT_OPENROUTER_MODELS = {
19
60
  deepseek: 'deepseek/deepseek-v4-pro-0813',
20
61
  gemini: 'google/gemini-3.1-pro-preview',
21
- zai: 'z-ai/glm-5.2',
62
+ grok: 'x-ai/grok-4.6',
63
+ zai: 'z-ai/glm-5.3-flash',
22
64
  };
23
65
  // Gemini flash fallback model.
24
66
  export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.7-flash';
25
67
  export const MAX_PI_THINKING_BY_PROVIDER = {
26
68
  deepseek: 'high',
27
69
  gemini: 'high',
70
+ grok: 'high',
28
71
  zai: 'high',
29
72
  openai: 'xhigh',
30
73
  };
@@ -41,6 +84,7 @@ const PI_PROGRESS_HEARTBEAT_MS = 30_000;
41
84
  const OPENROUTER_SESSION_ID_ENV_VAR = 'DOISTBOT_OPENROUTER_SESSION_ID';
42
85
  const OPENROUTER_SESSION_ID_MAX_LENGTH = 256;
43
86
  const PI_TOOLS_BY_PRESET = {
87
+ none: [],
44
88
  'read-only': ['read', 'grep', 'find', 'ls'],
45
89
  'workspace-inspection': ['read', 'bash', 'grep', 'find', 'ls'],
46
90
  'shell-focused': ['read', 'bash', 'ls'],
@@ -50,6 +94,7 @@ function resolvePiCliPath() {
50
94
  return path.join(path.dirname(piPackageEntryPath), 'cli.js');
51
95
  }
52
96
  const PI_CLI_PATH = resolvePiCliPath();
97
+ const PI_EXECUTION_LIMIT_EXTENSION_PATH = fileURLToPath(new URL('./pi-execution-limits-extension.js', import.meta.url));
53
98
  let cachedNodeExecutable;
54
99
  let cachedOpenRouterSessionExtensionPath;
55
100
  export function resolveNodeExecutable({ execPath = process.execPath, argv0 = process.argv0, canExecute = canExecuteNode, } = {}) {
@@ -174,6 +219,47 @@ function createOpenRouterSessionExtension({ piProviderId, sessionId, }) {
174
219
  cleanup() { },
175
220
  };
176
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
+ }
177
263
  function extractTextSegments(content) {
178
264
  if (!Array.isArray(content)) {
179
265
  return [];
@@ -217,9 +303,32 @@ function readStopReason(message) {
217
303
  return typeof stopReason === 'string' ? stopReason : undefined;
218
304
  }
219
305
  function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt, lastToolUpdateAt, toolStartedAt, }) {
220
- if (!onProgress) {
221
- 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 };
222
329
  }
330
+ if (!onProgress)
331
+ return undefined;
223
332
  switch (event.type) {
224
333
  case 'agent_start':
225
334
  onProgress({ type: 'agent_start' });
@@ -227,25 +336,6 @@ function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt
227
336
  case 'agent_end':
228
337
  onProgress({ type: 'agent_end' });
229
338
  return;
230
- case 'message_update': {
231
- const assistantMessageEvent = event.assistantMessageEvent;
232
- if (!assistantMessageEvent || typeof assistantMessageEvent !== 'object') {
233
- return;
234
- }
235
- const messageEventType = assistantMessageEvent.type;
236
- const now = Date.now();
237
- if (messageEventType === 'thinking_delta' &&
238
- now - lastThinkingAt.value >= PI_PROGRESS_HEARTBEAT_MS) {
239
- lastThinkingAt.value = now;
240
- onProgress({ type: 'thinking' });
241
- }
242
- if (messageEventType === 'text_delta' &&
243
- now - lastRespondingAt.value >= PI_PROGRESS_HEARTBEAT_MS) {
244
- lastRespondingAt.value = now;
245
- onProgress({ type: 'responding' });
246
- }
247
- return;
248
- }
249
339
  case 'tool_execution_start': {
250
340
  const toolName = event.toolName;
251
341
  if (typeof toolName === 'string' && toolName.length > 0) {
@@ -287,8 +377,10 @@ function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt
287
377
  durationMs: startedAt == null ? undefined : Math.max(0, Date.now() - startedAt),
288
378
  });
289
379
  }
380
+ return undefined;
290
381
  }
291
382
  }
383
+ return undefined;
292
384
  }
293
385
  function toSortedToolUsage(toolUsageByName) {
294
386
  return [...toolUsageByName.values()].sort((left, right) => {
@@ -298,7 +390,8 @@ function toSortedToolUsage(toolUsageByName) {
298
390
  return left.toolName.localeCompare(right.toolName);
299
391
  });
300
392
  }
301
- 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);
302
395
  return {
303
396
  async invoke({ prompt, modelName, timeoutMs = 5 * 60 * 1000, images, }) {
304
397
  const startedAt = Date.now();
@@ -315,6 +408,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
315
408
  piProviderId: resolvedPiProviderId,
316
409
  sessionId: openRouterSessionId,
317
410
  });
411
+ const executionLimitExtension = createExecutionLimitExtension(normalizedExecutionLimits, startedAt);
318
412
  const tools = PI_TOOLS_BY_PRESET[capabilityPreset].join(',');
319
413
  const args = [
320
414
  PI_CLI_PATH,
@@ -327,11 +421,11 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
327
421
  ...(noContextFiles ? ['--no-context-files'] : []),
328
422
  '--model',
329
423
  modelSpec,
330
- '--tools',
331
- tools,
424
+ ...(capabilityPreset === 'none' ? ['--no-tools'] : ['--tools', tools]),
332
425
  '--system-prompt',
333
426
  systemPrompt,
334
427
  ...openRouterSessionExtension.args,
428
+ ...executionLimitExtension.args,
335
429
  ...imageArgs,
336
430
  ];
337
431
  const inheritedEnv = {
@@ -347,6 +441,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
347
441
  GIT_TERMINAL_PROMPT: '0',
348
442
  ...extraEnv,
349
443
  ...openRouterSessionExtension.extraEnv,
444
+ ...executionLimitExtension.extraEnv,
350
445
  };
351
446
  const apiKeyEnvVar = PI_API_KEY_ENV_VAR_BY_PI_PROVIDER[resolvedPiProviderId];
352
447
  if (piAuthProviderId) {
@@ -376,6 +471,100 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
376
471
  const lastToolUpdateAt = new Map();
377
472
  const toolStartedAt = new Map();
378
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
+ }
379
568
  function parseStdout(chunk) {
380
569
  stdoutBuffer += chunk;
381
570
  const lines = stdoutBuffer.split(/\r?\n/);
@@ -391,7 +580,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
391
580
  sessionHeaderSeen = true;
392
581
  continue;
393
582
  }
394
- maybeEmitProgress({
583
+ const assistantMessageActivity = maybeEmitProgress({
395
584
  event: parsed,
396
585
  onProgress,
397
586
  lastThinkingAt,
@@ -399,9 +588,33 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
399
588
  lastToolUpdateAt,
400
589
  toolStartedAt,
401
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
+ }
402
600
  if (parsed.type === 'tool_execution_start') {
403
601
  const toolName = typeof parsed.toolName === 'string' ? parsed.toolName : null;
404
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
+ }
405
618
  const toolCallId = typeof parsed.toolCallId === 'string'
406
619
  ? parsed.toolCallId
407
620
  : toolName;
@@ -435,7 +648,14 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
435
648
  const startedAt = toolStartedAt.get(toolCallId);
436
649
  toolStartedAt.delete(toolCallId);
437
650
  if (startedAt != null) {
438
- 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);
439
659
  }
440
660
  if (parsed.isError === true) {
441
661
  current.errorCount += 1;
@@ -444,37 +664,23 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
444
664
  }
445
665
  }
446
666
  if (parsed.type === 'message_end') {
447
- // Read the stop reason regardless of whether the
448
- // message carried text or an error — a terminal
449
- // "length"/"error"/"aborted" ending must surface
450
- // even when there's no usable text.
451
- const stopReason = readStopReason(parsed.message);
452
- if (stopReason) {
453
- lastAssistantStopReason = stopReason;
454
- }
455
- const result = extractAssistantMessageResult(parsed.message);
456
- if (result?.text) {
457
- lastAssistantText = result.text;
458
- }
459
- else if (result?.error) {
460
- lastAssistantError = result.error;
667
+ if (recordAssistantMessage(parsed.message, Date.now())) {
668
+ assistantMessageCount += 1;
461
669
  }
462
670
  continue;
463
671
  }
464
672
  if (parsed.type === 'agent_end' && Array.isArray(parsed.messages)) {
673
+ const observedAt = Date.now();
674
+ let finalAssistantMessageCount = 0;
465
675
  for (const message of parsed.messages) {
466
- const stopReason = readStopReason(message);
467
- if (stopReason) {
468
- lastAssistantStopReason = stopReason;
469
- }
470
- const result = extractAssistantMessageResult(message);
471
- if (result?.text) {
472
- lastAssistantText = result.text;
473
- }
474
- else if (result?.error) {
475
- lastAssistantError = result.error;
676
+ if (recordAssistantMessage(message, observedAt)) {
677
+ finalAssistantMessageCount += 1;
476
678
  }
477
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);
478
684
  }
479
685
  }
480
686
  catch {
@@ -485,10 +691,24 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
485
691
  }
486
692
  }
487
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
+ }
488
703
  const timeoutHandle = setTimeout(() => {
489
704
  timedOut = true;
490
- child.kill('SIGTERM');
705
+ child.kill(normalizedExecutionLimits ? 'SIGKILL' : 'SIGTERM');
491
706
  }, timeoutMs);
707
+ function clearTimers() {
708
+ clearTimeout(timeoutHandle);
709
+ for (const handle of limitTimeoutHandles)
710
+ clearTimeout(handle);
711
+ }
492
712
  child.stdout.setEncoding('utf8');
493
713
  child.stdout.on('data', (chunk) => {
494
714
  parseStdout(chunk);
@@ -498,26 +718,28 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
498
718
  stderr += chunk;
499
719
  });
500
720
  child.on('error', (error) => {
501
- clearTimeout(timeoutHandle);
502
- resolve({
721
+ clearTimers();
722
+ const durationMs = Date.now() - startedAt;
723
+ resolveWithTelemetry(resolve, {
503
724
  ok: false,
504
725
  error: error.message,
505
- durationMs: Date.now() - startedAt,
726
+ durationMs,
506
727
  timedOut,
507
728
  toolUsage: toSortedToolUsage(toolUsageByName),
508
729
  stopReason: lastAssistantStopReason,
509
730
  });
510
731
  });
511
732
  child.on('close', (code, signal) => {
512
- clearTimeout(timeoutHandle);
733
+ clearTimers();
734
+ const durationMs = Date.now() - startedAt;
513
735
  if (stdoutBuffer.trim()) {
514
736
  parseStdout('\n');
515
737
  }
516
738
  if (timedOut) {
517
- resolve({
739
+ resolveWithTelemetry(resolve, {
518
740
  ok: false,
519
741
  error: `Pi request timed out after ${timeoutMs}ms`,
520
- durationMs: Date.now() - startedAt,
742
+ durationMs,
521
743
  timedOut: true,
522
744
  toolUsage: toSortedToolUsage(toolUsageByName),
523
745
  stopReason: lastAssistantStopReason,
@@ -525,10 +747,10 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
525
747
  return;
526
748
  }
527
749
  if (signal) {
528
- resolve({
750
+ resolveWithTelemetry(resolve, {
529
751
  ok: false,
530
752
  error: `Pi process terminated with signal ${signal}`,
531
- durationMs: Date.now() - startedAt,
753
+ durationMs,
532
754
  timedOut: false,
533
755
  toolUsage: toSortedToolUsage(toolUsageByName),
534
756
  stopReason: lastAssistantStopReason,
@@ -536,10 +758,10 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
536
758
  return;
537
759
  }
538
760
  if (code !== 0) {
539
- resolve({
761
+ resolveWithTelemetry(resolve, {
540
762
  ok: false,
541
763
  error: stderr.trim() || `Pi exited with status ${String(code)}`,
542
- durationMs: Date.now() - startedAt,
764
+ durationMs,
543
765
  timedOut: false,
544
766
  toolUsage: toSortedToolUsage(toolUsageByName),
545
767
  stopReason: lastAssistantStopReason,
@@ -548,22 +770,22 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
548
770
  }
549
771
  const output = lastAssistantText.trim();
550
772
  if (!output) {
551
- resolve({
773
+ resolveWithTelemetry(resolve, {
552
774
  ok: false,
553
775
  error: lastAssistantError ||
554
776
  stderr.trim() ||
555
777
  'Pi session did not produce an assistant response',
556
- durationMs: Date.now() - startedAt,
778
+ durationMs,
557
779
  timedOut: false,
558
780
  toolUsage: toSortedToolUsage(toolUsageByName),
559
781
  stopReason: lastAssistantStopReason,
560
782
  });
561
783
  return;
562
784
  }
563
- resolve({
785
+ resolveWithTelemetry(resolve, {
564
786
  ok: true,
565
787
  output,
566
- durationMs: Date.now() - startedAt,
788
+ durationMs,
567
789
  toolUsage: toSortedToolUsage(toolUsageByName),
568
790
  stopReason: lastAssistantStopReason,
569
791
  });
@@ -577,7 +799,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
577
799
  },
578
800
  };
579
801
  }
580
- 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, }) {
581
803
  return await createPiInvoker({
582
804
  provider,
583
805
  apiKey,
@@ -589,6 +811,7 @@ export async function invokePiPrompt({ provider, apiKey, piProviderId, prompt, c
589
811
  systemPrompt,
590
812
  noContextFiles,
591
813
  openRouterSessionId,
814
+ executionLimits,
592
815
  onProgress,
593
816
  }).invoke({
594
817
  prompt,