@hayasaka7/haya-pet 0.2.4 → 0.2.6

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.
@@ -0,0 +1,136 @@
1
+ // Tails the Codex guardian-review trunk rollout and reports review lifecycle
2
+ // events. With `approvals_reviewer = auto_review` ("Approve for me"), Codex
3
+ // never shows the human approval UI for guardian-routed requests — the
4
+ // PermissionRequest hook fires at request creation, then a guardian subagent
5
+ // decides. No hook fires when the review starts or finishes and the
6
+ // GuardianAssessment events are not persisted to the main rollout, so the
7
+ // guardian's own rollout (one trunk per parent thread, one turn per review) is
8
+ // the only observable, event-backed signal. This watcher exists so the pet can
9
+ // show "reviewing" during the auto-review instead of a false "waiting for
10
+ // approval", without ever clearing a real pending approval on a guess.
11
+ import { join } from "node:path";
12
+ import {
13
+ classifyCodexSessionMeta,
14
+ parseGuardianTranscriptLines
15
+ } from "../../adapters/src/codex-guardian.js";
16
+ import { listJsonlFiles, readFirstLine, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
17
+
18
+ const DEFAULT_POLL_MS = 700;
19
+ const MTIME_SKEW_MS = 2000;
20
+
21
+ export function watchCodexGuardianReviews(options = {}) {
22
+ const {
23
+ homeDir = process.env.USERPROFILE || process.env.HOME,
24
+ startedAt = 0,
25
+ onReviewEvent = () => {},
26
+ pollIntervalMs = DEFAULT_POLL_MS,
27
+ sessionsRoot,
28
+ setInterval: setIntervalFn = setInterval,
29
+ clearInterval: clearIntervalFn = clearInterval
30
+ } = options;
31
+
32
+ const root = sessionsRoot ?? (homeDir ? join(homeDir, ".codex", "sessions") : undefined);
33
+ const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
34
+
35
+ // session_meta classifications are immutable once written, so cache them by
36
+ // path. A file with no complete first line yet is NOT cached — it is retried
37
+ // on the next poll (the rollout may still be flushing).
38
+ const metaByPath = new Map();
39
+ let mainThreadId;
40
+ let trunkPath;
41
+ let offset = 0;
42
+ let carry = "";
43
+
44
+ const classify = (file) => {
45
+ if (metaByPath.has(file)) {
46
+ return metaByPath.get(file);
47
+ }
48
+ const firstLine = readFirstLine(file);
49
+ if (firstLine === undefined) {
50
+ return undefined;
51
+ }
52
+ const meta = classifyCodexSessionMeta(firstLine) ?? null;
53
+ metaByPath.set(file, meta);
54
+ return meta;
55
+ };
56
+
57
+ const discoverTrunk = () => {
58
+ let newestMain;
59
+ let newestTrunk;
60
+
61
+ for (const file of listJsonlFiles(root)) {
62
+ const mtime = safeMtime(file);
63
+ if (mtime < minMtime) {
64
+ continue;
65
+ }
66
+ const meta = classify(file);
67
+ if (!meta) {
68
+ continue;
69
+ }
70
+
71
+ if (meta.kind === "main" && meta.threadId && (!newestMain || mtime > newestMain.mtime)) {
72
+ newestMain = { threadId: meta.threadId, mtime };
73
+ }
74
+ if (meta.kind === "guardian" && (!newestTrunk || mtime > newestTrunk.mtime)) {
75
+ newestTrunk = { file, parentThreadId: meta.parentThreadId, mtime };
76
+ }
77
+ }
78
+
79
+ // The guardian trunk only appears at the first review, usually long after
80
+ // the main rollout — bind the main thread first, then match the trunk to
81
+ // it so another session's (or a collab subagent's) reviews are ignored.
82
+ mainThreadId = mainThreadId ?? newestMain?.threadId;
83
+ if (mainThreadId && newestTrunk?.parentThreadId === mainThreadId) {
84
+ // Replay the trunk from the start: the first review is usually still in
85
+ // progress when we find the file, and the per-record timestamp filter
86
+ // keeps an earlier session's reviews from replaying as live events.
87
+ trunkPath = newestTrunk.file;
88
+ offset = 0;
89
+ carry = "";
90
+ }
91
+ };
92
+
93
+ const tick = () => {
94
+ try {
95
+ if (!trunkPath) {
96
+ discoverTrunk();
97
+ if (!trunkPath) {
98
+ return;
99
+ }
100
+ }
101
+
102
+ const size = safeSize(trunkPath);
103
+ if (size <= offset) {
104
+ if (size < offset) {
105
+ offset = size;
106
+ carry = "";
107
+ }
108
+ return;
109
+ }
110
+
111
+ const chunk = readRange(trunkPath, offset, size);
112
+ offset = size;
113
+
114
+ const lines = (carry + chunk).split("\n");
115
+ carry = lines.pop() ?? "";
116
+
117
+ for (const event of parseGuardianTranscriptLines(lines, { minTimestampMs: startedAt })) {
118
+ onReviewEvent(event);
119
+ }
120
+ } catch {
121
+ // best-effort: rollout surprises must never crash the wrapper
122
+ }
123
+ };
124
+
125
+ const timer = setIntervalFn(tick, pollIntervalMs);
126
+ if (timer && typeof timer.unref === "function") {
127
+ timer.unref();
128
+ }
129
+
130
+ return {
131
+ stop() {
132
+ clearIntervalFn(timer);
133
+ },
134
+ _tick: tick
135
+ };
136
+ }
@@ -0,0 +1,88 @@
1
+ // Shared best-effort filesystem helpers for tailing Codex rollout JSONL files
2
+ // (~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl). Used by the main transcript
3
+ // watcher and the guardian-review watcher. Every helper swallows fs errors —
4
+ // rollout surprises must never crash the wrapper.
5
+ import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ // A session_meta first line is normally a few KB, but guardian trunks embed the
9
+ // reviewer's full base instructions (~10 KB observed); leave generous headroom.
10
+ const FIRST_LINE_MAX_BYTES = 262_144;
11
+
12
+ export function listJsonlFiles(root) {
13
+ const files = [];
14
+ if (!root || !existsSync(root)) {
15
+ return files;
16
+ }
17
+
18
+ const stack = [root];
19
+ while (stack.length > 0) {
20
+ const dir = stack.pop();
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(dir, { withFileTypes: true });
24
+ } catch {
25
+ continue;
26
+ }
27
+
28
+ for (const entry of entries) {
29
+ const full = join(dir, entry.name);
30
+ if (entry.isDirectory()) {
31
+ stack.push(full);
32
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
33
+ files.push(full);
34
+ }
35
+ }
36
+ }
37
+
38
+ return files;
39
+ }
40
+
41
+ export function safeSize(path) {
42
+ try {
43
+ return statSync(path).size;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ }
48
+
49
+ export function safeMtime(path) {
50
+ try {
51
+ return statSync(path).mtimeMs;
52
+ } catch {
53
+ return 0;
54
+ }
55
+ }
56
+
57
+ export function readRange(path, start, end) {
58
+ const length = end - start;
59
+ if (length <= 0) {
60
+ return "";
61
+ }
62
+ const fd = openSync(path, "r");
63
+ try {
64
+ const buffer = Buffer.alloc(length);
65
+ const bytesRead = readSync(fd, buffer, 0, length, start);
66
+ return buffer.toString("utf8", 0, bytesRead);
67
+ } finally {
68
+ closeSync(fd);
69
+ }
70
+ }
71
+
72
+ // First newline-terminated line of a file, or undefined while none exists yet
73
+ // (a rollout that was just created and not flushed). Callers must treat
74
+ // undefined as "retry later", never as a final classification.
75
+ export function readFirstLine(path, maxBytes = FIRST_LINE_MAX_BYTES) {
76
+ let chunk;
77
+ try {
78
+ chunk = readRange(path, 0, Math.min(safeSize(path), maxBytes));
79
+ } catch {
80
+ return undefined;
81
+ }
82
+
83
+ const newlineIndex = chunk.indexOf("\n");
84
+ if (newlineIndex === -1) {
85
+ return undefined;
86
+ }
87
+ return chunk.slice(0, newlineIndex);
88
+ }
@@ -1,16 +1,10 @@
1
1
  // Tails Codex session JSONL and reports tool start/finish activity. Codex hooks
2
2
  // cover turn lifecycle, but the transcript is the reliable source for tool use
3
3
  // when PreToolUse is unavailable.
4
- import {
5
- closeSync,
6
- existsSync,
7
- openSync,
8
- readdirSync,
9
- readSync,
10
- statSync
11
- } from "node:fs";
4
+ import { existsSync } from "node:fs";
12
5
  import { join } from "node:path";
13
6
  import { parseCodexTranscriptLines } from "../../adapters/src/codex-transcript.js";
7
+ import { listJsonlFiles, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
14
8
 
15
9
  const DEFAULT_POLL_MS = 700;
16
10
  const MTIME_SKEW_MS = 2000;
@@ -101,60 +95,3 @@ export function discoverCodexTranscript(root, minMtime = 0) {
101
95
  }
102
96
  return newest?.file;
103
97
  }
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,217 @@
1
+ import assert from "node:assert/strict";
2
+ import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "../../../test/harness.mjs";
6
+ import { watchCodexGuardianReviews } from "../src/codex-guardian-watcher.js";
7
+
8
+ const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
9
+
10
+ function metaLine(payload) {
11
+ return `${JSON.stringify({ type: "session_meta", payload })}\n`;
12
+ }
13
+
14
+ function reviewStarted(turnId = "turn-1", timestamp) {
15
+ return `${JSON.stringify({
16
+ ...(timestamp ? { timestamp } : {}),
17
+ type: "event_msg",
18
+ payload: { type: "task_started", turn_id: turnId }
19
+ })}\n`;
20
+ }
21
+
22
+ function reviewFinished(outcome, turnId = "turn-1") {
23
+ return `${JSON.stringify({
24
+ type: "event_msg",
25
+ payload: {
26
+ type: "task_complete",
27
+ turn_id: turnId,
28
+ last_agent_message: JSON.stringify({ outcome })
29
+ }
30
+ })}\n`;
31
+ }
32
+
33
+ function makeSessionsRoot() {
34
+ const root = mkdtempSync(join(tmpdir(), "codex-guardian-"));
35
+ const dir = join(root, "2026", "06", "12");
36
+ mkdirSync(dir, { recursive: true });
37
+ return { root, dir };
38
+ }
39
+
40
+ test("watchCodexGuardianReviews tails the guardian trunk of the main session", () => {
41
+ const { root, dir } = makeSessionsRoot();
42
+ writeFileSync(
43
+ join(dir, "rollout-main.jsonl"),
44
+ metaLine({ id: "main-1", parent_thread_id: null, source: "cli", thread_source: "user" })
45
+ );
46
+ // A non-guardian subagent of the same parent must not be tailed.
47
+ writeFileSync(
48
+ join(dir, "rollout-collab.jsonl"),
49
+ metaLine({ id: "agent-1", parent_thread_id: "main-1", source: { subagent: { other: "collab" } } }) +
50
+ reviewStarted("decoy")
51
+ );
52
+ const trunkPath = join(dir, "rollout-guardian.jsonl");
53
+ writeFileSync(
54
+ trunkPath,
55
+ metaLine({ id: "guardian-1", parent_thread_id: "main-1", source: { subagent: { other: "guardian" } } })
56
+ );
57
+
58
+ const events = [];
59
+ const watcher = watchCodexGuardianReviews({
60
+ sessionsRoot: root,
61
+ onReviewEvent: (event) => events.push(event),
62
+ ...noopTimers
63
+ });
64
+
65
+ watcher._tick();
66
+ assert.deepEqual(events, [], "no review turns yet");
67
+
68
+ appendFileSync(trunkPath, reviewStarted());
69
+ watcher._tick();
70
+ appendFileSync(trunkPath, reviewFinished("allow"));
71
+ watcher._tick();
72
+
73
+ assert.deepEqual(events, [
74
+ { type: "review_started" },
75
+ { type: "review_finished", outcome: "allow" }
76
+ ]);
77
+
78
+ watcher.stop();
79
+ });
80
+
81
+ test("watchCodexGuardianReviews replays a trunk discovered after the review began", () => {
82
+ const { root, dir } = makeSessionsRoot();
83
+ writeFileSync(
84
+ join(dir, "rollout-main.jsonl"),
85
+ metaLine({ id: "main-1", parent_thread_id: null, source: "cli", thread_source: "user" })
86
+ );
87
+ writeFileSync(
88
+ join(dir, "rollout-guardian.jsonl"),
89
+ metaLine({ id: "guardian-1", parent_thread_id: "main-1", source: { subagent: { other: "guardian" } } }) +
90
+ reviewStarted("turn-1") +
91
+ reviewFinished("deny", "turn-1")
92
+ );
93
+
94
+ const events = [];
95
+ const watcher = watchCodexGuardianReviews({
96
+ sessionsRoot: root,
97
+ onReviewEvent: (event) => events.push(event),
98
+ ...noopTimers
99
+ });
100
+
101
+ watcher._tick();
102
+
103
+ assert.deepEqual(events, [
104
+ { type: "review_started" },
105
+ { type: "review_finished", outcome: "deny" }
106
+ ]);
107
+
108
+ watcher.stop();
109
+ });
110
+
111
+ test("watchCodexGuardianReviews skips review records from before the session start", () => {
112
+ const { root, dir } = makeSessionsRoot();
113
+ writeFileSync(
114
+ join(dir, "rollout-main.jsonl"),
115
+ metaLine({ id: "main-1", parent_thread_id: null, source: "cli", thread_source: "user" })
116
+ );
117
+ writeFileSync(
118
+ join(dir, "rollout-guardian.jsonl"),
119
+ metaLine({ id: "guardian-1", parent_thread_id: "main-1", source: { subagent: { other: "guardian" } } }) +
120
+ reviewStarted("turn-old", "2026-06-12T00:00:00.000Z") +
121
+ reviewStarted("turn-new", "2026-06-12T02:00:00.000Z")
122
+ );
123
+
124
+ const events = [];
125
+ const watcher = watchCodexGuardianReviews({
126
+ sessionsRoot: root,
127
+ startedAt: Date.parse("2026-06-12T01:00:00.000Z"),
128
+ onReviewEvent: (event) => events.push(event),
129
+ ...noopTimers
130
+ });
131
+
132
+ watcher._tick();
133
+
134
+ assert.deepEqual(events, [{ type: "review_started" }]);
135
+
136
+ watcher.stop();
137
+ });
138
+
139
+ test("watchCodexGuardianReviews emits nothing without a classifiable main session", () => {
140
+ const { root, dir } = makeSessionsRoot();
141
+ // Guardian trunk exists but there is no main rollout to bind its parent to.
142
+ writeFileSync(
143
+ join(dir, "rollout-guardian.jsonl"),
144
+ metaLine({ id: "guardian-1", parent_thread_id: "main-1", source: { subagent: { other: "guardian" } } }) +
145
+ reviewStarted()
146
+ );
147
+
148
+ const events = [];
149
+ const watcher = watchCodexGuardianReviews({
150
+ sessionsRoot: root,
151
+ onReviewEvent: (event) => events.push(event),
152
+ ...noopTimers
153
+ });
154
+
155
+ watcher._tick();
156
+ watcher._tick();
157
+
158
+ assert.deepEqual(events, []);
159
+
160
+ watcher.stop();
161
+ });
162
+
163
+ test("watchCodexGuardianReviews ignores guardian trunks of other parents", () => {
164
+ const { root, dir } = makeSessionsRoot();
165
+ writeFileSync(
166
+ join(dir, "rollout-main.jsonl"),
167
+ metaLine({ id: "main-1", parent_thread_id: null, source: "cli", thread_source: "user" })
168
+ );
169
+ writeFileSync(
170
+ join(dir, "rollout-guardian-other.jsonl"),
171
+ metaLine({ id: "guardian-9", parent_thread_id: "someone-else", source: { subagent: { other: "guardian" } } }) +
172
+ reviewStarted()
173
+ );
174
+
175
+ const events = [];
176
+ const watcher = watchCodexGuardianReviews({
177
+ sessionsRoot: root,
178
+ onReviewEvent: (event) => events.push(event),
179
+ ...noopTimers
180
+ });
181
+
182
+ watcher._tick();
183
+
184
+ assert.deepEqual(events, []);
185
+
186
+ watcher.stop();
187
+ });
188
+
189
+ test("watchCodexGuardianReviews picks up a trunk created after watching began", () => {
190
+ const { root, dir } = makeSessionsRoot();
191
+ writeFileSync(
192
+ join(dir, "rollout-main.jsonl"),
193
+ metaLine({ id: "main-1", parent_thread_id: null, source: "cli", thread_source: "user" })
194
+ );
195
+
196
+ const events = [];
197
+ const watcher = watchCodexGuardianReviews({
198
+ sessionsRoot: root,
199
+ onReviewEvent: (event) => events.push(event),
200
+ ...noopTimers
201
+ });
202
+
203
+ watcher._tick();
204
+ assert.deepEqual(events, [], "no trunk yet");
205
+
206
+ const trunkPath = join(dir, "rollout-guardian.jsonl");
207
+ writeFileSync(
208
+ trunkPath,
209
+ metaLine({ id: "guardian-1", parent_thread_id: "main-1", source: { subagent: { other: "guardian" } } }) +
210
+ reviewStarted()
211
+ );
212
+ watcher._tick();
213
+
214
+ assert.deepEqual(events, [{ type: "review_started" }]);
215
+
216
+ watcher.stop();
217
+ });
@@ -1,75 +0,0 @@
1
- name: CI
2
-
3
- # Run code quality checks and the test suite on every push that touches code.
4
- on:
5
- push:
6
- paths:
7
- - "**/*.js"
8
- - "**/*.mjs"
9
- - "**/*.cjs"
10
- - "package.json"
11
- - "package-lock.json"
12
- - ".github/workflows/ci.yml"
13
- pull_request:
14
- paths:
15
- - "**/*.js"
16
- - "**/*.mjs"
17
- - "**/*.cjs"
18
- - "package.json"
19
- - "package-lock.json"
20
- - ".github/workflows/ci.yml"
21
-
22
- concurrency:
23
- group: ci-${{ github.workflow }}-${{ github.ref }}
24
- cancel-in-progress: true
25
-
26
- permissions:
27
- contents: read
28
-
29
- jobs:
30
- lint:
31
- name: Code quality (ESLint)
32
- runs-on: ubuntu-latest
33
- steps:
34
- - uses: actions/checkout@v4
35
-
36
- - name: Set up Node.js
37
- uses: actions/setup-node@v4
38
- with:
39
- node-version: 22
40
- cache: npm
41
-
42
- - name: Install dependencies
43
- # Electron's binary isn't needed for linting or tests; skip the ~150 MB
44
- # download so CI is fast and isn't at the mercy of the Electron CDN.
45
- env:
46
- ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
47
- run: npm ci
48
-
49
- - name: Run ESLint
50
- run: npm run lint
51
-
52
- test:
53
- name: Tests (Node ${{ matrix.node }} on ${{ matrix.os }})
54
- runs-on: ${{ matrix.os }}
55
- strategy:
56
- fail-fast: false
57
- matrix:
58
- os: [ubuntu-latest, windows-latest, macos-latest]
59
- node: [20, 22]
60
- steps:
61
- - uses: actions/checkout@v4
62
-
63
- - name: Set up Node.js
64
- uses: actions/setup-node@v4
65
- with:
66
- node-version: ${{ matrix.node }}
67
- cache: npm
68
-
69
- - name: Install dependencies
70
- env:
71
- ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
72
- run: npm ci
73
-
74
- - name: Run the test suite
75
- run: npm test
@@ -1,61 +0,0 @@
1
- name: Release
2
-
3
- # Publish to npm when a version tag is pushed, e.g.:
4
- # npm version patch # bumps package.json + creates a vX.Y.Z tag
5
- # git push --follow-tags
6
- # or manually:
7
- # git tag v0.1.1 && git push origin v0.1.1
8
- on:
9
- push:
10
- tags:
11
- - "v*"
12
-
13
- permissions:
14
- contents: read
15
- id-token: write # required for npm provenance (--provenance)
16
-
17
- concurrency:
18
- group: release-${{ github.ref }}
19
- cancel-in-progress: false
20
-
21
- jobs:
22
- publish:
23
- runs-on: ubuntu-latest
24
- steps:
25
- - name: Check out the tagged commit
26
- uses: actions/checkout@v4
27
-
28
- - name: Set up Node.js
29
- uses: actions/setup-node@v4
30
- with:
31
- node-version: 20
32
- registry-url: "https://registry.npmjs.org"
33
-
34
- - name: Install dependencies
35
- # Electron's binary isn't needed to test or publish; skip the ~150 MB
36
- # download so CI is fast and isn't at the mercy of the Electron CDN.
37
- env:
38
- ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
39
- run: npm ci
40
-
41
- - name: Run the test suite
42
- run: npm test
43
-
44
- - name: Verify the tag matches package.json version
45
- run: |
46
- TAG="${GITHUB_REF_NAME#v}"
47
- PKG="$(node -p "require('./package.json').version")"
48
- echo "tag=$TAG package.json=$PKG"
49
- if [ "$TAG" != "$PKG" ]; then
50
- echo "::error::Tag v$TAG does not match package.json version $PKG"
51
- exit 1
52
- fi
53
-
54
- - name: Publish to npm
55
- env:
56
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
57
- # --access public is needed for the first publish of a scoped package and
58
- # harmless otherwise. --provenance attaches a signed build attestation
59
- # (requires a public repo + the id-token permission above; drop it if the
60
- # repo is private).
61
- run: npm publish --provenance --access public