@oh-my-pi/pi-coding-agent 16.1.6 → 16.1.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 (97) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/cli.js +3160 -3139
  3. package/dist/types/cli/session-picker.d.ts +0 -1
  4. package/dist/types/cli/tiny-models-cli.d.ts +2 -0
  5. package/dist/types/collab/protocol.d.ts +20 -1
  6. package/dist/types/config/models-config-schema.d.ts +10 -0
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +57 -6
  9. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +5 -1
  10. package/dist/types/internal-urls/filesystem-resource.d.ts +9 -0
  11. package/dist/types/mcp/loader.d.ts +3 -2
  12. package/dist/types/mcp/manager.d.ts +4 -3
  13. package/dist/types/mcp/startup-events.d.ts +21 -4
  14. package/dist/types/mnemopi/config.d.ts +1 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +1 -0
  16. package/dist/types/modes/components/agent-transcript-viewer.d.ts +1 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  18. package/dist/types/modes/components/btw-panel.d.ts +2 -0
  19. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -0
  20. package/dist/types/modes/components/session-selector.d.ts +0 -2
  21. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/controllers/streaming-reveal.d.ts +3 -2
  24. package/dist/types/modes/interactive-mode.d.ts +3 -0
  25. package/dist/types/modes/types.d.ts +3 -0
  26. package/dist/types/session/agent-session.d.ts +5 -3
  27. package/dist/types/session/session-history-format.d.ts +25 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +1 -1
  29. package/dist/types/slash-commands/builtin-registry.d.ts +7 -4
  30. package/dist/types/slash-commands/types.d.ts +2 -0
  31. package/dist/types/system-prompt.d.ts +1 -0
  32. package/dist/types/tiny/models.d.ts +11 -7
  33. package/dist/types/tools/todo.d.ts +1 -0
  34. package/dist/types/utils/thinking-display.d.ts +2 -0
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +104 -0
  37. package/src/advisor/runtime.ts +38 -2
  38. package/src/cli/session-picker.ts +1 -2
  39. package/src/cli/tiny-models-cli.ts +7 -2
  40. package/src/collab/guest.ts +172 -20
  41. package/src/collab/host.ts +47 -5
  42. package/src/collab/protocol.ts +16 -1
  43. package/src/config/models-config-schema.ts +1 -0
  44. package/src/config/settings-schema.ts +59 -5
  45. package/src/edit/renderer.ts +8 -12
  46. package/src/extensibility/legacy-pi-ai-shim.ts +16 -4
  47. package/src/internal-urls/docs-index.generated.txt +1 -1
  48. package/src/internal-urls/filesystem-resource.ts +34 -0
  49. package/src/internal-urls/local-protocol.ts +7 -1
  50. package/src/internal-urls/memory-protocol.ts +5 -1
  51. package/src/internal-urls/skill-protocol.ts +20 -4
  52. package/src/internal-urls/vault-protocol.ts +5 -2
  53. package/src/main.ts +8 -8
  54. package/src/mcp/loader.ts +4 -3
  55. package/src/mcp/manager.ts +35 -15
  56. package/src/mcp/startup-events.ts +106 -11
  57. package/src/mnemopi/config.ts +2 -0
  58. package/src/mnemopi/state.ts +3 -1
  59. package/src/modes/components/agent-hub.ts +4 -0
  60. package/src/modes/components/agent-transcript-viewer.ts +2 -0
  61. package/src/modes/components/assistant-message.ts +217 -18
  62. package/src/modes/components/btw-panel.ts +15 -3
  63. package/src/modes/components/chat-transcript-builder.ts +8 -2
  64. package/src/modes/components/model-selector.ts +72 -9
  65. package/src/modes/components/omfg-panel.ts +1 -1
  66. package/src/modes/components/session-selector.ts +4 -9
  67. package/src/modes/components/snapcompact-shape-preview.ts +1 -1
  68. package/src/modes/components/tool-execution.ts +10 -2
  69. package/src/modes/controllers/btw-controller.ts +32 -7
  70. package/src/modes/controllers/event-controller.ts +24 -4
  71. package/src/modes/controllers/input-controller.ts +43 -21
  72. package/src/modes/controllers/selector-controller.ts +23 -13
  73. package/src/modes/controllers/streaming-reveal.ts +78 -20
  74. package/src/modes/controllers/todo-command-controller.ts +9 -10
  75. package/src/modes/interactive-mode.ts +83 -8
  76. package/src/modes/types.ts +3 -0
  77. package/src/modes/utils/ui-helpers.ts +1 -0
  78. package/src/prompts/advisor/system.md +1 -1
  79. package/src/prompts/system/side-channel-no-tools.md +3 -0
  80. package/src/prompts/system/system-prompt.md +164 -156
  81. package/src/sdk.ts +12 -8
  82. package/src/session/agent-session.ts +349 -84
  83. package/src/session/history-storage.ts +15 -16
  84. package/src/session/session-history-format.ts +41 -1
  85. package/src/session/snapcompact-inline.ts +9 -6
  86. package/src/slash-commands/builtin-registry.ts +147 -21
  87. package/src/slash-commands/helpers/todo.ts +12 -6
  88. package/src/slash-commands/types.ts +2 -0
  89. package/src/system-prompt.ts +6 -11
  90. package/src/tiny/models.ts +7 -3
  91. package/src/tiny/title-client.ts +8 -2
  92. package/src/tiny/worker.ts +1 -0
  93. package/src/tools/search.ts +10 -1
  94. package/src/tools/sqlite-reader.ts +59 -3
  95. package/src/tools/todo.ts +6 -0
  96. package/src/tools/write.ts +4 -6
  97. package/src/utils/thinking-display.ts +78 -0
@@ -0,0 +1,34 @@
1
+ import * as fs from "node:fs/promises";
2
+ import type { InternalResource } from "./types";
3
+
4
+ /**
5
+ * Builds a text resource for a filesystem directory resolved by an internal URL handler.
6
+ *
7
+ * The resource is flagged immutable so the read tool never mints hashline edit
8
+ * anchors against a directory listing — only file resources from the same
9
+ * handler stay editable.
10
+ */
11
+ export async function buildDirectoryResource(
12
+ url: string,
13
+ directoryPath: string,
14
+ notes?: string[],
15
+ ): Promise<InternalResource> {
16
+ const entries = await fs.readdir(directoryPath, { withFileTypes: true });
17
+ entries.sort((a, b) => {
18
+ const directoryOrder = Number(b.isDirectory()) - Number(a.isDirectory());
19
+ return directoryOrder || a.name.localeCompare(b.name);
20
+ });
21
+ const content =
22
+ entries.length === 0
23
+ ? "(empty directory)"
24
+ : entries.map(e => `${e.name}${e.isDirectory() ? "/" : ""}`).join("\n");
25
+ return {
26
+ url,
27
+ content,
28
+ contentType: "text/plain",
29
+ size: Buffer.byteLength(content, "utf-8"),
30
+ sourcePath: directoryPath,
31
+ immutable: true,
32
+ ...(notes ? { notes } : {}),
33
+ };
34
+ }
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { isEnoent } from "@oh-my-pi/pi-utils";
5
5
  import { AgentRegistry } from "../registry/agent-registry";
6
+ import { buildDirectoryResource } from "./filesystem-resource";
6
7
  import { parseInternalUrl } from "./parse";
7
8
  import { validateRelativePath } from "./skill-protocol";
8
9
  import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
@@ -279,8 +280,13 @@ export class LocalProtocolHandler implements ProtocolHandler {
279
280
  ensureWithinRoot(realTargetPath, resolvedRoot);
280
281
 
281
282
  const stat = await fs.stat(realTargetPath);
283
+ if (stat.isDirectory()) {
284
+ return buildDirectoryResource(url.href, realTargetPath, [
285
+ "Use write path local://<file> to persist large intermediate artifacts across turns.",
286
+ ]);
287
+ }
282
288
  if (!stat.isFile()) {
283
- throw new Error(`local:// URL must resolve to a file: ${url.href}`);
289
+ throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
284
290
  }
285
291
 
286
292
  const content = await Bun.file(realTargetPath).text();
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
4
4
  import { getMemoryRoot } from "../memories";
5
5
  import { AgentRegistry } from "../registry/agent-registry";
6
+ import { buildDirectoryResource } from "./filesystem-resource";
6
7
  import { validateRelativePath } from "./skill-protocol";
7
8
  import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
8
9
 
@@ -102,8 +103,11 @@ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise<I
102
103
  ensureWithinRoot(realTargetPath, resolvedRoot);
103
104
 
104
105
  const stat = await fs.stat(realTargetPath);
106
+ if (stat.isDirectory()) {
107
+ return buildDirectoryResource(url.href, realTargetPath);
108
+ }
105
109
  if (!stat.isFile()) {
106
- throw new Error(`memory:// URL must resolve to a file: ${url.href}`);
110
+ throw new Error(`memory:// URL must resolve to a file or directory: ${url.href}`);
107
111
  }
108
112
 
109
113
  const content = await Bun.file(realTargetPath).text();
@@ -7,8 +7,12 @@
7
7
  * - skill://<name> - Reads SKILL.md
8
8
  * - skill://<name>/<path> - Reads relative path within skill's baseDir
9
9
  */
10
+ import type * as fsTypes from "node:fs";
11
+ import * as fs from "node:fs/promises";
10
12
  import * as path from "node:path";
13
+ import { isEnoent } from "@oh-my-pi/pi-utils";
11
14
  import { getActiveSkills } from "../extensibility/skills";
15
+ import { buildDirectoryResource } from "./filesystem-resource";
12
16
  import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
13
17
 
14
18
  function getContentType(filePath: string): InternalResource["contentType"] {
@@ -71,12 +75,24 @@ export class SkillProtocolHandler implements ProtocolHandler {
71
75
  targetPath = skill.filePath;
72
76
  }
73
77
 
74
- const file = Bun.file(targetPath);
75
- if (!(await file.exists())) {
76
- throw new Error(`File not found: ${targetPath}`);
78
+ let stats: fsTypes.Stats;
79
+ try {
80
+ stats = await fs.stat(targetPath);
81
+ } catch (error) {
82
+ if (isEnoent(error)) {
83
+ throw new Error(`File not found: ${targetPath}`);
84
+ }
85
+ throw error;
86
+ }
87
+
88
+ if (stats.isDirectory()) {
89
+ return buildDirectoryResource(url.href, targetPath);
90
+ }
91
+ if (!stats.isFile()) {
92
+ throw new Error(`skill:// URL must resolve to a file or directory: ${url.href}`);
77
93
  }
78
94
 
79
- const content = await file.text();
95
+ const content = await Bun.file(targetPath).text();
80
96
  return {
81
97
  url: url.href,
82
98
  content,
@@ -822,7 +822,7 @@ export class VaultProtocolHandler implements ProtocolHandler {
822
822
  }
823
823
 
824
824
  async #listDir(
825
- parsed: Extract<ParsedVaultUrl, { kind: "fs-dir" }>,
825
+ parsed: Extract<ParsedVaultUrl, { kind: "fs-dir" | "fs-file" }>,
826
826
  context?: ResolveContext,
827
827
  ): Promise<InternalResource> {
828
828
  const { root, targetPath } = await this.#resolveFsTarget(parsed, context);
@@ -878,8 +878,11 @@ export class VaultProtocolHandler implements ProtocolHandler {
878
878
  }
879
879
  ensureWithinRoot(realTargetPath, root);
880
880
  const stat = await fs.promises.stat(realTargetPath);
881
+ if (stat.isDirectory()) {
882
+ return this.#listDir(parsed, context);
883
+ }
881
884
  if (!stat.isFile()) {
882
- throw new Error(`vault:// URL must resolve to a file: ${parsed.url}`);
885
+ throw new Error(`vault:// URL must resolve to a file or directory: ${parsed.url}`);
883
886
  }
884
887
 
885
888
  const content = await Bun.file(realTargetPath).text();
package/src/main.ts CHANGED
@@ -1056,11 +1056,11 @@ export async function runRootCommand(
1056
1056
  }
1057
1057
 
1058
1058
  // --print-thoughts (single-shot print mode) must surface reasoning, so un-hide
1059
- // thinking before the session is built — otherwise a passive hideThinkingBlock
1059
+ // thinking before the session is built — otherwise a passive omitThinking
1060
1060
  // setting makes the provider omit summaries and the flag prints nothing. An
1061
- // explicit --hide-thinking below still wins.
1061
+ // explicit --hide-thinking block display option still wins for output display.
1062
1062
  if (parsedArgs.printThoughts && !isProtocolMode && !isInteractive) {
1063
- settingsInstance.override("hideThinkingBlock", false);
1063
+ settingsInstance.override("omitThinking", false);
1064
1064
  }
1065
1065
  // Apply --hide-thinking CLI flag (ephemeral, not persisted)
1066
1066
  if (parsedArgs.hideThinking) {
@@ -1130,21 +1130,21 @@ export async function runRootCommand(
1130
1130
  if (parsedArgs.resume === true && !parsedArgs.fork) {
1131
1131
  const folderSessions = await logger.time("SessionManager.list", SessionManager.list, cwd, parsedArgs.sessionDir);
1132
1132
  let preloadedAllSessions: SessionInfo[] | undefined;
1133
- let startInAllScope = false;
1134
1133
  if (folderSessions.length === 0) {
1135
- // Nothing in the current folder fall back to a global scan so the
1136
- // picker can still open in all-projects scope instead of dead-ending.
1134
+ // Probe globally so we can exit fast when the user has no sessions at
1135
+ // all, but never auto-switch the picker into all-projects scope that
1136
+ // silently surfaced other projects' history when the cwd was empty
1137
+ // (issue #3099). The preloaded list also makes the user's Tab switch
1138
+ // instant on the way in.
1137
1139
  preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
1138
1140
  if (preloadedAllSessions.length === 0) {
1139
1141
  writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
1140
1142
  return;
1141
1143
  }
1142
- startInAllScope = true;
1143
1144
  }
1144
1145
  pauseStartupWatchdog();
1145
1146
  const selected = await logger.time("selectSession", selectSession, folderSessions, {
1146
1147
  allSessions: preloadedAllSessions,
1147
- startInAllScope,
1148
1148
  });
1149
1149
  resumeStartupWatchdog();
1150
1150
  if (!selected) {
package/src/mcp/loader.ts CHANGED
@@ -8,6 +8,7 @@ import type { LoadedCustomTool } from "../extensibility/custom-tools/types";
8
8
  import { AgentStorage } from "../session/agent-storage";
9
9
  import type { AuthStorage } from "../session/auth-storage";
10
10
  import { type MCPLoadResult, MCPManager } from "./manager";
11
+ import type { McpConnectionStatusEvent } from "./startup-events";
11
12
  import { MCPToolCache } from "./tool-cache";
12
13
 
13
14
  /** Result from loading MCP tools */
@@ -26,8 +27,8 @@ export interface MCPToolsLoadResult {
26
27
 
27
28
  /** Options for loading MCP tools */
28
29
  export interface MCPToolsLoadOptions {
29
- /** Called when starting to connect to servers */
30
- onConnecting?: (serverNames: string[]) => void;
30
+ /** Called when MCP server connection state changes. */
31
+ onStatus?: (event: McpConnectionStatusEvent) => void;
31
32
  /** Whether to load project-level config (default: true) */
32
33
  enableProjectConfig?: boolean;
33
34
  /** Whether to filter out Exa MCP servers (default: true) */
@@ -68,7 +69,7 @@ export async function discoverAndLoadMCPTools(cwd: string, options?: MCPToolsLoa
68
69
  let result: MCPLoadResult;
69
70
  try {
70
71
  result = await manager.discoverAndConnect({
71
- onConnecting: options?.onConnecting,
72
+ onStatus: options?.onStatus,
72
73
  enableProjectConfig: options?.enableProjectConfig,
73
74
  filterExa: options?.filterExa,
74
75
  filterBrowser: options?.filterBrowser,
@@ -26,13 +26,14 @@ import {
26
26
  subscribeToResources,
27
27
  unsubscribeFromResources,
28
28
  } from "./client";
29
- import { loadAllMCPConfigs, validateServerConfig } from "./config";
29
+ import { type LoadMCPConfigsResult, loadAllMCPConfigs, validateServerConfig } from "./config";
30
30
  import {
31
31
  lookupMcpOAuthCredential,
32
32
  type MCPOAuthCredentialLookup,
33
33
  selectMcpOAuthRefreshMaterial,
34
34
  } from "./oauth-credentials";
35
35
  import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
36
+ import type { McpConnectionStatusEvent } from "./startup-events";
36
37
  import type { MCPToolDetails } from "./tool-bridge";
37
38
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
38
39
  import type { MCPToolCache } from "./tool-cache";
@@ -148,8 +149,8 @@ export interface MCPDiscoverOptions {
148
149
  filterExa?: boolean;
149
150
  /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */
150
151
  filterBrowser?: boolean;
151
- /** Called when starting to connect to servers */
152
- onConnecting?: (serverNames: string[]) => void;
152
+ /** Called when MCP server connection state changes. */
153
+ onStatus?: (event: McpConnectionStatusEvent) => void;
153
154
  }
154
155
 
155
156
  /**
@@ -309,12 +310,20 @@ export class MCPManager {
309
310
  * Returns tools and any connection errors.
310
311
  */
311
312
  async discoverAndConnect(options?: MCPDiscoverOptions): Promise<MCPLoadResult> {
312
- const { configs, exaApiKeys, sources } = await loadAllMCPConfigs(this.cwd, {
313
- enableProjectConfig: options?.enableProjectConfig,
314
- filterExa: options?.filterExa,
315
- filterBrowser: options?.filterBrowser,
316
- });
317
- const result = await this.connectServers(configs, sources, options?.onConnecting);
313
+ let loadedConfigs: LoadMCPConfigsResult;
314
+ try {
315
+ loadedConfigs = await loadAllMCPConfigs(this.cwd, {
316
+ enableProjectConfig: options?.enableProjectConfig,
317
+ filterExa: options?.filterExa,
318
+ filterBrowser: options?.filterBrowser,
319
+ });
320
+ } catch (error) {
321
+ const message = error instanceof Error ? error.message : String(error);
322
+ options?.onStatus?.({ type: "failed", serverName: ".mcp.json", error: message });
323
+ throw error;
324
+ }
325
+ const { configs, exaApiKeys, sources } = loadedConfigs;
326
+ const result = await this.connectServers(configs, sources, options?.onStatus);
318
327
  result.exaApiKeys = exaApiKeys;
319
328
  return result;
320
329
  }
@@ -326,7 +335,7 @@ export class MCPManager {
326
335
  async connectServers(
327
336
  configs: Record<string, MCPServerConfig>,
328
337
  sources: Record<string, SourceMeta>,
329
- onConnecting?: (serverNames: string[]) => void,
338
+ onStatus?: (event: McpConnectionStatusEvent) => void,
330
339
  ): Promise<MCPLoadResult> {
331
340
  type ConnectionTask = {
332
341
  name: string;
@@ -340,6 +349,8 @@ export class MCPManager {
340
349
  const allTools: CustomTool<TSchema, MCPToolDetails>[] = [];
341
350
  const reportedErrors = new Set<string>();
342
351
  let allowBackgroundLogging = false;
352
+ const statusServerNames: string[] = [];
353
+ const validationFailures: Array<{ name: string; message: string }> = [];
343
354
 
344
355
  // Prepare connection tasks
345
356
  const connectionTasks: ConnectionTask[] = [];
@@ -367,10 +378,14 @@ export class MCPManager {
367
378
  continue;
368
379
  }
369
380
 
381
+ statusServerNames.push(name);
382
+
370
383
  // Validate config
371
384
  const validationErrors = validateServerConfig(name, config);
372
385
  if (validationErrors.length > 0) {
373
- errors.set(name, validationErrors.join("; "));
386
+ const message = validationErrors.join("; ");
387
+ errors.set(name, message);
388
+ validationFailures.push({ name, message });
374
389
  reportedErrors.add(name);
375
390
  continue;
376
391
  }
@@ -458,20 +473,25 @@ export class MCPManager {
458
473
  this.#onToolsChanged?.(this.#tools);
459
474
  void this.toolCache?.set(name, config, serverTools);
460
475
 
476
+ onStatus?.({ type: "connected", serverName: name });
461
477
  await this.#loadServerResourcesAndPrompts(name, connection);
462
478
  })
463
479
  .catch(error => {
464
480
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
465
481
  this.#pendingToolLoads.delete(name);
466
- if (!allowBackgroundLogging || reportedErrors.has(name)) return;
467
482
  const message = error instanceof Error ? error.message : String(error);
483
+ onStatus?.({ type: "failed", serverName: name, error: message });
484
+ if (!allowBackgroundLogging || reportedErrors.has(name)) return;
468
485
  logger.error("MCP tool load failed", { path: `mcp:${name}`, error: message });
469
486
  });
470
487
  }
471
488
 
472
- // Notify about servers we're connecting to
473
- if (connectionTasks.length > 0 && onConnecting) {
474
- onConnecting(connectionTasks.map(task => task.name));
489
+ // Notify about servers we're connecting to, including configs that fail fast.
490
+ if (statusServerNames.length > 0 && onStatus) {
491
+ onStatus({ type: "connecting", serverNames: statusServerNames });
492
+ for (const { name, message } of validationFailures) {
493
+ onStatus({ type: "failed", serverName: name, error: message });
494
+ }
475
495
  }
476
496
 
477
497
  if (connectionTasks.length > 0) {
@@ -1,9 +1,99 @@
1
- export const MCP_CONNECTING_EVENT_CHANNEL = "mcp:connecting";
1
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
2
+ import { replaceTabs, shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
2
3
 
3
- export type McpConnectingEvent = { serverNames: string[] };
4
+ export const MCP_CONNECTION_STATUS_EVENT_CHANNEL = "mcp:connection-status";
4
5
 
5
- export function formatMCPConnectingMessage(serverNames: string[]): string {
6
- return `Connecting to MCP servers: ${serverNames.join(", ")}…`;
6
+ export type McpConnectionStatusEvent =
7
+ | { type: "connecting"; serverNames: string[] }
8
+ | { type: "connected"; serverName: string }
9
+ | { type: "failed"; serverName: string; error: string };
10
+
11
+ export type McpConnectionStatusSnapshot = {
12
+ pendingServers: readonly string[];
13
+ connectedServers: readonly string[];
14
+ failedServers: readonly { serverName: string; error: string }[];
15
+ };
16
+
17
+ function sanitizeMcpStatusText(value: string, maxWidth: number): string {
18
+ const text = shortenEmbeddedPaths(
19
+ replaceTabs(sanitizeText(value))
20
+ .replace(/[\r\n]+/g, " ")
21
+ .trim(),
22
+ );
23
+ return truncateToWidth(text.length > 0 ? text : "(unnamed)", maxWidth);
24
+ }
25
+
26
+ function sanitizeMcpServerName(serverName: string): string {
27
+ return sanitizeMcpStatusText(serverName, TRUNCATE_LENGTHS.SHORT);
28
+ }
29
+
30
+ function formatServerList(serverNames: readonly string[]): string {
31
+ return serverNames.map(sanitizeMcpServerName).join(", ");
32
+ }
33
+
34
+ function formatServerCount(count: number): string {
35
+ return count === 1 ? "server" : "servers";
36
+ }
37
+ function sanitizeMcpStatusError(error: string): string {
38
+ return sanitizeMcpStatusText(error, TRUNCATE_LENGTHS.CONTENT);
39
+ }
40
+
41
+ function shortenEmbeddedPaths(text: string): string {
42
+ return text
43
+ .split(" ")
44
+ .map(segment => {
45
+ const leading = segment.match(/^[("'`[]*/)?.[0] ?? "";
46
+ const trailing = segment.match(/[)"'`,.;:\]]*$/)?.[0] ?? "";
47
+ const end = segment.length - trailing.length;
48
+ if (leading.length >= end) return segment;
49
+ return `${leading}${shortenPath(segment.slice(leading.length, end))}${trailing}`;
50
+ })
51
+ .join(" ");
52
+ }
53
+
54
+ export function formatMCPConnectingMessage(serverNames: readonly string[]): string {
55
+ return `Connecting to MCP servers: ${formatServerList(serverNames)}…`;
56
+ }
57
+
58
+ function formatFailedServer({ serverName, error }: { serverName: string; error: string }): string {
59
+ return `${sanitizeMcpServerName(serverName)}: ${sanitizeMcpStatusError(error)}`;
60
+ }
61
+
62
+ export function formatMCPConnectionStatusMessage(snapshot: McpConnectionStatusSnapshot): string {
63
+ const { pendingServers, connectedServers, failedServers } = snapshot;
64
+ if (pendingServers.length > 0) {
65
+ if (connectedServers.length === 0 && failedServers.length === 0) {
66
+ return formatMCPConnectingMessage(pendingServers);
67
+ }
68
+ const parts: string[] = [];
69
+ if (connectedServers.length > 0) {
70
+ parts.push(`Connected: ${formatServerList(connectedServers)}.`);
71
+ }
72
+ if (failedServers.length > 0) {
73
+ parts.push(`Failed: ${failedServers.map(formatFailedServer).join("; ")}.`);
74
+ }
75
+ parts.push(`Still connecting: ${formatServerList(pendingServers)}…`);
76
+ return parts.join(" ");
77
+ }
78
+ if (failedServers.length > 0) {
79
+ const failureText = failedServers.map(formatFailedServer).join("; ");
80
+ if (connectedServers.length === 0) {
81
+ return `MCP ${formatServerCount(failedServers.length)} failed to connect: ${failureText}`;
82
+ }
83
+ return `MCP finished with failures. Connected: ${formatServerList(connectedServers)}. Failed: ${failureText}`;
84
+ }
85
+ if (connectedServers.length > 0) {
86
+ return `Connected to MCP ${formatServerCount(connectedServers.length)}: ${formatServerList(connectedServers)}.`;
87
+ }
88
+ return "";
89
+ }
90
+
91
+ function isRecord(data: unknown): data is Record<string, unknown> {
92
+ return typeof data === "object" && data !== null;
93
+ }
94
+
95
+ function isStringArray(data: unknown): data is string[] {
96
+ return Array.isArray(data) && data.every(item => typeof item === "string");
7
97
  }
8
98
 
9
99
  /**
@@ -11,11 +101,16 @@ export function formatMCPConnectingMessage(serverNames: string[]): string {
11
101
  * untyped at runtime, so the subscriber verifies the shape before formatting
12
102
  * rather than trusting a cast — a malformed emit is ignored instead of throwing.
13
103
  */
14
- export function isMcpConnectingEvent(data: unknown): data is McpConnectingEvent {
15
- return (
16
- typeof data === "object" &&
17
- data !== null &&
18
- Array.isArray((data as { serverNames?: unknown }).serverNames) &&
19
- (data as { serverNames: unknown[] }).serverNames.every(name => typeof name === "string")
20
- );
104
+ export function isMcpConnectionStatusEvent(data: unknown): data is McpConnectionStatusEvent {
105
+ if (!isRecord(data) || typeof data.type !== "string") return false;
106
+ switch (data.type) {
107
+ case "connecting":
108
+ return isStringArray(data.serverNames);
109
+ case "connected":
110
+ return typeof data.serverName === "string";
111
+ case "failed":
112
+ return typeof data.serverName === "string" && typeof data.error === "string";
113
+ default:
114
+ return false;
115
+ }
21
116
  }
@@ -26,6 +26,7 @@ export interface MnemopiBackendConfig {
26
26
  autoRetain: boolean;
27
27
  polyphonicRecall: boolean;
28
28
  enhancedRecall: boolean;
29
+ proactiveLinking: boolean;
29
30
  retainEveryNTurns: number;
30
31
  recallLimit: number;
31
32
  recallContextTurns: number;
@@ -71,6 +72,7 @@ export function loadMnemopiConfig(settings: Settings, agentDir: string): Mnemopi
71
72
  autoRetain: settings.get("mnemopi.autoRetain"),
72
73
  polyphonicRecall: settings.get("mnemopi.polyphonicRecall"),
73
74
  enhancedRecall: settings.get("mnemopi.enhancedRecall"),
75
+ proactiveLinking: settings.get("mnemopi.proactiveLinking"),
74
76
  retainEveryNTurns: Math.max(1, Math.floor(settings.get("mnemopi.retainEveryNTurns"))),
75
77
  recallLimit: Math.max(1, Math.floor(settings.get("mnemopi.recallLimit"))),
76
78
  recallContextTurns: Math.max(1, Math.floor(settings.get("mnemopi.recallContextTurns"))),
@@ -450,7 +450,8 @@ export class MnemopiSessionState {
450
450
  // shared bank, then merging recall results while keeping writes project-local.
451
451
  function createScopedResources(config: MnemopiBackendConfig): MnemopiScopedResources {
452
452
  // Env vars (MNEMOPI_POLYPHONIC_RECALL / MNEMOPI_ENHANCED_RECALL) still override
453
- // these config-driven defaults inside the core gates.
453
+ // these config-driven defaults inside the core gates. Proactive linking is
454
+ // per-memory instance below so concurrent sessions cannot clobber each other.
454
455
  requireMnemopi().configureRecallFeatures({
455
456
  polyphonicRecall: config.polyphonicRecall,
456
457
  enhancedRecall: config.enhancedRecall,
@@ -576,6 +577,7 @@ function createMemory(config: MnemopiBackendConfig, bank: string): Mnemopi {
576
577
  authorType: "agent",
577
578
  channelId: bank,
578
579
  ...providerOptions,
580
+ proactiveLinking: config.proactiveLinking,
579
581
  } as ConstructorParameters<typeof Mnemopi>[0]);
580
582
  }
581
583
 
@@ -157,6 +157,7 @@ export interface AgentHubDeps {
157
157
  cwd?: string;
158
158
  /** Mirrors the main transcript's thinking-block visibility. */
159
159
  hideThinkingBlock?: () => boolean;
160
+ proseOnlyThinking?: () => boolean;
160
161
  /** Keys toggling tool output expansion (app.tools.expand). */
161
162
  expandKeys?: KeyId[];
162
163
  /** Focus the main view on this agent's live session (ctx.focusAgentSession). When absent (collab guest, tests), Enter opens the in-hub chat view instead. */
@@ -194,6 +195,7 @@ export class AgentHubOverlayComponent extends Container {
194
195
  #getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined;
195
196
  #cwd: string;
196
197
  #hideThinkingBlock: (() => boolean) | undefined;
198
+ #proseOnlyThinking: (() => boolean) | undefined;
197
199
  #expandKeys: KeyId[];
198
200
  #focusAgent: ((id: string) => Promise<void>) | undefined;
199
201
 
@@ -223,6 +225,7 @@ export class AgentHubOverlayComponent extends Container {
223
225
  this.#getMessageRenderer = deps.getMessageRenderer;
224
226
  this.#cwd = deps.cwd ?? getProjectDir();
225
227
  this.#hideThinkingBlock = deps.hideThinkingBlock;
228
+ this.#proseOnlyThinking = deps.proseOnlyThinking;
226
229
  this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
227
230
  this.#focusAgent = deps.focusAgent;
228
231
 
@@ -291,6 +294,7 @@ export class AgentHubOverlayComponent extends Container {
291
294
  getMessageRenderer: this.#getMessageRenderer,
292
295
  cwd: this.#cwd,
293
296
  hideThinkingBlock: this.#hideThinkingBlock,
297
+ proseOnlyThinking: this.#proseOnlyThinking,
294
298
  expandKeys: this.#expandKeys,
295
299
  hubKeys: this.#hubKeys,
296
300
  requestRender: this.#requestRender,
@@ -50,6 +50,7 @@ export interface AgentTranscriptViewerDeps {
50
50
  getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
51
51
  cwd: string;
52
52
  hideThinkingBlock?: () => boolean;
53
+ proseOnlyThinking?: () => boolean;
53
54
  expandKeys: KeyId[];
54
55
  /** Keys that toggle the whole hub closed (app.agents.hub + app.session.observe). */
55
56
  hubKeys: KeyId[];
@@ -105,6 +106,7 @@ export class AgentTranscriptViewer implements Component {
105
106
  getMessageRenderer: deps.getMessageRenderer,
106
107
  cwd: deps.cwd,
107
108
  hideThinkingBlock: deps.hideThinkingBlock,
109
+ proseOnlyThinking: deps.proseOnlyThinking,
108
110
  requestRender: deps.requestRender,
109
111
  });
110
112
  this.#scrollView = new ScrollView([], {