pi-edit-fence 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/extensions/edit-fence.ts +373 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 anh-chu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# pi-edit-fence
|
|
2
|
+
|
|
3
|
+
Run two or more pi sessions in the same directory without losing work.
|
|
4
|
+
|
|
5
|
+
pi-edit-fence gives each session lightweight, per-file locks that it claims automatically as it edits, so two sessions never overwrite each other's work. No git worktrees, no manual setup, same directory. Without it, concurrent sessions editing the same file just let the last write win, silently discarding the other session's changes.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
Each session auto-claims a lock the moment it edits a file. If another live session tries to edit a file you hold, its edit is fenced.
|
|
10
|
+
|
|
11
|
+
- **Per-file by default.** Editing `src/api/handlers.ts` locks that exact file. A second session editing `src/api/routes.ts` is never blocked. Only a true same-file collision is stopped.
|
|
12
|
+
- **Temporary, not fatal.** A blocked edit waits briefly for the lock to free. If it is still held, the agent gets a retry-later message telling it to work elsewhere and come back, not to abort.
|
|
13
|
+
- **Self-releasing.** Locks auto-expire after the owner stops editing that file (lease). When another session is waiting, the idle lease shortens so a finished owner hands over fast.
|
|
14
|
+
- **Crash-safe.** A session that crashes leaves its lock behind. The next session detects the dead process and reclaims it. No stuck locks.
|
|
15
|
+
- **Shared files warn, never block.** Config and lockfiles (`package.json`, `tsconfig*.json`, `*.config.*`, `.env*`, root-level files, `.pi/**`) are coordination-free zones: you get a heads-up, the edit proceeds.
|
|
16
|
+
|
|
17
|
+
The lock registry lives at `<cwd>/.pi/ownership.json` and is runtime data, auto-added to `.pi/.gitignore`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
From npm:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:pi-edit-fence
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
From git, without waiting for an npm publish:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install git:github.com/anh-chu/pi-edit-fence
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
By default this writes to user settings (`~/.pi/agent/settings.json`). Use `-l` to install into project settings (`.pi/settings.json`) so a team shares it.
|
|
34
|
+
|
|
35
|
+
Try it for one run without installing:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi -e npm:pi-edit-fence
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or drop `extensions/edit-fence.ts` into `~/.pi/agent/extensions/` for a global, no-npm install.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
It works with zero configuration. Open the same project in two pi sessions and edit away. Same-file collisions are fenced; everything else runs free.
|
|
46
|
+
|
|
47
|
+
### Agent tool
|
|
48
|
+
|
|
49
|
+
- `release_path` — an agent can release its lock(s) the instant it finishes an area, for immediate handover. Locks also auto-expire, so this is optional hygiene.
|
|
50
|
+
|
|
51
|
+
### Commands (type in the pi prompt)
|
|
52
|
+
|
|
53
|
+
- `/claims` — list live locks and who holds them
|
|
54
|
+
- `/claim <key>` — manually claim a file or subtree
|
|
55
|
+
- `/release [key]` — drop one or all of your locks
|
|
56
|
+
- `/steal <key>` — force-reassign a lock to your session (use when an owner is stuck or idle)
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Edit the tunables at the top of `extensions/edit-fence.ts`:
|
|
61
|
+
|
|
62
|
+
| Constant | Default | Meaning |
|
|
63
|
+
| -------------------- | ------------------ | ----------------------------------------------------------------------------------- |
|
|
64
|
+
| `SCOPE` | `"file"` | `"file"` locks the exact file. `"dir"` locks a subtree for area-level coordination. |
|
|
65
|
+
| `CLAIM_DEPTH` | `2` | Subtree depth when `SCOPE === "dir"` (2 keeps `src/api` and `src/ui` distinct). |
|
|
66
|
+
| `LEASE_MS` | `5 min` | Idle time before a lock auto-expires. |
|
|
67
|
+
| `CONTENDED_LEASE_MS` | `20 s` | Shorter idle lease applied while another session is waiting. |
|
|
68
|
+
| `WAIT_MS` | `15 s` | How long a blocked edit waits before returning the retry-later message. |
|
|
69
|
+
| `SHARED_PATTERNS` | configs, lockfiles | Glob patterns treated as warn-only shared zones. |
|
|
70
|
+
|
|
71
|
+
### File scope vs dir scope
|
|
72
|
+
|
|
73
|
+
- **`"file"` (default)** matches the real threat: two sessions writing the same file. Zero false fences on sibling files.
|
|
74
|
+
- **`"dir"`** is for coordination: keep two agents out of the same area entirely, even across different files. Use it when files in an area share tight coupling (common imports, generated code) that a per-file lock would miss.
|
|
75
|
+
|
|
76
|
+
## Limitations
|
|
77
|
+
|
|
78
|
+
- **Single host.** Liveness uses the local process table. On a shared filesystem with sessions on different machines, lease expiry still bounds staleness, but cross-host liveness is not reliable.
|
|
79
|
+
- **Shared zone is unfenced by design.** Concurrent edits to `package.json` and other shared files are not prevented, only warned. Coordinate those manually.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-edit-fence
|
|
3
|
+
*
|
|
4
|
+
* Stops concurrent pi sessions in the same directory from clobbering each other's
|
|
5
|
+
* edits, without git worktrees. Each session auto-claims a lock as it edits.
|
|
6
|
+
*
|
|
7
|
+
* Default scope is per-file: a session that edits a file locks that exact file;
|
|
8
|
+
* sibling files are never fenced. Switch SCOPE to "dir" for subtree coordination.
|
|
9
|
+
*
|
|
10
|
+
* Behaviour:
|
|
11
|
+
* - Editing a file/area locked by another LIVE session is blocked with a named owner.
|
|
12
|
+
* - The block is temporary: the editor waits briefly, then gets a retry-later message.
|
|
13
|
+
* - Locks auto-expire after inactivity (lease), and faster when another session is waiting.
|
|
14
|
+
* - Crashed sessions are detected by dead pid and their locks are reclaimed.
|
|
15
|
+
* - Shared paths (configs, lockfiles, root files) warn instead of block.
|
|
16
|
+
*
|
|
17
|
+
* Registry: <cwd>/.pi/ownership.json (runtime data, auto-gitignored)
|
|
18
|
+
*
|
|
19
|
+
* Tool (agent-callable):
|
|
20
|
+
* release_path release your lock(s) when done, for instant handover
|
|
21
|
+
*
|
|
22
|
+
* Commands (human, in the pi prompt):
|
|
23
|
+
* /claims list live locks
|
|
24
|
+
* /claim <key> manually claim a file/subtree
|
|
25
|
+
* /release [key] drop one or all of your locks
|
|
26
|
+
* /steal <key> force-reassign a lock to this session
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import * as crypto from "node:crypto";
|
|
32
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import { Type } from "typebox";
|
|
34
|
+
|
|
35
|
+
// ---- tunables ----
|
|
36
|
+
// Lock granularity.
|
|
37
|
+
// "file" (default): lock the exact file. Matches the real threat (two sessions writing the
|
|
38
|
+
// same file = lost work). Sibling files in the same dir are never fenced.
|
|
39
|
+
// "dir": lock a subtree for coordination. CLAIM_DEPTH sets how many path segments
|
|
40
|
+
// under cwd form the key (2 => src/api and src/ui are distinct areas).
|
|
41
|
+
const SCOPE: "file" | "dir" = "file";
|
|
42
|
+
const CLAIM_DEPTH = 2; // only used when SCOPE === "dir"
|
|
43
|
+
const SHARED_PATTERNS = [
|
|
44
|
+
"package.json",
|
|
45
|
+
"package-lock.json",
|
|
46
|
+
"pnpm-lock.yaml",
|
|
47
|
+
"yarn.lock",
|
|
48
|
+
"bun.lockb",
|
|
49
|
+
"tsconfig*.json",
|
|
50
|
+
"*.config.js",
|
|
51
|
+
"*.config.ts",
|
|
52
|
+
"*.config.mjs",
|
|
53
|
+
".env*",
|
|
54
|
+
"README*",
|
|
55
|
+
".pi/**",
|
|
56
|
+
".gitignore",
|
|
57
|
+
];
|
|
58
|
+
const LOCK_TIMEOUT_MS = 2000;
|
|
59
|
+
const LOCK_RETRY_MS = 25;
|
|
60
|
+
// Lease: a claim auto-expires this long after the owner last edited that subtree.
|
|
61
|
+
// Moving on to other work releases the subtree without any explicit action.
|
|
62
|
+
const LEASE_MS = 5 * 60 * 1000;
|
|
63
|
+
// When another session is actively waiting on a subtree, the owner's idle lease
|
|
64
|
+
// shortens to this. A genuinely-done owner frees fast, but only under contention;
|
|
65
|
+
// a solo owner keeps the full LEASE_MS.
|
|
66
|
+
const CONTENDED_LEASE_MS = 20 * 1000;
|
|
67
|
+
// On a blocked edit, wait this long for the owner to release/expire before giving
|
|
68
|
+
// the agent a retry-later message (transparently absorbs brief overlaps).
|
|
69
|
+
const WAIT_MS = 15 * 1000;
|
|
70
|
+
const POLL_MS = 500;
|
|
71
|
+
|
|
72
|
+
type Claim = { session: string; pid: number; dir: string; ts: number; contendedAt?: number };
|
|
73
|
+
type Registry = { claims: Claim[] };
|
|
74
|
+
|
|
75
|
+
export default function (pi: ExtensionAPI) {
|
|
76
|
+
// Stable per-process identity. pid drives liveness; session label is cosmetic.
|
|
77
|
+
const PID = process.pid;
|
|
78
|
+
let sessionLabel = `pid-${PID}`;
|
|
79
|
+
|
|
80
|
+
pi.on("session_start", (_event, ctx) => {
|
|
81
|
+
const f = ctx.sessionManager.getSessionFile();
|
|
82
|
+
sessionLabel = f ? path.basename(f).replace(/\.[^.]+$/, "") : `eph-${crypto.randomUUID().slice(0, 8)}`;
|
|
83
|
+
ensureGitignore(ctx.cwd);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Keep runtime files out of git without touching the repo-root .gitignore.
|
|
87
|
+
// Scoped to .pi/ so project-local .pi/extensions stay committable.
|
|
88
|
+
function ensureGitignore(cwd: string) {
|
|
89
|
+
try {
|
|
90
|
+
const dir = path.join(cwd, ".pi");
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
const gi = path.join(dir, ".gitignore");
|
|
93
|
+
const want = ["/ownership.json", "/ownership.lock", "/ownership.json.*.tmp"];
|
|
94
|
+
let cur = "";
|
|
95
|
+
try {
|
|
96
|
+
cur = fs.readFileSync(gi, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
/* no file yet */
|
|
99
|
+
}
|
|
100
|
+
const have = new Set(cur.split(/\r?\n/).map((l) => l.trim()));
|
|
101
|
+
const add = want.filter((w) => !have.has(w));
|
|
102
|
+
if (add.length === 0) return;
|
|
103
|
+
const out = (cur && !cur.endsWith("\n") ? cur + "\n" : cur) + add.join("\n") + "\n";
|
|
104
|
+
fs.writeFileSync(gi, out);
|
|
105
|
+
} catch {
|
|
106
|
+
/* best effort */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- registry IO (atomic, lock-guarded) ----
|
|
111
|
+
const regPath = (cwd: string) => path.join(cwd, ".pi", "ownership.json");
|
|
112
|
+
const lockPath = (cwd: string) => path.join(cwd, ".pi", "ownership.lock");
|
|
113
|
+
|
|
114
|
+
function acquireLock(cwd: string): boolean {
|
|
115
|
+
const lp = lockPath(cwd);
|
|
116
|
+
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
117
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
118
|
+
for (;;) {
|
|
119
|
+
try {
|
|
120
|
+
fs.mkdirSync(lp); // atomic: succeeds only for one writer
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
// stale lock guard: if older than timeout, steal it
|
|
124
|
+
try {
|
|
125
|
+
const age = Date.now() - fs.statSync(lp).mtimeMs;
|
|
126
|
+
if (age > LOCK_TIMEOUT_MS) {
|
|
127
|
+
fs.rmdirSync(lp);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
/* lock vanished, retry */
|
|
132
|
+
}
|
|
133
|
+
if (Date.now() > deadline) return false;
|
|
134
|
+
// busy-wait sleep
|
|
135
|
+
const until = Date.now() + LOCK_RETRY_MS;
|
|
136
|
+
while (Date.now() < until) {
|
|
137
|
+
/* spin */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function releaseLock(cwd: string) {
|
|
143
|
+
try {
|
|
144
|
+
fs.rmdirSync(lockPath(cwd));
|
|
145
|
+
} catch {
|
|
146
|
+
/* already gone */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function readReg(cwd: string): Registry {
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(fs.readFileSync(regPath(cwd), "utf8")) as Registry;
|
|
152
|
+
} catch {
|
|
153
|
+
return { claims: [] };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function writeReg(cwd: string, reg: Registry) {
|
|
157
|
+
const p = regPath(cwd);
|
|
158
|
+
const tmp = `${p}.${PID}.tmp`;
|
|
159
|
+
fs.writeFileSync(tmp, JSON.stringify(reg, null, 2));
|
|
160
|
+
fs.renameSync(tmp, p); // atomic replace
|
|
161
|
+
}
|
|
162
|
+
function alive(pid: number): boolean {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(pid, 0);
|
|
165
|
+
return true;
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
return e?.code === "EPERM"; // exists but not ours
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function sweep(reg: Registry): Registry {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
// Drop claims whose owner process is gone (crash) OR whose lease lapsed (owner moved on).
|
|
173
|
+
// A contended claim uses the short lease so a done-but-idle owner frees fast under pressure.
|
|
174
|
+
reg.claims = reg.claims.filter((c) => {
|
|
175
|
+
const lease = c.contendedAt != null ? CONTENDED_LEASE_MS : LEASE_MS;
|
|
176
|
+
return alive(c.pid) && now - c.ts < lease;
|
|
177
|
+
});
|
|
178
|
+
return reg;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// run a read-modify-write under lock; returns the callback result or null if lock failed
|
|
182
|
+
function withReg<T>(cwd: string, fn: (reg: Registry) => T): T | null {
|
|
183
|
+
if (!acquireLock(cwd)) return null;
|
|
184
|
+
try {
|
|
185
|
+
const reg = sweep(readReg(cwd));
|
|
186
|
+
const out = fn(reg);
|
|
187
|
+
writeReg(cwd, reg);
|
|
188
|
+
return out;
|
|
189
|
+
} finally {
|
|
190
|
+
releaseLock(cwd);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- path classification ----
|
|
195
|
+
function relOf(cwd: string, input: string): string | null {
|
|
196
|
+
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
|
|
197
|
+
const rel = path.relative(cwd, abs);
|
|
198
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null; // outside repo → not fenced
|
|
199
|
+
return rel.split(path.sep).join("/");
|
|
200
|
+
}
|
|
201
|
+
function claimKey(rel: string): string {
|
|
202
|
+
if (SCOPE === "file") return rel; // exact-file lock
|
|
203
|
+
const segs = rel.split("/");
|
|
204
|
+
if (segs.length <= 1) return rel; // root file → its own key (will be shared anyway)
|
|
205
|
+
return segs.slice(0, CLAIM_DEPTH).join("/");
|
|
206
|
+
}
|
|
207
|
+
function globToRe(g: string): RegExp {
|
|
208
|
+
const re = g
|
|
209
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
210
|
+
.replace(/\*\*/g, "\u0000")
|
|
211
|
+
.replace(/\*/g, "[^/]*")
|
|
212
|
+
.replace(/\u0000/g, ".*");
|
|
213
|
+
return new RegExp(`^${re}$`);
|
|
214
|
+
}
|
|
215
|
+
const sharedRes = SHARED_PATTERNS.map(globToRe);
|
|
216
|
+
function isShared(rel: string): boolean {
|
|
217
|
+
if (!rel.includes("/")) return true; // any root-level file = shared zone
|
|
218
|
+
const base = rel.split("/").pop()!;
|
|
219
|
+
return sharedRes.some((r) => r.test(rel) || r.test(base));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- the fence ----
|
|
223
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
224
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
|
|
225
|
+
const input = (event.input.path ?? event.input.file_path) as string | undefined;
|
|
226
|
+
if (!input) return undefined;
|
|
227
|
+
|
|
228
|
+
const cwd = ctx.cwd;
|
|
229
|
+
const rel = relOf(cwd, input);
|
|
230
|
+
if (rel === null) return undefined; // outside repo
|
|
231
|
+
|
|
232
|
+
const key = claimKey(rel);
|
|
233
|
+
const shared = isShared(rel);
|
|
234
|
+
|
|
235
|
+
// One attempt: claim if free / refresh own lease / report contention.
|
|
236
|
+
const tryClaim = () =>
|
|
237
|
+
withReg(cwd, (reg) => {
|
|
238
|
+
const owner = reg.claims.find((c) => c.dir === key);
|
|
239
|
+
if (owner && owner.pid !== PID) {
|
|
240
|
+
if (shared) return { kind: "warn" as const, owner };
|
|
241
|
+
owner.contendedAt = Date.now(); // someone's waiting → shorten owner's idle lease
|
|
242
|
+
return { kind: "block" as const, owner };
|
|
243
|
+
}
|
|
244
|
+
if (owner) {
|
|
245
|
+
owner.ts = Date.now(); // refresh own lease — still active here
|
|
246
|
+
delete owner.contendedAt; // active edit clears contention pressure
|
|
247
|
+
} else {
|
|
248
|
+
reg.claims.push({ session: sessionLabel, pid: PID, dir: key, ts: Date.now() });
|
|
249
|
+
}
|
|
250
|
+
return { kind: "allow" as const };
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
let result = tryClaim();
|
|
254
|
+
|
|
255
|
+
// Retry-later, handled transparently: if blocked, wait briefly for the owner to
|
|
256
|
+
// release or its lease to lapse, polling. Absorbs short overlaps without aborting.
|
|
257
|
+
if (result?.kind === "block") {
|
|
258
|
+
const deadline = Date.now() + WAIT_MS;
|
|
259
|
+
while (Date.now() < deadline) {
|
|
260
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
261
|
+
result = tryClaim();
|
|
262
|
+
if (!result || result.kind !== "block") break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (result === null) {
|
|
267
|
+
// could not lock registry — fail open, but tell the user
|
|
268
|
+
if (ctx.hasUI) ctx.ui.notify(`path-fence: registry busy, allowing ${rel}`, "warning");
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
if (result.kind === "block") {
|
|
272
|
+
return {
|
|
273
|
+
block: true,
|
|
274
|
+
reason:
|
|
275
|
+
`path-fence: ${SCOPE === "file" ? "file" : "area"} "${key}" is currently locked by another active pi session (${result.owner.session}, pid ${result.owner.pid}). ` +
|
|
276
|
+
`This lock is TEMPORARY and releases automatically when that session moves on. ` +
|
|
277
|
+
`Do not loop-retry now. Instead: ${SCOPE === "file" ? "work on OTHER files" : `work on files OUTSIDE "${key}"`} first, then RETRY this edit LATER — it will succeed once the other session is done. ` +
|
|
278
|
+
`If you have no other work, tell the user "${key}" is held by another session (they can run "/steal ${key}" to force handover; you cannot run that yourself).`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (result.kind === "warn" && ctx.hasUI) {
|
|
282
|
+
ctx.ui.notify(
|
|
283
|
+
`path-fence: ${rel} is in shared zone, also claimed by ${result.owner.session} (pid ${result.owner.pid}). Proceeding.`,
|
|
284
|
+
"warning",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return undefined;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// release this session's claims on exit
|
|
291
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
292
|
+
withReg(ctx.cwd, (reg) => {
|
|
293
|
+
reg.claims = reg.claims.filter((c) => c.pid !== PID);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ---- agent-callable release (fast handover; lease auto-expiry is the fallback) ----
|
|
298
|
+
pi.registerTool({
|
|
299
|
+
name: "release_path",
|
|
300
|
+
label: "Release path lock",
|
|
301
|
+
description:
|
|
302
|
+
"Release this session's path-fence lock(s) once you are DONE editing there, " +
|
|
303
|
+
"so other concurrent pi sessions can edit immediately. Pass the exact file path you locked (or, in dir-scope, the subtree). " +
|
|
304
|
+
"Call with no args to release all your locks. Locks also auto-expire after inactivity; calling this is faster and good hygiene when you finish.",
|
|
305
|
+
parameters: Type.Object({
|
|
306
|
+
dir: Type.Optional(
|
|
307
|
+
Type.String({ description: "Lock key to release: the file path (file-scope) or subtree (dir-scope). Omit to release all your locks." }),
|
|
308
|
+
),
|
|
309
|
+
}),
|
|
310
|
+
async execute(_id: string, params: { dir?: string }, _signal: unknown, _onUpdate: unknown, ctx: any) {
|
|
311
|
+
const dir = (params.dir ?? "").trim().replace(/\/+$/, "");
|
|
312
|
+
let freed: string[] = [];
|
|
313
|
+
withReg(ctx.cwd, (reg) => {
|
|
314
|
+
freed = reg.claims.filter((c) => c.pid === PID && (!dir || c.dir === dir)).map((c) => c.dir);
|
|
315
|
+
reg.claims = reg.claims.filter((c) => !(c.pid === PID && (!dir || c.dir === dir)));
|
|
316
|
+
});
|
|
317
|
+
const msg = freed.length ? `Released: ${freed.join(", ")}` : dir ? `No lock held on "${dir}"` : "No locks held";
|
|
318
|
+
return { content: [{ type: "text", text: msg }], details: { freed } };
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
// ---- commands ----
|
|
322
|
+
pi.registerCommand("claims", {
|
|
323
|
+
description: "List live path-ownership claims",
|
|
324
|
+
handler: async (_args, ctx) => {
|
|
325
|
+
const reg = sweep(readReg(ctx.cwd));
|
|
326
|
+
if (reg.claims.length === 0) return ctx.ui.notify("path-fence: no live claims", "info");
|
|
327
|
+
const lines = reg.claims
|
|
328
|
+
.map((c) => `${c.dir} ← ${c.session} (pid ${c.pid})${c.pid === PID ? " [you]" : ""}`)
|
|
329
|
+
.join("\n");
|
|
330
|
+
ctx.ui.notify(`Claims:\n${lines}`, "info");
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
pi.registerCommand("claim", {
|
|
335
|
+
description: "Manually claim a subtree (e.g. /claim src/api)",
|
|
336
|
+
handler: async (args, ctx) => {
|
|
337
|
+
const dir = args.trim().replace(/\/+$/, "");
|
|
338
|
+
if (!dir) return ctx.ui.notify("usage: /claim <dir>", "warning");
|
|
339
|
+
const r = withReg(ctx.cwd, (reg) => {
|
|
340
|
+
const owner = reg.claims.find((c) => c.dir === dir);
|
|
341
|
+
if (owner && owner.pid !== PID) return { taken: owner };
|
|
342
|
+
if (!owner) reg.claims.push({ session: sessionLabel, pid: PID, dir, ts: Date.now() });
|
|
343
|
+
return { taken: null };
|
|
344
|
+
});
|
|
345
|
+
if (r?.taken) ctx.ui.notify(`"${dir}" already owned by ${r.taken.session} (pid ${r.taken.pid}). Use /steal.`, "warning");
|
|
346
|
+
else ctx.ui.notify(`Claimed ${dir}`, "info");
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
pi.registerCommand("release", {
|
|
351
|
+
description: "Release your claims (/release [dir], no arg = all yours)",
|
|
352
|
+
handler: async (args, ctx) => {
|
|
353
|
+
const dir = args.trim().replace(/\/+$/, "");
|
|
354
|
+
withReg(ctx.cwd, (reg) => {
|
|
355
|
+
reg.claims = reg.claims.filter((c) => !(c.pid === PID && (!dir || c.dir === dir)));
|
|
356
|
+
});
|
|
357
|
+
ctx.ui.notify(dir ? `Released ${dir}` : "Released all your claims", "info");
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
pi.registerCommand("steal", {
|
|
362
|
+
description: "Force-reassign a claim to this session (/steal <dir>)",
|
|
363
|
+
handler: async (args, ctx) => {
|
|
364
|
+
const dir = args.trim().replace(/\/+$/, "");
|
|
365
|
+
if (!dir) return ctx.ui.notify("usage: /steal <dir>", "warning");
|
|
366
|
+
withReg(ctx.cwd, (reg) => {
|
|
367
|
+
reg.claims = reg.claims.filter((c) => c.dir !== dir);
|
|
368
|
+
reg.claims.push({ session: sessionLabel, pid: PID, dir, ts: Date.now() });
|
|
369
|
+
});
|
|
370
|
+
ctx.ui.notify(`Stole ${dir} → ${sessionLabel}`, "warning");
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-edit-fence",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Stop concurrent pi sessions from clobbering each other's edits. Per-file locks, auto-claim on edit, retry-later, lease expiry, crash recovery. No git worktrees.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"concurrency",
|
|
10
|
+
"file-lock",
|
|
11
|
+
"worktree-free"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "anh-chu",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/anh-chu/pi-edit-fence.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/anh-chu/pi-edit-fence#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/anh-chu/pi-edit-fence/issues"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"extensions",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./extensions"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"typebox": "*"
|
|
36
|
+
}
|
|
37
|
+
}
|