@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/CHANGELOG.md +13 -0
- package/README.md +3 -3
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/tests.md +1 -0
- package/docs/operations.md +119 -27
- package/docs/quickstart.md +1 -1
- package/docs/security.md +34 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +111 -27
- package/docs/specs/2026-10-12-office-view-design.md +55 -11
- package/docs/verification/2026-10-15-0.12.27.md +31 -0
- package/docs/verification/assets/391-pet-window-spike.png +0 -0
- package/docs/verified.json +11 -11
- package/package.json +3 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +26 -5
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/pi.ts +12 -10
- package/src/cli/help.ts +2 -1
- package/src/cli/identity.ts +2 -0
- package/src/cli/main.ts +21 -6
- package/src/cli/pet-config.ts +1 -1
- package/src/cli/preview.ts +7 -1
- package/src/hub/bus.ts +9 -0
- package/src/hub/daemon.ts +30 -7
- package/src/hub/ui.ts +1 -3
- package/src/pet/config.ts +42 -5
- package/src/pet/window.html +114 -0
- package/src/pet/window.jxa.js +122 -0
- package/src/pet/window.ts +154 -0
- package/src/pi/session.ts +28 -0
- package/src/pi/tool-receipts.ts +1 -0
- package/src/ui/index.html +318 -129
- package/src/ui/office-model.ts +12 -12
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 (
|
|
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("/*
|
|
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
|
|
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.
|
|
26
|
-
|
|
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
|
+
}
|