@hayasaka7/haya-pet 0.1.0

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 (131) hide show
  1. package/.gitattributes +34 -0
  2. package/.github/workflows/release.yml +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +247 -0
  5. package/apps/cli/src/haya-pet.js +395 -0
  6. package/apps/cli/test/haya-pet.test.mjs +339 -0
  7. package/apps/companion/README.md +83 -0
  8. package/apps/companion/package.json +17 -0
  9. package/apps/companion/src/main/display-manager.js +71 -0
  10. package/apps/companion/src/main/index.js +349 -0
  11. package/apps/companion/src/main/lock-file.js +52 -0
  12. package/apps/companion/src/main/panel-placement.js +45 -0
  13. package/apps/companion/src/main/pet-loader.js +2 -0
  14. package/apps/companion/src/main/position-store.js +3 -0
  15. package/apps/companion/src/main/preload.cjs +13 -0
  16. package/apps/companion/src/main/state-file.js +2 -0
  17. package/apps/companion/src/main/terminal-helper-client.js +79 -0
  18. package/apps/companion/src/main/terminal-locator.js +44 -0
  19. package/apps/companion/src/main/tray-menu.js +79 -0
  20. package/apps/companion/src/main/window-options.js +66 -0
  21. package/apps/companion/src/renderer/index.html +18 -0
  22. package/apps/companion/src/renderer/interaction-controller.js +114 -0
  23. package/apps/companion/src/renderer/pet-window.js +275 -0
  24. package/apps/companion/src/renderer/session-bubbles.js +138 -0
  25. package/apps/companion/src/renderer/styles.css +225 -0
  26. package/apps/companion/src/renderer/task-talk-window.js +141 -0
  27. package/apps/companion/test/display-manager.test.mjs +48 -0
  28. package/apps/companion/test/interaction-controller.test.mjs +107 -0
  29. package/apps/companion/test/panel-placement.test.mjs +60 -0
  30. package/apps/companion/test/position-store.test.mjs +54 -0
  31. package/apps/companion/test/state-file.test.mjs +52 -0
  32. package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
  33. package/apps/companion/test/terminal-locator.test.mjs +35 -0
  34. package/apps/companion/test/tray-menu.test.mjs +45 -0
  35. package/apps/companion/test/window-options.test.mjs +62 -0
  36. package/apps/pet-preview/index.html +42 -0
  37. package/apps/pet-preview/src/preview-app.js +123 -0
  38. package/apps/pet-preview/src/preview-state.js +70 -0
  39. package/apps/pet-preview/src/preview.css +125 -0
  40. package/apps/pet-preview/test/preview-state.test.mjs +62 -0
  41. package/assets/fallback-pet/README.md +16 -0
  42. package/assets/fallback-pet/pet.json +13 -0
  43. package/docs/architecture.md +144 -0
  44. package/docs/known-issues.md +49 -0
  45. package/docs/publishing.md +48 -0
  46. package/docs/screenshots/README.md +7 -0
  47. package/docs/screenshots/folder-collapsed.png +0 -0
  48. package/docs/screenshots/hero.png +0 -0
  49. package/docs/screenshots/pet-overlay.png +0 -0
  50. package/docs/screenshots/session-bubbles.png +0 -0
  51. package/docs/screenshots/tray-menu.png +0 -0
  52. package/docs/troubleshooting.md +36 -0
  53. package/native/README.md +80 -0
  54. package/native/linux-window-helper/README.md +29 -0
  55. package/native/mac-window-helper/README.md +30 -0
  56. package/native/win-window-helper/Program.cs +312 -0
  57. package/native/win-window-helper/README.md +53 -0
  58. package/native/win-window-helper/win-window-helper.csproj +12 -0
  59. package/package.json +35 -0
  60. package/packages/adapters/src/adapter-info.js +61 -0
  61. package/packages/adapters/src/capabilities.js +39 -0
  62. package/packages/adapters/src/heuristics.js +114 -0
  63. package/packages/adapters/src/output-observer.js +164 -0
  64. package/packages/adapters/src/routing.js +86 -0
  65. package/packages/adapters/test/adapter-info.test.mjs +35 -0
  66. package/packages/adapters/test/capabilities.test.mjs +44 -0
  67. package/packages/adapters/test/heuristics.test.mjs +42 -0
  68. package/packages/adapters/test/output-observer.test.mjs +142 -0
  69. package/packages/adapters/test/routing.test.mjs +93 -0
  70. package/packages/app-state/src/state-file.js +53 -0
  71. package/packages/app-state/src/state.js +80 -0
  72. package/packages/app-state/test/state.test.mjs +36 -0
  73. package/packages/cli-core/src/companion-launcher.js +69 -0
  74. package/packages/cli-core/src/pty-runner.js +96 -0
  75. package/packages/cli-core/src/run-command.js +353 -0
  76. package/packages/cli-core/src/strip-ansi.js +16 -0
  77. package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
  78. package/packages/cli-core/test/run-command.test.mjs +177 -0
  79. package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
  80. package/packages/daemon-core/src/daemon-runtime.js +49 -0
  81. package/packages/daemon-core/src/ipc-server.js +180 -0
  82. package/packages/daemon-core/src/ipc-transport.js +70 -0
  83. package/packages/daemon-core/src/singleton.js +46 -0
  84. package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
  85. package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
  86. package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
  87. package/packages/daemon-core/test/singleton.test.mjs +32 -0
  88. package/packages/pet-core/src/animation-state.js +84 -0
  89. package/packages/pet-core/src/animator.js +26 -0
  90. package/packages/pet-core/src/atlas.js +81 -0
  91. package/packages/pet-core/src/discovery.js +90 -0
  92. package/packages/pet-core/src/manifest.js +112 -0
  93. package/packages/pet-core/src/validation.js +43 -0
  94. package/packages/pet-core/test/animation-state.test.mjs +47 -0
  95. package/packages/pet-core/test/animator.test.mjs +31 -0
  96. package/packages/pet-core/test/atlas.test.mjs +81 -0
  97. package/packages/pet-core/test/discovery.test.mjs +93 -0
  98. package/packages/pet-core/test/manifest.test.mjs +93 -0
  99. package/packages/pet-core/test/validation.test.mjs +69 -0
  100. package/packages/platform-core/src/capabilities.js +49 -0
  101. package/packages/platform-core/src/paths.js +75 -0
  102. package/packages/platform-core/src/platform.js +15 -0
  103. package/packages/platform-core/test/platform.test.mjs +84 -0
  104. package/packages/protocol/src/messages.js +156 -0
  105. package/packages/protocol/test/messages.test.mjs +112 -0
  106. package/packages/session-core/src/bubble-linger.js +47 -0
  107. package/packages/session-core/src/bubble-view.js +79 -0
  108. package/packages/session-core/src/pet-state.js +56 -0
  109. package/packages/session-core/src/priority.js +56 -0
  110. package/packages/session-core/src/registry.js +144 -0
  111. package/packages/session-core/src/summaries.js +54 -0
  112. package/packages/session-core/test/bubble-linger.test.mjs +96 -0
  113. package/packages/session-core/test/bubble-view.test.mjs +79 -0
  114. package/packages/session-core/test/pet-state.test.mjs +118 -0
  115. package/packages/session-core/test/priority.test.mjs +53 -0
  116. package/packages/session-core/test/registry.test.mjs +161 -0
  117. package/packages/session-core/test/summaries.test.mjs +38 -0
  118. package/packages/task-core/src/approvals.js +91 -0
  119. package/packages/task-core/src/controls.js +61 -0
  120. package/packages/task-core/src/replies.js +80 -0
  121. package/packages/task-core/src/task-events.js +101 -0
  122. package/packages/task-core/src/task-status.js +93 -0
  123. package/packages/task-core/src/task-store.js +74 -0
  124. package/packages/task-core/test/approvals.test.mjs +61 -0
  125. package/packages/task-core/test/controls.test.mjs +61 -0
  126. package/packages/task-core/test/replies.test.mjs +51 -0
  127. package/packages/task-core/test/task-events.test.mjs +67 -0
  128. package/packages/task-core/test/task-status.test.mjs +49 -0
  129. package/packages/task-core/test/task-store.test.mjs +65 -0
  130. package/test/harness.mjs +22 -0
  131. package/test/run-tests.mjs +47 -0
@@ -0,0 +1,339 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { createDaemonRuntime } from "../../../packages/daemon-core/src/daemon-runtime.js";
4
+ import { createIpcServer } from "../../../packages/daemon-core/src/ipc-server.js";
5
+ import { parseAiPetArgs, runAiPet } from "../src/haya-pet.js";
6
+
7
+ test("parses generic run command arguments", () => {
8
+ assert.deepEqual(
9
+ parseAiPetArgs(["run", "--no-observe", "--client", "generic", "--", "node", "-e", "process.exit(0)"]),
10
+ {
11
+ command: "run",
12
+ clientId: "generic",
13
+ observe: false,
14
+ childCommand: "node",
15
+ childArgs: ["-e", "process.exit(0)"]
16
+ }
17
+ );
18
+ });
19
+
20
+ test("observation is on by default and --no-observe opts out", () => {
21
+ assert.equal(parseAiPetArgs(["run", "--client", "codex"]).observe, true);
22
+ assert.equal(parseAiPetArgs(["run", "--no-observe", "--client", "codex"]).observe, false);
23
+
24
+ const parsedWithCommand = parseAiPetArgs(["run", "--", "claude", "--resume"]);
25
+ assert.equal(parsedWithCommand.observe, true);
26
+ assert.equal(parsedWithCommand.childCommand, "claude");
27
+ assert.deepEqual(parsedWithCommand.childArgs, ["--resume"]);
28
+ });
29
+
30
+ test("defaults run command client to generic", () => {
31
+ assert.equal(parseAiPetArgs(["run", "--", "node"]).clientId, "generic");
32
+ });
33
+
34
+ test("falls back to the client default command when no -- is given", () => {
35
+ assert.deepEqual(parseAiPetArgs(["run", "--no-observe", "--client", "codex"]), {
36
+ command: "run",
37
+ clientId: "codex",
38
+ observe: false,
39
+ childCommand: "codex",
40
+ childArgs: []
41
+ });
42
+ assert.equal(parseAiPetArgs(["run", "--client", "claude-code"]).childCommand, "claude");
43
+ assert.equal(parseAiPetArgs(["run", "--client", "antigravity"]).childCommand, "antigravity");
44
+ });
45
+
46
+ test("still requires -- for generic (no default command)", () => {
47
+ assert.throws(() => parseAiPetArgs(["run", "--client", "generic"]), /no default command/);
48
+ });
49
+
50
+ test("accepts a bare command without -- (shells may strip the separator)", () => {
51
+ assert.deepEqual(parseAiPetArgs(["run", "--no-observe", "node", "-v"]), {
52
+ command: "run",
53
+ clientId: "generic",
54
+ observe: false,
55
+ childCommand: "node",
56
+ childArgs: ["-v"]
57
+ });
58
+ // The shape PowerShell's npm shim produces after stripping `--`:
59
+ assert.deepEqual(parseAiPetArgs(["run", "--no-observe", "--client", "claude-code", "claude", "--resume"]), {
60
+ command: "run",
61
+ clientId: "claude-code",
62
+ observe: false,
63
+ childCommand: "claude",
64
+ childArgs: ["--resume"]
65
+ });
66
+ });
67
+
68
+ test("rejects unsupported commands and missing arguments", () => {
69
+ assert.throws(() => parseAiPetArgs(["status"]), /Unsupported haya-pet command: status/);
70
+ assert.throws(() => parseAiPetArgs(["run", "--client"]), /--client requires a value/);
71
+ assert.throws(() => parseAiPetArgs(["run", "--"]), /run requires a child command/);
72
+ assert.throws(() => parseAiPetArgs(["run", "--bogus"]), /Unknown run option/);
73
+ });
74
+
75
+ test("runs parsed command through injectable generic runner", async () => {
76
+ const calls = [];
77
+ const result = await runAiPet(
78
+ ["run", "--client", "generic", "--", "node", "-e", "process.exit(0)"],
79
+ {
80
+ cwd: "D:\\Work\\project",
81
+ heartbeatIntervalMs: 10,
82
+ send: async () => {},
83
+ runGenericCommand: async (options) => {
84
+ calls.push(options);
85
+ return { sessionId: "sess_a", pid: 123, exitCode: 0 };
86
+ }
87
+ }
88
+ );
89
+
90
+ assert.equal(result.exitCode, 0);
91
+ assert.equal(calls.length, 1);
92
+ assert.equal(calls[0].command, "node");
93
+ assert.deepEqual(calls[0].args, ["-e", "process.exit(0)"]);
94
+ assert.equal(calls[0].clientId, "generic");
95
+ assert.equal(calls[0].clientDisplayName, "Generic");
96
+ assert.equal(calls[0].cwd, "D:\\Work\\project");
97
+ assert.equal(calls[0].heartbeatIntervalMs, 10);
98
+ });
99
+
100
+ test("runs command through daemon IPC when no send function is injected", async () => {
101
+ const runtime = createDaemonRuntime();
102
+ const server = await createIpcServer({
103
+ endpoint: "test-haya-petd",
104
+ platform: "test",
105
+ onMessage: (message) => runtime.handleMessage(message)
106
+ });
107
+
108
+ try {
109
+ const result = await runAiPet(
110
+ // --no-observe keeps this lifecycle test on the deterministic plain path.
111
+ ["run", "--no-observe", "--client", "generic", "--", process.execPath, "-e", "process.exit(0)"],
112
+ {
113
+ cwd: process.cwd(),
114
+ stdio: "ignore",
115
+ heartbeatIntervalMs: 10,
116
+ ipcEndpoint: server.endpoint
117
+ }
118
+ );
119
+
120
+ await waitFor(() => runtime.getSession(result.sessionId)?.state === "exited");
121
+
122
+ const session = runtime.getSession(result.sessionId);
123
+ assert.equal(result.exitCode, 0);
124
+ assert.equal(session.clientId, "generic");
125
+ assert.equal(session.state, "exited");
126
+ assert.equal(session.exitCode, 0);
127
+ } finally {
128
+ await server.close();
129
+ }
130
+ });
131
+
132
+ test("parses pets list and pets use commands", () => {
133
+ assert.deepEqual(parseAiPetArgs(["pets"]), { command: "pets", action: "list" });
134
+ assert.deepEqual(parseAiPetArgs(["pets", "list"]), { command: "pets", action: "list" });
135
+ assert.deepEqual(parseAiPetArgs(["pets", "use", "cat"]), { command: "pets", action: "use", petId: "cat" });
136
+ assert.throws(() => parseAiPetArgs(["pets", "use"]), /pets use requires a pet id/);
137
+ assert.throws(() => parseAiPetArgs(["pets", "bogus"]), /Unknown pets action: bogus/);
138
+ });
139
+
140
+ test("pets list marks the currently selected pet", async () => {
141
+ const lines = [];
142
+ const result = await runAiPet(["pets", "list"], {
143
+ homeDir: "C:\\Users\\A",
144
+ discoverPets: async () => [
145
+ { manifest: { id: "cat", name: "Cat" } },
146
+ { manifest: { id: "dog", name: "Dog" } }
147
+ ],
148
+ createStateFile: () => fakeStateFile({ globalPet: { selectedPetId: "dog" } }),
149
+ print: (line) => lines.push(line)
150
+ });
151
+
152
+ assert.deepEqual(result.pets, ["cat", "dog"]);
153
+ assert.equal(result.selectedId, "dog");
154
+ assert.ok(lines.some((line) => line.startsWith("* dog")));
155
+ assert.ok(lines.some((line) => line.startsWith(" cat")));
156
+ });
157
+
158
+ test("pets use stores the selection in the state file", async () => {
159
+ const store = fakeStateFile({ globalPet: {} });
160
+ const result = await runAiPet(["pets", "use", "cat"], {
161
+ homeDir: "C:\\Users\\A",
162
+ discoverPets: async () => [{ manifest: { id: "cat", name: "Cat" } }],
163
+ createStateFile: () => store,
164
+ print: () => {}
165
+ });
166
+
167
+ assert.equal(result.ok, true);
168
+ assert.equal(result.installed, true);
169
+ assert.equal(store.current().globalPet.selectedPetId, "cat");
170
+ });
171
+
172
+ test("pets use still stores an id that is not currently installed", async () => {
173
+ const store = fakeStateFile({ globalPet: {} });
174
+ const lines = [];
175
+ const result = await runAiPet(["pets", "use", "ghost"], {
176
+ homeDir: "C:\\Users\\A",
177
+ discoverPets: async () => [],
178
+ createStateFile: () => store,
179
+ print: (line) => lines.push(line)
180
+ });
181
+
182
+ assert.equal(result.ok, true);
183
+ assert.equal(result.installed, false);
184
+ assert.equal(store.current().globalPet.selectedPetId, "ghost");
185
+ assert.ok(lines.some((line) => line.includes("not currently installed")));
186
+ });
187
+
188
+ function fakeStateFile(initial) {
189
+ let state = initial;
190
+ return {
191
+ statePath: "state.json",
192
+ load: async () => state,
193
+ save: async (next) => {
194
+ state = next;
195
+ return next;
196
+ },
197
+ current: () => state
198
+ };
199
+ }
200
+
201
+ test("still runs the wrapped command when no daemon is available", async () => {
202
+ const calls = [];
203
+ const result = await runAiPet(
204
+ ["run", "--client", "generic", "--", "node", "-e", "process.exit(0)"],
205
+ {
206
+ cwd: process.cwd(),
207
+ heartbeatIntervalMs: 10,
208
+ autoStart: false, // no daemon and no auto-start -> degrade gracefully
209
+ createIpcClient: async () => {
210
+ throw new Error("ECONNREFUSED");
211
+ },
212
+ runGenericCommand: async (options) => {
213
+ calls.push(options);
214
+ return { sessionId: "sess_a", pid: 123, exitCode: 0 };
215
+ }
216
+ }
217
+ );
218
+
219
+ assert.equal(result.exitCode, 0);
220
+ assert.equal(calls.length, 1);
221
+ });
222
+
223
+ test("auto-starts the companion when one is not already running", async () => {
224
+ const calls = [];
225
+ let launched = 0;
226
+ let connects = 0;
227
+
228
+ const result = await runAiPet(
229
+ ["run", "--client", "generic", "--", "node", "-e", "process.exit(0)"],
230
+ {
231
+ cwd: process.cwd(),
232
+ heartbeatIntervalMs: 10,
233
+ sleep: async () => {}, // no real waiting between connect attempts
234
+ createIpcClient: async () => {
235
+ connects += 1;
236
+ if (connects === 1) {
237
+ throw new Error("ECONNREFUSED"); // not running yet
238
+ }
239
+ return { send: async () => {}, close: async () => {} }; // up after launch
240
+ },
241
+ launchCompanion: async () => {
242
+ launched += 1;
243
+ },
244
+ runGenericCommand: async (options) => {
245
+ calls.push(options);
246
+ return { sessionId: "sess_a", pid: 123, exitCode: 0 };
247
+ }
248
+ }
249
+ );
250
+
251
+ assert.equal(result.exitCode, 0);
252
+ assert.equal(launched, 1, "launches the companion exactly once");
253
+ assert.equal(calls.length, 1, "still runs the wrapped command");
254
+ });
255
+
256
+ test("HAYA_PET_NO_AUTOSTART disables auto-starting the companion", async () => {
257
+ let launched = 0;
258
+ await runAiPet(
259
+ ["run", "--client", "generic", "--", "node", "-e", "process.exit(0)"],
260
+ {
261
+ cwd: process.cwd(),
262
+ heartbeatIntervalMs: 10,
263
+ env: { HAYA_PET_NO_AUTOSTART: "1" },
264
+ ipcEndpoint: "test-endpoint",
265
+ createIpcClient: async () => {
266
+ throw new Error("ECONNREFUSED");
267
+ },
268
+ launchCompanion: async () => {
269
+ launched += 1;
270
+ },
271
+ runGenericCommand: async () => ({ sessionId: "sess_a", pid: 1, exitCode: 0 })
272
+ }
273
+ );
274
+
275
+ assert.equal(launched, 0);
276
+ });
277
+
278
+ test("parses the start command and reports when already running", async () => {
279
+ assert.deepEqual(parseAiPetArgs(["start"]), { command: "start" });
280
+
281
+ const lines = [];
282
+ const result = await runAiPet(["start"], {
283
+ createIpcClient: async () => ({ send: async () => {}, close: async () => {} }),
284
+ launchCompanion: async () => {
285
+ throw new Error("should not launch when already running");
286
+ },
287
+ print: (line) => lines.push(line)
288
+ });
289
+
290
+ assert.equal(result.ok, true);
291
+ assert.equal(result.started, false);
292
+ assert.ok(lines.some((line) => line.includes("already running")));
293
+ });
294
+
295
+ test("stop command sends a shutdown to a running companion", async () => {
296
+ assert.deepEqual(parseAiPetArgs(["stop"]), { command: "stop" });
297
+
298
+ const sent = [];
299
+ const lines = [];
300
+ const result = await runAiPet(["stop"], {
301
+ createIpcClient: async () => ({
302
+ send: async (message) => sent.push(message),
303
+ close: async () => {}
304
+ }),
305
+ print: (line) => lines.push(line)
306
+ });
307
+
308
+ assert.equal(result.ok, true);
309
+ assert.equal(result.wasRunning, true);
310
+ assert.deepEqual(sent, [{ type: "shutdown" }]);
311
+ assert.ok(lines.some((line) => line.includes("stopped")));
312
+ });
313
+
314
+ test("stop command is a no-op when nothing is running", async () => {
315
+ const lines = [];
316
+ const result = await runAiPet(["stop"], {
317
+ ipcEndpoint: "test-endpoint",
318
+ createIpcClient: async () => {
319
+ throw new Error("ECONNREFUSED");
320
+ },
321
+ print: (line) => lines.push(line)
322
+ });
323
+
324
+ assert.equal(result.ok, true);
325
+ assert.equal(result.wasRunning, false);
326
+ assert.ok(lines.some((line) => line.includes("not running")));
327
+ });
328
+
329
+ async function waitFor(predicate) {
330
+ const startedAt = Date.now();
331
+
332
+ while (!predicate()) {
333
+ if (Date.now() - startedAt > 1000) {
334
+ throw new Error("Timed out waiting for condition");
335
+ }
336
+
337
+ await new Promise((resolve) => setTimeout(resolve, 5));
338
+ }
339
+ }
@@ -0,0 +1,83 @@
1
+ # Haya Pet Companion (Electron overlay)
2
+
3
+ The desktop overlay app for the AI CLI pet runtime. It hosts the daemon IPC
4
+ server, renders the global pet, and shows the session bubbles. (A reply/approval
5
+ "task talk window" is scaffolded but parked — see below.)
6
+
7
+ > Most users never launch this directly: `haya-pet run` auto-starts it. This doc
8
+ > covers its internals. For installing/using Haya Pet, see the
9
+ > [root README](../../README.md) and [docs/architecture.md](../../docs/architecture.md).
10
+
11
+ ## Architecture
12
+
13
+ The companion is intentionally thin glue. All decision logic lives in the
14
+ unit-tested pure packages and is imported directly:
15
+
16
+ | Concern | Source of truth |
17
+ |---|---|
18
+ | Pet atlas / frames / animation | `packages/pet-core` |
19
+ | Click vs drag | `apps/companion/src/renderer/interaction-controller.js` |
20
+ | Window options / overlay vs fallback | `src/main/window-options.js` |
21
+ | Display clamping / DPI | `src/main/display-manager.js` |
22
+ | Position persistence | `src/main/position-store.js` + `state-file.js` |
23
+ | Session bubbles | `packages/session-core/src/bubble-view.js` |
24
+ | Adapter capabilities | `packages/adapters` |
25
+ | Task status / controls / reply safety | `packages/task-core` |
26
+ | Tray menu model | `src/main/tray-menu.js` |
27
+ | Daemon singleton | `packages/daemon-core/src/singleton.js` |
28
+
29
+ ```
30
+ main process (index.js)
31
+ ├─ IPC server (daemon-core) ← haya-pet wrappers send register/state/heartbeat
32
+ ├─ daemon runtime + session registry
33
+ ├─ overlay BrowserWindow → renderer
34
+ └─ tray + position persistence
35
+
36
+ renderer
37
+ ├─ pet-window.js (Layer 1: pet canvas + drag + panel placement)
38
+ ├─ session-bubbles.js (Layer 2: bubbles + folder toggle + status icons)
39
+ └─ task-talk-window.js(Layer 3: reply/approval surface — PARKED, not wired)
40
+ ```
41
+
42
+ ## Run
43
+
44
+ Normally you don't — `haya-pet run` (or `haya-pet start`) launches the overlay by
45
+ spawning Electron, which is a root runtime dependency. For development you can
46
+ still start it directly:
47
+
48
+ ```bash
49
+ npm start # electron . (from apps/companion)
50
+ ```
51
+
52
+ Requires Node ≥ 18. Then, from any terminal, launch an AI CLI through the wrapper
53
+ so the pet reflects it:
54
+
55
+ ```bash
56
+ haya-pet run --client generic -- sleep 10
57
+ haya-pet run --client codex -- codex
58
+ ```
59
+
60
+ ## Pets
61
+
62
+ Pets are discovered from `~/.codex/pets` and `~/.haya-pet/pets`. Without a
63
+ spritesheet the renderer draws labelled placeholder frames so interactions and
64
+ state mapping remain testable. See `assets/fallback-pet/README.md`.
65
+
66
+ Select a pet from the tray menu → **Installed Pets**, or from the CLI:
67
+
68
+ ```bash
69
+ haya-pet pets # list (the * marks the selected pet)
70
+ haya-pet pets use my-pet # persist the selection; used on next companion start
71
+ ```
72
+
73
+ The selection is stored in the shared state file (`globalPet.selectedPetId`),
74
+ so the companion starts with your last selected pet.
75
+
76
+ ## Safety
77
+
78
+ - The overlay never steals focus (`focusable: false` on supported platforms) and
79
+ is click-through except over the pet and bubbles.
80
+ - All IPC is local-only; nothing is sent to the network.
81
+ - When the parked reply/approval surface is wired up, replies will be gated by
82
+ adapter capability (wrapper-only clients can't inject text blindly) and
83
+ approvals will always require explicit user action.
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@haya-pet/companion",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Electron overlay companion app for the AI CLI pet runtime.",
7
+ "main": "src/main/index.js",
8
+ "scripts": {
9
+ "start": "electron ."
10
+ },
11
+ "devDependencies": {
12
+ "electron": "42.3.3"
13
+ },
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ }
17
+ }
@@ -0,0 +1,71 @@
1
+ const DEFAULT_SIZE = Object.freeze({
2
+ width: 192,
3
+ height: 208
4
+ });
5
+
6
+ export function resolveSavedPosition(savedPosition, displays, size = DEFAULT_SIZE) {
7
+ const display = findDisplayForSavedPosition(savedPosition, displays);
8
+ const windowBounds = savedPosition
9
+ ? {
10
+ x: savedPosition.x,
11
+ y: savedPosition.y,
12
+ width: savedPosition.width ?? size.width,
13
+ height: savedPosition.height ?? size.height
14
+ }
15
+ : defaultBoundsForDisplay(display, size);
16
+ const clamped = clampWindowBounds(windowBounds, display);
17
+
18
+ return {
19
+ ...clamped,
20
+ displayId: display.id,
21
+ scaleFactor: display.scaleFactor ?? 1
22
+ };
23
+ }
24
+
25
+ export function clampWindowBounds(bounds, display) {
26
+ const workArea = display.workArea ?? display.bounds;
27
+ const width = bounds.width ?? DEFAULT_SIZE.width;
28
+ const height = bounds.height ?? DEFAULT_SIZE.height;
29
+ const maxX = workArea.x + workArea.width - width;
30
+ const maxY = workArea.y + workArea.height - height;
31
+
32
+ return {
33
+ x: clamp(bounds.x ?? workArea.x, workArea.x, maxX),
34
+ y: clamp(bounds.y ?? workArea.y, workArea.y, maxY),
35
+ width,
36
+ height
37
+ };
38
+ }
39
+
40
+ function findDisplayForSavedPosition(savedPosition, displays) {
41
+ if (!Array.isArray(displays) || displays.length === 0) {
42
+ throw new Error("At least one display is required");
43
+ }
44
+
45
+ if (savedPosition?.displayId) {
46
+ const savedDisplay = displays.find((display) => display.id === savedPosition.displayId);
47
+ if (savedDisplay) {
48
+ return savedDisplay;
49
+ }
50
+ }
51
+
52
+ return displays.find((display) => display.primary) ?? displays[0];
53
+ }
54
+
55
+ function defaultBoundsForDisplay(display, size) {
56
+ const workArea = display.workArea ?? display.bounds;
57
+ return {
58
+ x: workArea.x + workArea.width - size.width,
59
+ y: workArea.y + workArea.height - size.height,
60
+ width: size.width,
61
+ height: size.height
62
+ };
63
+ }
64
+
65
+ function clamp(value, min, max) {
66
+ if (max < min) {
67
+ return min;
68
+ }
69
+
70
+ return Math.min(Math.max(value, min), max);
71
+ }