@av-pi-studio/client 0.0.92 → 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.
- package/dist/agent-ui-controller.d.ts +39 -0
- package/dist/agent-ui-controller.js +157 -0
- package/dist/agent-ui-state.d.ts +161 -0
- package/dist/agent-ui-state.js +284 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/pistudio-client.d.ts +236 -1
- package/dist/pistudio-client.js +357 -0
- package/dist/test-support/scripted-daemon.d.ts +34 -0
- package/dist/test-support/scripted-daemon.js +235 -0
- package/package.json +3 -3
|
@@ -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).
|