@hayasaka7/haya-pet 0.2.8 → 0.3.1
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 +32 -0
- package/apps/cli/src/haya-pet.js +35 -1
- package/apps/cli/test/haya-pet.test.mjs +50 -0
- package/apps/companion/src/main/index.js +2 -2
- package/apps/companion/src/main/pet-loader.js +5 -1
- package/apps/companion/src/renderer/pet-hit-test.js +23 -0
- package/apps/companion/src/renderer/pet-window.js +62 -6
- package/apps/companion/src/renderer/styles.css +4 -1
- package/apps/companion/test/pet-hit-test.test.mjs +30 -0
- package/docs/architecture.md +10 -3
- package/docs/known-issues.md +26 -0
- package/package.json +1 -1
- package/packages/adapters/src/claude-transcript.js +16 -0
- package/packages/adapters/src/codex-transcript.js +13 -2
- package/packages/adapters/test/claude-transcript.test.mjs +38 -0
- package/packages/adapters/test/codex-transcript.test.mjs +22 -0
- package/packages/cli-core/src/claude-transcript-watcher.js +3 -0
- package/packages/cli-core/test/claude-transcript-watcher.test.mjs +28 -0
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +29 -0
- package/packages/pet-core/src/atlas.js +1 -0
- package/packages/pet-core/src/discovery.js +29 -2
- package/packages/pet-core/test/discovery.test.mjs +63 -1
- package/packages/protocol/src/messages.js +3 -0
- package/packages/protocol/test/messages.test.mjs +3 -0
- package/packages/session-core/src/bubble-view.js +3 -0
- package/packages/session-core/src/pet-state.js +6 -4
- package/packages/session-core/src/summaries.js +1 -0
- package/packages/session-core/test/bubble-linger.test.mjs +16 -0
- package/packages/session-core/test/bubble-view.test.mjs +8 -0
- package/packages/session-core/test/pet-state.test.mjs +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ 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.3.1]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Clicks pass through the empty space around the pet.** The overlay used the
|
|
14
|
+
pet's whole rectangular sprite cell as its click target, so the transparent
|
|
15
|
+
margins around the character still swallowed clicks meant for the window
|
|
16
|
+
behind it. The pet now intercepts the mouse only where the current frame
|
|
17
|
+
actually has opaque pixels — sampled live from the canvas, with no per-frame
|
|
18
|
+
cost — so the catch area hugs the visible silhouette. The resize grip is
|
|
19
|
+
unaffected: it still reveals and works across the pet's full bounding box.
|
|
20
|
+
- **Interrupting a turn no longer leaves the pet stuck "thinking".** When you
|
|
21
|
+
press Esc while the agent is working — especially mid-thought, with no tool
|
|
22
|
+
running — neither Claude Code nor Codex fires a hook (`Stop` only fires on a
|
|
23
|
+
normal turn end), so the pet kept spinning on *thinking* until the 30 s stale
|
|
24
|
+
sweep. The transcript watchers now recognise each client's interrupt marker
|
|
25
|
+
(Claude's `[Request interrupted by user]` message; Codex's `turn_aborted`
|
|
26
|
+
record) and report a new `interrupted` status — a red ✕ that, unlike a real
|
|
27
|
+
failure, is **not** treated as a finished session, so the bubble stays put
|
|
28
|
+
(the session is still alive) instead of disappearing, and returns to the live
|
|
29
|
+
status as soon as you continue.
|
|
30
|
+
|
|
31
|
+
## [0.3.0]
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **A fresh install now shows a real pet.** The package has always shipped a
|
|
35
|
+
ready-to-use pet (`assets/fallback-pet`), but discovery only scanned the
|
|
36
|
+
user's pet folders (`~/.codex/pets`, `~/.haya-pet/pets`), so a new user with
|
|
37
|
+
no pets got a blue "dev placeholder" box and an empty pet list instead. The
|
|
38
|
+
bundled pet is now composed into discovery as a last resort — appended after
|
|
39
|
+
any of the user's own pets (which still win and stay the default) and deduped
|
|
40
|
+
by id — so the overlay always renders a real character out of the box.
|
|
41
|
+
|
|
10
42
|
## [0.2.8]
|
|
11
43
|
|
|
12
44
|
### Fixed
|
package/apps/cli/src/haya-pet.js
CHANGED
|
@@ -14,7 +14,7 @@ import { watchCodexGuardianReviews as defaultWatchCodexGuardianReviews } from ".
|
|
|
14
14
|
import { ensureCompanionConnection } from "../../../packages/cli-core/src/companion-launcher.js";
|
|
15
15
|
import { createIpcClient as defaultCreateIpcClient } from "../../../packages/daemon-core/src/ipc-server.js";
|
|
16
16
|
import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
|
|
17
|
-
import {
|
|
17
|
+
import { discoverPetsWithFallback as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
|
|
18
18
|
import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
|
|
19
19
|
import { getSelectedPetId, setSelectedPet, getHooksEnabled, setHooksEnabled } from "../../../packages/app-state/src/state.js";
|
|
20
20
|
import { checkForUpdate, UPDATE_COMMAND } from "../../../packages/app-state/src/update-check.js";
|
|
@@ -241,6 +241,22 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
241
241
|
updatedAt: now()
|
|
242
242
|
})
|
|
243
243
|
.catch(() => {});
|
|
244
|
+
},
|
|
245
|
+
// Esc-interrupt fires no Stop hook, so without this the pet stays stuck on
|
|
246
|
+
// "thinking"/"running". The transcript's interrupt marker is the only signal.
|
|
247
|
+
onInterrupt: () => {
|
|
248
|
+
hookDebugLog(env, now, { source: "transcript", event: "interrupted", state: "interrupted" });
|
|
249
|
+
messageSender
|
|
250
|
+
.send({
|
|
251
|
+
type: "state",
|
|
252
|
+
sessionId,
|
|
253
|
+
state: "interrupted",
|
|
254
|
+
summary: "interrupted",
|
|
255
|
+
confidence: 0.9,
|
|
256
|
+
source: "client_log",
|
|
257
|
+
updatedAt: now()
|
|
258
|
+
})
|
|
259
|
+
.catch(() => {});
|
|
244
260
|
}
|
|
245
261
|
});
|
|
246
262
|
stopWatcher = watcher.stop;
|
|
@@ -277,6 +293,24 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
277
293
|
state: event.state
|
|
278
294
|
});
|
|
279
295
|
|
|
296
|
+
// Esc-interrupt fires no Stop hook, so without this the pet stays stuck
|
|
297
|
+
// on "thinking"/"running" until the stale sweep.
|
|
298
|
+
if (event.type === "turn_aborted") {
|
|
299
|
+
activeToolCalls.clear();
|
|
300
|
+
messageSender
|
|
301
|
+
.send({
|
|
302
|
+
type: "state",
|
|
303
|
+
sessionId,
|
|
304
|
+
state: "interrupted",
|
|
305
|
+
summary: "interrupted",
|
|
306
|
+
confidence: 0.9,
|
|
307
|
+
source: "client_log",
|
|
308
|
+
updatedAt: now()
|
|
309
|
+
})
|
|
310
|
+
.catch(() => {});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
280
314
|
if (event.type === "tool_started") {
|
|
281
315
|
activeToolCalls.add(event.toolCallId);
|
|
282
316
|
messageSender
|
|
@@ -683,6 +683,56 @@ test("a transcript denial clears the stuck approval to idle", async () => {
|
|
|
683
683
|
assert.equal(idle.updatedAt, 42);
|
|
684
684
|
});
|
|
685
685
|
|
|
686
|
+
test("a transcript interrupt reports a failed status for Claude", async () => {
|
|
687
|
+
const sent = [];
|
|
688
|
+
let fireInterrupt;
|
|
689
|
+
await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
|
|
690
|
+
cwd: process.cwd(),
|
|
691
|
+
env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
|
|
692
|
+
now: () => 42,
|
|
693
|
+
heartbeatIntervalMs: 10,
|
|
694
|
+
send: async (message) => sent.push(message),
|
|
695
|
+
injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
|
|
696
|
+
watchClaudeTranscript: ({ onInterrupt }) => { fireInterrupt = onInterrupt; return { stop: () => {} }; },
|
|
697
|
+
runGenericCommand: async (options) => {
|
|
698
|
+
// Simulate the user pressing Esc to interrupt mid-turn.
|
|
699
|
+
fireInterrupt({ type: "interrupted" });
|
|
700
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const interrupted = sent.find((m) => m.type === "state" && m.source === "client_log");
|
|
705
|
+
assert.ok(interrupted, "a client_log state was sent on interrupt");
|
|
706
|
+
assert.equal(interrupted.state, "interrupted");
|
|
707
|
+
assert.equal(interrupted.summary, "interrupted");
|
|
708
|
+
assert.equal(interrupted.updatedAt, 42);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("a transcript turn_aborted reports a failed status for Codex", async () => {
|
|
712
|
+
const sent = [];
|
|
713
|
+
let fireToolEvent;
|
|
714
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
715
|
+
cwd: process.cwd(),
|
|
716
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
717
|
+
now: () => 42,
|
|
718
|
+
heartbeatIntervalMs: 10,
|
|
719
|
+
send: async (message) => sent.push(message),
|
|
720
|
+
createStateFile: hooksStateFile(true),
|
|
721
|
+
injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
|
|
722
|
+
watchCodexTranscript: ({ onToolEvent }) => { fireToolEvent = onToolEvent; return { stop: () => {} }; },
|
|
723
|
+
runGenericCommand: async (options) => {
|
|
724
|
+
// Simulate the user pressing Esc: Codex writes a turn_aborted record.
|
|
725
|
+
fireToolEvent({ type: "turn_aborted", reason: "interrupted" });
|
|
726
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const interrupted = sent.find((m) => m.type === "state" && m.source === "client_log" && m.state === "interrupted");
|
|
731
|
+
assert.ok(interrupted, "a client_log interrupted state was sent on turn_aborted");
|
|
732
|
+
assert.equal(interrupted.summary, "interrupted");
|
|
733
|
+
assert.equal(interrupted.updatedAt, 42);
|
|
734
|
+
});
|
|
735
|
+
|
|
686
736
|
test("non-hook-capable clients are never injected even with HAYA_PET_HOOKS=1", async () => {
|
|
687
737
|
const calls = [];
|
|
688
738
|
await runAiPet(["run", "--client", "generic", "--", "aider"], {
|
|
@@ -18,7 +18,7 @@ import { resolveSavedPosition } from "./display-manager.js";
|
|
|
18
18
|
import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
|
|
19
19
|
import { buildTrayMenu, buildTrayTooltip } from "./tray-menu.js";
|
|
20
20
|
import { createStateFile } from "./state-file.js";
|
|
21
|
-
import {
|
|
21
|
+
import { discoverPetsWithFallback } from "./pet-loader.js";
|
|
22
22
|
import { checkForUpdate, UPDATE_PAGE_URL } from "../../../../packages/app-state/src/update-check.js";
|
|
23
23
|
|
|
24
24
|
const STALE_SWEEP_INTERVAL_MS = 10_000;
|
|
@@ -67,7 +67,7 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
67
67
|
async function bootstrap() {
|
|
68
68
|
positionState = await stateFile.load();
|
|
69
69
|
petScale = clampScale(getPetScale(positionState));
|
|
70
|
-
pets = await
|
|
70
|
+
pets = await discoverPetsWithFallback(paths.petSearchPaths);
|
|
71
71
|
|
|
72
72
|
// Clients fire no event at the moment the user ACCEPTS a permission prompt
|
|
73
73
|
// (only denial/finish are observable), so a waiting_approval session would
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
// Re-exported from pet-core so the companion and CLI share one discovery path.
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
discoverPets,
|
|
4
|
+
discoverPetsWithFallback,
|
|
5
|
+
loadPetFromDir
|
|
6
|
+
} from "../../../../packages/pet-core/src/discovery.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Pure helpers for the pet's pixel-precise click-through. The browser glue in
|
|
2
|
+
// pet-window.js supplies the live pixel alpha and the canvas bounding box; these
|
|
3
|
+
// functions hold the (testable) decisions.
|
|
4
|
+
|
|
5
|
+
// Default minimum alpha for a pixel to count as "the pet" rather than the
|
|
6
|
+
// transparent margin of its sprite cell. A small threshold ignores faint
|
|
7
|
+
// anti-aliased edges so the click-through hugs the visible silhouette.
|
|
8
|
+
export const DEFAULT_ALPHA_THRESHOLD = 16;
|
|
9
|
+
|
|
10
|
+
// Top-left inclusive, bottom-right exclusive — matches canvas pixel indexing so
|
|
11
|
+
// the same predicate works for both bounding-box and per-pixel tests.
|
|
12
|
+
export function isPointInsideRect(point, rect) {
|
|
13
|
+
return (
|
|
14
|
+
point.x >= rect.left &&
|
|
15
|
+
point.x < rect.right &&
|
|
16
|
+
point.y >= rect.top &&
|
|
17
|
+
point.y < rect.bottom
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isOpaqueAlpha(alpha, threshold = DEFAULT_ALPHA_THRESHOLD) {
|
|
22
|
+
return alpha >= threshold;
|
|
23
|
+
}
|
|
@@ -19,12 +19,15 @@ import { resolvePanelPlacement } from "../main/panel-placement.js";
|
|
|
19
19
|
import { resolveBubbleListMaxHeight } from "../main/bubble-list-viewport.js";
|
|
20
20
|
import { createInteractionController } from "./interaction-controller.js";
|
|
21
21
|
import { createBubbleList } from "./session-bubbles.js";
|
|
22
|
+
import { isOpaqueAlpha, isPointInsideRect } from "./pet-hit-test.js";
|
|
22
23
|
|
|
23
24
|
const bridge = window.aiPet;
|
|
24
25
|
const petEl = document.getElementById("pet");
|
|
25
26
|
const canvas = document.getElementById("pet-canvas");
|
|
26
27
|
const gripEl = document.getElementById("pet-resize-grip");
|
|
27
|
-
|
|
28
|
+
// willReadFrequently: the click-through hit-test samples a single pixel on every
|
|
29
|
+
// pointer move (see pointerHitsPetPixel), so keep the canvas CPU-backed.
|
|
30
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
28
31
|
const panelEl = document.getElementById("bubbles");
|
|
29
32
|
|
|
30
33
|
// The sprite's natural cell size; the canvas is this times the user's scale.
|
|
@@ -190,6 +193,10 @@ function playOneShot(action) {
|
|
|
190
193
|
|
|
191
194
|
canvas.addEventListener("pointerdown", (event) => {
|
|
192
195
|
canvas.setPointerCapture(event.pointerId);
|
|
196
|
+
// Hold click-through off for the whole press: a drag swaps to the running
|
|
197
|
+
// frames, whose opaque pixels differ from the grabbed one, so re-running the
|
|
198
|
+
// pixel test mid-gesture could flip the window to pass-through and drop it.
|
|
199
|
+
petPressed = true;
|
|
193
200
|
dragOffset = { x: event.offsetX, y: event.offsetY };
|
|
194
201
|
// Window-local (clientX/Y) coords throughout — the overlay covers the work area.
|
|
195
202
|
controller.pointerDown({ x: event.clientX, y: event.clientY, time: performance.now() });
|
|
@@ -206,11 +213,18 @@ canvas.addEventListener("pointermove", (event) => {
|
|
|
206
213
|
canvas.addEventListener("pointerup", (event) => {
|
|
207
214
|
// Click / double-click are delivered asynchronously via onAction; only the
|
|
208
215
|
// synchronous drag-end is handled here.
|
|
216
|
+
petPressed = false;
|
|
209
217
|
const result = controller.pointerUp({ x: event.clientX, y: event.clientY, time: performance.now() });
|
|
210
218
|
if (result?.type === "drag-end") {
|
|
211
219
|
animationState = clearDragAction(animationState);
|
|
212
220
|
bridge?.savePetPosition?.(petLocal);
|
|
213
221
|
}
|
|
222
|
+
refreshMouseIgnore(event.clientX, event.clientY);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
canvas.addEventListener("pointercancel", () => {
|
|
226
|
+
petPressed = false;
|
|
227
|
+
animationState = clearDragAction(animationState);
|
|
214
228
|
});
|
|
215
229
|
|
|
216
230
|
// --- Resize grip: drag to scale the pet, double-click to reset ---
|
|
@@ -310,23 +324,54 @@ function renderBubbles() {
|
|
|
310
324
|
// The overlay window covers a large area but should only intercept the mouse
|
|
311
325
|
// over the pet and the bubble chips; everywhere else must pass through to the
|
|
312
326
|
// desktop. The window is created ignoring mouse events (with forwarding), and
|
|
313
|
-
// we flip it back on whenever the cursor is over an `.interactive` element.
|
|
327
|
+
// we flip it back on whenever the cursor is over an `.interactive` element. Over
|
|
328
|
+
// the pet canvas we go further and only intercept where the sprite has opaque
|
|
329
|
+
// pixels, so the transparent margins of the cell pass clicks through too.
|
|
314
330
|
|
|
315
331
|
let mouseIgnored;
|
|
332
|
+
let petPressed = false; // a press/drag is in progress on the pet canvas
|
|
316
333
|
let lastPointer = { x: -1, y: -1 };
|
|
317
334
|
|
|
335
|
+
// True when the cursor is over a non-transparent pixel of the current frame. The
|
|
336
|
+
// canvas already holds the current frame at the current scale, so sampling it
|
|
337
|
+
// directly needs no atlas math. (Electron lets us read a file://-drawn canvas;
|
|
338
|
+
// if a future version ever taints it, getImageData throws and we fall back to
|
|
339
|
+
// treating the whole canvas box as interactive.)
|
|
340
|
+
function pointerHitsPetPixel(x, y) {
|
|
341
|
+
if (!spritesheet) {
|
|
342
|
+
return true; // dev placeholder has no sprite: keep the whole box interactive
|
|
343
|
+
}
|
|
344
|
+
const rect = canvas.getBoundingClientRect();
|
|
345
|
+
if (!isPointInsideRect({ x, y }, rect)) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
const lx = Math.floor(x - rect.left);
|
|
349
|
+
const ly = Math.floor(y - rect.top);
|
|
350
|
+
try {
|
|
351
|
+
return isOpaqueAlpha(ctx.getImageData(lx, ly, 1, 1).data[3]);
|
|
352
|
+
} catch {
|
|
353
|
+
return true; // tainted canvas: degrade to full-box interactivity
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
318
357
|
function refreshMouseIgnore(x, y) {
|
|
319
358
|
// While the grip is captured, the pointer can briefly leave it (the pet only
|
|
320
359
|
// approximately tracks the diagonal); flipping click-through mid-drag would
|
|
321
|
-
// drop the pointerup, so hold interaction until the drag ends.
|
|
322
|
-
|
|
360
|
+
// drop the pointerup, so hold interaction until the drag ends. The same holds
|
|
361
|
+
// for a press/drag on the pet body (petPressed).
|
|
362
|
+
if (resizeDrag || petPressed) {
|
|
323
363
|
return;
|
|
324
364
|
}
|
|
325
365
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
326
366
|
return;
|
|
327
367
|
}
|
|
328
|
-
const
|
|
329
|
-
|
|
368
|
+
const interactiveEl = document.elementFromPoint(x, y)?.closest(".interactive");
|
|
369
|
+
let interactive = Boolean(interactiveEl);
|
|
370
|
+
// Pixel precision applies only to the pet body; the grip and bubbles (their
|
|
371
|
+
// own .interactive elements) keep their full hit area.
|
|
372
|
+
if (interactiveEl === canvas) {
|
|
373
|
+
interactive = pointerHitsPetPixel(x, y);
|
|
374
|
+
}
|
|
330
375
|
const ignore = !interactive;
|
|
331
376
|
if (ignore !== mouseIgnored) {
|
|
332
377
|
mouseIgnored = ignore;
|
|
@@ -334,9 +379,20 @@ function refreshMouseIgnore(x, y) {
|
|
|
334
379
|
}
|
|
335
380
|
}
|
|
336
381
|
|
|
382
|
+
// The resize grip reveals whenever the cursor is over the pet's bounding box —
|
|
383
|
+
// the same judgement as before — kept independent of the pixel-precise
|
|
384
|
+
// click-through so passing clicks through transparent areas never hides it. The
|
|
385
|
+
// window forwards move events even while click-through is on, so this stays
|
|
386
|
+
// accurate over transparent (passed-through) areas too.
|
|
387
|
+
function refreshGripVisibility(x, y) {
|
|
388
|
+
const inside = Number.isFinite(x) && Number.isFinite(y) && isPointInsideRect({ x, y }, canvas.getBoundingClientRect());
|
|
389
|
+
petEl.classList.toggle("show-grip", inside || Boolean(resizeDrag));
|
|
390
|
+
}
|
|
391
|
+
|
|
337
392
|
window.addEventListener("mousemove", (event) => {
|
|
338
393
|
lastPointer = { x: event.clientX, y: event.clientY };
|
|
339
394
|
refreshMouseIgnore(event.clientX, event.clientY);
|
|
395
|
+
refreshGripVisibility(event.clientX, event.clientY);
|
|
340
396
|
});
|
|
341
397
|
|
|
342
398
|
if (bridge) {
|
|
@@ -61,7 +61,10 @@ body {
|
|
|
61
61
|
transition: opacity 0.15s ease;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/* .show-grip is toggled by pet-window.js based on the pet's bounding box, so the
|
|
65
|
+
grip's reveal is independent of the pixel-precise click-through (which would
|
|
66
|
+
otherwise hide it over the sprite's transparent corners). */
|
|
67
|
+
#pet.show-grip .resize-grip,
|
|
65
68
|
.resize-grip.active {
|
|
66
69
|
opacity: 1;
|
|
67
70
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { isOpaqueAlpha, isPointInsideRect } from "../src/renderer/pet-hit-test.js";
|
|
4
|
+
|
|
5
|
+
const RECT = { left: 10, top: 20, right: 110, bottom: 220 };
|
|
6
|
+
|
|
7
|
+
test("isPointInsideRect includes the top-left edge and excludes the bottom-right", () => {
|
|
8
|
+
assert.equal(isPointInsideRect({ x: 10, y: 20 }, RECT), true); // top-left corner is inside
|
|
9
|
+
assert.equal(isPointInsideRect({ x: 60, y: 120 }, RECT), true); // centre
|
|
10
|
+
assert.equal(isPointInsideRect({ x: 110, y: 120 }, RECT), false); // right edge is exclusive
|
|
11
|
+
assert.equal(isPointInsideRect({ x: 60, y: 220 }, RECT), false); // bottom edge is exclusive
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("isPointInsideRect rejects points outside the box", () => {
|
|
15
|
+
assert.equal(isPointInsideRect({ x: 9, y: 120 }, RECT), false);
|
|
16
|
+
assert.equal(isPointInsideRect({ x: 60, y: 19 }, RECT), false);
|
|
17
|
+
assert.equal(isPointInsideRect({ x: 200, y: 300 }, RECT), false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("isOpaqueAlpha compares against the threshold (default 16)", () => {
|
|
21
|
+
assert.equal(isOpaqueAlpha(255), true);
|
|
22
|
+
assert.equal(isOpaqueAlpha(16), true); // threshold is inclusive
|
|
23
|
+
assert.equal(isOpaqueAlpha(15), false);
|
|
24
|
+
assert.equal(isOpaqueAlpha(0), false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("isOpaqueAlpha honours a custom threshold", () => {
|
|
28
|
+
assert.equal(isOpaqueAlpha(120, 128), false);
|
|
29
|
+
assert.equal(isOpaqueAlpha(128, 128), true);
|
|
30
|
+
});
|
package/docs/architecture.md
CHANGED
|
@@ -99,9 +99,16 @@ L2/PTY tradeoffs.
|
|
|
99
99
|
|
|
100
100
|
The overlay is a transparent, always-on-top window spanning the work area, kept
|
|
101
101
|
click-through except over the pet and bubble chips (via `setIgnoreMouseEvents`
|
|
102
|
-
with mouse-move forwarding).
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
with mouse-move forwarding). Over the pet the hit test is **pixel-precise**: the
|
|
103
|
+
renderer samples the live canvas (`getImageData`) at the cursor and only
|
|
104
|
+
intercepts where the current frame has opaque pixels, so the transparent margins
|
|
105
|
+
of the sprite cell pass clicks through too. The press/drag is held interactive
|
|
106
|
+
for its whole duration (the running frames have a different silhouette), and the
|
|
107
|
+
resize grip keeps its own bounding-box reveal so pixel precision never hides it
|
|
108
|
+
(`pet-hit-test.js`, `pet-window.js`). The pet is positioned inside the window and
|
|
109
|
+
dragged via CSS; the bubble panel is placed on whichever side of the pet has room
|
|
110
|
+
so it stays fully on-screen. The pet currently lives on a single display's work
|
|
111
|
+
area.
|
|
105
112
|
|
|
106
113
|
The bubble panel shows at most three sessions and scrolls for the rest (capped
|
|
107
114
|
by the smaller of a height budget and a count budget, see
|
package/docs/known-issues.md
CHANGED
|
@@ -103,6 +103,32 @@ Issues found in live use, with their current status.
|
|
|
103
103
|
`Notification` hook by type (`permission_prompt`→approval, `idle_prompt`→idle) so
|
|
104
104
|
non-approval notifications no longer masquerade as approvals.
|
|
105
105
|
|
|
106
|
+
## ✅ Resolved: pet stuck on "thinking" after an Esc interrupt
|
|
107
|
+
|
|
108
|
+
- **Symptom:** With hooks enabled, pressing Esc to interrupt the agent — most
|
|
109
|
+
visibly while it was *thinking* with no tool running — left the pet spinning on
|
|
110
|
+
*thinking* (or *running*) until the 30 s stale sweep, instead of showing the
|
|
111
|
+
turn was cut short. Affected both Claude Code and Codex.
|
|
112
|
+
- **Root cause:** Neither client fires a hook on an interrupt. `Stop` fires only
|
|
113
|
+
on a *normal* turn end, so the hook-driven status had no event to leave the
|
|
114
|
+
working state. A timeout was rejected (same reasoning as the denial case — it
|
|
115
|
+
would misreport a genuinely long turn).
|
|
116
|
+
- **Fix:** The existing **L3 transcript watchers** already tail each client's
|
|
117
|
+
session JSONL, so they now also recognise the interrupt marker each client
|
|
118
|
+
*does* write — ground truth, not a timer. Claude records a synthetic user
|
|
119
|
+
message `[Request interrupted by user]` (and `…for tool use]`); Codex records
|
|
120
|
+
an `event_msg` with `payload.type: "turn_aborted"`. On seeing it the wrapper
|
|
121
|
+
reports a dedicated **`interrupted`** state (summary "interrupted", source
|
|
122
|
+
`client_log`).
|
|
123
|
+
- **Why a new state, not `failed`:** the first attempt reported `failed`, which
|
|
124
|
+
*looked* right (red ✕) but is in `bubble-linger.js`'s `ENDED_STATES`, so the
|
|
125
|
+
linger logic treated the interrupt as a finished session and **hid the bubble
|
|
126
|
+
after ~2 s** — even though the session was still alive (no unregister on an
|
|
127
|
+
interrupt). `interrupted` maps to the same red ✕ kind and the same one-shot pet
|
|
128
|
+
reaction as `failed`, but is **not** terminal (not in `ENDED_STATES` or the
|
|
129
|
+
pet's `TERMINAL_STATES`), so the bubble stays visible until the next turn (or a
|
|
130
|
+
real exit). Heartbeats keep it from going stale.
|
|
131
|
+
|
|
106
132
|
## ✅ Resolved: pet stuck on "waiting for approval" after the user ACCEPTS
|
|
107
133
|
|
|
108
134
|
- **Symptom:** The denial fix above covered "deny", but **accepting** a prompt
|
package/package.json
CHANGED
|
@@ -17,6 +17,12 @@ const REJECTION_MARKERS = Object.freeze([
|
|
|
17
17
|
"user chose not to"
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
+
// When the user presses Esc, Claude fires no Stop hook — it writes a synthetic
|
|
21
|
+
// USER message carrying this marker (e.g. "[Request interrupted by user]" or
|
|
22
|
+
// "[Request interrupted by user for tool use]"). That's the only signal the turn
|
|
23
|
+
// was aborted, so the pet doesn't stay stuck on "thinking"/"running".
|
|
24
|
+
const INTERRUPT_MARKER = "request interrupted by user";
|
|
25
|
+
|
|
20
26
|
// Returns a resolution event for a single transcript line, or undefined.
|
|
21
27
|
// Currently we only surface denials — the approve path is already covered by the
|
|
22
28
|
// PostToolUse hook, and emitting on every result would race with it.
|
|
@@ -33,6 +39,8 @@ export function parseTranscriptLine(line) {
|
|
|
33
39
|
return undefined;
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
const isUserMessage = entry?.message?.role === "user";
|
|
43
|
+
|
|
36
44
|
for (const block of content) {
|
|
37
45
|
if (block?.type === "tool_result" && block.is_error === true) {
|
|
38
46
|
const text = extractText(block.content).toLowerCase();
|
|
@@ -40,6 +48,14 @@ export function parseTranscriptLine(line) {
|
|
|
40
48
|
return { type: "tool_denied", toolUseId: block.tool_use_id };
|
|
41
49
|
}
|
|
42
50
|
}
|
|
51
|
+
|
|
52
|
+
// Only a user message carries the synthetic interrupt marker; guarding on the
|
|
53
|
+
// role avoids matching the assistant merely quoting the phrase.
|
|
54
|
+
if (block?.type === "text" && isUserMessage) {
|
|
55
|
+
if (extractText(block).toLowerCase().includes(INTERRUPT_MARKER)) {
|
|
56
|
+
return { type: "interrupted" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
return undefined;
|
|
@@ -12,12 +12,12 @@ export function parseCodexTranscriptLine(line, options = {}) {
|
|
|
12
12
|
return undefined;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
if (entry
|
|
15
|
+
if (!entry || typeof entry !== "object") {
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Skip records from before the current session (used when replaying a
|
|
20
|
-
// freshly-discovered transcript so an earlier session's
|
|
20
|
+
// freshly-discovered transcript so an earlier session's events don't
|
|
21
21
|
// masquerade as live activity). Records without a parseable timestamp are
|
|
22
22
|
// kept — losing live events is worse than a rare stale one.
|
|
23
23
|
const minTimestampMs = options.minTimestampMs ?? 0;
|
|
@@ -33,6 +33,17 @@ export function parseCodexTranscriptLine(line, options = {}) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// The user pressing Esc aborts the turn. Codex fires no Stop hook on an abort,
|
|
37
|
+
// so this event_msg is the only signal the turn ended by interruption — without
|
|
38
|
+
// it the pet stays stuck on "thinking" until the stale sweep.
|
|
39
|
+
if (entry.type === "event_msg" && payload.type === "turn_aborted") {
|
|
40
|
+
return { type: "turn_aborted", reason: typeof payload.reason === "string" ? payload.reason : undefined };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (entry.type !== "response_item") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
if (payload.type === "function_call" || payload.type === "custom_tool_call") {
|
|
37
48
|
const toolName = typeof payload.name === "string" ? payload.name : undefined;
|
|
38
49
|
const toolCallId = typeof payload.call_id === "string" ? payload.call_id : undefined;
|
|
@@ -68,3 +68,41 @@ test("parseTranscriptLines collects only denial events from a batch", () => {
|
|
|
68
68
|
{ type: "tool_denied", toolUseId: "toolu_b" }
|
|
69
69
|
]);
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
function interruptLine(text = "[Request interrupted by user]") {
|
|
73
|
+
return JSON.stringify({
|
|
74
|
+
type: "user",
|
|
75
|
+
message: { role: "user", content: [{ type: "text", text }] },
|
|
76
|
+
interruptedMessageId: "msg_1"
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("parseTranscriptLine detects a user interrupt during text generation", () => {
|
|
81
|
+
assert.deepEqual(parseTranscriptLine(interruptLine()), { type: "interrupted" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("parseTranscriptLine detects a user interrupt during tool use", () => {
|
|
85
|
+
assert.deepEqual(
|
|
86
|
+
parseTranscriptLine(interruptLine("[Request interrupted by user for tool use]")),
|
|
87
|
+
{ type: "interrupted" }
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("parseTranscriptLine ignores the interrupt phrase quoted by the assistant", () => {
|
|
92
|
+
const line = JSON.stringify({
|
|
93
|
+
message: { role: "assistant", content: [{ type: "text", text: "Earlier you saw [Request interrupted by user]." }] }
|
|
94
|
+
});
|
|
95
|
+
assert.equal(parseTranscriptLine(line), undefined);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("parseTranscriptLine ignores a normal user prompt", () => {
|
|
99
|
+
const line = JSON.stringify({ message: { role: "user", content: [{ type: "text", text: "fix this bug" }] } });
|
|
100
|
+
assert.equal(parseTranscriptLine(line), undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parseTranscriptLines collects interrupt events alongside denials", () => {
|
|
104
|
+
assert.deepEqual(parseTranscriptLines([interruptLine(), rejectionLine("toolu_z")]), [
|
|
105
|
+
{ type: "interrupted" },
|
|
106
|
+
{ type: "tool_denied", toolUseId: "toolu_z" }
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
@@ -95,3 +95,25 @@ test("parseCodexTranscriptLines can skip records older than the session start",
|
|
|
95
95
|
}
|
|
96
96
|
]);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test("parseCodexTranscriptLine detects a user-interrupted turn", () => {
|
|
100
|
+
const event = parseCodexTranscriptLine(JSON.stringify({
|
|
101
|
+
type: "event_msg",
|
|
102
|
+
payload: { type: "turn_aborted", reason: "interrupted" }
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(event, { type: "turn_aborted", reason: "interrupted" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("parseCodexTranscriptLine skips a turn_aborted older than the session start", () => {
|
|
109
|
+
const old = JSON.stringify({
|
|
110
|
+
timestamp: "2026-06-08T10:59:59.000Z",
|
|
111
|
+
type: "event_msg",
|
|
112
|
+
payload: { type: "turn_aborted", reason: "interrupted" }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.equal(
|
|
116
|
+
parseCodexTranscriptLine(old, { minTimestampMs: Date.parse("2026-06-08T11:00:00.000Z") }),
|
|
117
|
+
undefined
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -34,6 +34,7 @@ export function watchClaudeTranscript(options = {}) {
|
|
|
34
34
|
homeDir = process.env.USERPROFILE || process.env.HOME,
|
|
35
35
|
startedAt = 0,
|
|
36
36
|
onDenial = () => {},
|
|
37
|
+
onInterrupt = () => {},
|
|
37
38
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
38
39
|
projectsRoot,
|
|
39
40
|
transcriptPath: fixedPath,
|
|
@@ -82,6 +83,8 @@ export function watchClaudeTranscript(options = {}) {
|
|
|
82
83
|
for (const event of parseTranscriptLines(lines)) {
|
|
83
84
|
if (event.type === "tool_denied") {
|
|
84
85
|
onDenial(event);
|
|
86
|
+
} else if (event.type === "interrupted") {
|
|
87
|
+
onInterrupt(event);
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
} catch {
|
|
@@ -17,6 +17,14 @@ function rejection(toolUseId) {
|
|
|
17
17
|
})}\n`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function interrupt() {
|
|
21
|
+
return `${JSON.stringify({
|
|
22
|
+
type: "user",
|
|
23
|
+
message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }] },
|
|
24
|
+
interruptedMessageId: "msg_1"
|
|
25
|
+
})}\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
test("claudeProjectDirName sanitizes drive + separators like Claude does", () => {
|
|
21
29
|
assert.equal(claudeProjectDirName("D:\\Projects\\AI\\haya-pet"), "D--Projects-AI-haya-pet");
|
|
22
30
|
assert.equal(claudeProjectDirName("/home/a/proj"), "-home-a-proj");
|
|
@@ -119,3 +127,23 @@ test("watchClaudeTranscript handles a line split across two appends", () => {
|
|
|
119
127
|
|
|
120
128
|
watcher.stop();
|
|
121
129
|
});
|
|
130
|
+
|
|
131
|
+
test("watchClaudeTranscript reports an interrupt appended after it starts tailing", () => {
|
|
132
|
+
const dir = mkdtempSync(join(tmpdir(), "transcript-"));
|
|
133
|
+
const path = join(dir, "session.jsonl");
|
|
134
|
+
writeFileSync(path, "");
|
|
135
|
+
|
|
136
|
+
const interrupts = [];
|
|
137
|
+
const watcher = watchClaudeTranscript({
|
|
138
|
+
transcriptPath: path,
|
|
139
|
+
onInterrupt: (event) => interrupts.push(event),
|
|
140
|
+
...noopTimers
|
|
141
|
+
});
|
|
142
|
+
watcher._tick();
|
|
143
|
+
|
|
144
|
+
appendFileSync(path, interrupt());
|
|
145
|
+
watcher._tick();
|
|
146
|
+
assert.deepEqual(interrupts, [{ type: "interrupted" }]);
|
|
147
|
+
|
|
148
|
+
watcher.stop();
|
|
149
|
+
});
|
|
@@ -15,6 +15,14 @@ function toolStart(toolName = "shell_command", callId = "call_1", timestamp) {
|
|
|
15
15
|
})}\n`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function turnAborted(timestamp) {
|
|
19
|
+
return `${JSON.stringify({
|
|
20
|
+
...(timestamp ? { timestamp } : {}),
|
|
21
|
+
type: "event_msg",
|
|
22
|
+
payload: { type: "turn_aborted", reason: "interrupted" }
|
|
23
|
+
})}\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
test("discoverCodexTranscript finds the newest session jsonl under date folders", () => {
|
|
19
27
|
const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
|
|
20
28
|
const oldDir = join(root, "2026", "06", "07");
|
|
@@ -106,3 +114,24 @@ test("watchCodexTranscript replays current-session records when a transcript is
|
|
|
106
114
|
|
|
107
115
|
watcher.stop();
|
|
108
116
|
});
|
|
117
|
+
|
|
118
|
+
test("watchCodexTranscript forwards a turn_aborted interrupt event", () => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
|
|
120
|
+
const path = join(dir, "session.jsonl");
|
|
121
|
+
writeFileSync(path, "");
|
|
122
|
+
|
|
123
|
+
const events = [];
|
|
124
|
+
const watcher = watchCodexTranscript({
|
|
125
|
+
transcriptPath: path,
|
|
126
|
+
onToolEvent: (event) => events.push(event),
|
|
127
|
+
...noopTimers
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
watcher._tick();
|
|
131
|
+
appendFileSync(path, turnAborted());
|
|
132
|
+
watcher._tick();
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(events, [{ type: "turn_aborted", reason: "interrupted" }]);
|
|
135
|
+
|
|
136
|
+
watcher.stop();
|
|
137
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir as fsReaddir, readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { parsePetManifest } from "./manifest.js";
|
|
5
5
|
|
|
6
6
|
// Scans pet search paths for Codex-compatible pets. Each pet is a directory
|
|
@@ -28,6 +28,33 @@ export async function discoverPets(searchPaths = [], deps = {}) {
|
|
|
28
28
|
return pets;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// discoverPets only scans the user's pet folders (~/.codex/pets, ~/.haya-pet/pets),
|
|
32
|
+
// so a fresh install with no pets renders the dev placeholder instead of a real
|
|
33
|
+
// character. The package ships a ready-to-use pet; this composes it in as a
|
|
34
|
+
// last resort — appended after the user's pets so a real install still wins for
|
|
35
|
+
// the default selection, and deduped by id so a user who copied it in isn't
|
|
36
|
+
// shown two. The bundled directory is injectable so this stays testable.
|
|
37
|
+
export async function discoverPetsWithFallback(searchPaths = [], deps = {}) {
|
|
38
|
+
const pets = await discoverPets(searchPaths, deps);
|
|
39
|
+
const fallbackDir = deps.fallbackPetDir ?? getBundledFallbackPetDir();
|
|
40
|
+
const fallback = await loadPetFromDir(fallbackDir, deps);
|
|
41
|
+
|
|
42
|
+
if (!fallback || pets.some((pet) => pet.manifest.id === fallback.manifest.id)) {
|
|
43
|
+
return pets;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [...pets, fallback];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Absolute path to the bundled fallback pet, resolved relative to this module so
|
|
50
|
+
// it works the same for global installs, npm link, and source checkouts. The
|
|
51
|
+
// asset lives at the package root (assets/fallback-pet); this file sits three
|
|
52
|
+
// levels below it (packages/pet-core/src).
|
|
53
|
+
export function getBundledFallbackPetDir() {
|
|
54
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
return join(here, "..", "..", "..", "assets", "fallback-pet");
|
|
56
|
+
}
|
|
57
|
+
|
|
31
58
|
export async function loadPetFromDir(petDir, deps = {}) {
|
|
32
59
|
const { readFile, stat } = resolveFs(deps);
|
|
33
60
|
const manifestPath = join(petDir, "pet.json");
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { test } from "../../../test/harness.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
discoverPets,
|
|
6
|
+
discoverPetsWithFallback,
|
|
7
|
+
getBundledFallbackPetDir,
|
|
8
|
+
loadPetFromDir
|
|
9
|
+
} from "../src/discovery.js";
|
|
5
10
|
|
|
6
11
|
function manifest(id, name) {
|
|
7
12
|
return JSON.stringify({ id, name, spritesheet: "spritesheet.webp" });
|
|
@@ -91,3 +96,60 @@ test("returns undefined for an invalid manifest", async () => {
|
|
|
91
96
|
test("tolerates missing search directories", async () => {
|
|
92
97
|
assert.deepEqual(await discoverPets([join("does-not-exist")], fakeFs()), []);
|
|
93
98
|
});
|
|
99
|
+
|
|
100
|
+
test("falls back to the bundled pet when the user has no pets", async () => {
|
|
101
|
+
const fallbackDir = join("bundled", "fallback-pet");
|
|
102
|
+
const fs = fakeFs({
|
|
103
|
+
files: {
|
|
104
|
+
[join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Fallback"),
|
|
105
|
+
[join(fallbackDir, "spritesheet.webp")]: "x"
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const pets = await discoverPetsWithFallback([join("empty")], { ...fs, fallbackPetDir: fallbackDir });
|
|
110
|
+
assert.equal(pets.length, 1);
|
|
111
|
+
assert.equal(pets[0].manifest.id, "fallback-pet");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("appends the bundled pet after the user's own pets", async () => {
|
|
115
|
+
const root = join("petsdir");
|
|
116
|
+
const fallbackDir = join("bundled", "fallback-pet");
|
|
117
|
+
const fs = fakeFs({
|
|
118
|
+
dirs: { [root]: ["cat"] },
|
|
119
|
+
files: {
|
|
120
|
+
[join(root, "cat", "pet.json")]: manifest("cat", "Cat"),
|
|
121
|
+
[join(root, "cat", "spritesheet.webp")]: "x",
|
|
122
|
+
[join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Fallback"),
|
|
123
|
+
[join(fallbackDir, "spritesheet.webp")]: "x"
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const pets = await discoverPetsWithFallback([root], { ...fs, fallbackPetDir: fallbackDir });
|
|
128
|
+
assert.deepEqual(pets.map((pet) => pet.manifest.id), ["cat", "fallback-pet"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("does not duplicate the bundled pet when the user already installed it", async () => {
|
|
132
|
+
const root = join("petsdir");
|
|
133
|
+
const fallbackDir = join("bundled", "fallback-pet");
|
|
134
|
+
const fs = fakeFs({
|
|
135
|
+
dirs: { [root]: ["fallback-pet"] },
|
|
136
|
+
files: {
|
|
137
|
+
[join(root, "fallback-pet", "pet.json")]: manifest("fallback-pet", "User Copy"),
|
|
138
|
+
[join(root, "fallback-pet", "spritesheet.webp")]: "x",
|
|
139
|
+
[join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Bundled"),
|
|
140
|
+
[join(fallbackDir, "spritesheet.webp")]: "x"
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const pets = await discoverPetsWithFallback([root], { ...fs, fallbackPetDir: fallbackDir });
|
|
145
|
+
assert.equal(pets.length, 1);
|
|
146
|
+
assert.equal(pets[0].manifest.name, "User Copy");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Guards both the package-relative path resolution and that the asset actually
|
|
150
|
+
// ships: if either breaks, a fresh install renders the dev placeholder again.
|
|
151
|
+
test("ships a bundled fallback pet that loads from disk", async () => {
|
|
152
|
+
const pet = await loadPetFromDir(getBundledFallbackPetDir());
|
|
153
|
+
assert.ok(pet, "bundled fallback pet should load from the shipped package");
|
|
154
|
+
assert.equal(pet.manifest.id, "fallback-pet");
|
|
155
|
+
});
|
|
@@ -8,6 +8,9 @@ export const AI_CLIENT_STATES = Object.freeze([
|
|
|
8
8
|
"reviewing",
|
|
9
9
|
"compacting",
|
|
10
10
|
"failed",
|
|
11
|
+
// The user interrupted the turn (Esc). Looks like a failure (red ✕) but is NOT
|
|
12
|
+
// terminal — the session is still alive, so its bubble must not linger-out.
|
|
13
|
+
"interrupted",
|
|
11
14
|
"success",
|
|
12
15
|
"stale",
|
|
13
16
|
"exited"
|
|
@@ -20,11 +20,14 @@ test("declares normalized AI states and state sources from the plan", () => {
|
|
|
20
20
|
"reviewing",
|
|
21
21
|
"compacting",
|
|
22
22
|
"failed",
|
|
23
|
+
"interrupted",
|
|
23
24
|
"success",
|
|
24
25
|
"stale",
|
|
25
26
|
"exited"
|
|
26
27
|
]);
|
|
27
28
|
|
|
29
|
+
assert.equal(isAiClientState("interrupted"), true);
|
|
30
|
+
|
|
28
31
|
assert.deepEqual(STATE_SOURCES, [
|
|
29
32
|
"wrapper",
|
|
30
33
|
"pty_output",
|
|
@@ -16,6 +16,9 @@ const STATUS_KIND_BY_STATE = Object.freeze({
|
|
|
16
16
|
waiting_approval: "attention",
|
|
17
17
|
stale: "attention",
|
|
18
18
|
failed: "failed",
|
|
19
|
+
// An interrupt shows the same red ✕ as a failure, but it is a live (non-ended)
|
|
20
|
+
// state — see bubble-linger ENDED_STATES — so the bubble stays put.
|
|
21
|
+
interrupted: "failed",
|
|
19
22
|
idle: "done",
|
|
20
23
|
success: "done",
|
|
21
24
|
exited: "done"
|
|
@@ -4,14 +4,16 @@ import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
|
|
|
4
4
|
const TERMINAL_STATES = new Set(["exited", "success"]);
|
|
5
5
|
|
|
6
6
|
// States that should NOT drive a looping stable action. success/exited are
|
|
7
|
-
// terminal; failed
|
|
8
|
-
// the "failed" animation forever).
|
|
9
|
-
|
|
7
|
+
// terminal; failed/interrupted are timed reactions (a stray error or an Esc must
|
|
8
|
+
// not pin the pet on the "failed" animation forever). Note interrupted is NOT in
|
|
9
|
+
// TERMINAL_STATES — the session is still alive, it just had its turn cut short.
|
|
10
|
+
const NON_STABLE_STATES = new Set(["exited", "success", "failed", "interrupted"]);
|
|
10
11
|
|
|
11
12
|
// States that play a one-shot reaction exactly once when first entered.
|
|
12
13
|
const ONE_SHOT_BY_STATE = Object.freeze({
|
|
13
14
|
success: "jumping",
|
|
14
|
-
failed: "failed"
|
|
15
|
+
failed: "failed",
|
|
16
|
+
interrupted: "failed"
|
|
15
17
|
});
|
|
16
18
|
|
|
17
19
|
// Active-work states. Transitioning from one of these to idle means a turn
|
|
@@ -12,6 +12,22 @@ test("ENDED_STATES covers the process-finished states", () => {
|
|
|
12
12
|
assert.ok(ENDED_STATES.has("failed"));
|
|
13
13
|
assert.ok(!ENDED_STATES.has("idle"));
|
|
14
14
|
assert.ok(!ENDED_STATES.has("running_tool"));
|
|
15
|
+
// An interrupt ends the TURN, not the session — the session is still alive, so
|
|
16
|
+
// its bubble must NOT linger-out and vanish.
|
|
17
|
+
assert.ok(!ENDED_STATES.has("interrupted"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("keeps an interrupted bubble visible (the session is still alive)", () => {
|
|
21
|
+
// Long after any linger window would have elapsed, an interrupted bubble stays.
|
|
22
|
+
const result = resolveVisibleBubbles({
|
|
23
|
+
bubbles: [bubble("a", "interrupted")],
|
|
24
|
+
now: 1_000_000,
|
|
25
|
+
lingerState: { a: 1000 },
|
|
26
|
+
lingerMs: 2000
|
|
27
|
+
});
|
|
28
|
+
assert.deepEqual(result.visible.map((b) => b.sessionId), ["a"]);
|
|
29
|
+
assert.deepEqual(result.lingerState, {});
|
|
30
|
+
assert.equal(result.nextWakeMs, undefined);
|
|
15
31
|
});
|
|
16
32
|
|
|
17
33
|
test("keeps active (non-ended) bubbles visible with no linger tracking", () => {
|
|
@@ -85,6 +85,14 @@ test("resolves failure to the 'failed' status kind (red cross)", () => {
|
|
|
85
85
|
assert.equal(resolveBubbleStatusKind("failed"), "failed");
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
test("resolves an interrupt to the 'failed' status kind (red cross) but keeps it a live state", () => {
|
|
89
|
+
assert.equal(resolveBubbleStatusKind("interrupted"), "failed");
|
|
90
|
+
const view = buildBubbleView({ ...baseSession, state: "interrupted", summary: "interrupted" }, 6_000);
|
|
91
|
+
assert.equal(view.statusKind, "failed");
|
|
92
|
+
assert.equal(view.statusLabel, "Interrupted");
|
|
93
|
+
assert.equal(view.petAction, "failed");
|
|
94
|
+
});
|
|
95
|
+
|
|
88
96
|
test("resolves idle/finished states to the 'done' status kind (check mark)", () => {
|
|
89
97
|
for (const state of ["idle", "success", "exited"]) {
|
|
90
98
|
assert.equal(resolveBubbleStatusKind(state), "done", state);
|
|
@@ -82,6 +82,29 @@ test("a failed session still yields to active work for the stable action", () =>
|
|
|
82
82
|
assert.equal(result.stableAction, "running");
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test("an interrupt is a one-shot reaction, not a sticky stable action (like failed)", () => {
|
|
86
|
+
const result = resolveCompanionPetState({
|
|
87
|
+
bubbles: [bubble("a", "interrupted")],
|
|
88
|
+
prioritySessionId: "a",
|
|
89
|
+
previousStates: { a: "thinking" }
|
|
90
|
+
});
|
|
91
|
+
// plays the failed reaction once on the interrupt...
|
|
92
|
+
assert.deepEqual(result.oneShots, ["failed"]);
|
|
93
|
+
// ...but does not pin the pet on a looping action, and the session stays alive
|
|
94
|
+
// (still in the active bubble list, unlike exited/success).
|
|
95
|
+
assert.equal(result.stableAction, "idle");
|
|
96
|
+
assert.deepEqual(result.activeBubbles.map((b) => b.sessionId), ["a"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("does not repeat the interrupt reaction while it stays interrupted", () => {
|
|
100
|
+
const result = resolveCompanionPetState({
|
|
101
|
+
bubbles: [bubble("a", "interrupted")],
|
|
102
|
+
prioritySessionId: "a",
|
|
103
|
+
previousStates: { a: "interrupted" }
|
|
104
|
+
});
|
|
105
|
+
assert.deepEqual(result.oneShots, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
85
108
|
test("celebrates with a one-shot jump when a working turn finishes (working -> idle)", () => {
|
|
86
109
|
const result = resolveCompanionPetState({
|
|
87
110
|
bubbles: [bubble("a", "idle")],
|