@saadnvd1/agent-os 0.3.660 → 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 (76) hide show
  1. package/app/api/sessions/[id]/route.ts +3 -3
  2. package/app/api/sessions/[id]/summarize/route.ts +3 -2
  3. package/app/api/worktree-sweep/route.test.ts +19 -1
  4. package/app/api/worktree-sweep/route.ts +25 -6
  5. package/components/Health/SweepRuns.tsx +76 -1
  6. package/data/worktree-sweep/index.ts +15 -0
  7. package/lib/agentos-home.ts +11 -0
  8. package/lib/agents/launch.ts +6 -9
  9. package/lib/agents/server-env.ts +24 -0
  10. package/lib/artifacts/store.ts +2 -5
  11. package/lib/chat/drivers/claude.ts +3 -2
  12. package/lib/chat/drivers/codex.ts +3 -2
  13. package/lib/chat/drivers/copilot.ts +2 -1
  14. package/lib/chat/drivers/opencode.ts +3 -2
  15. package/lib/chat/drivers/pi-approvals.ts +2 -2
  16. package/lib/chat/drivers/pi.ts +3 -2
  17. package/lib/chat/worker/client.ts +18 -13
  18. package/lib/chat/worker/protocol.ts +2 -2
  19. package/lib/connect/config.ts +2 -3
  20. package/lib/db/backup.ts +2 -2
  21. package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -0
  22. package/lib/dev-instance/processInfo.ts +48 -27
  23. package/lib/dev-instance/startDevServer.ts +1 -1
  24. package/lib/dev-instance/stopDevServer.ts +20 -17
  25. package/lib/dev-servers.ts +2 -1
  26. package/lib/done/index.ts +2 -2
  27. package/lib/health/boots.ts +37 -0
  28. package/lib/health/ci.ts +23 -2
  29. package/lib/health/detectors.ts +2 -1
  30. package/lib/health/evidence.ts +1 -1
  31. package/lib/health/facts.ts +3 -3
  32. package/lib/health/fixtures.ts +1 -0
  33. package/lib/health/tick.ts +6 -1
  34. package/lib/health/types.ts +2 -0
  35. package/lib/hosts/attach.ts +8 -1
  36. package/lib/orchestration.ts +3 -1
  37. package/lib/orchestrator/home.ts +2 -5
  38. package/lib/orchestrator/shell-guard.ts +2 -0
  39. package/lib/orchestrator/write-guard.ts +3 -2
  40. package/lib/plans/features.ts +2 -2
  41. package/lib/pr-generation.ts +2 -1
  42. package/lib/program-status/claude-hooks.ts +2 -2
  43. package/lib/program-status/tap.ts +2 -2
  44. package/lib/project-config/database.ts +71 -16
  45. package/lib/project-config/session.ts +18 -0
  46. package/lib/security/tailnet-https.ts +2 -2
  47. package/lib/session-artifacts/collect.ts +18 -1
  48. package/lib/session-artifacts/keep.ts +2 -4
  49. package/lib/session-audit/store.ts +2 -5
  50. package/lib/session-logs/folder.ts +2 -3
  51. package/lib/sessions/launch.ts +2 -2
  52. package/lib/sessions/leftovers.ts +61 -0
  53. package/lib/simulator/agent-device.ts +2 -2
  54. package/lib/tasks/finish.ts +2 -2
  55. package/lib/tasks/import.ts +3 -5
  56. package/lib/tasks/move.ts +2 -2
  57. package/lib/tasks/moved-away.ts +2 -0
  58. package/lib/tasks/state.ts +7 -3
  59. package/lib/tasks/ui-brief.ts +2 -2
  60. package/lib/tmux/exec.ts +3 -1
  61. package/lib/worktree-deps/fastcopy.ts +2 -2
  62. package/lib/worktree-deps/spare.ts +2 -2
  63. package/lib/worktree-sweep/caches.ts +123 -0
  64. package/lib/worktree-sweep/databases.ts +222 -0
  65. package/lib/worktree-sweep/du.ts +13 -0
  66. package/lib/worktree-sweep/extras.ts +188 -0
  67. package/lib/worktree-sweep/findOrphans.ts +19 -8
  68. package/lib/worktree-sweep/report.ts +85 -0
  69. package/lib/worktree-sweep/runSweep.ts +46 -13
  70. package/lib/worktree-sweep/settings.ts +24 -0
  71. package/lib/worktree-sweep/state.ts +5 -0
  72. package/lib/worktree-sweep/store.ts +11 -0
  73. package/lib/worktree-sweep/types.ts +44 -1
  74. package/lib/worktrees.ts +3 -0
  75. package/package.json +2 -1
  76. 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
@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
8
8
  import { readFile } from "fs/promises";
9
9
  import { homedir } from "os";
10
10
  import { pasteText, tmux } from "@/lib/tmux/exec";
11
+ import { sessionBaseEnv, UNSET_SERVER_ENV } from "@/lib/agents/server-env";
11
12
 
12
13
  // Get Claude session ID from tmux environment
13
14
  async function getClaudeSessionId(tmuxSession: string): Promise<string | null> {
@@ -135,7 +136,7 @@ async function generateSummary(conversation: string): Promise<string> {
135
136
  const routed = await routedRun("helper");
136
137
  return new Promise((resolve, reject) => {
137
138
  const claude = spawn("claude", ["-p", prompt, "--model", routed.model], {
138
- env: { ...process.env, ...routed.env },
139
+ env: { ...sessionBaseEnv(), ...routed.env },
139
140
  stdio: ["pipe", "pipe", "pipe"],
140
141
  });
141
142
 
@@ -309,7 +310,7 @@ export async function POST(
309
310
 
310
311
  // Start new tmux session with Claude directly
311
312
  const isRoot = process.getuid?.() === 0;
312
- const envPrefix = isRoot ? "IS_SANDBOX=1 " : "";
313
+ const envPrefix = UNSET_SERVER_ENV + (isRoot ? "IS_SANDBOX=1 " : "");
313
314
  const claudeCmd = effectiveAutoApprove(session.auto_approve)
314
315
  ? `${envPrefix}claude --dangerously-skip-permissions`
315
316
  : "claude";
@@ -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";
@@ -10,6 +10,7 @@ import { runInBackground } from "../async-operations";
10
10
  import { trustPromptKeys } from "../tasks/state";
11
11
  import { BUS_BRIEF } from "./brief";
12
12
  import { selfUrl } from "./self-env";
13
+ import { sessionBaseEnv, UNSET_SERVER_ENV } from "./server-env";
13
14
  import { sessionPath } from "./tool-path";
14
15
  import { autoCompactEnv } from "../chat/refresh";
15
16
  import { subagentEnv } from "../models/subagents";
@@ -21,12 +22,8 @@ import {
21
22
  } from "../project-config/session";
22
23
 
23
24
  const execFileAsync = promisify(execFile);
24
- const PROMPTS_DIR = path.join(os.homedir(), ".agent-os", "prompts");
25
- export const BUS_BRIEF_FILE = path.join(
26
- os.homedir(),
27
- ".agent-os",
28
- "bus-brief.md"
29
- );
25
+ const PROMPTS_DIR = agentosHome("prompts");
26
+ export const BUS_BRIEF_FILE = agentosHome("bus-brief.md");
30
27
 
31
28
  export function ensureBusBrief(): void {
32
29
  fs.mkdirSync(path.dirname(BUS_BRIEF_FILE), { recursive: true });
@@ -110,7 +107,7 @@ export async function launchClaude(opts: {
110
107
  model_provider: opts.provider ?? null,
111
108
  });
112
109
  const mcpFlags = routedMcpFlags(opts.provider);
113
- const agent = `export PATH=${shellQuote(AOS_BIN_DIR)}:"$HOME/.local/bin:$PATH"; ${routing.exportKey} ${provider.command} ${flags} ${mcpFlags}${CLAUDE_STATUS_SETTINGS_FLAG} --append-system-prompt-file ${shellQuote(briefFile)} "$(cat ${shellQuote(promptFile)})"; exec "\${SHELL:-/bin/sh}" -l`;
110
+ const agent = `${UNSET_SERVER_ENV}export PATH=${shellQuote(AOS_BIN_DIR)}:"$HOME/.local/bin:$PATH"; ${routing.exportKey} ${provider.command} ${flags} ${mcpFlags}${CLAUDE_STATUS_SETTINGS_FLAG} --append-system-prompt-file ${shellQuote(briefFile)} "$(cat ${shellQuote(promptFile)})"; exec "\${SHELL:-/bin/sh}" -l`;
114
111
  await execFileAsync(
115
112
  "tmux",
116
113
  [
@@ -123,7 +120,7 @@ export async function launchClaude(opts: {
123
120
  ...envArgs({ ...agentEnv(opts.sessionId), ...routing.env }),
124
121
  agent,
125
122
  ],
126
- { cwd: opts.cwd }
123
+ { cwd: opts.cwd, env: sessionBaseEnv() }
127
124
  );
128
125
  runInBackground(
129
126
  () => acceptTrustPrompt(opts.tmuxName),
@@ -0,0 +1,24 @@
1
+ // Variables the server sets only for itself (`npm start`, `npm run dev`,
2
+ // the dev-instance spawner). A session that inherits them breaks the
3
+ // project it works in: NODE_ENV=production makes Vitest load React's
4
+ // production build, PORT makes a dev server bind the live one's port.
5
+ // AGENTOS_URL, AGENTOS_PORT and the session ids are not here: sessions need them.
6
+ export const SERVER_ONLY_ENV = [
7
+ "NODE_ENV",
8
+ "PORT",
9
+ "AGENTOS_SERVER",
10
+ "AGENTOS_DEV",
11
+ ] as const;
12
+
13
+ // Shell text that clears them inside a pane. `tmux new-session -e` can only
14
+ // add variables, and an already-running tmux server may hold them globally.
15
+ export const UNSET_SERVER_ENV = `unset ${SERVER_ONLY_ENV.join(" ")}; `;
16
+
17
+ // The server's environment as an agent or its tools should see it.
18
+ export function sessionBaseEnv(
19
+ env: NodeJS.ProcessEnv = process.env
20
+ ): NodeJS.ProcessEnv {
21
+ const out = { ...env };
22
+ for (const k of SERVER_ONLY_ENV) delete out[k];
23
+ return out;
24
+ }
@@ -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,3 +1,4 @@
1
+ import { sessionBaseEnv } from "../../agents/server-env";
1
2
  import { randomUUID } from "crypto";
2
3
  import {
3
4
  getSessionMessages,
@@ -145,7 +146,7 @@ export const claudeDriver: ChatDriver = {
145
146
  cwd,
146
147
  permissionMode: "bypassPermissions",
147
148
  allowDangerouslySkipPermissions: true,
148
- env: { ...process.env, ...env },
149
+ env: { ...sessionBaseEnv(), ...env },
149
150
  },
150
151
  });
151
152
  try {
@@ -235,7 +236,7 @@ export const claudeDriver: ChatDriver = {
235
236
  append: options.systemAppend,
236
237
  },
237
238
  env: {
238
- ...process.env,
239
+ ...sessionBaseEnv(),
239
240
  ...options.env,
240
241
  // When the agent is done with every turn it had queued: the end
241
242
  // of a turn that never sent its result.
@@ -1,3 +1,4 @@
1
+ import { sessionBaseEnv } from "../../agents/server-env";
1
2
  import type { ChatConversation, ChatDriver } from "../driver";
2
3
  import type { ChatImage, DriverEvent } from "../events";
3
4
  import { InputQueue } from "../queue";
@@ -16,7 +17,7 @@ export const codexDriver: ChatDriver = {
16
17
 
17
18
  async discover({ cwd, env }) {
18
19
  const rpc = new CodexRpc(
19
- { cwd, env: { ...process.env, ...env } },
20
+ { cwd, env: { ...sessionBaseEnv(), ...env } },
20
21
  { notify: () => {}, request: () => {}, exit: () => {} }
21
22
  );
22
23
  try {
@@ -51,7 +52,7 @@ export const codexDriver: ChatDriver = {
51
52
  let starting = false;
52
53
  let stopOnStart = false;
53
54
  const rpc: CodexRpc = new CodexRpc(
54
- { cwd: options.cwd, env: { ...process.env, ...options.env } },
55
+ { cwd: options.cwd, env: { ...sessionBaseEnv(), ...options.env } },
55
56
  {
56
57
  notify(method, params) {
57
58
  if (method === "item/started") approvals.sawItem(params.item as P);
@@ -1,3 +1,4 @@
1
+ import { sessionBaseEnv } from "../../agents/server-env";
1
2
  import type { ChatConversation, ChatDriver, ChatStartOptions } from "../driver";
2
3
  import type { ChatImage, DriverEvent } from "../events";
3
4
  import { InputQueue } from "../queue";
@@ -52,7 +53,7 @@ export const copilotDriver: ChatDriver = {
52
53
  const held: { text: string; images?: ChatImage[] }[] = [];
53
54
  const rpc: CopilotRpc = new CopilotRpc(
54
55
  copilotArgs(options),
55
- { cwd: options.cwd, env: { ...process.env, ...options.env } },
56
+ { cwd: options.cwd, env: { ...sessionBaseEnv(), ...options.env } },
56
57
  {
57
58
  notify(method, params) {
58
59
  if (method === "session/update")
@@ -1,3 +1,4 @@
1
+ import { sessionBaseEnv } from "../../agents/server-env";
1
2
  import type { ChatConversation, ChatDriver } from "../driver";
2
3
  import type { ChatImage, DriverEvent } from "../events";
3
4
  import { InputQueue } from "../queue";
@@ -46,7 +47,7 @@ export const openCodeDriver: ChatDriver = {
46
47
  atRest: false,
47
48
 
48
49
  async discover({ cwd, env }) {
49
- const server = new OpenCodeServer(cwd, { ...process.env, ...env });
50
+ const server = new OpenCodeServer(cwd, { ...sessionBaseEnv(), ...env });
50
51
  try {
51
52
  const [providers, commands] = await Promise.all([
52
53
  server.call<{ all: P[]; connected: string[] }>("GET", "/provider"),
@@ -79,7 +80,7 @@ export const openCodeDriver: ChatDriver = {
79
80
  const out = new InputQueue<DriverEvent>();
80
81
  const emit = (e: DriverEvent) => out.push(e);
81
82
  const server = new OpenCodeServer(options.cwd, {
82
- ...process.env,
83
+ ...sessionBaseEnv(),
83
84
  ...options.env,
84
85
  });
85
86
  let sessionId: string | null = null;
@@ -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,
@@ -1,3 +1,4 @@
1
+ import { sessionBaseEnv } from "../../agents/server-env";
1
2
  import type { ChatConversation, ChatDriver, ChatStartOptions } from "../driver";
2
3
  import type { ChatImage, DriverEvent } from "../events";
3
4
  import { InputQueue } from "../queue";
@@ -45,7 +46,7 @@ export const piDriver: ChatDriver = {
45
46
  async discover({ cwd, env }) {
46
47
  const rpc = new PiRpc(
47
48
  ["--no-session", "--offline"],
48
- { cwd, env: { ...process.env, ...env } },
49
+ { cwd, env: { ...sessionBaseEnv(), ...env } },
49
50
  { event: () => {}, ui: () => {}, exit: () => {} }
50
51
  );
51
52
  try {
@@ -82,7 +83,7 @@ export const piDriver: ChatDriver = {
82
83
  {
83
84
  cwd: options.cwd,
84
85
  env: {
85
- ...process.env,
86
+ ...sessionBaseEnv(),
86
87
  ...options.env,
87
88
  AGENTOS_PI_ACCESS_FILE: access.file,
88
89
  PI_OFFLINE: "1",
@@ -1,3 +1,4 @@
1
+ import { UNSET_SERVER_ENV, sessionBaseEnv } from "../../agents/server-env";
1
2
  /**
2
3
  * The server's side of a chat worker: starts one in tmux when none is
3
4
  * running, connects to its socket, and passes commands and events along.
@@ -224,7 +225,7 @@ async function spawnWorker(sessionId: string): Promise<void> {
224
225
  if (await tmuxHas(name)) return;
225
226
  fs.mkdirSync(workerDir(), { recursive: true, mode: 0o700 });
226
227
  const env: Record<string, string | undefined> = {
227
- ...process.env,
228
+ ...sessionBaseEnv(),
228
229
  DB_PATH: path.resolve(
229
230
  process.env.DB_PATH || path.join(process.cwd(), "agent-os.db")
230
231
  ),
@@ -233,18 +234,22 @@ async function spawnWorker(sessionId: string): Promise<void> {
233
234
  delete env.TMUX_PANE;
234
235
  fs.writeFileSync(envPath(sessionId), JSON.stringify(env), { mode: 0o600 });
235
236
  const entry = path.join(process.cwd(), "lib/chat/worker/main.ts");
236
- await execFileAsync("tmux", [
237
- "new-session",
238
- "-d",
239
- "-s",
240
- name,
241
- "-c",
242
- process.cwd(),
243
- // The worker finds its env file by DB_PATH, so it can't come from there.
244
- "-e",
245
- `DB_PATH=${env.DB_PATH}`,
246
- `exec ${shellQuote(process.execPath)} --import tsx ${shellQuote(entry)} ${shellQuote(sessionId)} > ${shellQuote(path.join(workerDir(), `${sessionId}.log`))} 2>&1`,
247
- ]);
237
+ await execFileAsync(
238
+ "tmux",
239
+ [
240
+ "new-session",
241
+ "-d",
242
+ "-s",
243
+ name,
244
+ "-c",
245
+ process.cwd(),
246
+ // The worker finds its env file by DB_PATH, so it can't come from there.
247
+ "-e",
248
+ `DB_PATH=${env.DB_PATH}`,
249
+ `${UNSET_SERVER_ENV}exec ${shellQuote(process.execPath)} --import tsx ${shellQuote(entry)} ${shellQuote(sessionId)} > ${shellQuote(path.join(workerDir(), `${sessionId}.log`))} 2>&1`,
250
+ ],
251
+ { env: sessionBaseEnv() }
252
+ );
248
253
  }
249
254
 
250
255
  const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
@@ -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;