@saadnvd1/agent-os 0.3.587 → 0.3.591

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.
@@ -163,7 +163,8 @@ export function ComposerBody({
163
163
  insertMarkdown(editor, content, insert.append);
164
164
  }, [insert, editor]);
165
165
 
166
- const query = slashQuery(text);
166
+ const slash = slashQuery(caret.before);
167
+ const query = slash?.query ?? null;
167
168
  const mentions = useMentions(editor, caret, query === null, requestFiles);
168
169
  const matches = useMemo(() => {
169
170
  if (query === null) return [];
@@ -173,8 +174,9 @@ export function ComposerBody({
173
174
  ];
174
175
  return rankCommands(query, all);
175
176
  }, [query, commands, onSetModel]);
176
- // One menu at a time: "/" commands at the start, "@" files at the caret.
177
- const menuKey = query !== null ? `/${text}` : mentions.key;
177
+ // One menu at a time: "/" commands or "@" files at the caret.
178
+ const menuKey =
179
+ query !== null ? `/${caret.pos}:${caret.before}` : mentions.key;
178
180
  const menuOpen = menuKey !== null && dismissed !== menuKey;
179
181
  const options: MenuOption[] =
180
182
  query !== null
@@ -205,12 +207,18 @@ export function ComposerBody({
205
207
  );
206
208
 
207
209
  const pickCommand = (command: ChatCommand) => {
210
+ if (!editor || !slash) return;
211
+ // The "/" and what follows it, up to the caret.
212
+ const from = caret.pos - (caret.before.length - slash.start);
213
+ const chain = editor.chain().focus().deleteRange({ from, to: caret.pos });
208
214
  if (command === MODEL_COMMAND) {
209
- setText("");
215
+ chain.run();
210
216
  setModelOpen(true);
211
217
  return;
212
218
  }
213
- setText(insertCommand(command.name), true);
219
+ chain
220
+ .insertContent({ type: "text", text: insertCommand(command.name) })
221
+ .run();
214
222
  };
215
223
  const pick = (index: number) => {
216
224
  if (query !== null) {
package/lib/bus/format.ts CHANGED
@@ -15,16 +15,19 @@ export interface BusMessageView {
15
15
  }
16
16
 
17
17
  const INLINE_LIMIT = 600;
18
+ // A relayed line stays under the linked machine's own cap (tasks/[id]/message).
19
+ export const RELAY_LIMIT = 30_000;
18
20
 
19
21
  // Enough of a session id to address it (aos send takes a unique prefix).
20
22
  export const shortId = (id: string) => id.slice(0, 8);
21
23
 
22
- // Typed into the recipient's terminal. Long bodies are left for `aos inbox`.
23
- export function wakeLine(m: {
24
- fromName: string;
25
- fromId: string | null;
26
- body: string;
27
- }): string {
24
+ // Typed into the recipient's terminal. Long bodies are left for `aos inbox`,
25
+ // unless `inline`: the recipient's AgentOS is another machine's, whose inbox
26
+ // never held the message, so the whole body goes in the line.
27
+ export function wakeLine(
28
+ m: { fromName: string; fromId: string | null; body: string },
29
+ opts: { inline?: boolean } = {}
30
+ ): string {
28
31
  const short = m.fromId ? shortId(m.fromId) : null;
29
32
  const who = short
30
33
  ? `"${m.fromName}" (${short})`
@@ -32,8 +35,13 @@ export function wakeLine(m: {
32
35
  ? "the user (via AgentOS)"
33
36
  : `${m.fromName} (via AgentOS)`;
34
37
  const oneLine = m.body.replace(/\s*\n\s*/g, " ").trim();
35
- const text =
36
- oneLine.length <= INLINE_LIMIT
38
+ const text = opts.inline
39
+ ? `: ${
40
+ oneLine.length <= RELAY_LIMIT
41
+ ? oneLine
42
+ : `${oneLine.slice(0, RELAY_LIMIT)} [cut: ${oneLine.length - RELAY_LIMIT} more chars; ask the sender to resend the rest]`
43
+ }`
44
+ : oneLine.length <= INLINE_LIMIT
37
45
  ? `: ${oneLine}`
38
46
  : ` (${m.body.length} chars). Read it with: aos inbox`;
39
47
  // By id: a reply can't break when either side is renamed.
package/lib/bus/index.ts CHANGED
@@ -333,7 +333,12 @@ export async function sendMessage(opts: {
333
333
  await statusDetector.refreshCache();
334
334
  const delivery = await deliver(
335
335
  to,
336
- opts.line ?? wakeLine({ fromName, fromId: from?.id ?? null, body }),
336
+ opts.line ??
337
+ wakeLine(
338
+ { fromName, fromId: from?.id ?? null, body },
339
+ // The linked machine's inbox never held a long body.
340
+ { inline: isHeldRemote(to) }
341
+ ),
337
342
  {
338
343
  name: fromName,
339
344
  messageId: id,
@@ -1,10 +1,14 @@
1
1
  import type { ChatCommand } from "./events";
2
2
 
3
- // The command being typed, if the message is still just "/something".
4
- // Agents only run a command that opens the message.
5
- export function slashQuery(text: string): string | null {
6
- const m = /^\/(\S*)$/.exec(text);
7
- return m ? m[1].toLowerCase() : null;
3
+ // The /command being typed: a "/" at the start of a line or after whitespace,
4
+ // then the partial name, right up to the caret. Not "a/b" or "http://x".
5
+ // `start` is where the "/" sits in the text before the caret.
6
+ export function slashQuery(
7
+ beforeCaret: string
8
+ ): { query: string; start: number } | null {
9
+ const m = /(^|\s)\/([^\s/]*)$/.exec(beforeCaret);
10
+ if (!m) return null;
11
+ return { query: m[2].toLowerCase(), start: m.index + m[1].length };
8
12
  }
9
13
 
10
14
  function isSubsequence(q: string, s: string): boolean {
@@ -2325,6 +2325,22 @@ export const migrations: Migration[] = [
2325
2325
  installChangeTriggers(db, TABLES_WATCHED_CHAT_BOOKMARKS);
2326
2326
  },
2327
2327
  },
2328
+ {
2329
+ key: "20261014T2100_gate_failure_ask_held",
2330
+ up: (db) => {
2331
+ // When a failed-twice escalation started holding its ask until the
2332
+ // agent is idle and CI has settled; null is not held. Additive.
2333
+ const has = (
2334
+ db.prepare(`PRAGMA table_info(orchestrator_gate_failures)`).all() as {
2335
+ name: string;
2336
+ }[]
2337
+ ).some((c) => c.name === "ask_held_at");
2338
+ if (!has)
2339
+ db.exec(
2340
+ `ALTER TABLE orchestrator_gate_failures ADD COLUMN ask_held_at TEXT`
2341
+ );
2342
+ },
2343
+ },
2328
2344
  ];
2329
2345
 
2330
2346
  // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
@@ -3,9 +3,13 @@
3
3
  // gone; the ask is moved to the head it would merge, with a line on whether
4
4
  // the PR's patch changed. The approve button is bound to the commit shown,
5
5
  // so an answer from a screen that predates the move is refused (asks.ts).
6
+ // The head is read from GitHub, never taken from the facts (which can be
7
+ // older than the ask), and an ask never moves back to a commit it contains.
6
8
 
7
9
  import { db, type Session } from "../db";
10
+ import { isAncestor } from "../git-exec";
8
11
  import { hostLink } from "../hosts/remote-api";
12
+ import { prFor } from "../tasks/session";
9
13
  import { moveAskHead, openAsks, taskSubject } from "./asks";
10
14
  import type { SessionFacts } from "./facts";
11
15
  import { addNote } from "./notes";
@@ -21,16 +25,24 @@ export async function followAskHeads(
21
25
  ): Promise<void> {
22
26
  for (const ask of openAsks(workspaceId)) {
23
27
  const f = facts.find((x) => taskSubject(x.id) === ask.subject);
24
- const pr = f?.task?.pr;
25
- if (!ask.sha || pr?.state !== "OPEN" || !pr.head || pr.head === ask.sha)
28
+ const seen = f?.task?.pr;
29
+ if (
30
+ !ask.sha ||
31
+ seen?.state !== "OPEN" ||
32
+ !seen.head ||
33
+ seen.head === ask.sha
34
+ )
26
35
  continue;
27
36
  const task = db
28
37
  .prepare(`SELECT * FROM sessions WHERE id = ?`)
29
38
  .get(f!.id) as Session | undefined;
30
39
  if (!task || hostLink(task.host_id)) continue;
31
40
  try {
41
+ const pr = await prFor(task, true);
42
+ if (pr?.state !== "OPEN" || !pr.head || pr.head === ask.sha) continue;
32
43
  const repo = repoOf(task);
33
44
  const base = await fetchRefs(repo, task);
45
+ if (await isAncestor(repo, pr.head, ask.sha)) continue;
34
46
  const { same, note } = await comparePatches(repo, base, ask.sha, pr.head);
35
47
  const what = same ? `same patch (rebased onto ${base})` : note;
36
48
  const line = `Head moved from ${short(ask.sha)} to ${short(pr.head)}: ${what}. Approving merges ${short(pr.head)}.`;
@@ -2,26 +2,25 @@
2
2
  // it escalates), one escalation note in the orchestrator's chat, and the
3
3
  // task held so the orchestrator doesn't merge it.
4
4
 
5
- import { AskRefused, raiseAsk, workSubject } from "./asks";
5
+ import { AskRefused, EXTERNAL_PREFIX, raiseAsk, workSubject } from "./asks";
6
6
  import {
7
+ failedTwiceWhy,
7
8
  failureOf,
9
+ holdAsk,
8
10
  markEscalated,
9
11
  recordFailure,
10
12
  type GateOutcome,
11
13
  } from "./gates";
12
14
  import { addNote } from "./notes";
13
15
 
14
- export function escalate(
16
+ // Puts the ask on Saad's list (or notes why it couldn't be).
17
+ export function askSaadAbout(
15
18
  workspaceId: string,
16
19
  task: { id: string; name: string },
17
- gate: string,
18
20
  why: string,
19
21
  url: string,
20
- sha: string | null = null
21
- ): string {
22
- if (!failureOf(task.id, gate))
23
- recordFailure(workspaceId, task.id, gate, why, sha);
24
- markEscalated(task.id, gate);
22
+ sha: string | null
23
+ ): void {
25
24
  let created = false;
26
25
  try {
27
26
  created = raiseAsk({
@@ -47,26 +46,49 @@ export function escalate(
47
46
  `Saad must decide on ${task.name} (${url}): ${why}. The orchestrator won't merge it; it's on his asks list to approve, decline, or sign off or drop himself.`,
48
47
  "escalation"
49
48
  );
49
+ }
50
+
51
+ // `hold`: the task is held at once, but Saad's ask waits until its agent is
52
+ // idle and CI has settled, so it names the commit he'd merge (held-asks.ts).
53
+ export function escalate(
54
+ workspaceId: string,
55
+ task: { id: string; name: string },
56
+ gate: string,
57
+ why: string,
58
+ url: string,
59
+ sha: string | null = null,
60
+ opts: { hold?: boolean } = {}
61
+ ): string {
62
+ if (!failureOf(task.id, gate))
63
+ recordFailure(workspaceId, task.id, gate, why, sha);
64
+ markEscalated(task.id, gate);
65
+ if (opts.hold) {
66
+ holdAsk(task.id, gate);
67
+ return `Escalated to Saad: ${why}. His ask goes up once ${task.name}'s agent is idle and CI has settled on its latest head, so it names the commit he'd merge; his answer reaches you as an event. Don't retry; say so in your chat.`;
68
+ }
69
+ askSaadAbout(workspaceId, task, why, url, sha);
50
70
  return `Escalated to Saad: ${why}. It's on his asks list; his answer reaches you as an event. Don't retry; say so in your chat.`;
51
71
  }
52
72
 
53
73
  // Counts a gate's failure on this head against the task; the same gate
54
- // failing on a second, different head goes to Saad. The same head judged
74
+ // failing on a second, different head goes to Saad (a task's ask held until
75
+ // its agent is done; a PR no task owns has no agent to wait for). The same head judged
55
76
  // again, from any path, isn't another failure.
56
77
  export function countFailure(
57
78
  workspaceId: string,
58
79
  task: { id: string; name: string },
59
80
  o: GateOutcome,
60
81
  url: string,
61
- sha: string
82
+ sha: string,
83
+ opts: { hold?: boolean } = {}
62
84
  ): { line: string; escalated: boolean } {
63
85
  const n = recordFailure(workspaceId, task.id, o.gate, o.reason ?? "", sha);
64
86
  const line = `- ${o.gate} failed (${n === 1 ? "first" : "again"}): ${o.reason}`;
65
87
  if (n < 2 || failureOf(task.id, o.gate)?.escalated_at)
66
88
  return { line, escalated: false };
67
- const why = `the ${o.gate} gate failed on two different commits: ${o.reason}`;
89
+ const why = failedTwiceWhy(o.gate, o.reason ?? "");
68
90
  return {
69
- line: `${line}\n ${escalate(workspaceId, task, o.gate, why, url, sha)}`,
91
+ line: `${line}\n ${escalate(workspaceId, task, o.gate, why, url, sha, { hold: opts.hold ?? !task.id.startsWith(EXTERNAL_PREFIX) })}`,
70
92
  escalated: true,
71
93
  };
72
94
  }
@@ -215,6 +215,8 @@ export interface FailureRow {
215
215
  last_reason: string | null;
216
216
  escalated_at: string | null;
217
217
  last_sha: string | null;
218
+ // Set while the escalation's ask waits for the agent to finish (held-asks.ts).
219
+ ask_held_at: string | null;
218
220
  }
219
221
 
220
222
  // Counts one failure; returns the count now. With `sha`, a failure is
@@ -277,6 +279,30 @@ export function markEscalated(sessionId: string, gate: string): void {
277
279
  markStatusDirty(sessionId);
278
280
  }
279
281
 
282
+ export function holdAsk(sessionId: string, gate: string): void {
283
+ db.prepare(
284
+ `UPDATE orchestrator_gate_failures SET ask_held_at = COALESCE(ask_held_at, datetime('now')) WHERE session_id = ? AND gate = ?`
285
+ ).run(sessionId, gate);
286
+ }
287
+
288
+ export function releaseHeldAsks(sessionId: string): void {
289
+ db.prepare(
290
+ `UPDATE orchestrator_gate_failures SET ask_held_at = NULL WHERE session_id = ?`
291
+ ).run(sessionId);
292
+ }
293
+
294
+ export function heldAskFailures(workspaceId: string): FailureRow[] {
295
+ return db
296
+ .prepare(
297
+ `SELECT * FROM orchestrator_gate_failures
298
+ WHERE workspace_id = ? AND escalated_at IS NOT NULL AND ask_held_at IS NOT NULL`
299
+ )
300
+ .all(workspaceId) as FailureRow[];
301
+ }
302
+
303
+ export const failedTwiceWhy = (gate: string, reason: string) =>
304
+ `the ${gate} gate failed on two different commits: ${reason}`;
305
+
280
306
  export function escalations(sessionId: string): FailureRow[] {
281
307
  return db
282
308
  .prepare(
@@ -0,0 +1,117 @@
1
+ // A gate that failed twice holds the task at once, but Saad's ask waits
2
+ // until the task's agent is done: idle, nothing sent to it lately, and CI
3
+ // settled on its latest head. Then the ask goes up at that head, the commit
4
+ // he'd merge; if the head moves after that, followAskHeads moves the same ask.
5
+
6
+ import { db, type Session } from "../db";
7
+ import { prFor } from "../tasks/session";
8
+ import { latestApproval } from "./ask-approvals";
9
+ import { askSaadAbout } from "./escalate";
10
+ import { getCheck } from "./checks";
11
+ import { openBySubject, workSubject } from "./asks";
12
+ import { recentlyMessaged } from "./conditions";
13
+ import type { SessionFacts } from "./facts";
14
+ import {
15
+ failedTwiceWhy,
16
+ heldAskFailures,
17
+ releaseHeldAsks,
18
+ type FailureRow,
19
+ } from "./gates";
20
+ import { getOrchestrator } from "./home";
21
+ import { commitTime, fetchRefs, repoOf } from "./repo";
22
+ import { ciSettleIn, SETTLE_S } from "./task-state";
23
+
24
+ // Long enough for the orchestrator to send the findings that caused the
25
+ // escalation; the hour is the most a stuck agent can hide an escalation.
26
+ export const HOLD_MIN_MS = SETTLE_S * 1000;
27
+ export const HOLD_MAX_MS = 60 * 60 * 1000;
28
+
29
+ const short = (sha: string) => sha.slice(0, 7);
30
+ const sqliteMs = (t: string) => Date.parse(`${t.replace(" ", "T")}Z`);
31
+
32
+ export async function raiseHeldAsks(
33
+ workspaceId: string,
34
+ facts: SessionFacts[],
35
+ now = Date.now()
36
+ ): Promise<void> {
37
+ const byTask = new Map<string, FailureRow[]>();
38
+ for (const row of heldAskFailures(workspaceId))
39
+ byTask.set(row.session_id, [...(byTask.get(row.session_id) ?? []), row]);
40
+ if (!byTask.size) return;
41
+ const orch = getOrchestrator(workspaceId);
42
+ const messaged = orch ? recentlyMessaged(orch.id, now) : new Set<string>();
43
+ for (const [taskId, rows] of byTask) {
44
+ try {
45
+ await raiseFor(workspaceId, taskId, rows, facts, messaged, now);
46
+ } catch (error) {
47
+ console.error(`Raising ${taskId}'s held ask:`, error);
48
+ }
49
+ }
50
+ }
51
+
52
+ async function raiseFor(
53
+ workspaceId: string,
54
+ taskId: string,
55
+ rows: FailureRow[],
56
+ facts: SessionFacts[],
57
+ messaged: Set<string>,
58
+ now: number
59
+ ): Promise<void> {
60
+ const f = facts.find((x) => x.id === taskId);
61
+ const task = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(taskId) as
62
+ | Session
63
+ | undefined;
64
+ // Gone, merged or dropped: nothing left to ask. An open ask or an
65
+ // unspent approval already covers it (followAskHeads, heldVerdict).
66
+ if (
67
+ !task ||
68
+ task.task_status !== "running" ||
69
+ openBySubject(workspaceId, workSubject(taskId)) ||
70
+ latestApproval(workspaceId, workSubject(taskId))
71
+ )
72
+ return releaseHeldAsks(taskId);
73
+ const heldFor =
74
+ now - Math.min(...rows.map((r) => sqliteMs(r.ask_held_at as string)));
75
+ const overdue = heldFor >= HOLD_MAX_MS;
76
+ // No facts yet is a wait, but never past the hour.
77
+ if (!f && !overdue) return;
78
+ // A gh failure throws and keeps the hold: only GitHub saying the PR isn't
79
+ // open releases it.
80
+ const pr = await prFor(task, true, true);
81
+ if (pr?.state !== "OPEN" || !pr.head) return releaseHeldAsks(taskId);
82
+ if (!overdue && f) {
83
+ if (f.status === "running" || f.compacting) return;
84
+ if (messaged.has(taskId) || heldFor < HOLD_MIN_MS) return;
85
+ if (getCheck(taskId, pr.head, "review")?.status === "running") return;
86
+ const repo = repoOf(task);
87
+ // The head may be a commit this clone hasn't fetched yet.
88
+ await fetchRefs(repo, task);
89
+ const committedAt = await commitTime(repo, pr.head);
90
+ if (pr.checks === "pending") return;
91
+ if (pr.checks === "pass") {
92
+ const left = ciSettleIn({
93
+ workspaceId,
94
+ taskId,
95
+ sha: pr.head,
96
+ checkCount: pr.checkCount ?? 0,
97
+ committedAt,
98
+ now,
99
+ });
100
+ if (left > 0) return;
101
+ } else if (now / 1000 - committedAt < SETTLE_S) return;
102
+ }
103
+ const last = rows.find((r) => r.last_sha)?.last_sha;
104
+ const why = [
105
+ ...rows.map((r) => failedTwiceWhy(r.gate, r.last_reason ?? "")),
106
+ ...(last && last !== pr.head
107
+ ? [
108
+ `Asked at ${short(pr.head)}, its head after the fixes since ${short(last)}.`,
109
+ ]
110
+ : []),
111
+ ...(overdue
112
+ ? [`${task.name} was still working after an hour; this is its head now.`]
113
+ : []),
114
+ ].join("\n");
115
+ askSaadAbout(workspaceId, task, why, pr.url, pr.head);
116
+ releaseHeldAsks(taskId);
117
+ }
@@ -40,9 +40,12 @@ export function heldVerdict(
40
40
  text: `${task.name}: ${why}. Asked him again; don't retry.`,
41
41
  };
42
42
  }
43
+ const waiting = held.some((h) => h.ask_held_at)
44
+ ? " His ask goes up once its agent is idle and CI has settled on its latest head."
45
+ : "";
43
46
  return {
44
47
  ok: false,
45
48
  wait: false,
46
- text: `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}: ${held[0].last_reason}). Only he can merge it now, or approve it on his asks list.`,
49
+ text: `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}: ${held[0].last_reason}). Only he can merge it now, or approve it on his asks list.${waiting}`,
47
50
  };
48
51
  }
@@ -12,10 +12,13 @@ import path from "path";
12
12
  import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
13
13
  import { db, type Session } from "../db";
14
14
  import { getWorkspace } from "../workspaces";
15
+ import { refuseRealStateUnderTest } from "../test-home-guard";
15
16
 
16
17
  // It works through tools, not in a repo: a scratch folder of its own.
17
18
  export const orchestratorDir = (workspaceId: string) =>
18
- path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId);
19
+ refuseRealStateUnderTest(
20
+ path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId)
21
+ );
19
22
 
20
23
  export function getOrchestrator(workspaceId: string): Session | null {
21
24
  return (
@@ -17,6 +17,7 @@ import { settleStaleAsks } from "./ask-settle";
17
17
  import { brakesOn, liftBrake } from "./brakes";
18
18
  import { anyArmed, mergeArmed } from "./armed";
19
19
  import { followAskHeads } from "./ask-heads";
20
+ import { raiseHeldAsks } from "./held-asks";
20
21
  import { rebaseConflicting } from "./rebase";
21
22
  import { wakeTasks } from "./pr-wake";
22
23
  import { awayTick } from "../away/run";
@@ -79,6 +80,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
79
80
  await followAskHeads(workspaceId, facts).catch((error) =>
80
81
  console.error(`Following ask heads in ${workspaceId}:`, error)
81
82
  );
83
+ await raiseHeldAsks(workspaceId, facts).catch((error) =>
84
+ console.error(`Raising held asks in ${workspaceId}:`, error)
85
+ );
82
86
  await rebaseConflicting(workspaceId, facts);
83
87
  await mergeArmed(workspaceId, facts);
84
88
  })()
@@ -113,11 +113,10 @@ export async function flushUndelivered(
113
113
  ).run(m.id);
114
114
  continue;
115
115
  }
116
- const line = wakeLine({
117
- fromName: m.from_name,
118
- fromId: m.from_id,
119
- body: m.body,
120
- });
116
+ const line = wakeLine(
117
+ { fromName: m.from_name, fromId: m.from_id, body: m.body },
118
+ { inline: true }
119
+ );
121
120
  const res = await relayToHeldTask(task, line, m.from_name, m.id, false);
122
121
  if (res.state === "failed") return;
123
122
  db.prepare(
@@ -0,0 +1,23 @@
1
+ import os from "os";
2
+ import path from "path";
3
+
4
+ // Under the test runner, refuse a path inside the real ~/.agent-os: a test
5
+ // that resolves there would leave folders in the owner's state. The real
6
+ // home comes from the account, not $HOME, which the test setup replaces.
7
+ export function refuseRealStateUnderTest(p: string): string {
8
+ if (!process.env.VITEST) return p;
9
+ let real: string;
10
+ try {
11
+ real = os.userInfo().homedir;
12
+ } catch {
13
+ return p;
14
+ }
15
+ const state = path.join(real, ".agent-os");
16
+ const resolved = path.resolve(p);
17
+ if (resolved === state || resolved.startsWith(state + path.sep)) {
18
+ throw new Error(
19
+ `Refusing to use ${state} under the test runner (${resolved}): point HOME at a temp folder`
20
+ );
21
+ }
22
+ return p;
23
+ }
package/lib/worktrees.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  slugify,
13
13
  generateBranchName,
14
14
  } from "./git";
15
+ import { refuseRealStateUnderTest } from "./test-home-guard";
15
16
  import { installWorktreeHooks } from "./worktree-hooks";
16
17
  import { gitResult, isGitError, worktreeList } from "./git-exec";
17
18
  import { addWorktree } from "./worktree-add";
@@ -43,8 +44,13 @@ export async function mainCheckoutOf(worktreePath: string): Promise<string> {
43
44
  }
44
45
  }
45
46
 
47
+ /** Where task worktrees live: ~/.agent-os/worktrees. */
48
+ export function worktreesRoot(home = os.homedir()): string {
49
+ return refuseRealStateUnderTest(path.join(home, ".agent-os", "worktrees"));
50
+ }
51
+
46
52
  // Base directory for all worktrees
47
- export const WORKTREES_DIR = path.join(os.homedir(), ".agent-os", "worktrees");
53
+ export const WORKTREES_DIR = worktreesRoot();
48
54
 
49
55
  export interface WorktreeInfo {
50
56
  worktreePath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.587",
3
+ "version": "0.3.591",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"