@oh-my-pi/pi-coding-agent 15.13.0 → 15.13.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 (83) hide show
  1. package/CHANGELOG.md +1656 -613
  2. package/dist/cli.js +12765 -12731
  3. package/dist/types/autolearn/managed-skills.d.ts +1 -1
  4. package/dist/types/capability/mcp.d.ts +2 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/flag-tables.d.ts +126 -0
  7. package/dist/types/cli/profile-alias.d.ts +29 -0
  8. package/dist/types/cli/profile-bootstrap.d.ts +55 -0
  9. package/dist/types/commands/launch.d.ts +6 -0
  10. package/dist/types/config/model-roles.d.ts +3 -2
  11. package/dist/types/config/settings-schema.d.ts +2 -0
  12. package/dist/types/edit/file-snapshot-store.d.ts +14 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +11 -0
  14. package/dist/types/mcp/manager.d.ts +5 -1
  15. package/dist/types/mcp/oauth-credentials.d.ts +17 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +41 -0
  17. package/dist/types/mcp/types.d.ts +2 -0
  18. package/dist/types/modes/components/background-tan-message.d.ts +9 -0
  19. package/dist/types/modes/components/mcp-add-wizard.d.ts +9 -5
  20. package/dist/types/modes/interactive-mode.d.ts +4 -0
  21. package/dist/types/modes/types.d.ts +3 -0
  22. package/dist/types/sdk.d.ts +1 -1
  23. package/dist/types/session/messages.d.ts +8 -0
  24. package/dist/types/session/session-manager.d.ts +6 -0
  25. package/dist/types/tools/builtin-names.d.ts +2 -0
  26. package/dist/types/tools/index.d.ts +3 -2
  27. package/dist/types/utils/external-editor.d.ts +11 -1
  28. package/package.json +12 -12
  29. package/src/autolearn/managed-skills.ts +3 -5
  30. package/src/capability/mcp.ts +2 -1
  31. package/src/cli/args.ts +61 -103
  32. package/src/cli/completion-gen.ts +2 -2
  33. package/src/cli/flag-tables.ts +270 -0
  34. package/src/cli/profile-alias.ts +338 -0
  35. package/src/cli/profile-bootstrap.ts +243 -0
  36. package/src/cli.ts +83 -16
  37. package/src/commands/launch.ts +7 -0
  38. package/src/config/mcp-schema.json +4 -0
  39. package/src/config/model-roles.ts +17 -4
  40. package/src/config/settings-schema.ts +2 -0
  41. package/src/discovery/builtin.ts +15 -9
  42. package/src/discovery/helpers.ts +25 -0
  43. package/src/discovery/mcp-json.ts +1 -0
  44. package/src/discovery/omp-extension-roots.ts +2 -2
  45. package/src/edit/file-snapshot-store.ts +43 -0
  46. package/src/eval/__tests__/agent-bridge.test.ts +3 -2
  47. package/src/eval/__tests__/helpers-local-roots.test.ts +1 -1
  48. package/src/eval/js/shared/runtime.ts +54 -0
  49. package/src/extensibility/extensions/runner.ts +25 -2
  50. package/src/goals/runtime.ts +4 -1
  51. package/src/internal-urls/docs-index.generated.ts +6 -6
  52. package/src/mcp/manager.ts +108 -71
  53. package/src/mcp/oauth-credentials.ts +104 -0
  54. package/src/mcp/oauth-flow.ts +67 -0
  55. package/src/mcp/types.ts +2 -0
  56. package/src/modes/components/agent-hub.ts +6 -0
  57. package/src/modes/components/background-tan-message.ts +36 -0
  58. package/src/modes/components/mcp-add-wizard.ts +17 -10
  59. package/src/modes/components/model-selector.ts +50 -6
  60. package/src/modes/components/tool-execution.ts +12 -0
  61. package/src/modes/controllers/input-controller.ts +21 -10
  62. package/src/modes/controllers/mcp-command-controller.ts +184 -112
  63. package/src/modes/controllers/tan-command-controller.ts +27 -11
  64. package/src/modes/interactive-mode.ts +6 -0
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/ui-helpers.ts +6 -0
  67. package/src/prompts/bench.md +9 -4
  68. package/src/sdk.ts +6 -5
  69. package/src/session/agent-session.ts +30 -1
  70. package/src/session/messages.ts +9 -0
  71. package/src/session/session-manager.ts +7 -2
  72. package/src/tiny/text.ts +5 -1
  73. package/src/tools/ast-grep.ts +5 -1
  74. package/src/tools/builtin-names.ts +35 -0
  75. package/src/tools/index.ts +3 -2
  76. package/src/tools/read.ts +9 -0
  77. package/src/tools/search.ts +5 -1
  78. package/src/tts/tts-worker.ts +13 -5
  79. package/src/utils/external-editor.ts +15 -2
  80. package/src/utils/title-generator.ts +1 -1
  81. package/src/workspace-tree.ts +46 -6
  82. package/dist/types/utils/tools-manager.test.d.ts +0 -1
  83. package/src/utils/tools-manager.test.ts +0 -25
@@ -624,6 +624,18 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
624
624
  isTranscriptBlockCommitStable(): boolean {
625
625
  if (this.#displaceable) return false;
626
626
  if (this.isTranscriptBlockFinalized()) return true;
627
+ // `provisionalPendingPreview` describes only the PENDING call preview
628
+ // (`renderCall`, before any result): the result render may re-anchor it
629
+ // wholesale, so its rows must never commit. Once a (streaming partial)
630
+ // result exists the result renderer is the live shape — its body is
631
+ // top-anchored and grows append-only, and `deriveLiveCommitState` gates
632
+ // per-row durability — so the block is commit-stable like any settled
633
+ // stream. Gating the flag on the pending phase is what keeps a collapsed
634
+ // streaming eval/bash/ssh whose box outgrows the viewport from stranding
635
+ // its head: while commit-unstable its scrolled-off top committed nowhere
636
+ // and repainted nowhere, so it read as truncated until ctrl+o (expanded)
637
+ // flipped it stable.
638
+ if (this.#result !== undefined) return true;
627
639
  const tool = this.#tool as { provisionalPendingPreview?: boolean | "collapsed" } | undefined;
628
640
  const provisionalPendingPreview =
629
641
  tool?.provisionalPendingPreview ?? toolRenderers[this.#toolName]?.provisionalPendingPreview;
@@ -785,18 +785,11 @@ export class InputController {
785
785
  }
786
786
 
787
787
  handleCtrlC(): void {
788
- const now = Date.now();
789
- if (now - this.ctx.lastSigintTime < 500) {
790
- void this.ctx.shutdown();
791
- } else {
792
- this.ctx.clearEditor();
793
- this.ctx.lastSigintTime = now;
794
- }
795
788
  // Sync-flush the session JSONL so in-flight writes survive a hard exit.
796
789
  // The TUI consumes Ctrl+C as a key event in raw mode, so postmortem's
797
- // process-level SIGINT handler never fires. The second press still
798
- // funnels through shutdown() which awaits its own async flush the
799
- // sync flush here is a superset that also covers the first-press case.
790
+ // process-level SIGINT handler never fires. shutdown() awaits its own
791
+ // async flush this sync pass is a superset that also covers the
792
+ // first-press case and the hard-abort path below.
800
793
  try {
801
794
  this.ctx.sessionManager.flushSync();
802
795
  } catch (err) {
@@ -804,6 +797,24 @@ export class InputController {
804
797
  error: err instanceof Error ? err.message : String(err),
805
798
  });
806
799
  }
800
+
801
+ // Hard-abort: a Ctrl+C arriving while shutdown() is already running
802
+ // means the user has waited long enough for whatever teardown step is
803
+ // stuck (typically an extension's session_shutdown handler hanging on
804
+ // IPC). The 2s session_shutdown cap (see runner.ts) already bounds the
805
+ // common case; this is the defense-in-depth ladder for everything
806
+ // else. See issue #2600.
807
+ if (this.ctx.isShuttingDown) {
808
+ process.exit(130); // 128 + SIGINT
809
+ }
810
+
811
+ const now = Date.now();
812
+ if (now - this.ctx.lastSigintTime < 500) {
813
+ void this.ctx.shutdown();
814
+ } else {
815
+ this.ctx.clearEditor();
816
+ this.ctx.lastSigintTime = now;
817
+ }
807
818
  }
808
819
 
809
820
  handleCtrlD(): void {
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
8
8
  import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
9
9
  import type { SourceMeta } from "../../capability/types";
10
+ import { expandEnvVarsDeep } from "../../discovery/helpers";
10
11
  import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
11
12
  import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
12
13
  import {
@@ -17,7 +18,13 @@ import {
17
18
  setServerDisabled,
18
19
  updateMCPServer,
19
20
  } from "../../mcp/config-writer";
20
- import { MCPOAuthFlow } from "../../mcp/oauth-flow";
21
+ import {
22
+ lookupMcpOAuthCredentialForServer,
23
+ mcpOAuthCredentialIdsForServerUrl,
24
+ removeManagedMcpOAuthCredential,
25
+ removeManagedMcpOAuthCredentials,
26
+ } from "../../mcp/oauth-credentials";
27
+ import { MCPOAuthFlow, type MCPStoredOAuthCredential, mcpOAuthCredentialId } from "../../mcp/oauth-flow";
21
28
  import {
22
29
  clearSmitheryApiKey,
23
30
  createSmitheryCliAuthSession,
@@ -34,7 +41,6 @@ import {
34
41
  toConfigName,
35
42
  } from "../../mcp/smithery-registry";
36
43
  import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
37
- import type { OAuthCredential } from "../../session/auth-storage";
38
44
  import { shortenPath } from "../../tools/render-utils";
39
45
  import { urlHyperlinkAlways } from "../../tui";
40
46
  import { openPath } from "../../utils/open";
@@ -116,17 +122,16 @@ class McpConnectingBlock extends ChatBlock {
116
122
  /**
117
123
  * Outcome of {@link MCPCommandController}'s OAuth handler.
118
124
  *
119
- * `clientId`/`clientSecret` are populated when the OAuth provider required (or
120
- * accepted) dynamic client registration; callers MUST persist them alongside
121
- * `credentialId` so subsequent token refreshes and reauthorizations can reuse
122
- * the same registered client. Both are also set when the caller pre-supplied a
123
- * client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the
124
- * write-back is a no-op.
125
+ * `credentialId` is deterministic per server URL when the URL was supplied, so
126
+ * every profile resolves its own credential row under the same id. Refresh
127
+ * material (token URL, client id/secret) is embedded in the stored credential;
128
+ * the returned `clientId` may be folded into `mcp.json` for pre-auth reuse.
129
+ * DCR-issued client secrets stay embedded in the stored credential and are
130
+ * deliberately not surfaced here, so they cannot leak into config files.
125
131
  */
126
132
  interface OAuthFlowResult {
127
133
  credentialId: string;
128
134
  clientId?: string;
129
- clientSecret?: string;
130
135
  resource?: string;
131
136
  }
132
137
 
@@ -490,38 +495,28 @@ export class MCPCommandController {
490
495
  }
491
496
 
492
497
  try {
493
- const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
494
498
  const oauthResource = oauth.resource ?? finalConfig.url;
495
499
  const oauthResult = await this.#handleOAuthFlow(
496
500
  oauth.authorizationUrl,
497
501
  oauth.tokenUrl,
498
502
  oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
499
- oauthClientSecret,
503
+ finalConfig.oauth?.clientSecret ?? "",
500
504
  oauth.scopes ?? "",
501
- finalConfig.oauth?.callbackPort,
502
- finalConfig.oauth?.callbackPath,
503
- finalConfig.oauth?.redirectUri,
504
- oauthResource,
505
- );
506
- const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
507
- const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
508
- const persistedResource = oauthResult.resource ?? oauthResource;
509
- finalConfig = {
510
- ...finalConfig,
511
- auth: {
512
- type: "oauth",
513
- credentialId: oauthResult.credentialId,
514
- tokenUrl: oauth.tokenUrl,
515
- resource: persistedResource,
516
- clientId: persistedClientId,
517
- clientSecret: persistedClientSecret,
518
- },
519
- oauth: {
520
- ...finalConfig.oauth,
521
- clientId: persistedClientId ?? finalConfig.oauth?.clientId,
522
- clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret,
505
+ {
506
+ callbackPort: finalConfig.oauth?.callbackPort,
507
+ callbackPath: finalConfig.oauth?.callbackPath,
508
+ redirectUri: finalConfig.oauth?.redirectUri,
509
+ prompt: finalConfig.oauth?.prompt,
510
+ serverUrl: finalConfig.url,
511
+ resource: oauthResource,
523
512
  },
524
- };
513
+ );
514
+ finalConfig = this.#persistOAuthResult(finalConfig, oauthResult, {
515
+ tokenUrl: oauth.tokenUrl,
516
+ resource: oauthResource,
517
+ clientId: oauth.clientId,
518
+ userClientSecret: finalConfig.oauth?.clientSecret,
519
+ });
525
520
  } catch (oauthError) {
526
521
  this.ctx.showError(
527
522
  `OAuth flow failed for "${parsed.initialName}": ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`,
@@ -553,25 +548,8 @@ export class MCPCommandController {
553
548
  done();
554
549
  this.#handleWizardCancel();
555
550
  },
556
- async (
557
- authUrl: string,
558
- tokenUrl: string,
559
- clientId: string,
560
- clientSecret: string,
561
- scopes: string,
562
- resource?: string,
563
- ) => {
564
- return await this.#handleOAuthFlow(
565
- authUrl,
566
- tokenUrl,
567
- clientId,
568
- clientSecret,
569
- scopes,
570
- undefined,
571
- undefined,
572
- undefined,
573
- resource,
574
- );
551
+ async (authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string, options) => {
552
+ return await this.#handleOAuthFlow(authUrl, tokenUrl, clientId, clientSecret, scopes, options);
575
553
  },
576
554
  async (config: MCPServerConfig) => {
577
555
  return await this.#handleTestConnection(config);
@@ -598,10 +576,14 @@ export class MCPCommandController {
598
576
  clientId: string,
599
577
  clientSecret: string,
600
578
  scopes: string,
601
- callbackPort?: number,
602
- callbackPath?: string,
603
- redirectUri?: string,
604
- resource?: string,
579
+ opts?: {
580
+ callbackPort?: number;
581
+ callbackPath?: string;
582
+ redirectUri?: string;
583
+ prompt?: string;
584
+ serverUrl?: string;
585
+ resource?: string;
586
+ },
605
587
  ): Promise<OAuthFlowResult> {
606
588
  const authStorage = this.ctx.session.modelRegistry.authStorage;
607
589
  let parsedAuthUrl: URL;
@@ -637,10 +619,11 @@ export class MCPCommandController {
637
619
  clientId: resolvedClientId,
638
620
  clientSecret: resolvedClientSecret,
639
621
  scopes: scopes || undefined,
640
- redirectUri,
641
- callbackPort,
642
- callbackPath,
643
- resource,
622
+ prompt: opts?.prompt,
623
+ redirectUri: opts?.redirectUri,
624
+ callbackPort: opts?.callbackPort,
625
+ callbackPath: opts?.callbackPath,
626
+ resource: opts?.resource,
644
627
  },
645
628
  {
646
629
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -712,22 +695,29 @@ export class MCPCommandController {
712
695
  new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0),
713
696
  ]);
714
697
 
715
- // Generate a unique credential ID
716
- const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
698
+ // Deterministic per-URL id: every profile resolves its own credential row
699
+ // under the same key, so shared project configs stay profile-isolated.
700
+ // Random fallback only for flows that never knew the server URL.
701
+ const credentialId = opts?.serverUrl
702
+ ? mcpOAuthCredentialId(opts.serverUrl)
703
+ : `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
717
704
 
718
- // Store credentials in auth storage
719
- const oauthCredential: OAuthCredential = {
705
+ // Embed refresh material so the credential is self-contained: token
706
+ // refresh must work for configs that carry no auth block at all.
707
+ const oauthCredential: MCPStoredOAuthCredential = {
720
708
  type: "oauth",
721
709
  ...credentials,
710
+ tokenUrl,
711
+ clientId: flow.resolvedClientId ?? resolvedClientId,
712
+ clientSecret: flow.registeredClientSecret ?? resolvedClientSecret,
713
+ resource: flow.resource,
722
714
  };
723
715
 
724
- // Store under a synthetic provider name
725
716
  await authStorage.set(credentialId, oauthCredential);
726
717
 
727
718
  return {
728
719
  credentialId,
729
720
  clientId: flow.resolvedClientId,
730
- clientSecret: flow.registeredClientSecret,
731
721
  resource: flow.resource,
732
722
  };
733
723
  } catch (error) {
@@ -750,20 +740,52 @@ export class MCPCommandController {
750
740
  }
751
741
  }
752
742
 
743
+ /**
744
+ * Fold a completed OAuth flow back into a server config. Owns the
745
+ * persistence policy in one place: the auth block records the credential
746
+ * pointer plus refresh material, the oauth block echoes the client id for
747
+ * pre-auth reuse, and only a user-supplied client secret is ever written —
748
+ * DCR-issued secrets stay embedded in the stored credential so they cannot
749
+ * leak into (possibly shared/committed) config files.
750
+ */
751
+ #persistOAuthResult(
752
+ config: MCPServerConfig,
753
+ result: OAuthFlowResult,
754
+ opts: { tokenUrl: string; resource?: string; clientId?: string; userClientSecret?: string },
755
+ ): MCPServerConfig {
756
+ const clientId = result.clientId ?? opts.clientId ?? config.oauth?.clientId;
757
+ const resource = result.resource ?? opts.resource ?? config.auth?.resource;
758
+ return {
759
+ ...config,
760
+ auth: {
761
+ type: "oauth",
762
+ credentialId: result.credentialId,
763
+ tokenUrl: opts.tokenUrl,
764
+ clientId,
765
+ clientSecret: opts.userClientSecret,
766
+ resource,
767
+ },
768
+ oauth: {
769
+ ...config.oauth,
770
+ clientId,
771
+ },
772
+ };
773
+ }
774
+
753
775
  /**
754
776
  * Test connection to an MCP server.
755
777
  * Throws an error if connection fails (used for auto-detection).
756
778
  */
757
- async #handleTestConnection(config: MCPServerConfig): Promise<void> {
779
+ async #handleTestConnection(config: MCPServerConfig, options?: { oauth?: boolean }): Promise<void> {
758
780
  // Create temporary connection using a test name
759
781
  const testName = `test_${Date.now()}`;
760
782
  let resolvedConfig: MCPServerConfig;
761
783
  if (this.ctx.mcpManager) {
762
- resolvedConfig = await this.ctx.mcpManager.prepareConfig(config);
784
+ resolvedConfig = await this.ctx.mcpManager.prepareConfig(config, options);
763
785
  } else {
764
786
  const tempManager = new MCPManager(getProjectDir());
765
787
  tempManager.setAuthStorage(this.ctx.session.modelRegistry.authStorage);
766
- resolvedConfig = await tempManager.prepareConfig(config);
788
+ resolvedConfig = await tempManager.prepareConfig(config, options);
767
789
  }
768
790
 
769
791
  const connection = await connectToServer(testName, resolvedConfig);
@@ -850,11 +872,6 @@ export class MCPCommandController {
850
872
  };
851
873
  }
852
874
 
853
- async #removeManagedOAuthCredential(credentialId: string | undefined): Promise<void> {
854
- if (!credentialId?.startsWith("mcp_oauth_")) return;
855
- await this.ctx.session.modelRegistry.authStorage.remove(credentialId);
856
- }
857
-
858
875
  #stripOAuthAuth(config: MCPServerConfig): MCPServerConfig {
859
876
  const next = { ...config } as MCPServerConfig & { auth?: MCPAuthConfig };
860
877
  delete next.auth;
@@ -868,11 +885,26 @@ export class MCPCommandController {
868
885
  scopes?: string;
869
886
  resource?: string;
870
887
  }> {
888
+ // Stdio servers manage credentials inside the child process; OMP's OAuth
889
+ // flow only applies to http/sse transports. Without this guard the
890
+ // unauthenticated preflight below spawns the child, which happily reuses
891
+ // its own cached tokens (e.g. mcp-remote's machine-wide ~/.mcp-auth) and
892
+ // produces the misleading "reauthorization is not required".
893
+ if (config.type !== "http" && config.type !== "sse") {
894
+ const remoteUrl = config.args?.find(arg => /^https?:\/\//.test(arg));
895
+ const httpHint = `{ "type": "http", "url": ${JSON.stringify(remoteUrl ?? "<remote url>")} }`;
896
+ const usesMcpRemote = [config.command, ...(config.args ?? [])].some(part => part?.includes("mcp-remote"));
897
+ throw new Error(
898
+ usesMcpRemote
899
+ ? `this server proxies OAuth through mcp-remote, which caches tokens machine-wide in ~/.mcp-auth (shared across every OMP profile). Clear ~/.mcp-auth to force a fresh login, or replace the proxy with ${httpHint} so OMP manages OAuth per profile.`
900
+ : `stdio servers manage their own credentials, so OMP has no OAuth to reauthorize. If the service supports OAuth over HTTP, configure it as ${httpHint} instead.`,
901
+ );
902
+ }
871
903
  // First test if server actually needs auth by connecting without OAuth
872
904
  let connectionSucceeded = false;
873
905
  let connectionError: Error | undefined;
874
906
  try {
875
- await this.#handleTestConnection(this.#stripOAuthAuth(config));
907
+ await this.#handleTestConnection(this.#stripOAuthAuth(config), { oauth: false });
876
908
  connectionSucceeded = true;
877
909
  } catch (error) {
878
910
  connectionError = error as Error;
@@ -1433,13 +1465,35 @@ export class MCPCommandController {
1433
1465
  }
1434
1466
 
1435
1467
  const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
1468
+ const authStorage = this.ctx.session.modelRegistry.authStorage;
1469
+ if (currentAuth?.type === "oauth") {
1470
+ await removeManagedMcpOAuthCredential(authStorage, currentAuth.credentialId);
1471
+ }
1472
+ // Also drop this profile's url-keyed binding so the server is truly
1473
+ // signed out even when the config carries no auth block. Runtime
1474
+ // discovery expands `${...}` URL values before MCPManager looks up the
1475
+ // deterministic credential row, so unauth must clear that same key.
1476
+ let removedUrlKeyedCredential = false;
1477
+ if ((found.config.type === "http" || found.config.type === "sse") && found.config.url) {
1478
+ removedUrlKeyedCredential = await removeManagedMcpOAuthCredentials(
1479
+ authStorage,
1480
+ mcpOAuthCredentialIdsForServerUrl(found.config.url),
1481
+ );
1482
+ }
1483
+
1436
1484
  if (found.discovered && currentAuth?.type !== "oauth") {
1437
- this.#showMessage(["", theme.fg("muted", `No stored OAuth auth to remove for "${name}".`), ""].join("\n"));
1485
+ if (!removedUrlKeyedCredential) {
1486
+ this.#showMessage(
1487
+ ["", theme.fg("muted", `No stored OAuth auth to remove for "${name}".`), ""].join("\n"),
1488
+ );
1489
+ return;
1490
+ }
1491
+ await this.#reloadMCP();
1492
+ this.#showMessage(
1493
+ ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
1494
+ );
1438
1495
  return;
1439
1496
  }
1440
- if (currentAuth?.type === "oauth") {
1441
- await this.#removeManagedOAuthCredential(currentAuth.credentialId);
1442
- }
1443
1497
 
1444
1498
  const updated = this.#stripOAuthAuth(found.config);
1445
1499
  await updateMCPServer(found.filePath, name, updated);
@@ -1472,52 +1526,70 @@ export class MCPCommandController {
1472
1526
  }
1473
1527
 
1474
1528
  const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
1475
- if (currentAuth?.type === "oauth") {
1476
- await this.#removeManagedOAuthCredential(currentAuth.credentialId);
1477
- }
1478
-
1529
+ const authStorage = this.ctx.session.modelRegistry.authStorage;
1479
1530
  const baseConfig = this.#stripOAuthAuth(found.config);
1480
- const oauth = await this.#resolveOAuthEndpointsFromServer(baseConfig);
1481
- const oauthClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret ?? "";
1531
+ const runtimeBaseConfig = expandEnvVarsDeep(baseConfig);
1532
+ // Resolve endpoints first: this fails fast for stdio transports and
1533
+ // probes http/sse with { oauth: false }, so nothing destructive has
1534
+ // happened yet if the server turns out not to need (or support) OAuth.
1535
+ // Use the same env-expanded config shape runtime discovery passes to
1536
+ // MCPManager; the raw file value may contain `${...}` placeholders.
1537
+ const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig);
1538
+ const serverUrl =
1539
+ runtimeBaseConfig.type === "http" || runtimeBaseConfig.type === "sse" ? runtimeBaseConfig.url : undefined;
1540
+ // A user-supplied client secret may live in either block (the wizard
1541
+ // writes it to auth.clientSecret); DCR secrets are embedded in the
1542
+ // stored credential and never echoed back into config files.
1543
+ const configuredClientId = found.config.oauth?.clientId ?? currentAuth?.clientId;
1544
+ const existingCredential = lookupMcpOAuthCredentialForServer(authStorage, currentAuth, serverUrl)?.credential;
1545
+ const flowClientId = oauth.clientId ?? configuredClientId ?? existingCredential?.clientId ?? "";
1546
+ const storedClientSecret =
1547
+ existingCredential?.clientId === flowClientId ? existingCredential.clientSecret : undefined;
1548
+ const userClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret;
1549
+ const flowClientSecret = userClientSecret ?? storedClientSecret ?? "";
1482
1550
 
1483
1551
  this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1484
1552
 
1553
+ const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined;
1485
1554
  const oauthResource =
1486
- oauth.resource ?? currentAuth?.resource ?? ("url" in baseConfig ? baseConfig.url : undefined);
1555
+ oauth.resource ?? currentAuthResource ?? ("url" in runtimeBaseConfig ? runtimeBaseConfig.url : undefined);
1487
1556
 
1488
1557
  const oauthResult = await this.#handleOAuthFlow(
1489
1558
  oauth.authorizationUrl,
1490
1559
  oauth.tokenUrl,
1491
- oauth.clientId ?? found.config.oauth?.clientId ?? "",
1492
- oauthClientSecret,
1560
+ flowClientId,
1561
+ flowClientSecret,
1493
1562
  oauth.scopes ?? "",
1494
- found.config.oauth?.callbackPort,
1495
- found.config.oauth?.callbackPath,
1496
- found.config.oauth?.redirectUri,
1497
- oauthResource,
1563
+ {
1564
+ callbackPort: found.config.oauth?.callbackPort,
1565
+ callbackPath: found.config.oauth?.callbackPath,
1566
+ redirectUri: found.config.oauth?.redirectUri,
1567
+ prompt: found.config.oauth?.prompt,
1568
+ serverUrl,
1569
+ resource: oauthResource,
1570
+ },
1498
1571
  );
1499
1572
 
1500
- const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
1501
- const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
1502
- const persistedResource = oauthResult.resource ?? oauthResource;
1573
+ // The flow overwrote (or minted) this profile's row; a superseded
1574
+ // pointer row from the legacy random-id era is now orphaned. GC only
1575
+ // after success so cancelling the browser step leaves the previous
1576
+ // session signed in.
1577
+ if (currentAuth?.type === "oauth" && currentAuth.credentialId !== oauthResult.credentialId) {
1578
+ await removeManagedMcpOAuthCredential(authStorage, currentAuth.credentialId);
1579
+ }
1503
1580
 
1504
- const updated: MCPServerConfig = {
1505
- ...baseConfig,
1506
- auth: {
1507
- type: "oauth",
1508
- credentialId: oauthResult.credentialId,
1581
+ // Definition-only entries resolve through the url-keyed binding alone;
1582
+ // skip the write-back so a committed project mcp.json stays clean.
1583
+ const urlKeyedId = serverUrl ? mcpOAuthCredentialId(serverUrl) : undefined;
1584
+ if (currentAuth || oauthResult.credentialId !== urlKeyedId) {
1585
+ const updated = this.#persistOAuthResult(baseConfig, oauthResult, {
1509
1586
  tokenUrl: oauth.tokenUrl,
1510
- resource: persistedResource,
1511
- clientId: persistedClientId,
1512
- clientSecret: persistedClientSecret,
1513
- },
1514
- oauth: {
1515
- ...found.config.oauth,
1516
- clientId: persistedClientId ?? found.config.oauth?.clientId,
1517
- clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret,
1518
- },
1519
- };
1520
- await updateMCPServer(found.filePath, name, updated);
1587
+ clientId: oauth.clientId,
1588
+ userClientSecret,
1589
+ resource: oauthResource,
1590
+ });
1591
+ await updateMCPServer(found.filePath, name, updated);
1592
+ }
1521
1593
  await this.#reloadMCP();
1522
1594
  const state = await this.#waitForServerConnectionWithAnimation(name);
1523
1595
 
@@ -1,10 +1,12 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
2
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
4
  import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
4
5
  import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
5
6
  import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
6
7
  import * as sdk from "../../sdk";
7
8
  import type { AgentSession } from "../../session/agent-session";
9
+ import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages";
8
10
  import { SessionManager } from "../../session/session-manager";
9
11
  import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
10
12
  import type { InteractiveModeContext } from "../types";
@@ -44,10 +46,6 @@ export class TanCommandController {
44
46
  }
45
47
 
46
48
  const session = this.ctx.session;
47
- if (session.isStreaming) {
48
- this.ctx.showWarning("Wait for the current response to finish or abort it before using /tan.");
49
- return;
50
- }
51
49
 
52
50
  const model = session.model;
53
51
  if (!model) {
@@ -84,19 +82,18 @@ export class TanCommandController {
84
82
  const enableLsp = this.ctx.settings.get("task.enableLsp") !== false;
85
83
  const agentRegistry = AgentRegistry.global();
86
84
  const cloneId = `Tan-${Snowflake.next()}`;
85
+ const cloneFile = path.join(sessionDir, `${cloneId}.jsonl`);
87
86
  const label = `/tan ${previewWork(trimmedWork)}`;
88
87
 
89
88
  await this.ctx.sessionManager.ensureOnDisk();
90
89
  await this.ctx.sessionManager.flush();
91
90
 
92
- let cloneFile = "";
93
91
  let jobId = "";
94
92
  try {
95
93
  const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, {
96
94
  suppressBreadcrumb: true,
95
+ sessionFile: cloneFile,
97
96
  });
98
- cloneFile = cloneManager.getSessionFile() ?? "";
99
- if (!cloneFile) throw new Error("Forked session did not create a session file.");
100
97
 
101
98
  jobId = manager.register(
102
99
  "task",
@@ -145,7 +142,21 @@ export class TanCommandController {
145
142
  signal.removeEventListener("abort", abortClone);
146
143
  }
147
144
  } finally {
148
- await clone?.dispose();
145
+ // Keep the finished tan in the Agent Hub instead of unregistering it:
146
+ // flip the ref to parked BEFORE dispose so the sdk dispose wrapper
147
+ // skips its unregister, then null the disposed session so the hub
148
+ // treats it as a transcript-only parked agent. An aborted tan is
149
+ // terminal — let dispose unregister it.
150
+ if (clone) {
151
+ if (signal.aborted) {
152
+ agentRegistry.setStatus(cloneId, "aborted");
153
+ await clone.dispose();
154
+ } else {
155
+ agentRegistry.setStatus(cloneId, "parked");
156
+ await clone.dispose();
157
+ agentRegistry.detachSession(cloneId);
158
+ }
159
+ }
149
160
  }
150
161
  },
151
162
  { ownerId },
@@ -157,17 +168,22 @@ export class TanCommandController {
157
168
  }
158
169
 
159
170
  const content = prompt.render(backgroundTanDispatchPrompt, { jobId, work: trimmedWork });
171
+ // /tan is meant to run alongside an active session. While the parent turn is
172
+ // still streaming, queue the dispatch breadcrumb for the next turn rather than
173
+ // steering the in-flight response; when idle this same call appends + persists
174
+ // the entry immediately (identical to omitting deliverAs).
175
+ const wasStreaming = session.isStreaming;
160
176
  await session.sendCustomMessage(
161
177
  {
162
- customType: "background-tan-dispatch",
178
+ customType: BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
163
179
  content,
164
180
  display: true,
165
181
  attribution: "user",
166
182
  details: { jobId, work: trimmedWork, sessionFile: cloneFile },
167
183
  },
168
- { triggerTurn: false },
184
+ { triggerTurn: false, deliverAs: "nextTurn" },
169
185
  );
170
- this.ctx.rebuildChatFromMessages();
186
+ if (!wasStreaming) this.ctx.rebuildChatFromMessages();
171
187
  this.ctx.showStatus(`Dispatched background tan ${jobId}`);
172
188
  }
173
189
  }
@@ -423,6 +423,12 @@ export class InteractiveMode implements InteractiveModeContext {
423
423
  lastLeftTapTime = 0;
424
424
  shutdownRequested = false;
425
425
  #isShuttingDown = false;
426
+ /** True once `shutdown()` has begun teardown. Surfaced to the input
427
+ * controller so a Ctrl+C arriving while teardown is in flight can hard-
428
+ * abort the remaining work instead of stacking another no-op call. */
429
+ get isShuttingDown(): boolean {
430
+ return this.#isShuttingDown;
431
+ }
426
432
  hookSelector: HookSelectorComponent | undefined = undefined;
427
433
  hookInput: HookInputComponent | undefined = undefined;
428
434
  hookEditor: HookEditorComponent | undefined = undefined;
@@ -159,6 +159,9 @@ export interface InteractiveModeContext {
159
159
  lastEscapeTime: number;
160
160
  lastLeftTapTime: number;
161
161
  shutdownRequested: boolean;
162
+ /** True once `shutdown()` has started. Read-only from the context;
163
+ * controllers use this to skip work that races with teardown. */
164
+ readonly isShuttingDown: boolean;
162
165
  hookSelector: HookSelectorComponent | undefined;
163
166
  hookInput: HookInputComponent | undefined;
164
167
  hookEditor: HookEditorComponent | undefined;
@@ -5,6 +5,7 @@ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../coll
5
5
  import { settings } from "../../config/settings";
6
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
7
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
+ import { createBackgroundTanDispatchBlock } from "../../modes/components/background-tan-message";
8
9
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
9
10
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
10
11
  import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message";
@@ -33,6 +34,7 @@ import { materializeImageReferenceLinksSync } from "../../modes/image-references
33
34
  import { theme } from "../../modes/theme/theme";
34
35
  import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
35
36
  import {
37
+ BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
36
38
  type CustomMessage,
37
39
  isSilentAbort,
38
40
  LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
@@ -238,6 +240,10 @@ export class UiHelpers {
238
240
  this.ctx.chatContainer.addChild(card);
239
241
  return [card];
240
242
  }
243
+ if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
244
+ this.ctx.chatContainer.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
245
+ break;
246
+ }
241
247
  const handoffComponent = createHandoffSummaryMessageComponent(
242
248
  message as CustomMessage<unknown>,
243
249
  this.ctx.toolOutputExpanded,