@hayasaka7/haya-pet 0.3.7 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ 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.9]
11
+
12
+ ### Fixed
13
+ - **The cross-session contamination fix now covers Codex too.** Codex had the same
14
+ flaw fixed for Claude in 0.3.8: its transcript watcher chose the rollout by
15
+ newest mtime + cwd, and the guardian-review watcher derived the main thread id
16
+ from the newest main rollout — so two Codex sessions in the same folder could
17
+ cross-report each other's `turn_aborted` (interrupt) or tool activity, with the
18
+ idle session showing the busy one's state. Codex's command-hook payload also
19
+ carries `transcript_path`, so the `haya-pet state` reporter's per-session
20
+ `session → transcript` link (already written for every client) now pins the Codex
21
+ transcript watcher to its own rollout, and the guardian watcher binds the main
22
+ thread id from the linked rollout's `payload.id` (and only follows a trunk whose
23
+ `parent_thread_id` matches it). Both fall back to the previous mtime+cwd heuristic
24
+ when no link is available (e.g. `transcript_path` null early), so there is no
25
+ regression. No timer involved.
26
+
27
+ ## [0.3.8]
28
+
29
+ ### Fixed
30
+ - **An interrupt or denial in one Claude Code session no longer leaks into a
31
+ concurrent, idle one.** The transcript watcher discovered its file by "newest
32
+ `.jsonl` by mtime in the project dir", so two Claude sessions in the same folder
33
+ (one project dir, one transcript each) could make an idle session's watcher lock
34
+ onto a *busy* session's transcript — then read its `[Request interrupted by
35
+ user]` marker (or a denial) and report the wrong pet as *interrupted*. Each
36
+ session's watcher now pins to its own transcript via the `transcript_path` Claude
37
+ includes in every hook payload (recorded as a per-session link by the `haya-pet
38
+ state` reporter) instead of guessing; until that link exists it idles rather than
39
+ locking onto another session's file. (Codex had the same discovery shape — fixed
40
+ in 0.3.9.)
41
+ - **The pet no longer disappears when the display layout changes.** The overlay
42
+ window's bounds were set once at creation to span one display's work area and
43
+ never re-homed, so unplugging a monitor, changing resolution/DPI, docking or
44
+ undocking, or waking from sleep could strand it off-screen (or on a display that
45
+ no longer exists) — the pet vanished while the process kept running, and neither
46
+ **Show/Hide Pet** (which only flips visibility) nor **Reset Position** (which only
47
+ moved the sprite *inside* the window) brought it back. The companion now re-homes
48
+ the overlay onto a valid display on `screen` display add/remove/metrics-change and
49
+ on resume from sleep, and **Reset Position** / **Show Pet** / relaunch re-home the
50
+ window itself. Automatic re-homes preserve the preferred display, so the pet
51
+ returns there when the monitor comes back.
52
+
10
53
  ## [0.3.7]
11
54
 
12
55
  ### 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
@@ -422,12 +434,20 @@ async function runRunCommand(parsed, dependencies) {
422
434
  };
423
435
  cleanup = injected.cleanup;
424
436
 
437
+ // Pin both Codex watchers to THIS session's rollout via the
438
+ // session->transcript link the `haya-pet state` reporter records from the
439
+ // hook payload's transcript_path, instead of guessing newest-by-mtime (which
440
+ // leaks a concurrent same-cwd session's activity/interrupts).
441
+ const sessionDir = resolveSessionDir(dependencies, env);
442
+
425
443
  const activeToolCalls = new Set();
426
444
  const watcher = watchCodexTranscript({
427
445
  homeDir: dependencies.homeDir,
428
446
  sessionsRoot: dependencies.codexSessionsRoot,
429
447
  cwd,
430
448
  startedAt: now(),
449
+ sessionId,
450
+ sessionDir,
431
451
  onToolEvent: (event) => {
432
452
  hookDebugLog(env, now, {
433
453
  source: "codex_transcript",
@@ -510,6 +530,8 @@ async function runRunCommand(parsed, dependencies) {
510
530
  sessionsRoot: dependencies.codexSessionsRoot,
511
531
  cwd,
512
532
  startedAt: now(),
533
+ sessionId,
534
+ sessionDir,
513
535
  onReviewEvent: (event) => {
514
536
  hookDebugLog(env, now, {
515
537
  source: "codex_guardian",
@@ -538,6 +560,7 @@ async function runRunCommand(parsed, dependencies) {
538
560
  stopWatcher = () => {
539
561
  guardianWatcher.stop();
540
562
  stopWithoutGuardian();
563
+ removeSessionTranscriptLink({ sessionDir, sessionId });
541
564
  };
542
565
  }
543
566
  }
@@ -624,6 +647,21 @@ function createConfigStateFile(dependencies) {
624
647
  return createStateFile({ statePath: paths.statePath });
625
648
  }
626
649
 
650
+ // Where per-session transcript links live. Resolved defensively: if no home dir
651
+ // is available (some tests), return undefined and the watcher falls back to its
652
+ // legacy discovery instead of crashing the run.
653
+ function resolveSessionDir(dependencies, env) {
654
+ try {
655
+ return getDefaultPaths({
656
+ platform: dependencies.platform,
657
+ env,
658
+ homeDir: dependencies.homeDir
659
+ }).sessionDir;
660
+ } catch {
661
+ return undefined;
662
+ }
663
+ }
664
+
627
665
  // Best-effort: mirror the reporter's HAYA_PET_HOOK_DEBUG log so transcript-driven
628
666
  // events (which don't go through `haya-pet state`) show up in the same trace.
629
667
  function hookDebugLog(env, now, entry) {
@@ -917,7 +955,7 @@ async function noopSend() {}
917
955
  async function noopClose() {}
918
956
 
919
957
  if (isDirectRun(import.meta.url, process.argv[1])) {
920
- main()
958
+ bootstrap()
921
959
  .catch((error) => {
922
960
  console.error(error.message);
923
961
  process.exitCode = 1;
@@ -930,6 +968,28 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
930
968
  });
931
969
  }
932
970
 
971
+ // Real-process entry. For a `haya-pet state` invocation — which is ALWAYS a client
972
+ // hook child — read the hook payload from stdin once to learn this session's real
973
+ // transcript path, and hand it to the reporter so it can record the
974
+ // session->transcript link. Done here (not inside main/runStateCommand) so unit
975
+ // tests, and every other command that needs stdin passed through to its child
976
+ // (e.g. `run`), never touch stdin.
977
+ async function bootstrap() {
978
+ const argv = process.argv.slice(2);
979
+ const dependencies = {};
980
+ if (argv[0] === "state") {
981
+ try {
982
+ const transcriptPath = await readHookTranscriptPathFromStdin();
983
+ if (transcriptPath) {
984
+ dependencies.transcriptPath = transcriptPath;
985
+ }
986
+ } catch {
987
+ // a missing/garbled payload just means no binding this time — never fatal
988
+ }
989
+ }
990
+ return main(argv, dependencies);
991
+ }
992
+
933
993
  function isDirectRun(moduleUrl, scriptPath) {
934
994
  if (!scriptPath) {
935
995
  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,103 @@
2
2
 
3
3
  Issues found in live use, with their current status.
4
4
 
5
+ ## ✅ Resolved: cross-session status contamination on Codex
6
+
7
+ - **Symptom (same class as the Claude entry below):** interrupting one Codex
8
+ session could flip a **different, concurrent** Codex session's pet to
9
+ *interrupted* (and more generally mirror another session's tool/working states).
10
+ Most likely when two Codex sessions ran in the **same folder** and one was busy
11
+ while the other was idle.
12
+ - **Root cause:** `discoverCodexTranscript` (`codex-transcript-watcher.js`) picked
13
+ the rollout by **newest `.jsonl` by mtime**, filtered only by `session_meta.cwd`
14
+ / freshness — it did **not** bind to a specific session, so an idle session's
15
+ watcher could lock onto a busy session's rollout and read that session's
16
+ `turn_aborted` (Codex's interrupt signal) as its own. The `isFreshSession` branch
17
+ even admitted recently-started rollouts from **other cwds**, so the exposure was
18
+ slightly *wider* than Claude's (scoped to one project dir). The guardian-review
19
+ watcher had the same flaw: it derived the main thread id from the newest main
20
+ rollout by mtime, so a concurrent session's review status could be misattributed.
21
+ - **Fix:** the same per-session binding used for Claude. Verified against the
22
+ OpenAI Codex docs that the command-hook stdin payload carries **`transcript_path`**
23
+ (and `session_id`, the conversation/rollout id — which I also confirmed on disk
24
+ equals `session_meta.payload.id` and the rollout filename uuid). The
25
+ `haya-pet state` reporter already records a per-session `session→transcript` link
26
+ from that `transcript_path` (the capture is client-agnostic), so the Codex
27
+ transcript watcher now pins to its own rollout via the link
28
+ (`session-transcript-link.js`) instead of guessing newest-by-mtime, and the
29
+ guardian watcher derives the main thread id from the **linked** rollout's
30
+ `payload.id` (and only considers a trunk whose `parent_thread_id` matches it).
31
+ Both fall back to the old heuristic when no link is available (e.g. `transcript_path`
32
+ null early), so there is no regression. No timer involved.
33
+
34
+ ## ✅ Resolved: Claude interrupt/denial leaked into a concurrent idle session
35
+
36
+ - **Symptom:** With Claude Code hooks enabled, interrupting (Esc) one Claude
37
+ session could also flip a **different, idle** Claude session's pet to
38
+ *interrupted* (and mirror its working states). Intermittent — most visible when
39
+ the two ran in the **same folder** and one was busy while the other sat idle.
40
+ - **Root cause:** the L3 transcript watcher had **no binding to a specific
41
+ session's transcript**. It discovered the file by "newest `.jsonl` by mtime in
42
+ the project dir" (`claude-transcript-watcher.js` `discoverTranscript`). Two
43
+ Claude sessions in one folder share a project dir
44
+ (`~/.claude/projects/<sanitized-cwd>/`), each with its own UUID file, so an idle
45
+ session's watcher could lock onto a **busy** session's transcript and then read
46
+ that session's `[Request interrupted by user]` marker (or a denial) and report
47
+ it for itself. `HAYA_PET_SESSION_ID` identified the session to the daemon, but
48
+ nothing tied the watched **file** to the session.
49
+ - **Fix:** bind each watcher to its own transcript via the **`transcript_path`
50
+ Claude includes in every hook payload** (ground truth). The `haya-pet state`
51
+ reporter — already a hook child that knows `HAYA_PET_SESSION_ID` — reads the hook
52
+ payload from stdin (only in the real process entry, never in tests/other
53
+ commands) and records a per-session **session→transcript link**
54
+ (`packages/cli-core/src/session-transcript-link.js`, stored under
55
+ `…/haya-pet/sessions/<id>.json`). The watcher pins to that exact file instead of
56
+ guessing; until the link exists it simply idles (nothing to interrupt yet)
57
+ rather than locking onto another session's file. Newest-by-mtime remains only as
58
+ a fallback for the no-session case (never hit in production, where the watcher
59
+ only runs with hooks on). The link is removed on wrapper exit. Local-only and
60
+ best-effort; **no timer** is involved.
61
+ - **Tests:** `session-transcript-link.test.mjs` (write/read round-trip + per-session
62
+ isolation) and a `claude-transcript-watcher.test.mjs` case proving an interrupt
63
+ in session A is **not** reported for idle session B, plus a case that the watcher
64
+ idles until its link appears.
65
+ - **How to diagnose if it recurs:** with `HAYA_PET_HOOK_DEBUG=<path>` set, the
66
+ transcript-sourced `interrupted` line is logged with its `sessionId`; if it
67
+ appears under a session that was idle, the binding (the
68
+ `…/haya-pet/sessions/<id>.json` link) resolved to the wrong file.
69
+
70
+ ## ✅ Resolved: pet disappeared (and could not be restored) after a display change
71
+
72
+ - **Symptom:** the pet sometimes vanished from the screen while the companion was
73
+ still running — and once gone, **Show/Hide Pet** and **Reset Position** both
74
+ failed to bring it back. Intermittent.
75
+ - **Root cause:** the overlay is a single full-work-area `BrowserWindow` whose
76
+ bounds are computed **once**, at creation, for whichever display it resolved to
77
+ then. The companion subscribed to **no** `screen` events and never called
78
+ `setBounds` again, so a display-layout change underneath it — monitor unplugged,
79
+ resolution/DPI change, dock/undock, or sleep→resume — left the window at
80
+ coordinates that were now **off-screen or on a display that no longer exists**.
81
+ The window stayed alive and `isVisible() === true`; it was just painting where no
82
+ monitor covered. The two recovery actions failed for the same reason: *Show/Hide*
83
+ only flips `isVisible()` (an off-screen window is already "visible", so it
84
+ toggled between hidden and shown-at-the-same-bad-bounds), and *Reset Position*
85
+ only moved the **sprite's CSS position inside** the overlay (against a stale work
86
+ area), never the window's bounds.
87
+ - **Fix:** the companion now re-homes the overlay onto a currently-valid display.
88
+ It listens for `screen` `display-metrics-changed` / `display-added` /
89
+ `display-removed` and `powerMonitor` `resume`, re-resolving the target display
90
+ and calling `setBounds` (decision logic in the pure, tested
91
+ `display-manager.js` `resolveOverlayPlacement`). **Reset Position**, **Show
92
+ Pet**, and relaunch now re-home the window itself, not just the sprite.
93
+ Automatic re-homes do **not** persist the position, so the user's preferred
94
+ display is remembered and the pet returns there when that monitor comes back. No
95
+ timer is involved — every re-home is driven by a real display/power event or a
96
+ user action.
97
+ - **Known residual (Windows):** a transparent surface can still occasionally go
98
+ blank after resume even with correct bounds (an Electron compositor issue);
99
+ re-asserting bounds repaints it in the common case, and a hide/show repaint nudge
100
+ is the fallback if it recurs.
101
+
5
102
  ## ✅ Resolved: Codex interrupt sometimes left the pet "working"
6
103
 
7
104
  - **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.9",
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;