# dsh-file-claim [![English](https://img.shields.io/badge/lang-English-blue.svg)](README.md) [![简体中文](https://img.shields.io/badge/lang-%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-red.svg)](README.zh-CN.md) [![npm version](https://img.shields.io/npm/v/dsh-file-claim)](https://www.npmjs.com/package/dsh-file-claim) [![npm downloads](https://img.shields.io/npm/dm/dsh-file-claim)](https://www.npmjs.com/package/dsh-file-claim) [![CI](https://github.com/Nwflower/dsh-file-claim/actions/workflows/ci.yml/badge.svg)](https://github.com/Nwflower/dsh-file-claim/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![node](https://img.shields.io/badge/node-%3E%3D18-green.svg)](package.json) > **Write in parallel. Never overwrite.** > File claim / protection for concurrent DeepSeek Harness (DSH) sessions working the same workspace. When several DSH sessions run in parallel against one workspace, they have no awareness of each other: two sessions can overwrite the same file, a crashed session leaves stale state behind, and a session that wants to edit a file another session owns can only wait or guess. `dsh-file-claim` turns a proven coordination protocol into native DSH tools, lifecycle events, and a write guard — so parallel agents cooperate instead of clobbering each other. ```text claim_files({ paths: ["README.md"] }) # "I'm editing this" write / edit ... # writes to files claimed by others are denied release_files({ paths: ["README.md"] }) # "done — pending edits auto-merge now" ``` ## Table of Contents - [Features](#features) - [Why](#why) - [Install](#install) - [Quick Start](#quick-start) - [Usage Examples](#usage-examples) - [Tools](#tools) - [Commands](#commands) - [Write Guard](#write-guard) - [Configuration](#configuration) - [Audit Log](#audit-log) - [Pending Merge Area](#pending-merge-area) - [Enforcement Boundary](#enforcement-boundary) - [FAQ](#faq) - [Development](#development) - [Related Projects](#related-projects) - [License](#license) ## Features - 🔒 **claim / release** — a session declares exclusive ownership of file paths before editing them; duplicates merge idempotently, directory claims cover descendants, and `'.'` claims the whole workspace. - ❤️ **heartbeat + stale takeover + orphan self-heal** — heartbeats refresh automatically via agent lifecycle events; a crashed / hard-killed session's claims are **cleared immediately on the next activity** (per-process pid check) and swept by the heartbeat interval; `staleMs` expiry (2h, for records without a pid) and `--force` takeover remain as the slow fallback. - 🧩 **async pending merge area** — instead of blocking, a session writes its edited content plus the git HEAD base into a pending area; when the owner releases, the entry is **auto-merged** via a **git 3-way merge** (`current × base × pending`) when conflict-free, or applied manually with `pending apply` when it conflicts. - 🛡️ **write guard** — a `tools/pre-execute` guard refuses writes to files actively claimed by another session, with a hint (wait / takeover when stale / pend) and an opt-in commit guard. - ⚡ **zero automation burden** — `agent/created` / `agent/status` refresh the heartbeat, and `agent/disposed` auto-releases every claim of a departed session. - 📦 **pure Host plugin, zero dependencies** — no Browser side, no build step, `node:` builtins only; Windows-friendly. - 🧾 **audit trail** — every claim / release / takeover / pending mutation is appended as one JSON line, for traceability and post-crash reconciliation. ## Why The DSH host has no built-in cross-session file protection, and a full scan of 505 `dsh-plugin` topic repositories found **zero** file-claim/coordination plugins. The pending merge area — write your edit now, merge it cleanly once the owner releases — is unique in the agent file-lock category. This is a gap-filler, not a duplicate. ### Compared with the category Checked against 11 Claude Code / Codex file-lock and coordination tools (claude-code-file-locks, parallel-sessions, guardex, agent-orchestrator, blackboard-mcp, mclaude, ruah-orch, knot, …): | Differentiator | dsh-file-claim | Typical alternatives | | --- | --- | --- | | Conflict handling | **async pending area + git 3-way merge** — write now, merge cleanly once the owner releases | wait / deny only ("lock → write → release") | | Target platform | **DSH-native** — identity, tools, events, guard and slash commands all integrated | Claude Code / Codex hooks; none target DSH | | Platform support | zero-dependency Node, **Windows-friendly** | Bash/jq/flock solutions lean macOS/Linux; guardex has no native Windows | | Storage | **workspace sidecars** — `.dshclaim` next to each protected file (the `.agentlock` idiom) | central state dir (`.coord/`, `~/.claude/…`) or worktree isolation | | Enforcement | cooperative tool-layer guardrail (fail-open, matching the category's de-facto standard) | hook interception / declarative locks; top tools fall back to worktree isolation | ## Install ```sh dsh plugin add dsh-file-claim ``` For development / manual verification against a local checkout: ```sh dsh plugin --profile web add -w link: ``` Requires DSH with `node >= 18` and `git` on `PATH` (used only by the 3-way merge). ## Quick Start 1. **Claim before you write.** Editing files? Call `claim_files` first — it declares exclusive ownership so other sessions leave them alone. 2. **Write freely.** Your own claims never block you; writes to files actively claimed by *another* session are denied with a hint (wait / takeover when stale / pend). 3. **Busy file? Don't wait — pend.** Use `pending_write` to drop your edited content into the pending area (with the git HEAD base). Once the owner calls `release_files`, the entry is auto-merged when conflict-free — or run `pending_apply` manually for a clean 3-way merge. 4. **Release when done.** `release_files` clears your claims, auto-merges waiting pending entries, and surfaces the ones that need manual attention. ```text claim_files({ paths: ["README.md", "src/"] }) write / edit ... release_files({ paths: ["README.md"] }) ``` ## Usage Examples **Two sessions, one workspace.** Session A owns `README.md`; session B wants to edit it too: ```text // Session A claim_files({ paths: ["README.md"], note: "rewriting the docs" }) write ... README.md // allowed: own claim release_files({ paths: ["README.md"] }) // Session B — meanwhile who_claims({ paths: ["README.md"] }) // → claimed by A write ... README.md // → DENIED with a hint pending_write({ path: "README.md", content: "..." }) // async, no blocking // When A releases, the entry is auto 3-way merged (or surfaced for manual pending_apply) ``` **Recover from a crashed session.** Session A dies mid-work; its claims expire after `staleMs` (2h default), then: ```text claim_status() # → A shows [stale] claim_files({ paths: ["README.md"], force: true }) # take over ``` ## Tools Eight model-facing tools (identity is the calling session — no `--as` needed): | Tool | Purpose | | --- | --- | | `claim_files` | Claim file/dir paths exclusively before editing (`paths`, optional `note`, `force` for stale takeover) | | `release_files` | Release paths (`paths`) or everything (`all`) | | `who_claims` | Read-only: who claims given paths | | `claim_status` | Read-only: session registry, claims, pending area overview, recent audit | | `pending_write` | Async write: put edited content (+ git HEAD base) into the pending area for a file actively claimed by another session | | `pending_apply` | 3-way merge `current × base × pending` onto disk; conflict-free auto-clears, conflicts leave markers | | `pending_show` | Read-only: view one pending entry's meta and content | | `pending_drop` | Discard one pending entry (no merge) | ## Commands Human-usable slash commands (same semantics as the tools above — useful when the model is unavailable, or for shell-bound users). The line after the command name is split quote-aware, so paths and notes containing spaces work (`--note "multi word note"`). Command runs are recorded in the session log only, never fed to the model. | Command | Purpose | | --- | --- | | `/claim ... [--note ] [--force]` | Claim file/dir paths exclusively; `--force` takes over a stale holder | | `/release [... \| --all]` | Release paths or everything | | `/claim-status` | Read-only: session registry, claims, pending area overview | The standalone core also ships a CLI: `node claim.mjs status | audit [n] | claim ...` — same semantics, no DSH required. ## Write Guard `tools/pre-execute` denies `write` / `edit` / `bash` / `pwsh` calls whose target path is actively claimed by **another** session. The denial message names the holder and suggests: wait for `release_files`, take over with `claim_files(force: true)` once stale, or use `pending_write`. `read` is **not** intercepted — reading is observation, not modification, and the claim contract only protects the write surface. Shell-path parsing (`bash`/`pwsh`) is best-effort: only **redirection targets** and the target arguments of **explicit write commands** are extracted (pwsh `Set-Content` / `Add-Content` / `Out-File` / `New-Item` / `Copy-Item` / `Move-Item` / `Remove-Item` / `Rename-Item`; bash `tee` / `dd of=` / `cp` / `mv` / `rm`). Quoted literals are **never** treated as write targets — they are data, URLs or patterns, not files being written — and commands that yield no parseable target pass through (fail-open). With `guardCommit: true`, a `git commit` that explicitly stages paths actively claimed by another session (`git commit -- ` or legacy `git commit `) is also denied; commit messages are never inspected, and a bare `git commit` (no paths) passes through — its scope cannot be known. ## Configuration Passed as plugin config in the bundle (`cordis.patch.yml`): | Key | Default | Meaning | | --- | --- | --- | | `staleMs` | `7200000` (2h) | Heartbeat expiry before a session is considered stale | | `guard` | `true` | Set `false` to disable the pre-execute write guard | | `guardCommit` | `false` | Opt-in: also deny `git commit` that explicitly stages paths actively claimed by another session | | `heartbeatMs` | `600000` (10min) | Fallback heartbeat interval | ```yaml - insert: - id: dsh-file-claim name: dsh-file-claim config: staleMs: 3600000 # 1h guardCommit: true # also guard explicit git commits ``` Since 0.2.0, state is stored as **workspace sidecar files** — the lock travels with the protected file (the claude-code-file-locks `.agentlock` idiom, adopted because DSH reserves no in-workspace directory convention): ```text README.md.dshclaim claim sidecar (JSON: version, path, tag, note, startedAt, lastSeenAt, pid) README.md.dshpending/ pending-merge sidecar (content / base / meta.json), next to the target .dsh-file-claim.audit.jsonl workspace-global audit log (append-only, at the root) .dsh-file-claim.lock transient cross-process mutex (exists only during a mutation) ``` Recommend adding these to `.gitignore`: ```gitignore *.dshclaim *.dshpending/ .dsh-file-claim.audit.jsonl .dsh-file-claim.lock ``` When a workspace still carries the ≤0.1.7 flat `.dsh-file-claim/` directory (or an unpublished 0.2.0 `.dsh/dsh-file-claim/`), the first tool/command call **migrates** it to sidecars (the old directory is kept — delete it manually once verified; its `registry.json` is renamed `registry.json.migrated` as the idempotency marker). The write guard also reads the old registry until that first call, so protection is seamless across the upgrade. State survives restarts; nothing ever touches `.git/`. ## Audit Log Every business mutation — claim, takeover, release, pending write / apply / drop, prune, drop — is appended as one JSON line to `.dsh-file-claim.audit.jsonl` (`{ at, tag, type, paths/path, detail }`), for traceability and post-crash reconciliation. Heartbeats are intentionally **not** logged (noise). `node claim.mjs audit [n]` prints the latest `n` entries (default 10); `claim_status` always shows the three most recent. Audit writes are append-only and never block or alter claim semantics; a failed audit append surfaces a warning line without failing the operation. The file self-rotates at 1 MB (keeping the most recent half plus the new entry), so it never grows without bound. ## Pending Merge Area Storage layout (a sidecar directory next to the protected file): ```text .dshpending/content new file content to merge .dshpending/base git HEAD version at write time (merge base) .dshpending/meta.json { pender, claimedBy, at, baseSha } ``` Write conditions: `pending_write` requires the target to be actively claimed by another session — otherwise write the file directly after `claim_files`. `base` is only recorded when `git HEAD` contains the path; a missing base is a deliberate non-mergeable entry. Apply semantics (`pending_apply`): runs `git merge-file` over `current × base × pending` (three real file snapshots staged in a temp dir). No conflicts → merged content lands on disk and the entry is cleared. Conflicts → the merged output (with conflict markers) lands on disk and the entry is **kept** for manual resolution. Missing base → refused, never a blind merge. Active claim by any session → refused until released. `release_files` runs an unlock check: pending entries aimed at the released paths (or at the releasing session) are **auto-merged** when the 3-way merge is conflict-free (content lands on disk, entry cleared); entries that cannot merge — still occupied, missing base, conflicts, or missing file — stay pending and are surfaced with manual `pending_apply` / `pending_show` / `pending_drop` hints. ## Enforcement Boundary The guard is **advisory / cooperative**, not a mandatory lock: arbitrary shell commands (`echo > file`, `git checkout`, scripts), external editors, and IDE/git operations bypass the tool stack entirely. It upgrades "self-discipline via AGENTS.md" to "tool-layer guardrail + model-visible state", matching the fail-open posture of the whole category. ## FAQ **Can bash/pwsh writes be fully intercepted?** No. Only redirection targets and the target arguments of explicit write commands are parsed; arbitrary shell, scripts, external editors and IDE/git operations bypass the tool stack. That is the documented cooperative boundary, not a bug — see [Enforcement Boundary](#enforcement-boundary). **How long until a crashed session's claims are cleared?** Normally **immediately**: every session record carries its process pid, and any session activity (claim / release / sync / prune) sweeps records whose pid is dead — a crash or hard kill is cleaned up on the very next activity, no waiting. `agent/disposed` releases normally-departed sessions at once, and `staleMs` (default 2h) is only the slow fallback for records without a pid (e.g. written by older versions). **Does this work across multiple repos?** Yes. The claim root is the workspace resolved from the session's cwd (`workspaceRegistry`), falling back to cwd — parallel repos are isolated by design. **Where is state stored?** As workspace sidecar files: `.dshclaim` next to each claimed file, `.dshpending/` next to each pending-merge target, and `.dsh-file-claim.audit.jsonl` at the workspace root. Add the gitignore patterns above; state survives restarts and never touches `.git/`. Workspaces upgraded from ≤0.1.7 have their old `.dsh-file-claim/` state migrated to sidecars on the first tool/command call (the old directory is kept; delete it manually once verified). **Why can't the model see the claim tools?** Model-visible tools depend on the deployment's tool presentation/restrictions (like every plugin tool). The plugin registers globally via `ctx.tools.register`, the same path as official tool packages. **What if a pending entry can't merge?** It stays pending with the reason surfaced (still occupied, missing base, conflicts, missing file). Use `pending_show` to inspect and `pending_apply` / `pending_drop` to resolve — nothing is ever blindly merged. **Do claim sidecars and the audit file grow forever?** No. Stale sessions' sidecars are pruned automatically on the heartbeat interval (so departed sessions' records don't accumulate), and `.dsh-file-claim.audit.jsonl` self-rotates at 1 MB. Both stay bounded in normal use. ## Development ```sh npm test # node --test: claim.mjs unit tests (20) + index.mjs mock-ctx integration (13) npm pack --dry-run ``` Layout: `claim.mjs` is the zero-dependency pure-logic core (portable, CLI entry retained); `index.mjs` is the only host-facing file; `test/` covers both. CI runs tests, a bilingual-README structure sync check, and a pack dry-run. ## Related Projects - [dsh-chat-import](https://github.com/Nwflower/dsh-chat-import) — the sibling DSH plugin whose `session.mjs` coordination protocol this plugin was ported and enhanced from. - [awesome-dsh-plugin](https://github.com/bruc3van/awesome-dsh-plugin) — the DSH plugin ecosystem index (505 repos scanned during this project's research). - [@deepseek-ai/dsh](https://www.npmjs.com/package/@deepseek-ai/dsh) — the DeepSeek Harness host. ## License MIT — see [LICENSE](LICENSE).