@saadnvd1/agent-os 0.3.662 → 0.3.664

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 (64) hide show
  1. package/app/api/sessions/[id]/route.ts +3 -3
  2. package/app/api/worktree-sweep/route.test.ts +19 -1
  3. package/app/api/worktree-sweep/route.ts +25 -6
  4. package/components/Health/SweepRuns.tsx +76 -1
  5. package/data/worktree-sweep/index.ts +15 -0
  6. package/lib/agentos-home.ts +11 -0
  7. package/lib/agents/launch.ts +3 -7
  8. package/lib/artifacts/store.ts +2 -5
  9. package/lib/chat/drivers/pi-approvals.ts +2 -2
  10. package/lib/chat/worker/protocol.ts +2 -2
  11. package/lib/connect/config.ts +2 -3
  12. package/lib/db/backup.ts +2 -2
  13. package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -0
  14. package/lib/dev-instance/processInfo.ts +48 -27
  15. package/lib/dev-instance/startDevServer.ts +1 -1
  16. package/lib/dev-instance/stopDevServer.ts +20 -17
  17. package/lib/dev-servers.ts +2 -1
  18. package/lib/done/index.ts +2 -2
  19. package/lib/health/boots.ts +37 -0
  20. package/lib/health/ci.ts +23 -2
  21. package/lib/health/detectors.ts +2 -1
  22. package/lib/health/evidence.ts +1 -1
  23. package/lib/health/facts.ts +3 -3
  24. package/lib/health/fixtures.ts +1 -0
  25. package/lib/health/tick.ts +6 -1
  26. package/lib/health/types.ts +2 -0
  27. package/lib/orchestrator/home.ts +2 -5
  28. package/lib/orchestrator/shell-guard.ts +2 -0
  29. package/lib/orchestrator/write-guard.ts +3 -2
  30. package/lib/plans/features.ts +2 -2
  31. package/lib/program-status/claude-hooks.ts +2 -2
  32. package/lib/program-status/tap.ts +2 -2
  33. package/lib/project-config/database.ts +71 -16
  34. package/lib/project-config/session.ts +18 -0
  35. package/lib/security/tailnet-https.ts +2 -2
  36. package/lib/session-artifacts/collect.ts +18 -1
  37. package/lib/session-artifacts/keep.ts +2 -4
  38. package/lib/session-audit/store.ts +2 -5
  39. package/lib/session-logs/folder.ts +2 -3
  40. package/lib/sessions/launch.ts +2 -2
  41. package/lib/sessions/leftovers.ts +61 -0
  42. package/lib/simulator/agent-device.ts +2 -2
  43. package/lib/tasks/finish.ts +2 -2
  44. package/lib/tasks/import.ts +3 -5
  45. package/lib/tasks/move.ts +2 -2
  46. package/lib/tasks/moved-away.ts +2 -0
  47. package/lib/tasks/state.ts +7 -3
  48. package/lib/tasks/ui-brief.ts +2 -2
  49. package/lib/worktree-deps/fastcopy.ts +2 -2
  50. package/lib/worktree-deps/spare.ts +2 -2
  51. package/lib/worktree-sweep/caches.ts +123 -0
  52. package/lib/worktree-sweep/databases.ts +222 -0
  53. package/lib/worktree-sweep/du.ts +13 -0
  54. package/lib/worktree-sweep/extras.ts +188 -0
  55. package/lib/worktree-sweep/findOrphans.ts +19 -8
  56. package/lib/worktree-sweep/report.ts +85 -0
  57. package/lib/worktree-sweep/runSweep.ts +46 -13
  58. package/lib/worktree-sweep/settings.ts +24 -0
  59. package/lib/worktree-sweep/state.ts +5 -0
  60. package/lib/worktree-sweep/store.ts +11 -0
  61. package/lib/worktree-sweep/types.ts +44 -1
  62. package/lib/worktrees.ts +3 -0
  63. package/package.json +2 -1
  64. package/scripts/disk-dry-run.ts +57 -0
@@ -7,7 +7,7 @@ import {
7
7
  mainCheckoutOf,
8
8
  } from "@/lib/worktrees";
9
9
  import { releasePort } from "@/lib/ports";
10
- import { dropSessionDatabase } from "@/lib/project-config/database";
10
+ import { releaseSessionLeftovers } from "@/lib/sessions/leftovers";
11
11
  import { killWorker } from "@/lib/orchestration";
12
12
  import { endSessionAgent } from "@/lib/sessions/end";
13
13
  import { endTmuxSession } from "@/lib/tmux/end";
@@ -261,7 +261,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
261
261
  } catch (error) {
262
262
  console.error(`Failed to kill worker ${worker.id}:`, error);
263
263
  }
264
- void dropSessionDatabase(worker.id);
264
+ void releaseSessionLeftovers(worker);
265
265
  queries.deleteSession(db).run(worker.id);
266
266
  }
267
267
 
@@ -271,7 +271,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
271
271
  }
272
272
 
273
273
  // Its private Postgres copy: the row is read now, the drop runs on.
274
- void dropSessionDatabase(id);
274
+ void releaseSessionLeftovers(existing);
275
275
 
276
276
  // Delete from database immediately for instant UI feedback
277
277
  // The agent's tmux and processes go on in the background; the worktree
@@ -5,10 +5,11 @@ import { NextRequest } from "next/server";
5
5
  const { GET, PUT } = await import("./route");
6
6
  const { createProject } = await import("@/lib/projects");
7
7
 
8
- const put = (body: unknown) =>
8
+ const put = (body: unknown, trust: string | null = "loopback") =>
9
9
  PUT(
10
10
  new NextRequest("http://localhost/api/worktree-sweep", {
11
11
  method: "PUT",
12
+ headers: trust ? { "x-agentos-trust": trust } : {},
12
13
  body: JSON.stringify(body),
13
14
  })
14
15
  );
@@ -30,6 +31,23 @@ describe("/api/worktree-sweep", () => {
30
31
  expect((await (await put({ days: null })).json()).days).toBeNull();
31
32
  });
32
33
 
34
+ it("turns the full clean-up on and off, off by default", async () => {
35
+ expect((await (await GET()).json()).armed).toBe(false);
36
+ expect((await (await put({ armed: true })).json()).armed).toBe(true);
37
+ expect((await (await GET()).json()).armed).toBe(true);
38
+ expect((await (await put({ armed: false })).json()).armed).toBe(false);
39
+ expect((await put({ armed: "yes" })).status).toBe(400);
40
+ });
41
+
42
+ it("turns the full clean-up on only from this machine or the tailnet, off from anywhere", async () => {
43
+ expect((await put({ armed: true }, null)).status).toBe(403);
44
+ expect((await put({ armed: true }, "device")).status).toBe(403);
45
+ expect((await (await GET()).json()).armed).toBe(false);
46
+ expect((await put({ armed: true }, "tailnet")).status).toBe(200);
47
+ expect((await put({ armed: false }, null)).status).toBe(200);
48
+ expect((await (await GET()).json()).armed).toBe(false);
49
+ });
50
+
33
51
  it("refuses a number that isn't a whole 0-365", async () => {
34
52
  for (const days of [-1, 1.5, 400, "7", undefined]) {
35
53
  const res = await put({ days });
@@ -2,16 +2,21 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  setGlobalSweepDays,
5
+ setReaperArmed,
5
6
  setProjectSweepDays,
6
7
  sweepDays,
7
8
  } from "@/lib/worktree-sweep/settings";
9
+ import { requireLocalTrust } from "@/lib/security/route-guard";
8
10
  import { sweepState } from "@/lib/worktree-sweep/state";
9
11
 
10
- const body = z.object({
11
- // Null clears the setting: the global one, then the default, applies.
12
- days: sweepDays.nullable(),
13
- projectId: z.string().min(1).optional(),
14
- });
12
+ const body = z.union([
13
+ z.object({
14
+ // Null clears the setting: the global one, then the default, applies.
15
+ days: sweepDays.nullable(),
16
+ projectId: z.string().min(1).optional(),
17
+ }),
18
+ z.object({ armed: z.boolean() }),
19
+ ]);
15
20
 
16
21
  export async function GET() {
17
22
  return NextResponse.json(sweepState());
@@ -21,9 +26,23 @@ export async function PUT(request: NextRequest) {
21
26
  const got = body.safeParse(await request.json().catch(() => null));
22
27
  if (!got.success)
23
28
  return NextResponse.json(
24
- { error: "days must be a whole number from 0 to 365, or null" },
29
+ {
30
+ error:
31
+ "days must be a whole number from 0 to 365, or null; or armed true or false",
32
+ },
25
33
  { status: 400 }
26
34
  );
35
+ if ("armed" in got.data) {
36
+ // Turning the removal on is a person's call, made on this machine or
37
+ // over Tailscale: a stolen device token must not be able to. Turning it
38
+ // off stays on the ordinary gate.
39
+ if (got.data.armed) {
40
+ const refused = requireLocalTrust(request);
41
+ if (refused) return refused;
42
+ }
43
+ setReaperArmed(got.data.armed);
44
+ return NextResponse.json(sweepState());
45
+ }
27
46
  const { days, projectId } = got.data;
28
47
  if (projectId) setProjectSweepDays(projectId, days);
29
48
  else setGlobalSweepDays(days);
@@ -1,7 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
- import { useRunSweep, useWorktreeSweep } from "@/data/worktree-sweep";
4
+ import {
5
+ useRunSweep,
6
+ useSetReaperArmed,
7
+ useWorktreeSweep,
8
+ } from "@/data/worktree-sweep";
5
9
  import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
6
10
  import { formatBytes } from "@/lib/share/view";
7
11
  import type { SweepRun } from "@/lib/worktree-sweep/types";
@@ -10,8 +14,34 @@ const KIND: Record<string, string> = {
10
14
  settled: "settled session",
11
15
  orphan: "orphan folder",
12
16
  "test-leak": "test leftover",
17
+ database: "task database",
18
+ spare: "dependency spare",
19
+ temp: "temp folder",
20
+ "derived-data": "Xcode build folder",
13
21
  };
14
22
 
23
+ // "18.2 GB in 3,876 worktrees · 12 task databases (270 MB) · ..." from the
24
+ // latest run that measured, and what the last real run freed.
25
+ export function diskSummary(runs: SweepRun[]): string | null {
26
+ const totals = runs.find((r) => r.details?.totals)?.details?.totals;
27
+ if (!totals) return null;
28
+ const part = (n: number, bytes: number, what: string) =>
29
+ `${n.toLocaleString()} ${what}${bytes ? ` (${formatBytes(bytes)})` : ""}`;
30
+ const last = runs.find((r) => !r.dryRun && r.finishedAt);
31
+ return [
32
+ `Task leftovers: ${part(totals.worktrees.count, totals.worktrees.bytes, "worktrees")}`,
33
+ part(
34
+ totals.databases.count,
35
+ totals.databases.bytes,
36
+ "stale task databases"
37
+ ),
38
+ part(totals.spares.count, totals.spares.bytes, "dependency spares"),
39
+ last
40
+ ? `last clean-up freed ${formatBytes(last.freedBytes)}, ${compactTimeAgo(fromSqliteTime(last.finishedAt!))}`
41
+ : "no clean-up has run yet",
42
+ ].join(" · ");
43
+ }
44
+
15
45
  const name = (p: string) => p.split("/").pop();
16
46
 
17
47
  function Run({ run }: { run: SweepRun }) {
@@ -51,6 +81,22 @@ function Run({ run }: { run: SweepRun }) {
51
81
  </ul>
52
82
  </div>
53
83
  )}
84
+ {(run.details?.pending?.length ?? 0) > 0 && (
85
+ <div className="mt-2 space-y-1 pb-2">
86
+ <p className="text-sm font-medium">
87
+ Waiting for the full clean-up to be turned on (
88
+ {run.details?.pendingTotal?.count ?? 0},{" "}
89
+ {formatBytes(run.details?.pendingTotal?.bytes ?? 0)})
90
+ </p>
91
+ <ul className="text-muted-foreground space-y-1 text-xs">
92
+ {run.details?.pending?.slice(0, 100).map((p) => (
93
+ <li key={p.path} className="break-all">
94
+ {name(p.path)} ({KIND[p.kind]}, {formatBytes(p.bytes)})
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ </div>
99
+ )}
54
100
  {removed.length > 0 && (
55
101
  <div className="mt-2 space-y-1 pb-2">
56
102
  <p className="text-sm font-medium">Removed</p>
@@ -72,8 +118,37 @@ function Run({ run }: { run: SweepRun }) {
72
118
  export function SweepRuns() {
73
119
  const { data, isPending, error } = useWorktreeSweep();
74
120
  const run = useRunSweep();
121
+ const arm = useSetReaperArmed();
122
+ const summary = data ? diskSummary(data.runs) : null;
75
123
  return (
76
124
  <div className="space-y-3">
125
+ {summary && (
126
+ <p
127
+ className="text-sm font-medium tabular-nums"
128
+ data-testid="disk-summary"
129
+ >
130
+ {summary}
131
+ </p>
132
+ )}
133
+ {data && (
134
+ <div className="flex flex-wrap items-center gap-2">
135
+ <p className="text-muted-foreground min-w-0 flex-1 text-sm">
136
+ Full clean-up is {data.armed ? "on" : "off"}: test leftovers, stale
137
+ task databases, spares, temp folders and build caches{" "}
138
+ {data.armed
139
+ ? "are removed with each run."
140
+ : "are only listed until you turn it on. Read a dry run first."}
141
+ </p>
142
+ <Button
143
+ variant="outline"
144
+ className="min-h-11 md:min-h-8"
145
+ disabled={arm.isPending}
146
+ onClick={() => arm.mutate(!data.armed)}
147
+ >
148
+ {data.armed ? "Turn off" : "Turn on"}
149
+ </Button>
150
+ </div>
151
+ )}
77
152
  <div className="flex flex-wrap items-center gap-2">
78
153
  <p className="text-muted-foreground min-w-0 flex-1 text-sm">
79
154
  Settled worktrees older than {data?.days ?? data?.defaultDays ?? 7}{" "}
@@ -33,6 +33,21 @@ export function useSetSweepDays() {
33
33
  });
34
34
  }
35
35
 
36
+ export function useSetReaperArmed() {
37
+ const queryClient = useQueryClient();
38
+ return useMutation({
39
+ mutationFn: async (armed: boolean) =>
40
+ json<SweepState>(
41
+ await fetch("/api/worktree-sweep", {
42
+ method: "PUT",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ armed }),
45
+ })
46
+ ),
47
+ onSuccess: (data) => queryClient.setQueryData(sweepKeys.all, data),
48
+ });
49
+ }
50
+
36
51
  export function useRunSweep() {
37
52
  const queryClient = useQueryClient();
38
53
  return useMutation({
@@ -0,0 +1,11 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ import { refuseRealStateUnderTest } from "./test-home-guard";
4
+
5
+ // Every path under ~/.agent-os is built here, so a test run that resolves one
6
+ // to the real home throws instead of leaving files in the owner's state.
7
+ export function agentosHome(...parts: string[]): string {
8
+ return refuseRealStateUnderTest(
9
+ path.join(os.homedir(), ".agent-os", ...parts)
10
+ );
11
+ }
@@ -1,5 +1,5 @@
1
+ import { agentosHome } from "../agentos-home";
1
2
  import fs from "fs";
2
- import os from "os";
3
3
  import path from "path";
4
4
  import { execFile } from "child_process";
5
5
  import { promisify } from "util";
@@ -22,12 +22,8 @@ import {
22
22
  } from "../project-config/session";
23
23
 
24
24
  const execFileAsync = promisify(execFile);
25
- const PROMPTS_DIR = path.join(os.homedir(), ".agent-os", "prompts");
26
- export const BUS_BRIEF_FILE = path.join(
27
- os.homedir(),
28
- ".agent-os",
29
- "bus-brief.md"
30
- );
25
+ const PROMPTS_DIR = agentosHome("prompts");
26
+ export const BUS_BRIEF_FILE = agentosHome("bus-brief.md");
31
27
 
32
28
  export function ensureBusBrief(): void {
33
29
  fs.mkdirSync(path.dirname(BUS_BRIEF_FILE), { recursive: true });
@@ -3,8 +3,8 @@
3
3
  * ~/.agent-os/artifacts/<session id>/, and a row saying whose it is.
4
4
  */
5
5
 
6
+ import { agentosHome } from "../agentos-home";
6
7
  import fs from "fs";
7
- import os from "os";
8
8
  import path from "path";
9
9
  import { randomUUID } from "crypto";
10
10
  import { db } from "../db";
@@ -23,10 +23,7 @@ export const MAX_TITLE_CHARS = 120;
23
23
  const ID = /^[0-9a-f-]{36}$/;
24
24
 
25
25
  export function artifactsDir(): string {
26
- return (
27
- process.env.AGENTOS_ARTIFACTS_DIR ||
28
- path.join(os.homedir(), ".agent-os", "artifacts")
29
- );
26
+ return process.env.AGENTOS_ARTIFACTS_DIR || agentosHome("artifacts");
30
27
  }
31
28
 
32
29
  // Session ids are UUIDs; anything else must not become a path segment.
@@ -1,5 +1,5 @@
1
+ import { agentosHome } from "../../agentos-home";
1
2
  import fs from "fs";
2
- import os from "os";
3
3
  import path from "path";
4
4
  import { randomUUID } from "crypto";
5
5
  import type { ChatAccess, DriverEvent } from "../events";
@@ -9,7 +9,7 @@ import { piTool } from "./pi-mapper";
9
9
  type P = Record<string, unknown>;
10
10
  const str = (v: unknown) => (typeof v === "string" ? v : "");
11
11
 
12
- const DIR = path.join(os.homedir(), ".agent-os", "pi");
12
+ const DIR = agentosHome("pi");
13
13
  const MARK = "agentos:";
14
14
 
15
15
  // Pi runs every tool without asking. This extension asks the chat first,
@@ -3,8 +3,8 @@
3
3
  * worker's Unix socket: one JSON message per line.
4
4
  */
5
5
 
6
+ import { agentosHome } from "../../agentos-home";
6
7
  import crypto from "crypto";
7
- import os from "os";
8
8
  import path from "path";
9
9
  import type {
10
10
  ApprovalDecision,
@@ -116,7 +116,7 @@ export function workerDir(): string {
116
116
  process.env.DB_PATH || path.join(process.cwd(), "agent-os.db")
117
117
  );
118
118
  const key = crypto.createHash("sha1").update(db).digest("hex").slice(0, 8);
119
- return path.join(os.homedir(), ".agent-os", "chat", key);
119
+ return agentosHome("chat", key);
120
120
  }
121
121
 
122
122
  export const socketPath = (sessionId: string) =>
@@ -11,8 +11,8 @@
11
11
  * each time the tunnel starts.
12
12
  */
13
13
 
14
+ import { agentosHome } from "../agentos-home";
14
15
  import fs from "fs";
15
- import os from "os";
16
16
  import path from "path";
17
17
 
18
18
  export interface ConnectConfig {
@@ -32,8 +32,7 @@ export interface ConnectFiles {
32
32
  export const MACHINE_HOST = /^m-[0-9a-f]{16}\.runagentos\.com$/;
33
33
 
34
34
  export const connectDir = () =>
35
- process.env.AGENTOS_CONNECT_DIR ||
36
- path.join(os.homedir(), ".agent-os", "connect");
35
+ process.env.AGENTOS_CONNECT_DIR || agentosHome("connect");
37
36
 
38
37
  export type ConnectRead =
39
38
  | { ok: true; files: ConnectFiles; enabled: boolean }
package/lib/db/backup.ts CHANGED
@@ -1,13 +1,13 @@
1
+ import { agentosHome } from "../agentos-home";
1
2
  import type Database from "better-sqlite3";
2
3
  import Sqlite from "better-sqlite3";
3
4
  import { randomBytes } from "crypto";
4
5
  import fs from "fs";
5
- import os from "os";
6
6
  import path from "path";
7
7
  import { refuseRealStateUnderTest } from "../test-home-guard";
8
8
 
9
9
  export const backupsDir = (): string =>
10
- refuseRealStateUnderTest(path.join(os.homedir(), ".agent-os", "backups"));
10
+ refuseRealStateUnderTest(agentosHome("backups"));
11
11
 
12
12
  // A full copy of the database (VACUUM INTO) under ~/.agent-os/backups, taken
13
13
  // before a destructive migration. Throws, and the migration must stop, unless
@@ -0,0 +1,13 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261018T1600_health_boot_deploys",
5
+ up: (db) => {
6
+ const cols = (
7
+ db.prepare(`PRAGMA table_info(health_boots)`).all() as { name: string }[]
8
+ ).map((c) => c.name);
9
+ // 1 when autodeploy planned the restart that started this boot.
10
+ if (!cols.includes("deploy"))
11
+ db.exec(`ALTER TABLE health_boots ADD COLUMN deploy INTEGER`);
12
+ },
13
+ } satisfies Migration;
@@ -1,5 +1,27 @@
1
1
  import fs from "fs";
2
- import { execFileSync } from "child_process";
2
+ import { execFile } from "child_process";
3
+ import { promisify } from "util";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ // These run inside the server (task end, the sweep), so none may block the
8
+ // event loop or hang: a timeout reads as "could not verify".
9
+ export const PROBE_TIMEOUT_MS = 3000;
10
+
11
+ export class ProbeTimeout extends Error {}
12
+
13
+ export async function probe(cmd: string, args: string[]): Promise<string> {
14
+ try {
15
+ const { stdout } = await execFileAsync(cmd, args, {
16
+ encoding: "utf8",
17
+ timeout: PROBE_TIMEOUT_MS,
18
+ });
19
+ return stdout;
20
+ } catch (e) {
21
+ if ((e as { killed?: boolean }).killed) throw new ProbeTimeout(cmd);
22
+ throw e;
23
+ }
24
+ }
3
25
 
4
26
  export function pidAlive(pid: number): boolean {
5
27
  try {
@@ -18,18 +40,18 @@ const real = (p: string): string => {
18
40
  }
19
41
  };
20
42
 
21
- export function processCwd(pid: number): string | null {
43
+ export async function processCwd(pid: number): Promise<string | null> {
22
44
  try {
23
45
  if (process.platform === "linux")
24
46
  return real(fs.readlinkSync(`/proc/${pid}/cwd`));
25
- const out = execFileSync(
26
- "lsof",
27
- ["-a", "-p", String(pid), "-d", "cwd", "-Fn"],
28
- {
29
- encoding: "utf8",
30
- stdio: ["ignore", "pipe", "ignore"],
31
- }
32
- );
47
+ const out = await probe("lsof", [
48
+ "-a",
49
+ "-p",
50
+ String(pid),
51
+ "-d",
52
+ "cwd",
53
+ "-Fn",
54
+ ]);
33
55
  const line = out.split("\n").find((l) => l.startsWith("n"));
34
56
  return line ? real(line.slice(1)) : null;
35
57
  } catch {
@@ -38,30 +60,29 @@ export function processCwd(pid: number): string | null {
38
60
  }
39
61
 
40
62
  // Whether the process was launched as the live server: its environment
41
- // carries the marker, or its title is the server's.
42
- export function looksLive(pid: number): boolean {
63
+ // carries the marker, or its title is the server's. A probe that times out
64
+ // answers true: not being able to tell is a reason to leave it alone.
65
+ export async function looksLive(pid: number): Promise<boolean> {
43
66
  try {
44
67
  if (process.platform === "linux") {
45
68
  const env = fs.readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
46
69
  if (env.includes("AGENTOS_SERVER=live")) return true;
47
70
  } else {
48
- const out = execFileSync(
49
- "ps",
50
- ["eww", "-p", String(pid), "-o", "command="],
51
- {
52
- encoding: "utf8",
53
- stdio: ["ignore", "pipe", "ignore"],
54
- }
55
- );
71
+ const out = await probe("ps", [
72
+ "eww",
73
+ "-p",
74
+ String(pid),
75
+ "-o",
76
+ "command=",
77
+ ]);
56
78
  if (/\bAGENTOS_SERVER=live\b/.test(out)) return true;
57
79
  }
58
- const comm = execFileSync("ps", ["-p", String(pid), "-o", "comm="], {
59
- encoding: "utf8",
60
- stdio: ["ignore", "pipe", "ignore"],
61
- });
62
- return comm.trim() === "agentos-server";
63
- } catch {
64
- return false;
80
+ return (
81
+ (await probe("ps", ["-p", String(pid), "-o", "comm="])).trim() ===
82
+ "agentos-server"
83
+ );
84
+ } catch (e) {
85
+ return e instanceof ProbeTimeout;
65
86
  }
66
87
  }
67
88
 
@@ -35,7 +35,7 @@ export async function startDevServer(opts: {
35
35
  if (
36
36
  existing &&
37
37
  pidAlive(existing.pid) &&
38
- samePath(processCwd(existing.pid) ?? "", worktree)
38
+ samePath((await processCwd(existing.pid)) ?? "", worktree)
39
39
  )
40
40
  return { ...existing, reused: true };
41
41
 
@@ -1,18 +1,24 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { execFileSync } from "child_process";
4
3
  import { readLiveRecord } from "./liveServer";
5
- import { pidAlive, looksLive, processCwd, samePath } from "./processInfo";
4
+ import {
5
+ pidAlive,
6
+ looksLive,
7
+ processCwd,
8
+ samePath,
9
+ probe,
10
+ ProbeTimeout,
11
+ } from "./processInfo";
6
12
  import { readDevRecord, recordFile } from "./startDevServer";
7
13
 
8
14
  export interface StopDeps {
9
15
  kill: (pid: number, signal: NodeJS.Signals) => void;
10
- cwdOf: (pid: number) => string | null;
11
- looksLive: (pid: number) => boolean;
16
+ cwdOf: (pid: number) => string | null | Promise<string | null>;
17
+ looksLive: (pid: number) => boolean | Promise<boolean>;
12
18
  livePid: () => number | null;
13
19
  alive: (pid: number) => boolean;
14
20
  sleep: (ms: number) => Promise<void>;
15
- killTmux: (socket: string) => void;
21
+ killTmux: (socket: string) => void | Promise<void>;
16
22
  }
17
23
 
18
24
  const realDeps: StopDeps = {
@@ -22,16 +28,13 @@ const realDeps: StopDeps = {
22
28
  livePid: () => readLiveRecord()?.pid ?? null,
23
29
  alive: pidAlive,
24
30
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
25
- killTmux: (socket) => {
26
- const env = { ...process.env };
27
- delete env.TMUX;
31
+ killTmux: async (socket) => {
28
32
  try {
29
- execFileSync("tmux", ["-S", socket, "kill-server"], {
30
- env,
31
- stdio: "ignore",
32
- });
33
- } catch {
34
- // No server on it.
33
+ await probe("env", ["-u", "TMUX", "tmux", "-S", socket, "kill-server"]);
34
+ } catch (e) {
35
+ // No server on it, or tmux did not answer in time.
36
+ if (e instanceof ProbeTimeout)
37
+ console.warn(`[dev-server] tmux on ${socket} did not answer; left`);
35
38
  }
36
39
  },
37
40
  };
@@ -59,14 +62,14 @@ export async function stopDevServer(
59
62
  ok: false,
60
63
  message: `Refusing to stop pid ${rec.pid}: ${why}. Nothing was killed.`,
61
64
  });
62
- const cwd = d.cwdOf(rec.pid);
65
+ const cwd = await d.cwdOf(rec.pid);
63
66
  if (cwd === null || !samePath(cwd, worktree))
64
67
  return refuse(
65
68
  `its working directory (${cwd ?? "unknown"}) is not ${worktree}`
66
69
  );
67
70
  if (rec.pid === process.pid || rec.pid === process.ppid)
68
71
  return refuse("that is this command's own process");
69
- if (rec.pid === d.livePid() || d.looksLive(rec.pid))
72
+ if (rec.pid === d.livePid() || (await d.looksLive(rec.pid)))
70
73
  return refuse("it is the live AgentOS server");
71
74
 
72
75
  const signal = (s: NodeJS.Signals) => {
@@ -89,7 +92,7 @@ export async function stopDevServer(
89
92
  `tmux-${process.getuid?.() ?? 0}`,
90
93
  "default"
91
94
  );
92
- if (fs.existsSync(sock)) d.killTmux(sock);
95
+ if (fs.existsSync(sock)) await d.killTmux(sock);
93
96
  drop();
94
97
  return {
95
98
  ok: true,
@@ -1,3 +1,4 @@
1
+ import { agentosHome } from "./agentos-home";
1
2
  import { spawn, execFile } from "child_process";
2
3
  import { promisify } from "util";
3
4
  import fs from "fs";
@@ -12,7 +13,7 @@ const run = async (file: string, args: string[], cwd?: string) =>
12
13
  // A compose service is a name, never an option or more than one word.
13
14
  const COMPOSE_SERVICE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
14
15
 
15
- const LOGS_DIR = path.join(process.env.HOME || "~", ".agent-os", "logs");
16
+ const LOGS_DIR = agentosHome("logs");
16
17
 
17
18
  // Ensure logs directory exists
18
19
  if (!fs.existsSync(LOGS_DIR)) {
package/lib/done/index.ts CHANGED
@@ -28,7 +28,7 @@ import { deleteMergedRemote } from "./remote";
28
28
  import { settleWorktree, type WorktreeFate } from "./worktree";
29
29
  import { sessionMergePolicy } from "../tasks/finish";
30
30
  import { releasePorts } from "../ports";
31
- import { dropSessionDatabase } from "../project-config/database";
31
+ import { releaseSessionLeftovers } from "../sessions/leftovers";
32
32
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
33
33
  import { hostLink } from "../hosts/remote-api";
34
34
  import { doneHeldTask, isHeldRemote } from "../tasks/contract/held-calls";
@@ -70,7 +70,7 @@ async function stopAgent(s: Session): Promise<void> {
70
70
  clearQueue(s.id);
71
71
  releasePorts(s.id);
72
72
  // Never waited on: the row is read now, and Postgres can't hold this up.
73
- void dropSessionDatabase(s.id);
73
+ void releaseSessionLeftovers(s);
74
74
  }
75
75
 
76
76
  function worktreeLine(f: WorktreeFate): string {