@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
|
@@ -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, 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.
|
|
@@ -169,8 +169,176 @@ export interface PiStudioProviderActions {
|
|
|
169
169
|
/** Trigger an explicit provider snapshot refresh (no hidden revalidation). */
|
|
170
170
|
refreshSnapshot(): Promise<unknown>;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The five `provider_auth_*` request/response pairs have real protocol schemas (sprint-055), but the
|
|
174
|
+
* per-flow progress push does **not** — it rides `sessionMessageBaseSchema`'s passthrough fallback,
|
|
175
|
+
* the established convention for this family (see `swe/features/provider-auth-rpc.md`
|
|
176
|
+
* § Registration style, and `checkout_status_update` / `file_changed` before it). So the push's
|
|
177
|
+
* shape is narrowed here with a local interface + type guard, mirroring how `TimelineHandle`
|
|
178
|
+
* narrows `agent_stream`.
|
|
179
|
+
*/
|
|
180
|
+
export interface ProviderAuthFlowEventPush {
|
|
181
|
+
type: "provider_auth_flow_event";
|
|
182
|
+
flowId: string;
|
|
183
|
+
event: {
|
|
184
|
+
kind: string;
|
|
185
|
+
} & Record<string, unknown>;
|
|
186
|
+
}
|
|
187
|
+
/** Narrows an inbound session message to the `provider_auth_flow_event` push. */
|
|
188
|
+
export declare function isProviderAuthFlowEvent(message: unknown): message is ProviderAuthFlowEventPush;
|
|
189
|
+
/** One question Pi's auth engine asks mid-flow. `promptKind` decides the input control; a `select`
|
|
190
|
+
* carries its own option list. Answering means resolving `ProviderAuthCallbacks.prompt`. */
|
|
191
|
+
export interface ProviderAuthPromptUi {
|
|
192
|
+
promptId: string;
|
|
193
|
+
promptKind: "text" | "secret" | "select" | "manual_code";
|
|
194
|
+
message: string;
|
|
195
|
+
placeholder?: string;
|
|
196
|
+
options?: readonly {
|
|
197
|
+
id: string;
|
|
198
|
+
label: string;
|
|
199
|
+
description?: string;
|
|
200
|
+
}[];
|
|
201
|
+
/**
|
|
202
|
+
* Aborts when this specific prompt is cancelled out of band — Pi races a `manual_code` prompt
|
|
203
|
+
* against its own OAuth callback server, so the callback winning cancels the question while the
|
|
204
|
+
* flow carries on. A view MUST drop the input when this fires (and keep the rest of the flow's
|
|
205
|
+
* presentation): the promise it returned from `prompt` is discarded by the SDK, so nothing else
|
|
206
|
+
* tells it the question is gone. Also aborts when the flow itself ends.
|
|
207
|
+
*/
|
|
208
|
+
signal?: AbortSignal;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The `notify()`-sourced events the daemon forwards verbatim. Carries no secret by construction —
|
|
212
|
+
* a prompt *value* travels only in the `provider_auth_respond_request` payload, never in an event.
|
|
213
|
+
* Unknown future kinds still reach `onEvent` (append-only wire rule), so a view must tolerate them.
|
|
214
|
+
*/
|
|
215
|
+
export type ProviderAuthNotifyEvent = {
|
|
216
|
+
kind: "info";
|
|
217
|
+
message: string;
|
|
218
|
+
links?: readonly {
|
|
219
|
+
url: string;
|
|
220
|
+
label?: string;
|
|
221
|
+
}[];
|
|
222
|
+
} | {
|
|
223
|
+
kind: "auth_url";
|
|
224
|
+
url: string;
|
|
225
|
+
instructions?: string;
|
|
226
|
+
} | {
|
|
227
|
+
kind: "device_code";
|
|
228
|
+
userCode: string;
|
|
229
|
+
verificationUri: string;
|
|
230
|
+
intervalSeconds?: number;
|
|
231
|
+
expiresInSeconds?: number;
|
|
232
|
+
} | {
|
|
233
|
+
kind: "progress";
|
|
234
|
+
message: string;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Every event a login-flow view reduces over: the daemon's notify events plus the three the SDK
|
|
238
|
+
* driver consumes itself (`prompt` is delivered through `callbacks.prompt`, `prompt_cancelled`
|
|
239
|
+
* rejects that pending prompt, `done` settles `loginProvider`). A view re-dispatches those three
|
|
240
|
+
* into its own reducer from the callback/promise boundary.
|
|
241
|
+
*/
|
|
242
|
+
export type ProviderAuthFlowUiEvent = ProviderAuthNotifyEvent | ({
|
|
243
|
+
kind: "prompt";
|
|
244
|
+
} & ProviderAuthPromptUi) | {
|
|
245
|
+
kind: "prompt_cancelled";
|
|
246
|
+
promptId: string;
|
|
247
|
+
} | {
|
|
248
|
+
kind: "done";
|
|
249
|
+
ok: boolean;
|
|
250
|
+
error?: string;
|
|
251
|
+
};
|
|
252
|
+
export interface ProviderAuthCallbacks {
|
|
253
|
+
/**
|
|
254
|
+
* Ask the user for a value. Resolve with the answer; **reject to cancel the whole flow**.
|
|
255
|
+
* The returned promise is also rejected by the SDK when the daemon cancels the prompt
|
|
256
|
+
* out-of-band (an OAuth callback won the race) or the flow otherwise ends — a view should treat
|
|
257
|
+
* a rejection as "stop showing this input", not as an error to report.
|
|
258
|
+
*/
|
|
259
|
+
prompt(prompt: ProviderAuthPromptUi): Promise<string>;
|
|
260
|
+
/** Presentation/progress events. Never carries a secret. */
|
|
261
|
+
onEvent?(event: ProviderAuthNotifyEvent): void;
|
|
262
|
+
}
|
|
263
|
+
export interface ProviderAuthLoginOptions {
|
|
264
|
+
/** Abort to cancel the flow server-side. The promise still settles from the terminal `done`. */
|
|
265
|
+
signal?: AbortSignal;
|
|
266
|
+
}
|
|
267
|
+
/** How a login flow ended. `error` is a daemon-sanitized reason, never a credential. */
|
|
268
|
+
export interface ProviderAuthLoginResult {
|
|
269
|
+
ok: boolean;
|
|
270
|
+
error?: string;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A domain failure reported *in a response payload* rather than as an `rpc_error`. This family
|
|
274
|
+
* puts domain errors in payloads by design (`swe/features/provider-auth-rpc.md`), so a caller that
|
|
275
|
+
* only catches `RpcError` would silently read an empty provider list as "nothing configured".
|
|
276
|
+
*/
|
|
277
|
+
export declare class ProviderAuthError extends Error {
|
|
278
|
+
readonly operation: "list" | "logout";
|
|
279
|
+
constructor(message: string, operation: "list" | "logout");
|
|
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;
|
|
172
338
|
export declare class PiStudioClient {
|
|
173
339
|
private readonly daemon;
|
|
340
|
+
/** Exactly one login flow per client instance, per `swe/features/provider-auth-rpc.md`. */
|
|
341
|
+
private activeProviderAuthFlow;
|
|
174
342
|
constructor(daemon: DaemonClient);
|
|
175
343
|
get connection(): DaemonClient;
|
|
176
344
|
/**
|
|
@@ -209,6 +377,73 @@ export declare class PiStudioClient {
|
|
|
209
377
|
onAgentUpdate(handler: PiStudioAgentUpdateHandler): () => void;
|
|
210
378
|
/** Subscribe to ALL `workspace_update` events (any workspace). */
|
|
211
379
|
onWorkspaceUpdate(handler: PiStudioWorkspaceUpdateHandler): () => void;
|
|
380
|
+
/** True iff the daemon advertised the `providerAuth` capability in `server_info.features`. */
|
|
381
|
+
hasProviderAuthCapability(): boolean;
|
|
382
|
+
/** Every provider's login capability + current state. */
|
|
383
|
+
listProviderAuth(): Promise<ProviderAuthInfo[]>;
|
|
384
|
+
/** Clear a provider's stored credential. `stillConfigured` flags a surviving ambient one
|
|
385
|
+
* (e.g. an env var) — the provider may still show as configured after this resolves. */
|
|
386
|
+
logoutProvider(provider: string): Promise<{
|
|
387
|
+
stillConfigured?: boolean;
|
|
388
|
+
}>;
|
|
389
|
+
/**
|
|
390
|
+
* Drive one remote provider login end to end. `callbacks` answers Pi's questions and observes
|
|
391
|
+
* presentation events; the returned promise settles only once — from the flow's terminal `done`
|
|
392
|
+
* event, an immediate login rejection, or a lost connection — never left hanging.
|
|
393
|
+
*
|
|
394
|
+
* Only one flow may run per client instance; a second concurrent call rejects locally without
|
|
395
|
+
* sending a second `provider_auth_login_request`.
|
|
396
|
+
*/
|
|
397
|
+
loginProvider(provider: string, authType: ProviderAuthType, callbacks: ProviderAuthCallbacks, opts?: ProviderAuthLoginOptions): Promise<ProviderAuthLoginResult>;
|
|
398
|
+
/** Route one inbound flow-event push: buffer until `flowId` is known, drop stale/unknown flows,
|
|
399
|
+
* else dispatch by `kind`. */
|
|
400
|
+
private routeProviderAuthEvent;
|
|
401
|
+
private dispatchProviderAuthEvent;
|
|
402
|
+
/** Ask the caller for a value, racing its answer against an out-of-band `prompt_cancelled`
|
|
403
|
+
* (an OAuth callback can win first). Never lets an unanswered promise dangle: a caller refusal
|
|
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
|
+
*/
|
|
409
|
+
private handleProviderAuthPrompt;
|
|
410
|
+
/** The single place a flow ends. Idempotent — whichever path (a `done` event, a lost connection,
|
|
411
|
+
* an immediate login rejection) settles it first wins; releases every subscription exactly once. */
|
|
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;
|
|
212
447
|
}
|
|
213
448
|
/** Convenience re-export of the import operation (not agent-scoped). */
|
|
214
449
|
export declare function importAgentSession(daemon: DaemonClient, args: {
|
package/dist/pistudio-client.js
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
|
+
/** Narrows an inbound session message to the `provider_auth_flow_event` push. */
|
|
2
|
+
export function isProviderAuthFlowEvent(message) {
|
|
3
|
+
const m = message;
|
|
4
|
+
if (!m || m.type !== "provider_auth_flow_event" || typeof m.flowId !== "string")
|
|
5
|
+
return false;
|
|
6
|
+
const event = m.event;
|
|
7
|
+
return typeof event === "object" && event !== null && typeof event.kind === "string";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A domain failure reported *in a response payload* rather than as an `rpc_error`. This family
|
|
11
|
+
* puts domain errors in payloads by design (`swe/features/provider-auth-rpc.md`), so a caller that
|
|
12
|
+
* only catches `RpcError` would silently read an empty provider list as "nothing configured".
|
|
13
|
+
*/
|
|
14
|
+
export class ProviderAuthError extends Error {
|
|
15
|
+
operation;
|
|
16
|
+
constructor(message, operation) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.operation = operation;
|
|
19
|
+
this.name = "ProviderAuthError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Rejection used for a prompt the daemon cancelled out-of-band — distinguishes "the user refused"
|
|
23
|
+
* (cancel the flow) from "this question is moot now" (let the flow continue). */
|
|
24
|
+
class PromptCancelledError extends Error {
|
|
25
|
+
promptId;
|
|
26
|
+
constructor(promptId) {
|
|
27
|
+
super("prompt_cancelled");
|
|
28
|
+
this.promptId = promptId;
|
|
29
|
+
this.name = "PromptCancelledError";
|
|
30
|
+
}
|
|
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
|
+
}
|
|
1
78
|
// ─── Facade implementation ──────────────────────────────────────────────────────
|
|
2
79
|
export class PiStudioClient {
|
|
3
80
|
daemon;
|
|
81
|
+
/** Exactly one login flow per client instance, per `swe/features/provider-auth-rpc.md`. */
|
|
82
|
+
activeProviderAuthFlow = null;
|
|
4
83
|
constructor(daemon) {
|
|
5
84
|
this.daemon = daemon;
|
|
6
85
|
}
|
|
@@ -63,6 +142,284 @@ export class PiStudioClient {
|
|
|
63
142
|
}
|
|
64
143
|
});
|
|
65
144
|
}
|
|
145
|
+
// ─── Provider auth ──────────────────────────────────────────────────────────
|
|
146
|
+
/** True iff the daemon advertised the `providerAuth` capability in `server_info.features`. */
|
|
147
|
+
hasProviderAuthCapability() {
|
|
148
|
+
return this.daemon.hasFeature("providerAuth");
|
|
149
|
+
}
|
|
150
|
+
/** Every provider's login capability + current state. */
|
|
151
|
+
async listProviderAuth() {
|
|
152
|
+
const payload = await this.daemon.request("provider_auth_list_request", {});
|
|
153
|
+
if (!payload.ok) {
|
|
154
|
+
throw new ProviderAuthError(payload.error ?? "failed to list provider auth state", "list");
|
|
155
|
+
}
|
|
156
|
+
return payload.providers;
|
|
157
|
+
}
|
|
158
|
+
/** Clear a provider's stored credential. `stillConfigured` flags a surviving ambient one
|
|
159
|
+
* (e.g. an env var) — the provider may still show as configured after this resolves. */
|
|
160
|
+
async logoutProvider(provider) {
|
|
161
|
+
const payload = await this.daemon.request("provider_auth_logout_request", { provider });
|
|
162
|
+
if (!payload.ok) {
|
|
163
|
+
throw new ProviderAuthError(payload.error ?? "failed to log out", "logout");
|
|
164
|
+
}
|
|
165
|
+
return { stillConfigured: payload.stillConfigured };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Drive one remote provider login end to end. `callbacks` answers Pi's questions and observes
|
|
169
|
+
* presentation events; the returned promise settles only once — from the flow's terminal `done`
|
|
170
|
+
* event, an immediate login rejection, or a lost connection — never left hanging.
|
|
171
|
+
*
|
|
172
|
+
* Only one flow may run per client instance; a second concurrent call rejects locally without
|
|
173
|
+
* sending a second `provider_auth_login_request`.
|
|
174
|
+
*/
|
|
175
|
+
async loginProvider(provider, authType, callbacks, opts) {
|
|
176
|
+
if (this.activeProviderAuthFlow) {
|
|
177
|
+
throw new Error("a provider-auth login is already in progress for this client");
|
|
178
|
+
}
|
|
179
|
+
let settleResult;
|
|
180
|
+
const resultPromise = new Promise((resolve) => {
|
|
181
|
+
settleResult = resolve;
|
|
182
|
+
});
|
|
183
|
+
const flow = {
|
|
184
|
+
flowId: null,
|
|
185
|
+
buffered: [],
|
|
186
|
+
pendingPrompt: null,
|
|
187
|
+
cancelRequested: opts?.signal?.aborted ?? false,
|
|
188
|
+
terminal: false,
|
|
189
|
+
settle: settleResult,
|
|
190
|
+
unsubscribe: null,
|
|
191
|
+
};
|
|
192
|
+
this.activeProviderAuthFlow = flow;
|
|
193
|
+
// Subscribe BEFORE sending the login request: the daemon starts Pi's flow the moment it
|
|
194
|
+
// handles the RPC, so a `prompt`/`auth_url` can legitimately arrive before this request's own
|
|
195
|
+
// response does. Subscribing after the await would silently drop it and hang the dialog.
|
|
196
|
+
const unsubMessages = this.daemon.onSessionMessage((message) => {
|
|
197
|
+
if (isProviderAuthFlowEvent(message))
|
|
198
|
+
this.routeProviderAuthEvent(flow, message, callbacks);
|
|
199
|
+
});
|
|
200
|
+
const unsubState = this.daemon.onStateChange((state) => {
|
|
201
|
+
if (state === "closed") {
|
|
202
|
+
this.settleProviderAuthFlow(flow, { ok: false, error: "connection_lost" });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
let onAbort = null;
|
|
206
|
+
if (opts?.signal) {
|
|
207
|
+
const signal = opts.signal;
|
|
208
|
+
onAbort = () => {
|
|
209
|
+
flow.cancelRequested = true;
|
|
210
|
+
if (flow.flowId && !flow.terminal) {
|
|
211
|
+
this.daemon
|
|
212
|
+
.request("provider_auth_cancel_request", { flowId: flow.flowId })
|
|
213
|
+
.catch(() => { });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
217
|
+
flow.unsubscribe = () => {
|
|
218
|
+
unsubMessages();
|
|
219
|
+
unsubState();
|
|
220
|
+
signal.removeEventListener("abort", onAbort);
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
flow.unsubscribe = () => {
|
|
225
|
+
unsubMessages();
|
|
226
|
+
unsubState();
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const response = await this.daemon.request("provider_auth_login_request", { provider, authType });
|
|
231
|
+
if (flow.terminal)
|
|
232
|
+
return resultPromise;
|
|
233
|
+
if (!response.ok || !response.flowId) {
|
|
234
|
+
this.settleProviderAuthFlow(flow, { ok: false, error: response.error });
|
|
235
|
+
return resultPromise;
|
|
236
|
+
}
|
|
237
|
+
flow.flowId = response.flowId;
|
|
238
|
+
const buffered = flow.buffered;
|
|
239
|
+
flow.buffered = [];
|
|
240
|
+
for (const message of buffered) {
|
|
241
|
+
if (message.flowId === flow.flowId) {
|
|
242
|
+
this.dispatchProviderAuthEvent(flow, message.event, callbacks);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (flow.cancelRequested && !flow.terminal) {
|
|
246
|
+
this.daemon
|
|
247
|
+
.request("provider_auth_cancel_request", { flowId: flow.flowId })
|
|
248
|
+
.catch(() => { });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
if (!flow.terminal) {
|
|
253
|
+
this.settleProviderAuthFlow(flow, { ok: false, error: "connection_lost" });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return resultPromise;
|
|
257
|
+
}
|
|
258
|
+
/** Route one inbound flow-event push: buffer until `flowId` is known, drop stale/unknown flows,
|
|
259
|
+
* else dispatch by `kind`. */
|
|
260
|
+
routeProviderAuthEvent(flow, message, callbacks) {
|
|
261
|
+
if (flow.terminal)
|
|
262
|
+
return;
|
|
263
|
+
if (flow.flowId === null) {
|
|
264
|
+
flow.buffered.push(message);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (message.flowId !== flow.flowId)
|
|
268
|
+
return;
|
|
269
|
+
this.dispatchProviderAuthEvent(flow, message.event, callbacks);
|
|
270
|
+
}
|
|
271
|
+
dispatchProviderAuthEvent(flow, event, callbacks) {
|
|
272
|
+
switch (event.kind) {
|
|
273
|
+
case "done":
|
|
274
|
+
this.settleProviderAuthFlow(flow, {
|
|
275
|
+
ok: Boolean(event.ok),
|
|
276
|
+
error: typeof event.error === "string" ? event.error : undefined,
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
case "prompt_cancelled": {
|
|
280
|
+
const promptId = event.promptId;
|
|
281
|
+
if (flow.pendingPrompt && flow.pendingPrompt.promptId === promptId) {
|
|
282
|
+
flow.pendingPrompt.reject(new PromptCancelledError(promptId));
|
|
283
|
+
flow.pendingPrompt = null;
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case "prompt":
|
|
288
|
+
void this.handleProviderAuthPrompt(flow, event, callbacks);
|
|
289
|
+
return;
|
|
290
|
+
default:
|
|
291
|
+
callbacks.onEvent?.(event);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Ask the caller for a value, racing its answer against an out-of-band `prompt_cancelled`
|
|
296
|
+
* (an OAuth callback can win first). Never lets an unanswered promise dangle: a caller refusal
|
|
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
|
+
*/
|
|
302
|
+
async handleProviderAuthPrompt(flow, event, callbacks) {
|
|
303
|
+
// Aborted when this prompt is cancelled out of band (or the flow ends). The view cannot learn
|
|
304
|
+
// that any other way: `prompt_cancelled` is consumed here rather than forwarded to `onEvent`,
|
|
305
|
+
// and the promise the view returned is simply discarded by the race below — so without this
|
|
306
|
+
// signal a `manual_code` input stays on screen after the OAuth callback already won.
|
|
307
|
+
const cancelledController = new AbortController();
|
|
308
|
+
const prompt = {
|
|
309
|
+
promptId: event.promptId,
|
|
310
|
+
promptKind: event.promptKind,
|
|
311
|
+
message: event.message,
|
|
312
|
+
placeholder: event.placeholder,
|
|
313
|
+
options: event.options,
|
|
314
|
+
signal: cancelledController.signal,
|
|
315
|
+
};
|
|
316
|
+
let rejectCancelled;
|
|
317
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
318
|
+
rejectCancelled = reject;
|
|
319
|
+
});
|
|
320
|
+
cancelled.catch(() => { });
|
|
321
|
+
flow.pendingPrompt = {
|
|
322
|
+
promptId: prompt.promptId,
|
|
323
|
+
reject: (error) => {
|
|
324
|
+
cancelledController.abort();
|
|
325
|
+
rejectCancelled(error);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const answered = callbacks.prompt(prompt);
|
|
329
|
+
answered.catch(() => { });
|
|
330
|
+
try {
|
|
331
|
+
const value = await Promise.race([answered, cancelled]);
|
|
332
|
+
if (flow.terminal)
|
|
333
|
+
return;
|
|
334
|
+
flow.pendingPrompt = null;
|
|
335
|
+
await this.daemon.request("provider_auth_respond_request", {
|
|
336
|
+
flowId: flow.flowId,
|
|
337
|
+
promptId: prompt.promptId,
|
|
338
|
+
value,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
flow.pendingPrompt = null;
|
|
343
|
+
if (flow.terminal || err instanceof PromptCancelledError)
|
|
344
|
+
return;
|
|
345
|
+
if (flow.flowId) {
|
|
346
|
+
this.daemon
|
|
347
|
+
.request("provider_auth_cancel_request", { flowId: flow.flowId })
|
|
348
|
+
.catch(() => { });
|
|
349
|
+
}
|
|
350
|
+
this.settleProviderAuthFlow(flow, { ok: false, error: "connection_lost" });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** The single place a flow ends. Idempotent — whichever path (a `done` event, a lost connection,
|
|
354
|
+
* an immediate login rejection) settles it first wins; releases every subscription exactly once. */
|
|
355
|
+
settleProviderAuthFlow(flow, result) {
|
|
356
|
+
if (flow.terminal)
|
|
357
|
+
return;
|
|
358
|
+
flow.terminal = true;
|
|
359
|
+
if (flow.pendingPrompt) {
|
|
360
|
+
flow.pendingPrompt.reject(new Error("provider-auth: flow ended"));
|
|
361
|
+
flow.pendingPrompt = null;
|
|
362
|
+
}
|
|
363
|
+
flow.unsubscribe?.();
|
|
364
|
+
if (this.activeProviderAuthFlow === flow)
|
|
365
|
+
this.activeProviderAuthFlow = null;
|
|
366
|
+
flow.settle(result);
|
|
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
|
+
}
|
|
66
423
|
}
|
|
67
424
|
// ─── Handle classes ──────────────────────────────────────────────────────────────
|
|
68
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
|