@staix/agent-hub 0.12.25 → 0.12.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { realPath } from "../hub/project.ts";
3
5
  import { homedir } from "node:os";
4
6
  import { join } from "node:path";
5
7
  import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
@@ -9,7 +11,24 @@ export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
9
11
  /** Titles `ahub ask --remember` gives a model's answer (ask.ts ASK_NOTE_TITLE): never imported as a finding. */
10
12
  const MODEL_ANSWER = "ahub ask (model answer)";
11
13
 
12
- export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number; project: number } }
14
+ export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
15
+
16
+ /** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
17
+ function readCursors(file: string): Record<string, number> {
18
+ try {
19
+ const value: unknown = JSON.parse(readFileSync(file, "utf8"));
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
21
+ return Object.fromEntries(Object.entries(value).filter(([, id]) => Number.isSafeInteger(id) && id >= 0));
22
+ } catch { return {}; }
23
+ }
24
+ function writeCursor(file: string, key: string, cursor: number, log: (line: string) => void): void {
25
+ const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
26
+ try {
27
+ writeFileSync(temp, JSON.stringify({ ...readCursors(file), [key]: cursor }) + "\n", { mode: 0o600, flag: "wx" });
28
+ renameSync(temp, file);
29
+ } catch { log("claude-mem import progress could not be saved; the next run may replay handled rows"); }
30
+ finally { try { rmSync(temp, { force: true }); } catch { /* disposable progress */ } }
31
+ }
13
32
 
14
33
  /**
15
34
  * #258 phase 3: copy the hub's own notes from claude-mem (observations whose metadata names a `peer`: `hub_remember`
@@ -17,30 +36,38 @@ export interface ImportResult { read: number; added: number; skipped: { model: n
17
36
  * the projects in `chain` (claude-mem files them under the repository's basename). Model answers, PII-pattern text and secret-looking text are skipped.
18
37
  * Idempotent: an observation always maps to the same entry.
19
38
  */
20
- export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number }): ImportResult {
39
+ export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number; restart?: boolean; log?: (line: string) => void }): ImportResult {
21
40
  const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
22
41
  if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
42
+ const limit = opts.limit ?? 10_000;
43
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
44
+ const state = join(store.dir, "import-state.json");
45
+ const name = opts.chain.at(-1) ?? "";
46
+ const key = createHash("sha256").update(JSON.stringify([realPath(file), name, opts.project])).digest("hex");
47
+ let cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
23
48
  const db = new Database(file, { readonly: true });
24
49
  db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
25
- const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0, project: 0 } };
50
+ const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0 }, more: false, cursor };
26
51
  try {
27
- const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
28
- .all(opts.limit ?? 10_000) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
52
+ const max = db.query("SELECT MAX(id) AS id FROM observations WHERE project = ? AND metadata LIKE '%\"peer\"%'").get(name) as { id: number | null };
53
+ if ((max.id ?? 0) < cursor) cursor = 0;
54
+ const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE project = ? AND id > ? AND metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
55
+ .all(name, cursor, limit) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
29
56
  // The hub files its claude-mem notes under the repository's own name (the chain's last); the names above it are other
30
57
  // projects (a superproject) whose notes and privacy policy are not this one's. ponytail: claude-mem keys projects by
31
58
  // basename, so two unrelated repositories with one name still share it.
32
- const project = opts.project, names = new Set(opts.chain.slice(-1));
33
- for (const r of rows) {
34
- result.read++;
59
+ const project = opts.project;
60
+ result.read = rows.length;
61
+ result.more = rows.length === limit;
62
+ const handle = (r: typeof rows[number]) => {
35
63
  let meta: Record<string, unknown>;
36
- try { meta = JSON.parse(r.metadata ?? "{}"); } catch { continue; }
37
- if (typeof meta.peer !== "string" || !meta.peer) continue;
38
- if (!r.project || !names.has(r.project)) { result.skipped.project++; continue; }
64
+ try { meta = JSON.parse(r.metadata ?? "{}"); } catch { return; }
65
+ if (!meta || typeof meta !== "object" || typeof meta.peer !== "string" || !meta.peer) return;
39
66
  const title = r.title ?? "", text = (r.text ?? r.narrative ?? "").trim();
40
- if (!text) continue;
41
- if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; continue; }
42
- if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; continue; }
43
- if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; continue; }
67
+ if (!text) return;
68
+ if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; return; }
69
+ if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; return; }
70
+ if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; return; }
44
71
  const kind: Kind = KINDS.includes(meta.kind as Kind) ? meta.kind as Kind : "finding";
45
72
  const at = new Date(r.created_at_epoch ?? 0).toISOString();
46
73
  const { added } = store.importNote({ op: "note", project, scope: "project", tier: "long", kind, peer: meta.peer.slice(0, 32), ...(title ? { title } : {}), text,
@@ -48,8 +75,16 @@ export function importClaudeMem(store: MemoryStore, opts: { project: string; cha
48
75
  // own paths and symbols place the note in the graph.
49
76
  about: opts.nodes?.(`${title}\n${text}`) ?? [], source: "claude-mem" }, at);
50
77
  if (added) result.added++;
78
+ };
79
+ for (const row of rows) {
80
+ handle(row);
81
+ cursor = row.id; // only fully handled rows, including every skip
51
82
  }
52
- } finally { db.close(); }
83
+ } finally {
84
+ db.close();
85
+ result.cursor = cursor;
86
+ writeCursor(state, key, cursor, opts.log ?? console.error);
87
+ }
53
88
  store.refresh();
54
89
  return result;
55
90
  }
@@ -78,10 +78,10 @@ const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /
78
78
  export const looksSecret = (text: string): boolean => SECRETS.some((re) => re.test(text));
79
79
 
80
80
  /** `task`: the board task's id and proposal time; its node carries both, so a task id reused after `ahub reset --all` is another node. */
81
- export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number } }
81
+ export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number }; checkExists?: boolean }
82
82
  /**
83
83
  * Deterministic edges, fixed at write time so a rebuild never depends on the file system: the task's refs and plan
84
- * paths, plan symbols (with the path when the plan names exactly one), paths in the text that exist in the project then,
84
+ * paths, plan symbols (with the path when the plan names exactly one), paths in the text that exist in the project then (or text-only paths when checkExists is false),
85
85
  * `#N` as an issue of `repo`, and `task #N` as a board task. Paths stay relative to the project root; denylisted ones
86
86
  * are left out.
87
87
  */
@@ -99,7 +99,7 @@ export function extractNodes(text: string, x: Extract): Node[] {
99
99
  for (const m of text.matchAll(/(?:^|[\s(`'"])((?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]{1,8})(?=$|[\s)`'",:;])/g)) {
100
100
  const p = m[1]!;
101
101
  if (!p.includes("/") && !/\.(ts|js|tsx|jsx|py|rs|go|md|json|toml|yaml|yml|sh|html|css)$/.test(p)) continue;
102
- try { if (!isAbsolute(p) && !p.startsWith("..") && existsSync(join(x.cwd, p))) path(p); } catch { /* unreadable: not an edge */ }
102
+ try { if (!isAbsolute(p) && !p.startsWith("..") && (x.checkExists === false || existsSync(join(x.cwd, p)))) path(p); } catch { /* unreadable: not an edge */ }
103
103
  }
104
104
  for (const m of text.matchAll(/\btask #(\d{1,9})\b/gi)) out.add(`task:${x.project}#${m[1]}`);
105
105
  for (const m of text.matchAll(/(?<![\w/]|task )#(\d{1,9})\b/gi)) out.add(`issue:${x.repo}#${m[1]}`);
@@ -244,7 +244,7 @@ export class MemoryStore {
244
244
  // A fixed machine: the same observation imported on two machines is one entry once they sync.
245
245
  const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
246
246
  const id = entryId(body);
247
- // The observation, not the nodes derived from it (they follow the repository's files), says whether it is here already.
247
+ // The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
248
248
  const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
249
249
  if (known) return { id: known.id, added: false };
250
250
  appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
@@ -255,7 +255,55 @@ export class MemoryStore {
255
255
  private op(op: OpEntry["op"], target: View, actor: string, by?: string): string {
256
256
  return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local);
257
257
  }
258
- forget(id: string, actor: string, project?: string): View | undefined { const v = this.get(id, project); if (v) this.op("forget", v, actor); return v; }
258
+ forget(id: string, actor: string, project?: string): View | undefined {
259
+ const v = this.get(id, project);
260
+ if (!v) return undefined;
261
+ // Legacy imports may have different node-derived ids. Forget every known copy, including hidden ones.
262
+ const group = v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v];
263
+ for (const member of group) this.op("forget", member, actor);
264
+ return v;
265
+ }
266
+
267
+ /** Plan or append reconciliation of legacy imports, preserving every existing replacement relation. */
268
+ dedupe(project: string, apply = false): { groups: { members: View[]; heads: string[]; target: string }[]; applied: number } {
269
+ const rows = this.live(), byId = new Map(rows.map(v => [v.id, v]));
270
+ const tuples = new Map<string, View[]>();
271
+ for (const v of rows) {
272
+ if (v.project !== project || v.source !== "claude-mem") continue;
273
+ const key = JSON.stringify([v.at, v.text]);
274
+ const group = tuples.get(key) ?? []; group.push(v); tuples.set(key, group);
275
+ }
276
+ const groups: { members: View[]; heads: string[]; target: string }[] = [];
277
+ for (const members of tuples.values()) {
278
+ if (members.length < 2) continue;
279
+ members.sort((a, b) => a.id.localeCompare(b.id));
280
+ const heads = members.filter(v => !v.supersededBy);
281
+ if (!heads.length) continue;
282
+ const ids = new Set(members.map(v => v.id));
283
+ const terminals = new Map<string, View>();
284
+ for (const v of members) {
285
+ let terminal = v;
286
+ const seen = new Set<string>();
287
+ while (terminal.supersededBy && !seen.has(terminal.id)) {
288
+ seen.add(terminal.id);
289
+ const next = byId.get(terminal.supersededBy);
290
+ if (!next) break;
291
+ terminal = next;
292
+ }
293
+ if (!terminal.supersededBy && !ids.has(terminal.id) && !terminal.local && (terminal.project === project || terminal.scope === "shared") && heads.every(h => h.scope !== "shared" || terminal.scope === "shared")) terminals.set(terminal.id, terminal);
294
+ }
295
+ const outside = [...terminals.values()].sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))[0];
296
+ if (!outside && heads.length === 1) continue;
297
+ groups.push({ members, heads: heads.map(v => v.id), target: outside?.id ?? heads[0]!.id });
298
+ }
299
+ let applied = 0;
300
+ if (apply) for (const group of groups) for (const head of group.heads) {
301
+ if (head === group.target) continue;
302
+ if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
303
+ applied++;
304
+ }
305
+ return { groups, applied };
306
+ }
259
307
  /** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */
260
308
  keepShort(id: string, actor: string): void { const v = this.get(id); if (v?.tier === "short" && v.local && v.expiresAt === null) this.op("demote", v, actor, this.until()); } // once: a deadline is never pushed out
261
309
  demote(id: string, actor: string, project?: string): View | undefined { const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, this.until()); return v; }
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
+ }