diff --git a/.grace/graph/main.xml b/.grace/graph/main.xml
index 38bf8fa..5e0eac8 100644
--- a/.grace/graph/main.xml
+++ b/.grace/graph/main.xml
@@ -22,7 +22,7 @@
VvocPresetRegistryUTILITYimplementedDefine canonical managed preset role and orchestration definitions for config seeding, sync refresh, and completion, shipped as vv-codex, vv-zai, vv-deepseek, vv-alibaba, vv-osovv-ds, vv-osovv-mimo, vv-osovv-zai, vv-osovv-qwen, vv-astra-solo, and vv-astra-workers with canonical provider/model#variant role values, with the former vv-osovv-sol/flash/kimi names retained only as preserved saved user presets.src/lib/vvoc-preset-registry.tsM-ORCHESTRATION-PROFILESV-M-VVOC-PRESET-REGISTRY
GuardianPluginINTEGRATIONimplementedReview native OpenCode 2.0.18 permission evaluations through ctx.permission.hook("evaluate") with a constrained, settings-bound Guardian auxiliary model whose policy denies both built-in and canonical web tools, or with an opt-in provider-neutral System One decision provider when guardian.decisionBackend selects it and the systemone section, toggle, and ${VAR} key resolve; systemone answers (a low-risk noul and a risk score) map onto the same bounded low-risk verdict, and any provider failure defers; mutating effect only to auto-approve a bounded low-risk verdict and otherwise leaving the user’s manual or denied decision intact; unbound or disabled families defer.src/plugins/guardian/index.ts, templates/agents/guardian.mdM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-MODEL-ROLES, M-SYSTEMONE-PROVIDER, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-GUARDIAN
SystemOneProviderCORE_LOGICimplementedProvide a provider-neutral client for the de-facto System One decision protocol (POST /v1/systemone): shape state and typed noul, choice, and score questions, decode typed answers with probabilities and confidence while ignoring unknown fields, apply a bounded timeout and retry count, and raise credential-safe typed errors; any compliant endpoint is selected by baseUrl and model with no vendor-specific branch and no default endpoint.src/lib/systemone.tsnoneV-M-SYSTEMONE-PROVIDER
- HashlineEditPluginINTEGRATIONimplementedRoute per-model edit tooling by registering hashline_edit and dsh str_replace_editor as native OpenCode 2.0.18 tools; resolve the session edit mode from the family-settings-projected vvoc routing config (apply_patch | edit | str_replace_editor | hashline_edit); expose exactly one plugin edit tool per actual session model while never touching the host edit/apply_patch contracts; require a native resource permission before any write; transform native read results with true line/hash/context anchors and freshness tracking; apply valid payloads literally; honor an explicit host permission denial and restore plugin-owned versus captured host tool definitions by ownership; and report bounded post-edit diffs with apply warnings and editMode telemetry.src/plugins/hashline-edit/*.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIG, M-CONFIG-LAYERS, M-CLI-CONFIG, M-AGENT-TOOL-CONTRACTV-M-PLUGIN-HASHLINE-EDIT
+ HashlineEditPluginINTEGRATIONimplementedRoute per-model edit tooling by registering hashline_edit and dsh str_replace_editor as native OpenCode 2.0.18 tools; resolve the session edit mode from the family-settings-projected vvoc routing config (apply_patch | edit | str_replace_editor | hashline_edit); expose exactly one plugin edit tool per actual session model while never touching the host edit/apply_patch contracts; require a native resource permission before any write; serialize native str_replace_editor read/check/write/cache cycles per canonical target across sessions in one process and publish byte-verified staged UTF-8 writes atomically; transform native read results with true line/hash/context anchors and freshness tracking; apply valid payloads literally; honor an explicit host permission denial and restore plugin-owned versus captured host tool definitions by ownership; and report bounded post-edit diffs with apply warnings and editMode telemetry.src/plugins/hashline-edit/*.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIG, M-CONFIG-LAYERS, M-CLI-CONFIG, M-AGENT-TOOL-CONTRACTV-M-PLUGIN-HASHLINE-EDIT
SystemContextInjectionPluginINTEGRATIONimplementedLoad the bundled canonical controller template locally at startup and deliver its seven sections exactly once through exact-text deduplication against host-supplied system parts. Other eligible primary agents receive shared project, repository-memory, evidence and working-cycle sections plus authority and delivery fragments without controller-only task, dispatch/recovery or full communication policy. Preserve native-registry subagent exclusions, the explore-specific exception, disabled/unknown no-op and user-owned system content. Resolve the active controller orchestration policy from session family settings and append it only to the last user message; workflow protocols and native skill discovery retain their existing owners.src/plugins/system-context-injection/index.ts, src/plugins/system-context-injection/guidance.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-ORCHESTRATION-PROFILES, M-CLI-MANAGED-AGENTS, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-SYSTEM-CONTEXT-INJECTION
SecretsRedactionPluginINTEGRATIONimplementedRedact secrets including placeholder-resolved configured web apiKey values in native provider-bound request hooks (context/generate/compaction/title) under the family's resolved settings, then restore placeholders with copy-on-write semantics for frozen native tool inputs, SSE and non-SSE http.response bodies, and WebSocket frames — carrying placeholders split across SSE and WebSocket TEXT deltas with bounded carry while preserving opaque frames.src/plugins/secrets-redaction.ts, src/plugins/secrets-redaction/*.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIG, M-ENV-SUBSTITUTIONV-M-PLUGIN-SECRETS-REDACTION
SpecLintCORE_LOGICimplementedStrictly lint .vvoc spec-package XML artifacts through a content-addressed cache under XDG_CACHE_HOME keyed on every input hash plus a lint version: validate well-formedness over the htmlparser2 xmlMode tokenizer event stream, enforce attribute-free template contracts per artifact kind, component/task/wave/checkpoint identity patterns and uniqueness, bare-slug and task references, lifecycle-aware severity where draft incompleteness never blocks, the plan-components-subset-of-spec-components cross-file rule, the optional execution section with inline/classic/delegated modes, review_checkpoints with reviewer sets, scope paths, wave barriers, final coverage of tasks and write scopes, per-task write_scope requirements, and package layout checks; extractDelegatedPlanDefinition returns the typed obligations runtime registration reuses; the shared declared-path normalization and delegated obligation vocabulary are re-exported from M-WORKFLOW-CONTRACT.src/lib/spec-lint.ts, src/lib/spec-lint/parser.ts, src/lib/spec-lint/spec-rules.ts, src/lib/spec-lint/plan-rules.ts, src/lib/spec-lint/cross-file-rules.ts, src/lib/spec-lint/delegated-extraction.ts, src/lib/spec-lint/lint.ts, src/lib/spec-lint-cache.tshtmlparser2, M-CONFIG-LAYERSV-M-SPEC-LINT
@@ -83,7 +83,7 @@
role list/set/unset loads selected vvoc config, validates role IDs, normalizes assignments, writes role map, or renders inventory.
The shared native runtime resolves vv-role:* assignments from the family's session-owned settings record (root-session metadata, with the live effective vvoc role map plus the vvoc plugins modelIntent envelope as the birth-time and fallback source), applies concrete provider/model#variant selections at creation through agent definitions and a one-shot child reconciliation, never rewrites an existing session's model, and fails loudly for unknown roles instead of falling back.
Preset command reads presets, validates selected name, normalizes role and orchestration assignments, applies declared fields atomically, and leaves OpenCode config untouched.
- Hashline plugin publishes closed edit input contracts, requests a native resource permission before any write, rewrites read output to line/hash anchors, rejects unknown, conflicting, or malformed fields before touching a file, validates current anchors on edit, applies batches, and rejects stale or malformed anchors while preserving literal empty-content deletion, null/empty-array deletion, boundary insertion, and `view_range` ending at `-1`.
+ Hashline plugin publishes closed edit input contracts, requests a native resource permission before any write, rewrites read output to line/hash anchors, rejects unknown, conflicting, or malformed fields before touching a file, validates current anchors on edit, applies hashline batches, and rejects stale or malformed anchors while preserving literal empty-content deletion, null/empty-array deletion, boundary insertion, and `view_range` ending at `-1`. Native str_replace_editor calls serialize complete read/check/write/cache cycles per canonical target in one process and publish byte-verified staged UTF-8 content atomically; individual calls remain independent rather than a batch transaction, with no exclusion guarantee against other tools or external processes.
Guardian evaluates native permission requests through ctx.permission.hook("evaluate"), builds bounded review input from the actual action, resources, source and host history, runs the settings-bound Guardian auxiliary generation, interprets the verdict, and mutates the effect to allow only when policy declares low risk, otherwise leaving the user’s ask or deny decision intact.
The bundled controller template supplies canonical wording to managed installation and startup-local guidance projection. Native agent identity selects complete controller sections or shared-primary fragments; exact-text deduplication preserves the supplied system parts. Session family settings select the controller policy delivered at the request tail, with the existing explore exception and other subagent exclusions.
SecretsRedactionPlugin resolves the family's settings before provider dispatch, redacts provider-bound payloads in the native request hooks, restores placeholders after completions, restores them copy-on-write for frozen native tool input before execution, and restores SSE plus WebSocket frames while preserving opaque frames.
diff --git a/.grace/verification/main.xml b/.grace/verification/main.xml
index 013dbfe..e2a297d 100644
--- a/.grace/verification/main.xml
+++ b/.grace/verification/main.xml
@@ -57,7 +57,27 @@
src/plugins/model-roles.integration.test.tsbun test src/plugins/model-roles.integration.test.tsbun x tsc --noEmitThe native Plugin.define export shares one runtime and settings service per context and drives session-owned family settings: lazy metadata birth on the first resolution with no per-request rewrite, foreign-metadata preservation, family-scoped explicit metadata changes, restart-stable resolution, config-watch containment and recovery, lineage failures resolving to nothing without failing the caller, creation-level role application through agent definitions and the one-shot host-created-child reconciliation, auxiliary title children bound to family role models, and an optional isolated real OpenCode host smoke loading the actual built plugin.The declared TestFiles and Checks exercise this scenario deterministically.Disabled override leaves agent models untouched while settings resolution continues; an enabled override selects the vv-controller role through native creation paths; a born family keeps its recorded settings and roles across config flips while a fresh family births the changed config; an explicit root metadata change moves only that family; invalid config events are contained and recovered when fixed; and creation reconciliation applies exactly once per child.
src/plugins/guardian.integration.test.tsbun test src/plugins/guardian.integration.test.tsbun x tsc --noEmitGuardian permission review remains bounded, role-resolved, web-tool constrained, backend-selectable, and fail-safe.The declared TestFiles and Checks exercise this scenario deterministically.Native permission.evaluate events mutate effect only within policy: unresolved or disabled families defer, low-risk verdicts below the threshold auto-allow through settings-bound auxiliary generation or through the opt-in System One provider (a low-risk noul gated by lowRiskThreshold and a risk score mapped linearly onto the approval threshold); any provider failure or malformed answer, uncertainty, inference failure, and empty verdicts fall back to the user’s ask/deny decision; a disabled section, unresolved ${VAR} key, or disabled systemone toggle keeps the fast backend without auto-approving from the provider; review input stays bounded and credential-safe, provider failures never auto-approve, and setup registers the evaluate hook and releases the shared runtime on cleanup.
src/lib/systemone.test.tsbun test src/lib/systemone.test.tsbun x tsc --noEmitThe provider-neutral System One client shapes requests, decodes typed noul, choice, and score answers, and fails safely over an injected transport.The declared TestFiles and Checks exercise this scenario deterministically.POST {baseUrl}/v1/systemone receives state, model, and typed questions; noul, choice, and score answers decode with probabilities and confidence while unknown fields and provider extras are ignored; two distinct base URLs work through one client with no vendor branch; a bounded timeout and retry count apply and exhausted retries surface a typed error; 401 is not retried; invalid local request shapes are rejected before any call; and no error message contains the apiKey or the state.
- src/plugins/hashline-edit/normalize-edits.test.tssrc/plugins/hashline-edit/contracts.test.tssrc/plugins/hashline-edit.integration.test.tssrc/plugins/hashline-edit/edit-operations.test.tssrc/plugins/hashline-edit/validation.test.tssrc/plugins/hashline-edit/tool-description.test.tssrc/plugins/hashline-edit/routing.test.tssrc/plugins/hashline-edit/session-state.test.tssrc/plugins/hashline-edit/str-replace-editor.test.tsbun test src/plugins/hashline-edit/normalize-edits.test.ts src/plugins/hashline-edit/contracts.test.ts src/plugins/hashline-edit.integration.test.ts src/plugins/hashline-edit/edit-operations.test.ts src/plugins/hashline-edit/validation.test.ts src/plugins/hashline-edit/tool-description.test.ts src/plugins/hashline-edit/routing.test.ts src/plugins/hashline-edit/session-state.test.ts src/plugins/hashline-edit/str-replace-editor.test.tsbun x tsc --noEmitThe four-value routing vocabulary (apply_patch | edit | str_replace_editor | hashline_edit) resolves per family settings and session model, and native session tool-list visibility leaves exactly one existing-file editor visible — the host built-in edit stays visible for the edit cohort and is hidden for the str_replace_editor/hashline_edit cohorts, apply_patch is left to the host gate, new-file creation stays available, and the plugin registers only hashline_edit and str_replace_editor as native tools while requiring an awaited native resource permission before any write, honoring an explicit host permission denial (a session that exposes no edit-family tool is left untouched without throwing), and restoring plugin-owned definitions from the plugin itself rather than from the captured host map; hashline_edit and str_replace_editor publish closed operation-specific input contracts, reject unknown or conflicting fields and malformed ranges before mutation, and preserve hashline read anchors, three-part reference enforcement, unified replace with optional end, replace_range, lines:null/empty deletion, append/prepend boundary insertion, meaningful empty new_str, a view_range ending at -1, literal payload application, neighbor-duplication warnings, the dsh str_replace_editor contract, and bounded post-edit diff feedback. Post-release telemetry reruns the opencode.db baseline split by the editMode metadata tag.The declared TestFiles and Checks exercise this scenario deterministically.
+
+
+ src/plugins/hashline-edit/normalize-edits.test.ts
+ src/plugins/hashline-edit/contracts.test.ts
+ src/plugins/hashline-edit.integration.test.ts
+ src/plugins/hashline-edit/edit-operations.test.ts
+ src/plugins/hashline-edit/validation.test.ts
+ src/plugins/hashline-edit/tool-description.test.ts
+ src/plugins/hashline-edit/routing.test.ts
+ src/plugins/hashline-edit/session-state.test.ts
+ src/plugins/hashline-edit/str-replace-editor.test.ts
+ src/plugins/hashline-edit/target-lock.test.ts
+ src/plugins/hashline-edit/atomic-write.test.ts
+
+
+ bun test src/plugins/hashline-edit src/plugins/hashline-edit.integration.test.ts
+ bun run typecheck
+
+ The four-value routing vocabulary (apply_patch | edit | str_replace_editor | hashline_edit) resolves per family settings and session model, and native session tool-list visibility leaves exactly one existing-file editor visible — the host built-in edit stays visible for the edit cohort and is hidden for the str_replace_editor/hashline_edit cohorts, apply_patch is left to the host gate, new-file creation stays available, and the plugin registers only hashline_edit and str_replace_editor as native tools while requiring an awaited native resource permission before any write, honoring an explicit host permission denial (a session that exposes no edit-family tool is left untouched without throwing), and restoring plugin-owned definitions from the plugin itself rather than from the captured host map; hashline_edit and str_replace_editor publish closed operation-specific input contracts, reject unknown or conflicting fields and malformed ranges before mutation, and preserve hashline read anchors, three-part reference enforcement, unified replace with optional end, replace_range, lines:null/empty deletion, append/prepend boundary insertion, meaningful empty new_str, a view_range ending at -1, literal payload application, neighbor-duplication warnings, the dsh str_replace_editor contract, and bounded post-edit diff feedback. Native str_replace_editor concurrency checks cover complete canonical-target read/check/write/cache serialization across sessions and handler instances, independent-file progress, staged byte-exact UTF-8 publication without added NUL or stale tails, create conflicts, cancellation and failure recovery, and file-mode and symlink preservation. Individual calls are not a batch transaction, and other tools or external processes are outside the exclusion guarantee. Post-release telemetry reruns the opencode.db baseline split by the editMode metadata tag.
+ The declared TestFiles and Checks exercise this scenario deterministically.
+
src/plugins/system-context-injection.integration.test.tssrc/plugins/system-context-injection/guidance.test.tsbun test src/plugins/system-context-injection.integration.test.ts src/plugins/system-context-injection/guidance.test.tsbun x tsc --noEmitReal context-hook execution with the bundled managed controller retains its exact seven-section body and order after repeated injection for all four profiles, while the concrete profile policy appears once at the user tail. Other primaries retain shared memory, evidence, assumptions, diagnostic counterexample, drift thresholds, authority and delivery instructions without controller-only role/dispatch/recovery text. Missing or duplicated canonical sections and missing shared fragments reject projection explicitly. LF and CRLF templates project the same instructions, and deduplication preserves CRLF host text byte-for-byte. Existing user system text, disabled/unknown no-op, complete controller text with injection disabled, explore-specific guidance, other subagent/internal exclusions and fresh registry recovery remain covered. Tests establish deterministic delivery, not model behavior.The declared TestFiles and Checks exercise this scenario deterministically.
src/plugins/secrets-redaction/engine.test.tssrc/plugins/secrets-redaction/index.test.tssrc/plugins/secrets-redaction/patterns.test.tssrc/plugins/secrets-redaction/session.test.tssrc/plugins/secrets-redaction/stream.test.tsbun test src/plugins/secrets-redaction/engine.test.ts src/plugins/secrets-redaction/index.test.ts src/plugins/secrets-redaction/patterns.test.ts src/plugins/secrets-redaction/session.test.ts src/plugins/secrets-redaction/stream.test.tsbun run typecheckbun run lintBuilt-in, custom, and configured secrets redact before provider exposure under the family’s resolved settings, restore copy-on-write for frozen native tool input and streamed provider responses — including placeholders split across SSE and WebSocket TEXT deltas with bounded lane carry — and preserve opaque frames.The declared TestFiles and Checks exercise this scenario deterministically.Built-in, regex, custom keyword, and configured web apiKey values redact before provider calls, with ${VAR} placeholders in web apiKey fields resolved from the environment so redaction covers the real key rather than the placeholder text; split placeholders restore across text pushes, SSE events, and byte chunks with per-lane scoping, bounded carry, byte-for-byte opaque/comment/malformed frame preservation, and non-SSE body restoration; placeholder restoration and session behavior remain deterministic.
src/lib/spec-lint.test.tssrc/lib/spec-lint-cache.test.tssrc/lib/config-layers.test.tsbun test src/lib/spec-lint.test.ts src/lib/spec-lint-cache.test.ts src/lib/config-layers.test.tsbun run typecheckThe strict engine validates well-formedness, template contracts, identity patterns, references, lifecycle-aware severity, cross-file subset rules, layout checks, the optional delegated execution and review-checkpoint vocabulary with scope paths, reviewer sets, wave barriers, and final coverage, typed extractDelegatedPlanDefinition, and the content-addressed cache with content and version invalidation including rejection of old-version disk caches.The declared TestFiles and Checks exercise this scenario deterministically.Fixture corpus proves every violation class carries a specific rule with file and line, draft documents tolerate emptiness while approved documents do not, plan components must exist in the linked spec, legacy plans without execution stay valid, delegated obligations are required only for approved plans, the shipped reference templates lint clean, and cache runs hit on unchanged inputs and invalidate when any input hash or the lint version changes.
diff --git a/README.md b/README.md
index 2aa62df..89a5f25 100644
--- a/README.md
+++ b/README.md
@@ -578,6 +578,8 @@ Routing changes require an OpenCode restart, like other runtime plugin settings.
`hashline_edit` and `str_replace_editor` publish closed input contracts and reject conflicting or malformed fields before touching a file, without changing literal edit behavior: `replace` keeps its optional inclusive `end` (the `replace_range` alias still works), `lines` accepts a string, a string array, or `null` (an empty array or `null` deletes the range), `append`/`prepend` perform boundary insertion, `new_str` may be intentionally empty to delete, and `view_range` may end at `-1` to view to end of file. Supplying conflicting anchors, an unknown field, or a malformed range is a structural error that leaves file content, paths, freshness metadata, and the metadata sink unchanged, and the existing current-file and stale-anchor checks stay authoritative before any mutation.
+Native `str_replace_editor` calls targeting the same canonical path are serialized across sessions in one process, including the read, freshness check, mutation, and cache update; calls for different files remain independent. Native reads and editor views share session-isolated canonical freshness records, so aliases cannot bypass an existing observation. Writes stage and verify the complete UTF-8 payload before atomic publication instead of truncating the destination, preserving ordinary file modes, POSIX ownership, and existing symlink targets. Multi-hardlink targets and files with setuid, setgid, or sticky mode bits are refused; inability to preserve uid/gid also fails without changing the original file. Publication requires a writable parent directory and does not preserve extended ACLs or xattrs. Removing a restrictive ACL can widen effective access: use a metadata-preserving editor for files with extended ACLs. Separate calls are not an all-or-nothing batch: a rejected replacement does not undo earlier successful calls. This coordination does not lock out other editing tools or external processes, and it does not add support for arbitrary text encodings.
+
### Tool history compaction
`ToolHistoryCompactionPlugin` shrinks the context replayed to the model on every turn without touching on-disk storage. It rewrites only the in-memory provider-context copy the model is about to receive, through the native session `context` hook and gated per bound session family by its captured policy, and only the `output` of old completed tool parts — `input` and part structure (callID/type/order) are never changed, so provider tool_use/tool_result stitching stays intact.
diff --git a/src/plugins/hashline-edit.integration.test.ts b/src/plugins/hashline-edit.integration.test.ts
index 99bd109..bfd58fa 100644
--- a/src/plugins/hashline-edit.integration.test.ts
+++ b/src/plugins/hashline-edit.integration.test.ts
@@ -1,9 +1,9 @@
// FILE: src/plugins/hashline-edit.integration.test.ts
-// VERSION: 2.0.0
+// VERSION: 2.1.0
// START_MODULE_CONTRACT
-// PURPOSE: Verify the native hashline-edit boundary: native read-output anchoring, hash-anchored and dsh edit execution against real temporary files, routing visibility per actual session model, awaited permission-before-write, stale-anchor rejection, and truthful reporting when metadata publication fails.
-// SCOPE: Native handler seam tests using real temporary files and pinned native tool context/result shapes, plus unchanged pure edit-primitive coverage.
-// DEPENDS: [bun:test, node:fs/promises, node:os, node:path, src/plugins/hashline-edit/edit-operation-primitives.ts, src/plugins/hashline-edit/hash-computation.ts, src/plugins/hashline-edit/index.ts, src/plugins/hashline-edit/schemas.ts]
+// PURPOSE: Verify the native hashline-edit boundary: native read-output anchoring, hash-anchored and dsh edit execution against real temporary files, routing visibility per actual session model, awaited permission-before-write, stale-anchor rejection, truthful reporting when metadata publication fails, and process-wide str_replace_editor target coordination with atomic publication.
+// SCOPE: Native handler seam tests using real temporary files and pinned native tool context/result shapes, including parallel same-target and alias edits, cancellation/failure release, create conflicts, mode preservation, canonical freshness authority, and registration cleanup; plus unchanged pure edit-primitive coverage.
+// DEPENDS: [bun:test, node:fs, node:fs/promises, node:os, node:path, src/plugins/hashline-edit/edit-operation-primitives.ts, src/plugins/hashline-edit/hash-computation.ts, src/plugins/hashline-edit/index.ts, src/plugins/hashline-edit/schemas.ts]
// LINKS: [M-PLUGIN-HASHLINE-EDIT, V-M-PLUGIN-HASHLINE-EDIT, M-AGENT-TOOL-CONTRACT]
// ROLE: TEST
// MAP_MODE: LOCALS
@@ -16,6 +16,8 @@
// createToolContext - Build a pinned native tool context fixture (optionally with a throwing progress).
// anchorFor - Build a visible hashline anchor for fixture content.
// previousConfigHome - Preserved caller config-home environment.
+// registrations - Live test registrations disposed after every test.
+// pluginCleanups - Native setup cleanup callbacks awaited after every test.
// Harness - Native handler harness with recording permission calls.
// recordModel - Record a session model into the handler cache.
// REAL_HOST - Pinned host binary from VVOC_E2E_V2_HOST.
@@ -24,12 +26,22 @@
// END_MODULE_MAP
//
// START_CHANGE_SUMMARY
-// LAST_CHANGE: [C-HASHLINE-DENY-SAFETY T-002 - Added deny-all no-op regressions, plugin-owned definition restore coverage, and a realistic host registry fixture without plugin-owned seeds.]
+// LAST_CHANGE: [Direct fix - Added native-handler regressions for parallel disjoint edits, cross-session staleness, cancellation/failure release, create races, mode preservation, symlink alias coordination, and native-read freshness authority.]
// END_CHANGE_SUMMARY
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { appendFileSync, symlinkSync } from "node:fs";
-import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import {
+ chmod,
+ lstat,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ stat,
+ symlink,
+ writeFile,
+} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import type { ToolContext } from "@opencode/plugin/promise/tool";
@@ -45,6 +57,7 @@ import {
createHashlineEditHandlers,
createHashlineEditPlugin,
type HashlineEditHandlers,
+ type HashlineEditRegistration,
type HashlinePermissionGuard,
} from "./hashline-edit/index.js";
import {
@@ -58,6 +71,8 @@ import {
import { createDefaultVvocConfig, renderVvocConfig } from "../lib/vvoc-config.js";
const previousConfigHome = process.env.XDG_CONFIG_HOME;
+const registrations = new Set();
+const pluginCleanups = new Set<() => Promise>();
const METADATA_FAILURE_SECRET = "SECRET_TOKEN_must_not_leak";
const METADATA_FAILURE_MESSAGE = `${METADATA_FAILURE_SECRET} ${"x".repeat(4096)}`;
@@ -65,7 +80,11 @@ beforeEach(() => {
process.env.XDG_CONFIG_HOME = join(tmpdir(), `vvoc-hashline-empty-config-${process.pid}`);
});
-afterEach(() => {
+afterEach(async () => {
+ for (const cleanup of pluginCleanups) await cleanup();
+ pluginCleanups.clear();
+ for (const registration of registrations) registration.dispose();
+ registrations.clear();
if (previousConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
@@ -75,6 +94,7 @@ afterEach(() => {
interface Harness {
handlers: HashlineEditHandlers;
+ dispose(): void;
readonly permissionCalls: Array<{
action: string;
resources: ReadonlyArray;
@@ -92,6 +112,7 @@ function createHarness(
options: {
denyPermission?: boolean;
denyExternal?: boolean;
+ ignoreAbortSignal?: boolean;
baseDir?: string;
locations?: Record;
projectRoot?: string;
@@ -113,7 +134,8 @@ function createHarness(
throw new Error("EXTERNAL_DENIED");
}
if (options.denyPermission) throw new Error("PERMISSION_DENIED");
- if (guardOptions?.signal?.aborted) throw new Error("PERMISSION_ABORTED");
+ if (!options.ignoreAbortSignal && guardOptions?.signal?.aborted)
+ throw new Error("PERMISSION_ABORTED");
return effect();
},
};
@@ -126,7 +148,13 @@ function createHarness(
permission,
log: () => undefined,
});
- return { handlers: registration.handlers, permissionCalls, permissionFailures: [] };
+ registrations.add(registration);
+ return {
+ handlers: registration.handlers,
+ dispose: () => registration.dispose(),
+ permissionCalls,
+ permissionFailures: [],
+ };
}
function recordModel(handlers: HashlineEditHandlers, sessionID: string, modelID: string): void {
@@ -861,6 +889,513 @@ describe("HashlineEditPlugin dsh str_replace_editor", () => {
});
});
+describe("HashlineEditPlugin str_replace_editor target coordination", () => {
+ test("serializes three disjoint parallel replacements in one session with exact bytes", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-concurrent-"));
+ try {
+ const filePath = join(directory, "concurrent.txt");
+ await writeFile(filePath, "alpha one\nbeta two\ngamma three\n", "utf8");
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+
+ const results = await Promise.all([
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "alpha one", new_str: "ALPHA" },
+ context,
+ ),
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "beta two", new_str: "BETA-BETA" },
+ context,
+ ),
+ handlers.tools.str_replace_editor.execute(
+ {
+ command: "str_replace",
+ path: filePath,
+ old_str: "gamma three",
+ new_str: "GAMMA-GAMMA-GAMMA",
+ },
+ context,
+ ),
+ ]);
+
+ for (const result of results) {
+ expect(result.output).toContain("has been edited successfully");
+ }
+ const expected = "ALPHA\nBETA-BETA\nGAMMA-GAMMA-GAMMA\n";
+ const bytes = await readFile(filePath);
+ expect(bytes.equals(Buffer.from(expected, "utf8"))).toBe(true);
+ expect(bytes.includes(0)).toBe(false);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("applies concurrent unread edits from different sessions cumulatively", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-cross-session-"));
+ try {
+ const filePath = join(directory, "shared.txt");
+ await writeFile(filePath, "one\ntwo\n", "utf8");
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "sA", "deepseek-v4-flash");
+ recordModel(handlers, "sB", "deepseek-v4-flash");
+
+ const results = await Promise.all([
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "one", new_str: "ONE-LONG" },
+ createToolContext({ sessionID: "sA" }).context,
+ ),
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "two", new_str: "TWO-LONGER" },
+ createToolContext({ sessionID: "sB" }).context,
+ ),
+ ]);
+
+ for (const result of results) {
+ expect(result.output).toContain("has been edited successfully");
+ }
+ expect(await readFile(filePath, "utf8")).toBe("ONE-LONG\nTWO-LONGER\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("independent handler instances share coordination for the same session", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-independent-handlers-"));
+ try {
+ const filePath = join(directory, "shared.txt");
+ await writeFile(filePath, "one\ntwo\nthree\n", "utf8");
+ const first = createHarness(undefined, { baseDir: directory });
+ const second = createHarness(undefined, { baseDir: directory });
+ recordModel(first.handlers, "s1", "deepseek-v4-flash");
+ recordModel(second.handlers, "s1", "deepseek-v4-flash");
+
+ const results = await Promise.all([
+ first.handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "one", new_str: "ONE" },
+ createToolContext({ sessionID: "s1" }).context,
+ ),
+ second.handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "two", new_str: "TWO-TWO" },
+ createToolContext({ sessionID: "s1" }).context,
+ ),
+ first.handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "three", new_str: "THREE-THREE" },
+ createToolContext({ sessionID: "s1" }).context,
+ ),
+ ]);
+
+ for (const result of results) {
+ expect(result.output).toContain("has been edited successfully");
+ }
+ expect(await readFile(filePath, "utf8")).toBe("ONE\nTWO-TWO\nTHREE-THREE\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("keeps own queued edits usable and rejects another session's stale view", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-stale-view-"));
+ try {
+ const filePath = join(directory, "viewed.txt");
+ await writeFile(filePath, "alpha\nbeta\ngamma\n", "utf8");
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "sA", "deepseek-v4-flash");
+ recordModel(handlers, "sB", "deepseek-v4-flash");
+ const contextA = createToolContext({ sessionID: "sA" }).context;
+ const contextB = createToolContext({ sessionID: "sB" }).context;
+
+ await handlers.tools.str_replace_editor.execute(
+ { command: "view", path: filePath },
+ contextA,
+ );
+ await handlers.tools.str_replace_editor.execute(
+ { command: "view", path: filePath },
+ contextB,
+ );
+
+ const first = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "alpha", new_str: "ALPHA-ONE" },
+ contextA,
+ );
+ const second = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "beta", new_str: "BETA-TWO" },
+ contextA,
+ );
+ expect(first.output).toContain("has been edited successfully");
+ expect(second.output).toContain("has been edited successfully");
+
+ const stale = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "gamma", new_str: "GAMMA" },
+ contextB,
+ );
+ expect(stale.output).toContain("changed since it was last viewed");
+ expect(await readFile(filePath, "utf8")).toBe("ALPHA-ONE\nBETA-TWO\ngamma\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("native read freshness records stay authoritative under canonical coordination", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-native-read-"));
+ try {
+ const filePath = join(directory, "read.txt");
+ await writeFile(filePath, "alpha\nbeta\n", "utf8");
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+
+ await handlers.after({
+ tool: "read",
+ sessionID: "s1",
+ input: { path: filePath },
+ status: "completed",
+ result: { content: "1: alpha\n2: beta" },
+ });
+
+ const fresh = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "alpha", new_str: "ALPHA" },
+ context,
+ );
+ expect(fresh.output).toContain("has been edited successfully");
+
+ // A new native read re-establishes freshness; an external mutation after
+ // that read must still be detected under the same caller path.
+ await handlers.after({
+ tool: "read",
+ sessionID: "s1",
+ input: { path: filePath },
+ status: "completed",
+ result: { content: "1: ALPHA\n2: beta" },
+ });
+ await writeFile(filePath, "ALPHA\nexternal\n", "utf8");
+ const stale = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "beta", new_str: "BETA" },
+ context,
+ );
+ expect(stale.output).toContain("changed since it was last viewed");
+ expect(await readFile(filePath, "utf8")).toBe("ALPHA\nexternal\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("symlink alias freshness records survive canonical coordination", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-alias-freshness-"));
+ try {
+ const target = join(directory, "target.txt");
+ const alias = join(directory, "alias.txt");
+ await writeFile(target, "alpha\nbeta\n", "utf8");
+ await symlink(target, alias);
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+
+ const viewed = await handlers.tools.str_replace_editor.execute(
+ { command: "view", path: alias },
+ context,
+ );
+ expect(viewed.output).toContain("Here's the content of");
+ await writeFile(target, "alpha\nexternal\n", "utf8");
+
+ const stale = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: alias, old_str: "external", new_str: "EXTERNAL" },
+ context,
+ );
+ expect(stale.output).toContain("changed since it was last viewed");
+ expect(await readFile(target, "utf8")).toBe("alpha\nexternal\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("same-session edits refresh views of the same target through other aliases", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-alias-views-"));
+ try {
+ const target = join(directory, "target.txt");
+ const alias = join(directory, "alias.txt");
+ await writeFile(target, "alpha\nbeta\n", "utf8");
+ await symlink(target, alias);
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+ for (const path of [target, alias]) {
+ await handlers.tools.str_replace_editor.execute({ command: "view", path }, context);
+ }
+ const first = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: target, old_str: "alpha", new_str: "ALPHA-LONG" },
+ context,
+ );
+ const second = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: alias, old_str: "beta", new_str: "BETA-LONG" },
+ context,
+ );
+ expect(first.output).toContain("has been edited successfully");
+ expect(second.output).toContain("has been edited successfully");
+ expect(await readFile(target, "utf8")).toBe("ALPHA-LONG\nBETA-LONG\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("native reads cannot be bypassed through another alias or a later handler instance", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-alias-native-"));
+ try {
+ const target = join(directory, "target.txt");
+ const alias = join(directory, "alias.txt");
+ await writeFile(target, "alpha\nbeta\n", "utf8");
+ await symlink(target, alias);
+ const first = createHarness(undefined, { baseDir: directory });
+ recordModel(first.handlers, "s1", "deepseek-v4-flash");
+ await first.handlers.after({
+ tool: "read",
+ sessionID: "s1",
+ input: { path: alias },
+ status: "completed",
+ result: { content: "1: alpha\n2: beta" },
+ });
+ const second = createHarness(undefined, { baseDir: directory });
+ recordModel(second.handlers, "s1", "deepseek-v4-flash");
+ await writeFile(target, "alpha\nexternal-change\n", "utf8");
+ const result = await second.handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: target, old_str: "alpha", new_str: "ALPHA" },
+ createToolContext({ sessionID: "s1" }).context,
+ );
+ expect(result.output).toContain("changed since it was last viewed");
+ expect(await readFile(target, "utf8")).toBe("alpha\nexternal-change\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("shared freshness survives one disposal and is cleared after the last owner", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-cache-lifetime-"));
+ try {
+ const path = join(directory, "sample.txt");
+ await writeFile(path, "alpha\n", "utf8");
+ const first = createHarness(undefined, { baseDir: directory });
+ const second = createHarness(undefined, { baseDir: directory });
+ for (const instance of [first, second])
+ recordModel(instance.handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+ await first.handlers.tools.str_replace_editor.execute({ command: "view", path }, context);
+ first.dispose();
+ first.dispose();
+ await writeFile(path, "alpha\nexternal\n", "utf8");
+ const stale = await second.handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path, old_str: "alpha", new_str: "ALPHA" },
+ context,
+ );
+ expect(stale.output).toContain("changed since it was last viewed");
+ second.dispose();
+
+ const next = createHarness(undefined, { baseDir: directory });
+ recordModel(next.handlers, "s1", "deepseek-v4-flash");
+ const fresh = await next.handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path, old_str: "alpha", new_str: "ALPHA" },
+ context,
+ );
+ expect(fresh.output).toContain("has been edited successfully");
+ expect(await readFile(path, "utf8")).toBe("ALPHA\nexternal\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("releases a target after a failed or cancelled edit", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-release-"));
+ try {
+ const filePath = join(directory, "release.txt");
+ await writeFile(filePath, "alpha\nbeta\n", "utf8");
+ const { handlers } = createHarness(undefined, {
+ baseDir: directory,
+ ignoreAbortSignal: true,
+ });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+
+ const missing = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "absent", new_str: "x" },
+ createToolContext({ sessionID: "s1" }).context,
+ );
+ expect(missing.output).toContain("did not appear verbatim");
+
+ await expect(
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "alpha", new_str: "ALPHA" },
+ createToolContext({ sessionID: "s1", aborted: true }).context,
+ ),
+ ).rejects.toThrow(/abort/i);
+ expect(await readFile(filePath, "utf8")).toBe("alpha\nbeta\n");
+
+ const recovered = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "beta", new_str: "BETA" },
+ createToolContext({ sessionID: "s1" }).context,
+ );
+ expect(recovered.output).toContain("has been edited successfully");
+ expect(await readFile(filePath, "utf8")).toBe("alpha\nBETA\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("preserves BOM, CRLF, and Unicode bytes and supports deletion to empty", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-bytes-"));
+ try {
+ const filePath = join(directory, "bytes.txt");
+ const original = "\uFEFFalpha\r\nbeta\r\n\u{1F600}\r\n";
+ await writeFile(filePath, original, "utf8");
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+
+ const replaced = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "beta", new_str: "" },
+ context,
+ );
+ expect(replaced.output).toContain("has been edited successfully");
+ const expected = "\uFEFFalpha\r\n\r\n\u{1F600}\r\n";
+ expect((await readFile(filePath)).equals(Buffer.from(expected, "utf8"))).toBe(true);
+
+ const emptyTarget = join(directory, "empty.txt");
+ await writeFile(emptyTarget, "gone\n", "utf8");
+ const cleared = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: emptyTarget, old_str: "gone\n", new_str: "" },
+ context,
+ );
+ expect(cleared.output).toContain("has been edited successfully");
+ const emptyBytes = await readFile(emptyTarget);
+ expect(emptyBytes.length).toBe(0);
+ expect(emptyBytes.includes(0)).toBe(false);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("racing creates produce exactly one success without overwriting", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-create-race-"));
+ try {
+ const filePath = join(directory, "created.txt");
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+
+ const results = await Promise.all([
+ handlers.tools.str_replace_editor.execute(
+ { command: "create", path: filePath, file_text: "first\n" },
+ context,
+ ),
+ handlers.tools.str_replace_editor.execute(
+ { command: "create", path: filePath, file_text: "second\n" },
+ context,
+ ),
+ ]);
+
+ const successes = results.filter((result) => result.output.includes("created successfully"));
+ const conflicts = results.filter((result) => result.output.includes("already exists"));
+ expect(successes).toHaveLength(1);
+ expect(conflicts).toHaveLength(1);
+ const content = await readFile(filePath, "utf8");
+ expect(["first\n", "second\n"]).toContain(content);
+ expect((await readFile(filePath)).includes(0)).toBe(false);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("preserves ordinary file modes across replacement", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-mode-"));
+ try {
+ const filePath = join(directory, "mode.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ await chmod(filePath, 0o640);
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+
+ const result = await handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: filePath, old_str: "alpha", new_str: "omega" },
+ createToolContext({ sessionID: "s1" }).context,
+ );
+ expect(result.output).toContain("has been edited successfully");
+ expect((await stat(filePath)).mode & 0o777).toBe(0o640);
+ expect(await readFile(filePath, "utf8")).toBe("omega\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("coordinates symlink aliases without replacing the link", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-symlink-"));
+ try {
+ const target = join(directory, "target.txt");
+ const alias = join(directory, "alias.txt");
+ await writeFile(target, "alpha\nbeta\n", "utf8");
+ await symlink(target, alias);
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+
+ const results = await Promise.all([
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: target, old_str: "alpha", new_str: "ALPHA" },
+ context,
+ ),
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: alias, old_str: "beta", new_str: "BETA" },
+ context,
+ ),
+ ]);
+
+ for (const result of results) {
+ expect(result.output).toContain("has been edited successfully");
+ }
+ expect(await readFile(target, "utf8")).toBe("ALPHA\nBETA\n");
+ expect(await readFile(alias, "utf8")).toBe("ALPHA\nBETA\n");
+ expect((await lstat(alias)).isSymbolicLink()).toBe(true);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test("a failing target does not block a different target", async () => {
+ if (process.getuid?.() === 0) {
+ return;
+ }
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-different-paths-"));
+ const readOnly = join(directory, "readonly.txt");
+ try {
+ const writable = join(directory, "writable.txt");
+ await writeFile(readOnly, "alpha\n", "utf8");
+ await writeFile(writable, "beta\n", "utf8");
+ await chmod(readOnly, 0o444);
+ const { handlers } = createHarness(undefined, { baseDir: directory });
+ recordModel(handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+
+ const [failed, succeeded] = await Promise.allSettled([
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: readOnly, old_str: "alpha", new_str: "ALPHA" },
+ context,
+ ),
+ handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path: writable, old_str: "beta", new_str: "BETA" },
+ context,
+ ),
+ ]);
+
+ expect(failed.status).toBe("rejected");
+ expect(succeeded.status).toBe("fulfilled");
+ if (succeeded.status === "fulfilled") {
+ expect(succeeded.value.output).toContain("has been edited successfully");
+ }
+ expect(await readFile(readOnly, "utf8")).toBe("alpha\n");
+ expect(await readFile(writable, "utf8")).toBe("BETA\n");
+ } finally {
+ await chmod(readOnly, 0o644).catch(() => undefined);
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+});
+
describe("HashlineEditPlugin truthful reporting", () => {
test("a metadata publication failure after a successful edit stays truthful", async () => {
const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-meta-fail-"));
@@ -951,8 +1486,17 @@ describe("HashlineEditPlugin cold-start native definitions", () => {
},
};
const plugin = createHashlineEditPlugin({ acquireRuntime: async () => fakeRuntime as never });
- await plugin.setup(fakeContext as never);
+ const cleanup = await plugin.setup(fakeContext as never);
+ let disposed = false;
+ const dispose = async () => {
+ if (disposed) return;
+ disposed = true;
+ if (typeof cleanup === "function") await cleanup();
+ pluginCleanups.delete(dispose);
+ };
+ pluginCleanups.add(dispose);
return {
+ dispose,
runContext: async (modelID: string, tools: Record) => {
const handler = sessionHooks.get("context");
if (handler === undefined) throw new Error("no context hook");
@@ -1054,6 +1598,32 @@ describe("HashlineEditPlugin cold-start native definitions", () => {
expect(Object.keys(tools).sort()).toEqual(["str_replace_editor", "write"]);
expect(tools.str_replace_editor).toBeDefined();
});
+
+ test("plugin setup cleanup releases its shared freshness owner", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-hashline-setup-lifetime-"));
+ try {
+ const plugin = await setupPlugin({ enabled: true, routing: DEFAULT_ROUTING_CONFIG });
+ const path = join(directory, "sample.txt");
+ await writeFile(path, "alpha\n", "utf8");
+ const observer = createHarness(undefined, { baseDir: directory });
+ recordModel(observer.handlers, "s1", "deepseek-v4-flash");
+ const { context } = createToolContext({ sessionID: "s1" });
+ await observer.handlers.tools.str_replace_editor.execute({ command: "view", path }, context);
+ observer.dispose();
+ await plugin.dispose();
+ await writeFile(path, "alpha\nexternal\n", "utf8");
+ const next = createHarness(undefined, { baseDir: directory });
+ recordModel(next.handlers, "s1", "deepseek-v4-flash");
+ const result = await next.handlers.tools.str_replace_editor.execute(
+ { command: "str_replace", path, old_str: "alpha", new_str: "ALPHA" },
+ context,
+ );
+ expect(result.output).toContain("has been edited successfully");
+ expect(await readFile(path, "utf8")).toBe("ALPHA\nexternal\n");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
});
describe("HashlineEditPlugin native read content", () => {
diff --git a/src/plugins/hashline-edit/atomic-write.test.ts b/src/plugins/hashline-edit/atomic-write.test.ts
new file mode 100644
index 0000000..40da31a
--- /dev/null
+++ b/src/plugins/hashline-edit/atomic-write.test.ts
@@ -0,0 +1,344 @@
+// FILE: src/plugins/hashline-edit/atomic-write.test.ts
+// VERSION: 0.1.0
+// START_MODULE_CONTRACT
+// PURPOSE: Verify atomic UTF-8 publication safety for str_replace_editor: exact bytes, preserved mode and ownership, symlink preservation, read-only, special-mode and hardlink refusal, change detection, cancellation, exclusive create, and staging cleanup.
+// SCOPE: Real temporary files plus deterministic partial-write, corrupt-staging, ownership, cancellation, competing-write, and publication fault injection.
+// DEPENDS: [bun:test, node:fs, node:fs/promises, node:os, node:path, src/plugins/hashline-edit/atomic-write.ts]
+// LINKS: [M-PLUGIN-HASHLINE-EDIT, V-M-PLUGIN-HASHLINE-EDIT]
+// ROLE: TEST
+// MAP_MODE: LOCALS
+// END_MODULE_CONTRACT
+//
+// START_MODULE_MAP
+// directories - Temporary directories removed after every test.
+// withTempDir - Create an isolated temporary directory and remove it after the test body.
+// END_MODULE_MAP
+//
+// START_CHANGE_SUMMARY
+// LAST_CHANGE: [Direct fix - Cover byte-exact staged publication, metadata preservation, cancellation before commit, competing writers, and cleanup after partial writes.]
+// END_CHANGE_SUMMARY
+
+import { afterEach, describe, expect, test } from "bun:test";
+import {
+ lstat,
+ link,
+ mkdtemp,
+ readFile,
+ readdir,
+ rm,
+ stat,
+ symlink,
+ writeFile,
+ chmod,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ createUtf8FileExclusively,
+ nodeAtomicWriteIo,
+ type AtomicWriteIo,
+ writeUtf8FileAtomically,
+} from "./atomic-write.js";
+
+const directories: string[] = [];
+
+async function withTempDir(): Promise {
+ const directory = await mkdtemp(join(tmpdir(), "vvoc-atomic-write-"));
+ directories.push(directory);
+ return directory;
+}
+
+afterEach(async () => {
+ while (directories.length > 0) {
+ await rm(directories.pop()!, { recursive: true, force: true });
+ }
+});
+
+describe("writeUtf8FileAtomically", () => {
+ test("publishes exact bytes and preserves the ordinary file mode", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "sample.txt");
+ const content = "\uFEFFone\r\ntwo\r\n\u{1F600}\n";
+ await writeFile(filePath, "old\n", "utf8");
+ await chmod(filePath, 0o640);
+ const before = await stat(filePath);
+
+ await writeUtf8FileAtomically(filePath, content, { expectedText: "old\n" });
+
+ expect(Buffer.compare(await readFile(filePath), Buffer.from(content, "utf8"))).toBe(0);
+ expect((await stat(filePath)).mode & 0o777).toBe(0o640);
+ expect((await stat(filePath)).uid).toBe(before.uid);
+ expect((await stat(filePath)).gid).toBe(before.gid);
+ expect((await readdir(directory)).sort()).toEqual(["sample.txt"]);
+ });
+
+ test("preserves a symlink and replaces its target", async () => {
+ const directory = await withTempDir();
+ const target = join(directory, "target.txt");
+ const alias = join(directory, "alias.txt");
+ await writeFile(target, "alpha\n", "utf8");
+ await symlink(target, alias);
+
+ await writeUtf8FileAtomically(alias, "omega\n", { expectedText: "alpha\n" });
+
+ expect((await lstat(alias)).isSymbolicLink()).toBe(true);
+ expect(await readFile(alias, "utf8")).toBe("omega\n");
+ expect(await readFile(target, "utf8")).toBe("omega\n");
+ });
+
+ test("refuses to atomically replace a file with multiple hard links", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "linked.txt");
+ const second = join(directory, "second.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ await link(filePath, second);
+
+ await expect(writeUtf8FileAtomically(filePath, "omega\n")).rejects.toThrow(/hard links/);
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect(await readFile(second, "utf8")).toBe("alpha\n");
+ expect((await readdir(directory)).sort()).toEqual(["linked.txt", "second.txt"]);
+ });
+
+ test.each([0o1000, 0o2000, 0o4000])(
+ "refuses special mode bits %o without modifying the file",
+ async (special) => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "special.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ // Inject stat bits: Bun 1.3.8 chmod itself discards these flags, whereas
+ // existing files and Node stat may carry them. No privileged fixture is needed.
+ const io: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async stat(path) {
+ const info = await nodeAtomicWriteIo.stat(path);
+ return { ...info, mode: info.mode | special };
+ },
+ };
+ await expect(writeUtf8FileAtomically(filePath, "omega\n", { io })).rejects.toThrow(
+ /setuid, setgid, or sticky/,
+ );
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect(await readdir(directory)).toEqual(["special.txt"]);
+ },
+ );
+
+ test("refuses a read-only target instead of bypassing mode with rename", async () => {
+ if (process.getuid?.() === 0) {
+ return;
+ }
+ const directory = await withTempDir();
+ const filePath = join(directory, "readonly.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ await chmod(filePath, 0o444);
+ try {
+ await expect(writeUtf8FileAtomically(filePath, "omega\n")).rejects.toThrow(/not writable/);
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect((await readdir(directory)).sort()).toEqual(["readonly.txt"]);
+ } finally {
+ await chmod(filePath, 0o644);
+ }
+ });
+
+ test("fails closed when the target changed since the editor read it", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "changed.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+
+ await expect(
+ writeUtf8FileAtomically(filePath, "omega\n", { expectedText: "other\n" }),
+ ).rejects.toThrow(/changed after it was read/);
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ });
+
+ test("cleans the staging file when publication fails", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "sample.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ const failingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async rename() {
+ throw new Error("injected rename failure");
+ },
+ };
+
+ await expect(writeUtf8FileAtomically(filePath, "omega\n", { io: failingIo })).rejects.toThrow(
+ "injected rename failure",
+ );
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect((await readdir(directory)).sort()).toEqual(["sample.txt"]);
+ });
+
+ test("cleans a partially written staging file without touching the destination", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "sample.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ const failingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async writeFile(path, data, options) {
+ await nodeAtomicWriteIo.writeFile(path, data.subarray(0, 2), options);
+ throw Object.assign(new Error("injected partial write"), { code: "ENOSPC" });
+ },
+ };
+
+ await expect(writeUtf8FileAtomically(filePath, "omega\n", { io: failingIo })).rejects.toThrow(
+ "injected partial write",
+ );
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect(await readdir(directory)).toEqual(["sample.txt"]);
+ });
+
+ test("refuses corrupted staged bytes before publication", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "sample.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ const corruptingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async writeFile(path, data, options) {
+ await nodeAtomicWriteIo.writeFile(path, Buffer.concat([data, Buffer.from([0])]), options);
+ },
+ };
+
+ await expect(
+ writeUtf8FileAtomically(filePath, "omega\n", { io: corruptingIo }),
+ ).rejects.toThrow(/did not verify/);
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect(await readdir(directory)).toEqual(["sample.txt"]);
+ });
+
+ test("rechecks cancellation after staging and before publication", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "sample.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ const controller = new AbortController();
+ const cancellingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async writeFile(path, data, options) {
+ await nodeAtomicWriteIo.writeFile(path, data, options);
+ controller.abort();
+ },
+ };
+
+ await expect(
+ writeUtf8FileAtomically(filePath, "omega\n", {
+ io: cancellingIo,
+ signal: controller.signal,
+ }),
+ ).rejects.toMatchObject({ name: "AbortError" });
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect(await readdir(directory)).toEqual(["sample.txt"]);
+ });
+
+ test("detects an external update made while staging", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "sample.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ const competingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async writeFile(path, data, options) {
+ await nodeAtomicWriteIo.writeFile(path, data, options);
+ await writeFile(filePath, "competitor\n", "utf8");
+ },
+ };
+
+ await expect(
+ writeUtf8FileAtomically(filePath, "omega\n", {
+ io: competingIo,
+ expectedText: "alpha\n",
+ }),
+ ).rejects.toThrow(/changed after it was read/);
+ expect(await readFile(filePath, "utf8")).toBe("competitor\n");
+ expect(await readdir(directory)).toEqual(["sample.txt"]);
+ });
+
+ test("fails closed if staging cannot preserve the destination ownership", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "sample.txt");
+ await writeFile(filePath, "alpha\n", "utf8");
+ const owner = await stat(filePath);
+ let attempted = false;
+ const failingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async stat(path) {
+ return { ...(await nodeAtomicWriteIo.stat(path)), uid: owner.uid + 1, gid: owner.gid + 1 };
+ },
+ async chown(_path, uid, gid) {
+ attempted = true;
+ expect(uid).toBe(owner.uid + 1);
+ expect(gid).toBe(owner.gid + 1);
+ throw new Error("injected ownership refusal");
+ },
+ };
+
+ await expect(
+ writeUtf8FileAtomically(filePath, "omega\n", { io: failingIo }),
+ ).rejects.toMatchObject({
+ code: "EOWNERSHIP",
+ message: expect.stringContaining("cannot preserve POSIX ownership"),
+ cause: expect.objectContaining({ message: "injected ownership refusal" }),
+ });
+ expect(attempted).toBe(true);
+ expect(await readFile(filePath, "utf8")).toBe("alpha\n");
+ expect(await readdir(directory)).toEqual(["sample.txt"]);
+ });
+});
+
+describe("createUtf8FileExclusively", () => {
+ test("cancellation during staging does not publish a new file", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "created.txt");
+ const controller = new AbortController();
+ const cancellingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async writeFile(path, data, options) {
+ await nodeAtomicWriteIo.writeFile(path, data, options);
+ controller.abort();
+ },
+ };
+
+ await expect(
+ createUtf8FileExclusively(filePath, "ours\n", {
+ io: cancellingIo,
+ signal: controller.signal,
+ }),
+ ).rejects.toMatchObject({ name: "AbortError" });
+ expect(await readdir(directory)).toEqual([]);
+ });
+
+ test("preserves a competing creator that wins during staging", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "created.txt");
+ const competingIo: AtomicWriteIo = {
+ ...nodeAtomicWriteIo,
+ async link(from, to) {
+ await writeFile(to, "competitor\n", { flag: "wx" });
+ await nodeAtomicWriteIo.link(from, to);
+ },
+ };
+
+ expect(await createUtf8FileExclusively(filePath, "ours\n", { io: competingIo })).toBe(false);
+ expect(await readFile(filePath, "utf8")).toBe("competitor\n");
+ expect(await readdir(directory)).toEqual(["created.txt"]);
+ });
+
+ test("creates once and refuses an existing or racing creator without overwriting", async () => {
+ const directory = await withTempDir();
+ const filePath = join(directory, "created.txt");
+
+ expect(await createUtf8FileExclusively(filePath, "first\n")).toBe(true);
+ expect(await createUtf8FileExclusively(filePath, "second\n")).toBe(false);
+ expect(await readFile(filePath, "utf8")).toBe("first\n");
+ expect((await readdir(directory)).sort()).toEqual(["created.txt"]);
+ });
+
+ test("does not replace an existing symlink", async () => {
+ const directory = await withTempDir();
+ const target = join(directory, "target.txt");
+ const alias = join(directory, "alias.txt");
+ await writeFile(target, "alpha\n", "utf8");
+ await symlink(target, alias);
+
+ expect(await createUtf8FileExclusively(alias, "omega\n")).toBe(false);
+ expect((await lstat(alias)).isSymbolicLink()).toBe(true);
+ expect(await readFile(target, "utf8")).toBe("alpha\n");
+ });
+});
diff --git a/src/plugins/hashline-edit/atomic-write.ts b/src/plugins/hashline-edit/atomic-write.ts
new file mode 100644
index 0000000..2ae0b52
--- /dev/null
+++ b/src/plugins/hashline-edit/atomic-write.ts
@@ -0,0 +1,280 @@
+// FILE: src/plugins/hashline-edit/atomic-write.ts
+// VERSION: 0.1.0
+// START_MODULE_CONTRACT
+// PURPOSE: Publish str_replace_editor UTF-8 writes through a unique staging file in the target directory instead of truncating and rewriting the destination in place.
+// SCOPE: Symlink-resolving atomic replacement with ordinary mode and POSIX ownership preservation, read-only, special-mode and multi-hardlink refusal, actionable ownership errors, pre-publication change and cancellation checks, byte-verified staged payloads, exclusive create publication, and cleanup after partial staging failures. No cross-process compare-and-swap or crash-durability guarantee.
+// DEPENDS: [node:crypto, node:fs, node:fs/promises, node:path, src/plugins/hashline-edit/target-lock.ts]
+// LINKS: [M-PLUGIN-HASHLINE-EDIT, V-M-PLUGIN-HASHLINE-EDIT]
+// ROLE: RUNTIME
+// MAP_MODE: EXPORTS
+// END_MODULE_CONTRACT
+//
+// START_MODULE_MAP
+// AtomicWriteIo - Injectable filesystem operations used by the staging publisher.
+// AtomicWriteStat - Minimal target metadata required for safe publication.
+// nodeAtomicWriteIo - Default node:fs/promises-backed operation set; tests may wrap it for fault injection.
+// writeUtf8FileAtomically - Replace an existing file through a verified staging file and atomic rename.
+// createUtf8FileExclusively - Create a new file through a verified staging file without overwriting a racing creator.
+// END_MODULE_MAP
+//
+// START_CHANGE_SUMMARY
+// LAST_CHANGE: [Direct fix - Publish byte-verified UTF-8 atomically with metadata preservation, pre-commit cancellation and change checks, partial-write cleanup, and exclusive create.]
+// END_CHANGE_SUMMARY
+
+import { randomBytes } from "node:crypto";
+import { constants } from "node:fs";
+import {
+ access,
+ chmod,
+ chown,
+ link,
+ readFile,
+ realpath,
+ rename,
+ stat,
+ unlink,
+ writeFile,
+} from "node:fs/promises";
+import { basename, dirname, join } from "node:path";
+import { throwIfAborted } from "./target-lock.js";
+
+// START_BLOCK_TYPES
+export interface AtomicWriteStat {
+ mode: number;
+ nlink: number;
+ uid: number;
+ gid: number;
+}
+
+/** Injectable filesystem surface; production uses the node:fs/promises-backed default. */
+export interface AtomicWriteIo {
+ realpath(path: string): Promise;
+ stat(path: string): Promise;
+ access(path: string, mode: number): Promise;
+ readFile(path: string): Promise;
+ writeFile(
+ path: string,
+ data: Uint8Array,
+ options: { flag: "wx"; mode: number; signal?: AbortSignal },
+ ): Promise;
+ chmod(path: string, mode: number): Promise;
+ chown(path: string, uid: number, gid: number): Promise;
+ rename(from: string, to: string): Promise;
+ link(existingPath: string, newPath: string): Promise;
+ unlink(path: string): Promise;
+}
+// END_BLOCK_TYPES
+
+// START_BLOCK_DEFAULT_IO
+export const nodeAtomicWriteIo: AtomicWriteIo = {
+ realpath: (path) => realpath(path),
+ async stat(path) {
+ const info = await stat(path);
+ return { mode: info.mode, nlink: info.nlink, uid: info.uid, gid: info.gid };
+ },
+ access: (path, mode) => access(path, mode),
+ readFile: (path) => readFile(path),
+ async writeFile(path, data, options) {
+ await writeFile(path, data, options);
+ },
+ chmod: (path, mode) => chmod(path, mode),
+ async chown(path, uid, gid) {
+ // Windows has no POSIX uid/gid; native ACL preservation is not implemented here.
+ if (process.platform !== "win32") await chown(path, uid, gid);
+ },
+ rename: (from, to) => rename(from, to),
+ link: (existingPath, newPath) => link(existingPath, newPath),
+ unlink: (path) => unlink(path),
+};
+// END_BLOCK_DEFAULT_IO
+
+// START_BLOCK_HELPERS
+function ioFailure(message: string, code: string, cause?: unknown): NodeJS.ErrnoException {
+ const error = new Error(message, { cause }) as NodeJS.ErrnoException;
+ error.code = code;
+ return error;
+}
+
+function isErrno(error: unknown, code: string): boolean {
+ return (
+ typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === code
+ );
+}
+
+async function removeQuietly(io: AtomicWriteIo, path: string): Promise {
+ try {
+ await io.unlink(path);
+ } catch {
+ // The staging file may already be published or never created.
+ }
+}
+
+function stagingPath(target: string): string {
+ const suffix = `${process.pid}-${randomBytes(6).toString("hex")}`;
+ return join(dirname(target), `.${basename(target)}.vvoc-${suffix}.tmp`);
+}
+
+/**
+ * Write the exact UTF-8 bytes to a unique staging file and read them back
+ * before publication. The caller owns cleanup of the returned path.
+ */
+async function stageVerifiedBytes(
+ io: AtomicWriteIo,
+ target: string,
+ bytes: Uint8Array,
+ createMode: number,
+ chmodTo?: number,
+ owner?: { uid: number; gid: number },
+ signal?: AbortSignal,
+): Promise {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ throwIfAborted(signal);
+ const temp = stagingPath(target);
+ try {
+ await io.writeFile(temp, bytes, { flag: "wx", mode: createMode, signal });
+ } catch (error) {
+ if (isErrno(error, "EEXIST")) continue;
+ await removeQuietly(io, temp);
+ throw error;
+ }
+ try {
+ throwIfAborted(signal);
+ if (owner !== undefined) {
+ try {
+ await io.chown(temp, owner.uid, owner.gid);
+ } catch (error) {
+ throw ioFailure(
+ `Refusing to atomically replace ${target}: cannot preserve POSIX ownership (uid ${owner.uid}, gid ${owner.gid}). The original file is unchanged.`,
+ "EOWNERSHIP",
+ error,
+ );
+ }
+ }
+ if (chmodTo !== undefined) {
+ await io.chmod(temp, chmodTo);
+ }
+ const staged = await io.readFile(temp);
+ if (Buffer.compare(staged, bytes) !== 0) {
+ throw ioFailure(
+ `Refusing to publish ${target}: the staged UTF-8 bytes did not verify.`,
+ "EIO",
+ );
+ }
+ return temp;
+ } catch (error) {
+ await removeQuietly(io, temp);
+ throw error;
+ }
+ }
+ throw ioFailure(`Could not allocate a unique staging file for ${target}.`, "EEXIST");
+}
+
+async function resolveParentRealpath(io: AtomicWriteIo, path: string): Promise {
+ const parent = dirname(path);
+ try {
+ return await io.realpath(parent);
+ } catch {
+ return parent;
+ }
+}
+// END_BLOCK_HELPERS
+
+// START_BLOCK_PUBLICATION
+/**
+ * Replace `path` through a verified staging file. The canonical target (after
+ * resolving symlinks) is replaced so a symlink itself is never turned into a
+ * regular file. Ordinary modes and POSIX ownership are preserved; read-only targets and targets
+ * with special mode bits or multiple hard links are refused rather than silently
+ * changing their semantics or restoring privilege bits cleared by an in-place write.
+ *
+ * `expectedText` is the content the editor read for this command. Checking it
+ * immediately before publication detects an external change cheaply; it is not
+ * a universal compare-and-swap against other processes.
+ */
+export async function writeUtf8FileAtomically(
+ path: string,
+ content: string,
+ options: { expectedText?: string; signal?: AbortSignal; io?: AtomicWriteIo } = {},
+): Promise {
+ throwIfAborted(options.signal);
+ const io = options.io ?? nodeAtomicWriteIo;
+ const bytes = Buffer.from(content, "utf8");
+ const target = await io.realpath(path);
+ const info = await io.stat(target);
+
+ if ((info.mode & 0o222) === 0) {
+ throw ioFailure(`Refusing to write ${path}: the file is not writable.`, "EACCES");
+ }
+ await io.access(target, constants.W_OK);
+
+ if ((info.mode & 0o7000) !== 0) {
+ throw ioFailure(
+ `Refusing to atomically replace ${path}: setuid, setgid, or sticky mode bits require an ownership-preserving in-place editor.`,
+ "ESPECIALMODE",
+ );
+ }
+
+ if (info.nlink > 1) {
+ throw ioFailure(
+ `Refusing to atomically replace ${path}: the file has ${info.nlink} hard links.`,
+ "EHARDLINK",
+ );
+ }
+
+ const mode = info.mode & 0o777;
+ const temp = await stageVerifiedBytes(io, target, bytes, 0o600, mode, info, options.signal);
+ try {
+ if (options.expectedText !== undefined) {
+ const current = await io.readFile(target);
+ if (!current.equals(Buffer.from(options.expectedText, "utf8"))) {
+ throw ioFailure(
+ `Refusing to edit ${path}: the file changed after it was read. Run view again before editing.`,
+ "ECONCURRENT",
+ );
+ }
+ }
+ throwIfAborted(options.signal);
+ await io.rename(temp, target);
+ } catch (error) {
+ await removeQuietly(io, temp);
+ throw error;
+ }
+}
+
+/**
+ * Create `path` through a verified staging file and publish with a no-replace
+ * hard link. Returns false when any creator (including a racing one) already
+ * owns the directory entry, without overwriting it.
+ */
+export async function createUtf8FileExclusively(
+ path: string,
+ content: string,
+ options: { signal?: AbortSignal; io?: AtomicWriteIo } = {},
+): Promise {
+ throwIfAborted(options.signal);
+ const io = options.io ?? nodeAtomicWriteIo;
+ const bytes = Buffer.from(content, "utf8");
+ const target = join(await resolveParentRealpath(io, path), basename(path));
+ const temp = await stageVerifiedBytes(
+ io,
+ target,
+ bytes,
+ 0o666,
+ undefined,
+ undefined,
+ options.signal,
+ );
+ try {
+ throwIfAborted(options.signal);
+ await io.link(temp, target);
+ return true;
+ } catch (error) {
+ if (isErrno(error, "EEXIST")) {
+ return false;
+ }
+ throw error;
+ } finally {
+ await removeQuietly(io, temp);
+ }
+}
+// END_BLOCK_PUBLICATION
diff --git a/src/plugins/hashline-edit/index.ts b/src/plugins/hashline-edit/index.ts
index bd2fa70..acc822c 100644
--- a/src/plugins/hashline-edit/index.ts
+++ b/src/plugins/hashline-edit/index.ts
@@ -1,9 +1,9 @@
// FILE: src/plugins/hashline-edit/index.ts
-// VERSION: 2.0.0
+// VERSION: 2.1.0
// START_MODULE_CONTRACT
// PURPOSE: Route per-model native edit tooling: register hashline_edit and str_replace_editor as native OpenCode 2.0.18 tools, resolve the session edit mode from the captured vvoc routing config, expose exactly one plugin edit tool per actual session model (never touching the host edit/patch contracts), require a native resource permission before any write, transform native read results with true line anchors and freshness tracking, and honor an explicit host permission denial while restoring plugin-owned versus captured host tool definitions by ownership.
-// SCOPE: Native Plugin.define entry, per-bound-family captured routing resolution, session model capture from native context/model.request hooks, deny-safe native session context tool-list visibility enforcement with plugin-owned versus captured host definition ownership, execute.before visibility/structural guards, execute.after read-anchor transformation and freshness recording, native tool execution with awaited permission before mutation and respected caller abort, bounded post-edit metadata reporting, and editMode telemetry. Pure edit algorithms, anchors, encoding, normalization, and error semantics are unchanged. No V1 chat.message/tool.execute hooks, no V1 tool()/ToolContext.
-// DEPENDS: [@opencode/plugin, @opencode/plugin/promise/tool, effect (Schema.toJsonSchemaDocument; direct dep T009), zod, zod/v4/core, node:fs/promises, node:path, src/lib/agent-tool-contract.ts, src/plugins/hashline-edit/diff-summary.ts, src/plugins/hashline-edit/edit-operations.ts, src/plugins/hashline-edit/file-text-canonicalization.ts, src/plugins/hashline-edit/hash-computation.ts, src/plugins/hashline-edit/normalize-edits.ts, src/plugins/hashline-edit/routing.ts, src/plugins/hashline-edit/schemas.ts, src/plugins/hashline-edit/session-state.ts, src/plugins/hashline-edit/str-replace-editor.ts, src/plugins/hashline-edit/validation.ts, src/runtime/context.ts]
+// SCOPE: Native Plugin.define entry, per-bound-family captured routing resolution, session model capture from native context/model.request hooks, deny-safe native session context tool-list visibility enforcement with plugin-owned versus captured host definition ownership, execute.before visibility/structural guards, execute.after read-anchor transformation and freshness recording, native tool execution with awaited permission before mutation and respected caller abort, process-wide canonical-target serialization and session-isolated canonical freshness shared by live handler instances, atomic staged UTF-8 publication for str_replace_editor mutations, bounded post-edit metadata reporting, and editMode telemetry. Pure edit algorithms, anchors, and literal matching are unchanged. No V1 chat.message/tool.execute hooks, no V1 tool()/ToolContext.
+// DEPENDS: [@opencode/plugin, @opencode/plugin/promise/tool, effect (Schema.toJsonSchemaDocument; direct dep T009), zod, zod/v4/core, node:fs/promises, node:path, src/lib/agent-tool-contract.ts, src/plugins/hashline-edit/atomic-write.ts, src/plugins/hashline-edit/diff-summary.ts, src/plugins/hashline-edit/edit-operations.ts, src/plugins/hashline-edit/file-text-canonicalization.ts, src/plugins/hashline-edit/hash-computation.ts, src/plugins/hashline-edit/normalize-edits.ts, src/plugins/hashline-edit/routing.ts, src/plugins/hashline-edit/schemas.ts, src/plugins/hashline-edit/session-state.ts, src/plugins/hashline-edit/str-replace-editor.ts, src/plugins/hashline-edit/validation.ts, src/runtime/context.ts]
// LINKS: [M-PLUGIN-HASHLINE-EDIT, M-AGENT-TOOL-CONTRACT, M-NATIVE-RUNTIME, V-M-PLUGIN-HASHLINE-EDIT]
// ROLE: RUNTIME
// MAP_MODE: EXPORTS
@@ -24,7 +24,7 @@
// END_MODULE_MAP
//
// START_CHANGE_SUMMARY
-// LAST_CHANGE: [C-HASHLINE-DENY-SAFETY T-001 - Honor explicit permission denial and split plugin-owned versus captured host tool definitions in session visibility enforcement.]
+// LAST_CHANGE: [Direct fix - Serialize str_replace_editor target cycles, share canonical freshness across live handlers and aliases, and publish staged UTF-8 writes atomically with pre-commit cancellation.]
// END_CHANGE_SUMMARY
import { Plugin } from "@opencode/plugin";
@@ -32,10 +32,10 @@ import { Schema } from "effect";
import { z } from "zod";
import { $ZodType, toJSONSchema } from "zod/v4/core";
import type { ToolContext as NativeToolContext } from "@opencode/plugin/promise/tool";
-import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
+import { dirname, basename, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
-import { readFile, readdir, stat, writeFile } from "node:fs/promises";
+import { readFile, readdir, realpath, stat } from "node:fs/promises";
import {
acquireNativeSnapshotRuntime,
type NativeSnapshotContext,
@@ -43,6 +43,7 @@ import {
} from "../../runtime/context.js";
import type { FamilySettingsView } from "../../runtime/session-settings.js";
import { ContractInputError, formatContractIssues } from "../../lib/agent-tool-contract.js";
+import { createUtf8FileExclusively, writeUtf8FileAtomically } from "./atomic-write.js";
import { findFirstChangedLine, summarizeEditDiff } from "./diff-summary.js";
import { applyHashlineEditsWithReport } from "./edit-operations.js";
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization.js";
@@ -421,6 +422,12 @@ export interface HashlineEditRegistration {
}
// START_BLOCK_FS_HELPERS
+// Like the target lock, freshness must have one process-wide owner. Canonical
+// keys keep native reads and editor views authoritative through path aliases;
+// session keys still isolate observations made by different agents.
+const sharedFileCache = new SessionFileCache();
+let fileCacheOwners = 0;
+
async function statSnapshot(filePath: string): Promise {
try {
const info = await stat(filePath);
@@ -442,7 +449,20 @@ async function inferTargetKind(target: string): Promise<"file" | "directory"> {
}
}
-function createNodeStrReplaceFs(): StrReplaceEditorFs {
+/** Resolve symlink aliases for coordination; missing targets resolve through their parent. */
+async function canonicalizeExistingPath(path: string): Promise {
+ try {
+ return await realpath(path);
+ } catch {
+ try {
+ return join(await realpath(dirname(path)), basename(path));
+ } catch {
+ return resolve(path);
+ }
+ }
+}
+
+function createNodeStrReplaceFs(signal?: AbortSignal): StrReplaceEditorFs {
return {
async exists(path: string): Promise {
try {
@@ -462,8 +482,18 @@ function createNodeStrReplaceFs(): StrReplaceEditorFs {
async readText(path: string): Promise {
return readFile(path, "utf8");
},
- async writeText(path: string, content: string): Promise {
- await writeFile(path, content, "utf8");
+ async writeText(
+ path: string,
+ content: string,
+ options?: { expectedText?: string },
+ ): Promise {
+ await writeUtf8FileAtomically(path, content, { expectedText: options?.expectedText, signal });
+ },
+ async createText(path: string, content: string): Promise {
+ return createUtf8FileExclusively(path, content, { signal });
+ },
+ async canonicalize(path: string): Promise {
+ return canonicalizeExistingPath(path);
},
async listDir(path: string): Promise {
const entries = await readdir(path, { withFileTypes: true });
@@ -883,17 +913,20 @@ async function executeStrReplaceEditor(
publish: PublishMetadata,
baseDir: string,
hostHome: string,
+ signal?: AbortSignal,
): Promise {
const requestedPath = requireNonEmptyPath(args.path);
if (requestedPath === undefined) {
return "Error: path must be a non-empty path";
}
const targetPath = normalizeTargetPath(baseDir, requestedPath, hostHome);
+ const freshnessPath = await canonicalizeExistingPath(targetPath);
const resolvedArgs: StrReplaceEditorArgs = { ...args, path: targetPath };
const editor = new StrReplaceEditor({
- fs: createNodeStrReplaceFs(),
- onViewed: (path, snapshot) => fileCache.record(sessionID, path, snapshot),
- checkFreshness: (path, current) => fileCache.check(sessionID, path, current),
+ fs: createNodeStrReplaceFs(signal),
+ signal,
+ onViewed: (_path, snapshot) => fileCache.record(sessionID, freshnessPath, snapshot),
+ checkFreshness: (_path, current) => fileCache.check(sessionID, freshnessPath, current),
});
const result = await editor.execute(resolvedArgs);
@@ -937,7 +970,9 @@ export function createHashlineEditHandlers(
options: { readonly nativeDefinitions?: Map } = {},
): HashlineEditRegistration {
const modelCache = new SessionModelCache();
- const fileCache = new SessionFileCache();
+ const fileCache = sharedFileCache;
+ fileCacheOwners += 1;
+ let disposed = false;
const nativeDefinitions = options.nativeDefinitions ?? new Map();
const resolveMode = (sessionID: string, settings: HashlineEditPluginSettings): EditMode =>
@@ -1145,6 +1180,7 @@ export function createHashlineEditHandlers(
(u) => context.progress(u),
baseDir,
hostHome,
+ context.signal,
);
const output = await requestFileEffect(
context,
@@ -1258,8 +1294,9 @@ export function createHashlineEditHandlers(
// A successful native read always establishes freshness (needed by the
// str_replace editor); anchoring additionally requires hashline mode.
if (targetPath !== undefined) {
- const snapshot = await statSnapshot(targetPath);
- if (snapshot) fileCache.record(event.sessionID, targetPath, snapshot);
+ const freshnessPath = await canonicalizeExistingPath(targetPath);
+ const snapshot = await statSnapshot(freshnessPath);
+ if (snapshot) fileCache.record(event.sessionID, freshnessPath, snapshot);
}
const settings = await deps.settingsFor(event.sessionID);
if (settings === undefined || !settings.enabled) return;
@@ -1291,8 +1328,11 @@ export function createHashlineEditHandlers(
return {
handlers,
dispose() {
+ if (disposed) return;
+ disposed = true;
modelCache.clear();
- fileCache.clear();
+ fileCacheOwners -= 1;
+ if (fileCacheOwners === 0) fileCache.clear();
},
};
}
diff --git a/src/plugins/hashline-edit/str-replace-editor.test.ts b/src/plugins/hashline-edit/str-replace-editor.test.ts
index 5539d84..1a71606 100644
--- a/src/plugins/hashline-edit/str-replace-editor.test.ts
+++ b/src/plugins/hashline-edit/str-replace-editor.test.ts
@@ -13,11 +13,15 @@
// FakeFile - In-memory file record with content and snapshot.
// FakeFs - In-memory StrReplaceEditorFs implementation with mutable snapshots.
// SESSION - Fixed session identifier used across editor fixtures.
-// makeEditor - Build a StrReplaceEditor wired to a FakeFs and a session file cache.
+// makeEditor - Build a StrReplaceEditor wired to a FakeFs and a shared or fresh session file cache.
+// Deferred - Promise plus resolver used as a deterministic test barrier.
+// deferred - Create a deterministic test barrier and its resolver.
+// GatedFakeFs - FakeFs whose first read blocks until released, exposing lock overlap.
+// PathGateFakeFs - FakeFs that blocks reads of one path only, proving per-target independence.
// END_MODULE_MAP
//
// START_CHANGE_SUMMARY
-// LAST_CHANGE: [C-AGENT-TOOL-CONTRACTS T-005 - Updated view_range/missing-field expectations to the shared contract diagnostics, added direct-entry structural rejection, empty/whitespace path rejection with preserved in-name spaces, empty file_text/empty new_str acceptance, and result-envelope schema checks.]
+// LAST_CHANGE: [Direct fix - Added deterministic barrier coverage for per-target serialization, different-target independence, and repeated non-noop replacement semantics.]
// END_CHANGE_SUMMARY
import { describe, expect, test } from "bun:test";
@@ -84,8 +88,10 @@ class FakeFs implements StrReplaceEditorFs {
const SESSION = "ses_1";
-function makeEditor(fs: FakeFs): StrReplaceEditor {
- const cache = new SessionFileCache();
+function makeEditor(
+ fs: FakeFs,
+ cache: SessionFileCache = new SessionFileCache(),
+): StrReplaceEditor {
return new StrReplaceEditor({
fs,
onViewed: (path, snapshot) => cache.record(SESSION, path, snapshot),
@@ -93,6 +99,71 @@ function makeEditor(fs: FakeFs): StrReplaceEditor {
});
}
+interface Deferred {
+ promise: Promise;
+ resolve(): void;
+}
+
+function deferred(): Deferred {
+ let resolve!: () => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+/** FakeFs whose first read blocks until the test releases it, exposing queue overlap. */
+class GatedFakeFs extends FakeFs {
+ readsStarted = 0;
+ readonly firstReadStarted: Promise;
+ readonly #started = deferred();
+ readonly #release = deferred();
+
+ constructor() {
+ super();
+ this.firstReadStarted = this.#started.promise;
+ }
+
+ release(): void {
+ this.#release.resolve();
+ }
+
+ override async readText(path: string): Promise {
+ this.readsStarted += 1;
+ if (this.readsStarted === 1) {
+ this.#started.resolve();
+ await this.#release.promise;
+ }
+ return super.readText(path);
+ }
+}
+
+/** FakeFs that blocks reads of one path only, leaving other targets independent. */
+class PathGateFakeFs extends FakeFs {
+ readonly #gatePath: string;
+ readonly entered: Promise;
+ readonly #started = deferred();
+ readonly #release = deferred();
+
+ constructor(gatePath: string) {
+ super();
+ this.#gatePath = gatePath;
+ this.entered = this.#started.promise;
+ }
+
+ release(): void {
+ this.#release.resolve();
+ }
+
+ override async readText(path: string): Promise {
+ if (path === this.#gatePath) {
+ this.#started.resolve();
+ await this.#release.promise;
+ }
+ return super.readText(path);
+ }
+}
+
describe("str_replace_editor view command", () => {
test("renders the dsh header and 6-padded line numbers", async () => {
const fs = new FakeFs();
@@ -577,3 +648,102 @@ describe("str_replace_editor direct-entry contract rejection", () => {
).toBe(false);
});
});
+
+describe("str_replace_editor target coordination", () => {
+ test("serializes complete disjoint str_replace cycles across editor instances", async () => {
+ const fs = new GatedFakeFs();
+ fs.addFile("/repo/a.py", "alpha\nbeta\ngamma\n");
+ const cache = new SessionFileCache();
+ const editors = [makeEditor(fs, cache), makeEditor(fs, cache), makeEditor(fs, cache)];
+
+ const first = editors[0]!.execute({
+ command: "str_replace",
+ path: "/repo/a.py",
+ old_str: "alpha",
+ new_str: "ALPHA-ONE",
+ });
+ await fs.firstReadStarted;
+ expect(fs.readsStarted).toBe(1);
+
+ const second = editors[1]!.execute({
+ command: "str_replace",
+ path: "/repo/a.py",
+ old_str: "beta",
+ new_str: "BETA-TWO",
+ });
+ const third = editors[2]!.execute({
+ command: "str_replace",
+ path: "/repo/a.py",
+ old_str: "gamma",
+ new_str: "GAMMA-THREE",
+ });
+
+ // The first editor holds the target lock while its read is blocked; the
+ // other editors must not reach their reads yet.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(fs.readsStarted).toBe(1);
+
+ fs.release();
+ const results = await Promise.all([first, second, third]);
+ for (const result of results) {
+ expect(result.ok).toBe(true);
+ }
+ expect(fs.files.get("/repo/a.py")?.content).toBe("ALPHA-ONE\nBETA-TWO\nGAMMA-THREE\n");
+ });
+
+ test("does not block a different target while one target is held", async () => {
+ const fs = new PathGateFakeFs("/repo/a.py");
+ fs.addFile("/repo/a.py", "alpha\n");
+ fs.addFile("/repo/b.py", "beta\n");
+ const cache = new SessionFileCache();
+
+ const held = makeEditor(fs, cache).execute({
+ command: "str_replace",
+ path: "/repo/a.py",
+ old_str: "alpha",
+ new_str: "ALPHA",
+ });
+ await fs.entered;
+
+ const independent = await makeEditor(fs, cache).execute({
+ command: "str_replace",
+ path: "/repo/b.py",
+ old_str: "beta",
+ new_str: "BETA",
+ });
+ expect(independent.ok).toBe(true);
+ expect(fs.files.get("/repo/b.py")?.content).toBe("BETA\n");
+
+ fs.release();
+ expect((await held).ok).toBe(true);
+ expect(fs.files.get("/repo/a.py")?.content).toBe("ALPHA\n");
+ });
+
+ test("a repeated non-noop replacement reports not-found with bytes unchanged", async () => {
+ const fs = new FakeFs();
+ fs.addFile("/repo/a.py", "alpha\nbeta\n");
+ const editor = makeEditor(fs);
+
+ const first = await editor.execute({
+ command: "str_replace",
+ path: "/repo/a.py",
+ old_str: "beta",
+ new_str: "BETA",
+ });
+ expect(first.ok).toBe(true);
+ expect(fs.files.get("/repo/a.py")?.content).toBe("alpha\nBETA\n");
+
+ const repeat = await editor.execute({
+ command: "str_replace",
+ path: "/repo/a.py",
+ old_str: "beta",
+ new_str: "BETA",
+ });
+ expect(repeat.ok).toBe(false);
+ if (!repeat.ok) {
+ expect(repeat.error).toContain("did not appear verbatim");
+ }
+ expect(fs.files.get("/repo/a.py")?.content).toBe("alpha\nBETA\n");
+ });
+});
diff --git a/src/plugins/hashline-edit/str-replace-editor.ts b/src/plugins/hashline-edit/str-replace-editor.ts
index d426d86..e86c970 100644
--- a/src/plugins/hashline-edit/str-replace-editor.ts
+++ b/src/plugins/hashline-edit/str-replace-editor.ts
@@ -1,9 +1,9 @@
// FILE: src/plugins/hashline-edit/str-replace-editor.ts
-// VERSION: 0.2.0
+// VERSION: 0.3.0
// START_MODULE_CONTRACT
// PURPOSE: Implement the DeepSeek dsh str_replace_editor contract (view/create/str_replace/insert) as a native edit profile.
-// SCOPE: Command dispatch, dsh-verbatim view formatting and file-dependent error texts, exact-verbatim str_replace matching with occurrence line numbers, insert_line file-length validation, create guard, absolute-path hint, directory listing, output truncation, and view-cache freshness checks. Registered argument shapes and args-only command validation come from schemas.ts; this module reruns that validation at its direct entry before any filesystem access. The model-facing description is owned by tool-description.ts and re-exported here.
-// DEPENDS: [src/lib/agent-tool-contract.ts, src/plugins/hashline-edit/schemas.ts, src/plugins/hashline-edit/session-state.ts, src/plugins/hashline-edit/tool-description.ts]
+// SCOPE: Command dispatch, dsh-verbatim view formatting and file-dependent error texts, exact-verbatim str_replace matching with occurrence line numbers, insert_line file-length validation, create guard, absolute-path hint, directory listing, output truncation, and view-cache freshness checks. Registered argument shapes and args-only command validation come from schemas.ts; this module reruns that validation at its direct entry before any filesystem access. Every command runs under a process-global lock keyed by the canonical target so the complete existence/freshness/read/match/write/cache cycle is serialized across editor instances and sessions. Callbacks receive caller-visible paths; the native adapter maps both read and editor observations to shared canonical freshness keys. Cancellation is rechecked after the queue wait and before mutation. The model-facing description is owned by tool-description.ts and re-exported here.
+// DEPENDS: [src/lib/agent-tool-contract.ts, src/plugins/hashline-edit/schemas.ts, src/plugins/hashline-edit/session-state.ts, src/plugins/hashline-edit/target-lock.ts, src/plugins/hashline-edit/tool-description.ts]
// LINKS: [M-PLUGIN-HASHLINE-EDIT, M-AGENT-TOOL-CONTRACT]
// ROLE: RUNTIME
// MAP_MODE: EXPORTS
@@ -14,23 +14,25 @@
// STR_REPLACE_TRUNCATION_MARKER - dsh response-clipped marker appended to truncated view output.
// StrReplaceEditorArgs - Tool-facing command arguments; schema-derived from schemas.ts.
// StrReplaceEditorResult - Ok output or fail-closed error text; schema-derived from schemas.ts.
-// StrReplaceEditorFs - Filesystem seam used by the editor (node:fs-backed in production, fake in tests).
+// StrReplaceEditorFs - Filesystem seam used by the editor (node:fs-backed in production, fake in tests), including optional canonicalization and exclusive create.
// StrReplaceEditorFsEntry - Directory entry name and type returned by the filesystem seam.
-// StrReplaceEditorOptions - Editor options: filesystem seam, output cap, and view-cache callbacks.
+// StrReplaceEditorOptions - Editor options: filesystem seam, output cap, cancellation signal, and view-cache callbacks.
// StrReplaceEditor - Executes str_replace_editor commands with dsh semantics and view-cache freshness checks.
// END_MODULE_MAP
//
// START_CHANGE_SUMMARY
-// LAST_CHANGE: [C-AGENT-TOOL-CONTRACTS T-005 - Moved the description to tool-description.ts (re-exported), derived argument/result types from the single-source contract, and rejected structural/command-invalid arguments at the direct editor entry before any filesystem access.]
+// LAST_CHANGE: [Direct fix - Serialize complete command cycles by canonical target across editor instances, preserve caller-visible paths, and recheck cancellation before mutation.]
// END_CHANGE_SUMMARY
import { formatContractIssues } from "../../lib/agent-tool-contract.js";
+import { resolve } from "node:path";
import type { FileCacheVerdict, FileSnapshot } from "./session-state.js";
import {
validateStrReplaceEditorToolInput,
type StrReplaceEditorResult,
type StrReplaceEditorToolArgs,
} from "./schemas.js";
+import { throwIfAborted, withTargetLock } from "./target-lock.js";
import { STR_REPLACE_EDITOR_DESCRIPTION } from "./tool-description.js";
export { STR_REPLACE_EDITOR_DESCRIPTION };
@@ -56,7 +58,16 @@ export interface StrReplaceEditorFs {
exists(path: string): Promise;
isDirectory(path: string): Promise;
readText(path: string): Promise;
- writeText(path: string, content: string): Promise;
+ /**
+ * Replace file text. `expectedText` is the content this command read; the
+ * production filesystem refuses publication when the target no longer
+ * matches it. Fake filesystems may ignore it.
+ */
+ writeText(path: string, content: string, options?: { expectedText?: string }): Promise;
+ /** Atomically create a new file without replacing an existing directory entry. */
+ createText?(path: string, content: string): Promise;
+ /** Resolve symlinks for coordination keys; callbacks still receive caller-visible paths. */
+ canonicalize?(path: string): Promise;
listDir(path: string): Promise;
stat(path: string): Promise;
}
@@ -64,6 +75,7 @@ export interface StrReplaceEditorFs {
export interface StrReplaceEditorOptions {
fs: StrReplaceEditorFs;
maxOutputChars?: number;
+ signal?: AbortSignal;
onViewed?: (path: string, snapshot: FileSnapshot) => void;
checkFreshness?: (path: string, current: FileSnapshot | undefined) => FileCacheVerdict;
}
@@ -143,18 +155,41 @@ export class StrReplaceEditor {
);
}
- switch (args.command) {
- case "view":
- return this.view(path, args.view_range);
- case "create":
- return this.create(path, args.file_text);
- case "str_replace":
- return this.strReplace(path, args.old_str, args.new_str);
- case "insert":
- return this.insert(path, args.insert_line, args.new_str);
- default:
- return fail(`Unknown command: ${String(args.command)}`);
+ // Symlink aliases share one lock; keep caller-visible paths in diagnostics
+ // and let the native adapter canonicalize its freshness callback keys.
+ const coordinationKey = await this.coordinationKey(path);
+ return withTargetLock(
+ coordinationKey,
+ async () => {
+ throwIfAborted(this.options.signal);
+ switch (args.command) {
+ case "view":
+ return this.view(path, args.view_range);
+ case "create":
+ return this.create(path, args.file_text);
+ case "str_replace":
+ return this.strReplace(path, args.old_str, args.new_str);
+ case "insert":
+ return this.insert(path, args.insert_line, args.new_str);
+ default:
+ return fail(`Unknown command: ${String(args.command)}`);
+ }
+ },
+ this.options.signal,
+ );
+ }
+
+ private async coordinationKey(path: string): Promise {
+ try {
+ const canonical = await this.options.fs.canonicalize?.(path);
+ if (canonical !== undefined && canonical.length > 0) {
+ return resolve(canonical);
+ }
+ } catch {
+ // A metadata failure must not prevent the command; fall back to the
+ // lexical target so independent aliases still share one key when known.
}
+ return resolve(path);
}
private async statExisting(
@@ -286,12 +321,22 @@ export class StrReplaceEditor {
if (fileText === undefined) {
return fail("Parameter `file_text` is required for command: create");
}
- if (await this.options.fs.exists(path)) {
- return fail(
- `File already exists at: ${path}. Cannot overwrite files using command \`create\`.`,
- );
+ if (this.options.fs.createText !== undefined) {
+ throwIfAborted(this.options.signal);
+ if (!(await this.options.fs.createText(path, fileText))) {
+ return fail(
+ `File already exists at: ${path}. Cannot overwrite files using command \`create\`.`,
+ );
+ }
+ } else {
+ if (await this.options.fs.exists(path)) {
+ return fail(
+ `File already exists at: ${path}. Cannot overwrite files using command \`create\`.`,
+ );
+ }
+ throwIfAborted(this.options.signal);
+ await this.options.fs.writeText(path, fileText);
}
- await this.options.fs.writeText(path, fileText);
const snapshot = await this.options.fs.stat(path);
if (snapshot) {
this.options.onViewed?.(path, snapshot);
@@ -338,10 +383,9 @@ export class StrReplaceEditor {
const offset = offsets[0]!;
const newValue = newStr ?? "";
- await this.options.fs.writeText(
- path,
- before.slice(0, offset) + newValue + before.slice(offset + oldStr.length),
- );
+ const after = before.slice(0, offset) + newValue + before.slice(offset + oldStr.length);
+ throwIfAborted(this.options.signal);
+ await this.options.fs.writeText(path, after, { expectedText: before });
const snapshot = await this.options.fs.stat(path);
if (snapshot) {
this.options.onViewed?.(path, snapshot);
@@ -384,7 +428,8 @@ export class StrReplaceEditor {
...newStr.split("\n"),
...lines.slice(insertLine),
].join("\n");
- await this.options.fs.writeText(path, after);
+ throwIfAborted(this.options.signal);
+ await this.options.fs.writeText(path, after, { expectedText: before });
const snapshot = await this.options.fs.stat(path);
if (snapshot) {
this.options.onViewed?.(path, snapshot);
diff --git a/src/plugins/hashline-edit/target-lock.test.ts b/src/plugins/hashline-edit/target-lock.test.ts
new file mode 100644
index 0000000..70a8e69
--- /dev/null
+++ b/src/plugins/hashline-edit/target-lock.test.ts
@@ -0,0 +1,123 @@
+// FILE: src/plugins/hashline-edit/target-lock.test.ts
+// VERSION: 0.1.0
+// START_MODULE_CONTRACT
+// PURPOSE: Verify deterministic per-target FIFO coordination, cancellation while waiting, effect-error release, and independent keys for the str_replace_editor target lock.
+// SCOPE: Unit coverage of target-lock.ts with deferred barriers only; no timing or stress assertions.
+// DEPENDS: [bun:test, src/plugins/hashline-edit/target-lock.ts]
+// LINKS: [M-PLUGIN-HASHLINE-EDIT, V-M-PLUGIN-HASHLINE-EDIT]
+// ROLE: TEST
+// MAP_MODE: LOCALS
+// END_MODULE_CONTRACT
+//
+// START_MODULE_MAP
+// Deferred - Promise and resolver used as a deterministic barrier.
+// deferred - Promise plus resolvers used as deterministic test barriers.
+// END_MODULE_MAP
+//
+// START_CHANGE_SUMMARY
+// LAST_CHANGE: [Direct fix - Added deterministic coverage for process-global canonical-target coordination.]
+// END_CHANGE_SUMMARY
+
+import { describe, expect, test } from "bun:test";
+import { withTargetLock } from "./target-lock.js";
+
+interface Deferred {
+ promise: Promise;
+ resolve(): void;
+}
+
+function deferred(): Deferred {
+ let resolve!: () => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+describe("withTargetLock", () => {
+ test("runs same-key effects in FIFO order and never overlaps them", async () => {
+ const started = deferred();
+ const release = deferred();
+ const order: string[] = [];
+
+ const first = withTargetLock("target", async () => {
+ order.push("first:start");
+ started.resolve();
+ await release.promise;
+ order.push("first:end");
+ });
+ await started.promise;
+
+ const second = withTargetLock("target", () => {
+ order.push("second");
+ });
+ const third = withTargetLock("target", () => {
+ order.push("third");
+ });
+
+ // Let both waiters reach their queue positions without letting them run.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(order).toEqual(["first:start"]);
+
+ release.resolve();
+ await Promise.all([first, second, third]);
+ expect(order).toEqual(["first:start", "first:end", "second", "third"]);
+ });
+
+ test("cancels a waiter without running it or blocking the next caller", async () => {
+ const started = deferred();
+ const release = deferred();
+ const controller = new AbortController();
+
+ const holder = withTargetLock("target", async () => {
+ started.resolve();
+ await release.promise;
+ });
+ await started.promise;
+
+ const cancelled = withTargetLock(
+ "target",
+ () => {
+ throw new Error("cancelled effect must not run");
+ },
+ controller.signal,
+ );
+ await Promise.resolve();
+ controller.abort();
+
+ await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
+
+ release.resolve();
+ await holder;
+ const next = await withTargetLock("target", () => "next");
+ expect(next).toBe("next");
+ });
+
+ test("releases the lock when an effect throws", async () => {
+ await expect(
+ withTargetLock("target", () => {
+ throw new Error("boom");
+ }),
+ ).rejects.toThrow("boom");
+
+ const recovered = await withTargetLock("target", () => "recovered");
+ expect(recovered).toBe("recovered");
+ });
+
+ test("different keys progress independently", async () => {
+ const started = deferred();
+ const release = deferred();
+ const held = withTargetLock("a", async () => {
+ started.resolve();
+ await release.promise;
+ });
+ await started.promise;
+
+ const independent = await withTargetLock("b", () => "b-done");
+ expect(independent).toBe("b-done");
+
+ release.resolve();
+ await held;
+ });
+});
diff --git a/src/plugins/hashline-edit/target-lock.ts b/src/plugins/hashline-edit/target-lock.ts
new file mode 100644
index 0000000..b6249eb
--- /dev/null
+++ b/src/plugins/hashline-edit/target-lock.ts
@@ -0,0 +1,117 @@
+// FILE: src/plugins/hashline-edit/target-lock.ts
+// VERSION: 0.1.0
+// START_MODULE_CONTRACT
+// PURPOSE: Serialize complete str_replace_editor command cycles per canonical target across independent editor and handler instances in one process.
+// SCOPE: Process-global FIFO keyed asynchronous locks with cancellation-aware waiting, explicit abort rechecks, and release on success, error, or cancellation; no cross-process guarantee.
+// DEPENDS: [none]
+// LINKS: [M-PLUGIN-HASHLINE-EDIT, V-M-PLUGIN-HASHLINE-EDIT]
+// ROLE: RUNTIME
+// MAP_MODE: EXPORTS
+// END_MODULE_CONTRACT
+//
+// START_MODULE_MAP
+// abortError - Build the cancellation error used by queue waits and editor rechecks.
+// throwIfAborted - Throw the signal reason or an AbortError when the signal is already cancelled.
+// withTargetLock - Run one effect under the process-global FIFO lock for a canonical target key.
+// END_MODULE_MAP
+//
+// START_CHANGE_SUMMARY
+// LAST_CHANGE: [Direct fix - Added process-global canonical-target coordination for the dsh str_replace_editor read-modify-write cycle.]
+// END_CHANGE_SUMMARY
+
+// START_BLOCK_ABORT
+/** Build a cancellation error that preserves an explicit abort reason. */
+export function abortError(signal?: AbortSignal): Error {
+ const reason = signal?.reason;
+ if (reason instanceof Error) {
+ return reason;
+ }
+ const error = new Error("The operation was aborted.");
+ error.name = "AbortError";
+ return error;
+}
+
+/** Throw when the caller signal is already cancelled; used after queue waits and before mutation. */
+export function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) {
+ throw abortError(signal);
+ }
+}
+// END_BLOCK_ABORT
+
+// START_BLOCK_LOCK
+interface LockEntry {
+ /** Tail of the FIFO chain; resolves when the last queued holder releases. */
+ tail: Promise;
+ /** Number of callers that entered this entry and have not finished yet. */
+ waiters: number;
+}
+
+/**
+ * Process-global lock registry. Keyed by canonical target, so independent
+ * editor instances, handler instances, and sessions in this process share the
+ * same coordination. There is deliberately no cross-process exclusion.
+ */
+const LOCKS = new Map();
+
+function waitForTail(tail: Promise, signal?: AbortSignal): Promise {
+ if (signal === undefined) {
+ return tail;
+ }
+ if (signal.aborted) {
+ return Promise.reject(abortError(signal));
+ }
+ return new Promise((resolvePromise, rejectPromise) => {
+ const onAbort = (): void => rejectPromise(abortError(signal));
+ signal.addEventListener("abort", onAbort, { once: true });
+ tail.then(
+ () => {
+ signal.removeEventListener("abort", onAbort);
+ resolvePromise();
+ },
+ (error: unknown) => {
+ signal.removeEventListener("abort", onAbort);
+ rejectPromise(error);
+ },
+ );
+ });
+}
+
+/**
+ * Run `effect` under the process-global FIFO lock for `key`. A waiting caller
+ * that is cancelled rejects without running its effect and releases its place
+ * in the chain, so later callers are not blocked by the cancelled waiter.
+ */
+export async function withTargetLock(
+ key: string,
+ effect: () => Promise | T,
+ signal?: AbortSignal,
+): Promise {
+ let entry = LOCKS.get(key);
+ if (entry === undefined) {
+ entry = { tail: Promise.resolve(), waiters: 0 };
+ LOCKS.set(key, entry);
+ }
+ const activeEntry = entry;
+ activeEntry.waiters += 1;
+
+ const prior = activeEntry.tail;
+ let release!: () => void;
+ const gate = new Promise((resolveGate) => {
+ release = resolveGate;
+ });
+ activeEntry.tail = prior.then(() => gate);
+
+ try {
+ await waitForTail(prior, signal);
+ throwIfAborted(signal);
+ return await effect();
+ } finally {
+ release();
+ activeEntry.waiters -= 1;
+ if (activeEntry.waiters === 0 && LOCKS.get(key) === activeEntry) {
+ LOCKS.delete(key);
+ }
+ }
+}
+// END_BLOCK_LOCK