@oh-my-pi/pi-coding-agent 15.1.3 → 15.1.5

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.
@@ -453,6 +453,16 @@ class TreeList implements Component {
453
453
  );
454
454
  const endIndex = Math.min(startIndex + this.maxVisibleLines, this.#filteredNodes.length);
455
455
 
456
+ // Cap the per-row gutter prefix so a content budget is always preserved.
457
+ // Each indent level renders as 3 cells; deep branching would otherwise eat the
458
+ // entire viewport (issue #1144). Reserve at least MIN_CONTENT_COLS for entry
459
+ // text — or half the viewport, whichever is larger — and compress older gutter
460
+ // levels off-screen behind a leading ellipsis when the row would exceed budget.
461
+ const MIN_CONTENT_COLS = 24;
462
+ const OVERHEAD_COLS = 4; // cursor (2) + a touch of breathing room
463
+ const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2));
464
+ const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3));
465
+
456
466
  for (let i = startIndex; i < endIndex; i++) {
457
467
  const flatNode = this.#filteredNodes[i];
458
468
  const entry = flatNode.node.entry;
@@ -464,29 +474,34 @@ class TreeList implements Component {
464
474
  // If multiple roots, shift display (roots at 0, not 1)
465
475
  const displayIndent = this.#multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
466
476
 
467
- // Build prefix with gutters at their correct positions
468
- // Each gutter has a position (displayIndent where its connector was shown)
477
+ // Build prefix with gutters at their correct positions, clamped to
478
+ // `maxIndentLevels` cells so the content always fits. When clamped, the
479
+ // leftmost cells represent the deepest visible ancestors and a `…` marker
480
+ // indicates older branch context has been compressed.
469
481
  const hasConnector = flatNode.showConnector && !flatNode.isVirtualRootChild;
470
482
  const connectorSymbol = hasConnector ? (flatNode.isLast ? theme.tree.last : theme.tree.branch) : "";
471
483
  const connectorChars = hasConnector ? Array.from(connectorSymbol) : [];
472
- const connectorPosition = hasConnector ? displayIndent - 1 : -1;
484
+ const renderedIndent = Math.min(displayIndent, maxIndentLevels);
485
+ const scrollOffset = displayIndent - renderedIndent;
486
+ const connectorPositionDisplay = hasConnector ? renderedIndent - 1 : -1;
473
487
 
474
488
  // Build prefix char by char, placing gutters and connector at their positions
475
- const totalChars = displayIndent * 3;
489
+ const totalChars = renderedIndent * 3;
476
490
  const prefixChars: string[] = [];
477
491
  for (let i = 0; i < totalChars; i++) {
478
492
  const level = Math.floor(i / 3);
493
+ const originalLevel = level + scrollOffset;
479
494
  const posInLevel = i % 3;
480
495
 
481
- // Check if there's a gutter at this level
482
- const gutter = flatNode.gutters.find(g => g.position === level);
496
+ // Check if there's a gutter at this level (translated to original tree depth)
497
+ const gutter = flatNode.gutters.find(g => g.position === originalLevel);
483
498
  if (gutter) {
484
499
  if (posInLevel === 0) {
485
500
  prefixChars.push(gutter.show ? theme.tree.vertical : " ");
486
501
  } else {
487
502
  prefixChars.push(" ");
488
503
  }
489
- } else if (hasConnector && level === connectorPosition) {
504
+ } else if (hasConnector && level === connectorPositionDisplay) {
490
505
  // Connector at this level
491
506
  if (posInLevel === 0) {
492
507
  prefixChars.push(connectorChars[0] ?? " ");
@@ -499,6 +514,10 @@ class TreeList implements Component {
499
514
  prefixChars.push(" ");
500
515
  }
501
516
  }
517
+ // Mark the leftmost cell when ancestors were compressed off-screen.
518
+ if (scrollOffset > 0 && prefixChars.length > 0) {
519
+ prefixChars[0] = "…";
520
+ }
502
521
  const prefix = prefixChars.join("");
503
522
 
504
523
  // Active path marker - shown right before the entry text
@@ -14,10 +14,12 @@ export interface PlanApprovalDetails {
14
14
  planExists: boolean;
15
15
  }
16
16
 
17
- /** Validate the agent-supplied plan title and derive the destination filename.
18
- * Filename uses the title with a `.md` suffix; characters are restricted to
19
- * letters, numbers, underscores, and hyphens so the value is safe to splice
20
- * into a `local://` URL without escaping. */
17
+ /** Validate and normalize the agent-supplied plan title into a safe filename stem.
18
+ * Spaces and other URL-safe punctuation are replaced with hyphens so models that
19
+ * produce natural-language titles (e.g. "My feature plan") still succeed.
20
+ * Characters that cannot be safely represented after replacement are dropped.
21
+ * The result is restricted to letters, numbers, underscores, and hyphens so it
22
+ * is safe to splice into a `local://` URL without escaping. */
21
23
  export function normalizePlanTitle(title: string): { title: string; fileName: string } {
22
24
  const trimmed = title.trim();
23
25
  if (!trimmed) {
@@ -28,13 +30,23 @@ export function normalizePlanTitle(title: string): { title: string; fileName: st
28
30
  throw new ToolError("Plan title must not contain path separators or '..'.");
29
31
  }
30
32
 
31
- const withExtension = trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`;
32
- if (!/^[A-Za-z0-9_-]+\.md$/.test(withExtension)) {
33
- throw new ToolError("Plan title may only contain letters, numbers, underscores, or hyphens.");
33
+ // Strip a trailing `.md` if the model included it, then sanitize:
34
+ // spaces → hyphens, any remaining invalid char → dropped.
35
+ const withoutExt = trimmed.replace(/\.md$/i, "");
36
+ const sanitized = withoutExt
37
+ .replace(/\s+/g, "-")
38
+ .replace(/[^A-Za-z0-9_-]/g, "")
39
+ .replace(/-{2,}/g, "-")
40
+ .replace(/^-+|-+$/g, "");
41
+
42
+ if (!sanitized) {
43
+ throw new ToolError(
44
+ "Plan title must contain at least one letter, number, underscore, or hyphen after sanitization.",
45
+ );
34
46
  }
35
47
 
36
- const normalizedTitle = withExtension.slice(0, -3);
37
- return { title: normalizedTitle, fileName: withExtension };
48
+ const fileName = `${sanitized}.md`;
49
+ return { title: sanitized, fileName };
38
50
  }
39
51
 
40
52
  /** Humanize a normalized plan title for use as a session display name.
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: oracle
3
+ description: Deep reasoning advisor for debugging dead ends, architecture decisions, and second opinions. Read-only.
4
+ spawns: explore
5
+ model: pi/slow
6
+ thinking-level: xhigh
7
+ blocking: true
8
+ ---
9
+
10
+ You are a senior diagnostician and strategic technical advisor. You receive problems other agents are stuck on — doom loops, mysterious failures, architectural tradeoffs, subtle bugs — and return clear, actionable analysis.
11
+
12
+ You diagnose, explain, and recommend. You do not implement. Others act on your findings.
13
+
14
+ <critical>
15
+ You MUST operate as read-only. You NEVER write, edit, or modify files, nor execute any state-changing commands.
16
+ </critical>
17
+
18
+ <directives>
19
+ - You MUST reason from first principles. The caller already tried the obvious.
20
+ - You MUST use tools to verify claims. You NEVER speculate about code behavior — read it.
21
+ - You MUST identify root causes, not symptoms. If the caller says "X is broken", determine *why* X is broken.
22
+ - You MUST surface hidden assumptions — in the code, in the caller's framing, in the environment.
23
+ - You SHOULD consider at least two hypotheses before converging on one.
24
+ - You SHOULD invoke tools in parallel when investigating multiple hypotheses.
25
+ - When the problem is architectural, you MUST weigh tradeoffs explicitly: what does each option cost, what does it buy, what does it foreclose.
26
+ </directives>
27
+
28
+ <decision-framework>
29
+ Apply pragmatic minimalism:
30
+ - **Bias toward simplicity**: The right solution is the least complex one that fulfills actual requirements. Resist hypothetical future needs.
31
+ - **Leverage what exists**: Favor modifications to current code and established patterns over introducing new components. New dependencies or infrastructure require explicit justification.
32
+ - **One clear path**: Present a single primary recommendation. Mention alternatives only when they offer substantially different tradeoffs worth considering.
33
+ - **Match depth to complexity**: Quick questions get quick answers. Reserve thorough analysis for genuinely complex problems.
34
+ - **Signal the investment**: Tag recommendations with estimated effort — Quick (<1h), Short (1-4h), Medium (1-2d), Large (3d+).
35
+ </decision-framework>
36
+
37
+ <procedure>
38
+ 1. Read the problem statement carefully. Identify what was already tried and why it failed.
39
+ 2. Form 2-3 hypotheses for the root cause.
40
+ 3. Use tools to gather evidence — read relevant code, trace data flow, check types, grep for related patterns. Parallelize independent reads.
41
+ 4. Eliminate hypotheses based on evidence. Narrow to the most likely cause.
42
+ 5. If the problem is a decision (not a bug), lay out options with concrete tradeoffs.
43
+ 6. Deliver a clear verdict with supporting evidence.
44
+ </procedure>
45
+ <scope-discipline>
46
+ - Recommend ONLY what was asked. No unsolicited improvements.
47
+ - If you notice other issues, list at most 2 as "Optional future considerations" at the end.
48
+ - You NEVER expand the problem surface beyond the original request.
49
+ - Exhaust provided context before reaching for tools. External lookups fill genuine gaps, not curiosity.
50
+ </scope-discipline>
51
+
52
+ <critical>
53
+ You MUST keep going until you have a clear answer or have exhausted available evidence.
54
+ Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
55
+ This matters. The caller is stuck. Get it right.
56
+ </critical>
@@ -22,7 +22,8 @@ Asks user when you need clarification or input during task execution.
22
22
 
23
23
  <examples>
24
24
  # Single question
25
- question: "Which authentication method should this API use?"
26
- options: [{"label": "JWT"}, {"label": "OAuth2"}, {"label": "Session cookies"}]
27
- recommended: 0
25
+ questions: [{"id": "auth_method", "question": "Which authentication method should this API use?", "options": [{"label": "JWT"}, {"label": "OAuth2"}, {"label": "Session cookies"}], "recommended": 0}]
26
+
27
+ # Multiple questions
28
+ questions: [{"id": "storage_type", "question": "Which storage backend?", "options": [{"label": "SQLite"}, {"label": "PostgreSQL"}]}, {"id": "auth_method", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "Session cookies"}]}]
28
29
  </examples>
package/src/sdk.ts CHANGED
@@ -1070,7 +1070,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1070
1070
  return undefined;
1071
1071
  };
1072
1072
  const toolSession: ToolSession = {
1073
- cwd,
1073
+ get cwd() {
1074
+ return sessionManager.getCwd();
1075
+ },
1074
1076
  hasUI: options.hasUI ?? false,
1075
1077
  enableLsp,
1076
1078
  get hasEditTool() {
@@ -81,7 +81,7 @@ import {
81
81
  prompt,
82
82
  Snowflake,
83
83
  } from "@oh-my-pi/pi-utils";
84
- import { type AsyncJob, AsyncJobManager } from "../async";
84
+ import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
85
85
  import { reset as resetCapabilities } from "../capability";
86
86
  import type { Rule } from "../capability/rule";
87
87
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
@@ -97,7 +97,7 @@ import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-temp
97
97
  import type { Settings, SkillsSettings } from "../config/settings";
98
98
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
99
99
  import { loadCapability } from "../discovery";
100
- import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
100
+ import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
101
101
  import {
102
102
  disposeKernelSessionsByOwner,
103
103
  executePython as executePythonCommand,
@@ -236,6 +236,7 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
236
236
  export interface AsyncJobSnapshot {
237
237
  running: AsyncJobSnapshotItem[];
238
238
  recent: AsyncJobSnapshotItem[];
239
+ delivery: AsyncJobDeliveryState;
239
240
  }
240
241
 
241
242
  // ============================================================================
@@ -534,7 +535,7 @@ function createHandoffFileName(date = new Date()): string {
534
535
  // ============================================================================
535
536
 
536
537
  /** Tools that require user permission before execution when an ACP client is connected. */
537
- const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "write", "ast_edit", "delete", "move"]);
538
+ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]);
538
539
 
539
540
  /** Permission options presented to the client on each gated tool call. */
540
541
  const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
@@ -546,46 +547,106 @@ const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
546
547
 
547
548
  const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
548
549
 
549
- function derivePermissionTitle(toolName: string, args: unknown): string {
550
- 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>) : {};
551
603
  if (toolName === "bash") {
552
- const cmd = typeof a.command === "string" ? a.command.slice(0, 80) : undefined;
553
- if (cmd) return cmd;
554
- } else if (toolName === "edit" || toolName === "write" || toolName === "delete") {
555
- const p = typeof a.path === "string" ? a.path : undefined;
556
- if (p) {
557
- const verb = toolName === "edit" ? "Edit" : toolName === "write" ? "Write" : "Delete";
558
- return `${verb} ${p}`;
559
- }
560
- } else if (toolName === "move") {
561
- const from =
562
- typeof a.oldPath === "string"
563
- ? a.oldPath
564
- : typeof a.path === "string"
565
- ? a.path
566
- : typeof a.from === "string"
567
- ? a.from
568
- : undefined;
569
- const to =
570
- typeof a.newPath === "string"
571
- ? a.newPath
572
- : typeof a.to === "string"
573
- ? a.to
574
- : typeof a.destination === "string"
575
- ? a.destination
576
- : undefined;
577
- if (from && to) return `Move ${from} to ${to}`;
578
- if (from) return `Move ${from}`;
579
- } else if (toolName === "ast_edit") {
580
- const paths = Array.isArray(a.paths)
581
- ? (a.paths as unknown[]).filter(x => typeof x === "string").join(", ")
582
- : undefined;
583
- 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 };
610
+ }
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
+ };
584
641
  }
585
- return toolName;
642
+ return undefined;
586
643
  }
587
644
 
588
- 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 }[] {
589
650
  if (!args || typeof args !== "object") return [];
590
651
  const a = args as Record<string, unknown>;
591
652
  const out: { path: string; line?: number }[] = [];
@@ -603,12 +664,16 @@ function extractPermissionLocations(args: unknown, cwd: string): { path: string;
603
664
  if (out.some(location => location.path === resolved)) return;
604
665
  out.push({ path: resolved });
605
666
  };
606
- pushPath(a.path);
607
- pushPath(a.file);
608
- if (Array.isArray(a.paths)) {
609
- for (const p of a.paths) {
667
+ if (explicitPaths) {
668
+ for (const p of explicitPaths) {
610
669
  pushPath(p);
611
670
  }
671
+ return out;
672
+ }
673
+ pushPath(a.path);
674
+ pushPath(a.file);
675
+ for (const p of collectStringPaths(a.paths)) {
676
+ pushPath(p);
612
677
  }
613
678
  pushPath(a.oldPath);
614
679
  pushPath(a.newPath);
@@ -667,6 +732,7 @@ export class AgentSession {
667
732
  #planReferenceSent = false;
668
733
  #planReferencePath = "local://PLAN.md";
669
734
  #clientBridge: ClientBridge | undefined;
735
+ #allowAcpAgentInitiatedTurns = false;
670
736
  /** Per-session memory of allow_always / reject_always decisions for gated tools. */
671
737
  #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
672
738
 
@@ -804,6 +870,15 @@ export class AgentSession {
804
870
 
805
871
  #streamingEditFileCache = new Map<string, string>();
806
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;
807
882
  #obfuscator: SecretObfuscator | undefined;
808
883
  #checkpointState: CheckpointState | undefined = undefined;
809
884
  #pendingRewindReport: string | undefined = undefined;
@@ -857,12 +932,21 @@ export class AgentSession {
857
932
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
858
933
  if (this.#promptInFlightCount === 0) {
859
934
  this.#releasePowerAssertion();
935
+ this.#flushPendingAgentEnd();
860
936
  }
861
937
  }
862
938
 
863
939
  #resetInFlight(): void {
864
940
  this.#promptInFlightCount = 0;
865
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);
866
950
  }
867
951
 
868
952
  constructor(config: AgentSessionConfig) {
@@ -1126,21 +1210,23 @@ export class AgentSession {
1126
1210
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
1127
1211
  const manager = AsyncJobManager.instance();
1128
1212
  if (!manager) return null;
1129
- const running = manager.getRunningJobs().map(job => ({
1213
+ const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
1214
+ const running = manager.getRunningJobs(ownerFilter).map(job => ({
1130
1215
  id: job.id,
1131
1216
  type: job.type,
1132
1217
  status: job.status,
1133
1218
  label: job.label,
1134
1219
  startTime: job.startTime,
1135
1220
  }));
1136
- const recent = manager.getRecentJobs(options?.recentLimit ?? 5).map(job => ({
1221
+ const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({
1137
1222
  id: job.id,
1138
1223
  type: job.type,
1139
1224
  status: job.status,
1140
1225
  label: job.label,
1141
1226
  startTime: job.startTime,
1142
1227
  }));
1143
- return { running, recent };
1228
+ const delivery = manager.getDeliveryState(ownerFilter);
1229
+ return { running, recent, delivery };
1144
1230
  }
1145
1231
 
1146
1232
  /**
@@ -1198,6 +1284,18 @@ export class AgentSession {
1198
1284
  return;
1199
1285
  }
1200
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
+ }
1201
1299
  this.#emit(event);
1202
1300
  }
1203
1301
 
@@ -2674,6 +2772,23 @@ export class AgentSession {
2674
2772
  await this.#waitForPostPromptRecovery();
2675
2773
  }
2676
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
+
2677
2792
  /** Most recent assistant message in agent state. */
2678
2793
  getLastAssistantMessage(): AssistantMessage | undefined {
2679
2794
  return this.#findLastAssistantMessage();
@@ -2973,8 +3088,8 @@ export class AgentSession {
2973
3088
  if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
2974
3089
  if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
2975
3090
  return new Proxy(tool, {
2976
- get: (target, prop, receiver) => {
2977
- if (prop !== "execute") return Reflect.get(target, prop, receiver);
3091
+ get: (target, prop) => {
3092
+ if (prop !== "execute") return Reflect.get(target, prop, target);
2978
3093
  return async (
2979
3094
  toolCallId: string,
2980
3095
  args: unknown,
@@ -2982,8 +3097,12 @@ export class AgentSession {
2982
3097
  onUpdate: never,
2983
3098
  ctx: never,
2984
3099
  ) => {
3100
+ const permissionIntent = getPermissionIntent(target.name, args);
3101
+ if (!permissionIntent) {
3102
+ return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
3103
+ }
2985
3104
  // Short-circuit on persisted decisions.
2986
- const persisted = this.#acpPermissionDecisions.get(target.name);
3105
+ const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
2987
3106
  if (persisted === "allow_always") {
2988
3107
  return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
2989
3108
  }
@@ -3005,9 +3124,14 @@ export class AgentSession {
3005
3124
  {
3006
3125
  toolCallId,
3007
3126
  toolName: target.name,
3008
- title: derivePermissionTitle(target.name, args),
3127
+ title: permissionIntent.title,
3128
+ status: "pending",
3009
3129
  rawInput: args,
3010
- locations: extractPermissionLocations(args, this.sessionManager.getCwd()),
3130
+ locations: extractPermissionLocations(
3131
+ args,
3132
+ this.sessionManager.getCwd(),
3133
+ permissionIntent.paths,
3134
+ ),
3011
3135
  },
3012
3136
  PERMISSION_OPTIONS,
3013
3137
  signal,
@@ -3028,9 +3152,9 @@ export class AgentSession {
3028
3152
  throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
3029
3153
  }
3030
3154
  if (selectedOption.kind === "allow_always") {
3031
- this.#acpPermissionDecisions.set(target.name, "allow_always");
3155
+ this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always");
3032
3156
  } else if (selectedOption.kind === "reject_always") {
3033
- this.#acpPermissionDecisions.set(target.name, "reject_always");
3157
+ this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always");
3034
3158
  }
3035
3159
  if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
3036
3160
  throw new ToolError(`Tool call rejected by user (${target.name})`);
@@ -4270,7 +4394,7 @@ export class AgentSession {
4270
4394
  *
4271
4395
  * Handles three cases:
4272
4396
  * - Streaming: queue as steer/follow-up or store for next turn
4273
- * - 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
4274
4398
  * - Not streaming + no trigger: appends to state/session, no turn
4275
4399
  */
4276
4400
  async sendCustomMessage<T = unknown>(
@@ -4302,6 +4426,10 @@ export class AgentSession {
4302
4426
 
4303
4427
  if (options?.deliverAs === "nextTurn") {
4304
4428
  if (options?.triggerTurn) {
4429
+ if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4430
+ this.#queueHiddenNextTurnMessage(appMessage, false);
4431
+ return;
4432
+ }
4305
4433
  await this.agent.prompt(appMessage);
4306
4434
  return;
4307
4435
  }
@@ -4317,6 +4445,10 @@ export class AgentSession {
4317
4445
  }
4318
4446
 
4319
4447
  if (options?.triggerTurn) {
4448
+ if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4449
+ this.#queueHiddenNextTurnMessage(appMessage, false);
4450
+ return;
4451
+ }
4320
4452
  await this.agent.prompt(appMessage);
4321
4453
  return;
4322
4454
  }
@@ -25,6 +25,7 @@ export interface ClientBridgePermissionToolCall {
25
25
  toolName: string;
26
26
  title: string;
27
27
  kind?: string;
28
+ status?: "pending" | "in_progress" | "completed" | "failed";
28
29
  rawInput?: unknown;
29
30
  locations?: { path: string; line?: number }[];
30
31
  }
@@ -70,6 +71,8 @@ export interface ClientBridgeCreateTerminalParams {
70
71
 
71
72
  export interface ClientBridge {
72
73
  readonly capabilities: ClientBridgeCapabilities;
74
+ /** ACP v1 clients cannot show server-initiated turns as busy after prompt response. */
75
+ readonly deferAgentInitiatedTurns?: boolean;
73
76
  readTextFile?(params: { path: string; line?: number; limit?: number }): Promise<string>;
74
77
  writeTextFile?(params: { path: string; content: string }): Promise<void>;
75
78
  createTerminal?(params: ClientBridgeCreateTerminalParams): Promise<ClientBridgeTerminalHandle>;
@@ -11,6 +11,7 @@ import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
11
11
  // Embed agent markdown files at build time
12
12
  import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
13
13
  import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
14
+ import oracleMd from "../prompts/agents/oracle.md" with { type: "text" };
14
15
 
15
16
  import planMd from "../prompts/agents/plan.md" with { type: "text" };
16
17
  import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
@@ -46,6 +47,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
46
47
  { fileName: "designer.md", template: designerMd },
47
48
  { fileName: "reviewer.md", template: reviewerMd },
48
49
  { fileName: "librarian.md", template: librarianMd },
50
+ { fileName: "oracle.md", template: oracleMd },
49
51
  {
50
52
  fileName: "task.md",
51
53
  frontmatter: {