@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
@@ -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 {
@@ -6,6 +6,9 @@
6
6
  * next boot reads it back.
7
7
  */
8
8
 
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import path from "path";
9
12
  import { db } from "../db";
10
13
  import { gitSync } from "../git-exec";
11
14
  import { getSetting, putSetting } from "../settings-store";
@@ -62,6 +65,7 @@ export interface BootRow {
62
65
  head: string | null;
63
66
  prev_seen_at: number | null;
64
67
  prev_bg: string | null;
68
+ deploy: number | null;
65
69
  }
66
70
 
67
71
  export const bootsSince = (since: number) =>
@@ -76,4 +80,37 @@ export const recentBoots = (n = 6) =>
76
80
  .prepare(`SELECT * FROM health_boots ORDER BY started_at DESC LIMIT ?`)
77
81
  .all(n) as BootRow[];
78
82
 
83
+ export const DEPLOY_RESTART_FILE = path.join(
84
+ os.homedir(),
85
+ ".agent-os",
86
+ "autodeploy",
87
+ "restarted"
88
+ );
89
+
90
+ // When autodeploy last restarted the server (it writes epoch seconds once the
91
+ // server is back up), or null.
92
+ export function lastDeployRestart(file = DEPLOY_RESTART_FILE): number | null {
93
+ try {
94
+ const s = Number.parseInt(fs.readFileSync(file, "utf8").trim(), 10);
95
+ return Number.isFinite(s) && s > 0 ? s * 1000 : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ // Autodeploy writes its file up to the startup wait after the restart.
102
+ const DEPLOY_LOOKBACK_MS = 15 * MIN;
103
+
104
+ // Stamps the latest boot that lines up with a planned restart, so it isn't
105
+ // counted as an unexpected one. A crash loop during a deploy keeps its extra
106
+ // boots.
107
+ export function markDeployBoot(restartedAt: number | null): void {
108
+ if (restartedAt === null) return;
109
+ db.prepare(
110
+ `UPDATE health_boots SET deploy = 1 WHERE id = (
111
+ SELECT id FROM health_boots WHERE started_at BETWEEN ? AND ?
112
+ ORDER BY started_at DESC LIMIT 1)`
113
+ ).run(restartedAt - DEPLOY_LOOKBACK_MS, restartedAt + MIN);
114
+ }
115
+
79
116
  export const BOOT_WINDOW_MS = 24 * 60 * MIN;
package/lib/health/ci.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * How long CI takes, learned from what the orchestrator already sees. Each
3
3
  * diff it reads every open PR's verdict; the first time a head is pending is
4
4
  * when its run started (to within a diff), and the first time it isn't is
5
- * when it settled. No extra gh call.
5
+ * when it settled. No extra gh call. A row lives only while its PR is open.
6
6
  */
7
7
 
8
8
  import { db } from "../db";
@@ -19,6 +19,7 @@ export function observeCi(
19
19
  now: number
20
20
  ): void {
21
21
  db.transaction(() => {
22
+ const open = new Set<string>();
22
23
  for (const f of facts) {
23
24
  const pr = f.task?.pr;
24
25
  const project = f.project;
@@ -31,6 +32,7 @@ export function observeCi(
31
32
  ).run(project, pr.number);
32
33
  continue;
33
34
  }
35
+ open.add(`${project}#${pr.number}`);
34
36
  if (pr.checks === "pending") {
35
37
  // A newer head replaces the run still pending on an older one.
36
38
  db.prepare(
@@ -50,13 +52,32 @@ export function observeCi(
50
52
  ).run(now, project, pr.number, head);
51
53
  }
52
54
  }
55
+ // A PR that left the facts (done, archived) can't be seen merging.
56
+ const rows = db
57
+ .prepare(
58
+ `SELECT project_id, pr_number FROM ci_runs
59
+ WHERE workspace_id = ? AND settled_at IS NULL`
60
+ )
61
+ .all(workspaceId) as { project_id: string; pr_number: number }[];
62
+ for (const r of rows)
63
+ if (!open.has(`${r.project_id}#${r.pr_number}`))
64
+ db.prepare(
65
+ `DELETE FROM ci_runs WHERE project_id = ? AND pr_number = ?
66
+ AND settled_at IS NULL`
67
+ ).run(r.project_id, r.pr_number);
53
68
  })();
54
69
  }
55
70
 
56
71
  export function pendingCi(now: number): CiFact[] {
57
72
  const rows = db
58
73
  .prepare(
59
- `SELECT * FROM ci_runs WHERE settled_at IS NULL AND pending_since >= ?`
74
+ `SELECT r.* FROM ci_runs r
75
+ WHERE r.settled_at IS NULL AND r.pending_since >= ?
76
+ AND NOT EXISTS (
77
+ SELECT 1 FROM session_rows s JOIN projects p ON p.id = s.project_id
78
+ WHERE p.name = r.project_id AND s.pr_number = r.pr_number
79
+ AND (s.pr_status IN ('merged', 'closed')
80
+ OR s.task_status = 'merged'))`
60
81
  )
61
82
  .all(now - LOST_AFTER_MS) as {
62
83
  project_id: string;
@@ -90,12 +90,13 @@ export function restarts(f: HealthFacts, c: HealthConfig): Trip[] {
90
90
  (t) => f.now - t <= c.restartsWindowMinutes * MIN
91
91
  );
92
92
  if (recent.length <= c.restartsMax) return [];
93
+ const deploys = f.deploys.length;
93
94
  return [
94
95
  trip({
95
96
  check: "restarts",
96
97
  subject: "server",
97
98
  severity: "high",
98
- line: `AgentOS restarted ${recent.length} times in the last ${c.restartsWindowMinutes} minutes.`,
99
+ line: `AgentOS restarted ${recent.length} times in the last ${c.restartsWindowMinutes} minutes${deploys ? ` (besides ${deploys} autodeploy ${deploys === 1 ? "restart" : "restarts"})` : ""}.`,
99
100
  detail: { boots: recent },
100
101
  }),
101
102
  ];
@@ -96,7 +96,7 @@ function pipelineSnapshot(f: HealthFacts): string {
96
96
  lines.push("Recent server starts:");
97
97
  for (const b of recentBoots(6))
98
98
  lines.push(
99
- `- ${ago(f.now, b.started_at)}${b.head ? ` at ${b.head}` : ""}${b.prev_seen_at ? `; the previous run was last seen ${ago(f.now, b.prev_seen_at)}` : ""}`
99
+ `- ${ago(f.now, b.started_at)}${b.deploy ? " (autodeploy restart)" : ""}${b.head ? ` at ${b.head}` : ""}${b.prev_seen_at ? `; the previous run was last seen ${ago(f.now, b.prev_seen_at)}` : ""}`
100
100
  );
101
101
  lines.push(
102
102
  `Server errors in the last 10 minutes: ${f.errors.recent} (usual ${Math.round(f.errors.baselineMean)}).`
@@ -144,6 +144,7 @@ function armed(): ArmedFact[] {
144
144
  }
145
145
 
146
146
  export function gatherFacts(now: number, c: HealthConfig): HealthFacts {
147
+ const recent = bootsSince(now - c.restartsWindowMinutes * MIN);
147
148
  return {
148
149
  now,
149
150
  machines: machines(now),
@@ -151,9 +152,8 @@ export function gatherFacts(now: number, c: HealthConfig): HealthFacts {
151
152
  bg: backgroundJobs(now, c),
152
153
  armed: armed(),
153
154
  ci: pendingCi(now),
154
- boots: bootsSince(now - c.restartsWindowMinutes * MIN).map(
155
- (b) => b.started_at
156
- ),
155
+ boots: recent.filter((b) => !b.deploy).map((b) => b.started_at),
156
+ deploys: recent.filter((b) => b.deploy).map((b) => b.started_at),
157
157
  errors: errorRate(now),
158
158
  };
159
159
  }
@@ -15,6 +15,7 @@ export const facts = (over: Partial<HealthFacts> = {}): HealthFacts => ({
15
15
  armed: [],
16
16
  ci: [],
17
17
  boots: [],
18
+ deploys: [],
18
19
  errors: { recent: 0, baselineMean: 0 },
19
20
  ...over,
20
21
  });
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { runClaude } from "../orchestrator/claude-cli";
9
- import { leaveHeartbeat } from "./boots";
9
+ import { lastDeployRestart, leaveHeartbeat, markDeployBoot } from "./boots";
10
10
  import { healthConfig, type HealthConfig } from "./config";
11
11
  import { runDetectors } from "./detectors";
12
12
  import { gatherFacts, liveBackgroundJobs } from "./facts";
@@ -134,6 +134,11 @@ export function healthTick(
134
134
  if (now - last < EVERY_MS) return [];
135
135
  last = now;
136
136
  const c = deps.config();
137
+ try {
138
+ markDeployBoot(lastDeployRestart());
139
+ } catch (error) {
140
+ console.error("[health] deploy restarts:", error);
141
+ }
137
142
  const facts = deps.facts(now, c);
138
143
  const found = recordTrips(runDetectors(facts, c), TICK_CHECKS, now);
139
144
  for (const row of found.closed) deliverClosed(row, now, deps.route);
@@ -105,6 +105,8 @@ export interface HealthFacts {
105
105
  ci: CiFact[];
106
106
  // Server start times inside the restart window.
107
107
  boots: number[];
108
+ // Autodeploy's planned restarts inside the same window.
109
+ deploys: number[];
108
110
  errors: { recent: number; baselineMean: number };
109
111
  }
110
112
 
@@ -1,5 +1,12 @@
1
1
  import { SSH_OPTIONS, loginShellCommand, shellQuote } from "./ssh";
2
2
  import { isValidTmuxName } from "./tmux-name";
3
+ import { SERVER_ONLY_ENV } from "../agents/server-env";
4
+
5
+ // For a shared tmux server that already holds them: attach is the one path
6
+ // that cannot prefix the pane command.
7
+ const UNSET_GLOBAL_ENV = SERVER_ONLY_ENV.map(
8
+ (k) => `set-environment -gu ${k} \\;`
9
+ ).join(" ");
3
10
 
4
11
  export interface AttachSpec {
5
12
  sessionName: string;
@@ -28,7 +35,7 @@ export function buildTmuxAttachCommand(spec: AttachSpec): string {
28
35
  }
29
36
  // Commands chained after an attaching command only run once the client
30
37
  // detaches, so options go first; start-server keeps a fresh server alive.
31
- const prefix = "tmux start-server \\; set -g mouse on \\;";
38
+ const prefix = `tmux start-server \\; set -g mouse on \\; ${UNSET_GLOBAL_ENV}`;
32
39
  if (spec.attachOnly) {
33
40
  // Quoted: zsh expands a bare "=word" to a command path.
34
41
  return `${prefix} attach-session -t ${shellQuote(`=${spec.sessionName}`)}`;
@@ -15,6 +15,7 @@ import { resolveModelForAgent } from "./model-catalog";
15
15
  import { type AgentType, getProvider } from "./providers";
16
16
  import { statusDetector } from "./status-detector";
17
17
  import { wrapWithBanner } from "./banner";
18
+ import { UNSET_SERVER_ENV } from "./agents/server-env";
18
19
  import { runInBackground } from "./async-operations";
19
20
  import { tmux } from "./tmux/exec";
20
21
  import { endTmuxSession } from "./tmux/end";
@@ -168,7 +169,8 @@ export async function spawnWorker(
168
169
 
169
170
  // Create tmux session with the agent and banner
170
171
  const agentCmd = `${provider.command} ${flagsStr}`;
171
- const newSessionCmd = wrapWithBanner(agentCmd, provider.preamble);
172
+ const newSessionCmd =
173
+ UNSET_SERVER_ENV + wrapWithBanner(agentCmd, provider.preamble);
172
174
  try {
173
175
  await tmux(["set", "-g", "mouse", "on"]).catch(() => {});
174
176
  // The last argument is the agent's command line, which tmux runs.
@@ -5,10 +5,9 @@
5
5
  * sessions.
6
6
  */
7
7
 
8
+ import { agentosHome } from "../agentos-home";
8
9
  import { resolveRoleOrDefault } from "../models/resolve";
9
10
  import fs from "fs";
10
- import os from "os";
11
- import path from "path";
12
11
  import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
13
12
  import { db, type Session } from "../db";
14
13
  import { getWorkspace } from "../workspaces";
@@ -16,9 +15,7 @@ import { refuseRealStateUnderTest } from "../test-home-guard";
16
15
 
17
16
  // It works through tools, not in a repo: a scratch folder of its own.
18
17
  export const orchestratorDir = (workspaceId: string) =>
19
- refuseRealStateUnderTest(
20
- path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId)
21
- );
18
+ refuseRealStateUnderTest(agentosHome("orchestrators", workspaceId));
22
19
 
23
20
  export function getOrchestrator(workspaceId: string): Session | null {
24
21
  return (
@@ -229,6 +229,8 @@ function aosRefusal(args: string[]): string | null {
229
229
  resurrect: T.resurrect,
230
230
  stop: T.stop,
231
231
  };
232
+ if (sub === "sweep" && args.includes("--arm"))
233
+ return "Refused: `aos sweep --arm` turns on removing files and databases, which a person decides: Health, Clean-up, Turn on. `--dry-run` and `--disarm` are fine.";
232
234
  if (sub && tool[sub])
233
235
  return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
234
236
  return null;
@@ -12,6 +12,7 @@
12
12
  * `..` resolved the way the system will resolve it.
13
13
  */
14
14
 
15
+ import { agentosHome } from "../agentos-home";
15
16
  import { execFile } from "child_process";
16
17
  import fs from "fs";
17
18
  import os from "os";
@@ -296,9 +297,9 @@ export async function orchestratorWriteBounds(
296
297
  memory: [...new Set([cwd, await physicalPath(cwd)])].map((c) =>
297
298
  path.join(os.homedir(), ".claude", "projects", mangleCwd(c), "memory")
298
299
  ),
299
- audits: path.join(os.homedir(), ".agent-os", "audits"),
300
+ audits: agentosHome("audits"),
300
301
  quick: quickWorktrees(session.id),
301
- state: path.join(os.homedir(), ".agent-os"),
302
+ state: agentosHome(),
302
303
  repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
303
304
  };
304
305
  }
@@ -1,6 +1,7 @@
1
1
  // A plan that split into pieces: the feature, its pieces, and the one-time
2
2
  // checks that turn a plan file into rows. A one-piece plan makes none.
3
3
 
4
+ import { agentosHome } from "../agentos-home";
4
5
  import fs from "fs";
5
6
  import os from "os";
6
7
  import path from "path";
@@ -61,8 +62,7 @@ export interface FeaturePiece {
61
62
  const expandHome = (p: string) => p.replace(/^~/, os.homedir());
62
63
 
63
64
  export const plansDir = () =>
64
- process.env.AGENTOS_PLANS_DIR ||
65
- path.join(os.homedir(), ".agent-os", "plans");
65
+ process.env.AGENTOS_PLANS_DIR || agentosHome("plans");
66
66
  export const planPathOf = (featureId: string) =>
67
67
  path.join(plansDir(), `${featureId}.md`);
68
68
 
@@ -1,3 +1,4 @@
1
+ import { sessionBaseEnv } from "./agents/server-env";
1
2
  import { routedRun } from "./models/launch";
2
3
  import { execFile } from "child_process";
3
4
  import { git as runGit } from "./git-exec";
@@ -163,7 +164,7 @@ async function claudePrint(cwd: string, prompt: string): Promise<string> {
163
164
  ["--print", "--model", routed.model],
164
165
  {
165
166
  cwd,
166
- env: { ...process.env, ...routed.env },
167
+ env: { ...sessionBaseEnv(), ...routed.env },
167
168
  encoding: "utf-8",
168
169
  timeout: 30_000,
169
170
  killSignal: "SIGKILL",
@@ -1,9 +1,9 @@
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 { NO_ATTRIBUTION } from "../agents/attribution";
5
5
 
6
- const DIR = path.join(os.homedir(), ".agent-os");
6
+ const DIR = agentosHome();
7
7
  const HOOK = path.join(DIR, "bin", "agentos-status");
8
8
  const SETTINGS = path.join(DIR, "claude-status-hooks.json");
9
9
  const SUBAGENT_HOOK = path.join(DIR, "bin", "agentos-subagent-model.mjs");
@@ -9,9 +9,9 @@
9
9
  * Remote-host sessions are not tapped: their output never passes through
10
10
  * this machine except while attached. They keep reading the screen.
11
11
  */
12
+ import { agentosHome } from "../agentos-home";
12
13
  import fs from "fs";
13
14
  import net from "net";
14
- import os from "os";
15
15
  import path from "path";
16
16
  import { execFile } from "child_process";
17
17
  import { promisify } from "util";
@@ -50,7 +50,7 @@ const replied = new Map<string, number>();
50
50
 
51
51
  // In a directory only this user can enter: only they can write reports.
52
52
  export function tapSocketPath(port: number): string {
53
- return path.join(os.homedir(), ".agent-os", "run", `osc-${port}.sock`);
53
+ return agentosHome("run", `osc-${port}.sock`);
54
54
  }
55
55
 
56
56
  async function foreground(name: string): Promise<string | undefined> {