@staix/agent-hub 0.12.26 → 0.12.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
5
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
14
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
21
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
38
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
46
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -98,7 +98,7 @@
98
98
  ]
99
99
  },
100
100
  "docs/agent-notes/daemon.md": {
101
- "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
101
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
102
102
  "paths": [
103
103
  "src/hub/daemon.ts",
104
104
  "src/hub/control-client.ts",
@@ -155,7 +155,7 @@
155
155
  ]
156
156
  },
157
157
  "docs/agent-notes/tasks.md": {
158
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
158
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
159
159
  "paths": [
160
160
  "src/hub/tasks.ts",
161
161
  "src/hub/board.ts",
@@ -172,7 +172,7 @@
172
172
  ]
173
173
  },
174
174
  "docs/agent-notes/tests.md": {
175
- "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
175
+ "verifiedAgainst": "59dd48030a3272d984a738017d22bcecc66217e6",
176
176
  "paths": [
177
177
  "test/",
178
178
  "scripts/check.sh",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.26",
3
+ "version": "0.12.27",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,8 @@
24
24
  "files": [
25
25
  ".claude-plugin",
26
26
  "src",
27
+ "src/pet/window.jxa.js",
28
+ "src/pet/window.html",
27
29
  "plugins",
28
30
  "templates",
29
31
  "scripts/check-docs.mjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.26",
3
+ "version": "0.12.27",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -16157,7 +16157,7 @@ import { join as join4 } from "path";
16157
16157
  // package.json
16158
16158
  var package_default = {
16159
16159
  name: "@staix/agent-hub",
16160
- version: "0.12.26",
16160
+ version: "0.12.27",
16161
16161
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
16162
16162
  license: "MIT",
16163
16163
  type: "module",
@@ -16181,6 +16181,8 @@ var package_default = {
16181
16181
  files: [
16182
16182
  ".claude-plugin",
16183
16183
  "src",
16184
+ "src/pet/window.jxa.js",
16185
+ "src/pet/window.html",
16184
16186
  "plugins",
16185
16187
  "templates",
16186
16188
  "scripts/check-docs.mjs",
package/src/cli/help.ts CHANGED
@@ -18,6 +18,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
18
18
  ["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
19
19
  ["ahub console [--panels] [--color=auto|always|never]", "enter the human console; P cycles built-in pet characters/off; exiting leaves the daemon running"],
20
20
  ["ahub console --no-pet", "turn off attention for this session; combines with --panels and --color"],
21
+ ["ahub pet --window [--close]", "start or close the macOS floating window, from a person's terminal"],
21
22
  ["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
22
23
  ["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
23
24
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
@@ -28,6 +28,7 @@ export type PeerCommandAccess = "allowed" | "conductor" | "console";
28
28
 
29
29
  /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
30
30
  export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
31
+ if (command === "pet" && args.includes("--window")) return "console";
31
32
  if (command === "permission" || command === "stop" || command === "watch") return "console";
32
33
  if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
33
34
  if (command === "role") return ["list", "show"].includes(args[0] ?? "") ? "allowed" : "console";
package/src/cli/main.ts CHANGED
@@ -5,7 +5,8 @@ import { projectedDiff } from "../hub/projected-diff.ts";
5
5
  import { TextProjections } from "../hub/text-projection.ts";
6
6
  import { terminalText } from "./console-state.ts";
7
7
  import { petView, readPet, renderPet } from "./pet.ts";
8
- import { loadPetConfig } from "./pet-config.ts";
8
+ import { startPetWindow, closePetWindow } from "../pet/window.ts";
9
+ import { loadPetConfig, savePetPalette } from "./pet-config.ts";
9
10
  import { profileName, projectProfile, resolveProfile } from "../hub/profiles.ts";
10
11
  import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
11
12
  import { attachWatch } from "./watch.ts";
@@ -677,6 +678,8 @@ const commands: Record<string, () => Promise<void> | void> = {
677
678
  if (unattended) console.error(UNATTENDED_WARNING);
678
679
  const status = await startProject(registeredProject(), { unattended });
679
680
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
681
+ try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
682
+ catch { console.error("Pet window was not started; run ahub pet --window to inspect it."); }
680
683
  if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir });
681
684
  },
682
685
 
@@ -1359,8 +1362,17 @@ const commands: Record<string, () => Promise<void> | void> = {
1359
1362
  hub.close();
1360
1363
  },
1361
1364
 
1362
- pet: () => {
1363
- if (args.some(arg => arg !== "--short")) return fail("usage: ahub pet [--short]");
1365
+ pet: async () => {
1366
+ if (args.includes("--window")) {
1367
+ if (args.some(arg => !["--window", "--close"].includes(arg))) return fail("usage: ahub pet --window [--close]");
1368
+ return console.log(await (args.includes("--close") ? closePetWindow(stateDir) : startPetWindow(stateDir, cwd)));
1369
+ }
1370
+ if (args.includes("--palette")) {
1371
+ if (args.length !== 2 || args[0] !== "--palette") return fail("usage: ahub pet [--short] | --window [--close] | --palette <blue|teal|violet|ochre>");
1372
+ try { return console.log(savePetPalette(cwd, args[1]!)); }
1373
+ catch (error) { return fail((error as Error).message); }
1374
+ }
1375
+ if (args.some(arg => arg !== "--short")) return fail("usage: ahub pet [--short] | --window [--close] | --palette <blue|teal|violet|ochre>");
1364
1376
  const short = args.includes("--short");
1365
1377
  let character: import("../pet/frame.ts").CharacterId | undefined;
1366
1378
  try { character = loadPetConfig(cwd).character; } catch { /* explicit structural reads remain available with malformed presentation config */ }
@@ -1 +1 @@
1
- export { loadPetConfig, type PetConfig } from "../pet/config.ts";
1
+ export { loadPetConfig, savePetPalette, type PetConfig } from "../pet/config.ts";
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
+ }