@howaboua/pi-pet 0.1.1

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 (79) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -0
  4. package/SECURITY.md +24 -0
  5. package/authoring/PET-GUIDE.md +65 -0
  6. package/authoring/hatch/HATCH-GUIDE.md +136 -0
  7. package/authoring/hatch/LICENSE.txt +201 -0
  8. package/authoring/hatch/NOTICE.md +5 -0
  9. package/authoring/hatch/pyproject.toml +8 -0
  10. package/authoring/hatch/references/animation-rows.md +29 -0
  11. package/authoring/hatch/references/final-assembly.md +67 -0
  12. package/authoring/hatch/references/look-directions.md +35 -0
  13. package/authoring/hatch/references/pet-contract.md +40 -0
  14. package/authoring/hatch/references/qa-rubric.md +34 -0
  15. package/authoring/hatch/references/reviewer-protocol.md +72 -0
  16. package/authoring/hatch/references/standard-assembly.md +21 -0
  17. package/authoring/hatch/references/visual-generation.md +59 -0
  18. package/authoring/hatch/scripts/approve_cardinals.py +110 -0
  19. package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
  20. package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
  21. package/authoring/hatch/scripts/compose_atlas.py +171 -0
  22. package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
  23. package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
  24. package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
  25. package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
  26. package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
  27. package/authoring/hatch/scripts/inspect_frames.py +278 -0
  28. package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
  29. package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
  30. package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
  31. package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
  32. package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
  33. package/authoring/hatch/scripts/render_animation_previews.py +80 -0
  34. package/authoring/hatch/scripts/validate_atlas.py +299 -0
  35. package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
  36. package/authoring/hatch/uv.lock +99 -0
  37. package/authoring/references/import-existing-pet.md +47 -0
  38. package/authoring/references/pet-format.md +88 -0
  39. package/changelog.ts +327 -0
  40. package/deploy/pi-pet-hyprland.conf +10 -0
  41. package/desktop/build.mjs +194 -0
  42. package/desktop/package-lock.json +887 -0
  43. package/desktop/package.json +16 -0
  44. package/dist/authoring/prepare.mjs +3 -0
  45. package/dist/authoring/rebuild.mjs +4 -0
  46. package/dist/authoring/validate.mjs +2 -0
  47. package/dist/desktop/main.mjs +5 -0
  48. package/dist/desktop/preload.cjs +1 -0
  49. package/dist/web/app.js +1 -0
  50. package/dist/web/catalog.json +1 -0
  51. package/dist/web/index.html +100 -0
  52. package/dist/web/manifest.webmanifest +16 -0
  53. package/dist/web/pet-icon.svg +6 -0
  54. package/dist/web/spritesheet.webp +0 -0
  55. package/dist/web/styles.css +592 -0
  56. package/docs/architecture.md +41 -0
  57. package/docs/pi-pet.png +0 -0
  58. package/docs/research.md +17 -0
  59. package/extensions/desktop-command.ts +249 -0
  60. package/extensions/desktop-config.ts +152 -0
  61. package/extensions/desktop-launcher.ts +380 -0
  62. package/extensions/gippity.ts +24 -0
  63. package/extensions/index.ts +110 -0
  64. package/extensions/remote-app.ts +61 -0
  65. package/package.json +101 -0
  66. package/pets/clawa/PET.md +27 -0
  67. package/pets/clawa/pet.json +7 -0
  68. package/pets/clawa/spritesheet.webp +0 -0
  69. package/pets/clawa/validation.json +808 -0
  70. package/src/desktop/attention.ts +87 -0
  71. package/src/desktop/bridge.ts +31 -0
  72. package/src/desktop/config.ts +145 -0
  73. package/src/desktop/cursor-provider.ts +150 -0
  74. package/src/desktop/main.ts +459 -0
  75. package/src/desktop/preload.ts +19 -0
  76. package/src/pet-image-size.ts +72 -0
  77. package/src/pet-loader.ts +278 -0
  78. package/src/pet-storage.ts +240 -0
  79. package/src/protocol/index.ts +81 -0
@@ -0,0 +1,380 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parseDesktopConfig, parseSshTarget } from "../src/desktop/config.ts";
8
+ import type { PetDevice } from "./desktop-config.ts";
9
+
10
+ const MAX_DIAGNOSTIC_BYTES = 8 * 1024;
11
+ const STOP_TIMEOUT_MS = 3_000;
12
+ const READY_TIMEOUT_MS = 15 * 60 * 1_000;
13
+ const MAX_SOURCE_BYTES = 512 * 1024;
14
+ const SOURCE_CHUNK_BYTES = 4 * 1024;
15
+ const SOURCE_CHUNK_DELAY_MS = 10;
16
+ const PACKAGE_ROOT = fileURLToPath(new URL("../", import.meta.url));
17
+ const DESKTOP_SOURCE_PATHS = [
18
+ "desktop/build.mjs",
19
+ "desktop/package-lock.json",
20
+ "desktop/package.json",
21
+ "src/desktop/attention.ts",
22
+ "src/desktop/bridge.ts",
23
+ "src/desktop/config.ts",
24
+ "src/desktop/cursor-provider.ts",
25
+ "src/desktop/main.ts",
26
+ "src/desktop/preload.ts",
27
+ ] as const;
28
+
29
+ const REMOTE_BOOTSTRAP = String.raw`
30
+ const { spawn, spawnSync } = require("node:child_process");
31
+ const { access, mkdir, readFile, writeFile } = require("node:fs/promises");
32
+ const { homedir } = require("node:os");
33
+ const { isIP } = require("node:net");
34
+ const { dirname, join, resolve, sep } = require("node:path");
35
+ const { createRequire } = require("node:module");
36
+ let activeChild;
37
+ let stopping = false;
38
+ let stopTimer;
39
+ const ownerPid = process.ppid;
40
+ const emit = phase => process.stdout.write(JSON.stringify({ type: "phase", phase }) + "\n");
41
+ const bounded = value => value.length <= 8192 ? value : value.slice(value.length - 8192);
42
+ const ownerIsRunning = () => { try { process.kill(ownerPid, 0); return true; } catch { return false; } };
43
+ const heartbeat = setInterval(() => {
44
+ if (!ownerIsRunning()) { stop(); return; }
45
+ process.stdout.write('{"type":"heartbeat"}\n');
46
+ }, 1000);
47
+ const stop = () => {
48
+ if (stopping) return;
49
+ stopping = true;
50
+ clearInterval(heartbeat);
51
+ const child = activeChild;
52
+ signalTree(child, "SIGTERM");
53
+ stopTimer = setTimeout(() => { signalTree(child, "SIGKILL"); process.exit(0); }, 3000);
54
+ };
55
+ for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) process.on(signal, stop);
56
+ process.stdout.once("close", stop);
57
+ process.stdout.once("error", stop);
58
+ function signalTree(child, signal) {
59
+ if (!child?.pid) return;
60
+ if (process.platform === "win32") {
61
+ const args = ["/pid", String(child.pid), "/t"];
62
+ if (signal === "SIGKILL") args.push("/f");
63
+ spawnSync("taskkill", args, { stdio: "ignore", windowsHide: true });
64
+ return;
65
+ }
66
+ try { process.kill(-child.pid, signal); } catch { child.kill(signal); }
67
+ }
68
+ function run(command, args, cwd) {
69
+ return new Promise((resolve, reject) => {
70
+ let errorText = "";
71
+ const child = spawn(command, args, {
72
+ cwd, stdio: ["ignore", "ignore", "pipe"], windowsHide: true, detached: process.platform !== "win32",
73
+ });
74
+ activeChild = child;
75
+ child.stderr.on("data", chunk => { errorText = bounded(errorText + chunk.toString("utf8")); });
76
+ child.once("error", error => { if (activeChild === child) activeChild = undefined; reject(error); });
77
+ child.once("exit", code => {
78
+ if (activeChild === child) activeChild = undefined;
79
+ if (code === 0) resolve();
80
+ else reject(new Error(errorText.trim() || command + " exited " + (code ?? "without status")));
81
+ });
82
+ });
83
+ }
84
+ function graphicalEnvironment() {
85
+ const environment = { ...process.env };
86
+ if (process.platform !== "linux") return environment;
87
+ const result = spawnSync("systemctl", ["--user", "show-environment"], {
88
+ encoding: "utf8", timeout: 2000, maxBuffer: 64 * 1024, windowsHide: true,
89
+ });
90
+ if (result.status !== 0) return environment;
91
+ const allowed = new Set(["DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "HYPRLAND_INSTANCE_SIGNATURE"]);
92
+ for (const line of result.stdout.split("\n")) {
93
+ const separator = line.indexOf("=");
94
+ const key = separator < 0 ? "" : line.slice(0, separator);
95
+ if (allowed.has(key)) environment[key] = line.slice(separator + 1);
96
+ }
97
+ return environment;
98
+ }
99
+ function displayGippityUrl() {
100
+ if (!options.useSshSourceAddress) return options.gippityUrl;
101
+ const sourceAddress = process.env.SSH_CONNECTION?.trim().split(/\s+/)[0];
102
+ const addressFamily = sourceAddress ? isIP(sourceAddress) : 0;
103
+ if (!sourceAddress || !addressFamily) return options.gippityUrl;
104
+ const url = new URL(options.gippityUrl);
105
+ url.hostname = addressFamily === 6 ? "[" + sourceAddress + "]" : sourceAddress;
106
+ return url.origin;
107
+ }
108
+ async function buildIsCurrent(marker, desktop) {
109
+ try {
110
+ const built = JSON.parse(await readFile(marker, "utf8"));
111
+ for (const path of [
112
+ "attention.js", "bridge.cjs", "bridge.js", "config.js", "cursor-provider.js", "main.js", "package.json", "preload.cjs",
113
+ ]) await access(join(desktop, "dist", "app", path));
114
+ const requireFromDesktop = createRequire(join(desktop, "package.json"));
115
+ await access(requireFromDesktop("electron"));
116
+ return built.schemaVersion === 1
117
+ && built.packageVersion === options.packageVersion
118
+ && built.sourceDigest === options.sourceDigest;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+ async function main() {
124
+ const root = options.applicationRoot || join(homedir(), ".pi", "agent", "pi-pet");
125
+ const desktop = join(root, "desktop");
126
+ const marker = join(root, ".build.json");
127
+ await mkdir(root, { recursive: true, mode: 0o700 });
128
+ emit("check");
129
+ if (!(await buildIsCurrent(marker, desktop))) {
130
+ if (stopping) return;
131
+ emit("copy");
132
+ for (const [relativePath, encoded] of Object.entries(options.files)) {
133
+ const target = resolve(root, relativePath);
134
+ if (!target.startsWith(root + sep)) throw new Error("Pi Pet source path escaped its application directory");
135
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
136
+ await writeFile(target, Buffer.from(encoded, "base64"), { mode: 0o600 });
137
+ }
138
+ if (stopping) return;
139
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
140
+ emit("install");
141
+ await run(npm, ["install", "--include=dev", "--ignore-scripts", "--no-audit", "--no-fund"], desktop);
142
+ if (stopping) return;
143
+ emit("build");
144
+ await run(npm, ["run", "build"], desktop);
145
+ if (stopping) return;
146
+ await writeFile(marker, JSON.stringify({
147
+ schemaVersion: 1,
148
+ packageVersion: options.packageVersion,
149
+ sourceDigest: options.sourceDigest,
150
+ }) + "\n", { mode: 0o600 });
151
+ }
152
+ if (stopping) return;
153
+ const requireFromDesktop = createRequire(join(desktop, "package.json"));
154
+ const electron = requireFromDesktop("electron");
155
+ await new Promise((resolve, reject) => {
156
+ const child = spawn(electron, [join(desktop, "dist", "app")], {
157
+ cwd: desktop,
158
+ env: { ...graphicalEnvironment(), PI_PET_GIPPITY_URL: displayGippityUrl(), PI_PET_OWNER_FD: "3" },
159
+ stdio: ["ignore", "inherit", "inherit", "pipe"],
160
+ windowsHide: true,
161
+ detached: process.platform !== "win32",
162
+ });
163
+ activeChild = child;
164
+ child.once("spawn", () => emit("run"));
165
+ child.once("error", reject);
166
+ child.once("exit", code => {
167
+ if (activeChild === child) activeChild = undefined;
168
+ if (code === 0 || stopping) resolve();
169
+ else reject(new Error("Electron exited " + (code ?? "without status")));
170
+ });
171
+ });
172
+ }
173
+ main()
174
+ .catch(error => { process.stderr.write((error?.message || String(error)) + "\n"); process.exitCode = 1; })
175
+ .finally(() => { clearInterval(heartbeat); clearTimeout(stopTimer); });
176
+ `;
177
+
178
+ export interface DesktopProcessSpec {
179
+ program: string;
180
+ args: string[];
181
+ source: string;
182
+ }
183
+
184
+ function packagedDesktopSource(): { files: Record<string, string>; packageVersion: string; sourceDigest: string } {
185
+ const files: Record<string, string> = {};
186
+ const digest = createHash("sha256");
187
+ let size = 0;
188
+ for (const path of DESKTOP_SOURCE_PATHS) {
189
+ const contents = readFileSync(join(PACKAGE_ROOT, path));
190
+ size += contents.length;
191
+ if (size > MAX_SOURCE_BYTES) throw new Error("Pi Pet desktop source is unexpectedly large.");
192
+ files[path] = contents.toString("base64");
193
+ digest.update(path).update("\0").update(contents).update("\0");
194
+ }
195
+ const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: unknown };
196
+ if (typeof manifest.version !== "string") throw new Error("Pi Pet package version is missing.");
197
+ return { files, packageVersion: manifest.version, sourceDigest: digest.digest("hex") };
198
+ }
199
+
200
+ export function remoteDesktopProcessSpec(
201
+ target: string,
202
+ gippityUrl: string,
203
+ useSshSourceAddress = false,
204
+ ): DesktopProcessSpec {
205
+ const origin = parseDesktopConfig({ schemaVersion: 1, gippityUrl }).gippityUrl;
206
+ const options = { ...packagedDesktopSource(), gippityUrl: origin, useSshSourceAddress };
207
+ return {
208
+ program: "ssh",
209
+ args: [parseSshTarget(target), "node", "-"],
210
+ source: `const options = ${JSON.stringify(options)};\n${REMOTE_BOOTSTRAP}`,
211
+ };
212
+ }
213
+
214
+ export function localDesktopProcessSpec(gippityUrl: string): DesktopProcessSpec {
215
+ const origin = parseDesktopConfig({ schemaVersion: 1, gippityUrl }).gippityUrl;
216
+ const agentDirectory = process.env["PI_CODING_AGENT_DIR"]?.trim() || join(homedir(), ".pi", "agent");
217
+ const options = {
218
+ ...packagedDesktopSource(),
219
+ gippityUrl: origin,
220
+ useSshSourceAddress: false,
221
+ applicationRoot: join(agentDirectory, "pi-pet"),
222
+ };
223
+ return {
224
+ program: process.execPath,
225
+ args: ["-"],
226
+ source: `const options = ${JSON.stringify(options)};\n${REMOTE_BOOTSTRAP}`,
227
+ };
228
+ }
229
+
230
+ interface DesktopDeviceCallbacks {
231
+ onExit(device: string, error?: Error): void;
232
+ onPhase(device: string, phase: string): void;
233
+ }
234
+
235
+ function appendBounded(current: string, chunk: Buffer): string {
236
+ const next = current + chunk.toString("utf8");
237
+ return next.length <= MAX_DIAGNOSTIC_BYTES ? next : next.slice(next.length - MAX_DIAGNOSTIC_BYTES);
238
+ }
239
+
240
+ function sendSource(child: ChildProcessWithoutNullStreams, source: string): void {
241
+ const payload = Buffer.from(source);
242
+ let offset = 0;
243
+ const sendNext = () => {
244
+ if (!child.stdin.writable) return;
245
+ if (offset >= payload.length) {
246
+ child.stdin.end();
247
+ return;
248
+ }
249
+ const nextOffset = Math.min(offset + SOURCE_CHUNK_BYTES, payload.length);
250
+ const ready = child.stdin.write(payload.subarray(offset, nextOffset));
251
+ offset = nextOffset;
252
+ const resume = () => setTimeout(sendNext, SOURCE_CHUNK_DELAY_MS);
253
+ if (ready) resume();
254
+ else child.stdin.once("drain", resume);
255
+ };
256
+ sendNext();
257
+ }
258
+
259
+ function stopChild(child: ChildProcessWithoutNullStreams): Promise<void> {
260
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
261
+ child.kill();
262
+ return new Promise((resolvePromise) => {
263
+ const timer = setTimeout(() => {
264
+ child.kill("SIGKILL");
265
+ resolvePromise();
266
+ }, STOP_TIMEOUT_MS);
267
+ timer.unref();
268
+ child.once("exit", () => {
269
+ clearTimeout(timer);
270
+ resolvePromise();
271
+ });
272
+ });
273
+ }
274
+
275
+ export class DesktopDeviceFleet {
276
+ readonly #callbacks: DesktopDeviceCallbacks;
277
+ readonly #children = new Map<string, ChildProcessWithoutNullStreams>();
278
+
279
+ constructor(callbacks: DesktopDeviceCallbacks) {
280
+ this.#callbacks = callbacks;
281
+ }
282
+
283
+ deviceNames(): string[] {
284
+ return [...this.#children.keys()].sort();
285
+ }
286
+
287
+ async start(name: string, device: PetDevice, automaticUrl: string): Promise<void> {
288
+ await this.stop(name);
289
+ const gippityUrl = device.gippityUrl ?? automaticUrl;
290
+ const spec =
291
+ device.kind === "local"
292
+ ? localDesktopProcessSpec(gippityUrl)
293
+ : remoteDesktopProcessSpec(device.target, gippityUrl, !device.gippityUrl);
294
+ const child = spawn(spec.program, spec.args, {
295
+ stdio: ["pipe", "pipe", "pipe", "pipe"],
296
+ windowsHide: true,
297
+ });
298
+ this.#children.set(name, child);
299
+ let diagnostics = "";
300
+ let output = "";
301
+ let ready = false;
302
+ let rejectReady: (error: Error) => void = () => undefined;
303
+ let resolveReady: () => void = () => undefined;
304
+ const readiness = new Promise<void>((resolvePromise, rejectPromise) => {
305
+ resolveReady = resolvePromise;
306
+ rejectReady = rejectPromise;
307
+ });
308
+ const timer = setTimeout(() => {
309
+ const error = new Error(`Pi Pet device ${name} did not start within 15 minutes.`);
310
+ void this.stop(name).finally(() => rejectReady(error));
311
+ }, READY_TIMEOUT_MS);
312
+ timer.unref();
313
+ child.stderr.on("data", (chunk: Buffer) => {
314
+ diagnostics = appendBounded(diagnostics, chunk);
315
+ });
316
+ child.stdin.on("error", (error: Error) => {
317
+ diagnostics = appendBounded(diagnostics, Buffer.from(error.message));
318
+ });
319
+ const handleOutputLine = (line: string) => {
320
+ try {
321
+ const event = JSON.parse(line) as { type?: unknown; phase?: unknown };
322
+ if (event.type !== "phase" || typeof event.phase !== "string") return;
323
+ this.#callbacks.onPhase(name, event.phase);
324
+ if (event.phase === "run" && !ready) {
325
+ ready = true;
326
+ clearTimeout(timer);
327
+ resolveReady();
328
+ }
329
+ } catch {
330
+ diagnostics = appendBounded(diagnostics, Buffer.from(line));
331
+ }
332
+ };
333
+ child.stdout.setEncoding("utf8");
334
+ child.stdout.on("data", (chunk: string) => {
335
+ output += chunk;
336
+ let newline = output.indexOf("\n");
337
+ while (newline >= 0) {
338
+ const line = output.slice(0, newline);
339
+ output = output.slice(newline + 1);
340
+ handleOutputLine(line);
341
+ newline = output.indexOf("\n");
342
+ }
343
+ if (output.length > MAX_DIAGNOSTIC_BYTES) output = output.slice(-MAX_DIAGNOSTIC_BYTES);
344
+ });
345
+ child.once("error", (error) => {
346
+ clearTimeout(timer);
347
+ this.#finished(name, child, ready ? error : undefined);
348
+ if (!ready) rejectReady(error || new Error(`Pi Pet device ${name} exited before it was ready.`));
349
+ });
350
+ child.once("exit", (code) => {
351
+ const error =
352
+ code === 0 && ready
353
+ ? undefined
354
+ : new Error(diagnostics.trim() || `Pi Pet device exited ${code ?? "without status"} before it was ready`);
355
+ clearTimeout(timer);
356
+ this.#finished(name, child, ready ? error : undefined);
357
+ if (!ready) rejectReady(error || new Error(`Pi Pet device ${name} exited before it was ready.`));
358
+ });
359
+ child.once("spawn", () => sendSource(child, spec.source));
360
+ await readiness;
361
+ }
362
+
363
+ async stop(name: string): Promise<void> {
364
+ const child = this.#children.get(name);
365
+ if (!child) return;
366
+ this.#children.delete(name);
367
+ await stopChild(child);
368
+ this.#callbacks.onExit(name);
369
+ }
370
+
371
+ async stopAll(): Promise<void> {
372
+ await Promise.all([...this.#children.keys()].map((name) => this.stop(name)));
373
+ }
374
+
375
+ #finished(name: string, child: ChildProcessWithoutNullStreams, error?: Error): void {
376
+ if (this.#children.get(name) !== child) return;
377
+ this.#children.delete(name);
378
+ this.#callbacks.onExit(name, error);
379
+ }
380
+ }
@@ -0,0 +1,24 @@
1
+ export const GIPPITY_REQUIRED_USER_MESSAGE =
2
+ "Pi Pet requires GipPity Control 0.0.13 or newer. Run: pi install npm:@howaboua/pi-gippity-control — then /reload.";
3
+
4
+ export const GIPPITY_REQUIRED_TOOL_MESSAGE =
5
+ "GipPity Control 0.0.13 or newer is unavailable. Ask the user to run `pi install npm:@howaboua/pi-gippity-control`, then `/reload`.";
6
+
7
+ export class GippityUnavailableError extends Error {
8
+ override name = "GippityUnavailableError";
9
+
10
+ constructor() {
11
+ super(GIPPITY_REQUIRED_USER_MESSAGE);
12
+ }
13
+ }
14
+
15
+ export function isMissingGippity(error: unknown): boolean {
16
+ if (error instanceof GippityUnavailableError) return true;
17
+ if (!(error instanceof Error)) return false;
18
+ if (error.message.startsWith("GipPity Control is unavailable")) return true;
19
+ const code = (error as NodeJS.ErrnoException).code;
20
+ return (
21
+ (code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED") &&
22
+ error.message.includes("@howaboua/pi-gippity-control")
23
+ );
24
+ }
@@ -0,0 +1,110 @@
1
+ import { join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Text } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+ import { type PetRuntime, resolvePetRuntime } from "../src/pet-storage.ts";
7
+ import { type PetCatalog, type PetState, parseActionName, parseNote } from "../src/protocol/index.ts";
8
+ import { registerPetDevices } from "./desktop-command.ts";
9
+ import { GIPPITY_REQUIRED_TOOL_MESSAGE, GIPPITY_REQUIRED_USER_MESSAGE } from "./gippity.ts";
10
+ import { registerRemoteApp } from "./remote-app.ts";
11
+
12
+ const packageRoot = fileURLToPath(new URL("../", import.meta.url));
13
+
14
+ function registerPetRuntime(pi: ExtensionAPI, runtime: PetRuntime): boolean {
15
+ const appRoot = runtime.root;
16
+ const catalog: PetCatalog = runtime.catalog;
17
+ let state: PetState = { schemaVersion: 1, pet: catalog.id, revision: 1, action: catalog.defaultAction };
18
+ const listeners = new Set<(update: { state: PetState }) => void>();
19
+ const registration = registerRemoteApp(pi, {
20
+ id: "pi-pet",
21
+ root: appRoot,
22
+ snapshot: () => state,
23
+ subscribe(listener) {
24
+ listeners.add(listener);
25
+ return () => listeners.delete(listener);
26
+ },
27
+ });
28
+
29
+ function setAction(action: string, note?: string): PetState {
30
+ const requested = parseActionName(action);
31
+ const resolved = catalog.actions[requested] ? requested : catalog.aliases[requested];
32
+ if (!(resolved && catalog.actions[resolved])) {
33
+ throw new Error(`Unknown pet action: ${requested}. Call pet_show with action "list", then retry.`);
34
+ }
35
+ state = {
36
+ schemaVersion: 1,
37
+ pet: catalog.id,
38
+ revision: state.revision + 1,
39
+ action: resolved,
40
+ ...(note ? { note } : {}),
41
+ };
42
+ for (const listener of listeners) listener({ state });
43
+ return state;
44
+ }
45
+
46
+ pi.registerTool({
47
+ name: "pet_show",
48
+ label: "Pet",
49
+ description: "Show a named reaction on the connected Pi Pet",
50
+ promptGuidelines: ["Use pet_show sparingly; routine task activity animates automatically"],
51
+ parameters: Type.Object({
52
+ action: Type.String({
53
+ minLength: 1,
54
+ maxLength: 64,
55
+ description: 'Reaction name, or "list" to return available names',
56
+ }),
57
+ note: Type.Optional(Type.String({ minLength: 1, maxLength: 280, description: "Short visible context" })),
58
+ }),
59
+ async execute(_toolCallId, params) {
60
+ if (!registration.available) {
61
+ throw new Error(GIPPITY_REQUIRED_TOOL_MESSAGE);
62
+ }
63
+ if (params.action === "list") {
64
+ const actions = Object.keys(catalog.actions).sort().join(", ");
65
+ const aliases = Object.entries(catalog.aliases)
66
+ .sort(([left], [right]) => left.localeCompare(right))
67
+ .map(([alias, target]) => `${alias}=${target}`)
68
+ .join(", ");
69
+ return {
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: `${catalog.displayName} actions: ${actions}.${aliases ? ` Aliases: ${aliases}.` : ""}`,
74
+ },
75
+ ],
76
+ details: state,
77
+ };
78
+ }
79
+ const next = setAction(params.action, parseNote(params.note));
80
+ return {
81
+ content: [{ type: "text", text: `Pet reaction set to ${next.action}.` }],
82
+ details: next,
83
+ };
84
+ },
85
+ renderCall(args, theme) {
86
+ return new Text(`${theme.fg("toolTitle", theme.bold("Pet"))} ${theme.fg("muted", args.action)}`, 0, 0);
87
+ },
88
+ renderResult(result, _options, theme, renderContext) {
89
+ if (!renderContext.isError) return new Container();
90
+ const message = result.content.find((part) => part.type === "text")?.text || "Pet reaction failed.";
91
+ return new Text(theme.fg("error", message), 0, 0);
92
+ },
93
+ });
94
+ pi.on("agent_settled", () => {
95
+ if (state.action !== catalog.defaultAction) setAction(catalog.defaultAction);
96
+ });
97
+ return registration.available;
98
+ }
99
+
100
+ export default function piPetExtension(pi: ExtensionAPI): void {
101
+ registerPetDevices(pi);
102
+ pi.on("session_start", (_event, ctx) => {
103
+ const resolution = resolvePetRuntime(packageRoot, join(ctx.cwd, CONFIG_DIR_NAME, "pi-pet.json"));
104
+ for (const warning of resolution.warnings) {
105
+ if (ctx.hasUI) ctx.ui.notify(`Pi Pet ${warning}`, "warning");
106
+ }
107
+ const gippityAvailable = registerPetRuntime(pi, resolution.runtime);
108
+ if (!gippityAvailable && ctx.hasUI) ctx.ui.notify(GIPPITY_REQUIRED_USER_MESSAGE, "error");
109
+ });
110
+ }
@@ -0,0 +1,61 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { PetState } from "../src/protocol/index.ts";
3
+
4
+ const PROTOCOL = "@howaboua/pi-gippity-control/remote-app/v1";
5
+ const AVAILABLE_CHANNEL = `${PROTOCOL}/available`;
6
+ const REQUEST_CHANNEL = `${PROTOCOL}/request`;
7
+
8
+ interface RemoteAppBroker {
9
+ protocol: typeof PROTOCOL;
10
+ isActive(): boolean;
11
+ register(provider: RemoteAppProvider): (() => void) | undefined;
12
+ }
13
+
14
+ interface RemoteAppProvider {
15
+ id: "pi-pet";
16
+ root: string;
17
+ snapshot(): PetState;
18
+ subscribe(listener: (update: { state: PetState }) => void): () => void;
19
+ }
20
+
21
+ export interface RemoteAppRegistration {
22
+ readonly available: boolean;
23
+ dispose(): void;
24
+ }
25
+
26
+ function isBroker(value: unknown): value is RemoteAppBroker {
27
+ if (!value || typeof value !== "object") return false;
28
+ const broker = value as Partial<RemoteAppBroker>;
29
+ return broker.protocol === PROTOCOL && typeof broker.isActive === "function" && typeof broker.register === "function";
30
+ }
31
+
32
+ export function registerRemoteApp(
33
+ pi: Pick<ExtensionAPI, "events" | "on">,
34
+ provider: RemoteAppProvider,
35
+ ): RemoteAppRegistration {
36
+ let broker: RemoteAppBroker | undefined;
37
+ let unregisterProvider: (() => void) | undefined;
38
+ let disposed = false;
39
+ const unregisterAvailable = pi.events.on(AVAILABLE_CHANNEL, (value) => {
40
+ if (disposed || !isBroker(value) || value === broker) return;
41
+ unregisterProvider?.();
42
+ broker = value;
43
+ unregisterProvider = value.register(provider);
44
+ });
45
+ const registration: RemoteAppRegistration = {
46
+ get available() {
47
+ return !disposed && Boolean(unregisterProvider && broker?.isActive());
48
+ },
49
+ dispose() {
50
+ if (disposed) return;
51
+ disposed = true;
52
+ unregisterAvailable();
53
+ unregisterProvider?.();
54
+ unregisterProvider = undefined;
55
+ broker = undefined;
56
+ },
57
+ };
58
+ pi.on("session_shutdown", () => registration.dispose());
59
+ pi.events.emit(REQUEST_CHANNEL, { protocol: PROTOCOL });
60
+ return registration;
61
+ }
package/package.json ADDED
@@ -0,0 +1,101 @@
1
+ {
2
+ "name": "@howaboua/pi-pet",
3
+ "version": "0.1.1",
4
+ "description": "Animated companions for GipPity Remote.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/IgorWarzocha/howaboua-pi-stuff.git",
8
+ "directory": "packages/pi-pet"
9
+ },
10
+ "homepage": "https://github.com/IgorWarzocha/howaboua-pi-stuff/tree/main/packages/pi-pet#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/IgorWarzocha/howaboua-pi-stuff/issues"
13
+ },
14
+ "type": "module",
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi-extension",
18
+ "pet"
19
+ ],
20
+ "files": [
21
+ "README.md",
22
+ "SECURITY.md",
23
+ "LICENSE",
24
+ "deploy",
25
+ "desktop/build.mjs",
26
+ "desktop/package-lock.json",
27
+ "desktop/package.json",
28
+ "dist",
29
+ "docs",
30
+ "extensions",
31
+ "pets",
32
+ "authoring",
33
+ "src/desktop",
34
+ "src/pet-image-size.ts",
35
+ "src/pet-loader.ts",
36
+ "src/pet-storage.ts",
37
+ "src/protocol/index.ts",
38
+ "!**/AGENTS.md",
39
+ "!**/__pycache__",
40
+ "!**/*.pyc",
41
+ "!**/.venv",
42
+ "!authoring/hatch/tests",
43
+ "changelog.ts",
44
+ "CHANGELOG.md"
45
+ ],
46
+ "pi": {
47
+ "extensions": [
48
+ "./changelog.ts",
49
+ "./extensions/index.ts"
50
+ ]
51
+ },
52
+ "scripts": {
53
+ "build": "bun scripts/build.mjs",
54
+ "desktop:start": "npm --prefix desktop run build && npm --prefix desktop start",
55
+ "pet:hatch:test": "uv run --isolated --project authoring/hatch --locked python -m unittest discover -s authoring/hatch/tests -p 'test_*.py' -v",
56
+ "pet:prepare": "node dist/authoring/prepare.mjs",
57
+ "pet:rebuild": "node dist/authoring/rebuild.mjs",
58
+ "pet:validate": "node dist/authoring/validate.mjs",
59
+ "typecheck": "tsgo --noEmit",
60
+ "test": "node --test --experimental-strip-types tests/*.test.ts",
61
+ "lint": "biome check .",
62
+ "lint:fix": "biome check --write .",
63
+ "lint:strict": "biome check --error-on-warnings .",
64
+ "knip": "knip",
65
+ "deadcode": "knip",
66
+ "ai:check": "biome check . && tsgo --noEmit --noCheck && knip && bun run test && bun run build",
67
+ "ai:check:strict": "biome check --error-on-warnings . && tsgo --noEmit && knip && bun run test && bun run build",
68
+ "check": "bun run ai:check:strict",
69
+ "prepack": "bun run check",
70
+ "pack:dry": "npm pack --dry-run"
71
+ },
72
+ "engines": {
73
+ "node": ">=22"
74
+ },
75
+ "packageManager": "bun@1.3.14",
76
+ "peerDependencies": {
77
+ "@howaboua/pi-gippity-control": ">=0.0.13",
78
+ "@earendil-works/pi-coding-agent": "*",
79
+ "@earendil-works/pi-tui": "*",
80
+ "typebox": "*"
81
+ },
82
+ "peerDependenciesMeta": {
83
+ "@howaboua/pi-gippity-control": {
84
+ "optional": true
85
+ }
86
+ },
87
+ "devDependencies": {
88
+ "@biomejs/biome": "2.5.3",
89
+ "@earendil-works/pi-coding-agent": "0.84.1",
90
+ "@earendil-works/pi-tui": "0.84.1",
91
+ "@types/node": "^24.10.0",
92
+ "electron": "41.0.2",
93
+ "knip": "^6.29.0",
94
+ "typebox": "1.3.7",
95
+ "@typescript/native-preview": "^7.0.0-dev.20260519.1",
96
+ "typescript": "^6.0.3"
97
+ },
98
+ "publishConfig": {
99
+ "access": "public"
100
+ }
101
+ }