@hayasaka7/haya-pet 0.2.5 → 0.2.7
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 +51 -0
- package/README.md +27 -5
- package/apps/cli/src/haya-pet.js +136 -4
- package/apps/cli/test/haya-pet.test.mjs +109 -0
- package/apps/companion/src/main/bubble-list-viewport.js +26 -0
- package/apps/companion/src/main/index.js +52 -2
- package/apps/companion/src/main/tray-menu.js +5 -0
- package/apps/companion/src/renderer/pet-window.js +5 -2
- package/apps/companion/src/renderer/session-bubbles.js +5 -1
- package/apps/companion/src/renderer/styles.css +19 -0
- package/apps/companion/test/bubble-list-viewport.test.mjs +50 -0
- package/apps/companion/test/tray-menu.test.mjs +10 -0
- package/docs/architecture.md +8 -2
- package/docs/known-issues.md +90 -5
- package/docs/troubleshooting.md +4 -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/app-state/src/state.js +4 -1
- package/packages/app-state/src/update-check.js +173 -0
- package/packages/app-state/test/update-check.test.mjs +227 -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/src/deadline.js +23 -0
- package/packages/cli-core/src/run-state.js +31 -11
- package/packages/cli-core/test/codex-guardian-watcher.test.mjs +217 -0
- package/packages/cli-core/test/deadline.test.mjs +29 -0
- package/packages/cli-core/test/run-state.test.mjs +41 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Best-effort npm update check shared by the CLI (one-line notice) and the
|
|
2
|
+
// companion (tray item). One small registry request per TTL window, cached in
|
|
3
|
+
// state.json so the CLI and companion share it; every failure path resolves to
|
|
4
|
+
// undefined — an update notice must never block or break a run. Opt out with
|
|
5
|
+
// HAYA_PET_NO_UPDATE_CHECK=1.
|
|
6
|
+
|
|
7
|
+
export const UPDATE_PACKAGE_NAME = "@hayasaka7/haya-pet";
|
|
8
|
+
export const UPDATE_COMMAND = `npm install -g ${UPDATE_PACKAGE_NAME}`;
|
|
9
|
+
export const UPDATE_PAGE_URL = `https://www.npmjs.com/package/${UPDATE_PACKAGE_NAME}`;
|
|
10
|
+
|
|
11
|
+
// The version-specific manifest (a few KB) — not the full packument.
|
|
12
|
+
const REGISTRY_LATEST_URL = "https://registry.npmjs.org/@hayasaka7%2fhaya-pet/latest";
|
|
13
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
15
|
+
|
|
16
|
+
// Strictly-numeric dotted compare ("v" prefix tolerated). Anything else —
|
|
17
|
+
// prerelease tags, garbage, missing values — is conservatively "not newer",
|
|
18
|
+
// so a weird registry response can never produce a false update nag.
|
|
19
|
+
export function isNewerVersion(candidate, current) {
|
|
20
|
+
const a = parseVersion(candidate);
|
|
21
|
+
const b = parseVersion(current);
|
|
22
|
+
if (!a || !b) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
27
|
+
const x = a[i] ?? 0;
|
|
28
|
+
const y = b[i] ?? 0;
|
|
29
|
+
if (x !== y) {
|
|
30
|
+
return x > y;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseVersion(value) {
|
|
37
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const parts = value.trim().replace(/^v/, "").split(".");
|
|
41
|
+
if (!parts.every((part) => /^\d+$/.test(part))) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return parts.map((part) => Number.parseInt(part, 10));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getLastUpdateCheck(state) {
|
|
48
|
+
const entry = state?.updateCheck;
|
|
49
|
+
if (!entry || typeof entry !== "object") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return entry;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setUpdateCheck(state, { checkedAt, latestVersion }) {
|
|
56
|
+
return {
|
|
57
|
+
...state,
|
|
58
|
+
updateCheck: { checkedAt, latestVersion }
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve the latest published version from the npm registry. The outer timer
|
|
63
|
+
// (not request.setTimeout) also covers DNS stalls, which happen before any
|
|
64
|
+
// socket exists. Always resolves — undefined on any failure.
|
|
65
|
+
export function fetchLatestVersion({
|
|
66
|
+
url = REGISTRY_LATEST_URL,
|
|
67
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
68
|
+
get
|
|
69
|
+
} = {}) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let request;
|
|
72
|
+
let settled = false;
|
|
73
|
+
const settle = (value) => {
|
|
74
|
+
if (settled) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
try {
|
|
80
|
+
request?.destroy?.();
|
|
81
|
+
} catch {
|
|
82
|
+
// already closed
|
|
83
|
+
}
|
|
84
|
+
resolve(value);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Deliberately ref'd: this timer is what guarantees the promise settles
|
|
88
|
+
// (and an awaiting caller terminates) even when the request never responds
|
|
89
|
+
// — e.g. a DNS stall. It is cleared the moment anything else settles, so it
|
|
90
|
+
// never holds the process open after a normal success or failure.
|
|
91
|
+
const timer = setTimeout(() => settle(undefined), timeoutMs);
|
|
92
|
+
|
|
93
|
+
resolveGet(get)
|
|
94
|
+
.then((getFn) => {
|
|
95
|
+
if (settled) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
request = getFn(url, { headers: { accept: "application/json" } }, (response) => {
|
|
99
|
+
if (response.statusCode !== 200) {
|
|
100
|
+
response.resume();
|
|
101
|
+
settle(undefined);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let body = "";
|
|
105
|
+
response.setEncoding("utf8");
|
|
106
|
+
response.on("data", (chunk) => {
|
|
107
|
+
body += chunk;
|
|
108
|
+
});
|
|
109
|
+
response.on("end", () => {
|
|
110
|
+
try {
|
|
111
|
+
const version = JSON.parse(body)?.version;
|
|
112
|
+
settle(typeof version === "string" ? version : undefined);
|
|
113
|
+
} catch {
|
|
114
|
+
settle(undefined);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
request.on("error", () => settle(undefined));
|
|
119
|
+
})
|
|
120
|
+
.catch(() => settle(undefined));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// node:https is imported lazily so merely loading this module (e.g. from the
|
|
125
|
+
// renderer-adjacent companion code or tests) never touches the network stack.
|
|
126
|
+
async function resolveGet(get) {
|
|
127
|
+
if (get) {
|
|
128
|
+
return get;
|
|
129
|
+
}
|
|
130
|
+
const https = await import("node:https");
|
|
131
|
+
return https.get;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The single entry point: load the cached result (or fetch + cache it), and
|
|
135
|
+
// report `{ currentVersion, latestVersion }` only when an update exists.
|
|
136
|
+
// Never throws and never rejects.
|
|
137
|
+
export async function checkForUpdate(options = {}) {
|
|
138
|
+
const {
|
|
139
|
+
currentVersion,
|
|
140
|
+
stateFile,
|
|
141
|
+
env = process.env,
|
|
142
|
+
now = Date.now,
|
|
143
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
144
|
+
fetchLatest = fetchLatestVersion
|
|
145
|
+
} = options;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
if (env.HAYA_PET_NO_UPDATE_CHECK === "1" || env.HAYA_PET_NO_UPDATE_CHECK === "true") {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
if (typeof currentVersion !== "string" || currentVersion === "" || !stateFile) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const state = await stateFile.load();
|
|
156
|
+
const cached = getLastUpdateCheck(state);
|
|
157
|
+
|
|
158
|
+
let latestVersion;
|
|
159
|
+
if (cached && Number.isFinite(cached.checkedAt) && now() - cached.checkedAt < ttlMs) {
|
|
160
|
+
latestVersion = cached.latestVersion;
|
|
161
|
+
} else {
|
|
162
|
+
latestVersion = await fetchLatest();
|
|
163
|
+
if (typeof latestVersion !== "string" || latestVersion === "") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
await stateFile.save(setUpdateCheck(state, { checkedAt: now(), latestVersion }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return isNewerVersion(latestVersion, currentVersion) ? { currentVersion, latestVersion } : undefined;
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { parsePositionState, serializePositionState } from "../src/state.js";
|
|
4
|
+
import {
|
|
5
|
+
checkForUpdate,
|
|
6
|
+
fetchLatestVersion,
|
|
7
|
+
getLastUpdateCheck,
|
|
8
|
+
isNewerVersion,
|
|
9
|
+
setUpdateCheck
|
|
10
|
+
} from "../src/update-check.js";
|
|
11
|
+
|
|
12
|
+
test("isNewerVersion compares dotted numeric versions", () => {
|
|
13
|
+
assert.equal(isNewerVersion("0.2.8", "0.2.7"), true);
|
|
14
|
+
assert.equal(isNewerVersion("0.3.0", "0.2.7"), true);
|
|
15
|
+
assert.equal(isNewerVersion("1.0.0", "0.9.9"), true);
|
|
16
|
+
assert.equal(isNewerVersion("0.2.10", "0.2.9"), true);
|
|
17
|
+
assert.equal(isNewerVersion("v0.2.8", "0.2.7"), true);
|
|
18
|
+
|
|
19
|
+
assert.equal(isNewerVersion("0.2.7", "0.2.7"), false);
|
|
20
|
+
assert.equal(isNewerVersion("0.2.6", "0.2.7"), false);
|
|
21
|
+
assert.equal(isNewerVersion("0.2", "0.2.0"), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("isNewerVersion is conservative about unparseable versions", () => {
|
|
25
|
+
assert.equal(isNewerVersion(undefined, "0.2.7"), false);
|
|
26
|
+
assert.equal(isNewerVersion("0.3.0", undefined), false);
|
|
27
|
+
assert.equal(isNewerVersion("not-a-version", "0.2.7"), false);
|
|
28
|
+
assert.equal(isNewerVersion("0.3.0-beta.1", "0.2.7"), false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("setUpdateCheck stores the cache immutably and survives (de)serialization", () => {
|
|
32
|
+
const original = parsePositionState("{}");
|
|
33
|
+
const updated = setUpdateCheck(original, { checkedAt: 123, latestVersion: "0.3.0" });
|
|
34
|
+
|
|
35
|
+
assert.equal(getLastUpdateCheck(original), undefined, "original state untouched");
|
|
36
|
+
assert.deepEqual(getLastUpdateCheck(updated), { checkedAt: 123, latestVersion: "0.3.0" });
|
|
37
|
+
|
|
38
|
+
const reloaded = parsePositionState(serializePositionState(updated));
|
|
39
|
+
assert.deepEqual(getLastUpdateCheck(reloaded), { checkedAt: 123, latestVersion: "0.3.0" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function memoryStateFile(initial = parsePositionState("{}")) {
|
|
43
|
+
let state = initial;
|
|
44
|
+
const saves = [];
|
|
45
|
+
return {
|
|
46
|
+
load: async () => state,
|
|
47
|
+
save: async (next) => {
|
|
48
|
+
state = next;
|
|
49
|
+
saves.push(next);
|
|
50
|
+
return next;
|
|
51
|
+
},
|
|
52
|
+
saves
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("checkForUpdate fetches, caches, and reports a newer version", async () => {
|
|
57
|
+
const stateFile = memoryStateFile();
|
|
58
|
+
let fetches = 0;
|
|
59
|
+
|
|
60
|
+
const result = await checkForUpdate({
|
|
61
|
+
currentVersion: "0.2.7",
|
|
62
|
+
stateFile,
|
|
63
|
+
env: {},
|
|
64
|
+
now: () => 1000,
|
|
65
|
+
fetchLatest: async () => {
|
|
66
|
+
fetches += 1;
|
|
67
|
+
return "0.3.0";
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(result, { currentVersion: "0.2.7", latestVersion: "0.3.0" });
|
|
72
|
+
assert.equal(fetches, 1);
|
|
73
|
+
assert.deepEqual(getLastUpdateCheck(stateFile.saves[0]), { checkedAt: 1000, latestVersion: "0.3.0" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("checkForUpdate uses a fresh cache without fetching", async () => {
|
|
77
|
+
const cached = setUpdateCheck(parsePositionState("{}"), { checkedAt: 1000, latestVersion: "0.3.0" });
|
|
78
|
+
const stateFile = memoryStateFile(cached);
|
|
79
|
+
|
|
80
|
+
const result = await checkForUpdate({
|
|
81
|
+
currentVersion: "0.2.7",
|
|
82
|
+
stateFile,
|
|
83
|
+
env: {},
|
|
84
|
+
now: () => 1000 + 60_000,
|
|
85
|
+
fetchLatest: async () => {
|
|
86
|
+
throw new Error("must not fetch while the cache is fresh");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.deepEqual(result, { currentVersion: "0.2.7", latestVersion: "0.3.0" });
|
|
91
|
+
assert.equal(stateFile.saves.length, 0, "fresh cache is not re-saved");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("checkForUpdate refetches once the cache expires", async () => {
|
|
95
|
+
const cached = setUpdateCheck(parsePositionState("{}"), { checkedAt: 0, latestVersion: "0.2.8" });
|
|
96
|
+
const stateFile = memoryStateFile(cached);
|
|
97
|
+
|
|
98
|
+
const result = await checkForUpdate({
|
|
99
|
+
currentVersion: "0.2.7",
|
|
100
|
+
stateFile,
|
|
101
|
+
env: {},
|
|
102
|
+
now: () => 25 * 60 * 60 * 1000,
|
|
103
|
+
ttlMs: 24 * 60 * 60 * 1000,
|
|
104
|
+
fetchLatest: async () => "0.4.0"
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.equal(result.latestVersion, "0.4.0");
|
|
108
|
+
assert.equal(stateFile.saves.length, 1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("checkForUpdate reports nothing when already up to date", async () => {
|
|
112
|
+
const result = await checkForUpdate({
|
|
113
|
+
currentVersion: "0.3.0",
|
|
114
|
+
stateFile: memoryStateFile(),
|
|
115
|
+
env: {},
|
|
116
|
+
now: () => 1000,
|
|
117
|
+
fetchLatest: async () => "0.3.0"
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.equal(result, undefined);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("checkForUpdate is silent on opt-out, failure, and bad input", async () => {
|
|
124
|
+
const optedOut = await checkForUpdate({
|
|
125
|
+
currentVersion: "0.2.7",
|
|
126
|
+
stateFile: memoryStateFile(),
|
|
127
|
+
env: { HAYA_PET_NO_UPDATE_CHECK: "1" },
|
|
128
|
+
fetchLatest: async () => "9.9.9"
|
|
129
|
+
});
|
|
130
|
+
assert.equal(optedOut, undefined);
|
|
131
|
+
|
|
132
|
+
const fetchFailed = await checkForUpdate({
|
|
133
|
+
currentVersion: "0.2.7",
|
|
134
|
+
stateFile: memoryStateFile(),
|
|
135
|
+
env: {},
|
|
136
|
+
fetchLatest: async () => undefined
|
|
137
|
+
});
|
|
138
|
+
assert.equal(fetchFailed, undefined);
|
|
139
|
+
|
|
140
|
+
const loadFailed = await checkForUpdate({
|
|
141
|
+
currentVersion: "0.2.7",
|
|
142
|
+
stateFile: { load: async () => { throw new Error("disk"); }, save: async () => {} },
|
|
143
|
+
env: {},
|
|
144
|
+
fetchLatest: async () => "9.9.9"
|
|
145
|
+
});
|
|
146
|
+
assert.equal(loadFailed, undefined);
|
|
147
|
+
|
|
148
|
+
const noVersion = await checkForUpdate({
|
|
149
|
+
currentVersion: undefined,
|
|
150
|
+
stateFile: memoryStateFile(),
|
|
151
|
+
env: {},
|
|
152
|
+
fetchLatest: async () => "9.9.9"
|
|
153
|
+
});
|
|
154
|
+
assert.equal(noVersion, undefined);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
function fakeResponse({ statusCode = 200, body = "" } = {}) {
|
|
158
|
+
const handlers = {};
|
|
159
|
+
return {
|
|
160
|
+
statusCode,
|
|
161
|
+
setEncoding() {},
|
|
162
|
+
resume() {},
|
|
163
|
+
on(event, handler) {
|
|
164
|
+
handlers[event] = handler;
|
|
165
|
+
return this;
|
|
166
|
+
},
|
|
167
|
+
emit(event, payload) {
|
|
168
|
+
handlers[event]?.(payload);
|
|
169
|
+
},
|
|
170
|
+
body
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function fakeGet({ response, requestError } = {}) {
|
|
175
|
+
return (url, options, onResponse) => {
|
|
176
|
+
const handlers = {};
|
|
177
|
+
const request = {
|
|
178
|
+
on(event, handler) {
|
|
179
|
+
handlers[event] = handler;
|
|
180
|
+
return this;
|
|
181
|
+
},
|
|
182
|
+
destroy() {
|
|
183
|
+
this.destroyed = true;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
queueMicrotask(() => {
|
|
187
|
+
if (requestError) {
|
|
188
|
+
handlers.error?.(requestError);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
onResponse(response);
|
|
192
|
+
response.emit("data", response.body);
|
|
193
|
+
response.emit("end");
|
|
194
|
+
});
|
|
195
|
+
return request;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
test("fetchLatestVersion extracts the version from the registry response", async () => {
|
|
200
|
+
const version = await fetchLatestVersion({
|
|
201
|
+
get: fakeGet({ response: fakeResponse({ body: '{"name":"x","version":"0.3.0"}' }) })
|
|
202
|
+
});
|
|
203
|
+
assert.equal(version, "0.3.0");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("fetchLatestVersion resolves undefined on bad status, bad JSON, and errors", async () => {
|
|
207
|
+
assert.equal(
|
|
208
|
+
await fetchLatestVersion({ get: fakeGet({ response: fakeResponse({ statusCode: 404, body: "{}" }) }) }),
|
|
209
|
+
undefined
|
|
210
|
+
);
|
|
211
|
+
assert.equal(
|
|
212
|
+
await fetchLatestVersion({ get: fakeGet({ response: fakeResponse({ body: "not json" }) }) }),
|
|
213
|
+
undefined
|
|
214
|
+
);
|
|
215
|
+
assert.equal(
|
|
216
|
+
await fetchLatestVersion({ get: fakeGet({ requestError: new Error("offline") }) }),
|
|
217
|
+
undefined
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("fetchLatestVersion times out instead of hanging", async () => {
|
|
222
|
+
const version = await fetchLatestVersion({
|
|
223
|
+
timeoutMs: 5,
|
|
224
|
+
get: () => ({ on() { return this; }, destroy() {} }) // never responds
|
|
225
|
+
});
|
|
226
|
+
assert.equal(version, undefined);
|
|
227
|
+
});
|
|
@@ -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
|
-
}
|