@hayasaka7/haya-pet 0.2.6 → 0.2.8

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.
@@ -103,6 +103,14 @@ with mouse-move forwarding). The pet is positioned inside the window and dragged
103
103
  via CSS; the bubble panel is placed on whichever side of the pet has room so it
104
104
  stays fully on-screen. The pet currently lives on a single display's work area.
105
105
 
106
+ The bubble panel shows at most three sessions and scrolls for the rest (capped
107
+ by the smaller of a height budget and a count budget, see
108
+ `bubble-list-viewport.js`). It renders **incrementally** — the list element and
109
+ each session's bubble persist across updates and are mutated in place rather
110
+ than rebuilt — because status pushes and a 2 s linger tick arrive constantly,
111
+ and replacing the node under the cursor would drop an in-progress scroll
112
+ gesture and reset the scroll position.
113
+
106
114
  ## Distribution & runtime dependencies
107
115
 
108
116
  - `electron` is a **runtime dependency** (not just a dev tool), because
@@ -48,6 +48,39 @@ Issues found in live use, with their current status.
48
48
  surfaces as turn-end *idle*). The TUI's passive `/approve` denial-override
49
49
  picker is not a blocking prompt.
50
50
 
51
+ ## ✅ Resolved: Codex `/quit` hung on its goodbye (and the pet kept showing "working")
52
+
53
+ - **Symptom:** Exiting Codex with `/quit` printed the token-usage goodbye and the
54
+ `codex resume` hint, but the terminal never returned to a prompt and the pet
55
+ kept showing the session as ongoing. Ctrl+C exited fine. Only happened under
56
+ `haya-pet run`.
57
+ - **Root cause (verified against codex-rs 0.139.0 source + a live orphaned
58
+ process):** the `haya-pet state` hook reporter had three **unbounded awaits**
59
+ in its IPC path — pipe connect, write drain, and `socket.end()` → `close` —
60
+ and the CLI entry's `process.exit()` only runs after the command resolves, so
61
+ one never-settling await made a reporter hang forever. Codex awaits every
62
+ hook child with a **default 600 s timeout**
63
+ (`hooks/engine/discovery.rs` `timeout_sec.unwrap_or(600)`;
64
+ `command_runner.rs` `timeout(…, child.wait_with_output())`), and `Stop` hooks
65
+ are awaited in turn completion (`core/hook_runtime.rs run_turn_stop_hooks`)
66
+ with the TUI exiting only after `ShutdownComplete`. So one hung turn-end
67
+ `state idle` reporter produced BOTH symptoms: the idle report never arrived
68
+ (pet stuck on "working"), and `/quit` waited up to 10 minutes on the hook
69
+ child after printing its goodbye. Ctrl+C kills Codex without that wait and
70
+ orphans the reporter — exactly what live process-tree monitoring showed (a
71
+ parentless reporter under the hook node version).
72
+ - **Fix:** every IPC await now has a hard deadline (`cli-core/deadline.js`).
73
+ The reporter races its whole connect→send→close against **2 s** and exits
74
+ with `{ ok:false, reason:"timeout" }` on the deadline (one best-effort status
75
+ update lost; `HAYA_PET_HOOK_DEBUG` logs a `timeout: true` line for evidence).
76
+ The wrapper's companion connection gets the same guard (**5 s** per
77
+ send/close) so a wedged companion can never keep the wrapper — and the user's
78
+ terminal — alive after the wrapped CLI exits. Dead sessions still resolve via
79
+ the registry's stale/drop sweep, so a lost message self-heals.
80
+ - **Note:** why a pipe await occasionally never settles (companion busy/wedged
81
+ at that moment) is not yet pinned down; the deadline makes it harmless and
82
+ the debug log will show `timeout: true` entries if it recurs.
83
+
51
84
  ## ✅ Resolved: pet stuck on "waiting for approval" after a manual denial
52
85
 
53
86
  - **Symptom:** With Claude Code hooks enabled, denying a permission prompt left the
@@ -25,7 +25,9 @@ deferred problems with known root causes.
25
25
  | Pet stayed on **waiting for approval** after I denied a tool | Fixed — Claude fires no hook on a manual denial, so the wrapper tails the session transcript and clears to **idle** when the denial is recorded. A genuinely-pending approval (you haven't decided yet) correctly keeps alerting — there's no timer. |
26
26
  | Pet stayed on **waiting for approval** after I *approved* a command | Fixed — Claude also fires no hook at the accept moment, so the companion watches the client's process tree while a session waits: when the approved command verifiably starts (a new persistent process under the client), the pet flips to **working**. Expect a ~2–3s lag after your click. File-edit approvals (no process) resolve at completion, which is near-instant. |
27
27
  | Want to see which status events fire | Set `HAYA_PET_HOOK_DEBUG=<file.jsonl>` before `haya-pet run`; each hook- and transcript-sourced status appends one JSON line (timestamp, state, and source/event). |
28
+ | Don't want the update check / notice | Set `HAYA_PET_NO_UPDATE_CHECK=1`. The check is a daily, cached HTTPS request to the npm registry (no session data); it is already skipped automatically when output is piped. |
28
29
  | Pet stays **idle** after force-quitting a CLI | The wrapper marks the session stale ~15s after the heartbeat stops, then drops it. Exiting normally (incl. Ctrl+C) reports **exited** immediately. |
30
+ | **Codex `/quit`** printed its goodbye but the terminal hung (pet stuck on "working") | Fixed — a hook reporter could hang on a pipe await and Codex waits up to 600s for hook children at shutdown. Reporters now hard-deadline at 2s. Update to the latest version. |
29
31
  | Ctrl+C doesn't exit the CLI cleanly under `haya-pet run` | Fixed — the wrapper no longer dies on Ctrl+C; the signal reaches the CLI, which exits, and the pet shows the result. |
30
32
  | `ENOENT … electron\path.txt` | Electron's install extraction was interrupted — see below. |
31
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -105,7 +105,10 @@ export function parsePositionState(text) {
105
105
  settings: {
106
106
  ...defaults.settings,
107
107
  ...(isPlainObject(parsed.settings) ? parsed.settings : {})
108
- }
108
+ },
109
+ // Cached npm update-check result (see update-check.js) — must survive a
110
+ // load/save round-trip or every run would re-fetch from the registry.
111
+ ...(isPlainObject(parsed.updateCheck) ? { updateCheck: parsed.updateCheck } : {})
109
112
  };
110
113
  } catch {
111
114
  return defaults;
@@ -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,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,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
+ });