@saadnvd1/agent-os 0.3.592 → 0.3.595

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 (55) 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/components/Archived/ArchivedRow.tsx +11 -7
  8. package/components/Chat/PausedBanner.tsx +1 -1
  9. package/components/Providers.tsx +5 -1
  10. package/components/SessionList/LiveLine.tsx +1 -1
  11. package/components/SessionList/OrchestratorPinRow.tsx +10 -5
  12. package/components/SessionList/QuickChangeRow.tsx +1 -1
  13. package/components/SessionList/RowParts.tsx +37 -2
  14. package/components/SessionList/RowShell.tsx +6 -2
  15. package/components/SessionList/RowSubtitle.tsx +20 -12
  16. package/components/SessionList/SessionRow.tsx +28 -17
  17. package/components/SessionList/SessionRowMenu.tsx +12 -1
  18. package/components/SessionList/SessionShelves.tsx +40 -8
  19. package/components/SessionList/SidebarExtras.tsx +7 -1
  20. package/components/SessionList/index.tsx +2 -0
  21. package/components/SessionList/useDoneAction.ts +14 -0
  22. package/components/SessionList/useResurrectAction.test.ts +96 -0
  23. package/components/SessionList/useSessionTips.ts +62 -0
  24. package/components/Tasks/TaskRow.tsx +18 -0
  25. package/components/ui/HoverTip.tsx +20 -0
  26. package/data/done/index.ts +17 -0
  27. package/data/presence/index.ts +14 -7
  28. package/lib/db/migrations.ts +11 -0
  29. package/lib/db/types.ts +2 -0
  30. package/lib/done/worktree.ts +18 -2
  31. package/lib/hover-tip.ts +161 -0
  32. package/lib/orchestrator/act.ts +44 -5
  33. package/lib/orchestrator/brief.ts +2 -1
  34. package/lib/orchestrator/digest.ts +3 -2
  35. package/lib/orchestrator/serve.ts +6 -0
  36. package/lib/orchestrator/shell-guard.ts +2 -1
  37. package/lib/orchestrator/tool-names.ts +1 -0
  38. package/lib/orchestrator/tool-schemas.ts +6 -0
  39. package/lib/orchestrator/tools.ts +3 -1
  40. package/lib/resurrect/find.ts +111 -0
  41. package/lib/resurrect/index.ts +422 -0
  42. package/lib/resurrect/plan.ts +61 -0
  43. package/lib/resurrect/snapshot.ts +229 -0
  44. package/lib/security/demo.ts +1 -0
  45. package/lib/sidebar/paused.ts +8 -1
  46. package/lib/sidebar/row-actions.ts +7 -3
  47. package/lib/sidebar/row-badge.ts +40 -0
  48. package/lib/sidebar/row-details.ts +53 -0
  49. package/lib/sidebar/shelves.ts +7 -1
  50. package/lib/tasks/auto-hold.ts +17 -0
  51. package/lib/tasks/finish.ts +9 -0
  52. package/lib/tasks/pause-hold.ts +1 -1
  53. package/lib/tasks/pause.ts +76 -8
  54. package/lib/tasks/queue.ts +39 -12
  55. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { ReactNode } from "react";
4
- import { ChevronDown } from "lucide-react";
4
+ import { ChevronDown, ListTodo, Server, type LucideIcon } from "lucide-react";
5
5
  import {
6
6
  DONE_PAGE,
7
7
  doneLimit,
@@ -16,21 +16,35 @@ import { OrchestratorPinRow, OrchestratorStartRow } from "./OrchestratorPinRow";
16
16
  import { QueuedRow } from "./QueuedRow";
17
17
  import type { QueuedRow as QueuedRowData } from "@/lib/sidebar/queued";
18
18
 
19
- function ShelfLabel({
19
+ // A shelf's header. Only groups that aren't a state (a machine, the line,
20
+ // terminals) carry an icon; a shelf that asks for you counts in amber.
21
+ export function ShelfLabel({
20
22
  label,
21
23
  count,
24
+ icon: Icon,
25
+ accent = false,
22
26
  collapsed,
23
27
  onToggle,
24
28
  }: {
25
29
  label: string;
26
30
  count?: number;
31
+ icon?: LucideIcon;
32
+ accent?: boolean;
27
33
  collapsed?: boolean;
28
34
  onToggle?: () => void;
29
35
  }) {
30
36
  const text = (
31
37
  <>
38
+ {Icon && <Icon aria-hidden className="h-3 w-3 shrink-0" />}
32
39
  {label}
33
- {count !== undefined && ` · ${count}`}
40
+ {count !== undefined && (
41
+ <>
42
+ {" · "}
43
+ <span className={cn(accent && "text-amber-600 dark:text-amber-400")}>
44
+ {count}
45
+ </span>
46
+ </>
47
+ )}
34
48
  {onToggle && (
35
49
  <ChevronDown
36
50
  className={cn(
@@ -63,15 +77,19 @@ function ShelfLabel({
63
77
  function Shelf({
64
78
  label,
65
79
  count,
80
+ icon,
81
+ accent,
66
82
  children,
67
83
  }: {
68
84
  label: string;
69
85
  count?: number;
86
+ icon?: LucideIcon;
87
+ accent?: boolean;
70
88
  children: ReactNode;
71
89
  }) {
72
90
  return (
73
91
  <section aria-label={label}>
74
- <ShelfLabel label={label} count={count} />
92
+ <ShelfLabel label={label} count={count} icon={icon} accent={accent} />
75
93
  {children}
76
94
  </section>
77
95
  );
@@ -80,9 +98,9 @@ function Shelf({
80
98
  // A linked machine's sessions no project here holds, under its name.
81
99
  export function MachineShelves({ groups }: { groups: MachineGroup[] }) {
82
100
  return groups.map((g) => (
83
- <Shelf key={g.hostId} label={g.name} count={g.rows.length}>
101
+ <Shelf key={g.hostId} label={g.name} count={g.rows.length} icon={Server}>
84
102
  {g.rows.map((r) => (
85
- <SessionRow key={r.session.id} row={r} />
103
+ <SessionRow key={r.session.id} row={r} inMachineShelf />
86
104
  ))}
87
105
  </Shelf>
88
106
  ));
@@ -108,6 +126,17 @@ export function SessionShelves({
108
126
  const shown = shelves.done.slice(0, doneLimit(donePages));
109
127
  const hidden = shelves.done.length - shown.length;
110
128
  const summary = doneSummary(shelves.done).text;
129
+ // Live work above, what's resting below a hairline.
130
+ const live =
131
+ shelves.orchestrators.length +
132
+ toStart.length +
133
+ shelves.pinned.length +
134
+ shelves.needsYou.length +
135
+ shelves.working.length +
136
+ shelves.inReview.length +
137
+ queued.length +
138
+ shelves.ongoing.length >
139
+ 0;
111
140
  return (
112
141
  <>
113
142
  {shelves.orchestrators.length + toStart.length > 0 && (
@@ -128,7 +157,7 @@ export function SessionShelves({
128
157
  </Shelf>
129
158
  )}
130
159
  {shelves.needsYou.length > 0 && (
131
- <Shelf label="Needs you" count={shelves.needsYou.length}>
160
+ <Shelf label="Needs you" count={shelves.needsYou.length} accent>
132
161
  {shelves.needsYou.map((r) => (
133
162
  <SessionRow key={r.session.id} row={r} />
134
163
  ))}
@@ -149,7 +178,7 @@ export function SessionShelves({
149
178
  </Shelf>
150
179
  )}
151
180
  {queued.length > 0 && (
152
- <Shelf label="Queued" count={queued.length}>
181
+ <Shelf label="Queued" count={queued.length} icon={ListTodo}>
153
182
  {queued.map((r) => (
154
183
  <QueuedRow key={r.item.id} row={r} />
155
184
  ))}
@@ -162,6 +191,9 @@ export function SessionShelves({
162
191
  ))}
163
192
  </Shelf>
164
193
  )}
194
+ {live && shelves.snoozed.length + shelves.done.length > 0 && (
195
+ <div aria-hidden className="border-border mx-2.5 mt-2.5 border-t" />
196
+ )}
165
197
  {shelves.snoozed.length > 0 && (
166
198
  <section aria-label="Snoozed">
167
199
  <ShelfLabel
@@ -4,7 +4,9 @@ import { DevServerCard } from "@/components/DevServers/DevServerCard";
4
4
  import { ElsewhereTmuxList, TmuxSessionRow } from "@/components/Hosts";
5
5
  import { useDevServersQuery } from "@/data/dev-servers";
6
6
  import { useDiscoveredTmuxQuery } from "@/data/hosts";
7
+ import { SquareTerminal } from "lucide-react";
7
8
  import type { ProjectWithRepositories } from "@/lib/projects";
9
+ import { ShelfLabel } from "./SessionShelves";
8
10
 
9
11
  interface DevServerHandlers {
10
12
  onStop: (id: string) => Promise<void>;
@@ -58,7 +60,11 @@ export function SidebarExtras({
58
60
  )}
59
61
  {tmux.length > 0 && (
60
62
  <section aria-label="Terminals">
61
- <div className={label}>Terminals · {tmux.length}</div>
63
+ <ShelfLabel
64
+ label="Terminals"
65
+ count={tmux.length}
66
+ icon={SquareTerminal}
67
+ />
62
68
  {tmux.map((t) => (
63
69
  <TmuxSessionRow key={`${t.hostId}:${t.name}`} session={t} />
64
70
  ))}
@@ -12,6 +12,7 @@ import type { OrchestratorOverview } from "@/lib/orchestrator/overview";
12
12
  import { useSessionListMutations } from "./hooks/useSessionListMutations";
13
13
  import { useBulkDelete } from "./hooks/useBulkDelete";
14
14
  import { useSidebarData } from "./useSidebarData";
15
+ import { useSessionTips } from "./useSessionTips";
15
16
  import { RowProvider, type RowContextValue } from "./RowContext";
16
17
  import { SidebarHeader } from "./SidebarHeader";
17
18
  import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
@@ -43,6 +44,7 @@ export function SessionList({
43
44
  pinControls,
44
45
  }: SessionListProps) {
45
46
  const data = useSidebarData(sessionStatuses);
47
+ useSessionTips(data);
46
48
  const { shelves, ui, project } = data;
47
49
  const mutations = useSessionListMutations({ onSelectSession: onSelect });
48
50
  const bulkDelete = useBulkDelete();
@@ -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
+ });
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef } from "react";
4
+ import { useHostNames } from "@/data/hosts";
5
+ import type { SidebarRow } from "@/lib/sidebar/shelves";
6
+ import type { SidebarData } from "./useSidebarData";
7
+
8
+ function find(rows: readonly SidebarRow[], id: string): SidebarRow | null {
9
+ for (const row of rows) {
10
+ if (row.session.id === id) return row;
11
+ const nested = find(row.workers, id) ?? find(row.quick ?? [], id);
12
+ if (nested) return nested;
13
+ }
14
+ return null;
15
+ }
16
+
17
+ // A session row's hover tip, built from the latest list only when it
18
+ // opens: nothing runs per row, and registering never re-renders.
19
+ export function useSessionTips(data: SidebarData) {
20
+ const hostNames = useHostNames();
21
+ const latest = useRef({ data, hostNames });
22
+ useLayoutEffect(() => {
23
+ latest.current = { data, hostNames };
24
+ });
25
+ // Loaded after the page, like the tip itself: no first-load cost.
26
+ useEffect(() => {
27
+ let off: (() => void) | undefined;
28
+ let gone = false;
29
+ Promise.all([
30
+ import("@/lib/hover-tip"),
31
+ import("@/lib/sidebar/row-details"),
32
+ ]).then(([{ registerTip }, { rowDetails }]) => {
33
+ if (gone) return;
34
+ off = registerTip("session", (id) => {
35
+ const { data, hostNames } = latest.current;
36
+ const row = find(
37
+ [
38
+ ...Object.values(data.shelves).flat(),
39
+ ...data.machines.flatMap((g) => g.rows),
40
+ ],
41
+ id
42
+ );
43
+ if (!row) return null;
44
+ const s = row.session;
45
+ return rowDetails(row, {
46
+ project: data.projectNames.get(s.project_id ?? ""),
47
+ startedBy: s.started_by
48
+ ? data.orchestratorNames.get(s.started_by)
49
+ : undefined,
50
+ host:
51
+ s.host_id && s.host_id !== "local"
52
+ ? hostNames[s.host_id] || "remote"
53
+ : undefined,
54
+ });
55
+ });
56
+ });
57
+ return () => {
58
+ gone = true;
59
+ off?.();
60
+ };
61
+ }, []);
62
+ }
@@ -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
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ // Mounted once: the app's single hover tooltip (lib/hover-tip.ts), loaded
6
+ // after the page so it costs the first load nothing.
7
+ export function HoverTip() {
8
+ useEffect(() => {
9
+ let off: (() => void) | undefined;
10
+ let gone = false;
11
+ import("@/lib/hover-tip").then(({ installHoverTip }) => {
12
+ if (!gone) off = installHoverTip();
13
+ });
14
+ return () => {
15
+ gone = true;
16
+ off?.();
17
+ };
18
+ }, []);
19
+ return null;
20
+ }
@@ -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),
@@ -1,7 +1,3 @@
1
- import {
2
- startAuthentication,
3
- startRegistration,
4
- } from "@simplewebauthn/browser";
5
1
  import type {
6
2
  AuthenticationResponseJSON,
7
3
  PublicKeyCredentialCreationOptionsJSON,
@@ -30,12 +26,20 @@ export const passkeysHere = () =>
30
26
  export const NO_PASSKEYS_HERE =
31
27
  "Approving this needs a passkey, and passkeys need https or localhost: approve it on the machine running AgentOS, or over Connect.";
32
28
 
29
+ // The passkey library loads only when a passkey is used, fetched alongside
30
+ // the server's options so the prompt waits on nothing extra.
31
+ const webauthn = () => import("@simplewebauthn/browser");
32
+
33
33
  // Adds a passkey on this browser: free for the very first one, otherwise
34
34
  // with a code from a device that has one.
35
35
  export async function registerPasskey(enrollCode?: string): Promise<void> {
36
- const { options } = await post<{
37
- options: PublicKeyCredentialCreationOptionsJSON;
38
- }>("/api/presence/register/options", { enrollCode });
36
+ const [{ startRegistration }, { options }] = await Promise.all([
37
+ webauthn(),
38
+ post<{ options: PublicKeyCredentialCreationOptionsJSON }>(
39
+ "/api/presence/register/options",
40
+ { enrollCode }
41
+ ),
42
+ ]);
39
43
  const response = await startRegistration({ optionsJSON: options });
40
44
  await post("/api/presence/register/verify", { response, enrollCode });
41
45
  }
@@ -55,6 +59,8 @@ export async function provePresence(req: {
55
59
  needsPasskey?: boolean;
56
60
  canBootstrap?: boolean;
57
61
  };
62
+ const lib = webauthn();
63
+ lib.catch(() => {}); // awaited below; an early throw mustn't leave it unhandled
58
64
  let answer = await post<Answer>("/api/presence/options", req);
59
65
  if (answer.needsPasskey) {
60
66
  if (!answer.canBootstrap)
@@ -65,5 +71,6 @@ export async function provePresence(req: {
65
71
  answer = await post<Answer>("/api/presence/options", req);
66
72
  }
67
73
  if (!answer.options) throw new Error("No passkey for this host");
74
+ const { startAuthentication } = await lib;
68
75
  return startAuthentication({ optionsJSON: answer.options });
69
76
  }
@@ -2298,6 +2298,17 @@ export const migrations: Migration[] = [
2298
2298
  `);
2299
2299
  },
2300
2300
  },
2301
+ {
2302
+ key: "20261014T1900_pause_note",
2303
+ up: (db) => {
2304
+ // Why a resume that was asked for hasn't happened yet (waiting for a
2305
+ // slot, held by load), shown on the task row. Additive.
2306
+ const has = (
2307
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
2308
+ ).some((c) => c.name === "pause_note");
2309
+ if (!has) db.exec(`ALTER TABLE sessions ADD COLUMN pause_note TEXT`);
2310
+ },
2311
+ },
2301
2312
  {
2302
2313
  key: "20261014T2000_chat_bookmarks",
2303
2314
  up: (db) => {
package/lib/db/types.ts CHANGED
@@ -92,6 +92,8 @@ export interface Session {
92
92
  pause_state?: "pausing" | "paused" | "resuming" | null;
93
93
  paused_until?: string | null;
94
94
  pause_id?: number | null;
95
+ // Why a requested resume hasn't happened yet.
96
+ pause_note?: string | null;
95
97
  placed_note?: string | null;
96
98
  // Set by done: hidden from the sidebar and the orchestrator, never deleted.
97
99
  archived_at: string | null;
@@ -13,6 +13,7 @@ import { getProject } from "../projects";
13
13
  import { deleteWorktree, isAgentOSWorktree } from "../worktrees";
14
14
  import { removalRunning, removalSettled } from "../worktree-removal";
15
15
  import { describeChanges, unsavedChanges } from "../worktree-placed";
16
+ import { dropSnapshot, snapshotWorktree } from "../resurrect/snapshot";
16
17
 
17
18
  export type WorktreeFate =
18
19
  | { action: "none" }
@@ -100,7 +101,7 @@ export type FatePreview =
100
101
  | { action: "remove"; why: string; repo: string };
101
102
 
102
103
  type Judged = Pick<Session, "worktree_path" | "base_branch"> &
103
- Partial<Pick<Session, "project_id">>;
104
+ Partial<Pick<Session, "project_id" | "id">>;
104
105
 
105
106
  // Gone, or still going: removed by whatever else got to it first.
106
107
  const gone = (merged: MergedAs | null, still = false) => ({
@@ -215,8 +216,23 @@ export async function settleWorktree(
215
216
  merged: MergedAs | null,
216
217
  movedAway = false
217
218
  ): Promise<WorktreeFate> {
219
+ // Unmerged work is saved for a restore while the fate is judged, and kept
220
+ // only if the worktree is really removed.
221
+ const snapshot =
222
+ !merged && !movedAway && session.id && session.worktree_path
223
+ ? snapshotWorktree(session.worktree_path, session.id)
224
+ : null;
218
225
  const fate = await worktreeFate(session, merged, undefined, movedAway);
219
- if (fate.action !== "remove") return fate;
226
+ if (fate.action !== "remove") {
227
+ if (snapshot && fate.action === "kept") {
228
+ const { path } = fate;
229
+ void snapshot.then((made) => {
230
+ if (made) void dropSnapshot(path, session.id!);
231
+ });
232
+ }
233
+ return fate;
234
+ }
235
+ await snapshot;
220
236
  const path = session.worktree_path!;
221
237
  await deleteWorktree(path, fate.repo, true).catch(() => {});
222
238
  if (fs.existsSync(path))