wezard 1.2.7 → 1.2.8
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/cli/wezard.sh +59 -20
- package/dist/daemon/inbound.js +3 -1
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +4 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +66 -27
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/hooks/pre-tool-use.sh +8 -3
- package/package.json +1 -1
|
@@ -92,7 +92,7 @@ const readCwdFromJsonl = (path) => {
|
|
|
92
92
|
// that merely happens to be newer in the same directory would hand the chat to
|
|
93
93
|
// a session it can neither resume (`claude --resume` wouldn't find the sid) nor
|
|
94
94
|
// parse (wrong jsonl dialect). Only genuine discovery searches the union.
|
|
95
|
-
const rankedJsonlsForCwd = (cwd, only) => projectDirsFor(expandHome(cwd))
|
|
95
|
+
const rankedJsonlsForCwd = (cwd, only, exclude) => projectDirsFor(expandHome(cwd))
|
|
96
96
|
.filter(({ backend }) => !only || backend.name === only.name)
|
|
97
97
|
.flatMap(({ dir }) => {
|
|
98
98
|
let names;
|
|
@@ -103,9 +103,12 @@ const rankedJsonlsForCwd = (cwd, only) => projectDirsFor(expandHome(cwd))
|
|
|
103
103
|
return [];
|
|
104
104
|
}
|
|
105
105
|
return names.flatMap((n) => {
|
|
106
|
+
const sid = n.replace(/\.jsonl$/, "");
|
|
107
|
+
if (exclude?.has(sid))
|
|
108
|
+
return []; // already bound to another chat/peer — never steal it
|
|
106
109
|
const p = join(dir, n);
|
|
107
110
|
try {
|
|
108
|
-
return [{ sessionId:
|
|
111
|
+
return [{ sessionId: sid, jsonlPath: p, mtime: statSync(p).mtimeMs }];
|
|
109
112
|
}
|
|
110
113
|
catch {
|
|
111
114
|
return [];
|
|
@@ -117,8 +120,8 @@ const rankedJsonlsForCwd = (cwd, only) => projectDirsFor(expandHome(cwd))
|
|
|
117
120
|
// `/new`) rotation always leaves a fresh jsonl here, so this is how the mirror
|
|
118
121
|
// re-finds the live session after a rotation it didn't itself record — used by
|
|
119
122
|
// resolveSession's auto-pick and by restoreFromStore's heal path.
|
|
120
|
-
const latestJsonlForCwd = (cwd, only) => {
|
|
121
|
-
const top = rankedJsonlsForCwd(cwd, only)[0];
|
|
123
|
+
const latestJsonlForCwd = (cwd, only, exclude) => {
|
|
124
|
+
const top = rankedJsonlsForCwd(cwd, only, exclude)[0];
|
|
122
125
|
return top ? { sessionId: top.sessionId, jsonlPath: top.jsonlPath } : undefined;
|
|
123
126
|
};
|
|
124
127
|
// Locate a transcript by sessionId across every sibling project dir of every
|
|
@@ -216,8 +219,8 @@ const findInjectOffset = (path, fp) => {
|
|
|
216
219
|
// the real session. Reliable for worktree dirs (one session lives there);
|
|
217
220
|
// restore + the drift follower only consult it on cross-dir moves, where that
|
|
218
221
|
// assumption holds.
|
|
219
|
-
const liveSessionForCwd = (cwd, only) => {
|
|
220
|
-
for (const c of rankedJsonlsForCwd(cwd, only)) {
|
|
222
|
+
const liveSessionForCwd = (cwd, only, exclude) => {
|
|
223
|
+
for (const c of rankedJsonlsForCwd(cwd, only, exclude)) {
|
|
221
224
|
if (jsonlHasUserLine(c.jsonlPath))
|
|
222
225
|
return { sessionId: c.sessionId, jsonlPath: c.jsonlPath };
|
|
223
226
|
}
|
|
@@ -2314,11 +2317,41 @@ export const startMirror = (deps) => {
|
|
|
2314
2317
|
// Re-attach a stored binding for `principal`. Returns the resulting state, or
|
|
2315
2318
|
// undefined if the on-disk transcript is gone (in which case the entry is
|
|
2316
2319
|
// dropped so the next inbound flows through /new auto-spawn).
|
|
2320
|
+
// Every sessionId already bound to a DIFFERENT principal (live attachment or
|
|
2321
|
+
// persisted store). The cwd-newest heal must never hand one of these to
|
|
2322
|
+
// another peer: a chat's siblings all share one cwd, so "newest jsonl in cwd"
|
|
2323
|
+
// is a different peer's live transcript, not this one's rotation. Healing onto
|
|
2324
|
+
// it collapses N distinct peers onto one sessionId (attach() then dedupes by
|
|
2325
|
+
// sid and silently detaches all-but-one). Excluding claimed sids keeps each
|
|
2326
|
+
// peer on its own session.
|
|
2327
|
+
const sidsClaimedByOthers = (principal) => {
|
|
2328
|
+
const s = new Set();
|
|
2329
|
+
for (const [k, v] of Object.entries(deps.store.all()))
|
|
2330
|
+
if (k !== principal && v.sessionId)
|
|
2331
|
+
s.add(v.sessionId);
|
|
2332
|
+
for (const [k, a] of byTarget)
|
|
2333
|
+
if (k !== principal && a.sessionId)
|
|
2334
|
+
s.add(a.sessionId);
|
|
2335
|
+
return s;
|
|
2336
|
+
};
|
|
2317
2337
|
const restoreFromStore = async (principal) => {
|
|
2318
2338
|
const rec = deps.store.get(principal);
|
|
2319
2339
|
if (!rec)
|
|
2320
2340
|
return undefined;
|
|
2321
2341
|
let jsonlAbs = expandHome(rec.jsonlPath);
|
|
2342
|
+
// Prefer the stored pane id and validate via display-message. Pane ids
|
|
2343
|
+
// (`%N`) are monotonic per tmux server lifetime, so they're stable across
|
|
2344
|
+
// daemon reloads as long as the tmux server didn't restart. With the
|
|
2345
|
+
// shared `wezard` session hosting many chats, listing panes by session
|
|
2346
|
+
// name would mis-route to whichever window happens to be first — never
|
|
2347
|
+
// do that. If the stored pane is dead, leave tmuxPane empty and let
|
|
2348
|
+
// dispatch's respawn check reincarnate via `claude --resume <sid>`.
|
|
2349
|
+
// Resolved BEFORE the heal below: a live pane authoritatively owns
|
|
2350
|
+
// `rec.sessionId` (spawned as `--session-id <uuid>`), so a not-yet-written
|
|
2351
|
+
// jsonl means "no input yet", NOT "rotated away" — healing it onto some
|
|
2352
|
+
// unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
|
|
2353
|
+
const storedPane = (rec.tmuxPane ?? "").trim();
|
|
2354
|
+
const livePane = storedPane && (await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
2322
2355
|
if (!existsSync(jsonlAbs)) {
|
|
2323
2356
|
// First: the SAME-sid transcript may have merely relocated to a sibling
|
|
2324
2357
|
// project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
|
|
@@ -2334,35 +2367,34 @@ export const startMirror = (deps) => {
|
|
|
2334
2367
|
jsonlAbs = relocated;
|
|
2335
2368
|
deps.store.set(principal, rec);
|
|
2336
2369
|
}
|
|
2370
|
+
else if (livePane) {
|
|
2371
|
+
// Live pane, no transcript yet: a freshly-spawned peer that hasn't
|
|
2372
|
+
// received its first turn. The pane owns `rec.sessionId` and will write
|
|
2373
|
+
// `<sid>.jsonl` on first input; keep spawn-mode (the tail tolerates a
|
|
2374
|
+
// missing file). Do NOT heal — that is what made every idle sibling in a
|
|
2375
|
+
// shared cwd collapse onto the same newest jsonl.
|
|
2376
|
+
log.info({ principal, sessionId: rec.sessionId, pane: livePane }, "mirror restore: live pane, transcript not written yet — keeping spawn-mode binding");
|
|
2377
|
+
}
|
|
2337
2378
|
else {
|
|
2338
|
-
//
|
|
2339
|
-
// the daemon
|
|
2340
|
-
//
|
|
2341
|
-
// project dir
|
|
2342
|
-
//
|
|
2343
|
-
//
|
|
2344
|
-
const healed = latestJsonlForCwd(rec.cwd || cfg.wrc.cwd, owner);
|
|
2379
|
+
// Pane dead AND transcript gone. Either the session rotated (`/clear` /
|
|
2380
|
+
// native `/new` the daemon missed, leaving a newer jsonl) or the peer was
|
|
2381
|
+
// spawned-then-killed before writing anything. Heal onto the newest jsonl
|
|
2382
|
+
// in the project dir — but skip any sid already bound to another peer, so
|
|
2383
|
+
// a dead node can't steal a live sibling's transcript. Fail-closed drop
|
|
2384
|
+
// when nothing unclaimed remains.
|
|
2385
|
+
const healed = latestJsonlForCwd(rec.cwd || cfg.wrc.cwd, owner, sidsClaimedByOthers(principal));
|
|
2345
2386
|
if (!healed) {
|
|
2346
|
-
log.warn({ principal, jsonlPath: rec.jsonlPath }, "mirror restore: jsonl missing and no sibling, dropping entry");
|
|
2387
|
+
log.warn({ principal, jsonlPath: rec.jsonlPath }, "mirror restore: jsonl missing and no unclaimed sibling, dropping entry");
|
|
2347
2388
|
deps.store.drop(principal);
|
|
2348
2389
|
return undefined;
|
|
2349
2390
|
}
|
|
2350
|
-
log.warn({ principal, from: rec.sessionId, to: healed.sessionId }, "mirror restore: recorded jsonl gone, healed to latest in project dir");
|
|
2391
|
+
log.warn({ principal, from: rec.sessionId, to: healed.sessionId }, "mirror restore: recorded jsonl gone, healed to latest unclaimed in project dir");
|
|
2351
2392
|
rec.sessionId = healed.sessionId;
|
|
2352
2393
|
rec.jsonlPath = healed.jsonlPath;
|
|
2353
2394
|
jsonlAbs = healed.jsonlPath;
|
|
2354
2395
|
deps.store.set(principal, rec);
|
|
2355
2396
|
}
|
|
2356
2397
|
}
|
|
2357
|
-
// Prefer the stored pane id and validate via display-message. Pane ids
|
|
2358
|
-
// (`%N`) are monotonic per tmux server lifetime, so they're stable across
|
|
2359
|
-
// daemon reloads as long as the tmux server didn't restart. With the
|
|
2360
|
-
// shared `wezard` session hosting many chats, listing panes by session
|
|
2361
|
-
// name would mis-route to whichever window happens to be first — never
|
|
2362
|
-
// do that. If the stored pane is dead, leave tmuxPane empty and let
|
|
2363
|
-
// dispatch's respawn check reincarnate via `claude --resume <sid>`.
|
|
2364
|
-
const storedPane = (rec.tmuxPane ?? "").trim();
|
|
2365
|
-
const livePane = storedPane && (await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
2366
2398
|
// Pane-cwd worktree re-home: the stored jsonl can EXIST yet be stale because
|
|
2367
2399
|
// the live pane entered/selected a git worktree, switching it to a DIFFERENT
|
|
2368
2400
|
// sessionId in a sibling project dir. Trust the pane's real cwd — if its
|
|
@@ -2380,7 +2412,7 @@ export const startMirror = (deps) => {
|
|
|
2380
2412
|
// session belonging to a non-primary CLI.
|
|
2381
2413
|
const paneDir = projectDirFor(jsonlAbs, expandHome(paneCwd));
|
|
2382
2414
|
if (paneDir !== dirname(jsonlAbs)) {
|
|
2383
|
-
const live = liveSessionForCwd(paneCwd, backendForPath(jsonlAbs));
|
|
2415
|
+
const live = liveSessionForCwd(paneCwd, backendForPath(jsonlAbs), sidsClaimedByOthers(principal));
|
|
2384
2416
|
if (live && live.sessionId !== rec.sessionId) {
|
|
2385
2417
|
log.info({ principal, from: rec.sessionId, to: live.sessionId, paneCwd }, "mirror restore: pane in worktree, re-homed to live session");
|
|
2386
2418
|
rec.sessionId = live.sessionId;
|
|
@@ -2631,9 +2663,16 @@ export const startMirror = (deps) => {
|
|
|
2631
2663
|
return;
|
|
2632
2664
|
}
|
|
2633
2665
|
const current = listJsonls(projectDir);
|
|
2666
|
+
// Exclude sids already bound to OTHER targets: when several panes in one
|
|
2667
|
+
// cwd fork/spawn concurrently (a graph's #t3/#sp/base all resuming), each
|
|
2668
|
+
// watcher sees ALL the new jsonls, not just its own pane's. Without this a
|
|
2669
|
+
// resume-fork watcher grabs a sibling's freshly-written transcript (newest
|
|
2670
|
+
// + has content) and two tags collapse onto one sessionId. The claimed set
|
|
2671
|
+
// pins each watcher off its siblings' sessions.
|
|
2672
|
+
const claimed = sidsClaimedByOthers(a.target);
|
|
2634
2673
|
const candidates = [];
|
|
2635
2674
|
for (const name of current)
|
|
2636
|
-
if (!baseline.has(name))
|
|
2675
|
+
if (!baseline.has(name) && !claimed.has(name.replace(/\.jsonl$/, "")))
|
|
2637
2676
|
candidates.push(name);
|
|
2638
2677
|
// Pick newest-mtime candidate that has user content. Older candidates
|
|
2639
2678
|
// without content stay in the running until they accrue — never aborts.
|
|
@@ -2820,7 +2859,7 @@ export const startMirror = (deps) => {
|
|
|
2820
2859
|
const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
|
|
2821
2860
|
if (paneDir === dirname(a.jsonlPath))
|
|
2822
2861
|
continue; // same project dir — no drift
|
|
2823
|
-
const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath));
|
|
2862
|
+
const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
|
|
2824
2863
|
if (!live || live.sessionId === a.sessionId)
|
|
2825
2864
|
continue; // empty dir, or same-sid rename (tail follows it)
|
|
2826
2865
|
a.runningCwd = expandHome(cwd); // migrateAttachment persists this to store
|