@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 +43 -0
- package/apps/cli/src/haya-pet.js +63 -3
- package/apps/companion/src/main/display-manager.js +35 -0
- package/apps/companion/src/main/index.js +98 -29
- package/apps/companion/test/display-manager.test.mjs +51 -1
- package/docs/known-issues.md +97 -0
- package/package.json +1 -1
- package/packages/cli-core/src/claude-transcript-watcher.js +25 -1
- package/packages/cli-core/src/codex-guardian-watcher.js +35 -2
- package/packages/cli-core/src/codex-transcript-watcher.js +25 -1
- package/packages/cli-core/src/run-state.js +110 -0
- package/packages/cli-core/src/session-transcript-link.js +72 -0
- package/packages/cli-core/test/claude-transcript-watcher.test.mjs +73 -0
- package/packages/cli-core/test/codex-guardian-watcher.test.mjs +48 -0
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +50 -0
- package/packages/cli-core/test/run-state.test.mjs +47 -1
- package/packages/cli-core/test/session-transcript-link.test.mjs +67 -0
- package/packages/platform-core/src/paths.js +3 -0
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
|
package/apps/cli/src/haya-pet.js
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
175
|
-
//
|
|
176
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
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 {
|
|
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
|
+
});
|
package/docs/known-issues.md
CHANGED
|
@@ -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
|
@@ -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 =
|
|
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;
|