@staix/agent-hub 0.12.26 → 0.12.28

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/src/hub/bus.ts CHANGED
@@ -46,6 +46,11 @@ export interface BusOptions {
46
46
  * the console and the log see it as published.
47
47
  */
48
48
  silence?: (env: Envelope, peer: PeerId) => string | undefined;
49
+ /**
50
+ * Optional: an `important` envelope was just queued for a pull-only peer (issue #398). Advisory only: nothing was
51
+ * dequeued, steered or journaled for it, and the queue still waits for `Bus.pull`; a throw is swallowed.
52
+ */
53
+ onImportantQueued?: (peer: PeerId, counts: { queued: number; important: number }) => void;
49
54
  }
50
55
 
51
56
  /** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
@@ -732,6 +737,10 @@ export class Bus {
732
737
  if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded" });
733
738
  this.emit({ t: "overflow", env: lost!, peer: id });
734
739
  }
740
+ if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
741
+ // Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
742
+ try { this.opts.onImportantQueued?.(id, { queued: queue.length, important: queue.filter((e) => e.priority === "important").length }); } catch { /* a nudge never stops an enqueue */ }
743
+ }
735
744
  this.onQueues?.();
736
745
  if (!this.suppressDrain) this.persist();
737
746
  if (!this.suppressDrain) void this.drain(id);
package/src/hub/daemon.ts CHANGED
@@ -38,6 +38,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
38
38
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
39
39
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
40
40
  import { processSignature } from "../pi/process-signature.ts";
41
+ import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
41
42
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
42
43
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
43
44
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
@@ -489,6 +490,11 @@ class WsPeer extends BasePeer {
489
490
  if (deliveryId) this.delivered.add(deliveryId);
490
491
  this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
491
492
  }
493
+ /** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
494
+ notifyQueued(counts: { queued: number; important: number }): void {
495
+ if (!this.sock || !this.pullOnly) return;
496
+ this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
497
+ }
492
498
  owns(sock: Sock): boolean { return this.sock === sock; }
493
499
  nativeStarted(session: string): void {
494
500
  if (!this.sock) return;
@@ -769,7 +775,8 @@ export async function startDaemon(opts: DaemonOptions) {
769
775
  const staleOff = config.experiments?.stale_notices === "deliver";
770
776
  if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
771
777
  const requestedPeerStops = new Set<string>();
772
- const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence });
778
+ const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence,
779
+ onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
773
780
  startupCleanup.push(() => bus.closeJournal());
774
781
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
775
782
  const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
@@ -2406,6 +2413,7 @@ export async function startDaemon(opts: DaemonOptions) {
2406
2413
  let piAutoRestartAt = -Infinity;
2407
2414
  let piAutoRestartPending = false;
2408
2415
  const replacingPi = new Set<PiPeer>();
2416
+ const piReplacementAuthority = Symbol("console Pi replacement");
2409
2417
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
2410
2418
  const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
2411
2419
  (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
@@ -2414,7 +2422,8 @@ export async function startDaemon(opts: DaemonOptions) {
2414
2422
  replacingPi.add(pi);
2415
2423
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
2416
2424
  }
2417
- function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
2425
+ function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, replacementAuthority?: symbol): Promise<Record<string, unknown>> {
2426
+ if (args.replaceSession !== undefined && (args.replaceSession !== true || replacementAuthority !== piReplacementAuthority || peer !== "pi")) return Promise.resolve({ ok: false, error: "Pi session replacement requires explicit person authorization" });
2418
2427
  if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
2419
2428
  if (!membershipAllowed(peer)) return Promise.resolve({ ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` });
2420
2429
  if (rosterChanging === peer) return Promise.resolve({ ok: false, error: `${peer} roster change is in progress` });
@@ -2544,7 +2553,7 @@ export async function startDaemon(opts: DaemonOptions) {
2544
2553
  };
2545
2554
  }
2546
2555
 
2547
- async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
2556
+ async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
2548
2557
  let unmute: (() => void) | undefined;
2549
2558
  try {
2550
2559
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
@@ -2553,7 +2562,7 @@ export async function startDaemon(opts: DaemonOptions) {
2553
2562
  }
2554
2563
  }
2555
2564
 
2556
- async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
2565
+ async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
2557
2566
  if (peer === "jcode" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Jcode native TUI driving is unverified; use headless" };
2558
2567
  if (peer === "qwen" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Qwen TUI is not offered before its native verification; use ahub qwen --mode headless" };
2559
2568
  if (peer === "kimi") {
@@ -2581,7 +2590,18 @@ export async function startDaemon(opts: DaemonOptions) {
2581
2590
  if (args.model !== undefined && !["dgx/coding", "dgx/fast", "mlx/fast"].includes(args.model)) return { ok: false, error: "unknown Pi model alias" };
2582
2591
  }
2583
2592
  const existing = bus.peers.get(peer);
2584
- if (peer === "pi" && existing instanceof PiPeer) {
2593
+ if (args.replaceSession) {
2594
+ if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
2595
+ if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
2596
+ if (!(existing instanceof PiPeer) || !existing.replacementReady || piReceipts?.unsettled || [...permissions.values()].some(p => p.peer === "pi")) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
2597
+ const selected = validatePiSession(opts.stateDir, opts.cwd, args.sessionFile);
2598
+ const previous = existing.recoveryMetadata();
2599
+ recordPiReplacement(opts.stateDir, instanceId, previous, selected);
2600
+ args = { ...args, ...selected };
2601
+ mute(existing);
2602
+ await stopPiForReplacement(existing);
2603
+ }
2604
+ if (peer === "pi" && existing instanceof PiPeer && !args.replaceSession) {
2585
2605
  let saved = existing.recoveryMetadata();
2586
2606
  const launch = saved.launch as Record<string, unknown>;
2587
2607
  if ((args.sessionId && saved.sessionId && args.sessionId !== saved.sessionId) || (args.sessionFile && saved.sessionFile && args.sessionFile !== saved.sessionFile)) return { ok: false, error: "Pi already owns a different session; refusing to replace its identity" };
@@ -3876,6 +3896,9 @@ export async function startDaemon(opts: DaemonOptions) {
3876
3896
  writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
3877
3897
  void ensurePreface(c.peer).finally(() => {
3878
3898
  if (sock.readyState === WebSocket.OPEN && membershipAllowed(c.peer!)) ws.attach(sock);
3899
+ // Important work queued while no session held the id gets its nudge now (#398); a push session ignores it.
3900
+ const important = ws.owns(sock) ? bus.queuedImportant(c.peer!) : 0;
3901
+ if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
3879
3902
  });
3880
3903
  }
3881
3904
  return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
@@ -3935,7 +3958,7 @@ export async function startDaemon(opts: DaemonOptions) {
3935
3958
  peer.lastInboxAt = Date.now();
3936
3959
  writeStatus();
3937
3960
  if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
3938
- return void reply({ ok: true, envs, waiting: bus.queued(peer.id) });
3961
+ return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id) });
3939
3962
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
3940
3963
  }
3941
3964
  case "delivery_complete": {
@@ -4255,7 +4278,7 @@ export async function startDaemon(opts: DaemonOptions) {
4255
4278
  if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
4256
4279
  if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: "recovery is holding mutations" });
4257
4280
  if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
4258
- startPeer(String(msg.peer), msg.args ?? {})
4281
+ startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
4259
4282
  .catch((e: Error) => ({ ok: false, error: e.message }))
4260
4283
  .then((r) => {
4261
4284
  if (!r.ok) log(`start ${msg.peer} failed: ${r.error}`);
package/src/hub/ui.ts CHANGED
@@ -6,8 +6,6 @@ import { PEER_START_RETRY_MS } from "./start-mode.ts";
6
6
  import { taskProgress } from "../ui/task-progress.ts";
7
7
  import { officeModel } from "../ui/office-model.ts";
8
8
  import { needs } from "../ui/needs.ts";
9
- import { CHARACTERS } from "../pet/characters.ts";
10
- import { petPixels } from "../pet/pixels.ts";
11
9
  import { createHash, randomBytes } from "node:crypto";
12
10
  import { readFileSync } from "node:fs";
13
11
 
@@ -41,7 +39,7 @@ export function startDashboard(options: DashboardOptions) {
41
39
  .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
42
40
  .replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
43
41
  .replace("/* OFFICE_MODEL */", () => `const officeModel = ${officeModel.toString()};`)
44
- .replace("/* PET_MODEL */", () => `const needs = ${needs.toString()}; const petCharacters = ${JSON.stringify(Object.fromEntries(Object.entries(CHARACTERS).map(([id, character]) => [id, { name: character.name, accent: character.accent, frames: { blocks: Object.fromEntries(Object.entries(character.frames.blocks).map(([pose, sizes]) => [pose, { full: sizes.full }])) } }])))}; const petPixels = ${petPixels.toString()};`);
42
+ .replace("/* NEEDS_MODEL */", () => `const needs = ${needs.toString()};`);
45
43
  const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
46
44
  .map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
47
45
  const headers = {
package/src/pet/config.ts CHANGED
@@ -1,14 +1,47 @@
1
- import { readFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { characterId, type CharacterId, type PetSize, type PetGlyphs } from "./frame.ts";
4
5
 
5
- export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; notice?: string }
6
+ export type PetPalette = "blue" | "teal" | "violet" | "ochre";
7
+ const PALETTES: Record<string, PetPalette> = { a: "blue", b: "teal", c: "violet", d: "ochre", blue: "blue", teal: "teal", violet: "violet", ochre: "ochre" };
8
+ export function petPalette(value: unknown): PetPalette | undefined {
9
+ return typeof value === "string" ? PALETTES[value.toLowerCase()] : undefined;
10
+ }
11
+ export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string; window?: boolean }
12
+ /** Console-local presentation only; it merges pet.palette into config.local.json and grants no authority. */
13
+ export function savePetPalette(cwd: string, value: string): string {
14
+ const palette = petPalette(value);
15
+ if (!palette) throw new Error("Unknown pet palette; use blue, teal, violet or ochre (a/b/c/d).");
16
+ const dir = join(cwd, ".agenthub");
17
+ const path = join(dir, "config.local.json");
18
+ let file: Record<string, unknown> = {};
19
+ try {
20
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
21
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("malformed");
22
+ file = parsed as Record<string, unknown>;
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("Cannot update config.local.json; it is malformed.");
25
+ }
26
+ const pet = file.pet && typeof file.pet === "object" && !Array.isArray(file.pet) ? file.pet as Record<string, unknown> : {};
27
+ mkdirSync(dir, { recursive: true });
28
+ const temporary = join(dir, `config.local.${randomUUID()}.tmp`);
29
+ try {
30
+ writeFileSync(temporary, JSON.stringify({ ...file, pet: { ...pet, palette } }, null, 2) + "\n", { flag: "wx" });
31
+ renameSync(temporary, path);
32
+ } finally { rmSync(temporary, { force: true }); }
33
+ return `Pet palette set to ${palette}.`;
34
+ }
6
35
  /** Console-local presentation only; this reader never writes config or grants authority. */
7
36
  export function loadPetConfig(cwd: string): PetConfig {
8
37
  let value: unknown;
38
+ let enabled = true;
9
39
  for (const name of ["config.json", "config.local.json"]) {
10
40
  try {
11
41
  const file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
42
+ // A window-only local opt-in does not undo inherited pet:false presentation.
43
+ if (file.pet === false) enabled = false;
44
+ else if (file.pet !== undefined && !(file.pet && typeof file.pet === "object" && !Array.isArray(file.pet) && Object.keys(file.pet).length === 1 && Object.hasOwn(file.pet, "window"))) enabled = true;
12
45
  if (file.pet !== undefined) value = value && typeof value === "object" && file.pet && typeof file.pet === "object" ? { ...value, ...file.pet } : file.pet;
13
46
  } catch (error) {
14
47
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
@@ -19,13 +52,17 @@ export function loadPetConfig(cwd: string): PetConfig {
19
52
  if (value === undefined) return defaults;
20
53
  if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: "Unknown pet configuration; using stai line defaults." };
21
54
  const row = value as Record<string, unknown>;
22
- const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell"].includes(key)) ||
55
+ const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette", "window"].includes(key)) ||
23
56
  row.character !== undefined && !characterId(row.character) || row.size !== undefined && !["line", "mini", "full"].includes(row.size as string) ||
24
57
  row.glyphs !== undefined && (typeof row.glyphs !== "string" || !["auto", "ascii", "blocks"].includes(row.glyphs)) ||
25
- row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean";
26
- return { enabled: true, blink: typeof row.blink === "boolean" ? row.blink : true, bell: typeof row.bell === "boolean" ? row.bell : false,
58
+ row.palette !== undefined && !petPalette(row.palette) ||
59
+ row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean" || row.window !== undefined && typeof row.window !== "boolean";
60
+ const palette = petPalette(row.palette);
61
+ return { enabled, blink: typeof row.blink === "boolean" ? row.blink : true, bell: typeof row.bell === "boolean" ? row.bell : false,
62
+ ...(typeof row.window === "boolean" ? { window: row.window } : {}),
27
63
  ...(characterId(row.character) && row.character !== "stai" ? { character: row.character } : {}),
28
64
  ...(row.size === "mini" || row.size === "full" ? { size: row.size } : {}),
29
65
  ...(row.glyphs === "ascii" || row.glyphs === "blocks" ? { glyphs: row.glyphs } : {}),
66
+ ...(palette && palette !== "teal" ? { palette } : {}),
30
67
  ...(invalid ? { notice: "Unknown pet option; using stai line defaults for that option." } : {}) };
31
68
  }
@@ -0,0 +1,114 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; base-uri 'none'; form-action 'none'">
6
+ <title>agent-hub pet</title>
7
+ <style>
8
+ :root { color-scheme: dark; font: 12px system-ui, sans-serif; color: #eceef3; }
9
+ html, body { margin: 0; background: transparent; overflow: hidden; }
10
+ body { width: 150px; height: 150px; display: flex; align-items: center; flex-direction: column; justify-content: flex-end; gap: 4px; padding-bottom: 4px; box-sizing: border-box; }
11
+ button { font: inherit; color: inherit; cursor: pointer; border: 0; background: transparent; border-radius: 10px; }
12
+ button:focus-visible, input:focus-visible { outline: 2px solid #79dfb2; outline-offset: 2px; }
13
+ #pet { width: 63px; height: 58px; padding: 0; cursor: grab; touch-action: none; }
14
+ #pet:active { cursor: grabbing; }
15
+ svg { width: 100%; height: 100%; overflow: visible; image-rendering: pixelated; filter: drop-shadow(0 1px 1px #687a78) drop-shadow(0 0 1px #687a78); }
16
+ [data-palette="blue"] svg { filter: drop-shadow(0 1px 1px #64748b) drop-shadow(0 0 1px #64748b); }
17
+ [data-palette="violet"] svg { filter: drop-shadow(0 1px 1px #796d8b) drop-shadow(0 0 1px #796d8b); }
18
+ [data-palette="ochre"] svg { filter: drop-shadow(0 1px 1px #81725f) drop-shadow(0 0 1px #81725f); }
19
+ rect { fill: #168b87; }
20
+ [data-palette="blue"] rect { fill: #3979d6; }
21
+ [data-palette="violet"] rect { fill: #8864cc; }
22
+ [data-palette="ochre"] rect { fill: #be7a22; }
23
+ [data-state="waiting"] rect { fill: #c4880f; }
24
+ [data-state="blocked"] rect { fill: #cf6156; }
25
+ [data-state="review"] rect { fill: #8a6cd8; }
26
+ [data-state="offline"] rect { fill: #6f7787; }
27
+ body[data-state="working"] #frame { animation: working-bob 0.7s steps(3, end) infinite; }
28
+ @keyframes working-bob {
29
+ 0%, 100% { transform: translateY(0); }
30
+ 50% { transform: translateY(-4px); }
31
+ }
32
+ @media (prefers-reduced-motion: reduce) {
33
+ body[data-state="working"] #frame { animation: none; }
34
+ }
35
+ #pill { display: flex; align-items: center; gap: 4px; border-radius: 14px; padding: 3px 7px; background: #242936ed; font-size: 11px; }
36
+ #pill button { padding: 2px 4px; }
37
+ #composer { width: 100%; background: #242936f5; padding: 4px; border-radius: 10px; box-sizing: border-box; }
38
+ #composer[hidden] { display: none; }
39
+ /* Hidden by opacity only: visibility would drop the #state live region from the a11y tree. */
40
+ #pill { opacity: 0; pointer-events: none; transition: opacity .12s ease; }
41
+ #pet:hover ~ #pill, #pet:focus-visible ~ #pill, #pill:hover, #pill:focus-within, body[data-hover] #pill, body[data-composing] #pill { opacity: 1; pointer-events: auto; }
42
+ input { width: 100%; box-sizing: border-box; border: 1px solid #657081; border-radius: 6px; padding: 5px; background: #111722; color: #eceef3; font: 13px system-ui; }
43
+ #result { margin-top: 2px; min-height: 12px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
44
+ </style>
45
+ <button id="pet" aria-label="Open agent-hub dashboard"><svg id="frame" role="img" aria-label="Pet" viewBox="0 0 60 64"></svg></button>
46
+ <div id="pill"><span id="state" aria-live="polite">Starting</span><button id="pencil" aria-label="Write a console message">✎</button><button id="close" aria-label="Close pet window">×</button></div>
47
+ <div id="composer" hidden><input id="message" aria-label="Console message" placeholder="Message, Enter to send" maxlength="65536"><div id="result" role="status">Esc to cancel</div></div>
48
+ <script>
49
+ const post = body => window.webkit.messageHandlers.pet.postMessage(body);
50
+ const pet = document.getElementById('pet'), frame = document.getElementById('frame'), pill = document.getElementById('pill');
51
+ const state = document.getElementById('state'), composer = document.getElementById('composer'), input = document.getElementById('message'), result = document.getElementById('result');
52
+ let signature = '', drag, sending = false;
53
+ const draw = (x, y, w, h) => {
54
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
55
+ rect.setAttribute('x', x); rect.setAttribute('y', y); rect.setAttribute('width', w); rect.setAttribute('height', h);
56
+ return rect;
57
+ };
58
+ window.update = view => {
59
+ if (view.offline) {
60
+ document.body.dataset.state = 'offline'; state.textContent = 'Offline';
61
+ document.getElementById('pencil').disabled = true; return;
62
+ }
63
+ document.getElementById('pencil').disabled = false;
64
+ document.body.dataset.state = view.state;
65
+ document.body.dataset.palette = view.palette;
66
+ state.textContent = view.state + (view.act ? ' · act ' + view.act : '') + (view.watch ? ' · watch ' + view.watch : '');
67
+ pet.setAttribute('aria-label', 'Open dashboard, ' + state.textContent);
68
+ const next = JSON.stringify(view.frame);
69
+ if (signature === next) return;
70
+ signature = next;
71
+ frame.setAttribute('viewBox', '0 0 ' + view.frame.width + ' ' + view.frame.height);
72
+ frame.replaceChildren(...view.frame.pixels.map(([x, y]) => draw(x, y, 1, 1)));
73
+ };
74
+ window.focusInput = () => { document.body.dataset.composing = ''; composer.hidden = false; input.focus(); };
75
+ window.commandResult = ok => { sending = false; input.disabled = false; result.textContent = ok ? 'Sent' : 'Not confirmed; check the dashboard.'; if (ok) { input.value = ''; composer.hidden = true; delete document.body.dataset.composing; } };
76
+ document.getElementById('pencil').addEventListener('click', () => post({ kind: 'pencil' }));
77
+ document.getElementById('close').addEventListener('click', () => post({ kind: 'close' }));
78
+ input.addEventListener('keydown', event => {
79
+ if (event.key === 'Escape') { composer.hidden = true; delete document.body.dataset.composing; pet.focus(); }
80
+ if (event.key === 'Enter' && !event.isComposing && input.value.trim() && !sending) {
81
+ event.preventDefault(); sending = true; input.disabled = true; result.textContent = 'Sending'; post({ kind: 'send', text: input.value });
82
+ }
83
+ });
84
+ // The native host polls the global mouse position against this rect (pet + pill) and
85
+ // calls hoverChanged, because an inactive accessory panel never fires CSS :hover.
86
+ window.hoverChanged = on => { if (on) document.body.dataset.hover = ''; else delete document.body.dataset.hover; };
87
+ const postHotspot = () => {
88
+ const a = pet.getBoundingClientRect(), b = pill.getBoundingClientRect();
89
+ const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
90
+ post({ kind: 'hotspot', x, y, w: Math.max(a.right, b.right) - x, h: Math.max(a.bottom, b.bottom) - y });
91
+ };
92
+ // While the panel is key, 1/2/3/4 switch the palette (blue/teal/violet/ochre) and save it.
93
+ const KEY_PALETTES = { '1': 'blue', '2': 'teal', '3': 'violet', '4': 'ochre' };
94
+ document.addEventListener('keydown', event => {
95
+ const palette = KEY_PALETTES[event.key];
96
+ if (!palette || event.target === input) return;
97
+ document.body.dataset.palette = palette;
98
+ post({ kind: 'palette', palette });
99
+ });
100
+ pet.addEventListener('pointerdown', event => { drag = { x: event.screenX, y: event.screenY, moved: false }; pet.setPointerCapture(event.pointerId); });
101
+ pet.addEventListener('pointermove', event => {
102
+ if (!drag) return;
103
+ const dx = event.screenX - drag.x, dy = event.screenY - drag.y;
104
+ if (!dx && !dy) return;
105
+ drag.moved = true; drag.x = event.screenX; drag.y = event.screenY;
106
+ post({ kind: 'drag', dx, dy });
107
+ });
108
+ pet.addEventListener('pointerup', () => { const moved = drag?.moved; drag = null; if (!moved) post({ kind: 'open' }); });
109
+ pet.addEventListener('pointercancel', () => { drag = null; });
110
+ pet.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); post({ kind: 'open' }); } });
111
+ postHotspot();
112
+ post({ kind: 'ready' });
113
+ </script>
114
+ </html>
@@ -0,0 +1,122 @@
1
+ // Local AppKit/WebKit surface. No shell evaluation or remote content.
2
+ ObjC.import('Cocoa');
3
+ ObjC.import('WebKit');
4
+ var panel, web, app, viewPath, positionPath, readyPath, cliArgv, timer, hoverTimer, bridge;
5
+ var offlineAt = 0, commands = [], exiting = false, hotspot = null, hovering = false;
6
+ function ignoreCompletion(result, error) {}
7
+ function evaluate(source) { web.evaluateJavaScriptCompletionHandler(source, ignoreCompletion); }
8
+ // An inactive accessory panel receives no mouse-moved events, so hover reveal is
9
+ // polled from the global mouse position against the hotspot rect the page posts.
10
+ function pollHover() {
11
+ if (!hotspot || exiting) return;
12
+ var mouse = $.NSEvent.mouseLocation, frame = panel.frame;
13
+ var x = mouse.x - frame.origin.x, y = frame.origin.y + frame.size.height - mouse.y;
14
+ var inside = x >= hotspot.x && x <= hotspot.x + hotspot.w && y >= hotspot.y && y <= hotspot.y + hotspot.h;
15
+ if (inside !== hovering) { hovering = inside; evaluate('hoverChanged(' + inside + ')'); }
16
+ }
17
+ function savePosition() {
18
+ var origin = panel.frame.origin;
19
+ $(JSON.stringify({ x: origin.x, y: origin.y })).writeToFileAtomicallyEncodingError(positionPath, true, $.NSUTF8StringEncoding, null);
20
+ }
21
+ function quit() { exiting = true; savePosition(); app.terminate(null); }
22
+ function runCommand(command, extra, argv) {
23
+ if (exiting || commands.length >= 8) return;
24
+ var task = $.NSTask.alloc.init;
25
+ task.executableURL = $.NSURL.fileURLWithPath(argv[0]);
26
+ var args = argv.slice(1).concat([command]).concat(extra);
27
+ task.arguments = $(args); // argv array only; message text is a single argument.
28
+ task.standardInput = $.NSFileHandle.fileHandleWithNullDevice;
29
+ task.standardOutput = $.NSFileHandle.fileHandleWithNullDevice;
30
+ task.standardError = $.NSFileHandle.fileHandleWithNullDevice;
31
+ if (task.launchAndReturnError(null)) commands.push({ task: task, command: command });
32
+ else evaluate('commandResult(false)');
33
+ }
34
+ ObjC.registerSubclass({ name: 'AhubPetPanel', superclass: 'NSPanel', methods: {
35
+ canBecomeKeyWindow: { types: ['bool', []], implementation: function() { return true; } },
36
+ canBecomeMainWindow: { types: ['bool', []], implementation: function() { return false; } }
37
+ } });
38
+ // JXA cannot resolve WKScriptMessageHandler by name on some macOS versions;
39
+ // WebKit invokes the exact Objective-C selector on this retained subclass.
40
+ ObjC.registerSubclass({ name: 'AhubPetBridge', superclass: 'NSObject', methods: {
41
+ 'userContentController:didReceiveScriptMessage:': { types: ['void', ['id', 'id']], implementation: function(controller, message) {
42
+ var body = ObjC.deepUnwrap(message.body);
43
+ if (!body || typeof body !== 'object' || exiting) return;
44
+ if (body.kind === 'ready') {
45
+ tick();
46
+ $('ready').writeToFileAtomicallyEncodingError(readyPath, true, $.NSUTF8StringEncoding, null);
47
+ } else if (body.kind === 'pencil') {
48
+ panel.orderFrontRegardless; panel.makeKeyWindow;
49
+ evaluate('focusInput()');
50
+ } else if (body.kind === 'drag' && typeof body.dx === 'number' && typeof body.dy === 'number' && isFinite(body.dx) && isFinite(body.dy) && Math.abs(body.dx) <= 2000 && Math.abs(body.dy) <= 2000) {
51
+ var origin = panel.frame.origin;
52
+ panel.setFrameOrigin($.NSMakePoint(origin.x + body.dx, origin.y - body.dy));
53
+ savePosition();
54
+ } else if (body.kind === 'hotspot' && typeof body.x === 'number' && typeof body.y === 'number' && typeof body.w === 'number' && typeof body.h === 'number' && isFinite(body.x) && isFinite(body.y) && Math.abs(body.x) <= 500 && Math.abs(body.y) <= 500 && body.w > 0 && body.h > 0 && body.w <= 500 && body.h <= 500) {
55
+ hotspot = { x: body.x, y: body.y, w: body.w, h: body.h };
56
+ } else if (body.kind === 'send' && typeof body.text === 'string' && body.text.trim() && body.text.length <= 65536 && body.text.indexOf('\0') === -1) {
57
+ runCommand('say', ['--', body.text], cliArgv);
58
+ } else if (body.kind === 'palette' && typeof body.palette === 'string' && /^(blue|teal|violet|ochre)$/.test(body.palette)) {
59
+ runCommand('pet', ['--palette', body.palette], cliArgv);
60
+ } else if (body.kind === 'open') runCommand('ui', [], cliArgv);
61
+ else if (body.kind === 'close') quit();
62
+ } },
63
+ 'tick:': { types: ['void', ['id']], implementation: function(sender) { tick(); } },
64
+ 'hover:': { types: ['void', ['id']], implementation: function(sender) { pollHover(); } }
65
+ } });
66
+ function tick() {
67
+ var value;
68
+ try { value = JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(viewPath, $.NSUTF8StringEncoding, null))); }
69
+ catch (error) { value = { offline: true }; }
70
+ evaluate('update(' + JSON.stringify(value) + ')');
71
+ if (value.offline === true) { if (!offlineAt) offlineAt = Date.now(); if (Date.now() - offlineAt >= 2000) quit(); }
72
+ else offlineAt = 0;
73
+ commands = commands.filter(function(item) {
74
+ if (item.task.isRunning) return true;
75
+ if (item.command === 'say') evaluate('commandResult(' + (Number(item.task.terminationStatus) === 0) + ')');
76
+ return false;
77
+ });
78
+ }
79
+ function run(argv) {
80
+ if (argv.length !== 3) throw new Error('Invalid pet window arguments');
81
+ viewPath = argv[0];
82
+ var htmlPath = argv[1];
83
+ cliArgv = JSON.parse(argv[2]);
84
+ if (!Array.isArray(cliArgv) || cliArgv.length !== 4 || cliArgv.some(function(arg) { return typeof arg !== 'string'; }) || cliArgv[2] !== '--project') throw new Error('Invalid CLI argv');
85
+ var stateDir = ObjC.unwrap($(viewPath).stringByDeletingLastPathComponent);
86
+ positionPath = stateDir + '/pet-window-pos.json';
87
+ var owner = JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(stateDir + '/pet-window.pid', $.NSUTF8StringEncoding, null)));
88
+ readyPath = stateDir + '/pet-window-ready-' + owner.token;
89
+ app = $.NSApplication.sharedApplication;
90
+ app.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
91
+ var screen = $.NSScreen.mainScreen.visibleFrame;
92
+ var W = 150, H = 150;
93
+ var origin = { x: screen.origin.x + screen.size.width - (W + 20), y: screen.origin.y + 80 };
94
+ try {
95
+ var saved = JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(positionPath, $.NSUTF8StringEncoding, null)));
96
+ if (typeof saved.x === 'number' && typeof saved.y === 'number' && isFinite(saved.x) && isFinite(saved.y)) origin = saved;
97
+ } catch (error) {}
98
+ var screens = $.NSScreen.screens;
99
+ var visible = false;
100
+ for (var i = 0; i < screens.count; i++) {
101
+ var frame = screens.objectAtIndex(i).visibleFrame;
102
+ if (origin.x >= frame.origin.x && origin.y >= frame.origin.y && origin.x + W <= frame.origin.x + frame.size.width && origin.y + H <= frame.origin.y + frame.size.height) visible = true;
103
+ }
104
+ if (!visible) origin = { x: screen.origin.x + screen.size.width - (W + 20), y: screen.origin.y + 80 };
105
+ panel = $.AhubPetPanel.alloc.initWithContentRectStyleMaskBackingDefer($.NSMakeRect(origin.x, origin.y, W, H), $.NSWindowStyleMaskBorderless | $.NSWindowStyleMaskNonactivatingPanel, $.NSBackingStoreBuffered, false);
106
+ panel.title = 'agent-hub pet'; panel.opaque = false; panel.backgroundColor = $.NSColor.clearColor;
107
+ panel.level = $.NSFloatingWindowLevel;
108
+ panel.collectionBehavior = $.NSWindowCollectionBehaviorCanJoinAllSpaces | $.NSWindowCollectionBehaviorStationary;
109
+ panel.hidesOnDeactivate = false;
110
+ panel.acceptsMouseMovedEvents = true; // CSS :hover while key; pollHover() covers the inactive case
111
+ var config = $.WKWebViewConfiguration.alloc.init;
112
+ bridge = $.AhubPetBridge.alloc.init;
113
+ config.userContentController.addScriptMessageHandlerName(bridge, 'pet');
114
+ web = $.WKWebView.alloc.initWithFrameConfiguration($.NSMakeRect(0, 0, W, H), config);
115
+ web.setValueForKey(false, 'drawsBackground');
116
+ panel.contentView = web;
117
+ web.loadFileURLAllowingReadAccessToURL($.NSURL.fileURLWithPath(htmlPath), $.NSURL.fileURLWithPath(htmlPath));
118
+ panel.orderFrontRegardless; panel.makeKeyWindow;
119
+ timer = $.NSTimer.scheduledTimerWithTimeIntervalTargetSelectorUserInfoRepeats(1, bridge, 'tick:', null, true);
120
+ hoverTimer = $.NSTimer.scheduledTimerWithTimeIntervalTargetSelectorUserInfoRepeats(0.1, bridge, 'hover:', null, true);
121
+ app.run;
122
+ }
@@ -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
+ }