@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.
- package/app/api/bus/resurrect/route.test.ts +47 -0
- package/app/api/bus/resurrect/route.ts +36 -0
- package/app/api/sessions/[id]/pause/route.ts +5 -2
- package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
- package/app/api/sessions/[id]/resurrect/route.ts +24 -0
- package/app/api/sessions/ended/route.ts +19 -0
- package/app/globals.css +11 -0
- package/components/Archived/ArchivedRow.tsx +11 -7
- package/components/Chat/ChatPanel.tsx +45 -11
- package/components/Chat/Messages.test.ts +40 -1
- package/components/Chat/Messages.tsx +96 -26
- package/components/Chat/PausedBanner.tsx +1 -1
- package/components/Chat/PlanCard.tsx +4 -2
- package/components/Chat/Undo.tsx +3 -0
- package/components/Chat/idle.ts +9 -0
- package/components/SessionList/RowSubtitle.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +12 -1
- package/components/SessionList/useDoneAction.ts +14 -0
- package/components/SessionList/useResurrectAction.test.ts +96 -0
- package/components/Tasks/TaskRow.tsx +18 -0
- package/data/chat/useChat.ts +83 -13
- package/data/chat/usePending.ts +70 -0
- package/data/done/index.ts +17 -0
- package/lib/chat/client-send.ts +8 -1
- package/lib/chat/demo.ts +22 -3
- package/lib/chat/drivers/claude.ts +15 -2
- package/lib/chat/drivers/opencode.ts +1 -0
- package/lib/chat/events.ts +7 -2
- package/lib/chat/pending.ts +169 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/socket.ts +12 -1
- package/lib/db/migrations.ts +11 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +18 -2
- package/lib/orchestrator/act.ts +44 -5
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +6 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/resurrect/find.ts +111 -0
- package/lib/resurrect/index.ts +422 -0
- package/lib/resurrect/plan.ts +61 -0
- package/lib/resurrect/snapshot.ts +229 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sidebar/paused.ts +8 -1
- package/lib/sidebar/row-actions.ts +7 -3
- package/lib/sidebar/shelves.ts +7 -1
- package/lib/tasks/auto-hold.ts +17 -0
- package/lib/tasks/finish.ts +9 -0
- package/lib/tasks/pause-hold.ts +1 -1
- package/lib/tasks/pause.ts +76 -8
- package/lib/tasks/queue.ts +39 -12
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plan-only task's plan file lives in its worktree and in git nowhere, so a
|
|
3
|
+
* worktree that's gone takes it along. The conversation still has it: the
|
|
4
|
+
* Write that made it and the Edits after. Replays them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PLAN_FILE } from "../models/plan-step";
|
|
8
|
+
|
|
9
|
+
interface ToolUse {
|
|
10
|
+
name?: string;
|
|
11
|
+
input?: {
|
|
12
|
+
file_path?: unknown;
|
|
13
|
+
content?: unknown;
|
|
14
|
+
old_string?: unknown;
|
|
15
|
+
new_string?: unknown;
|
|
16
|
+
replace_all?: unknown;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function* toolUses(jsonl: string): Generator<ToolUse> {
|
|
21
|
+
for (const line of jsonl.split("\n")) {
|
|
22
|
+
if (!line.includes("tool_use")) continue;
|
|
23
|
+
let entry: { message?: { content?: unknown } };
|
|
24
|
+
try {
|
|
25
|
+
entry = JSON.parse(line);
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const content = entry.message?.content;
|
|
30
|
+
if (!Array.isArray(content)) continue;
|
|
31
|
+
for (const part of content)
|
|
32
|
+
if (part?.type === "tool_use") yield part as ToolUse;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function planFromTranscript(jsonl: string): string | null {
|
|
37
|
+
let text: string | null = null;
|
|
38
|
+
for (const use of toolUses(jsonl)) {
|
|
39
|
+
const file = use.input?.file_path;
|
|
40
|
+
if (typeof file !== "string" || !file.endsWith(PLAN_FILE)) continue;
|
|
41
|
+
if (use.name === "Write" && typeof use.input?.content === "string") {
|
|
42
|
+
text = use.input.content;
|
|
43
|
+
} else if (
|
|
44
|
+
use.name === "Edit" &&
|
|
45
|
+
text !== null &&
|
|
46
|
+
typeof use.input?.old_string === "string" &&
|
|
47
|
+
typeof use.input.new_string === "string" &&
|
|
48
|
+
text.includes(use.input.old_string)
|
|
49
|
+
) {
|
|
50
|
+
const { old_string: from, new_string: to } = use.input as {
|
|
51
|
+
old_string: string;
|
|
52
|
+
new_string: string;
|
|
53
|
+
};
|
|
54
|
+
text =
|
|
55
|
+
use.input.replace_all === true
|
|
56
|
+
? text.split(from).join(to)
|
|
57
|
+
: text.replace(from, () => to);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
}
|