@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,349 @@
1
+ import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, Tray } from "electron";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+ import { createDaemonRuntime } from "../../../../packages/daemon-core/src/daemon-runtime.js";
5
+ import { createIpcServer } from "../../../../packages/daemon-core/src/ipc-server.js";
6
+ import { getDefaultPaths } from "../../../../packages/platform-core/src/paths.js";
7
+ import { getPlatformCapabilities } from "../../../../packages/platform-core/src/capabilities.js";
8
+ import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
9
+ import { buildPetWindowOptions, PET_SIZE } from "./window-options.js";
10
+ import { resolveSavedPosition } from "./display-manager.js";
11
+ import { setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
12
+ import { buildTrayMenu } from "./tray-menu.js";
13
+ import { createStateFile } from "./state-file.js";
14
+ import { discoverPets } from "./pet-loader.js";
15
+
16
+ const STALE_SWEEP_INTERVAL_MS = 10_000;
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+
19
+ // Fallback tray icon (16×16 blue dot) used when no tray.png asset is present, so
20
+ // the tray — and therefore the Quit menu — always appears. Without it, a missing
21
+ // icon left users with no way to exit.
22
+ const TRAY_ICON_DATA_URL =
23
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAANElEQVR4nGNgoAXIW/HvPzZMtkaiDCJWM05DKDKAVM0YhowaQAUDBj4dUCUpE2MQQY3kAACyf/g8DHVl5wAAAABJRU5ErkJggg==";
24
+
25
+ const paths = getDefaultPaths();
26
+ const capabilities = getPlatformCapabilities();
27
+ const stateFile = createStateFile({ statePath: paths.statePath });
28
+
29
+ let petWindow;
30
+ let tray;
31
+ let ipcServer;
32
+ let positionState;
33
+ let pets = [];
34
+ let runtime;
35
+ // The overlay window spans this work area; the pet is positioned *inside* it at
36
+ // `petLocal` (work-area-relative coords) and moved via CSS rather than by moving
37
+ // the window, so the bubble panel can always be placed on-screen beside it.
38
+ let currentWorkArea;
39
+ let currentDisplayId;
40
+ let petLocal = { x: 0, y: 0 };
41
+
42
+ // Electron singleton: a second launch forwards to the running instance.
43
+ if (!app.requestSingleInstanceLock()) {
44
+ app.quit();
45
+ } else {
46
+ app.on("second-instance", () => focusPet());
47
+ app.whenReady().then(bootstrap).catch((error) => {
48
+ console.error("haya-pet companion failed to start:", error);
49
+ app.quit();
50
+ });
51
+ }
52
+
53
+ async function bootstrap() {
54
+ positionState = await stateFile.load();
55
+ pets = await discoverPets(paths.petSearchPaths);
56
+
57
+ runtime = createDaemonRuntime({
58
+ onSessionChanged: () => pushSessions()
59
+ });
60
+
61
+ ipcServer = await createIpcServer({
62
+ endpoint: paths.ipcEndpoint,
63
+ onMessage: (message) => {
64
+ // `haya-pet stop` asks the daemon to exit; everything else is a session event.
65
+ if (message?.type === "shutdown") {
66
+ app.quit();
67
+ return;
68
+ }
69
+ return runtime.handleMessage(message);
70
+ },
71
+ onProtocolError: (error) => console.error("protocol error:", error.message)
72
+ });
73
+
74
+ createPetWindow();
75
+ createTray();
76
+ registerRendererHandlers();
77
+
78
+ const sweep = setInterval(() => {
79
+ runtime.markStaleSessions(Date.now());
80
+ // Refresh the renderer so dropped (dead/finished) sessions disappear and the
81
+ // pet settles to idle even when no new session event fires.
82
+ pushSessions();
83
+ }, STALE_SWEEP_INTERVAL_MS);
84
+ sweep.unref?.();
85
+
86
+ app.on("before-quit", async () => {
87
+ clearInterval(sweep);
88
+ await ipcServer?.close();
89
+ });
90
+ }
91
+
92
+ function createPetWindow() {
93
+ const displays = listDisplays();
94
+ // Resolve the pet's saved on-screen position (pet-sized), which also tells us
95
+ // which display it belongs on. The window then spans that display's work area.
96
+ const saved = resolveSavedPosition(positionState.globalPet, displays);
97
+ const display = displays.find((d) => d.id === saved.displayId)
98
+ ?? displays.find((d) => d.primary)
99
+ ?? displays[0];
100
+ currentWorkArea = display.workArea ?? display.bounds;
101
+ currentDisplayId = display.id;
102
+ petLocal = clampPetLocal({ x: saved.x - currentWorkArea.x, y: saved.y - currentWorkArea.y });
103
+
104
+ const { browserWindow } = buildPetWindowOptions({ capabilities, bounds: currentWorkArea });
105
+
106
+ petWindow = new BrowserWindow({
107
+ ...browserWindow,
108
+ webPreferences: {
109
+ preload: join(__dirname, "preload.cjs"),
110
+ contextIsolation: true,
111
+ nodeIntegration: false,
112
+ sandbox: false
113
+ }
114
+ });
115
+
116
+ petWindow.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true });
117
+ // The whole work area is covered, so empty area MUST pass clicks through to the
118
+ // desktop; the renderer re-enables interaction (via haya-pet:set-mouse-ignore)
119
+ // only over the pet + bubbles.
120
+ petWindow.setIgnoreMouseEvents(true, { forward: true });
121
+ petWindow.loadFile(join(__dirname, "..", "renderer", "index.html"));
122
+ petWindow.webContents.on("did-finish-load", () => {
123
+ sendPetConfig();
124
+ pushSessions();
125
+ });
126
+ }
127
+
128
+ function clampPetLocal(local) {
129
+ const maxX = Math.max(0, (currentWorkArea?.width ?? PET_SIZE.width) - PET_SIZE.width);
130
+ const maxY = Math.max(0, (currentWorkArea?.height ?? PET_SIZE.height) - PET_SIZE.height);
131
+ return {
132
+ x: Math.min(Math.max(local.x ?? 0, 0), maxX),
133
+ y: Math.min(Math.max(local.y ?? 0, 0), maxY)
134
+ };
135
+ }
136
+
137
+ function createTray() {
138
+ try {
139
+ tray = new Tray(loadTrayIcon());
140
+ tray.setToolTip("Haya Pet — right-click to Quit");
141
+ } catch (error) {
142
+ // A failed tray must not take the whole app down; log and continue.
143
+ console.error("tray unavailable:", error.message);
144
+ return;
145
+ }
146
+ refreshTrayMenu();
147
+ }
148
+
149
+ function loadTrayIcon() {
150
+ const fileIcon = nativeImage.createFromPath(join(__dirname, "..", "renderer", "assets", "tray.png"));
151
+ // createFromPath returns an empty image (no throw) when the file is missing.
152
+ return fileIcon.isEmpty() ? nativeImage.createFromDataURL(TRAY_ICON_DATA_URL) : fileIcon;
153
+ }
154
+
155
+ function refreshTrayMenu() {
156
+ if (!tray) {
157
+ return;
158
+ }
159
+
160
+ const sessions = (runtime?.listSessions() ?? []).map((session) => ({
161
+ sessionId: session.sessionId,
162
+ label: `${session.clientDisplayName} · ${session.projectName}`
163
+ }));
164
+
165
+ const template = buildTrayMenu({
166
+ petVisible: petWindow?.isVisible() ?? true,
167
+ displayMode: positionState.settings.displayMode,
168
+ attachBubblesToTerminals: positionState.settings.attachBubblesToTerminals,
169
+ selectedPetId: positionState.globalPet.selectedPetId,
170
+ sessions,
171
+ pets: pets.map((pet) => ({ id: pet.manifest.id, name: pet.manifest.name }))
172
+ }).map(toElectronMenuItem);
173
+
174
+ tray.setContextMenu(Menu.buildFromTemplate(template));
175
+ }
176
+
177
+ function toElectronMenuItem(item) {
178
+ if (item.type === "separator") {
179
+ return { type: "separator" };
180
+ }
181
+
182
+ const electronItem = {
183
+ label: item.label,
184
+ type: item.type === "checkbox" || item.type === "radio" ? item.type : undefined,
185
+ checked: item.checked,
186
+ enabled: item.enabled !== false
187
+ };
188
+
189
+ if (item.submenu) {
190
+ electronItem.submenu = item.submenu.map(toElectronMenuItem);
191
+ } else {
192
+ electronItem.click = () => handleTrayClick(item);
193
+ }
194
+
195
+ return electronItem;
196
+ }
197
+
198
+ function handleTrayClick(item) {
199
+ switch (item.id) {
200
+ case "toggle_pet":
201
+ togglePet();
202
+ break;
203
+ case "reset_position":
204
+ resetPosition();
205
+ break;
206
+ case "quit":
207
+ app.quit();
208
+ break;
209
+ default:
210
+ if (item.id?.startsWith("display_mode:")) {
211
+ setDisplayMode(item.value);
212
+ } else if (item.id === "attach_bubbles") {
213
+ toggleAttachBubbles();
214
+ } else if (item.petId) {
215
+ selectPet(item.petId);
216
+ }
217
+ }
218
+ }
219
+
220
+ function registerRendererHandlers() {
221
+ ipcMain.handle("haya-pet:list-sessions", () => buildSessionPayload());
222
+
223
+ // Fired on every cursor move while hovering the overlay, so use the
224
+ // fire-and-forget channel (no round-trip) to toggle click-through.
225
+ ipcMain.on("haya-pet:set-mouse-ignore", (_event, ignore) => {
226
+ if (petWindow && !petWindow.isDestroyed()) {
227
+ petWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true });
228
+ }
229
+ });
230
+
231
+ // The pet moves within the overlay (CSS), so the renderer reports its new
232
+ // work-area-relative position instead of moving the window.
233
+ ipcMain.handle("haya-pet:save-pet-position", async (_event, local) => {
234
+ petLocal = clampPetLocal(local ?? petLocal);
235
+ persistPetPosition();
236
+ return petLocal;
237
+ });
238
+ }
239
+
240
+ function buildSessionPayload() {
241
+ const sessions = runtime?.listSessions() ?? [];
242
+ const priority = runtime?.getPrioritySession({ pinnedSessionId: positionState.globalPet.selectedSessionId });
243
+
244
+ return {
245
+ bubbles: buildBubbleViews(sessions, Date.now(), {
246
+ selectedSessionId: priority?.sessionId
247
+ }),
248
+ prioritySessionId: priority?.sessionId
249
+ };
250
+ }
251
+
252
+ function pushSessions() {
253
+ refreshTrayMenu();
254
+ if (petWindow && !petWindow.isDestroyed()) {
255
+ petWindow.webContents.send("haya-pet:sessions", buildSessionPayload());
256
+ }
257
+ }
258
+
259
+ function sendPetConfig() {
260
+ const selected = pets.find((pet) => pet.manifest.id === positionState.globalPet.selectedPetId) ?? pets[0];
261
+ petWindow.webContents.send("haya-pet:config", {
262
+ pet: selected
263
+ ? { manifest: selected.manifest, spritesheetUrl: selected.spritesheetUrl }
264
+ : undefined,
265
+ overlayMode: capabilities.transparentOverlay === "required" ? "transparent-overlay" : "fallback-window",
266
+ petPosition: petLocal
267
+ });
268
+ }
269
+
270
+ function sendPetPosition() {
271
+ petWindow?.webContents.send("haya-pet:pet-position", petLocal);
272
+ }
273
+
274
+ let persistTimer;
275
+ function persistPetPosition() {
276
+ if (!currentWorkArea) {
277
+ return;
278
+ }
279
+
280
+ // Store the pet's absolute on-screen top-left so it can be restored on the
281
+ // right display, mapping the in-window position back to screen coordinates.
282
+ positionState = updateGlobalPetPosition(positionState, {
283
+ x: currentWorkArea.x + petLocal.x,
284
+ y: currentWorkArea.y + petLocal.y,
285
+ width: PET_SIZE.width,
286
+ height: PET_SIZE.height,
287
+ displayId: currentDisplayId
288
+ });
289
+
290
+ // Debounce disk writes during drag (positionSaveDebounce, plan section 27).
291
+ clearTimeout(persistTimer);
292
+ persistTimer = setTimeout(() => {
293
+ stateFile.save(positionState).catch((error) => console.error("save failed:", error.message));
294
+ }, 200);
295
+ }
296
+
297
+ function togglePet() {
298
+ if (!petWindow) {
299
+ return;
300
+ }
301
+ petWindow.isVisible() ? petWindow.hide() : petWindow.show();
302
+ refreshTrayMenu();
303
+ }
304
+
305
+ function focusPet() {
306
+ petWindow?.show();
307
+ }
308
+
309
+ function resetPosition() {
310
+ // Drop the pet back to the bottom-right corner of its work area.
311
+ const margin = 24;
312
+ petLocal = clampPetLocal({
313
+ x: (currentWorkArea?.width ?? PET_SIZE.width) - PET_SIZE.width - margin,
314
+ y: (currentWorkArea?.height ?? PET_SIZE.height) - PET_SIZE.height - margin
315
+ });
316
+ sendPetPosition();
317
+ persistPetPosition();
318
+ }
319
+
320
+ function setDisplayMode(displayMode) {
321
+ positionState = { ...positionState, settings: { ...positionState.settings, displayMode } };
322
+ stateFile.save(positionState).catch(() => {});
323
+ petWindow?.webContents.send("haya-pet:display-mode", displayMode);
324
+ refreshTrayMenu();
325
+ }
326
+
327
+ function toggleAttachBubbles() {
328
+ const attachBubblesToTerminals = !positionState.settings.attachBubblesToTerminals;
329
+ positionState = { ...positionState, settings: { ...positionState.settings, attachBubblesToTerminals } };
330
+ stateFile.save(positionState).catch(() => {});
331
+ refreshTrayMenu();
332
+ }
333
+
334
+ function selectPet(petId) {
335
+ positionState = setSelectedPet(positionState, petId);
336
+ stateFile.save(positionState).catch(() => {});
337
+ sendPetConfig();
338
+ refreshTrayMenu();
339
+ }
340
+
341
+ function listDisplays() {
342
+ return screen.getAllDisplays().map((display) => ({
343
+ id: String(display.id),
344
+ primary: display.id === screen.getPrimaryDisplay().id,
345
+ bounds: display.bounds,
346
+ workArea: display.workArea,
347
+ scaleFactor: display.scaleFactor
348
+ }));
349
+ }
@@ -0,0 +1,52 @@
1
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import {
4
+ parseLock,
5
+ resolveSingletonAction,
6
+ serializeLock
7
+ } from "../../../../packages/daemon-core/src/singleton.js";
8
+
9
+ // IPC-socket / PID-file singleton fallback (product plan section 39). Electron's
10
+ // app.requestSingleInstanceLock() is the primary guard; this lock lets wrappers
11
+ // and non-Electron callers detect and reclaim a stale daemon.
12
+ export async function acquireDaemonLock({ lockPath, pid = process.pid, endpoint, now = Date.now, isAlive = defaultIsAlive }) {
13
+ const existing = await readLock(lockPath);
14
+ const action = resolveSingletonAction({ lock: existing, isAlive });
15
+
16
+ if (action === "forward") {
17
+ return { acquired: false, action, lock: existing };
18
+ }
19
+
20
+ const lock = { pid, startedAt: now(), endpoint };
21
+ await mkdir(dirname(lockPath), { recursive: true });
22
+ await writeFile(lockPath, serializeLock(lock));
23
+
24
+ return { acquired: true, action, lock };
25
+ }
26
+
27
+ export async function releaseDaemonLock(lockPath) {
28
+ try {
29
+ await unlink(lockPath);
30
+ } catch (error) {
31
+ if (error && error.code !== "ENOENT") {
32
+ throw error;
33
+ }
34
+ }
35
+ }
36
+
37
+ async function readLock(lockPath) {
38
+ try {
39
+ return parseLock(await readFile(lockPath, "utf8"));
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ function defaultIsAlive(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ } catch (error) {
50
+ return error.code === "EPERM";
51
+ }
52
+ }
@@ -0,0 +1,45 @@
1
+ // Decides where a panel (task talk window / bubble cluster) should sit relative
2
+ // to the pet so it sits *beside* the pet — never on top of it — and stays fully
3
+ // inside the visible work area. Picks the side with the most room: right, then
4
+ // left, then below, then above, clamping the cross-axis to the work area.
5
+ const DEFAULT_GAP = 8;
6
+
7
+ export function resolvePanelPlacement({ pet, panel, workArea, gap = DEFAULT_GAP } = {}) {
8
+ const right = pet.x + pet.width + gap;
9
+ const left = pet.x - gap - panel.width;
10
+ const below = pet.y + pet.height + gap;
11
+ const above = pet.y - gap - panel.height;
12
+
13
+ const areaRight = workArea.x + workArea.width;
14
+ const areaBottom = workArea.y + workArea.height;
15
+
16
+ if (right + panel.width <= areaRight) {
17
+ return { side: "right", x: right, y: clampAxis(pet.y, panel.height, workArea.y, areaBottom) };
18
+ }
19
+
20
+ if (left >= workArea.x) {
21
+ return { side: "left", x: left, y: clampAxis(pet.y, panel.height, workArea.y, areaBottom) };
22
+ }
23
+
24
+ if (below + panel.height <= areaBottom) {
25
+ return { side: "below", x: clampAxis(pet.x, panel.width, workArea.x, areaRight), y: below };
26
+ }
27
+
28
+ if (above >= workArea.y) {
29
+ return { side: "above", x: clampAxis(pet.x, panel.width, workArea.x, areaRight), y: above };
30
+ }
31
+
32
+ // No clear room on any side (tiny screen): overlap as a last resort, clamped.
33
+ return {
34
+ side: "overlap",
35
+ x: clampAxis(pet.x, panel.width, workArea.x, areaRight),
36
+ y: clampAxis(pet.y, panel.height, workArea.y, areaBottom)
37
+ };
38
+ }
39
+
40
+ function clampAxis(start, size, min, max) {
41
+ if (start + size > max) {
42
+ return Math.max(min, max - size);
43
+ }
44
+ return Math.max(min, start);
45
+ }
@@ -0,0 +1,2 @@
1
+ // Re-exported from pet-core so the companion and CLI share one discovery path.
2
+ export { discoverPets, loadPetFromDir } from "../../../../packages/pet-core/src/discovery.js";
@@ -0,0 +1,3 @@
1
+ // Re-exported from the shared app-state package so the companion and the CLI
2
+ // operate on the same state shape and selection helpers.
3
+ export * from "../../../../packages/app-state/src/state.js";
@@ -0,0 +1,13 @@
1
+ const { contextBridge, ipcRenderer } = require("electron");
2
+
3
+ // Minimal, safe bridge between the sandboxed renderer and the main process.
4
+ // No Node APIs are exposed to the renderer directly.
5
+ contextBridge.exposeInMainWorld("aiPet", {
6
+ listSessions: () => ipcRenderer.invoke("haya-pet:list-sessions"),
7
+ savePetPosition: (local) => ipcRenderer.invoke("haya-pet:save-pet-position", local),
8
+ setMouseIgnore: (ignore) => ipcRenderer.send("haya-pet:set-mouse-ignore", ignore),
9
+ onConfig: (handler) => ipcRenderer.on("haya-pet:config", (_event, config) => handler(config)),
10
+ onSessions: (handler) => ipcRenderer.on("haya-pet:sessions", (_event, payload) => handler(payload)),
11
+ onPetPosition: (handler) => ipcRenderer.on("haya-pet:pet-position", (_event, pos) => handler(pos)),
12
+ onDisplayMode: (handler) => ipcRenderer.on("haya-pet:display-mode", (_event, mode) => handler(mode))
13
+ });
@@ -0,0 +1,2 @@
1
+ // Re-exported from the shared app-state package (see ./position-store.js).
2
+ export * from "../../../../packages/app-state/src/state-file.js";
@@ -0,0 +1,79 @@
1
+ // Node-side client for the native terminal-window helper. Spawns the helper
2
+ // process and speaks the line-delimited JSON protocol from native/README.md,
3
+ // correlating responses to requests by id. `spawn` is injectable for tests.
4
+ export function createHelperClient({ spawn, command, args = [] } = {}) {
5
+ if (typeof spawn !== "function") {
6
+ throw new TypeError("spawn must be a function");
7
+ }
8
+ if (typeof command !== "string" || command.trim() === "") {
9
+ throw new Error("command is required");
10
+ }
11
+
12
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "inherit"] });
13
+ const pending = new Map();
14
+ let counter = 0;
15
+ let buffer = "";
16
+
17
+ child.stdout.on("data", (chunk) => {
18
+ buffer += chunk.toString("utf8");
19
+ let newlineIndex = buffer.indexOf("\n");
20
+ while (newlineIndex !== -1) {
21
+ const line = buffer.slice(0, newlineIndex);
22
+ buffer = buffer.slice(newlineIndex + 1);
23
+ handleLine(line);
24
+ newlineIndex = buffer.indexOf("\n");
25
+ }
26
+ });
27
+
28
+ child.on?.("error", (error) => rejectAll(error));
29
+ child.on?.("close", () => rejectAll(new Error("helper process closed")));
30
+
31
+ function handleLine(line) {
32
+ if (line.trim() === "") {
33
+ return;
34
+ }
35
+
36
+ let message;
37
+ try {
38
+ message = JSON.parse(line);
39
+ } catch {
40
+ return; // ignore non-protocol noise
41
+ }
42
+
43
+ const entry = pending.get(message.id);
44
+ if (entry) {
45
+ pending.delete(message.id);
46
+ entry.resolve(message);
47
+ }
48
+ }
49
+
50
+ function rejectAll(error) {
51
+ for (const entry of pending.values()) {
52
+ entry.reject(error);
53
+ }
54
+ pending.clear();
55
+ }
56
+
57
+ function request(op, params = {}) {
58
+ const id = `req_${counter++}`;
59
+ return new Promise((resolve, reject) => {
60
+ pending.set(id, { resolve, reject });
61
+ child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`);
62
+ });
63
+ }
64
+
65
+ return {
66
+ capabilities() {
67
+ return request("capabilities");
68
+ },
69
+
70
+ locate(pid, terminalPid) {
71
+ return request("locate", terminalPid === undefined ? { pid } : { pid, terminalPid });
72
+ },
73
+
74
+ async close() {
75
+ child.stdin.end?.();
76
+ child.kill?.();
77
+ }
78
+ };
79
+ }
@@ -0,0 +1,44 @@
1
+ import { normalizePlatform } from "../../../../packages/platform-core/src/platform.js";
2
+
3
+ export function getTerminalAttachmentStrategy(options = {}) {
4
+ const platform = normalizePlatform(options.platform);
5
+ const env = options.env ?? process.env;
6
+
7
+ if (platform === "windows") {
8
+ return {
9
+ supported: "best-effort",
10
+ strategy: "win32-window-helper",
11
+ reason: "Windows terminal windows require process tree and HWND lookup."
12
+ };
13
+ }
14
+
15
+ if (platform === "macos") {
16
+ return {
17
+ supported: "best-effort",
18
+ strategy: "macos-accessibility-helper",
19
+ reason: "macOS terminal attachment requires Accessibility or window-list permissions."
20
+ };
21
+ }
22
+
23
+ if (platform === "linux" && env.WAYLAND_DISPLAY) {
24
+ return {
25
+ supported: "fallback",
26
+ strategy: "manual-fallback",
27
+ reason: "Wayland commonly blocks global window positioning and terminal lookup."
28
+ };
29
+ }
30
+
31
+ if (platform === "linux") {
32
+ return {
33
+ supported: "best-effort",
34
+ strategy: "x11-window-helper",
35
+ reason: "X11 allows best-effort terminal window discovery."
36
+ };
37
+ }
38
+
39
+ return {
40
+ supported: "fallback",
41
+ strategy: "manual-fallback",
42
+ reason: "Unsupported platforms use manual bubble positioning."
43
+ };
44
+ }
@@ -0,0 +1,79 @@
1
+ // Pure tray menu model (product plan section 40). The Electron main process
2
+ // converts these descriptors into a native Menu; keeping it pure makes the
3
+ // recovery controls testable.
4
+
5
+ const DISPLAY_MODES = Object.freeze([
6
+ { value: "global", label: "Global" },
7
+ { value: "cluster", label: "Cluster" },
8
+ { value: "per-terminal", label: "Per Terminal" },
9
+ { value: "hybrid", label: "Hybrid" }
10
+ ]);
11
+
12
+ export function buildTrayMenu(state = {}) {
13
+ const sessions = Array.isArray(state.sessions) ? state.sessions : [];
14
+ const pets = Array.isArray(state.pets) ? state.pets : [];
15
+
16
+ return [
17
+ {
18
+ id: "toggle_pet",
19
+ label: state.petVisible ? "Hide Pet" : "Show Pet"
20
+ },
21
+ {
22
+ id: "display_mode",
23
+ label: "Display Mode",
24
+ submenu: DISPLAY_MODES.map((mode) => ({
25
+ id: `display_mode:${mode.value}`,
26
+ label: mode.label,
27
+ value: mode.value,
28
+ type: "radio",
29
+ checked: state.displayMode === mode.value
30
+ }))
31
+ },
32
+ {
33
+ id: "sessions",
34
+ label: "Active Sessions",
35
+ submenu: buildSessionItems(sessions)
36
+ },
37
+ {
38
+ id: "pets",
39
+ label: "Installed Pets",
40
+ submenu: buildPetItems(pets, state.selectedPetId)
41
+ },
42
+ {
43
+ id: "attach_bubbles",
44
+ label: "Attach Bubbles to Terminals",
45
+ type: "checkbox",
46
+ checked: Boolean(state.attachBubblesToTerminals)
47
+ },
48
+ { id: "reset_position", label: "Reset Position" },
49
+ { id: "settings", label: "Open Settings" },
50
+ { id: "separator", type: "separator" },
51
+ { id: "quit", label: "Quit" }
52
+ ];
53
+ }
54
+
55
+ function buildSessionItems(sessions) {
56
+ if (sessions.length === 0) {
57
+ return [{ id: "sessions:empty", label: "No active sessions", enabled: false }];
58
+ }
59
+
60
+ return sessions.map((session) => ({
61
+ id: `session:${session.sessionId}`,
62
+ label: session.label,
63
+ sessionId: session.sessionId
64
+ }));
65
+ }
66
+
67
+ function buildPetItems(pets, selectedPetId) {
68
+ if (pets.length === 0) {
69
+ return [{ id: "pets:empty", label: "No pets found", enabled: false }];
70
+ }
71
+
72
+ return pets.map((pet) => ({
73
+ id: `pet:${pet.id}`,
74
+ label: pet.name,
75
+ petId: pet.id,
76
+ type: "radio",
77
+ checked: pet.id === selectedPetId
78
+ }));
79
+ }