@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/needs.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline" | "stall" | "offer";
|
|
1
|
+
export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline" | "stall" | "offer" | "question" | "result";
|
|
2
2
|
export interface NeedItem { kind: NeedKind; level: "act" | "watch"; peer?: string; id?: string | number; until?: number; since?: number; }
|
|
3
3
|
export interface NeedsInput {
|
|
4
4
|
approvals?: readonly { id: string; peer?: string; expiresAt: number; since?: number }[];
|
|
@@ -10,6 +10,10 @@ export interface NeedsInput {
|
|
|
10
10
|
facts?: readonly { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
|
|
11
11
|
messages?: readonly { id?: string; peer?: string; important: boolean; since?: number }[];
|
|
12
12
|
budget?: Readonly<Record<string, { paused?: { since?: number; resetsAt?: number } }>>;
|
|
13
|
+
/** #1053: the open questions a peer asked the person: the set id, the asking peer and when it was asked, never a prompt or an option. */
|
|
14
|
+
questions?: readonly { set: string; peer?: string; since?: number }[];
|
|
15
|
+
/** #1054: unread results, a run a person started that settled and has not been seen: the completion id, the peer and when it settled, never a prompt or a reply. */
|
|
16
|
+
results?: readonly { id: string; peer?: string; since?: number }[];
|
|
13
17
|
}
|
|
14
18
|
export interface Needs { items: NeedItem[]; act: number; watch: number; state: "waiting" | "blocked" | "review" | "working" | "idle" | "sleeping"; }
|
|
15
19
|
export interface NeedsSummary { act: number; watch: number; state: Needs["state"]; first?: NeedItem }
|
|
@@ -22,6 +26,7 @@ export function needs(input: NeedsInput, now: number): Needs {
|
|
|
22
26
|
const add = (kind: NeedKind, level: "act" | "watch", peer?: unknown, identifier?: unknown, until?: unknown, since?: unknown) => items.push({ kind, level, ...(id(peer) ? { peer } : {}), ...(id(identifier) || typeof identifier === "number" && Number.isSafeInteger(identifier) && identifier > 0 ? { id: identifier } : {}), ...(time(until) ? { until } : {}), ...(time(since) ? { since } : {}) });
|
|
23
27
|
for (const request of input.approvals ?? []) if (id(request.id) && time(request.expiresAt) && request.expiresAt > now) add("approval", "act", request.peer, request.id, request.expiresAt, request.since);
|
|
24
28
|
for (const pending of input.defaults ?? []) if (id(pending.peer)) add("default", "act", pending.peer);
|
|
29
|
+
for (const question of input.questions ?? []) if (id(question.set)) add("question", "act", question.peer, question.set, undefined, question.since);
|
|
25
30
|
if (input.storageBlocked === true) add("storage", "act");
|
|
26
31
|
if (input.recovery) add("recovery", input.recovery.running === false ? "act" : "watch", undefined, input.recovery.id, undefined, input.recovery.since);
|
|
27
32
|
for (const [peer, status] of Object.entries(input.peers ?? {})) {
|
|
@@ -44,17 +49,18 @@ export function needs(input: NeedsInput, now: number): Needs {
|
|
|
44
49
|
else if (fact.kind === "offline" && agent(fact.peer) && !offline.has(fact.peer)) { offline.add(fact.peer); add("offline", "watch", fact.peer, fact.id); }
|
|
45
50
|
}
|
|
46
51
|
for (const [peer, budget] of Object.entries(input.budget ?? {})) if (budget.paused) add("budget", "watch", peer, undefined, budget.paused.resetsAt, budget.paused.since);
|
|
47
|
-
const
|
|
52
|
+
for (const result of input.results ?? []) if (id(result.id)) add("result", "watch", result.peer, result.id, undefined, result.since);
|
|
53
|
+
const order: Record<NeedKind, number> = { approval: 0, default: 1, question: 2, storage: 3, recovery: 4, delivery: 5, review: 6, stall: 7, offer: 8, message: 9, result: 10, budget: 11, offline: 12 };
|
|
48
54
|
items.sort((a,b) => (a.level === "act" ? 0 : 1) - (b.level === "act" ? 0 : 1) || order[a.kind] - order[b.kind] || (a.kind === "approval" ? (a.until ?? Infinity) - (b.until ?? Infinity) : 0) || (a.since ?? 0) - (b.since ?? 0));
|
|
49
55
|
const act = items.filter(item => item.level === "act").length, watch = items.length - act;
|
|
50
56
|
const peers = Object.values(input.peers ?? {}), attached = peers.some(peer => peer.attached !== false && ["idle", "busy", "paused"].includes(peer.state ?? ""));
|
|
51
|
-
const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery", "stall"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
|
|
57
|
+
const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default" || item.kind === "question") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery", "stall"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
|
|
52
58
|
return { items, act, watch, state };
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
/** Global status never reads console-local unread messages or source text. */
|
|
56
62
|
export function summarizeNeeds(input: Omit<NeedsInput, "messages">, now: number): NeedsSummary {
|
|
57
63
|
const value = needs({ approvals: input.approvals, defaults: input.defaults, peers: input.peers,
|
|
58
|
-
storageBlocked: input.storageBlocked, recovery: input.recovery, tasks: input.tasks, facts: input.facts, budget: input.budget }, now);
|
|
64
|
+
storageBlocked: input.storageBlocked, recovery: input.recovery, tasks: input.tasks, facts: input.facts, budget: input.budget, questions: input.questions, results: input.results }, now);
|
|
59
65
|
return { act: value.act, watch: value.watch, state: value.state, ...(value.items[0] ? { first: value.items[0] } : {}) };
|
|
60
66
|
}
|
package/src/ui/office-model.ts
CHANGED
|
@@ -33,6 +33,16 @@ export interface OfficeTaskInput {
|
|
|
33
33
|
deps?: readonly number[];
|
|
34
34
|
/** Optional (phase 3): the task's check is running; the task stays in_progress meanwhile. */
|
|
35
35
|
checking?: boolean;
|
|
36
|
+
rank?: number | null;
|
|
37
|
+
priority?: number | null;
|
|
38
|
+
reserved?: string | null;
|
|
39
|
+
release?: number | null;
|
|
40
|
+
pinned?: boolean;
|
|
41
|
+
plannedStart?: number | null;
|
|
42
|
+
}
|
|
43
|
+
export interface OfficeReleaseInput {
|
|
44
|
+
id: number; name?: string; state?: string; order?: number; cut?: number | null;
|
|
45
|
+
tasks?: readonly { id: number; state?: string; deps?: readonly number[] }[];
|
|
36
46
|
}
|
|
37
47
|
/** Optional (phase 3): a warning line between desks. Task and peer ids only, never paths or symbols. */
|
|
38
48
|
export interface OfficeLinkInput {
|
|
@@ -55,7 +65,8 @@ export interface OfficeEventInput {
|
|
|
55
65
|
};
|
|
56
66
|
}
|
|
57
67
|
export interface OfficeSnapshotInput {
|
|
58
|
-
status?: { peers?: Record<string, OfficePeerInput> };
|
|
68
|
+
status?: { peers?: Record<string, OfficePeerInput>; roleInfo?: Record<string, { flowRoles?: readonly string[] }> };
|
|
69
|
+
releases?: readonly OfficeReleaseInput[];
|
|
59
70
|
tasks?: readonly OfficeTaskInput[];
|
|
60
71
|
permissions?: readonly { peer?: string }[];
|
|
61
72
|
budget?: Record<string, { paused?: { reason?: string; resetsAt?: number | null } | null; [key: string]: unknown }>;
|
|
@@ -84,6 +95,10 @@ export interface OfficeCard {
|
|
|
84
95
|
rework: boolean;
|
|
85
96
|
/** The task's check is running; the card stays at its owner's desk. */
|
|
86
97
|
checking: boolean;
|
|
98
|
+
reserved: string;
|
|
99
|
+
pinned: boolean;
|
|
100
|
+
plannedDate: string;
|
|
101
|
+
releaseTag?: { ordinal: number; name: string };
|
|
87
102
|
/** Visible text: "#id public title". */
|
|
88
103
|
label: string;
|
|
89
104
|
/** Accessible name: "#id class: public title (state)", plus waiting/rework/review marks. */
|
|
@@ -114,6 +129,8 @@ export interface OfficeDesk {
|
|
|
114
129
|
breakResetsAt: number | null;
|
|
115
130
|
cards: OfficeCard[];
|
|
116
131
|
extraCards: number;
|
|
132
|
+
planner: boolean;
|
|
133
|
+
unplanned: number;
|
|
117
134
|
}
|
|
118
135
|
export interface OfficeRoom {
|
|
119
136
|
id: string;
|
|
@@ -123,6 +140,12 @@ export interface OfficeRoom {
|
|
|
123
140
|
count: number;
|
|
124
141
|
cards: OfficeCard[];
|
|
125
142
|
overflow: number;
|
|
143
|
+
unplanned?: number;
|
|
144
|
+
x: number; y: number; w: number; h: number;
|
|
145
|
+
}
|
|
146
|
+
export interface OfficeDock {
|
|
147
|
+
id: number; name: string; state: string; ordinal: number;
|
|
148
|
+
approved: number; scope: number; ratio: number; cut: string;
|
|
126
149
|
x: number; y: number; w: number; h: number;
|
|
127
150
|
}
|
|
128
151
|
export interface OfficeTransitions {
|
|
@@ -158,6 +181,8 @@ export interface OfficeScene {
|
|
|
158
181
|
desks: OfficeDesk[];
|
|
159
182
|
extraPeers: number;
|
|
160
183
|
rooms: OfficeRoom[];
|
|
184
|
+
docks: OfficeDock[];
|
|
185
|
+
extraReleases: number;
|
|
161
186
|
counts: OfficeCounts;
|
|
162
187
|
transitions: OfficeTransitions;
|
|
163
188
|
links: OfficeLink[];
|
|
@@ -168,7 +193,7 @@ export interface OfficeScene {
|
|
|
168
193
|
* every `cards` list. Zero geometry: the dashboard never draws it, acon Space maps it. Same allowlisted fields as `cards`.
|
|
169
194
|
*/
|
|
170
195
|
attributedCards: OfficeCard[];
|
|
171
|
-
/** Task id -> placement ("room:todo" | "room:work" | "room:review" | "shelf:done" | "desk:<peer>" | "counted:unknown"). */
|
|
196
|
+
/** Task id -> placement ("room:backlog" | "room:todo" | "room:work" | "room:review" | "shelf:done" | "desk:<peer>" | "counted:unknown"). */
|
|
172
197
|
placed: Record<string, string>;
|
|
173
198
|
arcs: OfficeArc[];
|
|
174
199
|
alerts: OfficeAlert[];
|
|
@@ -204,6 +229,17 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
204
229
|
const tasks = Array.isArray(snapshot?.tasks) ? snapshot.tasks.filter((task) => task && typeof task.id === "number") : [];
|
|
205
230
|
const permissions = Array.isArray(snapshot?.permissions) ? snapshot.permissions : [];
|
|
206
231
|
const budget = snapshot?.budget && typeof snapshot.budget === "object" ? snapshot.budget : {};
|
|
232
|
+
const date = (value: unknown) => {
|
|
233
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 8_640_000_000_000_000) return "";
|
|
234
|
+
const local = new Date(value);
|
|
235
|
+
return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, "0")}-${String(local.getDate()).padStart(2, "0")}`;
|
|
236
|
+
};
|
|
237
|
+
const rank = (value: unknown) => typeof value === "number" && Number.isFinite(value) ? value : Infinity;
|
|
238
|
+
const backlog = tasks.filter(task => task.state === "backlog").sort((a, b) => (rank(a.rank) - rank(b.rank)) || (rank(a.priority) - rank(b.priority)) || a.id - b.id);
|
|
239
|
+
const unplanned = backlog.filter(task => rank(task.rank) === Infinity && !text(task.reserved)).length;
|
|
240
|
+
const releases: OfficeReleaseInput[] = (Array.isArray(snapshot?.releases) ? snapshot.releases : []).filter(row => row && Number.isSafeInteger(row.id) && row.id > 0 && ["planning", "active", "frozen"].includes(text(row.state)))
|
|
241
|
+
.sort((a, b) => (rank(a.order) - rank(b.order)) || a.id - b.id);
|
|
242
|
+
const docked = releases.slice(0, 3);
|
|
207
243
|
|
|
208
244
|
// State counts mirror taskProgress (src/ui/task-progress.ts) exactly, including the waiting
|
|
209
245
|
// rule on deps; test/office-model.test.ts pins the equality so a drift fails there.
|
|
@@ -272,17 +308,24 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
272
308
|
const card = (task: OfficeTaskInput, x: number, y: number, w: number, h: number): OfficeCard => {
|
|
273
309
|
const state = text(task.state) || "unknown";
|
|
274
310
|
const taskClass = text(task.class) || "task";
|
|
275
|
-
const
|
|
311
|
+
const plannedDate = state === "backlog" && date(task.plannedStart) && task.plannedStart! > now ? date(task.plannedStart) : "";
|
|
312
|
+
const waiting = waitingById.get(task.id) === true || state === "backlog" && ((task.deps ?? []).some(id => states.get(id) !== "approved") || !!plannedDate);
|
|
276
313
|
const rework = state === "changes_requested";
|
|
277
314
|
const checking = task.checking === true;
|
|
278
315
|
const owner = text(task.owner);
|
|
279
316
|
const reviewer = text(task.reviewer);
|
|
317
|
+
const reserved = state === "backlog" ? text(task.reserved) : "", pinned = state === "backlog" && task.pinned === true;
|
|
318
|
+
const releaseIndex = docked.findIndex(row => row.id === task.release);
|
|
319
|
+
const releaseTag = releaseIndex < 0 ? undefined : { ordinal: releaseIndex + 1, name: text(docked[releaseIndex]!.name) || "[withheld]" };
|
|
280
320
|
let name = `#${task.id} ${taskClass}: ${text(task.title)} (${state.replaceAll("_", " ")})`;
|
|
281
|
-
if (waiting) name += ", waiting on dependencies";
|
|
321
|
+
if (waiting) name += state === "backlog" ? ", waits" + (plannedDate ? ` until ${plannedDate}` : "") : ", waiting on dependencies";
|
|
322
|
+
if (reserved) name += `, reserved for ${reserved}`;
|
|
323
|
+
if (pinned) name += ", pinned";
|
|
324
|
+
if (releaseTag) name += `, release ${releaseTag.ordinal}: ${releaseTag.name}`;
|
|
282
325
|
if (rework) name += ", rework";
|
|
283
326
|
if (checking) name += ", check running";
|
|
284
327
|
if (state === "in_review") name += `, ${owner || "unassigned"} reviewed by ${reviewer || "unassigned"}`;
|
|
285
|
-
return { id: task.id, taskClass, state, owner, reviewer, waiting, rework, checking, label: `#${task.id} ${text(task.title)}`, name, x, y, w, h };
|
|
328
|
+
return { id: task.id, taskClass, state, owner, reviewer, waiting, rework, checking, reserved, pinned, plannedDate, ...(releaseTag ? { releaseTag } : {}), label: `#${task.id} ${text(task.title)}`, name, x, y, w, h };
|
|
286
329
|
};
|
|
287
330
|
|
|
288
331
|
const desks: OfficeDesk[] = shownIds.map((id) => {
|
|
@@ -320,6 +363,8 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
320
363
|
if (finishing) parts.push("finishing a turn");
|
|
321
364
|
if (condition !== "waiting" && count > 0) parts.push(`${count} approval${count === 1 ? "" : "s"} waiting`);
|
|
322
365
|
if (!attached) parts.push("disconnected, work retained");
|
|
366
|
+
const planner = Array.isArray(snapshot.status?.roleInfo?.[id]?.flowRoles) && snapshot.status!.roleInfo![id]!.flowRoles!.includes("planner");
|
|
367
|
+
if (planner) parts.push(`planner, ${unplanned} unplanned backlog cards`);
|
|
323
368
|
// An over-full desk shows two chips and a "+N more" line in the third slot.
|
|
324
369
|
const shown = work.length > DESK_CARDS ? work.slice(0, DESK_CARDS - 1) : work;
|
|
325
370
|
return {
|
|
@@ -331,12 +376,14 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
331
376
|
finishing,
|
|
332
377
|
cards: shown.map((task, index) => card(task, x + 0.8, y + 8.5 + index * 2.5, DESK_W - 1.6, 2)),
|
|
333
378
|
extraCards: work.length - shown.length,
|
|
379
|
+
planner, unplanned: planner ? unplanned : 0,
|
|
334
380
|
};
|
|
335
381
|
});
|
|
336
382
|
|
|
337
383
|
const desksBottom = Math.max(0, ...desks.map(desk => desk.y + desk.h));
|
|
338
384
|
const roomsY = desksBottom ? desksBottom + 2 : 0;
|
|
339
385
|
const roomDefs = [
|
|
386
|
+
{ id: "backlog", label: "Backlog", filterState: "backlog", list: backlog, count: counts.backlog ?? 0 },
|
|
340
387
|
{ id: "todo", label: "To do", filterState: "proposed", list: todo, count: counts.proposed + counts.waiting },
|
|
341
388
|
{ id: "work", label: "Work floor", filterState: "work", list: workUnplaced, count: counts.in_progress + counts.changes_requested },
|
|
342
389
|
{ id: "review", label: "Review", filterState: "in_review", list: review, count: counts.in_review },
|
|
@@ -347,12 +394,20 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
347
394
|
const y = roomsY + Math.floor(index / cols) * ROOM_DY;
|
|
348
395
|
return {
|
|
349
396
|
id: def.id, label: def.label, filterState: def.filterState, count: def.count,
|
|
397
|
+
...(def.id === "backlog" && !desks.some(desk => desk.planner) ? { unplanned } : {}),
|
|
350
398
|
// One column at 12 px per unit: 28 px cards, 6 px gaps, eight cards at most.
|
|
351
399
|
cards: def.list.slice(0, ROOM_CARDS).map((task, row) => card(task, x + 1, y + 3.4 + row * (ROOM_CARD_H + ROOM_CARD_GAP), ROOM_W - 2, ROOM_CARD_H)),
|
|
352
400
|
overflow: Math.max(0, def.list.length - ROOM_CARDS),
|
|
353
401
|
x, y, w: ROOM_W, h: ROOM_H,
|
|
354
402
|
};
|
|
355
403
|
});
|
|
404
|
+
const docks: OfficeDock[] = docked.map((row, index) => {
|
|
405
|
+
const scope: NonNullable<OfficeReleaseInput["tasks"]> = Array.isArray(row.tasks) ? row.tasks.filter((task: NonNullable<OfficeReleaseInput["tasks"]>[number]) => task && Number.isSafeInteger(task.id) && task.id > 0) : [];
|
|
406
|
+
const approved = scope.filter(task => task.state === "approved").length, position = roomDefs.length + index;
|
|
407
|
+
return { id: row.id, name: text(row.name) || "[withheld]", state: text(row.state), ordinal: index + 1, approved, scope: scope.length,
|
|
408
|
+
ratio: scope.length ? approved / scope.length : 0, cut: date(row.cut), x: (position % cols) * ROOM_DX,
|
|
409
|
+
y: roomsY + Math.floor(position / cols) * ROOM_DY, w: ROOM_W, h: ROOM_H };
|
|
410
|
+
});
|
|
356
411
|
|
|
357
412
|
const placed: Record<string, string> = {};
|
|
358
413
|
for (const task of tasks) { const state = text(task.state);
|
|
@@ -362,7 +417,7 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
362
417
|
: state === "approved" ? "shelf:done"
|
|
363
418
|
: state === "cancelled" ? "counted:cancelled"
|
|
364
419
|
: state === "in_progress" || state === "changes_requested" ? (owner && shownSet.has(owner) ? `desk:${owner}` : "room:work")
|
|
365
|
-
: state === "backlog" ? "
|
|
420
|
+
: state === "backlog" ? "room:backlog" : "counted:unknown";
|
|
366
421
|
}
|
|
367
422
|
// A bubble's attributed task may sit past the rendering cut; keep its public card so a consumer that draws more cards than the page can.
|
|
368
423
|
const drawn = new Set<number>([...desks.flatMap((desk) => desk.cards), ...rooms.flatMap((room) => room.cards)].map((entry) => entry.id));
|
|
@@ -454,9 +509,10 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
454
509
|
}
|
|
455
510
|
return {
|
|
456
511
|
cols,
|
|
457
|
-
width: Math.max(desksWidth, ...rooms.map(room => room.x + room.w), 1),
|
|
458
|
-
height: Math.max(desksBottom, ...rooms.map(room => room.y + room.h), 1),
|
|
512
|
+
width: Math.max(desksWidth, ...[...rooms, ...docks].map(room => room.x + room.w), 1),
|
|
513
|
+
height: Math.max(desksBottom, ...[...rooms, ...docks].map(room => room.y + room.h), 1),
|
|
459
514
|
desks, extraPeers, rooms, counts, transitions, slots, placed, attributedCards,
|
|
515
|
+
docks, extraReleases: Math.max(0, releases.length - docks.length),
|
|
460
516
|
links,
|
|
461
517
|
arcs: keptArcs, alerts: keptAlerts, latched, settle, at, eventTime, lastSeq,
|
|
462
518
|
};
|
package/src/ui/office-space.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface SpaceScene {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const ZONES: Record<string, { kind: ZoneKind; title: string }> = {
|
|
24
|
+
backlog: { kind: "queue", title: "Backlog" },
|
|
24
25
|
todo: { kind: "queue", title: "To do" }, work: { kind: "work", title: "Work floor" },
|
|
25
26
|
review: { kind: "review", title: "Review" }, done: { kind: "done", title: "Done" },
|
|
26
27
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1053 T5: the acon inbox side of a question a peer asked with hub_ask. Pure: it builds the inbox item from the stored row the hub reports and checks the
|
|
3
|
+
* person evidence an answer needs; `plugin.ts` owns the transport. No text here is logged.
|
|
4
|
+
*
|
|
5
|
+
* Authority: an answer is accepted only when the console advertises `action.meta` and `action()` received the console's own `meta` (`via: "console"`, a verified
|
|
6
|
+
* `identity`), which the page cannot set (agent-console `action(input, meta?)`). An answer without it is refused before any request reaches the hub.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ActionMeta, InboxAnswers, InboxVerb, VerbForm } from "@staix/agent-console/contract";
|
|
10
|
+
|
|
11
|
+
// The acon types come only through the generated bridge (`scripts/acon-review-types.ts`, regenerated and compared by its check), so a drift of the form or of the answer it
|
|
12
|
+
// submits fails there and not at a user's console. Type-only: agent-hub keeps zero runtime dependencies.
|
|
13
|
+
export type { ActionMeta, InboxAnswers };
|
|
14
|
+
export interface QuestionRowView {
|
|
15
|
+
set: string; peer: string; created: number; private?: boolean;
|
|
16
|
+
questions: { id: string; prompt: string; options: { id: string; label: string; description?: string }[]; multi?: boolean; allowOther?: boolean }[];
|
|
17
|
+
}
|
|
18
|
+
type FormQuestion = VerbForm["questions"][number];
|
|
19
|
+
export interface QuestionItem {
|
|
20
|
+
id: string; group: "decision"; kind: string; title: string; actor: string; project: string; at: string; chip: { text: string; tone: "warn" };
|
|
21
|
+
verbs: InboxVerb[];
|
|
22
|
+
link: { panel: string; params: Record<string, string> };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Both features are needed: `inbox.form` renders the form, `action.meta` gives the answer its person evidence. One alone is the plain item. */
|
|
26
|
+
export const canAnswerInInbox = (features: readonly string[] | undefined): boolean => !!features?.includes("inbox.form") && features.includes("action.meta");
|
|
27
|
+
|
|
28
|
+
const flat = (text: string, max: number) => Array.from(text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]+/g, " ").trim()).slice(0, max).join("");
|
|
29
|
+
export const questionItemId = (project: string, set: string) => `question:${project}:${set}`;
|
|
30
|
+
export const QUESTION_ITEM = /^question:([^:]+):(qs-[a-f0-9]{12})$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The inbox item. A private question (asked in a PII turn or about a PII task) gets a stub title and no detail: its text is in the form only, which is the person's
|
|
34
|
+
* own answer surface. Without the two features the item is a plain decision with an `open` verb that links to the agents panel.
|
|
35
|
+
*/
|
|
36
|
+
export function questionItem(row: QuestionRowView, project: { id: string; name: string }, features: readonly string[] | undefined): QuestionItem {
|
|
37
|
+
const head = row.private ? "a question" : `: ${flat(row.questions[0]?.prompt ?? "", 80)}`;
|
|
38
|
+
const title = (row.private ? `${row.peer} asks ${head}` : `${row.peer} asks${head}`).slice(0, 200);
|
|
39
|
+
const link = { panel: "agents", params: { project: project.id } };
|
|
40
|
+
const base = { id: questionItemId(project.id, row.set), group: "decision" as const, title, actor: row.peer, project: project.name, at: new Date(row.created).toISOString(), chip: { text: "question", tone: "warn" as const }, link };
|
|
41
|
+
if (!canAnswerInInbox(features)) return { ...base, kind: "decision", verbs: [{ id: "open", label: "Open Agents", primary: true }] };
|
|
42
|
+
const form = { questions: row.questions.map<FormQuestion>(question => ({ id: question.id, prompt: question.prompt, options: question.options.map(option => ({ id: option.id, label: option.label, ...(option.description ? { description: option.description } : {}) })),
|
|
43
|
+
...(question.multi === true ? { multiple: true } : {}), ...(question.allowOther === true ? { other: { maxLength: 500 } } : {}) })) };
|
|
44
|
+
return { ...base, kind: "question", verbs: [{ id: "answer", label: "Answer", primary: true, form }, { id: "open", label: "Open Agents" }] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The console's evidence, or the refusal that tells the person how to get it. Page-supplied fields that would stand in for it are refused too. */
|
|
48
|
+
export function personEvidence(features: readonly string[] | undefined, meta: ActionMeta | undefined, input: Record<string, unknown>): { ok: true; identity: string } | { ok: false; error: string } {
|
|
49
|
+
if (!features?.includes("action.meta") || !meta || typeof meta !== "object") return { ok: false, error: "Answering needs a console with verified person identity; update acon and reopen this panel, or answer with ahub answer." };
|
|
50
|
+
const value = meta as unknown as Record<string, unknown>;
|
|
51
|
+
if (value.via !== "console" || typeof value.identity !== "string" || !value.identity.trim() || typeof value.node !== "string" || !value.node.trim())
|
|
52
|
+
return { ok: false, error: "Answering needs a console with verified person identity; update acon and reopen this panel, or answer with ahub answer." };
|
|
53
|
+
if (["author", "meta", "personMeta", "person", "identity", "surface", "questionSet", "projectId", "instanceId"].some(key => Object.hasOwn(input, key)))
|
|
54
|
+
return { ok: false, error: "The person's identity and the target come from the console; remove those fields and retry." };
|
|
55
|
+
return { ok: true, identity: value.identity.trim().slice(0, 200) };
|
|
56
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1054 T6: the acon inbox side of a result: a run a person started that settled and has not been seen. Pure: it builds the inbox item from the row the hub reports and checks the
|
|
3
|
+
* person evidence a receipt needs; `plugin.ts` owns the transport. No text here is logged.
|
|
4
|
+
*
|
|
5
|
+
* Publication: an item is published only to a console that advertises BOTH `inbox.result` (it renders the group) and `action.meta` (a receipt then has person evidence). One alone,
|
|
6
|
+
* or neither, and results stay in the ahub panel, `ahub console`, `ahub status` and `ahub results`: nothing is published that the console could not render or that the person
|
|
7
|
+
* could not mark read with evidence.
|
|
8
|
+
*
|
|
9
|
+
* Authority: `result_seen`, `results_read_all` and `results_unread_last` are accepted only when the console advertises `action.meta` and `action()` received the console's own
|
|
10
|
+
* `meta` (`via: "console"`, a verified `identity`), which the page cannot set (agent-console `action(input, meta?)`). They never go through the dashboard's forwarding list. The
|
|
11
|
+
* acon "seeing" rule (a focused, visible window, the result's end in view for a second, the latest edge) is agent-console#27 and is not decided here: this only accepts the receipt.
|
|
12
|
+
* Mark unread is NOT an inbox verb: a read item leaves the inbox, so mark-unread lives in the ahub panel (this plugin's dashboard) and in `ahub results --unread-last`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ActionMeta } from "@staix/agent-console/contract";
|
|
16
|
+
|
|
17
|
+
export type { ActionMeta };
|
|
18
|
+
export type ResultOutcome = "done" | "failed" | "limit" | "interrupted";
|
|
19
|
+
/** The detail the hub built at display (`completion-detail.ts`): a head of the request, a stub, or nothing. */
|
|
20
|
+
export type ResultDetailView = { kind: "head" | "stub"; text: string } | { kind: "none" };
|
|
21
|
+
/** One row of the hub's `results` snapshot field. */
|
|
22
|
+
export interface ResultRowView {
|
|
23
|
+
id: string; peer: string; outcome: ResultOutcome; settledAt: number; ms?: number; private?: boolean; title?: string; detail?: ResultDetailView;
|
|
24
|
+
}
|
|
25
|
+
export interface ResultItem {
|
|
26
|
+
id: string; group: "result"; kind: "result"; title: string; detail?: string; actor: string; project: string; at: string; chip: { text: string; tone: "ok" | "warn" | "err" | "neutral" };
|
|
27
|
+
verbs: { id: string; label: string; primary?: boolean }[];
|
|
28
|
+
link: { panel: string; params: Record<string, string> };
|
|
29
|
+
seen: { action: "result_seen" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The receipt actions this plugin advertises (so acon lets the page and the inbox send them), and answers itself with the console's person meta: never in the dashboard's forwarding
|
|
34
|
+
* list. `result_seen` and `results_read_all` come from the inbox (acon's seeing rule and its "Mark all read") and from the panel; `results_unread_last` only from the panel, because a
|
|
35
|
+
* read item has left the inbox: there is no inbox verb for it. All three are the person's writes, so all three need the same evidence.
|
|
36
|
+
*/
|
|
37
|
+
export const RESULT_ACTIONS = ["result_seen", "results_read_all", "results_unread_last"] as const;
|
|
38
|
+
export type ResultAction = (typeof RESULT_ACTIONS)[number];
|
|
39
|
+
|
|
40
|
+
/** Both features are needed. One alone is not enough: no group to render, or no person evidence for the receipt. */
|
|
41
|
+
export const canPublishResults = (features: readonly string[] | undefined): boolean => !!features?.includes("inbox.result") && features.includes("action.meta");
|
|
42
|
+
|
|
43
|
+
export const resultItemId = (project: string, id: string) => `result:${project}:${id}`;
|
|
44
|
+
export const RESULT_ITEM = /^result:([^:]+):(cmp-[a-f0-9]{12})$/;
|
|
45
|
+
const OUTCOME_WORD: Record<ResultOutcome, string> = { done: "finished", failed: "failed", limit: "stopped at a limit", interrupted: "interrupted" };
|
|
46
|
+
const CHIP: Record<ResultOutcome, { text: string; tone: "ok" | "warn" | "err" | "neutral" }> = {
|
|
47
|
+
done: { text: "done", tone: "ok" }, failed: { text: "failed", tone: "err" }, limit: { text: "limit", tone: "warn" }, interrupted: { text: "interrupted", tone: "warn" },
|
|
48
|
+
};
|
|
49
|
+
const flat = (text: string, max: number) => Array.from(text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]+/g, " ").trim()).slice(0, max).join("");
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The inbox item. The title is `<peer>: <outcome word>`, computed here from the row's peer and outcome and never taken from the row's own title text; a private result is a stub
|
|
53
|
+
* (`<peer>: a private result`) with no detail. The detail is whatever the hub built at display, or none: this never reads a prompt itself. `at` is the settle time, stable
|
|
54
|
+
* across snapshots, which is what acon's snooze and oldest-first order key on.
|
|
55
|
+
*/
|
|
56
|
+
export function resultItem(row: ResultRowView, project: { id: string; name: string }): ResultItem {
|
|
57
|
+
const outcome: ResultOutcome = row.outcome in OUTCOME_WORD ? row.outcome : "interrupted";
|
|
58
|
+
const title = (row.private ? `${row.peer}: a private result` : `${row.peer}: ${OUTCOME_WORD[outcome]}`).slice(0, 200);
|
|
59
|
+
const detail = !row.private && row.detail && row.detail.kind !== "none" && typeof row.detail.text === "string" ? flat(row.detail.text, 200) : row.private ? "a private request" : "";
|
|
60
|
+
return {
|
|
61
|
+
id: resultItemId(project.id, row.id), group: "result", kind: "result", title, ...(detail ? { detail } : {}), actor: row.peer, project: project.name,
|
|
62
|
+
at: new Date(row.settledAt).toISOString(), chip: CHIP[outcome], verbs: [{ id: "open", label: "Open Agents", primary: true }],
|
|
63
|
+
link: { panel: "agents", params: { project: project.id } }, seen: { action: "result_seen" },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const REFUSAL = "Marking a result read needs a console with verified person identity; update acon and reopen this panel, or use ahub results.";
|
|
68
|
+
/**
|
|
69
|
+
* The console's evidence, or the refusal that tells the person how to get it. Page-supplied fields that would stand in for the person (`author`, `meta`, `identity`, `surface`) or
|
|
70
|
+
* name a completion by another key are refused too. `projectId` is allowed: the panel names the project it is showing, as every dashboard action does, and it only picks WHICH running
|
|
71
|
+
* project's receipt this is; the daemon instance is never taken from the page (`receiptVerb` resolves it from the running listing), and the person evidence is the console's alone.
|
|
72
|
+
*/
|
|
73
|
+
export function receiptEvidence(features: readonly string[] | undefined, meta: ActionMeta | undefined, input: Record<string, unknown>): { ok: true } | { ok: false; error: string } {
|
|
74
|
+
if (!features?.includes("action.meta") || !meta || typeof meta !== "object") return { ok: false, error: REFUSAL };
|
|
75
|
+
const value = meta as unknown as Record<string, unknown>;
|
|
76
|
+
if (value.via !== "console" || typeof value.identity !== "string" || !value.identity.trim() || typeof value.node !== "string" || !value.node.trim()) return { ok: false, error: REFUSAL };
|
|
77
|
+
// `seenAt` and `explicit` are acon's own (the shell sends them with the seeing rule): the hub stamps its own time, so they are accepted and ignored, never trusted.
|
|
78
|
+
if (["author", "meta", "personMeta", "person", "identity", "surface", "completion", "completionId", "seen"].some(key => Object.hasOwn(input, key)))
|
|
79
|
+
return { ok: false, error: "The person's identity and the target come from the console; remove those fields and retry." };
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|