@saadnvd1/agent-os 0.3.591 → 0.3.594

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 (56) hide show
  1. package/app/api/bus/resurrect/route.test.ts +47 -0
  2. package/app/api/bus/resurrect/route.ts +36 -0
  3. package/app/api/sessions/[id]/pause/route.ts +5 -2
  4. package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
  5. package/app/api/sessions/[id]/resurrect/route.ts +24 -0
  6. package/app/api/sessions/ended/route.ts +19 -0
  7. package/app/globals.css +11 -0
  8. package/components/Archived/ArchivedRow.tsx +11 -7
  9. package/components/Chat/ChatPanel.tsx +45 -11
  10. package/components/Chat/Messages.test.ts +40 -1
  11. package/components/Chat/Messages.tsx +96 -26
  12. package/components/Chat/PausedBanner.tsx +1 -1
  13. package/components/Chat/PlanCard.tsx +4 -2
  14. package/components/Chat/Undo.tsx +3 -0
  15. package/components/Chat/idle.ts +9 -0
  16. package/components/SessionList/RowSubtitle.tsx +1 -0
  17. package/components/SessionList/SessionRowMenu.tsx +12 -1
  18. package/components/SessionList/useDoneAction.ts +14 -0
  19. package/components/SessionList/useResurrectAction.test.ts +96 -0
  20. package/components/Tasks/TaskRow.tsx +18 -0
  21. package/data/chat/useChat.ts +83 -13
  22. package/data/chat/usePending.ts +70 -0
  23. package/data/done/index.ts +17 -0
  24. package/lib/chat/client-send.ts +8 -1
  25. package/lib/chat/demo.ts +22 -3
  26. package/lib/chat/drivers/claude.ts +15 -2
  27. package/lib/chat/drivers/opencode.ts +1 -0
  28. package/lib/chat/events.ts +7 -2
  29. package/lib/chat/pending.ts +169 -0
  30. package/lib/chat/runner.ts +11 -2
  31. package/lib/chat/socket.ts +12 -1
  32. package/lib/db/migrations.ts +11 -0
  33. package/lib/db/types.ts +2 -0
  34. package/lib/done/worktree.ts +18 -2
  35. package/lib/orchestrator/act.ts +44 -5
  36. package/lib/orchestrator/brief.ts +2 -1
  37. package/lib/orchestrator/digest.ts +3 -2
  38. package/lib/orchestrator/serve.ts +6 -0
  39. package/lib/orchestrator/shell-guard.ts +2 -1
  40. package/lib/orchestrator/tool-names.ts +1 -0
  41. package/lib/orchestrator/tool-schemas.ts +6 -0
  42. package/lib/orchestrator/tools.ts +3 -1
  43. package/lib/resurrect/find.ts +111 -0
  44. package/lib/resurrect/index.ts +422 -0
  45. package/lib/resurrect/plan.ts +61 -0
  46. package/lib/resurrect/snapshot.ts +229 -0
  47. package/lib/security/demo.ts +1 -0
  48. package/lib/sidebar/paused.ts +8 -1
  49. package/lib/sidebar/row-actions.ts +7 -3
  50. package/lib/sidebar/shelves.ts +7 -1
  51. package/lib/tasks/auto-hold.ts +17 -0
  52. package/lib/tasks/finish.ts +9 -0
  53. package/lib/tasks/pause-hold.ts +1 -1
  54. package/lib/tasks/pause.ts +76 -8
  55. package/lib/tasks/queue.ts +39 -12
  56. package/package.json +1 -1
@@ -34,7 +34,11 @@ import { schedulesUiActions } from "@/stores/schedulesUi";
34
34
  import { recipesUiActions } from "@/stores/recipesUi";
35
35
  import { rowGroup } from "@/lib/recipes/chip";
36
36
  import { finishActions } from "@/lib/sidebar/row-actions";
37
- import { useArchiveAction, useDoneAction } from "./useDoneAction";
37
+ import {
38
+ useArchiveAction,
39
+ useDoneAction,
40
+ useResurrectAction,
41
+ } from "./useDoneAction";
38
42
  import { useShelfActions } from "./useShelfActions";
39
43
  import {
40
44
  PAUSE_PRESETS,
@@ -106,6 +110,7 @@ function FinishItems({
106
110
  }) {
107
111
  const markDone = useDoneAction();
108
112
  const archive = useArchiveAction();
113
+ const restore = useResurrectAction();
109
114
  const actions = finishActions(session, plain);
110
115
  return (
111
116
  <>
@@ -115,6 +120,12 @@ function FinishItems({
115
120
  Done
116
121
  </Item>
117
122
  )}
123
+ {actions.includes("restore") && (
124
+ <Item onClick={() => restore(session)}>
125
+ <RotateCcw className={icon} />
126
+ Restore
127
+ </Item>
128
+ )}
118
129
  {actions.includes("archive") && (
119
130
  <Item onClick={() => archive(session)}>
120
131
  <Archive className={icon} />
@@ -3,6 +3,7 @@ import type { Session } from "@/lib/db";
3
3
  import {
4
4
  useArchiveSession,
5
5
  useDoneSession,
6
+ useResurrectSession,
6
7
  useUnarchiveSession,
7
8
  } from "@/data/done";
8
9
  import { useSettleSession } from "@/data/sessions/shelf";
@@ -68,3 +69,16 @@ export function useArchiveAction() {
68
69
  onError: (e) => toast.error(e.message, { duration: 12000 }),
69
70
  });
70
71
  }
72
+
73
+ // Restore from a session's menu: the same conversation, its worktree back.
74
+ export function useResurrectAction() {
75
+ const resurrect = useResurrectSession();
76
+ return (session: Session) => {
77
+ const id = toast.loading(`Restoring ${session.name}…`);
78
+ resurrect.mutate(session.id, {
79
+ onSuccess: (outcome) =>
80
+ toast.success(outcome.text, { id, duration: 8000 }),
81
+ onError: (e) => toast.error(e.message, { id, duration: 12000 }),
82
+ });
83
+ };
84
+ }
@@ -0,0 +1,96 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement, useEffect } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Session } from "@/lib/db/types";
7
+
8
+ const toast = vi.hoisted(() =>
9
+ Object.assign(vi.fn(), {
10
+ loading: vi.fn(() => "t1"),
11
+ success: vi.fn(),
12
+ error: vi.fn(),
13
+ })
14
+ );
15
+ vi.mock("sonner", () => ({ toast }));
16
+
17
+ const { useResurrectAction } = await import("./useDoneAction");
18
+
19
+ (
20
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
21
+ ).IS_REACT_ACT_ENVIRONMENT = true;
22
+
23
+ let root: Root;
24
+ let host: HTMLElement;
25
+ const hook: { current: (s: Session) => void } = { current: () => {} };
26
+ const fetchSpy = vi.fn();
27
+
28
+ function Probe() {
29
+ const restore = useResurrectAction();
30
+ useEffect(() => {
31
+ hook.current = restore;
32
+ });
33
+ return null;
34
+ }
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ vi.stubGlobal("fetch", fetchSpy);
39
+ host = document.createElement("div");
40
+ document.body.append(host);
41
+ root = createRoot(host);
42
+ act(() =>
43
+ root.render(
44
+ createElement(
45
+ QueryClientProvider,
46
+ { client: new QueryClient() },
47
+ createElement(Probe)
48
+ )
49
+ )
50
+ );
51
+ });
52
+ afterEach(() => {
53
+ act(() => root.unmount());
54
+ host.remove();
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ const task = {
59
+ id: "s1",
60
+ name: "Bookmark plan",
61
+ task_status: "done",
62
+ } as Session;
63
+ const respond = (status: number, body: object) =>
64
+ fetchSpy.mockResolvedValue({
65
+ ok: status < 400,
66
+ status,
67
+ json: async () => body,
68
+ });
69
+ const restore = async () => {
70
+ await act(async () => {
71
+ hook.current(task);
72
+ await new Promise((r) => setTimeout(r, 0));
73
+ });
74
+ };
75
+
76
+ describe("Restore from a session's menu", () => {
77
+ it("posts to the resurrect route and shows what came back", async () => {
78
+ respond(200, { outcome: { text: "Bookmark plan is back" } });
79
+ await restore();
80
+ expect(fetchSpy.mock.calls[0][0]).toBe("/api/sessions/s1/resurrect");
81
+ expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: "POST" });
82
+ expect(toast.success).toHaveBeenCalledWith(
83
+ "Bookmark plan is back",
84
+ expect.objectContaining({ id: "t1" })
85
+ );
86
+ });
87
+
88
+ it("shows a refusal's reason", async () => {
89
+ respond(409, { error: "its PR #3 was merged" });
90
+ await restore();
91
+ expect(toast.error).toHaveBeenCalledWith(
92
+ "its PR #3 was merged",
93
+ expect.objectContaining({ id: "t1" })
94
+ );
95
+ });
96
+ });
@@ -5,6 +5,7 @@ import {
5
5
  ChefHat,
6
6
  ExternalLink,
7
7
  GitMerge,
8
+ RotateCcw,
8
9
  KanbanSquare,
9
10
  SquareTerminal,
10
11
  } from "lucide-react";
@@ -12,6 +13,7 @@ import { Button } from "@/components/ui/button";
12
13
  import type { TaskView } from "@/lib/tasks";
13
14
  import { isFinished } from "@/lib/tasks/state";
14
15
  import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
16
+ import { useResurrectSession } from "@/data/done";
15
17
  import { tmuxAttachActions } from "@/stores/tmuxAttach";
16
18
  import { tasksUiActions } from "@/stores/tasksUi";
17
19
  import { recipesUiActions } from "@/stores/recipesUi";
@@ -27,6 +29,7 @@ export function TaskRow({ task }: { task: TaskView }) {
27
29
  const signOff = useSignOffTask();
28
30
  const drop = useDropTask();
29
31
  const arm = useArmTask();
32
+ const restore = useResurrectSession();
30
33
  const [confirmDrop, setConfirmDrop] = useState(false);
31
34
  const [moveError, setMoveError] = useState<string | null>(null);
32
35
  const live = !isFinished(task.state);
@@ -34,6 +37,7 @@ export function TaskRow({ task }: { task: TaskView }) {
34
37
  signOff.error?.message ||
35
38
  drop.error?.message ||
36
39
  arm.error?.message ||
40
+ restore.error?.message ||
37
41
  moveError ||
38
42
  task.hostError;
39
43
  const settingUp = live && task.setup?.status === "running";
@@ -165,6 +169,20 @@ export function TaskRow({ task }: { task: TaskView }) {
165
169
  </div>
166
170
  )}
167
171
  {live && task.state === "plan-ready" && <PlanReview task={task} />}
172
+ {!live && task.state !== "merged" && (
173
+ <div>
174
+ <Button
175
+ size="sm"
176
+ variant="outline"
177
+ className="h-11 md:h-8"
178
+ disabled={restore.isPending}
179
+ onClick={() => restore.mutate(task.id)}
180
+ >
181
+ <RotateCcw className="h-3.5 w-3.5" />
182
+ {restore.isPending ? "Restoring..." : "Restore"}
183
+ </Button>
184
+ </div>
185
+ )}
168
186
  {!live && !task.hostId && (
169
187
  <div>
170
188
  <Button
@@ -23,6 +23,26 @@ import type {
23
23
  import type { SearchResult } from "@/lib/chat/find";
24
24
  import { addBody, touchBody } from "@/lib/chat/bodyCache";
25
25
  import { backgroundFailure, type TaskOutput } from "@/lib/chat/background";
26
+ import {
27
+ NOT_SENT,
28
+ clientId,
29
+ overSends,
30
+ toResend,
31
+ type PendingSend,
32
+ } from "@/lib/chat/pending";
33
+ import { usePending } from "./usePending";
34
+
35
+ // A pending send as the socket's `send`, under its own id.
36
+ const sendFrame = (
37
+ p: Pick<PendingSend, "id" | "text" | "images" | "compact">
38
+ ) =>
39
+ JSON.stringify({
40
+ type: "send",
41
+ text: p.text,
42
+ images: p.images,
43
+ clientId: p.id,
44
+ ...(p.compact && { compact: true }),
45
+ });
26
46
 
27
47
  // The index of the item with `id`, searched from the end: what changes is
28
48
  // almost always the latest.
@@ -180,6 +200,12 @@ export function useChat(
180
200
  onUndoneRef.current = onUndone;
181
201
  });
182
202
  const wsRef = useRef<WebSocket | null>(null);
203
+ const {
204
+ pending,
205
+ pendingRef,
206
+ dispatch: pend,
207
+ restore: restorePending,
208
+ } = usePending(sessionId, connected);
183
209
 
184
210
  useEffect(() => {
185
211
  let closed = false;
@@ -214,7 +240,18 @@ export function useChat(
214
240
  `${protocol}//${window.location.host}/ws/chat?session=${encodeURIComponent(sessionId)}&paged=1`
215
241
  );
216
242
  wsRef.current = ws;
217
- ws.onopen = () => setConnected(true);
243
+ ws.onopen = () => {
244
+ setConnected(true);
245
+ restorePending();
246
+ // Sends the last socket took and may have lost: the server drops
247
+ // any it already has.
248
+ for (const p of overSends(pendingRef.current))
249
+ pend({ type: "failed", id: p.id, error: NOT_SENT });
250
+ for (const p of toResend(pendingRef.current)) {
251
+ ws.send(sendFrame(p));
252
+ pend({ type: "resent", id: p.id, now: Date.now() });
253
+ }
254
+ };
218
255
  ws.onmessage = (event) => {
219
256
  const m = JSON.parse(event.data) as ChatServerMessage;
220
257
  if (m.type === "snapshot") {
@@ -235,8 +272,17 @@ export function useChat(
235
272
  setQueue(m.queue ?? []);
236
273
  setSuggestion(m.suggestion ?? null);
237
274
  setLoadedFor(sessionId);
275
+ pend({
276
+ type: "resolved",
277
+ ids: [...m.items, ...(m.queue ?? [])].map((i) => i.id),
278
+ });
238
279
  } else if (m.type === "queue") {
239
280
  setQueue(m.queue);
281
+ pend({ type: "resolved", ids: m.queue.map((q) => q.id) });
282
+ } else if (m.type === "accepted") {
283
+ pend({ type: "accepted", id: m.id, now: Date.now() });
284
+ } else if (m.type === "send_failed") {
285
+ pend({ type: "failed", id: m.id, error: m.message });
240
286
  } else if (m.type === "suggestion") {
241
287
  setSuggestion(m.text);
242
288
  } else if (m.type === "files") {
@@ -303,6 +349,8 @@ export function useChat(
303
349
  addBody(prev, m.id, m.body, visibleBodies.current)
304
350
  );
305
351
  } else if (m.type === "item") {
352
+ if (m.item.kind === "user")
353
+ pend({ type: "resolved", ids: [m.item.id] });
306
354
  withDeltas(() => {
307
355
  const routed = routeItem(
308
356
  itemsRef.current,
@@ -346,25 +394,44 @@ export function useChat(
346
394
  document.removeEventListener("visibilitychange", onVisible);
347
395
  wsRef.current?.close();
348
396
  };
349
- }, [sessionId, setItems, setEarlierTasks]);
397
+ }, [sessionId, setItems, setEarlierTasks, pend, pendingRef, restorePending]);
350
398
 
351
399
  const queryClient = useQueryClient();
352
400
  const send = useCallback(
353
401
  // `compact`: the reader let a stale chat compact first (lib/chat/stale).
402
+ // It shows as a pending bubble at once, until it's in the chat.
354
403
  (text: string, images?: ChatImage[], compact?: boolean) => {
404
+ const p = { id: clientId(), text, images, compact };
405
+ pend({ type: "add", send: p, now: Date.now() });
355
406
  const ws = wsRef.current;
356
- if (!ws) return;
357
- ws.send(
358
- JSON.stringify({
359
- type: "send",
360
- text,
361
- images,
362
- ...(compact && { compact: true }),
363
- })
364
- );
365
- if (ws.readyState === WebSocket.OPEN) markRunning(queryClient, sessionId);
407
+ if (ws?.readyState !== WebSocket.OPEN) {
408
+ pend({ type: "failed", id: p.id, error: NOT_SENT });
409
+ return;
410
+ }
411
+ ws.send(sendFrame(p));
412
+ markRunning(queryClient, sessionId);
366
413
  },
367
- [queryClient, sessionId]
414
+ [queryClient, sessionId, pend]
415
+ );
416
+
417
+ const retrySend = useCallback(
418
+ (id: string) => {
419
+ const p = pendingRef.current.find((s) => s.id === id);
420
+ const ws = wsRef.current;
421
+ if (!p) return;
422
+ if (ws?.readyState !== WebSocket.OPEN) {
423
+ pend({ type: "failed", id, error: NOT_SENT });
424
+ return;
425
+ }
426
+ ws.send(sendFrame(p));
427
+ pend({ type: "resent", id, now: Date.now() });
428
+ },
429
+ [pend, pendingRef]
430
+ );
431
+
432
+ const dropSend = useCallback(
433
+ (id: string) => pend({ type: "drop", id }),
434
+ [pend]
368
435
  );
369
436
 
370
437
  const interrupt = useCallback(() => {
@@ -549,6 +616,9 @@ export function useChat(
549
616
  loadTaskOutput,
550
617
  taskOutputs,
551
618
  queue,
619
+ pending,
620
+ retrySend,
621
+ dropSend,
552
622
  queueEdit,
553
623
  queueMove,
554
624
  queueDelete,
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ loadPending,
6
+ pendingReducer,
7
+ savePending,
8
+ type PendingAction,
9
+ type PendingSend,
10
+ } from "@/lib/chat/pending";
11
+
12
+ const NONE: PendingSend[] = [];
13
+
14
+ // The composer's sends not yet in the chat (lib/chat/pending), kept in this
15
+ // browser per session. `dispatch` and `restore` are stable and read no
16
+ // render's state, so the socket's handlers can call them.
17
+ export function usePending(sessionId: string, connected: boolean) {
18
+ const [state, setState] = useState({ session: sessionId, list: NONE });
19
+ // Another session's sends never show here, even for the render before
20
+ // its own are restored.
21
+ const pending = state.session === sessionId ? state.list : NONE;
22
+ const ref = useRef<PendingSend[]>(NONE);
23
+ const session = useRef(sessionId);
24
+ const restored = useRef<string | null>(null);
25
+ useEffect(() => {
26
+ session.current = sessionId;
27
+ ref.current = NONE;
28
+ }, [sessionId]);
29
+
30
+ const replace = useCallback((list: PendingSend[]) => {
31
+ ref.current = list;
32
+ setState({ session: session.current, list });
33
+ }, []);
34
+ const dispatch = useCallback(
35
+ (action: PendingAction) => {
36
+ const next = pendingReducer(ref.current, action);
37
+ if (next === ref.current) return;
38
+ replace(next);
39
+ savePending(session.current, next);
40
+ },
41
+ [replace]
42
+ );
43
+ // Once per session, as its socket first opens: what an earlier page left
44
+ // mid-send, to be sent again.
45
+ const restore = useCallback(() => {
46
+ if (restored.current === session.current) return;
47
+ restored.current = session.current;
48
+ const now = Date.now();
49
+ replace(
50
+ loadPending(session.current).map((p) =>
51
+ p.status === "failed"
52
+ ? p
53
+ : { ...p, status: "sending" as const, at: now }
54
+ )
55
+ );
56
+ }, [replace]);
57
+
58
+ // Timeouts only count while connected: a reconnect sends them again.
59
+ const live = connected && pending.some((p) => p.status !== "failed");
60
+ useEffect(() => {
61
+ if (!live) return;
62
+ const timer = setInterval(
63
+ () => dispatch({ type: "tick", now: Date.now() }),
64
+ 1000
65
+ );
66
+ return () => clearInterval(timer);
67
+ }, [live, dispatch]);
68
+
69
+ return { pending, pendingRef: ref, dispatch, restore };
70
+ }
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import type { DoneOutcome } from "@/lib/done";
3
3
  import type { BulkResult, PreviewRow } from "@/lib/done/bulk";
4
4
  import type { ArchivedView } from "@/lib/done/archive";
5
+ import type { ResurrectOutcome } from "@/lib/resurrect";
5
6
  import { sessionKeys } from "../sessions/keys";
6
7
  import { taskKeys } from "../tasks";
7
8
 
@@ -90,6 +91,22 @@ export function useUnarchiveSession() {
90
91
  );
91
92
  }
92
93
 
94
+ // Brings a dropped, done or archived session back (lib/resurrect).
95
+ export function useResurrectSession() {
96
+ return useDoneMutation(
97
+ async (id: string) =>
98
+ (
99
+ await json<{ outcome: ResurrectOutcome }>(
100
+ await post(
101
+ `/api/sessions/${id}/resurrect`,
102
+ undefined,
103
+ DONE_REQUEST_TIMEOUT_MS
104
+ )
105
+ )
106
+ ).outcome
107
+ );
108
+ }
109
+
93
110
  export function useArchivedQuery(workspaceId?: string, enabled = true) {
94
111
  return useQuery({
95
112
  queryKey: archivedKeys.list(workspaceId),
@@ -3,20 +3,27 @@ import type { CompactFirst } from "./stale";
3
3
 
4
4
  // What a browser's send may carry: its text and images, and whether the
5
5
  // reader chose to compact a stale chat first. Who a message is from (`from`,
6
- // `peer`, `origin`) is set by the server alone, never by a client.
6
+ // `peer`, `origin`) is set by the server alone, never by a client. Its own
7
+ // id for the message is kept only in a shape no server id takes.
7
8
  export function clientSend(msg: unknown): {
8
9
  text: string;
9
10
  images?: ChatImage[];
10
11
  compact?: CompactFirst;
12
+ id?: string;
11
13
  } {
12
14
  const m = (msg ?? {}) as {
13
15
  text?: unknown;
14
16
  images?: unknown;
15
17
  compact?: unknown;
18
+ clientId?: unknown;
16
19
  };
17
20
  return {
18
21
  text: typeof m.text === "string" ? m.text : "",
19
22
  images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined,
20
23
  ...(m.compact === true && { compact: "yes" as const }),
24
+ ...(isClientId(m.clientId) && { id: m.clientId }),
21
25
  };
22
26
  }
27
+
28
+ export const isClientId = (id: unknown): id is string =>
29
+ typeof id === "string" && /^user-c-[A-Za-z0-9-]{8,64}$/.test(id);
package/lib/chat/demo.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { randomUUID } from "crypto";
8
+ import { clientSend } from "./client-send";
8
9
  import type { ChatConversation } from "./driver";
9
10
  import type { ChatClientMessage, ChatServerMessage } from "./events";
10
11
  import { demoConversation } from "./drivers/demo";
@@ -37,7 +38,11 @@ function conversation(sessionId: string): ChatConversation {
37
38
  return c;
38
39
  }
39
40
 
40
- export function sendDemoChat(sessionId: string, text: string): void {
41
+ export function sendDemoChat(
42
+ sessionId: string,
43
+ text: string,
44
+ id = `user-${Date.now()}-${randomUUID().slice(0, 5)}`
45
+ ): void {
41
46
  const trimmed = text.trim();
42
47
  if (!trimmed) return;
43
48
  getSession(sessionId);
@@ -45,7 +50,7 @@ export function sendDemoChat(sessionId: string, text: string): void {
45
50
  if (count >= MAX_SENDS) throw new Error(DEMO_REFUSAL);
46
51
  sends.set(sessionId, count + 1);
47
52
  const item = {
48
- id: `user-${Date.now()}-${randomUUID().slice(0, 5)}`,
53
+ id,
49
54
  kind: "user" as const,
50
55
  text: DEMO_VISITOR_TEXT,
51
56
  tagged: true as const,
@@ -84,7 +89,21 @@ export function handleDemoChat(
84
89
  msg: ChatClientMessage,
85
90
  reply: (m: ChatServerMessage) => void
86
91
  ): void {
87
- if (msg.type === "send") return sendDemoChat(sessionId, String(msg.text));
92
+ if (msg.type === "send") {
93
+ const { id } = clientSend(msg);
94
+ if (!id) return sendDemoChat(sessionId, String(msg.text));
95
+ reply({ type: "accepted", id });
96
+ try {
97
+ return sendDemoChat(sessionId, String(msg.text), id);
98
+ } catch (err) {
99
+ reply({
100
+ type: "send_failed",
101
+ id,
102
+ message: err instanceof Error ? err.message : String(err),
103
+ });
104
+ throw err;
105
+ }
106
+ }
88
107
  if (msg.type === "interrupt") {
89
108
  void conversations.get(sessionId)?.interrupt();
90
109
  return;
@@ -183,6 +183,19 @@ export const claudeDriver: ChatDriver = {
183
183
  );
184
184
  let access = options.access;
185
185
  let plan = !options.permissionMode && !!options.plan;
186
+ // The mode the CLI is in. Asking again for the same one waits on the
187
+ // CLI starting up (seconds, on a new worker) for nothing.
188
+ let applied = sdkMode(access, plan);
189
+ const applyMode = async () => {
190
+ const mode = sdkMode(access, plan);
191
+ if (mode === applied) return;
192
+ const before = applied;
193
+ applied = mode;
194
+ await q.setPermissionMode(mode).catch((error: unknown) => {
195
+ if (applied === mode) applied = before;
196
+ throw error;
197
+ });
198
+ };
186
199
  const q = query({
187
200
  prompt: input,
188
201
  options: {
@@ -344,12 +357,12 @@ export const claudeDriver: ChatDriver = {
344
357
  async setAccess(next) {
345
358
  access = next;
346
359
  if (options.permissionMode || plan) return;
347
- await q.setPermissionMode(sdkMode(access, plan));
360
+ await applyMode();
348
361
  },
349
362
  async setPlan(next) {
350
363
  if (options.permissionMode) return;
351
364
  plan = next;
352
- await q.setPermissionMode(sdkMode(access, plan));
365
+ await applyMode();
353
366
  },
354
367
  respond(id, answer) {
355
368
  approvals.respond(id, answer);
@@ -209,6 +209,7 @@ export const openCodeDriver: ChatDriver = {
209
209
  model = openCodeModel(next);
210
210
  },
211
211
  async setAccess(next) {
212
+ if (next === access) return;
212
213
  access = next;
213
214
  await ready;
214
215
  await setRules();
@@ -381,11 +381,16 @@ export type ChatServerMessage =
381
381
  ok: boolean;
382
382
  error?: string;
383
383
  }
384
- | { type: "undone"; from: string; text: string };
384
+ | { type: "undone"; from: string; text: string }
385
+ // A send with a `clientId` was taken in (before any worker is up), or
386
+ // failed. The message itself still comes as an item or in the queue.
387
+ | { type: "accepted"; id: string }
388
+ | { type: "send_failed"; id: string; message: string };
385
389
 
386
390
  // What a browser sends.
387
391
  export type ChatClientMessage =
388
- | { type: "send"; text: string; images?: ChatImage[] }
392
+ // `clientId` (`user-c-…`) becomes the message's id: a resend is a no-op.
393
+ | { type: "send"; text: string; images?: ChatImage[]; clientId?: string }
389
394
  | { type: "queue_edit"; id: string; text: string }
390
395
  | { type: "queue_move"; id: string; by: -1 | 1 }
391
396
  | { type: "queue_delete"; id: string }