@staix/agent-hub 0.29.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. 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
- const FORWARDED = ["start", "stop", "send", "pause", "resume", "permit", "propose", "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"] as const;
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"]);
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,35 @@ 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
+ }
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
259
+ // beyond opening the panel, because removal is the person's `ahub worktrees tidy` and never a button.
260
+ const shelf = snap.worktrees as { settled?: unknown[]; alertOver?: number } | undefined;
261
+ const settledCount = Array.isArray(shelf?.settled) ? shelf!.settled.length : 0;
262
+ if (typeof shelf?.alertOver === "number" && shelf.alertOver > 0 && settledCount > shelf.alertOver) {
263
+ const id = `worktrees:${project.id}`;
264
+ const at = seen.get(id) ?? new Date().toISOString();
265
+ seen.set(id, at);
266
+ items.push({ id, group: "alert", kind: "worktrees", title: `${settledCount} settled worktrees`, detail: `More than ${shelf.alertOver}. Review them in Agents, then run ahub worktrees tidy.`, project: name, at,
267
+ chip: { text: "worktrees", tone: "warn" }, verbs: [{ id: "open", label: "Open Agents", primary: true }], link: { panel: "agents", params: { project: project.id } } });
268
+ }
179
269
  for (const t of (snap.tasks as { id: number; title: string; state: string; reviewer: string | null; owner: string | null }[] | undefined) ?? []) {
180
270
  if (t.state !== "in_review" || t.reviewer !== "user") continue;
181
271
  const id = `review:${project.id}:${t.id}`;
@@ -184,13 +274,46 @@ export function createProvider(options: { home: string; registry?: Registry; lif
184
274
  items.push({ id, group: "review", kind: "review", title: `#${t.id} ${t.title}`.slice(0, 200), ...(t.owner ? { actor: t.owner } : {}), project: name, at,
185
275
  chip: { text: "review", tone: "neutral" }, verbs: [{ id: "open", label: "Open board", primary: true }], link: { panel: "board", params: { project: project.id } } });
186
276
  }
277
+ if (generation === inboxGeneration) lastProjectItems.set(project.id, { generation, items: items.slice(firstItem) });
187
278
  }
188
- // 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`.
189
281
  const current = new Set(items.map((item) => item.id));
190
282
  for (const id of seen.keys()) if (!current.has(id)) seen.delete(id);
191
283
  return items;
192
284
  }
193
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
+
194
317
  /**
195
318
  * #1012 Part 2: every running project's office as one acon SpaceScene. The office model is kept per project as
196
319
  * `previous`, as the dashboard keeps it, so desk slots and message arcs persist across polls. A restarted daemon
@@ -207,11 +330,8 @@ export function createProvider(options: { home: string; registry?: Registry; lif
207
330
  const late = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), ms); });
208
331
  try { return await Promise.race([work, late]); } catch { return null; } finally { clearTimeout(timer); }
209
332
  };
210
- let lastRunning: ProjectRow[] = [];
211
333
  async function space() {
212
- // A listing that finishes after its budget still becomes the fallback for the next poll.
213
- const listing = running().then((rows) => { lastRunning = rows; return rows; }, () => lastRunning);
214
- const projects = await within(listing, Math.round(SPACE_BUDGET_MS / 2)) ?? lastRunning;
334
+ const projects = await runningWithin(Math.round(SPACE_BUDGET_MS / 2));
215
335
  for (const id of offices.keys()) if (!projects.some((p) => p.id === id)) offices.delete(id);
216
336
  const scenes = [];
217
337
  const snaps = await Promise.all(projects.map((project) =>
@@ -228,10 +348,45 @@ export function createProvider(options: { home: string; registry?: Registry; lif
228
348
  return mergeSpaces(scenes);
229
349
  }
230
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
+
231
386
  /** approve/deny resolve to the hub's own `permit` with the request's option; nothing else is reachable from an item. */
232
387
  async function inboxVerb(input: Record<string, unknown>): Promise<ActionResult> {
233
388
  const m = typeof input.id === "string" ? /^perm:([^:]+):(.+)$/.exec(input.id) : null;
234
- if (input.action === "open") return { ok: true, message: "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." };
235
390
  if (!m) return { ok: false, error: "unknown inbox item" };
236
391
  const project = (await running()).find((p) => p.id === m[1]);
237
392
  if (!project) return { ok: false, error: "project is not running" };
@@ -249,7 +404,11 @@ export function createProvider(options: { home: string; registry?: Registry; lif
249
404
  return {
250
405
  assets,
251
406
  async snapshot(input) {
252
- 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() };
253
412
  if (input.panel === "space") return { scene: await space() };
254
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;
255
414
  if (typeof input.projectId !== "string" || typeof input.instanceId !== "string") {
@@ -260,34 +419,66 @@ export function createProvider(options: { home: string; registry?: Registry; lif
260
419
  return { ...listing, ...(live.length === 1 ? { selectedProjectId: live[0]!.id } : {}), panel: input.panel };
261
420
  }
262
421
  const { panel: _panel, ...rest } = input;
263
- 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++; }
264
428
  },
265
- 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> {
266
433
  const name = String(input.action);
267
- 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" };
268
456
  const fingerprint = fingerprintOf(input);
269
457
  if (CONFIRMED.has(name) && !(input.confirmed === true && input.fingerprint === fingerprint)) {
270
458
  const target = typeof input.projectId === "string" ? registry.get(input.projectId)?.root.split("/").filter(Boolean).pop() : undefined;
271
- 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.`]
272
461
  : name === "send" ? [`Send a message as you${target ? ` in ${target}` : ""}.`]
273
462
  : [`Run ${name}${target ? ` in ${target}` : ""}.`];
274
463
  return { ok: false, needsConfirm: true, preview, fingerprint, verb: name === "approve" ? "Allow once" : name === "deny" ? "Deny" : name };
275
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
+ }
276
469
  const { confirmed: _c, fingerprint: _f, ...forward } = input;
277
470
  if (name === "approve" || name === "deny" || name === "open") return await inboxVerb(forward);
278
471
  const result = await act(forward) as Record<string, unknown>;
279
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;
280
- },
281
- async close() { if (owned) registry.close(); },
282
- };
473
+ }
283
474
  }
284
475
 
285
476
  const plugin: ConsolePlugin = {
286
477
  manifest: {
287
478
  api: 1, tool: "ahub", version, title: "agent-hub", icon: "agents", pollMs: 2000,
288
479
  panels: DASHBOARD_VIEWS.map((id, order) => ({ id, title: TITLES[id], order })),
289
- 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 },
290
481
  },
291
- 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 }); },
292
483
  };
293
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>