@staix/agent-hub 0.30.0 → 0.32.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 +52 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +11 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +178 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +23 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +355 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +24 -5
- package/docs/security.md +178 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- 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 +311 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-0.32.0.md +149 -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/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 +4393 -3609
- 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 +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -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/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +78 -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 +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -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/room.ts +20 -10
- 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/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +511 -53
- 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/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1576 -139
- 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/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -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/peers.ts +25 -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/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +338 -36
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +475 -35
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +447 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +142 -9
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- 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
54
|
const FORWARDED = ["start", "stop", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release", "worktree_override_set", "worktree_override_clear", "worktrees_refresh_prs"] as const;
|
|
55
|
+
/** Advertised so acon lets the page send it, never forwarded: `action()` answers it through its own port (`answerVerb`), after the console's person meta is checked (#1053 T5). */
|
|
56
|
+
const QUESTION_ACTIONS = ["answer"] as const;
|
|
57
|
+
/** #1054 T6: the receipt actions, advertised so acon lets the inbox and the page send them, never forwarded: `action()` checks the console's person meta, then calls the dedicated `projectConsole(...).receipt` port (`receiptVerb`). */
|
|
58
|
+
const RESULT_ADVERTISED = RESULT_ACTIONS;
|
|
59
|
+
const REVIEW_ACTIONS = ["review_comment_put", "review_comment_delete", "review_comment_send"] as const;
|
|
50
60
|
const INBOX_VERBS = ["approve", "deny", "open"] as const;
|
|
51
61
|
/** Outward or destructive: answered needsConfirm first (agent-console AGENTS.md); previews are read-only and stay direct. */
|
|
52
|
-
const CONFIRMED = new Set(["stop", "permit", "setting", "setting_undo", "setting_reload", "config_edit_apply", "start_peer", "backlog_split", "send", "approve", "deny", "worktree_override_set", "worktrees_refresh_prs"]);
|
|
62
|
+
const CONFIRMED = new Set(["review_comment_delete", "review_comment_send", "stop", "permit", "setting", "setting_undo", "setting_reload", "config_edit_apply", "start_peer", "backlog_split", "send", "approve", "deny", "worktree_override_set", "worktrees_refresh_prs"]);
|
|
53
63
|
const fingerprintOf = (input: Record<string, unknown>) => {
|
|
54
64
|
const { confirmed: _c, fingerprint: _f, reason: _r, ...rest } = input;
|
|
55
65
|
return createHash("sha256").update(JSON.stringify(Object.keys(rest).sort().map((key) => [key, rest[key]]))).digest("hex");
|
|
@@ -72,6 +82,7 @@ export function panelAssets(): { js: string; css: string } {
|
|
|
72
82
|
body = swap(body, "let ticket = location.hash.slice(1);\nhistory.replaceState(null, '', location.pathname);", "let ticket = '';");
|
|
73
83
|
body = swap(body, "const apply = () => { document.documentElement.dataset.theme = choice; };", "const apply = () => {};");
|
|
74
84
|
body = swap(body, "const $ = (id) => $$(id);", "const $ = $$;");
|
|
85
|
+
body = swap(body, "const reviewKit = null;", "const reviewKit = typeof host.ui.review === 'function' ? {render:host.ui.review.bind(host.ui),snapshot:input=>host.snapshot(input)} : null;");
|
|
75
86
|
// post() goes to the host: /projects is the manager listing, /snapshot and /action are the plugin's panel calls.
|
|
76
87
|
body = swap(body, "async function post(path, body) {\n const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});\n if (response.status === 401) { expire(); throw new Error('Run ahub ui to start a new session.'); }\n const result = await response.json();",
|
|
77
88
|
"async function post(path, body) {\n const result = path === '/projects' ? await host.snapshot({ panel: 'board' }) : path === '/snapshot' ? await host.snapshot({ ...body, panel: 'board' }) : await host.action(body);\n if (!result) { expire(); throw new Error('The console session ended.'); }\n const response = { ok: result.ok !== false, status: 200 };");
|
|
@@ -135,6 +146,10 @@ ${body}
|
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
interface ProjectRow { id: string; root: string; state: string; instanceId: string | null }
|
|
149
|
+
/** #727: the inbox reply is bounded; `more` counts what was left out. Most urgent first: a decision waits on the person, then a review, a result, a candidate, an alert. */
|
|
150
|
+
const INBOX_MAX_ITEMS = 50;
|
|
151
|
+
const INBOX_GROUP_RANK: Record<InboxItem["group"], number> = { decision: 0, review: 1, result: 2, candidate: 3, alert: 4 };
|
|
152
|
+
interface InboxReply { items: InboxItem[]; more: number }
|
|
138
153
|
|
|
139
154
|
/** lifecycle.ts reaches the whole daemon (and files acon's import scan cannot parse); load it on first use only. */
|
|
140
155
|
function lazyLifecycle(): Lifecycle {
|
|
@@ -147,11 +162,11 @@ function lazyLifecycle(): Lifecycle {
|
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
/** Build the provider over an injectable registry and lifecycle (tests use a temp home and fakes). */
|
|
150
|
-
export function createProvider(options: { home: string; registry?: Registry; lifecycle?: Lifecycle; log?: (line: string) => void; spaceBudgetMs?: number }): ConsoleProvider {
|
|
165
|
+
export function createProvider(options: { home: string; registry?: Registry; lifecycle?: Lifecycle; log?: (line: string) => void; spaceBudgetMs?: number; features?: readonly string[]; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"]; inboxFreshMs?: number; inboxStaleMs?: number; inboxProjectBudgetMs?: number }): ConsoleProvider {
|
|
151
166
|
const registry = options.registry ?? new Registry(join(options.home, "registry.db"));
|
|
152
167
|
const owned = !options.registry;
|
|
153
168
|
const lifecycle = options.lifecycle ?? lazyLifecycle();
|
|
154
|
-
const { list, act } = projectConsole({ registry, lifecycle, home: options.home });
|
|
169
|
+
const { list, act, answer, receipt, consoleControl } = projectConsole({ registry, lifecycle, home: options.home, forwardTimeoutMs: options.forwardTimeoutMs });
|
|
155
170
|
const assets = panelAssets();
|
|
156
171
|
const seen = new Map<string, string>(); // inbox item id -> first-seen ISO time, so `at` stays stable
|
|
157
172
|
|
|
@@ -159,11 +174,57 @@ export function createProvider(options: { home: string; registry?: Registry; lif
|
|
|
159
174
|
const listing = await list() as { projects: ProjectRow[] };
|
|
160
175
|
return listing.projects.filter((p) => p.state === "running" && p.instanceId);
|
|
161
176
|
}
|
|
162
|
-
|
|
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,24 @@ export function createProvider(options: { home: string; registry?: Registry; lif
|
|
|
176
237
|
verbs: [...(allow ? [{ id: "approve", label: "Allow once", primary: true }] : []), ...(deny ? [{ id: "deny", label: "Deny", destructive: true }] : [])],
|
|
177
238
|
link: { panel: "agents", params: { project: project.id } } });
|
|
178
239
|
}
|
|
240
|
+
// #1053 T5: the open questions peers asked the person. A private question's title is a stub; the form (the person's own answer surface) carries the text.
|
|
241
|
+
for (const q of (snap.questions as QuestionRowView[] | undefined) ?? []) {
|
|
242
|
+
if (!q || typeof q.set !== "string" || !Array.isArray(q.questions) || !q.questions.length) continue;
|
|
243
|
+
const item = questionItem(q, { id: project.id, name }, options.features);
|
|
244
|
+
seen.set(item.id, item.at);
|
|
245
|
+
items.push(item);
|
|
246
|
+
}
|
|
247
|
+
// #1054 T6: unread results, only to a console that renders the `result` group AND gives a receipt person evidence. Otherwise they stay in the ahub panel, `ahub console`, `ahub status`
|
|
248
|
+
// and `ahub results`. A row that is not well formed is dropped, never rendered; the detail is what the hub built at display (a stub when private).
|
|
249
|
+
if (canPublishResults(options.features)) {
|
|
250
|
+
const results = (snap.results as { unread?: ResultRowView[] } | undefined)?.unread;
|
|
251
|
+
for (const row of Array.isArray(results) ? results : []) {
|
|
252
|
+
if (!row || typeof row.id !== "string" || !/^cmp-[a-f0-9]{12}$/.test(row.id) || typeof row.peer !== "string" || !Number.isSafeInteger(row.settledAt)) continue;
|
|
253
|
+
const item = resultItem(row, { id: project.id, name });
|
|
254
|
+
seen.set(item.id, item.at);
|
|
255
|
+
items.push(item);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
179
258
|
// #1057 D4: one alert when the shelf holds more settled worktrees than `worktrees.alert_over` (0 = never). Counts only: no path, branch or title, and no action
|
|
180
259
|
// beyond opening the panel, because removal is the person's `ahub worktrees tidy` and never a button.
|
|
181
260
|
const shelf = snap.worktrees as { settled?: unknown[]; alertOver?: number } | undefined;
|
|
@@ -195,13 +274,46 @@ export function createProvider(options: { home: string; registry?: Registry; lif
|
|
|
195
274
|
items.push({ id, group: "review", kind: "review", title: `#${t.id} ${t.title}`.slice(0, 200), ...(t.owner ? { actor: t.owner } : {}), project: name, at,
|
|
196
275
|
chip: { text: "review", tone: "neutral" }, verbs: [{ id: "open", label: "Open board", primary: true }], link: { panel: "board", params: { project: project.id } } });
|
|
197
276
|
}
|
|
277
|
+
if (generation === inboxGeneration) lastProjectItems.set(project.id, { generation, items: items.slice(firstItem) });
|
|
198
278
|
}
|
|
199
|
-
|
|
279
|
+
for (const id of lastProjectItems.keys()) if (!projects.some((project) => project.id === id)) lastProjectItems.delete(id);
|
|
280
|
+
// Forget ids that left the inbox, so `seen` stays the size of what is waiting now. This runs on the whole list, before the cap, so a row below the cap keeps its `at`.
|
|
200
281
|
const current = new Set(items.map((item) => item.id));
|
|
201
282
|
for (const id of seen.keys()) if (!current.has(id)) seen.delete(id);
|
|
202
283
|
return items;
|
|
203
284
|
}
|
|
204
285
|
|
|
286
|
+
/**
|
|
287
|
+
* #727: what acon polls. The expensive part (every running project's `agents` snapshot) is shared and cached:
|
|
288
|
+
* - concurrent snapshots join one computation in flight;
|
|
289
|
+
* - a result younger than `inboxFreshMs` is served as it is;
|
|
290
|
+
* - a result younger than `inboxStaleMs` is served at once while one refresh runs in the background (nothing runs while nobody polls);
|
|
291
|
+
* - older, or after any action (an approval or an answer changes what is waiting), the caller waits for a fresh computation.
|
|
292
|
+
* The reply is bounded: at most INBOX_MAX_ITEMS items, the most urgent first, and `more` says how many were left out.
|
|
293
|
+
*/
|
|
294
|
+
const INBOX_FRESH_MS = options.inboxFreshMs ?? 1_000, INBOX_STALE_MS = options.inboxStaleMs ?? 10_000, INBOX_PROJECT_BUDGET_MS = options.inboxProjectBudgetMs ?? 2_000;
|
|
295
|
+
let inboxGeneration = 0, inboxCache: { generation: number; at: number; value: InboxReply } | undefined, inboxFlight: { generation: number; promise: Promise<InboxReply> } | undefined;
|
|
296
|
+
function inboxComputation(): Promise<InboxReply> {
|
|
297
|
+
if (inboxFlight && inboxFlight.generation === inboxGeneration) return inboxFlight.promise;
|
|
298
|
+
const generation = inboxGeneration;
|
|
299
|
+
const promise: Promise<InboxReply> = inbox(generation).then((all) => {
|
|
300
|
+
const ordered = [...all].sort((a, b) => INBOX_GROUP_RANK[a.group] - INBOX_GROUP_RANK[b.group] || (a.at < b.at ? -1 : a.at > b.at ? 1 : 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
301
|
+
const value: InboxReply = { items: ordered.slice(0, INBOX_MAX_ITEMS), more: Math.max(0, ordered.length - INBOX_MAX_ITEMS) };
|
|
302
|
+
if (!inboxCache || inboxCache.generation <= generation) inboxCache = { generation, at: Date.now(), value };
|
|
303
|
+
return value;
|
|
304
|
+
}).finally(() => { if (inboxFlight?.promise === promise) inboxFlight = undefined; });
|
|
305
|
+
inboxFlight = { generation, promise };
|
|
306
|
+
return promise;
|
|
307
|
+
}
|
|
308
|
+
async function inboxSnapshot(): Promise<InboxReply> {
|
|
309
|
+
if (inboxCache && inboxCache.generation === inboxGeneration) {
|
|
310
|
+
const age = Date.now() - inboxCache.at;
|
|
311
|
+
if (age < INBOX_FRESH_MS) return inboxCache.value;
|
|
312
|
+
if (age < INBOX_STALE_MS) { void inboxComputation().catch(() => {}); return inboxCache.value; }
|
|
313
|
+
}
|
|
314
|
+
return await inboxComputation();
|
|
315
|
+
}
|
|
316
|
+
|
|
205
317
|
/**
|
|
206
318
|
* #1012 Part 2: every running project's office as one acon SpaceScene. The office model is kept per project as
|
|
207
319
|
* `previous`, as the dashboard keeps it, so desk slots and message arcs persist across polls. A restarted daemon
|
|
@@ -218,11 +330,8 @@ export function createProvider(options: { home: string; registry?: Registry; lif
|
|
|
218
330
|
const late = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), ms); });
|
|
219
331
|
try { return await Promise.race([work, late]); } catch { return null; } finally { clearTimeout(timer); }
|
|
220
332
|
};
|
|
221
|
-
let lastRunning: ProjectRow[] = [];
|
|
222
333
|
async function space() {
|
|
223
|
-
|
|
224
|
-
const listing = running().then((rows) => { lastRunning = rows; return rows; }, () => lastRunning);
|
|
225
|
-
const projects = await within(listing, Math.round(SPACE_BUDGET_MS / 2)) ?? lastRunning;
|
|
334
|
+
const projects = await runningWithin(Math.round(SPACE_BUDGET_MS / 2));
|
|
226
335
|
for (const id of offices.keys()) if (!projects.some((p) => p.id === id)) offices.delete(id);
|
|
227
336
|
const scenes = [];
|
|
228
337
|
const snaps = await Promise.all(projects.map((project) =>
|
|
@@ -239,10 +348,45 @@ export function createProvider(options: { home: string; registry?: Registry; lif
|
|
|
239
348
|
return mergeSpaces(scenes);
|
|
240
349
|
}
|
|
241
350
|
|
|
351
|
+
/**
|
|
352
|
+
* #1053 T5: an answer to a question item. Only with the console's own person meta (checked by the caller); the project and set come from the item id, never from the
|
|
353
|
+
* page. The hub re-validates every answer against the stored row and names a stale or superseded set's terminal state.
|
|
354
|
+
*/
|
|
355
|
+
async function answerVerb(input: Record<string, unknown>, person: string): Promise<ActionResult> {
|
|
356
|
+
const m = typeof input.id === "string" ? QUESTION_ITEM.exec(input.id) : null;
|
|
357
|
+
if (!m) return { ok: false, error: "unknown inbox item" };
|
|
358
|
+
const project = (await running()).find((p) => p.id === m[1]);
|
|
359
|
+
if (!project) return { ok: false, error: "project is not running" };
|
|
360
|
+
const result = await answer({ projectId: project.id, instanceId: project.instanceId }, { questionSet: m[2]!, answers: input.answers as InboxAnswers, person }) as Record<string, unknown>;
|
|
361
|
+
return result.ok === true ? { ok: true, message: "Answered" } : { ok: false, error: String(result.error ?? "the answer was refused") };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* #1054 T6: a receipt for the person: one result read (the inbox's `result_seen`, whose item id names the project and the completion; the panel's carries the same ids), all read, or the
|
|
366
|
+
* last read unread again. Only with the console's own person meta (checked by the caller). The project comes from the item id or, from the panel, from the project it is showing; the
|
|
367
|
+
* hub re-checks that the result exists and is unread, and names a stale one. `seenAt` and `explicit` are acon's and are not forwarded: the hub stamps its own time.
|
|
368
|
+
*/
|
|
369
|
+
async function receiptVerb(name: ResultAction, input: Record<string, unknown>): Promise<ActionResult> {
|
|
370
|
+
let projectId: string | undefined, id: string | undefined;
|
|
371
|
+
if (name === "result_seen") {
|
|
372
|
+
const m = typeof input.id === "string" ? RESULT_ITEM.exec(input.id) : null;
|
|
373
|
+
if (m) { projectId = m[1]; id = m[2]; }
|
|
374
|
+
else if (typeof input.projectId === "string" && typeof input.id === "string" && /^cmp-[a-f0-9]{12}$/.test(input.id)) { projectId = input.projectId; id = input.id; }
|
|
375
|
+
if (!projectId || !id) return { ok: false, error: "unknown result" };
|
|
376
|
+
} else projectId = typeof input.projectId === "string" ? input.projectId : undefined;
|
|
377
|
+
const project = (await running()).find((p) => p.id === projectId);
|
|
378
|
+
if (!project) return { ok: false, error: "project is not running" };
|
|
379
|
+
const target = { projectId: project.id, instanceId: project.instanceId };
|
|
380
|
+
const result = (name === "result_seen" ? await receipt(target, { op: "result_seen", id: id! })
|
|
381
|
+
: await receipt(target, { op: "results", action: name === "results_read_all" ? "read-all" : "unread-last" })) as Record<string, unknown>;
|
|
382
|
+
if (result.ok !== true) return { ok: false, error: String(result.error ?? "the receipt was refused") };
|
|
383
|
+
return { ok: true, message: name === "result_seen" ? "Marked read" : name === "results_read_all" ? `Marked ${Number(result.marked) || 0} read` : "Marked unread again" };
|
|
384
|
+
}
|
|
385
|
+
|
|
242
386
|
/** approve/deny resolve to the hub's own `permit` with the request's option; nothing else is reachable from an item. */
|
|
243
387
|
async function inboxVerb(input: Record<string, unknown>): Promise<ActionResult> {
|
|
244
388
|
const m = typeof input.id === "string" ? /^perm:([^:]+):(.+)$/.exec(input.id) : null;
|
|
245
|
-
if (input.action === "open") return { ok: true, message: typeof input.id === "string" && input.id.startsWith("worktrees:") ?
|
|
389
|
+
if (input.action === "open") return { ok: true, message: typeof input.id === "string" && (input.id.startsWith("worktrees:") || input.id.startsWith("question:") || input.id.startsWith("result:")) ? `Open the Agents panel to ${input.id.startsWith("question:") ? "answer" : input.id.startsWith("result:") ? "read the result" : "review the shelf"}.` : "Open the Board panel to review." };
|
|
246
390
|
if (!m) return { ok: false, error: "unknown inbox item" };
|
|
247
391
|
const project = (await running()).find((p) => p.id === m[1]);
|
|
248
392
|
if (!project) return { ok: false, error: "project is not running" };
|
|
@@ -260,7 +404,11 @@ export function createProvider(options: { home: string; registry?: Registry; lif
|
|
|
260
404
|
return {
|
|
261
405
|
assets,
|
|
262
406
|
async snapshot(input) {
|
|
263
|
-
if (input.panel === "
|
|
407
|
+
if (input.panel === "review_blob" || input.panel === "review_comments") {
|
|
408
|
+
const { panel: _panel, projectId, instanceId, ...payload } = input;
|
|
409
|
+
return await consoleControl({ projectId, instanceId }, input.panel, payload);
|
|
410
|
+
}
|
|
411
|
+
if (input.panel === "inbox") return { ...await inboxSnapshot() };
|
|
264
412
|
if (input.panel === "space") return { scene: await space() };
|
|
265
413
|
const view = (DASHBOARD_VIEWS as readonly string[]).includes(String(input.view)) ? input.view : (DASHBOARD_VIEWS as readonly string[]).includes(input.panel) ? input.panel : undefined;
|
|
266
414
|
if (typeof input.projectId !== "string" || typeof input.instanceId !== "string") {
|
|
@@ -271,34 +419,66 @@ export function createProvider(options: { home: string; registry?: Registry; lif
|
|
|
271
419
|
return { ...listing, ...(live.length === 1 ? { selectedProjectId: live[0]!.id } : {}), panel: input.panel };
|
|
272
420
|
}
|
|
273
421
|
const { panel: _panel, ...rest } = input;
|
|
274
|
-
return await act({ ...rest, action: "snapshot", ...(view ? { view } : {}) }) as Record<string, unknown
|
|
422
|
+
return { ...await act({ ...rest, action: "snapshot", ...(view ? { view } : {}) }) as Record<string, unknown>, reviewWritable: options.features?.includes("action.meta") === true };
|
|
423
|
+
},
|
|
424
|
+
async action(input, meta) {
|
|
425
|
+
// #727: an action changes what is waiting, so the cached inbox is dropped before it runs and again when it ends (a poll in between must not keep the old answer).
|
|
426
|
+
inboxGeneration++;
|
|
427
|
+
try { return await runAction(input, meta); } finally { inboxGeneration++; }
|
|
275
428
|
},
|
|
276
|
-
async
|
|
429
|
+
async close() { if (owned) registry.close(); },
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
async function runAction(input: Parameters<ConsoleProvider["action"]>[0], meta: ActionMeta | undefined): Promise<ActionResult> {
|
|
277
433
|
const name = String(input.action);
|
|
278
|
-
|
|
434
|
+
// #1053 T5: `answer` is accepted only with the console's own verified person meta, and never with page-supplied identity or target fields; anything else is refused before the hub.
|
|
435
|
+
if (name === "answer") {
|
|
436
|
+
const evidence = personEvidence(options.features, meta, input);
|
|
437
|
+
if (!evidence.ok) return { ok: false, error: evidence.error };
|
|
438
|
+
const { action: _a, confirmed: _c, fingerprint: _f, ...forward } = input;
|
|
439
|
+
return await answerVerb(forward, evidence.identity);
|
|
440
|
+
}
|
|
441
|
+
// #1054 T6: a receipt is accepted only with the console's own verified person meta, and never with page-supplied identity or target fields; anything else is refused before the hub.
|
|
442
|
+
if ((RESULT_ACTIONS as readonly string[]).includes(name)) {
|
|
443
|
+
const evidence = receiptEvidence(options.features, meta, input);
|
|
444
|
+
if (!evidence.ok) return { ok: false, error: evidence.error };
|
|
445
|
+
const { action: _a, confirmed: _c, fingerprint: _f, seenAt: _s, explicit: _e, ...forward } = input;
|
|
446
|
+
return await receiptVerb(name as ResultAction, forward);
|
|
447
|
+
}
|
|
448
|
+
const review = REVIEW_ACTIONS.includes(name as typeof REVIEW_ACTIONS[number]);
|
|
449
|
+
if (review && (!options.features?.includes("action.meta") || !meta || meta.via !== "console" || typeof meta.identity !== "string" || !meta.identity.trim() || typeof meta.node !== "string" || !meta.node.trim())) {
|
|
450
|
+
return { ok: false, error: "Review comments need a console with verified person identity; update acon and reopen this panel." };
|
|
451
|
+
}
|
|
452
|
+
if (review && ["author", "meta", "personMeta", "at", "state", "sentAt"].some(key => Object.hasOwn(input, key))) {
|
|
453
|
+
return { ok: false, error: "Review identity and timestamps are supplied by the console; remove those fields and retry." };
|
|
454
|
+
}
|
|
455
|
+
if (![...FORWARDED, ...INBOX_VERBS, ...REVIEW_ACTIONS].includes(name as never)) return { ok: false, error: "unknown action" };
|
|
279
456
|
const fingerprint = fingerprintOf(input);
|
|
280
457
|
if (CONFIRMED.has(name) && !(input.confirmed === true && input.fingerprint === fingerprint)) {
|
|
281
458
|
const target = typeof input.projectId === "string" ? registry.get(input.projectId)?.root.split("/").filter(Boolean).pop() : undefined;
|
|
282
|
-
const preview = name === "
|
|
459
|
+
const preview = name === "review_comment_delete" ? [`Delete or dismiss this review comment on task #${String(input.task ?? "?")}.`]
|
|
460
|
+
: name === "approve" || name === "deny" ? [`${name === "approve" ? "Allow" : "Deny"} this tool request once.`]
|
|
283
461
|
: name === "send" ? [`Send a message as you${target ? ` in ${target}` : ""}.`]
|
|
284
462
|
: [`Run ${name}${target ? ` in ${target}` : ""}.`];
|
|
285
463
|
return { ok: false, needsConfirm: true, preview, fingerprint, verb: name === "approve" ? "Allow once" : name === "deny" ? "Deny" : name };
|
|
286
464
|
}
|
|
465
|
+
if (review) {
|
|
466
|
+
const { action: _action, confirmed: _confirmed, fingerprint: _fingerprint, projectId, instanceId, ...payload } = input;
|
|
467
|
+
return await consoleControl({ projectId, instanceId }, name as typeof REVIEW_ACTIONS[number], payload, meta) as ActionResult;
|
|
468
|
+
}
|
|
287
469
|
const { confirmed: _c, fingerprint: _f, ...forward } = input;
|
|
288
470
|
if (name === "approve" || name === "deny" || name === "open") return await inboxVerb(forward);
|
|
289
471
|
const result = await act(forward) as Record<string, unknown>;
|
|
290
472
|
return (result.ok === false ? { ok: false, error: String(result.error ?? "action failed"), ...(result.unconfirmed === true ? { unconfirmed: true as const } : {}) } : { ...result, ok: true }) as ActionResult;
|
|
291
|
-
}
|
|
292
|
-
async close() { if (owned) registry.close(); },
|
|
293
|
-
};
|
|
473
|
+
}
|
|
294
474
|
}
|
|
295
475
|
|
|
296
476
|
const plugin: ConsolePlugin = {
|
|
297
477
|
manifest: {
|
|
298
478
|
api: 1, tool: "ahub", version, title: "agent-hub", icon: "agents", pollMs: 2000,
|
|
299
479
|
panels: DASHBOARD_VIEWS.map((id, order) => ({ id, title: TITLES[id], order })),
|
|
300
|
-
capabilities: { actions: [...FORWARDED, ...INBOX_VERBS], confirm: true, multiProject: true, inbox: true, space: true },
|
|
480
|
+
capabilities: { actions: [...FORWARDED, ...INBOX_VERBS, ...QUESTION_ACTIONS, ...RESULT_ADVERTISED, ...REVIEW_ACTIONS], confirm: true, multiProject: true, inbox: true, space: true },
|
|
301
481
|
},
|
|
302
|
-
async open(ctx) { return createProvider({ home: ctx.home, log: ctx.log }); },
|
|
482
|
+
async open(ctx) { return createProvider({ home: ctx.home, log: ctx.log, features: ctx.features }); },
|
|
303
483
|
};
|
|
304
484
|
export default plugin;
|
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>
|
package/src/pet/window.jxa.js
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
// Local AppKit/WebKit surface. No shell evaluation or remote content.
|
|
2
|
-
ObjC.import('Cocoa');
|
|
3
|
-
ObjC.import('WebKit');
|
|
4
|
-
var panel, web, app, viewPath, positionPath, readyPath, cliArgv, timer, hoverTimer, bridge;
|
|
5
|
-
var offlineAt = 0, commands = [], exiting = false, hotspot = null, hovering = false;
|
|
6
|
-
function ignoreCompletion(result, error) {}
|
|
7
|
-
function evaluate(source) { web.evaluateJavaScriptCompletionHandler(source, ignoreCompletion); }
|
|
8
|
-
// An inactive accessory panel receives no mouse-moved events, so hover reveal is
|
|
9
|
-
// polled from the global mouse position against the hotspot rect the page posts.
|
|
10
|
-
function pollHover() {
|
|
11
|
-
if (!hotspot || exiting) return;
|
|
12
|
-
var mouse = $.NSEvent.mouseLocation, frame = panel.frame;
|
|
13
|
-
var x = mouse.x - frame.origin.x, y = frame.origin.y + frame.size.height - mouse.y;
|
|
14
|
-
var inside = x >= hotspot.x && x <= hotspot.x + hotspot.w && y >= hotspot.y && y <= hotspot.y + hotspot.h;
|
|
15
|
-
if (inside !== hovering) { hovering = inside; evaluate('hoverChanged(' + inside + ')'); }
|
|
16
|
-
}
|
|
17
|
-
function savePosition() {
|
|
18
|
-
var origin = panel.frame.origin;
|
|
19
|
-
$(JSON.stringify({ x: origin.x, y: origin.y })).writeToFileAtomicallyEncodingError(positionPath, true, $.NSUTF8StringEncoding, null);
|
|
20
|
-
}
|
|
21
|
-
function quit() { exiting = true; savePosition(); app.terminate(null); }
|
|
22
|
-
function runCommand(command, extra, argv) {
|
|
23
|
-
if (exiting || commands.length >= 8) return;
|
|
24
|
-
var task = $.NSTask.alloc.init;
|
|
25
|
-
task.executableURL = $.NSURL.fileURLWithPath(argv[0]);
|
|
26
|
-
var args = argv.slice(1).concat([command]).concat(extra);
|
|
27
|
-
task.arguments = $(args); // argv array only; message text is a single argument.
|
|
28
|
-
task.standardInput = $.NSFileHandle.fileHandleWithNullDevice;
|
|
29
|
-
task.standardOutput = $.NSFileHandle.fileHandleWithNullDevice;
|
|
30
|
-
task.standardError = $.NSFileHandle.fileHandleWithNullDevice;
|
|
31
|
-
if (task.launchAndReturnError(null)) commands.push({ task: task, command: command });
|
|
32
|
-
else evaluate('commandResult(false)');
|
|
33
|
-
}
|
|
34
|
-
ObjC.registerSubclass({ name: 'AhubPetPanel', superclass: 'NSPanel', methods: {
|
|
35
|
-
canBecomeKeyWindow: { types: ['bool', []], implementation: function() { return true; } },
|
|
36
|
-
canBecomeMainWindow: { types: ['bool', []], implementation: function() { return false; } }
|
|
37
|
-
} });
|
|
38
|
-
// JXA cannot resolve WKScriptMessageHandler by name on some macOS versions;
|
|
39
|
-
// WebKit invokes the exact Objective-C selector on this retained subclass.
|
|
40
|
-
ObjC.registerSubclass({ name: 'AhubPetBridge', superclass: 'NSObject', methods: {
|
|
41
|
-
'userContentController:didReceiveScriptMessage:': { types: ['void', ['id', 'id']], implementation: function(controller, message) {
|
|
42
|
-
var body = ObjC.deepUnwrap(message.body);
|
|
43
|
-
if (!body || typeof body !== 'object' || exiting) return;
|
|
44
|
-
if (body.kind === 'ready') {
|
|
45
|
-
tick();
|
|
46
|
-
$('ready').writeToFileAtomicallyEncodingError(readyPath, true, $.NSUTF8StringEncoding, null);
|
|
47
|
-
} else if (body.kind === 'pencil') {
|
|
48
|
-
panel.orderFrontRegardless; panel.makeKeyWindow;
|
|
49
|
-
evaluate('focusInput()');
|
|
50
|
-
} else if (body.kind === 'drag' && typeof body.dx === 'number' && typeof body.dy === 'number' && isFinite(body.dx) && isFinite(body.dy) && Math.abs(body.dx) <= 2000 && Math.abs(body.dy) <= 2000) {
|
|
51
|
-
var origin = panel.frame.origin;
|
|
52
|
-
panel.setFrameOrigin($.NSMakePoint(origin.x + body.dx, origin.y - body.dy));
|
|
53
|
-
savePosition();
|
|
54
|
-
} else if (body.kind === 'hotspot' && typeof body.x === 'number' && typeof body.y === 'number' && typeof body.w === 'number' && typeof body.h === 'number' && isFinite(body.x) && isFinite(body.y) && Math.abs(body.x) <= 500 && Math.abs(body.y) <= 500 && body.w > 0 && body.h > 0 && body.w <= 500 && body.h <= 500) {
|
|
55
|
-
hotspot = { x: body.x, y: body.y, w: body.w, h: body.h };
|
|
56
|
-
} else if (body.kind === 'send' && typeof body.text === 'string' && body.text.trim() && body.text.length <= 65536 && body.text.indexOf('\0') === -1) {
|
|
57
|
-
runCommand('say', ['--', body.text], cliArgv);
|
|
58
|
-
} else if (body.kind === 'palette' && typeof body.palette === 'string' && /^(blue|teal|violet|ochre)$/.test(body.palette)) {
|
|
59
|
-
runCommand('pet', ['--palette', body.palette], cliArgv);
|
|
60
|
-
} else if (body.kind === 'open') runCommand('ui', [], cliArgv);
|
|
61
|
-
else if (body.kind === 'close') quit();
|
|
62
|
-
} },
|
|
63
|
-
'tick:': { types: ['void', ['id']], implementation: function(sender) { tick(); } },
|
|
64
|
-
'hover:': { types: ['void', ['id']], implementation: function(sender) { pollHover(); } }
|
|
65
|
-
} });
|
|
66
|
-
function tick() {
|
|
67
|
-
var value;
|
|
68
|
-
try { value = JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(viewPath, $.NSUTF8StringEncoding, null))); }
|
|
69
|
-
catch (error) { value = { offline: true }; }
|
|
70
|
-
evaluate('update(' + JSON.stringify(value) + ')');
|
|
71
|
-
if (value.offline === true) { if (!offlineAt) offlineAt = Date.now(); if (Date.now() - offlineAt >= 2000) quit(); }
|
|
72
|
-
else offlineAt = 0;
|
|
73
|
-
commands = commands.filter(function(item) {
|
|
74
|
-
if (item.task.isRunning) return true;
|
|
75
|
-
if (item.command === 'say') evaluate('commandResult(' + (Number(item.task.terminationStatus) === 0) + ')');
|
|
76
|
-
return false;
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
function run(argv) {
|
|
80
|
-
if (argv.length !== 3) throw new Error('Invalid pet window arguments');
|
|
81
|
-
viewPath = argv[0];
|
|
82
|
-
var htmlPath = argv[1];
|
|
83
|
-
cliArgv = JSON.parse(argv[2]);
|
|
84
|
-
if (!Array.isArray(cliArgv) || cliArgv.length !== 4 || cliArgv.some(function(arg) { return typeof arg !== 'string'; }) || cliArgv[2] !== '--project') throw new Error('Invalid CLI argv');
|
|
85
|
-
var stateDir = ObjC.unwrap($(viewPath).stringByDeletingLastPathComponent);
|
|
86
|
-
positionPath = stateDir + '/pet-window-pos.json';
|
|
87
|
-
var owner = JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(stateDir + '/pet-window.pid', $.NSUTF8StringEncoding, null)));
|
|
88
|
-
readyPath = stateDir + '/pet-window-ready-' + owner.token;
|
|
89
|
-
app = $.NSApplication.sharedApplication;
|
|
90
|
-
app.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
|
|
91
|
-
var screen = $.NSScreen.mainScreen.visibleFrame;
|
|
92
|
-
var W = 150, H = 150;
|
|
93
|
-
var origin = { x: screen.origin.x + screen.size.width - (W + 20), y: screen.origin.y + 80 };
|
|
94
|
-
try {
|
|
95
|
-
var saved = JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(positionPath, $.NSUTF8StringEncoding, null)));
|
|
96
|
-
if (typeof saved.x === 'number' && typeof saved.y === 'number' && isFinite(saved.x) && isFinite(saved.y)) origin = saved;
|
|
97
|
-
} catch (error) {}
|
|
98
|
-
var screens = $.NSScreen.screens;
|
|
99
|
-
var visible = false;
|
|
100
|
-
for (var i = 0; i < screens.count; i++) {
|
|
101
|
-
var frame = screens.objectAtIndex(i).visibleFrame;
|
|
102
|
-
if (origin.x >= frame.origin.x && origin.y >= frame.origin.y && origin.x + W <= frame.origin.x + frame.size.width && origin.y + H <= frame.origin.y + frame.size.height) visible = true;
|
|
103
|
-
}
|
|
104
|
-
if (!visible) origin = { x: screen.origin.x + screen.size.width - (W + 20), y: screen.origin.y + 80 };
|
|
105
|
-
panel = $.AhubPetPanel.alloc.initWithContentRectStyleMaskBackingDefer($.NSMakeRect(origin.x, origin.y, W, H), $.NSWindowStyleMaskBorderless | $.NSWindowStyleMaskNonactivatingPanel, $.NSBackingStoreBuffered, false);
|
|
106
|
-
panel.title = 'agent-hub pet'; panel.opaque = false; panel.backgroundColor = $.NSColor.clearColor;
|
|
107
|
-
panel.level = $.NSFloatingWindowLevel;
|
|
108
|
-
panel.collectionBehavior = $.NSWindowCollectionBehaviorCanJoinAllSpaces | $.NSWindowCollectionBehaviorStationary;
|
|
109
|
-
panel.hidesOnDeactivate = false;
|
|
110
|
-
panel.acceptsMouseMovedEvents = true; // CSS :hover while key; pollHover() covers the inactive case
|
|
111
|
-
var config = $.WKWebViewConfiguration.alloc.init;
|
|
112
|
-
bridge = $.AhubPetBridge.alloc.init;
|
|
113
|
-
config.userContentController.addScriptMessageHandlerName(bridge, 'pet');
|
|
114
|
-
web = $.WKWebView.alloc.initWithFrameConfiguration($.NSMakeRect(0, 0, W, H), config);
|
|
115
|
-
web.setValueForKey(false, 'drawsBackground');
|
|
116
|
-
panel.contentView = web;
|
|
117
|
-
web.loadFileURLAllowingReadAccessToURL($.NSURL.fileURLWithPath(htmlPath), $.NSURL.fileURLWithPath(htmlPath));
|
|
118
|
-
panel.orderFrontRegardless; panel.makeKeyWindow;
|
|
119
|
-
timer = $.NSTimer.scheduledTimerWithTimeIntervalTargetSelectorUserInfoRepeats(1, bridge, 'tick:', null, true);
|
|
120
|
-
hoverTimer = $.NSTimer.scheduledTimerWithTimeIntervalTargetSelectorUserInfoRepeats(0.1, bridge, 'hover:', null, true);
|
|
121
|
-
app.run;
|
|
122
|
-
}
|