@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.
- package/.github/workflows/ci.yml +75 -0
- package/CHANGELOG.md +112 -0
- package/README.md +31 -14
- package/apps/cli/src/haya-pet.js +110 -21
- package/apps/cli/test/haya-pet.test.mjs +111 -7
- package/apps/companion/src/main/index.js +40 -1
- package/apps/companion/src/renderer/task-talk-window.js +1 -1
- package/apps/companion/test/position-store.test.mjs +1 -1
- package/docs/architecture.md +33 -10
- package/docs/cross-os-qa.md +72 -0
- package/docs/known-issues.md +92 -9
- package/docs/troubleshooting.md +3 -1
- package/eslint.config.js +32 -0
- package/package.json +7 -1
- package/packages/adapters/src/codex-hooks.js +152 -0
- package/packages/adapters/src/codex-transcript.js +73 -0
- package/packages/adapters/test/codex-hooks.test.mjs +120 -0
- package/packages/adapters/test/codex-transcript.test.mjs +97 -0
- package/packages/app-state/src/state.js +10 -5
- package/packages/cli-core/src/codex-hook-injection.js +49 -0
- package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
- package/packages/cli-core/src/run-command.js +0 -1
- package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
- package/packages/daemon-core/src/approval-process-watcher.js +169 -0
- package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
- package/packages/platform-core/src/process-snapshot.js +88 -0
- package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
- package/packages/session-core/src/bubble-view.js +10 -7
- 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
|
-
|
|
16
|
+
hooksEnabled: false
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
29
|
+
hooksEnabled: Boolean(enabled)
|
|
27
30
|
}
|
|
28
31
|
};
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
export function
|
|
32
|
-
|
|
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
|
+
}
|
|
@@ -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
|
+
});
|