@av-pi-studio/client 0.0.93 → 0.0.94

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.
@@ -0,0 +1,39 @@
1
+ import type { AgentUiResponse } from "@av-pi-studio/protocol";
2
+ import { type AgentUiEffect, type AgentUiState } from "./agent-ui-state.js";
3
+ import { type AgentUiRespondResult, type PiStudioClient } from "./pistudio-client.js";
4
+ /**
5
+ * Impure wiring layer that drives task-002's reducer from task-001's SDK
6
+ * (`swe/features/extension-ui-client-sdk.md` § Controller). Owns the three things a consumer would
7
+ * otherwise get wrong: subscribe-then-list rehydration ordering, automatic re-sync on reconnect
8
+ * (never left to the consumer — `disconnected` is a one-way door in the reducer without it), and
9
+ * `agent_removed` pruning on agent archive/delete.
10
+ *
11
+ * **Rehydration is subscribe-then-list, merge is "replace, discard, apply".** The two `agent_ui_*`
12
+ * subscriptions attach before the first `listAgentUi()` call, queueing whatever arrives while it is
13
+ * in flight. When the snapshot lands: dispatch `snapshot` (wholesale replace), **discard** every
14
+ * queued dialog/surface event (already reflected in the snapshot — the socket is one ordered stream
15
+ * and the daemon composed the snapshot from state that postdates them), and **apply** every queued
16
+ * transient exactly once (nothing retains a transient, so it is genuinely new).
17
+ *
18
+ * **Pruning rides `agent_archived`/`agent_deleted`, never `onAgentUpdate`.** Verified against
19
+ * `agent-manager.ts`: `archiveAgent`/`deleteAgent` call `broadcastArchived`/`broadcastDeleted`
20
+ * exclusively and never the `agent_update`-emitting path, so a pruner wired to `onAgentUpdate` would
21
+ * silently never fire — dialogs would still clear (the daemon broadcasts `agent_ui_resolved` for
22
+ * those), so only surfaces would leak, invisibly, after every archive.
23
+ */
24
+ export interface AgentUiController {
25
+ getState(): AgentUiState;
26
+ /** Fires on every committed transition; `effects` are the effects of that transition only. */
27
+ subscribe(listener: (state: AgentUiState, effects: AgentUiEffect[]) => void): () => void;
28
+ respond(uiRequestId: string, response: AgentUiResponse): Promise<AgentUiRespondResult>;
29
+ /** Escape hatch; reconnect already triggers this internally — never required in normal operation. */
30
+ resync(): Promise<void>;
31
+ dispose(): void;
32
+ }
33
+ export interface AgentUiControllerOptions {
34
+ /** Reported once per unknown fire-and-forget method for the controller's lifetime. Falls back to
35
+ * `console.warn` when omitted. */
36
+ onUnknownMethod?: (method: string) => void;
37
+ }
38
+ export declare function createAgentUiController(client: PiStudioClient, opts?: AgentUiControllerOptions): AgentUiController;
39
+ //# sourceMappingURL=agent-ui-controller.d.ts.map
@@ -0,0 +1,157 @@
1
+ import { initialAgentUiState, reduce, } from "./agent-ui-state.js";
2
+ import { isAgentArchived, isAgentDeleted, } from "./pistudio-client.js";
3
+ /** Predicate ladder mirrored from the reducer, used ONLY to decide whether a queued event is
4
+ * discarded (dialog/surface — already reflected in the snapshot that supersedes it) or replayed
5
+ * (transient — genuinely new). Never a method list. */
6
+ function classifyUiRequest(event) {
7
+ if (event.expectsResponse)
8
+ return "dialog";
9
+ if (event.surfaceKey !== undefined)
10
+ return "surface";
11
+ return "transient";
12
+ }
13
+ export function createAgentUiController(client, opts = {}) {
14
+ let state = initialAgentUiState;
15
+ const listeners = new Set();
16
+ const reportedUnknownMethods = new Set();
17
+ let disposed = false;
18
+ /** True while a `listAgentUi()` call is in flight; the two `agent_ui_*` subscriptions queue
19
+ * instead of committing while this is true. */
20
+ let queueing = false;
21
+ let queue = [];
22
+ /** Bumped at the start of every `resync()` attempt. A response whose generation no longer matches
23
+ * the latest one was superseded and commits nothing — "last write wins", by start order, not by
24
+ * response arrival order (responses are not guaranteed to resolve in the order they were sent). */
25
+ let generation = 0;
26
+ function commit(action) {
27
+ if (disposed)
28
+ return;
29
+ const result = reduce(state, action);
30
+ state = result.state;
31
+ if (action.type === "ui_request" &&
32
+ classifyUiRequest(action.event) === "transient" &&
33
+ result.effects.length === 0) {
34
+ reportUnknownMethod(action.event.method);
35
+ }
36
+ for (const listener of listeners)
37
+ listener(state, result.effects);
38
+ }
39
+ function reportUnknownMethod(method) {
40
+ if (reportedUnknownMethods.has(method))
41
+ return;
42
+ reportedUnknownMethods.add(method);
43
+ if (opts.onUnknownMethod)
44
+ opts.onUnknownMethod(method);
45
+ else
46
+ console.warn(`agent-ui-controller: unknown extension-UI method "${method}"`);
47
+ }
48
+ async function resync() {
49
+ if (disposed)
50
+ return;
51
+ // Capability gating: stays inert, no RPC — an ungated call earns an rpc_error from an older
52
+ // daemon. Re-checked on every call (including every reconnect), because the daemon on the
53
+ // other end may have been upgraded since the last check.
54
+ if (!client.extensionUiAvailable())
55
+ return;
56
+ const myGeneration = ++generation;
57
+ // Only reset the window when transitioning from "not queueing" — an overlapping resync joins
58
+ // the existing window instead of dropping whatever it already collected, so a superseded
59
+ // resync never causes a queued transient to be silently lost.
60
+ if (!queueing) {
61
+ queueing = true;
62
+ queue = [];
63
+ }
64
+ let snapshot;
65
+ try {
66
+ snapshot = await client.listAgentUi();
67
+ }
68
+ catch (err) {
69
+ if (disposed || myGeneration !== generation)
70
+ return; // superseded: the newer resync owns the window
71
+ queueing = false;
72
+ queue = [];
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ console.error(`agent-ui-controller: failed to sync extension UI state: ${message}`);
75
+ return;
76
+ }
77
+ if (disposed || myGeneration !== generation)
78
+ return; // superseded: do not commit, do not touch the queue
79
+ queueing = false;
80
+ const drained = queue;
81
+ queue = [];
82
+ commit({ type: "snapshot", pending: snapshot.pending, surfaces: snapshot.surfaces });
83
+ for (const action of drained) {
84
+ if (action.type === "ui_request" && classifyUiRequest(action.event) === "transient") {
85
+ commit(action);
86
+ }
87
+ // Every other queued action (dialog/surface ui_request, every ui_resolved) is already
88
+ // reflected in the snapshot just committed above — discarded, never replayed.
89
+ }
90
+ }
91
+ const unsubscribeRequest = client.onAgentUiRequest((event, meta) => {
92
+ const action = { type: "ui_request", event, meta };
93
+ if (queueing)
94
+ queue.push(action);
95
+ else
96
+ commit(action);
97
+ });
98
+ const unsubscribeResolved = client.onAgentUiResolved((event) => {
99
+ const action = { type: "ui_resolved", event };
100
+ if (queueing)
101
+ queue.push(action);
102
+ else
103
+ commit(action);
104
+ });
105
+ const unsubscribePrune = client.connection.onSessionMessage((msg) => {
106
+ if (isAgentArchived(msg) || isAgentDeleted(msg)) {
107
+ commit({ type: "agent_removed", agentId: msg.agentId });
108
+ }
109
+ });
110
+ const unsubscribeState = client.connection.onStateChange((connState) => {
111
+ if (connState === "closed" || connState === "closing") {
112
+ commit({ type: "disconnected" });
113
+ }
114
+ else if (connState === "open") {
115
+ void resync();
116
+ }
117
+ // "idle"/"connecting" carry no actionable transition here.
118
+ });
119
+ // Kick off the first sync immediately if the daemon is already connected and capable — the
120
+ // common case, since a controller is normally constructed against an already-open client. If
121
+ // the client is not yet open, `extensionUiAvailable()` is false here (no features known yet) and
122
+ // this is a no-op; the eventual real "open" transition above triggers the one-and-only initial
123
+ // sync, so there is no risk of a duplicate initial snapshot either way.
124
+ void resync();
125
+ /** Dispatches `respond_sent` before the RPC (so a spinner can render immediately), then
126
+ * `respond_failed` only on a domain failure — `ui_resolved` is what actually clears `submitting`
127
+ * on success, never this function. Never throws on a domain outcome; `AgentUiRespondResult`
128
+ * stays the contract task-001 established. */
129
+ async function respond(uiRequestId, response) {
130
+ commit({ type: "respond_sent", requestId: uiRequestId, response });
131
+ const result = await client.respondToUi(uiRequestId, response);
132
+ if (!result.ok)
133
+ commit({ type: "respond_failed", requestId: uiRequestId });
134
+ return result;
135
+ }
136
+ return {
137
+ getState: () => state,
138
+ subscribe(listener) {
139
+ listeners.add(listener);
140
+ return () => listeners.delete(listener);
141
+ },
142
+ respond,
143
+ resync,
144
+ dispose() {
145
+ if (disposed)
146
+ return;
147
+ disposed = true;
148
+ unsubscribeRequest();
149
+ unsubscribeResolved();
150
+ unsubscribePrune();
151
+ unsubscribeState();
152
+ listeners.clear();
153
+ queue = [];
154
+ },
155
+ };
156
+ }
157
+ //# sourceMappingURL=agent-ui-controller.js.map
@@ -0,0 +1,161 @@
1
+ import type { AgentUiPendingRequest, AgentUiRequest, AgentUiResolved, AgentUiResponse, AgentUiSurface } from "@av-pi-studio/protocol";
2
+ import type { AgentUiEventMeta } from "./pistudio-client.js";
3
+ /**
4
+ * Framework-free extension-UI state machine (`swe/features/extension-ui-client-sdk.md` § Pure
5
+ * state module). No DOM, no timers, no I/O, no logging — every transition returns a **new** state
6
+ * plus a list of effects to perform, never performs them itself. This is the module that makes the
7
+ * sibling rendering scope component work over an already-tested machine.
8
+ *
9
+ * **Routing is by wire predicate, never by a `method` table** (mirrors the server-side adapter,
10
+ * `packages/server/src/agent/agent-ui/agent-ui-service.ts`, which is equally payload-blind):
11
+ *
12
+ * ```
13
+ * expectsResponse → dialog (select, confirm, input, editor)
14
+ * surfaceKey && removed → delete surface
15
+ * surfaceKey → upsert surface (setStatus, setWidget, setTitle)
16
+ * otherwise → transient effect (notify, set_editor_text)
17
+ * ```
18
+ *
19
+ * `method` is inspected only *within* the transient category, to pick which effect to build — the
20
+ * one place the routing rule permits it. An unknown fire-and-forget method therefore produces
21
+ * **zero** effects; that absence is deliberately the caller's "unknown method" signal, not a stored
22
+ * flag on any entry (a flag would require the exact known-methods table this module refuses to
23
+ * keep).
24
+ *
25
+ * **Timeouts are displayed, never acted on.** Pi auto-resolves its own timed dialogs and states the
26
+ * client does not need to track them. `remainingMs` is a pure function of `(entry, now)`; no action
27
+ * or code path here dismisses, expires, or mutates an entry — only a `ui_resolved` action removes
28
+ * one from `pending` (moving it to `resolved`, see below).
29
+ *
30
+ * **`method` is also inspected once more, on resolution — a storage rule, not routing.** A
31
+ * `select`/`confirm` answer is retained on the resolved entry for display (`answer`, below); an
32
+ * `input`/`editor` answer, or any unrecognised method's, never is. Those two methods resolve to free
33
+ * text the user typed, and an extension asking for a secret (an API token, say) is an expected,
34
+ * documented case — so the value must be unrepresentable in this module's state, not merely
35
+ * convention-excluded from rendering. See `answerFromResponse` below.
36
+ */
37
+ export interface AgentUiPendingEntry {
38
+ requestId: string;
39
+ agentId: string;
40
+ method: string;
41
+ payload: Record<string, unknown>;
42
+ timeoutMs?: number;
43
+ /** As received off the wire — epoch ms or ISO string. Normalised only inside `remainingMs`. */
44
+ createdAt: number | string;
45
+ /** Local clock reading, live events only — absent for a snapshot-recovered entry. */
46
+ receivedAt?: number;
47
+ answerable: boolean;
48
+ /** A `respond` from this client is in flight. Never an optimistic resolution — the entry stays in
49
+ * `pending` until a real `ui_resolved` arrives; `respond_failed` clears this if the RPC itself
50
+ * reports failure (e.g. lost the first-answer-wins race). */
51
+ submitting?: boolean;
52
+ /** What this client submitted, `select`/`confirm` only — see the module header. Carried onto the
53
+ * resolved entry's `answer` once `ui_resolved` arrives. */
54
+ submittedAnswer?: AgentUiAnswer;
55
+ }
56
+ export interface AgentUiSurfaceEntry {
57
+ agentId: string;
58
+ surfaceKey: string;
59
+ method: string;
60
+ payload: Record<string, unknown>;
61
+ updatedAt: number | string;
62
+ }
63
+ /** Retained on a resolved dialog only for `select`/`confirm` — see the module header. */
64
+ export interface AgentUiAnswer {
65
+ value?: string;
66
+ confirmed?: boolean;
67
+ }
68
+ /** A dialog that is no longer answerable, kept for display only. Page-lifetime state: it survives a
69
+ * reconnect (the daemon has no resolved history to re-serve — `snapshot` passes this through
70
+ * untouched) and dies with the tab. */
71
+ export interface AgentUiResolvedEntry {
72
+ requestId: string;
73
+ agentId: string;
74
+ method: string;
75
+ payload: Record<string, unknown>;
76
+ /** The ORIGINAL `createdAt` from when the dialog was pending, so a card keeps its slot in a list
77
+ * merged with `pendingForAgent` when it resolves — no `resolvedAt` field exists. */
78
+ createdAt: number | string;
79
+ /** Verbatim from `agent_ui_resolved` — an open string, never relabeled, never enumerated. */
80
+ reason: string;
81
+ /** Present only when THIS client answered a `select`/`confirm` — see the module header. */
82
+ answer?: AgentUiAnswer;
83
+ }
84
+ export interface AgentUiState {
85
+ /** Keyed by `requestId` — daemon-minted, unique across every agent. */
86
+ pending: Record<string, AgentUiPendingEntry>;
87
+ /** Keyed by `surfaceMapKey(agentId, surfaceKey)` — a `surfaceKey` is unique only within an agent. */
88
+ surfaces: Record<string, AgentUiSurfaceEntry>;
89
+ /** Keyed by `requestId`, same as `pending`. Bounded per agent — see `RESOLVED_HISTORY_LIMIT`. */
90
+ resolved: Record<string, AgentUiResolvedEntry>;
91
+ }
92
+ export declare const initialAgentUiState: AgentUiState;
93
+ /** Per-agent cap on retained resolved dialogs. Page-lifetime state, but a chatty extension over a
94
+ * long session must not grow it without limit. */
95
+ export declare const RESOLVED_HISTORY_LIMIT = 50;
96
+ export type AgentUiAction = {
97
+ type: "ui_request";
98
+ event: AgentUiRequest;
99
+ meta: AgentUiEventMeta;
100
+ } | {
101
+ type: "ui_resolved";
102
+ event: AgentUiResolved;
103
+ } | {
104
+ type: "snapshot";
105
+ pending: AgentUiPendingRequest[];
106
+ surfaces: AgentUiSurface[];
107
+ } | {
108
+ type: "disconnected";
109
+ } | {
110
+ type: "agent_removed";
111
+ agentId: string;
112
+ } | {
113
+ type: "respond_sent";
114
+ requestId: string;
115
+ response: AgentUiResponse;
116
+ } | {
117
+ type: "respond_failed";
118
+ requestId: string;
119
+ };
120
+ export type AgentUiEffect = {
121
+ type: "replace_composer_text";
122
+ agentId: string;
123
+ text: string;
124
+ } | {
125
+ type: "notify";
126
+ agentId: string;
127
+ message: string;
128
+ level: string;
129
+ };
130
+ /**
131
+ * Unambiguous composite key: `agentId` is a UUID, so `\u0000` (no UUID contains it) is a safe
132
+ * separator. `agentId`/`surfaceKey` are also stored verbatim on the entry, so nothing ever needs to
133
+ * parse this key back apart.
134
+ */
135
+ export declare function surfaceMapKey(agentId: string, surfaceKey: string): string;
136
+ export declare function reduce(state: AgentUiState, action: AgentUiAction): {
137
+ state: AgentUiState;
138
+ effects: AgentUiEffect[];
139
+ };
140
+ /** Every pending dialog for one agent, oldest first (`createdAt`, `requestId` tie-break) — stable
141
+ * so a list cannot jitter between renders. */
142
+ export declare function pendingForAgent(state: AgentUiState, agentId: string): AgentUiPendingEntry[];
143
+ /** Pending-dialog count per agent — the attention-badge derivation, so badging is a subscription
144
+ * rather than UI logic. An agent with zero pending is **absent** from the map, never present at `0`. */
145
+ export declare function pendingByAgent(state: AgentUiState): Record<string, number>;
146
+ /** Every retained surface for one agent, oldest-updated first (`updatedAt`, `surfaceKey` tie-break). */
147
+ export declare function surfacesForAgent(state: AgentUiState, agentId: string): AgentUiSurfaceEntry[];
148
+ /** Every resolved dialog for one agent, oldest first (`createdAt`, `requestId` tie-break) — the same
149
+ * ordering key and comparator as `pendingForAgent`, so merging both lists by that key keeps a
150
+ * card's index stable across resolution (no per-card `resolvedAt`, and no clock reads here). */
151
+ export declare function resolvedForAgent(state: AgentUiState, agentId: string): AgentUiResolvedEntry[];
152
+ /**
153
+ * Time left before Pi auto-resolves this dialog, or `null` when it is untimed (a human may take
154
+ * hours). A **legibility affordance only** — nothing in this module or its callers may act on the
155
+ * result (dismiss, expire, resend); only a real `ui_resolved` event ever removes an entry. Anchors
156
+ * on the local `receivedAt` when available; a snapshot-recovered entry has none, so it falls back to
157
+ * the daemon's `createdAt` and accepts the residual cross-host clock skew — acceptable for a rough
158
+ * countdown display, which is the only thing this value drives. Clamped at `0`, never negative.
159
+ */
160
+ export declare function remainingMs(entry: AgentUiPendingEntry, now: number): number | null;
161
+ //# sourceMappingURL=agent-ui-state.d.ts.map
@@ -0,0 +1,284 @@
1
+ export const initialAgentUiState = { pending: {}, surfaces: {}, resolved: {} };
2
+ /** Per-agent cap on retained resolved dialogs. Page-lifetime state, but a chatty extension over a
3
+ * long session must not grow it without limit. */
4
+ export const RESOLVED_HISTORY_LIMIT = 50;
5
+ /**
6
+ * Unambiguous composite key: `agentId` is a UUID, so `\u0000` (no UUID contains it) is a safe
7
+ * separator. `agentId`/`surfaceKey` are also stored verbatim on the entry, so nothing ever needs to
8
+ * parse this key back apart.
9
+ */
10
+ export function surfaceMapKey(agentId, surfaceKey) {
11
+ return `${agentId}\u0000${surfaceKey}`;
12
+ }
13
+ /** Epoch ms or ISO string → epoch ms, for ordering/countdown arithmetic only. */
14
+ function normalizeTimestamp(value) {
15
+ return typeof value === "number" ? value : Date.parse(value);
16
+ }
17
+ // ─── Reducer ──────────────────────────────────────────────────────────────────
18
+ export function reduce(state, action) {
19
+ switch (action.type) {
20
+ case "ui_request":
21
+ return reduceUiRequest(state, action.event, action.meta);
22
+ case "ui_resolved":
23
+ return { state: reduceUiResolved(state, action.event), effects: [] };
24
+ case "snapshot":
25
+ return {
26
+ state: buildSnapshotState(action.pending, action.surfaces, state.resolved),
27
+ effects: [],
28
+ };
29
+ case "disconnected":
30
+ return { state: markAllUnanswerable(state), effects: [] };
31
+ case "agent_removed":
32
+ return { state: dropAgent(state, action.agentId), effects: [] };
33
+ case "respond_sent":
34
+ return { state: applyRespondSent(state, action.requestId, action.response), effects: [] };
35
+ case "respond_failed":
36
+ return { state: applyRespondFailed(state, action.requestId), effects: [] };
37
+ }
38
+ }
39
+ function reduceUiRequest(state, event, meta) {
40
+ // Dialog: expectsResponse wins the ladder regardless of any other field.
41
+ if (event.expectsResponse) {
42
+ const entry = {
43
+ requestId: event.requestId,
44
+ agentId: event.agentId,
45
+ method: event.method,
46
+ payload: event.payload,
47
+ timeoutMs: event.timeoutMs,
48
+ createdAt: event.createdAt,
49
+ receivedAt: meta.receivedAt,
50
+ answerable: true,
51
+ };
52
+ return {
53
+ state: { ...state, pending: { ...state.pending, [event.requestId]: entry } },
54
+ effects: [],
55
+ };
56
+ }
57
+ // Surface clear/upsert.
58
+ if (event.surfaceKey !== undefined) {
59
+ const key = surfaceMapKey(event.agentId, event.surfaceKey);
60
+ if (event.removed) {
61
+ if (!(key in state.surfaces))
62
+ return { state, effects: [] }; // never-seen key: no-op, not an error
63
+ const surfaces = { ...state.surfaces };
64
+ delete surfaces[key];
65
+ return { state: { ...state, surfaces }, effects: [] };
66
+ }
67
+ const entry = {
68
+ agentId: event.agentId,
69
+ surfaceKey: event.surfaceKey,
70
+ method: event.method,
71
+ payload: event.payload,
72
+ updatedAt: event.createdAt,
73
+ };
74
+ // Last-write-wins on (agentId, surfaceKey) — never deduped by requestId, which is fresh per upsert.
75
+ return { state: { ...state, surfaces: { ...state.surfaces, [key]: entry } }, effects: [] };
76
+ }
77
+ // Transient: no state change, effects built from `method` — the one place this module reads it
78
+ // for routing purposes, and only to pick an effect shape within this already-decided category.
79
+ return { state, effects: buildTransientEffects(event) };
80
+ }
81
+ function buildTransientEffects(event) {
82
+ switch (event.method) {
83
+ case "set_editor_text": {
84
+ // Always replace — matches Pi's own semantics (`pasteToEditor` delegates to `setEditorText`
85
+ // with no paste handling), and is the predictable behavior for extension authors.
86
+ const text = typeof event.payload.text === "string" ? event.payload.text : "";
87
+ return [{ type: "replace_composer_text", agentId: event.agentId, text }];
88
+ }
89
+ case "notify": {
90
+ const message = typeof event.payload.message === "string" ? event.payload.message : "";
91
+ // Forwarded verbatim — never collapsed onto today's ToastVariant, which has no "warning".
92
+ const level = typeof event.payload.level === "string" ? event.payload.level : "info";
93
+ return [{ type: "notify", agentId: event.agentId, message, level }];
94
+ }
95
+ default:
96
+ // Unrecognised fire-and-forget method: zero effects IS the "unknown method" signal — the
97
+ // caller (task-003) reports it, this module stays silent and stateless about it.
98
+ return [];
99
+ }
100
+ }
101
+ /**
102
+ * Retention rule for a resolved dialog's answer (module header). `select`/`confirm` responses are
103
+ * kept for display; `input`/`editor` (and any unrecognised method) resolve to free text or secrets
104
+ * and are never retained — the value must be unrepresentable in state, not merely unrendered. This
105
+ * is the only place besides `buildTransientEffects` that this module inspects `method`, and only to
106
+ * enforce this exclusion, never to route.
107
+ */
108
+ function answerFromResponse(method, response) {
109
+ if (method !== "select" && method !== "confirm")
110
+ return undefined;
111
+ return { value: response.value, confirmed: response.confirmed };
112
+ }
113
+ function reduceUiResolved(state, event) {
114
+ // Unknown requestId: ordered delivery on a single socket makes "resolved before its own
115
+ // snapshot/create arrived" unconstructible, so this is a plain no-op — no tombstone bookkeeping.
116
+ const pendingEntry = state.pending[event.requestId];
117
+ if (!pendingEntry)
118
+ return state;
119
+ const pending = { ...state.pending };
120
+ delete pending[event.requestId];
121
+ const resolvedEntry = {
122
+ requestId: pendingEntry.requestId,
123
+ agentId: pendingEntry.agentId,
124
+ method: pendingEntry.method,
125
+ payload: pendingEntry.payload,
126
+ createdAt: pendingEntry.createdAt,
127
+ reason: event.reason,
128
+ };
129
+ if (pendingEntry.submittedAnswer !== undefined)
130
+ resolvedEntry.answer = pendingEntry.submittedAnswer;
131
+ return { ...state, pending, resolved: addResolvedEntry(state.resolved, resolvedEntry) };
132
+ }
133
+ /** Inserts `entry`, then evicts that agent's oldest resolved entries (by the same
134
+ * `createdAt`/`requestId` ordering the selectors use) past `RESOLVED_HISTORY_LIMIT`. Other agents'
135
+ * entries are never touched by one agent's eviction. */
136
+ function addResolvedEntry(resolved, entry) {
137
+ const next = { ...resolved, [entry.requestId]: entry };
138
+ const forAgent = Object.values(next)
139
+ .filter((e) => e.agentId === entry.agentId)
140
+ .toSorted((a, b) => compareByTimeThenId(a.createdAt, a.requestId, b.createdAt, b.requestId));
141
+ const evictCount = forAgent.length - RESOLVED_HISTORY_LIMIT;
142
+ if (evictCount <= 0)
143
+ return next;
144
+ for (const evicted of forAgent.slice(0, evictCount))
145
+ delete next[evicted.requestId];
146
+ return next;
147
+ }
148
+ function buildSnapshotState(pendingList, surfaceList, resolved) {
149
+ // Wholesale replacement for `pending`/`surfaces`, never a merge: the daemon composes this response
150
+ // from state that postdates every broadcast already delivered on this (single, ordered) socket, so
151
+ // it is authoritative. Every rebuilt pending entry resets `answerable: true` and carries no
152
+ // `receivedAt`/`submitting`/`submittedAnswer` — it was not observed locally, it was recovered, and
153
+ // a dropped socket invalidates any respond that had been in flight. `resolved` is passed through
154
+ // untouched instead: `agent_ui_list_response` carries no resolved history for the daemon to
155
+ // re-serve, so replacing it here would wipe every collapsed card on reconnect.
156
+ const pending = {};
157
+ for (const p of pendingList) {
158
+ pending[p.requestId] = {
159
+ requestId: p.requestId,
160
+ agentId: p.agentId,
161
+ method: p.method,
162
+ payload: p.payload,
163
+ timeoutMs: p.timeoutMs,
164
+ createdAt: p.createdAt,
165
+ answerable: true,
166
+ };
167
+ }
168
+ const surfaces = {};
169
+ for (const s of surfaceList) {
170
+ surfaces[surfaceMapKey(s.agentId, s.surfaceKey)] = {
171
+ agentId: s.agentId,
172
+ surfaceKey: s.surfaceKey,
173
+ method: s.method,
174
+ payload: s.payload,
175
+ updatedAt: s.updatedAt,
176
+ };
177
+ }
178
+ return { pending, surfaces, resolved };
179
+ }
180
+ function markAllUnanswerable(state) {
181
+ const pending = {};
182
+ for (const [id, entry] of Object.entries(state.pending)) {
183
+ pending[id] = { ...entry, answerable: false };
184
+ }
185
+ // Nothing removed — only a later `snapshot` action round-trips `answerable` back to `true`.
186
+ return { ...state, pending };
187
+ }
188
+ function dropAgent(state, agentId) {
189
+ const pending = {};
190
+ for (const [id, entry] of Object.entries(state.pending)) {
191
+ if (entry.agentId !== agentId)
192
+ pending[id] = entry;
193
+ }
194
+ const surfaces = {};
195
+ for (const [key, entry] of Object.entries(state.surfaces)) {
196
+ if (entry.agentId !== agentId)
197
+ surfaces[key] = entry;
198
+ }
199
+ const resolved = {};
200
+ for (const [id, entry] of Object.entries(state.resolved)) {
201
+ if (entry.agentId !== agentId)
202
+ resolved[id] = entry;
203
+ }
204
+ return { pending, surfaces, resolved };
205
+ }
206
+ function applyRespondSent(state, requestId, response) {
207
+ const entry = state.pending[requestId];
208
+ if (!entry)
209
+ return state; // unknown id: nothing to mark in flight
210
+ const submittedAnswer = answerFromResponse(entry.method, response);
211
+ const next = { ...entry, submitting: true };
212
+ if (submittedAnswer !== undefined)
213
+ next.submittedAnswer = submittedAnswer;
214
+ return { ...state, pending: { ...state.pending, [requestId]: next } };
215
+ }
216
+ function applyRespondFailed(state, requestId) {
217
+ const entry = state.pending[requestId];
218
+ if (!entry)
219
+ return state; // no longer pending (already resolved, or never was) — no-op
220
+ // Rebuilt explicitly, not spread-and-delete: this is what stops a lost first-answer-wins race
221
+ // from leaving a spinner running forever, since a `not_found` response is followed by no
222
+ // broadcast to this client.
223
+ const next = {
224
+ requestId: entry.requestId,
225
+ agentId: entry.agentId,
226
+ method: entry.method,
227
+ payload: entry.payload,
228
+ timeoutMs: entry.timeoutMs,
229
+ createdAt: entry.createdAt,
230
+ receivedAt: entry.receivedAt,
231
+ answerable: entry.answerable,
232
+ };
233
+ return { ...state, pending: { ...state.pending, [requestId]: next } };
234
+ }
235
+ // ─── Selectors ────────────────────────────────────────────────────────────────
236
+ function compareByTimeThenId(aTime, aId, bTime, bId) {
237
+ const diff = normalizeTimestamp(aTime) - normalizeTimestamp(bTime);
238
+ return diff !== 0 ? diff : aId.localeCompare(bId);
239
+ }
240
+ /** Every pending dialog for one agent, oldest first (`createdAt`, `requestId` tie-break) — stable
241
+ * so a list cannot jitter between renders. */
242
+ export function pendingForAgent(state, agentId) {
243
+ return Object.values(state.pending)
244
+ .filter((entry) => entry.agentId === agentId)
245
+ .toSorted((a, b) => compareByTimeThenId(a.createdAt, a.requestId, b.createdAt, b.requestId));
246
+ }
247
+ /** Pending-dialog count per agent — the attention-badge derivation, so badging is a subscription
248
+ * rather than UI logic. An agent with zero pending is **absent** from the map, never present at `0`. */
249
+ export function pendingByAgent(state) {
250
+ const counts = {};
251
+ for (const entry of Object.values(state.pending)) {
252
+ counts[entry.agentId] = (counts[entry.agentId] ?? 0) + 1;
253
+ }
254
+ return counts;
255
+ }
256
+ /** Every retained surface for one agent, oldest-updated first (`updatedAt`, `surfaceKey` tie-break). */
257
+ export function surfacesForAgent(state, agentId) {
258
+ return Object.values(state.surfaces)
259
+ .filter((entry) => entry.agentId === agentId)
260
+ .toSorted((a, b) => compareByTimeThenId(a.updatedAt, a.surfaceKey, b.updatedAt, b.surfaceKey));
261
+ }
262
+ /** Every resolved dialog for one agent, oldest first (`createdAt`, `requestId` tie-break) — the same
263
+ * ordering key and comparator as `pendingForAgent`, so merging both lists by that key keeps a
264
+ * card's index stable across resolution (no per-card `resolvedAt`, and no clock reads here). */
265
+ export function resolvedForAgent(state, agentId) {
266
+ return Object.values(state.resolved)
267
+ .filter((entry) => entry.agentId === agentId)
268
+ .toSorted((a, b) => compareByTimeThenId(a.createdAt, a.requestId, b.createdAt, b.requestId));
269
+ }
270
+ /**
271
+ * Time left before Pi auto-resolves this dialog, or `null` when it is untimed (a human may take
272
+ * hours). A **legibility affordance only** — nothing in this module or its callers may act on the
273
+ * result (dismiss, expire, resend); only a real `ui_resolved` event ever removes an entry. Anchors
274
+ * on the local `receivedAt` when available; a snapshot-recovered entry has none, so it falls back to
275
+ * the daemon's `createdAt` and accepts the residual cross-host clock skew — acceptable for a rough
276
+ * countdown display, which is the only thing this value drives. Clamped at `0`, never negative.
277
+ */
278
+ export function remainingMs(entry, now) {
279
+ if (entry.timeoutMs === undefined)
280
+ return null;
281
+ const anchor = entry.receivedAt ?? normalizeTimestamp(entry.createdAt);
282
+ return Math.max(0, entry.timeoutMs - (now - anchor));
283
+ }
284
+ //# sourceMappingURL=agent-ui-state.js.map
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export * from "./relay-transport.js";
6
6
  export * from "./pairing.js";
7
7
  export * from "./daemon-client.js";
8
8
  export * from "./pistudio-client.js";
9
+ export * from "./agent-ui-state.js";
10
+ export * from "./agent-ui-controller.js";
9
11
  export * from "./terminal-stream-router.js";
10
12
  export * from "./file-transfer-client.js";
11
13
  export * from "./reconnect.js";
package/dist/index.js CHANGED
@@ -13,6 +13,10 @@ export * from "./pairing.js";
13
13
  export * from "./daemon-client.js";
14
14
  // High-level PiStudioClient SDK facade + handles.
15
15
  export * from "./pistudio-client.js";
16
+ // Extension-UI pure state module (reducer/selectors, sprint-067).
17
+ export * from "./agent-ui-state.js";
18
+ // Extension-UI controller (subscribe-then-list, reconnect resync, agent pruning, sprint-067).
19
+ export * from "./agent-ui-controller.js";
16
20
  // Terminal-stream router (binary frame demux per slot).
17
21
  export * from "./terminal-stream-router.js";
18
22
  // File-transfer client (binary download frame demux + assembly).
@@ -1,4 +1,4 @@
1
- import type { AgentCloneResponse, AgentCompactResponse, AgentCycleModelResponse, AgentExportHtmlResponse, AgentForkMessagesResponse, AgentForkResponse, AgentLastAssistantTextResponse, AgentListCommandsResponse, AgentNewSessionResponse, AgentSessionStatsResponse, AgentSetModelResponse, AgentSetSessionNameResponse, AgentStreamEvent, AgentSwitchSessionResponse, CreateAgentRequest, ExtensionPacksListResponse, ExtensionPacksSetResponse, FetchAgentTimelineResponse, ProviderAuthInfo, ProviderAuthType, SessionMessage, TimelineDirection } from "@av-pi-studio/protocol";
1
+ import type { AgentCloneResponse, AgentCompactResponse, AgentCycleModelResponse, AgentExportHtmlResponse, AgentForkMessagesResponse, AgentForkResponse, AgentLastAssistantTextResponse, AgentListCommandsResponse, AgentNewSessionResponse, AgentSessionStatsResponse, AgentSetModelResponse, AgentSetSessionNameResponse, AgentStreamEvent, AgentSwitchSessionResponse, AgentUiPendingRequest, AgentUiRequest, AgentUiResolved, AgentUiResponse, AgentUiSurface, CreateAgentRequest, ExtensionPacksListResponse, ExtensionPacksSetResponse, FetchAgentTimelineResponse, ProviderAuthInfo, ProviderAuthType, SessionMessage, TimelineDirection } from "@av-pi-studio/protocol";
2
2
  import type { DaemonClient } from "./daemon-client.js";
3
3
  /**
4
4
  * High-level `PiStudioClient` SDK facade over the low-level `DaemonClient` driver.
@@ -278,6 +278,63 @@ export declare class ProviderAuthError extends Error {
278
278
  readonly operation: "list" | "logout";
279
279
  constructor(message: string, operation: "list" | "logout");
280
280
  }
281
+ /**
282
+ * Local-clock anchor stamped when the SDK receives the push. Deliberately **not** shaped like
283
+ * `AgentStreamEventMeta` (daemon `timestamp`/`seq`, line 49): the daemon may run on another host,
284
+ * and this family's whole timeout-display story (`extension-ui-client-sdk.md` § Timeout display)
285
+ * depends on the client noticing that its own clock and the daemon's `createdAt` may be skewed —
286
+ * reusing the stream shape here would bake that skew in silently.
287
+ */
288
+ export interface AgentUiEventMeta {
289
+ receivedAt: number;
290
+ }
291
+ /**
292
+ * `respondToUi`'s outcome. `not_found` is the **normal** result of a multi-client broadcast race
293
+ * (another client answered first) — it is returned, not thrown, so a caller cannot ignore it
294
+ * without a type error, but also is not forced into a `try`/`catch` around the happy path. Compare
295
+ * `AgentUiError` below, which is used for `listAgentUi`'s genuinely-exceptional failure instead.
296
+ */
297
+ export type AgentUiRespondResult = {
298
+ ok: true;
299
+ } | {
300
+ ok: false;
301
+ reason: "not_found" | "unsupported" | string;
302
+ };
303
+ /**
304
+ * A domain failure reported *in a response payload* rather than as an `rpc_error` — same hazard
305
+ * `ProviderAuthError` (line 313) guards against. Unlike `AgentUiRespondResult` above, a failed
306
+ * snapshot is genuinely exceptional, so this one throws.
307
+ */
308
+ export declare class AgentUiError extends Error {
309
+ readonly operation: "list";
310
+ constructor(message: string, operation: "list");
311
+ }
312
+ /** Narrows an inbound session message to the `agent_ui_request` broadcast. */
313
+ export declare function isAgentUiRequest(message: unknown): message is AgentUiRequest;
314
+ /** Narrows an inbound session message to the `agent_ui_resolved` broadcast. */
315
+ export declare function isAgentUiResolved(message: unknown): message is AgentUiResolved;
316
+ /** The `agent_archived` lifecycle push (`messages.ts`'s `agentArchivedSchema`). */
317
+ export interface AgentArchivedMessage {
318
+ type: "agent_archived";
319
+ agentId: string;
320
+ archivedAt?: string;
321
+ }
322
+ /** The `agent_deleted` lifecycle push (`messages.ts`'s `agentDeletedSchema`). */
323
+ export interface AgentDeletedMessage {
324
+ type: "agent_deleted";
325
+ agentId: string;
326
+ }
327
+ /**
328
+ * Narrows an inbound session message to `agent_archived`. **Not** part of the `agent_ui_*` family —
329
+ * exported so a controller (task-003) can prune retained extension-UI state on archive/delete.
330
+ * Deliberately **not** `onAgentUpdate` (line 418): `AgentManager.archiveAgent`/`deleteAgent` call
331
+ * `broadcastArchived`/`broadcastDeleted` exclusively and never the `agent_update`-emitting
332
+ * `broadcast(record)` path, so `onAgentUpdate` (which filters `type === "agent_update"`) can never
333
+ * see an archive or delete.
334
+ */
335
+ export declare function isAgentArchived(message: unknown): message is AgentArchivedMessage;
336
+ /** Narrows an inbound session message to `agent_deleted`. See `isAgentArchived`. */
337
+ export declare function isAgentDeleted(message: unknown): message is AgentDeletedMessage;
281
338
  export declare class PiStudioClient {
282
339
  private readonly daemon;
283
340
  /** Exactly one login flow per client instance, per `swe/features/provider-auth-rpc.md`. */
@@ -344,11 +401,49 @@ export declare class PiStudioClient {
344
401
  private dispatchProviderAuthEvent;
345
402
  /** Ask the caller for a value, racing its answer against an out-of-band `prompt_cancelled`
346
403
  * (an OAuth callback can win first). Never lets an unanswered promise dangle: a caller refusal
347
- * or a failed respond RPC cancels the whole flow. */
404
+ * or a failed `respond` RPC settles the whole flow as `connection_lost` — a best-effort
405
+ * server-side cancel is fired alongside, but the flow's OWN promise does not wait on it (that
406
+ * RPC is just as unreachable as the one that failed, so awaiting it left the dialog stuck
407
+ * forever — the live defect this comment now documents, sprint-065/task-007).
408
+ */
348
409
  private handleProviderAuthPrompt;
349
410
  /** The single place a flow ends. Idempotent — whichever path (a `done` event, a lost connection,
350
411
  * an immediate login rejection) settles it first wins; releases every subscription exactly once. */
351
412
  private settleProviderAuthFlow;
413
+ /**
414
+ * Subscribe to every `agent_ui_request` broadcast (any agent) — a new dialog, a fire-and-forget
415
+ * UI event, or a surface upsert/clear. `meta.receivedAt` is stamped here, not forwarded from the
416
+ * wire (see `AgentUiEventMeta`).
417
+ */
418
+ onAgentUiRequest(handler: (event: AgentUiRequest, meta: AgentUiEventMeta) => void): () => void;
419
+ /** Subscribe to every `agent_ui_resolved` broadcast (any agent) — a dialog is no longer
420
+ * answerable (answered elsewhere, cancelled, timed out, or the agent was swept). */
421
+ onAgentUiResolved(handler: (event: AgentUiResolved) => void): () => void;
422
+ /**
423
+ * Answer a pending extension-UI dialog. `uiRequestId` is the dialog's id (`AgentUiRequest.requestId`
424
+ * off the wire) — **not** the RPC's own `requestId`, which `daemon.request` stamps separately; do
425
+ * not collapse the two. Resolves `{ ok: false, reason }` rather than throwing when the daemon
426
+ * reports a domain failure — `reason` forwards the daemon's string verbatim (open, not
427
+ * relabelled: `not_found` covers answered-elsewhere, a bogus id, and an already-swept agent
428
+ * alike). A transport-level `RpcError` still rejects; that is not a domain outcome.
429
+ */
430
+ respondToUi(uiRequestId: string, response: AgentUiResponse): Promise<AgentUiRespondResult>;
431
+ /**
432
+ * Reconnect catch-up snapshot: every pending dialog and retained surface, for one agent or all.
433
+ * Throws `AgentUiError` on a failed snapshot — unlike `respondToUi`, this has no benign failure
434
+ * mode to return instead of throw.
435
+ */
436
+ listAgentUi(agentId?: string): Promise<{
437
+ pending: AgentUiPendingRequest[];
438
+ surfaces: AgentUiSurface[];
439
+ }>;
440
+ /**
441
+ * True iff the daemon advertised the `extensionUi` capability in `server_info.features`. **Not**
442
+ * named `supportsExtensionUi()` — that name is already the *provider* capability flag
443
+ * (`protocol/src/provider-manifest.ts`, "this provider forwards UI events"), a different question
444
+ * from "does this daemon speak the `agent_ui_*` RPC family at all".
445
+ */
446
+ extensionUiAvailable(): boolean;
352
447
  }
353
448
  /** Convenience re-export of the import operation (not agent-scoped). */
354
449
  export declare function importAgentSession(daemon: DaemonClient, args: {
@@ -29,6 +29,52 @@ class PromptCancelledError extends Error {
29
29
  this.name = "PromptCancelledError";
30
30
  }
31
31
  }
32
+ /**
33
+ * A domain failure reported *in a response payload* rather than as an `rpc_error` — same hazard
34
+ * `ProviderAuthError` (line 313) guards against. Unlike `AgentUiRespondResult` above, a failed
35
+ * snapshot is genuinely exceptional, so this one throws.
36
+ */
37
+ export class AgentUiError extends Error {
38
+ operation;
39
+ constructor(message, operation) {
40
+ super(message);
41
+ this.operation = operation;
42
+ this.name = "AgentUiError";
43
+ }
44
+ }
45
+ /** Narrows an inbound session message to the `agent_ui_request` broadcast. */
46
+ export function isAgentUiRequest(message) {
47
+ const m = message;
48
+ return (!!m &&
49
+ m.type === "agent_ui_request" &&
50
+ typeof m.requestId === "string" &&
51
+ typeof m.agentId === "string");
52
+ }
53
+ /** Narrows an inbound session message to the `agent_ui_resolved` broadcast. */
54
+ export function isAgentUiResolved(message) {
55
+ const m = message;
56
+ return (!!m &&
57
+ m.type === "agent_ui_resolved" &&
58
+ typeof m.requestId === "string" &&
59
+ typeof m.agentId === "string");
60
+ }
61
+ /**
62
+ * Narrows an inbound session message to `agent_archived`. **Not** part of the `agent_ui_*` family —
63
+ * exported so a controller (task-003) can prune retained extension-UI state on archive/delete.
64
+ * Deliberately **not** `onAgentUpdate` (line 418): `AgentManager.archiveAgent`/`deleteAgent` call
65
+ * `broadcastArchived`/`broadcastDeleted` exclusively and never the `agent_update`-emitting
66
+ * `broadcast(record)` path, so `onAgentUpdate` (which filters `type === "agent_update"`) can never
67
+ * see an archive or delete.
68
+ */
69
+ export function isAgentArchived(message) {
70
+ const m = message;
71
+ return !!m && m.type === "agent_archived" && typeof m.agentId === "string";
72
+ }
73
+ /** Narrows an inbound session message to `agent_deleted`. See `isAgentArchived`. */
74
+ export function isAgentDeleted(message) {
75
+ const m = message;
76
+ return !!m && m.type === "agent_deleted" && typeof m.agentId === "string";
77
+ }
32
78
  // ─── Facade implementation ──────────────────────────────────────────────────────
33
79
  export class PiStudioClient {
34
80
  daemon;
@@ -248,7 +294,11 @@ export class PiStudioClient {
248
294
  }
249
295
  /** Ask the caller for a value, racing its answer against an out-of-band `prompt_cancelled`
250
296
  * (an OAuth callback can win first). Never lets an unanswered promise dangle: a caller refusal
251
- * or a failed respond RPC cancels the whole flow. */
297
+ * or a failed `respond` RPC settles the whole flow as `connection_lost` — a best-effort
298
+ * server-side cancel is fired alongside, but the flow's OWN promise does not wait on it (that
299
+ * RPC is just as unreachable as the one that failed, so awaiting it left the dialog stuck
300
+ * forever — the live defect this comment now documents, sprint-065/task-007).
301
+ */
252
302
  async handleProviderAuthPrompt(flow, event, callbacks) {
253
303
  // Aborted when this prompt is cancelled out of band (or the flow ends). The view cannot learn
254
304
  // that any other way: `prompt_cancelled` is consumed here rather than forwarded to `onEvent`,
@@ -297,6 +347,7 @@ export class PiStudioClient {
297
347
  .request("provider_auth_cancel_request", { flowId: flow.flowId })
298
348
  .catch(() => { });
299
349
  }
350
+ this.settleProviderAuthFlow(flow, { ok: false, error: "connection_lost" });
300
351
  }
301
352
  }
302
353
  /** The single place a flow ends. Idempotent — whichever path (a `done` event, a lost connection,
@@ -314,6 +365,61 @@ export class PiStudioClient {
314
365
  this.activeProviderAuthFlow = null;
315
366
  flow.settle(result);
316
367
  }
368
+ // ─── Extension UI (agent_ui_*, sprint-066) ─────────────────────────────────
369
+ /**
370
+ * Subscribe to every `agent_ui_request` broadcast (any agent) — a new dialog, a fire-and-forget
371
+ * UI event, or a surface upsert/clear. `meta.receivedAt` is stamped here, not forwarded from the
372
+ * wire (see `AgentUiEventMeta`).
373
+ */
374
+ onAgentUiRequest(handler) {
375
+ return this.daemon.onSessionMessage((msg) => {
376
+ if (isAgentUiRequest(msg))
377
+ handler(msg, { receivedAt: Date.now() });
378
+ });
379
+ }
380
+ /** Subscribe to every `agent_ui_resolved` broadcast (any agent) — a dialog is no longer
381
+ * answerable (answered elsewhere, cancelled, timed out, or the agent was swept). */
382
+ onAgentUiResolved(handler) {
383
+ return this.daemon.onSessionMessage((msg) => {
384
+ if (isAgentUiResolved(msg))
385
+ handler(msg);
386
+ });
387
+ }
388
+ /**
389
+ * Answer a pending extension-UI dialog. `uiRequestId` is the dialog's id (`AgentUiRequest.requestId`
390
+ * off the wire) — **not** the RPC's own `requestId`, which `daemon.request` stamps separately; do
391
+ * not collapse the two. Resolves `{ ok: false, reason }` rather than throwing when the daemon
392
+ * reports a domain failure — `reason` forwards the daemon's string verbatim (open, not
393
+ * relabelled: `not_found` covers answered-elsewhere, a bogus id, and an already-swept agent
394
+ * alike). A transport-level `RpcError` still rejects; that is not a domain outcome.
395
+ */
396
+ async respondToUi(uiRequestId, response) {
397
+ const payload = await this.daemon.request("agent_ui_respond_request", { uiRequestId, response });
398
+ if (!payload.ok)
399
+ return { ok: false, reason: payload.error ?? "unknown" };
400
+ return { ok: true };
401
+ }
402
+ /**
403
+ * Reconnect catch-up snapshot: every pending dialog and retained surface, for one agent or all.
404
+ * Throws `AgentUiError` on a failed snapshot — unlike `respondToUi`, this has no benign failure
405
+ * mode to return instead of throw.
406
+ */
407
+ async listAgentUi(agentId) {
408
+ const payload = await this.daemon.request("agent_ui_list_request", agentId ? { agentId } : {});
409
+ if (!payload.ok) {
410
+ throw new AgentUiError(payload.error ?? "failed to list extension UI state", "list");
411
+ }
412
+ return { pending: payload.pending ?? [], surfaces: payload.surfaces ?? [] };
413
+ }
414
+ /**
415
+ * True iff the daemon advertised the `extensionUi` capability in `server_info.features`. **Not**
416
+ * named `supportsExtensionUi()` — that name is already the *provider* capability flag
417
+ * (`protocol/src/provider-manifest.ts`, "this provider forwards UI events"), a different question
418
+ * from "does this daemon speak the `agent_ui_*` RPC family at all".
419
+ */
420
+ extensionUiAvailable() {
421
+ return this.daemon.hasFeature("extensionUi");
422
+ }
317
423
  }
318
424
  // ─── Handle classes ──────────────────────────────────────────────────────────────
319
425
  class TimelineHandle {
@@ -0,0 +1,34 @@
1
+ import { DaemonClient } from "../daemon-client.js";
2
+ import { PiStudioClient } from "../pistudio-client.js";
3
+ import type { Transport } from "../transport.js";
4
+ /**
5
+ * Scripted in-memory daemon transport, shared across `pistudio-client.test.ts` (sprint-055+) and
6
+ * `agent-ui-controller.test.ts` (sprint-067/task-003). Speaks just enough of the protocol for the
7
+ * facade/controller tests: completes the handshake, echoes responses correlated by `requestId`,
8
+ * and can push broadcasts or drop the connection on command.
9
+ *
10
+ * `features` is the **live** object read on every `hello` — mutate it in place
11
+ * (`fake.features.extensionUi = true`) before calling `daemon.connect()` again to simulate a
12
+ * reconnect where the daemon-advertised capability set changed.
13
+ */
14
+ export interface ScriptedDaemon {
15
+ transport: Transport;
16
+ sent: Array<Record<string, unknown>>;
17
+ features: Record<string, boolean>;
18
+ push: (sessionMessage: Record<string, unknown>) => void;
19
+ /** Simulate the socket dying mid-session (distinct from a clean RPC response). */
20
+ drop: (reason?: string) => void;
21
+ }
22
+ export declare function makeScriptedDaemon(opts?: {
23
+ features?: Record<string, boolean>;
24
+ }): ScriptedDaemon;
25
+ export interface ScriptedFacade {
26
+ client: PiStudioClient;
27
+ daemon: DaemonClient;
28
+ fake: ScriptedDaemon;
29
+ }
30
+ /** Builds a connected `PiStudioClient` over `makeScriptedDaemon`. */
31
+ export declare function makeFacade(opts?: {
32
+ features?: Record<string, boolean>;
33
+ }): Promise<ScriptedFacade>;
34
+ //# sourceMappingURL=scripted-daemon.d.ts.map
@@ -0,0 +1,235 @@
1
+ import { DaemonClient } from "../daemon-client.js";
2
+ import { PiStudioClient } from "../pistudio-client.js";
3
+ export function makeScriptedDaemon(opts) {
4
+ const sent = [];
5
+ let agentSeq = 0;
6
+ const features = opts?.features ?? { providersSnapshot: true, providerAuth: true };
7
+ const transport = {
8
+ onMessage: null,
9
+ onClose: null,
10
+ onError: null,
11
+ isOpen: true,
12
+ connect: () => Promise.resolve(),
13
+ sendBinary: () => { },
14
+ close: () => { },
15
+ sendText: (data) => {
16
+ const frame = JSON.parse(data);
17
+ if (frame.type === "hello") {
18
+ queueMicrotask(() => transport.onMessage?.(JSON.stringify({
19
+ type: "status",
20
+ payload: {
21
+ status: "server_info",
22
+ serverId: "srv-mock",
23
+ capabilities: {},
24
+ features,
25
+ },
26
+ })));
27
+ return;
28
+ }
29
+ if (frame.type === "session") {
30
+ const msg = frame.message;
31
+ sent.push(msg);
32
+ respond(msg);
33
+ }
34
+ },
35
+ };
36
+ function reply(message) {
37
+ queueMicrotask(() => transport.onMessage?.(JSON.stringify({ type: "session", message })));
38
+ }
39
+ function respond(msg) {
40
+ const requestId = msg.requestId;
41
+ // The `provider_auth_*` family is driven manually by each test via `fake.push()` — real
42
+ // flows are ordering-sensitive (buffering-before-response, prompt races), so an automatic
43
+ // reply here would hide exactly the bugs those tests exist to catch.
44
+ if (typeof msg.type === "string" && msg.type.startsWith("provider_auth_")) {
45
+ return;
46
+ }
47
+ switch (msg.type) {
48
+ case "agent_ui_respond_request":
49
+ case "agent_ui_list_request":
50
+ // Driven manually by each test via `fake.push()` — the ok/error mix (and, for
51
+ // agent_ui_list_request, the exact moment the reply lands relative to other pushed
52
+ // events) is exactly what the acceptance criteria differentiate on; an automatic reply
53
+ // would hide it.
54
+ return;
55
+ case "create_agent_request": {
56
+ const agentId = `agent-${++agentSeq}`;
57
+ reply({ type: "create_agent_response", requestId, payload: { agentId } });
58
+ // Simulate a streamed turn for the new agent.
59
+ if (msg.initialPrompt) {
60
+ reply({
61
+ type: "agent_stream",
62
+ agentId,
63
+ event: { kind: "turn_started" },
64
+ });
65
+ reply({
66
+ type: "agent_stream",
67
+ agentId,
68
+ event: { kind: "assistant_message", text: "hello from mock" },
69
+ });
70
+ reply({ type: "agent_stream", agentId, event: { kind: "turn_completed" } });
71
+ reply({ type: "agent_update", agentId, status: "idle" });
72
+ }
73
+ return;
74
+ }
75
+ case "fetch_agent_timeline_request": {
76
+ reply({
77
+ type: "fetch_agent_timeline_response",
78
+ requestId,
79
+ payload: {
80
+ agentId: msg.agentId,
81
+ items: [{ kind: "assistant_message", text: "hello from mock" }],
82
+ startCursor: "c0",
83
+ endCursor: "c1",
84
+ hasOlder: false,
85
+ hasNewer: false,
86
+ seqStart: 1,
87
+ seqEnd: 1,
88
+ },
89
+ });
90
+ return;
91
+ }
92
+ case "update_agent": {
93
+ reply({ type: "update_agent_response", requestId, payload: { ok: true } });
94
+ reply({
95
+ type: "agent_update",
96
+ agentId: msg.agentId,
97
+ title: msg.title,
98
+ });
99
+ return;
100
+ }
101
+ case "agent_session_stats_request": {
102
+ reply({
103
+ type: "agent_session_stats_response",
104
+ requestId,
105
+ payload: { sessionId: "s1", totalMessages: 3 },
106
+ });
107
+ return;
108
+ }
109
+ case "agent_compact_request": {
110
+ reply({
111
+ type: "agent_compact_response",
112
+ requestId,
113
+ payload: { summary: "compacted", tokensBefore: 1000 },
114
+ });
115
+ return;
116
+ }
117
+ case "agent_list_commands_request": {
118
+ reply({
119
+ type: "agent_list_commands_response",
120
+ requestId,
121
+ payload: {
122
+ commands: [
123
+ {
124
+ name: "fix-tests",
125
+ description: "Fix failing tests",
126
+ source: "prompt",
127
+ scope: "project",
128
+ path: "/w/.pi/agent/prompts/fix-tests.md",
129
+ },
130
+ { name: "skill:brave-search", source: "skill", scope: "user" },
131
+ ],
132
+ },
133
+ });
134
+ return;
135
+ }
136
+ case "list_provider_models": {
137
+ reply({
138
+ type: "list_provider_models_response",
139
+ requestId,
140
+ payload: { models: [{ id: "m1" }, { id: "m2" }] },
141
+ });
142
+ return;
143
+ }
144
+ case "list_provider_modes": {
145
+ reply({
146
+ type: "list_provider_modes_response",
147
+ requestId,
148
+ payload: { modes: [{ id: "plan" }, { id: "default" }] },
149
+ });
150
+ return;
151
+ }
152
+ case "providers.snapshot.refresh.request": {
153
+ reply({
154
+ type: "providers.snapshot.refresh.response",
155
+ requestId,
156
+ payload: { refreshed: true },
157
+ });
158
+ return;
159
+ }
160
+ case "resolve_default_model": {
161
+ reply({
162
+ type: "resolve_default_model_response",
163
+ requestId,
164
+ provider: msg.provider,
165
+ model: "claude-sonnet-5",
166
+ modelProvider: "anthropic",
167
+ });
168
+ return;
169
+ }
170
+ case "extension_packs_list_request": {
171
+ // Flat fields on the message — the real wire schema (packages/protocol/src/messages.ts)
172
+ // has no `payload` wrapper for this pair, unlike several older RPCs above.
173
+ reply({
174
+ type: "extension_packs_list_response",
175
+ requestId,
176
+ autoSync: true,
177
+ selected: ["swe"],
178
+ packs: [
179
+ { id: "core", title: "Core", description: "Always-on core pack", packages: [] },
180
+ { id: "swe", title: "Software Engineering", description: "SWE tools", packages: [] },
181
+ ],
182
+ });
183
+ return;
184
+ }
185
+ case "extension_packs_set_request": {
186
+ const packs = msg.packs;
187
+ if (packs?.includes("unknown")) {
188
+ reply({
189
+ type: "extension_packs_set_response",
190
+ requestId,
191
+ autoSync: true,
192
+ selected: ["swe"],
193
+ packs: [],
194
+ ok: false,
195
+ error: "unknown pack: unknown",
196
+ });
197
+ return;
198
+ }
199
+ reply({
200
+ type: "extension_packs_set_response",
201
+ requestId,
202
+ autoSync: true,
203
+ selected: packs ?? ["swe"],
204
+ packs: [],
205
+ ok: true,
206
+ report: { at: new Date().toISOString(), outcome: "ok", installed: [], failures: [] },
207
+ });
208
+ return;
209
+ }
210
+ default:
211
+ reply({ type: `${String(msg.type)}_response`, requestId, payload: { ok: true } });
212
+ }
213
+ }
214
+ return {
215
+ transport,
216
+ sent,
217
+ features,
218
+ push: (sessionMessage) => reply(sessionMessage),
219
+ drop: (reason = "test drop") => transport.onClose?.(1006, reason),
220
+ };
221
+ }
222
+ /** Builds a connected `PiStudioClient` over `makeScriptedDaemon`. */
223
+ export async function makeFacade(opts) {
224
+ const fake = makeScriptedDaemon(opts);
225
+ const daemon = new DaemonClient({
226
+ url: "ws://mock/ws",
227
+ clientId: "c1",
228
+ clientType: "cli",
229
+ transport: fake.transport,
230
+ rpcTimeoutMs: 1000,
231
+ });
232
+ await daemon.connect();
233
+ return { client: new PiStudioClient(daemon), daemon, fake };
234
+ }
235
+ //# sourceMappingURL=scripted-daemon.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@av-pi-studio/client",
3
- "version": "0.0.93",
3
+ "version": "0.0.94",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.map",
@@ -20,7 +20,7 @@
20
20
  "clean": "rm -rf dist *.tsbuildinfo"
21
21
  },
22
22
  "dependencies": {
23
- "@av-pi-studio/protocol": "^0.0.93",
24
- "@av-pi-studio/relay": "^0.0.93"
23
+ "@av-pi-studio/protocol": "^0.0.94",
24
+ "@av-pi-studio/relay": "^0.0.94"
25
25
  }
26
26
  }