@oh-my-pi/pi-coding-agent 14.9.2 → 14.9.5

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 +89 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +3 -3
  4. package/src/async/job-manager.ts +66 -9
  5. package/src/capability/rule.ts +20 -0
  6. package/src/config/model-registry.ts +13 -0
  7. package/src/config/model-resolver.ts +8 -2
  8. package/src/config/prompt-templates.ts +0 -5
  9. package/src/config/settings-schema.ts +39 -1
  10. package/src/edit/index.ts +8 -0
  11. package/src/edit/renderer.ts +6 -1
  12. package/src/edit/streaming.ts +53 -2
  13. package/src/eval/eval.lark +10 -31
  14. package/src/eval/index.ts +1 -0
  15. package/src/eval/js/context-manager.ts +1 -38
  16. package/src/eval/js/prelude.txt +0 -2
  17. package/src/eval/parse.ts +156 -255
  18. package/src/eval/py/executor.ts +24 -8
  19. package/src/eval/py/index.ts +1 -0
  20. package/src/eval/py/prelude.py +11 -80
  21. package/src/eval/sniff.ts +28 -0
  22. package/src/export/html/template.css +50 -0
  23. package/src/export/html/template.generated.ts +1 -1
  24. package/src/export/html/template.js +229 -17
  25. package/src/extensibility/plugins/loader.ts +31 -6
  26. package/src/extensibility/skills.ts +20 -0
  27. package/src/hashline/constants.ts +20 -0
  28. package/src/hashline/grammar.lark +16 -23
  29. package/src/hashline/hash.ts +4 -34
  30. package/src/hashline/input.ts +16 -2
  31. package/src/hashline/parser.ts +12 -1
  32. package/src/internal-urls/agent-protocol.ts +64 -52
  33. package/src/internal-urls/artifact-protocol.ts +52 -51
  34. package/src/internal-urls/docs-index.generated.ts +34 -1
  35. package/src/internal-urls/index.ts +6 -19
  36. package/src/internal-urls/local-protocol.ts +50 -7
  37. package/src/internal-urls/mcp-protocol.ts +3 -8
  38. package/src/internal-urls/memory-protocol.ts +90 -59
  39. package/src/internal-urls/pi-protocol.ts +1 -0
  40. package/src/internal-urls/router.ts +40 -23
  41. package/src/internal-urls/rule-protocol.ts +3 -20
  42. package/src/internal-urls/skill-protocol.ts +5 -27
  43. package/src/internal-urls/types.ts +18 -2
  44. package/src/main.ts +1 -1
  45. package/src/mcp/manager.ts +17 -0
  46. package/src/modes/components/session-observer-overlay.ts +2 -2
  47. package/src/modes/components/tool-execution.ts +6 -0
  48. package/src/modes/components/tree-selector.ts +4 -0
  49. package/src/modes/controllers/event-controller.ts +23 -2
  50. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  51. package/src/modes/interactive-mode.ts +2 -2
  52. package/src/modes/theme/theme.ts +27 -27
  53. package/src/modes/types.ts +1 -1
  54. package/src/modes/utils/ui-helpers.ts +14 -9
  55. package/src/prompts/commands/orchestrate.md +1 -0
  56. package/src/prompts/system/custom-system-prompt.md +0 -2
  57. package/src/prompts/system/project-prompt.md +10 -0
  58. package/src/prompts/system/subagent-system-prompt.md +18 -9
  59. package/src/prompts/system/subagent-user-prompt.md +1 -10
  60. package/src/prompts/system/system-prompt.md +159 -232
  61. package/src/prompts/tools/ask.md +0 -1
  62. package/src/prompts/tools/bash.md +0 -34
  63. package/src/prompts/tools/eval.md +27 -16
  64. package/src/prompts/tools/github.md +6 -5
  65. package/src/prompts/tools/hashline.md +1 -0
  66. package/src/prompts/tools/job.md +14 -6
  67. package/src/prompts/tools/task.md +20 -3
  68. package/src/registry/agent-registry.ts +2 -1
  69. package/src/sdk.ts +87 -89
  70. package/src/session/agent-session.ts +107 -37
  71. package/src/session/artifacts.ts +7 -4
  72. package/src/session/session-manager.ts +30 -1
  73. package/src/ssh/connection-manager.ts +32 -16
  74. package/src/ssh/sshfs-mount.ts +10 -7
  75. package/src/system-prompt.ts +3 -9
  76. package/src/task/executor.ts +23 -7
  77. package/src/task/index.ts +57 -36
  78. package/src/tool-discovery/tool-index.ts +21 -8
  79. package/src/tools/ast-edit.ts +3 -2
  80. package/src/tools/ast-grep.ts +3 -2
  81. package/src/tools/bash.ts +30 -50
  82. package/src/tools/browser/tab-supervisor.ts +12 -2
  83. package/src/tools/eval.ts +59 -44
  84. package/src/tools/fetch.ts +1 -1
  85. package/src/tools/gh.ts +140 -4
  86. package/src/tools/index.ts +12 -11
  87. package/src/tools/job.ts +48 -12
  88. package/src/tools/path-utils.ts +21 -1
  89. package/src/tools/read.ts +74 -31
  90. package/src/tools/search.ts +16 -3
  91. package/src/tools/todo-write.ts +1 -1
  92. package/src/utils/file-display-mode.ts +11 -5
  93. package/src/web/scrapers/mastodon.ts +1 -1
  94. package/src/web/scrapers/repology.ts +7 -7
  95. package/src/internal-urls/jobs-protocol.ts +0 -119
  96. package/src/task/template.ts +0 -47
  97. package/src/tools/bash-normalize.ts +0 -107
@@ -1,28 +1,15 @@
1
1
  /**
2
- * Internal URL routing system for internal protocols like agent://, memory://, skill://, mcp://, and local://.
2
+ * Internal URL routing system for internal protocols like agent://, memory://,
3
+ * skill://, mcp://, and local://.
3
4
  *
4
- * This module provides a unified way to resolve internal URLs without
5
- * exposing filesystem paths to the agent.
6
- *
7
- * @example
8
- * ```ts
9
- * import { InternalUrlRouter, AgentProtocolHandler, MemoryProtocolHandler, SkillProtocolHandler } from './internal-urls';
10
- *
11
- * const router = new InternalUrlRouter();
12
- * router.register(new AgentProtocolHandler({ getArtifactsDir: () => sessionDir }));
13
- * router.register(new MemoryProtocolHandler({ getMemoryRoot: () => memoryRoot }));
14
- * router.register(new SkillProtocolHandler({ getSkills: () => skills }));
15
- *
16
- * if (router.canHandle('agent://reviewer_0')) {
17
- * const resource = await router.resolve('agent://reviewer_0');
18
- * console.log(resource.content);
19
- * }
20
- * ```
5
+ * One process-global `InternalUrlRouter` is shared across sessions. Handlers
6
+ * are stateless; they pull whatever they need (active skills/rules, active
7
+ * MCP/async managers, AgentRegistry-listed sessions) from the owning module
8
+ * on each resolve call.
21
9
  */
22
10
 
23
11
  export * from "./agent-protocol";
24
12
  export * from "./artifact-protocol";
25
- export * from "./jobs-protocol";
26
13
  export * from "./json-query";
27
14
  export * from "./local-protocol";
28
15
  export * from "./mcp-protocol";
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  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
+ import { AgentRegistry } from "../registry/agent-registry";
5
6
  import { parseInternalUrl } from "./parse";
6
7
  import { validateRelativePath } from "./skill-protocol";
7
8
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
@@ -136,16 +137,60 @@ export function resolveLocalUrlToPath(input: string | InternalUrl, options: Loca
136
137
  * Protocol handler for local:// URLs.
137
138
  *
138
139
  * URL forms:
139
- * - local:// - Lists all session local files
140
- * - local://<path> - Reads a file under session local root
140
+ * - local:// - Lists files at the session local root
141
+ * - local://<path> - Reads a file under the session local root
141
142
  */
142
143
  export class LocalProtocolHandler implements ProtocolHandler {
143
144
  readonly scheme = "local";
145
+ readonly immutable = false;
144
146
 
145
- constructor(private readonly options: LocalProtocolOptions) {}
147
+ static #override: LocalProtocolOptions | undefined;
148
+
149
+ /**
150
+ * Install a process-global override that wins over the AgentRegistry-based
151
+ * derivation. Used by SDK consumers that wire `localProtocolOptions` on
152
+ * `createAgentSession` and by subagents that share their parent's root.
153
+ */
154
+ static setOverride(value: LocalProtocolOptions | undefined): void {
155
+ LocalProtocolHandler.#override = value;
156
+ }
157
+
158
+ /** Reset the process-global override. Test-only. */
159
+ static resetOverrideForTests(): void {
160
+ LocalProtocolHandler.#override = undefined;
161
+ }
162
+
163
+ /**
164
+ * Returns the active local-protocol options.
165
+ *
166
+ * Resolution order:
167
+ * 1. Explicit override installed via {@link setOverride} (used by subagents
168
+ * that share their parent's root and by SDK consumers with a custom
169
+ * artifacts/session id mapping).
170
+ * 2. The main session in `AgentRegistry.global()`. Its `SessionManager`
171
+ * supplies both `getArtifactsDir` and `getSessionId`.
172
+ */
173
+ static resolveOptions(): LocalProtocolOptions | undefined {
174
+ const override = LocalProtocolHandler.#override;
175
+ if (override) return override;
176
+ const main = AgentRegistry.global()
177
+ .list()
178
+ .find(ref => ref.kind === "main");
179
+ const sessionManager = main?.session?.sessionManager;
180
+ if (!sessionManager) return undefined;
181
+ return {
182
+ getArtifactsDir: () => sessionManager.getArtifactsDir(),
183
+ getSessionId: () => sessionManager.getSessionId(),
184
+ };
185
+ }
146
186
 
147
187
  async resolve(url: InternalUrl): Promise<InternalResource> {
148
- const localRoot = path.resolve(resolveLocalRoot(this.options));
188
+ const opts = LocalProtocolHandler.resolveOptions();
189
+ if (!opts) {
190
+ throw new Error("No session - local:// unavailable");
191
+ }
192
+
193
+ const localRoot = path.resolve(resolveLocalRoot(opts));
149
194
  await fs.mkdir(localRoot, { recursive: true });
150
195
 
151
196
  let resolvedRoot: string;
@@ -171,9 +216,7 @@ export class LocalProtocolHandler implements ProtocolHandler {
171
216
  const realParent = await fs.realpath(parentDir);
172
217
  ensureWithinRoot(realParent, resolvedRoot);
173
218
  } catch (error) {
174
- if (!isEnoent(error)) {
175
- throw error;
176
- }
219
+ if (!isEnoent(error)) throw error;
177
220
  }
178
221
 
179
222
  let realTargetPath: string;
@@ -1,11 +1,7 @@
1
- import type { MCPManager } from "../mcp/manager";
1
+ import { MCPManager } from "../mcp/manager";
2
2
  import type { MCPResourceReadResult } from "../mcp/types";
3
3
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
4
4
 
5
- export interface McpProtocolOptions {
6
- getMcpManager: () => MCPManager | undefined;
7
- }
8
-
9
5
  function escapeRegex(text: string): string {
10
6
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
7
  }
@@ -106,11 +102,10 @@ function formatAvailableResources(mcpManager: MCPManager): string {
106
102
  */
107
103
  export class McpProtocolHandler implements ProtocolHandler {
108
104
  readonly scheme = "mcp";
109
-
110
- constructor(private readonly options: McpProtocolOptions) {}
105
+ readonly immutable = true;
111
106
 
112
107
  async resolve(url: InternalUrl): Promise<InternalResource> {
113
- const mcpManager = this.options.getMcpManager();
108
+ const mcpManager = MCPManager.instance();
114
109
  if (!mcpManager) {
115
110
  throw new Error("No MCP manager available. MCP servers may not be configured.");
116
111
  }
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { isEnoent } from "@oh-my-pi/pi-utils";
3
+ import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
4
+ import { getMemoryRoot } from "../memories";
5
+ import { AgentRegistry } from "../registry/agent-registry";
4
6
  import { validateRelativePath } from "./skill-protocol";
5
7
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
6
8
 
@@ -8,13 +10,20 @@ const DEFAULT_MEMORY_FILE = "memory_summary.md";
8
10
  const MEMORY_NAMESPACE = "root";
9
11
 
10
12
  /**
11
- * Options for the memory:// URL protocol.
13
+ * Snapshot of memory roots for every registered session, deduped.
14
+ * Each session has its own cwd (possibly a worktree), so subagents and main
15
+ * may see different roots.
12
16
  */
13
- export interface MemoryProtocolOptions {
14
- /**
15
- * Returns the absolute path to the current project's memory root.
16
- */
17
- getMemoryRoot: () => string;
17
+ function memoryRootsFromRegistry(): string[] {
18
+ const agentDir = getAgentDir();
19
+ const roots: string[] = [];
20
+ for (const ref of AgentRegistry.global().list()) {
21
+ const sm = ref.session?.sessionManager;
22
+ if (!sm) continue;
23
+ const root = getMemoryRoot(agentDir, sm.getCwd());
24
+ if (root && !roots.includes(root)) roots.push(root);
25
+ }
26
+ return roots;
18
27
  }
19
28
 
20
29
  function ensureWithinRoot(targetPath: string, rootPath: string): void {
@@ -61,73 +70,95 @@ export function resolveMemoryUrlToPath(url: InternalUrl, memoryRoot: string): st
61
70
  return path.resolve(memoryRoot, relativePath);
62
71
  }
63
72
 
73
+ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise<InternalResource | undefined> {
74
+ const resolved = path.resolve(memoryRoot);
75
+ let resolvedRoot: string;
76
+ try {
77
+ resolvedRoot = await fs.realpath(resolved);
78
+ } catch (error) {
79
+ if (isEnoent(error)) return undefined;
80
+ throw error;
81
+ }
82
+
83
+ const targetPath = resolveMemoryUrlToPath(url, resolvedRoot);
84
+ ensureWithinRoot(targetPath, resolvedRoot);
85
+
86
+ const parentDir = path.dirname(targetPath);
87
+ try {
88
+ const realParent = await fs.realpath(parentDir);
89
+ ensureWithinRoot(realParent, resolvedRoot);
90
+ } catch (error) {
91
+ if (!isEnoent(error)) throw error;
92
+ }
93
+
94
+ let realTargetPath: string;
95
+ try {
96
+ realTargetPath = await fs.realpath(targetPath);
97
+ } catch (error) {
98
+ if (isEnoent(error)) return undefined;
99
+ throw error;
100
+ }
101
+
102
+ ensureWithinRoot(realTargetPath, resolvedRoot);
103
+
104
+ const stat = await fs.stat(realTargetPath);
105
+ if (!stat.isFile()) {
106
+ throw new Error(`memory:// URL must resolve to a file: ${url.href}`);
107
+ }
108
+
109
+ const content = await Bun.file(realTargetPath).text();
110
+ const ext = path.extname(realTargetPath).toLowerCase();
111
+ const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain";
112
+
113
+ return {
114
+ url: url.href,
115
+ content,
116
+ contentType,
117
+ size: Buffer.byteLength(content, "utf-8"),
118
+ sourcePath: realTargetPath,
119
+ notes: [],
120
+ };
121
+ }
122
+
64
123
  /**
65
124
  * Protocol handler for memory:// URLs.
66
125
  *
67
- * URL forms:
68
- * - memory://root - Reads memory_summary.md
69
- * - memory://root/<path> - Reads a relative file under memory root
126
+ * Walks every active session's memory root. Worktree-based subagents have
127
+ * their own root; first one containing the file wins. Parent and subagent
128
+ * sharing a cwd see the same file regardless of order.
70
129
  */
71
130
  export class MemoryProtocolHandler implements ProtocolHandler {
72
131
  readonly scheme = "memory";
73
-
74
- constructor(private readonly options: MemoryProtocolOptions) {}
132
+ readonly immutable = true;
75
133
 
76
134
  async resolve(url: InternalUrl): Promise<InternalResource> {
77
- const memoryRoot = path.resolve(this.options.getMemoryRoot());
78
- let resolvedRoot: string;
79
- try {
80
- resolvedRoot = await fs.realpath(memoryRoot);
81
- } catch (error) {
82
- if (isEnoent(error)) {
83
- throw new Error(
84
- "Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
85
- );
86
- }
87
- throw error;
88
- }
89
-
90
- const targetPath = resolveMemoryUrlToPath(url, resolvedRoot);
91
- ensureWithinRoot(targetPath, resolvedRoot);
135
+ const roots = memoryRootsFromRegistry();
92
136
 
93
- const parentDir = path.dirname(targetPath);
94
- try {
95
- const realParent = await fs.realpath(parentDir);
96
- ensureWithinRoot(realParent, resolvedRoot);
97
- } catch (error) {
98
- if (!isEnoent(error)) {
99
- throw error;
100
- }
137
+ if (roots.length === 0) {
138
+ throw new Error(
139
+ "Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
140
+ );
101
141
  }
102
142
 
103
- let realTargetPath: string;
104
- try {
105
- realTargetPath = await fs.realpath(targetPath);
106
- } catch (error) {
107
- if (isEnoent(error)) {
108
- throw new Error(`Memory file not found: ${url.href}`);
143
+ let anyExists = false;
144
+ for (const root of roots) {
145
+ try {
146
+ await fs.stat(root);
147
+ anyExists = true;
148
+ } catch (error) {
149
+ if (isEnoent(error)) continue;
150
+ throw error;
109
151
  }
110
- throw error;
152
+ const result = await tryResolveInRoot(url, root);
153
+ if (result) return result;
111
154
  }
112
155
 
113
- ensureWithinRoot(realTargetPath, resolvedRoot);
114
-
115
- const stat = await fs.stat(realTargetPath);
116
- if (!stat.isFile()) {
117
- throw new Error(`memory:// URL must resolve to a file: ${url.href}`);
156
+ if (!anyExists) {
157
+ throw new Error(
158
+ "Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
159
+ );
118
160
  }
119
161
 
120
- const content = await Bun.file(realTargetPath).text();
121
- const ext = path.extname(realTargetPath).toLowerCase();
122
- const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain";
123
-
124
- return {
125
- url: url.href,
126
- content,
127
- contentType,
128
- size: Buffer.byteLength(content, "utf-8"),
129
- sourcePath: realTargetPath,
130
- notes: [],
131
- };
162
+ throw new Error(`Memory file not found: ${url.href}`);
132
163
  }
133
164
  }
@@ -18,6 +18,7 @@ import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
18
18
  */
19
19
  export class PiProtocolHandler implements ProtocolHandler {
20
20
  readonly scheme = "pi";
21
+ readonly immutable = true;
21
22
 
22
23
  async resolve(url: InternalUrl): Promise<InternalResource> {
23
24
  // Extract filename from host + path
@@ -1,42 +1,58 @@
1
1
  /**
2
2
  * Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://).
3
+ *
4
+ * One process-global router with one handler per scheme. Access via
5
+ * `InternalUrlRouter.instance()`. Handlers are stateless; per-session and
6
+ * shared state lives in `./state.ts`.
3
7
  */
8
+ import { AgentProtocolHandler } from "./agent-protocol";
9
+ import { ArtifactProtocolHandler } from "./artifact-protocol";
10
+ import { LocalProtocolHandler } from "./local-protocol";
11
+ import { McpProtocolHandler } from "./mcp-protocol";
12
+ import { MemoryProtocolHandler } from "./memory-protocol";
4
13
  import { parseInternalUrl } from "./parse";
14
+ import { PiProtocolHandler } from "./pi-protocol";
15
+ import { RuleProtocolHandler } from "./rule-protocol";
16
+ import { SkillProtocolHandler } from "./skill-protocol";
5
17
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
6
18
 
7
- /**
8
- * Router for internal URL schemes.
9
- *
10
- * Dispatches URLs like `agent://output_id` or `memory://root/memory_summary.md` to
11
- * registered protocol handlers.
12
- */
13
19
  export class InternalUrlRouter {
20
+ static #instance: InternalUrlRouter | undefined;
21
+
14
22
  #handlers = new Map<string, ProtocolHandler>();
15
23
 
16
- /**
17
- * Register a protocol handler.
18
- * @param handler Handler to register (uses handler.scheme as key)
19
- */
24
+ constructor() {
25
+ this.register(new PiProtocolHandler());
26
+ this.register(new AgentProtocolHandler());
27
+ this.register(new ArtifactProtocolHandler());
28
+ this.register(new MemoryProtocolHandler());
29
+ this.register(new LocalProtocolHandler());
30
+ this.register(new SkillProtocolHandler());
31
+ this.register(new RuleProtocolHandler());
32
+ this.register(new McpProtocolHandler());
33
+ }
34
+
35
+ /** Process-global router instance. */
36
+ static instance(): InternalUrlRouter {
37
+ InternalUrlRouter.#instance ??= new InternalUrlRouter();
38
+ return InternalUrlRouter.#instance;
39
+ }
40
+
41
+ /** Reset the global instance in tests. */
42
+ static resetForTests(): void {
43
+ InternalUrlRouter.#instance = undefined;
44
+ }
45
+
20
46
  register(handler: ProtocolHandler): void {
21
- this.#handlers.set(handler.scheme, handler);
47
+ this.#handlers.set(handler.scheme.toLowerCase(), handler);
22
48
  }
23
49
 
24
- /**
25
- * Check if the router can handle a URL.
26
- * @param input URL string to check
27
- */
28
50
  canHandle(input: string): boolean {
29
51
  const match = input.match(/^([a-z][a-z0-9+.-]*):\/\//i);
30
52
  if (!match) return false;
31
- const scheme = match[1].toLowerCase();
32
- return this.#handlers.has(scheme);
53
+ return this.#handlers.has(match[1].toLowerCase());
33
54
  }
34
55
 
35
- /**
36
- * Resolve an internal URL to its content.
37
- * @param input URL string (e.g., "agent://reviewer_0", "skill://notion-pages")
38
- * @throws Error if scheme is not registered or resolution fails
39
- */
40
56
  async resolve(input: string): Promise<InternalResource> {
41
57
  const parsed = parseInternalUrl(input);
42
58
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -49,6 +65,7 @@ export class InternalUrlRouter {
49
65
  throw new Error(`Unknown protocol: ${scheme}://\nSupported: ${available || "none"}`);
50
66
  }
51
67
 
52
- return handler.resolve(parsed as InternalUrl);
68
+ const resource = await handler.resolve(parsed as InternalUrl);
69
+ return { ...resource, immutable: resource.immutable ?? handler.immutable };
53
70
  }
54
71
  }
@@ -1,41 +1,24 @@
1
1
  /**
2
2
  * Protocol handler for rule:// URLs.
3
3
  *
4
- * Resolves rule names to their content files.
5
- *
6
4
  * URL forms:
7
5
  * - rule://<name> - Reads rule content
8
6
  */
9
- import type { Rule } from "../capability/rule";
7
+ import { getActiveRules } from "../capability/rule";
10
8
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
11
9
 
12
- export interface RuleProtocolOptions {
13
- /**
14
- * Returns the currently loaded rules.
15
- */
16
- getRules: () => readonly Rule[];
17
- }
18
-
19
- /**
20
- * Handler for rule:// URLs.
21
- *
22
- * Resolves rule names to their content.
23
- */
24
10
  export class RuleProtocolHandler implements ProtocolHandler {
25
11
  readonly scheme = "rule";
26
-
27
- constructor(private readonly options: RuleProtocolOptions) {}
12
+ readonly immutable = true;
28
13
 
29
14
  async resolve(url: InternalUrl): Promise<InternalResource> {
30
- const rules = this.options.getRules();
15
+ const rules = getActiveRules();
31
16
 
32
- // Extract rule name from host
33
17
  const ruleName = url.rawHost || url.hostname;
34
18
  if (!ruleName) {
35
19
  throw new Error("rule:// URL requires a rule name: rule://<name>");
36
20
  }
37
21
 
38
- // Find the rule
39
22
  const rule = rules.find(r => r.name === ruleName);
40
23
  if (!rule) {
41
24
  const available = rules.map(r => r.name);
@@ -8,19 +8,9 @@
8
8
  * - skill://<name>/<path> - Reads relative path within skill's baseDir
9
9
  */
10
10
  import * as path from "node:path";
11
- import type { Skill } from "../extensibility/skills";
11
+ import { getActiveSkills } from "../extensibility/skills";
12
12
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
13
13
 
14
- export interface SkillProtocolOptions {
15
- /**
16
- * Returns the currently loaded skills.
17
- */
18
- getSkills: () => readonly Skill[];
19
- }
20
-
21
- /**
22
- * Get content type based on file extension.
23
- */
24
14
  function getContentType(filePath: string): InternalResource["contentType"] {
25
15
  const ext = path.extname(filePath).toLowerCase();
26
16
  if (ext === ".md") return "text/markdown";
@@ -43,24 +33,19 @@ export function validateRelativePath(relativePath: string): void {
43
33
 
44
34
  /**
45
35
  * Handler for skill:// URLs.
46
- *
47
- * Resolves skill names to their content files.
48
36
  */
49
37
  export class SkillProtocolHandler implements ProtocolHandler {
50
38
  readonly scheme = "skill";
51
-
52
- constructor(private readonly options: SkillProtocolOptions) {}
39
+ readonly immutable = true;
53
40
 
54
41
  async resolve(url: InternalUrl): Promise<InternalResource> {
55
- const skills = this.options.getSkills();
42
+ const skills = getActiveSkills();
56
43
 
57
- // Extract skill name from host
58
44
  const skillName = url.rawHost || url.hostname;
59
45
  if (!skillName) {
60
46
  throw new Error("skill:// URL requires a skill name: skill://<name>");
61
47
  }
62
48
 
63
- // Find the skill
64
49
  const skill = skills.find(s => s.name === skillName);
65
50
  if (!skill) {
66
51
  const available = skills.map(s => s.name);
@@ -68,41 +53,34 @@ export class SkillProtocolHandler implements ProtocolHandler {
68
53
  throw new Error(`Unknown skill: ${skillName}\nAvailable: ${availableStr}`);
69
54
  }
70
55
 
71
- // Determine the file to read
72
56
  let targetPath: string;
73
57
  const urlPath = url.pathname;
74
58
  const hasRelativePath = urlPath && urlPath !== "/" && urlPath !== "";
75
59
 
76
60
  if (hasRelativePath) {
77
- // Read relative path within skill's baseDir
78
- const relativePath = decodeURIComponent(urlPath.slice(1)); // Remove leading /
61
+ const relativePath = decodeURIComponent(urlPath.slice(1));
79
62
  validateRelativePath(relativePath);
80
63
  targetPath = path.join(skill.baseDir, relativePath);
81
64
 
82
- // Verify the resolved path is still within baseDir
83
65
  const resolvedPath = path.resolve(targetPath);
84
66
  const resolvedBaseDir = path.resolve(skill.baseDir);
85
67
  if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
86
68
  throw new Error("Path traversal is not allowed");
87
69
  }
88
70
  } else {
89
- // Read SKILL.md
90
71
  targetPath = skill.filePath;
91
72
  }
92
73
 
93
- // Read the file
94
74
  const file = Bun.file(targetPath);
95
75
  if (!(await file.exists())) {
96
76
  throw new Error(`File not found: ${targetPath}`);
97
77
  }
98
78
 
99
79
  const content = await file.text();
100
- const contentType = getContentType(targetPath);
101
-
102
80
  return {
103
81
  url: url.href,
104
82
  content,
105
- contentType,
83
+ contentType: getContentType(targetPath),
106
84
  size: Buffer.byteLength(content, "utf-8"),
107
85
  sourcePath: targetPath,
108
86
  notes: [],
@@ -6,7 +6,9 @@
6
6
  */
7
7
 
8
8
  /**
9
- * Resolved internal resource returned by protocol handlers.
9
+ * Raw resource payload returned by protocol handlers. The `immutable` flag is
10
+ * applied by the router from {@link ProtocolHandler.immutable}, so handlers do
11
+ * not need to set it themselves.
10
12
  */
11
13
  export interface InternalResource {
12
14
  /** Canonical URL that was resolved */
@@ -21,6 +23,13 @@ export interface InternalResource {
21
23
  sourcePath?: string;
22
24
  /** Additional notes about resolution */
23
25
  notes?: string[];
26
+ /**
27
+ * True when the resolved content cannot be edited by the agent (e.g. sealed
28
+ * artifacts, harness docs, machine-generated memory summaries). Hashline
29
+ * anchors and similar edit affordances are suppressed for immutable
30
+ * resources. Mutable resources (e.g. local://) behave like editable files.
31
+ */
32
+ immutable?: boolean;
24
33
  }
25
34
 
26
35
  /**
@@ -44,7 +53,14 @@ export interface ProtocolHandler {
44
53
  /** The scheme this handler processes (without trailing ://) */
45
54
  readonly scheme: string;
46
55
  /**
47
- * Resolve an internal URL to its content.
56
+ * Whether resources produced by this handler are immutable (cannot be
57
+ * edited by the agent). When true, callers suppress hashline anchors and
58
+ * other edit affordances. When false, resources behave like editable files.
59
+ */
60
+ readonly immutable: boolean;
61
+ /**
62
+ * Resolve an internal URL to its content. The router stamps the
63
+ * {@link InternalResource.immutable} flag from {@link ProtocolHandler.immutable}.
48
64
  * @param url Parsed URL object
49
65
  * @throws Error with user-friendly message if resolution fails
50
66
  */
package/src/main.ts CHANGED
@@ -765,7 +765,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
765
765
  await mgr.upgradeAllPlugins();
766
766
  logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s)`);
767
767
  } else {
768
- logger.debug(`${updates.length} marketplace plugin update(s) available \u2014 /marketplace upgrade`);
768
+ logger.debug(`${updates.length} marketplace plugin update(s) available /marketplace upgrade`);
769
769
  }
770
770
  } catch {
771
771
  // Silently ignore — network failure, corrupt data, offline.
@@ -132,6 +132,23 @@ export interface MCPDiscoverOptions {
132
132
  * Manages connections to MCP servers and provides tools to the agent.
133
133
  */
134
134
  export class MCPManager {
135
+ static #instance: MCPManager | undefined;
136
+
137
+ /** Process-global instance shared by internal URL protocol handlers and tools. */
138
+ static instance(): MCPManager | undefined {
139
+ return MCPManager.#instance;
140
+ }
141
+
142
+ /** Install or clear the process-global instance. */
143
+ static setInstance(value: MCPManager | undefined): void {
144
+ MCPManager.#instance = value;
145
+ }
146
+
147
+ /** Reset the process-global instance. Test-only. */
148
+ static resetForTests(): void {
149
+ MCPManager.#instance = undefined;
150
+ }
151
+
135
152
  #connections = new Map<string, MCPServerConnection>();
136
153
  #tools: CustomTool<TSchema, MCPToolDetails>[] = [];
137
154
  #pendingConnections = new Map<string, Promise<MCPServerConnection>>();
@@ -192,7 +192,7 @@ export class SessionObserverOverlayComponent extends Container {
192
192
  const statsLine = this.#buildStatsLine(session);
193
193
  if (statsLine) this.#viewerFooterLines.push(statsLine);
194
194
  this.#viewerFooterLines.push(
195
- theme.fg("dim", "j/k:scroll Enter:expand [/]/\u2190\u2192:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
195
+ theme.fg("dim", "j/k:scroll Enter:expand [/]/←→:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
196
196
  );
197
197
 
198
198
  // Auto-scroll to bottom if we were at bottom
@@ -452,7 +452,7 @@ export class SessionObserverOverlayComponent extends Container {
452
452
 
453
453
  // Tool call header
454
454
  const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : "";
455
- lines.push(`${cursor} ${theme.fg("accent", "\u25B8")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
455
+ lines.push(`${cursor} ${theme.fg("accent", "")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
456
456
 
457
457
  // Key arguments
458
458
  const argSummary = this.#formatToolArgs(call.name, call.arguments);
@@ -665,6 +665,12 @@ export class ToolExecutionComponent extends Container {
665
665
  context.perFileDiffPreview = previews;
666
666
  }
667
667
  }
668
+ if (!previews?.some(preview => preview.diff)) {
669
+ const editMode = this.#editMode;
670
+ const strategy = editMode ? EDIT_MODE_STRATEGIES[editMode] : undefined;
671
+ const fallback = strategy?.renderStreamingFallback(this.#args, theme);
672
+ if (fallback) context.editStreamingFallback = fallback;
673
+ }
668
674
  context.renderDiff = renderDiff;
669
675
  }
670
676