@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.
@@ -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)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -15,6 +15,7 @@ import {
15
15
  } from "node:fs";
16
16
  import { basename, join } from "node:path";
17
17
  import { parseTranscriptLines } from "../../adapters/src/claude-transcript.js";
18
+ import { readSessionTranscriptLink } from "./session-transcript-link.js";
18
19
 
19
20
  const DEFAULT_POLL_MS = 700;
20
21
 
@@ -37,6 +38,8 @@ export function watchClaudeTranscript(options = {}) {
37
38
  onInterrupt = () => {},
38
39
  pollIntervalMs = DEFAULT_POLL_MS,
39
40
  projectsRoot,
41
+ sessionId,
42
+ sessionDir,
40
43
  transcriptPath: fixedPath,
41
44
  setInterval: setIntervalFn = setInterval,
42
45
  clearInterval: clearIntervalFn = clearInterval
@@ -47,6 +50,13 @@ export function watchClaudeTranscript(options = {}) {
47
50
  // before Claude has created THIS session's transcript.
48
51
  const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
49
52
 
53
+ // Preferred resolution: pin to the exact transcript this session's hook reported
54
+ // (via the session->transcript link). Only when no session identity is available
55
+ // (e.g. hooks off, or older tests) do we fall back to the newest-by-mtime guess —
56
+ // which is unsafe with multiple concurrent sessions in one folder. In production
57
+ // the watcher only runs with hooks on, so the link path is always used.
58
+ const useLink = Boolean(sessionId && sessionDir);
59
+
50
60
  let transcriptPath = fixedPath;
51
61
  let offset = 0;
52
62
  let carry = "";
@@ -54,7 +64,9 @@ export function watchClaudeTranscript(options = {}) {
54
64
  const tick = () => {
55
65
  try {
56
66
  if (!transcriptPath) {
57
- transcriptPath = discoverTranscript(root, cwd, minMtime);
67
+ transcriptPath = useLink
68
+ ? resolveLinkedTranscript(sessionDir, sessionId)
69
+ : discoverTranscript(root, cwd, minMtime);
58
70
  if (!transcriptPath) {
59
71
  return;
60
72
  }
@@ -106,6 +118,18 @@ export function watchClaudeTranscript(options = {}) {
106
118
  };
107
119
  }
108
120
 
121
+ // Resolve the transcript this session's hook bound itself to. Returns undefined
122
+ // until the link exists and points at a real file, so before the first hook fires
123
+ // the watcher simply idles (there is nothing to interrupt yet) rather than
124
+ // guessing a file that might belong to another session.
125
+ function resolveLinkedTranscript(sessionDir, sessionId) {
126
+ const linked = readSessionTranscriptLink({ sessionDir, sessionId });
127
+ if (!linked || !existsSync(linked)) {
128
+ return undefined;
129
+ }
130
+ return linked;
131
+ }
132
+
109
133
  export function discoverTranscript(root, cwd, minMtime = 0) {
110
134
  if (!root || !existsSync(root)) {
111
135
  return undefined;
@@ -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
- writeFileSync(profilePath, toml, "utf8");
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
+ });