@saadnvd1/agent-os 0.3.477 → 0.3.481

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 (65) hide show
  1. package/README.md +26 -1
  2. package/app/api/schedules/route.ts +1 -0
  3. package/app/api/sessions/[id]/route.test.ts +66 -0
  4. package/app/api/sessions/[id]/route.ts +10 -7
  5. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  6. package/app/api/tmux/kill-all/route.ts +2 -1
  7. package/components/Orchestrator/OrchestratorBar.tsx +12 -0
  8. package/components/Schedules/ScheduleForm.tsx +94 -12
  9. package/components/Schedules/SchedulesPanel.tsx +60 -14
  10. package/components/SessionList/OrchestratorPinRow.tsx +12 -6
  11. package/components/SessionList/QuickChangeRow.tsx +58 -0
  12. package/components/SessionList/SessionRow.tsx +4 -0
  13. package/components/SessionList/SidebarHeader.tsx +1 -1
  14. package/components/SessionList/useSchedulesGlance.ts +6 -2
  15. package/components/Tasks/TaskRow.tsx +2 -17
  16. package/components/Tasks/task-state-labels.ts +18 -0
  17. package/lib/away/sweep.ts +2 -0
  18. package/lib/chat/drivers/demo.ts +2 -1
  19. package/lib/chat/hold.ts +6 -2
  20. package/lib/chat/runner.ts +5 -0
  21. package/lib/chat/test-touch.ts +58 -12
  22. package/lib/chat/worker/client.ts +2 -3
  23. package/lib/db/migrations.ts +31 -0
  24. package/lib/db/types.ts +3 -0
  25. package/lib/done/index.ts +2 -8
  26. package/lib/orchestration.ts +4 -5
  27. package/lib/orchestrator/act.ts +3 -6
  28. package/lib/orchestrator/brief.ts +8 -3
  29. package/lib/orchestrator/conditions.ts +2 -1
  30. package/lib/orchestrator/describe.ts +1 -1
  31. package/lib/orchestrator/facts.ts +13 -0
  32. package/lib/orchestrator/git-guard.ts +201 -0
  33. package/lib/orchestrator/pr-wake.ts +9 -1
  34. package/lib/orchestrator/quick-store.ts +18 -0
  35. package/lib/orchestrator/quick.ts +307 -0
  36. package/lib/orchestrator/rebase.ts +11 -1
  37. package/lib/orchestrator/reminders.ts +91 -0
  38. package/lib/orchestrator/review.ts +3 -0
  39. package/lib/orchestrator/serve.ts +26 -0
  40. package/lib/orchestrator/signoff.ts +28 -6
  41. package/lib/orchestrator/task-state.ts +2 -0
  42. package/lib/orchestrator/tool-names.ts +12 -2
  43. package/lib/orchestrator/tool-schemas.ts +43 -0
  44. package/lib/orchestrator/tools.ts +10 -0
  45. package/lib/orchestrator/write-guard.ts +80 -3
  46. package/lib/schedules/badge.ts +11 -1
  47. package/lib/schedules/cron.ts +1 -1
  48. package/lib/schedules/index.ts +67 -6
  49. package/lib/schedules/run.ts +23 -6
  50. package/lib/schedules/scheduler.ts +12 -2
  51. package/lib/schedules/start.ts +11 -5
  52. package/lib/schedules/store.ts +133 -16
  53. package/lib/schedules/when.ts +166 -0
  54. package/lib/sessions/end.ts +15 -0
  55. package/lib/sidebar/shelves.ts +19 -1
  56. package/lib/tasks/finish.ts +2 -8
  57. package/lib/tasks/index.ts +18 -6
  58. package/lib/tasks/move-guard.ts +2 -0
  59. package/lib/tasks/move.ts +2 -6
  60. package/lib/tasks/queue.ts +3 -2
  61. package/lib/tasks/start.ts +2 -4
  62. package/lib/tmux/end.ts +33 -0
  63. package/lib/tmux/orphans.ts +146 -0
  64. package/package.json +1 -1
  65. package/server.ts +9 -0
@@ -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}`], {
@@ -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.477",
3
+ "version": "0.3.481",
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.