@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.
- package/CHANGELOG.md +18 -1
- package/README.md +209 -205
- package/apps/cli/src/haya-pet.js +64 -0
- package/apps/cli/test/haya-pet.test.mjs +41 -0
- package/apps/companion/README.md +2 -2
- package/apps/companion/src/main/index.js +2 -2
- package/apps/companion/src/main/tray-menu.js +4 -0
- package/apps/companion/src/renderer/index.html +1 -1
- package/apps/companion/test/tray-menu.test.mjs +5 -1
- package/apps/pet-preview/index.html +2 -2
- package/docs/architecture.md +9 -3
- package/docs/known-issues.md +58 -6
- package/docs/publishing.md +1 -1
- package/docs/troubleshooting.md +2 -1
- package/native/win-window-helper/Program.cs +1 -1
- package/package.json +23 -1
- package/packages/adapters/src/codex-guardian.js +131 -0
- package/packages/adapters/src/codex-hooks.js +11 -2
- package/packages/adapters/test/codex-guardian.test.mjs +174 -0
- package/packages/cli-core/src/codex-guardian-watcher.js +136 -0
- package/packages/cli-core/src/codex-rollout-fs.js +88 -0
- package/packages/cli-core/src/codex-transcript-watcher.js +2 -65
- package/packages/cli-core/test/codex-guardian-watcher.test.mjs +217 -0
- package/.github/workflows/ci.yml +0 -75
- package/.github/workflows/release.yml +0 -61
|
@@ -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
|
+
});
|
package/.github/workflows/ci.yml
DELETED
|
@@ -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
|