@hayasaka7/haya-pet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.gitattributes +34 -0
  2. package/.github/workflows/release.yml +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +247 -0
  5. package/apps/cli/src/haya-pet.js +395 -0
  6. package/apps/cli/test/haya-pet.test.mjs +339 -0
  7. package/apps/companion/README.md +83 -0
  8. package/apps/companion/package.json +17 -0
  9. package/apps/companion/src/main/display-manager.js +71 -0
  10. package/apps/companion/src/main/index.js +349 -0
  11. package/apps/companion/src/main/lock-file.js +52 -0
  12. package/apps/companion/src/main/panel-placement.js +45 -0
  13. package/apps/companion/src/main/pet-loader.js +2 -0
  14. package/apps/companion/src/main/position-store.js +3 -0
  15. package/apps/companion/src/main/preload.cjs +13 -0
  16. package/apps/companion/src/main/state-file.js +2 -0
  17. package/apps/companion/src/main/terminal-helper-client.js +79 -0
  18. package/apps/companion/src/main/terminal-locator.js +44 -0
  19. package/apps/companion/src/main/tray-menu.js +79 -0
  20. package/apps/companion/src/main/window-options.js +66 -0
  21. package/apps/companion/src/renderer/index.html +18 -0
  22. package/apps/companion/src/renderer/interaction-controller.js +114 -0
  23. package/apps/companion/src/renderer/pet-window.js +275 -0
  24. package/apps/companion/src/renderer/session-bubbles.js +138 -0
  25. package/apps/companion/src/renderer/styles.css +225 -0
  26. package/apps/companion/src/renderer/task-talk-window.js +141 -0
  27. package/apps/companion/test/display-manager.test.mjs +48 -0
  28. package/apps/companion/test/interaction-controller.test.mjs +107 -0
  29. package/apps/companion/test/panel-placement.test.mjs +60 -0
  30. package/apps/companion/test/position-store.test.mjs +54 -0
  31. package/apps/companion/test/state-file.test.mjs +52 -0
  32. package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
  33. package/apps/companion/test/terminal-locator.test.mjs +35 -0
  34. package/apps/companion/test/tray-menu.test.mjs +45 -0
  35. package/apps/companion/test/window-options.test.mjs +62 -0
  36. package/apps/pet-preview/index.html +42 -0
  37. package/apps/pet-preview/src/preview-app.js +123 -0
  38. package/apps/pet-preview/src/preview-state.js +70 -0
  39. package/apps/pet-preview/src/preview.css +125 -0
  40. package/apps/pet-preview/test/preview-state.test.mjs +62 -0
  41. package/assets/fallback-pet/README.md +16 -0
  42. package/assets/fallback-pet/pet.json +13 -0
  43. package/docs/architecture.md +144 -0
  44. package/docs/known-issues.md +49 -0
  45. package/docs/publishing.md +48 -0
  46. package/docs/screenshots/README.md +7 -0
  47. package/docs/screenshots/folder-collapsed.png +0 -0
  48. package/docs/screenshots/hero.png +0 -0
  49. package/docs/screenshots/pet-overlay.png +0 -0
  50. package/docs/screenshots/session-bubbles.png +0 -0
  51. package/docs/screenshots/tray-menu.png +0 -0
  52. package/docs/troubleshooting.md +36 -0
  53. package/native/README.md +80 -0
  54. package/native/linux-window-helper/README.md +29 -0
  55. package/native/mac-window-helper/README.md +30 -0
  56. package/native/win-window-helper/Program.cs +312 -0
  57. package/native/win-window-helper/README.md +53 -0
  58. package/native/win-window-helper/win-window-helper.csproj +12 -0
  59. package/package.json +35 -0
  60. package/packages/adapters/src/adapter-info.js +61 -0
  61. package/packages/adapters/src/capabilities.js +39 -0
  62. package/packages/adapters/src/heuristics.js +114 -0
  63. package/packages/adapters/src/output-observer.js +164 -0
  64. package/packages/adapters/src/routing.js +86 -0
  65. package/packages/adapters/test/adapter-info.test.mjs +35 -0
  66. package/packages/adapters/test/capabilities.test.mjs +44 -0
  67. package/packages/adapters/test/heuristics.test.mjs +42 -0
  68. package/packages/adapters/test/output-observer.test.mjs +142 -0
  69. package/packages/adapters/test/routing.test.mjs +93 -0
  70. package/packages/app-state/src/state-file.js +53 -0
  71. package/packages/app-state/src/state.js +80 -0
  72. package/packages/app-state/test/state.test.mjs +36 -0
  73. package/packages/cli-core/src/companion-launcher.js +69 -0
  74. package/packages/cli-core/src/pty-runner.js +96 -0
  75. package/packages/cli-core/src/run-command.js +353 -0
  76. package/packages/cli-core/src/strip-ansi.js +16 -0
  77. package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
  78. package/packages/cli-core/test/run-command.test.mjs +177 -0
  79. package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
  80. package/packages/daemon-core/src/daemon-runtime.js +49 -0
  81. package/packages/daemon-core/src/ipc-server.js +180 -0
  82. package/packages/daemon-core/src/ipc-transport.js +70 -0
  83. package/packages/daemon-core/src/singleton.js +46 -0
  84. package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
  85. package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
  86. package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
  87. package/packages/daemon-core/test/singleton.test.mjs +32 -0
  88. package/packages/pet-core/src/animation-state.js +84 -0
  89. package/packages/pet-core/src/animator.js +26 -0
  90. package/packages/pet-core/src/atlas.js +81 -0
  91. package/packages/pet-core/src/discovery.js +90 -0
  92. package/packages/pet-core/src/manifest.js +112 -0
  93. package/packages/pet-core/src/validation.js +43 -0
  94. package/packages/pet-core/test/animation-state.test.mjs +47 -0
  95. package/packages/pet-core/test/animator.test.mjs +31 -0
  96. package/packages/pet-core/test/atlas.test.mjs +81 -0
  97. package/packages/pet-core/test/discovery.test.mjs +93 -0
  98. package/packages/pet-core/test/manifest.test.mjs +93 -0
  99. package/packages/pet-core/test/validation.test.mjs +69 -0
  100. package/packages/platform-core/src/capabilities.js +49 -0
  101. package/packages/platform-core/src/paths.js +75 -0
  102. package/packages/platform-core/src/platform.js +15 -0
  103. package/packages/platform-core/test/platform.test.mjs +84 -0
  104. package/packages/protocol/src/messages.js +156 -0
  105. package/packages/protocol/test/messages.test.mjs +112 -0
  106. package/packages/session-core/src/bubble-linger.js +47 -0
  107. package/packages/session-core/src/bubble-view.js +79 -0
  108. package/packages/session-core/src/pet-state.js +56 -0
  109. package/packages/session-core/src/priority.js +56 -0
  110. package/packages/session-core/src/registry.js +144 -0
  111. package/packages/session-core/src/summaries.js +54 -0
  112. package/packages/session-core/test/bubble-linger.test.mjs +96 -0
  113. package/packages/session-core/test/bubble-view.test.mjs +79 -0
  114. package/packages/session-core/test/pet-state.test.mjs +118 -0
  115. package/packages/session-core/test/priority.test.mjs +53 -0
  116. package/packages/session-core/test/registry.test.mjs +161 -0
  117. package/packages/session-core/test/summaries.test.mjs +38 -0
  118. package/packages/task-core/src/approvals.js +91 -0
  119. package/packages/task-core/src/controls.js +61 -0
  120. package/packages/task-core/src/replies.js +80 -0
  121. package/packages/task-core/src/task-events.js +101 -0
  122. package/packages/task-core/src/task-status.js +93 -0
  123. package/packages/task-core/src/task-store.js +74 -0
  124. package/packages/task-core/test/approvals.test.mjs +61 -0
  125. package/packages/task-core/test/controls.test.mjs +61 -0
  126. package/packages/task-core/test/replies.test.mjs +51 -0
  127. package/packages/task-core/test/task-events.test.mjs +67 -0
  128. package/packages/task-core/test/task-status.test.mjs +49 -0
  129. package/packages/task-core/test/task-store.test.mjs +65 -0
  130. package/test/harness.mjs +22 -0
  131. package/test/run-tests.mjs +47 -0
@@ -0,0 +1,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
+ }