@candor-ui/react 1.3.0 → 1.7.0

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 (74) hide show
  1. package/dist/adapters/ag-ui.d.ts +81 -16
  2. package/dist/adapters/ag-ui.js +308 -1
  3. package/dist/adapters/ai-sdk.d.ts +11 -6
  4. package/dist/adapters/ai-sdk.js +18 -1
  5. package/dist/adapters/claude-agent-sdk.d.ts +123 -14
  6. package/dist/adapters/claude-agent-sdk.js +163 -5
  7. package/dist/adapters/claude-agent-server.d.ts +199 -14
  8. package/dist/adapters/claude-agent-server.js +448 -49
  9. package/dist/adapters/use-ag-ui.d.ts +12 -6
  10. package/dist/adapters/use-ag-ui.js +23 -5
  11. package/dist/adapters/use-candor-chat.d.ts +1 -1
  12. package/dist/adapters/use-candor-chat.js +3 -3
  13. package/dist/adapters/use-claude-agent.d.ts +38 -9
  14. package/dist/adapters/use-claude-agent.js +96 -20
  15. package/dist/blocks/copy-field.d.ts +1 -1
  16. package/dist/blocks/copy-field.js +1 -1
  17. package/dist/blocks/degraded-panel.d.ts +5 -5
  18. package/dist/blocks/locale.d.ts +6 -6
  19. package/dist/blocks/locked.d.ts +8 -8
  20. package/dist/blocks/relative-time.d.ts +6 -6
  21. package/dist/blocks/status-dot.d.ts +3 -3
  22. package/dist/blocks/step-list.d.ts +3 -3
  23. package/dist/blocks/streaming-text.d.ts +3 -3
  24. package/dist/chat/agent-avatar.d.ts +4 -4
  25. package/dist/chat/ai-disclosure.d.ts +37 -0
  26. package/dist/chat/ai-disclosure.js +48 -0
  27. package/dist/chat/attachment-preview.d.ts +6 -6
  28. package/dist/chat/attachment-preview.js +2 -2
  29. package/dist/chat/card-registry.js +1 -1
  30. package/dist/chat/citation.d.ts +3 -3
  31. package/dist/chat/citation.js +6 -4
  32. package/dist/chat/composer.d.ts +31 -20
  33. package/dist/chat/composer.js +10 -6
  34. package/dist/chat/context-meter.d.ts +1 -1
  35. package/dist/chat/context-meter.js +1 -1
  36. package/dist/chat/entity-chip.d.ts +3 -3
  37. package/dist/chat/entity-chip.js +1 -1
  38. package/dist/chat/folded-turns.d.ts +11 -6
  39. package/dist/chat/folded-turns.js +2 -2
  40. package/dist/chat/message-actions.d.ts +6 -6
  41. package/dist/chat/message-actions.js +1 -1
  42. package/dist/chat/message-content.d.ts +13 -7
  43. package/dist/chat/message-content.js +10 -5
  44. package/dist/chat/message.d.ts +64 -37
  45. package/dist/chat/message.js +17 -11
  46. package/dist/chat/payload.d.ts +4 -4
  47. package/dist/chat/payload.js +5 -2
  48. package/dist/chat/permission-prompt.d.ts +129 -0
  49. package/dist/chat/permission-prompt.js +144 -0
  50. package/dist/chat/prompt-suggestions.d.ts +4 -4
  51. package/dist/chat/prompt-suggestions.js +1 -1
  52. package/dist/chat/reasoning.d.ts +3 -3
  53. package/dist/chat/reasoning.js +1 -1
  54. package/dist/chat/thread-divider.d.ts +3 -3
  55. package/dist/chat/thread-event.d.ts +10 -5
  56. package/dist/chat/thread-event.js +3 -2
  57. package/dist/chat/thread.d.ts +23 -7
  58. package/dist/chat/thread.js +29 -6
  59. package/dist/chat/tool-call.d.ts +7 -2
  60. package/dist/chat/tool-call.js +8 -3
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.js +2 -0
  63. package/dist/lib/chat.d.ts +53 -38
  64. package/dist/lib/know.d.ts +14 -12
  65. package/dist/lib/render-prop.d.ts +4 -4
  66. package/dist/lib/safe-markdown.d.ts +1 -1
  67. package/dist/lib/status.d.ts +21 -0
  68. package/dist/lib/status.js +17 -0
  69. package/dist/lib/use-shortcut.d.ts +1 -1
  70. package/dist/lib/use-virtual-list.d.ts +6 -6
  71. package/dist/styles.css +4 -1
  72. package/dist/ui/button.d.ts +1 -1
  73. package/dist/ui/button.js +1 -1
  74. package/package.json +1 -1
@@ -1,4 +1,248 @@
1
- const waiting = new Map();
1
+ import { claudePermissionResult, claudeQuestions, } from "./claude-agent-sdk.js";
2
+ /**
3
+ * The default ApprovalStore: this process's memory. Answered and closed requests are forgotten after `keepMs`
4
+ * (10 minutes), so it doesn't grow without bound.
5
+ */
6
+ export function memoryApprovalStore({ keepMs = 10 * 60_000 } = {}) {
7
+ const items = new Map();
8
+ const waiters = new Map();
9
+ const wake = (id) => {
10
+ for (const w of waiters.get(id) ?? [])
11
+ w();
12
+ waiters.delete(id);
13
+ };
14
+ const forget = (id) => setTimeout(() => items.delete(id), keepMs).unref?.();
15
+ const now = () => new Date().toISOString();
16
+ const answer = (id, a) => {
17
+ const item = items.get(id);
18
+ if (!item || item.answer || item.closed)
19
+ return false;
20
+ item.answer = { ...structuredClone(a), at: now() };
21
+ wake(id);
22
+ forget(id);
23
+ return true;
24
+ };
25
+ const store = {
26
+ async put(approval) {
27
+ items.set(approval.id, structuredClone(approval));
28
+ },
29
+ async get(id) {
30
+ const a = items.get(id);
31
+ return a && structuredClone(a);
32
+ },
33
+ async answer(id, a) {
34
+ return answer(id, a);
35
+ },
36
+ async close(id, reason) {
37
+ const a = items.get(id);
38
+ if (!a || a.answer || a.closed)
39
+ return false;
40
+ a.closed = { at: now(), reason };
41
+ wake(id);
42
+ forget(id);
43
+ return true;
44
+ },
45
+ wait(id, signal) {
46
+ return new Promise((resolve) => {
47
+ const settle = () => {
48
+ signal.removeEventListener("abort", onAbort);
49
+ waiters.get(id)?.delete(settle);
50
+ const a = items.get(id);
51
+ resolve(a?.answer ? stripAt(a.answer) : undefined);
52
+ };
53
+ // Stopped before an answer: closed in the same step, so a late answer is refused rather than recorded.
54
+ const onAbort = () => {
55
+ const a = items.get(id);
56
+ if (a && !a.answer && !a.closed) {
57
+ a.closed = { at: now(), reason: "The run was stopped." };
58
+ forget(id);
59
+ }
60
+ settle();
61
+ };
62
+ const a = items.get(id);
63
+ if (!a || a.answer || a.closed)
64
+ return settle();
65
+ if (signal.aborted)
66
+ return onAbort();
67
+ let set = waiters.get(id);
68
+ if (!set)
69
+ waiters.set(id, (set = new Set()));
70
+ set.add(settle);
71
+ signal.addEventListener("abort", onAbort, { once: true });
72
+ });
73
+ },
74
+ async pending(runId) {
75
+ return [...items.values()].filter((a) => !a.answer && !a.closed && (runId === undefined || a.runId === runId)).map((a) => structuredClone(a));
76
+ },
77
+ };
78
+ synchronous.set(store, { get: (id) => items.get(id), answer });
79
+ return store;
80
+ }
81
+ /** The memory store's own synchronous calls, so the 1.4 answer function can stay synchronous. */
82
+ const synchronous = new WeakMap();
83
+ const stripAt = (stored) => {
84
+ const answer = { ...stored };
85
+ delete answer.at;
86
+ return answer;
87
+ };
88
+ /** A `wait` for stores that can't push: reads the request every `intervalMs` (1s) until it's answered or closed. */
89
+ export async function waitByPolling(get, id, signal, intervalMs = 1000) {
90
+ while (!signal.aborted) {
91
+ const a = await get(id);
92
+ if (!a || a.closed)
93
+ return undefined;
94
+ if (a.answer)
95
+ return stripAt(a.answer);
96
+ await new Promise((resolve) => {
97
+ const timer = setTimeout(done, intervalMs);
98
+ function done() {
99
+ clearTimeout(timer);
100
+ signal.removeEventListener("abort", done);
101
+ resolve();
102
+ }
103
+ signal.addEventListener("abort", done, { once: true });
104
+ });
105
+ }
106
+ return undefined;
107
+ }
108
+ /** The store streamClaudeAgent and answerClaudeAgentApproval use when you don't pass one. */
109
+ const defaultApprovals = memoryApprovalStore();
110
+ /**
111
+ * A canUseTool that keeps each request in an ApprovalStore and waits for its answer from wherever it's given: your
112
+ * route, a queue, a workflow signal (`approvals.answer(id, { approved: true })`). Use it on its own when your agent
113
+ * runs somewhere other than streamClaudeAgent. When the SDK stops waiting, the request is closed, so a late answer
114
+ * is refused rather than lost.
115
+ */
116
+ export function claudeCanUseTool({ approvals = defaultApprovals, emit, owner, runId, mode, track, editable, approvalTimeoutMs }) {
117
+ return async (tool, input, request) => {
118
+ const id = crypto.randomUUID();
119
+ const questions = tool === "AskUserQuestion" ? claudeQuestions(input) : undefined;
120
+ const approval = {
121
+ id,
122
+ toolUseId: request.toolUseID,
123
+ tool,
124
+ input,
125
+ title: request.title,
126
+ description: request.description,
127
+ reason: request.decisionReason,
128
+ blockedPath: request.blockedPath,
129
+ defaultToNo: request.defaultToNo,
130
+ displayName: request.displayName,
131
+ // A question has no rule to remember; and a request that mustn't offer one gets none.
132
+ suggestions: questions || request.suppressAlwaysAllowRule ? undefined : request.suggestions,
133
+ noAlwaysAllow: request.suppressAlwaysAllowRule || undefined,
134
+ mode: mode?.(),
135
+ questions,
136
+ agentId: request.agentID,
137
+ mcpServer: request.mcpServer,
138
+ // A question's answers aren't an edit; otherwise the server's policy decides, and the browser can't widen it.
139
+ editable: questions ? false : editable ? editable(tool, input) : true,
140
+ };
141
+ await approvals.put({ id, runId, owner, request: approval, createdAt: new Date().toISOString() });
142
+ track?.add(id);
143
+ emit({ type: "candor_approval", ...approval });
144
+ const timer = approvalTimeoutMs ? setTimeout(() => void approvals.close(id, EXPIRED), approvalTimeoutMs) : undefined;
145
+ const answer = await approvals.wait(id, request.signal);
146
+ clearTimeout(timer);
147
+ track?.delete(id);
148
+ if (!answer || request.signal.aborted) {
149
+ const stored = await approvals.get(id);
150
+ const reason = request.signal.aborted
151
+ ? stored?.answer
152
+ ? "The run stopped before the answer could be used."
153
+ : "The run was stopped."
154
+ : (stored?.closed?.reason ?? "The run ended.");
155
+ await approvals.close(id, reason);
156
+ emit({ type: "candor_approval_closed", id, approved: false, reason, expired: reason === EXPIRED || undefined });
157
+ return { behavior: "deny", message: reason, interrupt: request.signal.aborted };
158
+ }
159
+ const edited = answer.approved && answer.updatedInput !== undefined;
160
+ emit({
161
+ type: "candor_approval_closed",
162
+ id,
163
+ approved: answer.approved,
164
+ reason: answer.reason,
165
+ edited: edited || undefined,
166
+ // What actually ran, so the tool card and a replay show it rather than what the agent proposed.
167
+ input: edited ? answer.updatedInput : undefined,
168
+ kept: answer.alwaysAllow?.length || undefined,
169
+ });
170
+ return claudePermissionResult(approval, answer);
171
+ };
172
+ }
173
+ const EXPIRED = "Nobody answered in time.";
174
+ /**
175
+ * The default ClaudeRunStore: this process's memory. It keeps at most `maxRuns` finished runs (100), forgetting the
176
+ * oldest finished one first; a run still going is never forgotten. Each run keeps at most `maxEvents` events
177
+ * (5,000): past that, partial-text events are no longer kept (the finished message still is), so a re-attached
178
+ * browser sees text arrive a message at a time instead of as it streams.
179
+ */
180
+ export function memoryRunStore({ maxRuns = 100, maxEvents = 5000 } = {}) {
181
+ const runs = new Map();
182
+ const notify = (id) => runs.get(id)?.listeners.forEach((l) => l());
183
+ const forgetFinished = () => {
184
+ // Oldest first: Map keeps insertion order. Runs still going are skipped, so their owner is never lost.
185
+ for (const [id, r] of runs) {
186
+ if (runs.size <= maxRuns)
187
+ return;
188
+ if (r.ended)
189
+ runs.delete(id);
190
+ }
191
+ };
192
+ const start = (id, owner) => {
193
+ const r = runs.get(id);
194
+ if (r) {
195
+ if (owner !== undefined && r.owner === undefined)
196
+ r.owner = owner;
197
+ return;
198
+ }
199
+ runs.set(id, { events: [], ended: false, owner, listeners: new Set() });
200
+ forgetFinished();
201
+ };
202
+ return {
203
+ async start(id, owner) {
204
+ start(id, owner);
205
+ },
206
+ async append(id, event) {
207
+ const r = runs.get(id);
208
+ if (!r || r.ended)
209
+ return;
210
+ if (r.events.length >= maxEvents && event.type === "stream_event")
211
+ return;
212
+ r.events.push(event);
213
+ notify(id);
214
+ },
215
+ async end(id) {
216
+ const r = runs.get(id);
217
+ if (!r)
218
+ return;
219
+ r.ended = true;
220
+ notify(id);
221
+ forgetFinished();
222
+ },
223
+ async read(id, from) {
224
+ const r = runs.get(id);
225
+ return r && { events: r.events.slice(Math.max(0, from)), ended: r.ended };
226
+ },
227
+ subscribe(id, listener) {
228
+ const r = runs.get(id);
229
+ if (!r)
230
+ return () => { };
231
+ r.listeners.add(listener);
232
+ return () => r.listeners.delete(listener);
233
+ },
234
+ async ownerOf(id) {
235
+ return runs.get(id)?.owner;
236
+ },
237
+ async setOwner(id, owner) {
238
+ start(id, owner);
239
+ },
240
+ };
241
+ }
242
+ /* ---------------------------------------------------------------- streaming a run */
243
+ /** Runs started by this process, so stop and the mode switch can reach them. */
244
+ const live = new Map();
245
+ const permits = (policy, mode) => (!policy ? true : typeof policy === "function" ? policy(mode) : policy.includes(mode));
2
246
  /**
3
247
  * Run an agent and stream it to useClaudeAgent.
4
248
  *
@@ -12,52 +256,61 @@ const waiting = new Map();
12
256
  * )
13
257
  * }
14
258
  */
15
- export function streamClaudeAgent(run, { signal, owner, describeError, onMessage } = {}) {
259
+ export function streamClaudeAgent(run, { signal, owner, describeError, onMessage, onEvent, approvals = defaultApprovals, runs, runId = crypto.randomUUID(), detach = false, prompt, editable, approvalTimeoutMs, modes } = {}) {
16
260
  const encoder = new TextEncoder();
17
261
  const abortController = new AbortController();
18
262
  const mine = new Set();
263
+ let mode;
19
264
  let send = () => { };
20
- const canUseTool = (tool, input, request) => new Promise((resolve) => {
21
- const id = crypto.randomUUID();
22
- const answer = (approved, reason, interrupt) => {
23
- if (!waiting.delete(id))
24
- return;
25
- mine.delete(id);
26
- send({ type: "candor_approval_closed", id, approved, reason });
27
- resolve(approved
28
- ? { behavior: "allow", updatedInput: input }
29
- : { behavior: "deny", message: reason ? `The user declined: ${reason}` : "The user declined this action.", interrupt });
30
- };
31
- waiting.set(id, { owner, answer });
32
- mine.add(id);
33
- request.signal.addEventListener("abort", () => answer(false, "The run was stopped.", true), { once: true });
34
- send({
35
- type: "candor_approval",
36
- id,
37
- toolUseId: request.toolUseID,
38
- tool,
39
- input,
40
- title: request.title,
41
- description: request.description,
42
- reason: request.decisionReason,
43
- blockedPath: request.blockedPath,
44
- defaultToNo: request.defaultToNo,
45
- });
46
- });
265
+ // Keeps the record in the order the browser saw it, even when the stores are slow.
266
+ let recorded = Promise.resolve();
267
+ const record = (event) => {
268
+ recorded = recorded.then(async () => {
269
+ await runs?.append(runId, event);
270
+ await onEvent?.(event);
271
+ }).catch((error) => console.error("Recording a Claude agent event failed:", error));
272
+ };
273
+ const emit = (event) => {
274
+ if (event.type === "candor_mode")
275
+ mode = event.mode;
276
+ else if (event.type === "system" && (event.subtype === "init" || event.subtype === "status") && event.permissionMode)
277
+ mode = event.permissionMode;
278
+ record(event);
279
+ send(event);
280
+ };
281
+ const canUseTool = claudeCanUseTool({ approvals, emit, owner, runId, mode: () => mode, track: mine, editable, approvalTimeoutMs });
47
282
  const body = new ReadableStream({
48
283
  async start(controller) {
49
284
  let open = true;
50
285
  send = (event) => {
51
- if (open)
286
+ if (!open)
287
+ return;
288
+ try {
52
289
  controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
290
+ }
291
+ catch {
292
+ // The browser went away mid-run and the run carries on (detach): later events go only to the run store.
293
+ open = false;
294
+ }
53
295
  };
54
296
  const stop = () => abortController.abort();
55
- signal?.addEventListener("abort", stop, { once: true });
297
+ if (!detach)
298
+ signal?.addEventListener("abort", stop, { once: true });
299
+ if (runs?.start)
300
+ await runs.start(runId, owner);
301
+ else if (owner !== undefined)
302
+ await runs?.setOwner?.(runId, owner);
303
+ if (prompt)
304
+ record({ type: "candor_user", ...prompt });
305
+ const entry = { owner, abort: abortController, emit, query: undefined, modes };
306
+ live.set(runId, entry);
56
307
  try {
57
- for await (const message of run({ canUseTool, abortController, emit: (event) => send(event) })) {
308
+ const query = run({ canUseTool, abortController, emit, runId });
309
+ entry.query = query;
310
+ for await (const message of query) {
58
311
  // Awaited, so bookkeeping such as recording spend finishes before the next message is handled.
59
312
  await onMessage?.(message);
60
- send(message);
313
+ emit(message);
61
314
  // Stopped (the browser went away, or onMessage or a tool aborted the run): nothing more is sent.
62
315
  if (abortController.signal.aborted)
63
316
  break;
@@ -66,39 +319,185 @@ export function streamClaudeAgent(run, { signal, owner, describeError, onMessage
66
319
  catch (error) {
67
320
  if (!abortController.signal.aborted) {
68
321
  console.error("Claude agent run failed:", error);
69
- send({ type: "candor_error", message: describeError?.(error) ?? "The agent stopped because of an error on the server." });
322
+ emit({ type: "candor_error", message: describeError?.(error) ?? "The agent stopped because of an error on the server." });
70
323
  }
71
324
  }
72
325
  finally {
326
+ live.delete(runId);
73
327
  // Nobody can answer once the run is over.
74
- for (const id of [...mine])
75
- waiting.get(id)?.answer(false, "The run ended.");
328
+ await Promise.all([...mine].map((id) => approvals.close(id, "The run ended.")));
329
+ await recorded;
330
+ await runs?.end(runId);
76
331
  open = false;
77
332
  signal?.removeEventListener("abort", stop);
78
- controller.close();
333
+ try {
334
+ controller.close();
335
+ }
336
+ catch {
337
+ /* already closed by the browser going away */
338
+ }
79
339
  }
80
340
  },
81
341
  cancel() {
82
- abortController.abort();
342
+ // The browser stopped reading. With detach, the run goes on and the browser can re-attach.
343
+ if (!detach)
344
+ abortController.abort();
345
+ },
346
+ });
347
+ return new Response(body, {
348
+ headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no", "x-candor-run-id": runId },
349
+ });
350
+ }
351
+ /**
352
+ * Stream a run's events again, from index `from`, then live until it ends: for a browser re-attaching after a
353
+ * reload. 404 when the store doesn't know the run, 403 when `owner` isn't who started it.
354
+ *
355
+ * // app/api/agent/[runId]/route.ts
356
+ * export async function GET(request: Request, { params }) {
357
+ * const from = Number(new URL(request.url).searchParams.get("from") ?? 0)
358
+ * return attachClaudeAgentRun(runs, (await params).runId, { from, owner: userId, signal: request.signal })
359
+ * }
360
+ */
361
+ export async function attachClaudeAgentRun(runs, runId, { from = 0, owner, signal, pollMs = 500 } = {}) {
362
+ const first = await runs.read(runId, 0);
363
+ if (!first)
364
+ return Response.json({ error: "No such run." }, { status: 404 });
365
+ const startedBy = await runs.ownerOf?.(runId);
366
+ if (startedBy !== undefined && startedBy !== owner)
367
+ return Response.json({ error: "Not your run." }, { status: 403 });
368
+ const encoder = new TextEncoder();
369
+ let next = Number.isSafeInteger(from) && from > 0 ? from : 0;
370
+ const body = new ReadableStream({
371
+ async start(controller) {
372
+ let wake = () => { };
373
+ // Set by every notification, so one that lands while a read is under way isn't lost to the wait after it.
374
+ let dirty = false;
375
+ const unsubscribe = runs.subscribe?.(runId, () => {
376
+ dirty = true;
377
+ wake();
378
+ });
379
+ try {
380
+ while (!signal?.aborted) {
381
+ dirty = false;
382
+ const page = await runs.read(runId, next);
383
+ if (!page)
384
+ break;
385
+ for (const event of page.events)
386
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
387
+ next += page.events.length;
388
+ if (page.ended)
389
+ break;
390
+ if (dirty)
391
+ continue;
392
+ await new Promise((resolve) => {
393
+ const timer = setTimeout(done, unsubscribe ? 30_000 : pollMs);
394
+ function done() {
395
+ clearTimeout(timer);
396
+ signal?.removeEventListener("abort", done);
397
+ resolve();
398
+ }
399
+ wake = done;
400
+ signal?.addEventListener("abort", done, { once: true });
401
+ });
402
+ }
403
+ }
404
+ catch {
405
+ /* the browser went away */
406
+ }
407
+ finally {
408
+ unsubscribe?.();
409
+ try {
410
+ controller.close();
411
+ }
412
+ catch {
413
+ /* already closed */
414
+ }
415
+ }
83
416
  },
84
417
  });
85
- return new Response(body, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" } });
418
+ return new Response(body, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no", "x-candor-run-id": runId } });
419
+ }
420
+ /** Stop a run this process is running. False when it isn't running here, or `owner` didn't start it. */
421
+ export function stopClaudeAgentRun(runId, owner) {
422
+ const run = live.get(runId);
423
+ if (!run || (run.owner !== undefined && run.owner !== owner))
424
+ return false;
425
+ run.abort.abort();
426
+ return true;
427
+ }
428
+ /**
429
+ * Change a running agent's permission mode (the SDK's `setPermissionMode`), and tell the browser. Only for a query
430
+ * in streaming input mode, as the SDK requires. False when the run isn't here, `owner` didn't start it, the mode
431
+ * isn't allowed, or the SDK refused. The route is where policy is enforced: a mode PermissionMode shows as locked
432
+ * must be left out of `allowed` (or the run's `modes`) too, since anyone can post to the route directly.
433
+ */
434
+ export async function setClaudeAgentPermissionMode(runId, mode, owner, { allowed } = {}) {
435
+ const run = live.get(runId);
436
+ if (!run?.query || (run.owner !== undefined && run.owner !== owner))
437
+ return false;
438
+ if (!permits(run.modes, mode) || !permits(allowed, mode))
439
+ return false;
440
+ try {
441
+ await run.query.setPermissionMode(mode);
442
+ }
443
+ catch {
444
+ return false;
445
+ }
446
+ run.emit({ type: "candor_mode", mode });
447
+ return true;
86
448
  }
449
+ /* ---------------------------------------------------------------- answering */
450
+ const isPlainObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
87
451
  /**
88
- * Answer a waiting permission request. Returns false when there's no such
89
- * request (already answered, the run ended, or it ran in another process) or
90
- * `owner` doesn't match the run's owner.
452
+ * Check an answer from the browser against the request it answers, and record it. False when there's no such
453
+ * request, it's already settled, `owner` isn't the run's owner, or the answer doesn't fit the request (an edited
454
+ * input that isn't an object, answers to a request that isn't a question, a suggestion that wasn't offered).
91
455
  *
92
456
  * // app/api/agent/approve/route.ts
93
457
  * export async function POST(request: Request) {
94
- * const { id, approved, reason } = await request.json()
95
- * return Response.json({ ok: answerClaudeAgentApproval(id, approved, reason, userId) })
458
+ * const { id, ...answer } = await request.json()
459
+ * return Response.json({ ok: await answerClaudeApproval(approvals, id, answer, { owner: userId }) })
96
460
  * }
97
461
  */
462
+ export async function answerClaudeApproval(approvals, id, answer, { owner } = {}) {
463
+ const clean = checkAnswer(await approvals.get(id), answer, owner);
464
+ return clean ? approvals.answer(id, clean) : false;
465
+ }
466
+ /** The answer as it will be recorded, or null when it doesn't fit the request or the viewer may not give it. */
467
+ function checkAnswer(stored, answer, owner) {
468
+ if (!stored || stored.answer || stored.closed)
469
+ return null;
470
+ if (stored.owner !== undefined && stored.owner !== owner)
471
+ return null;
472
+ if (typeof answer?.approved !== "boolean")
473
+ return null;
474
+ const r = stored.request;
475
+ if (answer.updatedInput !== undefined && (!isPlainObject(answer.updatedInput) || r.questions || r.editable === false))
476
+ return null;
477
+ if (answer.answers !== undefined && (!r.questions || !Array.isArray(answer.answers)))
478
+ return null;
479
+ if (answer.alwaysAllow !== undefined) {
480
+ const offered = r.noAlwaysAllow ? 0 : (r.suggestions?.length ?? 0);
481
+ if (!Array.isArray(answer.alwaysAllow) || answer.alwaysAllow.some((i) => !Number.isInteger(i) || i < 0 || i >= offered))
482
+ return null;
483
+ }
484
+ if (answer.reason != null && typeof answer.reason !== "string")
485
+ return null;
486
+ return {
487
+ approved: answer.approved,
488
+ ...(answer.reason && { reason: answer.reason.slice(0, 2000) }),
489
+ ...(answer.approved && answer.updatedInput && { updatedInput: answer.updatedInput }),
490
+ ...(answer.approved && answer.answers && { answers: answer.answers }),
491
+ ...(answer.approved && answer.alwaysAllow?.length && { alwaysAllow: answer.alwaysAllow }),
492
+ };
493
+ }
494
+ /**
495
+ * Answer a waiting permission request in the default, in-memory store. Kept from 1.4; answerClaudeApproval takes a
496
+ * store and the full answer (an edited input, answers to questions, "always allow"). Returns false when there's no
497
+ * such request, or `owner` doesn't match the run's owner.
498
+ */
98
499
  export function answerClaudeAgentApproval(id, approved, reason, owner) {
99
- const request = waiting.get(id);
100
- if (!request || (request.owner !== undefined && request.owner !== owner))
101
- return false;
102
- request.answer(approved, reason);
103
- return true;
500
+ const memory = synchronous.get(defaultApprovals);
501
+ const clean = checkAnswer(memory.get(id), { approved, reason }, owner);
502
+ return clean ? memory.answer(id, clean) : false;
104
503
  }
@@ -5,16 +5,16 @@ import { type FromAgUiOptions } from "./ag-ui.js";
5
5
  export type UseAgUiOptions = Omit<FromAgUiOptions, "running" | "now"> & {
6
6
  /** Your AG-UI endpoint (LangGraph, Mastra, CrewAI, Google ADK, Pydantic AI…). It receives a RunAgentInput and streams events back. */
7
7
  url: string;
8
- headers?: Record<string, string>;
8
+ headers?: Record<string, string> | undefined;
9
9
  /** Replaces the global fetch, e.g. to add auth or to play a script in tests and demos. */
10
- fetch?: typeof fetch;
10
+ fetch?: typeof fetch | undefined;
11
11
  /** Continue an existing thread instead of starting a new one. */
12
- threadId?: string;
12
+ threadId?: string | undefined;
13
13
  /** Sent with every run as `forwardedProps`, next to the `references` the person mentioned. */
14
- forwardedProps?: Record<string, unknown>;
14
+ forwardedProps?: Record<string, unknown> | undefined;
15
15
  /** Tools and context for every run, as AG-UI defines them. Candor doesn't run frontend tools for you. */
16
- tools?: RunAgentParameters["tools"];
17
- context?: RunAgentParameters["context"];
16
+ tools?: RunAgentParameters["tools"] | undefined;
17
+ context?: RunAgentParameters["context"] | undefined;
18
18
  };
19
19
  /**
20
20
  * Wires an AG-UI agent to Candor: messages for <Thread>/<Message>, props for
@@ -41,6 +41,12 @@ export declare function useAgUi(options: UseAgUiOptions): {
41
41
  /** The request failed; say so, and let the person send again. */
42
42
  error: string | undefined;
43
43
  threadId: string | undefined;
44
+ /** The agent's shared state (STATE_SNAPSHOT/STATE_DELTA); undefined until it sends one. */
45
+ state: unknown;
46
+ /** Set, with the reason, when a state update couldn't be applied and `state` may be behind. */
47
+ stateOutOfDate: string | undefined;
48
+ /** The activities the agent reported, in order, with plan steps and agent entries where they map. */
49
+ activities: import("./ag-ui.js").AgUiActivity[];
44
50
  /** Forget the conversation and start a new thread. */
45
51
  reset: () => void;
46
52
  composer: {
@@ -16,7 +16,7 @@ import { fromAgUiEvents } from "./ag-ui.js";
16
16
  * Mentions are sent as structured `forwardedProps.references`, not pasted into the text.
17
17
  */
18
18
  export function useAgUi(options) {
19
- const { url, headers, agent, toolTitle, fetch: fetcher, threadId, forwardedProps, tools, context } = options;
19
+ const { url, headers, agent, agents, toolTitle, fetch: fetcher, threadId, forwardedProps, tools, context, initialState } = options;
20
20
  const [events, setEvents] = React.useState([]);
21
21
  const [running, setRunning] = React.useState(false);
22
22
  const [error, setError] = React.useState();
@@ -27,7 +27,7 @@ export function useAgUi(options) {
27
27
  const run = React.useRef(null);
28
28
  const client = React.useRef(null);
29
29
  const answers = React.useRef(new Map());
30
- const state = React.useMemo(() => fromAgUiEvents(events, { agent, toolTitle, running, now: new Date(now) }), [events, agent, toolTitle, running, now]);
30
+ const state = React.useMemo(() => fromAgUiEvents(events, { agent, agents, toolTitle, running, now: new Date(now), initialState }), [events, agent, agents, toolTitle, running, now, initialState]);
31
31
  const nextExpiry = state.approvals.reduce((soonest, a) => {
32
32
  const at = a.expiresAt ? Date.parse(a.expiresAt) : NaN;
33
33
  return Number.isNaN(at) ? soonest : Math.min(soonest ?? at, at);
@@ -42,7 +42,13 @@ export function useAgUi(options) {
42
42
  React.useEffect(() => () => run.current?.abort(), []);
43
43
  const getClient = React.useCallback(() => {
44
44
  if (!client.current)
45
- client.current = new HttpAgent({ url, headers, fetch: fetcher, threadId });
45
+ client.current = new HttpAgent({
46
+ url,
47
+ ...(headers && { headers }),
48
+ ...(fetcher && { fetch: fetcher }),
49
+ ...(threadId !== undefined && { threadId }),
50
+ ...(initialState !== undefined && { initialState }),
51
+ });
46
52
  const http = client.current;
47
53
  // The latest options win, without losing the thread.
48
54
  http.url = url;
@@ -50,7 +56,7 @@ export function useAgUi(options) {
50
56
  if (fetcher)
51
57
  http.fetch = fetcher;
52
58
  return http;
53
- }, [url, headers, fetcher, threadId]);
59
+ }, [url, headers, fetcher, threadId, initialState]);
54
60
  const start = React.useCallback(async (http, params, before) => {
55
61
  const controller = new AbortController();
56
62
  run.current = controller;
@@ -66,7 +72,13 @@ export function useAgUi(options) {
66
72
  setEvents((e) => [...e, ...batch]);
67
73
  };
68
74
  try {
69
- await http.runAgent({ resume: params.resume, tools, context, forwardedProps: { ...forwardedProps, references: params.references ?? [] }, abortController: controller }, {
75
+ await http.runAgent({
76
+ ...(params.resume && { resume: params.resume }),
77
+ ...(tools && { tools }),
78
+ ...(context && { context }),
79
+ forwardedProps: { ...forwardedProps, references: params.references ?? [] },
80
+ abortController: controller,
81
+ }, {
70
82
  onEvent: ({ event }) => {
71
83
  pending.events.push(event);
72
84
  pending.frame ||= requestAnimationFrame(flush);
@@ -134,6 +146,12 @@ export function useAgUi(options) {
134
146
  /** The request failed; say so, and let the person send again. */
135
147
  error,
136
148
  threadId: state.threadId,
149
+ /** The agent's shared state (STATE_SNAPSHOT/STATE_DELTA); undefined until it sends one. */
150
+ state: state.state,
151
+ /** Set, with the reason, when a state update couldn't be applied and `state` may be behind. */
152
+ stateOutOfDate: state.stateOutOfDate,
153
+ /** The activities the agent reported, in order, with plan steps and agent entries where they map. */
154
+ activities: state.activities,
137
155
  /** Forget the conversation and start a new thread. */
138
156
  reset: () => {
139
157
  run.current?.abort();
@@ -8,7 +8,7 @@ export type PendingApproval = {
8
8
  toolCallId: string;
9
9
  tool: string;
10
10
  input: unknown;
11
- reason?: string;
11
+ reason?: string | undefined;
12
12
  };
13
13
  /**
14
14
  * Wires a `useChat()` result to Candor: messages for <Thread>/<Message>, props