@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
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
parseGuardianTranscriptLines
|
|
15
15
|
} from "../../adapters/src/codex-guardian.js";
|
|
16
16
|
import { listJsonlFiles, readFirstLine, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
|
|
17
|
+
import { readSessionTranscriptLink } from "./session-transcript-link.js";
|
|
17
18
|
|
|
18
19
|
const DEFAULT_POLL_MS = 700;
|
|
19
20
|
const MTIME_SKEW_MS = 2000;
|
|
@@ -26,6 +27,8 @@ export function watchCodexGuardianReviews(options = {}) {
|
|
|
26
27
|
onReviewEvent = () => {},
|
|
27
28
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
28
29
|
sessionsRoot,
|
|
30
|
+
sessionId,
|
|
31
|
+
sessionDir,
|
|
29
32
|
setInterval: setIntervalFn = setInterval,
|
|
30
33
|
clearInterval: clearIntervalFn = clearInterval
|
|
31
34
|
} = options;
|
|
@@ -66,7 +69,33 @@ export function watchCodexGuardianReviews(options = {}) {
|
|
|
66
69
|
return meta;
|
|
67
70
|
};
|
|
68
71
|
|
|
72
|
+
// Authoritative main thread id from the session->transcript link (the linked
|
|
73
|
+
// rollout's session_meta.payload.id). Lets the guardian bind to OUR main thread
|
|
74
|
+
// even when another session's main rollout has a newer mtime.
|
|
75
|
+
const resolveLinkedMainThreadId = () => {
|
|
76
|
+
if (!sessionId || !sessionDir) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const linked = readSessionTranscriptLink({ sessionDir, sessionId });
|
|
80
|
+
if (!linked) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const firstLine = readFirstLine(linked);
|
|
84
|
+
if (firstLine === undefined) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const meta = classifyCodexSessionMeta(firstLine);
|
|
88
|
+
return meta?.kind === "main" ? meta.threadId : undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
69
91
|
const discoverTrunk = () => {
|
|
92
|
+
// Prefer the linked main thread id; fall back to the newest main rollout by
|
|
93
|
+
// mtime only when no link is available (older behavior, unsafe across
|
|
94
|
+
// concurrent same-cwd sessions).
|
|
95
|
+
if (!mainThreadId) {
|
|
96
|
+
mainThreadId = resolveLinkedMainThreadId();
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
let newestMain;
|
|
71
100
|
let newestTrunk;
|
|
72
101
|
|
|
@@ -83,8 +112,12 @@ export function watchCodexGuardianReviews(options = {}) {
|
|
|
83
112
|
if (meta.kind === "main" && meta.threadId && (!newestMain || mtime > newestMain.mtime)) {
|
|
84
113
|
newestMain = { threadId: meta.threadId, mtime };
|
|
85
114
|
}
|
|
86
|
-
if (meta.kind === "guardian" && (!newestTrunk || mtime > newestTrunk.mtime)) {
|
|
87
|
-
|
|
115
|
+
if (meta.kind === "guardian" && meta.parentThreadId && (!newestTrunk || mtime > newestTrunk.mtime)) {
|
|
116
|
+
// Once our main thread id is known, only consider OUR trunk so a
|
|
117
|
+
// concurrent session's (or collab subagent's) newer trunk cannot win.
|
|
118
|
+
if (!mainThreadId || meta.parentThreadId === mainThreadId) {
|
|
119
|
+
newestTrunk = { file, parentThreadId: meta.parentThreadId, mtime };
|
|
120
|
+
}
|
|
88
121
|
}
|
|
89
122
|
}
|
|
90
123
|
|
|
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { parseCodexTranscriptLines } from "../../adapters/src/codex-transcript.js";
|
|
7
7
|
import { listJsonlFiles, readFirstLine, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
|
|
8
|
+
import { readSessionTranscriptLink } from "./session-transcript-link.js";
|
|
8
9
|
|
|
9
10
|
const DEFAULT_POLL_MS = 700;
|
|
10
11
|
const MTIME_SKEW_MS = 2000;
|
|
@@ -17,6 +18,8 @@ export function watchCodexTranscript(options = {}) {
|
|
|
17
18
|
onToolEvent = () => {},
|
|
18
19
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
19
20
|
sessionsRoot,
|
|
21
|
+
sessionId,
|
|
22
|
+
sessionDir,
|
|
20
23
|
transcriptPath: fixedPath,
|
|
21
24
|
setInterval: setIntervalFn = setInterval,
|
|
22
25
|
clearInterval: clearIntervalFn = clearInterval
|
|
@@ -25,6 +28,13 @@ export function watchCodexTranscript(options = {}) {
|
|
|
25
28
|
const root = sessionsRoot ?? (homeDir ? join(homeDir, ".codex", "sessions") : undefined);
|
|
26
29
|
const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
|
|
27
30
|
|
|
31
|
+
// Preferred resolution: pin to the exact rollout this session's hook reported
|
|
32
|
+
// (Codex puts `transcript_path` in every hook payload; the `haya-pet state`
|
|
33
|
+
// reporter records it as a session->transcript link). Without that link (e.g.
|
|
34
|
+
// the path was null early), fall back to the newest-by-mtime heuristic — unsafe
|
|
35
|
+
// with concurrent same-cwd sessions, which is the bug the link avoids.
|
|
36
|
+
const useLink = Boolean(sessionId && sessionDir);
|
|
37
|
+
|
|
28
38
|
let transcriptPath = fixedPath;
|
|
29
39
|
let offset = 0;
|
|
30
40
|
let carry = "";
|
|
@@ -32,7 +42,9 @@ export function watchCodexTranscript(options = {}) {
|
|
|
32
42
|
const tick = () => {
|
|
33
43
|
try {
|
|
34
44
|
if (!transcriptPath) {
|
|
35
|
-
transcriptPath =
|
|
45
|
+
transcriptPath = useLink
|
|
46
|
+
? resolveLinkedRollout(sessionDir, sessionId)
|
|
47
|
+
: discoverCodexTranscript(root, minMtime, { cwd });
|
|
36
48
|
if (!transcriptPath) {
|
|
37
49
|
return;
|
|
38
50
|
}
|
|
@@ -79,6 +91,18 @@ export function watchCodexTranscript(options = {}) {
|
|
|
79
91
|
};
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
// Resolve the rollout this session's hook bound itself to (via the
|
|
95
|
+
// session->transcript link). Returns undefined until the link exists and points
|
|
96
|
+
// at a real file, so before the first hook the watcher idles rather than guessing
|
|
97
|
+
// a rollout that may belong to another concurrent session.
|
|
98
|
+
function resolveLinkedRollout(sessionDir, sessionId) {
|
|
99
|
+
const linked = readSessionTranscriptLink({ sessionDir, sessionId });
|
|
100
|
+
if (!linked || !existsSync(linked)) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return linked;
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
export function discoverCodexTranscript(root, minMtime = 0, options = {}) {
|
|
83
107
|
if (!root || !existsSync(root)) {
|
|
84
108
|
return undefined;
|
|
@@ -6,6 +6,7 @@ import { createIpcClient as defaultCreateIpcClient } from "../../daemon-core/src
|
|
|
6
6
|
import { getDefaultPaths } from "../../platform-core/src/paths.js";
|
|
7
7
|
import { isAiClientState } from "../../protocol/src/messages.js";
|
|
8
8
|
import { DEADLINE, raceDeadline } from "./deadline.js";
|
|
9
|
+
import { writeSessionTranscriptLink } from "./session-transcript-link.js";
|
|
9
10
|
|
|
10
11
|
// Hard ceiling on the whole connect→send→close interaction. The reporter is a
|
|
11
12
|
// child process of the wrapped AI client, and the client may wait for its hook
|
|
@@ -69,6 +70,13 @@ export async function runStateCommand(parsed, dependencies = {}) {
|
|
|
69
70
|
return { command: "state", ok: false, reason: "invalid-state" };
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
// Record this session's real transcript path (ground truth from the Claude hook
|
|
74
|
+
// payload, captured at the process entry and passed in via dependencies) so the
|
|
75
|
+
// wrapper's watcher tails THIS session's file rather than the newest in the
|
|
76
|
+
// project dir — which can bind it to a concurrent session and leak that
|
|
77
|
+
// session's interrupt/denial. Best-effort and synchronous; never blocks the hook.
|
|
78
|
+
recordTranscriptLink(sessionId, env, dependencies);
|
|
79
|
+
|
|
72
80
|
const createIpcClient = dependencies.createIpcClient ?? defaultCreateIpcClient;
|
|
73
81
|
const deadlineMs = dependencies.reportDeadlineMs ?? REPORT_DEADLINE_MS;
|
|
74
82
|
|
|
@@ -105,3 +113,105 @@ export async function runStateCommand(parsed, dependencies = {}) {
|
|
|
105
113
|
return { command: "state", ok: false, reason: "no-daemon" };
|
|
106
114
|
}
|
|
107
115
|
}
|
|
116
|
+
|
|
117
|
+
// Persist the session -> transcript binding for the wrapper's watcher. The
|
|
118
|
+
// transcript path is supplied by the caller (read from the hook payload at the
|
|
119
|
+
// process entry); when it's absent we simply skip — there is nothing to bind and
|
|
120
|
+
// guessing is exactly the bug this avoids.
|
|
121
|
+
function recordTranscriptLink(sessionId, env, dependencies) {
|
|
122
|
+
const transcriptPath = dependencies.transcriptPath;
|
|
123
|
+
if (typeof transcriptPath !== "string" || transcriptPath === "") {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const sessionDir =
|
|
128
|
+
dependencies.sessionDir ??
|
|
129
|
+
getDefaultPaths({
|
|
130
|
+
platform: dependencies.platform,
|
|
131
|
+
env,
|
|
132
|
+
homeDir: dependencies.homeDir
|
|
133
|
+
}).sessionDir;
|
|
134
|
+
writeSessionTranscriptLink({ sessionDir, sessionId, transcriptPath }, dependencies);
|
|
135
|
+
} catch {
|
|
136
|
+
// never break a hook over a best-effort binding write
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pull `transcript_path` out of a Claude hook payload (JSON on stdin). Pure and
|
|
141
|
+
// defensive: any non-JSON, missing-field, or wrong-type input yields undefined.
|
|
142
|
+
export function extractTranscriptPath(raw) {
|
|
143
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(raw);
|
|
148
|
+
const value = parsed?.transcript_path;
|
|
149
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Read the Claude hook payload from stdin and return its transcript_path. Used by
|
|
156
|
+
// the real `haya-pet state` process (a Claude hook child) — NOT by internal
|
|
157
|
+
// callers, so tests and other commands never touch stdin. Bounded and best-effort:
|
|
158
|
+
// a TTY (manual invocation) or a slow/absent payload resolves to undefined rather
|
|
159
|
+
// than ever hanging the host client's hook.
|
|
160
|
+
export function readHookTranscriptPathFromStdin(options = {}) {
|
|
161
|
+
const stdin = options.stdin ?? process.stdin;
|
|
162
|
+
const timeoutMs = options.timeoutMs ?? 400;
|
|
163
|
+
const maxBytes = options.maxBytes ?? 1_000_000;
|
|
164
|
+
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
if (!stdin || stdin.isTTY) {
|
|
167
|
+
resolve(undefined);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let data = "";
|
|
172
|
+
let settled = false;
|
|
173
|
+
let timer;
|
|
174
|
+
|
|
175
|
+
const finish = (value) => {
|
|
176
|
+
if (settled) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
settled = true;
|
|
180
|
+
if (timer) {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
stdin.removeListener("data", onData);
|
|
185
|
+
stdin.removeListener("end", onEnd);
|
|
186
|
+
stdin.removeListener("error", onError);
|
|
187
|
+
stdin.pause();
|
|
188
|
+
} catch {
|
|
189
|
+
// detaching is best-effort
|
|
190
|
+
}
|
|
191
|
+
resolve(value);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const onData = (chunk) => {
|
|
195
|
+
data += chunk;
|
|
196
|
+
if (data.length > maxBytes) {
|
|
197
|
+
finish(extractTranscriptPath(data));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const onEnd = () => finish(extractTranscriptPath(data));
|
|
201
|
+
const onError = () => finish(undefined);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
stdin.setEncoding("utf8");
|
|
205
|
+
stdin.on("data", onData);
|
|
206
|
+
stdin.on("end", onEnd);
|
|
207
|
+
stdin.on("error", onError);
|
|
208
|
+
stdin.resume();
|
|
209
|
+
timer = setTimeout(() => finish(extractTranscriptPath(data)), timeoutMs);
|
|
210
|
+
if (timer && typeof timer.unref === "function") {
|
|
211
|
+
timer.unref();
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
finish(undefined);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Records which transcript file belongs to which haya-pet session, so a wrapper's
|
|
2
|
+
// transcript watcher can tail ITS OWN session's transcript instead of guessing
|
|
3
|
+
// "the newest .jsonl in the project dir".
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: two Claude Code sessions running in the same folder share one
|
|
6
|
+
// project dir (~/.claude/projects/<sanitized-cwd>/), each with its own UUID file.
|
|
7
|
+
// Picking newest-by-mtime can bind a watcher to a DIFFERENT concurrent session's
|
|
8
|
+
// transcript — so an interrupt (or denial) in session A leaks onto idle session B.
|
|
9
|
+
// The only ground-truth source of a session's transcript path is the Claude hook
|
|
10
|
+
// payload (`transcript_path` on stdin), which the `haya-pet state` reporter sees.
|
|
11
|
+
// The reporter writes the path here (keyed by HAYA_PET_SESSION_ID); the wrapper's
|
|
12
|
+
// watcher reads it to pin to the exact file.
|
|
13
|
+
//
|
|
14
|
+
// Local-only and best-effort: every operation swallows errors so it can never
|
|
15
|
+
// break a hook or the wrapped command, and nothing here is ever sent off-device.
|
|
16
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
// Session ids are our own (`sess_<uuid>`), but sanitize defensively so the value
|
|
20
|
+
// can never escape the sessions dir or produce an invalid filename.
|
|
21
|
+
function sanitizeSessionId(sessionId) {
|
|
22
|
+
return String(sessionId).replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sessionLinkPath(sessionDir, sessionId) {
|
|
26
|
+
return join(sessionDir, `${sanitizeSessionId(sessionId)}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeSessionTranscriptLink(options = {}, dependencies = {}) {
|
|
30
|
+
const { sessionDir, sessionId, transcriptPath } = options;
|
|
31
|
+
if (!sessionDir || !sessionId || typeof transcriptPath !== "string" || transcriptPath === "") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const mkdir = dependencies.mkdirSync ?? mkdirSync;
|
|
35
|
+
const write = dependencies.writeFileSync ?? writeFileSync;
|
|
36
|
+
try {
|
|
37
|
+
mkdir(sessionDir, { recursive: true });
|
|
38
|
+
write(sessionLinkPath(sessionDir, sessionId), JSON.stringify({ transcriptPath }), "utf8");
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readSessionTranscriptLink(options = {}, dependencies = {}) {
|
|
46
|
+
const { sessionDir, sessionId } = options;
|
|
47
|
+
if (!sessionDir || !sessionId) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const read = dependencies.readFileSync ?? readFileSync;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(read(sessionLinkPath(sessionDir, sessionId), "utf8"));
|
|
53
|
+
return typeof parsed?.transcriptPath === "string" && parsed.transcriptPath !== ""
|
|
54
|
+
? parsed.transcriptPath
|
|
55
|
+
: undefined;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function removeSessionTranscriptLink(options = {}, dependencies = {}) {
|
|
62
|
+
const { sessionDir, sessionId } = options;
|
|
63
|
+
if (!sessionDir || !sessionId) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const rm = dependencies.rmSync ?? rmSync;
|
|
67
|
+
try {
|
|
68
|
+
rm(sessionLinkPath(sessionDir, sessionId), { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// best-effort cleanup; a stale link is harmless (session ids are unique per run)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
discoverTranscript,
|
|
9
9
|
watchClaudeTranscript
|
|
10
10
|
} from "../src/claude-transcript-watcher.js";
|
|
11
|
+
import { writeSessionTranscriptLink } from "../src/session-transcript-link.js";
|
|
11
12
|
|
|
12
13
|
const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
|
|
13
14
|
|
|
@@ -128,6 +129,78 @@ test("watchClaudeTranscript handles a line split across two appends", () => {
|
|
|
128
129
|
watcher.stop();
|
|
129
130
|
});
|
|
130
131
|
|
|
132
|
+
test("watchClaudeTranscript pins to the session's linked transcript, not newest-by-mtime", () => {
|
|
133
|
+
// Two concurrent sessions share one project dir. Each has its own link.
|
|
134
|
+
const projectDir = mkdtempSync(join(tmpdir(), "proj-"));
|
|
135
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
136
|
+
const fileA = join(projectDir, "a.jsonl");
|
|
137
|
+
const fileB = join(projectDir, "b.jsonl");
|
|
138
|
+
writeFileSync(fileA, "");
|
|
139
|
+
writeFileSync(fileB, "");
|
|
140
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: fileA });
|
|
141
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: fileB });
|
|
142
|
+
|
|
143
|
+
const interruptsA = [];
|
|
144
|
+
const interruptsB = [];
|
|
145
|
+
const watcherA = watchClaudeTranscript({
|
|
146
|
+
sessionId: "sess_a",
|
|
147
|
+
sessionDir,
|
|
148
|
+
onInterrupt: (e) => interruptsA.push(e),
|
|
149
|
+
...noopTimers
|
|
150
|
+
});
|
|
151
|
+
const watcherB = watchClaudeTranscript({
|
|
152
|
+
sessionId: "sess_b",
|
|
153
|
+
sessionDir,
|
|
154
|
+
onInterrupt: (e) => interruptsB.push(e),
|
|
155
|
+
...noopTimers
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Both pin to their own file, then skip to EOF.
|
|
159
|
+
watcherA._tick();
|
|
160
|
+
watcherB._tick();
|
|
161
|
+
|
|
162
|
+
// Interrupt happens in session A only.
|
|
163
|
+
appendFileSync(fileA, interrupt());
|
|
164
|
+
watcherA._tick();
|
|
165
|
+
watcherB._tick();
|
|
166
|
+
|
|
167
|
+
assert.deepEqual(interruptsA, [{ type: "interrupted" }], "session A sees its own interrupt");
|
|
168
|
+
assert.deepEqual(interruptsB, [], "session B (idle) is NOT contaminated by session A's interrupt");
|
|
169
|
+
|
|
170
|
+
watcherA.stop();
|
|
171
|
+
watcherB.stop();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("watchClaudeTranscript with a session link idles until the link appears", () => {
|
|
175
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
176
|
+
const projectDir = mkdtempSync(join(tmpdir(), "proj-"));
|
|
177
|
+
const file = join(projectDir, "s.jsonl");
|
|
178
|
+
writeFileSync(file, "");
|
|
179
|
+
|
|
180
|
+
const interrupts = [];
|
|
181
|
+
const watcher = watchClaudeTranscript({
|
|
182
|
+
sessionId: "sess_late",
|
|
183
|
+
sessionDir,
|
|
184
|
+
onInterrupt: (e) => interrupts.push(e),
|
|
185
|
+
...noopTimers
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// No link yet → nothing tailed, even if a marker is already present.
|
|
189
|
+
appendFileSync(file, interrupt());
|
|
190
|
+
watcher._tick();
|
|
191
|
+
assert.deepEqual(interrupts, [], "no guessing before the hook reports the path");
|
|
192
|
+
|
|
193
|
+
// The hook fires and records the binding; the watcher now pins (skipping to EOF,
|
|
194
|
+
// so the pre-existing marker is not replayed) and only catches NEW events.
|
|
195
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_late", transcriptPath: file });
|
|
196
|
+
watcher._tick();
|
|
197
|
+
appendFileSync(file, interrupt());
|
|
198
|
+
watcher._tick();
|
|
199
|
+
assert.deepEqual(interrupts, [{ type: "interrupted" }]);
|
|
200
|
+
|
|
201
|
+
watcher.stop();
|
|
202
|
+
});
|
|
203
|
+
|
|
131
204
|
test("watchClaudeTranscript reports an interrupt appended after it starts tailing", () => {
|
|
132
205
|
const dir = mkdtempSync(join(tmpdir(), "transcript-"));
|
|
133
206
|
const path = join(dir, "session.jsonl");
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "../../../test/harness.mjs";
|
|
6
6
|
import { watchCodexGuardianReviews } from "../src/codex-guardian-watcher.js";
|
|
7
|
+
import { writeSessionTranscriptLink } from "../src/session-transcript-link.js";
|
|
7
8
|
|
|
8
9
|
const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
|
|
9
10
|
|
|
@@ -262,6 +263,53 @@ test("watchCodexGuardianReviews ignores guardian trunks of other parents", () =>
|
|
|
262
263
|
watcher.stop();
|
|
263
264
|
});
|
|
264
265
|
|
|
266
|
+
test("watchCodexGuardianReviews binds to the LINKED main thread, not the newest main", () => {
|
|
267
|
+
const { root, dir } = makeSessionsRoot();
|
|
268
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
269
|
+
|
|
270
|
+
// Our main rollout, and a DIFFERENT concurrent session's main rollout written
|
|
271
|
+
// afterwards (so it has the newer mtime — what the old heuristic would pick).
|
|
272
|
+
const ourMain = join(dir, "rollout-main-ours.jsonl");
|
|
273
|
+
writeFileSync(ourMain, metaLine({ id: "main-ours", parent_thread_id: null, source: "cli", thread_source: "user" }));
|
|
274
|
+
writeFileSync(
|
|
275
|
+
join(dir, "rollout-main-other.jsonl"),
|
|
276
|
+
metaLine({ id: "main-other", parent_thread_id: null, source: "cli", thread_source: "user" })
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// A guardian trunk for OUR main, plus a decoy trunk for the OTHER main that is
|
|
280
|
+
// newer and already has a review turn.
|
|
281
|
+
const ourTrunk = join(dir, "rollout-guardian-ours.jsonl");
|
|
282
|
+
writeFileSync(
|
|
283
|
+
ourTrunk,
|
|
284
|
+
metaLine({ id: "g-ours", parent_thread_id: "main-ours", source: { subagent: { other: "guardian" } } })
|
|
285
|
+
);
|
|
286
|
+
writeFileSync(
|
|
287
|
+
join(dir, "rollout-guardian-other.jsonl"),
|
|
288
|
+
metaLine({ id: "g-other", parent_thread_id: "main-other", source: { subagent: { other: "guardian" } } }) +
|
|
289
|
+
reviewStarted("decoy")
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: ourMain });
|
|
293
|
+
|
|
294
|
+
const events = [];
|
|
295
|
+
const watcher = watchCodexGuardianReviews({
|
|
296
|
+
sessionsRoot: root,
|
|
297
|
+
sessionId: "sess_a",
|
|
298
|
+
sessionDir,
|
|
299
|
+
onReviewEvent: (e) => events.push(e),
|
|
300
|
+
...noopTimers
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
watcher._tick();
|
|
304
|
+
assert.deepEqual(events, [], "the newer decoy trunk (another session) is ignored");
|
|
305
|
+
|
|
306
|
+
appendFileSync(ourTrunk, reviewStarted());
|
|
307
|
+
watcher._tick();
|
|
308
|
+
assert.deepEqual(events, [{ type: "review_started" }], "our trunk is the one tailed");
|
|
309
|
+
|
|
310
|
+
watcher.stop();
|
|
311
|
+
});
|
|
312
|
+
|
|
265
313
|
test("watchCodexGuardianReviews picks up a trunk created after watching began", () => {
|
|
266
314
|
const { root, dir } = makeSessionsRoot();
|
|
267
315
|
writeFileSync(
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "../../../test/harness.mjs";
|
|
6
6
|
import { discoverCodexTranscript, watchCodexTranscript } from "../src/codex-transcript-watcher.js";
|
|
7
|
+
import { writeSessionTranscriptLink } from "../src/session-transcript-link.js";
|
|
7
8
|
|
|
8
9
|
const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
|
|
9
10
|
|
|
@@ -60,6 +61,55 @@ test("discoverCodexTranscript skips files older than session start", () => {
|
|
|
60
61
|
assert.equal(discoverCodexTranscript(root, Date.now() - 1000), undefined);
|
|
61
62
|
});
|
|
62
63
|
|
|
64
|
+
test("watchCodexTranscript pins to the session's linked rollout, not newest-by-mtime", () => {
|
|
65
|
+
// Two concurrent Codex sessions, each with its own rollout and its own link.
|
|
66
|
+
const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
|
|
67
|
+
const dir = join(root, "2026", "06", "12");
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
70
|
+
|
|
71
|
+
const fileA = join(dir, "rollout-a.jsonl");
|
|
72
|
+
const fileB = join(dir, "rollout-b.jsonl");
|
|
73
|
+
writeFileSync(fileA, sessionMeta("2026-06-12T01:00:00.000Z", "thread-a"));
|
|
74
|
+
writeFileSync(fileB, sessionMeta("2026-06-12T01:00:00.000Z", "thread-b"));
|
|
75
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: fileA });
|
|
76
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: fileB });
|
|
77
|
+
|
|
78
|
+
const eventsA = [];
|
|
79
|
+
const eventsB = [];
|
|
80
|
+
const watcherA = watchCodexTranscript({
|
|
81
|
+
sessionId: "sess_a",
|
|
82
|
+
sessionDir,
|
|
83
|
+
onToolEvent: (e) => eventsA.push(e),
|
|
84
|
+
...noopTimers
|
|
85
|
+
});
|
|
86
|
+
const watcherB = watchCodexTranscript({
|
|
87
|
+
sessionId: "sess_b",
|
|
88
|
+
sessionDir,
|
|
89
|
+
onToolEvent: (e) => eventsB.push(e),
|
|
90
|
+
...noopTimers
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Both pin to their own rollout and consume the session_meta line.
|
|
94
|
+
watcherA._tick();
|
|
95
|
+
watcherB._tick();
|
|
96
|
+
|
|
97
|
+
// Session A is interrupted.
|
|
98
|
+
appendFileSync(fileA, turnAborted());
|
|
99
|
+
watcherA._tick();
|
|
100
|
+
watcherB._tick();
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
eventsA,
|
|
104
|
+
[{ type: "turn_aborted", reason: "interrupted" }],
|
|
105
|
+
"session A sees its own interrupt"
|
|
106
|
+
);
|
|
107
|
+
assert.deepEqual(eventsB, [], "session B is NOT contaminated by session A's interrupt");
|
|
108
|
+
|
|
109
|
+
watcherA.stop();
|
|
110
|
+
watcherB.stop();
|
|
111
|
+
});
|
|
112
|
+
|
|
63
113
|
test("watchCodexTranscript reports appended tool events", () => {
|
|
64
114
|
const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
|
|
65
115
|
const path = join(dir, "session.jsonl");
|
|
@@ -3,7 +3,8 @@ import { mkdtempSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "../../../test/harness.mjs";
|
|
6
|
-
import { parseStateArgs, runStateCommand } from "../src/run-state.js";
|
|
6
|
+
import { extractTranscriptPath, parseStateArgs, runStateCommand } from "../src/run-state.js";
|
|
7
|
+
import { readSessionTranscriptLink } from "../src/session-transcript-link.js";
|
|
7
8
|
|
|
8
9
|
test("runStateCommand appends a debug line when HAYA_PET_HOOK_DEBUG is set", async () => {
|
|
9
10
|
const logPath = join(mkdtempSync(join(tmpdir(), "haya-dbg-")), "hooks.jsonl");
|
|
@@ -21,6 +22,51 @@ test("runStateCommand appends a debug line when HAYA_PET_HOOK_DEBUG is set", asy
|
|
|
21
22
|
assert.deepEqual(line, { ts: 7, state: "waiting_approval", sessionId: "s1", summary: "approval" });
|
|
22
23
|
});
|
|
23
24
|
|
|
25
|
+
test("extractTranscriptPath pulls transcript_path out of a Claude hook payload", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
extractTranscriptPath(JSON.stringify({ session_id: "x", transcript_path: "/p/a.jsonl", cwd: "/p" })),
|
|
28
|
+
"/p/a.jsonl"
|
|
29
|
+
);
|
|
30
|
+
// Defensive: junk, missing field, wrong type, and empty all yield undefined.
|
|
31
|
+
assert.equal(extractTranscriptPath("{not json"), undefined);
|
|
32
|
+
assert.equal(extractTranscriptPath(JSON.stringify({ session_id: "x" })), undefined);
|
|
33
|
+
assert.equal(extractTranscriptPath(JSON.stringify({ transcript_path: 42 })), undefined);
|
|
34
|
+
assert.equal(extractTranscriptPath(""), undefined);
|
|
35
|
+
assert.equal(extractTranscriptPath(undefined), undefined);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("runStateCommand records the session->transcript link when given a transcript path", async () => {
|
|
39
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
40
|
+
await runStateCommand(
|
|
41
|
+
{ command: "state", state: "thinking", summary: undefined, session: "sess_link" },
|
|
42
|
+
{
|
|
43
|
+
now: () => 1,
|
|
44
|
+
sessionDir,
|
|
45
|
+
transcriptPath: "/p/.claude/projects/D--p/abc.jsonl",
|
|
46
|
+
createIpcClient: async () => ({ send: async () => {}, close: async () => {} })
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
assert.equal(
|
|
51
|
+
readSessionTranscriptLink({ sessionDir, sessionId: "sess_link" }),
|
|
52
|
+
"/p/.claude/projects/D--p/abc.jsonl"
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("runStateCommand writes no link when no transcript path is supplied", async () => {
|
|
57
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
58
|
+
await runStateCommand(
|
|
59
|
+
{ command: "state", state: "thinking", summary: undefined, session: "sess_nolink" },
|
|
60
|
+
{
|
|
61
|
+
now: () => 1,
|
|
62
|
+
sessionDir,
|
|
63
|
+
createIpcClient: async () => ({ send: async () => {}, close: async () => {} })
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_nolink" }), undefined);
|
|
68
|
+
});
|
|
69
|
+
|
|
24
70
|
test("parseStateArgs reads state, summary, and session", () => {
|
|
25
71
|
assert.deepEqual(parseStateArgs(["thinking"]), {
|
|
26
72
|
command: "state",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "../../../test/harness.mjs";
|
|
6
|
+
import {
|
|
7
|
+
readSessionTranscriptLink,
|
|
8
|
+
removeSessionTranscriptLink,
|
|
9
|
+
sessionLinkPath,
|
|
10
|
+
writeSessionTranscriptLink
|
|
11
|
+
} from "../src/session-transcript-link.js";
|
|
12
|
+
|
|
13
|
+
test("write then read round-trips a session's transcript path", () => {
|
|
14
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
15
|
+
const transcriptPath = "D:\\proj\\.claude\\projects\\D--proj\\abc.jsonl";
|
|
16
|
+
|
|
17
|
+
const wrote = writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath });
|
|
18
|
+
assert.equal(wrote, true);
|
|
19
|
+
assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }), transcriptPath);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("two sessions in the same dir keep separate, independent links", () => {
|
|
23
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
24
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" });
|
|
25
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: "/p/b.jsonl" });
|
|
26
|
+
|
|
27
|
+
// The core of the bug fix: each session resolves ONLY its own transcript.
|
|
28
|
+
assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }), "/p/a.jsonl");
|
|
29
|
+
assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_b" }), "/p/b.jsonl");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("reading a session with no link returns undefined (no guessing)", () => {
|
|
33
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
34
|
+
assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "missing" }), undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("write is a no-op without the required fields", () => {
|
|
38
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
39
|
+
assert.equal(writeSessionTranscriptLink({ sessionDir, sessionId: "s" }), false);
|
|
40
|
+
assert.equal(writeSessionTranscriptLink({ sessionDir, transcriptPath: "/p/a.jsonl" }), false);
|
|
41
|
+
assert.equal(writeSessionTranscriptLink({ sessionId: "s", transcriptPath: "/p/a.jsonl" }), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("remove deletes the link and is safe when it is already gone", () => {
|
|
45
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
46
|
+
writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" });
|
|
47
|
+
const path = sessionLinkPath(sessionDir, "sess_a");
|
|
48
|
+
assert.equal(existsSync(path), true);
|
|
49
|
+
|
|
50
|
+
removeSessionTranscriptLink({ sessionDir, sessionId: "sess_a" });
|
|
51
|
+
assert.equal(existsSync(path), false);
|
|
52
|
+
// Second removal must not throw.
|
|
53
|
+
removeSessionTranscriptLink({ sessionDir, sessionId: "sess_a" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a corrupt link file reads as undefined rather than throwing", () => {
|
|
57
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
|
|
58
|
+
writeSessionTranscriptLink(
|
|
59
|
+
{ sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" },
|
|
60
|
+
{ writeFileSync: () => {} } // pretend write; nothing on disk
|
|
61
|
+
);
|
|
62
|
+
// Inject a reader returning junk to simulate a partially-written file.
|
|
63
|
+
assert.equal(
|
|
64
|
+
readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }, { readFileSync: () => "{not json" }),
|
|
65
|
+
undefined
|
|
66
|
+
);
|
|
67
|
+
});
|