@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.
- package/.gitattributes +34 -0
- package/.github/workflows/release.yml +61 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/apps/cli/src/haya-pet.js +395 -0
- package/apps/cli/test/haya-pet.test.mjs +339 -0
- package/apps/companion/README.md +83 -0
- package/apps/companion/package.json +17 -0
- package/apps/companion/src/main/display-manager.js +71 -0
- package/apps/companion/src/main/index.js +349 -0
- package/apps/companion/src/main/lock-file.js +52 -0
- package/apps/companion/src/main/panel-placement.js +45 -0
- package/apps/companion/src/main/pet-loader.js +2 -0
- package/apps/companion/src/main/position-store.js +3 -0
- package/apps/companion/src/main/preload.cjs +13 -0
- package/apps/companion/src/main/state-file.js +2 -0
- package/apps/companion/src/main/terminal-helper-client.js +79 -0
- package/apps/companion/src/main/terminal-locator.js +44 -0
- package/apps/companion/src/main/tray-menu.js +79 -0
- package/apps/companion/src/main/window-options.js +66 -0
- package/apps/companion/src/renderer/index.html +18 -0
- package/apps/companion/src/renderer/interaction-controller.js +114 -0
- package/apps/companion/src/renderer/pet-window.js +275 -0
- package/apps/companion/src/renderer/session-bubbles.js +138 -0
- package/apps/companion/src/renderer/styles.css +225 -0
- package/apps/companion/src/renderer/task-talk-window.js +141 -0
- package/apps/companion/test/display-manager.test.mjs +48 -0
- package/apps/companion/test/interaction-controller.test.mjs +107 -0
- package/apps/companion/test/panel-placement.test.mjs +60 -0
- package/apps/companion/test/position-store.test.mjs +54 -0
- package/apps/companion/test/state-file.test.mjs +52 -0
- package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
- package/apps/companion/test/terminal-locator.test.mjs +35 -0
- package/apps/companion/test/tray-menu.test.mjs +45 -0
- package/apps/companion/test/window-options.test.mjs +62 -0
- package/apps/pet-preview/index.html +42 -0
- package/apps/pet-preview/src/preview-app.js +123 -0
- package/apps/pet-preview/src/preview-state.js +70 -0
- package/apps/pet-preview/src/preview.css +125 -0
- package/apps/pet-preview/test/preview-state.test.mjs +62 -0
- package/assets/fallback-pet/README.md +16 -0
- package/assets/fallback-pet/pet.json +13 -0
- package/docs/architecture.md +144 -0
- package/docs/known-issues.md +49 -0
- package/docs/publishing.md +48 -0
- package/docs/screenshots/README.md +7 -0
- package/docs/screenshots/folder-collapsed.png +0 -0
- package/docs/screenshots/hero.png +0 -0
- package/docs/screenshots/pet-overlay.png +0 -0
- package/docs/screenshots/session-bubbles.png +0 -0
- package/docs/screenshots/tray-menu.png +0 -0
- package/docs/troubleshooting.md +36 -0
- package/native/README.md +80 -0
- package/native/linux-window-helper/README.md +29 -0
- package/native/mac-window-helper/README.md +30 -0
- package/native/win-window-helper/Program.cs +312 -0
- package/native/win-window-helper/README.md +53 -0
- package/native/win-window-helper/win-window-helper.csproj +12 -0
- package/package.json +35 -0
- package/packages/adapters/src/adapter-info.js +61 -0
- package/packages/adapters/src/capabilities.js +39 -0
- package/packages/adapters/src/heuristics.js +114 -0
- package/packages/adapters/src/output-observer.js +164 -0
- package/packages/adapters/src/routing.js +86 -0
- package/packages/adapters/test/adapter-info.test.mjs +35 -0
- package/packages/adapters/test/capabilities.test.mjs +44 -0
- package/packages/adapters/test/heuristics.test.mjs +42 -0
- package/packages/adapters/test/output-observer.test.mjs +142 -0
- package/packages/adapters/test/routing.test.mjs +93 -0
- package/packages/app-state/src/state-file.js +53 -0
- package/packages/app-state/src/state.js +80 -0
- package/packages/app-state/test/state.test.mjs +36 -0
- package/packages/cli-core/src/companion-launcher.js +69 -0
- package/packages/cli-core/src/pty-runner.js +96 -0
- package/packages/cli-core/src/run-command.js +353 -0
- package/packages/cli-core/src/strip-ansi.js +16 -0
- package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
- package/packages/cli-core/test/run-command.test.mjs +177 -0
- package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
- package/packages/daemon-core/src/daemon-runtime.js +49 -0
- package/packages/daemon-core/src/ipc-server.js +180 -0
- package/packages/daemon-core/src/ipc-transport.js +70 -0
- package/packages/daemon-core/src/singleton.js +46 -0
- package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
- package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
- package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
- package/packages/daemon-core/test/singleton.test.mjs +32 -0
- package/packages/pet-core/src/animation-state.js +84 -0
- package/packages/pet-core/src/animator.js +26 -0
- package/packages/pet-core/src/atlas.js +81 -0
- package/packages/pet-core/src/discovery.js +90 -0
- package/packages/pet-core/src/manifest.js +112 -0
- package/packages/pet-core/src/validation.js +43 -0
- package/packages/pet-core/test/animation-state.test.mjs +47 -0
- package/packages/pet-core/test/animator.test.mjs +31 -0
- package/packages/pet-core/test/atlas.test.mjs +81 -0
- package/packages/pet-core/test/discovery.test.mjs +93 -0
- package/packages/pet-core/test/manifest.test.mjs +93 -0
- package/packages/pet-core/test/validation.test.mjs +69 -0
- package/packages/platform-core/src/capabilities.js +49 -0
- package/packages/platform-core/src/paths.js +75 -0
- package/packages/platform-core/src/platform.js +15 -0
- package/packages/platform-core/test/platform.test.mjs +84 -0
- package/packages/protocol/src/messages.js +156 -0
- package/packages/protocol/test/messages.test.mjs +112 -0
- package/packages/session-core/src/bubble-linger.js +47 -0
- package/packages/session-core/src/bubble-view.js +79 -0
- package/packages/session-core/src/pet-state.js +56 -0
- package/packages/session-core/src/priority.js +56 -0
- package/packages/session-core/src/registry.js +144 -0
- package/packages/session-core/src/summaries.js +54 -0
- package/packages/session-core/test/bubble-linger.test.mjs +96 -0
- package/packages/session-core/test/bubble-view.test.mjs +79 -0
- package/packages/session-core/test/pet-state.test.mjs +118 -0
- package/packages/session-core/test/priority.test.mjs +53 -0
- package/packages/session-core/test/registry.test.mjs +161 -0
- package/packages/session-core/test/summaries.test.mjs +38 -0
- package/packages/task-core/src/approvals.js +91 -0
- package/packages/task-core/src/controls.js +61 -0
- package/packages/task-core/src/replies.js +80 -0
- package/packages/task-core/src/task-events.js +101 -0
- package/packages/task-core/src/task-status.js +93 -0
- package/packages/task-core/src/task-store.js +74 -0
- package/packages/task-core/test/approvals.test.mjs +61 -0
- package/packages/task-core/test/controls.test.mjs +61 -0
- package/packages/task-core/test/replies.test.mjs +51 -0
- package/packages/task-core/test/task-events.test.mjs +67 -0
- package/packages/task-core/test/task-status.test.mjs +49 -0
- package/packages/task-core/test/task-store.test.mjs +65 -0
- package/test/harness.mjs +22 -0
- 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
|
+
}
|