@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,98 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { ensureCompanionConnection } from "../src/companion-launcher.js";
|
|
4
|
+
|
|
5
|
+
const noSleep = async () => {};
|
|
6
|
+
|
|
7
|
+
test("connects without launching when the companion is already running", async () => {
|
|
8
|
+
let launched = 0;
|
|
9
|
+
const client = { id: "existing" };
|
|
10
|
+
const result = await ensureCompanionConnection({
|
|
11
|
+
connect: async () => client,
|
|
12
|
+
launch: async () => { launched += 1; },
|
|
13
|
+
sleep: noSleep
|
|
14
|
+
});
|
|
15
|
+
assert.equal(result.client, client);
|
|
16
|
+
assert.equal(result.started, false);
|
|
17
|
+
assert.equal(launched, 0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("launches the companion then connects after it comes up", async () => {
|
|
21
|
+
let launched = 0;
|
|
22
|
+
let attempts = 0;
|
|
23
|
+
const client = { id: "fresh" };
|
|
24
|
+
const result = await ensureCompanionConnection({
|
|
25
|
+
// First check fails (not running); after launch, the 3rd connect succeeds.
|
|
26
|
+
connect: async () => {
|
|
27
|
+
attempts += 1;
|
|
28
|
+
if (attempts <= 3) {
|
|
29
|
+
throw new Error("ECONNREFUSED");
|
|
30
|
+
}
|
|
31
|
+
return client;
|
|
32
|
+
},
|
|
33
|
+
launch: async () => { launched += 1; },
|
|
34
|
+
attempts: 10,
|
|
35
|
+
intervalMs: 1,
|
|
36
|
+
sleep: noSleep
|
|
37
|
+
});
|
|
38
|
+
assert.equal(result.client, client);
|
|
39
|
+
assert.equal(result.started, true);
|
|
40
|
+
assert.equal(launched, 1, "launches exactly once");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("does not launch when autoStart is disabled", async () => {
|
|
44
|
+
let launched = 0;
|
|
45
|
+
const result = await ensureCompanionConnection({
|
|
46
|
+
connect: async () => { throw new Error("no daemon"); },
|
|
47
|
+
launch: async () => { launched += 1; },
|
|
48
|
+
autoStart: false,
|
|
49
|
+
sleep: noSleep
|
|
50
|
+
});
|
|
51
|
+
assert.equal(result.client, null);
|
|
52
|
+
assert.equal(result.started, false);
|
|
53
|
+
assert.equal(launched, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("times out gracefully when the companion never comes up", async () => {
|
|
57
|
+
let launched = 0;
|
|
58
|
+
const result = await ensureCompanionConnection({
|
|
59
|
+
connect: async () => { throw new Error("never listening"); },
|
|
60
|
+
launch: async () => { launched += 1; },
|
|
61
|
+
attempts: 4,
|
|
62
|
+
intervalMs: 1,
|
|
63
|
+
sleep: noSleep
|
|
64
|
+
});
|
|
65
|
+
assert.equal(result.client, null);
|
|
66
|
+
assert.equal(result.started, false);
|
|
67
|
+
assert.equal(result.timedOut, true);
|
|
68
|
+
assert.equal(launched, 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("degrades gracefully when launching throws (e.g. electron missing)", async () => {
|
|
72
|
+
const result = await ensureCompanionConnection({
|
|
73
|
+
connect: async () => { throw new Error("no daemon"); },
|
|
74
|
+
launch: async () => { throw new Error("electron not installed"); },
|
|
75
|
+
sleep: noSleep
|
|
76
|
+
});
|
|
77
|
+
assert.equal(result.client, null);
|
|
78
|
+
assert.equal(result.started, false);
|
|
79
|
+
assert.ok(result.error instanceof Error);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("stops polling as soon as a connection succeeds", async () => {
|
|
83
|
+
let connectCalls = 0;
|
|
84
|
+
const client = { id: "x" };
|
|
85
|
+
await ensureCompanionConnection({
|
|
86
|
+
connect: async () => {
|
|
87
|
+
connectCalls += 1;
|
|
88
|
+
if (connectCalls === 1) throw new Error("not yet"); // initial check
|
|
89
|
+
if (connectCalls === 2) return client; // first poll succeeds
|
|
90
|
+
throw new Error("should not poll again");
|
|
91
|
+
},
|
|
92
|
+
launch: async () => {},
|
|
93
|
+
attempts: 10,
|
|
94
|
+
intervalMs: 1,
|
|
95
|
+
sleep: noSleep
|
|
96
|
+
});
|
|
97
|
+
assert.equal(connectCalls, 2);
|
|
98
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { test } from "../../../test/harness.mjs";
|
|
6
|
+
import { runGenericCommand } from "../src/run-command.js";
|
|
7
|
+
|
|
8
|
+
function createClock(start = 1000, step = 10) {
|
|
9
|
+
let current = start;
|
|
10
|
+
return () => {
|
|
11
|
+
const value = current;
|
|
12
|
+
current += step;
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("emits generic command lifecycle messages and preserves exit code", async () => {
|
|
18
|
+
const messages = [];
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
|
|
21
|
+
const result = await runGenericCommand({
|
|
22
|
+
command: process.execPath,
|
|
23
|
+
args: ["-e", "setTimeout(() => process.exit(7), 40)"],
|
|
24
|
+
cwd,
|
|
25
|
+
clientId: "generic",
|
|
26
|
+
clientDisplayName: "Generic",
|
|
27
|
+
sessionId: "sess_test",
|
|
28
|
+
heartbeatIntervalMs: 5,
|
|
29
|
+
now: createClock(),
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
send: async (message) => {
|
|
32
|
+
messages.push(message);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
assert.equal(result.exitCode, 7);
|
|
37
|
+
assert.equal(result.sessionId, "sess_test");
|
|
38
|
+
|
|
39
|
+
assert.equal(messages[0].type, "register");
|
|
40
|
+
assert.equal(messages[0].sessionId, "sess_test");
|
|
41
|
+
assert.equal(messages[0].clientId, "generic");
|
|
42
|
+
assert.equal(messages[0].clientDisplayName, "Generic");
|
|
43
|
+
assert.equal(messages[0].cwd, cwd);
|
|
44
|
+
assert.equal(messages[0].projectName, basename(cwd));
|
|
45
|
+
assert.equal(messages[0].pid, result.pid);
|
|
46
|
+
|
|
47
|
+
const stateMessage = messages.find((message) => message.type === "state");
|
|
48
|
+
assert.equal(stateMessage.state, "idle");
|
|
49
|
+
assert.equal(stateMessage.source, "wrapper");
|
|
50
|
+
assert.equal(stateMessage.summary, "session started");
|
|
51
|
+
|
|
52
|
+
assert.ok(messages.some((message) => message.type === "heartbeat"));
|
|
53
|
+
|
|
54
|
+
const unregister = messages[messages.length - 1];
|
|
55
|
+
assert.equal(unregister.type, "unregister");
|
|
56
|
+
assert.equal(unregister.sessionId, "sess_test");
|
|
57
|
+
assert.equal(unregister.exitCode, 7);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("launches a Windows .cmd shim via the shell and reports a valid pid", async () => {
|
|
61
|
+
if (process.platform !== "win32") {
|
|
62
|
+
return; // shell resolution of .cmd shims is Windows-specific
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), "haya-pet-cmd-"));
|
|
66
|
+
const cmdPath = join(dir, "fake-cli.cmd");
|
|
67
|
+
writeFileSync(cmdPath, "@echo off\r\nexit /b 5\r\n");
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const messages = [];
|
|
71
|
+
const result = await runGenericCommand({
|
|
72
|
+
command: cmdPath,
|
|
73
|
+
args: [],
|
|
74
|
+
cwd: process.cwd(),
|
|
75
|
+
sessionId: "sess_cmd",
|
|
76
|
+
heartbeatIntervalMs: 50,
|
|
77
|
+
now: createClock(),
|
|
78
|
+
stdio: "ignore",
|
|
79
|
+
send: async (message) => messages.push(message)
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.equal(result.exitCode, 5);
|
|
83
|
+
const register = messages.find((message) => message.type === "register");
|
|
84
|
+
assert.ok(register, "should send a register message");
|
|
85
|
+
assert.ok(Number.isInteger(register.pid) && register.pid > 0, "pid must be a positive integer");
|
|
86
|
+
} finally {
|
|
87
|
+
rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("observe mode infers state from PTY output via the observer", async () => {
|
|
92
|
+
const messages = [];
|
|
93
|
+
const ESC = String.fromCharCode(27);
|
|
94
|
+
|
|
95
|
+
const result = await runGenericCommand({
|
|
96
|
+
command: "claude",
|
|
97
|
+
args: [],
|
|
98
|
+
cwd: process.cwd(),
|
|
99
|
+
clientId: "claude-code",
|
|
100
|
+
clientDisplayName: "Claude Code",
|
|
101
|
+
sessionId: "sess_obs",
|
|
102
|
+
heartbeatIntervalMs: 1000,
|
|
103
|
+
now: createClock(),
|
|
104
|
+
observe: true,
|
|
105
|
+
send: async (message) => messages.push(message),
|
|
106
|
+
// Injected PTY: emits a tool-use line (with ANSI noise), then exits 0.
|
|
107
|
+
spawnPty: async ({ onData }) => {
|
|
108
|
+
onData(`${ESC}[mRunning tests${ESC}[0m\r\n`);
|
|
109
|
+
return {
|
|
110
|
+
pid: 4321,
|
|
111
|
+
exit: new Promise((resolve) => setTimeout(() => resolve({ exitCode: 0 }), 10)),
|
|
112
|
+
write() {},
|
|
113
|
+
kill() {}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
assert.equal(result.exitCode, 0);
|
|
119
|
+
assert.equal(result.pid, 4321);
|
|
120
|
+
|
|
121
|
+
const register = messages.find((m) => m.type === "register");
|
|
122
|
+
assert.equal(register.pid, 4321);
|
|
123
|
+
|
|
124
|
+
const inferred = messages.find((m) => m.type === "state" && m.source === "pty_output");
|
|
125
|
+
assert.ok(inferred, "should emit a state inferred from PTY output");
|
|
126
|
+
assert.equal(inferred.state, "running_tool");
|
|
127
|
+
|
|
128
|
+
assert.equal(messages.at(-1).type, "unregister");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("maps successful generic command exit to a success state before unregister", async () => {
|
|
132
|
+
const messages = [];
|
|
133
|
+
|
|
134
|
+
const result = await runGenericCommand({
|
|
135
|
+
command: process.execPath,
|
|
136
|
+
args: ["-e", "process.exit(0)"],
|
|
137
|
+
cwd: process.cwd(),
|
|
138
|
+
sessionId: "sess_success",
|
|
139
|
+
heartbeatIntervalMs: 50,
|
|
140
|
+
now: createClock(),
|
|
141
|
+
stdio: "ignore",
|
|
142
|
+
send: async (message) => {
|
|
143
|
+
messages.push(message);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
assert.equal(result.exitCode, 0);
|
|
148
|
+
|
|
149
|
+
const finalState = messages.filter((message) => message.type === "state").at(-1);
|
|
150
|
+
assert.equal(finalState.state, "success");
|
|
151
|
+
assert.equal(finalState.summary, "process exited successfully");
|
|
152
|
+
assert.equal(messages.at(-1).type, "unregister");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("maps failed generic command exit to a failed state before unregister", async () => {
|
|
156
|
+
const messages = [];
|
|
157
|
+
|
|
158
|
+
const result = await runGenericCommand({
|
|
159
|
+
command: process.execPath,
|
|
160
|
+
args: ["-e", "process.exit(3)"],
|
|
161
|
+
cwd: process.cwd(),
|
|
162
|
+
sessionId: "sess_failed",
|
|
163
|
+
heartbeatIntervalMs: 50,
|
|
164
|
+
now: createClock(),
|
|
165
|
+
stdio: "ignore",
|
|
166
|
+
send: async (message) => {
|
|
167
|
+
messages.push(message);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
assert.equal(result.exitCode, 3);
|
|
172
|
+
|
|
173
|
+
const finalState = messages.filter((message) => message.type === "state").at(-1);
|
|
174
|
+
assert.equal(finalState.state, "failed");
|
|
175
|
+
assert.equal(finalState.summary, "process exited with code 3");
|
|
176
|
+
assert.equal(messages.at(-1).exitCode, 3);
|
|
177
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { stripAnsi } from "../src/strip-ansi.js";
|
|
4
|
+
|
|
5
|
+
const ESC = String.fromCharCode(27); //
|
|
6
|
+
const BEL = String.fromCharCode(7); //
|
|
7
|
+
|
|
8
|
+
test("strips CSI cursor/clear sequences", () => {
|
|
9
|
+
assert.equal(stripAnsi(`${ESC}[2J${ESC}[H${ESC}[mrunning tests\r\n`), "running tests\r\n");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("strips OSC title sequences", () => {
|
|
13
|
+
assert.equal(stripAnsi(`${ESC}]0;C:\\cmd.exe${BEL}plain text`), "plain text");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("strips color codes but keeps the words", () => {
|
|
17
|
+
assert.equal(stripAnsi(`${ESC}[31mError:${ESC}[0m boom`), "Error: boom");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("leaves plain text untouched", () => {
|
|
21
|
+
assert.equal(stripAnsi("waiting for approval"), "waiting for approval");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("handles real ConPTY-style output", () => {
|
|
25
|
+
const raw = `${ESC}[?25l${ESC}[2J${ESC}[mApplying patch to src/index.js\r\n${ESC}]0;cmd${BEL}${ESC}[?25h`;
|
|
26
|
+
assert.ok(stripAnsi(raw).includes("Applying patch to src/index.js"));
|
|
27
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createSessionRegistry } from "../../session-core/src/registry.js";
|
|
2
|
+
import { attachProtocolStream } from "./ipc-transport.js";
|
|
3
|
+
|
|
4
|
+
export function createDaemonRuntime(options = {}) {
|
|
5
|
+
const registry = options.registry ?? createSessionRegistry(options.registryOptions);
|
|
6
|
+
const onSessionChanged = options.onSessionChanged ?? noop;
|
|
7
|
+
const onProtocolError = options.onProtocolError ?? noop;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
registry,
|
|
11
|
+
|
|
12
|
+
handleMessage(message) {
|
|
13
|
+
const session = registry.applyMessage(message);
|
|
14
|
+
onSessionChanged(session);
|
|
15
|
+
return session;
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
attachStream(stream) {
|
|
19
|
+
return attachProtocolStream(stream, {
|
|
20
|
+
onMessage: (message) => {
|
|
21
|
+
this.handleMessage(message);
|
|
22
|
+
},
|
|
23
|
+
onError: onProtocolError
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
getSession(sessionId) {
|
|
28
|
+
return registry.getSession(sessionId);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
listSessions() {
|
|
32
|
+
return registry.listSessions();
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
getPrioritySession(priorityOptions = {}) {
|
|
36
|
+
return registry.getPrioritySession(priorityOptions);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
markStaleSessions(now) {
|
|
40
|
+
const staleSessions = registry.markStaleSessions(now);
|
|
41
|
+
for (const session of staleSessions) {
|
|
42
|
+
onSessionChanged(session);
|
|
43
|
+
}
|
|
44
|
+
return staleSessions;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function noop() {}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { attachProtocolStream, writeProtocolMessage } from "./ipc-transport.js";
|
|
5
|
+
|
|
6
|
+
export async function createIpcServer(options = {}) {
|
|
7
|
+
const {
|
|
8
|
+
endpoint,
|
|
9
|
+
platform = process.platform,
|
|
10
|
+
onMessage,
|
|
11
|
+
onProtocolError = noop
|
|
12
|
+
} = options;
|
|
13
|
+
|
|
14
|
+
if (typeof onMessage !== "function") {
|
|
15
|
+
throw new TypeError("onMessage must be a function");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const sockets = new Set();
|
|
19
|
+
const server = net.createServer((socket) => {
|
|
20
|
+
sockets.add(socket);
|
|
21
|
+
socket.on("close", () => sockets.delete(socket));
|
|
22
|
+
attachProtocolStream(socket, {
|
|
23
|
+
onMessage,
|
|
24
|
+
onError: onProtocolError
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const listenOptions = await resolveListenOptions(endpoint, platform);
|
|
29
|
+
await listen(server, listenOptions);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
endpoint: getServerEndpoint(server, listenOptions),
|
|
33
|
+
server,
|
|
34
|
+
|
|
35
|
+
async close() {
|
|
36
|
+
for (const socket of sockets) {
|
|
37
|
+
socket.destroy();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await closeServer(server);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function createIpcClient(options = {}) {
|
|
46
|
+
const { endpoint } = options;
|
|
47
|
+
const socket = net.createConnection(toConnectionOptions(endpoint));
|
|
48
|
+
await waitForConnect(socket);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
socket,
|
|
52
|
+
|
|
53
|
+
async send(message) {
|
|
54
|
+
if (!writeProtocolMessage(socket, message)) {
|
|
55
|
+
await once(socket, "drain");
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async close() {
|
|
60
|
+
if (socket.destroyed) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
socket.end();
|
|
65
|
+
await once(socket, "close");
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveListenOptions(endpoint, platform) {
|
|
71
|
+
if (platform === "test") {
|
|
72
|
+
return {
|
|
73
|
+
type: "tcp",
|
|
74
|
+
host: "127.0.0.1",
|
|
75
|
+
port: 0,
|
|
76
|
+
name: endpoint
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof endpoint !== "string" || endpoint.trim() === "") {
|
|
81
|
+
throw new Error("endpoint must be a non-empty string");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!endpoint.startsWith("\\\\.\\")) {
|
|
85
|
+
await mkdir(dirname(endpoint), { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
type: "path",
|
|
90
|
+
path: endpoint
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getServerEndpoint(server, listenOptions) {
|
|
95
|
+
if (listenOptions.type === "tcp") {
|
|
96
|
+
const address = server.address();
|
|
97
|
+
return {
|
|
98
|
+
type: "tcp",
|
|
99
|
+
host: address.address,
|
|
100
|
+
port: address.port
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return listenOptions.path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toConnectionOptions(endpoint) {
|
|
108
|
+
if (typeof endpoint === "string") {
|
|
109
|
+
return { path: endpoint };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (endpoint?.type === "tcp") {
|
|
113
|
+
return {
|
|
114
|
+
host: endpoint.host,
|
|
115
|
+
port: endpoint.port
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (Number.isInteger(endpoint?.port)) {
|
|
120
|
+
return endpoint;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new Error("Unsupported IPC endpoint");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function listen(server, listenOptions) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
server.once("error", reject);
|
|
129
|
+
server.listen(toServerListenOptions(listenOptions), () => {
|
|
130
|
+
server.off("error", reject);
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toServerListenOptions(listenOptions) {
|
|
137
|
+
if (listenOptions.type === "tcp") {
|
|
138
|
+
return {
|
|
139
|
+
host: listenOptions.host,
|
|
140
|
+
port: listenOptions.port
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return listenOptions.path;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function waitForConnect(socket) {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
socket.once("error", reject);
|
|
150
|
+
socket.once("connect", () => {
|
|
151
|
+
socket.off("error", reject);
|
|
152
|
+
resolve();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function closeServer(server) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
if (!server.listening) {
|
|
160
|
+
resolve();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
server.close((error) => {
|
|
165
|
+
if (error) {
|
|
166
|
+
reject(error);
|
|
167
|
+
} else {
|
|
168
|
+
resolve();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function once(emitter, eventName) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
emitter.once(eventName, resolve);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function noop() {}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { assertProtocolMessage } from "../../protocol/src/messages.js";
|
|
2
|
+
|
|
3
|
+
export function encodeProtocolMessage(message) {
|
|
4
|
+
assertProtocolMessage(message);
|
|
5
|
+
return `${JSON.stringify(message)}\n`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function writeProtocolMessage(writable, message) {
|
|
9
|
+
return writable.write(encodeProtocolMessage(message));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createProtocolMessageReader({ onMessage, onError = defaultErrorHandler } = {}) {
|
|
13
|
+
if (typeof onMessage !== "function") {
|
|
14
|
+
throw new TypeError("onMessage must be a function");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let buffer = "";
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
push(chunk) {
|
|
21
|
+
buffer += chunk.toString("utf8");
|
|
22
|
+
|
|
23
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
24
|
+
while (newlineIndex !== -1) {
|
|
25
|
+
const line = buffer.slice(0, newlineIndex);
|
|
26
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
27
|
+
readFrame(line, onMessage, onError);
|
|
28
|
+
newlineIndex = buffer.indexOf("\n");
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
flush() {
|
|
33
|
+
if (buffer.trim() !== "") {
|
|
34
|
+
readFrame(buffer, onMessage, onError);
|
|
35
|
+
}
|
|
36
|
+
buffer = "";
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function attachProtocolStream(readable, options) {
|
|
42
|
+
const reader = createProtocolMessageReader(options);
|
|
43
|
+
readable.on("data", (chunk) => reader.push(chunk));
|
|
44
|
+
readable.on("end", () => reader.flush());
|
|
45
|
+
return reader;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readFrame(line, onMessage, onError) {
|
|
49
|
+
if (line.trim() === "") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let message;
|
|
54
|
+
try {
|
|
55
|
+
message = JSON.parse(line);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
onError(new Error(`Invalid protocol JSON frame: ${error.message}`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
onMessage(assertProtocolMessage(message));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
onError(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultErrorHandler(error) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Daemon singleton enforcement helpers (product plan section 39). The pure
|
|
2
|
+
// decision logic here lets the Electron main process and tests share the same
|
|
3
|
+
// stale-lock detection without touching the filesystem.
|
|
4
|
+
|
|
5
|
+
export function serializeLock({ pid, startedAt, endpoint }) {
|
|
6
|
+
return `${JSON.stringify({ pid, startedAt, endpoint })}\n`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseLock(text) {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(text);
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!isPlainObject(parsed)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!Number.isFinite(parsed.startedAt) || typeof parsed.endpoint !== "string") {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { pid: parsed.pid, startedAt: parsed.startedAt, endpoint: parsed.endpoint };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveSingletonAction({ lock, isAlive }) {
|
|
33
|
+
if (typeof isAlive !== "function") {
|
|
34
|
+
throw new TypeError("isAlive must be a function");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!lock) {
|
|
38
|
+
return "acquire";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return isAlive(lock.pid) ? "forward" : "reclaim";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPlainObject(value) {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|