@staix/agent-hub 0.12.25 → 0.12.27

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.
@@ -0,0 +1,154 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { readPet, type PetView } from "../cli/pet.ts";
6
+ import { CHARACTERS, type CharacterId } from "./characters.ts";
7
+ import { loadPetConfig, type PetConfig, type PetPalette } from "./config.ts";
8
+ import { petPixels } from "./pixels.ts";
9
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
10
+
11
+ export const PET_WINDOW_UNSUPPORTED = "The floating pet window is supported only on macOS; use ahub pet in this terminal.";
12
+ export type WindowView = { offline: true } | { state: NonNullable<PetView["summary"]>["state"]; act: number; watch: number; character: CharacterId; blink: boolean; palette: PetPalette; frame: ReturnType<typeof petPixels> };
13
+
14
+ /** No ids, task text, paths or notices leave the existing readPet projection. */
15
+ export function windowView(view: PetView, config: PetConfig, now = Date.now()): WindowView {
16
+ if (view.unavailable || !view.summary) return { offline: true };
17
+ const { state, act, watch } = view.summary;
18
+ const character = config.character ?? "stai";
19
+ const pose = state === "waiting" && config.blink && Math.floor(now / 1000) % 2 ? "waitingBlink" : state;
20
+ return { state, act, watch, character, blink: config.blink, palette: config.palette ?? "teal", frame: petPixels(CHARACTERS[character].frames.blocks[pose].full) };
21
+ }
22
+
23
+ export function writePetView(stateDir: string, view: WindowView): void {
24
+ const temporary = join(stateDir, `pet-view.${randomUUID()}.tmp`);
25
+ try {
26
+ writeFileSync(temporary, JSON.stringify(view), { flag: "wx", mode: 0o600 });
27
+ renameSync(temporary, join(stateDir, "pet-view.json"));
28
+ } finally { rmSync(temporary, { force: true }); }
29
+ }
30
+
31
+ interface WindowOwner { pid: number; signature: string; token: string; ready?: boolean }
32
+ const ownerPath = (stateDir: string) => join(stateDir, "pet-window.pid");
33
+ function readOwner(stateDir: string): WindowOwner | undefined {
34
+ try {
35
+ const row = JSON.parse(readFileSync(ownerPath(stateDir), "utf8"));
36
+ if (!Number.isSafeInteger(row.pid) || row.pid <= 0 || typeof row.signature !== "string" || !/^[a-f0-9]{64}$/.test(row.signature) || typeof row.token !== "string") throw new Error();
37
+ return row;
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
40
+ throw new Error("Cannot confirm the pet window owner; pidfile retained.");
41
+ }
42
+ }
43
+ function removeOwner(stateDir: string, token: string): void {
44
+ if (readOwner(stateDir)?.token === token) rmSync(ownerPath(stateDir));
45
+ }
46
+
47
+ /** Exclusive creation, including during startup; stale owners require an explicit --close. */
48
+ export function claimPetWindow(stateDir: string): { owner: WindowOwner; release(): void } | undefined {
49
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
50
+ const signature = processSignature(process.pid);
51
+ if (!signature) throw new Error("Cannot confirm the pet window process identity.");
52
+ const owner: WindowOwner = { pid: process.pid, signature, token: randomUUID() };
53
+ try { writeFileSync(ownerPath(stateDir), JSON.stringify(owner), { flag: "wx", mode: 0o600 }); }
54
+ catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
56
+ const prior = readOwner(stateDir);
57
+ if (prior && processLiveness(prior.pid, prior.signature) === "live") return;
58
+ throw new Error("Pet window owner is stale or unconfirmed; run ahub pet --window --close before starting it.");
59
+ }
60
+ return { owner, release: () => removeOwner(stateDir, owner.token) };
61
+ }
62
+
63
+ export function petWindowArgv(projectDir: string): string[] {
64
+ return [process.execPath, join(import.meta.dir, "../cli/main.js"), "--project", projectDir];
65
+ }
66
+
67
+ /** Launch the host from the authorized CLI; never wait on or open the real JXA in tests. */
68
+ export async function startPetWindow(stateDir: string, projectDir: string, platform: string = process.platform): Promise<string> {
69
+ if (platform !== "darwin") return PET_WINDOW_UNSUPPORTED;
70
+ const prior = readOwner(stateDir);
71
+ if (prior) {
72
+ if (processLiveness(prior.pid, prior.signature) === "live" && prior.ready) return "Pet window is already running.";
73
+ throw new Error("Pet window owner is starting, stale or unconfirmed; use --close before retrying.");
74
+ }
75
+ const child = spawn(process.execPath, [join(import.meta.dir, "window.ts"), stateDir, projectDir], { detached: true, stdio: "ignore" });
76
+ child.unref();
77
+ let failed = false;
78
+ child.on("error", () => { failed = true; });
79
+ const deadline = Date.now() + 5000;
80
+ while (Date.now() < deadline && !failed) {
81
+ const owner = readOwner(stateDir);
82
+ if (owner?.ready && processLiveness(owner.pid, owner.signature) === "live") return "Pet window started.";
83
+ if (child.exitCode !== null || child.signalCode !== null) break;
84
+ await Bun.sleep(50);
85
+ }
86
+ throw new Error("Pet window startup was not confirmed; use --close before retrying.");
87
+ }
88
+
89
+ export async function closePetWindow(stateDir: string, platform: string = process.platform): Promise<string> {
90
+ if (platform !== "darwin") return PET_WINDOW_UNSUPPORTED;
91
+ const owner = readOwner(stateDir);
92
+ if (!owner) return "Pet window is not running.";
93
+ const live = processLiveness(owner.pid, owner.signature);
94
+ if (live === "unknown") throw new Error("Cannot confirm the pet window owner; nothing was stopped.");
95
+ if (live === "live") {
96
+ process.kill(owner.pid, "SIGTERM");
97
+ const deadline = Date.now() + 5000;
98
+ while (Date.now() < deadline && processLiveness(owner.pid, owner.signature) !== "gone") await Bun.sleep(50);
99
+ if (processLiveness(owner.pid, owner.signature) !== "gone") throw new Error("Pet window stop was not confirmed; pidfile retained.");
100
+ }
101
+ removeOwner(stateDir, owner.token);
102
+ return "Pet window closed.";
103
+ }
104
+
105
+ export interface PetWindowHostOptions {
106
+ read?: () => PetView;
107
+ config?: () => PetConfig;
108
+ // Injectable child argv for process-level lifecycle tests; the CLI never accepts it.
109
+ childArgv?: string[];
110
+ }
111
+ export async function runPetWindowHost(stateDir: string, projectDir: string, options: PetWindowHostOptions = {}): Promise<void> {
112
+ const claim = claimPetWindow(stateDir);
113
+ if (!claim) return;
114
+ const readyFile = join(stateDir, `pet-window-ready-${claim.owner.token}`);
115
+ let child: ReturnType<typeof Bun.spawn> | undefined;
116
+ let timer: ReturnType<typeof setInterval> | undefined;
117
+ const refresh = () => {
118
+ let config: PetConfig;
119
+ try { config = (options.config ?? (() => loadPetConfig(projectDir)))(); }
120
+ catch { config = { enabled: true, blink: true, bell: false }; }
121
+ const view = (options.read ?? (() => readPet(stateDir)))();
122
+ writePetView(stateDir, windowView(view, config));
123
+ };
124
+ const stop = () => { if (timer) clearInterval(timer); child?.kill("SIGTERM"); };
125
+ try {
126
+ refresh();
127
+ child = Bun.spawn(options.childArgv ?? ["/usr/bin/osascript", "-l", "JavaScript", join(import.meta.dir, "window.jxa.js"), join(stateDir, "pet-view.json"), join(import.meta.dir, "window.html"), JSON.stringify(petWindowArgv(projectDir))], { cwd: projectDir, stdin: "ignore", stdout: "ignore", stderr: "ignore" });
128
+ process.on("SIGTERM", stop); process.on("SIGINT", stop);
129
+ timer = setInterval(() => { try { refresh(); } catch { stop(); } }, 1000);
130
+ // The child posts this file only once its panel and page have loaded.
131
+ const readyDeadline = Date.now() + 4000;
132
+ while (!existsSync(readyFile) && child.exitCode === null && Date.now() < readyDeadline) await Bun.sleep(50);
133
+ if (existsSync(readyFile)) {
134
+ rmSync(readyFile);
135
+ const temporary = join(stateDir, `pet-owner.${claim.owner.token}.tmp`);
136
+ try {
137
+ writeFileSync(temporary, JSON.stringify({ ...claim.owner, ready: true }), { flag: "wx", mode: 0o600 });
138
+ renameSync(temporary, ownerPath(stateDir));
139
+ } finally { rmSync(temporary, { force: true }); }
140
+ } else stop();
141
+ await child.exited;
142
+ } finally {
143
+ if (timer) clearInterval(timer);
144
+ process.off("SIGTERM", stop); process.off("SIGINT", stop);
145
+ rmSync(readyFile, { force: true });
146
+ claim.release();
147
+ }
148
+ }
149
+
150
+ if (import.meta.main) {
151
+ const [stateDir, projectDir] = process.argv.slice(2);
152
+ if (!stateDir || !projectDir || process.platform !== "darwin") process.exit(1);
153
+ try { await runPetWindowHost(stateDir, projectDir); } catch { process.exitCode = 1; }
154
+ }