@hayasaka7/haya-pet 0.3.7 → 0.3.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/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ All notable changes to HAYA Pet are documented here. This project adheres to
7
7
  > 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
8
8
  > ships them.
9
9
 
10
+ ## [0.3.8]
11
+
12
+ ### Fixed
13
+ - **An interrupt or denial in one Claude Code session no longer leaks into a
14
+ concurrent, idle one.** The transcript watcher discovered its file by "newest
15
+ `.jsonl` by mtime in the project dir", so two Claude sessions in the same folder
16
+ (one project dir, one transcript each) could make an idle session's watcher lock
17
+ onto a *busy* session's transcript — then read its `[Request interrupted by
18
+ user]` marker (or a denial) and report the wrong pet as *interrupted*. Each
19
+ session's watcher now pins to its own transcript via the `transcript_path` Claude
20
+ includes in every hook payload (recorded as a per-session link by the `haya-pet
21
+ state` reporter) instead of guessing; until that link exists it idles rather than
22
+ locking onto another session's file. Codex shares the same discovery shape and is
23
+ tracked as a known issue.
24
+ - **The pet no longer disappears when the display layout changes.** The overlay
25
+ window's bounds were set once at creation to span one display's work area and
26
+ never re-homed, so unplugging a monitor, changing resolution/DPI, docking or
27
+ undocking, or waking from sleep could strand it off-screen (or on a display that
28
+ no longer exists) — the pet vanished while the process kept running, and neither
29
+ **Show/Hide Pet** (which only flips visibility) nor **Reset Position** (which only
30
+ moved the sprite *inside* the window) brought it back. The companion now re-homes
31
+ the overlay onto a valid display on `screen` display add/remove/metrics-change and
32
+ on resume from sleep, and **Reset Position** / **Show Pet** / relaunch re-home the
33
+ window itself. Automatic re-homes preserve the preferred display, so the pet
34
+ returns there when the monitor comes back.
35
+
10
36
  ## [0.3.7]
11
37
 
12
38
  ### Changed
@@ -5,7 +5,8 @@ import { randomUUID } from "node:crypto";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
8
- import { parseStateArgs, runStateCommand } from "../../../packages/cli-core/src/run-state.js";
8
+ import { parseStateArgs, runStateCommand, readHookTranscriptPathFromStdin } from "../../../packages/cli-core/src/run-state.js";
9
+ import { removeSessionTranscriptLink } from "../../../packages/cli-core/src/session-transcript-link.js";
9
10
  import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
10
11
  import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
11
12
  import { watchClaudeTranscript as defaultWatchClaudeTranscript } from "../../../packages/cli-core/src/claude-transcript-watcher.js";
@@ -353,6 +354,12 @@ async function runRunCommand(parsed, dependencies) {
353
354
  childEnv = { ...env, HAYA_PET_SESSION_ID: sessionId };
354
355
  cleanup = injected.cleanup;
355
356
 
357
+ // The transcript watcher pins to THIS session's transcript via the
358
+ // session->transcript link the `haya-pet state` reporter writes from the hook
359
+ // payload. sessionDir is where that link lives; resolve it defensively so a
360
+ // missing home dir (tests) never breaks the run.
361
+ const sessionDir = resolveSessionDir(dependencies, env);
362
+
356
363
  // Claude fires NO hook when the user manually denies a permission, so the
357
364
  // pet would stay stuck on "waiting for approval". Tail the session transcript
358
365
  // (ground truth) and clear to idle the moment a denial is recorded — never on
@@ -361,6 +368,8 @@ async function runRunCommand(parsed, dependencies) {
361
368
  cwd,
362
369
  homeDir: dependencies.homeDir,
363
370
  startedAt: now(),
371
+ sessionId,
372
+ sessionDir,
364
373
  onDenial: (event) => {
365
374
  hookDebugLog(env, now, { source: "transcript", event: "denied", state: "idle", toolUseId: event?.toolUseId });
366
375
  messageSender
@@ -392,7 +401,10 @@ async function runRunCommand(parsed, dependencies) {
392
401
  .catch(() => {});
393
402
  }
394
403
  });
395
- stopWatcher = watcher.stop;
404
+ stopWatcher = () => {
405
+ watcher.stop();
406
+ removeSessionTranscriptLink({ sessionDir, sessionId });
407
+ };
396
408
  }
397
409
 
398
410
  // Codex: no `--settings` equivalent, so inject a stable profile and add
@@ -624,6 +636,21 @@ function createConfigStateFile(dependencies) {
624
636
  return createStateFile({ statePath: paths.statePath });
625
637
  }
626
638
 
639
+ // Where per-session transcript links live. Resolved defensively: if no home dir
640
+ // is available (some tests), return undefined and the watcher falls back to its
641
+ // legacy discovery instead of crashing the run.
642
+ function resolveSessionDir(dependencies, env) {
643
+ try {
644
+ return getDefaultPaths({
645
+ platform: dependencies.platform,
646
+ env,
647
+ homeDir: dependencies.homeDir
648
+ }).sessionDir;
649
+ } catch {
650
+ return undefined;
651
+ }
652
+ }
653
+
627
654
  // Best-effort: mirror the reporter's HAYA_PET_HOOK_DEBUG log so transcript-driven
628
655
  // events (which don't go through `haya-pet state`) show up in the same trace.
629
656
  function hookDebugLog(env, now, entry) {
@@ -917,7 +944,7 @@ async function noopSend() {}
917
944
  async function noopClose() {}
918
945
 
919
946
  if (isDirectRun(import.meta.url, process.argv[1])) {
920
- main()
947
+ bootstrap()
921
948
  .catch((error) => {
922
949
  console.error(error.message);
923
950
  process.exitCode = 1;
@@ -930,6 +957,28 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
930
957
  });
931
958
  }
932
959
 
960
+ // Real-process entry. For a `haya-pet state` invocation — which is ALWAYS a client
961
+ // hook child — read the hook payload from stdin once to learn this session's real
962
+ // transcript path, and hand it to the reporter so it can record the
963
+ // session->transcript link. Done here (not inside main/runStateCommand) so unit
964
+ // tests, and every other command that needs stdin passed through to its child
965
+ // (e.g. `run`), never touch stdin.
966
+ async function bootstrap() {
967
+ const argv = process.argv.slice(2);
968
+ const dependencies = {};
969
+ if (argv[0] === "state") {
970
+ try {
971
+ const transcriptPath = await readHookTranscriptPathFromStdin();
972
+ if (transcriptPath) {
973
+ dependencies.transcriptPath = transcriptPath;
974
+ }
975
+ } catch {
976
+ // a missing/garbled payload just means no binding this time — never fatal
977
+ }
978
+ }
979
+ return main(argv, dependencies);
980
+ }
981
+
933
982
  function isDirectRun(moduleUrl, scriptPath) {
934
983
  if (!scriptPath) {
935
984
  return false;
@@ -22,6 +22,41 @@ export function resolveSavedPosition(savedPosition, displays, size = DEFAULT_SIZ
22
22
  };
23
23
  }
24
24
 
25
+ // Resolve where the overlay window should sit RIGHT NOW: which display's work
26
+ // area it spans, plus where the pet sprite sits inside it (work-area-relative).
27
+ // Shared by initial placement and by re-homing after a display change (monitor
28
+ // unplugged, resolution/DPI change, dock/undock, sleep→resume). Re-homing is what
29
+ // stops the overlay from being stranded off-screen on a display that no longer
30
+ // exists — the bug where the pet "vanishes" but the process is alive and neither
31
+ // Show/Hide nor Reset (which only move the sprite inside the window) recover it.
32
+ export function resolveOverlayPlacement({ savedPosition, displays, petSize = DEFAULT_SIZE } = {}) {
33
+ const saved = resolveSavedPosition(savedPosition, displays, petSize);
34
+ // saved.displayId always names a display that currently exists (resolveSavedPosition
35
+ // falls back to primary/first when the saved one is gone), so this re-homes a
36
+ // stranded position onto a valid display.
37
+ const display = findDisplayForSavedPosition({ displayId: saved.displayId }, displays);
38
+ const workArea = display.workArea ?? display.bounds;
39
+ const petLocal = clampLocalToWorkArea(
40
+ { x: saved.x - workArea.x, y: saved.y - workArea.y },
41
+ workArea,
42
+ petSize
43
+ );
44
+
45
+ return { workArea, displayId: display.id, petLocal };
46
+ }
47
+
48
+ // Keep a pet's work-area-relative position inside the work area for a given pet
49
+ // size. Pure mirror of the companion's in-window clamp, so placement resolution
50
+ // can be unit-tested without Electron.
51
+ export function clampLocalToWorkArea(local, workArea, petSize = DEFAULT_SIZE) {
52
+ const maxX = Math.max(0, (workArea?.width ?? petSize.width) - petSize.width);
53
+ const maxY = Math.max(0, (workArea?.height ?? petSize.height) - petSize.height);
54
+ return {
55
+ x: clamp(local?.x ?? 0, 0, maxX),
56
+ y: clamp(local?.y ?? 0, 0, maxY)
57
+ };
58
+ }
59
+
25
60
  export function clampWindowBounds(bounds, display) {
26
61
  const workArea = display.workArea ?? display.bounds;
27
62
  const width = bounds.width ?? DEFAULT_SIZE.width;
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron";
1
+ import { app, BrowserWindow, ipcMain, Menu, nativeImage, powerMonitor, screen, shell, Tray } from "electron";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { appendFileSync, readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
@@ -14,7 +14,7 @@ import { getPlatformCapabilities } from "../../../../packages/platform-core/src/
14
14
  import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
15
15
  import { clampScale } from "../../../../packages/pet-core/src/pet-scale.js";
16
16
  import { buildPetWindowOptions, PET_SIZE } from "./window-options.js";
17
- import { resolveSavedPosition } from "./display-manager.js";
17
+ import { clampLocalToWorkArea, resolveOverlayPlacement } from "./display-manager.js";
18
18
  import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
19
19
  import { buildTrayMenu, buildTrayTooltip } from "./tray-menu.js";
20
20
  import { createStateFile } from "./state-file.js";
@@ -151,6 +151,7 @@ async function bootstrap() {
151
151
  createPetWindow();
152
152
  createTray();
153
153
  registerRendererHandlers();
154
+ registerDisplayWatchers();
154
155
  // Best-effort and cached in state.json (shared with the CLI's check, so at
155
156
  // most one registry request per day between them); never blocks startup.
156
157
  void detectUpdate();
@@ -171,16 +172,9 @@ async function bootstrap() {
171
172
  }
172
173
 
173
174
  function createPetWindow() {
174
- const displays = listDisplays();
175
- // Resolve the pet's saved on-screen position (pet-sized), which also tells us
176
- // which display it belongs on. The window then spans that display's work area.
177
- const saved = resolveSavedPosition(positionState.globalPet, displays);
178
- const display = displays.find((d) => d.id === saved.displayId)
179
- ?? displays.find((d) => d.primary)
180
- ?? displays[0];
181
- currentWorkArea = display.workArea ?? display.bounds;
182
- currentDisplayId = display.id;
183
- petLocal = clampPetLocal({ x: saved.x - currentWorkArea.x, y: saved.y - currentWorkArea.y });
175
+ // Resolve which display the pet belongs on and where it sits inside that
176
+ // display's work area; the window then spans that work area.
177
+ applyOverlayPlacement(resolveCurrentPlacement());
184
178
 
185
179
  const { browserWindow } = buildPetWindowOptions({ capabilities, bounds: currentWorkArea });
186
180
 
@@ -214,13 +208,69 @@ function scaledPetSize() {
214
208
  }
215
209
 
216
210
  function clampPetLocal(local) {
211
+ return clampLocalToWorkArea(local ?? petLocal, currentWorkArea, scaledPetSize());
212
+ }
213
+
214
+ // Resolve where the overlay should sit right now, given the saved position and the
215
+ // CURRENT set of displays. Used at startup and on every re-home.
216
+ function resolveCurrentPlacement() {
217
+ return resolveOverlayPlacement({
218
+ savedPosition: positionState.globalPet,
219
+ displays: listDisplays(),
220
+ petSize: scaledPetSize()
221
+ });
222
+ }
223
+
224
+ // Adopt a resolved placement into the module's window/pet state (does NOT move the
225
+ // BrowserWindow — callers decide whether to create or setBounds).
226
+ function applyOverlayPlacement(placement) {
227
+ currentWorkArea = placement.workArea;
228
+ currentDisplayId = placement.displayId;
229
+ petLocal = placement.petLocal;
230
+ }
231
+
232
+ // Re-home the overlay onto a currently-valid display and re-assert its bounds.
233
+ // A display change (monitor unplugged, resolution/DPI change, dock/undock,
234
+ // sleep→resume) can strand the window off-screen on a display that no longer
235
+ // exists: the pet "vanishes" while the process is alive, and neither Show/Hide
236
+ // nor Reset (which only move the sprite INSIDE the window) bring it back. This is
237
+ // the one operation that puts the window itself back on screen.
238
+ function rehomeOverlay({ recenter = false, persist = true } = {}) {
239
+ if (!petWindow || petWindow.isDestroyed()) {
240
+ return;
241
+ }
242
+
243
+ applyOverlayPlacement(resolveCurrentPlacement());
244
+ if (recenter) {
245
+ petLocal = cornerPetLocal();
246
+ }
247
+
248
+ try {
249
+ petWindow.setBounds(currentWorkArea);
250
+ } catch (error) {
251
+ // Setting bounds must never crash the overlay; the next display event retries.
252
+ console.error("overlay re-home failed:", error.message);
253
+ }
254
+ if (!petWindow.isVisible()) {
255
+ petWindow.show();
256
+ }
257
+ sendPetPosition();
258
+ // Automatic re-homes (display change / resume) deliberately do NOT persist, so
259
+ // the user's preferred display is preserved and the pet returns there once that
260
+ // display comes back. User-initiated re-homes (reset/show) persist as usual.
261
+ if (persist) {
262
+ persistPetPosition();
263
+ }
264
+ }
265
+
266
+ // Bottom-right corner of the current work area (the Reset target).
267
+ function cornerPetLocal() {
268
+ const margin = 24;
217
269
  const size = scaledPetSize();
218
- const maxX = Math.max(0, (currentWorkArea?.width ?? size.width) - size.width);
219
- const maxY = Math.max(0, (currentWorkArea?.height ?? size.height) - size.height);
220
- return {
221
- x: Math.min(Math.max(local.x ?? 0, 0), maxX),
222
- y: Math.min(Math.max(local.y ?? 0, 0), maxY)
223
- };
270
+ return clampPetLocal({
271
+ x: (currentWorkArea?.width ?? size.width) - size.width - margin,
272
+ y: (currentWorkArea?.height ?? size.height) - size.height - margin
273
+ });
224
274
  }
225
275
 
226
276
  function createTray() {
@@ -348,6 +398,22 @@ function handleTrayClick(item) {
348
398
  }
349
399
  }
350
400
 
401
+ // The overlay window is positioned once at creation; without these it would never
402
+ // react to the display layout changing under it. Re-home on any display add/remove/
403
+ // metrics change, and on resume from sleep (which commonly fires a metrics change
404
+ // and, on Windows, can blank a transparent surface — re-asserting bounds repaints).
405
+ function registerDisplayWatchers() {
406
+ const onDisplayChange = () => rehomeOverlay({ persist: false });
407
+ screen.on("display-metrics-changed", onDisplayChange);
408
+ screen.on("display-added", onDisplayChange);
409
+ screen.on("display-removed", onDisplayChange);
410
+ try {
411
+ powerMonitor.on("resume", onDisplayChange);
412
+ } catch {
413
+ // powerMonitor is unavailable on some platforms/headless; never fatal.
414
+ }
415
+ }
416
+
351
417
  function registerRendererHandlers() {
352
418
  ipcMain.handle("haya-pet:list-sessions", () => buildSessionPayload());
353
419
 
@@ -441,24 +507,27 @@ function togglePet() {
441
507
  if (!petWindow) {
442
508
  return;
443
509
  }
444
- petWindow.isVisible() ? petWindow.hide() : petWindow.show();
510
+ if (petWindow.isVisible()) {
511
+ petWindow.hide();
512
+ } else {
513
+ // Re-home on show: a stranded (off-screen) window still reports isVisible()
514
+ // === true, so the user's first click hides it and this second click must put
515
+ // it back on a valid display, not just show it at the same bad bounds.
516
+ rehomeOverlay();
517
+ }
445
518
  refreshTrayMenu();
446
519
  }
447
520
 
448
521
  function focusPet() {
449
- petWindow?.show();
522
+ // Relaunching `haya-pet` (second-instance) is also a "bring it back" gesture, so
523
+ // re-home rather than show at possibly-stale bounds.
524
+ rehomeOverlay();
450
525
  }
451
526
 
452
527
  function resetPosition() {
453
- // Drop the pet back to the bottom-right corner of its work area.
454
- const margin = 24;
455
- const size = scaledPetSize();
456
- petLocal = clampPetLocal({
457
- x: (currentWorkArea?.width ?? size.width) - size.width - margin,
458
- y: (currentWorkArea?.height ?? size.height) - size.height - margin
459
- });
460
- sendPetPosition();
461
- persistPetPosition();
528
+ // Re-home onto a valid display FIRST (a stale display layout is exactly when
529
+ // users reach for Reset), then drop the pet to the bottom-right corner.
530
+ rehomeOverlay({ recenter: true });
462
531
  }
463
532
 
464
533
  function setDisplayMode(displayMode) {
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "../../../test/harness.mjs";
3
- import { clampWindowBounds, resolveSavedPosition } from "../src/main/display-manager.js";
3
+ import {
4
+ clampLocalToWorkArea,
5
+ clampWindowBounds,
6
+ resolveOverlayPlacement,
7
+ resolveSavedPosition
8
+ } from "../src/main/display-manager.js";
4
9
 
5
10
  const displays = [
6
11
  {
@@ -46,3 +51,48 @@ test("uses primary display when no saved position exists", () => {
46
51
  { x: 416, y: 144, width: 384, height: 416, displayId: "primary", scaleFactor: 1.25 }
47
52
  );
48
53
  });
54
+
55
+ test("resolveOverlayPlacement spans the saved display and places the sprite inside it", () => {
56
+ assert.deepEqual(
57
+ resolveOverlayPlacement({
58
+ savedPosition: { x: 900, y: 120, width: 192, height: 208, displayId: "secondary" },
59
+ displays,
60
+ petSize: { width: 192, height: 208 }
61
+ }),
62
+ {
63
+ workArea: { x: 820, y: 20, width: 1160, height: 840 },
64
+ displayId: "secondary",
65
+ petLocal: { x: 80, y: 100 } // 900-820, 120-20
66
+ }
67
+ );
68
+ });
69
+
70
+ test("resolveOverlayPlacement re-homes a stranded position onto a valid display", () => {
71
+ // The pet was last on a display that is now gone (monitor unplugged). It must
72
+ // come back on the primary display, fully inside its work area — this is the
73
+ // recovery the disappear-and-can't-restore bug needed.
74
+ assert.deepEqual(
75
+ resolveOverlayPlacement({
76
+ savedPosition: { x: 4000, y: 4000, width: 192, height: 208, displayId: "gone" },
77
+ displays,
78
+ petSize: { width: 192, height: 208 }
79
+ }),
80
+ {
81
+ workArea: { x: 0, y: 0, width: 800, height: 560 },
82
+ displayId: "primary",
83
+ petLocal: { x: 608, y: 352 } // clamped to primary work area (800-192, 560-208)
84
+ }
85
+ );
86
+ });
87
+
88
+ test("clampLocalToWorkArea keeps the sprite within the work area for its size", () => {
89
+ const workArea = { x: 0, y: 0, width: 800, height: 560 };
90
+ assert.deepEqual(
91
+ clampLocalToWorkArea({ x: 5000, y: 5000 }, workArea, { width: 192, height: 208 }),
92
+ { x: 608, y: 352 }
93
+ );
94
+ assert.deepEqual(
95
+ clampLocalToWorkArea({ x: -50, y: -50 }, workArea, { width: 192, height: 208 }),
96
+ { x: 0, y: 0 }
97
+ );
98
+ });
@@ -2,6 +2,107 @@
2
2
 
3
3
  Issues found in live use, with their current status.
4
4
 
5
+ ## 🔲 Open: cross-session status contamination on Codex
6
+
7
+ - **Symptom (same class as the resolved Claude entry below):** interrupting one
8
+ Codex session can flip a **different, concurrent** Codex session's pet to
9
+ *interrupted* (and more generally mirror another session's tool/working
10
+ states). Most likely when two Codex sessions run in the **same folder** and one
11
+ is busy while the other is idle. Not yet fixed.
12
+ - **Root cause:** `discoverCodexTranscript` (`codex-transcript-watcher.js`) picks
13
+ the rollout by **newest `.jsonl` by mtime**, filtered only by
14
+ `session_meta.cwd` / freshness — it does **not** bind to a specific session, so
15
+ an idle session's watcher can lock onto a busy session's rollout and then read
16
+ that session's `turn_aborted` (Codex's interrupt signal) as its own. The rollout
17
+ *does* identify itself (`session_meta.payload.id` = the Codex thread id, plus a
18
+ unique per-session filename), but (1) we never learn **which** thread id belongs
19
+ to the session our wrapper launched — the Codex hooks pass only
20
+ `HAYA_PET_SESSION_ID` via env and the reporter currently **discards Codex's hook
21
+ stdin** — and (2) the watcher matches on mtime+cwd, not on that id. The
22
+ `isFreshSession` branch even admits recently-started rollouts from **other
23
+ cwds**, so the exposure is slightly *wider* than Claude's (which is scoped to one
24
+ project dir). This is the residual same-session-folder case the earlier
25
+ "Codex pet looked busy immediately after startup" fix narrowed but did not
26
+ eliminate.
27
+ - **Plan (handle later):** port the Claude binding to Codex. Verify against the
28
+ live Codex CLI whether the hook **stdin payload** carries the rollout path or
29
+ the thread id (`session_id`); if so, have the `haya-pet state` reporter record a
30
+ per-session session→rollout link (reusing `session-transcript-link.js`) and pin
31
+ the watcher to it — or, failing that, match `session_meta.payload.id` once we
32
+ can learn our session's id. Fall back to current behavior when no identifier is
33
+ available. No timer, consistent with the rest of the status model.
34
+ - **Status:** unfixed. The binding fix shipped this session is **Claude-only** and
35
+ does not touch the Codex watcher or the guardian-review watcher (which shares the
36
+ same discovery shape and should be checked alongside it).
37
+
38
+ ## ✅ Resolved: Claude interrupt/denial leaked into a concurrent idle session
39
+
40
+ - **Symptom:** With Claude Code hooks enabled, interrupting (Esc) one Claude
41
+ session could also flip a **different, idle** Claude session's pet to
42
+ *interrupted* (and mirror its working states). Intermittent — most visible when
43
+ the two ran in the **same folder** and one was busy while the other sat idle.
44
+ - **Root cause:** the L3 transcript watcher had **no binding to a specific
45
+ session's transcript**. It discovered the file by "newest `.jsonl` by mtime in
46
+ the project dir" (`claude-transcript-watcher.js` `discoverTranscript`). Two
47
+ Claude sessions in one folder share a project dir
48
+ (`~/.claude/projects/<sanitized-cwd>/`), each with its own UUID file, so an idle
49
+ session's watcher could lock onto a **busy** session's transcript and then read
50
+ that session's `[Request interrupted by user]` marker (or a denial) and report
51
+ it for itself. `HAYA_PET_SESSION_ID` identified the session to the daemon, but
52
+ nothing tied the watched **file** to the session.
53
+ - **Fix:** bind each watcher to its own transcript via the **`transcript_path`
54
+ Claude includes in every hook payload** (ground truth). The `haya-pet state`
55
+ reporter — already a hook child that knows `HAYA_PET_SESSION_ID` — reads the hook
56
+ payload from stdin (only in the real process entry, never in tests/other
57
+ commands) and records a per-session **session→transcript link**
58
+ (`packages/cli-core/src/session-transcript-link.js`, stored under
59
+ `…/haya-pet/sessions/<id>.json`). The watcher pins to that exact file instead of
60
+ guessing; until the link exists it simply idles (nothing to interrupt yet)
61
+ rather than locking onto another session's file. Newest-by-mtime remains only as
62
+ a fallback for the no-session case (never hit in production, where the watcher
63
+ only runs with hooks on). The link is removed on wrapper exit. Local-only and
64
+ best-effort; **no timer** is involved.
65
+ - **Tests:** `session-transcript-link.test.mjs` (write/read round-trip + per-session
66
+ isolation) and a `claude-transcript-watcher.test.mjs` case proving an interrupt
67
+ in session A is **not** reported for idle session B, plus a case that the watcher
68
+ idles until its link appears.
69
+ - **How to diagnose if it recurs:** with `HAYA_PET_HOOK_DEBUG=<path>` set, the
70
+ transcript-sourced `interrupted` line is logged with its `sessionId`; if it
71
+ appears under a session that was idle, the binding (the
72
+ `…/haya-pet/sessions/<id>.json` link) resolved to the wrong file.
73
+
74
+ ## ✅ Resolved: pet disappeared (and could not be restored) after a display change
75
+
76
+ - **Symptom:** the pet sometimes vanished from the screen while the companion was
77
+ still running — and once gone, **Show/Hide Pet** and **Reset Position** both
78
+ failed to bring it back. Intermittent.
79
+ - **Root cause:** the overlay is a single full-work-area `BrowserWindow` whose
80
+ bounds are computed **once**, at creation, for whichever display it resolved to
81
+ then. The companion subscribed to **no** `screen` events and never called
82
+ `setBounds` again, so a display-layout change underneath it — monitor unplugged,
83
+ resolution/DPI change, dock/undock, or sleep→resume — left the window at
84
+ coordinates that were now **off-screen or on a display that no longer exists**.
85
+ The window stayed alive and `isVisible() === true`; it was just painting where no
86
+ monitor covered. The two recovery actions failed for the same reason: *Show/Hide*
87
+ only flips `isVisible()` (an off-screen window is already "visible", so it
88
+ toggled between hidden and shown-at-the-same-bad-bounds), and *Reset Position*
89
+ only moved the **sprite's CSS position inside** the overlay (against a stale work
90
+ area), never the window's bounds.
91
+ - **Fix:** the companion now re-homes the overlay onto a currently-valid display.
92
+ It listens for `screen` `display-metrics-changed` / `display-added` /
93
+ `display-removed` and `powerMonitor` `resume`, re-resolving the target display
94
+ and calling `setBounds` (decision logic in the pure, tested
95
+ `display-manager.js` `resolveOverlayPlacement`). **Reset Position**, **Show
96
+ Pet**, and relaunch now re-home the window itself, not just the sprite.
97
+ Automatic re-homes do **not** persist the position, so the user's preferred
98
+ display is remembered and the pet returns there when that monitor comes back. No
99
+ timer is involved — every re-home is driven by a real display/power event or a
100
+ user action.
101
+ - **Known residual (Windows):** a transparent surface can still occasionally go
102
+ blank after resume even with correct bounds (an Electron compositor issue);
103
+ re-asserting bounds repaints it in the common case, and a hide/show repaint nudge
104
+ is the fallback if it recurs.
105
+
5
106
  ## ✅ Resolved: Codex interrupt sometimes left the pet "working"
6
107
 
7
108
  - **Symptom:** Pressing Esc to interrupt a Codex turn occasionally does **not**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -15,6 +15,7 @@ import {
15
15
  } from "node:fs";
16
16
  import { basename, join } from "node:path";
17
17
  import { parseTranscriptLines } from "../../adapters/src/claude-transcript.js";
18
+ import { readSessionTranscriptLink } from "./session-transcript-link.js";
18
19
 
19
20
  const DEFAULT_POLL_MS = 700;
20
21
 
@@ -37,6 +38,8 @@ export function watchClaudeTranscript(options = {}) {
37
38
  onInterrupt = () => {},
38
39
  pollIntervalMs = DEFAULT_POLL_MS,
39
40
  projectsRoot,
41
+ sessionId,
42
+ sessionDir,
40
43
  transcriptPath: fixedPath,
41
44
  setInterval: setIntervalFn = setInterval,
42
45
  clearInterval: clearIntervalFn = clearInterval
@@ -47,6 +50,13 @@ export function watchClaudeTranscript(options = {}) {
47
50
  // before Claude has created THIS session's transcript.
48
51
  const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
49
52
 
53
+ // Preferred resolution: pin to the exact transcript this session's hook reported
54
+ // (via the session->transcript link). Only when no session identity is available
55
+ // (e.g. hooks off, or older tests) do we fall back to the newest-by-mtime guess —
56
+ // which is unsafe with multiple concurrent sessions in one folder. In production
57
+ // the watcher only runs with hooks on, so the link path is always used.
58
+ const useLink = Boolean(sessionId && sessionDir);
59
+
50
60
  let transcriptPath = fixedPath;
51
61
  let offset = 0;
52
62
  let carry = "";
@@ -54,7 +64,9 @@ export function watchClaudeTranscript(options = {}) {
54
64
  const tick = () => {
55
65
  try {
56
66
  if (!transcriptPath) {
57
- transcriptPath = discoverTranscript(root, cwd, minMtime);
67
+ transcriptPath = useLink
68
+ ? resolveLinkedTranscript(sessionDir, sessionId)
69
+ : discoverTranscript(root, cwd, minMtime);
58
70
  if (!transcriptPath) {
59
71
  return;
60
72
  }
@@ -106,6 +118,18 @@ export function watchClaudeTranscript(options = {}) {
106
118
  };
107
119
  }
108
120
 
121
+ // Resolve the transcript this session's hook bound itself to. Returns undefined
122
+ // until the link exists and points at a real file, so before the first hook fires
123
+ // the watcher simply idles (there is nothing to interrupt yet) rather than
124
+ // guessing a file that might belong to another session.
125
+ function resolveLinkedTranscript(sessionDir, sessionId) {
126
+ const linked = readSessionTranscriptLink({ sessionDir, sessionId });
127
+ if (!linked || !existsSync(linked)) {
128
+ return undefined;
129
+ }
130
+ return linked;
131
+ }
132
+
109
133
  export function discoverTranscript(root, cwd, minMtime = 0) {
110
134
  if (!root || !existsSync(root)) {
111
135
  return undefined;
@@ -6,6 +6,7 @@ import { createIpcClient as defaultCreateIpcClient } from "../../daemon-core/src
6
6
  import { getDefaultPaths } from "../../platform-core/src/paths.js";
7
7
  import { isAiClientState } from "../../protocol/src/messages.js";
8
8
  import { DEADLINE, raceDeadline } from "./deadline.js";
9
+ import { writeSessionTranscriptLink } from "./session-transcript-link.js";
9
10
 
10
11
  // Hard ceiling on the whole connect→send→close interaction. The reporter is a
11
12
  // child process of the wrapped AI client, and the client may wait for its hook
@@ -69,6 +70,13 @@ export async function runStateCommand(parsed, dependencies = {}) {
69
70
  return { command: "state", ok: false, reason: "invalid-state" };
70
71
  }
71
72
 
73
+ // Record this session's real transcript path (ground truth from the Claude hook
74
+ // payload, captured at the process entry and passed in via dependencies) so the
75
+ // wrapper's watcher tails THIS session's file rather than the newest in the
76
+ // project dir — which can bind it to a concurrent session and leak that
77
+ // session's interrupt/denial. Best-effort and synchronous; never blocks the hook.
78
+ recordTranscriptLink(sessionId, env, dependencies);
79
+
72
80
  const createIpcClient = dependencies.createIpcClient ?? defaultCreateIpcClient;
73
81
  const deadlineMs = dependencies.reportDeadlineMs ?? REPORT_DEADLINE_MS;
74
82
 
@@ -105,3 +113,105 @@ export async function runStateCommand(parsed, dependencies = {}) {
105
113
  return { command: "state", ok: false, reason: "no-daemon" };
106
114
  }
107
115
  }
116
+
117
+ // Persist the session -> transcript binding for the wrapper's watcher. The
118
+ // transcript path is supplied by the caller (read from the hook payload at the
119
+ // process entry); when it's absent we simply skip — there is nothing to bind and
120
+ // guessing is exactly the bug this avoids.
121
+ function recordTranscriptLink(sessionId, env, dependencies) {
122
+ const transcriptPath = dependencies.transcriptPath;
123
+ if (typeof transcriptPath !== "string" || transcriptPath === "") {
124
+ return;
125
+ }
126
+ try {
127
+ const sessionDir =
128
+ dependencies.sessionDir ??
129
+ getDefaultPaths({
130
+ platform: dependencies.platform,
131
+ env,
132
+ homeDir: dependencies.homeDir
133
+ }).sessionDir;
134
+ writeSessionTranscriptLink({ sessionDir, sessionId, transcriptPath }, dependencies);
135
+ } catch {
136
+ // never break a hook over a best-effort binding write
137
+ }
138
+ }
139
+
140
+ // Pull `transcript_path` out of a Claude hook payload (JSON on stdin). Pure and
141
+ // defensive: any non-JSON, missing-field, or wrong-type input yields undefined.
142
+ export function extractTranscriptPath(raw) {
143
+ if (typeof raw !== "string" || raw.trim() === "") {
144
+ return undefined;
145
+ }
146
+ try {
147
+ const parsed = JSON.parse(raw);
148
+ const value = parsed?.transcript_path;
149
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ // Read the Claude hook payload from stdin and return its transcript_path. Used by
156
+ // the real `haya-pet state` process (a Claude hook child) — NOT by internal
157
+ // callers, so tests and other commands never touch stdin. Bounded and best-effort:
158
+ // a TTY (manual invocation) or a slow/absent payload resolves to undefined rather
159
+ // than ever hanging the host client's hook.
160
+ export function readHookTranscriptPathFromStdin(options = {}) {
161
+ const stdin = options.stdin ?? process.stdin;
162
+ const timeoutMs = options.timeoutMs ?? 400;
163
+ const maxBytes = options.maxBytes ?? 1_000_000;
164
+
165
+ return new Promise((resolve) => {
166
+ if (!stdin || stdin.isTTY) {
167
+ resolve(undefined);
168
+ return;
169
+ }
170
+
171
+ let data = "";
172
+ let settled = false;
173
+ let timer;
174
+
175
+ const finish = (value) => {
176
+ if (settled) {
177
+ return;
178
+ }
179
+ settled = true;
180
+ if (timer) {
181
+ clearTimeout(timer);
182
+ }
183
+ try {
184
+ stdin.removeListener("data", onData);
185
+ stdin.removeListener("end", onEnd);
186
+ stdin.removeListener("error", onError);
187
+ stdin.pause();
188
+ } catch {
189
+ // detaching is best-effort
190
+ }
191
+ resolve(value);
192
+ };
193
+
194
+ const onData = (chunk) => {
195
+ data += chunk;
196
+ if (data.length > maxBytes) {
197
+ finish(extractTranscriptPath(data));
198
+ }
199
+ };
200
+ const onEnd = () => finish(extractTranscriptPath(data));
201
+ const onError = () => finish(undefined);
202
+
203
+ try {
204
+ stdin.setEncoding("utf8");
205
+ stdin.on("data", onData);
206
+ stdin.on("end", onEnd);
207
+ stdin.on("error", onError);
208
+ stdin.resume();
209
+ timer = setTimeout(() => finish(extractTranscriptPath(data)), timeoutMs);
210
+ if (timer && typeof timer.unref === "function") {
211
+ timer.unref();
212
+ }
213
+ } catch {
214
+ finish(undefined);
215
+ }
216
+ });
217
+ }
@@ -0,0 +1,72 @@
1
+ // Records which transcript file belongs to which haya-pet session, so a wrapper's
2
+ // transcript watcher can tail ITS OWN session's transcript instead of guessing
3
+ // "the newest .jsonl in the project dir".
4
+ //
5
+ // Why this exists: two Claude Code sessions running in the same folder share one
6
+ // project dir (~/.claude/projects/<sanitized-cwd>/), each with its own UUID file.
7
+ // Picking newest-by-mtime can bind a watcher to a DIFFERENT concurrent session's
8
+ // transcript — so an interrupt (or denial) in session A leaks onto idle session B.
9
+ // The only ground-truth source of a session's transcript path is the Claude hook
10
+ // payload (`transcript_path` on stdin), which the `haya-pet state` reporter sees.
11
+ // The reporter writes the path here (keyed by HAYA_PET_SESSION_ID); the wrapper's
12
+ // watcher reads it to pin to the exact file.
13
+ //
14
+ // Local-only and best-effort: every operation swallows errors so it can never
15
+ // break a hook or the wrapped command, and nothing here is ever sent off-device.
16
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ // Session ids are our own (`sess_<uuid>`), but sanitize defensively so the value
20
+ // can never escape the sessions dir or produce an invalid filename.
21
+ function sanitizeSessionId(sessionId) {
22
+ return String(sessionId).replace(/[^a-zA-Z0-9._-]/g, "_");
23
+ }
24
+
25
+ export function sessionLinkPath(sessionDir, sessionId) {
26
+ return join(sessionDir, `${sanitizeSessionId(sessionId)}.json`);
27
+ }
28
+
29
+ export function writeSessionTranscriptLink(options = {}, dependencies = {}) {
30
+ const { sessionDir, sessionId, transcriptPath } = options;
31
+ if (!sessionDir || !sessionId || typeof transcriptPath !== "string" || transcriptPath === "") {
32
+ return false;
33
+ }
34
+ const mkdir = dependencies.mkdirSync ?? mkdirSync;
35
+ const write = dependencies.writeFileSync ?? writeFileSync;
36
+ try {
37
+ mkdir(sessionDir, { recursive: true });
38
+ write(sessionLinkPath(sessionDir, sessionId), JSON.stringify({ transcriptPath }), "utf8");
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export function readSessionTranscriptLink(options = {}, dependencies = {}) {
46
+ const { sessionDir, sessionId } = options;
47
+ if (!sessionDir || !sessionId) {
48
+ return undefined;
49
+ }
50
+ const read = dependencies.readFileSync ?? readFileSync;
51
+ try {
52
+ const parsed = JSON.parse(read(sessionLinkPath(sessionDir, sessionId), "utf8"));
53
+ return typeof parsed?.transcriptPath === "string" && parsed.transcriptPath !== ""
54
+ ? parsed.transcriptPath
55
+ : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ export function removeSessionTranscriptLink(options = {}, dependencies = {}) {
62
+ const { sessionDir, sessionId } = options;
63
+ if (!sessionDir || !sessionId) {
64
+ return;
65
+ }
66
+ const rm = dependencies.rmSync ?? rmSync;
67
+ try {
68
+ rm(sessionLinkPath(sessionDir, sessionId), { force: true });
69
+ } catch {
70
+ // best-effort cleanup; a stale link is harmless (session ids are unique per run)
71
+ }
72
+ }
@@ -8,6 +8,7 @@ import {
8
8
  discoverTranscript,
9
9
  watchClaudeTranscript
10
10
  } from "../src/claude-transcript-watcher.js";
11
+ import { writeSessionTranscriptLink } from "../src/session-transcript-link.js";
11
12
 
12
13
  const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
13
14
 
@@ -128,6 +129,78 @@ test("watchClaudeTranscript handles a line split across two appends", () => {
128
129
  watcher.stop();
129
130
  });
130
131
 
132
+ test("watchClaudeTranscript pins to the session's linked transcript, not newest-by-mtime", () => {
133
+ // Two concurrent sessions share one project dir. Each has its own link.
134
+ const projectDir = mkdtempSync(join(tmpdir(), "proj-"));
135
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
136
+ const fileA = join(projectDir, "a.jsonl");
137
+ const fileB = join(projectDir, "b.jsonl");
138
+ writeFileSync(fileA, "");
139
+ writeFileSync(fileB, "");
140
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: fileA });
141
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: fileB });
142
+
143
+ const interruptsA = [];
144
+ const interruptsB = [];
145
+ const watcherA = watchClaudeTranscript({
146
+ sessionId: "sess_a",
147
+ sessionDir,
148
+ onInterrupt: (e) => interruptsA.push(e),
149
+ ...noopTimers
150
+ });
151
+ const watcherB = watchClaudeTranscript({
152
+ sessionId: "sess_b",
153
+ sessionDir,
154
+ onInterrupt: (e) => interruptsB.push(e),
155
+ ...noopTimers
156
+ });
157
+
158
+ // Both pin to their own file, then skip to EOF.
159
+ watcherA._tick();
160
+ watcherB._tick();
161
+
162
+ // Interrupt happens in session A only.
163
+ appendFileSync(fileA, interrupt());
164
+ watcherA._tick();
165
+ watcherB._tick();
166
+
167
+ assert.deepEqual(interruptsA, [{ type: "interrupted" }], "session A sees its own interrupt");
168
+ assert.deepEqual(interruptsB, [], "session B (idle) is NOT contaminated by session A's interrupt");
169
+
170
+ watcherA.stop();
171
+ watcherB.stop();
172
+ });
173
+
174
+ test("watchClaudeTranscript with a session link idles until the link appears", () => {
175
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
176
+ const projectDir = mkdtempSync(join(tmpdir(), "proj-"));
177
+ const file = join(projectDir, "s.jsonl");
178
+ writeFileSync(file, "");
179
+
180
+ const interrupts = [];
181
+ const watcher = watchClaudeTranscript({
182
+ sessionId: "sess_late",
183
+ sessionDir,
184
+ onInterrupt: (e) => interrupts.push(e),
185
+ ...noopTimers
186
+ });
187
+
188
+ // No link yet → nothing tailed, even if a marker is already present.
189
+ appendFileSync(file, interrupt());
190
+ watcher._tick();
191
+ assert.deepEqual(interrupts, [], "no guessing before the hook reports the path");
192
+
193
+ // The hook fires and records the binding; the watcher now pins (skipping to EOF,
194
+ // so the pre-existing marker is not replayed) and only catches NEW events.
195
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_late", transcriptPath: file });
196
+ watcher._tick();
197
+ appendFileSync(file, interrupt());
198
+ watcher._tick();
199
+ assert.deepEqual(interrupts, [{ type: "interrupted" }]);
200
+
201
+ watcher.stop();
202
+ });
203
+
131
204
  test("watchClaudeTranscript reports an interrupt appended after it starts tailing", () => {
132
205
  const dir = mkdtempSync(join(tmpdir(), "transcript-"));
133
206
  const path = join(dir, "session.jsonl");
@@ -3,7 +3,8 @@ import { mkdtempSync, readFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "../../../test/harness.mjs";
6
- import { parseStateArgs, runStateCommand } from "../src/run-state.js";
6
+ import { extractTranscriptPath, parseStateArgs, runStateCommand } from "../src/run-state.js";
7
+ import { readSessionTranscriptLink } from "../src/session-transcript-link.js";
7
8
 
8
9
  test("runStateCommand appends a debug line when HAYA_PET_HOOK_DEBUG is set", async () => {
9
10
  const logPath = join(mkdtempSync(join(tmpdir(), "haya-dbg-")), "hooks.jsonl");
@@ -21,6 +22,51 @@ test("runStateCommand appends a debug line when HAYA_PET_HOOK_DEBUG is set", asy
21
22
  assert.deepEqual(line, { ts: 7, state: "waiting_approval", sessionId: "s1", summary: "approval" });
22
23
  });
23
24
 
25
+ test("extractTranscriptPath pulls transcript_path out of a Claude hook payload", () => {
26
+ assert.equal(
27
+ extractTranscriptPath(JSON.stringify({ session_id: "x", transcript_path: "/p/a.jsonl", cwd: "/p" })),
28
+ "/p/a.jsonl"
29
+ );
30
+ // Defensive: junk, missing field, wrong type, and empty all yield undefined.
31
+ assert.equal(extractTranscriptPath("{not json"), undefined);
32
+ assert.equal(extractTranscriptPath(JSON.stringify({ session_id: "x" })), undefined);
33
+ assert.equal(extractTranscriptPath(JSON.stringify({ transcript_path: 42 })), undefined);
34
+ assert.equal(extractTranscriptPath(""), undefined);
35
+ assert.equal(extractTranscriptPath(undefined), undefined);
36
+ });
37
+
38
+ test("runStateCommand records the session->transcript link when given a transcript path", async () => {
39
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
40
+ await runStateCommand(
41
+ { command: "state", state: "thinking", summary: undefined, session: "sess_link" },
42
+ {
43
+ now: () => 1,
44
+ sessionDir,
45
+ transcriptPath: "/p/.claude/projects/D--p/abc.jsonl",
46
+ createIpcClient: async () => ({ send: async () => {}, close: async () => {} })
47
+ }
48
+ );
49
+
50
+ assert.equal(
51
+ readSessionTranscriptLink({ sessionDir, sessionId: "sess_link" }),
52
+ "/p/.claude/projects/D--p/abc.jsonl"
53
+ );
54
+ });
55
+
56
+ test("runStateCommand writes no link when no transcript path is supplied", async () => {
57
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
58
+ await runStateCommand(
59
+ { command: "state", state: "thinking", summary: undefined, session: "sess_nolink" },
60
+ {
61
+ now: () => 1,
62
+ sessionDir,
63
+ createIpcClient: async () => ({ send: async () => {}, close: async () => {} })
64
+ }
65
+ );
66
+
67
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_nolink" }), undefined);
68
+ });
69
+
24
70
  test("parseStateArgs reads state, summary, and session", () => {
25
71
  assert.deepEqual(parseStateArgs(["thinking"]), {
26
72
  command: "state",
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "../../../test/harness.mjs";
6
+ import {
7
+ readSessionTranscriptLink,
8
+ removeSessionTranscriptLink,
9
+ sessionLinkPath,
10
+ writeSessionTranscriptLink
11
+ } from "../src/session-transcript-link.js";
12
+
13
+ test("write then read round-trips a session's transcript path", () => {
14
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
15
+ const transcriptPath = "D:\\proj\\.claude\\projects\\D--proj\\abc.jsonl";
16
+
17
+ const wrote = writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath });
18
+ assert.equal(wrote, true);
19
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }), transcriptPath);
20
+ });
21
+
22
+ test("two sessions in the same dir keep separate, independent links", () => {
23
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
24
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" });
25
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: "/p/b.jsonl" });
26
+
27
+ // The core of the bug fix: each session resolves ONLY its own transcript.
28
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }), "/p/a.jsonl");
29
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_b" }), "/p/b.jsonl");
30
+ });
31
+
32
+ test("reading a session with no link returns undefined (no guessing)", () => {
33
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
34
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "missing" }), undefined);
35
+ });
36
+
37
+ test("write is a no-op without the required fields", () => {
38
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
39
+ assert.equal(writeSessionTranscriptLink({ sessionDir, sessionId: "s" }), false);
40
+ assert.equal(writeSessionTranscriptLink({ sessionDir, transcriptPath: "/p/a.jsonl" }), false);
41
+ assert.equal(writeSessionTranscriptLink({ sessionId: "s", transcriptPath: "/p/a.jsonl" }), false);
42
+ });
43
+
44
+ test("remove deletes the link and is safe when it is already gone", () => {
45
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
46
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" });
47
+ const path = sessionLinkPath(sessionDir, "sess_a");
48
+ assert.equal(existsSync(path), true);
49
+
50
+ removeSessionTranscriptLink({ sessionDir, sessionId: "sess_a" });
51
+ assert.equal(existsSync(path), false);
52
+ // Second removal must not throw.
53
+ removeSessionTranscriptLink({ sessionDir, sessionId: "sess_a" });
54
+ });
55
+
56
+ test("a corrupt link file reads as undefined rather than throwing", () => {
57
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
58
+ writeSessionTranscriptLink(
59
+ { sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" },
60
+ { writeFileSync: () => {} } // pretend write; nothing on disk
61
+ );
62
+ // Inject a reader returning junk to simulate a partially-written file.
63
+ assert.equal(
64
+ readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }, { readFileSync: () => "{not json" }),
65
+ undefined
66
+ );
67
+ });
@@ -30,6 +30,7 @@ function getWindowsPaths(env, homeDir) {
30
30
  statePath: joinWindows(localAppData, "haya-pet", "state.json"),
31
31
  configPath: joinWindows(appData, "haya-pet", "config.json"),
32
32
  logDir: joinWindows(localAppData, "haya-pet", "logs"),
33
+ sessionDir: joinWindows(localAppData, "haya-pet", "sessions"),
33
34
  petSearchPaths: [
34
35
  joinWindows(homeDir, ".codex", "pets"),
35
36
  joinWindows(localAppData, "haya-pet", "pets")
@@ -44,6 +45,7 @@ function getUnixPaths(homeDir) {
44
45
  statePath: joinUnix(homeDir, ".haya-pet", "state.json"),
45
46
  configPath: joinUnix(homeDir, ".haya-pet", "config.json"),
46
47
  logDir: joinUnix(homeDir, ".haya-pet", "logs"),
48
+ sessionDir: joinUnix(homeDir, ".haya-pet", "sessions"),
47
49
  petSearchPaths: [
48
50
  joinUnix(homeDir, ".codex", "pets"),
49
51
  joinUnix(homeDir, ".haya-pet", "pets")
@@ -58,6 +60,7 @@ function getUnsupportedPaths(homeDir) {
58
60
  statePath: joinUnix(homeDir, ".haya-pet", "state.json"),
59
61
  configPath: joinUnix(homeDir, ".haya-pet", "config.json"),
60
62
  logDir: joinUnix(homeDir, ".haya-pet", "logs"),
63
+ sessionDir: joinUnix(homeDir, ".haya-pet", "sessions"),
61
64
  petSearchPaths: [
62
65
  joinUnix(homeDir, ".codex", "pets"),
63
66
  joinUnix(homeDir, ".haya-pet", "pets")