@hayasaka7/haya-pet 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +27 -5
- package/apps/cli/src/haya-pet.js +136 -4
- package/apps/cli/test/haya-pet.test.mjs +109 -0
- package/apps/companion/src/main/bubble-list-viewport.js +26 -0
- package/apps/companion/src/main/index.js +52 -2
- package/apps/companion/src/main/tray-menu.js +5 -0
- package/apps/companion/src/renderer/pet-window.js +5 -2
- package/apps/companion/src/renderer/session-bubbles.js +5 -1
- package/apps/companion/src/renderer/styles.css +19 -0
- package/apps/companion/test/bubble-list-viewport.test.mjs +50 -0
- package/apps/companion/test/tray-menu.test.mjs +10 -0
- package/docs/architecture.md +8 -2
- package/docs/known-issues.md +90 -5
- package/docs/troubleshooting.md +4 -1
- package/package.json +23 -1
- package/packages/adapters/src/codex-guardian.js +131 -0
- package/packages/adapters/src/codex-hooks.js +11 -2
- package/packages/adapters/test/codex-guardian.test.mjs +174 -0
- package/packages/app-state/src/state.js +4 -1
- package/packages/app-state/src/update-check.js +173 -0
- package/packages/app-state/test/update-check.test.mjs +227 -0
- package/packages/cli-core/src/codex-guardian-watcher.js +136 -0
- package/packages/cli-core/src/codex-rollout-fs.js +88 -0
- package/packages/cli-core/src/codex-transcript-watcher.js +2 -65
- package/packages/cli-core/src/deadline.js +23 -0
- package/packages/cli-core/src/run-state.js +31 -11
- package/packages/cli-core/test/codex-guardian-watcher.test.mjs +217 -0
- package/packages/cli-core/test/deadline.test.mjs +29 -0
- package/packages/cli-core/test/run-state.test.mjs +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ 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
|
+
|
|
44
|
+
## [0.2.6]
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- **Codex "Approve for me" no longer shows a false *waiting for approval*.**
|
|
48
|
+
With `approvals_reviewer = auto_review` (the TUI's "Approve for me"; legacy
|
|
49
|
+
config alias `guardian_subagent`), Codex routes approval requests to a
|
|
50
|
+
guardian subagent and never prompts the user — but its `PermissionRequest`
|
|
51
|
+
hook still fires when the request is created, so the pet sat on *waiting for
|
|
52
|
+
approval* for the entire auto-review (and the approved command's run). A new
|
|
53
|
+
guardian-review watcher tails the guardian's own session rollout (the only
|
|
54
|
+
persisted trace of the review) and reports event-backed states instead:
|
|
55
|
+
*reviewing* while the guardian assesses, *running tools* on an `allow`
|
|
56
|
+
verdict, *thinking* on a `deny` (the rejection goes back to the model — no
|
|
57
|
+
human decision is pending). When the reviewer is the user (`approvals_reviewer
|
|
58
|
+
= "user"`, "Ask for approval"), nothing changes: *waiting for approval* still
|
|
59
|
+
shows until the user decides.
|
|
60
|
+
|
|
10
61
|
## [0.2.4]
|
|
11
62
|
|
|
12
63
|
### Fixed
|
package/README.md
CHANGED
|
@@ -187,10 +187,14 @@ Why opt in? Both clients show a one-time trust prompt when hooks are added. HAYA
|
|
|
187
187
|
Pet lets you decide when to approve that instead of surprising you in the middle
|
|
188
188
|
of work.
|
|
189
189
|
|
|
190
|
-
Codex
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
Codex live status combines three sources: hooks report `thinking`/`idle` and
|
|
191
|
+
approval requests, a transcript watcher reports tool/file activity, and a
|
|
192
|
+
guardian-review watcher tracks Codex's **"Approve for me"** auto-reviewer — the
|
|
193
|
+
pet shows *reviewing* while the guardian assesses a request and only shows
|
|
194
|
+
*waiting for approval* when Codex actually asks you ("Ask for approval" mode).
|
|
195
|
+
Per-tool `PreToolUse` hooks still depend on an upstream Codex gap
|
|
196
|
+
([openai/codex#16732](https://github.com/openai/codex/issues/16732)); the
|
|
197
|
+
transcript watcher covers that in the meantime.
|
|
194
198
|
|
|
195
199
|
For any client, you can ask HAYA Pet to infer rough activity from terminal
|
|
196
200
|
output:
|
|
@@ -244,6 +248,21 @@ exit code. Disable auto-start with `HAYA_PET_NO_AUTOSTART=1`.
|
|
|
244
248
|
| Electron | Installed as a runtime dependency. |
|
|
245
249
|
| node-pty | Optional; used only for `--observe`. |
|
|
246
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
|
+
|
|
247
266
|
## Troubleshooting
|
|
248
267
|
|
|
249
268
|
| Symptom | Fix |
|
|
@@ -261,7 +280,10 @@ repairing a broken Electron install.
|
|
|
261
280
|
|
|
262
281
|
HAYA Pet is local-only by default. It does not upload prompts, files,
|
|
263
282
|
screenshots, or session logs. The overlay stores only local state needed for
|
|
264
|
-
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`.
|
|
265
287
|
|
|
266
288
|
## Documentation
|
|
267
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";
|
|
@@ -9,12 +10,18 @@ import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages
|
|
|
9
10
|
import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
|
|
10
11
|
import { watchClaudeTranscript as defaultWatchClaudeTranscript } from "../../../packages/cli-core/src/claude-transcript-watcher.js";
|
|
11
12
|
import { watchCodexTranscript as defaultWatchCodexTranscript } from "../../../packages/cli-core/src/codex-transcript-watcher.js";
|
|
13
|
+
import { watchCodexGuardianReviews as defaultWatchCodexGuardianReviews } from "../../../packages/cli-core/src/codex-guardian-watcher.js";
|
|
12
14
|
import { ensureCompanionConnection } from "../../../packages/cli-core/src/companion-launcher.js";
|
|
13
15
|
import { createIpcClient as defaultCreateIpcClient } from "../../../packages/daemon-core/src/ipc-server.js";
|
|
14
16
|
import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
|
|
15
17
|
import { discoverPets as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
|
|
16
18
|
import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
|
|
17
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;
|
|
18
25
|
import { getAdapterInfo } from "../../../packages/adapters/src/adapter-info.js";
|
|
19
26
|
|
|
20
27
|
const CLIENT_DISPLAY_NAMES = Object.freeze({
|
|
@@ -104,6 +111,13 @@ export async function runStopCommand(_parsed, dependencies = {}) {
|
|
|
104
111
|
// Explicitly start the companion overlay (so users never need `npm start`).
|
|
105
112
|
export async function runStartCommand(_parsed, dependencies = {}) {
|
|
106
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) {
|
|
107
121
|
const { client, started, error, timedOut } = await connectCompanion(dependencies, true);
|
|
108
122
|
|
|
109
123
|
if (client) {
|
|
@@ -122,17 +136,67 @@ export async function runStartCommand(_parsed, dependencies = {}) {
|
|
|
122
136
|
return { command: "start", ok: false, started: false };
|
|
123
137
|
}
|
|
124
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
|
+
|
|
125
184
|
async function runRunCommand(parsed, dependencies) {
|
|
126
185
|
const runGenericCommand = dependencies.runGenericCommand ?? defaultRunGenericCommand;
|
|
127
186
|
const injectClaudeHooks = dependencies.injectClaudeHooks ?? defaultInjectClaudeHooks;
|
|
128
187
|
const injectCodexHooks = dependencies.injectCodexHooks ?? defaultInjectCodexHooks;
|
|
129
188
|
const watchClaudeTranscript = dependencies.watchClaudeTranscript ?? defaultWatchClaudeTranscript;
|
|
130
189
|
const watchCodexTranscript = dependencies.watchCodexTranscript ?? defaultWatchCodexTranscript;
|
|
190
|
+
const watchCodexGuardianReviews =
|
|
191
|
+
dependencies.watchCodexGuardianReviews ?? defaultWatchCodexGuardianReviews;
|
|
131
192
|
const print = dependencies.print ?? defaultPrint;
|
|
132
193
|
const env = dependencies.env ?? process.env;
|
|
133
194
|
const now = dependencies.now ?? Date.now;
|
|
134
195
|
const cwd = dependencies.cwd ?? process.cwd();
|
|
135
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);
|
|
136
200
|
|
|
137
201
|
const sessionId = dependencies.sessionId ?? `sess_${randomUUID()}`;
|
|
138
202
|
let childArgs = parsed.childArgs;
|
|
@@ -251,11 +315,55 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
251
315
|
watcher.stop();
|
|
252
316
|
previousStopWatcher();
|
|
253
317
|
};
|
|
318
|
+
|
|
319
|
+
// With "Approve for me" (approvals_reviewer=auto_review, legacy alias
|
|
320
|
+
// guardian_subagent), Codex routes approval requests to a guardian
|
|
321
|
+
// subagent and never shows the human approval UI — yet the
|
|
322
|
+
// PermissionRequest hook still fires at request creation, which used to
|
|
323
|
+
// pin the pet on a false "waiting for approval" for the whole review.
|
|
324
|
+
// The guardian's own rollout is the only observable record of the
|
|
325
|
+
// review, so tail it: a review turn starting proves the agent is
|
|
326
|
+
// reviewing; an "allow" verdict proves the action proceeds; a "deny"
|
|
327
|
+
// verdict goes back to the model, which keeps working. An unreadable
|
|
328
|
+
// verdict reports nothing — a pending cue is never cleared on a guess.
|
|
329
|
+
const guardianWatcher = watchCodexGuardianReviews({
|
|
330
|
+
homeDir: dependencies.homeDir,
|
|
331
|
+
sessionsRoot: dependencies.codexSessionsRoot,
|
|
332
|
+
startedAt: now(),
|
|
333
|
+
onReviewEvent: (event) => {
|
|
334
|
+
hookDebugLog(env, now, {
|
|
335
|
+
source: "codex_guardian",
|
|
336
|
+
event: event.type,
|
|
337
|
+
outcome: event.outcome
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const report = resolveGuardianStateReport(event);
|
|
341
|
+
if (!report) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
messageSender
|
|
345
|
+
.send({
|
|
346
|
+
type: "state",
|
|
347
|
+
sessionId,
|
|
348
|
+
state: report.state,
|
|
349
|
+
summary: report.summary,
|
|
350
|
+
confidence: 0.85,
|
|
351
|
+
source: "client_log",
|
|
352
|
+
updatedAt: now()
|
|
353
|
+
})
|
|
354
|
+
.catch(() => {});
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
const stopWithoutGuardian = stopWatcher;
|
|
358
|
+
stopWatcher = () => {
|
|
359
|
+
guardianWatcher.stop();
|
|
360
|
+
stopWithoutGuardian();
|
|
361
|
+
};
|
|
254
362
|
}
|
|
255
363
|
}
|
|
256
364
|
|
|
257
365
|
try {
|
|
258
|
-
|
|
366
|
+
const result = await runGenericCommand({
|
|
259
367
|
command: parsed.childCommand,
|
|
260
368
|
args: childArgs,
|
|
261
369
|
cwd,
|
|
@@ -269,6 +377,8 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
269
377
|
stdio: dependencies.stdio,
|
|
270
378
|
send: messageSender.send
|
|
271
379
|
});
|
|
380
|
+
await reportUpdateNotice(updateCheck, print);
|
|
381
|
+
return result;
|
|
272
382
|
} finally {
|
|
273
383
|
stopWatcher();
|
|
274
384
|
cleanup();
|
|
@@ -276,6 +386,23 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
276
386
|
}
|
|
277
387
|
}
|
|
278
388
|
|
|
389
|
+
// Map a guardian review event to the pet state it proves. waiting_approval is
|
|
390
|
+
// deliberately NOT among these: with the guardian reviewing, the user is not
|
|
391
|
+
// being asked anything, and after a deny the request is resolved (the model
|
|
392
|
+
// receives the rejection and continues) — there is no pending human decision.
|
|
393
|
+
function resolveGuardianStateReport(event) {
|
|
394
|
+
if (event.type === "review_started") {
|
|
395
|
+
return { state: "reviewing", summary: "agent reviewing approval" };
|
|
396
|
+
}
|
|
397
|
+
if (event.type === "review_finished" && event.outcome === "allow") {
|
|
398
|
+
return { state: "running_tool", summary: "reviewer approved" };
|
|
399
|
+
}
|
|
400
|
+
if (event.type === "review_finished" && event.outcome === "deny") {
|
|
401
|
+
return { state: "thinking", summary: "reviewer denied" };
|
|
402
|
+
}
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
279
406
|
// Resolve whether live-status hooks should be injected for this run (any
|
|
280
407
|
// hook-capable client). Precedence: HAYA_PET_NO_HOOKS forces off, HAYA_PET_HOOKS
|
|
281
408
|
// forces on (per-run overrides), otherwise the persisted `haya-pet hooks on/off`
|
|
@@ -545,9 +672,14 @@ async function createMessageSender(dependencies) {
|
|
|
545
672
|
process.stderr.write("haya-pet: started the companion overlay.\n");
|
|
546
673
|
}
|
|
547
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;
|
|
548
680
|
return {
|
|
549
|
-
send: (message) => client.send(message),
|
|
550
|
-
close: () => client.close()
|
|
681
|
+
send: (message) => raceDeadline(client.send(message), deadlineMs),
|
|
682
|
+
close: () => raceDeadline(client.close(), deadlineMs)
|
|
551
683
|
};
|
|
552
684
|
}
|
|
553
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
|
|
|
@@ -483,6 +551,47 @@ test("codex hooks also start a transcript watcher for tool activity", async () =
|
|
|
483
551
|
assert.ok(sent.every((message) => message.updatedAt === undefined || message.updatedAt === 42));
|
|
484
552
|
});
|
|
485
553
|
|
|
554
|
+
test("codex hooks also start a guardian-review watcher that reports review states", async () => {
|
|
555
|
+
const sent = [];
|
|
556
|
+
let fireReviewEvent;
|
|
557
|
+
let stopped = false;
|
|
558
|
+
|
|
559
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
560
|
+
cwd: process.cwd(),
|
|
561
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
562
|
+
now: () => 42,
|
|
563
|
+
heartbeatIntervalMs: 10,
|
|
564
|
+
send: async (message) => sent.push(message),
|
|
565
|
+
createStateFile: hooksStateFile(true),
|
|
566
|
+
injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
|
|
567
|
+
watchCodexTranscript: () => ({ stop: () => {} }),
|
|
568
|
+
watchCodexGuardianReviews: ({ onReviewEvent }) => {
|
|
569
|
+
fireReviewEvent = onReviewEvent;
|
|
570
|
+
return { stop: () => { stopped = true; } };
|
|
571
|
+
},
|
|
572
|
+
runGenericCommand: async (options) => {
|
|
573
|
+
fireReviewEvent({ type: "review_started" });
|
|
574
|
+
fireReviewEvent({ type: "review_finished", outcome: "allow" });
|
|
575
|
+
fireReviewEvent({ type: "review_started" });
|
|
576
|
+
fireReviewEvent({ type: "review_finished", outcome: "deny" });
|
|
577
|
+
// An unreadable verdict must not change the state (leave the cue as-is).
|
|
578
|
+
fireReviewEvent({ type: "review_finished", outcome: undefined });
|
|
579
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
assert.ok(stopped, "guardian watcher is stopped after the wrapped command exits");
|
|
584
|
+
const reviewStates = sent
|
|
585
|
+
.filter((message) => message.type === "state" && message.source === "client_log")
|
|
586
|
+
.map((message) => [message.state, message.summary]);
|
|
587
|
+
assert.deepEqual(reviewStates, [
|
|
588
|
+
["reviewing", "agent reviewing approval"],
|
|
589
|
+
["running_tool", "reviewer approved"],
|
|
590
|
+
["reviewing", "agent reviewing approval"],
|
|
591
|
+
["thinking", "reviewer denied"]
|
|
592
|
+
]);
|
|
593
|
+
});
|
|
594
|
+
|
|
486
595
|
test("codex hooks are skipped (with a notice) when the user passes their own -p", async () => {
|
|
487
596
|
const calls = [];
|
|
488
597
|
let injected = 0;
|
|
@@ -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; }
|