diff --git a/packages/coding-agent/src/internal-urls/agent-protocol.ts b/packages/coding-agent/src/internal-urls/agent-protocol.ts index 8875b1757..69c8e7078 100644 --- a/packages/coding-agent/src/internal-urls/agent-protocol.ts +++ b/packages/coding-agent/src/internal-urls/agent-protocol.ts @@ -42,6 +42,7 @@ async function listAvailableOutputs(artifactsDir: string): Promise { */ export class AgentProtocolHandler implements ProtocolHandler { readonly scheme = "agent"; + readonly immutable = true; constructor(private readonly options: AgentProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/artifact-protocol.ts b/packages/coding-agent/src/internal-urls/artifact-protocol.ts index 7878d0325..9d2e70fef 100644 --- a/packages/coding-agent/src/internal-urls/artifact-protocol.ts +++ b/packages/coding-agent/src/internal-urls/artifact-protocol.ts @@ -44,6 +44,7 @@ async function listAvailableArtifacts(artifactsDir: string): Promise { */ export class ArtifactProtocolHandler implements ProtocolHandler { readonly scheme = "artifact"; + readonly immutable = true; constructor(private readonly options: ArtifactProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/jobs-protocol.ts b/packages/coding-agent/src/internal-urls/jobs-protocol.ts index c05136b0c..f45b02608 100644 --- a/packages/coding-agent/src/internal-urls/jobs-protocol.ts +++ b/packages/coding-agent/src/internal-urls/jobs-protocol.ts @@ -24,6 +24,7 @@ function normalizeJobId(url: InternalUrl): string { export class JobsProtocolHandler implements ProtocolHandler { readonly scheme = "jobs"; + readonly immutable = true; constructor(private readonly options: JobsProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index bf47a6f2e..972956508 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -141,6 +141,7 @@ export function resolveLocalUrlToPath(input: string | InternalUrl, options: Loca */ export class LocalProtocolHandler implements ProtocolHandler { readonly scheme = "local"; + readonly immutable = false; constructor(private readonly options: LocalProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/mcp-protocol.ts b/packages/coding-agent/src/internal-urls/mcp-protocol.ts index 763b7f88a..fd498415d 100644 --- a/packages/coding-agent/src/internal-urls/mcp-protocol.ts +++ b/packages/coding-agent/src/internal-urls/mcp-protocol.ts @@ -106,6 +106,7 @@ function formatAvailableResources(mcpManager: MCPManager): string { */ export class McpProtocolHandler implements ProtocolHandler { readonly scheme = "mcp"; + readonly immutable = true; constructor(private readonly options: McpProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 2e06a5d5e..94e7de0b2 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -70,6 +70,7 @@ export function resolveMemoryUrlToPath(url: InternalUrl, memoryRoot: string): st */ export class MemoryProtocolHandler implements ProtocolHandler { readonly scheme = "memory"; + readonly immutable = true; constructor(private readonly options: MemoryProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/pi-protocol.ts b/packages/coding-agent/src/internal-urls/pi-protocol.ts index 41e0044f7..b5e9f9ec1 100644 --- a/packages/coding-agent/src/internal-urls/pi-protocol.ts +++ b/packages/coding-agent/src/internal-urls/pi-protocol.ts @@ -18,6 +18,7 @@ import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; */ export class PiProtocolHandler implements ProtocolHandler { readonly scheme = "pi"; + readonly immutable = true; async resolve(url: InternalUrl): Promise { // Extract filename from host + path diff --git a/packages/coding-agent/src/internal-urls/router.ts b/packages/coding-agent/src/internal-urls/router.ts index 0779cfee6..717742b57 100644 --- a/packages/coding-agent/src/internal-urls/router.ts +++ b/packages/coding-agent/src/internal-urls/router.ts @@ -49,6 +49,7 @@ export class InternalUrlRouter { throw new Error(`Unknown protocol: ${scheme}://\nSupported: ${available || "none"}`); } - return handler.resolve(parsed as InternalUrl); + const resource = await handler.resolve(parsed as InternalUrl); + return { ...resource, immutable: resource.immutable ?? handler.immutable }; } } diff --git a/packages/coding-agent/src/internal-urls/rule-protocol.ts b/packages/coding-agent/src/internal-urls/rule-protocol.ts index d5629837a..f8aea56e1 100644 --- a/packages/coding-agent/src/internal-urls/rule-protocol.ts +++ b/packages/coding-agent/src/internal-urls/rule-protocol.ts @@ -23,6 +23,7 @@ export interface RuleProtocolOptions { */ export class RuleProtocolHandler implements ProtocolHandler { readonly scheme = "rule"; + readonly immutable = true; constructor(private readonly options: RuleProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/skill-protocol.ts b/packages/coding-agent/src/internal-urls/skill-protocol.ts index a47330692..e506489ad 100644 --- a/packages/coding-agent/src/internal-urls/skill-protocol.ts +++ b/packages/coding-agent/src/internal-urls/skill-protocol.ts @@ -48,6 +48,7 @@ export function validateRelativePath(relativePath: string): void { */ export class SkillProtocolHandler implements ProtocolHandler { readonly scheme = "skill"; + readonly immutable = true; constructor(private readonly options: SkillProtocolOptions) {} diff --git a/packages/coding-agent/src/internal-urls/types.ts b/packages/coding-agent/src/internal-urls/types.ts index 0465d8ae2..3399f9831 100644 --- a/packages/coding-agent/src/internal-urls/types.ts +++ b/packages/coding-agent/src/internal-urls/types.ts @@ -6,7 +6,9 @@ */ /** - * Resolved internal resource returned by protocol handlers. + * Raw resource payload returned by protocol handlers. The `immutable` flag is + * applied by the router from {@link ProtocolHandler.immutable}, so handlers do + * not need to set it themselves. */ export interface InternalResource { /** Canonical URL that was resolved */ @@ -21,6 +23,13 @@ export interface InternalResource { sourcePath?: string; /** Additional notes about resolution */ notes?: string[]; + /** + * True when the resolved content cannot be edited by the agent (e.g. sealed + * artifacts, harness docs, machine-generated memory summaries). Hashline + * anchors and similar edit affordances are suppressed for immutable + * resources. Mutable resources (e.g. local://) behave like editable files. + */ + immutable?: boolean; } /** @@ -44,7 +53,14 @@ export interface ProtocolHandler { /** The scheme this handler processes (without trailing ://) */ readonly scheme: string; /** - * Resolve an internal URL to its content. + * Whether resources produced by this handler are immutable (cannot be + * edited by the agent). When true, callers suppress hashline anchors and + * other edit affordances. When false, resources behave like editable files. + */ + readonly immutable: boolean; + /** + * Resolve an internal URL to its content. The router stamps the + * {@link InternalResource.immutable} flag from {@link ProtocolHandler.immutable}. * @param url Parsed URL object * @throws Error with user-friendly message if resolution fails */ diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index e796dd3b2..a18e1ca44 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1590,7 +1590,7 @@ export class ReadTool implements AgentTool { sourceInternal: url, entityLabel: "resource", ignoreResultLimits: scheme === "skill", - immutable: true, + immutable: resource.immutable, raw: options?.raw, }); } diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index afb649b6e..52e8ad3d3 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -121,7 +121,6 @@ export class SearchTool implements AgentTool formatPathRelativeToCwd(targetPath, this.session.cwd); let searchPath: string; let scopePath: string; @@ -134,12 +133,15 @@ export class SearchTool implements AgentTool(); for (const rawPath of rawPaths) { if (!internalRouter?.canHandle(rawPath)) { resolvedPathInputs.push(rawPath); continue; } - hasImmutableInput = true; if (hasGlobPathChars(rawPath)) { throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`); } @@ -147,9 +149,13 @@ export class SearchTool implements AgentTool { let nextWidth = Math.max(width, String(match.lineNumber).length); for (const ctx of match.contextBefore ?? []) { diff --git a/packages/coding-agent/test/tools/bash-skill-urls.test.ts b/packages/coding-agent/test/tools/bash-skill-urls.test.ts index 7dfed3d14..3dd755165 100644 --- a/packages/coding-agent/test/tools/bash-skill-urls.test.ts +++ b/packages/coding-agent/test/tools/bash-skill-urls.test.ts @@ -24,7 +24,7 @@ function createInternalRouter(resources: Record boolean; resolve: ( input: string, - ) => Promise<{ url: string; content: string; contentType: "text/plain"; sourcePath?: string }>; + ) => Promise<{ url: string; content: string; contentType: "text/plain"; sourcePath?: string; immutable: boolean }>; } { return { canHandle: input => /^(agent|artifact|plan|memory|rule):\/\//.test(input), @@ -41,6 +41,7 @@ function createInternalRouter(resources: Record { const router = new InternalUrlRouter(); router.register({ scheme: "agent", + immutable: true, async resolve() { return { url: "agent://0", @@ -154,6 +156,52 @@ describe("SearchTool internal URL resolution", () => { expect(text).not.toMatch(/^\*?\s*\d+[a-z]{2}\|/m); }); + it("keeps hashline anchors when searching mutable local:// sources", async () => { + const localRoot = path.join(artifactsDir, "local"); + await fs.mkdir(localRoot, { recursive: true }); + await Bun.write(path.join(localRoot, "plan.md"), "alpha line\nbeta needle line\ngamma line\n"); + + const router = new InternalUrlRouter(); + router.register( + new LocalProtocolHandler({ + getArtifactsDir: () => artifactsDir, + getSessionId: () => "session", + }), + ); + const session = createSession({ internalRouter: router, hasEditTool: true }); + const tool = new SearchTool(session); + + const result = await tool.execute("test-call", { + pattern: "needle", + paths: ["local://plan.md"], + }); + + const text = getResultText(result); + expect(text).toContain("needle"); + // Hashline anchor (LINE+ID|content) is kept for mutable local:// sources + expect(text).toMatch(/^\*?\s*\d+[a-z]{2}\|/m); + }); + + it("keeps hashlines on mutable files when mixed with immutable artifact:// inputs", async () => { + const content = "alpha line\nbeta needle line\ngamma line\n"; + await Bun.write(path.join(artifactsDir, "11.bash.log"), content); + await Bun.write(path.join(tmpDir, "mixed.txt"), "mixed needle line\n"); + + const router = createRouterWithArtifacts(); + const session = createSession({ internalRouter: router, hasEditTool: true }); + const tool = new SearchTool(session); + + const result = await tool.execute("test-call", { + pattern: "needle", + paths: ["artifact://11", "mixed.txt"], + }); + + const text = getResultText(result); + expect(text).toContain("needle"); + // Mutable mixed.txt keeps hashlines somewhere in the output + expect(text).toMatch(/^\*?\s*\d+[a-z]{2}\|.*mixed needle/m); + }); + it("throws on nonexistent artifact ID", async () => { const router = createRouterWithArtifacts(); const session = createSession({ internalRouter: router });