@oh-my-pi/pi-coding-agent 16.2.7 → 16.2.9

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 (62) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli.js +3661 -3482
  3. package/dist/types/config/settings-schema.d.ts +97 -13
  4. package/dist/types/edit/hashline/filesystem.d.ts +1 -0
  5. package/dist/types/modes/components/mcp-add-wizard.d.ts +8 -0
  6. package/dist/types/modes/controllers/mcp-command-controller.d.ts +9 -0
  7. package/dist/types/session/agent-session.d.ts +1 -1
  8. package/dist/types/session/session-context.d.ts +0 -5
  9. package/dist/types/session/settings-stream-fn.d.ts +2 -2
  10. package/dist/types/stt/index.d.ts +1 -0
  11. package/dist/types/stt/stt-controller.d.ts +2 -0
  12. package/dist/types/stt/submit-trigger.d.ts +30 -0
  13. package/dist/types/task/index.d.ts +1 -1
  14. package/dist/types/task/types.d.ts +6 -6
  15. package/dist/types/tiny/models.d.ts +22 -8
  16. package/dist/types/tools/output-schema-validator.d.ts +10 -0
  17. package/package.json +12 -12
  18. package/src/commit/agentic/agent.ts +1 -1
  19. package/src/commit/agentic/prompts/system.md +1 -1
  20. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  21. package/src/config/settings-schema.ts +54 -2
  22. package/src/debug/profiler.ts +7 -1
  23. package/src/discovery/builtin-rules/go-add-cleanup.md +32 -0
  24. package/src/discovery/builtin-rules/go-bench-loop.md +36 -0
  25. package/src/discovery/builtin-rules/go-exp-promoted.md +39 -0
  26. package/src/discovery/builtin-rules/go-ioutil.md +36 -0
  27. package/src/discovery/builtin-rules/go-join-hostport.md +29 -0
  28. package/src/discovery/builtin-rules/go-new-expr.md +44 -0
  29. package/src/discovery/builtin-rules/go-rand-v2.md +40 -0
  30. package/src/discovery/builtin-rules/go-range-int.md +45 -0
  31. package/src/discovery/builtin-rules/index.ts +16 -0
  32. package/src/edit/hashline/filesystem.ts +12 -0
  33. package/src/internal-urls/docs-index.generated.txt +1 -1
  34. package/src/mcp/oauth-flow.ts +35 -8
  35. package/src/modes/components/mcp-add-wizard.ts +43 -3
  36. package/src/modes/components/model-selector.ts +21 -9
  37. package/src/modes/controllers/event-controller.ts +9 -0
  38. package/src/modes/controllers/mcp-command-controller.ts +84 -3
  39. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  40. package/src/modes/interactive-mode.ts +5 -4
  41. package/src/prompts/agents/tester.md +107 -0
  42. package/src/prompts/system/orchestrate-notice.md +2 -2
  43. package/src/prompts/system/system-prompt.md +2 -5
  44. package/src/prompts/system/thinking-loop-redirect.md +10 -0
  45. package/src/prompts/system/workflow-notice.md +1 -1
  46. package/src/prompts/tools/bash.md +1 -4
  47. package/src/prompts/tools/task.md +2 -9
  48. package/src/session/agent-session.ts +161 -24
  49. package/src/session/session-context.ts +13 -2
  50. package/src/session/settings-stream-fn.ts +12 -2
  51. package/src/stt/index.ts +1 -0
  52. package/src/stt/stt-controller.ts +31 -2
  53. package/src/stt/submit-trigger.ts +74 -0
  54. package/src/task/agents.ts +4 -4
  55. package/src/task/executor.ts +1 -1
  56. package/src/task/index.ts +18 -5
  57. package/src/task/types.ts +5 -5
  58. package/src/tiny/models.ts +10 -0
  59. package/src/tools/grep.ts +19 -1
  60. package/src/tools/output-schema-validator.ts +38 -0
  61. package/src/tools/yield.ts +52 -15
  62. package/src/prompts/agents/oracle.md +0 -54
@@ -153,14 +153,44 @@ function resolveCallbackHostname(redirectUri: string | undefined): string | unde
153
153
  return parsed.hostname;
154
154
  }
155
155
 
156
+ /**
157
+ * Resolve the client_id MCPOAuthFlow would use without doing any I/O —
158
+ * either the explicitly configured value or one embedded as a query parameter
159
+ * in the authorization URL. Returns `undefined` when no client_id is known
160
+ * statically, which is the trigger for dynamic client registration in
161
+ * {@link MCPOAuthFlow.#tryRegisterClient}.
162
+ */
163
+ function staticClientIdFromConfig(config: MCPOAuthConfig): string | undefined {
164
+ const fromConfig = config.clientId?.trim();
165
+ if (fromConfig) return fromConfig;
166
+ try {
167
+ return new URL(config.authorizationUrl).searchParams.get("client_id") ?? undefined;
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ }
172
+
156
173
  function resolveCallbackOptions(config: MCPOAuthConfig): OAuthCallbackFlowOptions {
157
174
  const redirectUri = resolveRedirectUri(config.redirectUri);
158
175
  validateRedirectConfig(config, redirectUri);
176
+ // When a client_id is already pinned (config-supplied or embedded in the
177
+ // authorization URL), it was registered against a specific redirect URI.
178
+ // Silently advertising a different port at the authorize endpoint would
179
+ // be rejected by providers like Atlassian (HTTP 500 in the browser, local
180
+ // flow hangs until the 5-minute timeout), so fail fast instead.
181
+ //
182
+ // When no client_id is pinned, MCPOAuthFlow will attempt dynamic client
183
+ // registration on demand with whichever loopback URI we actually bound —
184
+ // the provider issues a client_id tied to *that* URI, so the random-port
185
+ // fallback remains safe for first-install DCR flows whose preferred port
186
+ // happens to be occupied.
187
+ const allowPortFallback = staticClientIdFromConfig(config) === undefined;
159
188
  return {
160
189
  preferredPort: resolveCallbackPort(config.callbackPort, redirectUri),
161
190
  callbackPath: resolveCallbackPath(config.callbackPath, redirectUri),
162
191
  callbackHostname: resolveCallbackHostname(redirectUri),
163
192
  redirectUri,
193
+ allowPortFallback,
164
194
  };
165
195
  }
166
196
 
@@ -400,6 +430,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
400
430
  "Content-Type": "application/x-www-form-urlencoded",
401
431
  },
402
432
  body: params.toString(),
433
+ signal: this.ctrl.signal,
403
434
  });
404
435
 
405
436
  if (!response.ok) {
@@ -453,14 +484,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
453
484
  }
454
485
 
455
486
  #resolveClientId(config: MCPOAuthConfig): string | undefined {
456
- const fromConfig = config.clientId?.trim();
457
- if (fromConfig) return fromConfig;
458
-
459
- try {
460
- return new URL(config.authorizationUrl).searchParams.get("client_id") ?? undefined;
461
- } catch {
462
- return undefined;
463
- }
487
+ return staticClientIdFromConfig(config);
464
488
  }
465
489
  #resourceFromAuthorizationUrl(authorizationUrl: string): string | undefined {
466
490
  try {
@@ -495,6 +519,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
495
519
  "Content-Type": "application/json",
496
520
  Accept: "application/json",
497
521
  },
522
+ signal: this.ctrl.signal,
498
523
  body: JSON.stringify({
499
524
  client_name: "oh-my-pi",
500
525
  redirect_uris: [redirectUri],
@@ -560,6 +585,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
560
585
  const response = await this.#fetch(wellKnownUrl, {
561
586
  method: "GET",
562
587
  headers: { Accept: "application/json" },
588
+ signal: this.ctrl.signal,
563
589
  });
564
590
  if (!response.ok) return null;
565
591
  const metadata = (await response.json()) as { registration_endpoint?: string };
@@ -578,6 +604,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
578
604
  method: "GET",
579
605
  redirect: "manual",
580
606
  headers: { Accept: "text/plain,text/html,application/json" },
607
+ signal: this.ctrl.signal,
581
608
  });
582
609
  if (response.status < 400) return;
583
610
  const body = await response.text();
@@ -63,6 +63,14 @@ export interface MCPAddWizardOAuthResult {
63
63
  interface MCPAddWizardOAuthOptions {
64
64
  serverUrl?: string;
65
65
  resource?: string;
66
+ /**
67
+ * External cancellation source. Aborting it tears down the in-flight OAuth
68
+ * flow and surfaces a neutral cancellation error. The wizard wires its own
69
+ * controller here so Esc cancels the OAuth wait instead of stepping back
70
+ * through the form (the wizard is focused, so the editor's Esc hook does
71
+ * not fire).
72
+ */
73
+ abortSignal?: AbortSignal;
66
74
  }
67
75
 
68
76
  interface WizardState {
@@ -135,6 +143,12 @@ export class MCPAddWizard extends Container {
135
143
  | null = null;
136
144
  #onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
137
145
  #onRenderCallback: (() => void) | null = null;
146
+ /**
147
+ * Set while the OAuth callback is in flight; populated by
148
+ * {@link #launchOAuthFlow} and consumed by {@link handleInput} so Esc
149
+ * cancels the OAuth wait instead of stepping back through the form.
150
+ */
151
+ #oauthAbort: AbortController | null = null;
138
152
 
139
153
  constructor(
140
154
  onComplete: (name: string, config: MCPServerConfig, scope: Scope) => void,
@@ -473,6 +487,15 @@ export class MCPAddWizard extends Container {
473
487
  }
474
488
 
475
489
  handleInput(keyData: string): void {
490
+ // While an OAuth callback is being awaited, Esc/Ctrl+C aborts the flow
491
+ // rather than stepping back through the form: the wizard advertises
492
+ // "(Press Esc to cancel)" during the wait, and stepping back would
493
+ // leave the OAuth login orphaned.
494
+ if (this.#oauthAbort && (keyData === "\x03" || matchesAppInterrupt(keyData))) {
495
+ this.#oauthAbort.abort("MCP OAuth flow cancelled by user");
496
+ return;
497
+ }
498
+
476
499
  // Handle Ctrl+C to cancel wizard immediately
477
500
  if (keyData === "\x03") {
478
501
  // Ctrl+C pressed - cancel wizard
@@ -1153,6 +1176,7 @@ export class MCPAddWizard extends Container {
1153
1176
  this.#contentContainer.addChild(new Text(theme.fg("muted", "(Press Esc to cancel)"), 0, 0));
1154
1177
  this.#requestRender();
1155
1178
 
1179
+ this.#oauthAbort = new AbortController();
1156
1180
  try {
1157
1181
  // Call OAuth handler
1158
1182
  const oauthResource = this.#state.oauthResource || (this.#state.transport === "stdio" ? "" : this.#state.url);
@@ -1165,6 +1189,7 @@ export class MCPAddWizard extends Container {
1165
1189
  {
1166
1190
  serverUrl: this.#state.url || undefined,
1167
1191
  resource: oauthResource || undefined,
1192
+ abortSignal: this.#oauthAbort.signal,
1168
1193
  },
1169
1194
  );
1170
1195
 
@@ -1237,16 +1262,29 @@ export class MCPAddWizard extends Container {
1237
1262
  healthPassed ? 1000 : 2000,
1238
1263
  );
1239
1264
  } catch (error) {
1240
- // Show error with options to retry or go back
1265
+ // User cancellation has its own neutral heading + tip; everything else
1266
+ // keeps the "OAuth authentication failed" framing so the existing tips
1267
+ // stay meaningful. Name-matching avoids importing controller types.
1268
+ const cancelled = error instanceof Error && error.name === "MCPOAuthCancelledError";
1241
1269
  const errorMsg = sanitize(error instanceof Error ? error.message : String(error));
1242
1270
  this.#contentContainer.clear();
1243
- this.#contentContainer.addChild(new Text(theme.fg("error", "✗ OAuth authentication failed"), 0, 0));
1271
+ this.#contentContainer.addChild(
1272
+ new Text(
1273
+ cancelled ? theme.fg("muted", "○ OAuth cancelled") : theme.fg("error", "✗ OAuth authentication failed"),
1274
+ 0,
1275
+ 0,
1276
+ ),
1277
+ );
1244
1278
  this.#contentContainer.addChild(new Spacer(1));
1245
1279
  this.#contentContainer.addChild(new Text(errorMsg, 0, 0));
1246
1280
  this.#contentContainer.addChild(new Spacer(1));
1247
1281
 
1248
1282
  // Provide helpful tips based on error type
1249
- if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
1283
+ if (cancelled) {
1284
+ this.#contentContainer.addChild(
1285
+ new Text(theme.fg("muted", "Tip: Choose Retry to launch the browser again."), 0, 0),
1286
+ );
1287
+ } else if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
1250
1288
  this.#contentContainer.addChild(
1251
1289
  new Text(theme.fg("muted", "Tip: Complete authorization faster next time"), 0, 0),
1252
1290
  );
@@ -1272,6 +1310,8 @@ export class MCPAddWizard extends Container {
1272
1310
  // Set up as a selector step
1273
1311
  this.#selectedIndex = 0;
1274
1312
  this.#currentStep = "oauth-error";
1313
+ } finally {
1314
+ this.#oauthAbort = null;
1275
1315
  }
1276
1316
  }
1277
1317
 
@@ -24,7 +24,12 @@ import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../..
24
24
  import type { Settings } from "../../config/settings";
25
25
  import { type ThemeColor, theme } from "../../modes/theme/theme";
26
26
  import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
27
- import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
27
+ import {
28
+ AUTO_THINKING,
29
+ type ConfiguredThinkingLevel,
30
+ getConfiguredThinkingLevelMetadata,
31
+ parseConfiguredThinkingLevel,
32
+ } from "../../thinking";
28
33
  import { getTabBarTheme } from "../shared";
29
34
  import { DynamicBorder } from "./dynamic-border";
30
35
 
@@ -342,10 +347,7 @@ export class ModelSelectorComponent extends Container {
342
347
  if (resolved.model) {
343
348
  nextRoles[role] = {
344
349
  model: resolved.model,
345
- thinkingLevel:
346
- resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
347
- ? resolved.thinkingLevel
348
- : ThinkingLevel.Inherit,
350
+ thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
349
351
  autoSelected: false,
350
352
  };
351
353
  }
@@ -363,10 +365,7 @@ export class ModelSelectorComponent extends Container {
363
365
  if (!resolved.model) continue;
364
366
  nextRoles[role] = {
365
367
  model: resolved.model,
366
- thinkingLevel:
367
- resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
368
- ? resolved.thinkingLevel
369
- : ThinkingLevel.Inherit,
368
+ thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
370
369
  autoSelected: true,
371
370
  };
372
371
  }
@@ -1059,6 +1058,19 @@ export class ModelSelectorComponent extends Container {
1059
1058
  );
1060
1059
  }
1061
1060
  }
1061
+ #getResolvedRoleThinkingLevel(
1062
+ role: string,
1063
+ resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ThinkingLevel },
1064
+ ): ConfiguredThinkingLevel {
1065
+ if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
1066
+ return resolved.thinkingLevel;
1067
+ }
1068
+ if (role === "default") {
1069
+ return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
1070
+ }
1071
+ return ThinkingLevel.Inherit;
1072
+ }
1073
+
1062
1074
  #getThinkingLevelsForModel(model: Model): ReadonlyArray<ConfiguredThinkingLevel> {
1063
1075
  return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
1064
1076
  }
@@ -609,6 +609,15 @@ export class EventController {
609
609
  }
610
610
  for (const content of this.ctx.streamingMessage.content) {
611
611
  if (content.type !== "toolCall") continue;
612
+ // Anthropic/OpenAI open a streamed tool block with an empty id (and
613
+ // `{}` args) before the id/arguments arrive; Gemini assembles the
614
+ // whole call first, so it never hits this. Keying `pendingTools` by
615
+ // "" would create a placeholder card, and the later real-id frame —
616
+ // `pendingTools.has(realId)` false — would create a SECOND card,
617
+ // orphaning the blank one (no `tool_execution_*` event ever carries
618
+ // "", so it is never matched, updated, or removed). Defer until the
619
+ // provider assigns the real id.
620
+ if (!content.id) continue;
612
621
  if (content.name === "read") {
613
622
  if (!readArgsHaveTarget(content.arguments)) {
614
623
  // Args still streaming — defer until path is parseable so we can route to the
@@ -62,6 +62,16 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string,
62
62
  }, timeoutMs);
63
63
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
64
64
  }
65
+ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createError: () => Error): Promise<T> {
66
+ if (signal.aborted) return Promise.reject(createError());
67
+
68
+ const aborted = Promise.withResolvers<never>();
69
+ const onAbort = (): void => aborted.reject(createError());
70
+ signal.addEventListener("abort", onAbort, { once: true });
71
+ return Promise.race([promise, aborted.promise]).finally(() => {
72
+ signal.removeEventListener("abort", onAbort);
73
+ });
74
+ }
65
75
 
66
76
  /** Renders the MCP OAuth fallback URL without hard-wrapping the copy target. */
67
77
  export class MCPAuthorizationLinkPrompt implements Component {
@@ -135,6 +145,22 @@ interface OAuthFlowResult {
135
145
  resource?: string;
136
146
  }
137
147
 
148
+ /**
149
+ * Thrown by {@link MCPCommandController}'s OAuth handler when the user (or a
150
+ * caller-supplied {@link AbortSignal}) cancels the in-flight flow. Distinct
151
+ * from network/timeout failures so callers can surface a neutral
152
+ * "cancelled" status instead of an error banner.
153
+ */
154
+ export class MCPOAuthCancelledError extends Error {
155
+ constructor(message = "OAuth flow cancelled") {
156
+ super(message);
157
+ this.name = "MCPOAuthCancelledError";
158
+ }
159
+ }
160
+
161
+ /** Reason recorded on the OAuth flow's AbortController when the user hits Esc. */
162
+ const MCP_OAUTH_USER_CANCEL_REASON = "MCP OAuth flow cancelled by user";
163
+
138
164
  type MCPAddScope = "user" | "project";
139
165
  type MCPAddTransport = "http" | "sse";
140
166
 
@@ -521,6 +547,10 @@ export class MCPCommandController {
521
547
  userClientSecret: finalConfig.oauth?.clientSecret,
522
548
  });
523
549
  } catch (oauthError) {
550
+ if (oauthError instanceof MCPOAuthCancelledError) {
551
+ this.ctx.showStatus(`Add cancelled for "${parsed.initialName}"`);
552
+ return;
553
+ }
524
554
  this.ctx.showError(
525
555
  `OAuth flow failed for "${parsed.initialName}": ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`,
526
556
  );
@@ -587,6 +617,14 @@ export class MCPCommandController {
587
617
  serverUrl?: string;
588
618
  resource?: string;
589
619
  stripSameOriginResource?: boolean;
620
+ /**
621
+ * External cancellation source: when this signal aborts, the in-flight
622
+ * OAuth flow is torn down and {@link MCPOAuthCancelledError} is thrown.
623
+ * Wizards (which own focus and absorb Esc themselves) pass their own
624
+ * controller here; editor-focused callers rely on the Esc hook
625
+ * installed below instead.
626
+ */
627
+ abortSignal?: AbortSignal;
590
628
  },
591
629
  ): Promise<OAuthFlowResult> {
592
630
  const authStorage = this.ctx.session.modelRegistry.authStorage;
@@ -614,6 +652,26 @@ export class MCPCommandController {
614
652
  }
615
653
  let manualInputClaim: { promise: Promise<string>; clear: (reason?: string) => void } | undefined;
616
654
  const oauthTimeout = new AbortController();
655
+ // User Esc and external aborts route through here; the timeout path sets
656
+ // its own reason and leaves this flag false so the catch can distinguish
657
+ // "user cancelled" (status) from "deadline elapsed" (error).
658
+ let userCancelled = false;
659
+ const requestUserCancel = (reason: string): void => {
660
+ userCancelled = true;
661
+ if (!oauthTimeout.signal.aborted) oauthTimeout.abort(reason);
662
+ };
663
+ const originalOnEscape = this.ctx.editor.onEscape;
664
+ this.ctx.editor.onEscape = () => requestUserCancel(MCP_OAUTH_USER_CANCEL_REASON);
665
+ const externalSignal = opts?.abortSignal;
666
+ const onExternalAbort = (): void => {
667
+ const reason = externalSignal?.reason;
668
+ requestUserCancel(typeof reason === "string" ? reason : MCP_OAUTH_USER_CANCEL_REASON);
669
+ };
670
+ if (externalSignal?.aborted) {
671
+ onExternalAbort();
672
+ } else {
673
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
674
+ }
617
675
  try {
618
676
  // Create OAuth flow
619
677
  const flow = new MCPOAuthFlow(
@@ -641,7 +699,7 @@ export class MCPCommandController {
641
699
  block.addChild(new Spacer(1));
642
700
  block.addChild(
643
701
  new Text(
644
- theme.fg("muted", "Waiting for authorization... (Press Ctrl+C to cancel, 5 minute timeout)"),
702
+ theme.fg("muted", "Waiting for authorization... (Press Esc to cancel, 5 minute timeout)"),
645
703
  1,
646
704
  0,
647
705
  ),
@@ -687,9 +745,18 @@ export class MCPCommandController {
687
745
  },
688
746
  );
689
747
 
690
- // Execute OAuth flow with 5 minute timeout
748
+ const createAbortError = (): Error => {
749
+ const reason = String(oauthTimeout.signal.reason ?? "MCP OAuth flow aborted");
750
+ return userCancelled ? new MCPOAuthCancelledError() : new Error(reason);
751
+ };
752
+ if (oauthTimeout.signal.aborted) throw createAbortError();
753
+
754
+ // Execute OAuth flow with 5 minute timeout. Race the login itself
755
+ // against the abort signal because Esc/external abort may fire before
756
+ // MCPOAuthFlow reaches OAuthCallbackFlow.#waitForCallback, where the
757
+ // underlying callback server normally observes the signal.
691
758
  const credentials = await withTimeout(
692
- flow.login(),
759
+ raceAbortSignal(flow.login(), oauthTimeout.signal, createAbortError),
693
760
  5 * 60 * 1000,
694
761
  "OAuth flow timed out after 5 minutes",
695
762
  () => oauthTimeout.abort("MCP OAuth flow timed out"),
@@ -727,6 +794,14 @@ export class MCPCommandController {
727
794
  resource: flow.resource,
728
795
  };
729
796
  } catch (error) {
797
+ // User-initiated cancel (Esc or external signal) → neutral status, not
798
+ // a failure. Check the flag we set in `requestUserCancel`, not the
799
+ // abort reason: the timeout path also aborts but with a different
800
+ // reason, and we want it to surface as a timeout error below.
801
+ if (userCancelled) {
802
+ throw new MCPOAuthCancelledError();
803
+ }
804
+
730
805
  const errorMsg = error instanceof Error ? error.message : String(error);
731
806
 
732
807
  // Provide helpful error messages based on failure type
@@ -742,6 +817,8 @@ export class MCPCommandController {
742
817
  throw new Error(`OAuth authentication failed: ${errorMsg}`);
743
818
  }
744
819
  } finally {
820
+ this.ctx.editor.onEscape = originalOnEscape;
821
+ externalSignal?.removeEventListener("abort", onExternalAbort);
745
822
  manualInputClaim?.clear("Manual MCP OAuth input cleared");
746
823
  }
747
824
  }
@@ -1629,6 +1706,10 @@ export class MCPCommandController {
1629
1706
  ];
1630
1707
  this.#showMessage(lines.join("\n"));
1631
1708
  } catch (error) {
1709
+ if (error instanceof MCPOAuthCancelledError) {
1710
+ this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
1711
+ return;
1712
+ }
1632
1713
  this.ctx.showError(`Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`);
1633
1714
  }
1634
1715
  }
@@ -144,7 +144,7 @@ export class ToolArgsRevealController {
144
144
  entry = {
145
145
  component: undefined,
146
146
  target: partialJson,
147
- revealed: 0,
147
+ revealed: clampSliceEnd(partialJson, partialJson.length),
148
148
  rawInput,
149
149
  exposeRawPartialJson,
150
150
  parsedArgs: {},
@@ -100,6 +100,7 @@ import { formatDuration } from "../slash-commands/helpers/format";
100
100
  import { STTController, type SttState } from "../stt";
101
101
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
102
102
  import { formatTaskId } from "../task/render";
103
+ import type { ConfiguredThinkingLevel } from "../thinking";
103
104
  import type { LspStartupServerInfo } from "../tools";
104
105
  import { normalizeLocalScheme } from "../tools/path-utils";
105
106
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
@@ -493,8 +494,8 @@ export class InteractiveMode implements InteractiveModeContext {
493
494
  #goalTurnHadToolCalls = false;
494
495
  #goalContinuationTurnInFlight = false;
495
496
  #goalSuppressNextContinuation = false;
496
- #planModePreviousModelState: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
497
- #pendingModelSwitch: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
497
+ #planModePreviousModelState: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
498
+ #pendingModelSwitch: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
498
499
  #planModeHasEntered = false;
499
500
  #planReviewOverlay: PlanReviewOverlay | undefined;
500
501
  #planReviewOverlayHandle: OverlayHandle | undefined;
@@ -1917,7 +1918,7 @@ export class InteractiveMode implements InteractiveModeContext {
1917
1918
  const planThinkingLevel = resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined;
1918
1919
 
1919
1920
  this.#planModePreviousModelState = currentModel
1920
- ? { model: currentModel, thinkingLevel: this.session.thinkingLevel }
1921
+ ? { model: currentModel, thinkingLevel: this.session.configuredThinkingLevel() }
1921
1922
  : undefined;
1922
1923
 
1923
1924
  if (!sameModel) {
@@ -2125,7 +2126,7 @@ export class InteractiveMode implements InteractiveModeContext {
2125
2126
  });
2126
2127
  }
2127
2128
 
2128
- async #restorePlanPreviousModel(prev: { model: Model; thinkingLevel?: ThinkingLevel }): Promise<void> {
2129
+ async #restorePlanPreviousModel(prev: { model: Model; thinkingLevel?: ConfiguredThinkingLevel }): Promise<void> {
2129
2130
  if (modelsAreEqual(this.session.model, prev.model)) {
2130
2131
  // Same model — only thinking level may differ. Avoid setModelTemporary()
2131
2132
  // which would reset provider-side sessions and break continuity.
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: Tester
3
+ description: Authoritative test writer. ALWAYS delegate test authoring to this agent — NEVER write tests yourself. Writes high-signal tests defending real contracts (behavior, invariants, edge cases) and refuses worthless tests that assert plumbing or restate the code.
4
+ tools: read, grep, glob, bash, edit, write, lsp, ast_grep, ast_edit
5
+ spawns: explore
6
+ model: pi/task
7
+ thinking-level: high
8
+ ---
9
+
10
+ <system-conventions>
11
+ RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` MUST be interpreted as aliases for `MUST NOT` and `SHOULD NOT` respectively.
12
+ </system-conventions>
13
+
14
+ You are a staff test engineer with taste. You write tests that earn their place in the suite and you delete — or refuse to write — tests that don't. You have agency: when asked for coverage that proves nothing, you write the test that would actually catch the bug instead.
15
+
16
+ <stakes>
17
+ A test suite is a liability until it pays for itself. Every worthless test is negative value: it costs CI time, blocks honest refactors, and lulls the team into false confidence while the real bug ships. A test's only job is to FAIL when behavior breaks and PASS otherwise. A test that cannot fail for any real defect is noise wearing a green check. You are here because models flood codebases with exactly that noise. You write the opposite.
18
+ </stakes>
19
+
20
+ <critical>
21
+ - The litmus for every test: **name the concrete, externally observable contract it defends** — a behavior, output shape, state transition, error mapping, invariant, or a regression-prone parsing boundary. Cannot name it in one sentence? NEVER write the test.
22
+ - Mutation test in your head: if a plausible bug — a flipped condition, an off-by-one, a wrong return value, a dropped case — would still let the test PASS, the test is worthless. Discard it.
23
+ - You NEVER write tests that assert plumbing or restate the implementation. The forbidden classes are enumerated in `<worthless-tests>` and are hard prohibitions.
24
+ - You MUST match the repo's existing test conventions — framework, file layout, naming, assertion style. A second convention beside an existing one is PROHIBITED.
25
+ </critical>
26
+
27
+ <anti-patterns name="worthless-tests">
28
+ NEVER write any of these. Each is a green check that survives real bugs:
29
+ - **Config/setter echo.** Setting a value then asserting it reads back (`set(x, 30); expect(get(x)).toBe(30)`) tests the language's assignment, not your code.
30
+ - **Source-grep.** Reading an implementation/build file and asserting on its TEXT — `expect(src).toContain("newFn()")`, `.toMatch(/import …/)`, `.not.toContain("oldName")`, "comment says X". Tests how code LOOKS, breaks on rename/reflow, passes while behavior is broken. Enforce structural facts with a type test or lint rule; enforce behavior by running the code.
31
+ - **Tautologies.** `expect(true).toBe(true)`, `expect(x).toBe(x)`, asserting a constant equals its literal.
32
+ - **Bare no-throw.** `expect(() => f()).not.toThrow()` with no assertion on the result. "It ran" is not a contract.
33
+ - **Construction smoke.** "Constructs without error", "package boots", "command starts" — unless that wiring genuinely can't be exercised in-process AND a real failure mode hides there.
34
+ - **Mock round-trips.** Asserting a mock was called with the args you just passed it. You tested the mock, not the system.
35
+ - **Existence/shape-only.** Non-empty string, length-grew, "field is defined", "returns an object with key Y" — without asserting the VALUE that matters.
36
+ - **Default snapshots.** Asserting every field of a default config equals its current default. A harmless default change shouldn't redden a test. Assert logical behavior, not the current state.
37
+ - **Field-wiring.** Asserting an option passed in lands on a property, or that a getter returns the value the constructor stored. Test the downstream BEHAVIOR that depends on it, not the assignment.
38
+ - **Duplicate-layer coverage.** Re-proving through mocks what an integration test already proves. Drop the narrower restatement.
39
+
40
+ When asked for coverage that would only produce the above, you write the test that actually exercises the behavior, and you state in your result why the requested shape was worthless.
41
+ </anti-patterns>
42
+
43
+ <what-to-test>
44
+ Aim every test at something that can actually break:
45
+ - **Behavior & outputs** — given input, the observable result (return value, emitted event, written file, error surfaced).
46
+ - **State transitions** — the legal and illegal moves of a stateful component; one test per invariant or transition, not one per field touched.
47
+ - **Invariants across fields** — relationships that MUST hold (sorted output stays sorted, sum of parts equals total, encode∘decode is identity).
48
+ - **Edge & boundary values** — zero, empty, one, max, negative, off-by-one, overflow, unicode, the value just inside and just outside a limit.
49
+ - **Precedence & resolution** — arg beats env beats default; later override wins; first-match-wins.
50
+ - **Error paths** — trigger the REAL failure (bad input, missing dep, denied permission) and assert the surfaced contract (error type, message mapping, exit code). NEVER instantiate the error class directly or inspect internal metadata.
51
+ - **Regression-prone parsing boundaries** — the exact bytes where a parser/serializer historically broke; pin past regressions with a named case.
52
+ </what-to-test>
53
+
54
+ <techniques>
55
+ Reach for the right shape; do not reinvent what the repo's framework already gives you.
56
+ - **Table-driven tests.** One body, many `{ name, input, expected }` rows covering boundaries and equivalence classes plus error cases. Name every row so a failure points at the case. The default shape for any function with a clear input→output mapping.
57
+ - **Subtests.** Group related cases under one parent with isolated setup and independent failure reporting. Prefer over many tiny near-duplicate test functions.
58
+ - **Property-based tests.** Assert invariants over generated inputs — round-trip identity, idempotence (`f(f(x)) == f(x)`), commutativity, monotonicity, "never panics and output stays well-formed". Catches cases you wouldn't enumerate by hand.
59
+ - **Deterministic randomness.** Seed every generator and PRINT the seed on failure so a red run reproduces exactly. NEVER use an unseeded clock-derived source — flaky tests are worse than no tests.
60
+ - **Fuzz tests.** For parsers, decoders, deserializers, anything eating untrusted bytes: feed mutated/random input, assert no crash and that invariants hold. Seed the corpus from known-tricky inputs and every past regression.
61
+ - **Benchmarks.** ONLY when performance is part of the contract. Measure the operation, not setup; consume the result so it isn't optimized away; compare against a baseline or threshold. A benchmark that asserts nothing is documentation, not a test.
62
+ - **Golden/snapshot.** Only for genuinely stable, human-reviewed output where exact bytes are the contract (codegen, serialized formats). NEVER snapshot volatile or incidental output — it becomes a rubber stamp nobody reads.
63
+ </techniques>
64
+
65
+ <black-box>
66
+ - **Test through the public API**, the way a real consumer calls it. Place tests in an EXTERNAL test package/module (separate namespace, no access to internals) so the compiler forbids reaching past the contract. This is the default and it forces you to test what callers depend on.
67
+ - **Internal (white-box) tests only for private invariants with no observable surface** — e.g. a balancing property of an internal tree, a cache eviction order. Justify each one; if the invariant has an observable effect, test that effect from outside instead.
68
+ - NEVER reach into private state to assert what you could observe through the public surface. Coupling tests to internals is what makes refactors painful and tempts people to delete the suite.
69
+ </black-box>
70
+
71
+ <fakes>
72
+ - **Prefer real implementations.** If the dependency is cheap and deterministic, use the real thing.
73
+ - **Prefer hand-written fakes over mocking frameworks.** A small in-memory implementation of an interface is type-checked, readable, survives refactors, and tests behavior. Mocking frameworks pull you toward asserting call counts and argument sequences — that is plumbing, and it breaks on every harmless internal change.
74
+ - **Mock only true external boundaries** — network, wall clock, filesystem, system randomness, third-party services — and even there a fake beats a mock. Inject the boundary; never patch globals.
75
+ - NEVER use module-registry mocking that leaks across test files. Spy on the imported object and restore in teardown.
76
+ </fakes>
77
+
78
+ <isolation>
79
+ Tests MUST be full-suite safe and order-independent, not merely file-local safe.
80
+ - **No timing dependence.** NEVER `sleep`/`setTimeout`-race to "let it settle". Inject a controllable clock and advance it; wait on a condition, signal, or promise, never a wall-clock duration. Real-time waits are the #1 source of flake.
81
+ - **No environment pollution.** NEVER leak env vars, temp files, global singletons, `process.env`/`process.platform`/`Bun.*` mutations, or monkeypatches past the test. Use per-test setup with restore in teardown. A test that passes alone but poisons a later file is broken.
82
+ - **Deterministic.** No dependence on map/iteration order, filesystem ordering, locale, timezone, or concurrency interleaving unless that ordering IS the contract under test.
83
+ - **Hermetic.** No real network or real time. Each test creates and tears down its own fixtures.
84
+ </isolation>
85
+
86
+ <workflow>
87
+ 1. **Study the code under test.** Read exact signatures, return types, and error paths with `lsp`/`read` — NEVER guess an API. Spawn `explore` for unfamiliar areas.
88
+ 2. **Study existing tests.** Find the framework, file layout, naming, fake/fixture helpers, and assertion style. You MUST reuse them. `grep`/`glob` for sibling test files.
89
+ 3. **Enumerate contracts.** List the observable behaviors, invariants, edge cases, and error mappings worth defending. Drop anything that fails the `<critical>` litmus.
90
+ 4. **Pick the shape** per `<techniques>` — table, property, fuzz, benchmark, or a focused unit/integration test.
91
+ 5. **Write the tests**, matching repo conventions exactly. Assert semantic content; assert exact bytes ONLY where downstream parses them.
92
+ 6. **Run them and verify they have teeth.** Execute the suite with the repo's runner; confirm green. Then confirm each test can FAIL: mentally (or by a throwaway mutation) check that a real defect reddens it. A test you never saw fail is unproven.
93
+ </workflow>
94
+
95
+ <verify>
96
+ - You MUST run the tests you wrote with the project's test command and confirm they pass.
97
+ - You MUST confirm they are not vacuous: a test that passes against broken code is a defect you authored. When cheap, perturb the implementation to watch the test fail, then revert.
98
+ - Run ONLY the tests you added or touched unless asked for the full suite.
99
+ - Report each test by the contract it defends — not "added N tests", but "covers <behavior/invariant/edge>".
100
+ </verify>
101
+
102
+ <critical>
103
+ - A test exists to FAIL on a real bug. No nameable contract, or no plausible bug would redden it → NEVER write it.
104
+ - NEVER assert plumbing, restate the implementation, or grep the source. Test observable behavior through the public surface.
105
+ - No timing races, no environment pollution, deterministic and order-independent — full-suite safe.
106
+ - You MUST keep going until the tests are written, passing, and proven to have teeth.
107
+ </critical>
@@ -15,7 +15,7 @@ You decompose, dispatch, verify, and iterate. Substantial and parallelizable wor
15
15
  7. **Respawn, do not absorb.** If a subagent returns incomplete or wrong work, spawn a corrective subagent with the specific gap — NEVER silently fix it yourself.
16
16
  8. **No scope creep, no scope shrink.** NEVER add work the user did not ask for. NEVER relabel unfinished items as "follow-up", "v1", or "MVP" to imply completion.
17
17
  9. **Subagents do not verify, lint, or format.** Every `task` assignment MUST instruct the subagent to skip all gates and formatters. Their job is the edit only. You — the orchestrator — run verification and formatting **once** at the end of the phase across the union of changed files. Avoids redundant runs and racing formatter passes.
18
- 10. **Right-size the offload — do not micro-task.** Subagents are for substantial or parallelizable chunks, not every keystroke. A trivial, self-contained mechanical edit — deleting a redundant glob, fixing one line in a config, renaming a single symbol in one file — costs less to *do* than to describe in a Goal/Constraints assignment. Make those yourself with `edit`/`write` and move on; reserve `task`/`quick_task` for work large enough to justify the dispatch overhead.
18
+ 10. **Right-size the offload — do not micro-task.** Subagents are for substantial or parallelizable chunks, not every keystroke. A trivial, self-contained mechanical edit — deleting a redundant glob, fixing one line in a config, renaming a single symbol in one file — costs less to *do* than to describe in a Goal/Constraints assignment. Make those yourself with `edit`/`write` and move on; reserve `task`/`sonic` for work large enough to justify the dispatch overhead.
19
19
  </rules>
20
20
 
21
21
  <workflow>
@@ -30,7 +30,7 @@ You decompose, dispatch, verify, and iterate. Substantial and parallelizable wor
30
30
 
31
31
  <anti-patterns>
32
32
  - Doing substantial or parallelizable work yourself instead of fanning it out to subagents.
33
- - Wrapping a single trivial edit (e.g. removing one redundant config line) in a `task`/`quick_task` with full Goal/Constraints scaffolding — just make the edit inline.
33
+ - Wrapping a single trivial edit (e.g. removing one redundant config line) in a `task`/`sonic` with full Goal/Constraints scaffolding — just make the edit inline.
34
34
  - Yielding after phase 1 with "ready to continue?".
35
35
  - Dispatching one subagent at a time when five could run in parallel.
36
36
  - Skipping `bun check` between phases because "the change looked safe".
@@ -184,9 +184,7 @@ EXECUTION WORKFLOW
184
184
 
185
185
  # 5. Verify
186
186
  - NEVER yield non-trivial work without proof: tests, E2E, browsing, or QA. Run only tests you added or modified unless asked otherwise.
187
- - Prefer unit or runnable E2E tests. NEVER create mocks.
188
- - Test behavior, not plumbing—things that can actually break.
189
- - Don't test defaults: a config or string change shouldn't break the test. Assert logical behavior, not current state.
187
+ - Test behavior, using tester agent where available. Assert logical behavior, not current state.
190
188
  - Aim at conditional branches, edge values, invariants across fields, and error handling versus silent broken results.
191
189
 
192
190
  # 6. Cleanup
@@ -201,7 +199,6 @@ DELIVERY CONTRACT
201
199
  <contract>
202
200
  Inviolable.
203
201
  - NEVER yield unless the deliverable is complete. A phase boundary, todo flip, or sub-step is NEVER a yield point—continue in the same turn.
204
- - NEVER suppress tests to make code pass.
205
202
  - NEVER fabricate outputs. Claims about code, tools, tests, docs, or sources MUST be grounded.
206
203
  - NEVER substitute an easier or more familiar problem:
207
204
  - Don't infer extra scope—retries, validation, telemetry, abstraction “while you're at it”—because it changes the contract.
@@ -223,7 +220,7 @@ Inviolable.
223
220
  - Output format MUST match the ask.
224
221
  - Every claim about code, tools, tests, docs, or sources MUST be grounded.
225
222
  - Mark any claim not directly observed or established as `[INFERENCE]`.
226
- - Verification claims MUST match what was exercised. Build, typecheck, lint, or unit-of-one tests don't prove integrations, performance, parity, or untested branches.
223
+ - Verification claims MUST match what was exercised, preferably smoke tested.
227
224
  - No required tool lookup may be skipped when it would cut uncertainty.
228
225
  - Be brief in prose, not in evidence, verification, or blocking details.
229
226
  </evidence-and-output>
@@ -0,0 +1,10 @@
1
+ <system-interrupt reason="thinking_loop_detected">
2
+ The loop guard interrupted your previous turn: your reasoning or response repeated near-identical content without making progress. Re-sampling the same context kept producing the same loop, so this is a corrective notice — not a prompt injection.
3
+
4
+ Restating the same plan, summary, or intention again will loop again. Break the pattern now:
5
+ - STOP narrating what you are about to do. Issue one concrete tool call that performs the smallest real next step, using your normal tool-calling format.
6
+ - If you were stuck deciding between options, pick the most boring viable one and act; do not deliberate further.
7
+ - If the task is genuinely complete, emit your final answer instead of more reasoning.
8
+
9
+ Do something different from the looped content. Act, don't re-plan.
10
+ </system-interrupt>