@guuey/chat 0.7.2 → 0.8.1

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.
Files changed (50) hide show
  1. package/dist/hitl.d.ts +5 -0
  2. package/dist/hitl.d.ts.map +1 -1
  3. package/dist/hitl.js +5 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/native/components.d.ts.map +1 -1
  8. package/dist/native/components.js +5 -5
  9. package/dist/oauth.d.ts +87 -0
  10. package/dist/oauth.d.ts.map +1 -0
  11. package/dist/oauth.js +121 -0
  12. package/dist/plan.d.ts.map +1 -1
  13. package/dist/plan.js +4 -1
  14. package/dist/react/components.d.ts.map +1 -1
  15. package/dist/react/components.js +7 -5
  16. package/dist/react/guuey-chat.d.ts +14 -0
  17. package/dist/react/guuey-chat.d.ts.map +1 -1
  18. package/dist/react/guuey-chat.js +21 -3
  19. package/dist/react/oauth-return.d.ts +61 -0
  20. package/dist/react/oauth-return.d.ts.map +1 -0
  21. package/dist/react/oauth-return.js +103 -0
  22. package/dist/react/use-transcript.d.ts +3 -3
  23. package/dist/react/use-transcript.d.ts.map +1 -1
  24. package/dist/react/use-transcript.js +4 -27
  25. package/dist/react.d.ts +1 -0
  26. package/dist/react.d.ts.map +1 -1
  27. package/dist/react.js +1 -0
  28. package/dist/strings.d.ts +10 -0
  29. package/dist/strings.d.ts.map +1 -1
  30. package/dist/strings.js +4 -0
  31. package/dist/types.d.ts +25 -11
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +4 -3
  34. package/src/corpus/README.md +7 -1
  35. package/src/corpus/__snapshots__/corpus.test.ts.snap +126 -7
  36. package/src/corpus/drive.ts +43 -12
  37. package/src/corpus/fixtures.ts +95 -4
  38. package/src/hitl.ts +5 -0
  39. package/src/index.ts +11 -0
  40. package/src/native/components.tsx +15 -7
  41. package/src/oauth.ts +157 -0
  42. package/src/plan.ts +4 -1
  43. package/src/react/components.tsx +17 -9
  44. package/src/react/guuey-chat.tsx +49 -1
  45. package/src/react/oauth-return.ts +129 -0
  46. package/src/react/use-transcript.ts +7 -32
  47. package/src/react.tsx +9 -0
  48. package/src/strings.ts +14 -0
  49. package/src/types.ts +22 -11
  50. package/styles.css +26 -0
@@ -5,8 +5,9 @@
5
5
  * `@silverprotocol/core` Reducer). Test-only plumbing — excluded from the
6
6
  * build; the 3b live/history assemblers are the production twins.
7
7
  */
8
- import { Reducer, type AgEvent } from "@silverprotocol/core";
8
+ import { Reducer, type AgEvent, type AgPausedAsk } from "@silverprotocol/core";
9
9
  import type { InvokeTurnEvent } from "@guuey/agent-client";
10
+ import { hitlPromptsFromFold, type HitlAnswerRecord } from "../hitl.js";
10
11
  import type { ProfilePromptInput, TranscriptInputs, TranscriptMessage } from "../types.js";
11
12
 
12
13
  export interface DriveOptions {
@@ -21,8 +22,10 @@ export interface DriveOptions {
21
22
  viewPhases?: TranscriptInputs["viewPhases"];
22
23
  historyCards?: TranscriptInputs["historyCards"];
23
24
  historyState?: TranscriptInputs["historyState"];
24
- /** Resolve accumulated prompts to this state (fixture 4's answered leg). */
25
+ /** Resolve accumulated link prompts to this state. */
25
26
  promptState?: ProfilePromptInput["state"];
27
+ /** The host-side answer ledger for AgJSON hitl asks (fixture 4's answered leg). */
28
+ hitlAnswers?: Readonly<Record<string, HitlAnswerRecord>>;
26
29
  }
27
30
 
28
31
  export function driveTurn(events: InvokeTurnEvent[], opts: DriveOptions = {}): TranscriptInputs {
@@ -53,15 +56,6 @@ export function driveTurn(events: InvokeTurnEvent[], opts: DriveOptions = {}): T
53
56
  error = { message: ev.message, code: ev.code };
54
57
  status = "ready";
55
58
  break;
56
- case "profile-consent":
57
- prompts.push({
58
- id: `consent-${prompts.length}`,
59
- kind: "consent",
60
- appId: ev.request.appId,
61
- requested: ev.request.requested,
62
- state: opts.promptState ?? "pending",
63
- });
64
- break;
65
59
  case "profile-link":
66
60
  prompts.push({
67
61
  id: `link-${prompts.length}`,
@@ -101,7 +95,9 @@ export function driveTurn(events: InvokeTurnEvent[], opts: DriveOptions = {}): T
101
95
  statusElapsedMs: opts.statusElapsedMs ?? 0,
102
96
  activeTool,
103
97
  error,
104
- prompts,
98
+ // The link ledger + the AgJSON hitl asks lifted from the fold — the
99
+ // live assembler's exact composition (use-transcript.ts).
100
+ prompts: [...prompts, ...hitlPromptsFromFold(sawAgEvents ? reducer.result() : null, opts.hitlAnswers ?? {})],
105
101
  messages,
106
102
  ...(opts.sendStates !== undefined ? { sendStates: opts.sendStates } : {}),
107
103
  ...(opts.viewPhases !== undefined ? { viewPhases: opts.viewPhases } : {}),
@@ -180,5 +176,40 @@ export function frame(
180
176
  };
181
177
  }
182
178
 
179
+ /**
180
+ * The runtime's cross-app profile consent ask (guuey#207) — the paused turn
181
+ * the pod appends AFTER the agent's own turn: `turn.start` → `hitl.ask` →
182
+ * `turn.done outcome:"paused"` carrying the same `AgPausedAsk`. Mirrors
183
+ * `nocode-runtime/src/profile-consent.ts` byte-for-byte in shape.
184
+ */
185
+ export const CONSENT_ASK: AgPausedAsk = {
186
+ askId: "profile-consent:app-1:thread-corpus",
187
+ kind: "approval",
188
+ message: "Trip Planner wants to read your guuey profile — the notes agents keep about you across apps.",
189
+ grantModes: [
190
+ { id: "always", label: "Always allow", description: "Every conversation with this agent" },
191
+ { id: "once", label: "Allow this chat", description: "Only this conversation" },
192
+ ],
193
+ metadata: { appId: "app-1", requested: "read" },
194
+ };
195
+ export function consentTurn(next: () => number, ask: AgPausedAsk = CONSENT_ASK): AgEvent[] {
196
+ const turnId = `${ask.askId}#turn`;
197
+ return [
198
+ { type: "turn.start", threadId: THREAD, turnId, seq: next() },
199
+ {
200
+ type: "hitl.ask",
201
+ turnId,
202
+ askId: ask.askId,
203
+ kind: ask.kind,
204
+ ...(ask.message !== undefined ? { message: ask.message } : {}),
205
+ ...(ask.grantModes !== undefined ? { grantModes: ask.grantModes } : {}),
206
+ ...(ask.metadata !== undefined ? { metadata: ask.metadata } : {}),
207
+ continuation: "turn",
208
+ seq: next(),
209
+ },
210
+ { type: "turn.done", turnId, finishReason: "paused", outcome: { type: "paused", asks: [ask] }, seq: next() },
211
+ ];
212
+ }
213
+
183
214
  export const session: InvokeTurnEvent = { kind: "session", threadId: THREAD };
184
215
  export const doneEvent: InvokeTurnEvent = { kind: "done", stopReason: "end_turn" };
@@ -13,6 +13,8 @@ import type { InvokeTurnEvent } from "@guuey/agent-client";
13
13
  import type { TranscriptInputs } from "../types.js";
14
14
  import {
15
15
  boot,
16
+ CONSENT_ASK,
17
+ consentTurn,
16
18
  doneEvent,
17
19
  driveTurn,
18
20
  frame,
@@ -81,17 +83,29 @@ export function coldStart(elapsedMs: number): TranscriptInputs {
81
83
  return driveTurn([], { userText: "hi", finalStatus: "connecting", statusElapsedMs: elapsedMs });
82
84
  }
83
85
 
84
- /** 4. consent-gate — R10 pending → answered collapse. */
86
+ /**
87
+ * 4. consent-gate — R10 pending grant-mode card → answered one-line collapse.
88
+ * The pod's real shape (guuey#207): the agent turn runs WITHOUT the profile
89
+ * and closes, then the paused consent turn rides after it; the answered leg
90
+ * is the host ledger recording the picked mode (`resolved` + `always`).
91
+ */
85
92
  export function consentGate(state: "pending" | "answered"): TranscriptInputs {
86
93
  const s = seqSource();
87
94
  const events: InvokeTurnEvent[] = [
88
95
  session,
89
96
  frame(boot(s), { status: "thinking" }),
90
- { kind: "profile-consent", request: { appId: "app-1", requested: "read" } },
91
- frame(textPart(s, "x", "May I use your profile?"), { status: "responding", text: "May I use your profile?" }),
97
+ frame(textPart(s, "x", "Booked your usual I couldn't see your profile yet."), {
98
+ status: "responding",
99
+ text: "Booked your usual — I couldn't see your profile yet.",
100
+ }),
101
+ frame([{ type: "message.end", id: "msg-corpus", seq: s() }, { type: "turn.done", turnId: "turn-corpus", finishReason: "stop", outcome: { type: "success" }, seq: s() }]),
102
+ frame(consentTurn(s)),
92
103
  doneEvent,
93
104
  ];
94
- return driveTurn(events, { userText: "book my usual", promptState: state });
105
+ return driveTurn(events, {
106
+ userText: "book my usual",
107
+ ...(state === "answered" ? { hitlAnswers: { [CONSENT_ASK.askId]: { status: "resolved", grantModeId: "always" } } } : {}),
108
+ });
95
109
  }
96
110
 
97
111
  /** 5. bypass-text-only — plan identical to a silver stream carrying only text. */
@@ -627,6 +641,83 @@ export function prodWireGguiRender(): TranscriptInputs {
627
641
  return driveTurn(events, { userText: "show me the board" });
628
642
  }
629
643
 
644
+ /**
645
+ * 25. oauth-auth-ask (guuey#178 Slice 4) — the MCP OAuth broker's "authorize
646
+ * this server" card: a paused turn whose PERSISTED ask is `kind:"auth"` with
647
+ * `authConfig:{ scheme:"oauth2", authorizationUrl }` + the two grant modes
648
+ * (the exact wire `nocode-runtime/src/mcp-oauth-consent.ts` emits — askId
649
+ * `mcp-oauth:<appId>:<serverName>:<threadId>`, `metadata:{appId, serverName,
650
+ * expiresAt}`, top-level `expiresAt`). Unlike family 20 there is NO answer
651
+ * door: a mode pick opens `authorizationUrl&mode=<id>&returnTo=<here>`; the
652
+ * decline is a dismissal ("Not now"). Driven through the real Reducer.
653
+ */
654
+ export const OAUTH_ASK_AUTHORIZE_URL = "https://mcp.dev.sandbox.guuey.com/oauth/start?state=" + "a".repeat(64);
655
+ export function oauthAuthAsk(): TranscriptInputs {
656
+ const reducer = new Reducer();
657
+ let seq = 0;
658
+ const s = (): number => seq++;
659
+ const askId = "mcp-oauth:app_1:linear:thread-oauth";
660
+ const events: AgEvent[] = [
661
+ { type: "turn.start", threadId: "thread-oauth", turnId: "turn-o", seq: s() },
662
+ { type: "message.start", id: "msg-o", role: "assistant", turnId: "turn-o", threadId: "thread-oauth", seq: s() },
663
+ { type: "text.start", id: "to", seq: s() },
664
+ { type: "text.delta", id: "to", delta: "I can't reach Linear yet.", seq: s() },
665
+ { type: "text.end", id: "to", seq: s() },
666
+ { type: "turn.done", turnId: "turn-o", outcome: { type: "success" }, seq: s() },
667
+ { type: "turn.start", threadId: "thread-oauth", turnId: `${askId}#turn`, seq: s() },
668
+ {
669
+ type: "hitl.ask",
670
+ turnId: `${askId}#turn`,
671
+ askId,
672
+ kind: "auth",
673
+ message: "Trip Planner wants to use your Linear account",
674
+ authConfig: { scheme: "oauth2", authorizationUrl: OAUTH_ASK_AUTHORIZE_URL },
675
+ grantModes: [
676
+ { id: "always", label: "Always allow", description: "Every conversation with this agent" },
677
+ { id: "once", label: "Allow this chat", description: "Only this conversation" },
678
+ ],
679
+ metadata: { appId: "app_1", serverName: "linear", expiresAt: "2026-08-17T10:10:00.000Z" },
680
+ expiresAt: "2026-08-17T10:10:00.000Z",
681
+ continuation: "turn",
682
+ seq: s(),
683
+ },
684
+ {
685
+ type: "turn.done",
686
+ turnId: `${askId}#turn`,
687
+ finishReason: "paused",
688
+ outcome: {
689
+ type: "paused",
690
+ asks: [
691
+ {
692
+ askId,
693
+ kind: "auth",
694
+ message: "Trip Planner wants to use your Linear account",
695
+ authConfig: { scheme: "oauth2", authorizationUrl: OAUTH_ASK_AUTHORIZE_URL },
696
+ grantModes: [
697
+ { id: "always", label: "Always allow", description: "Every conversation with this agent" },
698
+ { id: "once", label: "Allow this chat", description: "Only this conversation" },
699
+ ],
700
+ metadata: { appId: "app_1", serverName: "linear", expiresAt: "2026-08-17T10:10:00.000Z" },
701
+ expiresAt: "2026-08-17T10:10:00.000Z",
702
+ },
703
+ ],
704
+ },
705
+ seq: s(),
706
+ },
707
+ ];
708
+ for (const ev of events) reducer.push(ev);
709
+ return {
710
+ result: reducer.result(),
711
+ assistantText: "",
712
+ status: "ready",
713
+ statusElapsedMs: 0,
714
+ activeTool: null,
715
+ error: null,
716
+ prompts: [], // the test composes these via hitlPromptsFromFold + the answer ledger
717
+ messages: [{ role: "user", text: "what's on my Linear board?" }],
718
+ };
719
+ }
720
+
630
721
  /**
631
722
  * What ggui's `resources/read` answers for a render locator (guuey#209 C2):
632
723
  * the shell that boots ggui's runtime with the live-channel material
package/src/hitl.ts CHANGED
@@ -19,6 +19,11 @@
19
19
  * Answer states encode guuey's ratified dismissal ruling (#16): dismissal
20
20
  * maps to `cancelled` (still-pending, re-askable — the card stays
21
21
  * answerable), `declined` is the durable explicit deny.
22
+ *
23
+ * The first producer is the guuey runtime's cross-app profile consent
24
+ * (guuey#207): the pod appends a paused turn whose ask declares
25
+ * `grantModes: [always, once?]`; the host delivers the built answer through
26
+ * `@guuey/agent-client`'s `createHitlAnswerRelay` (`POST <pod>/agent/hitl-answer`).
22
27
  */
23
28
  import {
24
29
  validateHitlAnswer,
package/src/index.ts CHANGED
@@ -32,6 +32,17 @@ export {
32
32
  type HitlAnswerRecord,
33
33
  type HitlPromptAction,
34
34
  } from "./hitl.js";
35
+ export {
36
+ OAUTH_LINK_PARAMS,
37
+ OAUTH_RETURN_PARAMS,
38
+ OAUTH_SCHEME,
39
+ oauthAuthorizeAsk,
40
+ oauthAuthorizeHref,
41
+ parseOAuthReturn,
42
+ stripOAuthReturn,
43
+ type OAuthAuthorizeAsk,
44
+ type OAuthReturn,
45
+ } from "./oauth.js";
35
46
  export {
36
47
  DEFAULT_CHAT_THEME,
37
48
  GUUEY_CHAT_THEME,
@@ -492,7 +492,9 @@ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
492
492
  <MutedText ctx={ctx}>
493
493
  {item.state === "resolved"
494
494
  ? item.chosenModeLabel !== null
495
- ? s.promptAnsweredWith(item.chosenModeLabel)
495
+ ? item.oauth !== null
496
+ ? s.promptOAuthSent(item.chosenModeLabel)
497
+ : s.promptAnsweredWith(item.chosenModeLabel)
496
498
  : s.promptAccept
497
499
  : s.promptDeclinedRecord}
498
500
  </MutedText>
@@ -540,9 +542,17 @@ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
540
542
  </Pressable>
541
543
  ))
542
544
  )}
543
- <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "decline")}>
544
- <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>{s.promptDecline}</Text>
545
- </Pressable>
545
+ {/* The OAuth arm has no durable deny — "Not now" dismisses
546
+ (nothing written; the ask re-emits next turn). */}
547
+ {item.oauth !== null ? (
548
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "dismiss")}>
549
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>{s.promptNotNow}</Text>
550
+ </Pressable>
551
+ ) : (
552
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "decline")}>
553
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>{s.promptDecline}</Text>
554
+ </Pressable>
555
+ )}
546
556
  </View>
547
557
  </>
548
558
  )}
@@ -565,9 +575,7 @@ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
565
575
  style={{ backgroundColor: tokens.palette.surface, borderRadius: tokens.radius, padding: tokens.pad, gap: 8 }}
566
576
  >
567
577
  <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
568
- {item.promptKind === "consent"
569
- ? `${item.appId} requests ${item.requested} access`
570
- : `Link your account to ${item.appId}`}
578
+ {`Link your account to ${item.appId}`}
571
579
  </Text>
572
580
  <View style={{ flexDirection: "row", gap: 12 }}>
573
581
  <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "accept")}>
package/src/oauth.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The "authorize this server" arm of R10 (guuey#178 Slice 4) — the pure
3
+ * helpers every surface (web kit, widget, studio, portal native) uses for a
4
+ * `hitl.ask kind:"auth"` whose `authConfig.scheme` is `oauth2`.
5
+ *
6
+ * The producer is the guuey runtime's MCP OAuth broker: the pod appends a
7
+ * paused turn (`turn.done outcome:"paused"`) whose ask declares
8
+ * `authConfig:{ scheme:"oauth2", authorizationUrl }` + `grantModes:
9
+ * [always, once?]`. Unlike the profile-consent ask there is **no answer
10
+ * door**: the answer IS the redirect. Picking a mode opens
11
+ * `authorizationUrl` with two query params appended by THIS client —
12
+ *
13
+ * - `mode=<grantModeId>` — the pre-chosen grant (recorded by the broker's
14
+ * callback; the mode is fixed BEFORE the identity-provider hop), and
15
+ * - `returnTo=<the surface's own location>` — where the broker 302s back
16
+ * to when the dance ends (`?connected=<serverName>` on success,
17
+ * `?error=<reason>` on failure). The broker allowlists it; the client
18
+ * just says where it lives.
19
+ *
20
+ * On return the surface strips the two params, shows a one-line notice, and
21
+ * does nothing else: the NEXT turn's pre-turn preflight on the pod resolves
22
+ * `connected` (or asks again). No client-side state survives the redirect
23
+ * on purpose. "Not now" is a plain dismissal (`cancelled` — still pending,
24
+ * re-askable); nothing is written anywhere and the ask re-emits next turn.
25
+ *
26
+ * Everything here is string-level (no `URL`/`URLSearchParams`) so it runs
27
+ * identically on React Native, whose URL polyfill is partial.
28
+ */
29
+ import type { AgPausedAsk } from "@silverprotocol/core";
30
+
31
+ /** The `authConfig.scheme` this arm recognises (the spec's OAuth 2 vocabulary). */
32
+ export const OAUTH_SCHEME = "oauth2";
33
+
34
+ /** The query params THIS client appends to the authorize link. */
35
+ export const OAUTH_LINK_PARAMS = { mode: "mode", returnTo: "returnTo" } as const;
36
+
37
+ /** The query params the broker's callback appends to `returnTo`. */
38
+ export const OAUTH_RETURN_PARAMS = { connected: "connected", error: "error" } as const;
39
+
40
+ /** What an auth ask declares once narrowed to the OAuth arm. */
41
+ export interface OAuthAuthorizeAsk {
42
+ authorizationUrl: string;
43
+ scopes: readonly string[];
44
+ }
45
+
46
+ /**
47
+ * Narrow an ask to the OAuth arm: `kind:"auth"` + `authConfig.scheme ===
48
+ * "oauth2"` + an `authorizationUrl` to open. Anything else (a plain
49
+ * approval, an auth ask with another scheme, a URL-less declaration) is
50
+ * `null` and renders as an ordinary hitl card.
51
+ */
52
+ export function oauthAuthorizeAsk(ask: AgPausedAsk): OAuthAuthorizeAsk | null {
53
+ if (ask.kind !== "auth" || ask.authConfig === undefined) return null;
54
+ const { scheme, authorizationUrl, scopes } = ask.authConfig;
55
+ if (scheme !== OAUTH_SCHEME || authorizationUrl === undefined || authorizationUrl === "") return null;
56
+ return { authorizationUrl, scopes: scopes ?? [] };
57
+ }
58
+
59
+ /**
60
+ * The link to open for a mode pick: `authorizationUrl` + `&mode=<grantModeId>`
61
+ * + `&returnTo=<returnTo>` (each value URI-encoded; `?` vs `&` chosen from
62
+ * the URL as declared). `grantModeId` is required iff the ask declares
63
+ * modes (`null` = a plain accept on a mode-less auth ask — no `mode` param).
64
+ * Throws on a mode the ask did not declare — the inputs came from the
65
+ * declaration itself, so a mismatch is a construction bug, never a user
66
+ * state.
67
+ */
68
+ export function oauthAuthorizeHref(ask: AgPausedAsk, grantModeId: string | null, returnTo: string): string {
69
+ const oauth = oauthAuthorizeAsk(ask);
70
+ if (oauth === null) throw new Error("oauthAuthorizeHref: the ask is not an oauth2 auth ask");
71
+ const declared = ask.grantModes ?? [];
72
+ if (grantModeId === null && declared.length > 0) {
73
+ throw new Error(`oauthAuthorizeHref: ${ask.askId} declares grant modes — a mode is required`);
74
+ }
75
+ if (grantModeId !== null && !declared.some((m) => m.id === grantModeId)) {
76
+ throw new Error(`oauthAuthorizeHref: grant mode "${grantModeId}" is not declared on ${ask.askId}`);
77
+ }
78
+ if (returnTo === "") throw new Error("oauthAuthorizeHref: returnTo is required");
79
+ const sep = oauth.authorizationUrl.includes("?") ? "&" : "?";
80
+ const mode = grantModeId === null ? "" : `${OAUTH_LINK_PARAMS.mode}=${encodeURIComponent(grantModeId)}&`;
81
+ return `${oauth.authorizationUrl}${sep}${mode}${OAUTH_LINK_PARAMS.returnTo}=${encodeURIComponent(returnTo)}`;
82
+ }
83
+
84
+ /** The broker's answer, lifted off the return location. */
85
+ export type OAuthReturn =
86
+ | { kind: "connected"; serverName: string }
87
+ | { kind: "error"; reason: string };
88
+
89
+ /** `decodeURIComponent` that yields the raw text for a malformed escape (a foreign param is not ours to fail on). */
90
+ function safeDecode(s: string): string {
91
+ try {
92
+ return decodeURIComponent(s.replace(/\+/g, " "));
93
+ } catch {
94
+ return s;
95
+ }
96
+ }
97
+
98
+ /** Split a URL (or bare query) into its query pairs — RN-safe, no `URLSearchParams`. */
99
+ function queryPairs(urlOrQuery: string): Array<[string, string]> {
100
+ const q = urlOrQuery.indexOf("?");
101
+ let query = q === -1 ? (urlOrQuery.startsWith("&") || !urlOrQuery.includes("=") ? "" : urlOrQuery) : urlOrQuery.slice(q + 1);
102
+ const hash = query.indexOf("#");
103
+ if (hash !== -1) query = query.slice(0, hash);
104
+ const pairs: Array<[string, string]> = [];
105
+ for (const part of query.split("&")) {
106
+ if (part === "") continue;
107
+ const eq = part.indexOf("=");
108
+ const k = eq === -1 ? part : part.slice(0, eq);
109
+ const v = eq === -1 ? "" : part.slice(eq + 1);
110
+ pairs.push([safeDecode(k), safeDecode(v)]);
111
+ }
112
+ return pairs;
113
+ }
114
+
115
+ /**
116
+ * Read the broker's return params off a location (a full URL, a deep link
117
+ * like `guuey://oauth/done?connected=linear`, or a bare `?connected=…`
118
+ * query). `null` when neither param is present. `connected` wins when both
119
+ * appear (the broker never sends both).
120
+ */
121
+ export function parseOAuthReturn(urlOrQuery: string): OAuthReturn | null {
122
+ let connected: string | undefined;
123
+ let error: string | undefined;
124
+ for (const [k, v] of queryPairs(urlOrQuery)) {
125
+ if (k === OAUTH_RETURN_PARAMS.connected && v !== "") connected ??= v;
126
+ else if (k === OAUTH_RETURN_PARAMS.error && v !== "") error ??= v;
127
+ }
128
+ if (connected !== undefined) return { kind: "connected", serverName: connected };
129
+ if (error !== undefined) return { kind: "error", reason: error };
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * The same location with the broker's return params removed (every other
135
+ * param, and the hash, preserved). Idempotent; a URL without them is
136
+ * returned unchanged. This is what a surface writes back into its address
137
+ * bar (`history.replaceState`) so a reload never re-shows the notice — and
138
+ * what it passes as `returnTo` for the next dance.
139
+ */
140
+ export function stripOAuthReturn(url: string): string {
141
+ const q = url.indexOf("?");
142
+ if (q === -1) return url;
143
+ const hashAt = url.indexOf("#", q);
144
+ const base = url.slice(0, q);
145
+ const hash = hashAt === -1 ? "" : url.slice(hashAt);
146
+ const query = hashAt === -1 ? url.slice(q + 1) : url.slice(q + 1, hashAt);
147
+ const kept = query
148
+ .split("&")
149
+ .filter((part) => {
150
+ if (part === "") return false;
151
+ const eq = part.indexOf("=");
152
+ const key = safeDecode(eq === -1 ? part : part.slice(0, eq));
153
+ return key !== OAUTH_RETURN_PARAMS.connected && key !== OAUTH_RETURN_PARAMS.error;
154
+ })
155
+ .join("&");
156
+ return kept === "" ? `${base}${hash}` : `${base}?${kept}${hash}`;
157
+ }
package/src/plan.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  } from "@guuey/mcp-apps-host";
42
42
  import type { TranscriptPolicy } from "./policy.js";
43
43
  import { grantModeDisplay } from "./hitl.js";
44
+ import { oauthAuthorizeAsk } from "./oauth.js";
44
45
  import type {
45
46
  CitationsItem,
46
47
  DataResultItem,
@@ -838,7 +839,8 @@ export function planTranscript(
838
839
  }
839
840
 
840
841
  // R10 — prompts, in input order (narrow on `kind`: the guuey-wire
841
- // profile arm vs the AgJSON hitl arm, spec draft.2).
842
+ // link-invite arm vs the AgJSON hitl arm spec draft.2; consent rides
843
+ // hitl since guuey#207).
842
844
  for (const prompt of inputs.prompts) {
843
845
  const key = `p.${prompt.id}`;
844
846
  if (prompt.kind === "hitl") {
@@ -858,6 +860,7 @@ export function planTranscript(
858
860
  message: prompt.ask.message ?? null,
859
861
  askKind: prompt.ask.kind,
860
862
  grantModes: prompt.ask.grantModes ?? [],
863
+ oauth: oauthAuthorizeAsk(prompt.ask),
861
864
  state: prompt.state,
862
865
  chosenModeId: prompt.grantModeId ?? null,
863
866
  chosenModeLabel: chosen !== undefined ? grantModeDisplay(chosen) : null,
@@ -520,11 +520,15 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
520
520
  // the durable deny. `cancelled` is guuey's re-askable dismissal: it
521
521
  // collapses to a record but stays answerable when expanded.
522
522
  if (item.state === "resolved" || item.state === "declined") {
523
+ // The OAuth arm's resolved record: the pick SENT the user to the
524
+ // provider (no answer door) — the grant lands on the next turn.
523
525
  return (
524
526
  <p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
525
527
  {item.state === "resolved"
526
528
  ? item.chosenModeLabel !== null
527
- ? s.promptAnsweredWith(item.chosenModeLabel)
529
+ ? item.oauth !== null
530
+ ? s.promptOAuthSent(item.chosenModeLabel)
531
+ : s.promptAnsweredWith(item.chosenModeLabel)
528
532
  : s.promptAccept
529
533
  : s.promptDeclinedRecord}
530
534
  </p>
@@ -570,9 +574,17 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
570
574
  </button>
571
575
  ))
572
576
  )}
573
- <button type="button" onClick={() => ctx.onPromptAction?.(item, "decline")}>
574
- {s.promptDecline}
575
- </button>
577
+ {/* The OAuth arm has no durable deny — "Not now" dismisses
578
+ (nothing written; the ask re-emits next turn). */}
579
+ {item.oauth !== null ? (
580
+ <button type="button" onClick={() => ctx.onPromptAction?.(item, "dismiss")}>
581
+ {s.promptNotNow}
582
+ </button>
583
+ ) : (
584
+ <button type="button" onClick={() => ctx.onPromptAction?.(item, "decline")}>
585
+ {s.promptDecline}
586
+ </button>
587
+ )}
576
588
  </div>
577
589
  </>
578
590
  )}
@@ -588,11 +600,7 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
588
600
  }
589
601
  return (
590
602
  <div className="guuey-chat-prompt" role="group">
591
- <p className="guuey-chat-prompt-ask">
592
- {item.promptKind === "consent"
593
- ? `${item.appId} requests ${item.requested} access`
594
- : `Link your account to ${item.appId}`}
595
- </p>
603
+ <p className="guuey-chat-prompt-ask">{`Link your account to ${item.appId}`}</p>
596
604
  <div className="guuey-chat-prompt-actions">
597
605
  <button
598
606
  ref={firstAction}
@@ -71,6 +71,7 @@ import type { ThemeMode } from "./theme-css.js";
71
71
  import { Transcript, type TranscriptWindowing } from "./transcript.js";
72
72
  import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
73
73
  import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
74
+ import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
74
75
 
75
76
  /**
76
77
  * The imperative seam (guuey#210): programmatic send/prefill/focus for
@@ -177,6 +178,20 @@ export interface GuueyChatProps {
177
178
  * transport). Fired after the transcript record moves.
178
179
  */
179
180
  onHitlAnswer?: (answer: AgHitlAnswer, ask: AgPausedAsk) => void;
181
+ /**
182
+ * The OAuth "authorize this server" arm (guuey#178): where the broker
183
+ * sends the user back after the provider dance. Defaults to this page's
184
+ * own location (stale return params stripped). Pass a value when the
185
+ * chat lives at a URL that is not where the user should land.
186
+ */
187
+ oauthReturnTo?: string;
188
+ /**
189
+ * How the authorize link is opened. Defaults to `openOAuthAuthorize`
190
+ * (in-place navigation; a new tab when framed). The composite ALSO shows
191
+ * the return notice itself (`useOAuthReturn`) — a host that overrides
192
+ * `open` and lands elsewhere renders its own.
193
+ */
194
+ onOAuthAuthorize?: (href: string, ask: AgPausedAsk) => void;
180
195
  /** R11 action slot (sign-in / retry affordances). */
181
196
  onErrorAction?: (item: ErrorItem) => void;
182
197
  /**
@@ -226,6 +241,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
226
241
  viewProps,
227
242
  onPromptAction,
228
243
  onHitlAnswer,
244
+ oauthReturnTo,
245
+ onOAuthAuthorize,
229
246
  onErrorAction,
230
247
  onReady,
231
248
  onThread,
@@ -388,9 +405,27 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
388
405
  [invoke],
389
406
  );
390
407
 
408
+ // guuey#178: the broker's return notice (`?connected=` / `?error=`), read
409
+ // + stripped off the address bar once on mount.
410
+ const oauthReturn = useOAuthReturn();
411
+
391
412
  const handlePromptAction = useCallback(
392
413
  (item: PromptItem, action: "accept" | "decline" | "dismiss" | { grantModeId: string }) => {
393
414
  if (item.promptKind === "hitl") {
415
+ // The OAuth arm: the answer is the redirect (no pod door, no
416
+ // `onHitlAnswer`); the ledger records the pick and the link opens.
417
+ if (
418
+ oauthPromptAction({
419
+ item,
420
+ action,
421
+ answerHitlPrompt,
422
+ ...(oauthReturnTo !== undefined ? { returnTo: oauthReturnTo } : {}),
423
+ ...(onOAuthAuthorize !== undefined ? { open: onOAuthAuthorize } : {}),
424
+ })
425
+ ) {
426
+ onPromptAction?.(item, action);
427
+ return;
428
+ }
394
429
  const answer = answerHitlPrompt(
395
430
  item.ask,
396
431
  typeof action === "object" ? action : action === "accept" ? "accept" : action,
@@ -404,7 +439,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
404
439
  if (typeof action !== "object") resolvePrompt(item.promptId, PROMPT_STATE[action]);
405
440
  onPromptAction?.(item, action);
406
441
  },
407
- [resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction],
442
+ [resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction, oauthReturnTo, onOAuthAuthorize],
408
443
  );
409
444
 
410
445
  const strings = policy.strings;
@@ -430,6 +465,19 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
430
465
  onViewDiagnosis={onViewDiagnosis}
431
466
  {...(viewProps !== undefined ? { viewProps } : {})}
432
467
  />
468
+ {oauthReturn.notice !== null && (
469
+ <p
470
+ role="status"
471
+ className={`guuey-chat-oauth-notice guuey-chat-oauth-${oauthReturn.notice.kind}`}
472
+ >
473
+ {oauthReturn.notice.kind === "connected"
474
+ ? strings.oauthConnected(oauthReturn.notice.serverName)
475
+ : strings.oauthFailed(oauthReturn.notice.reason)}
476
+ <button type="button" className="guuey-chat-oauth-dismiss" onClick={oauthReturn.dismiss}>
477
+ {strings.promptDismissed}
478
+ </button>
479
+ </p>
480
+ )}
433
481
  <form
434
482
  className="guuey-chat-composer"
435
483
  onSubmit={(e) => {