@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2

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 (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
@@ -75,6 +75,17 @@ export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): b
75
75
  return severity === "concern" || severity === "blocker";
76
76
  }
77
77
 
78
+ /**
79
+ * Append a staleness caveat to an advisor note when newer primary turns arrived
80
+ * after the reviewed transcript window (i.e. `hasFreshBacklog` is true on the
81
+ * advisor runtime at delivery time). Pure function — no session coupling — so it
82
+ * can be unit-tested in isolation and called from `AgentSession#routeAdvice`.
83
+ */
84
+ export function annotateForStaleness(note: string, hasFreshBacklog: boolean): string {
85
+ if (!hasFreshBacklog) return note;
86
+ return `${note}\n\n_(Note: newer primary turns arrived after this reviewed window — verify this still applies.)_`;
87
+ }
88
+
78
89
  /** How an advisor note is routed to the primary. */
79
90
  export type AdvisorDeliveryChannel = "aside" | "steer" | "preserve";
80
91
  /** Half-open turn-count fence for the post-interrupt cooldown. */
@@ -94,6 +105,9 @@ export function isAdvisorInterruptImmuneTurnActive(opts: {
94
105
  * - An interrupting `concern`/`blocker` is normally steered into the agent: into
95
106
  * the live turn while one is streaming, or (when idle) a triggered turn so the
96
107
  * advice is acted on immediately.
108
+ * - If the primary tail is already a terminal text answer and there is no queued
109
+ * work, late interrupting advice is preserved as a visible card instead of
110
+ * waking the primary to restate completion.
97
111
  * - After a deliberate user interrupt (`autoResumeSuppressed`) the advisor must
98
112
  * not auto-resume the stopped run. While the agent is idle — or still tearing
99
113
  * the interrupted turn down (`aborting`) — the note is preserved as a visible
@@ -103,17 +117,19 @@ export function isAdvisorInterruptImmuneTurnActive(opts: {
103
117
  * run instead strands it (it never reaches the running agent) and the withheld
104
118
  * notes dump as one burst at the next user prompt — the bug this guards.
105
119
  * - During the post-interrupt immune-turn window, further `concern`/`blocker`
106
- * notes are downgraded to asides; suppression preservation still wins.
120
+ * notes are downgraded to asides; preservation still wins.
107
121
  */
108
122
  export function resolveAdvisorDeliveryChannel(opts: {
109
123
  severity: AdvisorSeverity | undefined;
110
124
  autoResumeSuppressed: boolean;
111
125
  streaming: boolean;
112
126
  aborting: boolean;
127
+ terminalAnswerNoQueuedWork?: boolean;
113
128
  interruptImmuneTurnActive?: boolean;
114
129
  }): AdvisorDeliveryChannel {
115
130
  if (!isInterruptingSeverity(opts.severity)) return "aside";
116
131
  if (opts.autoResumeSuppressed && (opts.aborting || !opts.streaming)) return "preserve";
132
+ if (opts.terminalAnswerNoQueuedWork && !opts.streaming && !opts.aborting) return "preserve";
117
133
  if (opts.interruptImmuneTurnActive) return "aside";
118
134
  return "steer";
119
135
  }
@@ -63,9 +63,126 @@ export interface AdvisorRuntimeHost {
63
63
  notifyFailure?(error: unknown): void;
64
64
  }
65
65
 
66
+ const ADVISOR_QUARANTINE_PREFIX = "Advisor response quarantined";
67
+
68
+ /** Signals that an advisor response was discarded before it could become model-visible context. */
69
+ export class AdvisorOutputQuarantinedError extends Error {
70
+ constructor(message: string) {
71
+ super(message);
72
+ this.name = "AdvisorOutputQuarantinedError";
73
+ }
74
+ }
75
+
76
+ interface AdvisorOutputHazard {
77
+ label: string;
78
+ pattern: RegExp;
79
+ }
80
+
81
+ const ADVISOR_OUTPUT_ONLY_HAZARDS: readonly AdvisorOutputHazard[] = [
82
+ { label: "account-deletion claim", pattern: /\buser\b.{0,80}\b(?:deleted|erased)\b.{0,80}\baccount\b/i },
83
+ {
84
+ label: "instruction override",
85
+ pattern: /\bignore\s+(?:all\s+)?(?:prior|previous|earlier)\s+(?:user\s+)?instructions\b/i,
86
+ },
87
+ {
88
+ label: "destructive shell command",
89
+ pattern: /\brm\s+(?=(?:-[a-z]+\s*)*-[a-z]*r[a-z]*)(?=(?:-[a-z]+\s*)*-[a-z]*f[a-z]*)(?:-[a-z]+\s*)+/i,
90
+ },
91
+ { label: "denial instruction", pattern: /\bdeny\s+(?:this|it|the\s+request)\s+if\s+(?:asked|questioned)\b/i },
92
+ ];
93
+
94
+ /**
95
+ * Replaces an advisor assistant turn that requested unavailable tools or generated
96
+ * output-only destructive directives with a sanitized error before dispatch.
97
+ *
98
+ * The agent loop records assistant turns before dispatching tools. Without this
99
+ * pre-dispatch rewrite, an advisor hallucination can leave unrelated text in the
100
+ * advisor transcript even though the action itself never executes.
101
+ */
102
+ export function quarantineAdvisorUnsafeOutput(
103
+ message: AssistantMessage,
104
+ availableToolNames: ReadonlySet<string>,
105
+ sourceText = "",
106
+ ): string | undefined {
107
+ const reasons: string[] = [];
108
+ const unavailableToolNames = new Set<string>();
109
+ const generatedParts: string[] = [];
110
+ for (const block of message.content) {
111
+ if (block.type === "toolCall" && !availableToolNames.has(block.name)) unavailableToolNames.add(block.name);
112
+ if (block.type === "toolCall" && block.name === "advise" && typeof block.arguments.note === "string") {
113
+ generatedParts.push(block.arguments.note);
114
+ }
115
+ if (block.type === "text") generatedParts.push(block.text);
116
+ }
117
+ if (unavailableToolNames.size > 0) {
118
+ const names = [...unavailableToolNames].sort();
119
+ const toolLabel = names.length === 1 ? "tool" : "tools";
120
+ reasons.push(`requested unavailable ${toolLabel} ${names.join(", ")}`);
121
+ }
122
+
123
+ const generatedText = generatedParts.join("\n");
124
+ if (generatedText) {
125
+ const labels: string[] = [];
126
+ const matchedLabels: string[] = [];
127
+ for (const hazard of ADVISOR_OUTPUT_ONLY_HAZARDS) {
128
+ if (!hazard.pattern.test(generatedText)) continue;
129
+ matchedLabels.push(hazard.label);
130
+ if (!hazard.pattern.test(sourceText)) labels.push(hazard.label);
131
+ }
132
+ // A transcript can quote a destructive command while the advisor turns it
133
+ // into a new instruction. The output-only override remains sufficient
134
+ // provenance to quarantine that combination.
135
+ if (
136
+ matchedLabels.includes("destructive shell command") &&
137
+ labels.includes("instruction override") &&
138
+ !labels.includes("destructive shell command")
139
+ ) {
140
+ labels.push("destructive shell command");
141
+ }
142
+ if (labels.includes("destructive shell command") || labels.length >= 3) {
143
+ reasons.push(`generated output-only destructive directives: ${labels.join(", ")}`);
144
+ }
145
+ }
146
+
147
+ if (reasons.length === 0) return undefined;
148
+
149
+ const messageText = `${ADVISOR_QUARANTINE_PREFIX}: ${reasons.join("; ")}`;
150
+ message.content = [{ type: "text", text: messageText }];
151
+ message.stopReason = "error";
152
+ message.stopDetails = undefined;
153
+ message.toolCallAbortMessages = undefined;
154
+ message.providerPayload = undefined;
155
+ message.errorMessage = messageText;
156
+ return messageText;
157
+ }
158
+
159
+ /**
160
+ * Builds the provenance text used to decide whether hazardous advisor output was
161
+ * generated by the advisor or came from model-visible primary/tool context.
162
+ */
163
+ export function buildAdvisorQuarantineSourceText(currentInput: string, messages: readonly AgentMessage[]): string {
164
+ const parts: string[] = [];
165
+ if (currentInput) parts.push(currentInput);
166
+ for (const message of messages) {
167
+ if (message.role !== "toolResult") continue;
168
+ for (const block of message.content) {
169
+ if (block.type === "text") parts.push(block.text);
170
+ }
171
+ }
172
+ return parts.join("\n");
173
+ }
174
+ /**
175
+ * Maximum number of late-arrival coalescing rounds in {@link AdvisorRuntime.#collectAndMaintainBatch}.
176
+ * After this many rounds any items still in `#pending` are left for the next drain iteration
177
+ * so a pathologically fast primary + slow `maintainContext` cannot stall dispatch indefinitely.
178
+ */
179
+ const MAX_COALESCE_ROUNDS = 3;
180
+
66
181
  interface PendingDelta {
67
182
  text: string;
68
183
  turns: number;
184
+ /** Whether the primary was mid-turn (willContinue:true) when this delta was rendered. */
185
+ wip: boolean;
69
186
  }
70
187
 
71
188
  interface CatchupWaiter {
@@ -107,13 +224,36 @@ export class AdvisorRuntime {
107
224
  return this.#backlog;
108
225
  }
109
226
 
110
- onTurnEnd(messages?: AgentMessage[]): void {
227
+ /**
228
+ * True when `#pending` is non-empty while the drain loop is busy — i.e., newer
229
+ * primary turns arrived after the current batch's transcript window was fixed
230
+ * but before the advisor model finished processing it. The delivery path uses
231
+ * this to annotate advice that was generated without seeing those newer turns.
232
+ * Can be true during `agent.prompt()`, a `maintainContext` await, or a retry
233
+ * sleep — any time `#drain` is busy and a concurrent `onTurnEnd` pushed.
234
+ */
235
+ get hasFreshBacklog(): boolean {
236
+ return this.#pending.length > 0;
237
+ }
238
+
239
+ /**
240
+ * Called after each primary turn ends. Renders the incremental delta and
241
+ * queues it for the advisor model.
242
+ *
243
+ * @param messages - Live primary transcript snapshot (defaults to `snapshotMessages()`).
244
+ * @param opts.willContinue - When `true` the primary is mid-turn (more tool-call
245
+ * steps will follow). The rendered heading is tagged `[in progress]` so the
246
+ * advisor knows to withhold critique on partial work. The flag is carried on
247
+ * the delta and forwarded to the reprime path so it is never silently dropped.
248
+ */
249
+ onTurnEnd(messages?: AgentMessage[], opts?: { willContinue?: boolean }): void {
111
250
  if (this.disposed) return;
112
251
  const all = messages ?? this.host.snapshotMessages();
113
252
  this.#latestMessages = all;
114
- const render = this.#renderDelta(all);
253
+ const wip = opts?.willContinue ?? false;
254
+ const render = this.#renderDelta(all, wip);
115
255
  if (render) {
116
- this.#pending.push({ text: render, turns: 1 });
256
+ this.#pending.push({ text: render, turns: 1, wip });
117
257
  this.#backlog++;
118
258
  this.#notifyWaiters();
119
259
  void this.#drain();
@@ -200,7 +340,7 @@ export class AdvisorRuntime {
200
340
  this.#wakeAllWaiters();
201
341
  }
202
342
 
203
- #renderDelta(messages?: AgentMessage[]): string | null {
343
+ #renderDelta(messages?: AgentMessage[], wip = false): string | null {
204
344
  const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
205
345
  if (all.length < this.#lastCount) {
206
346
  this.#lastCount = all.length;
@@ -209,7 +349,7 @@ export class AdvisorRuntime {
209
349
  }
210
350
  const delta = all
211
351
  .slice(this.#lastCount)
212
- .filter(m => !(m.role === "custom" && (m as { customType?: string }).customType === "advisor"))
352
+ .filter(m => !(m.role === "custom" && m.customType === "advisor"))
213
353
  .map(m => this.#dedupContextMessage(m));
214
354
  this.#lastCount = all.length;
215
355
  if (delta.length === 0) return null;
@@ -223,7 +363,8 @@ export class AdvisorRuntime {
223
363
  expandEditDiffs: true,
224
364
  });
225
365
  if (!md.trim()) return null;
226
- return `### Session update\n\n${md}`;
366
+ const heading = wip ? "### Session update [in progress — more steps follow]" : "### Session update";
367
+ return `${heading}\n\n${md}`;
227
368
  }
228
369
 
229
370
  /**
@@ -236,14 +377,13 @@ export class AdvisorRuntime {
236
377
  */
237
378
  #dedupContextMessage(msg: AgentMessage): AgentMessage {
238
379
  if (msg.role !== "custom") return msg;
239
- const type = (msg as { customType?: string }).customType;
240
- if (!type || !PRIMARY_CONTEXT_CUSTOM_TYPES.has(type)) return msg;
241
- const content = (msg as { content?: unknown }).content;
242
- if (typeof content !== "string") return msg;
243
- if (this.#seenContext.get(type) === content) {
244
- return { ...(msg as object), content: "(unchanged — still in effect)" } as AgentMessage;
380
+ // Narrowed to CustomMessage: customType and content are properly typed.
381
+ if (!PRIMARY_CONTEXT_CUSTOM_TYPES.has(msg.customType)) return msg;
382
+ if (typeof msg.content !== "string") return msg;
383
+ if (this.#seenContext.get(msg.customType) === msg.content) {
384
+ return { ...msg, content: "(unchanged still in effect)" };
245
385
  }
246
- this.#seenContext.set(type, content);
386
+ this.#seenContext.set(msg.customType, msg.content);
247
387
  return msg;
248
388
  }
249
389
 
@@ -284,46 +424,90 @@ export class AdvisorRuntime {
284
424
  }
285
425
  }
286
426
 
427
+ /**
428
+ * Collect all currently pending deltas into one batch, running
429
+ * `maintainContext` for correct token budgeting. Loops until the pending
430
+ * queue is stable (no new deltas arrived during a maintenance check) or a
431
+ * reprime is triggered. Every `await` inside the loop has an epoch guard so
432
+ * a reset/dispose mid-await cannot leak a stale batch into the post-reset
433
+ * conversation.
434
+ *
435
+ * The coalescing loop is capped at {@link MAX_COALESCE_ROUNDS} iterations so
436
+ * a pathologically fast primary combined with a slow `maintainContext` cannot
437
+ * stall dispatch indefinitely — any items still in `#pending` after the cap
438
+ * are left for the next drain iteration.
439
+ *
440
+ * Returns `null` when the epoch was invalidated — caller should `continue`.
441
+ * Returns `{ batch: null, finalTurns }` when there is nothing to render but
442
+ * backlog still needs to be decremented.
443
+ */
444
+ async #collectAndMaintainBatch(
445
+ epoch: number,
446
+ ): Promise<{ batch: string | null; finalTurns: number; wip: boolean } | null> {
447
+ const initial = this.#pending.splice(0);
448
+ let batchText = initial.map(b => b.text).join("\n\n");
449
+ let turns = initial.reduce((sum, b) => sum + b.turns, 0);
450
+ // Track WIP state of the most recent delta — forwarded to the reprime
451
+ // #renderDelta so a willContinue:true turn keeps its [in progress] heading
452
+ // even when the full transcript is replayed from scratch. Also returned to
453
+ // #drain so the retry-requeue path preserves it on failed turns.
454
+ let wip = initial.at(-1)?.wip ?? false;
455
+
456
+ for (let round = 0; round < MAX_COALESCE_ROUNDS; round++) {
457
+ if (this.host.maintainContext) {
458
+ const incomingTokens = estimateTokens({ role: "user", content: batchText, timestamp: Date.now() });
459
+ let shouldReprime = false;
460
+ try {
461
+ shouldReprime = await this.host.maintainContext(incomingTokens);
462
+ } catch (err) {
463
+ logger.debug("advisor context maintenance failed", { err: String(err) });
464
+ }
465
+ // Epoch guard — a reset/dispose during the maintainContext await
466
+ // invalidates this batch.
467
+ if (this.#epoch !== epoch) return null;
468
+
469
+ if (shouldReprime) {
470
+ // Tally deltas that arrived during this await before #resetAdvisorContext
471
+ // wipes #pending, so finalTurns stays accurate for backlog accounting.
472
+ // Also capture the latest WIP state before the queue is cleared.
473
+ const lateItems = this.#pending.splice(0);
474
+ turns += lateItems.reduce((sum, b) => sum + b.turns, 0);
475
+ if (lateItems.length > 0) wip = lateItems.at(-1)!.wip;
476
+ this.#resetAdvisorContext(false, false);
477
+ return { batch: this.#renderDelta(this.#latestMessages, wip), finalTurns: turns, wip };
478
+ }
479
+ }
480
+
481
+ // On the final round stop here — any late arrivals would ship without
482
+ // a subsequent maintainContext budget check. Leave them in #pending for
483
+ // the next drain iteration where they will be properly budgeted.
484
+ if (round === MAX_COALESCE_ROUNDS - 1) break;
485
+
486
+ // Coalesce any deltas that arrived while we were awaiting maintenance.
487
+ // If none arrived the batch is stable and we're done; otherwise merge,
488
+ // update WIP state, and re-check the maintenance budget.
489
+ const late = this.#pending.splice(0);
490
+ if (late.length === 0) break;
491
+ batchText = [batchText, ...late.map(b => b.text)].join("\n\n");
492
+ turns += late.reduce((sum, b) => sum + b.turns, 0);
493
+ wip = late.at(-1)!.wip;
494
+ }
495
+
496
+ return { batch: batchText || null, finalTurns: turns, wip };
497
+ }
498
+
287
499
  async #drain(): Promise<void> {
288
500
  if (this.#busy) return;
289
501
  this.#busy = true;
290
502
  try {
291
503
  while (!this.disposed && this.#pending.length) {
292
- const popped = this.#pending.splice(0);
293
504
  const epoch = this.#epoch;
294
- // Each delta already opens with a `### Session update` heading, so
295
- // join with a blank line rather than a `---` rule.
296
- const candidateBatch = popped.map(b => b.text).join("\n\n");
297
- const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0);
298
- const incomingTokens = estimateTokens({
299
- role: "user",
300
- content: candidateBatch,
301
- timestamp: Date.now(),
302
- });
505
+ const result = await this.#collectAndMaintainBatch(epoch);
303
506
 
304
- let shouldReprime = false;
305
- if (this.host.maintainContext) {
306
- try {
307
- shouldReprime = await this.host.maintainContext(incomingTokens);
308
- } catch (err) {
309
- logger.debug("advisor context maintenance failed", { err: String(err) });
310
- }
311
- }
312
- // A reset/dispose during context maintenance invalidates this batch.
313
- if (this.#epoch !== epoch) continue;
507
+ // Epoch was invalidated during batch collection; restart the loop.
508
+ if (result === null) continue;
314
509
 
315
- let batch: string | null;
316
- let finalTurns: number;
317
- if (shouldReprime) {
318
- // Promotion could not fit the advisor's context — re-prime.
319
- const newTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
320
- this.#resetAdvisorContext(false, false);
321
- batch = this.#renderDelta(this.#latestMessages);
322
- finalTurns = turnsCovered + newTurns;
323
- } else {
324
- batch = candidateBatch;
325
- finalTurns = turnsCovered;
326
- }
510
+ const { batch, finalTurns, wip } = result;
327
511
 
328
512
  if (this.disposed || batch === null) {
329
513
  this.#backlog = Math.max(0, this.#backlog - finalTurns);
@@ -333,33 +517,34 @@ export class AdvisorRuntime {
333
517
 
334
518
  let success = false;
335
519
  // Capture the advisor's message count BEFORE the prompt so a failure can
336
- // roll back the user batch + synthetic assistant-error turn `Agent.#runLoop`
337
- // appends to internal state. Without this, a retry would replay the
338
- // failed batch on top of the stale turns and the dropped-after-3 path
339
- // would leak orphan failures into the next successful run's context.
520
+ // roll back the user batch + synthetic assistant-error turn Agent.#runLoop
521
+ // appends to internal state. Without this, a retry would replay the failed
522
+ // batch on top of stale turns and the dropped-after-3 path would leak
523
+ // orphan failures into the next successful run's context.
340
524
  const messageSnapshot = this.agent.state.messages.length;
341
525
  try {
342
526
  // Reset the host's per-update advisor state (one-advise-per-update
343
- // gate) before each model cycle, so the new batch starts with a
344
- // fresh budget. Dedupe history persists across cycles.
527
+ // gate) before each model cycle so the new batch starts fresh.
345
528
  this.host.beginAdvisorUpdate?.();
346
529
  await this.agent.prompt(batch);
347
- // `Agent.#runLoop` catches provider/stream failures internally and
348
- // resolves `prompt()` cleanly with the assistant turn ending in
349
- // `stopReason: "error"` and the message recorded on `state.error`.
350
- // Treat that as a failed turn so OpenRouter ZDR-style endpoint
351
- // rejections trip the retry/notify path instead of looking like a
352
- // successful empty cycle.
530
+ // Agent.#runLoop catches provider/stream failures internally and
531
+ // resolves prompt() cleanly with stopReason: "error". Treat that
532
+ // as a failed turn so endpoint rejections trip the retry path.
353
533
  const promptError = this.agent.state.error;
354
534
  if (promptError) throw new Error(promptError);
535
+ // A content-less stop is a deliberate silent review — the documented
536
+ // verifier behavior ("prefer silence when the agent is on track") — and
537
+ // completes the turn. Sessions can legitimately have nothing to advise
538
+ // on for any number of consecutive turns, so silence is never warned
539
+ // about (#5216 did, spamming "Advisor unavailable" at quiet models).
540
+ const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot));
541
+ if (turnError) throw turnError;
355
542
  success = true;
356
543
  this.#consecutiveFailures = 0;
357
544
  this.#failureNotified = false;
358
545
  } catch (err) {
359
- // reset()/dispose() aborts the in-flight prompt; the rejection is the
360
- // reset itself, not a transient advisor failure. Drop the stale batch
361
- // (reset already cleared #pending and rewound the cursor) instead of
362
- // requeuing it into the post-reset conversation.
546
+ // reset()/dispose() aborts the in-flight prompt; treat it as a
547
+ // reset, not a transient failure drop the stale batch.
363
548
  if (this.#epoch !== epoch) continue;
364
549
  this.#rollbackFailedTurn(messageSnapshot);
365
550
  logger.debug("advisor turn failed", { err: String(err) });
@@ -368,8 +553,15 @@ export class AdvisorRuntime {
368
553
  } catch (hookErr) {
369
554
  logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
370
555
  }
371
- // The hook awaits; a reset during it invalidates this batch like the
372
- // prompt await above drop it instead of requeueing stale content.
556
+ if (err instanceof AdvisorOutputQuarantinedError) {
557
+ const rePrime = this.#pending.length > 0 ? this.#latestMessages : undefined;
558
+ // Wake catchup waiters only when nothing is re-primed; otherwise the
559
+ // re-primed turn restores the backlog and waiters resolve on its completion.
560
+ this.#resetAdvisorContext(true, !rePrime);
561
+ if (rePrime) this.onTurnEnd(rePrime);
562
+ continue;
563
+ }
564
+ // Epoch guard after the async error hook.
373
565
  if (this.#epoch !== epoch) continue;
374
566
  this.#consecutiveFailures++;
375
567
  if (this.#consecutiveFailures >= 3) {
@@ -383,13 +575,13 @@ export class AdvisorRuntime {
383
575
  }
384
576
  }
385
577
  this.#consecutiveFailures = 0;
386
- // The dropped batch may carry primary-context we never delivered; drop
387
- // the seen-state too so the next turn re-expands it instead of marking
388
- // it "unchanged" against content the advisor never received.
578
+ // Drop the seen-context so the next turn re-expands primary-context
579
+ // prompts instead of marking them "unchanged" against content the
580
+ // advisor never received.
389
581
  this.#seenContext.clear();
390
582
  success = true;
391
583
  } else {
392
- this.#pending.unshift({ text: batch, turns: finalTurns });
584
+ this.#pending.unshift({ text: batch, turns: finalTurns, wip });
393
585
  await Bun.sleep(this.retryDelayMs);
394
586
  }
395
587
  }
@@ -405,6 +597,17 @@ export class AdvisorRuntime {
405
597
  }
406
598
  }
407
599
 
600
+ /**
601
+ * The only malformed advisor turn shape: the prompt resolved but produced no
602
+ * assistant response at all. Everything an assistant message carries — advice,
603
+ * reasoning, or deliberate silence (empty `stop`) — is a completed review.
604
+ */
605
+ function getAdvisorTurnError(messages: readonly AgentMessage[]): Error | undefined {
606
+ if (messages.length === 0) return undefined;
607
+ if (messages.some(message => message.role === "assistant")) return undefined;
608
+ return new Error("Advisor turn ended without an assistant response");
609
+ }
610
+
408
611
  type TextualContent = string | readonly (TextContent | ImageContent)[];
409
612
 
410
613
  function obfuscateTextualContent(obfuscator: SecretObfuscator, content: TextualContent): TextualContent {
@@ -76,11 +76,11 @@ export class AutoLearnController {
76
76
  return;
77
77
  }
78
78
  if (event.type === "agent_end") {
79
- this.#onAgentEnd();
79
+ this.#onAgentEnd(event);
80
80
  }
81
81
  }
82
82
 
83
- #onAgentEnd(): void {
83
+ #onAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
84
84
  // Snapshot and reset every turn: the counter describes only the
85
85
  // just-finished turn, so below-threshold, disabled, and plan-mode stops
86
86
  // must not let tool calls accumulate into a later turn.
@@ -95,6 +95,18 @@ export class AutoLearnController {
95
95
  this.#suppressNext = false;
96
96
  return;
97
97
  }
98
+ // Never nudge a turn that ended in an abort (ESC, cancel, etc.). The
99
+ // abort flag on the session is unreliable by the time agent_end is
100
+ // deferred to subscribers; read stopReason from the event messages.
101
+ for (let i = event.messages.length - 1; i >= 0; i--) {
102
+ const message = event.messages[i];
103
+ if (message && typeof message === "object" && "role" in message && message.role === "assistant") {
104
+ if ("stopReason" in message && message.stopReason === "aborted") {
105
+ return;
106
+ }
107
+ break;
108
+ }
109
+ }
98
110
  // Honor a live opt-out: the subscription outlives the setting, so re-check
99
111
  // the current flag rather than trusting install-time state.
100
112
  if (!this.#settings.get("autolearn.enabled")) return;
@@ -132,7 +144,7 @@ export class AutoLearnController {
132
144
  display: false,
133
145
  attribution: "user",
134
146
  },
135
- { deliverAs: "nextTurn", triggerTurn: true },
147
+ { deliverAs: "nextTurn", triggerTurn: true, acceptTerminalEmptyStop: true },
136
148
  )
137
149
  .then(started => {
138
150
  if (!started) this.#suppressNext = false;
@@ -95,9 +95,9 @@ export function createDashboardController(): DashboardController {
95
95
  done(undefined);
96
96
  return;
97
97
  }
98
- if (matchesKey(data, "up") || data === "k") {
98
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
99
99
  scrollOffset = Math.max(0, scrollOffset - 1);
100
- } else if (matchesKey(data, "down") || data === "j") {
100
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
101
101
  scrollOffset = Math.min(maxScroll, scrollOffset + 1);
102
102
  } else if (matchesKey(data, "pageUp")) {
103
103
  scrollOffset = Math.max(0, scrollOffset - viewportRows);
package/src/cli/args.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  STRING_SETTERS,
14
14
  STRING_VALUE_FLAGS,
15
15
  } from "./flag-tables";
16
+ import { CliUsageError } from "./usage-error";
16
17
 
17
18
  export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
18
19
 
@@ -310,6 +311,17 @@ export function reportUnrecognizedFlags(
310
311
  return true;
311
312
  }
312
313
 
314
+ /** Emit a clean CLI usage error without an internal stack trace. */
315
+ export function reportCliUsageError(
316
+ error: unknown,
317
+ write: (text: string) => void = text => process.stderr.write(text),
318
+ ): boolean {
319
+ if (!(error instanceof CliUsageError)) return false;
320
+ write(`${chalk.red(`Error: ${error.message}`)}\n`);
321
+ write(`Run \`${APP_NAME} --help\` for available flags.\n`);
322
+ return true;
323
+ }
324
+
313
325
  export function getExtraHelpText(): string {
314
326
  return `${chalk.bold("Environment Variables:")}
315
327
  ${chalk.dim("# Core Providers")}
@@ -660,19 +660,25 @@ interface MigrateSkip {
660
660
 
661
661
  function credentialIdentity(provider: string, credential: AuthCredential): string {
662
662
  if (credential.type === "api_key") return "(api key)";
663
- return credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`;
663
+ const base = credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`;
664
+ return credential.orgId ? `${base} (${credential.orgName ?? credential.orgId})` : base;
664
665
  }
665
666
 
666
667
  /**
667
668
  * Build the set of "identities already on the broker" so re-runs are idempotent.
668
- * For OAuth, identity = email|accountId|projectId. For api_key, we collapse
669
- * to a single marker per provider (broker has no concept of "multiple api keys
670
- * per provider with different identities"; upsert would coalesce them).
669
+ * For OAuth, identity = email|accountId|projectId, each org-qualified when the
670
+ * row carries an organization (one Anthropic email can hold a Team seat AND a
671
+ * personal Max plan those must migrate as two rows). A row with NO base
672
+ * identity but an orgId (login recovered neither email nor account) is marked
673
+ * by the org alone, so re-running migrate does not re-upload a stale refresh
674
+ * token over the broker's newer one. For api_key, we collapse to a single
675
+ * marker per provider (broker has no concept of "multiple api keys per
676
+ * provider with different identities"; upsert would coalesce them).
671
677
  */
672
678
  function indexBrokerSnapshot(snapshot: {
673
679
  credentials: Array<{
674
680
  provider: string;
675
- credential: { type: string; email?: string; accountId?: string; projectId?: string };
681
+ credential: { type: string; email?: string; accountId?: string; projectId?: string; orgId?: string };
676
682
  }>;
677
683
  }): Map<string, Set<string>> {
678
684
  const out = new Map<string, Set<string>>();
@@ -681,9 +687,18 @@ function indexBrokerSnapshot(snapshot: {
681
687
  if (entry.credential.type === "api_key") {
682
688
  ids.add("@api_key");
683
689
  } else {
684
- if (entry.credential.email) ids.add(`email:${entry.credential.email}`);
685
- if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}`);
686
- if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}`);
690
+ const orgSuffix = entry.credential.orgId ? `|org:${entry.credential.orgId}` : "";
691
+ if (entry.credential.email) ids.add(`email:${entry.credential.email}${orgSuffix}`);
692
+ if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}${orgSuffix}`);
693
+ if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}${orgSuffix}`);
694
+ if (
695
+ !entry.credential.email &&
696
+ !entry.credential.accountId &&
697
+ !entry.credential.projectId &&
698
+ entry.credential.orgId
699
+ ) {
700
+ ids.add(`org:${entry.credential.orgId}`);
701
+ }
687
702
  }
688
703
  out.set(entry.provider, ids);
689
704
  }
@@ -694,9 +709,13 @@ function brokerAlreadyHas(existing: Map<string, Set<string>>, provider: string,
694
709
  const ids = existing.get(provider);
695
710
  if (!ids) return false;
696
711
  if (credential.type === "api_key") return ids.has("@api_key");
697
- if (credential.email && ids.has(`email:${credential.email}`)) return true;
698
- if (credential.accountId && ids.has(`accountId:${credential.accountId}`)) return true;
699
- if (credential.projectId && ids.has(`projectId:${credential.projectId}`)) return true;
712
+ const orgSuffix = credential.orgId ? `|org:${credential.orgId}` : "";
713
+ if (credential.email && ids.has(`email:${credential.email}${orgSuffix}`)) return true;
714
+ if (credential.accountId && ids.has(`accountId:${credential.accountId}${orgSuffix}`)) return true;
715
+ if (credential.projectId && ids.has(`projectId:${credential.projectId}${orgSuffix}`)) return true;
716
+ if (!credential.email && !credential.accountId && !credential.projectId && credential.orgId) {
717
+ return ids.has(`org:${credential.orgId}`);
718
+ }
700
719
  return false;
701
720
  }
702
721
 
@@ -573,8 +573,12 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
573
573
  : row.ok === false
574
574
  ? chalk.red("FAIL ")
575
575
  : chalk.yellow("unknown ");
576
- const identity =
576
+ const base =
577
577
  row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
578
+ // Two subscriptions (orgs) can share one email — without the org a
579
+ // failed row can't say which subscription needs re-login.
580
+ const org = row.orgName ?? row.orgId;
581
+ const identity = org && org !== base ? `${base} (${org})` : base;
578
582
  const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
579
583
  const reasonParts: string[] = [];
580
584
  if (row.reason) reasonParts.push(row.reason);