@staix/agent-hub 0.30.0 → 0.31.0

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
package/src/ui/plugin.ts CHANGED
@@ -8,22 +8,27 @@
8
8
  *
9
9
  * Contract: @staix/agent-console api 1, declared structurally below so agent-hub keeps zero runtime dependencies.
10
10
  */
11
+ import type { ActionMeta } from "@staix/agent-console/contract";
11
12
  import { createHash } from "node:crypto";
12
13
  import { join } from "node:path";
13
14
  import { Registry } from "../hub/registry.ts";
14
- import { projectConsole, type Lifecycle } from "../hub/manager.ts";
15
+ import { projectConsole, type Lifecycle, type ManagerOptions } from "../hub/manager.ts";
15
16
  // lifecycle.ts reaches the whole daemon; it is imported only when a project is inspected, started or stopped.
16
17
  import { DASHBOARD_VIEWS, renderDashboard } from "../hub/ui.ts";
17
18
  import { officeModel, type OfficeScene } from "./office-model.ts";
19
+ import { personEvidence, questionItem, QUESTION_ITEM, type InboxAnswers, type QuestionRowView } from "./plugin-questions.ts";
20
+ import { canPublishResults, receiptEvidence, resultItem, RESULT_ACTIONS, RESULT_ITEM, type ResultAction, type ResultRowView } from "./plugin-results.ts";
18
21
  import { mergeSpaces, officeSpace } from "./office-space.ts";
19
22
  import { VERSION as version } from "../version.ts";
20
23
 
21
24
  type Tone = "ok" | "warn" | "err" | "neutral";
22
25
  interface InboxItem {
23
- id: string; group: "decision" | "review" | "candidate" | "alert"; kind: string; title: string; detail?: string; actor?: string; project?: string;
26
+ id: string; group: "decision" | "review" | "result" | "candidate" | "alert"; kind: string; title: string; detail?: string; actor?: string; project?: string;
24
27
  at: string; chip?: { text: string; tone: Tone };
25
28
  verbs: { id: string; label: string; primary?: boolean; destructive?: boolean; reason?: "optional" | "required" }[];
26
29
  link: { panel: string; params?: Record<string, string> };
30
+ /** #1054: a `result` item carries the action acon runs once the person has seen it. */
31
+ seen?: { action: string };
27
32
  }
28
33
  type ActionResult =
29
34
  | ({ ok: true } & Record<string, unknown>)
@@ -32,7 +37,7 @@ type ActionResult =
32
37
  export interface ConsoleProvider {
33
38
  assets: { js: string; css?: string };
34
39
  snapshot(input: { after: number; panel: string } & Record<string, unknown>): Promise<Record<string, unknown>>;
35
- action(input: { action: string; confirmed?: true; fingerprint?: string } & Record<string, unknown>): Promise<ActionResult>;
40
+ action(input: { action: string; confirmed?: true; fingerprint?: string } & Record<string, unknown>, meta?: ActionMeta): Promise<ActionResult>;
36
41
  close?(): Promise<void>;
37
42
  }
38
43
  export interface ConsolePlugin {
@@ -41,15 +46,20 @@ export interface ConsolePlugin {
41
46
  panels: { id: string; title: string; order?: number }[];
42
47
  capabilities?: { actions?: string[]; confirm?: boolean; multiProject?: boolean; inbox?: boolean; space?: boolean };
43
48
  };
44
- open(ctx: { home: string; log(line: string): void }): Promise<ConsoleProvider>;
49
+ open(ctx: { home: string; log(line: string): void; features?: readonly string[] }): Promise<ConsoleProvider>;
45
50
  }
46
51
 
47
52
  const TITLES: Record<(typeof DASHBOARD_VIEWS)[number], string> = { board: "Board", office: "Office", stream: "Stream", agents: "Agents", message: "Message", memory: "Memory", settings: "Settings" };
48
53
  /** The dashboard's own actions plus the inbox verbs; the manager whitelist is still applied to each forwarded call. */
49
54
  const FORWARDED = ["start", "stop", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release", "worktree_override_set", "worktree_override_clear", "worktrees_refresh_prs"] as const;
55
+ /** Advertised so acon lets the page send it, never forwarded: `action()` answers it through its own port (`answerVerb`), after the console's person meta is checked (#1053 T5). */
56
+ const QUESTION_ACTIONS = ["answer"] as const;
57
+ /** #1054 T6: the receipt actions, advertised so acon lets the inbox and the page send them, never forwarded: `action()` checks the console's person meta, then calls the dedicated `projectConsole(...).receipt` port (`receiptVerb`). */
58
+ const RESULT_ADVERTISED = RESULT_ACTIONS;
59
+ const REVIEW_ACTIONS = ["review_comment_put", "review_comment_delete", "review_comment_send"] as const;
50
60
  const INBOX_VERBS = ["approve", "deny", "open"] as const;
51
61
  /** Outward or destructive: answered needsConfirm first (agent-console AGENTS.md); previews are read-only and stay direct. */
52
- const CONFIRMED = new Set(["stop", "permit", "setting", "setting_undo", "setting_reload", "config_edit_apply", "start_peer", "backlog_split", "send", "approve", "deny", "worktree_override_set", "worktrees_refresh_prs"]);
62
+ const CONFIRMED = new Set(["review_comment_delete", "review_comment_send", "stop", "permit", "setting", "setting_undo", "setting_reload", "config_edit_apply", "start_peer", "backlog_split", "send", "approve", "deny", "worktree_override_set", "worktrees_refresh_prs"]);
53
63
  const fingerprintOf = (input: Record<string, unknown>) => {
54
64
  const { confirmed: _c, fingerprint: _f, reason: _r, ...rest } = input;
55
65
  return createHash("sha256").update(JSON.stringify(Object.keys(rest).sort().map((key) => [key, rest[key]]))).digest("hex");
@@ -72,6 +82,7 @@ export function panelAssets(): { js: string; css: string } {
72
82
  body = swap(body, "let ticket = location.hash.slice(1);\nhistory.replaceState(null, '', location.pathname);", "let ticket = '';");
73
83
  body = swap(body, "const apply = () => { document.documentElement.dataset.theme = choice; };", "const apply = () => {};");
74
84
  body = swap(body, "const $ = (id) => $$(id);", "const $ = $$;");
85
+ body = swap(body, "const reviewKit = null;", "const reviewKit = typeof host.ui.review === 'function' ? {render:host.ui.review.bind(host.ui),snapshot:input=>host.snapshot(input)} : null;");
75
86
  // post() goes to the host: /projects is the manager listing, /snapshot and /action are the plugin's panel calls.
76
87
  body = swap(body, "async function post(path, body) {\n const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});\n if (response.status === 401) { expire(); throw new Error('Run ahub ui to start a new session.'); }\n const result = await response.json();",
77
88
  "async function post(path, body) {\n const result = path === '/projects' ? await host.snapshot({ panel: 'board' }) : path === '/snapshot' ? await host.snapshot({ ...body, panel: 'board' }) : await host.action(body);\n if (!result) { expire(); throw new Error('The console session ended.'); }\n const response = { ok: result.ok !== false, status: 200 };");
@@ -135,6 +146,10 @@ ${body}
135
146
  }
136
147
 
137
148
  interface ProjectRow { id: string; root: string; state: string; instanceId: string | null }
149
+ /** #727: the inbox reply is bounded; `more` counts what was left out. Most urgent first: a decision waits on the person, then a review, a result, a candidate, an alert. */
150
+ const INBOX_MAX_ITEMS = 50;
151
+ const INBOX_GROUP_RANK: Record<InboxItem["group"], number> = { decision: 0, review: 1, result: 2, candidate: 3, alert: 4 };
152
+ interface InboxReply { items: InboxItem[]; more: number }
138
153
 
139
154
  /** lifecycle.ts reaches the whole daemon (and files acon's import scan cannot parse); load it on first use only. */
140
155
  function lazyLifecycle(): Lifecycle {
@@ -147,11 +162,11 @@ function lazyLifecycle(): Lifecycle {
147
162
  }
148
163
 
149
164
  /** Build the provider over an injectable registry and lifecycle (tests use a temp home and fakes). */
150
- export function createProvider(options: { home: string; registry?: Registry; lifecycle?: Lifecycle; log?: (line: string) => void; spaceBudgetMs?: number }): ConsoleProvider {
165
+ export function createProvider(options: { home: string; registry?: Registry; lifecycle?: Lifecycle; log?: (line: string) => void; spaceBudgetMs?: number; features?: readonly string[]; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"]; inboxFreshMs?: number; inboxStaleMs?: number; inboxProjectBudgetMs?: number }): ConsoleProvider {
151
166
  const registry = options.registry ?? new Registry(join(options.home, "registry.db"));
152
167
  const owned = !options.registry;
153
168
  const lifecycle = options.lifecycle ?? lazyLifecycle();
154
- const { list, act } = projectConsole({ registry, lifecycle, home: options.home });
169
+ const { list, act, answer, receipt, consoleControl } = projectConsole({ registry, lifecycle, home: options.home, forwardTimeoutMs: options.forwardTimeoutMs });
155
170
  const assets = panelAssets();
156
171
  const seen = new Map<string, string>(); // inbox item id -> first-seen ISO time, so `at` stays stable
157
172
 
@@ -159,11 +174,57 @@ export function createProvider(options: { home: string; registry?: Registry; lif
159
174
  const listing = await list() as { projects: ProjectRow[] };
160
175
  return listing.projects.filter((p) => p.state === "running" && p.instanceId);
161
176
  }
162
- async function inbox(): Promise<InboxItem[]> {
177
+ /**
178
+ * #730 r1200-m1: the project listing asks every daemon for its status, so a slow one holds it. `space()` and `inbox()` both bound it to `ms` and fall back to the last listing.
179
+ * One listing is in flight at a time and both callers join it, so a listing that hangs is asked once, not once per poll. A listing that finishes after its budget still becomes the fallback.
180
+ */
181
+ let lastRunning: ProjectRow[] = [];
182
+ let listingFlight: Promise<ProjectRow[]> | undefined;
183
+ function runningWithin(ms: number): Promise<ProjectRow[]> {
184
+ if (!listingFlight) {
185
+ const flight: Promise<ProjectRow[]> = running().then((rows) => { lastRunning = rows; return rows; }, () => lastRunning)
186
+ .finally(() => { if (listingFlight === flight) listingFlight = undefined; });
187
+ listingFlight = flight;
188
+ }
189
+ return within(listingFlight, ms).then((rows) => rows ?? lastRunning);
190
+ }
191
+ /**
192
+ * #727: one project's items from its last answer, reused for a project that did not answer in time, so a slow daemon neither blanks its rows nor restarts their `at`.
193
+ * Fenced by the action generation on both sides: an entry is written only by a computation that no action has overtaken, and read only in the generation it was written in, so an approval
194
+ * or an answer that has been given never comes back from here (a late project is then simply left out of that poll, which is the safe side).
195
+ */
196
+ const lastProjectItems = new Map<string, { generation: number; items: InboxItem[] }>();
197
+ /**
198
+ * #730 r1200-m2: a project's `agents` snapshot in flight. `within()` stops waiting at the budget but the snapshot keeps running, so the next refresh joins it while it is pending instead of asking the
199
+ * same slow project again (one question per slow project, not one per poll). Joined only in the generation it started in, so an answer that began before an action never serves a poll after it.
200
+ */
201
+ const projectFlights = new Map<string, { generation: number; instanceId: string | null; promise: Promise<Record<string, unknown> | null> }>();
202
+ /** The project's `agents` snapshot, or null when it did not answer within the budget or threw: the other projects' rows are never held back by it. */
203
+ const projectSnapshot = (project: ProjectRow) => {
204
+ let flight = projectFlights.get(project.id);
205
+ if (!flight || flight.generation !== inboxGeneration || flight.instanceId !== project.instanceId) {
206
+ const promise: Promise<Record<string, unknown> | null> = Promise.resolve(act({ action: "snapshot", after: 0, projectId: project.id, instanceId: project.instanceId, view: "agents" }) as Promise<Record<string, unknown>>)
207
+ .then((snap) => snap, () => null)
208
+ .finally(() => { if (projectFlights.get(project.id)?.promise === promise) projectFlights.delete(project.id); });
209
+ flight = { generation: inboxGeneration, instanceId: project.instanceId, promise };
210
+ projectFlights.set(project.id, flight);
211
+ }
212
+ return within(flight.promise, INBOX_PROJECT_BUDGET_MS);
213
+ };
214
+ async function inbox(generation: number): Promise<InboxItem[]> {
163
215
  const items: InboxItem[] = [];
164
- for (const project of await running()) {
165
- const snap = await act({ action: "snapshot", after: 0, projectId: project.id, instanceId: project.instanceId, view: "agents" }) as Record<string, unknown>;
166
- if (snap.ok === false) continue;
216
+ const projects = await runningWithin(Math.round(INBOX_PROJECT_BUDGET_MS / 2));
217
+ // The projects are asked at once: the answer takes as long as the slowest daemon, not the sum of them (#727).
218
+ const snaps = await Promise.all(projects.map(projectSnapshot));
219
+ for (const [index, project] of projects.entries()) {
220
+ const snap = snaps[index];
221
+ if (snap === null || snap === undefined) {
222
+ const last = lastProjectItems.get(project.id);
223
+ if (last && last.generation === inboxGeneration) items.push(...last.items);
224
+ continue;
225
+ }
226
+ if (snap.ok === false) { lastProjectItems.delete(project.id); continue; }
227
+ const firstItem = items.length;
167
228
  const name = project.root.split("/").filter(Boolean).pop() ?? project.id;
168
229
  for (const p of (snap.permissions as { id: string; peer: string; title: string; createdAt?: number; terminalOnly?: boolean; options: { optionId: string; kind: string; name: string }[] }[] | undefined) ?? []) {
169
230
  const id = `perm:${project.id}:${p.id}`;
@@ -176,6 +237,24 @@ export function createProvider(options: { home: string; registry?: Registry; lif
176
237
  verbs: [...(allow ? [{ id: "approve", label: "Allow once", primary: true }] : []), ...(deny ? [{ id: "deny", label: "Deny", destructive: true }] : [])],
177
238
  link: { panel: "agents", params: { project: project.id } } });
178
239
  }
240
+ // #1053 T5: the open questions peers asked the person. A private question's title is a stub; the form (the person's own answer surface) carries the text.
241
+ for (const q of (snap.questions as QuestionRowView[] | undefined) ?? []) {
242
+ if (!q || typeof q.set !== "string" || !Array.isArray(q.questions) || !q.questions.length) continue;
243
+ const item = questionItem(q, { id: project.id, name }, options.features);
244
+ seen.set(item.id, item.at);
245
+ items.push(item);
246
+ }
247
+ // #1054 T6: unread results, only to a console that renders the `result` group AND gives a receipt person evidence. Otherwise they stay in the ahub panel, `ahub console`, `ahub status`
248
+ // and `ahub results`. A row that is not well formed is dropped, never rendered; the detail is what the hub built at display (a stub when private).
249
+ if (canPublishResults(options.features)) {
250
+ const results = (snap.results as { unread?: ResultRowView[] } | undefined)?.unread;
251
+ for (const row of Array.isArray(results) ? results : []) {
252
+ if (!row || typeof row.id !== "string" || !/^cmp-[a-f0-9]{12}$/.test(row.id) || typeof row.peer !== "string" || !Number.isSafeInteger(row.settledAt)) continue;
253
+ const item = resultItem(row, { id: project.id, name });
254
+ seen.set(item.id, item.at);
255
+ items.push(item);
256
+ }
257
+ }
179
258
  // #1057 D4: one alert when the shelf holds more settled worktrees than `worktrees.alert_over` (0 = never). Counts only: no path, branch or title, and no action
180
259
  // beyond opening the panel, because removal is the person's `ahub worktrees tidy` and never a button.
181
260
  const shelf = snap.worktrees as { settled?: unknown[]; alertOver?: number } | undefined;
@@ -195,13 +274,46 @@ export function createProvider(options: { home: string; registry?: Registry; lif
195
274
  items.push({ id, group: "review", kind: "review", title: `#${t.id} ${t.title}`.slice(0, 200), ...(t.owner ? { actor: t.owner } : {}), project: name, at,
196
275
  chip: { text: "review", tone: "neutral" }, verbs: [{ id: "open", label: "Open board", primary: true }], link: { panel: "board", params: { project: project.id } } });
197
276
  }
277
+ if (generation === inboxGeneration) lastProjectItems.set(project.id, { generation, items: items.slice(firstItem) });
198
278
  }
199
- // Forget ids that left the inbox, so `seen` stays the size of what is waiting now.
279
+ for (const id of lastProjectItems.keys()) if (!projects.some((project) => project.id === id)) lastProjectItems.delete(id);
280
+ // Forget ids that left the inbox, so `seen` stays the size of what is waiting now. This runs on the whole list, before the cap, so a row below the cap keeps its `at`.
200
281
  const current = new Set(items.map((item) => item.id));
201
282
  for (const id of seen.keys()) if (!current.has(id)) seen.delete(id);
202
283
  return items;
203
284
  }
204
285
 
286
+ /**
287
+ * #727: what acon polls. The expensive part (every running project's `agents` snapshot) is shared and cached:
288
+ * - concurrent snapshots join one computation in flight;
289
+ * - a result younger than `inboxFreshMs` is served as it is;
290
+ * - a result younger than `inboxStaleMs` is served at once while one refresh runs in the background (nothing runs while nobody polls);
291
+ * - older, or after any action (an approval or an answer changes what is waiting), the caller waits for a fresh computation.
292
+ * The reply is bounded: at most INBOX_MAX_ITEMS items, the most urgent first, and `more` says how many were left out.
293
+ */
294
+ const INBOX_FRESH_MS = options.inboxFreshMs ?? 1_000, INBOX_STALE_MS = options.inboxStaleMs ?? 10_000, INBOX_PROJECT_BUDGET_MS = options.inboxProjectBudgetMs ?? 2_000;
295
+ let inboxGeneration = 0, inboxCache: { generation: number; at: number; value: InboxReply } | undefined, inboxFlight: { generation: number; promise: Promise<InboxReply> } | undefined;
296
+ function inboxComputation(): Promise<InboxReply> {
297
+ if (inboxFlight && inboxFlight.generation === inboxGeneration) return inboxFlight.promise;
298
+ const generation = inboxGeneration;
299
+ const promise: Promise<InboxReply> = inbox(generation).then((all) => {
300
+ const ordered = [...all].sort((a, b) => INBOX_GROUP_RANK[a.group] - INBOX_GROUP_RANK[b.group] || (a.at < b.at ? -1 : a.at > b.at ? 1 : 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
301
+ const value: InboxReply = { items: ordered.slice(0, INBOX_MAX_ITEMS), more: Math.max(0, ordered.length - INBOX_MAX_ITEMS) };
302
+ if (!inboxCache || inboxCache.generation <= generation) inboxCache = { generation, at: Date.now(), value };
303
+ return value;
304
+ }).finally(() => { if (inboxFlight?.promise === promise) inboxFlight = undefined; });
305
+ inboxFlight = { generation, promise };
306
+ return promise;
307
+ }
308
+ async function inboxSnapshot(): Promise<InboxReply> {
309
+ if (inboxCache && inboxCache.generation === inboxGeneration) {
310
+ const age = Date.now() - inboxCache.at;
311
+ if (age < INBOX_FRESH_MS) return inboxCache.value;
312
+ if (age < INBOX_STALE_MS) { void inboxComputation().catch(() => {}); return inboxCache.value; }
313
+ }
314
+ return await inboxComputation();
315
+ }
316
+
205
317
  /**
206
318
  * #1012 Part 2: every running project's office as one acon SpaceScene. The office model is kept per project as
207
319
  * `previous`, as the dashboard keeps it, so desk slots and message arcs persist across polls. A restarted daemon
@@ -218,11 +330,8 @@ export function createProvider(options: { home: string; registry?: Registry; lif
218
330
  const late = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), ms); });
219
331
  try { return await Promise.race([work, late]); } catch { return null; } finally { clearTimeout(timer); }
220
332
  };
221
- let lastRunning: ProjectRow[] = [];
222
333
  async function space() {
223
- // A listing that finishes after its budget still becomes the fallback for the next poll.
224
- const listing = running().then((rows) => { lastRunning = rows; return rows; }, () => lastRunning);
225
- const projects = await within(listing, Math.round(SPACE_BUDGET_MS / 2)) ?? lastRunning;
334
+ const projects = await runningWithin(Math.round(SPACE_BUDGET_MS / 2));
226
335
  for (const id of offices.keys()) if (!projects.some((p) => p.id === id)) offices.delete(id);
227
336
  const scenes = [];
228
337
  const snaps = await Promise.all(projects.map((project) =>
@@ -239,10 +348,45 @@ export function createProvider(options: { home: string; registry?: Registry; lif
239
348
  return mergeSpaces(scenes);
240
349
  }
241
350
 
351
+ /**
352
+ * #1053 T5: an answer to a question item. Only with the console's own person meta (checked by the caller); the project and set come from the item id, never from the
353
+ * page. The hub re-validates every answer against the stored row and names a stale or superseded set's terminal state.
354
+ */
355
+ async function answerVerb(input: Record<string, unknown>, person: string): Promise<ActionResult> {
356
+ const m = typeof input.id === "string" ? QUESTION_ITEM.exec(input.id) : null;
357
+ if (!m) return { ok: false, error: "unknown inbox item" };
358
+ const project = (await running()).find((p) => p.id === m[1]);
359
+ if (!project) return { ok: false, error: "project is not running" };
360
+ const result = await answer({ projectId: project.id, instanceId: project.instanceId }, { questionSet: m[2]!, answers: input.answers as InboxAnswers, person }) as Record<string, unknown>;
361
+ return result.ok === true ? { ok: true, message: "Answered" } : { ok: false, error: String(result.error ?? "the answer was refused") };
362
+ }
363
+
364
+ /**
365
+ * #1054 T6: a receipt for the person: one result read (the inbox's `result_seen`, whose item id names the project and the completion; the panel's carries the same ids), all read, or the
366
+ * last read unread again. Only with the console's own person meta (checked by the caller). The project comes from the item id or, from the panel, from the project it is showing; the
367
+ * hub re-checks that the result exists and is unread, and names a stale one. `seenAt` and `explicit` are acon's and are not forwarded: the hub stamps its own time.
368
+ */
369
+ async function receiptVerb(name: ResultAction, input: Record<string, unknown>): Promise<ActionResult> {
370
+ let projectId: string | undefined, id: string | undefined;
371
+ if (name === "result_seen") {
372
+ const m = typeof input.id === "string" ? RESULT_ITEM.exec(input.id) : null;
373
+ if (m) { projectId = m[1]; id = m[2]; }
374
+ else if (typeof input.projectId === "string" && typeof input.id === "string" && /^cmp-[a-f0-9]{12}$/.test(input.id)) { projectId = input.projectId; id = input.id; }
375
+ if (!projectId || !id) return { ok: false, error: "unknown result" };
376
+ } else projectId = typeof input.projectId === "string" ? input.projectId : undefined;
377
+ const project = (await running()).find((p) => p.id === projectId);
378
+ if (!project) return { ok: false, error: "project is not running" };
379
+ const target = { projectId: project.id, instanceId: project.instanceId };
380
+ const result = (name === "result_seen" ? await receipt(target, { op: "result_seen", id: id! })
381
+ : await receipt(target, { op: "results", action: name === "results_read_all" ? "read-all" : "unread-last" })) as Record<string, unknown>;
382
+ if (result.ok !== true) return { ok: false, error: String(result.error ?? "the receipt was refused") };
383
+ return { ok: true, message: name === "result_seen" ? "Marked read" : name === "results_read_all" ? `Marked ${Number(result.marked) || 0} read` : "Marked unread again" };
384
+ }
385
+
242
386
  /** approve/deny resolve to the hub's own `permit` with the request's option; nothing else is reachable from an item. */
243
387
  async function inboxVerb(input: Record<string, unknown>): Promise<ActionResult> {
244
388
  const m = typeof input.id === "string" ? /^perm:([^:]+):(.+)$/.exec(input.id) : null;
245
- if (input.action === "open") return { ok: true, message: typeof input.id === "string" && input.id.startsWith("worktrees:") ? "Open the Agents panel to review the shelf." : "Open the Board panel to review." };
389
+ if (input.action === "open") return { ok: true, message: typeof input.id === "string" && (input.id.startsWith("worktrees:") || input.id.startsWith("question:") || input.id.startsWith("result:")) ? `Open the Agents panel to ${input.id.startsWith("question:") ? "answer" : input.id.startsWith("result:") ? "read the result" : "review the shelf"}.` : "Open the Board panel to review." };
246
390
  if (!m) return { ok: false, error: "unknown inbox item" };
247
391
  const project = (await running()).find((p) => p.id === m[1]);
248
392
  if (!project) return { ok: false, error: "project is not running" };
@@ -260,7 +404,11 @@ export function createProvider(options: { home: string; registry?: Registry; lif
260
404
  return {
261
405
  assets,
262
406
  async snapshot(input) {
263
- if (input.panel === "inbox") return { items: await inbox() };
407
+ if (input.panel === "review_blob" || input.panel === "review_comments") {
408
+ const { panel: _panel, projectId, instanceId, ...payload } = input;
409
+ return await consoleControl({ projectId, instanceId }, input.panel, payload);
410
+ }
411
+ if (input.panel === "inbox") return { ...await inboxSnapshot() };
264
412
  if (input.panel === "space") return { scene: await space() };
265
413
  const view = (DASHBOARD_VIEWS as readonly string[]).includes(String(input.view)) ? input.view : (DASHBOARD_VIEWS as readonly string[]).includes(input.panel) ? input.panel : undefined;
266
414
  if (typeof input.projectId !== "string" || typeof input.instanceId !== "string") {
@@ -271,34 +419,66 @@ export function createProvider(options: { home: string; registry?: Registry; lif
271
419
  return { ...listing, ...(live.length === 1 ? { selectedProjectId: live[0]!.id } : {}), panel: input.panel };
272
420
  }
273
421
  const { panel: _panel, ...rest } = input;
274
- return await act({ ...rest, action: "snapshot", ...(view ? { view } : {}) }) as Record<string, unknown>;
422
+ return { ...await act({ ...rest, action: "snapshot", ...(view ? { view } : {}) }) as Record<string, unknown>, reviewWritable: options.features?.includes("action.meta") === true };
423
+ },
424
+ async action(input, meta) {
425
+ // #727: an action changes what is waiting, so the cached inbox is dropped before it runs and again when it ends (a poll in between must not keep the old answer).
426
+ inboxGeneration++;
427
+ try { return await runAction(input, meta); } finally { inboxGeneration++; }
275
428
  },
276
- async action(input) {
429
+ async close() { if (owned) registry.close(); },
430
+ };
431
+
432
+ async function runAction(input: Parameters<ConsoleProvider["action"]>[0], meta: ActionMeta | undefined): Promise<ActionResult> {
277
433
  const name = String(input.action);
278
- if (![...FORWARDED, ...INBOX_VERBS].includes(name as never)) return { ok: false, error: "unknown action" };
434
+ // #1053 T5: `answer` is accepted only with the console's own verified person meta, and never with page-supplied identity or target fields; anything else is refused before the hub.
435
+ if (name === "answer") {
436
+ const evidence = personEvidence(options.features, meta, input);
437
+ if (!evidence.ok) return { ok: false, error: evidence.error };
438
+ const { action: _a, confirmed: _c, fingerprint: _f, ...forward } = input;
439
+ return await answerVerb(forward, evidence.identity);
440
+ }
441
+ // #1054 T6: a receipt is accepted only with the console's own verified person meta, and never with page-supplied identity or target fields; anything else is refused before the hub.
442
+ if ((RESULT_ACTIONS as readonly string[]).includes(name)) {
443
+ const evidence = receiptEvidence(options.features, meta, input);
444
+ if (!evidence.ok) return { ok: false, error: evidence.error };
445
+ const { action: _a, confirmed: _c, fingerprint: _f, seenAt: _s, explicit: _e, ...forward } = input;
446
+ return await receiptVerb(name as ResultAction, forward);
447
+ }
448
+ const review = REVIEW_ACTIONS.includes(name as typeof REVIEW_ACTIONS[number]);
449
+ if (review && (!options.features?.includes("action.meta") || !meta || meta.via !== "console" || typeof meta.identity !== "string" || !meta.identity.trim() || typeof meta.node !== "string" || !meta.node.trim())) {
450
+ return { ok: false, error: "Review comments need a console with verified person identity; update acon and reopen this panel." };
451
+ }
452
+ if (review && ["author", "meta", "personMeta", "at", "state", "sentAt"].some(key => Object.hasOwn(input, key))) {
453
+ return { ok: false, error: "Review identity and timestamps are supplied by the console; remove those fields and retry." };
454
+ }
455
+ if (![...FORWARDED, ...INBOX_VERBS, ...REVIEW_ACTIONS].includes(name as never)) return { ok: false, error: "unknown action" };
279
456
  const fingerprint = fingerprintOf(input);
280
457
  if (CONFIRMED.has(name) && !(input.confirmed === true && input.fingerprint === fingerprint)) {
281
458
  const target = typeof input.projectId === "string" ? registry.get(input.projectId)?.root.split("/").filter(Boolean).pop() : undefined;
282
- const preview = name === "approve" || name === "deny" ? [`${name === "approve" ? "Allow" : "Deny"} this tool request once.`]
459
+ const preview = name === "review_comment_delete" ? [`Delete or dismiss this review comment on task #${String(input.task ?? "?")}.`]
460
+ : name === "approve" || name === "deny" ? [`${name === "approve" ? "Allow" : "Deny"} this tool request once.`]
283
461
  : name === "send" ? [`Send a message as you${target ? ` in ${target}` : ""}.`]
284
462
  : [`Run ${name}${target ? ` in ${target}` : ""}.`];
285
463
  return { ok: false, needsConfirm: true, preview, fingerprint, verb: name === "approve" ? "Allow once" : name === "deny" ? "Deny" : name };
286
464
  }
465
+ if (review) {
466
+ const { action: _action, confirmed: _confirmed, fingerprint: _fingerprint, projectId, instanceId, ...payload } = input;
467
+ return await consoleControl({ projectId, instanceId }, name as typeof REVIEW_ACTIONS[number], payload, meta) as ActionResult;
468
+ }
287
469
  const { confirmed: _c, fingerprint: _f, ...forward } = input;
288
470
  if (name === "approve" || name === "deny" || name === "open") return await inboxVerb(forward);
289
471
  const result = await act(forward) as Record<string, unknown>;
290
472
  return (result.ok === false ? { ok: false, error: String(result.error ?? "action failed"), ...(result.unconfirmed === true ? { unconfirmed: true as const } : {}) } : { ...result, ok: true }) as ActionResult;
291
- },
292
- async close() { if (owned) registry.close(); },
293
- };
473
+ }
294
474
  }
295
475
 
296
476
  const plugin: ConsolePlugin = {
297
477
  manifest: {
298
478
  api: 1, tool: "ahub", version, title: "agent-hub", icon: "agents", pollMs: 2000,
299
479
  panels: DASHBOARD_VIEWS.map((id, order) => ({ id, title: TITLES[id], order })),
300
- capabilities: { actions: [...FORWARDED, ...INBOX_VERBS], confirm: true, multiProject: true, inbox: true, space: true },
480
+ capabilities: { actions: [...FORWARDED, ...INBOX_VERBS, ...QUESTION_ACTIONS, ...RESULT_ADVERTISED, ...REVIEW_ACTIONS], confirm: true, multiProject: true, inbox: true, space: true },
301
481
  },
302
- async open(ctx) { return createProvider({ home: ctx.home, log: ctx.log }); },
482
+ async open(ctx) { return createProvider({ home: ctx.home, log: ctx.log, features: ctx.features }); },
303
483
  };
304
484
  export default plugin;
@@ -1,114 +0,0 @@
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>
@@ -1,122 +0,0 @@
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
- }