@oh-my-pi/pi-coding-agent 15.4.3 → 15.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +81 -5
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/auth-broker-cli.d.ts +1 -1
  4. package/dist/types/commands/launch.d.ts +8 -0
  5. package/dist/types/config/settings-schema.d.ts +42 -1
  6. package/dist/types/edit/index.d.ts +2 -0
  7. package/dist/types/extensibility/custom-tools/types.d.ts +8 -2
  8. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  9. package/dist/types/hashline/executor.d.ts +6 -3
  10. package/dist/types/lsp/index.d.ts +9 -1
  11. package/dist/types/mcp/client.d.ts +2 -1
  12. package/dist/types/mcp/oauth-discovery.d.ts +4 -3
  13. package/dist/types/mcp/timeout.d.ts +9 -0
  14. package/dist/types/mcp/types.d.ts +1 -1
  15. package/dist/types/sdk.d.ts +2 -0
  16. package/dist/types/session/streaming-output.d.ts +1 -1
  17. package/dist/types/task/index.d.ts +2 -0
  18. package/dist/types/task/types.d.ts +4 -0
  19. package/dist/types/tools/approval.d.ts +46 -0
  20. package/dist/types/tools/ask.d.ts +1 -0
  21. package/dist/types/tools/ast-edit.d.ts +2 -0
  22. package/dist/types/tools/ast-grep.d.ts +1 -0
  23. package/dist/types/tools/bash.d.ts +11 -1
  24. package/dist/types/tools/browser.d.ts +2 -0
  25. package/dist/types/tools/calculator.d.ts +1 -0
  26. package/dist/types/tools/checkpoint.d.ts +2 -0
  27. package/dist/types/tools/debug.d.ts +9 -1
  28. package/dist/types/tools/eval.d.ts +2 -0
  29. package/dist/types/tools/find.d.ts +10 -0
  30. package/dist/types/tools/gh.d.ts +2 -1
  31. package/dist/types/tools/hindsight-recall.d.ts +1 -0
  32. package/dist/types/tools/hindsight-reflect.d.ts +1 -0
  33. package/dist/types/tools/hindsight-retain.d.ts +1 -0
  34. package/dist/types/tools/inspect-image.d.ts +1 -0
  35. package/dist/types/tools/irc.d.ts +1 -0
  36. package/dist/types/tools/job.d.ts +1 -0
  37. package/dist/types/tools/read.d.ts +1 -0
  38. package/dist/types/tools/recipe/index.d.ts +1 -0
  39. package/dist/types/tools/render-mermaid.d.ts +1 -0
  40. package/dist/types/tools/resolve.d.ts +1 -0
  41. package/dist/types/tools/search-tool-bm25.d.ts +1 -0
  42. package/dist/types/tools/search.d.ts +1 -0
  43. package/dist/types/tools/ssh.d.ts +2 -0
  44. package/dist/types/tools/todo-write.d.ts +1 -0
  45. package/dist/types/tools/write.d.ts +2 -0
  46. package/dist/types/tools/yield.d.ts +1 -0
  47. package/dist/types/web/search/index.d.ts +1 -0
  48. package/package.json +7 -7
  49. package/src/cli/args.ts +14 -0
  50. package/src/cli/auth-broker-cli.ts +171 -22
  51. package/src/commands/auth-broker.ts +3 -0
  52. package/src/commands/launch.ts +16 -0
  53. package/src/config/mcp-schema.json +2 -2
  54. package/src/config/model-registry.ts +19 -4
  55. package/src/config/prompt-templates.ts +0 -125
  56. package/src/config/settings-schema.ts +59 -1
  57. package/src/config/settings.ts +2 -1
  58. package/src/dap/session.ts +35 -2
  59. package/src/discovery/builtin.ts +2 -2
  60. package/src/discovery/mcp-json.ts +1 -1
  61. package/src/edit/index.ts +26 -0
  62. package/src/edit/modes/patch.ts +1 -1
  63. package/src/edit/streaming.ts +12 -2
  64. package/src/exec/bash-executor.ts +6 -2
  65. package/src/extensibility/custom-commands/bundled/review/index.ts +18 -14
  66. package/src/extensibility/custom-tools/types.ts +16 -2
  67. package/src/extensibility/extensions/wrapper.ts +36 -1
  68. package/src/extensibility/hooks/types.ts +8 -1
  69. package/src/hashline/apply.ts +47 -2
  70. package/src/hashline/executor.ts +46 -24
  71. package/src/internal-urls/docs-index.generated.ts +8 -7
  72. package/src/lsp/edits.ts +82 -29
  73. package/src/lsp/index.ts +38 -1
  74. package/src/lsp/utils.ts +1 -1
  75. package/src/main.ts +6 -0
  76. package/src/mcp/client.ts +8 -6
  77. package/src/mcp/oauth-discovery.ts +120 -32
  78. package/src/mcp/oauth-flow.ts +34 -6
  79. package/src/mcp/timeout.ts +59 -0
  80. package/src/mcp/transports/http.ts +42 -44
  81. package/src/mcp/transports/stdio.ts +8 -5
  82. package/src/mcp/types.ts +1 -1
  83. package/src/modes/components/hook-editor.ts +11 -3
  84. package/src/modes/components/mcp-add-wizard.ts +6 -2
  85. package/src/modes/components/model-selector.ts +33 -11
  86. package/src/modes/controllers/command-controller.ts +6 -4
  87. package/src/modes/controllers/mcp-command-controller.ts +8 -4
  88. package/src/prompts/review-custom-request.md +22 -0
  89. package/src/prompts/review-headless-request.md +16 -0
  90. package/src/prompts/review-request.md +2 -3
  91. package/src/prompts/system/project-prompt.md +4 -0
  92. package/src/prompts/tools/debug.md +1 -0
  93. package/src/prompts/tools/find.md +4 -2
  94. package/src/prompts/tools/hashline.md +43 -93
  95. package/src/sdk.ts +47 -73
  96. package/src/session/agent-session.ts +93 -27
  97. package/src/session/streaming-output.ts +1 -1
  98. package/src/slash-commands/helpers/usage-report.ts +3 -1
  99. package/src/task/executor.ts +11 -0
  100. package/src/task/index.ts +19 -0
  101. package/src/task/render.ts +12 -2
  102. package/src/task/types.ts +4 -0
  103. package/src/tools/approval.ts +185 -0
  104. package/src/tools/ask.ts +1 -0
  105. package/src/tools/ast-edit.ts +25 -1
  106. package/src/tools/ast-grep.ts +1 -0
  107. package/src/tools/bash.ts +69 -1
  108. package/src/tools/browser/tab-supervisor.ts +1 -1
  109. package/src/tools/browser.ts +15 -0
  110. package/src/tools/calculator.ts +1 -0
  111. package/src/tools/checkpoint.ts +2 -0
  112. package/src/tools/debug.ts +38 -0
  113. package/src/tools/eval.ts +15 -0
  114. package/src/tools/find.ts +17 -8
  115. package/src/tools/gh.ts +21 -1
  116. package/src/tools/hindsight-recall.ts +1 -0
  117. package/src/tools/hindsight-reflect.ts +1 -0
  118. package/src/tools/hindsight-retain.ts +1 -0
  119. package/src/tools/image-gen.ts +1 -0
  120. package/src/tools/inspect-image.ts +1 -0
  121. package/src/tools/irc.ts +1 -0
  122. package/src/tools/job.ts +1 -0
  123. package/src/tools/path-utils.ts +14 -1
  124. package/src/tools/read.ts +1 -0
  125. package/src/tools/recipe/index.ts +1 -0
  126. package/src/tools/render-mermaid.ts +1 -0
  127. package/src/tools/report-tool-issue.ts +1 -0
  128. package/src/tools/resolve.ts +1 -0
  129. package/src/tools/review.ts +1 -0
  130. package/src/tools/search-tool-bm25.ts +1 -0
  131. package/src/tools/search.ts +1 -0
  132. package/src/tools/ssh.ts +8 -0
  133. package/src/tools/todo-write.ts +1 -0
  134. package/src/tools/write.ts +12 -1
  135. package/src/tools/yield.ts +1 -0
  136. package/src/web/search/index.ts +2 -0
@@ -1,109 +1,59 @@
1
1
  Your patch language is a compact, line-anchored edit format.
2
2
 
3
- A patch contains one or more file sections. Each anchored section starts with `¶PATH#HASH`, copied verbatim from the latest `read`/`search` output. `HASH` is a 4-hex file hash; `¶PATH` without `#HASH` is allowed only for new-file / `BOF` / `EOF` boundary inserts.
4
-
5
- Operations reference lines by bare line number (`5`, `123`). Payload text is verbatim — NEVER escape unicode. The tool has NO awareness of language, indentation, brackets, fences, or table widths. Emit valid syntax in replacements/insertions.
3
+ <payload>
4
+ Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`.
5
+ - No context rows, no gutters.
6
+ - NEVER prefix payload with diff syntax.
7
+ - NEVER restate unchanged lines "for context".
8
+ - Payload indentation is literal.
9
+ </payload>
6
10
 
7
11
  <ops>
8
- ¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH
9
- ¶PATH unbound header: only BOF/EOF boundary inserts
10
- LINE↑PAYLOAD insert ABOVE the anchored line (or BOF)
11
- LINE↓PAYLOAD insert BELOW the anchored line (or EOF)
12
- A-B:PAYLOAD replace the inclusive range A..B with PAYLOAD
13
- A:PAYLOAD shorthand for A-A:PAYLOAD
14
- A-B! delete the inclusive range A..B (payload forbidden)
15
- A! shorthand for A-A!
12
+ LINE↑PAYLOAD insert before (or BOF↑)
13
+ LINE↓PAYLOAD insert after (or EOF↓)
14
+ A-B:PAYLOAD replace A..B (or A: == A..A)
15
+ A-B! delete A..B (or A! == A..A)
16
16
  </ops>
17
17
 
18
- <payload>
19
- - The first payload line is whatever follows the sigil on the op line. Additional payload lines follow on the next lines and append after the first.
20
- - An empty inline IS an empty first line. So bare `A↓` / `A↑` insert one blank line; bare `A:` / `A-B:` replace with one blank line. `A↓\nfoo` inserts blank-then-`foo`, NOT just `foo`.
21
- - Payload ends at the next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are dropped; blank lines between content lines are preserved.
22
- </payload>
23
-
24
18
  <rules>
25
- - The sigil tells where content lands: `↑` above, `↓` below, `:` replaces, `!` deletes.
26
- - **Payload is only what's NEW relative to your range.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat the anchor line or neighbors — that duplicates them.
27
- - **Pick a self-contained unit.** Touching a multiline construct (return, array, brace block, JSX element)? Widen the range to span it. Don't bisect.
28
- - Smallest op wins: add with `↑`/`↓`; replace with `:`; delete with `!`.
29
- - Anchors reference the file as last read. ONE patch, ONE coordinate space — later ops still use original line numbers.
19
+ - **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
20
+ - **Go small.** Add `↑`/`↓`; replace `:`; delete `!`.
21
+ - **Line numbers are frozen references to what you have seen.** Later ops still use original line numbers.
30
22
  </rules>
31
23
 
32
24
  <common-failures>
33
- - **NEVER replay past your range.** Stop before B+1; extend B if it must go.
34
- - **NEVER duplicate chunks inside one payload.**
35
- - **Read lines look like replace ops.** `84:content` already means "make line 84 equal to content" — don't echo a context line before it.
25
+ - **NEVER replay past your range.** Stop before B+1; extend B if needed.
26
+ - **Read lines look like replace ops.** `84:content` = "make line 84 content" — don't echo context before it.
36
27
  - **NEVER fabricate file hashes.** Missing? Re-`read`.
37
- - **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error.
38
28
  </common-failures>
39
29
 
40
- <case file="mod.ts">
41
- ¶mod.ts#1a2b
42
- {{hline 1 'const TITLE = "Mr";'}}
43
- {{hline 2 'export function greet(name) {'}}
44
- {{hline 3 ' return ['}}
45
- {{hline 4 ' TITLE,'}}
46
- {{hline 5 ' name?.trim() || "guest",'}}
47
- {{hline 6 ' ].join(" ");'}}
48
- {{hline 7 "}"}}
49
- </case>
50
-
51
- <examples>
52
- # Replace one line (inline payload preserves original indentation)
53
- ¶mod.ts#1a2b
54
- {{hrefr 1}}:const TITLE = "Mrs";
55
-
56
- # Replace a multiline statement — first line inline, rest below
57
- ¶mod.ts#1a2b
58
- {{hrefr 3}}-{{hrefr 6}}: return [
59
- "Mrs",
60
- name?.trim() || "guest",
61
- ].join(" ");
62
-
63
- # Insert ABOVE / BELOW a line
64
- ¶mod.ts#1a2b
65
- {{hrefr 4}}↓ "Dr",
66
- {{hrefr 5}}↑ "Dr",
67
-
68
- # Delete one line / blank a line / insert a blank line
69
- ¶mod.ts#1a2b
70
- {{hrefr 5}}!
71
- {{hrefr 6}}:
72
- {{hrefr 7}}↑
73
-
74
- # Create a file / append to one (hash optional for boundary-only inserts)
75
- ¶new.ts
76
- BOF↓export const done = true;
77
- ¶mod.ts
78
- EOF↓export const done = true;
79
-
80
- # Multi-file patch
81
- ¶src/a.ts#1a2b
82
- 12:const enabled = true;
83
- ¶src/b.ts#3c4d
84
- 20!
85
- </examples>
30
+ <example>
31
+ ```a.ts#1a2b
32
+ 1:const X = "a";
33
+ 2:export function f() { return X; }
34
+ ```
35
+
36
+ # replace, insert after, delete
37
+ ```
38
+ ¶a.ts#1a2b
39
+ 1:const X = "b";
40
+ 1↓const Y = "c";
41
+ 2!
42
+ ```
43
+ </example>
86
44
 
87
45
  <anti-pattern>
88
- # WRONG — replaces 2 lines just to add one.
89
- ¶mod.ts#1a2b
90
- {{hrefr 1}}-{{hrefr 2}}:const TITLE = "Mr";
91
- const DEBUG = false;
92
- export function greet(name) {
93
-
94
- # RIGHT — one-line insert
95
- ¶mod.ts#1a2b
96
- {{hrefr 1}}↓const DEBUG = false;
97
-
98
- # WRONG — bisects a multiline statement
99
- ¶mod.ts#1a2b
100
- {{hrefr 4}}-{{hrefr 5}}: "Dr",
101
- name?.trim() || "guest",
102
-
103
- # RIGHT — widen to the full statement
104
- ¶mod.ts#1a2b
105
- {{hrefr 3}}-{{hrefr 6}}: return [
106
- "Dr",
107
- name?.trim() || "guest",
108
- ].join(" ");
46
+ # WRONG — INSERT used to change a line (old line survives)
47
+ 1↓const X = "b";
48
+ # WRONG — echoing read-style lines as context before the real op
49
+ 1:const X = "a";
50
+ 1-2:const X = "b";
51
+ export const Y = X;
109
52
  </anti-pattern>
53
+
54
+ <critical>
55
+ - One op per range, ever.
56
+ - Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`.
57
+ - Payload is only what's NEW; never repeat anchor lines or neighbors.
58
+ - Anchor exactly; don't anchor neighbors.
59
+ </critical>
package/src/sdk.ts CHANGED
@@ -60,7 +60,6 @@ import {
60
60
  } from "./extensibility/custom-commands";
61
61
  import { discoverAndLoadCustomTools } from "./extensibility/custom-tools";
62
62
  import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./extensibility/custom-tools/types";
63
- import { CustomToolAdapter } from "./extensibility/custom-tools/wrapper";
64
63
  import {
65
64
  discoverAndLoadExtensions,
66
65
  type ExtensionContext,
@@ -343,6 +342,9 @@ export interface CreateAgentSessionOptions {
343
342
  * `@opentelemetry/api` package returns a no-op tracer in that case.
344
343
  */
345
344
  telemetry?: AgentTelemetryConfig;
345
+
346
+ /** Whether to auto-approve all tool calls (--auto-approve CLI flag). Default: false */
347
+ autoApprove?: boolean;
346
348
  }
347
349
 
348
350
  /** Result from createAgentSession */
@@ -835,7 +837,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
835
837
  // buffer — so we can't rely on it to catch startup events for the extension runner.
836
838
  const startupCredentialDisabledEvents: CredentialDisabledEvent[] = [];
837
839
  let credentialDisabledTarget: ExtensionRunner | undefined;
838
- let unsubscribeCredentialDisabled: (() => void) | undefined = authStorage.onCredentialDisabled(event => {
840
+ const unsubscribeCredentialDisabled: (() => void) | undefined = authStorage.onCredentialDisabled(event => {
839
841
  if (credentialDisabledTarget) {
840
842
  // Discard return: any handler error is routed through runner.onError listeners.
841
843
  void credentialDisabledTarget.emitCredentialDisabled(event);
@@ -1455,29 +1457,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1455
1457
  }
1456
1458
  }
1457
1459
 
1458
- let extensionRunner: ExtensionRunner | undefined;
1459
- if (extensionsResult.extensions.length > 0) {
1460
- extensionRunner = new ExtensionRunner(
1461
- extensionsResult.extensions,
1462
- extensionsResult.runtime,
1463
- cwd,
1464
- sessionManager,
1465
- modelRegistry,
1466
- );
1467
- }
1460
+ // The runner is created unconditionally — even with zero extensions loaded — because the
1461
+ // `ExtensionToolWrapper` installed below is the only place the per-tool approval gate runs.
1462
+ // A conditional runner means the approval system silently disappears for users with no
1463
+ // extensions, contradicting non-yolo `tools.approvalMode` settings without feedback.
1464
+ // (Today `createAutoresearchExtension` is unconditionally pushed below, so this scenario
1465
+ // is unreachable; the unconditional construction makes that invariant explicit instead of
1466
+ // implicit, so a future change to make autoresearch optional cannot silently re-open the hole.)
1467
+ const extensionRunner: ExtensionRunner = new ExtensionRunner(
1468
+ extensionsResult.extensions,
1469
+ extensionsResult.runtime,
1470
+ cwd,
1471
+ sessionManager,
1472
+ modelRegistry,
1473
+ );
1468
1474
 
1469
- if (extensionRunner) {
1470
- credentialDisabledTarget = extensionRunner;
1471
- for (const event of startupCredentialDisabledEvents.splice(0)) {
1472
- // Discard return: any handler error is routed through runner.onError listeners.
1473
- void extensionRunner.emitCredentialDisabled(event);
1474
- }
1475
- } else {
1476
- // No runner to forward to; release our subscription. The embedder's own
1477
- // onCredentialDisabled (if any) keeps firing through its own subscription.
1478
- startupCredentialDisabledEvents.length = 0;
1479
- unsubscribeCredentialDisabled?.();
1480
- unsubscribeCredentialDisabled = undefined;
1475
+ credentialDisabledTarget = extensionRunner;
1476
+ for (const event of startupCredentialDisabledEvents.splice(0)) {
1477
+ // Discard return: any handler error is routed through runner.onError listeners.
1478
+ void extensionRunner.emitCredentialDisabled(event);
1481
1479
  }
1482
1480
 
1483
1481
  const getSessionContext = () => ({
@@ -1490,38 +1488,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1490
1488
  session.abort();
1491
1489
  },
1492
1490
  settings,
1491
+ autoApprove: options.autoApprove ?? false,
1493
1492
  });
1494
1493
  const toolContextStore = new ToolContextStore(getSessionContext);
1495
1494
 
1496
- const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
1497
- let wrappedExtensionTools: Tool[];
1498
-
1499
- if (extensionRunner) {
1500
- // With extension runner: convert CustomTools to ToolDefinitions and wrap all together
1501
- const allCustomTools = [
1502
- ...registeredTools,
1503
- ...(options.customTools?.map(tool => {
1504
- const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool;
1505
- return { definition, extensionPath: "<sdk>" };
1506
- }) ?? []),
1507
- ];
1508
- wrappedExtensionTools = wrapRegisteredTools(allCustomTools, extensionRunner);
1509
- } else {
1510
- // Without extension runner: wrap CustomTools directly with CustomToolAdapter
1511
- // ToolDefinition items require ExtensionContext and cannot be used without a runner
1512
- const customToolContext = (): CustomToolContext => ({
1513
- sessionManager,
1514
- modelRegistry,
1515
- model: agent?.state.model,
1516
- isIdle: () => !session?.isStreaming,
1517
- hasQueuedMessages: () => (session?.queuedMessageCount ?? 0) > 0,
1518
- abort: () => session?.abort(),
1519
- settings,
1520
- });
1521
- wrappedExtensionTools = (options.customTools ?? [])
1522
- .filter(isCustomTool)
1523
- .map(tool => CustomToolAdapter.wrap(tool, customToolContext));
1524
- }
1495
+ const registeredTools = extensionRunner.getAllRegisteredTools();
1496
+ const allCustomTools = [
1497
+ ...registeredTools,
1498
+ ...(options.customTools?.map(tool => {
1499
+ const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool;
1500
+ return { definition, extensionPath: "<sdk>" };
1501
+ }) ?? []),
1502
+ ];
1503
+ const wrappedExtensionTools: Tool[] = wrapRegisteredTools(allCustomTools, extensionRunner);
1525
1504
 
1526
1505
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
1527
1506
  const toolRegistry = new Map<string, Tool>();
@@ -1537,10 +1516,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1537
1516
  for (const tool of wrappedExtensionTools) {
1538
1517
  toolRegistry.set(tool.name, tool);
1539
1518
  }
1540
- if (extensionRunner) {
1541
- for (const tool of toolRegistry.values()) {
1542
- toolRegistry.set(tool.name, new ExtensionToolWrapper(tool, extensionRunner));
1543
- }
1519
+ // Wrap every tool with `ExtensionToolWrapper` so the per-tool approval gate runs on every
1520
+ // call site, regardless of whether any user extensions are loaded. See the runner-construction
1521
+ // comment above for the safety invariant this enforces.
1522
+ for (const tool of toolRegistry.values()) {
1523
+ toolRegistry.set(tool.name, new ExtensionToolWrapper(tool, extensionRunner));
1544
1524
  }
1545
1525
  if (model?.provider === "cursor") {
1546
1526
  toolRegistry.delete("edit");
@@ -1564,7 +1544,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1564
1544
  })) as unknown as AgentTool | null;
1565
1545
  if (!sshTool) return null;
1566
1546
  const wrapped = wrapToolWithMetaNotice(sshTool);
1567
- return (extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped) as AgentTool;
1547
+ return new ExtensionToolWrapper(wrapped, extensionRunner) as AgentTool;
1568
1548
  };
1569
1549
 
1570
1550
  let cursorEventEmitter: ((event: AgentEvent) => void) | undefined;
@@ -1824,21 +1804,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1824
1804
  if (!obfuscator?.hasSecrets()) return converted;
1825
1805
  return obfuscateMessages(obfuscator, converted);
1826
1806
  };
1827
- const transformContext = extensionRunner
1828
- ? async (messages: AgentMessage[], _signal?: AbortSignal) => {
1829
- return await extensionRunner.emitContext(messages);
1830
- }
1831
- : undefined;
1832
- const onPayload = extensionRunner
1833
- ? async (payload: unknown, _model?: Model) => {
1834
- return await extensionRunner.emitBeforeProviderRequest(payload);
1835
- }
1836
- : undefined;
1837
- const onResponse: SimpleStreamOptions["onResponse"] | undefined = extensionRunner
1838
- ? async (response, model) => {
1839
- await extensionRunner.emitAfterProviderResponse(response, model);
1840
- }
1841
- : undefined;
1807
+ const transformContext = async (messages: AgentMessage[], _signal?: AbortSignal) => {
1808
+ return await extensionRunner.emitContext(messages);
1809
+ };
1810
+ const onPayload = async (payload: unknown, _model?: Model) => {
1811
+ return await extensionRunner.emitBeforeProviderRequest(payload);
1812
+ };
1813
+ const onResponse: SimpleStreamOptions["onResponse"] = async (response, model) => {
1814
+ await extensionRunner.emitAfterProviderResponse(response, model);
1815
+ };
1842
1816
 
1843
1817
  const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => {
1844
1818
  toolContextStore.setUIContext(uiContext, hasUI);
@@ -898,7 +898,7 @@ export class AgentSession {
898
898
  * combined with `Date.now()` so tags stay unique even across rapid
899
899
  * same-tick enqueues. */
900
900
  #customDisplayTagCounter = 0;
901
- #postPromptTasks = new Set<Promise<void>>();
901
+ #postPromptTasks = new Set<Promise<unknown>>();
902
902
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
903
903
  #postPromptTasksResolve: (() => void) | undefined = undefined;
904
904
  #postPromptTasksAbortController = new AbortController();
@@ -1786,12 +1786,19 @@ export class AgentSession {
1786
1786
 
1787
1787
  const compactionTask = this.#checkCompaction(msg);
1788
1788
  this.#trackPostPromptTask(compactionTask);
1789
- await compactionTask;
1789
+ const compactionDeferredHandoff = await compactionTask;
1790
1790
  // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
1791
1791
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
1792
1792
  if (hasToolCalls) {
1793
1793
  return;
1794
1794
  }
1795
+ // When checkCompaction scheduled a deferred handoff, skip the rewind/todo passes:
1796
+ // any reminder we append here would race the handoff's session reset, and
1797
+ // #scheduleAgentContinue would start a fresh streaming turn alongside the handoff
1798
+ // LLM call (visible as "Auto-handoff" loader + an assistant message still streaming).
1799
+ if (compactionDeferredHandoff) {
1800
+ return;
1801
+ }
1795
1802
  if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
1796
1803
  if (this.#enforceRewindBeforeYield()) {
1797
1804
  return;
@@ -1840,7 +1847,7 @@ export class AgentSession {
1840
1847
  this.#postPromptTasksPromise = undefined;
1841
1848
  }
1842
1849
 
1843
- #trackPostPromptTask(task: Promise<void>): void {
1850
+ #trackPostPromptTask(task: Promise<unknown>): void {
1844
1851
  this.#postPromptTasks.add(task);
1845
1852
  this.#ensurePostPromptTasksPromise();
1846
1853
  void task
@@ -1889,8 +1896,17 @@ export class AgentSession {
1889
1896
  }): void {
1890
1897
  this.#schedulePostPromptTask(
1891
1898
  async () => {
1899
+ // Defense in depth: if compaction/handoff slipped onto the post-prompt queue
1900
+ // alongside us (e.g. via a scheduler we don't own), refuse to start a fresh
1901
+ // streaming turn — agent.continue() here would race the handoff's session
1902
+ // reset. The first-class fix is in #checkCompaction/the agent_end handler,
1903
+ // but this guard catches anything that bypasses that path.
1904
+ if (this.isCompacting || this.isGeneratingHandoff) {
1905
+ options?.onSkip?.();
1906
+ return;
1907
+ }
1892
1908
  if (options?.shouldContinue && !options.shouldContinue()) {
1893
- options.onSkip?.();
1909
+ options?.onSkip?.();
1894
1910
  return;
1895
1911
  }
1896
1912
  try {
@@ -2756,6 +2772,21 @@ export class AgentSession {
2756
2772
  } catch (error) {
2757
2773
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
2758
2774
  }
2775
+ // Abort post-prompt work so the drain below can complete. Without this, a
2776
+ // deferred-handoff task that has already advanced into
2777
+ // `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
2778
+ // — Promise.allSettled() in #cancelPostPromptTasks then waits forever, freezing
2779
+ // /exit and Ctrl+C-double-tap. The post-prompt task's own AbortSignal does not
2780
+ // propagate into the inner handoff/compaction controllers, so we abort them
2781
+ // explicitly. agent.abort() is needed for an agent.continue() that may have
2782
+ // raced the deferred handoff (its streaming loop is awaited by the wrapper IIFE).
2783
+ //
2784
+ // Tool work (bash/eval/python) is NOT aborted here — those have their own
2785
+ // dispose paths and shared kernels are contractually allowed to survive a
2786
+ // session's dispose.
2787
+ this.abortRetry();
2788
+ this.abortCompaction();
2789
+ this.agent.abort();
2759
2790
  await this.#cancelPostPromptTasks();
2760
2791
  // Cancel jobs this agent registered so a subagent's teardown doesn't
2761
2792
  // leak its background bash/task work into the parent's manager. Only
@@ -4050,10 +4081,13 @@ export class AgentSession {
4050
4081
  );
4051
4082
  }
4052
4083
 
4053
- // Check if we need to compact before sending (catches aborted responses)
4084
+ // Check if we need to compact before sending (catches aborted responses). Run
4085
+ // inline (allowDefer=false) so the handoff/maintenance fully settles before this
4086
+ // prompt's agent loop starts — otherwise a deferred handoff would fire on the
4087
+ // next microtask alongside the new turn.
4054
4088
  const lastAssistant = this.#findLastAssistantMessage();
4055
4089
  if (lastAssistant && !options?.skipCompactionCheck) {
4056
- await this.#checkCompaction(lastAssistant, false);
4090
+ await this.#checkCompaction(lastAssistant, false, false);
4057
4091
  }
4058
4092
 
4059
4093
  // Build messages array (session context, eager todo prelude, then active prompt message)
@@ -5602,10 +5636,23 @@ export class AgentSession {
5602
5636
  *
5603
5637
  * @param assistantMessage The assistant message to check
5604
5638
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
5639
+ * @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a
5640
+ * deferred post-prompt task instead of running inline. Callers running inside the
5641
+ * `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
5642
+ * on the pre-prompt path (where the next agent turn is about to start) set it to false
5643
+ * to avoid racing the deferred handoff against the new turn.
5644
+ * @returns true when a deferred handoff was scheduled. Callers MUST then skip any
5645
+ * subsequent `#scheduleAgentContinue` / reminder appends for this turn — the
5646
+ * handoff will replace session state and a concurrent `agent.continue()` would
5647
+ * stream into the soon-to-be-discarded session.
5605
5648
  */
5606
- async #checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
5649
+ async #checkCompaction(
5650
+ assistantMessage: AssistantMessage,
5651
+ skipAbortedCheck = true,
5652
+ allowDefer = true,
5653
+ ): Promise<boolean> {
5607
5654
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
5608
- if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
5655
+ if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
5609
5656
  const contextWindow = this.model?.contextWindow ?? 0;
5610
5657
  const generation = this.#promptGeneration;
5611
5658
  // Skip overflow check if the message came from a different model.
@@ -5634,22 +5681,22 @@ export class AgentSession {
5634
5681
  if (promoted) {
5635
5682
  // Retry on the promoted (larger) model without compacting
5636
5683
  this.#scheduleAgentContinue({ delayMs: 100, generation });
5637
- return;
5684
+ return false;
5638
5685
  }
5639
5686
 
5640
5687
  // No promotion target available fall through to compaction
5641
5688
  const compactionSettings = this.settings.getGroup("compaction");
5642
5689
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
5643
- await this.#runAutoCompaction("overflow", true);
5690
+ await this.#runAutoCompaction("overflow", true, false, allowDefer);
5644
5691
  }
5645
- return;
5692
+ return false;
5646
5693
  }
5647
5694
  const compactionSettings = this.settings.getGroup("compaction");
5648
- if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
5695
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
5649
5696
 
5650
5697
  // Case 2: Threshold - turn succeeded but context is getting large
5651
5698
  // Skip if this was an error (non-overflow errors don't have usage data)
5652
- if (assistantMessage.stopReason === "error") return;
5699
+ if (assistantMessage.stopReason === "error") return false;
5653
5700
  const pruneResult = await this.#pruneToolOutputs();
5654
5701
  let contextTokens = calculateContextTokens(assistantMessage.usage);
5655
5702
  if (pruneResult) {
@@ -5659,9 +5706,10 @@ export class AgentSession {
5659
5706
  // Try promotion first — if a larger model is available, switch instead of compacting
5660
5707
  const promoted = await this.#tryContextPromotion(assistantMessage);
5661
5708
  if (!promoted) {
5662
- await this.#runAutoCompaction("threshold", false);
5709
+ return await this.#runAutoCompaction("threshold", false, false, allowDefer);
5663
5710
  }
5664
5711
  }
5712
+ return false;
5665
5713
  }
5666
5714
  #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
5667
5715
  const toolCallId = this.#lastSuccessfulYieldToolCallId;
@@ -6352,17 +6400,34 @@ export class AgentSession {
6352
6400
 
6353
6401
  /**
6354
6402
  * Internal: Run auto-compaction with events.
6403
+ *
6404
+ * @param allowDefer If true (default), threshold-driven handoff strategy is allowed to
6405
+ * schedule itself as a deferred post-prompt task and return `true` immediately. The
6406
+ * caller MUST treat that as "compaction will happen async — do not also schedule
6407
+ * `agent.continue()` for this turn", otherwise the deferred handoff races a fresh
6408
+ * streaming turn (the symptom: "Auto-handoff" loader + assistant message still
6409
+ * streaming). Callers on a path that is about to start a new agent turn (e.g.
6410
+ * the pre-prompt check in `#promptWithMessage`) pass `false` to force inline
6411
+ * execution so the handoff completes before the new turn begins.
6412
+ * @returns true when a deferred handoff was scheduled. Inline runs always return false.
6355
6413
  */
6356
6414
  async #runAutoCompaction(
6357
6415
  reason: "overflow" | "threshold" | "idle",
6358
6416
  willRetry: boolean,
6359
6417
  deferred = false,
6360
- ): Promise<void> {
6418
+ allowDefer = true,
6419
+ ): Promise<boolean> {
6361
6420
  const compactionSettings = this.settings.getGroup("compaction");
6362
- if (compactionSettings.strategy === "off") return;
6363
- if (reason !== "idle" && !compactionSettings.enabled) return;
6421
+ if (compactionSettings.strategy === "off") return false;
6422
+ if (reason !== "idle" && !compactionSettings.enabled) return false;
6364
6423
  const generation = this.#promptGeneration;
6365
- if (!deferred && reason !== "overflow" && reason !== "idle" && compactionSettings.strategy === "handoff") {
6424
+ if (
6425
+ !deferred &&
6426
+ allowDefer &&
6427
+ reason !== "overflow" &&
6428
+ reason !== "idle" &&
6429
+ compactionSettings.strategy === "handoff"
6430
+ ) {
6366
6431
  this.#schedulePostPromptTask(
6367
6432
  async signal => {
6368
6433
  await Promise.resolve();
@@ -6371,7 +6436,7 @@ export class AgentSession {
6371
6436
  },
6372
6437
  { generation },
6373
6438
  );
6374
- return;
6439
+ return true;
6375
6440
  }
6376
6441
 
6377
6442
  let action: "context-full" | "handoff" =
@@ -6400,7 +6465,7 @@ export class AgentSession {
6400
6465
  aborted: true,
6401
6466
  willRetry: false,
6402
6467
  });
6403
- return;
6468
+ return false;
6404
6469
  }
6405
6470
  logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
6406
6471
  reason,
@@ -6418,7 +6483,7 @@ export class AgentSession {
6418
6483
  if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
6419
6484
  this.#scheduleAutoContinuePrompt(generation);
6420
6485
  }
6421
- return;
6486
+ return false;
6422
6487
  }
6423
6488
  }
6424
6489
 
@@ -6431,7 +6496,7 @@ export class AgentSession {
6431
6496
  willRetry: false,
6432
6497
  skipped: true,
6433
6498
  });
6434
- return;
6499
+ return false;
6435
6500
  }
6436
6501
 
6437
6502
  const availableModels = this.#modelRegistry.getAvailable();
@@ -6444,7 +6509,7 @@ export class AgentSession {
6444
6509
  willRetry: false,
6445
6510
  skipped: true,
6446
6511
  });
6447
- return;
6512
+ return false;
6448
6513
  }
6449
6514
 
6450
6515
  const pathEntries = this.sessionManager.getBranch();
@@ -6466,7 +6531,7 @@ export class AgentSession {
6466
6531
  shouldContinue: () => this.agent.hasQueuedMessages(),
6467
6532
  });
6468
6533
  }
6469
- return;
6534
+ return false;
6470
6535
  }
6471
6536
 
6472
6537
  let hookCompaction: CompactionResult | undefined;
@@ -6490,7 +6555,7 @@ export class AgentSession {
6490
6555
  aborted: true,
6491
6556
  willRetry: false,
6492
6557
  });
6493
- return;
6558
+ return false;
6494
6559
  }
6495
6560
 
6496
6561
  if (hookResult?.compaction) {
@@ -6621,7 +6686,7 @@ export class AgentSession {
6621
6686
  aborted: true,
6622
6687
  willRetry: false,
6623
6688
  });
6624
- return;
6689
+ return false;
6625
6690
  }
6626
6691
 
6627
6692
  this.sessionManager.appendCompaction(
@@ -6692,7 +6757,7 @@ export class AgentSession {
6692
6757
  aborted: true,
6693
6758
  willRetry: false,
6694
6759
  });
6695
- return;
6760
+ return false;
6696
6761
  }
6697
6762
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
6698
6763
  await this.#emitSessionEvent({
@@ -6711,6 +6776,7 @@ export class AgentSession {
6711
6776
  this.#autoCompactionAbortController = undefined;
6712
6777
  }
6713
6778
  }
6779
+ return false;
6714
6780
  }
6715
6781
 
6716
6782
  /**
@@ -9,7 +9,7 @@ import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
9
9
 
10
10
  export const DEFAULT_MAX_LINES = 3000;
11
11
  export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
12
- export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
12
+ export const DEFAULT_MAX_COLUMN = 512; // Max chars per grep match line
13
13
 
14
14
  const NL = "\n";
15
15
  const ELLIPSIS = "…";
@@ -56,7 +56,9 @@ function renderUsageReports(reports: UsageReport[], nowMs: number): string {
56
56
  lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`);
57
57
  lines.push(` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}`);
58
58
  lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`);
59
- if (limit.window?.resetsAt) lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
59
+ if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
60
+ lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
61
+ }
60
62
  if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`);
61
63
  }
62
64
  }