@saadnvd1/agent-os 0.3.592 → 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 (39) 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/SessionList/RowSubtitle.tsx +1 -0
  10. package/components/SessionList/SessionRowMenu.tsx +12 -1
  11. package/components/SessionList/useDoneAction.ts +14 -0
  12. package/components/SessionList/useResurrectAction.test.ts +96 -0
  13. package/components/Tasks/TaskRow.tsx +18 -0
  14. package/data/done/index.ts +17 -0
  15. package/lib/db/migrations.ts +11 -0
  16. package/lib/db/types.ts +2 -0
  17. package/lib/done/worktree.ts +18 -2
  18. package/lib/orchestrator/act.ts +44 -5
  19. package/lib/orchestrator/brief.ts +2 -1
  20. package/lib/orchestrator/digest.ts +3 -2
  21. package/lib/orchestrator/serve.ts +6 -0
  22. package/lib/orchestrator/shell-guard.ts +2 -1
  23. package/lib/orchestrator/tool-names.ts +1 -0
  24. package/lib/orchestrator/tool-schemas.ts +6 -0
  25. package/lib/orchestrator/tools.ts +3 -1
  26. package/lib/resurrect/find.ts +111 -0
  27. package/lib/resurrect/index.ts +422 -0
  28. package/lib/resurrect/plan.ts +61 -0
  29. package/lib/resurrect/snapshot.ts +229 -0
  30. package/lib/security/demo.ts +1 -0
  31. package/lib/sidebar/paused.ts +8 -1
  32. package/lib/sidebar/row-actions.ts +7 -3
  33. package/lib/sidebar/shelves.ts +7 -1
  34. package/lib/tasks/auto-hold.ts +17 -0
  35. package/lib/tasks/finish.ts +9 -0
  36. package/lib/tasks/pause-hold.ts +1 -1
  37. package/lib/tasks/pause.ts +76 -8
  38. package/lib/tasks/queue.ts +39 -12
  39. package/package.json +1 -1
@@ -58,6 +58,7 @@ interface TaskRow {
58
58
  armed_at: string | null;
59
59
  pause_state: string | null;
60
60
  paused_until: string | null;
61
+ pause_note: string | null;
61
62
  placed_note: string | null;
62
63
  }
63
64
 
@@ -165,7 +166,7 @@ function tasksSection(
165
166
  const tasks = db
166
167
  .prepare(
167
168
  `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_status, s.view,
168
- s.merge_armed_at AS armed_at, s.pause_state, s.paused_until, s.placed_note
169
+ s.merge_armed_at AS armed_at, s.pause_state, s.paused_until, s.pause_note, s.placed_note
169
170
  FROM sessions s JOIN projects p ON p.id = s.project_id
170
171
  WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
171
172
  AND s.task_status = 'running'
@@ -181,7 +182,7 @@ function tasksSection(
181
182
  const gates = gateState(checks.get(t.id), failures.get(t.id));
182
183
  const arm = t.armed_at ? " | armed to merge" : "";
183
184
  const paused = t.pause_state
184
- ? ` | ${t.pause_state}${t.paused_until ? ` until ${t.paused_until} UTC` : t.pause_state === "paused" ? " until resumed" : ""}`
185
+ ? ` | ${t.pause_state}${t.paused_until ? ` until ${t.paused_until} UTC` : t.pause_state === "paused" ? " until resumed" : ""}${t.pause_note ? ` (${t.pause_note})` : ""}`
185
186
  : "";
186
187
  const placed = t.placed_note ? ` | ${t.placed_note}` : "";
187
188
  return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${paused}${placed}${gates ? ` | ${gates}` : ""}`;
@@ -6,6 +6,7 @@ import {
6
6
  archive,
7
7
  done,
8
8
  drop,
9
+ resurrect,
9
10
  moveTaskTo,
10
11
  send,
11
12
  stack,
@@ -64,6 +65,7 @@ const ACTING = new Set<ToolName>([
64
65
  "stack",
65
66
  "land",
66
67
  "drop",
68
+ "resurrect",
67
69
  "move",
68
70
  "approve_plan",
69
71
  "plan_pieces",
@@ -219,6 +221,10 @@ export async function runTool(
219
221
  return done(w, p(tool, raw).session);
220
222
  case "archive":
221
223
  return archive(w, p(tool, raw).session);
224
+ case "resurrect": {
225
+ const a = p(tool, raw);
226
+ return resurrect(w, a.session, a.reason);
227
+ }
222
228
  case "add_project": {
223
229
  const a = p(tool, raw);
224
230
  return addProject(w, a.path, a.name);
@@ -211,7 +211,7 @@ const PULL_MERGE = /(^|\/)pulls\/[^/?\s]+\/merge\/?(\?|$)/;
211
211
 
212
212
  const MERGE_MSG = `Refused: a PR merges only through the gates: ${T.sign_off} (or ${T.arm}) for a task's or an external PR, ${T.land} for a stack. Nothing merges, closes or retargets a PR from the shell.`;
213
213
 
214
- // aos move, done, archive, stop and drop reach a session by name, past the tools'
214
+ // aos move, done, archive, resurrect, stop and drop reach a session by name, past the tools'
215
215
  // Pause check and logging; the tools do the same work.
216
216
  function aosRefusal(args: string[]): string | null {
217
217
  const [sub] = positionals(args);
@@ -219,6 +219,7 @@ function aosRefusal(args: string[]): string | null {
219
219
  move: T.move,
220
220
  done: T.done,
221
221
  archive: T.archive,
222
+ resurrect: T.resurrect,
222
223
  stop: T.stop,
223
224
  };
224
225
  if (sub && tool[sub])
@@ -36,6 +36,7 @@ export const TOOL_NAMES = {
36
36
  stop: name("stop"),
37
37
  done: name("done"),
38
38
  archive: name("archive"),
39
+ resurrect: name("resurrect"),
39
40
  add_project: name("add_project"),
40
41
  note: name("note"),
41
42
  question: name("question"),
@@ -291,6 +291,12 @@ export const TOOL_SHAPES = {
291
291
  stop: { session },
292
292
  done: { session },
293
293
  archive: { session },
294
+ resurrect: {
295
+ session: ref(
296
+ "A dropped, done or archived session: its name, project/name or id (from the decision log, or as Saad named it)"
297
+ ),
298
+ reason: z.string().trim().min(1).max(2000),
299
+ },
294
300
  add_project: {
295
301
  path: z
296
302
  .string()
@@ -88,11 +88,13 @@ const DESCRIPTIONS: Record<ToolName, string> = {
88
88
  pause_task:
89
89
  "Pause a running task for a set time: its agent ends at the end of its current step (now true interrupts the step), its worktree and conversation stay, and it frees its slot so the next queued task can start. It resumes by itself at the time, ahead of new arrivals of the same priority, with whatever was held (CI and review notes, messages) in one continue prompt. Refused for a task that is merging, moving, setting up or waiting on Saad. Give a reason; it is shown with the pause.",
90
90
  resume_task:
91
- "Resume a task you paused (or the scheduler did) now. A pause Saad set is his to lift: this refuses and says so.",
91
+ "Resume a task you paused (or the scheduler did) now: it starts at once, past the running-task limit, unless the machine load holds it (the answer says so). A pause Saad set is his to lift: this refuses and says so.",
92
92
  stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
93
93
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it lands on the sidebar's Done shelf; it no longer archives (use archive for that). Not a rejection.",
94
94
  archive:
95
95
  "Put a finished session away, out of the sidebar (it stays in Archived, and Saad can unarchive it). Nothing is merged or cleaned up: refused while the session is working or has an open PR; done first for finished work.",
96
+ resurrect:
97
+ "Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
96
98
  add_project:
97
99
  "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
98
100
  note: "Add a line to this workspace's decision log; it shows in your chat.",
@@ -0,0 +1,111 @@
1
+ /** Sessions that ended (dropped, done, archived) and can be asked for back. */
2
+
3
+ import { db } from "../db";
4
+ import { resolveRef } from "../bus/resolve";
5
+
6
+ export type EndedKind = "dropped" | "done" | "archived" | "settled";
7
+
8
+ export interface EndedSession {
9
+ id: string;
10
+ name: string;
11
+ projectId: string | null;
12
+ projectName: string | null;
13
+ kind: EndedKind;
14
+ taskStatus: string | null;
15
+ // A plan-only task whose plan was written and never approved.
16
+ planWaiting: boolean;
17
+ endedAt: string;
18
+ }
19
+
20
+ // A workspace's, or one project's, or everyone's (a person's own shell).
21
+ export type EndedScope = { workspaceId: string } | { projectId: string } | null;
22
+
23
+ interface Row {
24
+ id: string;
25
+ name: string;
26
+ tmux_name: string;
27
+ project_id: string | null;
28
+ project_name: string | null;
29
+ task_status: string | null;
30
+ archived_at: string | null;
31
+ plan_phase: string | null;
32
+ ended_at: string;
33
+ }
34
+
35
+ function kindOf(r: Row): EndedKind {
36
+ if (r.task_status === "dropped") return "dropped";
37
+ if (r.archived_at) return "archived";
38
+ return r.task_status === "done" ? "done" : "settled";
39
+ }
40
+
41
+ function rows(scope: EndedScope, limit: number, kind?: EndedKind): Row[] {
42
+ const where = [
43
+ "s.role IS NULL",
44
+ "s.peer_mirror = 0",
45
+ "s.held_by_device IS NULL",
46
+ ];
47
+ const args: (string | number)[] = [];
48
+ if (scope && "workspaceId" in scope) {
49
+ where.push("p.workspace_id = ?");
50
+ args.push(scope.workspaceId);
51
+ } else if (scope) {
52
+ where.push("s.project_id = ?");
53
+ args.push(scope.projectId);
54
+ }
55
+ where.push(
56
+ `(s.archived_at IS NOT NULL OR s.task_status IN ('dropped', 'done')
57
+ OR (s.task_status IS NULL AND s.settled_at IS NOT NULL))`
58
+ );
59
+ if (kind === "dropped") where.push("s.task_status = 'dropped'");
60
+ if (kind === "archived") where.push("s.archived_at IS NOT NULL");
61
+ if (kind === "done")
62
+ where.push("s.task_status = 'done' AND s.archived_at IS NULL");
63
+ return db
64
+ .prepare(
65
+ `SELECT s.id, s.name, s.tmux_name, s.project_id, p.name AS project_name,
66
+ s.task_status, s.archived_at, s.plan_phase,
67
+ COALESCE(s.task_ended_at, s.archived_at, s.settled_at, s.updated_at) AS ended_at
68
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
69
+ WHERE ${where.join(" AND ")}
70
+ ORDER BY ended_at DESC LIMIT ?`
71
+ )
72
+ .all(...args, limit) as Row[];
73
+ }
74
+
75
+ export function endedSessions(
76
+ scope: EndedScope,
77
+ opts: { limit?: number; kind?: EndedKind } = {}
78
+ ): EndedSession[] {
79
+ return rows(scope, opts.limit ?? 50, opts.kind).map((r) => ({
80
+ id: r.id,
81
+ name: r.name,
82
+ projectId: r.project_id,
83
+ projectName: r.project_name,
84
+ kind: kindOf(r),
85
+ taskStatus: r.task_status,
86
+ planWaiting: r.plan_phase === "awaiting",
87
+ endedAt: r.ended_at,
88
+ }));
89
+ }
90
+
91
+ /** An ended session by name, project/name, id or id prefix, within the scope. */
92
+ export function resolveEnded(ref: string, scope: EndedScope): string {
93
+ const all = rows(scope, 500);
94
+ const r = resolveRef(
95
+ ref,
96
+ all.map((s) => ({
97
+ id: s.id,
98
+ name: s.name,
99
+ projectName: s.project_name,
100
+ tmuxName: s.tmux_name,
101
+ previousNames: [],
102
+ }))
103
+ );
104
+ if (!r.ok)
105
+ throw new Error(
106
+ r.reason === "none"
107
+ ? `No dropped, done or archived session "${ref}"${scope ? " here" : ""}.`
108
+ : r.error
109
+ );
110
+ return r.id;
111
+ }
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Resurrect: bring a dropped, done or archived session back with the same id
3
+ * and the same agent conversation. An archived session is unarchived; a plain
4
+ * one on the Done shelf is reopened; a task gets its worktree back (from the
5
+ * snapshot an end saved, or its branch), its status back to what it was (a
6
+ * plan-only task waits for approval again, one with a PR is idle) and a PR
7
+ * that a drop closed is reopened. Everything is checked before anything is
8
+ * changed, and a failure part-way puts the status back. The agent isn't
9
+ * started: a chat resumes on its next message, so a restore costs no tokens.
10
+ */
11
+
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import { db, queries, type Session } from "../db";
15
+ import { getProject } from "../projects";
16
+ import { unarchiveSession } from "../done/archive";
17
+ import { reopenSession } from "../sidebar/shelf-state";
18
+ import { peerSessionLink } from "../hosts/peer-actions";
19
+ import { hostApi, hostLink } from "../hosts/remote-api";
20
+ import { forgetHostTasks, isMirror, unknownOutcome } from "../tasks/remote";
21
+ import { heldRefusal } from "../tasks/held";
22
+ import { expandHome, projectPathFor } from "../tasks/session";
23
+ import { run } from "../tasks/gh";
24
+ import { gitPush } from "../git-exec";
25
+ import {
26
+ isClaudeSessionId,
27
+ readTranscript,
28
+ rewritePaths,
29
+ transcriptPath,
30
+ writeTranscript,
31
+ } from "../tasks/transcript";
32
+ import { buildTaskBrief } from "../tasks/brief";
33
+ import { syncTaskCardInBackground } from "../lumifyhub/task-cards";
34
+ import { allocatePorts } from "../ports";
35
+ import { loadProjectConfig, portBases } from "../project-config";
36
+ import { ensureSessionDatabase } from "../project-config/database";
37
+ import { runInBackground } from "../async-operations";
38
+ import { setupWorktree } from "../env-setup";
39
+ import { launchClaude } from "../agents/launch";
40
+ import { PLAN_FILE, preparePlanDir } from "../models/plan-step";
41
+ import { planFromTranscript } from "./plan";
42
+ import { dropSnapshot, restoreWorktree, type RestoredAs } from "./snapshot";
43
+
44
+ export interface ResurrectOptions {
45
+ // The paired device asking: a task held for another machine's orchestrator
46
+ // is restored only from there.
47
+ device?: string | null;
48
+ }
49
+
50
+ export interface ResurrectOutcome {
51
+ id: string;
52
+ name: string;
53
+ restored: "unarchived" | "reopened" | "task";
54
+ worktree: RestoredAs | "chat-only" | "none";
55
+ prReopened: number | null;
56
+ text: string;
57
+ }
58
+
59
+ const row = (id: string) =>
60
+ queries.getSession(db).get(id) as Session | undefined;
61
+
62
+ const ENDED_TASK = new Set(["dropped", "done"]);
63
+
64
+ // Restores in flight here, so two clicks can't both build a worktree.
65
+ const g = globalThis as unknown as { __agentosResurrecting?: Set<string> };
66
+ const inFlight = (g.__agentosResurrecting ??= new Set());
67
+
68
+ interface PrInfo {
69
+ number: number;
70
+ state: string;
71
+ }
72
+
73
+ // The task's PR as GitHub has it now. Throws when GitHub can't be asked: a
74
+ // merged PR must never be mistaken for an open one.
75
+ async function prState(repo: string, number: number): Promise<PrInfo | null> {
76
+ const out = await run(
77
+ "gh",
78
+ ["pr", "view", String(number), "--json", "number,state"],
79
+ repo
80
+ ).catch((err) => {
81
+ throw new Error(
82
+ `Can't check PR #${number} on GitHub (${(err as Error).message.split("\n")[0]}); try again.`
83
+ );
84
+ });
85
+ try {
86
+ const pr = JSON.parse(out) as PrInfo;
87
+ return typeof pr.state === "string" ? pr : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ export async function resurrectSession(
94
+ id: string,
95
+ opts: ResurrectOptions = {}
96
+ ): Promise<ResurrectOutcome> {
97
+ const s = row(id);
98
+ if (!s) throw new Error("Session not found");
99
+ if (s.role === "orchestrator")
100
+ throw new Error(`${s.name} is an orchestrator; it isn't ended.`);
101
+ const held = opts.device !== undefined && heldRefusal(id, opts.device);
102
+ if (held) throw new Error(held);
103
+ const peer = peerSessionLink(s);
104
+ if (peer)
105
+ throw new Error(`${s.name} runs on ${peer.hostName}: restore it there.`);
106
+ if (isMirror(s)) return onItsMachine(s);
107
+
108
+ const endedTask = !!s.task_status && ENDED_TASK.has(s.task_status);
109
+ if (s.task_status === "merged")
110
+ throw new Error(
111
+ `${s.name} was merged: its work is in the base branch. Start a new task to continue it.`
112
+ );
113
+ if (s.task_status === "running" || s.task_status === "moving")
114
+ throw new Error(`${s.name} is still running; nothing to restore.`);
115
+ if (s.task_status === "moved")
116
+ throw new Error(`${s.name} was moved to another machine; it runs there.`);
117
+
118
+ // Archived work just unarchives; a dropped task is hidden either way, so
119
+ // restoring it is the whole of it.
120
+ if (!endedTask || (s.archived_at && s.task_status !== "dropped")) {
121
+ if (s.archived_at) {
122
+ unarchiveSession(id);
123
+ return outcome(s, "unarchived", `${s.name} is back in the sidebar.`);
124
+ }
125
+ if (s.settled_at) {
126
+ reopenSession(id);
127
+ return outcome(s, "reopened", `${s.name} reopened.`);
128
+ }
129
+ throw new Error(`${s.name} isn't dropped, done or archived.`);
130
+ }
131
+ return restoreTask(s, s.task_status as "dropped" | "done");
132
+ }
133
+
134
+ const outcome = (
135
+ s: Pick<Session, "id" | "name">,
136
+ restored: ResurrectOutcome["restored"],
137
+ text: string,
138
+ extra: Partial<ResurrectOutcome> = {}
139
+ ): ResurrectOutcome => ({
140
+ id: s.id,
141
+ name: s.name,
142
+ restored,
143
+ worktree: "none",
144
+ prReopened: null,
145
+ text,
146
+ ...extra,
147
+ });
148
+
149
+ // A task that runs on a linked machine resumes there; when that machine is
150
+ // gone for good, the task it was moved from is brought back here instead.
151
+ async function onItsMachine(s: Session): Promise<ResurrectOutcome> {
152
+ const link = hostLink(s.host_id);
153
+ if (link) {
154
+ try {
155
+ const res = await hostApi<{ outcome?: Partial<ResurrectOutcome> }>(
156
+ link,
157
+ `/api/sessions/${encodeURIComponent(s.id)}/resurrect`,
158
+ { body: {}, timeout: 120_000 }
159
+ );
160
+ db.prepare(
161
+ `UPDATE sessions SET task_status = 'running' WHERE id = ? AND task_status IN ('dropped', 'done')`
162
+ ).run(s.id);
163
+ forgetHostTasks(link.hostId);
164
+ return outcome(
165
+ s,
166
+ "task",
167
+ `${link.hostName}: ${String(res.outcome?.text ?? `${s.name} restored.`).slice(0, 500)}`
168
+ );
169
+ } catch (err) {
170
+ if (!unknownOutcome(err)) throw err;
171
+ }
172
+ }
173
+ const before = db
174
+ .prepare(
175
+ `SELECT * FROM sessions WHERE host_id = 'local' AND task_status = 'moved'
176
+ AND project_id IS ? AND branch_name IS ? ORDER BY updated_at DESC LIMIT 1`
177
+ )
178
+ .get(s.project_id, s.branch_name) as Session | undefined;
179
+ const where = link?.hostName ?? "its machine";
180
+ if (!before)
181
+ throw new Error(
182
+ `${s.name} ran on ${where}, which can't be reached, and nothing of it was kept here.`
183
+ );
184
+ const here = await restoreTask(before, "moved");
185
+ return {
186
+ ...here,
187
+ text: `${where} can't be reached, so ${here.name} is back here from before it moved: anything it did there that was never pushed is lost. ${here.text}`,
188
+ };
189
+ }
190
+
191
+ async function restoreTask(
192
+ s: Session,
193
+ prev: "dropped" | "done" | "moved"
194
+ ): Promise<ResurrectOutcome> {
195
+ const project = s.project_id ? getProject(s.project_id) : null;
196
+ const repo = project ? projectPathFor(s) : null;
197
+ if (!project || !repo)
198
+ throw new Error(`${s.name}'s project was removed; it can't come back.`);
199
+ if (!fs.existsSync(repo))
200
+ throw new Error(`${s.name}'s project folder ${repo} is gone.`);
201
+ const oldCwd = expandHome(s.worktree_path ?? s.working_directory);
202
+ const claudeId = s.claude_session_id;
203
+ if (claudeId) {
204
+ if (
205
+ !isClaudeSessionId(claudeId) ||
206
+ !fs.existsSync(transcriptPath(oldCwd, claudeId))
207
+ )
208
+ throw new Error(
209
+ `${s.name}'s conversation is gone (no transcript on disk), so it can't be resumed. Start a new task.`
210
+ );
211
+ } else if (
212
+ !db
213
+ .prepare(`SELECT 1 FROM chat_items WHERE session_id = ? LIMIT 1`)
214
+ .get(s.id)
215
+ )
216
+ throw new Error(`${s.name} has no conversation to bring back.`);
217
+ if (s.branch_name) {
218
+ const busy = db
219
+ .prepare(
220
+ `SELECT name FROM sessions WHERE project_id = ? AND branch_name = ? AND id != ?
221
+ AND task_status IN ('running', 'moving') AND host_id = 'local'`
222
+ )
223
+ .get(project.id, s.branch_name, s.id) as { name: string } | undefined;
224
+ if (busy)
225
+ throw new Error(
226
+ `${busy.name} is running on ${s.branch_name} now; finish or drop it first.`
227
+ );
228
+ }
229
+ const pr =
230
+ s.pr_number && s.pr_status !== "merged"
231
+ ? await prState(repo, s.pr_number)
232
+ : null;
233
+ if (s.pr_status === "merged" || pr?.state === "MERGED")
234
+ throw new Error(
235
+ `${s.name}'s PR #${s.pr_number} was merged; start a new task to continue it.`
236
+ );
237
+
238
+ if (inFlight.has(s.id))
239
+ throw new Error(`${s.name} is being restored already.`);
240
+ inFlight.add(s.id);
241
+ const claimed = db
242
+ .prepare(
243
+ `UPDATE sessions SET task_status = 'running', task_ended_at = NULL WHERE id = ? AND task_status = ?`
244
+ )
245
+ .run(s.id, prev).changes;
246
+ if (!claimed) {
247
+ inFlight.delete(s.id);
248
+ throw new Error(`${s.name} is being restored already.`);
249
+ }
250
+ try {
251
+ return await bringBack(s, prev, repo, oldCwd, pr);
252
+ } catch (err) {
253
+ db.prepare(
254
+ `UPDATE sessions SET task_status = ? WHERE id = ? AND task_status = 'running'`
255
+ ).run(prev, s.id);
256
+ throw err;
257
+ } finally {
258
+ inFlight.delete(s.id);
259
+ }
260
+ }
261
+
262
+ // Claude files a conversation under the folder it ran in: a worktree that
263
+ // comes back elsewhere takes its transcript with it.
264
+ async function carryTranscript(
265
+ claudeId: string,
266
+ from: string,
267
+ to: string
268
+ ): Promise<void> {
269
+ if (path.resolve(from) === path.resolve(to)) return;
270
+ const text = await readTranscript(from, claudeId);
271
+ if (text === null) return;
272
+ const home = process.env.HOME ?? "";
273
+ await writeTranscript(
274
+ to,
275
+ claudeId,
276
+ rewritePaths(text, { cwd: from, home }, { cwd: to, home })
277
+ );
278
+ }
279
+
280
+ async function bringBack(
281
+ s: Session,
282
+ prev: "dropped" | "done" | "moved",
283
+ repo: string,
284
+ oldCwd: string,
285
+ pr: PrInfo | null
286
+ ): Promise<ResurrectOutcome> {
287
+ // A plan that was never approved has no commits to lose.
288
+ const planOnly =
289
+ !!s.plan_only &&
290
+ !s.pr_number &&
291
+ (s.plan_phase === "awaiting" || s.plan_phase === "planning");
292
+ const restored = s.branch_name
293
+ ? await restoreWorktree({
294
+ sessionId: s.id,
295
+ repo,
296
+ branch: s.branch_name,
297
+ base: s.base_branch,
298
+ oldPath: s.worktree_path ? expandHome(s.worktree_path) : null,
299
+ allowFresh: planOnly,
300
+ })
301
+ : null;
302
+ const cwd = restored?.path ?? repo;
303
+ if (s.claude_session_id)
304
+ await carryTranscript(s.claude_session_id, oldCwd, cwd);
305
+
306
+ if (restored && s.plan_phase === "awaiting")
307
+ await recoverPlan(s, restored.path, oldCwd);
308
+
309
+ const notes: string[] = [];
310
+ if (!restored) {
311
+ // Nothing to make a worktree from: the conversation alone, in the project.
312
+ db.prepare(
313
+ `UPDATE sessions SET task_status = NULL, task_ended_at = NULL, worktree_path = NULL,
314
+ working_directory = ?, settled_at = NULL, snoozed_until = NULL, snoozed_at = NULL,
315
+ archived_at = NULL, active_at = datetime('now') WHERE id = ?`
316
+ ).run(cwd, s.id);
317
+ notes.push(
318
+ "its branch and worktree were gone, so only the conversation came back, in the project folder"
319
+ );
320
+ } else {
321
+ db.prepare(
322
+ `UPDATE sessions SET worktree_path = ?, working_directory = ?, settled_at = NULL,
323
+ snoozed_until = NULL, snoozed_at = NULL, archived_at = NULL,
324
+ active_at = datetime('now') WHERE id = ?`
325
+ ).run(restored.path, restored.path, s.id);
326
+ if (restored.mode !== "kept") await setUp(s, repo, restored.path);
327
+ if (restored.mode === "fresh")
328
+ notes.push(
329
+ "its branch was gone: a new one from its base (it had no commits)"
330
+ );
331
+ }
332
+
333
+ let reopened: number | null = null;
334
+ if (restored && pr?.state === "CLOSED" && prev === "dropped") {
335
+ try {
336
+ await gitPush(restored.path, ["-u", "origin", s.branch_name!]);
337
+ await run("gh", ["pr", "reopen", String(pr.number)], repo);
338
+ db.prepare(`UPDATE sessions SET pr_status = 'open' WHERE id = ?`).run(
339
+ s.id
340
+ );
341
+ reopened = pr.number;
342
+ } catch (err) {
343
+ notes.push(
344
+ `PR #${pr.number} stays closed (${(err as Error).message.split("\n")[0]})`
345
+ );
346
+ }
347
+ } else if (pr?.state === "CLOSED")
348
+ notes.push(`PR #${pr.number} stays closed: it wasn't closed by a drop`);
349
+
350
+ const after = row(s.id)!;
351
+ if (restored && after.view !== "chat") await relaunch(after, restored.path);
352
+ syncTaskCardInBackground(after, "working", null);
353
+ void dropSnapshot(repo, s.id);
354
+ const text = `${s.name} is back${restored ? ` in ${restored.path}` : ""}${reopened ? `, PR #${reopened} reopened` : ""}${notes.length ? `; ${notes.join("; ")}` : ""}.`;
355
+ return outcome(s, "task", text, {
356
+ worktree: restored?.mode ?? "chat-only",
357
+ prReopened: reopened,
358
+ });
359
+ }
360
+
361
+ // A plan-only task's plan isn't in git: the snapshot has it, otherwise the
362
+ // conversation's own Write of it does.
363
+ async function recoverPlan(
364
+ s: Session,
365
+ worktree: string,
366
+ oldCwd: string
367
+ ): Promise<void> {
368
+ if (fs.existsSync(path.join(worktree, PLAN_FILE))) return;
369
+ if (!s.claude_session_id) return;
370
+ const text = await readTranscript(oldCwd, s.claude_session_id);
371
+ const plan = text && planFromTranscript(text);
372
+ if (!plan) return;
373
+ preparePlanDir(worktree);
374
+ fs.writeFileSync(path.join(worktree, PLAN_FILE), plan);
375
+ }
376
+
377
+ // Ports, its private database and the setup commands, the way a move's
378
+ // arrival does. A failure here leaves the worktree usable.
379
+ async function setUp(
380
+ s: Session,
381
+ repo: string,
382
+ worktree: string
383
+ ): Promise<void> {
384
+ try {
385
+ const { config } = loadProjectConfig(repo);
386
+ const { ports } = await allocatePorts(s.id, portBases(config));
387
+ if (config.database) await ensureSessionDatabase(s.id, config.database);
388
+ runInBackground(
389
+ () =>
390
+ setupWorktree({
391
+ worktreePath: worktree,
392
+ sourcePath: repo,
393
+ ports,
394
+ sessionId: s.id,
395
+ }).then(() => {}),
396
+ `setup-restored-${s.id}`
397
+ );
398
+ } catch (err) {
399
+ console.error(`[resurrect] setup for ${s.id}:`, err);
400
+ }
401
+ }
402
+
403
+ // A terminal task's agent is a process, so it's started again on its
404
+ // conversation. (A chat's worker starts with its next message.)
405
+ async function relaunch(s: Session, cwd: string): Promise<void> {
406
+ await launchClaude({
407
+ sessionId: s.id,
408
+ tmuxName: s.tmux_name,
409
+ cwd,
410
+ model: s.model,
411
+ prompt: s.claude_session_id
412
+ ? "This task was dropped and has been restored. Carry on where you left off."
413
+ : (s.task_prompt ?? ""),
414
+ resume: s.claude_session_id ?? undefined,
415
+ brief: s.branch_name
416
+ ? buildTaskBrief({
417
+ branch: s.branch_name,
418
+ baseBranch: s.base_branch ?? "main",
419
+ })
420
+ : undefined,
421
+ });
422
+ }