@oh-my-pi/pi-coding-agent 15.0.1 → 15.1.0

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 (168) hide show
  1. package/CHANGELOG.md +94 -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 +8 -18
  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/commands/commit.ts +10 -0
  17. package/src/commit/agentic/tools/analyze-file.ts +4 -4
  18. package/src/commit/agentic/tools/git-file-diff.ts +4 -4
  19. package/src/commit/agentic/tools/git-hunk.ts +5 -5
  20. package/src/commit/agentic/tools/git-overview.ts +4 -4
  21. package/src/commit/agentic/tools/propose-changelog.ts +13 -13
  22. package/src/commit/agentic/tools/propose-commit.ts +6 -6
  23. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  24. package/src/commit/agentic/tools/schemas.ts +28 -28
  25. package/src/commit/agentic/tools/split-commit.ts +22 -21
  26. package/src/commit/analysis/summary.ts +4 -4
  27. package/src/commit/changelog/generate.ts +7 -11
  28. package/src/commit/shared-llm.ts +22 -34
  29. package/src/config/config-file.ts +35 -13
  30. package/src/config/model-registry.ts +40 -191
  31. package/src/config/models-config-schema.ts +166 -0
  32. package/src/config/settings-schema.ts +29 -0
  33. package/src/discovery/claude-plugins.ts +19 -7
  34. package/src/edit/index.ts +2 -2
  35. package/src/edit/modes/apply-patch.ts +7 -6
  36. package/src/edit/modes/patch.ts +18 -25
  37. package/src/edit/modes/replace.ts +18 -20
  38. package/src/eval/js/shared/rewrite-imports.ts +131 -10
  39. package/src/eval/py/executor.ts +233 -623
  40. package/src/eval/py/kernel.ts +27 -2
  41. package/src/eval/py/runner.py +42 -11
  42. package/src/eval/py/runtime.ts +1 -0
  43. package/src/exa/factory.ts +5 -4
  44. package/src/exa/mcp-client.ts +1 -1
  45. package/src/exa/researcher.ts +9 -20
  46. package/src/exa/search.ts +26 -52
  47. package/src/exa/types.ts +1 -1
  48. package/src/exa/websets.ts +54 -53
  49. package/src/exec/bash-executor.ts +2 -1
  50. package/src/extensibility/custom-commands/loader.ts +5 -3
  51. package/src/extensibility/custom-commands/types.ts +4 -2
  52. package/src/extensibility/custom-tools/loader.ts +5 -3
  53. package/src/extensibility/custom-tools/types.ts +7 -6
  54. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  55. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  56. package/src/extensibility/extensions/loader.ts +7 -3
  57. package/src/extensibility/extensions/types.ts +9 -5
  58. package/src/extensibility/extensions/wrapper.ts +1 -2
  59. package/src/extensibility/hooks/loader.ts +3 -1
  60. package/src/extensibility/hooks/tool-wrapper.ts +1 -1
  61. package/src/extensibility/hooks/types.ts +4 -2
  62. package/src/extensibility/plugins/legacy-pi-compat.ts +78 -31
  63. package/src/extensibility/shared-events.ts +1 -1
  64. package/src/extensibility/typebox.ts +391 -0
  65. package/src/goals/tools/goal-tool.ts +6 -12
  66. package/src/hashline/input.ts +2 -1
  67. package/src/hashline/parser.ts +27 -3
  68. package/src/hashline/types.ts +4 -4
  69. package/src/hindsight/state.ts +2 -2
  70. package/src/index.ts +0 -2
  71. package/src/internal-urls/docs-index.generated.ts +15 -15
  72. package/src/internal-urls/router.ts +8 -0
  73. package/src/internal-urls/types.ts +21 -0
  74. package/src/lsp/config.ts +15 -6
  75. package/src/lsp/defaults.json +6 -2
  76. package/src/lsp/types.ts +30 -38
  77. package/src/mcp/manager.ts +1 -1
  78. package/src/mcp/tool-bridge.ts +1 -1
  79. package/src/modes/acp/acp-agent.ts +248 -50
  80. package/src/modes/components/session-observer-overlay.ts +12 -1
  81. package/src/modes/components/status-line/segments.ts +39 -4
  82. package/src/modes/controllers/command-controller.ts +27 -2
  83. package/src/modes/controllers/event-controller.ts +3 -4
  84. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  85. package/src/modes/interactive-mode.ts +1 -1
  86. package/src/modes/rpc/host-tools.ts +1 -1
  87. package/src/modes/rpc/host-uris.ts +235 -0
  88. package/src/modes/rpc/rpc-client.ts +1 -1
  89. package/src/modes/rpc/rpc-mode.ts +27 -1
  90. package/src/modes/rpc/rpc-types.ts +58 -1
  91. package/src/modes/runtime-init.ts +2 -1
  92. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  94. package/src/modes/theme/theme.ts +117 -117
  95. package/src/modes/types.ts +1 -1
  96. package/src/modes/utils/context-usage.ts +2 -2
  97. package/src/prompts/tools/github.md +4 -4
  98. package/src/prompts/tools/hashline.md +22 -26
  99. package/src/prompts/tools/read.md +55 -37
  100. package/src/sdk.ts +31 -8
  101. package/src/session/agent-session.ts +74 -104
  102. package/src/session/messages.ts +16 -51
  103. package/src/session/session-manager.ts +22 -2
  104. package/src/session/streaming-output.ts +16 -6
  105. package/src/task/discovery.ts +5 -2
  106. package/src/task/executor.ts +210 -87
  107. package/src/task/index.ts +15 -11
  108. package/src/task/render.ts +32 -5
  109. package/src/task/types.ts +54 -39
  110. package/src/tools/ask.ts +12 -12
  111. package/src/tools/ast-edit.ts +11 -15
  112. package/src/tools/ast-grep.ts +9 -10
  113. package/src/tools/bash-command-fixup.ts +47 -0
  114. package/src/tools/bash.ts +48 -38
  115. package/src/tools/browser/render.ts +2 -2
  116. package/src/tools/browser.ts +39 -53
  117. package/src/tools/calculator.ts +12 -11
  118. package/src/tools/checkpoint.ts +7 -7
  119. package/src/tools/debug.ts +40 -43
  120. package/src/tools/eval.ts +16 -10
  121. package/src/tools/find.ts +10 -13
  122. package/src/tools/gh.ts +108 -132
  123. package/src/tools/hindsight-recall.ts +4 -6
  124. package/src/tools/hindsight-reflect.ts +5 -5
  125. package/src/tools/hindsight-retain.ts +15 -17
  126. package/src/tools/image-gen.ts +31 -81
  127. package/src/tools/index.ts +4 -1
  128. package/src/tools/inspect-image.ts +8 -9
  129. package/src/tools/irc.ts +15 -27
  130. package/src/tools/job.ts +30 -28
  131. package/src/tools/output-meta.ts +26 -0
  132. package/src/tools/read.ts +39 -12
  133. package/src/tools/recipe/index.ts +7 -9
  134. package/src/tools/render-mermaid.ts +12 -12
  135. package/src/tools/report-tool-issue.ts +4 -4
  136. package/src/tools/resolve.ts +11 -11
  137. package/src/tools/review.ts +14 -26
  138. package/src/tools/search-tool-bm25.ts +7 -9
  139. package/src/tools/search.ts +19 -22
  140. package/src/tools/ssh.ts +10 -9
  141. package/src/tools/todo-write.ts +26 -34
  142. package/src/tools/vim.ts +10 -26
  143. package/src/tools/write.ts +25 -5
  144. package/src/tools/yield.ts +100 -54
  145. package/src/web/search/index.ts +9 -24
  146. package/src/web/search/providers/anthropic.ts +5 -0
  147. package/src/web/search/providers/exa.ts +3 -0
  148. package/src/web/search/providers/gemini.ts +5 -0
  149. package/src/web/search/providers/jina.ts +5 -2
  150. package/src/web/search/providers/zai.ts +5 -2
  151. package/src/prompts/compaction/branch-summary-context.md +0 -5
  152. package/src/prompts/compaction/branch-summary-preamble.md +0 -2
  153. package/src/prompts/compaction/branch-summary.md +0 -30
  154. package/src/prompts/compaction/compaction-short-summary.md +0 -9
  155. package/src/prompts/compaction/compaction-summary-context.md +0 -5
  156. package/src/prompts/compaction/compaction-summary.md +0 -38
  157. package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
  158. package/src/prompts/compaction/compaction-update-summary.md +0 -45
  159. package/src/prompts/system/auto-handoff-threshold-focus.md +0 -1
  160. package/src/prompts/system/file-operations.md +0 -10
  161. package/src/prompts/system/handoff-document.md +0 -49
  162. package/src/prompts/system/summarization-system.md +0 -3
  163. package/src/session/compaction/branch-summarization.ts +0 -324
  164. package/src/session/compaction/compaction.ts +0 -1420
  165. package/src/session/compaction/errors.ts +0 -31
  166. package/src/session/compaction/index.ts +0 -8
  167. package/src/session/compaction/pruning.ts +0 -91
  168. 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";
@@ -16,6 +16,7 @@ import { Settings } from "../config/settings";
16
16
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
17
17
  import type { CustomTool } from "../extensibility/custom-tools/types";
18
18
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
19
+ import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
19
20
  import type { Skill } from "../extensibility/skills";
20
21
  import type { HindsightSessionState } from "../hindsight/state";
21
22
  import type { LocalProtocolOptions } from "../internal-urls";
@@ -50,7 +51,6 @@ import {
50
51
  } from "./types";
51
52
 
52
53
  const MCP_CALL_TIMEOUT_MS = 60_000;
53
- const ajv = new Ajv({ allErrors: true, strict: false, logger: false });
54
54
 
55
55
  /** Agent event types to forward for progress tracking. */
56
56
  const agentEventTypes = new Set<AgentEvent["type"]>([
@@ -181,6 +181,15 @@ export interface ExecutorOptions {
181
181
  */
182
182
  parentArtifactManager?: ArtifactManager;
183
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;
184
193
  }
185
194
 
186
195
  function parseStringifiedJson(value: unknown): unknown {
@@ -195,16 +204,12 @@ function parseStringifiedJson(value: unknown): unknown {
195
204
  }
196
205
  }
197
206
 
198
- function buildOutputValidator(schema: unknown): { validate?: ValidateFunction; error?: string } {
207
+ function buildOutputValidator(schema: unknown): { validate?: (value: unknown) => boolean; error?: string } {
199
208
  const { normalized, error } = normalizeSchema(schema);
200
209
  if (error) return { error };
201
210
  if (normalized === undefined) return {};
202
211
  const jsonSchema = jtdToJsonSchema(normalized);
203
- try {
204
- return { validate: ajv.compile(jsonSchema as any) };
205
- } catch (err) {
206
- return { error: err instanceof Error ? err.message : String(err) };
207
- }
212
+ return { validate: value => isJsonSchemaValueValid(jsonSchema, value) };
208
213
  }
209
214
 
210
215
  function tryParseJsonOutput(text: string): unknown | undefined {
@@ -407,14 +412,14 @@ function getUsageTokens(usage: unknown): number {
407
412
  /**
408
413
  * Create proxy tools that reuse the parent's MCP connections.
409
414
  */
410
- function createMCPProxyTools(mcpManager: MCPManager): CustomTool<TSchema>[] {
415
+ function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
411
416
  return mcpManager.getTools().map(tool => {
412
417
  const mcpTool = tool as { mcpToolName?: string; mcpServerName?: string };
413
418
  return {
414
419
  name: tool.name,
415
420
  label: tool.label ?? tool.name,
416
421
  description: tool.description ?? "",
417
- parameters: tool.parameters as TSchema,
422
+ parameters: tool.parameters,
418
423
  execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
419
424
  if (signal?.aborted) {
420
425
  throw new ToolAbortError();
@@ -542,6 +547,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
542
547
  const settings = options.settings ?? Settings.isolated();
543
548
  const subagentSettings = createSubagentSettings(settings);
544
549
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
550
+ const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
545
551
  const parentDepth = options.taskDepth ?? 0;
546
552
  const childDepth = parentDepth + 1;
547
553
  const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
@@ -589,9 +595,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
589
595
  let recentOutputTail = "";
590
596
  let stderr = "";
591
597
  let resolved = false;
592
- type AbortReason = "signal" | "terminate";
598
+ type AbortReason = "signal" | "terminate" | "timeout";
593
599
  let abortSent = false;
594
600
  let abortReason: AbortReason | undefined;
601
+ let runtimeLimitExceeded = false;
595
602
  const listenerController = new AbortController();
596
603
  const listenerSignal = listenerController.signal;
597
604
  const abortController = new AbortController();
@@ -612,8 +619,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
612
619
  let hasUsage = false;
613
620
 
614
621
  const requestAbort = (reason: AbortReason) => {
622
+ if (reason === "timeout") {
623
+ runtimeLimitExceeded = true;
624
+ }
615
625
  if (abortSent) {
616
- if (reason === "signal" && abortReason !== "signal") {
626
+ if (reason === "signal" && abortReason !== "signal" && abortReason !== "timeout") {
617
627
  abortReason = "signal";
618
628
  }
619
629
  return;
@@ -635,6 +645,24 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
635
645
  signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
636
646
  }
637
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
+
638
666
  const resolveSignalAbortReason = (): string => {
639
667
  const reason = signal?.reason;
640
668
  if (reason instanceof Error) {
@@ -646,6 +674,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
646
674
  }
647
675
  return "Cancelled by caller";
648
676
  };
677
+ const resolveAbortReasonText = (): string => {
678
+ if (runtimeLimitExceeded) {
679
+ return `Subagent runtime limit exceeded (task.maxRuntimeMs=${maxRuntimeMs})`;
680
+ }
681
+ return resolveSignalAbortReason();
682
+ };
649
683
  const PROGRESS_COALESCE_MS = 150;
650
684
  let lastProgressEmitMs = 0;
651
685
  let progressTimeoutId: NodeJS.Timeout | null = null;
@@ -906,6 +940,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
906
940
  }
907
941
  // Accumulate tokens for progress display
908
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
+ }
909
951
  }
910
952
  break;
911
953
  }
@@ -946,21 +988,39 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
946
988
  let abortReasonText: string | undefined;
947
989
  const checkAbort = () => {
948
990
  if (abortSignal.aborted) {
949
- aborted = abortReason === "signal" || abortReason === undefined;
991
+ aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
950
992
  if (aborted) {
951
- abortReasonText ??= resolveSignalAbortReason();
993
+ abortReasonText ??= resolveAbortReasonText();
952
994
  }
953
995
  exitCode = 1;
954
996
  throw new ToolAbortError();
955
997
  }
956
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
+ };
957
1016
 
958
1017
  try {
959
1018
  checkAbort();
960
1019
  // Pin authStorage to modelRegistry.authStorage — mirrors the createAgentSession invariant.
961
1020
  const registryFromParent = options.modelRegistry !== undefined;
962
1021
  const modelRegistry =
963
- options.modelRegistry ?? new ModelRegistry(options.authStorage ?? (await discoverAuthStorage()));
1022
+ options.modelRegistry ??
1023
+ new ModelRegistry(options.authStorage ?? (await awaitAbortable(discoverAuthStorage())));
964
1024
  const authStorage = modelRegistry.authStorage;
965
1025
  if (options.authStorage && options.authStorage !== authStorage) {
966
1026
  throw new Error(
@@ -969,7 +1029,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
969
1029
  }
970
1030
  checkAbort();
971
1031
  if (!registryFromParent) {
972
- await modelRegistry.refresh();
1032
+ await awaitAbortable(modelRegistry.refresh());
973
1033
  } else {
974
1034
  logger.debug("runSubagent: reusing parent modelRegistry; skipping refresh");
975
1035
  }
@@ -980,11 +1040,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
980
1040
  thinkingLevel: resolvedThinkingLevel,
981
1041
  explicitThinkingLevel,
982
1042
  authFallbackUsed,
983
- } = await resolveModelOverrideWithAuthFallback(
984
- modelPatterns,
985
- options.parentActiveModelPattern,
986
- modelRegistry,
987
- settings,
1043
+ } = await awaitAbortable(
1044
+ resolveModelOverrideWithAuthFallback(
1045
+ modelPatterns,
1046
+ options.parentActiveModelPattern,
1047
+ modelRegistry,
1048
+ settings,
1049
+ ),
988
1050
  );
989
1051
  if (authFallbackUsed && model) {
990
1052
  logger.warn("Subagent model has no working credentials; falling back to parent session model", {
@@ -994,12 +1056,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
994
1056
  resolvedModel: model.id,
995
1057
  });
996
1058
  }
1059
+ if (model?.contextWindow && model.contextWindow > 0) {
1060
+ progress.contextWindow = model.contextWindow;
1061
+ }
997
1062
  const effectiveThinkingLevel = explicitThinkingLevel
998
1063
  ? resolvedThinkingLevel
999
1064
  : (thinkingLevel ?? resolvedThinkingLevel);
1000
1065
 
1001
1066
  const sessionManager = sessionFile
1002
- ? await SessionManager.open(sessionFile)
1067
+ ? await awaitAbortable(SessionManager.open(sessionFile))
1003
1068
  : SessionManager.inMemory(worktree ?? cwd);
1004
1069
  if (options.parentArtifactManager) {
1005
1070
  sessionManager.adoptArtifactManager(options.parentArtifactManager);
@@ -1008,51 +1073,84 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1008
1073
  const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
1009
1074
  const enableMCP = !options.mcpManager;
1010
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
+
1011
1106
  const { normalized: normalizedOutputSchema } = normalizeSchema(outputSchema);
1012
1107
 
1013
- const { session } = await createAgentSession({
1014
- cwd: worktree ?? cwd,
1015
- authStorage,
1016
- modelRegistry,
1017
- settings: subagentSettings,
1018
- model,
1019
- thinkingLevel: effectiveThinkingLevel,
1020
- toolNames,
1021
- outputSchema,
1022
- requireYieldTool: true,
1023
- contextFiles: options.contextFiles,
1024
- skills: options.skills,
1025
- promptTemplates: options.promptTemplates,
1026
- workspaceTree: options.workspaceTree,
1027
- systemPrompt: defaultPrompt => {
1028
- const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
1029
- agent: agent.systemPrompt,
1030
- context: options.context?.trim() ?? "",
1031
- worktree: worktree ?? "",
1032
- outputSchema: normalizedOutputSchema,
1033
- contextFile: contextFileForPrompt,
1034
- ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
1035
- ircSelfId: ircEnabled ? id : "",
1036
- });
1037
- return defaultPrompt.length === 0
1038
- ? [subagentPrompt]
1039
- : [...defaultPrompt.slice(0, -1), subagentPrompt, defaultPrompt[defaultPrompt.length - 1]];
1040
- },
1041
- sessionManager,
1042
- hasUI: false,
1043
- spawns: spawnsEnv,
1044
- taskDepth: childDepth,
1045
- parentHindsightSessionState: options.parentHindsightSessionState,
1046
- parentTaskPrefix: id,
1047
- agentId: id,
1048
- agentDisplayName: agent.name,
1049
- enableLsp: lspEnabled,
1050
- skipPythonPreflight,
1051
- enableMCP,
1052
- mcpManager: options.mcpManager,
1053
- customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
1054
- localProtocolOptions: options.localProtocolOptions,
1055
- });
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
+ );
1056
1154
 
1057
1155
  activeSession = session;
1058
1156
 
@@ -1073,7 +1171,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1073
1171
  const parentOwnedToolNames = new Set(["todo_write"]);
1074
1172
  const filteredSubagentTools = subagentToolNames.filter(name => !parentOwnedToolNames.has(name));
1075
1173
  if (filteredSubagentTools.length !== subagentToolNames.length) {
1076
- await session.setActiveToolsByName(filteredSubagentTools);
1174
+ await awaitAbortable(session.setActiveToolsByName(filteredSubagentTools));
1077
1175
  }
1078
1176
 
1079
1177
  session.sessionManager.appendSessionInit({
@@ -1090,6 +1188,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1090
1188
  },
1091
1189
  { once: true, signal: sessionAbortController.signal },
1092
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
+ }
1093
1197
 
1094
1198
  const extensionRunner = session.extensionRunner;
1095
1199
  if (extensionRunner) {
@@ -1119,7 +1223,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1119
1223
  getAllTools: () => session.getAllToolNames(),
1120
1224
  setActiveTools: (toolNames: string[]) =>
1121
1225
  session.setActiveToolsByName(toolNames.filter(name => !parentOwnedToolNames.has(name))),
1122
- getCommands: () => [],
1226
+ getCommands: () => getSessionSlashCommands(session),
1123
1227
  setModel: model => runExtensionSetModel(session, model),
1124
1228
  getThinkingLevel: () => session.thinkingLevel,
1125
1229
  setThinkingLevel: level => session.setThinkingLevel(level),
@@ -1142,7 +1246,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1142
1246
  extensionRunner.onError(err => {
1143
1247
  logger.error("Extension error", { path: err.extensionPath, error: err.error });
1144
1248
  });
1145
- await extensionRunner.emit({ type: "session_start" });
1249
+ await awaitAbortable(extensionRunner.emit({ type: "session_start" }));
1146
1250
  }
1147
1251
 
1148
1252
  const MAX_YIELD_RETRIES = 3;
@@ -1159,8 +1263,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1159
1263
  }
1160
1264
  });
1161
1265
 
1162
- await session.prompt(task, { attribution: "agent" });
1163
- await session.waitForIdle();
1266
+ checkAbort();
1267
+ await awaitAbortable(session.prompt(task, { attribution: "agent" }));
1268
+ await awaitAbortable(session.waitForIdle());
1164
1269
 
1165
1270
  const reminderToolChoice = buildNamedToolChoice("yield", session.model);
1166
1271
 
@@ -1174,11 +1279,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1174
1279
  });
1175
1280
 
1176
1281
  const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
1177
- await session.prompt(reminder, {
1178
- attribution: "agent",
1179
- ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1180
- });
1181
- 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());
1182
1289
  } catch (err) {
1183
1290
  logger.error("Subagent prompt failed", {
1184
1291
  error: err instanceof Error ? err.message : String(err),
@@ -1186,7 +1293,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1186
1293
  }
1187
1294
  }
1188
1295
 
1189
- await session.waitForIdle();
1296
+ await awaitAbortable(session.waitForIdle());
1190
1297
  if (!yieldCalled && !abortSignal.aborted) {
1191
1298
  exitCode = 0;
1192
1299
  }
@@ -1194,9 +1301,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1194
1301
  const lastAssistant = session.getLastAssistantMessage();
1195
1302
  if (lastAssistant) {
1196
1303
  if (lastAssistant.stopReason === "aborted") {
1197
- aborted = abortReason === "signal" || abortReason === undefined;
1304
+ aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
1198
1305
  if (aborted) {
1199
- abortReasonText ??= resolveSignalAbortReason();
1306
+ abortReasonText ??= resolveAbortReasonText();
1200
1307
  }
1201
1308
  exitCode = 1;
1202
1309
  } else if (lastAssistant.stopReason === "error") {
@@ -1211,9 +1318,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1211
1318
  }
1212
1319
  } finally {
1213
1320
  if (abortSignal.aborted) {
1214
- aborted = abortReason === "signal" || abortReason === undefined;
1321
+ aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
1215
1322
  if (aborted) {
1216
- abortReasonText ??= resolveSignalAbortReason();
1323
+ abortReasonText ??= resolveAbortReasonText();
1217
1324
  }
1218
1325
  if (exitCode === 0) exitCode = 1;
1219
1326
  }
@@ -1249,6 +1356,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1249
1356
  const done = await runSubagent();
1250
1357
  resolved = true;
1251
1358
  listenerController.abort();
1359
+ if (runtimeTimeoutId !== undefined) {
1360
+ clearTimeout(runtimeTimeoutId);
1361
+ runtimeTimeoutId = undefined;
1362
+ }
1252
1363
 
1253
1364
  if (progressTimeoutId) {
1254
1365
  clearTimeout(progressTimeoutId);
@@ -1302,12 +1413,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1302
1413
  }
1303
1414
  }
1304
1415
 
1305
- // Update final progress
1306
- 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));
1307
1426
  const finalAbortReason = wasAborted
1308
- ? abortedViaYield
1309
- ? yieldAbortReason
1310
- : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task"))
1427
+ ? runtimeLimitExceeded
1428
+ ? resolveAbortReasonText()
1429
+ : abortedViaYield
1430
+ ? yieldAbortReason
1431
+ : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : resolveAbortReasonText()))
1311
1432
  : undefined;
1312
1433
  progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
1313
1434
  scheduleProgress(true);
@@ -1340,6 +1461,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1340
1461
  truncated: Boolean(truncated),
1341
1462
  durationMs: Date.now() - startTime,
1342
1463
  tokens: progress.tokens,
1464
+ contextTokens: progress.contextTokens,
1465
+ contextWindow: progress.contextWindow,
1343
1466
  modelOverride,
1344
1467
  error: exitCode !== 0 && stderr ? stderr : undefined,
1345
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) {