@oh-my-pi/pi-coding-agent 17.3.7 → 17.3.8

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 (109) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/{CHANGELOG-1hmwwt45.md → CHANGELOG-vr9cckb4.md} +60 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/update-cli.d.ts +8 -0
  9. package/dist/types/cli-commands.d.ts +10 -2
  10. package/dist/types/config/settings-schema.d.ts +28 -0
  11. package/dist/types/config/settings.d.ts +9 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  13. package/dist/types/launch/presence.d.ts +4 -1
  14. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  15. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  16. package/dist/types/mnemopi/backend.d.ts +12 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  19. package/dist/types/modes/interactive-mode.d.ts +25 -3
  20. package/dist/types/modes/types.d.ts +10 -0
  21. package/dist/types/session/agent-session.d.ts +3 -0
  22. package/dist/types/session/prewalk.d.ts +4 -0
  23. package/dist/types/session/session-entries.d.ts +0 -1
  24. package/dist/types/session/session-manager.d.ts +12 -0
  25. package/dist/types/session/session-stats.d.ts +13 -1
  26. package/dist/types/session/skill-title-input.d.ts +13 -0
  27. package/dist/types/subprocess/worker-client.d.ts +7 -4
  28. package/dist/types/task/label.d.ts +2 -0
  29. package/dist/types/task/render.d.ts +2 -0
  30. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +6 -4
  32. package/dist/types/tiny/title-protocol.d.ts +1 -0
  33. package/dist/types/tiny/worker.d.ts +27 -0
  34. package/dist/types/tools/bash.d.ts +1 -1
  35. package/dist/types/tools/read-format.d.ts +6 -0
  36. package/dist/types/tools/read-summary.d.ts +7 -1
  37. package/dist/types/utils/block-context.d.ts +14 -0
  38. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  39. package/dist/types/utils/git.d.ts +25 -1
  40. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  41. package/package.json +13 -13
  42. package/src/advisor/advise-tool.ts +5 -3
  43. package/src/cli/auth-broker-cli.ts +36 -1
  44. package/src/cli/profile-bootstrap.ts +2 -6
  45. package/src/cli/update-cli.ts +63 -11
  46. package/src/cli-commands.ts +61 -7
  47. package/src/commands/completions.ts +2 -1
  48. package/src/commit/agentic/index.ts +15 -2
  49. package/src/commit/git/diff.ts +6 -2
  50. package/src/config/model-resolver.ts +52 -6
  51. package/src/config/models-config.ts +2 -2
  52. package/src/config/settings-schema.ts +33 -0
  53. package/src/config/settings.ts +159 -30
  54. package/src/discovery/helpers.ts +45 -2
  55. package/src/discovery/omp-plugins.ts +2 -1
  56. package/src/discovery/opencode.ts +56 -3
  57. package/src/eval/js/process-entry.ts +4 -4
  58. package/src/export/html/tool-views.generated.js +19 -19
  59. package/src/extensibility/extensions/runner.ts +3 -2
  60. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  61. package/src/launch/client.ts +9 -4
  62. package/src/launch/presence.ts +19 -4
  63. package/src/lsp/defaults.json +1 -1
  64. package/src/mcp/manager.ts +41 -20
  65. package/src/mcp/oauth-credentials.ts +38 -0
  66. package/src/mcp/oauth-flow.ts +21 -0
  67. package/src/mcp/tool-bridge.ts +32 -16
  68. package/src/mnemopi/backend.ts +35 -3
  69. package/src/modes/components/model-hub.ts +37 -4
  70. package/src/modes/components/settings-selector.ts +17 -11
  71. package/src/modes/components/tool-execution.ts +97 -29
  72. package/src/modes/components/tree-selector.ts +7 -2
  73. package/src/modes/controllers/event-controller.ts +12 -2
  74. package/src/modes/controllers/input-controller.ts +64 -27
  75. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  76. package/src/modes/interactive-mode.ts +79 -11
  77. package/src/modes/types.ts +11 -0
  78. package/src/prompts/system/memory-extraction-system.md +5 -22
  79. package/src/prompts/system/system-prompt.md +1 -1
  80. package/src/session/agent-session.ts +52 -6
  81. package/src/session/messages.ts +6 -0
  82. package/src/session/prewalk.ts +25 -7
  83. package/src/session/session-entries.ts +0 -1
  84. package/src/session/session-maintenance.ts +10 -1
  85. package/src/session/session-manager.ts +15 -0
  86. package/src/session/session-stats.ts +24 -3
  87. package/src/session/settings-stream-fn.ts +7 -0
  88. package/src/session/skill-title-input.ts +32 -0
  89. package/src/session/turn-recovery.ts +23 -18
  90. package/src/subprocess/worker-client.ts +8 -5
  91. package/src/task/executor.ts +11 -0
  92. package/src/task/index.ts +2 -0
  93. package/src/task/label.ts +14 -1
  94. package/src/task/persisted-revive.ts +13 -0
  95. package/src/task/render.ts +1 -1
  96. package/src/task/structured-subagent.ts +5 -2
  97. package/src/tiny/completion-prompt.ts +16 -0
  98. package/src/tiny/title-client.ts +15 -6
  99. package/src/tiny/title-protocol.ts +8 -1
  100. package/src/tiny/worker.ts +21 -19
  101. package/src/tools/bash.ts +7 -1
  102. package/src/tools/read-format.ts +16 -2
  103. package/src/tools/read-summary.ts +9 -4
  104. package/src/tools/read.ts +306 -72
  105. package/src/utils/block-context.ts +15 -1
  106. package/src/utils/fetch-timeout.ts +33 -0
  107. package/src/utils/git.ts +54 -11
  108. package/src/web/search/providers/browser-page.ts +21 -3
  109. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -1665,8 +1665,9 @@ export class ExtensionRunner {
1665
1665
  return currentPayload;
1666
1666
  }
1667
1667
 
1668
- async emitAfterProviderResponse(response: ProviderResponseMetadata, _model?: Model): Promise<void> {
1669
- const ctx = this.createContext();
1668
+ /** Runs response hooks with the model that produced that provider response. */
1669
+ async emitAfterProviderResponse(response: ProviderResponseMetadata, model?: Model): Promise<void> {
1670
+ const ctx = this.createContext(model);
1670
1671
 
1671
1672
  for (const ext of this.extensions) {
1672
1673
  const handlers = ext.handlers.get("after_provider_response");
@@ -1351,6 +1351,10 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st
1351
1351
  if (await pathExists(path.join(candidate, "package.json"))) {
1352
1352
  return candidate;
1353
1353
  }
1354
+ const workspaceMember = await findWorkspaceMemberPackageRoot(dir, packageName);
1355
+ if (workspaceMember) {
1356
+ return workspaceMember;
1357
+ }
1354
1358
  const parent = path.dirname(dir);
1355
1359
  if (parent === dir) {
1356
1360
  return null;
@@ -1359,6 +1363,49 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st
1359
1363
  }
1360
1364
  }
1361
1365
 
1366
+ /**
1367
+ * Resolve `packageName` as a workspace member when `dir` is a workspace root.
1368
+ *
1369
+ * An installed git dependency of a monorepo plugin contains the full
1370
+ * workspace tree but no node_modules links: `bun install` materializes a git
1371
+ * dependency's regular npm dependencies into the host tree and skips its
1372
+ * `workspace:*` / `file:` edges. Bare imports between workspace siblings
1373
+ * therefore never resolve through the node_modules walk above. When a
1374
+ * directory on that walk declares `workspaces` (array form or the yarn-style
1375
+ * `{ packages: [...] }` object), scan the member manifests for the requested
1376
+ * package name. node_modules candidates at the same level win, so an
1377
+ * explicitly installed copy still shadows the workspace member.
1378
+ */
1379
+ async function findWorkspaceMemberPackageRoot(dir: string, packageName: string): Promise<string | null> {
1380
+ if (!(await pathExists(path.join(dir, "package.json")))) {
1381
+ return null;
1382
+ }
1383
+ const manifest = await readPackageManifest(dir);
1384
+ const rawWorkspaces = manifest?.workspaces;
1385
+ const patterns = Array.isArray(rawWorkspaces)
1386
+ ? rawWorkspaces
1387
+ : isRecord(rawWorkspaces) && Array.isArray(rawWorkspaces.packages)
1388
+ ? rawWorkspaces.packages
1389
+ : null;
1390
+ if (!patterns) {
1391
+ return null;
1392
+ }
1393
+ for (const pattern of patterns) {
1394
+ if (typeof pattern !== "string" || pattern.startsWith("!")) {
1395
+ continue;
1396
+ }
1397
+ const glob = new Bun.Glob(path.join(pattern, "package.json"));
1398
+ for await (const match of glob.scan({ cwd: dir, onlyFiles: true })) {
1399
+ const memberRoot = path.dirname(path.join(dir, match));
1400
+ const memberManifest = await readPackageManifest(memberRoot);
1401
+ if (memberManifest?.name === packageName) {
1402
+ return memberRoot;
1403
+ }
1404
+ }
1405
+ }
1406
+ return null;
1407
+ }
1408
+
1362
1409
  async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
1363
1410
  const cached = packageManifestCache.get(packageRoot);
1364
1411
  if (cached) return cached;
@@ -516,8 +516,14 @@ export async function closeDaemonClients(): Promise<void> {
516
516
 
517
517
  /** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */
518
518
  export async function smokeTestDaemonBroker(): Promise<void> {
519
- const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-"));
520
- const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-"));
519
+ // Keep the broker's runtime dir under a private parent this process owns, so
520
+ // the broker's dead-scope sweep (pruneDeadDaemonRuntimeDirs, fired on startup)
521
+ // can only ever reclaim siblings inside it — never unrelated neighbours in
522
+ // os.tmpdir() such as tmux/ssh sockets or build trees (issue #8721).
523
+ const smokeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-"));
524
+ const projectDir = path.join(smokeRoot, "project");
525
+ const runtimeDir = path.join(smokeRoot, "run");
526
+ await fs.mkdir(projectDir, { recursive: true });
521
527
  const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
522
528
  try {
523
529
  const ping = await client.request({ op: "ping" });
@@ -525,7 +531,6 @@ export async function smokeTestDaemonBroker(): Promise<void> {
525
531
  await client.request({ op: "shutdown" });
526
532
  } finally {
527
533
  client.close();
528
- await fs.rm(projectDir, { recursive: true, force: true });
529
- await fs.rm(runtimeDir, { recursive: true, force: true });
534
+ await fs.rm(smokeRoot, { recursive: true, force: true });
530
535
  }
531
536
  }
@@ -6,7 +6,19 @@ import { daemonRuntimeDir } from "./paths";
6
6
 
7
7
  const CLIENTS_DIR = "clients";
8
8
  const BROKER_PID_FILE = "broker.pid";
9
- const GLOBAL_DAEMON_DIR = "global";
9
+ /**
10
+ * Basename of the container holding per-project daemon scopes
11
+ * (`<state>/run/daemons`). {@link pruneDeadDaemonRuntimeDirs} refuses to sweep
12
+ * any other root so a runtime dir passed from outside the state tree cannot
13
+ * turn the reclaim into an rm -rf of unrelated neighbours (issue #8721).
14
+ */
15
+ const DAEMONS_DIR = "daemons";
16
+ /**
17
+ * Name shape of a project daemon scope: the 16-hex wyhash of the project dir
18
+ * produced by `getDaemonRuntimeDir`. Only entries matching this are pruned,
19
+ * which excludes the machine-global `global` container and any foreign dir.
20
+ */
21
+ const DAEMON_SCOPE_KEY = /^[0-9a-f]{16}$/;
10
22
  /**
11
23
  * Grace before a dead daemon runtime dir becomes prune-eligible. Guards against
12
24
  * deleting a scope whose owning omp process is mid-startup (token written, broker
@@ -118,11 +130,14 @@ async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
118
130
  * Best-effort and non-throwing: a scope is deleted only when its `broker.pid`
119
131
  * is absent/dead, no live client presence remains, and it has been untouched
120
132
  * for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir`
121
- * and the machine-global daemon container are always skipped.
133
+ * is always skipped, and the sweep runs only inside the {@link DAEMONS_DIR}
134
+ * container over entries named like a {@link DAEMON_SCOPE_KEY} — so a runtime
135
+ * dir relocated elsewhere (e.g. the smoke test under `os.tmpdir()`) never
136
+ * reclaims unrelated neighbours (issue #8721).
122
137
  */
123
138
  export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise<void> {
124
139
  const root = path.dirname(currentRuntimeDir);
125
- if (path.basename(root) === GLOBAL_DAEMON_DIR) return;
140
+ if (path.basename(root) !== DAEMONS_DIR) return;
126
141
  const current = path.resolve(currentRuntimeDir);
127
142
  let entries: Dirent[];
128
143
  try {
@@ -138,7 +153,7 @@ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Pro
138
153
  }
139
154
  const now = Date.now();
140
155
  for (const entry of entries) {
141
- if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue;
156
+ if (!entry.isDirectory() || !DAEMON_SCOPE_KEY.test(entry.name)) continue;
142
157
  const dir = path.join(root, entry.name);
143
158
  if (path.resolve(dir) === current) continue;
144
159
  try {
@@ -69,7 +69,7 @@
69
69
  "biome": {
70
70
  "command": "biome",
71
71
  "args": ["lsp-proxy"],
72
- "fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc"],
72
+ "fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".css"],
73
73
  "rootMarkers": ["biome.json", "biome.jsonc"],
74
74
  "isLinter": true
75
75
  },
@@ -7,6 +7,7 @@
7
7
  import * as path from "node:path";
8
8
  import * as url from "node:url";
9
9
  import { isDefinitiveOAuthFailure, type TSchema } from "@oh-my-pi/pi-ai";
10
+ import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
10
11
  import { logger } from "@oh-my-pi/pi-utils";
11
12
  import type { SourceMeta } from "../capability/types";
12
13
  import { resolveConfigValue } from "../config/resolve-config-value";
@@ -30,9 +31,10 @@ import { type LoadMCPConfigsResult, loadAllMCPConfigs, validateServerConfig } fr
30
31
  import {
31
32
  lookupMcpOAuthCredential,
32
33
  type MCPOAuthCredentialLookup,
34
+ refreshManagedMcpOAuthCredential,
33
35
  selectMcpOAuthRefreshMaterial,
34
36
  } from "./oauth-credentials";
35
- import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
37
+ import type { MCPStoredOAuthCredential } from "./oauth-flow";
36
38
  import type { McpConnectionStatusEvent } from "./startup-events";
37
39
  import type { MCPToolDetails } from "./tool-bridge";
38
40
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
@@ -1404,6 +1406,36 @@ export class MCPManager {
1404
1406
  };
1405
1407
  }
1406
1408
 
1409
+ /**
1410
+ * Refresh a broker-redacted MCP OAuth credential through the auth-broker.
1411
+ *
1412
+ * When running in broker mode the client only ever holds the redacted
1413
+ * refresh sentinel; the real refresh token lives on the broker. Delegating
1414
+ * to {@link AuthStorage.forceRefreshCredentialById} makes the broker run the
1415
+ * `refresh_token` grant and return a fresh access token, which the client
1416
+ * uses while keeping {@link REMOTE_REFRESH_SENTINEL} in the refresh slot.
1417
+ */
1418
+ async #refreshBrokeredMcpCredential(credentialId: string, signal?: AbortSignal): Promise<OAuthCredentials> {
1419
+ const storage = this.#authStorage;
1420
+ if (!storage) throw new Error("MCP OAuth broker refresh requires an auth storage");
1421
+ const row = storage.listStoredCredentials(credentialId).find(entry => entry.credential.type === "oauth");
1422
+ if (!row) throw new Error(`No broker credential row for ${credentialId}`);
1423
+ const entry = await storage.forceRefreshCredentialById(row.id, signal);
1424
+ if (entry.credential.type !== "oauth") {
1425
+ throw new Error(`Broker returned non-OAuth credential for ${credentialId}`);
1426
+ }
1427
+ const refreshed = entry.credential;
1428
+ return {
1429
+ access: refreshed.access,
1430
+ refresh: REMOTE_REFRESH_SENTINEL,
1431
+ expires: refreshed.expires,
1432
+ accountId: refreshed.accountId,
1433
+ email: refreshed.email,
1434
+ projectId: refreshed.projectId,
1435
+ enterpriseUrl: refreshed.enterpriseUrl,
1436
+ };
1437
+ }
1438
+
1407
1439
  /**
1408
1440
  * Resolve OAuth credentials and shell commands in config.
1409
1441
  * `oauth: false` skips credential injection (reauth's unauthenticated probe);
@@ -1435,24 +1467,15 @@ export class MCPManager {
1435
1467
  return Boolean(current.refresh && material?.tokenUrl);
1436
1468
  },
1437
1469
  refresh: (current, signal) => {
1470
+ // Broker-backed credentials redact the refresh token
1471
+ // (REMOTE_REFRESH_SENTINEL); the broker holds the real one, so
1472
+ // route the refresh through it instead of failing locally.
1438
1473
  if (current.refresh === REMOTE_REFRESH_SENTINEL) {
1439
- throw new Error("MCP OAuth refresh token is broker-redacted; local refresh is unavailable");
1474
+ return this.#refreshBrokeredMcpCredential(credentialId, signal);
1440
1475
  }
1441
- const material = selectMcpOAuthRefreshMaterial(current, auth);
1442
- const tokenUrl = material?.tokenUrl;
1443
- if (!current.refresh || !tokenUrl) {
1444
- throw new Error("MCP OAuth credential is missing refresh material");
1445
- }
1446
- const clientId = material?.clientId;
1447
- const clientSecret = material?.clientSecret;
1448
- const authorizationUrl =
1449
- material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
1450
- const resourceIsFallback =
1451
- !material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
1452
- const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
1453
- return refreshMCPOAuthToken(tokenUrl, current.refresh, clientId, clientSecret, resource, {
1454
- authorizationUrl,
1455
- stripSameOriginResource: resourceIsFallback,
1476
+ return refreshManagedMcpOAuthCredential(current, {
1477
+ serverUrl: config.type === "http" || config.type === "sse" ? config.url : undefined,
1478
+ auth,
1456
1479
  signal,
1457
1480
  });
1458
1481
  },
@@ -1480,10 +1503,8 @@ export class MCPManager {
1480
1503
  isDefinitiveOAuthFailure(error instanceof Error ? error.message : String(error)),
1481
1504
  disabledCause: error =>
1482
1505
  `oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`,
1483
- keepCredentialOnRefreshFailure: error =>
1484
- !(error instanceof Error && error.message.includes("broker-redacted")),
1506
+ keepCredentialOnRefreshFailure: true,
1485
1507
  onRefreshFailure: refreshError => {
1486
- if (refreshError instanceof Error && refreshError.message.includes("broker-redacted")) return;
1487
1508
  logger.warn("MCP OAuth refresh failed, using existing token", {
1488
1509
  credentialId,
1489
1510
  error: refreshError,
@@ -1,3 +1,4 @@
1
+ import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
1
2
  import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
2
3
  import { expandEnvVarsDeep } from "../discovery/helpers";
3
4
  import type { AuthStorage } from "../session/auth-storage";
@@ -6,6 +7,7 @@ import {
6
7
  type MCPStoredOAuthCredential,
7
8
  mcpOAuthCredentialId,
8
9
  mcpOAuthCredentialProfile,
10
+ refreshMCPOAuthToken,
9
11
  } from "./oauth-flow";
10
12
  import type { MCPAuthConfig, MCPServerConfig } from "./types";
11
13
 
@@ -80,6 +82,42 @@ export function selectMcpOAuthRefreshMaterial(
80
82
  return credential.tokenUrl ? credential : auth;
81
83
  }
82
84
 
85
+ /**
86
+ * Refresh a stored MCP OAuth credential via the standard `refresh_token` grant.
87
+ *
88
+ * Refresh material is taken from the credential itself (self-contained modern
89
+ * credentials embed `tokenUrl`/`clientId`/`clientSecret`/`resource`) or, for
90
+ * legacy credentials that carry none, the server's `auth` block. Shared by the
91
+ * local MCP manager and the `omp auth-broker serve` refresh path so a broker
92
+ * with no access to the MCP config can still refresh `mcp_oauth:*` credentials
93
+ * from the vault.
94
+ *
95
+ * `serverUrl` supplies the RFC 8707 fallback resource indicator when neither
96
+ * the credential nor the auth block advertised one; the manager passes the
97
+ * configured server URL, the broker recovers it from the credential id via
98
+ * {@link mcpOAuthServerUrlFromCredentialId}.
99
+ *
100
+ * @throws when no usable refresh token or token endpoint is available.
101
+ */
102
+ export function refreshManagedMcpOAuthCredential(
103
+ credential: MCPStoredOAuthCredential,
104
+ opts: { serverUrl?: string; auth?: MCPAuthConfig; signal?: AbortSignal } = {},
105
+ ): Promise<OAuthCredentials> {
106
+ const material = selectMcpOAuthRefreshMaterial(credential, opts.auth);
107
+ const tokenUrl = material?.tokenUrl;
108
+ if (!credential.refresh || !tokenUrl) {
109
+ throw new Error("MCP OAuth credential is missing refresh material");
110
+ }
111
+ const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
112
+ const resourceIsFallback = !material?.resource && Boolean(opts.serverUrl);
113
+ const resource = material?.resource ?? (resourceIsFallback ? opts.serverUrl : undefined);
114
+ return refreshMCPOAuthToken(tokenUrl, credential.refresh, material?.clientId, material?.clientSecret, resource, {
115
+ authorizationUrl,
116
+ stripSameOriginResource: resourceIsFallback,
117
+ signal: opts.signal,
118
+ });
119
+ }
120
+
83
121
  export async function removeManagedMcpOAuthCredential(
84
122
  authStorage: AuthStorage,
85
123
  credentialId: string | undefined,
@@ -53,6 +53,27 @@ export function mcpOAuthCredentialProfile(credentialId: string): string | undefi
53
53
  return separator === -1 ? undefined : credentialId.slice(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length, separator);
54
54
  }
55
55
 
56
+ /**
57
+ * Server URL embedded in a managed MCP OAuth credential id, or `undefined`
58
+ * for legacy random ids (`mcp_oauth_<rand>`) minted before URL-keyed ids.
59
+ *
60
+ * Inverse of {@link mcpOAuthCredentialId}. Mirrors {@link mcpOAuthCredentialProfile}:
61
+ * the URL contains `:` and `/`, so for profile-scoped ids the URL is everything
62
+ * after the profile segment; for legacy url-keyed ids (`mcp_oauth:<url>`) it is
63
+ * everything after the prefix. Lets the auth-broker — which never sees the MCP
64
+ * config — recover the server URL for the RFC 8707 fallback resource on refresh.
65
+ */
66
+ export function mcpOAuthServerUrlFromCredentialId(credentialId: string): string | undefined {
67
+ if (credentialId.startsWith(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX)) {
68
+ const separator = credentialId.indexOf(":", MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length);
69
+ return separator === -1 ? undefined : credentialId.slice(separator + 1) || undefined;
70
+ }
71
+ if (credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX)) {
72
+ return credentialId.slice(MCP_OAUTH_URL_CREDENTIAL_PREFIX.length) || undefined;
73
+ }
74
+ return undefined;
75
+ }
76
+
56
77
  /**
57
78
  * Stored MCP OAuth credential. Refresh material is embedded so token refresh
58
79
  * works without any `auth` block persisted in (possibly shared) config files.
@@ -4,7 +4,7 @@
4
4
  * Converts MCP tool definitions to CustomTool format for the agent.
5
5
  */
6
6
  import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
- import type { TSchema } from "@oh-my-pi/pi-ai";
7
+ import type { ImageContent, TextContent, TSchema } from "@oh-my-pi/pi-ai";
8
8
  import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
9
9
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
@@ -191,30 +191,40 @@ export interface MCPToolDetails {
191
191
  meta?: OutputMeta;
192
192
  }
193
193
  /**
194
- * Format MCP content for LLM consumption.
194
+ * Convert MCP content to agent content while retaining image payloads.
195
195
  */
196
- function formatMCPContent(content: MCPContent[]): string {
197
- const parts: string[] = [];
196
+ function formatMCPContent(content: MCPContent[]): Array<TextContent | ImageContent> {
197
+ const blocks: Array<TextContent | ImageContent> = [];
198
+ let text = "";
199
+ const flushText = () => {
200
+ if (!text) return;
201
+ blocks.push({ type: "text", text });
202
+ text = "";
203
+ };
204
+ const appendText = (value: string) => {
205
+ text += text ? `\n\n${value}` : value;
206
+ };
198
207
 
199
208
  for (const item of content) {
200
209
  switch (item.type) {
201
210
  case "text":
202
- parts.push(item.text);
211
+ appendText(item.text);
203
212
  break;
204
213
  case "image":
205
- parts.push(`[Image: ${item.mimeType}]`);
214
+ flushText();
215
+ blocks.push(item);
206
216
  break;
207
217
  case "resource":
208
- if (item.resource.text) {
209
- parts.push(`[Resource: ${item.resource.uri}]\n${item.resource.text}`);
210
- } else {
211
- parts.push(`[Resource: ${item.resource.uri}]`);
212
- }
218
+ appendText(
219
+ item.resource.text
220
+ ? `[Resource: ${item.resource.uri}]\n${item.resource.text}`
221
+ : `[Resource: ${item.resource.uri}]`,
222
+ );
213
223
  break;
214
224
  }
215
225
  }
216
-
217
- return parts.join("\n\n");
226
+ flushText();
227
+ return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
218
228
  }
219
229
 
220
230
  /** Build a CustomToolResult from a callTool response. */
@@ -225,7 +235,7 @@ function buildResult(
225
235
  provider?: string,
226
236
  providerName?: string,
227
237
  ): CustomToolResult<MCPToolDetails> {
228
- const text = formatMCPContent(result.content);
238
+ const content = formatMCPContent(result.content);
229
239
  const details: MCPToolDetails = {
230
240
  serverName,
231
241
  mcpToolName,
@@ -235,8 +245,14 @@ function buildResult(
235
245
  provider,
236
246
  providerName,
237
247
  };
238
- const contentText = result.isError ? `Error: ${text}` : text;
239
- const toolResult: CustomToolResult<MCPToolDetails> = { content: [{ type: "text", text: contentText }], details };
248
+ if (result.isError) {
249
+ if (content[0]?.type === "text") {
250
+ content[0] = { type: "text", text: `Error: ${content[0].text}` };
251
+ } else {
252
+ content.unshift({ type: "text", text: "Error:" });
253
+ }
254
+ }
255
+ const toolResult: CustomToolResult<MCPToolDetails> = { content, details };
240
256
  if (result.isError) {
241
257
  toolResult.isError = true;
242
258
  }
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { type ApiKeyResolver, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
4
4
  import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
5
5
  import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
6
+ import type { MnemopiLlmCompleteOptions } from "@oh-my-pi/pi-mnemopi/core/runtime-options";
6
7
  import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
7
8
  import type { DiagnosticSummary } from "@oh-my-pi/pi-mnemopi/diagnose";
8
9
  import { logger } from "@oh-my-pi/pi-utils";
@@ -62,6 +63,27 @@ const STATIC_INSTRUCTIONS = [
62
63
  "",
63
64
  ].join("\n");
64
65
 
66
+ /** Prompt turns for one Mnemopi completion. */
67
+ export interface MemoryCompletionInput {
68
+ prompt: string;
69
+ systemPrompt?: string;
70
+ }
71
+
72
+ /** Maps a Mnemopi completion into instruction and input turns.
73
+ *
74
+ * Extraction is the only task with its own instructions, and it always supplies
75
+ * the raw text, so the instructions become the system turn and the text becomes
76
+ * the user turn. Every other task keeps the prompt Mnemopi rendered. */
77
+ export function resolveMemoryCompletionInput(
78
+ prompt: string,
79
+ options?: MnemopiLlmCompleteOptions,
80
+ ): MemoryCompletionInput {
81
+ if (options?.task?.kind === "memory-extraction") {
82
+ return { prompt: options.task.input, systemPrompt: memoryExtractionPrompt };
83
+ }
84
+ return { prompt };
85
+ }
86
+
65
87
  async function installMnemopiState(session: AgentSession, config: MnemopiBackendConfig): Promise<MnemopiSessionState> {
66
88
  const state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
67
89
  const previous = setMnemopiSessionState(session, state);
@@ -506,8 +528,16 @@ async function resolveMnemopiProviderOptions(
506
528
  return {
507
529
  ...base,
508
530
  llm: {
509
- complete: (prompt, opts) => tinyModelClient.complete(memoryModel, prompt, { maxTokens: opts?.maxTokens }),
510
- extractionPrompt: memoryExtractionPrompt,
531
+ complete: (prompt, opts) => {
532
+ const request = resolveMemoryCompletionInput(prompt, opts);
533
+ return tinyModelClient.complete(memoryModel, request.prompt, {
534
+ maxTokens: opts?.maxTokens,
535
+ systemPrompt: request.systemPrompt,
536
+ });
537
+ },
538
+ // No `extractionPrompt`: resolveMemoryCompletionInput supplies the
539
+ // instructions as a system turn for every extraction call, so anything
540
+ // rendered here would be built in code and then discarded.
511
541
  consolidationPrompt: memoryConsolidationPrompt,
512
542
  },
513
543
  };
@@ -537,6 +567,7 @@ async function resolveMnemopiProviderOptions(
537
567
  return {
538
568
  ...base,
539
569
  llm: async (prompt, opts) => {
570
+ const request = resolveMemoryCompletionInput(prompt, opts);
540
571
  const hasApiKey = await modelRegistry.getApiKey(model, sessionId);
541
572
  if (!hasApiKey) {
542
573
  logger.warn("Mnemopi: smol completion requested but no current API key is available.", {
@@ -549,7 +580,8 @@ async function resolveMnemopiProviderOptions(
549
580
  completeSimple(
550
581
  model,
551
582
  {
552
- messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
583
+ ...(request.systemPrompt ? { systemPrompt: [request.systemPrompt] } : {}),
584
+ messages: [{ role: "user", content: request.prompt, timestamp: Date.now() }],
553
585
  },
554
586
  {
555
587
  apiKey: modelRegistry.resolver(model, sessionId),
@@ -207,6 +207,10 @@ export class ModelHubComponent implements Component {
207
207
  #rolesRows: RolesRow[] = [];
208
208
  #roleIndex = 0;
209
209
  #roleHover: number | null = null;
210
+ /** First roles row drawn in the scroll window; follows the cursor and clamps to the list. */
211
+ #roleScrollStart = 0;
212
+ /** Roles rows actually drawn this frame; bounds mouse hit-testing to the visible window. */
213
+ #rolesVisibleCount = 0;
210
214
 
211
215
  #assigning: AssignTarget | null = null;
212
216
  #strip: StripState | null = null;
@@ -1302,6 +1306,15 @@ export class ModelHubComponent implements Component {
1302
1306
  }
1303
1307
  }
1304
1308
 
1309
+ /** Scroll `#roleScrollStart` just enough to keep `#roleIndex` inside a window of `viewHeight` rows, clamped to the list. */
1310
+ #ensureRoleVisible(viewHeight: number, total: number): number {
1311
+ if (viewHeight <= 0) return 0;
1312
+ let start = this.#roleScrollStart;
1313
+ if (this.#roleIndex < start) start = this.#roleIndex;
1314
+ else if (this.#roleIndex >= start + viewHeight) start = this.#roleIndex - viewHeight + 1;
1315
+ return Math.max(0, Math.min(start, Math.max(0, total - viewHeight)));
1316
+ }
1317
+
1305
1318
  /** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */
1306
1319
  #stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number {
1307
1320
  const wrap = options.wrap ?? true;
@@ -1473,7 +1486,8 @@ export class ModelHubComponent implements Component {
1473
1486
  this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
1474
1487
  if (overBody && entry.kind === "roles" && this.#assigning === null) {
1475
1488
  const roleLine = bodyLine - this.#rolesRowStart;
1476
- this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
1489
+ this.#roleHover =
1490
+ roleLine >= 0 && roleLine < this.#rolesVisibleCount ? roleLine + this.#roleScrollStart : null;
1477
1491
  } else {
1478
1492
  this.#roleHover = null;
1479
1493
  if (overBody && this.#isBrowserView(entry)) {
@@ -1508,8 +1522,9 @@ export class ModelHubComponent implements Component {
1508
1522
  if (overBody) {
1509
1523
  if (entry.kind === "roles" && this.#assigning === null) {
1510
1524
  this.#focus = "list";
1511
- const roleLine = bodyLine - this.#rolesRowStart;
1512
- if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
1525
+ const listLine = bodyLine - this.#rolesRowStart;
1526
+ if (listLine >= 0 && listLine < this.#rolesVisibleCount) {
1527
+ const roleLine = listLine + this.#roleScrollStart;
1513
1528
  const rowDef = this.#rolesRows[roleLine];
1514
1529
  if (rowDef && rowDef.kind !== "separator") {
1515
1530
  if (roleLine === this.#roleIndex) {
@@ -1718,7 +1733,16 @@ export class ModelHubComponent implements Component {
1718
1733
 
1719
1734
  const cycleOrder = this.#cycleOrder();
1720
1735
  const listFocused = this.#focus === "list";
1721
- for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
1736
+ // Window the list around the cursor so entries past the panel height stay
1737
+ // reachable; the trailing indicator line steals one row when clipped.
1738
+ const total = this.#rolesRows.length;
1739
+ const capacity = Math.max(0, rows - 2 - this.#rolesRowStart);
1740
+ const overflow = total > capacity;
1741
+ const viewHeight = overflow ? Math.max(0, capacity - 1) : capacity;
1742
+ this.#roleScrollStart = this.#ensureRoleVisible(viewHeight, total);
1743
+ const endIndex = Math.min(this.#roleScrollStart + viewHeight, total);
1744
+ this.#rolesVisibleCount = Math.max(0, endIndex - this.#roleScrollStart);
1745
+ for (let i = this.#roleScrollStart; i < endIndex; i++) {
1722
1746
  const rowDef = this.#rolesRows[i];
1723
1747
  if (!rowDef) continue;
1724
1748
  const selected = i === this.#roleIndex;
@@ -1802,6 +1826,15 @@ export class ModelHubComponent implements Component {
1802
1826
  lines.push(line);
1803
1827
  }
1804
1828
 
1829
+ if (overflow) {
1830
+ const hiddenAbove = this.#roleScrollStart;
1831
+ const hiddenBelow = total - endIndex;
1832
+ const parts: string[] = [];
1833
+ if (hiddenAbove > 0) parts.push(`↑ ${hiddenAbove} more`);
1834
+ if (hiddenBelow > 0) parts.push(`↓ ${hiddenBelow} more`);
1835
+ lines.push(truncateToWidth(theme.fg("dim", ` ${parts.join(" ")}`), width));
1836
+ }
1837
+
1805
1838
  // Live preview of the quick-switch cycle, rendered with the exact
1806
1839
  // segment track the ctrl+p status uses; the selected role's chip fills.
1807
1840
  while (lines.length < rows - 1) lines.push("");
@@ -1168,15 +1168,15 @@ export class SettingsSelectorComponent implements Component {
1168
1168
  return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
1169
1169
  }
1170
1170
 
1171
- #createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
1172
- let options = def.options;
1173
- if (def.path === "providers.webSearchOrder") {
1174
- const excluded: unknown = settings.get("providers.webSearchExclude");
1175
- if (Array.isArray(excluded)) {
1176
- options = options.filter(option => !excluded.includes(option.value));
1177
- }
1178
- }
1171
+ #getMultiSelectOptions(def: SettingDef & { type: "multiselect" }) {
1172
+ if (def.path !== "providers.webSearchOrder") return def.options;
1173
+ const excluded: unknown = settings.get("providers.webSearchExclude");
1174
+ if (!Array.isArray(excluded)) return def.options;
1175
+ return def.options.filter(option => !excluded.includes(option.value));
1176
+ }
1179
1177
 
1178
+ #createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
1179
+ const options = this.#getMultiSelectOptions(def);
1180
1180
  const current: unknown = settings.get(def.path);
1181
1181
  const initial = Array.isArray(current)
1182
1182
  ? current.filter((entry): entry is string => typeof entry === "string")
@@ -1196,9 +1196,15 @@ export class SettingsSelectorComponent implements Component {
1196
1196
  }
1197
1197
 
1198
1198
  #formatMultiSelectValue(def: SettingDef & { type: "multiselect" }, value: unknown): string {
1199
- const ids = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
1200
- if (ids.length === 0) return def.ordered ? "default" : "none";
1201
- const labels = ids.map(id => def.options.find(option => option.value === id)?.label ?? id);
1199
+ const options = this.#getMultiSelectOptions(def);
1200
+ const labels = Array.isArray(value)
1201
+ ? value.flatMap(entry => {
1202
+ if (typeof entry !== "string") return [];
1203
+ const option = options.find(candidate => candidate.value === entry);
1204
+ return option ? [option.label] : [];
1205
+ })
1206
+ : [];
1207
+ if (labels.length === 0) return def.ordered ? "default" : "none";
1202
1208
  return def.ordered ? labels.join(" → ") : labels.join(", ");
1203
1209
  }
1204
1210