wezard 1.2.7 → 1.2.9

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.
@@ -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: n.replace(/\.jsonl$/, ""), jsonlPath: p, mtime: statSync(p).mtimeMs }];
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
- // Recorded session rotated out from under us a `/clear` or native `/new`
2339
- // the daemon didn't record (e.g. it restarted while the /clear migration
2340
- // watcher was still open). The rotation left a newer jsonl in the same
2341
- // project dir; heal onto it instead of dropping the binding, else the
2342
- // chat silently loses all further responses. Fail-closed drop only when
2343
- // the project dir is truly empty.
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
@@ -2958,6 +2997,14 @@ export const startMirror = (deps) => {
2958
2997
  // 只有换过 pane 才是断点 —— 首次 /wrc 建会话时 prev 为空, 那不是"不连续", 是开局。
2959
2998
  if (prev)
2960
2999
  spawned.ctxCut = "new";
3000
+ // A freshly spawned session is empty — nothing in the cache to keep warm.
3001
+ // Pause keepalive like /stop; the first real turn (WeCom inbound, or the
3002
+ // pane going busy after the resume grace) re-earns the budget. Mirrors
3003
+ // interruptPane so /new can't strand a ping loop on an idle blank session.
3004
+ spawned.keepaliveOff = true;
3005
+ spawned.keepaliveOffAt = Date.now();
3006
+ spawned.keepalive = undefined; // re-anchors cleanly on resume
3007
+ persistPause(spawned);
2961
3008
  }
2962
3009
  // Clear the chat's pendingCwd on the BASE record too — the queued switch
2963
3010
  // has just been consumed by this respawn. Without this, a subsequent /new
@@ -3601,15 +3648,24 @@ export const startMirror = (deps) => {
3601
3648
  }
3602
3649
  return;
3603
3650
  }
3604
- // A real inbound is a new conversation lift any `/stop` keepalive pause
3605
- // and re-anchor both clocks to now (this turn is real work). Pane-side new
3606
- // turns re-anchor via the tick's stamps; this covers the WeCom-driven path.
3607
- a.keepaliveOff = false;
3608
- if (a.keepalive) {
3609
- a.keepalive.lastMs = Date.now();
3610
- a.keepalive.lastRealMs = Date.now();
3651
+ // `/clear` resets to an empty session nothing in the cache to keep warm,
3652
+ // so pause keepalive like `/stop` instead of lifting it. Every other inbound
3653
+ // is real work: lift any prior pause and re-anchor both clocks to now.
3654
+ // Pane-side new turns re-anchor via the tick's stamps; this covers the
3655
+ // WeCom-driven path.
3656
+ if (isClearCommand(text)) {
3657
+ a.keepaliveOff = true;
3658
+ a.keepaliveOffAt = Date.now();
3659
+ a.keepalive = undefined; // re-anchors cleanly on resume
3660
+ }
3661
+ else {
3662
+ a.keepaliveOff = false;
3663
+ if (a.keepalive) {
3664
+ a.keepalive.lastMs = Date.now();
3665
+ a.keepalive.lastRealMs = Date.now();
3666
+ }
3611
3667
  }
3612
- persistPause(a); // clear the persisted pause too, else a reload would re-pause
3668
+ persistPause(a); // persist the pause/resume too, else a reload would revert it
3613
3669
  // Finalize prior live stream (if any) so this new turn renders into its
3614
3670
  // own message bubble. Then open a fresh stream tied to the new frame and
3615
3671
  // ack immediately so WeCom doesn't time out while inject queues.