@hayasaka7/haya-pet 0.2.6 → 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 +34 -0
- package/README.md +19 -1
- package/apps/cli/src/haya-pet.js +72 -4
- package/apps/cli/test/haya-pet.test.mjs +68 -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/known-issues.md +33 -0
- package/docs/troubleshooting.md +2 -0
- package/package.json +1 -1
- 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/deadline.js +23 -0
- package/packages/cli-core/src/run-state.js +31 -11
- package/packages/cli-core/test/deadline.test.mjs +29 -0
- package/packages/cli-core/test/run-state.test.mjs +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ All notable changes to HAYA Pet are documented here. This project adheres to
|
|
|
7
7
|
> 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
|
|
8
8
|
> ships them.
|
|
9
9
|
|
|
10
|
+
## [0.2.7]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Codex `/quit` no longer hangs after its goodbye.** The `haya-pet state`
|
|
14
|
+
hook reporter could hang forever on a never-settling IPC await (pipe connect,
|
|
15
|
+
write drain, or close) — and Codex awaits each hook child with a default
|
|
16
|
+
**600 s** timeout, so a hung turn-end `state idle` reporter both froze the
|
|
17
|
+
pet on "working" and made `/quit` sit on its token-usage goodbye for up to
|
|
18
|
+
10 minutes (Ctrl+C worked because it kills Codex without the wait, orphaning
|
|
19
|
+
the reporter — observed live). The reporter now races its whole IPC
|
|
20
|
+
interaction against a 2 s deadline and always exits; the wrapper's own
|
|
21
|
+
companion connection gets the same guard (5 s) so a wedged companion can
|
|
22
|
+
never hold the terminal after the wrapped CLI exits.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Update notice.** HAYA Pet now checks npm (at most once a day, cached in
|
|
26
|
+
`state.json`, shared between the CLI and the overlay) for a newer published
|
|
27
|
+
version. The CLI prints a one-line notice after a wrapped command exits (and
|
|
28
|
+
after `haya-pet start`), and the tray gains an **Update Available (x.y.z)**
|
|
29
|
+
item that opens the package page — the app never runs npm itself. The check
|
|
30
|
+
is best-effort (3 s timeout, silent on any failure, never blocks a run),
|
|
31
|
+
skipped when stdout isn't a terminal, and can be disabled with
|
|
32
|
+
`HAYA_PET_NO_UPDATE_CHECK=1`.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- **The bubble panel shows at most three sessions at once.** Beyond the existing
|
|
36
|
+
height budget (the room between the folder button and the screen edge), the
|
|
37
|
+
list now also caps its viewport at the bottom of the third bubble — more
|
|
38
|
+
sessions are reached by scrolling, so a busy machine no longer grows a
|
|
39
|
+
screen-tall stack. The list surface itself (gaps between bubbles and the
|
|
40
|
+
scrollbar) is now pointer-active so wheel scrolling and scrollbar dragging
|
|
41
|
+
work anywhere on the open panel, and the scrollbar is a slim dark-theme thumb
|
|
42
|
+
instead of the stock bar.
|
|
43
|
+
|
|
10
44
|
## [0.2.6]
|
|
11
45
|
|
|
12
46
|
### Fixed
|
package/README.md
CHANGED
|
@@ -248,6 +248,21 @@ exit code. Disable auto-start with `HAYA_PET_NO_AUTOSTART=1`.
|
|
|
248
248
|
| Electron | Installed as a runtime dependency. |
|
|
249
249
|
| node-pty | Optional; used only for `--observe`. |
|
|
250
250
|
|
|
251
|
+
## Updates
|
|
252
|
+
|
|
253
|
+
HAYA Pet checks npm for a newer published version at most once a day (cached in
|
|
254
|
+
`state.json`). When one exists, the CLI prints a one-line notice after your
|
|
255
|
+
wrapped command exits, and the tray shows **Update Available (x.y.z)** — clicking
|
|
256
|
+
it opens the package page. Updating is always your action:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
npm install -g @hayasaka7/haya-pet
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The check is best-effort (3s timeout, silent on failure, never blocks a run),
|
|
263
|
+
skipped when output is piped, and fully disabled with
|
|
264
|
+
`HAYA_PET_NO_UPDATE_CHECK=1`.
|
|
265
|
+
|
|
251
266
|
## Troubleshooting
|
|
252
267
|
|
|
253
268
|
| Symptom | Fix |
|
|
@@ -265,7 +280,10 @@ repairing a broken Electron install.
|
|
|
265
280
|
|
|
266
281
|
HAYA Pet is local-only by default. It does not upload prompts, files,
|
|
267
282
|
screenshots, or session logs. The overlay stores only local state needed for
|
|
268
|
-
pet selection, position, size, and short derived status summaries.
|
|
283
|
+
pet selection, position, size, and short derived status summaries. The single
|
|
284
|
+
outbound request it ever makes is the daily npm version check (a standard
|
|
285
|
+
HTTPS request to `registry.npmjs.org` that sends no session data); disable it
|
|
286
|
+
with `HAYA_PET_NO_UPDATE_CHECK=1`.
|
|
269
287
|
|
|
270
288
|
## Documentation
|
|
271
289
|
|
package/apps/cli/src/haya-pet.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { realpathSync, appendFileSync } from "node:fs";
|
|
2
|
+
import { realpathSync, appendFileSync, readFileSync } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
|
|
7
8
|
import { parseStateArgs, runStateCommand } from "../../../packages/cli-core/src/run-state.js";
|
|
@@ -16,6 +17,11 @@ import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
|
|
|
16
17
|
import { discoverPets as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
|
|
17
18
|
import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
|
|
18
19
|
import { getSelectedPetId, setSelectedPet, getHooksEnabled, setHooksEnabled } from "../../../packages/app-state/src/state.js";
|
|
20
|
+
import { checkForUpdate, UPDATE_COMMAND } from "../../../packages/app-state/src/update-check.js";
|
|
21
|
+
import { raceDeadline } from "../../../packages/cli-core/src/deadline.js";
|
|
22
|
+
|
|
23
|
+
// Ceiling for wrapper→companion IPC awaits (see createMessageSender).
|
|
24
|
+
const SENDER_DEADLINE_MS = 5000;
|
|
19
25
|
import { getAdapterInfo } from "../../../packages/adapters/src/adapter-info.js";
|
|
20
26
|
|
|
21
27
|
const CLIENT_DISPLAY_NAMES = Object.freeze({
|
|
@@ -105,6 +111,13 @@ export async function runStopCommand(_parsed, dependencies = {}) {
|
|
|
105
111
|
// Explicitly start the companion overlay (so users never need `npm start`).
|
|
106
112
|
export async function runStartCommand(_parsed, dependencies = {}) {
|
|
107
113
|
const print = dependencies.print ?? defaultPrint;
|
|
114
|
+
const updateCheck = startUpdateCheck(dependencies);
|
|
115
|
+
const result = await startCompanionAndReport(dependencies, print);
|
|
116
|
+
await reportUpdateNotice(updateCheck, print);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function startCompanionAndReport(dependencies, print) {
|
|
108
121
|
const { client, started, error, timedOut } = await connectCompanion(dependencies, true);
|
|
109
122
|
|
|
110
123
|
if (client) {
|
|
@@ -123,6 +136,51 @@ export async function runStartCommand(_parsed, dependencies = {}) {
|
|
|
123
136
|
return { command: "start", ok: false, started: false };
|
|
124
137
|
}
|
|
125
138
|
|
|
139
|
+
// Kick off the (cached, best-effort) npm update check without blocking the
|
|
140
|
+
// actual work; callers await the promise only when they are about to print.
|
|
141
|
+
// Nothing here may ever break the run — even resolving the state path can
|
|
142
|
+
// throw (no HOME/USERPROFILE), which simply means "no check".
|
|
143
|
+
function startUpdateCheck(dependencies) {
|
|
144
|
+
try {
|
|
145
|
+
const check = dependencies.checkForUpdate ?? defaultCheckForUpdate;
|
|
146
|
+
return check({
|
|
147
|
+
currentVersion: dependencies.currentVersion ?? readOwnVersion(),
|
|
148
|
+
stateFile: createConfigStateFile(dependencies),
|
|
149
|
+
env: dependencies.env ?? process.env,
|
|
150
|
+
now: dependencies.now ?? Date.now
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
return Promise.resolve(undefined);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Default update check runs only on an interactive terminal: piped/CI output
|
|
158
|
+
// should not be nagged (and nothing non-interactive should touch the network).
|
|
159
|
+
function defaultCheckForUpdate(options) {
|
|
160
|
+
if (!process.stdout.isTTY) {
|
|
161
|
+
return Promise.resolve(undefined);
|
|
162
|
+
}
|
|
163
|
+
return checkForUpdate(options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function reportUpdateNotice(updateCheck, print) {
|
|
167
|
+
const update = await updateCheck;
|
|
168
|
+
if (update) {
|
|
169
|
+
print(
|
|
170
|
+
`haya-pet: update available — ${update.currentVersion} → ${update.latestVersion}. Run: ${UPDATE_COMMAND}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readOwnVersion() {
|
|
176
|
+
try {
|
|
177
|
+
const packagePath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
|
|
178
|
+
return JSON.parse(readFileSync(packagePath, "utf8")).version;
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
126
184
|
async function runRunCommand(parsed, dependencies) {
|
|
127
185
|
const runGenericCommand = dependencies.runGenericCommand ?? defaultRunGenericCommand;
|
|
128
186
|
const injectClaudeHooks = dependencies.injectClaudeHooks ?? defaultInjectClaudeHooks;
|
|
@@ -136,6 +194,9 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
136
194
|
const now = dependencies.now ?? Date.now;
|
|
137
195
|
const cwd = dependencies.cwd ?? process.cwd();
|
|
138
196
|
const messageSender = await createMessageSender(dependencies);
|
|
197
|
+
// Concurrent with the wrapped command; the result is printed after it exits
|
|
198
|
+
// (interactive TUIs clear the screen, so a pre-launch notice would be lost).
|
|
199
|
+
const updateCheck = startUpdateCheck(dependencies);
|
|
139
200
|
|
|
140
201
|
const sessionId = dependencies.sessionId ?? `sess_${randomUUID()}`;
|
|
141
202
|
let childArgs = parsed.childArgs;
|
|
@@ -302,7 +363,7 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
302
363
|
}
|
|
303
364
|
|
|
304
365
|
try {
|
|
305
|
-
|
|
366
|
+
const result = await runGenericCommand({
|
|
306
367
|
command: parsed.childCommand,
|
|
307
368
|
args: childArgs,
|
|
308
369
|
cwd,
|
|
@@ -316,6 +377,8 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
316
377
|
stdio: dependencies.stdio,
|
|
317
378
|
send: messageSender.send
|
|
318
379
|
});
|
|
380
|
+
await reportUpdateNotice(updateCheck, print);
|
|
381
|
+
return result;
|
|
319
382
|
} finally {
|
|
320
383
|
stopWatcher();
|
|
321
384
|
cleanup();
|
|
@@ -609,9 +672,14 @@ async function createMessageSender(dependencies) {
|
|
|
609
672
|
process.stderr.write("haya-pet: started the companion overlay.\n");
|
|
610
673
|
}
|
|
611
674
|
|
|
675
|
+
// Deadline every IPC await: if the companion wedges, a hanging send/close
|
|
676
|
+
// would keep THIS process alive after the wrapped CLI exits — leaving the
|
|
677
|
+
// user's terminal without a prompt. Losing a status message to the deadline
|
|
678
|
+
// is fine (the registry stales-out dead sessions); losing the terminal isn't.
|
|
679
|
+
const deadlineMs = dependencies.senderDeadlineMs ?? SENDER_DEADLINE_MS;
|
|
612
680
|
return {
|
|
613
|
-
send: (message) => client.send(message),
|
|
614
|
-
close: () => client.close()
|
|
681
|
+
send: (message) => raceDeadline(client.send(message), deadlineMs),
|
|
682
|
+
close: () => raceDeadline(client.close(), deadlineMs)
|
|
615
683
|
};
|
|
616
684
|
}
|
|
617
685
|
|
|
@@ -275,6 +275,74 @@ test("HAYA_PET_NO_AUTOSTART disables auto-starting the companion", async () => {
|
|
|
275
275
|
assert.equal(launched, 0);
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
+
test("run prints an update notice only after the wrapped command exits", async () => {
|
|
279
|
+
const order = [];
|
|
280
|
+
|
|
281
|
+
await runAiPet(["run", "--client", "generic", "--", "node", "-v"], {
|
|
282
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
283
|
+
heartbeatIntervalMs: 10,
|
|
284
|
+
send: async () => {},
|
|
285
|
+
print: (line) => order.push(line),
|
|
286
|
+
checkForUpdate: async () => ({ currentVersion: "0.2.7", latestVersion: "9.9.9" }),
|
|
287
|
+
runGenericCommand: async (options) => {
|
|
288
|
+
order.push("child-finished");
|
|
289
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const noticeIndex = order.findIndex((entry) => entry.includes("update available"));
|
|
294
|
+
assert.ok(noticeIndex !== -1, "notice printed");
|
|
295
|
+
assert.ok(order[noticeIndex].includes("0.2.7 → 9.9.9"), "notice names both versions");
|
|
296
|
+
assert.ok(order[noticeIndex].includes("npm install -g @hayasaka7/haya-pet"), "notice gives the command");
|
|
297
|
+
assert.ok(noticeIndex > order.indexOf("child-finished"), "notice comes after the child exits");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("run prints no update notice when the check finds nothing", async () => {
|
|
301
|
+
const lines = [];
|
|
302
|
+
|
|
303
|
+
await runAiPet(["run", "--client", "generic", "--", "node", "-v"], {
|
|
304
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
305
|
+
heartbeatIntervalMs: 10,
|
|
306
|
+
send: async () => {},
|
|
307
|
+
print: (line) => lines.push(line),
|
|
308
|
+
checkForUpdate: async () => undefined,
|
|
309
|
+
runGenericCommand: async (options) => ({ sessionId: options.sessionId, pid: 1, exitCode: 0 })
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
assert.ok(!lines.some((line) => line.includes("update available")));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("start prints an update notice after its status line", async () => {
|
|
316
|
+
const lines = [];
|
|
317
|
+
|
|
318
|
+
await runAiPet(["start"], {
|
|
319
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
320
|
+
createIpcClient: async () => ({ send: async () => {}, close: async () => {} }),
|
|
321
|
+
launchCompanion: async () => {},
|
|
322
|
+
print: (line) => lines.push(line),
|
|
323
|
+
checkForUpdate: async () => ({ currentVersion: "0.2.7", latestVersion: "9.9.9" })
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const noticeIndex = lines.findIndex((line) => line.includes("update available"));
|
|
327
|
+
assert.ok(noticeIndex !== -1, "notice printed");
|
|
328
|
+
assert.ok(noticeIndex > lines.findIndex((line) => line.includes("already running")));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("run returns even when the companion connection hangs on close", async () => {
|
|
332
|
+
const result = await runAiPet(["run", "--client", "generic", "--", "node", "-v"], {
|
|
333
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
334
|
+
heartbeatIntervalMs: 10,
|
|
335
|
+
senderDeadlineMs: 20,
|
|
336
|
+
createIpcClient: async () => ({
|
|
337
|
+
send: async () => {},
|
|
338
|
+
close: () => new Promise(() => {})
|
|
339
|
+
}),
|
|
340
|
+
runGenericCommand: async (options) => ({ sessionId: options.sessionId, pid: 1, exitCode: 0 })
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
assert.equal(result.exitCode, 0);
|
|
344
|
+
});
|
|
345
|
+
|
|
278
346
|
test("parses the start command and reports when already running", async () => {
|
|
279
347
|
assert.deepEqual(parseAiPetArgs(["start"]), { command: "start" });
|
|
280
348
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Caps the bubble list's visible height. Two budgets apply, and the tighter one
|
|
2
|
+
// wins: the HEIGHT budget (the pixel room between the folder button and the
|
|
3
|
+
// screen edge on the side the list opens toward) and a COUNT budget — at most
|
|
4
|
+
// MAX_VISIBLE_BUBBLES bubbles are visible at once, so a long session list stays
|
|
5
|
+
// a compact panel and the rest is reached by scrolling (.bubble-list has
|
|
6
|
+
// overflow-y: auto). The floor keeps the list usable even when the button sits
|
|
7
|
+
// against a screen edge.
|
|
8
|
+
const DEFAULT_MAX_VISIBLE_BUBBLES = 3;
|
|
9
|
+
const DEFAULT_MIN_HEIGHT = 96;
|
|
10
|
+
|
|
11
|
+
// `bubbleBottoms` are the layout bottoms (offsetTop + offsetHeight) of each
|
|
12
|
+
// bubble in list order; the count cap is the bottom of the last bubble allowed
|
|
13
|
+
// to be visible, so exactly that many show before scrolling.
|
|
14
|
+
export function resolveBubbleListMaxHeight({
|
|
15
|
+
room,
|
|
16
|
+
bubbleBottoms = [],
|
|
17
|
+
maxVisible = DEFAULT_MAX_VISIBLE_BUBBLES,
|
|
18
|
+
minHeight = DEFAULT_MIN_HEIGHT
|
|
19
|
+
} = {}) {
|
|
20
|
+
const heightBudget = Number.isFinite(room) ? Math.round(room) : 0;
|
|
21
|
+
|
|
22
|
+
const capBottom = bubbleBottoms.length > maxVisible ? bubbleBottoms[maxVisible - 1] : undefined;
|
|
23
|
+
const countBudget = Number.isFinite(capBottom) ? Math.round(capBottom) : Infinity;
|
|
24
|
+
|
|
25
|
+
return Math.max(minHeight, Math.min(heightBudget, countBudget));
|
|
26
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, Tray } from "electron";
|
|
1
|
+
import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import { createDaemonRuntime } from "../../../../packages/daemon-core/src/daemon-runtime.js";
|
|
5
6
|
import { createIpcServer } from "../../../../packages/daemon-core/src/ipc-server.js";
|
|
@@ -18,6 +19,7 @@ import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } fro
|
|
|
18
19
|
import { buildTrayMenu, buildTrayTooltip } from "./tray-menu.js";
|
|
19
20
|
import { createStateFile } from "./state-file.js";
|
|
20
21
|
import { discoverPets } from "./pet-loader.js";
|
|
22
|
+
import { checkForUpdate, UPDATE_PAGE_URL } from "../../../../packages/app-state/src/update-check.js";
|
|
21
23
|
|
|
22
24
|
const STALE_SWEEP_INTERVAL_MS = 10_000;
|
|
23
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -47,6 +49,9 @@ let petLocal = { x: 0, y: 0 };
|
|
|
47
49
|
// User-chosen pet scale (resize grip); the pet occupies PET_SIZE × petScale.
|
|
48
50
|
let petScale = 1;
|
|
49
51
|
let approvalWatch;
|
|
52
|
+
// Set once the daily npm update check finds a newer version; surfaces as a
|
|
53
|
+
// tray item (see tray-menu.js).
|
|
54
|
+
let updateAvailable;
|
|
50
55
|
|
|
51
56
|
// Electron singleton: a second launch forwards to the running instance.
|
|
52
57
|
if (!app.requestSingleInstanceLock()) {
|
|
@@ -116,6 +121,9 @@ async function bootstrap() {
|
|
|
116
121
|
createPetWindow();
|
|
117
122
|
createTray();
|
|
118
123
|
registerRendererHandlers();
|
|
124
|
+
// Best-effort and cached in state.json (shared with the CLI's check, so at
|
|
125
|
+
// most one registry request per day between them); never blocks startup.
|
|
126
|
+
void detectUpdate();
|
|
119
127
|
|
|
120
128
|
const sweep = setInterval(() => {
|
|
121
129
|
runtime.markStaleSessions(Date.now());
|
|
@@ -219,7 +227,8 @@ function refreshTrayMenu() {
|
|
|
219
227
|
attachBubblesToTerminals: positionState.settings.attachBubblesToTerminals,
|
|
220
228
|
selectedPetId: positionState.globalPet.selectedPetId,
|
|
221
229
|
sessions,
|
|
222
|
-
pets: pets.map((pet) => ({ id: pet.manifest.id, name: pet.manifest.name }))
|
|
230
|
+
pets: pets.map((pet) => ({ id: pet.manifest.id, name: pet.manifest.name })),
|
|
231
|
+
updateAvailable
|
|
223
232
|
}).map(toElectronMenuItem);
|
|
224
233
|
|
|
225
234
|
tray.setContextMenu(Menu.buildFromTemplate(template));
|
|
@@ -246,6 +255,42 @@ function toElectronMenuItem(item) {
|
|
|
246
255
|
return electronItem;
|
|
247
256
|
}
|
|
248
257
|
|
|
258
|
+
// Daily npm update check (shared cache with the CLI in state.json). On a hit,
|
|
259
|
+
// the tray gains an "Update Available (x.y.z)" item; checkForUpdate itself
|
|
260
|
+
// never throws, so this can run unawaited from bootstrap.
|
|
261
|
+
async function detectUpdate() {
|
|
262
|
+
const update = await checkForUpdate({
|
|
263
|
+
currentVersion: readPackageVersion(),
|
|
264
|
+
// Every companion write flows through the in-memory positionState (load →
|
|
265
|
+
// mutate → save). Mirror that here: read the live copy, and on save merge
|
|
266
|
+
// only the cache key into whatever positionState is by then — a direct
|
|
267
|
+
// stateFile.save of the load-time snapshot could clobber a pet move made
|
|
268
|
+
// while the registry fetch was in flight.
|
|
269
|
+
stateFile: {
|
|
270
|
+
load: async () => positionState,
|
|
271
|
+
save: async (next) => {
|
|
272
|
+
positionState = { ...positionState, updateCheck: next.updateCheck };
|
|
273
|
+
return stateFile.save(positionState);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (update) {
|
|
278
|
+
updateAvailable = update;
|
|
279
|
+
refreshTrayMenu();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The published version lives in the ROOT package.json (the companion workspace
|
|
284
|
+
// has its own, unpublished version number).
|
|
285
|
+
function readPackageVersion() {
|
|
286
|
+
try {
|
|
287
|
+
const packagePath = join(__dirname, "..", "..", "..", "..", "package.json");
|
|
288
|
+
return JSON.parse(readFileSync(packagePath, "utf8")).version;
|
|
289
|
+
} catch {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
249
294
|
function handleTrayClick(item) {
|
|
250
295
|
switch (item.id) {
|
|
251
296
|
case "toggle_pet":
|
|
@@ -257,6 +302,11 @@ function handleTrayClick(item) {
|
|
|
257
302
|
case "quit":
|
|
258
303
|
app.quit();
|
|
259
304
|
break;
|
|
305
|
+
case "update":
|
|
306
|
+
// Open the package page rather than running npm ourselves — installing
|
|
307
|
+
// is the user's call (and may need their node manager / sudo setup).
|
|
308
|
+
shell.openExternal(UPDATE_PAGE_URL);
|
|
309
|
+
break;
|
|
260
310
|
default:
|
|
261
311
|
if (item.id?.startsWith("display_mode:")) {
|
|
262
312
|
setDisplayMode(item.value);
|
|
@@ -55,6 +55,11 @@ export function buildTrayMenu(state = {}) {
|
|
|
55
55
|
// item would be a dead button. Re-enable once settings outgrow the tray
|
|
56
56
|
// (e.g. bubble text size, linger duration) and a handler is wired up.
|
|
57
57
|
// { id: "settings", label: "Open Settings" },
|
|
58
|
+
// Only present when the daily npm update check found a newer version;
|
|
59
|
+
// clicking it opens the package page (the app never runs npm itself).
|
|
60
|
+
...(state.updateAvailable?.latestVersion
|
|
61
|
+
? [{ id: "update", label: `Update Available (${state.updateAvailable.latestVersion})` }]
|
|
62
|
+
: []),
|
|
58
63
|
{ id: "separator", type: "separator" },
|
|
59
64
|
{ id: "quit", label: "Quit" }
|
|
60
65
|
];
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { resolveCompanionPetState } from "../../../../packages/session-core/src/pet-state.js";
|
|
17
17
|
import { resolveVisibleBubbles } from "../../../../packages/session-core/src/bubble-linger.js";
|
|
18
18
|
import { resolvePanelPlacement } from "../main/panel-placement.js";
|
|
19
|
+
import { resolveBubbleListMaxHeight } from "../main/bubble-list-viewport.js";
|
|
19
20
|
import { createInteractionController } from "./interaction-controller.js";
|
|
20
21
|
import { createBubbleList } from "./session-bubbles.js";
|
|
21
22
|
|
|
@@ -137,9 +138,11 @@ function placePanel() {
|
|
|
137
138
|
const alignRight = placement.x + rect.width / 2 > workArea.width / 2;
|
|
138
139
|
list.dataset.openDirection = openUp ? "up" : "down";
|
|
139
140
|
list.dataset.openAlign = alignRight ? "right" : "left";
|
|
140
|
-
// Cap the height to the room actually available on the chosen side
|
|
141
|
+
// Cap the height to the room actually available on the chosen side AND to
|
|
142
|
+
// three visible bubbles — more sessions are reached by scrolling the list.
|
|
141
143
|
const room = openUp ? placement.y - margin : workArea.height - (placement.y + rect.height) - margin;
|
|
142
|
-
|
|
144
|
+
const bubbleBottoms = Array.from(list.children, (child) => child.offsetTop + child.offsetHeight);
|
|
145
|
+
list.style.maxHeight = `${resolveBubbleListMaxHeight({ room, bubbleBottoms })}px`;
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
|
|
@@ -32,7 +32,11 @@ export function createBubbleList(container, { collapsed = false, onRender } = {}
|
|
|
32
32
|
|
|
33
33
|
if (!isCollapsed) {
|
|
34
34
|
const list = document.createElement("div");
|
|
35
|
-
list
|
|
35
|
+
// The list itself must be pointer-active (not just the bubbles): with
|
|
36
|
+
// more than three sessions it scrolls, and the scrollbar + the gaps
|
|
37
|
+
// between bubbles belong to the list element — if it stayed
|
|
38
|
+
// click-through, wheel/drag there would fall through to the desktop.
|
|
39
|
+
list.className = "bubble-list interactive";
|
|
36
40
|
for (const bubble of lastBubbles) {
|
|
37
41
|
list.appendChild(renderBubble(bubble));
|
|
38
42
|
}
|
|
@@ -149,6 +149,25 @@ body {
|
|
|
149
149
|
pointer-events: auto;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/* Scrolling is by design (at most three bubbles are visible at once), so give
|
|
153
|
+
the list a slim thumb that fits the dark pills instead of the stock bar. */
|
|
154
|
+
.bubble-list::-webkit-scrollbar {
|
|
155
|
+
width: 6px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.bubble-list::-webkit-scrollbar-track {
|
|
159
|
+
background: transparent;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.bubble-list::-webkit-scrollbar-thumb {
|
|
163
|
+
background: rgba(255, 255, 255, 0.25);
|
|
164
|
+
border-radius: 3px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.bubble-list::-webkit-scrollbar-thumb:hover {
|
|
168
|
+
background: rgba(255, 255, 255, 0.4);
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
.bubble-list[data-open-direction="down"] { top: calc(100% + 6px); }
|
|
153
172
|
.bubble-list[data-open-direction="up"] { bottom: calc(100% + 6px); }
|
|
154
173
|
.bubble-list[data-open-align="left"] { left: 0; right: auto; }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { resolveBubbleListMaxHeight } from "../src/main/bubble-list-viewport.js";
|
|
4
|
+
|
|
5
|
+
// Layout bottoms for bubbles ~48px tall with a 6px gap.
|
|
6
|
+
const FOUR_BUBBLES = [48, 102, 156, 210];
|
|
7
|
+
|
|
8
|
+
test("three or fewer bubbles use the height budget alone", () => {
|
|
9
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [48, 102, 156] }), 400);
|
|
10
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [48] }), 400);
|
|
11
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [] }), 400);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("more than three bubbles cap the viewport at the third bubble's bottom", () => {
|
|
15
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: FOUR_BUBBLES }), 156);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("the height budget still wins when it is tighter than the count cap", () => {
|
|
19
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 120, bubbleBottoms: FOUR_BUBBLES }), 120);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the minimum height floor applies to both budgets", () => {
|
|
23
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 40, bubbleBottoms: FOUR_BUBBLES }), 96);
|
|
24
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 40, bubbleBottoms: [48] }), 96);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("the room is rounded to whole pixels", () => {
|
|
28
|
+
assert.equal(resolveBubbleListMaxHeight({ room: 150.6, bubbleBottoms: [48] }), 151);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("maxVisible and minHeight are configurable", () => {
|
|
32
|
+
assert.equal(
|
|
33
|
+
resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: FOUR_BUBBLES, maxVisible: 2 }),
|
|
34
|
+
102
|
|
35
|
+
);
|
|
36
|
+
assert.equal(
|
|
37
|
+
resolveBubbleListMaxHeight({ room: 40, bubbleBottoms: [48], minHeight: 32 }),
|
|
38
|
+
40
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("garbage measurements fall back safely", () => {
|
|
43
|
+
// Unusable room → the floor keeps the list usable.
|
|
44
|
+
assert.equal(resolveBubbleListMaxHeight({ room: Number.NaN, bubbleBottoms: [48] }), 96);
|
|
45
|
+
// Unusable bottom at the cap index → no count cap, height budget alone.
|
|
46
|
+
assert.equal(
|
|
47
|
+
resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [48, 102, Number.NaN, 210] }),
|
|
48
|
+
400
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -47,6 +47,16 @@ test("reflects the attach-bubbles checkbox state", () => {
|
|
|
47
47
|
assert.equal(buildTrayMenu({ ...baseState, attachBubblesToTerminals: false }).find((i) => i.id === "attach_bubbles").checked, false);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("shows the update item only when a newer version is known", () => {
|
|
51
|
+
const withoutUpdate = buildTrayMenu(baseState);
|
|
52
|
+
assert.ok(!withoutUpdate.some((i) => i.id === "update"), "no update item by default");
|
|
53
|
+
|
|
54
|
+
const withUpdate = buildTrayMenu({ ...baseState, updateAvailable: { latestVersion: "9.9.9" } });
|
|
55
|
+
const item = withUpdate.find((i) => i.id === "update");
|
|
56
|
+
assert.ok(item, "update item appears when an update is known");
|
|
57
|
+
assert.ok(item.label.includes("9.9.9"), "label names the new version");
|
|
58
|
+
});
|
|
59
|
+
|
|
50
60
|
test("uses the HAYA Pet brand in the tray hover text", () => {
|
|
51
61
|
assert.equal(buildTrayTooltip(), "HAYA Pet");
|
|
52
62
|
});
|
package/docs/known-issues.md
CHANGED
|
@@ -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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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,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
|
+
});
|