@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +27 -5
  3. package/apps/cli/src/haya-pet.js +136 -4
  4. package/apps/cli/test/haya-pet.test.mjs +109 -0
  5. package/apps/companion/src/main/bubble-list-viewport.js +26 -0
  6. package/apps/companion/src/main/index.js +52 -2
  7. package/apps/companion/src/main/tray-menu.js +5 -0
  8. package/apps/companion/src/renderer/pet-window.js +5 -2
  9. package/apps/companion/src/renderer/session-bubbles.js +5 -1
  10. package/apps/companion/src/renderer/styles.css +19 -0
  11. package/apps/companion/test/bubble-list-viewport.test.mjs +50 -0
  12. package/apps/companion/test/tray-menu.test.mjs +10 -0
  13. package/docs/architecture.md +8 -2
  14. package/docs/known-issues.md +90 -5
  15. package/docs/troubleshooting.md +4 -1
  16. package/package.json +23 -1
  17. package/packages/adapters/src/codex-guardian.js +131 -0
  18. package/packages/adapters/src/codex-hooks.js +11 -2
  19. package/packages/adapters/test/codex-guardian.test.mjs +174 -0
  20. package/packages/app-state/src/state.js +4 -1
  21. package/packages/app-state/src/update-check.js +173 -0
  22. package/packages/app-state/test/update-check.test.mjs +227 -0
  23. package/packages/cli-core/src/codex-guardian-watcher.js +136 -0
  24. package/packages/cli-core/src/codex-rollout-fs.js +88 -0
  25. package/packages/cli-core/src/codex-transcript-watcher.js +2 -65
  26. package/packages/cli-core/src/deadline.js +23 -0
  27. package/packages/cli-core/src/run-state.js +31 -11
  28. package/packages/cli-core/test/codex-guardian-watcher.test.mjs +217 -0
  29. package/packages/cli-core/test/deadline.test.mjs +29 -0
  30. 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
- const client = await createIpcClient({ endpoint });
73
- await client.send({
74
- type: "state",
75
- sessionId,
76
- state: parsed.state,
77
- summary: parsed.summary,
78
- confidence: 0.9,
79
- source: "official_plugin",
80
- updatedAt: now()
81
- });
82
- await client.close();
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
+ });