@hayasaka7/haya-pet 0.2.0 → 0.2.2

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.
Files changed (30) hide show
  1. package/.github/workflows/ci.yml +75 -0
  2. package/CHANGELOG.md +112 -0
  3. package/README.md +31 -14
  4. package/apps/cli/src/haya-pet.js +110 -21
  5. package/apps/cli/test/haya-pet.test.mjs +111 -7
  6. package/apps/companion/src/main/index.js +40 -1
  7. package/apps/companion/src/renderer/task-talk-window.js +1 -1
  8. package/apps/companion/test/position-store.test.mjs +1 -1
  9. package/docs/architecture.md +33 -10
  10. package/docs/cross-os-qa.md +72 -0
  11. package/docs/known-issues.md +92 -9
  12. package/docs/troubleshooting.md +3 -1
  13. package/eslint.config.js +32 -0
  14. package/package.json +7 -1
  15. package/packages/adapters/src/codex-hooks.js +152 -0
  16. package/packages/adapters/src/codex-transcript.js +73 -0
  17. package/packages/adapters/test/codex-hooks.test.mjs +120 -0
  18. package/packages/adapters/test/codex-transcript.test.mjs +97 -0
  19. package/packages/app-state/src/state.js +10 -5
  20. package/packages/cli-core/src/codex-hook-injection.js +49 -0
  21. package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
  22. package/packages/cli-core/src/run-command.js +0 -1
  23. package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
  24. package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
  25. package/packages/daemon-core/src/approval-process-watcher.js +169 -0
  26. package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
  27. package/packages/platform-core/src/process-snapshot.js +88 -0
  28. package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
  29. package/packages/session-core/src/bubble-view.js +10 -7
  30. package/packages/session-core/test/bubble-view.test.mjs +30 -5
@@ -0,0 +1,73 @@
1
+ // Pure parser for Codex session JSONL records. This is the L3 fallback for live
2
+ // tool activity because Codex PreToolUse hooks may not fire in some builds, while
3
+ // the session transcript still records every tool call and tool result.
4
+
5
+ const EDIT_TOOLS = new Set(["apply_patch"]);
6
+
7
+ export function parseCodexTranscriptLine(line, options = {}) {
8
+ let entry;
9
+ try {
10
+ entry = JSON.parse(line);
11
+ } catch {
12
+ return undefined;
13
+ }
14
+
15
+ if (entry?.type !== "response_item") {
16
+ return undefined;
17
+ }
18
+
19
+ // Skip records from before the current session (used when replaying a
20
+ // freshly-discovered transcript so an earlier session's tool calls don't
21
+ // masquerade as live activity). Records without a parseable timestamp are
22
+ // kept — losing live events is worse than a rare stale one.
23
+ const minTimestampMs = options.minTimestampMs ?? 0;
24
+ if (minTimestampMs > 0 && typeof entry.timestamp === "string") {
25
+ const timestampMs = Date.parse(entry.timestamp);
26
+ if (Number.isFinite(timestampMs) && timestampMs < minTimestampMs) {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ const payload = entry.payload;
32
+ if (!payload || typeof payload !== "object") {
33
+ return undefined;
34
+ }
35
+
36
+ if (payload.type === "function_call" || payload.type === "custom_tool_call") {
37
+ const toolName = typeof payload.name === "string" ? payload.name : undefined;
38
+ const toolCallId = typeof payload.call_id === "string" ? payload.call_id : undefined;
39
+ if (!toolName || !toolCallId) {
40
+ return undefined;
41
+ }
42
+ return {
43
+ type: "tool_started",
44
+ toolCallId,
45
+ toolName,
46
+ state: EDIT_TOOLS.has(toolName) ? "editing_files" : "running_tool"
47
+ };
48
+ }
49
+
50
+ if (payload.type === "function_call_output" || payload.type === "custom_tool_call_output") {
51
+ const toolCallId = typeof payload.call_id === "string" ? payload.call_id : undefined;
52
+ if (!toolCallId) {
53
+ return undefined;
54
+ }
55
+ return { type: "tool_finished", toolCallId };
56
+ }
57
+
58
+ return undefined;
59
+ }
60
+
61
+ export function parseCodexTranscriptLines(lines, options = {}) {
62
+ const events = [];
63
+ for (const line of lines) {
64
+ if (typeof line !== "string" || line.trim() === "") {
65
+ continue;
66
+ }
67
+ const event = parseCodexTranscriptLine(line, options);
68
+ if (event) {
69
+ events.push(event);
70
+ }
71
+ }
72
+ return events;
73
+ }
@@ -0,0 +1,120 @@
1
+ // SPIKE tests for the Codex hook adapter prototype.
2
+ import assert from "node:assert/strict";
3
+ import { test } from "../../../test/harness.mjs";
4
+ import {
5
+ buildCodexHookSettings,
6
+ mapCodexEventToState,
7
+ serializeCodexHooksToml
8
+ } from "../src/codex-hooks.js";
9
+
10
+ test("mapCodexEventToState covers activity events", () => {
11
+ assert.equal(mapCodexEventToState("UserPromptSubmit"), "thinking");
12
+ assert.equal(mapCodexEventToState("PostToolUse"), "thinking");
13
+ assert.equal(mapCodexEventToState("PermissionRequest"), "waiting_approval");
14
+ assert.equal(mapCodexEventToState("PreCompact"), "compacting");
15
+ assert.equal(mapCodexEventToState("PostCompact"), "thinking");
16
+ assert.equal(mapCodexEventToState("SubagentStart"), "running_tool");
17
+ assert.equal(mapCodexEventToState("SubagentStop"), "thinking");
18
+ assert.equal(mapCodexEventToState("Stop"), "idle");
19
+ assert.equal(mapCodexEventToState("Unknown"), undefined);
20
+ });
21
+
22
+ test("mapCodexEventToState branches PreToolUse on tool name (apply_patch vs command)", () => {
23
+ assert.equal(mapCodexEventToState("PreToolUse", "apply_patch"), "editing_files");
24
+ assert.equal(mapCodexEventToState("PreToolUse", "shell_command"), "running_tool");
25
+ assert.equal(mapCodexEventToState("PreToolUse", "read_file"), "running_tool");
26
+ });
27
+
28
+ test("Stop is the only idle signal — SubagentStop stays working", () => {
29
+ // Regression guard for the key Codex-vs-Claude difference: a subagent finishing
30
+ // mid-turn must NOT flip the pet to idle.
31
+ assert.notEqual(mapCodexEventToState("SubagentStop"), "idle");
32
+ assert.equal(mapCodexEventToState("Stop"), "idle");
33
+ });
34
+
35
+ test("buildCodexHookSettings bakes node + cli, no volatile session id", () => {
36
+ const settings = buildCodexHookSettings({
37
+ nodePath: "/usr/bin/node",
38
+ cliPath: "/app/haya-pet.js"
39
+ });
40
+
41
+ const cmd = settings.hooks.UserPromptSubmit[0].hooks[0].command;
42
+ assert.equal(settings.hooks.UserPromptSubmit[0].hooks[0].type, "command");
43
+ // Program (node) must be UNQUOTED and must not lead with a quote — cmd /c on
44
+ // Windows strips a leading quote and breaks the hook. The cli path is quoted.
45
+ assert.doesNotMatch(cmd, /^"/);
46
+ assert.match(cmd, /^\/usr\/bin\/node /);
47
+ assert.match(cmd, /"\/app\/haya-pet\.js"/);
48
+ assert.match(cmd, /state thinking$/);
49
+ assert.doesNotMatch(JSON.stringify(settings), /--session/);
50
+ });
51
+
52
+ test("buildCodexHookSettings is stable across calls (for hook-trust caching)", () => {
53
+ const a = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
54
+ const b = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
55
+ assert.deepEqual(a, b);
56
+ });
57
+
58
+ test("buildCodexHookSettings splits PreToolUse into edit + command matchers", () => {
59
+ const pre = buildCodexHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PreToolUse;
60
+ assert.equal(pre.length, 2);
61
+ const edit = pre.find((e) => /editing_files/.test(e.hooks[0].command));
62
+ const other = pre.find((e) => /running_tool/.test(e.hooks[0].command));
63
+ assert.equal(edit.matcher, "apply_patch");
64
+ assert.equal(other.matcher, "shell_command");
65
+ });
66
+
67
+ test("no matcher uses look-around (Codex's Rust regex crate rejects it)", () => {
68
+ // Regression guard: a `(?!…)` / `(?=…)` matcher is a hard parse error in Codex
69
+ // and disables that hook. Keep all matchers look-around-free.
70
+ const settings = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
71
+ for (const entries of Object.values(settings.hooks)) {
72
+ for (const entry of entries) {
73
+ if (entry.matcher !== undefined) {
74
+ assert.doesNotMatch(entry.matcher, /\(\?[=!<]/, `look-around in matcher "${entry.matcher}"`);
75
+ }
76
+ }
77
+ }
78
+ });
79
+
80
+ test("serializeCodexHooksToml emits [[hooks.X]] tables with unquoted program", () => {
81
+ const settings = buildCodexHookSettings({
82
+ nodePath: "C:\\nodedir\\node.exe",
83
+ cliPath: "C:\\app\\haya-pet.js"
84
+ });
85
+ const toml = serializeCodexHooksToml(settings, { header: "test header" });
86
+
87
+ assert.match(toml, /^# test header\n/);
88
+ assert.match(toml, /\[\[hooks\.UserPromptSubmit\]\]/);
89
+ assert.match(toml, /\[\[hooks\.UserPromptSubmit\.hooks\]\]/);
90
+ assert.match(toml, /type = "command"/);
91
+ // matcher present for PreToolUse
92
+ assert.match(toml, /matcher = "apply_patch"/);
93
+ // The command value (a TOML basic string) must NOT start with an escaped quote
94
+ // right after the opening quote — i.e. the program is unquoted: command = "C:\\...
95
+ const cmdLine = toml.split("\n").find((l) => l.startsWith("command ="));
96
+ assert.doesNotMatch(cmdLine, /^command = "\\"/);
97
+ // Backslashes are TOML-escaped (doubled).
98
+ assert.match(toml, /C:\\\\nodedir\\\\node\.exe/);
99
+ });
100
+
101
+ test("serializeCodexHooksToml round-trips backslashes/quotes safely", () => {
102
+ const settings = { hooks: { Stop: [{ hooks: [{ type: "command", command: 'a\\b "c"' }] }] } };
103
+ const toml = serializeCodexHooksToml(settings);
104
+ // a\b "c" -> "a\\b \"c\""
105
+ assert.match(toml, /command = "a\\\\b \\"c\\""/);
106
+ });
107
+
108
+ test("buildCodexHookSettings includes Codex's event set (and omits Claude-only events)", () => {
109
+ const settings = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
110
+ for (const event of [
111
+ "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest",
112
+ "PreCompact", "PostCompact", "SubagentStart", "SubagentStop", "Stop"
113
+ ]) {
114
+ assert.ok(settings.hooks[event], `missing hook event ${event}`);
115
+ }
116
+ // Claude-only events Codex does not emit must not be registered.
117
+ for (const event of ["Notification", "PermissionDenied", "PostToolUseFailure", "StopFailure"]) {
118
+ assert.equal(settings.hooks[event], undefined, `unexpected Claude-only event ${event}`);
119
+ }
120
+ });
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { parseCodexTranscriptLine, parseCodexTranscriptLines } from "../src/codex-transcript.js";
4
+
5
+ test("parseCodexTranscriptLine reports shell tool starts as running tools", () => {
6
+ const event = parseCodexTranscriptLine(JSON.stringify({
7
+ type: "response_item",
8
+ payload: {
9
+ type: "function_call",
10
+ name: "shell_command",
11
+ call_id: "call_shell"
12
+ }
13
+ }));
14
+
15
+ assert.deepEqual(event, {
16
+ type: "tool_started",
17
+ toolCallId: "call_shell",
18
+ toolName: "shell_command",
19
+ state: "running_tool"
20
+ });
21
+ });
22
+
23
+ test("parseCodexTranscriptLine reports apply_patch starts as file editing", () => {
24
+ const event = parseCodexTranscriptLine(JSON.stringify({
25
+ type: "response_item",
26
+ payload: {
27
+ type: "custom_tool_call",
28
+ name: "apply_patch",
29
+ call_id: "call_patch"
30
+ }
31
+ }));
32
+
33
+ assert.deepEqual(event, {
34
+ type: "tool_started",
35
+ toolCallId: "call_patch",
36
+ toolName: "apply_patch",
37
+ state: "editing_files"
38
+ });
39
+ });
40
+
41
+ test("parseCodexTranscriptLine reports tool output as tool finished", () => {
42
+ const event = parseCodexTranscriptLine(JSON.stringify({
43
+ type: "response_item",
44
+ payload: {
45
+ type: "function_call_output",
46
+ call_id: "call_shell",
47
+ output: "done"
48
+ }
49
+ }));
50
+
51
+ assert.deepEqual(event, {
52
+ type: "tool_finished",
53
+ toolCallId: "call_shell"
54
+ });
55
+ });
56
+
57
+ test("parseCodexTranscriptLines ignores malformed and unrelated lines", () => {
58
+ const lines = [
59
+ "",
60
+ "{not-json",
61
+ JSON.stringify({ type: "event_msg", payload: { type: "agent_message" } }),
62
+ JSON.stringify({ type: "response_item", payload: { type: "function_call", name: "shell_command", call_id: "call_1" } })
63
+ ];
64
+
65
+ assert.deepEqual(parseCodexTranscriptLines(lines), [
66
+ {
67
+ type: "tool_started",
68
+ toolCallId: "call_1",
69
+ toolName: "shell_command",
70
+ state: "running_tool"
71
+ }
72
+ ]);
73
+ });
74
+
75
+ test("parseCodexTranscriptLines can skip records older than the session start", () => {
76
+ const lines = [
77
+ JSON.stringify({
78
+ timestamp: "2026-06-08T10:59:59.000Z",
79
+ type: "response_item",
80
+ payload: { type: "function_call", name: "shell_command", call_id: "call_old" }
81
+ }),
82
+ JSON.stringify({
83
+ timestamp: "2026-06-08T11:00:01.000Z",
84
+ type: "response_item",
85
+ payload: { type: "function_call", name: "shell_command", call_id: "call_new" }
86
+ })
87
+ ];
88
+
89
+ assert.deepEqual(parseCodexTranscriptLines(lines, { minTimestampMs: Date.parse("2026-06-08T11:00:00.000Z") }), [
90
+ {
91
+ type: "tool_started",
92
+ toolCallId: "call_new",
93
+ toolName: "shell_command",
94
+ state: "running_tool"
95
+ }
96
+ ]);
97
+ });
@@ -13,23 +13,28 @@ export function createDefaultPositionState() {
13
13
  settings: {
14
14
  displayMode: "hybrid",
15
15
  attachBubblesToTerminals: true,
16
- claudeHooks: false
16
+ hooksEnabled: false
17
17
  }
18
18
  };
19
19
  }
20
20
 
21
- export function setClaudeHooksEnabled(state, enabled) {
21
+ // Global live-status hooks toggle — covers every hook-capable client (Claude Code,
22
+ // Codex). One switch keeps the user-facing model simple; per-run env vars still
23
+ // override it (see resolveHooksEnabled in the CLI).
24
+ export function setHooksEnabled(state, enabled) {
22
25
  return {
23
26
  ...state,
24
27
  settings: {
25
28
  ...state.settings,
26
- claudeHooks: Boolean(enabled)
29
+ hooksEnabled: Boolean(enabled)
27
30
  }
28
31
  };
29
32
  }
30
33
 
31
- export function getClaudeHooksEnabled(state) {
32
- return Boolean(state?.settings?.claudeHooks);
34
+ export function getHooksEnabled(state) {
35
+ // Back-compat: honor the legacy Claude-only `claudeHooks` key if a user enabled
36
+ // it before the toggle went global.
37
+ return Boolean(state?.settings?.hooksEnabled ?? state?.settings?.claudeHooks);
33
38
  }
34
39
 
35
40
  export function updateGlobalPetPosition(state, position) {
@@ -0,0 +1,49 @@
1
+ // Resolves stable paths, builds the Codex hook settings, serializes them to TOML,
2
+ // and writes them to a STABLE named-profile file inside CODEX_HOME. The wrapper
3
+ // then launches `codex -p <profileName>`, which layers these hooks ON TOP of the
4
+ // user's base config (auth/model/MCP untouched) and is inert for any codex run
5
+ // that doesn't pass `-p <profileName>`.
6
+ //
7
+ // Like the Claude injector, the file path and command strings are kept identical
8
+ // across sessions so Codex's hook-trust review only needs approving once. fnm hands
9
+ // out a per-shell symlink for process.execPath that dies when the launching shell
10
+ // exits, so we realpath it before baking it into the hook command.
11
+ import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { buildCodexHookSettings, serializeCodexHooksToml } from "../../adapters/src/codex-hooks.js";
16
+
17
+ const DEFAULT_CLI_PATH = fileURLToPath(new URL("../../../apps/cli/src/haya-pet.js", import.meta.url));
18
+ const PROFILE_NAME = "haya-pet";
19
+ const PROFILE_FILE = `${PROFILE_NAME}.config.toml`;
20
+
21
+ export function injectCodexHooks({ nodePath, cliPath, codexHome, env = process.env } = {}) {
22
+ const resolvedNode = nodePath ?? safeRealpath(process.execPath);
23
+ const resolvedCli = cliPath ?? safeRealpath(DEFAULT_CLI_PATH);
24
+ const home = codexHome ?? env.CODEX_HOME ?? join(homedir(), ".codex");
25
+
26
+ const settings = buildCodexHookSettings({ nodePath: resolvedNode, cliPath: resolvedCli });
27
+ const toml = serializeCodexHooksToml(settings, {
28
+ header: "haya-pet live-status hooks profile. Managed by haya-pet; safe to delete."
29
+ });
30
+
31
+ // A fixed path with session-independent content: concurrent sessions just
32
+ // rewrite identical bytes, and the hooks stay "trusted" across launches.
33
+ mkdirSync(home, { recursive: true });
34
+ const profilePath = join(home, PROFILE_FILE);
35
+ writeFileSync(profilePath, toml, "utf8");
36
+
37
+ // The profile file is stable and reusable on purpose — leaving it in place is
38
+ // what lets Codex remember the hooks are trusted. cleanup is a no-op kept for
39
+ // API symmetry with the caller's finally block.
40
+ return { profileName: PROFILE_NAME, profilePath, cleanup: () => {} };
41
+ }
42
+
43
+ function safeRealpath(target) {
44
+ try {
45
+ return realpathSync(target);
46
+ } catch {
47
+ return target;
48
+ }
49
+ }
@@ -0,0 +1,160 @@
1
+ // Tails Codex session JSONL and reports tool start/finish activity. Codex hooks
2
+ // cover turn lifecycle, but the transcript is the reliable source for tool use
3
+ // when PreToolUse is unavailable.
4
+ import {
5
+ closeSync,
6
+ existsSync,
7
+ openSync,
8
+ readdirSync,
9
+ readSync,
10
+ statSync
11
+ } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { parseCodexTranscriptLines } from "../../adapters/src/codex-transcript.js";
14
+
15
+ const DEFAULT_POLL_MS = 700;
16
+ const MTIME_SKEW_MS = 2000;
17
+
18
+ export function watchCodexTranscript(options = {}) {
19
+ const {
20
+ homeDir = process.env.USERPROFILE || process.env.HOME,
21
+ startedAt = 0,
22
+ onToolEvent = () => {},
23
+ pollIntervalMs = DEFAULT_POLL_MS,
24
+ sessionsRoot,
25
+ transcriptPath: fixedPath,
26
+ setInterval: setIntervalFn = setInterval,
27
+ clearInterval: clearIntervalFn = clearInterval
28
+ } = options;
29
+
30
+ const root = sessionsRoot ?? (homeDir ? join(homeDir, ".codex", "sessions") : undefined);
31
+ const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
32
+
33
+ let transcriptPath = fixedPath;
34
+ let offset = 0;
35
+ let carry = "";
36
+
37
+ const tick = () => {
38
+ try {
39
+ if (!transcriptPath) {
40
+ transcriptPath = discoverCodexTranscript(root, minMtime);
41
+ if (!transcriptPath) {
42
+ return;
43
+ }
44
+ // Replay from the start rather than skipping to the end: Codex may
45
+ // have written the session's first tool calls before our first poll,
46
+ // and skipping them would lose the initial running-tool status. The
47
+ // per-record timestamp filter below keeps an earlier session's records
48
+ // (in a resumed/rotated file) from replaying as live activity.
49
+ }
50
+
51
+ const size = safeSize(transcriptPath);
52
+ if (size <= offset) {
53
+ if (size < offset) {
54
+ offset = size;
55
+ carry = "";
56
+ }
57
+ return;
58
+ }
59
+
60
+ const chunk = readRange(transcriptPath, offset, size);
61
+ offset = size;
62
+
63
+ const lines = (carry + chunk).split("\n");
64
+ carry = lines.pop() ?? "";
65
+
66
+ for (const event of parseCodexTranscriptLines(lines, { minTimestampMs: startedAt })) {
67
+ onToolEvent(event);
68
+ }
69
+ } catch {
70
+ // best-effort: transcript surprises must never crash the wrapper
71
+ }
72
+ };
73
+
74
+ const timer = setIntervalFn(tick, pollIntervalMs);
75
+ if (timer && typeof timer.unref === "function") {
76
+ timer.unref();
77
+ }
78
+
79
+ return {
80
+ stop() {
81
+ clearIntervalFn(timer);
82
+ },
83
+ _tick: tick
84
+ };
85
+ }
86
+
87
+ export function discoverCodexTranscript(root, minMtime = 0) {
88
+ if (!root || !existsSync(root)) {
89
+ return undefined;
90
+ }
91
+
92
+ let newest;
93
+ for (const file of listJsonlFiles(root)) {
94
+ const mtime = safeMtime(file);
95
+ if (mtime < minMtime) {
96
+ continue;
97
+ }
98
+ if (!newest || mtime > newest.mtime) {
99
+ newest = { file, mtime };
100
+ }
101
+ }
102
+ return newest?.file;
103
+ }
104
+
105
+ function listJsonlFiles(root) {
106
+ const files = [];
107
+ const stack = [root];
108
+
109
+ while (stack.length > 0) {
110
+ const dir = stack.pop();
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(dir, { withFileTypes: true });
114
+ } catch {
115
+ continue;
116
+ }
117
+
118
+ for (const entry of entries) {
119
+ const full = join(dir, entry.name);
120
+ if (entry.isDirectory()) {
121
+ stack.push(full);
122
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
123
+ files.push(full);
124
+ }
125
+ }
126
+ }
127
+
128
+ return files;
129
+ }
130
+
131
+ function safeSize(path) {
132
+ try {
133
+ return statSync(path).size;
134
+ } catch {
135
+ return 0;
136
+ }
137
+ }
138
+
139
+ function safeMtime(path) {
140
+ try {
141
+ return statSync(path).mtimeMs;
142
+ } catch {
143
+ return 0;
144
+ }
145
+ }
146
+
147
+ function readRange(path, start, end) {
148
+ const length = end - start;
149
+ if (length <= 0) {
150
+ return "";
151
+ }
152
+ const fd = openSync(path, "r");
153
+ try {
154
+ const buffer = Buffer.alloc(length);
155
+ const bytesRead = readSync(fd, buffer, 0, length, start);
156
+ return buffer.toString("utf8", 0, bytesRead);
157
+ } finally {
158
+ closeSync(fd);
159
+ }
160
+ }
@@ -256,7 +256,6 @@ async function runObservedCommand({
256
256
  registered = true;
257
257
  if (pendingState) {
258
258
  emitState(pendingState);
259
- pendingState = undefined;
260
259
  }
261
260
 
262
261
  await sendProtocolMessage(send, { type: "heartbeat", sessionId, updatedAt: now() });
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "../../../test/harness.mjs";
6
+ import { injectCodexHooks } from "../src/codex-hook-injection.js";
7
+
8
+ test("injectCodexHooks writes a stable profile into CODEX_HOME and returns its name", () => {
9
+ const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
10
+ try {
11
+ const result = injectCodexHooks({
12
+ nodePath: "C:\\nodedir\\node.exe",
13
+ cliPath: "C:\\app\\haya-pet.js",
14
+ codexHome: home
15
+ });
16
+
17
+ assert.equal(result.profileName, "haya-pet");
18
+ assert.equal(result.profilePath, join(home, "haya-pet.config.toml"));
19
+
20
+ const toml = readFileSync(result.profilePath, "utf8");
21
+ assert.match(toml, /\[\[hooks\.UserPromptSubmit\]\]/);
22
+ assert.match(toml, /state thinking/);
23
+ // Program unquoted (cmd /c strips a leading quote on Windows).
24
+ const cmdLine = toml.split("\n").find((l) => l.startsWith("command ="));
25
+ assert.doesNotMatch(cmdLine, /^command = "\\"/);
26
+ } finally {
27
+ rmSync(home, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ test("injectCodexHooks honors CODEX_HOME from env and is stable across calls", () => {
32
+ const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
33
+ try {
34
+ const opts = { nodePath: "n", cliPath: "c", env: { CODEX_HOME: home } };
35
+ const a = injectCodexHooks(opts);
36
+ const first = readFileSync(a.profilePath, "utf8");
37
+ const b = injectCodexHooks(opts);
38
+ const second = readFileSync(b.profilePath, "utf8");
39
+
40
+ assert.equal(a.profilePath, join(home, "haya-pet.config.toml"));
41
+ assert.equal(first, second, "stable content keeps Codex hook-trust cached");
42
+ } finally {
43
+ rmSync(home, { recursive: true, force: true });
44
+ }
45
+ });