cli-relay 1.0.2 → 1.0.3

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/README.md CHANGED
@@ -12,8 +12,9 @@ cli-relay is designed to be called *by your own coding agent* as a tool, not onl
12
12
  hand. Every `fresh`/`resume` call returns one structured JSON object on stdout — a native
13
13
  session id, the model's answer, exit code, timing/signal info — specifically so an agent can
14
14
  parse the result and decide what to do next without guessing at free-form text. Exit codes are
15
- deliberate and stable (0 success, 1 general error, 2 usage error, 3 "ran but produced no usable
16
- answer" see Exit codes below) so an agent's own control flow can branch on them directly.
15
+ deliberate and stable (0 success, 1 general error including `doctor` when no backend at
16
+ all is usable, 2 usage error, 3 "ran but produced no usable answer" see Exit
17
+ codes below) so an agent's own control flow can branch on them directly.
17
18
 
18
19
  In practice that means: point your daily-driver agent (Claude Code, Codex, whatever you're
19
20
  already using) at this README and ask it to use `cli-relay` to delegate a task to a different
@@ -60,9 +61,16 @@ cli-relay pins <thread>
60
61
  Backends: `codex`, `agy`, `claude-code`, `command-code` (command-code is fresh-only —
61
62
  its resume showed a reproducible seed-turn bug live, see file header).
62
63
 
63
- `cli-relay doctor` checks that every backend's binary is actually resolvable on PATH —
64
+ `cli-relay doctor` checks that each backend's binary is actually resolvable on PATH —
64
65
  useful after a fresh machine setup or when a backend call fails and you're not sure whether
65
- it's cli-relay or the backend itself. `--dry-run` / `--print-command` prints the exact argv
66
+ it's cli-relay or the backend itself. It exits 1 only when *no* backend at all is usable
67
+ (zero of the built-in codex/agy/claude-code/command-code binaries resolve) — a genuinely
68
+ broken install with nothing working. A partial install (1-3 of the four present) is the
69
+ documented normal setup (see Prerequisites: "you don't need all four") and exits 0, so the
70
+ gate catches "nothing works here" without punishing "not everything installed". Custom user
71
+ adapters are reported in its output but don't affect the exit code (they're optional by
72
+ definition). `--dry-run` /
73
+ `--print-command` prints the exact argv
66
74
  that would be spawned (prompt fully assembled, pins injected) without spawning anything or
67
75
  touching the session map at all — it enforces the same refusals a real run would (e.g. won't
68
76
  preview a `resume` on an unconfirmed thread, won't preview against a thread with a run
@@ -100,8 +108,11 @@ their configured base values.
100
108
 
101
109
  Built-in adapters live in `src/adapters/` and are discovered at runtime. Additional `.mjs`,
102
110
  `.js`, or `.cjs` adapters can be placed in `~/.cli-relay/adapters/`; an adapter with the same
103
- `name` as a built-in replaces it. Each adapter provides `fresh`, optional `resume`, `env`,
104
- `parse`, optional `checkCompaction`, and optional `binaryCandidates` (an ordered list of
111
+ `name` as a built-in replaces it. Note that `.js` files load as CommonJS unless a
112
+ `~/.cli-relay/package.json` sets `"type": "module"` write ESM adapters as `.mjs` (an
113
+ ESM-syntax `.js` file fails to load and is skipped with a warning). Each adapter provides
114
+ `fresh`, optional `resume`, `env`, `parse`, optional `checkCompaction`, and optional
115
+ `binaryCandidates` (an ordered list of
105
116
  binary names `doctor` tries — most adapters only need one, but a backend that ships under
106
117
  more than one binary name can list several). Housekeeping commands are dispatched before
107
118
  adapter discovery, so they remain available if an adapter cannot be loaded. A malformed
@@ -153,9 +164,13 @@ Backs up and restores your actual `~/.cli-relay/sessions.json` around the run, s
153
164
  time. Covers: list/reset, fresh→resume context retention (agy), the circuit breaker's actual
154
165
  3-strikes trip (live-fired against codex with a bad id, not just traced), SIGINT mid-run
155
166
  cleanup, and SIGINT while genuinely pre-spawn (lock held elsewhere — must abort immediately
156
- without spawning). 32/32 passing as of the last run. Does not exercise `claude-code` (real
157
- billing per call) or `command-code` resume (disabled). Also does not yet cover `doctor` or
158
- `--dry-run`both are verified manually against the real backends whenever they change (see
167
+ without spawning). 56/56 passing as of the last run. Since the 2026-09-02 GLM-5.3 fixes the
168
+ tail sections also cover, hermetically (a fake `agy` shimmed onto PATH, direct lock-module
169
+ harnesses, and PATH-stubbed binaries no live backend needed): the reset-then-recreate
170
+ refusal, the cancelled-resume circuit-breaker behavior, lock release ownership /
171
+ exclusive holder write / pid reuse / stale reclaim, and `doctor`'s exit-code gate. Does not
172
+ exercise `claude-code` (real billing per call) or `command-code` resume (disabled);
173
+ `--dry-run` is still verified manually against the real backends whenever it changes (see
159
174
  the 2026-09-01 Review history entries below for what that's caught), not by an automated
160
175
  case in this file yet.
161
176
 
@@ -254,6 +269,45 @@ correctly flagged its own uncertainty (its sandbox blocked `git` access) rather
254
269
  asserting it, which is why it was checked rather than trusted or dismissed outright. Full
255
270
  brief in `docs/cli-continues-cherrypick-brief.md`.
256
271
 
272
+ **GLM-5.3 adversarial audit fixes (2026-09-02).** A fresh GLM-5.3 session audited the
273
+ merged result against `main` at 1.0.2 (full brief in `docs/glm53-fix-brief.md`); every
274
+ finding was re-verified against the actual source before fixing, in this project's
275
+ usual audit → fix → adversarial re-check rhythm. Three must-fix races: (1)
276
+ `reset`-then-recreate mid-run corrupted the session map — if thread A was mid-run and
277
+ someone ran `cli-relay reset t` then a quick recreate that completed first, A's critical
278
+ section 2 would find the *replacement* session and unconditionally overwrite its
279
+ `native_session_id`, cross-wiring a codex id onto an agy-backend thread (or vice versa),
280
+ or silently discarding the newer id for an older one — CS2 and the fresh-failure branch
281
+ now verify the map entry is still the *same session instance* CS1 snapshotted (backend +
282
+ pre-run native id + CS1's run-start timestamp) and warn-and-refuse on mismatch, the same
283
+ pattern the thread-deleted race already used; (2) lock release never verified the
284
+ releaser still owned the lock — a holder suspended past `LOCK_STALE_MS` (laptop sleep,
285
+ VM pause) could wake and `rm` its *successor's* lock directory, admitting a third
286
+ entrant — release now mirrors `reclaim()`'s rename-verify-restore pattern; (3) the
287
+ `mkdir` → `holder.json` write gap wasn't safely closed — a suspension longer than
288
+ `LOCK_TIMEOUT_MS` between the two calls let the original holder's plain write clobber
289
+ the successor's `holder.json`, producing two holders — the write is now an exclusive
290
+ (`'wx'`) create whose EEXIST falls back to the waiter path, with the mkdir-EEXIST and
291
+ write-EEXIST error paths split. Lower severity: a Ctrl-C'd (or timeout-killed) resume no
292
+ longer counts toward the 3-strikes circuit breaker — a cancelled run is neither success
293
+ nor failure, so the counter is left untouched (three interrupted resumes on a healthy
294
+ thread previously auto-unconfirmed it); a stale holder record bearing the *next
295
+ invocation's own reused pid* is now reclaimed instead of being waited on until the lock
296
+ times out (pid reuse by an unrelated process still self-heals via the timestamp path);
297
+ `tests/smoke.sh`'s `rm -f` on the lock directory — a silent no-op on directories — is
298
+ now `rm -rf`; `doctor` exits 1 only when no backend at all is usable (a partial install —
299
+ the README-documented normal setup — still exits 0) so it works as a CI gate; and pin
300
+ text containing the literal `[END PINNED FACTS]` or a carriage return is rejected, so a
301
+ pin can no longer forge the pinned-block boundary.
302
+ Deliberately left as-is, with reasoning: the documented `--dry-run` token-stripping
303
+ limitation (any fix changes flag-position CLI parsing behavior, and the audience is
304
+ agents, not free-text discussion of flags); `.js` user adapters still load, now
305
+ explicitly documented as CommonJS unless a `~/.cli-relay/package.json` says otherwise
306
+ (chosen over dropping `.js` support — less disruptive for existing working CommonJS
307
+ adapters); and the Windows process-group-kill gap is now a documented known gap rather
308
+ than a guess-fix. The smoke suite grew hermetic fake-backend, lock-module, and doctor
309
+ harness sections alongside the live-backend cases.
310
+
257
311
  ## Field notes from real use
258
312
 
259
313
  First real-world use (2026-08-17, an audit task run across all three model lanes in
@@ -376,6 +430,13 @@ guessing at intent, the same trap this whole project has avoided everywhere else
376
430
  finished), so the thread doesn't self-heal via lock staleness — it stays stuck until
377
431
  `LOCK_STALE_MS` (~21 min) passes on the *next* invocation against that thread. Narrow
378
432
  window, real gap; not closed here.
433
+ - **Windows process-group cleanup is POSIX-only (documented 2026-09-02, not fixed).**
434
+ `package.json` declares no `os` restriction, but the wrapper's child termination —
435
+ `process.kill(-pgid, ...)` for Ctrl-C and the spawn timeout — no-ops on Windows, so a
436
+ backend child there would survive a router-initiated kill. A real fix needs
437
+ `taskkill /T /F` and a Windows box to verify against; this repo's test, dev, and
438
+ publish history is entirely macOS/Linux, so it's flagged here rather than
439
+ guess-fixed.
379
440
  - `doctor`'s `which`/`where` child processes aren't tracked by the SIGINT handler — a Ctrl-C
380
441
  during `doctor` reports "nothing spawned yet" even though those children are briefly alive.
381
442
  Low severity (`which` exits in milliseconds).
package/cli-relay.mjs CHANGED
@@ -13,7 +13,8 @@
13
13
  *
14
14
  * Exit codes:
15
15
  * 0 success
16
- * 1 general error or backend spawn failure
16
+ * 1 general error or backend spawn failure (also `doctor`, when no backend at
17
+ * all is usable — a partial install is the normal setup and still exits 0)
17
18
  * 2 usage error
18
19
  * 3 backend produced an id/exit-0-shaped result but no usable answer
19
20
  */
@@ -200,6 +201,20 @@ function assertNotInFlight(session, thread) {
200
201
  );
201
202
  }
202
203
 
204
+ // CS1 snapshots the exact session instance it marks running. CS2 (and the fresh-failure
205
+ // branch below) must verify the map entry is still that same instance before recording
206
+ // an outcome: a `reset` mid-run followed by a recreate on the same thread name leaves a
207
+ // *different* session under that name, and recording onto it would cross-wire a native
208
+ // session id across backends (a codex id on an agy-backend thread, or vice versa) or
209
+ // silently discard the replacement's newer id (found in the GLM-5.3 audit). Backend plus
210
+ // the pre-run native id plus CS1's own run-start timestamp identify the instance; on
211
+ // mismatch, warn and refuse to record — the same pattern the thread-deleted case uses.
212
+ function isSameSessionInstance(session, record) {
213
+ return session.backend === record.backend &&
214
+ session.native_session_id === record.native_session_id &&
215
+ session.run_started_iso === record.run_started_iso;
216
+ }
217
+
203
218
  async function main() {
204
219
  const cliArgs = process.argv.slice(2);
205
220
 
@@ -210,8 +225,8 @@ async function main() {
210
225
  const { loadAdapters } = await import('./src/adapter-loader.mjs');
211
226
  const adapters = await loadAdapters();
212
227
  if (cliArgs[0] === 'doctor') {
213
- await cmdDoctor(adapters);
214
- process.exit(0);
228
+ const anyBackendUsable = await cmdDoctor(adapters);
229
+ process.exit(anyBackendUsable ? 0 : 1);
215
230
  }
216
231
 
217
232
  const dryRun = cliArgs.includes('--dry-run') || cliArgs.includes('--print-command');
@@ -331,6 +346,17 @@ async function main() {
331
346
  );
332
347
  return;
333
348
  }
349
+ if (!isSameSessionInstance(session, record)) {
350
+ // Reset-then-recreate mid-run: the map entry is now a different session
351
+ // instance (possibly on a different backend). Don't cross-wire this run's
352
+ // outcome onto it.
353
+ console.error(
354
+ `warning: thread "${thread}" in ${MAP_PATH} is no longer the session this run ` +
355
+ `started from (it was reset or replaced while this run was in flight) — ` +
356
+ 'outcome not recorded.',
357
+ );
358
+ return;
359
+ }
334
360
  session.status = 'ready';
335
361
  session.last_run_iso = new Date().toISOString();
336
362
  session.last_exit_code = code;
@@ -369,22 +395,40 @@ async function main() {
369
395
  );
370
396
  return;
371
397
  }
398
+ if (!isSameSessionInstance(session, record)) {
399
+ // Reset-then-recreate mid-run: the map entry is now a different session
400
+ // instance (possibly on a different backend). Don't cross-wire this run's
401
+ // native id onto it or discard its newer id in favor of this run's.
402
+ console.error(
403
+ `warning: thread "${thread}" in ${MAP_PATH} is no longer the session this run ` +
404
+ `started from (it was reset or replaced while this run was in flight) — outcome` +
405
+ `${newId ? ` (including native id ${newId})` : ''} not recorded.`,
406
+ );
407
+ return;
408
+ }
372
409
  if (newId) {
373
410
  session.native_session_id = newId;
374
411
  session.confirmed = true;
375
412
  session.consecutive_resume_failures = 0;
376
413
  }
377
414
  if (mode === 'resume') {
378
- if (parsed.answer) {
379
- session.consecutive_resume_failures = 0;
380
- } else {
381
- session.consecutive_resume_failures = (session.consecutive_resume_failures ?? 0) + 1;
382
- if (session.confirmed &&
383
- session.consecutive_resume_failures >= RESUME_FAILURE_THRESHOLD) {
384
- session.confirmed = false;
385
- autoUnconfirmed = true;
415
+ if (!cancelled) {
416
+ if (parsed.answer) {
417
+ session.consecutive_resume_failures = 0;
418
+ } else {
419
+ session.consecutive_resume_failures = (session.consecutive_resume_failures ?? 0) + 1;
420
+ if (session.confirmed &&
421
+ session.consecutive_resume_failures >= RESUME_FAILURE_THRESHOLD) {
422
+ session.confirmed = false;
423
+ autoUnconfirmed = true;
424
+ }
386
425
  }
387
426
  }
427
+ // A run the wrapper cancelled (Ctrl-C, SIGTERM, or the spawn timeout) says
428
+ // nothing about the backend's resume health — neither success nor failure.
429
+ // The failure counter is deliberately left untouched: three interrupted
430
+ // resumes on a perfectly healthy thread must not auto-unconfirm it (found
431
+ // in the GLM-5.3 audit).
388
432
  resumeFailureCount = session.consecutive_resume_failures;
389
433
  }
390
434
  session.status = 'ready';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-relay",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Persistent CLI router for resuming named sessions across AI coding-agent backends.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { EXPECTED_BACKENDS } from '../adapter-loader.mjs';
3
+
4
+ const REQUIRED_BACKEND_SET = new Set(EXPECTED_BACKENDS);
2
5
 
3
6
  export function realWhichCheck(binary) {
4
7
  return new Promise((resolve) => {
@@ -36,9 +39,11 @@ export async function cmdDoctor(adapters, isAvailable = realWhichCheck) {
36
39
  return resolved;
37
40
  }));
38
41
 
42
+ let requiredAvailable = 0;
39
43
  for (let index = 0; index < results.length; index += 1) {
40
44
  const result = results[index];
41
45
  const check = checks[index];
46
+ const isRequired = REQUIRED_BACKEND_SET.has(check.adapter.name);
42
47
  const tried = check.attempted.join(', ') || 'none';
43
48
  if (result.status === 'rejected') {
44
49
  console.log(
@@ -52,9 +57,17 @@ export async function cmdDoctor(adapters, isAvailable = realWhichCheck) {
52
57
  console.log(
53
58
  `${check.adapter.name}: available (found; tried: ${tried}; resolved: ${resolved})`,
54
59
  );
60
+ if (isRequired) requiredAvailable += 1;
55
61
  } else {
56
62
  const hint = check.adapter.installHint ? ` — install: ${check.adapter.installHint}` : '';
57
63
  console.log(`${check.adapter.name}: unavailable (not found; tried: ${tried})${hint}`);
58
64
  }
59
65
  }
66
+
67
+ // doctor doubles as a machine-setup gate: the caller (main) exits 1 only when NO backend
68
+ // at all is usable — a genuinely broken install with nothing working. A partial install
69
+ // (1-3 of the four built-in backends present) is the documented normal setup (README,
70
+ // Prerequisites: "you don't need all four") and passes the gate. Custom user adapters are
71
+ // reported in the output above but never affect it — they're optional by definition.
72
+ return requiredAvailable > 0;
60
73
  }
package/src/core/lock.mjs CHANGED
@@ -75,49 +75,129 @@ function reclaim(expectedHolderRaw) {
75
75
 
76
76
  export async function withLock(fn) {
77
77
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
78
+ let myHolderRaw = null;
78
79
  for (;;) {
79
80
  try {
80
81
  mkdirSync(LOCK_PATH);
81
- writeFileSync(
82
- join(LOCK_PATH, LOCK_HOLDER_FILE),
83
- JSON.stringify({ pid: process.pid, ts: Date.now() }),
84
- );
85
- break;
86
82
  } catch (error) {
87
83
  if (error.code !== 'EEXIST') throw error;
88
- const holderRaw = readHolderRaw();
89
- let eligible = false;
90
- if (holderRaw !== null) {
91
- try {
92
- const holder = JSON.parse(holderRaw);
93
- eligible = Date.now() - holder.ts > LOCK_STALE_MS || !isPidAlive(holder.pid);
94
- } catch {
95
- eligible = true; // holder.json exists but isn't valid JSON — not a state any live holder writes
96
- }
97
- } else {
98
- // Missing holder.json while the lock dir exists: either mid-write (a real holder's
99
- // mkdir+writeFileSync are back-to-back synchronous, resolving in microseconds) or
100
- // the holder died in that exact window and never will. Use the lock directory's own
101
- // mtime — an objective fact any process can check on the same physical directory —
102
- // rather than this waiter's own elapsed wait time, which can't tell "the original
103
- // holder died" apart from "a brand-new holder's mkdir just landed."
104
- try {
105
- eligible = Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_TIMEOUT_MS;
106
- } catch {
107
- eligible = false; // the directory itself vanished already — nothing here to reclaim
108
- }
109
- }
110
- if (eligible && reclaim(holderRaw)) continue;
111
- if (Date.now() > deadline) {
112
- throw new Error(`another cli-relay invocation holds the lock (${LOCK_PATH}) — not waiting forever`);
113
- }
114
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
84
+ // Someone else's lock directory exists — evaluate it as a waiter below.
85
+ await waitForLockSlot(deadline);
86
+ continue;
115
87
  }
88
+
89
+ // The directory is ours and empty. Write holder.json with an EXCLUSIVE create
90
+ // ('wx'): if a waiter reclaimed this still-empty directory while we were
91
+ // suspended between mkdir and this write (a suspension longer than
92
+ // LOCK_TIMEOUT_MS — laptop sleep, VM pause), the successor's holder.json
93
+ // already exists and a plain write would silently clobber it — two holders at
94
+ // once (found in the GLM-5.3 audit). EEXIST throws loudly instead, and the
95
+ // loser falls back to the waiter path below: the successor is the rightful
96
+ // holder now.
97
+ myHolderRaw = JSON.stringify({ pid: process.pid, ts: Date.now() });
98
+ try {
99
+ writeFileSync(join(LOCK_PATH, LOCK_HOLDER_FILE), myHolderRaw, { flag: 'wx' });
100
+ } catch (error) {
101
+ if (error.code !== 'EEXIST') throw error;
102
+ // Our just-created directory was reclaimed mid-gap and now holds a
103
+ // successor's holder.json — evaluate the lock as a waiter, same as the
104
+ // mkdir-EEXIST case, but for a distinct reason (we briefly held the
105
+ // directory; the EEXIST came from the write, not the mkdir).
106
+ await waitForLockSlot(deadline);
107
+ continue;
108
+ }
109
+ break;
116
110
  }
117
111
 
118
112
  try {
119
113
  return await fn();
120
114
  } finally {
121
- try { rmSync(LOCK_PATH, { recursive: true, force: true }); } catch {}
115
+ releaseLock(myHolderRaw);
116
+ }
117
+ }
118
+
119
+ // The waiter half of acquisition, shared by both EEXIST paths above: the lock
120
+ // directory exists but isn't ours — either created by someone else, or created by
121
+ // us and then legitimately reclaimed out from under our suspended holder.json
122
+ // write. Judge the current holder, reclaim it if eligible, and otherwise sleep
123
+ // until the deadline.
124
+ async function waitForLockSlot(deadline) {
125
+ const holderRaw = readHolderRaw();
126
+ let eligible = false;
127
+ if (holderRaw !== null) {
128
+ try {
129
+ const holder = JSON.parse(holderRaw);
130
+ // A holder record bearing this process's own pid cannot be a lock we hold:
131
+ // this path only runs before this call acquires, and an earlier withLock in
132
+ // this process has always released first (cli-relay never overlaps them). It
133
+ // is either an abandoned leftover of our own failed release or — the case
134
+ // that matters — a dead holder whose pid was reused by this very invocation,
135
+ // which would otherwise see *itself* as a live holder and fail spuriously
136
+ // after LOCK_TIMEOUT_MS (found in the GLM-5.3 audit; the mirror-image
137
+ // false-positive, pid reuse by an unrelated process, still self-heals via
138
+ // the timestamp path above).
139
+ eligible = Date.now() - holder.ts > LOCK_STALE_MS ||
140
+ !isPidAlive(holder.pid) ||
141
+ holder.pid === process.pid;
142
+ } catch {
143
+ eligible = true; // holder.json exists but isn't valid JSON — not a state any live holder writes
144
+ }
145
+ } else {
146
+ // Missing holder.json while the lock dir exists: either mid-write (a real
147
+ // holder's mkdir+writeFileSync are back-to-back synchronous, resolving in
148
+ // microseconds, and the exclusive write now fails loudly rather than
149
+ // clobbering) or the holder died in that exact window and never will. Use the
150
+ // lock directory's own mtime — an objective fact any process can check on the
151
+ // same physical directory — rather than this waiter's own elapsed wait time,
152
+ // which can't tell "the original holder died" apart from "a brand-new
153
+ // holder's mkdir just landed."
154
+ try {
155
+ eligible = Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_TIMEOUT_MS;
156
+ } catch {
157
+ eligible = false; // the directory itself vanished already — nothing here to reclaim
158
+ }
159
+ }
160
+ if (eligible && reclaim(holderRaw)) return;
161
+ if (Date.now() > deadline) {
162
+ throw new Error(`another cli-relay invocation holds the lock (${LOCK_PATH}) — not waiting forever`);
163
+ }
164
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
165
+ }
166
+
167
+ // Releasing by unconditionally rmSync-ing LOCK_PATH never verifies the releaser
168
+ // still owns the lock. If a holder is suspended past LOCK_STALE_MS (laptop sleep,
169
+ // VM pause, or a clock adjustment that makes its timestamp look stale), a waiter
170
+ // legitimately reclaims and becomes the new holder; when the original holder wakes
171
+ // and finishes, an unconditional delete destroys its successor's lock and admits a
172
+ // third entrant (found in the GLM-5.3 audit). Mirror reclaim()'s own pattern:
173
+ // rename to a private path first (atomic — at most one renamer of a given source
174
+ // path can win), verify the captured holder.json is still this process's own
175
+ // write, and only then rm; if it isn't ours, put it back for the rightful holder
176
+ // rather than destroying it.
177
+ function releaseLock(myHolderRaw) {
178
+ const claim = `${LOCK_PATH}.release.${process.pid}.${Date.now()}`;
179
+ try {
180
+ renameSync(LOCK_PATH, claim);
181
+ } catch {
182
+ return; // nothing at LOCK_PATH (or a real error) — nothing to either verify or destroy
183
+ }
184
+ let capturedHolderRaw;
185
+ try {
186
+ capturedHolderRaw = readFileSync(join(claim, LOCK_HOLDER_FILE), 'utf8');
187
+ } catch {
188
+ capturedHolderRaw = null;
189
+ }
190
+ if (capturedHolderRaw === myHolderRaw) {
191
+ rmSync(claim, { recursive: true, force: true });
192
+ } else {
193
+ // Not our write anymore — a waiter reclaimed while we were suspended and this
194
+ // is its lock. Restore it; if LOCK_PATH was recreated in the meantime by yet
195
+ // another party there is nowhere safe to put it back — drop it rather than
196
+ // loop forever, the same compounding-race reasoning as reclaim().
197
+ try {
198
+ renameSync(claim, LOCK_PATH);
199
+ } catch {
200
+ rmSync(claim, { recursive: true, force: true });
201
+ }
122
202
  }
123
203
  }
package/src/core/pins.mjs CHANGED
@@ -2,9 +2,13 @@ import { MAX_PIN_LENGTH } from '../config.mjs';
2
2
 
3
3
  export function validatePinText(factText) {
4
4
  if (!factText || !factText.trim()) return 'fact text must not be empty or whitespace-only';
5
- if (factText.includes('\n')) {
5
+ if (factText.includes('\n') || factText.includes('\r')) {
6
6
  return 'fact text must be a single line — multiline facts risk breaking the pinned-block delimiter';
7
7
  }
8
+ if (factText.includes('[END PINNED FACTS]')) {
9
+ return 'fact text must not contain the literal "[END PINNED FACTS]" — a pin must not ' +
10
+ 'be able to forge the pinned-block boundary (found in the GLM-5.3 audit)';
11
+ }
8
12
  if (factText.length > MAX_PIN_LENGTH) {
9
13
  return `fact text is ${factText.length} chars, over the ${MAX_PIN_LENGTH}-char limit — ` +
10
14
  'keep pins short and curated, not a transcript';