@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.
- package/CHANGELOG.md +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4565 -3617
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +66 -11
- package/src/hub/manager.ts +67 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- 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
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
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;
|
package/src/pet/window.html
DELETED
|
@@ -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>
|