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

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 (141) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/cli/auth-broker-cli.d.ts +25 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +18 -0
  4. package/dist/types/cli/grievances-cli.d.ts +12 -0
  5. package/dist/types/commands/auth-broker.d.ts +54 -0
  6. package/dist/types/commands/auth-gateway.d.ts +32 -0
  7. package/dist/types/commands/grievances.d.ts +1 -1
  8. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -1
  9. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -1
  10. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -1
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/models-config-schema.d.ts +1 -0
  13. package/dist/types/config/settings-schema.d.ts +46 -0
  14. package/dist/types/discovery/agents.d.ts +12 -1
  15. package/dist/types/edit/renderer.d.ts +3 -0
  16. package/dist/types/eval/index.d.ts +0 -2
  17. package/dist/types/goals/tools/goal-tool.d.ts +10 -2
  18. package/dist/types/index.d.ts +0 -1
  19. package/dist/types/internal-urls/index.d.ts +1 -1
  20. package/dist/types/internal-urls/{pi-protocol.d.ts → omp-protocol.d.ts} +3 -3
  21. package/dist/types/internal-urls/types.d.ts +1 -1
  22. package/dist/types/modes/acp/acp-agent.d.ts +1 -0
  23. package/dist/types/modes/emoji-autocomplete.d.ts +16 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -1
  25. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  26. package/dist/types/plan-mode/approved-plan.d.ts +4 -0
  27. package/dist/types/sdk.d.ts +10 -3
  28. package/dist/types/session/agent-session.d.ts +1 -1
  29. package/dist/types/session/auth-broker-config.d.ts +13 -0
  30. package/dist/types/session/auth-storage.d.ts +1 -1
  31. package/dist/types/tools/eval.d.ts +41 -7
  32. package/dist/types/tools/irc.d.ts +8 -2
  33. package/dist/types/tools/report-tool-issue.d.ts +118 -1
  34. package/dist/types/tools/resolve.d.ts +8 -2
  35. package/examples/custom-tools/README.md +3 -12
  36. package/examples/extensions/README.md +2 -15
  37. package/examples/extensions/api-demo.ts +1 -7
  38. package/package.json +7 -7
  39. package/src/autoresearch/tools/init-experiment.ts +11 -33
  40. package/src/autoresearch/tools/log-experiment.ts +10 -24
  41. package/src/autoresearch/tools/run-experiment.ts +1 -1
  42. package/src/autoresearch/tools/update-notes.ts +2 -9
  43. package/src/cli/auth-broker-cli.ts +746 -0
  44. package/src/cli/auth-gateway-cli.ts +342 -0
  45. package/src/cli/grievances-cli.ts +109 -16
  46. package/src/cli.ts +4 -2
  47. package/src/commands/auth-broker.ts +96 -0
  48. package/src/commands/auth-gateway.ts +61 -0
  49. package/src/commands/grievances.ts +13 -8
  50. package/src/commands/launch.ts +1 -1
  51. package/src/commit/agentic/agent.ts +2 -0
  52. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  53. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  54. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  55. package/src/commit/agentic/tools/git-overview.ts +2 -2
  56. package/src/commit/agentic/tools/propose-changelog.ts +1 -3
  57. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  58. package/src/commit/agentic/tools/schemas.ts +1 -9
  59. package/src/config/model-equivalence.ts +279 -174
  60. package/src/config/model-registry.ts +37 -6
  61. package/src/config/model-resolver.ts +13 -8
  62. package/src/config/models-config-schema.ts +8 -0
  63. package/src/config/settings-schema.ts +52 -0
  64. package/src/cursor.ts +1 -1
  65. package/src/debug/log-formatting.ts +1 -1
  66. package/src/debug/log-viewer.ts +1 -1
  67. package/src/debug/profiler.ts +4 -0
  68. package/src/debug/raw-sse-buffer.ts +100 -59
  69. package/src/debug/raw-sse.ts +1 -1
  70. package/src/discovery/agents.ts +15 -4
  71. package/src/edit/modes/apply-patch.ts +1 -5
  72. package/src/edit/modes/patch.ts +5 -5
  73. package/src/edit/modes/replace.ts +5 -5
  74. package/src/edit/renderer.ts +2 -1
  75. package/src/edit/streaming.ts +1 -1
  76. package/src/eval/index.ts +0 -2
  77. package/src/eval/js/shared/runtime.ts +25 -0
  78. package/src/eval/py/kernel.ts +1 -1
  79. package/src/exa/researcher.ts +4 -4
  80. package/src/exa/search.ts +10 -22
  81. package/src/exa/websets.ts +33 -33
  82. package/src/goals/tools/goal-tool.ts +3 -3
  83. package/src/index.ts +0 -3
  84. package/src/internal-urls/docs-index.generated.ts +21 -18
  85. package/src/internal-urls/index.ts +1 -1
  86. package/src/internal-urls/{pi-protocol.ts → omp-protocol.ts} +10 -10
  87. package/src/internal-urls/router.ts +3 -3
  88. package/src/internal-urls/types.ts +1 -1
  89. package/src/lsp/types.ts +8 -11
  90. package/src/main.ts +3 -0
  91. package/src/mcp/tool-bridge.ts +3 -3
  92. package/src/modes/acp/acp-agent.ts +88 -25
  93. package/src/modes/components/bash-execution.ts +1 -1
  94. package/src/modes/components/diff.ts +1 -2
  95. package/src/modes/components/eval-execution.ts +1 -1
  96. package/src/modes/components/oauth-selector.ts +38 -2
  97. package/src/modes/components/tool-execution.ts +1 -2
  98. package/src/modes/controllers/command-controller.ts +95 -34
  99. package/src/modes/controllers/input-controller.ts +4 -3
  100. package/src/modes/data/emojis.json +1 -0
  101. package/src/modes/emoji-autocomplete.ts +285 -0
  102. package/src/modes/interactive-mode.ts +92 -19
  103. package/src/modes/print-mode.ts +3 -3
  104. package/src/modes/prompt-action-autocomplete.ts +14 -0
  105. package/src/plan-mode/approved-plan.ts +9 -0
  106. package/src/prompts/system/system-prompt.md +1 -1
  107. package/src/prompts/system/ttsr-tool-reminder.md +5 -0
  108. package/src/prompts/tools/eval.md +25 -26
  109. package/src/prompts/tools/read.md +1 -1
  110. package/src/prompts/tools/resolve.md +1 -1
  111. package/src/prompts/tools/search.md +1 -1
  112. package/src/prompts/tools/web-search.md +1 -1
  113. package/src/sdk.ts +78 -7
  114. package/src/session/agent-session.ts +176 -77
  115. package/src/session/agent-storage.ts +7 -2
  116. package/src/session/auth-broker-config.ts +102 -0
  117. package/src/session/auth-storage.ts +7 -1
  118. package/src/session/streaming-output.ts +1 -1
  119. package/src/task/types.ts +10 -35
  120. package/src/tools/bash-interactive.ts +4 -1
  121. package/src/tools/bash-pty-selection.ts +2 -2
  122. package/src/tools/browser.ts +12 -20
  123. package/src/tools/eval.ts +77 -100
  124. package/src/tools/gh.ts +21 -45
  125. package/src/tools/hindsight-recall.ts +1 -1
  126. package/src/tools/hindsight-reflect.ts +2 -2
  127. package/src/tools/hindsight-retain.ts +3 -7
  128. package/src/tools/index.ts +8 -1
  129. package/src/tools/inspect-image.ts +4 -1
  130. package/src/tools/irc.ts +4 -12
  131. package/src/tools/job.ts +3 -11
  132. package/src/tools/report-tool-issue.ts +462 -17
  133. package/src/tools/resolve.ts +2 -7
  134. package/src/tools/todo-write.ts +8 -15
  135. package/src/utils/title-generator.ts +3 -0
  136. package/src/web/search/index.ts +6 -6
  137. package/dist/types/eval/parse.d.ts +0 -28
  138. package/dist/types/eval/sniff.d.ts +0 -11
  139. package/src/eval/eval.lark +0 -36
  140. package/src/eval/parse.ts +0 -407
  141. package/src/eval/sniff.ts +0 -28
@@ -15,8 +15,8 @@ export * from "./json-query";
15
15
  export * from "./local-protocol";
16
16
  export * from "./mcp-protocol";
17
17
  export * from "./memory-protocol";
18
+ export * from "./omp-protocol";
18
19
  export * from "./parse";
19
- export * from "./pi-protocol";
20
20
  export * from "./router";
21
21
  export * from "./rule-protocol";
22
22
  export * from "./skill-protocol";
@@ -1,23 +1,23 @@
1
1
  /**
2
- * Protocol handler for pi:// URLs.
2
+ * Protocol handler for omp:// URLs.
3
3
  *
4
4
  * Serves statically embedded documentation files bundled at build time.
5
5
  *
6
6
  * URL forms:
7
- * - pi:// - Lists all available documentation files
8
- * - pi://<file>.md - Reads a specific documentation file
7
+ * - omp:// - Lists all available documentation files
8
+ * - omp://<file>.md - Reads a specific documentation file
9
9
  */
10
10
  import * as path from "node:path";
11
11
  import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
12
12
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
13
13
 
14
14
  /**
15
- * Handler for pi:// URLs.
15
+ * Handler for omp:// URLs.
16
16
  *
17
17
  * Resolves documentation file names to their content, or lists available docs.
18
18
  */
19
- export class PiProtocolHandler implements ProtocolHandler {
20
- readonly scheme = "pi";
19
+ export class OmpProtocolHandler implements ProtocolHandler {
20
+ readonly scheme = "omp";
21
21
  readonly immutable = true;
22
22
 
23
23
  async resolve(url: InternalUrl): Promise<InternalResource> {
@@ -38,7 +38,7 @@ export class PiProtocolHandler implements ProtocolHandler {
38
38
  throw new Error("No documentation files found");
39
39
  }
40
40
 
41
- const listing = EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](pi://${f})`).join("\n");
41
+ const listing = EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](omp://${f})`).join("\n");
42
42
  const content = `# Documentation\n\n${EMBEDDED_DOC_FILENAMES.length} files available:\n\n${listing}\n`;
43
43
 
44
44
  return {
@@ -52,12 +52,12 @@ export class PiProtocolHandler implements ProtocolHandler {
52
52
  async #readDoc(filename: string, url: InternalUrl): Promise<InternalResource> {
53
53
  // Validate: no traversal, no absolute paths
54
54
  if (path.isAbsolute(filename)) {
55
- throw new Error("Absolute paths are not allowed in pi:// URLs");
55
+ throw new Error("Absolute paths are not allowed in omp:// URLs");
56
56
  }
57
57
 
58
58
  const normalized = path.posix.normalize(filename.replaceAll("\\", "/"));
59
59
  if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
60
- throw new Error("Path traversal (..) is not allowed in pi:// URLs");
60
+ throw new Error("Path traversal (..) is not allowed in omp:// URLs");
61
61
  }
62
62
 
63
63
  const content = EMBEDDED_DOCS[normalized];
@@ -69,7 +69,7 @@ export class PiProtocolHandler implements ProtocolHandler {
69
69
  const suffix =
70
70
  suggestions.length > 0
71
71
  ? `\nDid you mean: ${suggestions.join(", ")}`
72
- : "\nUse pi:// to list available files.";
72
+ : "\nUse omp:// to list available files.";
73
73
  throw new Error(`Documentation file not found: ${filename}${suffix}`);
74
74
  }
75
75
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://).
2
+ * Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, omp://, local://).
3
3
  *
4
4
  * One process-global router with one handler per scheme. Access via
5
5
  * `InternalUrlRouter.instance()`. Handlers are stateless; per-session and
@@ -11,8 +11,8 @@ import { IssueProtocolHandler, PrProtocolHandler } from "./issue-pr-protocol";
11
11
  import { LocalProtocolHandler } from "./local-protocol";
12
12
  import { McpProtocolHandler } from "./mcp-protocol";
13
13
  import { MemoryProtocolHandler } from "./memory-protocol";
14
+ import { OmpProtocolHandler } from "./omp-protocol";
14
15
  import { parseInternalUrl } from "./parse";
15
- import { PiProtocolHandler } from "./pi-protocol";
16
16
  import { RuleProtocolHandler } from "./rule-protocol";
17
17
  import { SkillProtocolHandler } from "./skill-protocol";
18
18
  import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext } from "./types";
@@ -23,7 +23,7 @@ export class InternalUrlRouter {
23
23
  #handlers = new Map<string, ProtocolHandler>();
24
24
 
25
25
  constructor() {
26
- this.register(new PiProtocolHandler());
26
+ this.register(new OmpProtocolHandler());
27
27
  this.register(new AgentProtocolHandler());
28
28
  this.register(new ArtifactProtocolHandler());
29
29
  this.register(new MemoryProtocolHandler());
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Types for the internal URL routing system.
3
3
  *
4
- * Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://) are resolved by tools like read,
4
+ * Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, omp://, local://) are resolved by tools like read,
5
5
  * providing access to agent outputs and server resources without exposing filesystem paths.
6
6
  */
7
7
 
package/src/lsp/types.ts CHANGED
@@ -22,17 +22,14 @@ export const lspSchema = z.object({
22
22
  "capabilities",
23
23
  "request",
24
24
  ]),
25
- file: z.string().describe("File path or source path for rename_file").optional(),
26
- line: z.number().describe("Line number (1-indexed)").optional(),
27
- symbol: z.string().describe("Symbol/substring to locate on the line").optional(),
28
- query: z.string().describe("Search query, code-action selector, or LSP method name for action=request").optional(),
29
- new_name: z.string().describe("New name for rename, or destination path for rename_file").optional(),
30
- apply: z.boolean().describe("Apply edits (default: true for rename/rename_file)").optional(),
31
- timeout: z.number().describe("Request timeout in seconds").optional(),
32
- payload: z
33
- .string()
34
- .describe("JSON-encoded params for action=request. When omitted, params are auto-built from file/line/symbol.")
35
- .optional(),
25
+ file: z.string().describe("file path or source path for rename_file").optional(),
26
+ line: z.number().describe("line number (1-indexed)").optional(),
27
+ symbol: z.string().describe("symbol substring on the line").optional(),
28
+ query: z.string().describe("search query or code-action selector").optional(),
29
+ new_name: z.string().describe("new symbol name or destination path").optional(),
30
+ apply: z.boolean().describe("apply edits").optional(),
31
+ timeout: z.number().describe("request timeout in seconds").optional(),
32
+ payload: z.string().describe("json-encoded request params").optional(),
36
33
  });
37
34
 
38
35
  export type LspParams = z.infer<typeof lspSchema>;
package/src/main.ts CHANGED
@@ -940,6 +940,9 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
940
940
  initialMessage,
941
941
  initialImages,
942
942
  });
943
+ if ($env.PI_TIMING) {
944
+ logger.printTimings();
945
+ }
943
946
  await session.dispose();
944
947
  stopThemeWatcher();
945
948
  await postmortem.quit(0);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
7
  import type { TSchema } from "@oh-my-pi/pi-ai";
8
- import { sanitizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
8
+ import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
9
9
  import { untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import type { SourceMeta } from "../capability/types";
11
11
  import type {
@@ -231,7 +231,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
231
231
  this.name = createMCPToolName(connection.name, tool.name);
232
232
  this.label = `${connection.name}/${tool.name}`;
233
233
  this.description = tool.description ?? `MCP tool from ${connection.name}`;
234
- this.parameters = sanitizeSchemaForMCP(tool.inputSchema) as TSchema;
234
+ this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
235
235
  this.mcpToolName = tool.name;
236
236
  this.mcpServerName = connection.name;
237
237
  }
@@ -324,7 +324,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
324
324
  this.name = createMCPToolName(serverName, tool.name);
325
325
  this.label = `${serverName}/${tool.name}`;
326
326
  this.description = tool.description ?? `MCP tool from ${serverName}`;
327
- this.parameters = sanitizeSchemaForMCP(tool.inputSchema) as TSchema;
327
+ this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
328
328
  this.mcpToolName = tool.name;
329
329
  this.mcpServerName = serverName;
330
330
  this.#fallbackProvider = source?.provider;
@@ -86,6 +86,7 @@ const SESSION_PAGE_SIZE = 50;
86
86
  * wait past this guard without hard-coding the literal.
87
87
  */
88
88
  export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
89
+ const ACP_CANCEL_CLEANUP_TIMEOUT_MS = 5_000;
89
90
 
90
91
  type AgentImageContent = {
91
92
  type: "image";
@@ -102,6 +103,13 @@ type PromptTurnState = {
102
103
  userMessageId: string;
103
104
  cancelRequested: boolean;
104
105
  settled: boolean;
106
+ /**
107
+ * `abort()` is in-flight (or its bounded-timeout race). `undefined` while the turn is
108
+ * running normally and after cleanup completes. The turn occupies `record.promptTurn`
109
+ * for as long as either `!settled` or `cleanup` is set — that combined window is the
110
+ * "turn in flight" predicate (`isPromptTurnInFlight`) every consumer gates on.
111
+ */
112
+ cleanup: Promise<void> | undefined;
105
113
  usageBaseline: UsageStatistics;
106
114
  unsubscribe: (() => void) | undefined;
107
115
  resolve: (value: PromptResponse) => void;
@@ -109,6 +117,16 @@ type PromptTurnState = {
109
117
  promise: Promise<PromptResponse>;
110
118
  };
111
119
 
120
+ /**
121
+ * A turn is "in flight" from the moment `prompt()` reserves the slot until `settled` is
122
+ * true AND any cancel cleanup has completed. Fork/queue/event gating all depend on this
123
+ * combined window — a settled-but-still-aborting turn is not safe to fork from, queue
124
+ * onto, or forward late events for.
125
+ */
126
+ function isPromptTurnInFlight(turn: PromptTurnState | undefined): turn is PromptTurnState {
127
+ return turn !== undefined && (!turn.settled || turn.cleanup !== undefined);
128
+ }
129
+
112
130
  type ManagedSessionRecord = {
113
131
  session: AgentSession;
114
132
  mcpManager: MCPManager | undefined;
@@ -337,6 +355,7 @@ export class AcpAgent implements Agent {
337
355
  #disposePromise: Promise<void> | undefined;
338
356
  #cleanupRegistered = false;
339
357
  #clientCapabilities: ClientCapabilities | undefined;
358
+ #cancelCleanupTimeoutMs = ACP_CANCEL_CLEANUP_TIMEOUT_MS;
340
359
 
341
360
  constructor(connection: AgentSideConnection, initialSession: AgentSession, createSession: CreateAcpSession) {
342
361
  this.#connection = connection;
@@ -344,6 +363,10 @@ export class AcpAgent implements Agent {
344
363
  this.#createSession = createSession;
345
364
  }
346
365
 
366
+ setCancelCleanupTimeoutForTesting(timeoutMs: number): void {
367
+ this.#cancelCleanupTimeoutMs = Math.max(1, timeoutMs);
368
+ }
369
+
347
370
  async initialize(params: InitializeRequest): Promise<InitializeResponse> {
348
371
  this.#registerConnectionCleanup();
349
372
  this.#clientCapabilities = params.clientCapabilities;
@@ -546,9 +569,15 @@ export class AcpAgent implements Agent {
546
569
  throw new Error("ACP prompt already in progress for this session");
547
570
  }
548
571
  return await this.#queuePrompt(record, async () => {
549
- const queuedTurn = record.promptTurn;
550
- if (queuedTurn && !queuedTurn.settled) {
551
- await queuedTurn.promise.catch(() => undefined);
572
+ const previousTurn = record.promptTurn;
573
+ if (previousTurn) {
574
+ // Wait for any prompt that's still settling or whose cancel cleanup is
575
+ // still in flight. We deliberately swallow the prompt rejection (the
576
+ // owning caller already received it) but let cleanup rejections
577
+ // propagate — a timed-out cancel must fail this queued prompt instead
578
+ // of letting it run on a session that is about to be closed.
579
+ await previousTurn.promise.catch(() => undefined);
580
+ await previousTurn.cleanup;
552
581
  }
553
582
 
554
583
  const converted = this.#convertPromptBlocks(params.prompt);
@@ -557,6 +586,7 @@ export class AcpAgent implements Agent {
557
586
  userMessageId: params.messageId ?? crypto.randomUUID(),
558
587
  cancelRequested: false,
559
588
  settled: false,
589
+ cleanup: undefined,
560
590
  usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
561
591
  unsubscribe: undefined,
562
592
  resolve: pendingPrompt.resolve,
@@ -676,16 +706,53 @@ export class AcpAgent implements Agent {
676
706
  if (!promptTurn || promptTurn.settled) {
677
707
  return;
678
708
  }
679
- promptTurn.cancelRequested = true;
709
+ const cleanup = this.#beginCancelCleanup(record, promptTurn);
680
710
  try {
681
- await record.session.abort();
682
- this.#finishPrompt(record, {
683
- stopReason: "cancelled",
684
- usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
685
- userMessageId: promptTurn.userMessageId,
686
- });
711
+ await cleanup;
687
712
  } catch (error: unknown) {
688
- this.#finishPrompt(record, undefined, error);
713
+ logger.warn("ACP cancel cleanup timed out; closing session", { sessionId: record.session.sessionId, error });
714
+ await this.#closeManagedSession(record.session.sessionId, record);
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Transition a still-running turn into cancellation: mark intent, drop the live-event
720
+ * subscription, start the bounded `abort()` race, and resolve the ACP prompt response
721
+ * with `stopReason: "cancelled"` so the client sees acceptance immediately. The
722
+ * returned promise is the cleanup barrier — it resolves when `abort()` completes and
723
+ * rejects when the timeout fires. Idempotent: a second call returns the same barrier.
724
+ */
725
+ #beginCancelCleanup(record: ManagedSessionRecord, promptTurn: PromptTurnState): Promise<void> {
726
+ if (promptTurn.cleanup) {
727
+ return promptTurn.cleanup;
728
+ }
729
+ promptTurn.cancelRequested = true;
730
+ promptTurn.unsubscribe?.();
731
+ const cleanup = this.#runCancelCleanup(record, promptTurn);
732
+ promptTurn.cleanup = cleanup;
733
+ this.#finishPrompt(record, {
734
+ stopReason: "cancelled",
735
+ usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
736
+ userMessageId: promptTurn.userMessageId,
737
+ });
738
+ return cleanup;
739
+ }
740
+
741
+ async #runCancelCleanup(record: ManagedSessionRecord, promptTurn: PromptTurnState): Promise<void> {
742
+ let timer: NodeJS.Timeout | undefined;
743
+ const timeout = new Promise<never>((_, reject) => {
744
+ timer = setTimeout(() => reject(new Error("ACP cancel cleanup timed out")), this.#cancelCleanupTimeoutMs);
745
+ });
746
+ try {
747
+ await Promise.race([record.session.abort(), timeout]);
748
+ } finally {
749
+ if (timer) clearTimeout(timer);
750
+ // Order matters: clear `cleanup` before evicting the slot so the slot-eviction
751
+ // branch matches what `#finishPrompt` saw if it ran first.
752
+ promptTurn.cleanup = undefined;
753
+ if (promptTurn.settled && record.promptTurn === promptTurn) {
754
+ record.promptTurn = undefined;
755
+ }
689
756
  }
690
757
  }
691
758
 
@@ -929,8 +996,7 @@ export class AcpAgent implements Agent {
929
996
  async #resolveForkSourceSessionPath(sessionId: string): Promise<string> {
930
997
  const loaded = this.#sessions.get(sessionId);
931
998
  if (loaded) {
932
- const promptTurn = loaded.promptTurn;
933
- if (promptTurn && !promptTurn.settled) {
999
+ if (isPromptTurnInFlight(loaded.promptTurn)) {
934
1000
  throw new Error(`ACP session fork is unavailable while a prompt is in progress: ${sessionId}`);
935
1001
  }
936
1002
  await loaded.session.sessionManager.flush();
@@ -950,7 +1016,7 @@ export class AcpAgent implements Agent {
950
1016
 
951
1017
  async #handlePromptEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
952
1018
  const promptTurn = record.promptTurn;
953
- if (!promptTurn || promptTurn.settled) {
1019
+ if (!promptTurn || promptTurn.settled || promptTurn.cancelRequested) {
954
1020
  return;
955
1021
  }
956
1022
 
@@ -1019,7 +1085,11 @@ export class AcpAgent implements Agent {
1019
1085
  }
1020
1086
  promptTurn.settled = true;
1021
1087
  promptTurn.unsubscribe?.();
1022
- record.promptTurn = undefined;
1088
+ // Keep the slot occupied until cancel cleanup finishes — `#runCancelCleanup`
1089
+ // evicts the slot in its finally block once both flags say it's safe.
1090
+ if (!promptTurn.cleanup && record.promptTurn === promptTurn) {
1091
+ record.promptTurn = undefined;
1092
+ }
1023
1093
  if (error !== undefined) {
1024
1094
  promptTurn.reject(error);
1025
1095
  return;
@@ -1887,22 +1957,15 @@ export class AcpAgent implements Agent {
1887
1957
 
1888
1958
  async #cancelPromptForClose(record: ManagedSessionRecord): Promise<void> {
1889
1959
  const promptTurn = record.promptTurn;
1890
- if (!promptTurn || promptTurn.settled) {
1960
+ if (!isPromptTurnInFlight(promptTurn)) {
1891
1961
  return;
1892
1962
  }
1893
-
1894
- promptTurn.cancelRequested = true;
1895
- promptTurn.unsubscribe?.();
1963
+ const cleanup = promptTurn.cleanup ?? this.#beginCancelCleanup(record, promptTurn);
1896
1964
  try {
1897
- await record.session.abort();
1965
+ await cleanup;
1898
1966
  } catch (error) {
1899
1967
  logger.warn("Failed to abort ACP prompt during session close", { error });
1900
1968
  }
1901
- this.#finishPrompt(record, {
1902
- stopReason: "cancelled",
1903
- usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
1904
- userMessageId: promptTurn.userMessageId,
1905
- });
1906
1969
  }
1907
1970
 
1908
1971
  async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
@@ -2,7 +2,6 @@
2
2
  * Component for displaying bash command execution with streaming output.
3
3
  */
4
4
 
5
- import { sanitizeText } from "@oh-my-pi/pi-natives";
6
5
  import {
7
6
  Container,
8
7
  Ellipsis,
@@ -14,6 +13,7 @@ import {
14
13
  truncateToWidth,
15
14
  visibleWidth,
16
15
  } from "@oh-my-pi/pi-tui";
16
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
17
17
  import { theme } from "../../modes/theme/theme";
18
18
  import type { TruncationMeta } from "../../tools/output-meta";
19
19
  import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
@@ -1,5 +1,4 @@
1
- import { sanitizeText } from "@oh-my-pi/pi-natives";
2
- import { getIndentation } from "@oh-my-pi/pi-utils";
1
+ import { getIndentation, sanitizeText } from "@oh-my-pi/pi-utils";
3
2
  import * as Diff from "diff";
4
3
  import { getLanguageFromPath, highlightCode, theme } from "../../modes/theme/theme";
5
4
  import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../../tools/render-utils";
@@ -3,8 +3,8 @@
3
3
  * Shares the same kernel session as the agent's eval tool.
4
4
  */
5
5
 
6
- import { sanitizeText } from "@oh-my-pi/pi-natives";
7
6
  import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
7
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
8
8
  import { highlightCode, theme } from "../../modes/theme/theme";
9
9
  import type { TruncationMeta } from "../../tools/output-meta";
10
10
  import {
@@ -5,6 +5,8 @@ import { theme } from "../../modes/theme/theme";
5
5
  import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
6
6
  import type { AuthStorage } from "../../session/auth-storage";
7
7
  import { DynamicBorder } from "./dynamic-border";
8
+
9
+ const OAUTH_SELECTOR_MAX_VISIBLE = 10;
8
10
  /**
9
11
  * Component that renders an OAuth provider selector.
10
12
  */
@@ -144,7 +146,16 @@ export class OAuthSelectorComponent extends Container {
144
146
  }
145
147
  #updateList(): void {
146
148
  this.#listContainer.clear();
147
- for (let i = 0; i < this.#allProviders.length; i++) {
149
+
150
+ const total = this.#allProviders.length;
151
+ const maxVisible = OAUTH_SELECTOR_MAX_VISIBLE;
152
+ const startIndex =
153
+ total <= maxVisible
154
+ ? 0
155
+ : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
156
+ const endIndex = Math.min(startIndex + maxVisible, total);
157
+
158
+ for (let i = startIndex; i < endIndex; i++) {
148
159
  const provider = this.#allProviders[i];
149
160
  if (!provider) continue;
150
161
  const isSelected = i === this.#selectedIndex;
@@ -163,8 +174,14 @@ export class OAuthSelectorComponent extends Container {
163
174
  this.#listContainer.addChild(new TruncatedText(line, 0, 0));
164
175
  }
165
176
 
177
+ // Scroll indicator when list is windowed
178
+ if (startIndex > 0 || endIndex < total) {
179
+ const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${total})`);
180
+ this.#listContainer.addChild(new TruncatedText(scrollInfo, 0, 0));
181
+ }
182
+
166
183
  // Show "no providers" if empty
167
- if (this.#allProviders.length === 0) {
184
+ if (total === 0) {
168
185
  const message =
169
186
  this.#mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
170
187
  this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
@@ -191,6 +208,25 @@ export class OAuthSelectorComponent extends Container {
191
208
  this.#statusMessage = undefined;
192
209
  this.#updateList();
193
210
  }
211
+ // Page up - jump up by one visible page
212
+ else if (matchesKey(keyData, "pageUp")) {
213
+ if (this.#allProviders.length > 0) {
214
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - OAUTH_SELECTOR_MAX_VISIBLE);
215
+ }
216
+ this.#statusMessage = undefined;
217
+ this.#updateList();
218
+ }
219
+ // Page down - jump down by one visible page
220
+ else if (matchesKey(keyData, "pageDown")) {
221
+ if (this.#allProviders.length > 0) {
222
+ this.#selectedIndex = Math.min(
223
+ this.#allProviders.length - 1,
224
+ this.#selectedIndex + OAUTH_SELECTOR_MAX_VISIBLE,
225
+ );
226
+ }
227
+ this.#statusMessage = undefined;
228
+ this.#updateList();
229
+ }
194
230
  // Enter
195
231
  else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
196
232
  const selectedProvider = this.#allProviders[this.#selectedIndex];
@@ -1,5 +1,4 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
- import { sanitizeText } from "@oh-my-pi/pi-natives";
3
2
  import {
4
3
  Box,
5
4
  type Component,
@@ -13,7 +12,7 @@ import {
13
12
  Text,
14
13
  type TUI,
15
14
  } from "@oh-my-pi/pi-tui";
16
- import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
15
+ import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
16
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
18
17
  import type { Theme } from "../../modes/theme/theme";
19
18
  import { theme } from "../../modes/theme/theme";