@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,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { test } from "../../../test/harness.mjs";
|
|
4
|
+
import { writeProtocolMessage } from "../src/ipc-transport.js";
|
|
5
|
+
import { createDaemonRuntime } from "../src/daemon-runtime.js";
|
|
6
|
+
|
|
7
|
+
function registerMessage(sessionId) {
|
|
8
|
+
return {
|
|
9
|
+
type: "register",
|
|
10
|
+
sessionId,
|
|
11
|
+
clientId: "generic",
|
|
12
|
+
clientDisplayName: "Generic",
|
|
13
|
+
pid: 12345,
|
|
14
|
+
cwd: "D:\\Work\\project",
|
|
15
|
+
projectName: "project",
|
|
16
|
+
startedAt: 1000
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("applies protocol messages to the session registry", () => {
|
|
21
|
+
const changes = [];
|
|
22
|
+
const runtime = createDaemonRuntime({
|
|
23
|
+
onSessionChanged: (session) => changes.push(session)
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
runtime.handleMessage(registerMessage("sess_a"));
|
|
27
|
+
runtime.handleMessage({
|
|
28
|
+
type: "state",
|
|
29
|
+
sessionId: "sess_a",
|
|
30
|
+
state: "waiting_approval",
|
|
31
|
+
summary: "waiting for command approval",
|
|
32
|
+
confidence: 0.9,
|
|
33
|
+
source: "wrapper",
|
|
34
|
+
updatedAt: 1100
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
assert.equal(runtime.getSession("sess_a").state, "waiting_approval");
|
|
38
|
+
assert.equal(runtime.getPrioritySession().sessionId, "sess_a");
|
|
39
|
+
assert.deepEqual(changes.map((session) => session.state), ["idle", "waiting_approval"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("attaches stream input to the daemon runtime", () => {
|
|
43
|
+
const stream = new PassThrough();
|
|
44
|
+
const runtime = createDaemonRuntime();
|
|
45
|
+
|
|
46
|
+
runtime.attachStream(stream);
|
|
47
|
+
writeProtocolMessage(stream, registerMessage("sess_stream"));
|
|
48
|
+
|
|
49
|
+
assert.equal(runtime.getSession("sess_stream").clientDisplayName, "Generic");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reports transport errors without mutating the registry", () => {
|
|
53
|
+
const stream = new PassThrough();
|
|
54
|
+
const errors = [];
|
|
55
|
+
const runtime = createDaemonRuntime({
|
|
56
|
+
onProtocolError: (error) => errors.push(error)
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
runtime.attachStream(stream);
|
|
60
|
+
stream.write("{not-json}\n");
|
|
61
|
+
|
|
62
|
+
assert.equal(errors.length, 1);
|
|
63
|
+
assert.match(errors[0].message, /Invalid protocol JSON frame/);
|
|
64
|
+
assert.deepEqual(runtime.listSessions(), []);
|
|
65
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { createIpcClient, createIpcServer } from "../src/ipc-server.js";
|
|
4
|
+
|
|
5
|
+
test("server receives protocol messages from client", async () => {
|
|
6
|
+
const received = [];
|
|
7
|
+
const server = await createIpcServer({
|
|
8
|
+
endpoint: "test-haya-petd",
|
|
9
|
+
platform: "test",
|
|
10
|
+
onMessage: (message) => received.push(message)
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const client = await createIpcClient({ endpoint: server.endpoint });
|
|
14
|
+
await client.send({ type: "heartbeat", sessionId: "sess_a", updatedAt: 1 });
|
|
15
|
+
await waitFor(() => received.length === 1);
|
|
16
|
+
await client.close();
|
|
17
|
+
await server.close();
|
|
18
|
+
|
|
19
|
+
assert.deepEqual(received, [{ type: "heartbeat", sessionId: "sess_a", updatedAt: 1 }]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("server reports invalid client protocol frames", async () => {
|
|
23
|
+
const errors = [];
|
|
24
|
+
const server = await createIpcServer({
|
|
25
|
+
endpoint: "test-haya-petd",
|
|
26
|
+
platform: "test",
|
|
27
|
+
onMessage: () => {},
|
|
28
|
+
onProtocolError: (error) => errors.push(error)
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const client = await createIpcClient({ endpoint: server.endpoint });
|
|
32
|
+
client.socket.write("{not-json}\n");
|
|
33
|
+
await waitFor(() => errors.length === 1);
|
|
34
|
+
await client.close();
|
|
35
|
+
await server.close();
|
|
36
|
+
|
|
37
|
+
assert.match(errors[0].message, /Invalid protocol JSON frame/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("client rejects invalid protocol messages before writing", async () => {
|
|
41
|
+
const received = [];
|
|
42
|
+
const server = await createIpcServer({
|
|
43
|
+
endpoint: "test-haya-petd",
|
|
44
|
+
platform: "test",
|
|
45
|
+
onMessage: (message) => received.push(message)
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const client = await createIpcClient({ endpoint: server.endpoint });
|
|
49
|
+
|
|
50
|
+
await assert.rejects(
|
|
51
|
+
() => client.send({ type: "unknown", sessionId: "sess_a" }),
|
|
52
|
+
/Unknown protocol message type/
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
await client.close();
|
|
56
|
+
await server.close();
|
|
57
|
+
assert.deepEqual(received, []);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function waitFor(predicate) {
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
|
|
63
|
+
while (!predicate()) {
|
|
64
|
+
if (Date.now() - startedAt > 1000) {
|
|
65
|
+
throw new Error("Timed out waiting for condition");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { test } from "../../../test/harness.mjs";
|
|
4
|
+
import {
|
|
5
|
+
attachProtocolStream,
|
|
6
|
+
createProtocolMessageReader,
|
|
7
|
+
encodeProtocolMessage,
|
|
8
|
+
writeProtocolMessage
|
|
9
|
+
} from "../src/ipc-transport.js";
|
|
10
|
+
|
|
11
|
+
const heartbeat = {
|
|
12
|
+
type: "heartbeat",
|
|
13
|
+
sessionId: "sess_abc123",
|
|
14
|
+
updatedAt: 1780000010
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
test("encodes one validated protocol message per JSON line", () => {
|
|
18
|
+
assert.equal(encodeProtocolMessage(heartbeat), `${JSON.stringify(heartbeat)}\n`);
|
|
19
|
+
assert.throws(
|
|
20
|
+
() => encodeProtocolMessage({ type: "unknown", sessionId: "sess_abc123" }),
|
|
21
|
+
/Unknown protocol message type/
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("decodes complete and split protocol frames", () => {
|
|
26
|
+
const messages = [];
|
|
27
|
+
const errors = [];
|
|
28
|
+
const reader = createProtocolMessageReader({
|
|
29
|
+
onMessage: (message) => messages.push(message),
|
|
30
|
+
onError: (error) => errors.push(error)
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const encoded = encodeProtocolMessage(heartbeat);
|
|
34
|
+
reader.push(encoded.slice(0, 5));
|
|
35
|
+
reader.push(encoded.slice(5));
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(messages, [heartbeat]);
|
|
38
|
+
assert.deepEqual(errors, []);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("reports invalid JSON and invalid protocol frames without losing later messages", () => {
|
|
42
|
+
const messages = [];
|
|
43
|
+
const errors = [];
|
|
44
|
+
const reader = createProtocolMessageReader({
|
|
45
|
+
onMessage: (message) => messages.push(message),
|
|
46
|
+
onError: (error) => errors.push(error)
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
reader.push("{not-json}\n");
|
|
50
|
+
reader.push(`${JSON.stringify({ type: "state", sessionId: "", state: "busy" })}\n`);
|
|
51
|
+
reader.push(encodeProtocolMessage(heartbeat));
|
|
52
|
+
|
|
53
|
+
assert.deepEqual(messages, [heartbeat]);
|
|
54
|
+
assert.equal(errors.length, 2);
|
|
55
|
+
assert.match(errors[0].message, /Invalid protocol JSON frame/);
|
|
56
|
+
assert.match(errors[1].message, /sessionId must be a non-empty string/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("attaches protocol reader and writer to Node streams", async () => {
|
|
60
|
+
const input = new PassThrough();
|
|
61
|
+
const output = new PassThrough();
|
|
62
|
+
const messages = [];
|
|
63
|
+
|
|
64
|
+
attachProtocolStream(input, {
|
|
65
|
+
onMessage: (message) => messages.push(message)
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
writeProtocolMessage(output, heartbeat);
|
|
69
|
+
input.write(output.read());
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(messages, [heartbeat]);
|
|
72
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
parseLock,
|
|
5
|
+
serializeLock,
|
|
6
|
+
resolveSingletonAction
|
|
7
|
+
} from "../src/singleton.js";
|
|
8
|
+
|
|
9
|
+
test("round-trips a lock record", () => {
|
|
10
|
+
const lock = { pid: 1234, startedAt: 100, endpoint: "\\\\.\\pipe\\haya-petd" };
|
|
11
|
+
const parsed = parseLock(serializeLock(lock));
|
|
12
|
+
assert.deepEqual(parsed, lock);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("parses invalid lock content as undefined", () => {
|
|
16
|
+
assert.equal(parseLock("not json"), undefined);
|
|
17
|
+
assert.equal(parseLock(JSON.stringify({ startedAt: 1 })), undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("acquires when no lock is present", () => {
|
|
21
|
+
assert.equal(resolveSingletonAction({ lock: undefined, isAlive: () => true }), "acquire");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("forwards to a live daemon", () => {
|
|
25
|
+
const lock = { pid: 1234, startedAt: 1, endpoint: "x" };
|
|
26
|
+
assert.equal(resolveSingletonAction({ lock, isAlive: (pid) => pid === 1234 }), "forward");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("reclaims a stale lock when the process is dead", () => {
|
|
30
|
+
const lock = { pid: 1234, startedAt: 1, endpoint: "x" };
|
|
31
|
+
assert.equal(resolveSingletonAction({ lock, isAlive: () => false }), "reclaim");
|
|
32
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { isPetAction } from "./atlas.js";
|
|
2
|
+
|
|
3
|
+
const DRAG_ACTIONS = Object.freeze({
|
|
4
|
+
left: "running-left",
|
|
5
|
+
right: "running-right"
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
function assertPetAction(action) {
|
|
9
|
+
if (!isPetAction(action)) {
|
|
10
|
+
throw new Error(`Unknown pet action: ${action}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createAnimationState(stableAction = "idle") {
|
|
15
|
+
assertPetAction(stableAction);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
stableAction,
|
|
19
|
+
currentAction: stableAction
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setStableAction(state, stableAction) {
|
|
24
|
+
assertPetAction(stableAction);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
stableAction,
|
|
29
|
+
currentAction: state.dragAction ?? state.oneShotAction ?? stableAction
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function triggerOneShot(state, oneShotAction, now, durationMs) {
|
|
34
|
+
assertPetAction(oneShotAction);
|
|
35
|
+
|
|
36
|
+
if (!Number.isFinite(now)) {
|
|
37
|
+
throw new TypeError("now must be a finite timestamp");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
41
|
+
throw new RangeError("durationMs must be greater than 0");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
...state,
|
|
46
|
+
currentAction: oneShotAction,
|
|
47
|
+
oneShotAction,
|
|
48
|
+
oneShotStartedAt: now,
|
|
49
|
+
oneShotEndsAt: now + durationMs,
|
|
50
|
+
previousStableAction: state.stableAction
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setDragAction(state, direction) {
|
|
55
|
+
const dragAction = DRAG_ACTIONS[direction];
|
|
56
|
+
if (!dragAction) {
|
|
57
|
+
throw new Error(`Unknown drag direction: ${direction}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
...state,
|
|
62
|
+
currentAction: dragAction,
|
|
63
|
+
dragAction
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function clearDragAction(state) {
|
|
68
|
+
const next = { ...state };
|
|
69
|
+
delete next.dragAction;
|
|
70
|
+
next.currentAction = next.oneShotAction ?? next.stableAction;
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveCurrentAction(state, now) {
|
|
75
|
+
if (state.dragAction) {
|
|
76
|
+
return state.dragAction;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (state.oneShotAction && Number.isFinite(state.oneShotEndsAt) && now < state.oneShotEndsAt) {
|
|
80
|
+
return state.oneShotAction;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return state.stableAction;
|
|
84
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getFrameCount } from "./atlas.js";
|
|
2
|
+
import { DEFAULT_FRAME_DURATION_MS } from "./manifest.js";
|
|
3
|
+
|
|
4
|
+
export function getFrameAt(action, elapsedMs, manifest = {}) {
|
|
5
|
+
const frameCount = getFrameCount(action);
|
|
6
|
+
const duration = resolveDuration(action, manifest);
|
|
7
|
+
const elapsed = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
|
|
8
|
+
return Math.floor(elapsed / duration) % frameCount;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getActionDurationMs(action, manifest = {}) {
|
|
12
|
+
return getFrameCount(action) * resolveDuration(action, manifest);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveDuration(action, manifest) {
|
|
16
|
+
const override = manifest.actionFrameDurations?.[action];
|
|
17
|
+
if (Number.isFinite(override) && override > 0) {
|
|
18
|
+
return override;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (Number.isFinite(manifest.frameDurationMs) && manifest.frameDurationMs > 0) {
|
|
22
|
+
return manifest.frameDurationMs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return DEFAULT_FRAME_DURATION_MS;
|
|
26
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const CELL_WIDTH = 192;
|
|
2
|
+
export const CELL_HEIGHT = 208;
|
|
3
|
+
export const ATLAS_COLUMNS = 8;
|
|
4
|
+
export const ATLAS_ROWS = 9;
|
|
5
|
+
export const ATLAS_WIDTH = CELL_WIDTH * ATLAS_COLUMNS;
|
|
6
|
+
export const ATLAS_HEIGHT = CELL_HEIGHT * ATLAS_ROWS;
|
|
7
|
+
|
|
8
|
+
export const ACTION_ROWS = Object.freeze({
|
|
9
|
+
idle: 0,
|
|
10
|
+
"running-right": 1,
|
|
11
|
+
"running-left": 2,
|
|
12
|
+
waving: 3,
|
|
13
|
+
jumping: 4,
|
|
14
|
+
failed: 5,
|
|
15
|
+
waiting: 6,
|
|
16
|
+
running: 7,
|
|
17
|
+
review: 8
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const FRAME_COUNTS = Object.freeze({
|
|
21
|
+
idle: 6,
|
|
22
|
+
"running-right": 8,
|
|
23
|
+
"running-left": 8,
|
|
24
|
+
waving: 4,
|
|
25
|
+
jumping: 5,
|
|
26
|
+
failed: 8,
|
|
27
|
+
waiting: 6,
|
|
28
|
+
running: 6,
|
|
29
|
+
review: 6
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const AI_STATE_TO_PET_ACTION = Object.freeze({
|
|
33
|
+
idle: "idle",
|
|
34
|
+
thinking: "review",
|
|
35
|
+
running_tool: "running",
|
|
36
|
+
editing_files: "running",
|
|
37
|
+
waiting_user: "waiting",
|
|
38
|
+
waiting_approval: "waiting",
|
|
39
|
+
reviewing: "review",
|
|
40
|
+
compacting: "review",
|
|
41
|
+
failed: "failed",
|
|
42
|
+
success: "jumping",
|
|
43
|
+
stale: "waiting",
|
|
44
|
+
exited: "jumping"
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export function isPetAction(action) {
|
|
48
|
+
return Object.prototype.hasOwnProperty.call(ACTION_ROWS, action);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getFrameCount(action) {
|
|
52
|
+
if (!isPetAction(action)) {
|
|
53
|
+
throw new Error(`Unknown pet action: ${action}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return FRAME_COUNTS[action];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getFrameRect(action, frameIndex) {
|
|
60
|
+
const frameCount = getFrameCount(action);
|
|
61
|
+
|
|
62
|
+
if (!Number.isInteger(frameIndex) || frameIndex < 0 || frameIndex >= frameCount) {
|
|
63
|
+
throw new RangeError(`Frame index ${frameIndex} is out of range for ${action}; expected 0-${frameCount - 1}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
x: frameIndex * CELL_WIDTH,
|
|
68
|
+
y: ACTION_ROWS[action] * CELL_HEIGHT,
|
|
69
|
+
width: CELL_WIDTH,
|
|
70
|
+
height: CELL_HEIGHT
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function mapAiStateToPetAction(aiState) {
|
|
75
|
+
const action = AI_STATE_TO_PET_ACTION[aiState];
|
|
76
|
+
if (!action) {
|
|
77
|
+
throw new Error(`Unknown AI client state: ${aiState}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return action;
|
|
81
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readdir as fsReaddir, readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { parsePetManifest } from "./manifest.js";
|
|
5
|
+
|
|
6
|
+
// Scans pet search paths for Codex-compatible pets. Each pet is a directory
|
|
7
|
+
// containing pet.json plus a spritesheet file. Filesystem functions are
|
|
8
|
+
// injectable so discovery is testable without touching the real disk. Pets are
|
|
9
|
+
// deduplicated by manifest id (first search path wins).
|
|
10
|
+
export async function discoverPets(searchPaths = [], deps = {}) {
|
|
11
|
+
const { readdir } = resolveFs(deps);
|
|
12
|
+
const pets = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
|
|
15
|
+
for (const searchPath of searchPaths) {
|
|
16
|
+
const entries = await safeReaddir(readdir, searchPath);
|
|
17
|
+
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const petDir = join(searchPath, entry);
|
|
20
|
+
const pet = await loadPetFromDir(petDir, deps);
|
|
21
|
+
if (pet && !seen.has(pet.manifest.id)) {
|
|
22
|
+
seen.add(pet.manifest.id);
|
|
23
|
+
pets.push(pet);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return pets;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadPetFromDir(petDir, deps = {}) {
|
|
32
|
+
const { readFile, stat } = resolveFs(deps);
|
|
33
|
+
const manifestPath = join(petDir, "pet.json");
|
|
34
|
+
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = await readFile(manifestPath, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = parsePetManifest(parsed);
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const spritesheetPath = join(petDir, result.manifest.spritesheet);
|
|
55
|
+
if (!(await fileExists(stat, spritesheetPath))) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
dir: petDir,
|
|
61
|
+
manifest: result.manifest,
|
|
62
|
+
spritesheetPath,
|
|
63
|
+
spritesheetUrl: pathToFileURL(spritesheetPath).href
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveFs(deps) {
|
|
68
|
+
return {
|
|
69
|
+
readdir: deps.readdir ?? fsReaddir,
|
|
70
|
+
readFile: deps.readFile ?? fsReadFile,
|
|
71
|
+
stat: deps.stat ?? fsStat
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function safeReaddir(readdir, dir) {
|
|
76
|
+
try {
|
|
77
|
+
return await readdir(dir);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fileExists(stat, path) {
|
|
84
|
+
try {
|
|
85
|
+
await stat(path);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ACTION_ROWS, CELL_HEIGHT, CELL_WIDTH } from "./atlas.js";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_FRAME_DURATION_MS = 120;
|
|
4
|
+
|
|
5
|
+
export function parsePetManifest(input) {
|
|
6
|
+
if (!isPlainObject(input)) {
|
|
7
|
+
return { ok: false, errors: ["manifest must be an object"] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const errors = [];
|
|
11
|
+
|
|
12
|
+
// Accept both this runtime's canonical fields and the Codex Desktop Pet
|
|
13
|
+
// field names (displayName / spritesheetPath). Canonical wins when both exist.
|
|
14
|
+
const name = input.name ?? input.displayName;
|
|
15
|
+
const spritesheet = input.spritesheet ?? input.spritesheetPath;
|
|
16
|
+
|
|
17
|
+
requireNonEmptyString(errors, input.id, "id");
|
|
18
|
+
requireNonEmptyString(errors, name, "name");
|
|
19
|
+
requireNonEmptyString(errors, spritesheet, "spritesheet");
|
|
20
|
+
|
|
21
|
+
const cellWidth = input.cellWidth ?? CELL_WIDTH;
|
|
22
|
+
const cellHeight = input.cellHeight ?? CELL_HEIGHT;
|
|
23
|
+
|
|
24
|
+
if (cellWidth !== CELL_WIDTH) {
|
|
25
|
+
errors.push(`cellWidth must be ${CELL_WIDTH}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (cellHeight !== CELL_HEIGHT) {
|
|
29
|
+
errors.push(`cellHeight must be ${CELL_HEIGHT}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const frameDurationMs = resolveDefaultDuration(errors, input.frameDurationMs);
|
|
33
|
+
const actionFrameDurations = resolveActionDurations(errors, input.actionFrameDurations);
|
|
34
|
+
|
|
35
|
+
if (errors.length > 0) {
|
|
36
|
+
return { ok: false, errors };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
errors: [],
|
|
42
|
+
manifest: {
|
|
43
|
+
id: input.id,
|
|
44
|
+
name,
|
|
45
|
+
spritesheet,
|
|
46
|
+
cellWidth,
|
|
47
|
+
cellHeight,
|
|
48
|
+
frameDurationMs,
|
|
49
|
+
actionFrameDurations
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getFrameDurationMs(manifest, action) {
|
|
55
|
+
if (!Object.prototype.hasOwnProperty.call(ACTION_ROWS, action)) {
|
|
56
|
+
throw new Error(`Unknown pet action: ${action}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const override = manifest.actionFrameDurations?.[action];
|
|
60
|
+
return Number.isFinite(override) ? override : manifest.frameDurationMs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveDefaultDuration(errors, value) {
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
return DEFAULT_FRAME_DURATION_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
69
|
+
errors.push("frameDurationMs must be a positive number");
|
|
70
|
+
return DEFAULT_FRAME_DURATION_MS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveActionDurations(errors, value) {
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!isPlainObject(value)) {
|
|
82
|
+
errors.push("actionFrameDurations must be an object");
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const resolved = {};
|
|
87
|
+
for (const [action, duration] of Object.entries(value)) {
|
|
88
|
+
if (!Object.prototype.hasOwnProperty.call(ACTION_ROWS, action)) {
|
|
89
|
+
errors.push(`actionFrameDurations contains unknown action: ${action}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
94
|
+
errors.push(`actionFrameDurations.${action} must be a positive number`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
resolved[action] = duration;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function requireNonEmptyString(errors, value, fieldName) {
|
|
105
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
106
|
+
errors.push(`${fieldName} must be a non-empty string`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isPlainObject(value) {
|
|
111
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ACTION_ROWS, ATLAS_HEIGHT, ATLAS_WIDTH } from "./atlas.js";
|
|
2
|
+
import { parsePetManifest } from "./manifest.js";
|
|
3
|
+
|
|
4
|
+
export function validateAtlasDimensions(dimensions) {
|
|
5
|
+
const errors = [];
|
|
6
|
+
|
|
7
|
+
if (!dimensions || dimensions.width !== ATLAS_WIDTH || dimensions.height !== ATLAS_HEIGHT) {
|
|
8
|
+
errors.push(
|
|
9
|
+
`spritesheet must be ${ATLAS_WIDTH}x${ATLAS_HEIGHT}, received ${dimensions?.width}x${dimensions?.height}`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return { ok: errors.length === 0, errors };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validatePetActions(actionFrameCounts) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
const counts = actionFrameCounts ?? {};
|
|
19
|
+
|
|
20
|
+
for (const action of Object.keys(ACTION_ROWS)) {
|
|
21
|
+
const frameCount = counts[action];
|
|
22
|
+
|
|
23
|
+
if (!Number.isInteger(frameCount) || frameCount <= 0) {
|
|
24
|
+
errors.push(`action "${action}" must have at least one used cell`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { ok: errors.length === 0, errors };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validatePet({ manifest, dimensions, actionFrameCounts } = {}) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
|
|
34
|
+
const manifestResult = parsePetManifest(manifest);
|
|
35
|
+
if (!manifestResult.ok) {
|
|
36
|
+
errors.push(...manifestResult.errors);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
errors.push(...validateAtlasDimensions(dimensions).errors);
|
|
40
|
+
errors.push(...validatePetActions(actionFrameCounts).errors);
|
|
41
|
+
|
|
42
|
+
return { ok: errors.length === 0, errors };
|
|
43
|
+
}
|