@hayasaka7/haya-pet 0.3.1 → 0.3.3

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,25 @@ 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.3]
11
+
12
+ ### Fixed
13
+ - **Claude Code subagent completion no longer changes the main session status.**
14
+ Claude Code can emit `SubagentStop` after the main agent has already stopped,
15
+ so treating that event as `idle` could make the pet react to a stale subagent
16
+ completion instead of the main agent's real state. The Claude hook adapter now
17
+ ignores `SubagentStop`; the main turn still ends on Claude's `Stop` event.
18
+
19
+ ## [0.3.2]
20
+
21
+ ### Changed
22
+ - **Session bubble titles no longer run off the screen.** A long project name
23
+ used to stretch the bubble out to the panel's full width. The title now keeps
24
+ the **client name in full** (Codex, Claude Code, …) and shows the **project
25
+ name capped at 10 characters** with an ellipsis when it's longer (e.g.
26
+ `netdisk-server` → `netdisk-se...`); the complete `Client · Project` is kept
27
+ as a hover tooltip so nothing is lost.
28
+
10
29
  ## [0.3.1]
11
30
 
12
31
  ### Fixed
package/README.md CHANGED
@@ -64,7 +64,7 @@ npm install -g @hayasaka7/haya-pet
64
64
  From source:
65
65
 
66
66
  ```bash
67
- git clone <repo-url> haya-pet
67
+ git clone https://github.com/HAYASAKA7/HAYA-PET.git haya-pet
68
68
  cd haya-pet
69
69
  npm install
70
70
  npm link
@@ -218,6 +218,15 @@ function renderBubble(bubble) {
218
218
  const title = document.createElement("div");
219
219
  title.className = "title";
220
220
 
221
+ // Persistent title parts (mutated in place across updates, like everything
222
+ // else here). The client name is always shown in full; the project name is
223
+ // shown compact (bubble.projectLabel) with the full name kept as a tooltip.
224
+ const client = document.createElement("span");
225
+ client.className = "client";
226
+ const project = document.createElement("span");
227
+ project.className = "project";
228
+ title.append(client, project);
229
+
221
230
  const activity = document.createElement("div");
222
231
  activity.className = "activity";
223
232
 
@@ -239,8 +248,11 @@ function applyBubble(el, bubble) {
239
248
  icon.title = bubble.statusLabel;
240
249
 
241
250
  const [title, activity] = body.children;
242
- title.innerHTML = `<span class="client">${escapeHtml(bubble.clientName)}</span> ` +
243
- `<span class="project">${escapeHtml(bubble.projectName)}</span>`;
251
+ const [client, project] = title.children;
252
+ client.textContent = bubble.clientName;
253
+ project.textContent = bubble.projectLabel ?? bubble.projectName;
254
+ // Hover reveals the full, untruncated "Client · Project".
255
+ title.title = bubble.projectName ? `${bubble.clientName} · ${bubble.projectName}` : bubble.clientName;
244
256
  activity.textContent = bubble.summary;
245
257
  activity.title = `${bubble.statusLabel} · ${bubble.elapsedLabel}`;
246
258
  }
@@ -257,10 +269,3 @@ function mostUrgentKind(bubbles) {
257
269
  }
258
270
  return best;
259
271
  }
260
-
261
- function escapeHtml(value) {
262
- return String(value ?? "")
263
- .replace(/&/g, "&amp;")
264
- .replace(/</g, "&lt;")
265
- .replace(/>/g, "&gt;");
266
- }
@@ -209,6 +209,7 @@ body {
209
209
 
210
210
  .bubble .client {
211
211
  font-weight: 600;
212
+ margin-right: 0.4em;
212
213
  }
213
214
 
214
215
  .bubble .project {
@@ -170,6 +170,34 @@ test("scrolls to a session when it newly fails", () => {
170
170
  }
171
171
  });
172
172
 
173
+ test("shows the compact project label and keeps the full name as a tooltip", () => {
174
+ const restoreDocument = installFakeDocument();
175
+ try {
176
+ const container = new FakeElement("div");
177
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
178
+
179
+ listView.render([{
180
+ sessionId: "s1",
181
+ statusKind: "working",
182
+ statusLabel: "Working",
183
+ clientName: "Claude Code",
184
+ projectName: "netdisk-server",
185
+ projectLabel: "netdisk-se...",
186
+ summary: "running",
187
+ elapsedLabel: "1s"
188
+ }]);
189
+
190
+ const bubble = findBubble(container, "s1");
191
+ const title = childByClass(childByClass(bubble, "body"), "title");
192
+ assert.equal(childByClass(title, "client").textContent, "Claude Code");
193
+ assert.equal(childByClass(title, "project").textContent, "netdisk-se...");
194
+ // The full, untruncated name stays reachable on hover.
195
+ assert.equal(title.title, "Claude Code · netdisk-server");
196
+ } finally {
197
+ restoreDocument();
198
+ }
199
+ });
200
+
173
201
  test("clears everything when no sessions remain", () => {
174
202
  const restoreDocument = installFakeDocument();
175
203
  try {
@@ -2,6 +2,20 @@
2
2
 
3
3
  Issues found in live use, with their current status.
4
4
 
5
+ ## ✅ Resolved: Claude Code subagent completion changed the main session status
6
+
7
+ - **Symptom:** In Claude Code multi-agent runs, the main agent could already be
8
+ stopped while a subagent was still finishing. When that late subagent emitted
9
+ `SubagentStop`, the pet treated it as a main-session `idle` update and could
10
+ show a misleading working/done transition after the main agent had settled.
11
+ - **Root cause:** The Claude hook table mapped `SubagentStop` to `idle`. That is
12
+ only safe if subagent completion is ordered before the main turn finishes, which
13
+ Claude Code does not guarantee.
14
+ - **Fix:** Claude `SubagentStop` is now ignored. Main-session idle still comes
15
+ from Claude's real `Stop` hook, while late subagent completion cannot override
16
+ the current main-agent state. Codex keeps its separate behavior because Codex
17
+ uses `Stop` as the only idle signal and treats `SubagentStop` as mid-turn.
18
+
5
19
  ## ✅ Resolved: false "waiting for approval" while Codex auto-reviews an approval (Approve for me)
6
20
 
7
21
  - **Symptom:** Running Codex under the pet with the **"Approve for me"** preset
@@ -228,8 +242,9 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
228
242
  lifecycle status). Live in-session status is **opt-in** via `HAYA_PET_HOOKS=1`,
229
243
  which injects a settings file (`claude --settings <stable-file>`, no change to
230
244
  your global config) wiring Claude's `UserPromptSubmit`/`PreToolUse`/`PostToolUse`/
231
- `Notification`/`PreCompact`/`Stop`/`SubagentStop` events to `haya-pet state
232
- <state>`, reported to the daemon over the IPC pipe. `PreToolUse` distinguishes
245
+ `Notification`/`PreCompact`/`Stop` events to `haya-pet state <state>`, reported
246
+ to the daemon over the IPC pipe. `SubagentStop` is intentionally ignored because
247
+ it is not a main-turn idle signal. `PreToolUse` distinguishes
233
248
  file-editing tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit` → *editing files*)
234
249
  from other tools (→ *running tools*) via the hook `matcher`. **Why opt-in:**
235
250
  injecting hooks makes Claude show a one-time *review hooks* trust prompt; the
@@ -16,6 +16,7 @@ deferred problems with known root causes.
16
16
  | Terminal scroll / Shift+Tab / backspace odd while a CLI runs under `haya-pet run` | Fixed — `haya-pet run` now uses native passthrough by default (full fidelity). If you opted into `--observe`, drop it. See [known-issues.md](known-issues.md). |
17
17
  | Pet shows only **idle/lifecycle** while **Claude Code** works | Live in-session status is opt-in: run `haya-pet hooks on` once (persisted). The first `haya-pet run` afterward shows a one-time Claude *review hooks* prompt — approve it. Also make sure the companion is running (`haya-pet start`). Check the toggle with `haya-pet hooks status`. |
18
18
  | Typing doesn't work / **Claude Code** TUI frozen under `haya-pet run` | You have hooks enabled and Claude is showing its *review hooks* trust prompt (approve it once), or your Claude is too old for `--settings`. Run `haya-pet hooks off` (or set `HAYA_PET_NO_HOOKS=1`) for native passthrough with lifecycle-only status — typing and Shift+Tab work normally. |
19
+ | Pet changes status after a **Claude Code** subagent finishes, even though the main agent already stopped | Fixed — Claude `SubagentStop` is ignored because it is not a reliable main-turn state. Update to the latest version and restart the wrapped Claude session so the new hook settings are used. |
19
20
  | Pet shows only **idle/lifecycle** while **Codex** works | Live status is opt-in: run `haya-pet hooks on` once (persisted, global), then `haya-pet run --client codex -- codex`; approve Codex's one-time *review hooks* prompt. `thinking`/`idle` come from hooks, `running_tool`/`editing_files` from a transcript watcher, and approval states from the `PermissionRequest` hook plus a guardian-review watcher. |
20
21
  | Pet showed **waiting for approval** while **Codex** auto-reviewed the request ("Approve for me") | Fixed — with `approvals_reviewer = auto_review` (legacy `guardian_subagent`) Codex's guardian decides without asking you; the pet now shows **reviewing** during the assessment, then **working** on an allow verdict or **thinking** on a deny. *Waiting for approval* still shows when Codex actually asks you (`approvals_reviewer = "user"`). |
21
22
  | **Codex** live status didn't turn on / you pass your own `-p`/`--profile` | Codex allows only one profile, so haya-pet skips hook injection when you supply your own and prints a notice. Drop your `-p` for that run to get live status, or accept lifecycle-only. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -34,8 +34,7 @@ const HOOK_TABLE = Object.freeze([
34
34
  { event: "PermissionDenied", state: "idle", summary: "denied" },
35
35
  { event: "PreCompact", state: "compacting" },
36
36
  { event: "Stop", state: "idle" },
37
- { event: "StopFailure", state: "idle", summary: "stopped" },
38
- { event: "SubagentStop", state: "idle" }
37
+ { event: "StopFailure", state: "idle", summary: "stopped" }
39
38
  ]);
40
39
 
41
40
  // Resolve the pet state for a Claude event. `detail` is the tool name for
@@ -9,12 +9,15 @@ test("mapClaudeEventToState covers activity events", () => {
9
9
  assert.equal(mapClaudeEventToState("PreCompact"), "compacting");
10
10
  assert.equal(mapClaudeEventToState("Stop"), "idle");
11
11
  assert.equal(mapClaudeEventToState("StopFailure"), "idle");
12
- assert.equal(mapClaudeEventToState("SubagentStop"), "idle");
13
12
  assert.equal(mapClaudeEventToState("PermissionDenied"), "idle");
14
13
  assert.equal(mapClaudeEventToState("PermissionRequest"), "waiting_approval");
15
14
  assert.equal(mapClaudeEventToState("Unknown"), undefined);
16
15
  });
17
16
 
17
+ test("mapClaudeEventToState ignores Claude SubagentStop", () => {
18
+ assert.equal(mapClaudeEventToState("SubagentStop"), undefined);
19
+ });
20
+
18
21
  test("mapClaudeEventToState branches PreToolUse on tool name", () => {
19
22
  assert.equal(mapClaudeEventToState("PreToolUse", "Bash"), "running_tool");
20
23
  assert.equal(mapClaudeEventToState("PreToolUse", "Edit"), "editing_files");
@@ -80,8 +83,9 @@ test("buildClaudeHookSettings includes all subscribed events", () => {
80
83
  for (const event of [
81
84
  "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure",
82
85
  "PermissionRequest", "Notification", "PermissionDenied", "PreCompact",
83
- "Stop", "StopFailure", "SubagentStop"
86
+ "Stop", "StopFailure"
84
87
  ]) {
85
88
  assert.ok(settings.hooks[event], `missing hook event ${event}`);
86
89
  }
90
+ assert.equal(settings.hooks.SubagentStop, undefined);
87
91
  });
@@ -1,5 +1,5 @@
1
1
  import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
2
- import { buildSessionSummary, buildStatusLabel, formatElapsed } from "./summaries.js";
2
+ import { buildSessionSummary, buildStatusLabel, formatElapsed, truncateProjectName } from "./summaries.js";
3
3
 
4
4
  // Collapses the full AI-state vocabulary into the four progress kinds the
5
5
  // bubble panel renders: a spinning "working" circle, a "done" check mark (held
@@ -36,6 +36,7 @@ export function buildBubbleView(session, now = Date.now(), options = {}) {
36
36
  clientId: session.clientId,
37
37
  clientName: session.clientDisplayName ?? session.clientId,
38
38
  projectName: session.projectName,
39
+ projectLabel: truncateProjectName(session.projectName),
39
40
  state: session.state,
40
41
  statusLabel: buildStatusLabel(session.state),
41
42
  statusKind: resolveBubbleStatusKind(session.state),
@@ -26,6 +26,20 @@ export function buildSessionSummary(session) {
26
26
  return buildStatusLabel(session?.state);
27
27
  }
28
28
 
29
+ // Compacts a project name for the session bubble title, which sits beside the
30
+ // (always-full) client name in a narrow overlay. Names up to `maxLength`
31
+ // characters show whole; longer ones are cut to `maxLength` and marked with an
32
+ // ellipsis. The full name is kept elsewhere on the view model for a tooltip.
33
+ const DEFAULT_PROJECT_NAME_LENGTH = 10;
34
+
35
+ export function truncateProjectName(name, maxLength = DEFAULT_PROJECT_NAME_LENGTH) {
36
+ const text = typeof name === "string" ? name : "";
37
+ if (text.length <= maxLength) {
38
+ return text;
39
+ }
40
+ return `${text.slice(0, maxLength)}...`;
41
+ }
42
+
29
43
  export function formatElapsed(ms) {
30
44
  const totalSeconds = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1000));
31
45
 
@@ -20,6 +20,8 @@ test("builds a bubble view model with label, summary, action, and elapsed", () =
20
20
  assert.equal(view.clientId, "codex");
21
21
  assert.equal(view.clientName, "Codex");
22
22
  assert.equal(view.projectName, "netdisk-server");
23
+ // The full name is preserved; projectLabel is the compact bubble display form.
24
+ assert.equal(view.projectLabel, "netdisk-se...");
23
25
  assert.equal(view.state, "waiting_approval");
24
26
  assert.equal(view.statusLabel, "Waiting for approval");
25
27
  assert.equal(view.summary, "waiting for command approval");
@@ -64,6 +66,12 @@ test("breaks connect-time ties by session id for a deterministic order", () => {
64
66
  assert.deepEqual(views.map((view) => view.sessionId), ["sess_a", "sess_b"]);
65
67
  });
66
68
 
69
+ test("keeps a short project name whole in projectLabel", () => {
70
+ const view = buildBubbleView({ ...baseSession, projectName: "api" }, 6_000);
71
+ assert.equal(view.projectName, "api");
72
+ assert.equal(view.projectLabel, "api");
73
+ });
74
+
67
75
  test("marks the selected/pinned session", () => {
68
76
  const views = buildBubbleViews([baseSession], 6_000, { selectedSessionId: "sess_a" });
69
77
  assert.equal(views[0].selected, true);
@@ -3,7 +3,8 @@ import { test } from "../../../test/harness.mjs";
3
3
  import {
4
4
  buildStatusLabel,
5
5
  buildSessionSummary,
6
- formatElapsed
6
+ formatElapsed,
7
+ truncateProjectName
7
8
  } from "../src/summaries.js";
8
9
 
9
10
  test("maps every normalized state to a human status label", () => {
@@ -36,3 +37,25 @@ test("formats elapsed durations compactly", () => {
36
37
  assert.equal(formatElapsed(65_000), "1m 5s");
37
38
  assert.equal(formatElapsed(3_725_000), "1h 2m");
38
39
  });
40
+
41
+ test("keeps short project names whole", () => {
42
+ assert.equal(truncateProjectName("api"), "api");
43
+ // Exactly at the 10-character budget stays untouched (no ellipsis).
44
+ assert.equal(truncateProjectName("ten-charrr"), "ten-charrr");
45
+ });
46
+
47
+ test("truncates long project names to 10 characters plus an ellipsis", () => {
48
+ assert.equal(truncateProjectName("netdisk-server"), "netdisk-se...");
49
+ assert.equal(truncateProjectName("a-very-long-project-name"), "a-very-lon...");
50
+ });
51
+
52
+ test("honours a custom max length", () => {
53
+ assert.equal(truncateProjectName("netdisk-server", 4), "netd...");
54
+ assert.equal(truncateProjectName("abc", 4), "abc");
55
+ });
56
+
57
+ test("coerces missing or non-string project names to an empty string", () => {
58
+ assert.equal(truncateProjectName(undefined), "");
59
+ assert.equal(truncateProjectName(null), "");
60
+ assert.equal(truncateProjectName(123), "");
61
+ });