@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.
- package/CHANGELOG.md +1656 -613
- package/dist/cli.js +12765 -12731
- package/dist/types/autolearn/managed-skills.d.ts +1 -1
- package/dist/types/capability/mcp.d.ts +2 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/flag-tables.d.ts +126 -0
- package/dist/types/cli/profile-alias.d.ts +29 -0
- package/dist/types/cli/profile-bootstrap.d.ts +55 -0
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/config/model-roles.d.ts +3 -2
- package/dist/types/config/settings-schema.d.ts +2 -0
- package/dist/types/edit/file-snapshot-store.d.ts +14 -0
- package/dist/types/extensibility/extensions/runner.d.ts +11 -0
- package/dist/types/mcp/manager.d.ts +5 -1
- package/dist/types/mcp/oauth-credentials.d.ts +17 -0
- package/dist/types/mcp/oauth-flow.d.ts +41 -0
- package/dist/types/mcp/types.d.ts +2 -0
- package/dist/types/modes/components/background-tan-message.d.ts +9 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +9 -5
- package/dist/types/modes/interactive-mode.d.ts +4 -0
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/messages.d.ts +8 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/tools/builtin-names.d.ts +2 -0
- package/dist/types/tools/index.d.ts +3 -2
- package/dist/types/utils/external-editor.d.ts +11 -1
- package/package.json +12 -12
- package/src/autolearn/managed-skills.ts +3 -5
- package/src/capability/mcp.ts +2 -1
- package/src/cli/args.ts +61 -103
- package/src/cli/completion-gen.ts +2 -2
- package/src/cli/flag-tables.ts +270 -0
- package/src/cli/profile-alias.ts +338 -0
- package/src/cli/profile-bootstrap.ts +243 -0
- package/src/cli.ts +83 -16
- package/src/commands/launch.ts +7 -0
- package/src/config/mcp-schema.json +4 -0
- package/src/config/model-roles.ts +17 -4
- package/src/config/settings-schema.ts +2 -0
- package/src/discovery/builtin.ts +15 -9
- package/src/discovery/helpers.ts +25 -0
- package/src/discovery/mcp-json.ts +1 -0
- package/src/discovery/omp-extension-roots.ts +2 -2
- package/src/edit/file-snapshot-store.ts +43 -0
- package/src/eval/__tests__/agent-bridge.test.ts +3 -2
- package/src/eval/__tests__/helpers-local-roots.test.ts +1 -1
- package/src/eval/js/shared/runtime.ts +54 -0
- package/src/extensibility/extensions/runner.ts +25 -2
- package/src/goals/runtime.ts +4 -1
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/mcp/manager.ts +108 -71
- package/src/mcp/oauth-credentials.ts +104 -0
- package/src/mcp/oauth-flow.ts +67 -0
- package/src/mcp/types.ts +2 -0
- package/src/modes/components/agent-hub.ts +6 -0
- package/src/modes/components/background-tan-message.ts +36 -0
- package/src/modes/components/mcp-add-wizard.ts +17 -10
- package/src/modes/components/model-selector.ts +50 -6
- package/src/modes/components/tool-execution.ts +12 -0
- package/src/modes/controllers/input-controller.ts +21 -10
- package/src/modes/controllers/mcp-command-controller.ts +184 -112
- package/src/modes/controllers/tan-command-controller.ts +27 -11
- package/src/modes/interactive-mode.ts +6 -0
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/ui-helpers.ts +6 -0
- package/src/prompts/bench.md +9 -4
- package/src/sdk.ts +6 -5
- package/src/session/agent-session.ts +30 -1
- package/src/session/messages.ts +9 -0
- package/src/session/session-manager.ts +7 -2
- package/src/tiny/text.ts +5 -1
- package/src/tools/ast-grep.ts +5 -1
- package/src/tools/builtin-names.ts +35 -0
- package/src/tools/index.ts +3 -2
- package/src/tools/read.ts +9 -0
- package/src/tools/search.ts +5 -1
- package/src/tts/tts-worker.ts +13 -5
- package/src/utils/external-editor.ts +15 -2
- package/src/utils/title-generator.ts +1 -1
- package/src/workspace-tree.ts +46 -6
- package/dist/types/utils/tools-manager.test.d.ts +0 -1
- 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.
|
|
798
|
-
//
|
|
799
|
-
//
|
|
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 {
|
|
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
|
-
* `
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* the
|
|
123
|
-
* client
|
|
124
|
-
*
|
|
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
|
-
|
|
503
|
+
finalConfig.oauth?.clientSecret ?? "",
|
|
500
504
|
oauth.scopes ?? "",
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
//
|
|
716
|
-
|
|
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
|
-
//
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1481
|
-
|
|
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 ??
|
|
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
|
-
|
|
1492
|
-
|
|
1560
|
+
flowClientId,
|
|
1561
|
+
flowClientSecret,
|
|
1493
1562
|
oauth.scopes ?? "",
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
package/src/modes/types.ts
CHANGED
|
@@ -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,
|