@oh-my-pi/pi-coding-agent 15.1.2 → 15.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/types/async/job-manager.d.ts +3 -2
  3. package/dist/types/cli/auth-broker-cli.d.ts +25 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +18 -0
  5. package/dist/types/cli/grievances-cli.d.ts +12 -0
  6. package/dist/types/commands/auth-broker.d.ts +54 -0
  7. package/dist/types/commands/auth-gateway.d.ts +32 -0
  8. package/dist/types/commands/grievances.d.ts +1 -1
  9. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -1
  10. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -1
  11. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -1
  12. package/dist/types/config/model-registry.d.ts +3 -0
  13. package/dist/types/config/models-config-schema.d.ts +1 -0
  14. package/dist/types/config/settings-schema.d.ts +46 -0
  15. package/dist/types/discovery/agents.d.ts +12 -1
  16. package/dist/types/edit/renderer.d.ts +3 -0
  17. package/dist/types/eval/index.d.ts +0 -2
  18. package/dist/types/goals/tools/goal-tool.d.ts +10 -2
  19. package/dist/types/index.d.ts +0 -1
  20. package/dist/types/internal-urls/index.d.ts +1 -1
  21. package/dist/types/internal-urls/{pi-protocol.d.ts → omp-protocol.d.ts} +3 -3
  22. package/dist/types/internal-urls/types.d.ts +1 -1
  23. package/dist/types/main.d.ts +11 -2
  24. package/dist/types/modes/acp/acp-agent.d.ts +2 -1
  25. package/dist/types/modes/acp/acp-event-mapper.d.ts +13 -1
  26. package/dist/types/modes/acp/acp-mode.d.ts +3 -1
  27. package/dist/types/modes/emoji-autocomplete.d.ts +16 -0
  28. package/dist/types/modes/interactive-mode.d.ts +1 -1
  29. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  30. package/dist/types/plan-mode/approved-plan.d.ts +10 -4
  31. package/dist/types/sdk.d.ts +10 -3
  32. package/dist/types/session/agent-session.d.ts +7 -3
  33. package/dist/types/session/auth-broker-config.d.ts +13 -0
  34. package/dist/types/session/auth-storage.d.ts +1 -1
  35. package/dist/types/session/client-bridge.d.ts +3 -0
  36. package/dist/types/tools/eval.d.ts +41 -7
  37. package/dist/types/tools/irc.d.ts +8 -2
  38. package/dist/types/tools/report-tool-issue.d.ts +118 -1
  39. package/dist/types/tools/resolve.d.ts +8 -2
  40. package/examples/custom-tools/README.md +3 -12
  41. package/examples/extensions/README.md +2 -15
  42. package/examples/extensions/api-demo.ts +1 -7
  43. package/package.json +7 -7
  44. package/src/async/job-manager.ts +111 -13
  45. package/src/autoresearch/tools/init-experiment.ts +11 -33
  46. package/src/autoresearch/tools/log-experiment.ts +10 -24
  47. package/src/autoresearch/tools/run-experiment.ts +1 -1
  48. package/src/autoresearch/tools/update-notes.ts +2 -9
  49. package/src/cli/auth-broker-cli.ts +746 -0
  50. package/src/cli/auth-gateway-cli.ts +342 -0
  51. package/src/cli/grievances-cli.ts +109 -16
  52. package/src/cli/update-cli.ts +1 -5
  53. package/src/cli.ts +4 -2
  54. package/src/commands/auth-broker.ts +96 -0
  55. package/src/commands/auth-gateway.ts +61 -0
  56. package/src/commands/grievances.ts +13 -8
  57. package/src/commands/launch.ts +1 -1
  58. package/src/commit/agentic/agent.ts +2 -0
  59. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  60. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  61. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  62. package/src/commit/agentic/tools/git-overview.ts +2 -2
  63. package/src/commit/agentic/tools/propose-changelog.ts +1 -3
  64. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  65. package/src/commit/agentic/tools/schemas.ts +1 -9
  66. package/src/config/model-equivalence.ts +279 -174
  67. package/src/config/model-registry.ts +37 -6
  68. package/src/config/model-resolver.ts +13 -8
  69. package/src/config/models-config-schema.ts +8 -0
  70. package/src/config/settings-schema.ts +52 -0
  71. package/src/cursor.ts +1 -1
  72. package/src/debug/log-formatting.ts +1 -1
  73. package/src/debug/log-viewer.ts +1 -1
  74. package/src/debug/profiler.ts +4 -0
  75. package/src/debug/raw-sse-buffer.ts +100 -59
  76. package/src/debug/raw-sse.ts +1 -1
  77. package/src/discovery/agents.ts +15 -4
  78. package/src/edit/modes/apply-patch.ts +1 -5
  79. package/src/edit/modes/patch.ts +5 -5
  80. package/src/edit/modes/replace.ts +5 -5
  81. package/src/edit/renderer.ts +2 -1
  82. package/src/edit/streaming.ts +1 -1
  83. package/src/eval/index.ts +0 -2
  84. package/src/eval/js/shared/runtime.ts +107 -2
  85. package/src/eval/py/kernel.ts +1 -1
  86. package/src/exa/researcher.ts +4 -4
  87. package/src/exa/search.ts +10 -22
  88. package/src/exa/websets.ts +33 -33
  89. package/src/extensibility/typebox.ts +44 -17
  90. package/src/goals/tools/goal-tool.ts +3 -3
  91. package/src/index.ts +0 -3
  92. package/src/internal-urls/docs-index.generated.ts +21 -18
  93. package/src/internal-urls/index.ts +1 -1
  94. package/src/internal-urls/{pi-protocol.ts → omp-protocol.ts} +10 -10
  95. package/src/internal-urls/router.ts +3 -3
  96. package/src/internal-urls/types.ts +1 -1
  97. package/src/lsp/types.ts +8 -11
  98. package/src/main.ts +216 -146
  99. package/src/mcp/tool-bridge.ts +3 -3
  100. package/src/modes/acp/acp-agent.ts +203 -57
  101. package/src/modes/acp/acp-client-bridge.ts +2 -1
  102. package/src/modes/acp/acp-event-mapper.ts +208 -32
  103. package/src/modes/acp/acp-mode.ts +11 -3
  104. package/src/modes/components/bash-execution.ts +1 -1
  105. package/src/modes/components/diff.ts +1 -2
  106. package/src/modes/components/eval-execution.ts +1 -1
  107. package/src/modes/components/oauth-selector.ts +38 -2
  108. package/src/modes/components/tool-execution.ts +1 -2
  109. package/src/modes/components/tree-selector.ts +26 -7
  110. package/src/modes/controllers/command-controller.ts +95 -34
  111. package/src/modes/controllers/input-controller.ts +4 -3
  112. package/src/modes/data/emojis.json +1 -0
  113. package/src/modes/emoji-autocomplete.ts +285 -0
  114. package/src/modes/interactive-mode.ts +92 -19
  115. package/src/modes/print-mode.ts +3 -3
  116. package/src/modes/prompt-action-autocomplete.ts +14 -0
  117. package/src/plan-mode/approved-plan.ts +30 -9
  118. package/src/prompts/system/system-prompt.md +1 -1
  119. package/src/prompts/system/ttsr-tool-reminder.md +5 -0
  120. package/src/prompts/tools/ask.md +4 -3
  121. package/src/prompts/tools/eval.md +25 -26
  122. package/src/prompts/tools/read.md +1 -1
  123. package/src/prompts/tools/resolve.md +1 -1
  124. package/src/prompts/tools/search.md +1 -1
  125. package/src/prompts/tools/web-search.md +1 -1
  126. package/src/sdk.ts +81 -8
  127. package/src/session/agent-session.ts +362 -131
  128. package/src/session/agent-storage.ts +7 -2
  129. package/src/session/auth-broker-config.ts +102 -0
  130. package/src/session/auth-storage.ts +7 -1
  131. package/src/session/client-bridge.ts +3 -0
  132. package/src/session/streaming-output.ts +1 -1
  133. package/src/task/types.ts +10 -35
  134. package/src/tools/bash-interactive.ts +4 -1
  135. package/src/tools/bash-pty-selection.ts +2 -2
  136. package/src/tools/browser.ts +12 -20
  137. package/src/tools/eval.ts +77 -100
  138. package/src/tools/gh.ts +21 -45
  139. package/src/tools/hindsight-recall.ts +1 -1
  140. package/src/tools/hindsight-reflect.ts +2 -2
  141. package/src/tools/hindsight-retain.ts +3 -7
  142. package/src/tools/index.ts +8 -1
  143. package/src/tools/inspect-image.ts +4 -1
  144. package/src/tools/irc.ts +4 -12
  145. package/src/tools/job.ts +3 -11
  146. package/src/tools/report-tool-issue.ts +462 -17
  147. package/src/tools/resolve.ts +2 -7
  148. package/src/tools/todo-write.ts +8 -15
  149. package/src/utils/title-generator.ts +3 -0
  150. package/src/web/search/index.ts +6 -6
  151. package/dist/types/eval/parse.d.ts +0 -28
  152. package/dist/types/eval/sniff.d.ts +0 -11
  153. package/src/eval/eval.lark +0 -36
  154. package/src/eval/parse.ts +0 -407
  155. package/src/eval/sniff.ts +0 -28
@@ -18,12 +18,15 @@ import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { scheduler } from "node:timers/promises";
20
20
  import {
21
+ type AfterToolCallContext,
22
+ type AfterToolCallResult,
21
23
  type Agent,
22
24
  AgentBusyError,
23
25
  type AgentEvent,
24
26
  type AgentMessage,
25
27
  type AgentState,
26
28
  type AgentTool,
29
+ resolveTelemetry,
27
30
  ThinkingLevel,
28
31
  } from "@oh-my-pi/pi-agent-core";
29
32
  import {
@@ -78,7 +81,7 @@ import {
78
81
  prompt,
79
82
  Snowflake,
80
83
  } from "@oh-my-pi/pi-utils";
81
- import { type AsyncJob, AsyncJobManager } from "../async";
84
+ import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
82
85
  import { reset as resetCapabilities } from "../capability";
83
86
  import type { Rule } from "../capability/rule";
84
87
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
@@ -94,7 +97,7 @@ import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-temp
94
97
  import type { Settings, SkillsSettings } from "../config/settings";
95
98
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
96
99
  import { loadCapability } from "../discovery";
97
- import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
100
+ import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
98
101
  import {
99
102
  disposeKernelSessionsByOwner,
100
103
  executePython as executePythonCommand,
@@ -153,6 +156,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
153
156
  type: "text",
154
157
  };
155
158
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
159
+ import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
156
160
  import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
157
161
  import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
158
162
  import { invalidateHostMetadata } from "../ssh/connection-manager";
@@ -232,6 +236,7 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
232
236
  export interface AsyncJobSnapshot {
233
237
  running: AsyncJobSnapshotItem[];
234
238
  recent: AsyncJobSnapshotItem[];
239
+ delivery: AsyncJobDeliveryState;
235
240
  }
236
241
 
237
242
  // ============================================================================
@@ -530,7 +535,7 @@ function createHandoffFileName(date = new Date()): string {
530
535
  // ============================================================================
531
536
 
532
537
  /** Tools that require user permission before execution when an ACP client is connected. */
533
- const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "write", "ast_edit", "delete", "move"]);
538
+ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]);
534
539
 
535
540
  /** Permission options presented to the client on each gated tool call. */
536
541
  const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
@@ -542,46 +547,106 @@ const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
542
547
 
543
548
  const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
544
549
 
545
- function derivePermissionTitle(toolName: string, args: unknown): string {
546
- const a = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
550
+ function getStringProperty(value: Record<string, unknown>, key: string): string | undefined {
551
+ const candidate = value[key];
552
+ return typeof candidate === "string" ? candidate : undefined;
553
+ }
554
+
555
+ function collectStringPaths(value: unknown): string[] {
556
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
557
+ }
558
+
559
+ function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; paths: string[] } | undefined {
560
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
561
+ const a = args as Record<string, unknown>;
562
+
563
+ const edits = Array.isArray(a.edits) ? a.edits : undefined;
564
+ if (edits) {
565
+ const path = getStringProperty(a, "path");
566
+ if (path) {
567
+ for (const edit of edits) {
568
+ if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
569
+ const op = getStringProperty(edit as Record<string, unknown>, "op");
570
+ if (op === "delete") return { kind: "delete", paths: [path] };
571
+ }
572
+ }
573
+ for (const edit of edits) {
574
+ if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
575
+ const entry = edit as Record<string, unknown>;
576
+ const op = getStringProperty(entry, "op");
577
+ const rename = getStringProperty(entry, "rename");
578
+ if (op !== "create" && rename) return { kind: "move", paths: path ? [path, rename] : [rename] };
579
+ }
580
+ }
581
+
582
+ const input = getStringProperty(a, "input");
583
+ if (input) {
584
+ try {
585
+ const entries = expandApplyPatchToEntries({ input });
586
+ const deleteEntry = entries.find(entry => entry.op === "delete");
587
+ if (deleteEntry) return { kind: "delete", paths: [deleteEntry.path] };
588
+ const moveEntry = entries.find(entry => entry.rename);
589
+ if (moveEntry?.rename) return { kind: "move", paths: [moveEntry.path, moveEntry.rename] };
590
+ } catch {
591
+ // If the edit input is not an apply_patch envelope, it is not a delete/move operation.
592
+ }
593
+ }
594
+
595
+ return undefined;
596
+ }
597
+
598
+ function getPermissionIntent(
599
+ toolName: string,
600
+ args: unknown,
601
+ ): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined {
602
+ const a = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
547
603
  if (toolName === "bash") {
548
- const cmd = typeof a.command === "string" ? a.command.slice(0, 80) : undefined;
549
- if (cmd) return cmd;
550
- } else if (toolName === "edit" || toolName === "write" || toolName === "delete") {
551
- const p = typeof a.path === "string" ? a.path : undefined;
552
- if (p) {
553
- const verb = toolName === "edit" ? "Edit" : toolName === "write" ? "Write" : "Delete";
554
- return `${verb} ${p}`;
555
- }
556
- } else if (toolName === "move") {
557
- const from =
558
- typeof a.oldPath === "string"
559
- ? a.oldPath
560
- : typeof a.path === "string"
561
- ? a.path
562
- : typeof a.from === "string"
563
- ? a.from
564
- : undefined;
565
- const to =
566
- typeof a.newPath === "string"
567
- ? a.newPath
568
- : typeof a.to === "string"
569
- ? a.to
570
- : typeof a.destination === "string"
571
- ? a.destination
572
- : undefined;
573
- if (from && to) return `Move ${from} to ${to}`;
574
- if (from) return `Move ${from}`;
575
- } else if (toolName === "ast_edit") {
576
- const paths = Array.isArray(a.paths)
577
- ? (a.paths as unknown[]).filter(x => typeof x === "string").join(", ")
578
- : undefined;
579
- if (paths) return `AST edit ${paths}`;
604
+ const cmd = getStringProperty(a, "command")?.slice(0, 80);
605
+ return { toolName, title: cmd || toolName, cacheKey: toolName };
606
+ }
607
+ if (toolName === "delete") {
608
+ const p = getStringProperty(a, "path");
609
+ return { toolName, title: p ? `Delete ${p}` : toolName, paths: p ? [p] : undefined, cacheKey: toolName };
580
610
  }
581
- return toolName;
611
+ if (toolName === "move") {
612
+ const from = getStringProperty(a, "oldPath") ?? getStringProperty(a, "path") ?? getStringProperty(a, "from");
613
+ const to = getStringProperty(a, "newPath") ?? getStringProperty(a, "to") ?? getStringProperty(a, "destination");
614
+ if (from && to) return { toolName, title: `Move ${from} to ${to}`, paths: [from, to], cacheKey: toolName };
615
+ return {
616
+ toolName,
617
+ title: from ? `Move ${from}` : toolName,
618
+ paths: from ? [from] : undefined,
619
+ cacheKey: toolName,
620
+ };
621
+ }
622
+ if (toolName === "edit") {
623
+ const intent = getEditDestructiveIntent(args);
624
+ if (!intent) return undefined;
625
+ if (intent.kind === "delete") {
626
+ return {
627
+ toolName,
628
+ title: `Delete ${intent.paths[0] ?? "edit target"}`,
629
+ paths: intent.paths,
630
+ cacheKey: "edit:delete",
631
+ };
632
+ }
633
+ const from = intent.paths[0];
634
+ const to = intent.paths[1];
635
+ return {
636
+ toolName,
637
+ title: from && to ? `Move ${from} to ${to}` : `Move ${from ?? to ?? "edit target"}`,
638
+ paths: intent.paths,
639
+ cacheKey: "edit:move",
640
+ };
641
+ }
642
+ return undefined;
582
643
  }
583
644
 
584
- function extractPermissionLocations(args: unknown, cwd: string): { path: string; line?: number }[] {
645
+ function extractPermissionLocations(
646
+ args: unknown,
647
+ cwd: string,
648
+ explicitPaths?: string[],
649
+ ): { path: string; line?: number }[] {
585
650
  if (!args || typeof args !== "object") return [];
586
651
  const a = args as Record<string, unknown>;
587
652
  const out: { path: string; line?: number }[] = [];
@@ -599,12 +664,16 @@ function extractPermissionLocations(args: unknown, cwd: string): { path: string;
599
664
  if (out.some(location => location.path === resolved)) return;
600
665
  out.push({ path: resolved });
601
666
  };
602
- pushPath(a.path);
603
- pushPath(a.file);
604
- if (Array.isArray(a.paths)) {
605
- for (const p of a.paths) {
667
+ if (explicitPaths) {
668
+ for (const p of explicitPaths) {
606
669
  pushPath(p);
607
670
  }
671
+ return out;
672
+ }
673
+ pushPath(a.path);
674
+ pushPath(a.file);
675
+ for (const p of collectStringPaths(a.paths)) {
676
+ pushPath(p);
608
677
  }
609
678
  pushPath(a.oldPath);
610
679
  pushPath(a.newPath);
@@ -663,6 +732,7 @@ export class AgentSession {
663
732
  #planReferenceSent = false;
664
733
  #planReferencePath = "local://PLAN.md";
665
734
  #clientBridge: ClientBridge | undefined;
735
+ #allowAcpAgentInitiatedTurns = false;
666
736
  /** Per-session memory of allow_always / reject_always decisions for gated tools. */
667
737
  #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
668
738
 
@@ -767,6 +837,10 @@ export class AgentSession {
767
837
  // TTSR manager for time-traveling stream rules
768
838
  #ttsrManager: TtsrManager | undefined = undefined;
769
839
  #pendingTtsrInjections: Rule[] = [];
840
+ /** Per-tool TTSR rules whose `interruptMode` opted out of aborting the stream.
841
+ * These are folded into the matched tool call's `toolResult` content as an
842
+ * in-band system reminder, instead of spawning a separate follow-up turn. */
843
+ #perToolTtsrInjections = new Map<string, Rule[]>();
770
844
  #ttsrAbortPending = false;
771
845
  #ttsrRetryToken = 0;
772
846
  #ttsrResumePromise: Promise<void> | undefined = undefined;
@@ -796,6 +870,15 @@ export class AgentSession {
796
870
 
797
871
  #streamingEditFileCache = new Map<string, string>();
798
872
  #promptInFlightCount = 0;
873
+ // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
874
+ // Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo
875
+ // checks in #handleAgentEvent) still fire on the original schedule — only the
876
+ // `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
877
+ // Cursor exec, TUI listeners) is held back. Without this, a client that resumes
878
+ // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
879
+ // has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
880
+ // both #endInFlight (normal) and #resetInFlight (abort).
881
+ #pendingAgentEndEmit: AgentSessionEvent | undefined;
799
882
  #obfuscator: SecretObfuscator | undefined;
800
883
  #checkpointState: CheckpointState | undefined = undefined;
801
884
  #pendingRewindReport: string | undefined = undefined;
@@ -849,12 +932,21 @@ export class AgentSession {
849
932
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
850
933
  if (this.#promptInFlightCount === 0) {
851
934
  this.#releasePowerAssertion();
935
+ this.#flushPendingAgentEnd();
852
936
  }
853
937
  }
854
938
 
855
939
  #resetInFlight(): void {
856
940
  this.#promptInFlightCount = 0;
857
941
  this.#releasePowerAssertion();
942
+ this.#flushPendingAgentEnd();
943
+ }
944
+
945
+ #flushPendingAgentEnd(): void {
946
+ const pending = this.#pendingAgentEndEmit;
947
+ if (!pending) return;
948
+ this.#pendingAgentEndEmit = undefined;
949
+ this.#emit(pending);
858
950
  }
859
951
 
860
952
  constructor(config: AgentSessionConfig) {
@@ -880,16 +972,28 @@ export class AgentSession {
880
972
  this.#transformContext = config.transformContext ?? (messages => messages);
881
973
  this.#onPayload = config.onPayload;
882
974
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
975
+ // Avoid wrapping in an `async` closure when no user callback is configured: the
976
+ // outer await on `#onResponse` (provider-response.ts) tolerates a sync void return,
977
+ // and skipping the wrapper drops a per-event `newPromiseCapability` allocation that
978
+ // shows up as ~3.5% self time in streaming profiles.
883
979
  const configuredOnResponse = config.onResponse;
884
- this.#onResponse = async (response, model) => {
885
- this.rawSseDebugBuffer.recordResponse(response, model);
886
- await configuredOnResponse?.(response, model);
887
- };
980
+ this.#onResponse = configuredOnResponse
981
+ ? async (response, model) => {
982
+ this.rawSseDebugBuffer.recordResponse(response, model);
983
+ await configuredOnResponse(response, model);
984
+ }
985
+ : (response, model) => {
986
+ this.rawSseDebugBuffer.recordResponse(response, model);
987
+ };
888
988
  const configuredOnSseEvent = config.onSseEvent;
889
- this.#onSseEvent = (event, model) => {
890
- this.rawSseDebugBuffer.recordEvent(event, model);
891
- configuredOnSseEvent?.(event, model);
892
- };
989
+ this.#onSseEvent = configuredOnSseEvent
990
+ ? (event, model) => {
991
+ this.rawSseDebugBuffer.recordEvent(event, model);
992
+ configuredOnSseEvent(event, model);
993
+ }
994
+ : (event, model) => {
995
+ this.rawSseDebugBuffer.recordEvent(event, model);
996
+ };
893
997
  this.agent.setProviderResponseInterceptor(this.#onResponse);
894
998
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
895
999
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
@@ -932,6 +1036,8 @@ export class AgentSession {
932
1036
  this.#preCacheStreamingEditFile(event);
933
1037
  this.#maybeAbortStreamingEdit(event);
934
1038
  });
1039
+ // Per-tool TTSR reminders are folded into the matched tool's result via this hook.
1040
+ this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
935
1041
  this.agent.providerSessionState = this.#providerSessionState;
936
1042
  this.#syncAgentSessionId();
937
1043
  this.#syncTodoPhasesFromBranch();
@@ -1104,21 +1210,23 @@ export class AgentSession {
1104
1210
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
1105
1211
  const manager = AsyncJobManager.instance();
1106
1212
  if (!manager) return null;
1107
- const running = manager.getRunningJobs().map(job => ({
1213
+ const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
1214
+ const running = manager.getRunningJobs(ownerFilter).map(job => ({
1108
1215
  id: job.id,
1109
1216
  type: job.type,
1110
1217
  status: job.status,
1111
1218
  label: job.label,
1112
1219
  startTime: job.startTime,
1113
1220
  }));
1114
- const recent = manager.getRecentJobs(options?.recentLimit ?? 5).map(job => ({
1221
+ const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({
1115
1222
  id: job.id,
1116
1223
  type: job.type,
1117
1224
  status: job.status,
1118
1225
  label: job.label,
1119
1226
  startTime: job.startTime,
1120
1227
  }));
1121
- return { running, recent };
1228
+ const delivery = manager.getDeliveryState(ownerFilter);
1229
+ return { running, recent, delivery };
1122
1230
  }
1123
1231
 
1124
1232
  /**
@@ -1176,6 +1284,18 @@ export class AgentSession {
1176
1284
  return;
1177
1285
  }
1178
1286
  await this.#emitExtensionEvent(event);
1287
+ // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
1288
+ // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
1289
+ // emitting while #promptInFlightCount > 0 lets a client fire its next
1290
+ // `prompt` into a session that still reports isStreaming === true. Flush
1291
+ // happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
1292
+ // an auto-compaction turn that starts before the original prompt unwinds)
1293
+ // supersedes the pending one, which is what subscribers want — they only
1294
+ // care about the final settle.
1295
+ if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
1296
+ this.#pendingAgentEndEmit = event;
1297
+ return;
1298
+ }
1179
1299
  this.#emit(event);
1180
1300
  }
1181
1301
 
@@ -1325,77 +1445,87 @@ export class AgentSession {
1325
1445
  if (matchContext && "delta" in assistantEvent) {
1326
1446
  const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
1327
1447
  if (matches.length > 0) {
1328
- // Queue rules for injection; mark as injected only after successful enqueue.
1329
-
1330
- this.#addPendingTtsrInjections(matches);
1331
-
1332
- if (this.#shouldInterruptForTtsrMatch(matches, matchContext)) {
1333
- // Abort the stream immediately — do not gate on extension callbacks
1334
- this.#ttsrAbortPending = true;
1335
- this.#ensureTtsrResumePromise();
1336
- this.agent.abort();
1337
- // Notify extensions (fire-and-forget, does not block abort)
1448
+ // Decide first: a non-interrupting tool-source match attaches to the
1449
+ // specific tool call's result instead of driving a loop-wide follow-up.
1450
+ const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
1451
+ const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
1452
+ if (perToolId) {
1453
+ this.#addPerToolTtsrInjections(perToolId, matches);
1338
1454
  this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
1339
- // Schedule retry after a short delay
1340
- const retryToken = ++this.#ttsrRetryToken;
1341
- const generation = this.#promptGeneration;
1342
- const targetMessageTimestamp =
1343
- event.message.role === "assistant" ? event.message.timestamp : undefined;
1344
- this.#schedulePostPromptTask(
1345
- async () => {
1346
- if (this.#ttsrRetryToken !== retryToken) {
1347
- this.#resolveTtsrResume();
1348
- return;
1349
- }
1455
+ } else {
1456
+ // Queue rules for injection; mark as injected only after successful enqueue.
1457
+ this.#addPendingTtsrInjections(matches);
1458
+
1459
+ if (shouldInterrupt) {
1460
+ // Abort the stream immediately — do not gate on extension callbacks
1461
+ this.#ttsrAbortPending = true;
1462
+ this.#ensureTtsrResumePromise();
1463
+ this.agent.abort();
1464
+ // Notify extensions (fire-and-forget, does not block abort)
1465
+ this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
1466
+ // Schedule retry after a short delay
1467
+ const retryToken = ++this.#ttsrRetryToken;
1468
+ const generation = this.#promptGeneration;
1469
+ const targetMessageTimestamp =
1470
+ event.message.role === "assistant" ? event.message.timestamp : undefined;
1471
+ this.#schedulePostPromptTask(
1472
+ async () => {
1473
+ if (this.#ttsrRetryToken !== retryToken) {
1474
+ this.#resolveTtsrResume();
1475
+ return;
1476
+ }
1350
1477
 
1351
- const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
1352
- if (
1353
- !this.#ttsrAbortPending ||
1354
- this.#promptGeneration !== generation ||
1355
- targetAssistantIndex === -1
1356
- ) {
1478
+ const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
1479
+ if (
1480
+ !this.#ttsrAbortPending ||
1481
+ this.#promptGeneration !== generation ||
1482
+ targetAssistantIndex === -1
1483
+ ) {
1484
+ this.#ttsrAbortPending = false;
1485
+ this.#pendingTtsrInjections = [];
1486
+ this.#perToolTtsrInjections.clear();
1487
+ this.#resolveTtsrResume();
1488
+ return;
1489
+ }
1357
1490
  this.#ttsrAbortPending = false;
1358
- this.#pendingTtsrInjections = [];
1359
- this.#resolveTtsrResume();
1360
- return;
1361
- }
1362
- this.#ttsrAbortPending = false;
1363
- const ttsrSettings = this.#ttsrManager?.getSettings();
1364
- if (ttsrSettings?.contextMode === "discard") {
1365
- // Remove the partial/aborted assistant turn from agent state
1366
- this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
1367
- }
1368
- // Inject TTSR rules as system reminder before retry
1369
- const injection = this.#getTtsrInjectionContent();
1370
- if (injection) {
1371
- const details = { rules: injection.rules.map(rule => rule.name) };
1372
- this.agent.appendMessage({
1373
- role: "custom",
1374
- customType: "ttsr-injection",
1375
- content: injection.content,
1376
- display: false,
1377
- details,
1378
- attribution: "agent",
1379
- timestamp: Date.now(),
1380
- });
1381
- this.sessionManager.appendCustomMessageEntry(
1382
- "ttsr-injection",
1383
- injection.content,
1384
- false,
1385
- details,
1386
- "agent",
1387
- );
1388
- this.#markTtsrInjected(details.rules);
1389
- }
1390
- try {
1391
- await this.agent.continue();
1392
- } catch {
1393
- this.#resolveTtsrResume();
1394
- }
1395
- },
1396
- { delayMs: 50 },
1397
- );
1398
- return;
1491
+ this.#perToolTtsrInjections.clear();
1492
+ const ttsrSettings = this.#ttsrManager?.getSettings();
1493
+ if (ttsrSettings?.contextMode === "discard") {
1494
+ // Remove the partial/aborted assistant turn from agent state
1495
+ this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
1496
+ }
1497
+ // Inject TTSR rules as system reminder before retry
1498
+ const injection = this.#getTtsrInjectionContent();
1499
+ if (injection) {
1500
+ const details = { rules: injection.rules.map(rule => rule.name) };
1501
+ this.agent.appendMessage({
1502
+ role: "custom",
1503
+ customType: "ttsr-injection",
1504
+ content: injection.content,
1505
+ display: false,
1506
+ details,
1507
+ attribution: "agent",
1508
+ timestamp: Date.now(),
1509
+ });
1510
+ this.sessionManager.appendCustomMessageEntry(
1511
+ "ttsr-injection",
1512
+ injection.content,
1513
+ false,
1514
+ details,
1515
+ "agent",
1516
+ );
1517
+ this.#markTtsrInjected(details.rules);
1518
+ }
1519
+ try {
1520
+ await this.agent.continue();
1521
+ } catch {
1522
+ this.#resolveTtsrResume();
1523
+ }
1524
+ },
1525
+ { delayMs: 50 },
1526
+ );
1527
+ return;
1528
+ }
1399
1529
  }
1400
1530
  }
1401
1531
  }
@@ -1804,6 +1934,61 @@ export class AgentSession {
1804
1934
  }
1805
1935
  }
1806
1936
 
1937
+ /** Tool-call id whose argument deltas triggered a TTSR match, when known. */
1938
+ #extractTtsrToolCallId(matchContext: TtsrMatchContext): string | undefined {
1939
+ if (matchContext.source !== "tool") return undefined;
1940
+ const key = matchContext.streamKey;
1941
+ if (typeof key !== "string" || !key.startsWith("toolcall:")) return undefined;
1942
+ const id = key.slice("toolcall:".length);
1943
+ return id.length > 0 ? id : undefined;
1944
+ }
1945
+
1946
+ #addPerToolTtsrInjections(toolCallId: string, rules: Rule[]): void {
1947
+ const bucket = this.#perToolTtsrInjections.get(toolCallId) ?? [];
1948
+ const seen = new Set(bucket.map(rule => rule.name));
1949
+ // Dedupe against rules already bucketed for other tool calls in this
1950
+ // same assistant message so one rule attaches to exactly one tool call.
1951
+ const claimedElsewhere = new Set<string>();
1952
+ for (const [otherId, otherBucket] of this.#perToolTtsrInjections) {
1953
+ if (otherId === toolCallId) continue;
1954
+ for (const rule of otherBucket) claimedElsewhere.add(rule.name);
1955
+ }
1956
+ const newlyAdded: string[] = [];
1957
+ for (const rule of rules) {
1958
+ if (seen.has(rule.name) || claimedElsewhere.has(rule.name)) continue;
1959
+ bucket.push(rule);
1960
+ seen.add(rule.name);
1961
+ newlyAdded.push(rule.name);
1962
+ }
1963
+ if (bucket.length === 0) return;
1964
+ this.#perToolTtsrInjections.set(toolCallId, bucket);
1965
+ // Claim the rules in the TTSR manager so subsequent deltas in this same
1966
+ // turn (e.g. a sibling tool call's argument stream) don't re-match them.
1967
+ // Persistence still happens in #ttsrAfterToolCall when the tool actually
1968
+ // produces a result we can fold the reminder into.
1969
+ if (newlyAdded.length > 0) {
1970
+ this.#ttsrManager?.markInjectedByNames(newlyAdded);
1971
+ }
1972
+ }
1973
+
1974
+ /** `afterToolCall` hook: fold any per-tool TTSR reminders into the result. */
1975
+ #ttsrAfterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
1976
+ const rules = this.#perToolTtsrInjections.get(ctx.toolCall.id);
1977
+ if (!rules || rules.length === 0) return undefined;
1978
+ this.#perToolTtsrInjections.delete(ctx.toolCall.id);
1979
+ const reminder = rules
1980
+ .map(r => prompt.render(ttsrToolReminderTemplate, { name: r.name, path: r.path, content: r.content }))
1981
+ .join("\n\n");
1982
+ // The TTSR manager was already claimed at bucket time; only persistence remains.
1983
+ const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
1984
+ if (ruleNames.length > 0) {
1985
+ this.sessionManager.appendTtsrInjection(ruleNames);
1986
+ }
1987
+ return {
1988
+ content: [{ type: "text", text: reminder }, ...ctx.result.content],
1989
+ };
1990
+ }
1991
+
1807
1992
  #extractTtsrRuleNames(details: unknown): string[] {
1808
1993
  if (!details || typeof details !== "object" || Array.isArray(details)) {
1809
1994
  return [];
@@ -1854,6 +2039,11 @@ export class AgentSession {
1854
2039
  }
1855
2040
 
1856
2041
  #queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void {
2042
+ if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
2043
+ // Tools that hadn't started by abort/error will never produce results to
2044
+ // fold injections into — drop their stale per-tool entries.
2045
+ this.#perToolTtsrInjections.clear();
2046
+ }
1857
2047
  if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) {
1858
2048
  return;
1859
2049
  }
@@ -2582,6 +2772,23 @@ export class AgentSession {
2582
2772
  await this.#waitForPostPromptRecovery();
2583
2773
  }
2584
2774
 
2775
+ async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
2776
+ const manager = AsyncJobManager.instance();
2777
+ if (!manager) return false;
2778
+ const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
2779
+ const before = manager.getDeliveryState(ownerFilter);
2780
+ if (before.queued === 0 && !before.delivering) return false;
2781
+ const previousAllowAcpAgentInitiatedTurns = this.#allowAcpAgentInitiatedTurns;
2782
+ this.#allowAcpAgentInitiatedTurns = true;
2783
+ try {
2784
+ const drained = await manager.drainDeliveries({ timeoutMs: options?.timeoutMs, filter: ownerFilter });
2785
+ const after = manager.getDeliveryState(ownerFilter);
2786
+ return drained && (before.queued !== after.queued || before.delivering !== after.delivering);
2787
+ } finally {
2788
+ this.#allowAcpAgentInitiatedTurns = previousAllowAcpAgentInitiatedTurns;
2789
+ }
2790
+ }
2791
+
2585
2792
  /** Most recent assistant message in agent state. */
2586
2793
  getLastAssistantMessage(): AssistantMessage | undefined {
2587
2794
  return this.#findLastAssistantMessage();
@@ -2881,8 +3088,8 @@ export class AgentSession {
2881
3088
  if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
2882
3089
  if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
2883
3090
  return new Proxy(tool, {
2884
- get: (target, prop, receiver) => {
2885
- if (prop !== "execute") return Reflect.get(target, prop, receiver);
3091
+ get: (target, prop) => {
3092
+ if (prop !== "execute") return Reflect.get(target, prop, target);
2886
3093
  return async (
2887
3094
  toolCallId: string,
2888
3095
  args: unknown,
@@ -2890,8 +3097,12 @@ export class AgentSession {
2890
3097
  onUpdate: never,
2891
3098
  ctx: never,
2892
3099
  ) => {
3100
+ const permissionIntent = getPermissionIntent(target.name, args);
3101
+ if (!permissionIntent) {
3102
+ return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
3103
+ }
2893
3104
  // Short-circuit on persisted decisions.
2894
- const persisted = this.#acpPermissionDecisions.get(target.name);
3105
+ const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
2895
3106
  if (persisted === "allow_always") {
2896
3107
  return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
2897
3108
  }
@@ -2913,9 +3124,14 @@ export class AgentSession {
2913
3124
  {
2914
3125
  toolCallId,
2915
3126
  toolName: target.name,
2916
- title: derivePermissionTitle(target.name, args),
3127
+ title: permissionIntent.title,
3128
+ status: "pending",
2917
3129
  rawInput: args,
2918
- locations: extractPermissionLocations(args, this.sessionManager.getCwd()),
3130
+ locations: extractPermissionLocations(
3131
+ args,
3132
+ this.sessionManager.getCwd(),
3133
+ permissionIntent.paths,
3134
+ ),
2919
3135
  },
2920
3136
  PERMISSION_OPTIONS,
2921
3137
  signal,
@@ -2936,9 +3152,9 @@ export class AgentSession {
2936
3152
  throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
2937
3153
  }
2938
3154
  if (selectedOption.kind === "allow_always") {
2939
- this.#acpPermissionDecisions.set(target.name, "allow_always");
3155
+ this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always");
2940
3156
  } else if (selectedOption.kind === "reject_always") {
2941
- this.#acpPermissionDecisions.set(target.name, "reject_always");
3157
+ this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always");
2942
3158
  }
2943
3159
  if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
2944
3160
  throw new ToolError(`Tool call rejected by user (${target.name})`);
@@ -4178,7 +4394,7 @@ export class AgentSession {
4178
4394
  *
4179
4395
  * Handles three cases:
4180
4396
  * - Streaming: queue as steer/follow-up or store for next turn
4181
- * - Not streaming + triggerTurn: appends to state/session, starts new turn
4397
+ * - Not streaming + triggerTurn: appends to state/session, starts new turn unless the client cannot own it
4182
4398
  * - Not streaming + no trigger: appends to state/session, no turn
4183
4399
  */
4184
4400
  async sendCustomMessage<T = unknown>(
@@ -4210,6 +4426,10 @@ export class AgentSession {
4210
4426
 
4211
4427
  if (options?.deliverAs === "nextTurn") {
4212
4428
  if (options?.triggerTurn) {
4429
+ if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4430
+ this.#queueHiddenNextTurnMessage(appMessage, false);
4431
+ return;
4432
+ }
4213
4433
  await this.agent.prompt(appMessage);
4214
4434
  return;
4215
4435
  }
@@ -4225,6 +4445,10 @@ export class AgentSession {
4225
4445
  }
4226
4446
 
4227
4447
  if (options?.triggerTurn) {
4448
+ if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4449
+ this.#queueHiddenNextTurnMessage(appMessage, false);
4450
+ return;
4451
+ }
4228
4452
  await this.agent.prompt(appMessage);
4229
4453
  return;
4230
4454
  }
@@ -5249,6 +5473,7 @@ export class AgentSession {
5249
5473
  convertToLlm,
5250
5474
  initiatorOverride: "agent",
5251
5475
  metadata: this.agent.metadataForProvider(model.provider),
5476
+ telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
5252
5477
  },
5253
5478
  handoffSignal,
5254
5479
  );
@@ -5961,6 +6186,7 @@ export class AgentSession {
5961
6186
  options?: SummaryOptions,
5962
6187
  ): Promise<CompactionResult> {
5963
6188
  const candidates = this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
6189
+ const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
5964
6190
 
5965
6191
  for (const candidate of candidates) {
5966
6192
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
@@ -5971,6 +6197,7 @@ export class AgentSession {
5971
6197
  ...options,
5972
6198
  metadata: this.agent.metadataForProvider(candidate.provider),
5973
6199
  convertToLlm,
6200
+ telemetry,
5974
6201
  });
5975
6202
  } catch (error) {
5976
6203
  if (!this.#isCompactionAuthFailure(error)) {
@@ -6207,6 +6434,7 @@ export class AgentSession {
6207
6434
  } else {
6208
6435
  const candidates = this.#getCompactionModelCandidates(availableModels);
6209
6436
  const retrySettings = this.settings.getGroup("retry");
6437
+ const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
6210
6438
  let compactResult: CompactionResult | undefined;
6211
6439
  let lastError: unknown;
6212
6440
 
@@ -6224,6 +6452,7 @@ export class AgentSession {
6224
6452
  metadata: this.agent.metadataForProvider(candidate.provider),
6225
6453
  initiatorOverride: "agent",
6226
6454
  convertToLlm,
6455
+ telemetry,
6227
6456
  });
6228
6457
  break;
6229
6458
  } catch (error) {
@@ -7828,6 +8057,7 @@ export class AgentSession {
7828
8057
  reserveTokens: branchSummarySettings.reserveTokens,
7829
8058
  metadata: this.agent.metadataForProvider(model.provider),
7830
8059
  convertToLlm,
8060
+ telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
7831
8061
  });
7832
8062
  this.#branchSummaryAbortController = undefined;
7833
8063
  if (result.aborted) {
@@ -8063,11 +8293,12 @@ export class AgentSession {
8063
8293
  };
8064
8294
  }
8065
8295
 
8066
- async fetchUsageReports(): Promise<UsageReport[] | null> {
8296
+ async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
8067
8297
  const authStorage = this.#modelRegistry.authStorage;
8068
8298
  if (!authStorage.fetchUsageReports) return null;
8069
8299
  return authStorage.fetchUsageReports({
8070
8300
  baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
8301
+ signal,
8071
8302
  });
8072
8303
  }
8073
8304