@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ 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.8]
11
+
12
+ ### Fixed
13
+ - **Scrolling the session panel no longer fights you.** With four or more
14
+ sessions the bubble panel scrolls (introduced in 0.2.7), but two bugs made it
15
+ unusable: the scroll position snapped back to the top on every status update,
16
+ and a wheel scroll or scrollbar drag would "disconnect" mid-gesture and need
17
+ restarting. Both came from the panel rebuilding its entire DOM on each refresh
18
+ (every session push plus a 2 s linger tick). The panel now renders
19
+ incrementally — the list and each session's bubble persist across updates and
20
+ are mutated in place — so the scroll position holds and a gesture stays
21
+ attached to its element. The status spinner also no longer restarts on every
22
+ refresh.
23
+
24
+ ## [0.2.7]
25
+
26
+ ### Fixed
27
+ - **Codex `/quit` no longer hangs after its goodbye.** The `haya-pet state`
28
+ hook reporter could hang forever on a never-settling IPC await (pipe connect,
29
+ write drain, or close) — and Codex awaits each hook child with a default
30
+ **600 s** timeout, so a hung turn-end `state idle` reporter both froze the
31
+ pet on "working" and made `/quit` sit on its token-usage goodbye for up to
32
+ 10 minutes (Ctrl+C worked because it kills Codex without the wait, orphaning
33
+ the reporter — observed live). The reporter now races its whole IPC
34
+ interaction against a 2 s deadline and always exits; the wrapper's own
35
+ companion connection gets the same guard (5 s) so a wedged companion can
36
+ never hold the terminal after the wrapped CLI exits.
37
+
38
+ ### Added
39
+ - **Update notice.** HAYA Pet now checks npm (at most once a day, cached in
40
+ `state.json`, shared between the CLI and the overlay) for a newer published
41
+ version. The CLI prints a one-line notice after a wrapped command exits (and
42
+ after `haya-pet start`), and the tray gains an **Update Available (x.y.z)**
43
+ item that opens the package page — the app never runs npm itself. The check
44
+ is best-effort (3 s timeout, silent on any failure, never blocks a run),
45
+ skipped when stdout isn't a terminal, and can be disabled with
46
+ `HAYA_PET_NO_UPDATE_CHECK=1`.
47
+
48
+ ### Changed
49
+ - **The bubble panel shows at most three sessions at once.** Beyond the existing
50
+ height budget (the room between the folder button and the screen edge), the
51
+ list now also caps its viewport at the bottom of the third bubble — more
52
+ sessions are reached by scrolling, so a busy machine no longer grows a
53
+ screen-tall stack. The list surface itself (gaps between bubbles and the
54
+ scrollbar) is now pointer-active so wheel scrolling and scrollbar dragging
55
+ work anywhere on the open panel, and the scrollbar is a slim dark-theme thumb
56
+ instead of the stock bar.
57
+
10
58
  ## [0.2.6]
11
59
 
12
60
  ### 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
 
@@ -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
- return await runGenericCommand({
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
- list.style.maxHeight = `${Math.max(96, Math.round(room))}px`;
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