@saadnvd1/agent-os 0.3.476 → 0.3.479

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/README.md +9 -0
  2. package/app/api/sessions/[id]/route.test.ts +66 -0
  3. package/app/api/sessions/[id]/route.ts +10 -7
  4. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  5. package/app/api/tmux/kill-all/route.ts +2 -1
  6. package/components/SessionList/OrchestratorPinRow.tsx +12 -6
  7. package/components/SessionList/QuickChangeRow.tsx +58 -0
  8. package/components/SessionList/SessionRow.tsx +4 -0
  9. package/components/Tasks/TaskRow.tsx +2 -17
  10. package/components/Tasks/task-state-labels.ts +18 -0
  11. package/lib/away/sweep.ts +2 -0
  12. package/lib/chat/hold.ts +6 -2
  13. package/lib/chat/runner.ts +5 -0
  14. package/lib/chat/worker/client.ts +2 -3
  15. package/lib/db/migrations.ts +17 -0
  16. package/lib/db/types.ts +3 -0
  17. package/lib/done/index.ts +2 -8
  18. package/lib/done/worktree.ts +9 -2
  19. package/lib/orchestration.ts +4 -5
  20. package/lib/orchestrator/act.ts +3 -6
  21. package/lib/orchestrator/ask-approvals.ts +11 -0
  22. package/lib/orchestrator/brief.ts +7 -4
  23. package/lib/orchestrator/conditions.ts +2 -1
  24. package/lib/orchestrator/describe.ts +1 -1
  25. package/lib/orchestrator/facts.ts +13 -0
  26. package/lib/orchestrator/git-guard.ts +201 -0
  27. package/lib/orchestrator/pr-wake.ts +9 -1
  28. package/lib/orchestrator/quick-store.ts +18 -0
  29. package/lib/orchestrator/quick.ts +307 -0
  30. package/lib/orchestrator/rebase.ts +18 -2
  31. package/lib/orchestrator/repo.ts +78 -13
  32. package/lib/orchestrator/review.ts +21 -0
  33. package/lib/orchestrator/serve.ts +17 -0
  34. package/lib/orchestrator/signoff.ts +37 -6
  35. package/lib/orchestrator/task-state.ts +2 -0
  36. package/lib/orchestrator/tool-names.ts +9 -2
  37. package/lib/orchestrator/tool-schemas.ts +26 -0
  38. package/lib/orchestrator/tools.ts +4 -0
  39. package/lib/orchestrator/write-guard.ts +80 -3
  40. package/lib/sessions/end.ts +15 -0
  41. package/lib/sidebar/shelves.ts +19 -1
  42. package/lib/stacks/guard.ts +11 -0
  43. package/lib/tasks/finish.ts +9 -8
  44. package/lib/tasks/gh.ts +3 -1
  45. package/lib/tasks/index.ts +18 -6
  46. package/lib/tasks/move-guard.ts +2 -0
  47. package/lib/tasks/move.ts +2 -6
  48. package/lib/tasks/pr-poll.ts +1 -1
  49. package/lib/tasks/queue.ts +3 -2
  50. package/lib/tasks/session.ts +63 -2
  51. package/lib/tasks/start.ts +2 -4
  52. package/lib/tasks/state.ts +3 -0
  53. package/lib/tmux/end.ts +33 -0
  54. package/lib/tmux/orphans.ts +146 -0
  55. package/package.json +1 -1
  56. package/server.ts +9 -0
@@ -96,6 +96,8 @@ export interface TaskView {
96
96
  // plan step (lib/models).
97
97
  model?: string;
98
98
  planning?: boolean;
99
+ // A quick change: the orchestrator session that makes it (no agent).
100
+ quickOf?: string | null;
99
101
  }
100
102
 
101
103
  export async function createTask(opts: {
@@ -304,22 +306,31 @@ export async function taskView(session: Session): Promise<TaskView> {
304
306
  const setup = taskSetupOf(session);
305
307
  // A finished task's PR is in the database: only a running one asks gh.
306
308
  const chat = session.view === "chat";
309
+ // A quick change has no agent: the orchestrator is working on it until
310
+ // its PR is up, and nothing in it can be blocked.
311
+ const quick = !!session.quick_of;
307
312
  const [pr, chatTurn, screen] = await Promise.all([
308
313
  live ? prFor(session) : storedPR(session),
309
- live && chat
314
+ live && chat && !quick
310
315
  ? chatStateNow(session.id).catch((): ChatTurn => "unknown")
311
316
  : Promise.resolve(null),
312
- live && !chat
317
+ live && !chat && !quick
313
318
  ? statusDetector.getStatus(session.tmux_name)
314
319
  : Promise.resolve(undefined),
315
320
  ]);
316
321
  const sessionStatus = !live
317
322
  ? undefined
318
- : chat
319
- ? chatStatus(chatTurn)
320
- : screen;
323
+ : quick
324
+ ? "running"
325
+ : chat
326
+ ? chatStatus(chatTurn)
327
+ : screen;
321
328
  const agentGone =
322
- !chat && live && sessionStatus !== "dead" && sessionStatus !== undefined
329
+ !chat &&
330
+ !quick &&
331
+ live &&
332
+ sessionStatus !== "dead" &&
333
+ sessionStatus !== undefined
323
334
  ? await shellOnly(session.tmux_name)
324
335
  : false;
325
336
  const blocked =
@@ -365,6 +376,7 @@ export async function taskView(session: Session): Promise<TaskView> {
365
376
  armed: !!session.merge_armed_at,
366
377
  model: sessionModelRef(session),
367
378
  planning: session.plan_phase === "planning",
379
+ quickOf: session.quick_of ?? null,
368
380
  };
369
381
  }
370
382
 
@@ -10,6 +10,8 @@ import { db, type Session } from "../db";
10
10
  import { escalations } from "../orchestrator/gates";
11
11
 
12
12
  export function moveRefusal(session: Session): string | null {
13
+ if (session.quick_of)
14
+ return "It's a quick change its orchestrator makes on this machine, so it stays here";
13
15
  if (escalations(session.id).length)
14
16
  return "It's waiting on you (an escalated gate), so it stays here";
15
17
  const ask = db
package/lib/tasks/move.ts CHANGED
@@ -7,10 +7,9 @@
7
7
  */
8
8
 
9
9
  import os from "os";
10
- import { execFile } from "child_process";
11
- import { promisify } from "util";
12
10
  import { db, type Session } from "../db";
13
11
  import { getProject } from "../projects";
12
+ import { endTmuxSession } from "../tmux/end";
14
13
  import { launchClaude } from "../agents/launch";
15
14
  import { buildTaskBrief } from "./brief";
16
15
  import { git as runGit } from "../git-exec";
@@ -31,7 +30,6 @@ import {
31
30
 
32
31
  export * from "./move-bundle";
33
32
 
34
- const execFileAsync = promisify(execFile);
35
33
  const git = (cwd: string, ...args: string[]) =>
36
34
  runGit(cwd, args, { timeout: 120000 });
37
35
 
@@ -40,9 +38,7 @@ const g = globalThis as unknown as { __agentosExporting?: Set<string> };
40
38
  const exporting = (g.__agentosExporting ??= new Set());
41
39
 
42
40
  async function stopAgent(tmuxName: string): Promise<void> {
43
- await execFileAsync("tmux", ["kill-session", "-t", `=${tmuxName}`]).catch(
44
- () => undefined
45
- );
41
+ await endTmuxSession(null, tmuxName);
46
42
  }
47
43
 
48
44
  export async function claudeIdFor(session: Session): Promise<string | null> {
@@ -9,7 +9,7 @@ import type { TaskPR } from "./state";
9
9
  export const OPEN_TTL_MS = 60_000;
10
10
  export const BRANCH_TTL_MS = 5 * 60_000;
11
11
  const OPEN_FIELDS =
12
- "number,url,state,headRefOid,statusCheckRollup,body,headRefName,isCrossRepository,createdAt,mergeable";
12
+ "number,url,state,headRefOid,statusCheckRollup,body,headRefName,isCrossRepository,createdAt,mergeable,baseRefName";
13
13
 
14
14
  interface OpenList {
15
15
  at: number;
@@ -110,7 +110,8 @@ const workspaceOf = (projectId: string) =>
110
110
  getProject(projectId)?.workspace_id ?? null;
111
111
 
112
112
  // Running tasks hold their slot until they finish (merged, dropped, done),
113
- // PR open or not; a queued task being started counts too. Without a
113
+ // PR open or not; a queued task being started counts too. A quick change
114
+ // runs no agent, so it holds none. Without a
114
115
  // workspace, the project is the scope.
115
116
  function runningIn(scope: { workspaceId: string | null; projectId: string }) {
116
117
  const where = scope.workspaceId
@@ -120,7 +121,7 @@ function runningIn(scope: { workspaceId: string | null; projectId: string }) {
120
121
  db
121
122
  .prepare(
122
123
  `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
123
- WHERE ${where} AND s.archived_at IS NULL
124
+ WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
124
125
  AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
125
126
  )
126
127
  .all({ ws: scope.workspaceId, project: scope.projectId }) as {
@@ -1,7 +1,10 @@
1
1
  import fs from "fs";
2
2
  import os from "os";
3
- import { db, queries, type Session } from "../db";
4
- import { isBranchName } from "../git";
3
+ import { db, queries, stackQueries, type Session } from "../db";
4
+ import { getDefaultBranch, isBranchName } from "../git";
5
+ import { parentBranchOf } from "../stacks/guard";
6
+ import { voidApprovalsOn } from "../orchestrator/ask-approvals";
7
+ import { taskSubject } from "../orchestrator/asks";
5
8
  import { getProject } from "../projects";
6
9
  import { findPRStrict } from "./gh";
7
10
  import { currentBranch, git } from "../git-exec";
@@ -170,9 +173,67 @@ export async function prFor(
170
173
  `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
171
174
  ).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
172
175
  }
176
+ // Only an answer just read from GitHub: a cached one may predate a
177
+ // retarget AgentOS made itself (a restack).
178
+ if (fresh) await recordBase(session, repo, pr);
173
179
  return pr;
174
180
  }
175
181
 
182
+ // An open PR's base as GitHub has it now becomes the task's, in the row and
183
+ // in `session`, so every diff, the stack logic and the UI read the truth:
184
+ // the base recorded at the start goes stale when the PR is retargeted (a
185
+ // stacked parent's branch deleted, so GitHub moved it to main). Only the
186
+ // default branch or the stack parent's branch is taken: a base the task
187
+ // picked itself would choose what its diff is judged against, so it stays
188
+ // unrecorded and sign_off refuses it. Reviews and scope checks judged
189
+ // against the old base no longer count. A stack item on a merged parent
190
+ // keeps its base until the restack moves it, which is also what takes the
191
+ // parent's commits off it.
192
+ // The bases a task may be judged against: the default branch or its stack
193
+ // parent's branch (and what it already has).
194
+ export async function isAllowedBase(
195
+ session: Session,
196
+ repo: string,
197
+ base: string
198
+ ): Promise<boolean> {
199
+ if (base === session.base_branch) return true;
200
+ const item = stackQueries.itemForSession(db, session.id);
201
+ const parent = item?.parent_item_id
202
+ ? stackQueries.item(db, item.parent_item_id)
203
+ : null;
204
+ return (
205
+ base === parentBranchOf(parent) || base === (await getDefaultBranch(repo))
206
+ );
207
+ }
208
+
209
+ async function recordBase(
210
+ session: Session,
211
+ repo: string,
212
+ pr: TaskPR | null
213
+ ): Promise<void> {
214
+ const base = pr?.state === "OPEN" ? pr.base : undefined;
215
+ if (!base || base === session.base_branch || !isBranchName(base)) return;
216
+ if (!(await isAllowedBase(session, repo, base))) return;
217
+ const item = stackQueries.itemForSession(db, session.id);
218
+ const parent = item?.parent_item_id
219
+ ? stackQueries.item(db, item.parent_item_id)
220
+ : null;
221
+ db.transaction(() => {
222
+ db.prepare(`UPDATE sessions SET base_branch = ? WHERE id = ?`).run(
223
+ base,
224
+ session.id
225
+ );
226
+ db.prepare(
227
+ `DELETE FROM orchestrator_checks WHERE session_id = ? AND kind IN ('review', 'scope')`
228
+ ).run(session.id);
229
+ // An approval was for the diff against the old base.
230
+ voidApprovalsOn(taskSubject(session.id));
231
+ if (item && item.base_branch !== base && parent?.status !== "merged")
232
+ stackQueries.updateItem(db, item.id, { base_branch: base });
233
+ })();
234
+ session.base_branch = base;
235
+ }
236
+
176
237
  // A finished task's PR as the database last saw it: no gh call.
177
238
  export function storedPR(s: Session): TaskPR | null {
178
239
  if (!s.pr_number) return null;
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { execFile } from "child_process";
10
10
  import { promisify } from "util";
11
+ import { endTmuxSession } from "../tmux/end";
11
12
  import { db, type Session } from "../db";
12
13
  import { getProject } from "../projects";
13
14
  import { setupWorktree } from "../env-setup";
@@ -195,10 +196,7 @@ function earlierSetup(row: SetupColumns): TaskSetup | undefined {
195
196
  return undefined;
196
197
  }
197
198
 
198
- export const killTmux = (name: string) =>
199
- execFileAsync("tmux", ["kill-session", "-t", `=${name}`], {
200
- timeout: 5000,
201
- }).catch(() => undefined);
199
+ export const killTmux = (name: string) => endTmuxSession(null, name);
202
200
 
203
201
  const tmuxAlive = (name: string) =>
204
202
  execFileAsync("tmux", ["has-session", "-t", `=${name}`], {
@@ -30,6 +30,9 @@ export interface TaskPR {
30
30
  // GitHub's word on merging it into its base: CONFLICTING once the base
31
31
  // moved under it (UNKNOWN while GitHub works it out).
32
32
  mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
33
+ // The branch it merges into, as GitHub has it now: it moves when the
34
+ // PR is retargeted (a stacked parent's branch deleted, say).
35
+ base?: string;
33
36
  }
34
37
 
35
38
  export type TaskState =
@@ -0,0 +1,33 @@
1
+ import { hostExec } from "../hosts";
2
+ import { shellQuote } from "../hosts/ssh";
3
+ import { isValidTmuxName } from "../hosts/tmux-name";
4
+
5
+ // Run by sh on the machine the session lives on. The pane processes and
6
+ // everything under them are read before tmux ends the session (the kill only
7
+ // hangs up the panes' own shells), then asked to stop and, if any is still
8
+ // there a second later, killed. Only pids found under this session's panes
9
+ // are ever signalled.
10
+ export function endTmuxScript(name: string): string {
11
+ return [
12
+ `t=${shellQuote(`=${name}`)}`,
13
+ `roots=$(tmux list-panes -s -t "$t" -F '#{pane_pid}' 2>/dev/null)`,
14
+ `tree=$(ps -Ao pid=,ppid= | awk -v roots="$roots" 'BEGIN{n=split(roots,r,/[ \\n]+/);for(i=1;i<=n;i++)if(r[i]!="")s[r[i]]=1} {p[$1]=$2;a[NR]=$1} END{do{c=0;for(i=1;i<=NR;i++){k=a[i];if(!(k in s)&&(p[k] in s)){s[k]=1;c=1}}}while(c);for(k in s)print k}')`,
15
+ `tmux kill-session -t "$t" 2>/dev/null`,
16
+ `if [ -n "$tree" ]; then`,
17
+ ` kill -TERM $tree 2>/dev/null`,
18
+ ` alive=; for p in $tree; do kill -0 "$p" 2>/dev/null && alive="$alive $p"; done`,
19
+ ` if [ -n "$alive" ]; then sleep 1; kill -KILL $alive 2>/dev/null; fi`,
20
+ `fi`,
21
+ `exit 0`,
22
+ ].join("\n");
23
+ }
24
+
25
+ // Ends a tmux session and what its panes started, on the machine it runs on.
26
+ // A session that isn't there is nothing to do; this never throws.
27
+ export async function endTmuxSession(
28
+ hostId: string | null | undefined,
29
+ name: string | null | undefined
30
+ ): Promise<void> {
31
+ if (!name || !isValidTmuxName(name)) return;
32
+ await hostExec(hostId, endTmuxScript(name), 15_000).catch(() => {});
33
+ }
@@ -0,0 +1,146 @@
1
+ import { db } from "../db";
2
+ import { getManagedSessionPattern } from "../providers/registry";
3
+ import { WORKER_TMUX_PREFIX } from "../chat/worker/protocol";
4
+ import { isValidTmuxName } from "../hosts/tmux-name";
5
+ import { endTmuxSession } from "./end";
6
+ import { tmux } from "./exec";
7
+
8
+ // tmux sessions AgentOS made whose session row is gone: deleted before every
9
+ // end-of-life path ended the agent. Swept once at start. Only sessions that
10
+ // carry AgentOS's own marks are ever touched: its names (<agent>-<uuid>,
11
+ // aos-chat-<uuid>) or the AGENTOS_SESSION_ID it sets on the session at
12
+ // launch. Dispatch's sessions and anyone's own terminals have neither.
13
+
14
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
15
+ const WORKER_NAME = new RegExp(`^${WORKER_TMUX_PREFIX}(${UUID})$`, "i");
16
+ const AGENT_NAME = new RegExp(`(${UUID})$`, "i");
17
+
18
+ export interface TmuxListing {
19
+ name: string;
20
+ // AGENTOS_SESSION_ID on the session itself; null when absent or when the
21
+ // whole tmux server carries one (every session would then).
22
+ taggedId: string | null;
23
+ }
24
+
25
+ export interface SessionRow {
26
+ id: string;
27
+ tmux_name: string | null;
28
+ host_id: string | null;
29
+ }
30
+
31
+ const isLocal = (hostId: string | null) => !hostId || hostId === "local";
32
+
33
+ // The AgentOS session id a tmux session belongs to, or null if it isn't one
34
+ // of AgentOS's.
35
+ export function agentOsIdOf(
36
+ l: TmuxListing,
37
+ managed = getManagedSessionPattern()
38
+ ): string | null {
39
+ const worker = WORKER_NAME.exec(l.name);
40
+ if (worker) return worker[1].toLowerCase();
41
+ if (managed.test(l.name)) return AGENT_NAME.exec(l.name)![1].toLowerCase();
42
+ return l.taggedId && new RegExp(`^${UUID}$`, "i").test(l.taggedId)
43
+ ? l.taggedId.toLowerCase()
44
+ : null;
45
+ }
46
+
47
+ export function findOrphans(
48
+ listing: TmuxListing[],
49
+ rows: SessionRow[]
50
+ ): { name: string; id: string }[] {
51
+ const ids = new Set(rows.map((r) => r.id.toLowerCase()));
52
+ const names = new Set(
53
+ rows.filter((r) => isLocal(r.host_id)).map((r) => r.tmux_name)
54
+ );
55
+ const orphans: { name: string; id: string }[] = [];
56
+ for (const l of listing) {
57
+ const id = agentOsIdOf(l);
58
+ if (id && !ids.has(id) && !names.has(l.name))
59
+ orphans.push({ name: l.name, id });
60
+ }
61
+ return orphans;
62
+ }
63
+
64
+ async function taggedId(
65
+ name: string,
66
+ serverWide: boolean
67
+ ): Promise<string | null> {
68
+ if (serverWide) return null;
69
+ const out = await tmux(
70
+ ["show-environment", "-t", `=${name}`, "AGENTOS_SESSION_ID"],
71
+ 5000
72
+ ).catch(() => "");
73
+ return /^AGENTOS_SESSION_ID=(.+)$/m.exec(out)?.[1].trim() ?? null;
74
+ }
75
+
76
+ async function listLocal(createdBefore: number): Promise<TmuxListing[]> {
77
+ const out = await tmux(
78
+ ["list-sessions", "-F", "#{session_created} #{session_name}"],
79
+ 5000
80
+ ).catch(() => "");
81
+ // Sessions made after the cutoff belong to work this process started: their
82
+ // rows may not be committed yet, so they are never candidates.
83
+ const names = out
84
+ .split("\n")
85
+ .map((l) => /^(\d+) (.+)$/.exec(l))
86
+ .filter((m): m is RegExpExecArray => !!m && Number(m[1]) <= createdBefore)
87
+ .map((m) => m[2])
88
+ .filter(isValidTmuxName);
89
+ // A server started from inside an agent's shell has the variable in its
90
+ // own environment, which every session on it then shows.
91
+ const global = await tmux(
92
+ ["show-environment", "-g", "AGENTOS_SESSION_ID"],
93
+ 5000
94
+ ).catch(() => "");
95
+ const serverWide = /^AGENTOS_SESSION_ID=/m.test(global);
96
+ const managed = getManagedSessionPattern();
97
+ const listing: TmuxListing[] = [];
98
+ for (const name of names) {
99
+ const byName = WORKER_NAME.test(name) || managed.test(name);
100
+ listing.push({
101
+ name,
102
+ taggedId: byName ? null : await taggedId(name, serverWide),
103
+ });
104
+ }
105
+ return listing;
106
+ }
107
+
108
+ const hasRow = (id: string, tmuxName: string) =>
109
+ !!db
110
+ .prepare(`SELECT 1 FROM sessions WHERE id = ? OR tmux_name = ?`)
111
+ .get(id, tmuxName);
112
+
113
+ export async function sweepOrphanTmux(
114
+ opts: {
115
+ dryRun?: boolean;
116
+ rows?: SessionRow[];
117
+ // Epoch seconds; only tmux sessions created at or before it are swept.
118
+ // Defaults to when this process started.
119
+ createdBefore?: number;
120
+ } = {}
121
+ ): Promise<{ found: string[]; killed: string[] }> {
122
+ const none = { found: [], killed: [] };
123
+ const rows =
124
+ opts.rows ??
125
+ (db
126
+ .prepare(`SELECT id, tmux_name, host_id FROM sessions`)
127
+ .all() as SessionRow[]);
128
+ // An empty table is a fresh or wrong database, not proof that nothing is
129
+ // alive: never sweep against it.
130
+ if (!rows.length) return none;
131
+ const cutoff =
132
+ opts.createdBefore ?? Math.floor(Date.now() / 1000 - process.uptime());
133
+ const orphans = findOrphans(await listLocal(cutoff), rows);
134
+ const killed: string[] = [];
135
+ for (const o of orphans) {
136
+ if (opts.dryRun) continue;
137
+ // The snapshot is old by now: a row inserted since keeps its session.
138
+ if (!opts.rows && hasRow(o.id, o.name)) continue;
139
+ await endTmuxSession(null, o.name);
140
+ killed.push(o.name);
141
+ console.log(`[tmux-sweep] ended ${o.name}: session ${o.id} has no row`);
142
+ }
143
+ if (killed.length)
144
+ console.log(`[tmux-sweep] ended ${killed.length} orphaned tmux sessions`);
145
+ return { found: orphans.map((o) => o.name), killed };
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.476",
3
+ "version": "0.3.479",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -61,6 +61,7 @@ import { installClaudeStatusHooks } from "./lib/program-status/claude-hooks";
61
61
  import os from "os";
62
62
  import { resumeHeldStarts, resumeTaskStarts } from "./lib/tasks/start";
63
63
  import { failInterruptedSetups } from "./lib/sessions/worktree-setup";
64
+ import { sweepOrphanTmux } from "./lib/tmux/orphans";
64
65
  import { releaseInterruptedClaims } from "./lib/orchestrator/ask-approvals";
65
66
  import { resumeReviews } from "./lib/orchestrator/review";
66
67
  import { claimAgentosEnv } from "./lib/agents/self-env";
@@ -357,6 +358,14 @@ app.prepare().then(async () => {
357
358
  }
358
359
  // New sessions whose worktree setup a restart cut off.
359
360
  failInterruptedSetups();
361
+ // tmux sessions of sessions deleted before agents were ended with them.
362
+ // Production only: a dev or test instance has its own database but shares
363
+ // the default tmux server, so it would see the real instance's sessions as
364
+ // orphans.
365
+ if (!dev && process.env.AGENTOS_TMUX_SWEEP !== "off")
366
+ sweepOrphanTmux().catch((error) =>
367
+ console.error("Sweeping orphaned tmux sessions failed:", error)
368
+ );
360
369
  // Chat turns that kept running through a restart.
361
370
  void reattachChats();
362
371
  // Reviews this restart cut off run again and deliver their verdicts.