@hayasaka7/haya-pet 0.2.0 → 0.2.2

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.
Files changed (30) hide show
  1. package/.github/workflows/ci.yml +75 -0
  2. package/CHANGELOG.md +112 -0
  3. package/README.md +31 -14
  4. package/apps/cli/src/haya-pet.js +110 -21
  5. package/apps/cli/test/haya-pet.test.mjs +111 -7
  6. package/apps/companion/src/main/index.js +40 -1
  7. package/apps/companion/src/renderer/task-talk-window.js +1 -1
  8. package/apps/companion/test/position-store.test.mjs +1 -1
  9. package/docs/architecture.md +33 -10
  10. package/docs/cross-os-qa.md +72 -0
  11. package/docs/known-issues.md +92 -9
  12. package/docs/troubleshooting.md +3 -1
  13. package/eslint.config.js +32 -0
  14. package/package.json +7 -1
  15. package/packages/adapters/src/codex-hooks.js +152 -0
  16. package/packages/adapters/src/codex-transcript.js +73 -0
  17. package/packages/adapters/test/codex-hooks.test.mjs +120 -0
  18. package/packages/adapters/test/codex-transcript.test.mjs +97 -0
  19. package/packages/app-state/src/state.js +10 -5
  20. package/packages/cli-core/src/codex-hook-injection.js +49 -0
  21. package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
  22. package/packages/cli-core/src/run-command.js +0 -1
  23. package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
  24. package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
  25. package/packages/daemon-core/src/approval-process-watcher.js +169 -0
  26. package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
  27. package/packages/platform-core/src/process-snapshot.js +88 -0
  28. package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
  29. package/packages/session-core/src/bubble-view.js +10 -7
  30. package/packages/session-core/test/bubble-view.test.mjs +30 -5
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { createProcessSnapshotLister } from "../src/process-snapshot.js";
4
+
5
+ // Each platform lister returns the same shape — the full process table as
6
+ // [{ pid, ppid }] — so the approval watcher core stays platform-agnostic.
7
+
8
+ test("windows lister parses the PowerShell pid/ppid table", async () => {
9
+ const invocations = [];
10
+ const lister = createProcessSnapshotLister({
11
+ platform: "win32",
12
+ execFile: async (file, args) => {
13
+ invocations.push({ file, args });
14
+ return { stdout: "4 0\r\n100 4\r\n2332 100\r\n\r\n" };
15
+ }
16
+ });
17
+
18
+ const table = await lister();
19
+
20
+ assert.deepEqual(table, [
21
+ { pid: 4, ppid: 0 },
22
+ { pid: 100, ppid: 4 },
23
+ { pid: 2332, ppid: 100 }
24
+ ]);
25
+ assert.equal(invocations.length, 1);
26
+ assert.equal(invocations[0].file, "powershell.exe");
27
+ // Must never load the user's profile or prompt interactively.
28
+ assert.ok(invocations[0].args.includes("-NoProfile"));
29
+ assert.ok(invocations[0].args.includes("-NonInteractive"));
30
+ });
31
+
32
+ test("darwin lister parses ps output with ragged spacing", async () => {
33
+ const lister = createProcessSnapshotLister({
34
+ platform: "darwin",
35
+ execFile: async (file, args) => {
36
+ assert.equal(file, "ps");
37
+ assert.deepEqual(args, ["-axo", "pid=,ppid="]);
38
+ return { stdout: " 1 0\n 455 1\n12034 455\n" };
39
+ }
40
+ });
41
+
42
+ assert.deepEqual(await lister(), [
43
+ { pid: 1, ppid: 0 },
44
+ { pid: 455, ppid: 1 },
45
+ { pid: 12034, ppid: 455 }
46
+ ]);
47
+ });
48
+
49
+ test("linux lister reads /proc stat files, handling names with spaces and parens", async () => {
50
+ const files = {
51
+ "/proc/1/stat": "1 (systemd) S 0 1 1 0 -1 4194560",
52
+ "/proc/455/stat": "455 (my (weird) name) S 1 455 455 0 -1 0",
53
+ "/proc/9999/stat": "9999 (node) R 455 9999 9999 0 -1 0"
54
+ };
55
+ const lister = createProcessSnapshotLister({
56
+ platform: "linux",
57
+ readdir: async (dir) => {
58
+ assert.equal(dir, "/proc");
59
+ return ["1", "455", "9999", "self", "acpi", "cpuinfo"];
60
+ },
61
+ readFile: async (path) => {
62
+ if (!(path in files)) {
63
+ throw new Error(`unexpected read: ${path}`);
64
+ }
65
+ return files[path];
66
+ }
67
+ });
68
+
69
+ assert.deepEqual(await lister(), [
70
+ { pid: 1, ppid: 0 },
71
+ { pid: 455, ppid: 1 },
72
+ { pid: 9999, ppid: 455 }
73
+ ]);
74
+ });
75
+
76
+ test("linux lister skips processes that vanish mid-scan", async () => {
77
+ const lister = createProcessSnapshotLister({
78
+ platform: "linux",
79
+ readdir: async () => ["1", "2"],
80
+ readFile: async (path) => {
81
+ if (path === "/proc/2/stat") {
82
+ throw new Error("ENOENT: gone");
83
+ }
84
+ return "1 (init) S 0 1 1 0 -1 0";
85
+ }
86
+ });
87
+
88
+ assert.deepEqual(await lister(), [{ pid: 1, ppid: 0 }]);
89
+ });
90
+
91
+ test("listers skip malformed lines instead of failing", async () => {
92
+ const lister = createProcessSnapshotLister({
93
+ platform: "win32",
94
+ execFile: async () => ({ stdout: "4 0\nnot numbers\n77\n100 4\n" })
95
+ });
96
+
97
+ assert.deepEqual(await lister(), [
98
+ { pid: 4, ppid: 0 },
99
+ { pid: 100, ppid: 4 }
100
+ ]);
101
+ });
102
+
103
+ test("unsupported platforms yield no lister", () => {
104
+ assert.equal(createProcessSnapshotLister({ platform: "sunos" }), undefined);
105
+ });
@@ -1,5 +1,4 @@
1
1
  import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
2
- import { getSessionPriorityRank } from "./priority.js";
3
2
  import { buildSessionSummary, buildStatusLabel, formatElapsed } from "./summaries.js";
4
3
 
5
4
  // Collapses the full AI-state vocabulary into the four progress kinds the
@@ -53,17 +52,21 @@ export function buildBubbleViews(sessions, now = Date.now(), options = {}) {
53
52
  return sessions
54
53
  .filter(Boolean)
55
54
  .slice()
56
- .sort(compareByPriority)
55
+ .sort(compareByConnectTime)
57
56
  .map((session) => buildBubbleView(session, now, options));
58
57
  }
59
58
 
60
- function compareByPriority(left, right) {
61
- const rankDelta = getSessionPriorityRank(left) - getSessionPriorityRank(right);
62
- if (rankDelta !== 0) {
63
- return rankDelta;
59
+ // Bubbles stack by connect time — the newest session on top, the first one at
60
+ // the bottom and never reshuffle while sessions are in progress. State
61
+ // urgency only drives the collapsed-folder dot and the pet animation, not the
62
+ // list order.
63
+ function compareByConnectTime(left, right) {
64
+ const startedDelta = numeric(right.startedAt) - numeric(left.startedAt);
65
+ if (startedDelta !== 0) {
66
+ return startedDelta;
64
67
  }
65
68
 
66
- return numeric(right.updatedAt) - numeric(left.updatedAt);
69
+ return String(left.sessionId).localeCompare(String(right.sessionId));
67
70
  }
68
71
 
69
72
  function safePetAction(state) {
@@ -28,15 +28,40 @@ test("builds a bubble view model with label, summary, action, and elapsed", () =
28
28
  assert.equal(view.elapsedLabel, "1m 4s");
29
29
  });
30
30
 
31
- test("orders bubbles by session priority then recency", () => {
31
+ test("stacks bubbles by connect time, newest on top, so they never reshuffle mid-session", () => {
32
32
  const sessions = [
33
- { ...baseSession, sessionId: "sess_idle", state: "idle", updatedAt: 9_000 },
34
- { ...baseSession, sessionId: "sess_wait", state: "waiting_approval", updatedAt: 4_000 },
35
- { ...baseSession, sessionId: "sess_run", state: "running_tool", updatedAt: 8_000 }
33
+ { ...baseSession, sessionId: "sess_third", state: "waiting_approval", startedAt: 3_000, updatedAt: 4_000 },
34
+ { ...baseSession, sessionId: "sess_first", state: "idle", startedAt: 1_000, updatedAt: 9_000 },
35
+ { ...baseSession, sessionId: "sess_second", state: "running_tool", startedAt: 2_000, updatedAt: 8_000 }
36
36
  ];
37
37
 
38
38
  const views = buildBubbleViews(sessions, 10_000);
39
- assert.deepEqual(views.map((view) => view.sessionId), ["sess_wait", "sess_run", "sess_idle"]);
39
+ assert.deepEqual(views.map((view) => view.sessionId), ["sess_third", "sess_second", "sess_first"]);
40
+ });
41
+
42
+ test("keeps bubble order stable when states and activity change", () => {
43
+ const before = [
44
+ { ...baseSession, sessionId: "sess_first", state: "running_tool", startedAt: 1_000, updatedAt: 2_000 },
45
+ { ...baseSession, sessionId: "sess_second", state: "idle", startedAt: 2_000, updatedAt: 2_500 }
46
+ ];
47
+ // Later, the second session becomes urgent and more recently active.
48
+ const after = [
49
+ { ...baseSession, sessionId: "sess_first", state: "idle", startedAt: 1_000, updatedAt: 3_000 },
50
+ { ...baseSession, sessionId: "sess_second", state: "waiting_approval", startedAt: 2_000, updatedAt: 9_000 }
51
+ ];
52
+
53
+ const order = (sessions) => buildBubbleViews(sessions, 10_000).map((view) => view.sessionId);
54
+ assert.deepEqual(order(before), order(after));
55
+ });
56
+
57
+ test("breaks connect-time ties by session id for a deterministic order", () => {
58
+ const sessions = [
59
+ { ...baseSession, sessionId: "sess_b", startedAt: 1_000 },
60
+ { ...baseSession, sessionId: "sess_a", startedAt: 1_000 }
61
+ ];
62
+
63
+ const views = buildBubbleViews(sessions, 10_000);
64
+ assert.deepEqual(views.map((view) => view.sessionId), ["sess_a", "sess_b"]);
40
65
  });
41
66
 
42
67
  test("marks the selected/pinned session", () => {