@oh-my-pi/pi-coding-agent 15.0.2 → 15.1.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 (138) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/examples/custom-tools/README.md +11 -7
  3. package/examples/custom-tools/hello/index.ts +2 -2
  4. package/examples/extensions/README.md +19 -8
  5. package/examples/extensions/api-demo.ts +15 -19
  6. package/examples/extensions/hello.ts +5 -6
  7. package/examples/extensions/plan-mode.ts +1 -1
  8. package/examples/extensions/reload-runtime.ts +4 -3
  9. package/examples/extensions/with-deps/index.ts +4 -3
  10. package/examples/sdk/06-extensions.ts +4 -2
  11. package/package.json +7 -17
  12. package/src/autoresearch/tools/init-experiment.ts +38 -41
  13. package/src/autoresearch/tools/log-experiment.ts +32 -41
  14. package/src/autoresearch/tools/run-experiment.ts +3 -3
  15. package/src/autoresearch/tools/update-notes.ts +11 -11
  16. package/src/commit/agentic/tools/analyze-file.ts +4 -4
  17. package/src/commit/agentic/tools/git-file-diff.ts +4 -4
  18. package/src/commit/agentic/tools/git-hunk.ts +5 -5
  19. package/src/commit/agentic/tools/git-overview.ts +4 -4
  20. package/src/commit/agentic/tools/propose-changelog.ts +13 -13
  21. package/src/commit/agentic/tools/propose-commit.ts +6 -6
  22. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  23. package/src/commit/agentic/tools/schemas.ts +28 -28
  24. package/src/commit/agentic/tools/split-commit.ts +22 -21
  25. package/src/commit/analysis/summary.ts +4 -4
  26. package/src/commit/changelog/generate.ts +7 -11
  27. package/src/commit/shared-llm.ts +22 -34
  28. package/src/config/config-file.ts +35 -13
  29. package/src/config/model-registry.ts +9 -190
  30. package/src/config/models-config-schema.ts +166 -0
  31. package/src/config/settings-schema.ts +18 -0
  32. package/src/edit/index.ts +2 -2
  33. package/src/edit/modes/apply-patch.ts +7 -6
  34. package/src/edit/modes/patch.ts +18 -25
  35. package/src/edit/modes/replace.ts +18 -20
  36. package/src/eval/js/shared/rewrite-imports.ts +131 -10
  37. package/src/eval/py/executor.ts +233 -623
  38. package/src/eval/py/kernel.ts +27 -2
  39. package/src/exa/factory.ts +5 -4
  40. package/src/exa/mcp-client.ts +1 -1
  41. package/src/exa/researcher.ts +9 -20
  42. package/src/exa/search.ts +26 -52
  43. package/src/exa/types.ts +1 -1
  44. package/src/exa/websets.ts +54 -53
  45. package/src/exec/bash-executor.ts +2 -1
  46. package/src/extensibility/custom-commands/loader.ts +5 -3
  47. package/src/extensibility/custom-commands/types.ts +4 -2
  48. package/src/extensibility/custom-tools/loader.ts +5 -3
  49. package/src/extensibility/custom-tools/types.ts +7 -6
  50. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  51. package/src/extensibility/extensions/loader.ts +7 -3
  52. package/src/extensibility/extensions/types.ts +9 -5
  53. package/src/extensibility/extensions/wrapper.ts +1 -2
  54. package/src/extensibility/hooks/loader.ts +3 -1
  55. package/src/extensibility/hooks/tool-wrapper.ts +1 -1
  56. package/src/extensibility/hooks/types.ts +4 -2
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -0
  58. package/src/extensibility/shared-events.ts +1 -1
  59. package/src/extensibility/typebox.ts +391 -0
  60. package/src/goals/tools/goal-tool.ts +6 -12
  61. package/src/hashline/types.ts +4 -4
  62. package/src/hindsight/state.ts +2 -2
  63. package/src/index.ts +0 -2
  64. package/src/internal-urls/docs-index.generated.ts +7 -7
  65. package/src/lsp/types.ts +30 -38
  66. package/src/mcp/manager.ts +1 -1
  67. package/src/mcp/tool-bridge.ts +1 -1
  68. package/src/modes/components/session-observer-overlay.ts +12 -1
  69. package/src/modes/components/status-line/segments.ts +2 -1
  70. package/src/modes/controllers/command-controller.ts +27 -2
  71. package/src/modes/controllers/event-controller.ts +3 -4
  72. package/src/modes/interactive-mode.ts +1 -1
  73. package/src/modes/rpc/host-tools.ts +1 -1
  74. package/src/modes/rpc/rpc-client.ts +1 -1
  75. package/src/modes/rpc/rpc-types.ts +1 -1
  76. package/src/modes/theme/theme.ts +111 -117
  77. package/src/modes/types.ts +1 -1
  78. package/src/modes/utils/context-usage.ts +2 -2
  79. package/src/sdk.ts +31 -8
  80. package/src/session/agent-session.ts +74 -104
  81. package/src/session/messages.ts +16 -51
  82. package/src/session/session-manager.ts +22 -2
  83. package/src/session/streaming-output.ts +16 -6
  84. package/src/task/executor.ts +208 -86
  85. package/src/task/index.ts +15 -11
  86. package/src/task/render.ts +32 -5
  87. package/src/task/types.ts +54 -39
  88. package/src/tools/ask.ts +12 -12
  89. package/src/tools/ast-edit.ts +11 -15
  90. package/src/tools/ast-grep.ts +9 -10
  91. package/src/tools/bash.ts +9 -23
  92. package/src/tools/browser.ts +39 -53
  93. package/src/tools/calculator.ts +12 -11
  94. package/src/tools/checkpoint.ts +7 -7
  95. package/src/tools/debug.ts +40 -43
  96. package/src/tools/eval.ts +6 -8
  97. package/src/tools/find.ts +10 -13
  98. package/src/tools/gh.ts +71 -128
  99. package/src/tools/hindsight-recall.ts +4 -6
  100. package/src/tools/hindsight-reflect.ts +5 -5
  101. package/src/tools/hindsight-retain.ts +15 -17
  102. package/src/tools/image-gen.ts +32 -82
  103. package/src/tools/index.ts +4 -1
  104. package/src/tools/inspect-image.ts +8 -9
  105. package/src/tools/irc.ts +15 -27
  106. package/src/tools/job.ts +14 -21
  107. package/src/tools/read.ts +7 -8
  108. package/src/tools/recipe/index.ts +7 -9
  109. package/src/tools/render-mermaid.ts +12 -12
  110. package/src/tools/report-tool-issue.ts +4 -4
  111. package/src/tools/resolve.ts +11 -11
  112. package/src/tools/review.ts +14 -26
  113. package/src/tools/search-tool-bm25.ts +7 -9
  114. package/src/tools/search.ts +19 -22
  115. package/src/tools/ssh.ts +7 -7
  116. package/src/tools/todo-write.ts +26 -34
  117. package/src/tools/vim.ts +10 -26
  118. package/src/tools/write.ts +5 -5
  119. package/src/tools/yield.ts +100 -54
  120. package/src/web/search/index.ts +9 -24
  121. package/src/prompts/compaction/branch-summary-context.md +0 -5
  122. package/src/prompts/compaction/branch-summary-preamble.md +0 -2
  123. package/src/prompts/compaction/branch-summary.md +0 -30
  124. package/src/prompts/compaction/compaction-short-summary.md +0 -9
  125. package/src/prompts/compaction/compaction-summary-context.md +0 -5
  126. package/src/prompts/compaction/compaction-summary.md +0 -38
  127. package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
  128. package/src/prompts/compaction/compaction-update-summary.md +0 -45
  129. package/src/prompts/system/auto-handoff-threshold-focus.md +0 -1
  130. package/src/prompts/system/file-operations.md +0 -10
  131. package/src/prompts/system/handoff-document.md +0 -49
  132. package/src/prompts/system/summarization-system.md +0 -3
  133. package/src/session/compaction/branch-summarization.ts +0 -324
  134. package/src/session/compaction/compaction.ts +0 -1420
  135. package/src/session/compaction/errors.ts +0 -31
  136. package/src/session/compaction/index.ts +0 -8
  137. package/src/session/compaction/pruning.ts +0 -91
  138. package/src/session/compaction/utils.ts +0 -184
@@ -5,10 +5,10 @@
5
5
  */
6
6
 
7
7
  import path from "node:path";
8
- import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
+ import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
+ import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
+ import { isJsonSchemaValueValid } from "@oh-my-pi/pi-ai/utils/schema";
9
11
  import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
- import type { TSchema } from "@sinclair/typebox";
11
- import Ajv, { type ValidateFunction } from "ajv";
12
12
  import { ModelRegistry } from "../config/model-registry";
13
13
  import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
14
14
  import type { PromptTemplate } from "../config/prompt-templates";
@@ -51,7 +51,6 @@ import {
51
51
  } from "./types";
52
52
 
53
53
  const MCP_CALL_TIMEOUT_MS = 60_000;
54
- const ajv = new Ajv({ allErrors: true, strict: false, logger: false });
55
54
 
56
55
  /** Agent event types to forward for progress tracking. */
57
56
  const agentEventTypes = new Set<AgentEvent["type"]>([
@@ -182,6 +181,15 @@ export interface ExecutorOptions {
182
181
  */
183
182
  parentArtifactManager?: ArtifactManager;
184
183
  parentHindsightSessionState?: HindsightSessionState;
184
+ /**
185
+ * Parent agent's OpenTelemetry configuration. When defined, the subagent's
186
+ * loop is started with the same tracer/hooks but its own agent identity
187
+ * stamped, so its `invoke_agent` / `chat` / `execute_tool` spans appear as
188
+ * a sub-tree under the parent's active `execute_tool task` span. A
189
+ * `handoff` span is emitted on dispatch to mark the parent → subagent
190
+ * transition explicitly.
191
+ */
192
+ parentTelemetry?: AgentTelemetryConfig;
185
193
  }
186
194
 
187
195
  function parseStringifiedJson(value: unknown): unknown {
@@ -196,16 +204,12 @@ function parseStringifiedJson(value: unknown): unknown {
196
204
  }
197
205
  }
198
206
 
199
- function buildOutputValidator(schema: unknown): { validate?: ValidateFunction; error?: string } {
207
+ function buildOutputValidator(schema: unknown): { validate?: (value: unknown) => boolean; error?: string } {
200
208
  const { normalized, error } = normalizeSchema(schema);
201
209
  if (error) return { error };
202
210
  if (normalized === undefined) return {};
203
211
  const jsonSchema = jtdToJsonSchema(normalized);
204
- try {
205
- return { validate: ajv.compile(jsonSchema as any) };
206
- } catch (err) {
207
- return { error: err instanceof Error ? err.message : String(err) };
208
- }
212
+ return { validate: value => isJsonSchemaValueValid(jsonSchema, value) };
209
213
  }
210
214
 
211
215
  function tryParseJsonOutput(text: string): unknown | undefined {
@@ -408,14 +412,14 @@ function getUsageTokens(usage: unknown): number {
408
412
  /**
409
413
  * Create proxy tools that reuse the parent's MCP connections.
410
414
  */
411
- function createMCPProxyTools(mcpManager: MCPManager): CustomTool<TSchema>[] {
415
+ function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
412
416
  return mcpManager.getTools().map(tool => {
413
417
  const mcpTool = tool as { mcpToolName?: string; mcpServerName?: string };
414
418
  return {
415
419
  name: tool.name,
416
420
  label: tool.label ?? tool.name,
417
421
  description: tool.description ?? "",
418
- parameters: tool.parameters as TSchema,
422
+ parameters: tool.parameters,
419
423
  execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
420
424
  if (signal?.aborted) {
421
425
  throw new ToolAbortError();
@@ -543,6 +547,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
543
547
  const settings = options.settings ?? Settings.isolated();
544
548
  const subagentSettings = createSubagentSettings(settings);
545
549
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
550
+ const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
546
551
  const parentDepth = options.taskDepth ?? 0;
547
552
  const childDepth = parentDepth + 1;
548
553
  const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
@@ -590,9 +595,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
590
595
  let recentOutputTail = "";
591
596
  let stderr = "";
592
597
  let resolved = false;
593
- type AbortReason = "signal" | "terminate";
598
+ type AbortReason = "signal" | "terminate" | "timeout";
594
599
  let abortSent = false;
595
600
  let abortReason: AbortReason | undefined;
601
+ let runtimeLimitExceeded = false;
596
602
  const listenerController = new AbortController();
597
603
  const listenerSignal = listenerController.signal;
598
604
  const abortController = new AbortController();
@@ -613,8 +619,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
613
619
  let hasUsage = false;
614
620
 
615
621
  const requestAbort = (reason: AbortReason) => {
622
+ if (reason === "timeout") {
623
+ runtimeLimitExceeded = true;
624
+ }
616
625
  if (abortSent) {
617
- if (reason === "signal" && abortReason !== "signal") {
626
+ if (reason === "signal" && abortReason !== "signal" && abortReason !== "timeout") {
618
627
  abortReason = "signal";
619
628
  }
620
629
  return;
@@ -636,6 +645,24 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
636
645
  signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
637
646
  }
638
647
 
648
+ // Wall-clock hard limit. Defense-in-depth for the case where a provider stream
649
+ // hang escapes the inference-layer watchdog (see openai-completions
650
+ // `isOpenAICompletionsProgressChunk`). Disabled by default; set
651
+ // `task.maxRuntimeMs > 0` to cap each subagent's lifetime.
652
+ let runtimeTimeoutId: NodeJS.Timeout | undefined;
653
+ if (maxRuntimeMs > 0) {
654
+ runtimeTimeoutId = setTimeout(() => {
655
+ if (!resolved) {
656
+ logger.warn("Subagent runtime limit exceeded; aborting", {
657
+ id,
658
+ agent: agent.name,
659
+ maxRuntimeMs,
660
+ });
661
+ requestAbort("timeout");
662
+ }
663
+ }, maxRuntimeMs);
664
+ }
665
+
639
666
  const resolveSignalAbortReason = (): string => {
640
667
  const reason = signal?.reason;
641
668
  if (reason instanceof Error) {
@@ -647,6 +674,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
647
674
  }
648
675
  return "Cancelled by caller";
649
676
  };
677
+ const resolveAbortReasonText = (): string => {
678
+ if (runtimeLimitExceeded) {
679
+ return `Subagent runtime limit exceeded (task.maxRuntimeMs=${maxRuntimeMs})`;
680
+ }
681
+ return resolveSignalAbortReason();
682
+ };
650
683
  const PROGRESS_COALESCE_MS = 150;
651
684
  let lastProgressEmitMs = 0;
652
685
  let progressTimeoutId: NodeJS.Timeout | null = null;
@@ -907,6 +940,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
907
940
  }
908
941
  // Accumulate tokens for progress display
909
942
  progress.tokens += getUsageTokens(messageUsage);
943
+ // Track latest per-turn context size so the UI can show
944
+ // "current context", not just cumulative billing volume.
945
+ if (role === "assistant") {
946
+ const perTurnTotal = getNumberField(messageUsage as Record<string, unknown>, "totalTokens");
947
+ if (perTurnTotal !== undefined && perTurnTotal > 0) {
948
+ progress.contextTokens = perTurnTotal;
949
+ }
950
+ }
910
951
  }
911
952
  break;
912
953
  }
@@ -947,21 +988,39 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
947
988
  let abortReasonText: string | undefined;
948
989
  const checkAbort = () => {
949
990
  if (abortSignal.aborted) {
950
- aborted = abortReason === "signal" || abortReason === undefined;
991
+ aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
951
992
  if (aborted) {
952
- abortReasonText ??= resolveSignalAbortReason();
993
+ abortReasonText ??= resolveAbortReasonText();
953
994
  }
954
995
  exitCode = 1;
955
996
  throw new ToolAbortError();
956
997
  }
957
998
  };
999
+ const awaitAbortable = async <T>(promise: Promise<T>): Promise<T> => {
1000
+ checkAbort();
1001
+ const { promise: abortPromise, reject } = Promise.withResolvers<never>();
1002
+ const onAbort = () => {
1003
+ try {
1004
+ checkAbort();
1005
+ } catch (err) {
1006
+ reject(err);
1007
+ }
1008
+ };
1009
+ abortSignal.addEventListener("abort", onAbort, { once: true });
1010
+ try {
1011
+ return await Promise.race([promise, abortPromise]);
1012
+ } finally {
1013
+ abortSignal.removeEventListener("abort", onAbort);
1014
+ }
1015
+ };
958
1016
 
959
1017
  try {
960
1018
  checkAbort();
961
1019
  // Pin authStorage to modelRegistry.authStorage — mirrors the createAgentSession invariant.
962
1020
  const registryFromParent = options.modelRegistry !== undefined;
963
1021
  const modelRegistry =
964
- options.modelRegistry ?? new ModelRegistry(options.authStorage ?? (await discoverAuthStorage()));
1022
+ options.modelRegistry ??
1023
+ new ModelRegistry(options.authStorage ?? (await awaitAbortable(discoverAuthStorage())));
965
1024
  const authStorage = modelRegistry.authStorage;
966
1025
  if (options.authStorage && options.authStorage !== authStorage) {
967
1026
  throw new Error(
@@ -970,7 +1029,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
970
1029
  }
971
1030
  checkAbort();
972
1031
  if (!registryFromParent) {
973
- await modelRegistry.refresh();
1032
+ await awaitAbortable(modelRegistry.refresh());
974
1033
  } else {
975
1034
  logger.debug("runSubagent: reusing parent modelRegistry; skipping refresh");
976
1035
  }
@@ -981,11 +1040,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
981
1040
  thinkingLevel: resolvedThinkingLevel,
982
1041
  explicitThinkingLevel,
983
1042
  authFallbackUsed,
984
- } = await resolveModelOverrideWithAuthFallback(
985
- modelPatterns,
986
- options.parentActiveModelPattern,
987
- modelRegistry,
988
- settings,
1043
+ } = await awaitAbortable(
1044
+ resolveModelOverrideWithAuthFallback(
1045
+ modelPatterns,
1046
+ options.parentActiveModelPattern,
1047
+ modelRegistry,
1048
+ settings,
1049
+ ),
989
1050
  );
990
1051
  if (authFallbackUsed && model) {
991
1052
  logger.warn("Subagent model has no working credentials; falling back to parent session model", {
@@ -995,12 +1056,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
995
1056
  resolvedModel: model.id,
996
1057
  });
997
1058
  }
1059
+ if (model?.contextWindow && model.contextWindow > 0) {
1060
+ progress.contextWindow = model.contextWindow;
1061
+ }
998
1062
  const effectiveThinkingLevel = explicitThinkingLevel
999
1063
  ? resolvedThinkingLevel
1000
1064
  : (thinkingLevel ?? resolvedThinkingLevel);
1001
1065
 
1002
1066
  const sessionManager = sessionFile
1003
- ? await SessionManager.open(sessionFile)
1067
+ ? await awaitAbortable(SessionManager.open(sessionFile))
1004
1068
  : SessionManager.inMemory(worktree ?? cwd);
1005
1069
  if (options.parentArtifactManager) {
1006
1070
  sessionManager.adoptArtifactManager(options.parentArtifactManager);
@@ -1009,51 +1073,84 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1009
1073
  const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
1010
1074
  const enableMCP = !options.mcpManager;
1011
1075
 
1076
+ // Derive subagent-scoped telemetry from the parent's config so the
1077
+ // child loop's spans nest under the parent's active execute_tool span
1078
+ // (OTEL context propagation handles parent linkage automatically),
1079
+ // carry the subagent's own agent identity, and use the subagent's
1080
+ // own session id for `gen_ai.conversation.id`.
1081
+ const subagentAgentIdentity: AgentIdentity | undefined = options.parentTelemetry
1082
+ ? { id, name: agent.name, description: agent.description }
1083
+ : undefined;
1084
+ const subagentTelemetry: AgentTelemetryConfig | undefined =
1085
+ options.parentTelemetry && subagentAgentIdentity
1086
+ ? {
1087
+ ...options.parentTelemetry,
1088
+ agent: subagentAgentIdentity,
1089
+ // Clear parent's conversationId; the child loop falls back to
1090
+ // its own AgentLoopConfig.sessionId.
1091
+ conversationId: undefined,
1092
+ }
1093
+ : undefined;
1094
+
1095
+ if (options.parentTelemetry && subagentAgentIdentity) {
1096
+ const parentTelemetryHandle = resolveTelemetry(
1097
+ options.parentTelemetry,
1098
+ options.parentTelemetry.conversationId,
1099
+ );
1100
+ recordHandoff(parentTelemetryHandle, {
1101
+ fromAgent: options.parentTelemetry.agent,
1102
+ toAgent: subagentAgentIdentity,
1103
+ });
1104
+ }
1105
+
1012
1106
  const { normalized: normalizedOutputSchema } = normalizeSchema(outputSchema);
1013
1107
 
1014
- const { session } = await createAgentSession({
1015
- cwd: worktree ?? cwd,
1016
- authStorage,
1017
- modelRegistry,
1018
- settings: subagentSettings,
1019
- model,
1020
- thinkingLevel: effectiveThinkingLevel,
1021
- toolNames,
1022
- outputSchema,
1023
- requireYieldTool: true,
1024
- contextFiles: options.contextFiles,
1025
- skills: options.skills,
1026
- promptTemplates: options.promptTemplates,
1027
- workspaceTree: options.workspaceTree,
1028
- systemPrompt: defaultPrompt => {
1029
- const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
1030
- agent: agent.systemPrompt,
1031
- context: options.context?.trim() ?? "",
1032
- worktree: worktree ?? "",
1033
- outputSchema: normalizedOutputSchema,
1034
- contextFile: contextFileForPrompt,
1035
- ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
1036
- ircSelfId: ircEnabled ? id : "",
1037
- });
1038
- return defaultPrompt.length === 0
1039
- ? [subagentPrompt]
1040
- : [...defaultPrompt.slice(0, -1), subagentPrompt, defaultPrompt[defaultPrompt.length - 1]];
1041
- },
1042
- sessionManager,
1043
- hasUI: false,
1044
- spawns: spawnsEnv,
1045
- taskDepth: childDepth,
1046
- parentHindsightSessionState: options.parentHindsightSessionState,
1047
- parentTaskPrefix: id,
1048
- agentId: id,
1049
- agentDisplayName: agent.name,
1050
- enableLsp: lspEnabled,
1051
- skipPythonPreflight,
1052
- enableMCP,
1053
- mcpManager: options.mcpManager,
1054
- customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
1055
- localProtocolOptions: options.localProtocolOptions,
1056
- });
1108
+ const { session } = await awaitAbortable(
1109
+ createAgentSession({
1110
+ cwd: worktree ?? cwd,
1111
+ authStorage,
1112
+ modelRegistry,
1113
+ settings: subagentSettings,
1114
+ model,
1115
+ thinkingLevel: effectiveThinkingLevel,
1116
+ toolNames,
1117
+ outputSchema,
1118
+ requireYieldTool: true,
1119
+ contextFiles: options.contextFiles,
1120
+ skills: options.skills,
1121
+ promptTemplates: options.promptTemplates,
1122
+ workspaceTree: options.workspaceTree,
1123
+ systemPrompt: defaultPrompt => {
1124
+ const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
1125
+ agent: agent.systemPrompt,
1126
+ context: options.context?.trim() ?? "",
1127
+ worktree: worktree ?? "",
1128
+ outputSchema: normalizedOutputSchema,
1129
+ contextFile: contextFileForPrompt,
1130
+ ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
1131
+ ircSelfId: ircEnabled ? id : "",
1132
+ });
1133
+ return defaultPrompt.length === 0
1134
+ ? [subagentPrompt]
1135
+ : [...defaultPrompt.slice(0, -1), subagentPrompt, defaultPrompt[defaultPrompt.length - 1]];
1136
+ },
1137
+ sessionManager,
1138
+ hasUI: false,
1139
+ spawns: spawnsEnv,
1140
+ taskDepth: childDepth,
1141
+ parentHindsightSessionState: options.parentHindsightSessionState,
1142
+ parentTaskPrefix: id,
1143
+ agentId: id,
1144
+ agentDisplayName: agent.name,
1145
+ enableLsp: lspEnabled,
1146
+ skipPythonPreflight,
1147
+ enableMCP,
1148
+ mcpManager: options.mcpManager,
1149
+ customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
1150
+ localProtocolOptions: options.localProtocolOptions,
1151
+ telemetry: subagentTelemetry,
1152
+ }),
1153
+ );
1057
1154
 
1058
1155
  activeSession = session;
1059
1156
 
@@ -1074,7 +1171,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1074
1171
  const parentOwnedToolNames = new Set(["todo_write"]);
1075
1172
  const filteredSubagentTools = subagentToolNames.filter(name => !parentOwnedToolNames.has(name));
1076
1173
  if (filteredSubagentTools.length !== subagentToolNames.length) {
1077
- await session.setActiveToolsByName(filteredSubagentTools);
1174
+ await awaitAbortable(session.setActiveToolsByName(filteredSubagentTools));
1078
1175
  }
1079
1176
 
1080
1177
  session.sessionManager.appendSessionInit({
@@ -1091,6 +1188,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1091
1188
  },
1092
1189
  { once: true, signal: sessionAbortController.signal },
1093
1190
  );
1191
+ // Defensive: if the wall-clock timer (or external signal) fired during
1192
+ // the awaited setup above, the listener registration races the dispatch
1193
+ // and may not observe the already-fired abort event. Mirror it manually.
1194
+ if (abortSignal.aborted) {
1195
+ void session.abort();
1196
+ }
1094
1197
 
1095
1198
  const extensionRunner = session.extensionRunner;
1096
1199
  if (extensionRunner) {
@@ -1143,7 +1246,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1143
1246
  extensionRunner.onError(err => {
1144
1247
  logger.error("Extension error", { path: err.extensionPath, error: err.error });
1145
1248
  });
1146
- await extensionRunner.emit({ type: "session_start" });
1249
+ await awaitAbortable(extensionRunner.emit({ type: "session_start" }));
1147
1250
  }
1148
1251
 
1149
1252
  const MAX_YIELD_RETRIES = 3;
@@ -1160,8 +1263,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1160
1263
  }
1161
1264
  });
1162
1265
 
1163
- await session.prompt(task, { attribution: "agent" });
1164
- await session.waitForIdle();
1266
+ checkAbort();
1267
+ await awaitAbortable(session.prompt(task, { attribution: "agent" }));
1268
+ await awaitAbortable(session.waitForIdle());
1165
1269
 
1166
1270
  const reminderToolChoice = buildNamedToolChoice("yield", session.model);
1167
1271
 
@@ -1175,11 +1279,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1175
1279
  });
1176
1280
 
1177
1281
  const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
1178
- await session.prompt(reminder, {
1179
- attribution: "agent",
1180
- ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1181
- });
1182
- await session.waitForIdle();
1282
+ await awaitAbortable(
1283
+ session.prompt(reminder, {
1284
+ attribution: "agent",
1285
+ ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1286
+ }),
1287
+ );
1288
+ await awaitAbortable(session.waitForIdle());
1183
1289
  } catch (err) {
1184
1290
  logger.error("Subagent prompt failed", {
1185
1291
  error: err instanceof Error ? err.message : String(err),
@@ -1187,7 +1293,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1187
1293
  }
1188
1294
  }
1189
1295
 
1190
- await session.waitForIdle();
1296
+ await awaitAbortable(session.waitForIdle());
1191
1297
  if (!yieldCalled && !abortSignal.aborted) {
1192
1298
  exitCode = 0;
1193
1299
  }
@@ -1195,9 +1301,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1195
1301
  const lastAssistant = session.getLastAssistantMessage();
1196
1302
  if (lastAssistant) {
1197
1303
  if (lastAssistant.stopReason === "aborted") {
1198
- aborted = abortReason === "signal" || abortReason === undefined;
1304
+ aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
1199
1305
  if (aborted) {
1200
- abortReasonText ??= resolveSignalAbortReason();
1306
+ abortReasonText ??= resolveAbortReasonText();
1201
1307
  }
1202
1308
  exitCode = 1;
1203
1309
  } else if (lastAssistant.stopReason === "error") {
@@ -1212,9 +1318,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1212
1318
  }
1213
1319
  } finally {
1214
1320
  if (abortSignal.aborted) {
1215
- aborted = abortReason === "signal" || abortReason === undefined;
1321
+ aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
1216
1322
  if (aborted) {
1217
- abortReasonText ??= resolveSignalAbortReason();
1323
+ abortReasonText ??= resolveAbortReasonText();
1218
1324
  }
1219
1325
  if (exitCode === 0) exitCode = 1;
1220
1326
  }
@@ -1250,6 +1356,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1250
1356
  const done = await runSubagent();
1251
1357
  resolved = true;
1252
1358
  listenerController.abort();
1359
+ if (runtimeTimeoutId !== undefined) {
1360
+ clearTimeout(runtimeTimeoutId);
1361
+ runtimeTimeoutId = undefined;
1362
+ }
1253
1363
 
1254
1364
  if (progressTimeoutId) {
1255
1365
  clearTimeout(progressTimeoutId);
@@ -1303,12 +1413,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1303
1413
  }
1304
1414
  }
1305
1415
 
1306
- // Update final progress
1307
- const wasAborted = abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
1416
+ // Update final progress. A wall-clock timeout always wins: if the runtime
1417
+ // limit fired we report aborted/failed regardless of whether a yield landed
1418
+ // while we were tearing the session down. The yield data is still surfaced
1419
+ // to the caller via `progress.extractedToolData`, but the exit status must
1420
+ // reflect the timeout so on-call doesn't mistake a stuck run for success.
1421
+ if (runtimeLimitExceeded && exitCode === 0) {
1422
+ exitCode = 1;
1423
+ }
1424
+ const wasAborted =
1425
+ runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
1308
1426
  const finalAbortReason = wasAborted
1309
- ? abortedViaYield
1310
- ? yieldAbortReason
1311
- : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task"))
1427
+ ? runtimeLimitExceeded
1428
+ ? resolveAbortReasonText()
1429
+ : abortedViaYield
1430
+ ? yieldAbortReason
1431
+ : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : resolveAbortReasonText()))
1312
1432
  : undefined;
1313
1433
  progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
1314
1434
  scheduleProgress(true);
@@ -1341,6 +1461,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1341
1461
  truncated: Boolean(truncated),
1342
1462
  durationMs: Date.now() - startTime,
1343
1463
  tokens: progress.tokens,
1464
+ contextTokens: progress.contextTokens,
1465
+ contextWindow: progress.contextWindow,
1344
1466
  modelOverride,
1345
1467
  error: exitCode !== 0 && stderr ? stderr : undefined,
1346
1468
  aborted: wasAborted,
package/src/task/index.ts CHANGED
@@ -18,7 +18,6 @@ import path from "node:path";
18
18
  import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Usage } from "@oh-my-pi/pi-ai";
20
20
  import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
21
- import type { TSchema } from "@sinclair/typebox";
22
21
  import type { ToolSession } from "..";
23
22
  import { AsyncJobManager } from "../async";
24
23
  import { resolveAgentModelPatterns } from "../config/model-resolver";
@@ -29,6 +28,15 @@ import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.m
29
28
  import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
30
29
  import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
31
30
  import { formatBytes, formatDuration } from "../tools/render-utils";
31
+ import {
32
+ type AgentDefinition,
33
+ type AgentProgress,
34
+ getTaskSchema,
35
+ type SingleResult,
36
+ type TaskParams,
37
+ type TaskToolDetails,
38
+ type TaskToolSchemaInstance,
39
+ } from "./types";
32
40
  // Import review tools for side effects (registers subagent tool handlers)
33
41
  import "../tools/review";
34
42
  import type { LocalProtocolOptions } from "../internal-urls";
@@ -40,14 +48,6 @@ import { AgentOutputManager } from "./output-manager";
40
48
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
41
49
  import { renderResult, renderCall as renderTaskCall } from "./render";
42
50
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
43
- import {
44
- type AgentDefinition,
45
- type AgentProgress,
46
- getTaskSchema,
47
- type SingleResult,
48
- type TaskParams,
49
- type TaskToolDetails,
50
- } from "./types";
51
51
  import {
52
52
  applyNestedPatches,
53
53
  captureBaseline,
@@ -198,7 +198,7 @@ function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams):
198
198
  * Requires async initialization to discover available agents.
199
199
  * Use `TaskTool.create(session)` to instantiate.
200
200
  */
201
- export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
201
+ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetails, Theme> {
202
202
  readonly name = "task";
203
203
  readonly label = "Task";
204
204
  readonly summary = "Spawn a subagent to complete a parallel task";
@@ -208,7 +208,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
208
208
  readonly #discoveredAgents: AgentDefinition[];
209
209
  readonly #blockedAgent: string | undefined;
210
210
 
211
- get parameters(): TSchema {
211
+ get parameters(): TaskToolSchemaInstance {
212
212
  const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
213
213
  return getTaskSchema({ isolationEnabled, simpleMode: this.#getTaskSimpleMode() });
214
214
  }
@@ -391,6 +391,8 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
391
391
  : "failed";
392
392
  progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
393
393
  progress.tokens = singleResult?.tokens ?? 0;
394
+ progress.contextTokens = singleResult?.contextTokens;
395
+ progress.contextWindow = singleResult?.contextWindow;
394
396
  progress.cost = singleResult?.usage?.cost.total ?? 0;
395
397
  progress.extractedToolData = singleResult?.extractedToolData;
396
398
  }
@@ -881,6 +883,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
881
883
  localProtocolOptions,
882
884
  parentArtifactManager,
883
885
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
886
+ parentTelemetry: this.session.getTelemetry?.(),
884
887
  });
885
888
  }
886
889
 
@@ -934,6 +937,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
934
937
  localProtocolOptions,
935
938
  parentArtifactManager,
936
939
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
940
+ parentTelemetry: this.session.getTelemetry?.(),
937
941
  });
938
942
  if (mergeMode === "branch" && result.exitCode === 0) {
939
943
  try {
@@ -50,17 +50,35 @@ function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFra
50
50
  }
51
51
  }
52
52
 
53
- /** Append tool-count, token, and cost stats to a status line string. */
53
+ /** Append tool-count, context, cumulative-tokens, and cost stats to a status line string. */
54
54
  function appendAgentStats(
55
55
  line: string,
56
- opts: { toolCount?: number; tokens: number; cost: number },
56
+ opts: {
57
+ toolCount?: number;
58
+ tokens: number;
59
+ contextTokens?: number;
60
+ contextWindow?: number;
61
+ cost: number;
62
+ },
57
63
  theme: Theme,
58
64
  ): string {
59
65
  if (opts.toolCount) {
60
66
  line += `${theme.sep.dot}${theme.fg("dim", `${opts.toolCount} tools`)}`;
61
67
  }
62
- if (opts.tokens > 0) {
63
- line += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(opts.tokens)} tokens`)}`;
68
+ // Current per-turn context — what the user reads as "how full is the context".
69
+ // Cumulative tokens (billing volume) renders separately with a Σ sigil to avoid
70
+ // being mistaken for current window pressure.
71
+ if (opts.contextTokens && opts.contextTokens > 0) {
72
+ const ctx =
73
+ opts.contextWindow && opts.contextWindow > 0
74
+ ? `${formatNumber(opts.contextTokens)}/${formatNumber(opts.contextWindow)} ctx`
75
+ : `${formatNumber(opts.contextTokens)} ctx`;
76
+ line += `${theme.sep.dot}${theme.fg("dim", ctx)}`;
77
+ if (opts.tokens > 0) {
78
+ line += `${theme.sep.dot}${theme.fg("dim", `Σ${formatNumber(opts.tokens)}`)}`;
79
+ }
80
+ } else if (opts.tokens > 0) {
81
+ line += `${theme.sep.dot}${theme.fg("dim", `Σ${formatNumber(opts.tokens)}`)}`;
64
82
  }
65
83
  if (opts.cost > 0) {
66
84
  line += `${theme.sep.dot}${theme.fg("statusLineCost", `$${opts.cost.toFixed(2)}`)}`;
@@ -776,7 +794,16 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
776
794
  iconColor,
777
795
  theme,
778
796
  )}`;
779
- statusLine = appendAgentStats(statusLine, { tokens: result.tokens, cost: result.usage?.cost.total ?? 0 }, theme);
797
+ statusLine = appendAgentStats(
798
+ statusLine,
799
+ {
800
+ tokens: result.tokens,
801
+ contextTokens: result.contextTokens,
802
+ contextWindow: result.contextWindow,
803
+ cost: result.usage?.cost.total ?? 0,
804
+ },
805
+ theme,
806
+ );
780
807
  statusLine += `${theme.sep.dot}${theme.fg("dim", formatDuration(result.durationMs))}`;
781
808
 
782
809
  if (result.truncated) {