@av-pi-studio/client 0.0.92 → 0.0.93

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.
@@ -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, 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,119 @@ 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
+ }
172
281
  export declare class PiStudioClient {
173
282
  private readonly daemon;
283
+ /** Exactly one login flow per client instance, per `swe/features/provider-auth-rpc.md`. */
284
+ private activeProviderAuthFlow;
174
285
  constructor(daemon: DaemonClient);
175
286
  get connection(): DaemonClient;
176
287
  /**
@@ -209,6 +320,35 @@ export declare class PiStudioClient {
209
320
  onAgentUpdate(handler: PiStudioAgentUpdateHandler): () => void;
210
321
  /** Subscribe to ALL `workspace_update` events (any workspace). */
211
322
  onWorkspaceUpdate(handler: PiStudioWorkspaceUpdateHandler): () => void;
323
+ /** True iff the daemon advertised the `providerAuth` capability in `server_info.features`. */
324
+ hasProviderAuthCapability(): boolean;
325
+ /** Every provider's login capability + current state. */
326
+ listProviderAuth(): Promise<ProviderAuthInfo[]>;
327
+ /** Clear a provider's stored credential. `stillConfigured` flags a surviving ambient one
328
+ * (e.g. an env var) — the provider may still show as configured after this resolves. */
329
+ logoutProvider(provider: string): Promise<{
330
+ stillConfigured?: boolean;
331
+ }>;
332
+ /**
333
+ * Drive one remote provider login end to end. `callbacks` answers Pi's questions and observes
334
+ * presentation events; the returned promise settles only once — from the flow's terminal `done`
335
+ * event, an immediate login rejection, or a lost connection — never left hanging.
336
+ *
337
+ * Only one flow may run per client instance; a second concurrent call rejects locally without
338
+ * sending a second `provider_auth_login_request`.
339
+ */
340
+ loginProvider(provider: string, authType: ProviderAuthType, callbacks: ProviderAuthCallbacks, opts?: ProviderAuthLoginOptions): Promise<ProviderAuthLoginResult>;
341
+ /** Route one inbound flow-event push: buffer until `flowId` is known, drop stale/unknown flows,
342
+ * else dispatch by `kind`. */
343
+ private routeProviderAuthEvent;
344
+ private dispatchProviderAuthEvent;
345
+ /** Ask the caller for a value, racing its answer against an out-of-band `prompt_cancelled`
346
+ * (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. */
348
+ private handleProviderAuthPrompt;
349
+ /** The single place a flow ends. Idempotent — whichever path (a `done` event, a lost connection,
350
+ * an immediate login rejection) settles it first wins; releases every subscription exactly once. */
351
+ private settleProviderAuthFlow;
212
352
  }
213
353
  /** Convenience re-export of the import operation (not agent-scoped). */
214
354
  export declare function importAgentSession(daemon: DaemonClient, args: {
@@ -1,6 +1,39 @@
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
+ }
1
32
  // ─── Facade implementation ──────────────────────────────────────────────────────
2
33
  export class PiStudioClient {
3
34
  daemon;
35
+ /** Exactly one login flow per client instance, per `swe/features/provider-auth-rpc.md`. */
36
+ activeProviderAuthFlow = null;
4
37
  constructor(daemon) {
5
38
  this.daemon = daemon;
6
39
  }
@@ -63,6 +96,224 @@ export class PiStudioClient {
63
96
  }
64
97
  });
65
98
  }
99
+ // ─── Provider auth ──────────────────────────────────────────────────────────
100
+ /** True iff the daemon advertised the `providerAuth` capability in `server_info.features`. */
101
+ hasProviderAuthCapability() {
102
+ return this.daemon.hasFeature("providerAuth");
103
+ }
104
+ /** Every provider's login capability + current state. */
105
+ async listProviderAuth() {
106
+ const payload = await this.daemon.request("provider_auth_list_request", {});
107
+ if (!payload.ok) {
108
+ throw new ProviderAuthError(payload.error ?? "failed to list provider auth state", "list");
109
+ }
110
+ return payload.providers;
111
+ }
112
+ /** Clear a provider's stored credential. `stillConfigured` flags a surviving ambient one
113
+ * (e.g. an env var) — the provider may still show as configured after this resolves. */
114
+ async logoutProvider(provider) {
115
+ const payload = await this.daemon.request("provider_auth_logout_request", { provider });
116
+ if (!payload.ok) {
117
+ throw new ProviderAuthError(payload.error ?? "failed to log out", "logout");
118
+ }
119
+ return { stillConfigured: payload.stillConfigured };
120
+ }
121
+ /**
122
+ * Drive one remote provider login end to end. `callbacks` answers Pi's questions and observes
123
+ * presentation events; the returned promise settles only once — from the flow's terminal `done`
124
+ * event, an immediate login rejection, or a lost connection — never left hanging.
125
+ *
126
+ * Only one flow may run per client instance; a second concurrent call rejects locally without
127
+ * sending a second `provider_auth_login_request`.
128
+ */
129
+ async loginProvider(provider, authType, callbacks, opts) {
130
+ if (this.activeProviderAuthFlow) {
131
+ throw new Error("a provider-auth login is already in progress for this client");
132
+ }
133
+ let settleResult;
134
+ const resultPromise = new Promise((resolve) => {
135
+ settleResult = resolve;
136
+ });
137
+ const flow = {
138
+ flowId: null,
139
+ buffered: [],
140
+ pendingPrompt: null,
141
+ cancelRequested: opts?.signal?.aborted ?? false,
142
+ terminal: false,
143
+ settle: settleResult,
144
+ unsubscribe: null,
145
+ };
146
+ this.activeProviderAuthFlow = flow;
147
+ // Subscribe BEFORE sending the login request: the daemon starts Pi's flow the moment it
148
+ // handles the RPC, so a `prompt`/`auth_url` can legitimately arrive before this request's own
149
+ // response does. Subscribing after the await would silently drop it and hang the dialog.
150
+ const unsubMessages = this.daemon.onSessionMessage((message) => {
151
+ if (isProviderAuthFlowEvent(message))
152
+ this.routeProviderAuthEvent(flow, message, callbacks);
153
+ });
154
+ const unsubState = this.daemon.onStateChange((state) => {
155
+ if (state === "closed") {
156
+ this.settleProviderAuthFlow(flow, { ok: false, error: "connection_lost" });
157
+ }
158
+ });
159
+ let onAbort = null;
160
+ if (opts?.signal) {
161
+ const signal = opts.signal;
162
+ onAbort = () => {
163
+ flow.cancelRequested = true;
164
+ if (flow.flowId && !flow.terminal) {
165
+ this.daemon
166
+ .request("provider_auth_cancel_request", { flowId: flow.flowId })
167
+ .catch(() => { });
168
+ }
169
+ };
170
+ signal.addEventListener("abort", onAbort, { once: true });
171
+ flow.unsubscribe = () => {
172
+ unsubMessages();
173
+ unsubState();
174
+ signal.removeEventListener("abort", onAbort);
175
+ };
176
+ }
177
+ else {
178
+ flow.unsubscribe = () => {
179
+ unsubMessages();
180
+ unsubState();
181
+ };
182
+ }
183
+ try {
184
+ const response = await this.daemon.request("provider_auth_login_request", { provider, authType });
185
+ if (flow.terminal)
186
+ return resultPromise;
187
+ if (!response.ok || !response.flowId) {
188
+ this.settleProviderAuthFlow(flow, { ok: false, error: response.error });
189
+ return resultPromise;
190
+ }
191
+ flow.flowId = response.flowId;
192
+ const buffered = flow.buffered;
193
+ flow.buffered = [];
194
+ for (const message of buffered) {
195
+ if (message.flowId === flow.flowId) {
196
+ this.dispatchProviderAuthEvent(flow, message.event, callbacks);
197
+ }
198
+ }
199
+ if (flow.cancelRequested && !flow.terminal) {
200
+ this.daemon
201
+ .request("provider_auth_cancel_request", { flowId: flow.flowId })
202
+ .catch(() => { });
203
+ }
204
+ }
205
+ catch {
206
+ if (!flow.terminal) {
207
+ this.settleProviderAuthFlow(flow, { ok: false, error: "connection_lost" });
208
+ }
209
+ }
210
+ return resultPromise;
211
+ }
212
+ /** Route one inbound flow-event push: buffer until `flowId` is known, drop stale/unknown flows,
213
+ * else dispatch by `kind`. */
214
+ routeProviderAuthEvent(flow, message, callbacks) {
215
+ if (flow.terminal)
216
+ return;
217
+ if (flow.flowId === null) {
218
+ flow.buffered.push(message);
219
+ return;
220
+ }
221
+ if (message.flowId !== flow.flowId)
222
+ return;
223
+ this.dispatchProviderAuthEvent(flow, message.event, callbacks);
224
+ }
225
+ dispatchProviderAuthEvent(flow, event, callbacks) {
226
+ switch (event.kind) {
227
+ case "done":
228
+ this.settleProviderAuthFlow(flow, {
229
+ ok: Boolean(event.ok),
230
+ error: typeof event.error === "string" ? event.error : undefined,
231
+ });
232
+ return;
233
+ case "prompt_cancelled": {
234
+ const promptId = event.promptId;
235
+ if (flow.pendingPrompt && flow.pendingPrompt.promptId === promptId) {
236
+ flow.pendingPrompt.reject(new PromptCancelledError(promptId));
237
+ flow.pendingPrompt = null;
238
+ }
239
+ return;
240
+ }
241
+ case "prompt":
242
+ void this.handleProviderAuthPrompt(flow, event, callbacks);
243
+ return;
244
+ default:
245
+ callbacks.onEvent?.(event);
246
+ return;
247
+ }
248
+ }
249
+ /** Ask the caller for a value, racing its answer against an out-of-band `prompt_cancelled`
250
+ * (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. */
252
+ async handleProviderAuthPrompt(flow, event, callbacks) {
253
+ // Aborted when this prompt is cancelled out of band (or the flow ends). The view cannot learn
254
+ // that any other way: `prompt_cancelled` is consumed here rather than forwarded to `onEvent`,
255
+ // and the promise the view returned is simply discarded by the race below — so without this
256
+ // signal a `manual_code` input stays on screen after the OAuth callback already won.
257
+ const cancelledController = new AbortController();
258
+ const prompt = {
259
+ promptId: event.promptId,
260
+ promptKind: event.promptKind,
261
+ message: event.message,
262
+ placeholder: event.placeholder,
263
+ options: event.options,
264
+ signal: cancelledController.signal,
265
+ };
266
+ let rejectCancelled;
267
+ const cancelled = new Promise((_resolve, reject) => {
268
+ rejectCancelled = reject;
269
+ });
270
+ cancelled.catch(() => { });
271
+ flow.pendingPrompt = {
272
+ promptId: prompt.promptId,
273
+ reject: (error) => {
274
+ cancelledController.abort();
275
+ rejectCancelled(error);
276
+ },
277
+ };
278
+ const answered = callbacks.prompt(prompt);
279
+ answered.catch(() => { });
280
+ try {
281
+ const value = await Promise.race([answered, cancelled]);
282
+ if (flow.terminal)
283
+ return;
284
+ flow.pendingPrompt = null;
285
+ await this.daemon.request("provider_auth_respond_request", {
286
+ flowId: flow.flowId,
287
+ promptId: prompt.promptId,
288
+ value,
289
+ });
290
+ }
291
+ catch (err) {
292
+ flow.pendingPrompt = null;
293
+ if (flow.terminal || err instanceof PromptCancelledError)
294
+ return;
295
+ if (flow.flowId) {
296
+ this.daemon
297
+ .request("provider_auth_cancel_request", { flowId: flow.flowId })
298
+ .catch(() => { });
299
+ }
300
+ }
301
+ }
302
+ /** The single place a flow ends. Idempotent — whichever path (a `done` event, a lost connection,
303
+ * an immediate login rejection) settles it first wins; releases every subscription exactly once. */
304
+ settleProviderAuthFlow(flow, result) {
305
+ if (flow.terminal)
306
+ return;
307
+ flow.terminal = true;
308
+ if (flow.pendingPrompt) {
309
+ flow.pendingPrompt.reject(new Error("provider-auth: flow ended"));
310
+ flow.pendingPrompt = null;
311
+ }
312
+ flow.unsubscribe?.();
313
+ if (this.activeProviderAuthFlow === flow)
314
+ this.activeProviderAuthFlow = null;
315
+ flow.settle(result);
316
+ }
66
317
  }
67
318
  // ─── Handle classes ──────────────────────────────────────────────────────────────
68
319
  class TimelineHandle {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@av-pi-studio/client",
3
- "version": "0.0.92",
3
+ "version": "0.0.93",
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.92",
24
- "@av-pi-studio/relay": "^0.0.92"
23
+ "@av-pi-studio/protocol": "^0.0.93",
24
+ "@av-pi-studio/relay": "^0.0.93"
25
25
  }
26
26
  }