@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,23 @@
|
|
|
1
|
+
// Hard deadline for IPC awaits in processes that something else waits on.
|
|
2
|
+
// A hook reporter is a child process of the wrapped AI client, and the client
|
|
3
|
+
// may wait for its hook children at shutdown (observed: Codex /quit hanging on
|
|
4
|
+
// its goodbye while an orphaned reporter sat on a never-settling pipe await).
|
|
5
|
+
// Racing the interaction against a deadline guarantees the await terminates,
|
|
6
|
+
// which in turn guarantees the process can exit.
|
|
7
|
+
|
|
8
|
+
export const DEADLINE = Symbol("deadline");
|
|
9
|
+
|
|
10
|
+
// Resolves to the promise's value, or to DEADLINE after `ms` if the promise
|
|
11
|
+
// hasn't settled by then. The promise keeps running if it loses the race —
|
|
12
|
+
// callers are expected to exit (or proceed) regardless; its eventual rejection
|
|
13
|
+
// is swallowed so a late failure can't become an unhandled rejection.
|
|
14
|
+
export function raceDeadline(promise, ms) {
|
|
15
|
+
promise.catch(() => {});
|
|
16
|
+
|
|
17
|
+
let timer;
|
|
18
|
+
const timeout = new Promise((resolve) => {
|
|
19
|
+
timer = setTimeout(() => resolve(DEADLINE), ms);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
23
|
+
}
|
|
@@ -5,6 +5,14 @@ import { appendFileSync } from "node:fs";
|
|
|
5
5
|
import { createIpcClient as defaultCreateIpcClient } from "../../daemon-core/src/ipc-server.js";
|
|
6
6
|
import { getDefaultPaths } from "../../platform-core/src/paths.js";
|
|
7
7
|
import { isAiClientState } from "../../protocol/src/messages.js";
|
|
8
|
+
import { DEADLINE, raceDeadline } from "./deadline.js";
|
|
9
|
+
|
|
10
|
+
// Hard ceiling on the whole connect→send→close interaction. The reporter is a
|
|
11
|
+
// child process of the wrapped AI client, and the client may wait for its hook
|
|
12
|
+
// children at shutdown (observed: Codex /quit stuck on its goodbye while an
|
|
13
|
+
// orphaned reporter hung forever on a pipe await). Hitting the deadline only
|
|
14
|
+
// loses one best-effort status update; hanging loses the user's terminal.
|
|
15
|
+
const REPORT_DEADLINE_MS = 2000;
|
|
8
16
|
|
|
9
17
|
// Best-effort diagnostic: when HAYA_PET_HOOK_DEBUG points at a file, append one
|
|
10
18
|
// JSONL line per reporter invocation so we can see the exact sequence of states
|
|
@@ -62,6 +70,7 @@ export async function runStateCommand(parsed, dependencies = {}) {
|
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
const createIpcClient = dependencies.createIpcClient ?? defaultCreateIpcClient;
|
|
73
|
+
const deadlineMs = dependencies.reportDeadlineMs ?? REPORT_DEADLINE_MS;
|
|
65
74
|
|
|
66
75
|
try {
|
|
67
76
|
const endpoint = dependencies.ipcEndpoint ?? getDefaultPaths({
|
|
@@ -69,17 +78,28 @@ export async function runStateCommand(parsed, dependencies = {}) {
|
|
|
69
78
|
env,
|
|
70
79
|
homeDir: dependencies.homeDir
|
|
71
80
|
}).ipcEndpoint;
|
|
72
|
-
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
|
|
82
|
+
const outcome = await raceDeadline(
|
|
83
|
+
(async () => {
|
|
84
|
+
const client = await createIpcClient({ endpoint });
|
|
85
|
+
await client.send({
|
|
86
|
+
type: "state",
|
|
87
|
+
sessionId,
|
|
88
|
+
state: parsed.state,
|
|
89
|
+
summary: parsed.summary,
|
|
90
|
+
confidence: 0.9,
|
|
91
|
+
source: "official_plugin",
|
|
92
|
+
updatedAt: now()
|
|
93
|
+
});
|
|
94
|
+
await client.close();
|
|
95
|
+
})(),
|
|
96
|
+
deadlineMs
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (outcome === DEADLINE) {
|
|
100
|
+
debugLog(env, now, { state: parsed.state, sessionId, timeout: true });
|
|
101
|
+
return { command: "state", ok: false, reason: "timeout" };
|
|
102
|
+
}
|
|
83
103
|
return { command: "state", ok: true };
|
|
84
104
|
} catch {
|
|
85
105
|
return { command: "state", ok: false, reason: "no-daemon" };
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { DEADLINE, raceDeadline } from "../src/deadline.js";
|
|
4
|
+
|
|
5
|
+
test("raceDeadline passes through a value that settles in time", async () => {
|
|
6
|
+
assert.equal(await raceDeadline(Promise.resolve("done"), 50), "done");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("raceDeadline resolves to DEADLINE when the promise hangs", async () => {
|
|
10
|
+
const hang = new Promise(() => {});
|
|
11
|
+
assert.equal(await raceDeadline(hang, 10), DEADLINE);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("raceDeadline propagates a rejection that settles in time", async () => {
|
|
15
|
+
await assert.rejects(() => raceDeadline(Promise.reject(new Error("boom")), 50), /boom/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("raceDeadline swallows a rejection that loses the race", async () => {
|
|
19
|
+
let rejectLater;
|
|
20
|
+
const losing = new Promise((_resolve, reject) => {
|
|
21
|
+
rejectLater = reject;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.equal(await raceDeadline(losing, 10), DEADLINE);
|
|
25
|
+
|
|
26
|
+
// The late rejection must not surface as an unhandled rejection.
|
|
27
|
+
rejectLater(new Error("late failure"));
|
|
28
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
29
|
+
});
|
|
@@ -111,3 +111,44 @@ test("runStateCommand never throws when the daemon is unreachable", async () =>
|
|
|
111
111
|
assert.equal(result.ok, false);
|
|
112
112
|
assert.equal(result.reason, "no-daemon");
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
// The reporter is a child process the wrapped AI client may WAIT on at its own
|
|
116
|
+
// shutdown (Codex /quit hung on its goodbye because a reporter sat forever on
|
|
117
|
+
// a pipe await). Every IPC phase must therefore hit a hard deadline.
|
|
118
|
+
test("runStateCommand times out instead of hanging when the connect never settles", async () => {
|
|
119
|
+
const result = await runStateCommand(
|
|
120
|
+
{ command: "state", state: "thinking", summary: undefined, session: "s1" },
|
|
121
|
+
{
|
|
122
|
+
env: {},
|
|
123
|
+
ipcEndpoint: "test-endpoint",
|
|
124
|
+
reportDeadlineMs: 20,
|
|
125
|
+
createIpcClient: () => new Promise(() => {})
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
assert.equal(result.ok, false);
|
|
129
|
+
assert.equal(result.reason, "timeout");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("runStateCommand times out instead of hanging when send or close never settle", async () => {
|
|
133
|
+
const hangingSend = await runStateCommand(
|
|
134
|
+
{ command: "state", state: "thinking", summary: undefined, session: "s1" },
|
|
135
|
+
{
|
|
136
|
+
env: {},
|
|
137
|
+
ipcEndpoint: "test-endpoint",
|
|
138
|
+
reportDeadlineMs: 20,
|
|
139
|
+
createIpcClient: async () => ({ send: () => new Promise(() => {}), close: async () => {} })
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
assert.equal(hangingSend.reason, "timeout");
|
|
143
|
+
|
|
144
|
+
const hangingClose = await runStateCommand(
|
|
145
|
+
{ command: "state", state: "thinking", summary: undefined, session: "s1" },
|
|
146
|
+
{
|
|
147
|
+
env: {},
|
|
148
|
+
ipcEndpoint: "test-endpoint",
|
|
149
|
+
reportDeadlineMs: 20,
|
|
150
|
+
createIpcClient: async () => ({ send: async () => {}, close: () => new Promise(() => {}) })
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
assert.equal(hangingClose.reason, "timeout");
|
|
154
|
+
});
|