@hayasaka7/haya-pet 0.3.6 → 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 +40 -0
- package/README.md +3 -3
- package/apps/cli/src/haya-pet.js +52 -3
- package/apps/companion/src/main/display-manager.js +35 -0
- package/apps/companion/src/main/index.js +98 -29
- package/apps/companion/src/main/tray-menu.js +2 -28
- package/apps/companion/test/display-manager.test.mjs +51 -1
- package/apps/companion/test/tray-menu.test.mjs +3 -14
- package/docs/known-issues.md +115 -0
- package/docs/screenshots/README.md +1 -1
- package/docs/troubleshooting.md +1 -0
- package/package.json +1 -1
- package/packages/cli-core/src/claude-transcript-watcher.js +25 -1
- package/packages/cli-core/src/codex-hook-injection.js +51 -2
- 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-hook-injection.test.mjs +30 -1
- 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/docs/known-issues.md
CHANGED
|
@@ -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**
|
|
@@ -117,6 +218,20 @@ Issues found in live use, with their current status.
|
|
|
117
218
|
surfaces as turn-end *idle*). The TUI's passive `/approve` denial-override
|
|
118
219
|
picker is not a blocking prompt.
|
|
119
220
|
|
|
221
|
+
## ✅ Resolved: Codex asked to review HAYA hooks on every launch
|
|
222
|
+
|
|
223
|
+
- **Symptom:** Even after approving HAYA Pet's Codex hooks once, every new
|
|
224
|
+
`haya-pet run --client codex` showed Codex's hook review prompt again.
|
|
225
|
+
- **Root cause:** HAYA Pet correctly wrote a stable
|
|
226
|
+
`$CODEX_HOME/haya-pet.config.toml` profile, but Codex stores the user's hook
|
|
227
|
+
trust decisions back into that same profile under `[hooks.state]` as
|
|
228
|
+
`trusted_hash` entries. The injector rewrote the entire profile on every
|
|
229
|
+
launch, so it deleted Codex's trust cache before Codex could reuse it.
|
|
230
|
+
- **Fix:** The Codex hook injector now regenerates the HAYA-managed hook tables
|
|
231
|
+
while preserving the Codex-managed `[hooks.state]` tables from the existing
|
|
232
|
+
profile. Users may need to approve once after updating; after that, unchanged
|
|
233
|
+
hook commands should stay trusted.
|
|
234
|
+
|
|
120
235
|
## ✅ Resolved: Codex pet looked busy immediately after startup
|
|
121
236
|
|
|
122
237
|
- **Symptom:** Starting a wrapped Codex session and doing nothing could still make
|
|
@@ -4,4 +4,4 @@ Place screenshot PNGs referenced by the root README here (~800px wide):
|
|
|
4
4
|
- `pet-overlay.png` — the pet reacting to the highest-priority session
|
|
5
5
|
- `session-bubbles.png` — bubbles expanded, showing per-session status icons
|
|
6
6
|
- `folder-collapsed.png` — bubbles folded away beside the pet
|
|
7
|
-
- `tray-menu.png` — the tray menu (show/hide, pets, reset position, Quit)
|
|
7
|
+
- `tray-menu.png` — the tray menu (show/hide, sessions, pets, reset position, Quit)
|
package/docs/troubleshooting.md
CHANGED
|
@@ -18,6 +18,7 @@ deferred problems with known root causes.
|
|
|
18
18
|
| Typing doesn't work / **Claude Code** TUI frozen under `haya-pet run` | You have hooks enabled and Claude is showing its *review hooks* trust prompt (approve it once), or your Claude is too old for `--settings`. Run `haya-pet hooks off` (or set `HAYA_PET_NO_HOOKS=1`) for native passthrough with lifecycle-only status — typing and Shift+Tab work normally. |
|
|
19
19
|
| Pet changes status after a **Claude Code** subagent finishes, even though the main agent already stopped | Fixed — Claude `SubagentStop` is ignored because it is not a reliable main-turn state. Update to the latest version and restart the wrapped Claude session so the new hook settings are used. |
|
|
20
20
|
| Pet shows only **idle/lifecycle** while **Codex** works | Live status is opt-in: run `haya-pet hooks on` once (persisted, global), then `haya-pet run --client codex -- codex`; approve Codex's one-time *review hooks* prompt. `thinking`/`idle` come from hooks, `running_tool`/`editing_files` from a transcript watcher, and approval states from the `PermissionRequest` hook plus a guardian-review watcher. |
|
|
21
|
+
| **Codex** asks to review HAYA hooks on every launch | Fixed — update to the latest version, then approve once more. Codex writes trusted hook hashes into `$CODEX_HOME/haya-pet.config.toml` under `[hooks.state]`; HAYA Pet now preserves that Codex-managed block when refreshing the hook profile. |
|
|
21
22
|
| Pet showed **waiting for approval** while **Codex** auto-reviewed the request ("Approve for me") | Fixed — with `approvals_reviewer = auto_review` (legacy `guardian_subagent`) Codex's guardian decides without asking you; the pet now reports **reviewing** from the permission hook itself, then **working** on an allow verdict or **thinking** on a deny. *Waiting for approval* still shows when Codex actually asks you (`approvals_reviewer = "user"`). Restart the wrapped Codex session after updating so Codex reloads the changed hook command. |
|
|
22
23
|
| Pet shows **shell_command** or **thinking** right after starting Codex, before you prompt it | Fixed — the Codex transcript and guardian watchers now ignore rollouts whose `session_meta.timestamp` predates the current wrapper launch, so another active Codex session cannot drive this pet's status. Restart the wrapped Codex session after updating. |
|
|
23
24
|
| **Codex** live status didn't turn on / you pass your own `-p`/`--profile` | Codex allows only one profile, so haya-pet skips hook injection when you supply your own and prints a notice. Drop your `-p` for that run to get live status, or accept lifecycle-only. |
|
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;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// across sessions so Codex's hook-trust review only needs approving once. fnm hands
|
|
9
9
|
// out a per-shell symlink for process.execPath that dies when the launching shell
|
|
10
10
|
// exits, so we realpath it before baking it into the hook command.
|
|
11
|
-
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -32,7 +32,8 @@ export function injectCodexHooks({ nodePath, cliPath, codexHome, env = process.e
|
|
|
32
32
|
// rewrite identical bytes, and the hooks stay "trusted" across launches.
|
|
33
33
|
mkdirSync(home, { recursive: true });
|
|
34
34
|
const profilePath = join(home, PROFILE_FILE);
|
|
35
|
-
|
|
35
|
+
const trustedState = readCodexHookTrustState(profilePath);
|
|
36
|
+
writeFileSync(profilePath, appendCodexHookTrustState(toml, trustedState), "utf8");
|
|
36
37
|
|
|
37
38
|
// The profile file is stable and reusable on purpose — leaving it in place is
|
|
38
39
|
// what lets Codex remember the hooks are trusted. cleanup is a no-op kept for
|
|
@@ -47,3 +48,51 @@ function safeRealpath(target) {
|
|
|
47
48
|
return target;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
function readCodexHookTrustState(profilePath) {
|
|
53
|
+
try {
|
|
54
|
+
return extractCodexHookTrustState(readFileSync(profilePath, "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appendCodexHookTrustState(toml, trustedState) {
|
|
61
|
+
if (!trustedState) {
|
|
62
|
+
return toml;
|
|
63
|
+
}
|
|
64
|
+
return `${toml.trimEnd()}\n\n${trustedState.trim()}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractCodexHookTrustState(toml) {
|
|
68
|
+
const lines = String(toml).split(/\r?\n/);
|
|
69
|
+
const output = [];
|
|
70
|
+
let inHookState = false;
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const tableName = readTomlTableName(line);
|
|
74
|
+
if (tableName) {
|
|
75
|
+
const isHookStateTable = tableName === "hooks.state" || tableName.startsWith("hooks.state.");
|
|
76
|
+
if (isHookStateTable) {
|
|
77
|
+
inHookState = true;
|
|
78
|
+
} else if (inHookState) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (inHookState) {
|
|
84
|
+
output.push(line);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return output.join("\n").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readTomlTableName(line) {
|
|
92
|
+
const table = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
93
|
+
if (table) {
|
|
94
|
+
return table[1];
|
|
95
|
+
}
|
|
96
|
+
const arrayTable = /^\s*\[\[([^\]]+)\]\]\s*$/.exec(line);
|
|
97
|
+
return arrayTable?.[1];
|
|
98
|
+
}
|
|
@@ -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");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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";
|
|
@@ -43,3 +43,32 @@ test("injectCodexHooks honors CODEX_HOME from env and is stable across calls", (
|
|
|
43
43
|
rmSync(home, { recursive: true, force: true });
|
|
44
44
|
}
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
test("injectCodexHooks preserves Codex hook trust state in the managed profile", () => {
|
|
48
|
+
const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
|
|
49
|
+
try {
|
|
50
|
+
const first = injectCodexHooks({
|
|
51
|
+
nodePath: "n",
|
|
52
|
+
cliPath: "c",
|
|
53
|
+
codexHome: home
|
|
54
|
+
});
|
|
55
|
+
const trustedState = `[hooks.state]
|
|
56
|
+
|
|
57
|
+
[hooks.state.'${first.profilePath}:user_prompt_submit:0:0']
|
|
58
|
+
trusted_hash = "sha256:abc123"
|
|
59
|
+
`;
|
|
60
|
+
writeFileSync(first.profilePath, `${readFileSync(first.profilePath, "utf8")}\n${trustedState}`, "utf8");
|
|
61
|
+
|
|
62
|
+
injectCodexHooks({
|
|
63
|
+
nodePath: "n",
|
|
64
|
+
cliPath: "c",
|
|
65
|
+
codexHome: home
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const next = readFileSync(first.profilePath, "utf8");
|
|
69
|
+
assert.match(next, /\[hooks\.state\]/);
|
|
70
|
+
assert.match(next, /trusted_hash = "sha256:abc123"/);
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|