@saadnvd1/agent-os 0.3.464 → 0.3.466

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 (57) hide show
  1. package/README.md +20 -0
  2. package/app/api/fresh-start/route.ts +5 -3
  3. package/app/api/git/commit/route.ts +4 -3
  4. package/app/api/git/file-content/route.ts +4 -15
  5. package/app/api/git/history/[hash]/diff/route.ts +1 -1
  6. package/app/api/git/history/[hash]/route.ts +1 -1
  7. package/app/api/git/history/route.ts +1 -1
  8. package/app/api/git/push/route.ts +2 -2
  9. package/app/api/sessions/[id]/pr/route.ts +2 -2
  10. package/components/Chat/ChatPanel.tsx +23 -1
  11. package/components/Chat/Messages.tsx +2 -3
  12. package/components/Chat/StaleChip.tsx +55 -0
  13. package/components/Workspaces/FreshStartForm.tsx +29 -6
  14. package/data/chat/useChat.ts +10 -2
  15. package/lib/away/deploy.ts +3 -7
  16. package/lib/away/run.ts +6 -5
  17. package/lib/build.ts +2 -6
  18. package/lib/bus/index.ts +3 -0
  19. package/lib/chat/client-send.ts +11 -3
  20. package/lib/chat/drivers/claude-mapper.ts +11 -1
  21. package/lib/chat/drivers/claude.ts +9 -1
  22. package/lib/chat/events.ts +14 -1
  23. package/lib/chat/files.ts +31 -9
  24. package/lib/chat/refresh.ts +14 -2
  25. package/lib/chat/runner.ts +8 -1
  26. package/lib/chat/stale.ts +95 -0
  27. package/lib/chat/worker/host.ts +133 -4
  28. package/lib/chat/worker/protocol.ts +3 -0
  29. package/lib/done/remote.ts +5 -6
  30. package/lib/done/testing.ts +2 -7
  31. package/lib/done/worktree.ts +2 -2
  32. package/lib/git-exec.ts +334 -0
  33. package/lib/git-history.ts +29 -41
  34. package/lib/git-status.ts +45 -124
  35. package/lib/git.ts +44 -159
  36. package/lib/lumifyhub/publish.ts +2 -2
  37. package/lib/orchestrator/deliver.ts +9 -1
  38. package/lib/orchestrator/diff.ts +15 -13
  39. package/lib/orchestrator/rebase.ts +20 -24
  40. package/lib/orchestrator/repo-slug.ts +4 -7
  41. package/lib/orchestrator/repo.ts +24 -35
  42. package/lib/pr-generation.ts +2 -1
  43. package/lib/pr.ts +16 -57
  44. package/lib/schedules/start.ts +1 -0
  45. package/lib/sessions/worktree-setup.ts +17 -13
  46. package/lib/stacks/start.ts +7 -11
  47. package/lib/tasks/branch-worktree.ts +3 -2
  48. package/lib/tasks/finish.ts +5 -6
  49. package/lib/tasks/gh.ts +5 -0
  50. package/lib/tasks/move-testing.ts +2 -2
  51. package/lib/tasks/move.ts +3 -2
  52. package/lib/tasks/project-ref.ts +12 -19
  53. package/lib/tasks/session.ts +5 -5
  54. package/lib/worktree-hooks.ts +3 -7
  55. package/lib/worktree-placed.ts +3 -10
  56. package/lib/worktrees.ts +4 -42
  57. package/package.json +1 -1
@@ -19,7 +19,6 @@
19
19
  * hears one event either way.
20
20
  */
21
21
 
22
- import { execFile } from "child_process";
23
22
  import fs from "fs";
24
23
  import os from "os";
25
24
  import path from "path";
@@ -28,6 +27,7 @@ import { sendMessage } from "../bus";
28
27
  import { hostLink } from "../hosts/remote-api";
29
28
  import type { TaskPR } from "../tasks/state";
30
29
  import { run } from "../tasks/gh";
30
+ import { git as runGit, gitFetch, revParse } from "../git-exec";
31
31
  import { withRestackedLine } from "../tasks/code-review";
32
32
  import { outputOf, type Runner } from "../stacks/git";
33
33
  import { restackedHeads, stackNotice } from "../stacks/restack";
@@ -145,7 +145,7 @@ export async function mergesCleanly(
145
145
  head: string
146
146
  ): Promise<boolean> {
147
147
  try {
148
- await run("git", ["merge-tree", "--write-tree", base, head], repo);
148
+ await runGit(repo, ["merge-tree", "--write-tree", base, head]);
149
149
  return true;
150
150
  } catch (error) {
151
151
  if ((error as { code?: unknown }).code === 1) return false;
@@ -163,14 +163,11 @@ async function rebaseOne(
163
163
  const repo = repoOf(task);
164
164
  const branch = task.base_branch || "main";
165
165
  // A failed fetch throws: nothing is judged against a stale base.
166
- await run(
167
- "git",
168
- ["fetch", "--quiet", "--", "origin", branch, task.branch_name!],
169
- repo,
170
- 60000
171
- );
166
+ await gitFetch(repo, ["--quiet", "--", "origin", branch, task.branch_name!], {
167
+ timeout: 60000,
168
+ });
172
169
  const onto = `origin/${branch}`;
173
- const baseTip = (await run("git", ["rev-parse", onto], repo)).trim();
170
+ const baseTip = await revParse(repo, onto);
174
171
  const attempt = `${pr.head}:${baseTip}`;
175
172
  if (tried.get(task.id) === attempt) return;
176
173
 
@@ -344,22 +341,21 @@ async function rebaseAt(
344
341
 
345
342
  // The PR's own patch against the base, as `git patch-id` sees it.
346
343
  async function patchId(repo: string, range: string): Promise<string | null> {
347
- const diff = await run(
348
- "git",
349
- ["diff", "--no-color", "--no-ext-diff", "--full-index", range],
350
- repo
351
- ).catch(() => null);
344
+ const diff = await runGit(repo, [
345
+ "diff",
346
+ "--no-color",
347
+ "--no-ext-diff",
348
+ "--full-index",
349
+ range,
350
+ ]).catch(() => null);
352
351
  if (!diff) return null;
353
- return new Promise((resolve) => {
354
- const child = execFile(
355
- "git",
356
- ["patch-id", "--verbatim"],
357
- { cwd: repo, timeout: 30000 },
358
- (error, out) => resolve(error ? null : out.split(" ")[0] || null)
359
- );
360
- child.stdin?.on("error", () => {});
361
- child.stdin?.end(diff);
362
- });
352
+ return runGit(repo, ["patch-id", "--verbatim"], {
353
+ input: diff,
354
+ timeout: 30000,
355
+ }).then(
356
+ (out) => out.split(" ")[0] || null,
357
+ () => null
358
+ );
363
359
  }
364
360
 
365
361
  // The review of the head before the rebase, carried to the new head when
@@ -4,7 +4,7 @@
4
4
  // of its projects'.
5
5
 
6
6
  import type { Project } from "../db";
7
- import { run } from "../tasks/gh";
7
+ import { git } from "../git-exec";
8
8
  import { expandHome } from "../tasks/session";
9
9
  import { workspaceProjects } from "./brief";
10
10
 
@@ -24,12 +24,9 @@ const slugs = new Map<string, { at: number; slug: string | null }>();
24
24
  export async function repoSlug(dir: string): Promise<string | null> {
25
25
  const hit = slugs.get(dir);
26
26
  if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
27
- const url = await run(
28
- "git",
29
- ["config", "--get", "remote.origin.url"],
30
- dir,
31
- 10000
32
- ).catch(() => "");
27
+ const url = await git(dir, ["config", "--get", "remote.origin.url"], {
28
+ timeout: 10000,
29
+ }).catch(() => "");
33
30
  const slug = slugOfRemote(url);
34
31
  slugs.set(dir, { at: Date.now(), slug });
35
32
  return slug;
@@ -8,7 +8,7 @@ import path from "path";
8
8
  import type { Session } from "../db";
9
9
  import { SKILL_DIRS } from "../agents/skill-dirs";
10
10
  import { getProject } from "../projects";
11
- import { run } from "../tasks/gh";
11
+ import { git, gitFetch, gitOk, worktreeAdd, worktreeRemove } from "../git-exec";
12
12
  import { expandHome } from "../tasks/session";
13
13
 
14
14
  export function repoOf(task: Session): string {
@@ -23,26 +23,21 @@ export async function fetchRefs(
23
23
  task: Pick<Session, "base_branch" | "branch_name">
24
24
  ): Promise<string> {
25
25
  const base = task.base_branch || "main";
26
- await run(
27
- "git",
28
- ["fetch", "--quiet", "--", "origin", base, task.branch_name ?? base],
29
- repo
30
- ).catch(() => {});
26
+ await gitFetch(repo, [
27
+ "--quiet",
28
+ "--",
29
+ "origin",
30
+ base,
31
+ task.branch_name ?? base,
32
+ ]).catch(() => {});
31
33
  const remote = `origin/${base}`;
32
- const has = await run(
33
- "git",
34
- ["rev-parse", "--verify", "--quiet", remote],
35
- repo
36
- ).then(
37
- () => true,
38
- () => false
39
- );
34
+ const has = await gitOk(repo, ["rev-parse", "--verify", "--quiet", remote]);
40
35
  return has ? remote : base;
41
36
  }
42
37
 
43
38
  // When the commit was made, in seconds.
44
39
  export async function commitTime(repo: string, sha: string): Promise<number> {
45
- const out = await run("git", ["show", "-s", "--format=%ct", sha], repo);
40
+ const out = await git(repo, ["show", "-s", "--format=%ct", sha]);
46
41
  return Number(out.trim()) || 0;
47
42
  }
48
43
 
@@ -61,25 +56,21 @@ export async function baseReviewSkill(
61
56
  repo: string,
62
57
  base: string
63
58
  ): Promise<{ path: string; text: string } | null> {
64
- const listed = await run(
65
- "git",
66
- [
67
- "ls-tree",
68
- "-r",
69
- "--name-only",
70
- base,
71
- "--",
72
- ".claude/agents",
73
- ...SKILL_DIRS,
74
- ],
75
- repo
76
- ).catch(() => "");
59
+ const listed = await git(repo, [
60
+ "ls-tree",
61
+ "-r",
62
+ "--name-only",
63
+ base,
64
+ "--",
65
+ ".claude/agents",
66
+ ...SKILL_DIRS,
67
+ ]).catch(() => "");
77
68
  const files = listed.split("\n").map((f) => f.trim());
78
69
  const agents = files.filter((f) => AGENT_RULES.test(f));
79
70
  if (agents.length) {
80
71
  const parts = await Promise.all(
81
72
  agents.map(async (f) => {
82
- const text = await run("git", ["show", `${base}:${f}`], repo);
73
+ const text = await git(repo, ["show", `${base}:${f}`]);
83
74
  return `## ${f.slice(".claude/agents/".length, -3)}\n${rulesOf(text)}`;
84
75
  })
85
76
  );
@@ -94,7 +85,7 @@ export async function baseReviewSkill(
94
85
  /(^|\/)[^/]*review[^/]*(\/SKILL\.md|\.md)$/i.test(f)
95
86
  );
96
87
  if (!file) return null;
97
- const text = await run("git", ["show", `${base}:${file}`], repo);
88
+ const text = await git(repo, ["show", `${base}:${file}`]);
98
89
  return { path: file, text: text.slice(0, 20000) };
99
90
  }
100
91
 
@@ -113,8 +104,8 @@ export async function checkout(repo: string, sha: string): Promise<string> {
113
104
  const dir = fs.realpathSync(
114
105
  fs.mkdtempSync(path.join(os.tmpdir(), "aos-review-"))
115
106
  );
116
- await run("git", ["worktree", "add", "--detach", dir, sha], repo);
117
- const staged = await run("git", ["ls-files", "-s"], dir);
107
+ await worktreeAdd(repo, ["--detach", dir, sha]);
108
+ const staged = await git(dir, ["ls-files", "-s"]);
118
109
  for (const line of staged.split("\n"))
119
110
  if (line.startsWith("120000 "))
120
111
  fs.rmSync(path.join(dir, line.split("\t")[1]), { force: true });
@@ -122,8 +113,6 @@ export async function checkout(repo: string, sha: string): Promise<string> {
122
113
  }
123
114
 
124
115
  export async function removeCheckout(repo: string, dir: string) {
125
- await run("git", ["worktree", "remove", "--force", dir], repo).catch(
126
- () => {}
127
- );
116
+ await worktreeRemove(repo, dir).catch(() => {});
128
117
  fs.rmSync(dir, { recursive: true, force: true });
129
118
  }
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "child_process";
2
+ import { git as runGit } from "./git-exec";
2
3
  import { run } from "./tasks/gh";
3
4
  import { UNTRUSTED_RULE, untrusted } from "./orchestrator/untrusted";
4
5
 
@@ -56,7 +57,7 @@ async function getGitContext(
56
57
 
57
58
  // Names and text here come from the repository; every one is an
58
59
  // argument to git, never shell text or an option.
59
- const git = (args: string[]) => run("git", args, workingDir, 10_000);
60
+ const git = (args: string[]) => runGit(workingDir, args, { timeout: 10_000 });
60
61
  const lines = (out: string) =>
61
62
  out
62
63
  .split("\n")
package/lib/pr.ts CHANGED
@@ -1,26 +1,8 @@
1
- import { execFileSync } from "child_process";
1
+ import { getDefaultBranch } from "./git";
2
+ import { currentBranch, git, gitPush, mergeBase } from "./git-exec";
2
3
  import { run } from "./tasks/gh";
3
4
  import { GhBackoffError, isRateLimit } from "./tasks/gh-limit";
4
5
 
5
- /** The remote's default branch, or "main" when it has none. */
6
- export function remoteDefaultBranch(workingDir: string): string {
7
- try {
8
- return execFileSync(
9
- "git",
10
- ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
11
- {
12
- cwd: workingDir,
13
- encoding: "utf-8",
14
- stdio: ["ignore", "pipe", "ignore"],
15
- }
16
- )
17
- .trim()
18
- .replace(/^origin\//, "");
19
- } catch {
20
- return "main";
21
- }
22
- }
23
-
24
6
  export interface PRInfo {
25
7
  number: number;
26
8
  url: string;
@@ -62,26 +44,20 @@ export async function getCommitsSinceBase(
62
44
  // Get the merge base
63
45
  // Names come from the repository (its remote's default branch): they
64
46
  // are arguments, never shell text or options.
65
- const mergeBase = (
66
- await run(
67
- "git",
68
- ["merge-base", "--end-of-options", baseBranch, "HEAD"],
69
- workingDir,
70
- 10_000
71
- )
72
- ).trim();
47
+ const base = await mergeBase(workingDir, baseBranch, "HEAD", {
48
+ timeout: 10_000,
49
+ });
73
50
 
74
51
  // Get commits since merge base
75
- const output = await run(
76
- "git",
52
+ const output = await git(
53
+ workingDir,
77
54
  [
78
55
  "log",
79
- `${mergeBase}..HEAD`,
56
+ `${base}..HEAD`,
80
57
  "--format=COMMIT_START%n%H%n%s%n%b%nCOMMIT_END",
81
58
  "--",
82
59
  ],
83
- workingDir,
84
- 10_000
60
+ { timeout: 10_000 }
85
61
  );
86
62
 
87
63
  const commits: CommitInfo[] = [];
@@ -214,12 +190,9 @@ export async function createPR(
214
190
  ): Promise<PRInfo> {
215
191
  // First ensure branch is pushed
216
192
  try {
217
- await run(
218
- "git",
219
- ["push", "-u", "origin", "--", branchName],
220
- workingDir,
221
- 30_000
222
- );
193
+ await gitPush(workingDir, ["-u", "origin", "--", branchName], {
194
+ timeout: 30_000,
195
+ });
223
196
  } catch {
224
197
  // Branch might already be pushed
225
198
  }
@@ -251,27 +224,13 @@ export async function createPR(
251
224
  }
252
225
 
253
226
  /**
254
- * Get current branch name
227
+ * Get current branch name, "" when HEAD is detached.
255
228
  */
256
229
  export async function getCurrentBranch(workingDir: string): Promise<string> {
257
- return (
258
- await run("git", ["branch", "--show-current"], workingDir, 10_000)
259
- ).trim();
230
+ return currentBranch(workingDir, { timeout: 10_000 });
260
231
  }
261
232
 
262
233
  /**
263
- * Get the default base branch: the remote's, or "main" when it has none.
234
+ * The PR's default base: the repository's default branch.
264
235
  */
265
- export async function getBaseBranch(workingDir: string): Promise<string> {
266
- try {
267
- const ref = await run(
268
- "git",
269
- ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
270
- workingDir,
271
- 10_000
272
- );
273
- return ref.trim().replace(/^origin\//, "");
274
- } catch {
275
- return "main";
276
- }
277
- }
236
+ export const getBaseBranch = getDefaultBranch;
@@ -139,6 +139,7 @@ async function postToOrchestrator(
139
139
  ? `${fromLabel(schedule)}, set up by "${creator.name}"`
140
140
  : fromLabel(schedule),
141
141
  origin: scheduleOrigin(schedule, creator),
142
+ compact: "auto",
142
143
  });
143
144
  onSession(orchestrator.id);
144
145
  return orchestrator.id;
@@ -6,10 +6,9 @@
6
6
  * nothing.
7
7
  */
8
8
 
9
- import { execFile } from "child_process";
10
- import { promisify } from "util";
11
9
  import { db } from "../db";
12
10
  import { createWorktree } from "../worktrees";
11
+ import { git as runGit } from "../git-exec";
13
12
  import { setupWorktree } from "../env-setup";
14
13
  import {
15
14
  generateBranchName,
@@ -35,8 +34,6 @@ import {
35
34
  type SetupView,
36
35
  } from "./setup-progress";
37
36
 
38
- const execFileAsync = promisify(execFile);
39
-
40
37
  export interface WorktreeSetupInput {
41
38
  sessionId: string;
42
39
  projectPath: string;
@@ -50,7 +47,18 @@ export interface WorktreeSetupInput {
50
47
  }
51
48
 
52
49
  const git = (cwd: string, args: string[], timeout = 60_000) =>
53
- execFileAsync("git", ["-C", cwd, ...args], { timeout });
50
+ runGit(cwd, args, { timeout });
51
+
52
+ // git's own last line says it best ("not a git repository ..."), without
53
+ // the "git fetch failed: " a GitError puts before a one-line stderr (or
54
+ // createWorktree's "Failed to create worktree: " before that).
55
+ const lastLine = (message: string) =>
56
+ (message.trim().split("\n").pop() ?? "")
57
+ .replace(
58
+ /^(?:Failed to create worktree: )?git [\w-]+(?: [\w-]+)? failed: /,
59
+ ""
60
+ )
61
+ .replace(/^fatal: /, "");
54
62
 
55
63
  // No remote: nothing to fetch. A fetch that fails fails the setup: a
56
64
  // worktree cut from an old base would start the agent on stale code.
@@ -64,9 +72,7 @@ async function fetchBase(view: SetupView, cwd: string, base: string) {
64
72
  try {
65
73
  await git(cwd, ["fetch", "origin", "--", base]);
66
74
  } catch (error) {
67
- const why = (
68
- (error as Error).message.trim().split("\n").pop() ?? ""
69
- ).replace(/^fatal: /, "");
75
+ const why = lastLine((error as Error).message);
70
76
  throw new Error(`Fetching ${base} failed: ${why}`);
71
77
  }
72
78
  }
@@ -140,11 +146,9 @@ export async function setUpWorktree(input: WorktreeSetupInput): Promise<void> {
140
146
  // No worktree: the agent doesn't start. Its message stays queued, and
141
147
  // the session falls back to the project's folder if sent anyway.
142
148
  // git's own last line says it best ("not a git repository ...").
143
- const message = (error instanceof Error ? error.message : String(error))
144
- .trim()
145
- .split("\n")
146
- .pop()!
147
- .replace(/^fatal: /, "");
149
+ const message = lastLine(
150
+ error instanceof Error ? error.message : String(error)
151
+ );
148
152
  db.prepare(`UPDATE sessions SET working_directory = ? WHERE id = ?`).run(
149
153
  projectPath,
150
154
  sessionId
@@ -13,10 +13,10 @@ import { getProject } from "../projects";
13
13
  import { requireClient } from "../lumifyhub/connection";
14
14
  import { promptFromCard } from "../lumifyhub/task-cards";
15
15
  import { createTask } from "../tasks";
16
- import { run } from "../tasks/gh";
17
16
  import { expandHome } from "../tasks/session";
18
17
  import { itemName } from "./guard";
19
18
  import { outputOf } from "./git";
19
+ import { gitFetch, revParse } from "../git-exec";
20
20
 
21
21
  async function stackedBase(
22
22
  parent: StackItemRow,
@@ -31,16 +31,12 @@ async function stackedBase(
31
31
  if (!branch) throw new Error(`${itemName(parent)} has no branch to stack on`);
32
32
  // The branch must be on ORIGIN: the PR's base is the remote branch, and a
33
33
  // child cut from unpushed commits opens a PR whose diff includes them.
34
- await run("git", ["fetch", "origin", branch], repo).catch(
35
- (error: unknown) => {
36
- throw new Error(
37
- `${branch} is not on origin: ${outputOf(error).slice(0, 200)}`
38
- );
39
- }
40
- );
41
- const tip = (
42
- await run("git", ["rev-parse", `origin/${branch}`], repo)
43
- ).trim();
34
+ await gitFetch(repo, ["origin", branch]).catch((error: unknown) => {
35
+ throw new Error(
36
+ `${branch} is not on origin: ${outputOf(error).slice(0, 200)}`
37
+ );
38
+ });
39
+ const tip = await revParse(repo, `origin/${branch}`);
44
40
  const also = itemIds(item.also_item_ids)
45
41
  .map((id) => items.find((i) => i.id === id))
46
42
  .filter((i): i is StackItemRow => !!i && i.status !== "merged")
@@ -8,10 +8,11 @@ import fs from "fs";
8
8
  import path from "path";
9
9
  import { getRepoName, slugify } from "../git";
10
10
  import { WORKTREES_DIR } from "../worktrees";
11
- import { run } from "./gh";
11
+ import { git as runGit } from "../git-exec";
12
12
  import { pushedRef } from "./move";
13
13
 
14
- const git = (cwd: string, ...args: string[]) => run("git", args, cwd, 120000);
14
+ const git = (cwd: string, ...args: string[]) =>
15
+ runGit(cwd, args, { timeout: 120000 });
15
16
 
16
17
  /** The worktree that has this branch checked out, if one does. */
17
18
  export function worktreeOnBranch(
@@ -12,6 +12,7 @@ import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
12
12
  import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
13
13
  import { stopChat } from "../chat/runner";
14
14
  import { run } from "./gh";
15
+ import { gitFetch, gitPush } from "../git-exec";
15
16
  import {
16
17
  mergePolicy,
17
18
  type MergeMethod,
@@ -61,13 +62,11 @@ async function cleanup(
61
62
  !keepRemoteBranch &&
62
63
  !PROTECTED_BRANCHES.has(session.branch_name)
63
64
  ) {
64
- await run(
65
- "git",
66
- ["push", "origin", "--delete", session.branch_name],
67
- repo
68
- ).catch(() => {});
65
+ await gitPush(repo, ["origin", "--delete", session.branch_name]).catch(
66
+ () => {}
67
+ );
69
68
  }
70
- await run("git", ["fetch", "--prune", "--quiet"], repo).catch(() => {});
69
+ await gitFetch(repo, ["--prune", "--quiet"]).catch(() => {});
71
70
  forgetPR(session.id);
72
71
  }
73
72
 
package/lib/tasks/gh.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
+ import { git } from "../git-exec";
3
4
  import { parseCodeReview } from "./code-review";
4
5
  import { parseScopeChange } from "./scope-change";
5
6
  import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
@@ -16,12 +17,16 @@ import {
16
17
 
17
18
  const execFileAsync = promisify(execFile);
18
19
 
20
+ // Runs gh (or another CLI) and resolves stdout. git goes through
21
+ // lib/git-exec, so an injected runner (stacks, the orchestrator's rebase)
22
+ // gets the same git as everything else.
19
23
  export async function run(
20
24
  cmd: string,
21
25
  args: string[],
22
26
  cwd: string,
23
27
  timeout = 60000
24
28
  ): Promise<string> {
29
+ if (cmd === "git") return git(cwd, args, { timeout });
25
30
  if (cmd === "gh") assertGhAllowed(args);
26
31
  try {
27
32
  const { stdout } = await execFileAsync(cmd, args, {
@@ -5,16 +5,16 @@
5
5
  import fs from "fs";
6
6
  import os from "os";
7
7
  import path from "path";
8
- import { execFileSync } from "child_process";
9
8
  import { randomUUID } from "crypto";
10
9
  import { db, type Session } from "../db";
11
10
  import { createProject } from "../projects";
12
11
  import { writeTranscript } from "./transcript";
12
+ import { gitSync } from "../git-exec";
13
13
 
14
14
  export const CLAUDE_ID = "11111111-2222-3333-4444-555555555555";
15
15
 
16
16
  export const git = (cwd: string, ...args: string[]) =>
17
- execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
17
+ gitSync(cwd, args).trim();
18
18
 
19
19
  export const row = (id: string) =>
20
20
  db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as Session;
package/lib/tasks/move.ts CHANGED
@@ -13,7 +13,7 @@ import { db, type Session } from "../db";
13
13
  import { getProject } from "../projects";
14
14
  import { launchClaude } from "../agents/launch";
15
15
  import { buildTaskBrief } from "./brief";
16
- import { run } from "./gh";
16
+ import { git as runGit } from "../git-exec";
17
17
  import { getTaskSession } from "./session";
18
18
  import { projectRef } from "./project-ref";
19
19
  import { moveRefusal } from "./move-guard";
@@ -32,7 +32,8 @@ import {
32
32
  export * from "./move-bundle";
33
33
 
34
34
  const execFileAsync = promisify(execFile);
35
- const git = (cwd: string, ...args: string[]) => run("git", args, cwd, 120000);
35
+ const git = (cwd: string, ...args: string[]) =>
36
+ runGit(cwd, args, { timeout: 120000 });
36
37
 
37
38
  // Moves in flight in this process; the row's 'moving' outlives a restart.
38
39
  const g = globalThis as unknown as { __agentosExporting?: Set<string> };
@@ -7,12 +7,10 @@
7
7
  import fs from "fs";
8
8
  import os from "os";
9
9
  import path from "path";
10
- import { execFile } from "child_process";
11
- import { promisify } from "util";
12
10
  import { createProject, getAllProjects } from "../projects";
13
11
  import { isRemoteHost } from "../hosts";
14
12
  import type { Project } from "../db";
15
- import { run } from "./gh";
13
+ import { git, gitOk } from "../git-exec";
16
14
  import { expandHome } from "./session";
17
15
 
18
16
  export interface ProjectRef {
@@ -23,8 +21,6 @@ export interface ProjectRef {
23
21
  remote: string | null;
24
22
  }
25
23
 
26
- const execFileAsync = promisify(execFile);
27
-
28
24
  /** A ref as another machine sent it: its shape, before anything reads it. */
29
25
  export function isProjectRef(v: unknown): v is ProjectRef {
30
26
  const r = v as ProjectRef | null;
@@ -65,9 +61,7 @@ export function repoIdentity(remote: string | null): string | null {
65
61
  }
66
62
 
67
63
  export async function originOf(dir: string): Promise<string | null> {
68
- const url = await run("git", ["remote", "get-url", "origin"], dir).catch(
69
- () => ""
70
- );
64
+ const url = await git(dir, ["remote", "get-url", "origin"]).catch(() => "");
71
65
  return url.trim() || null;
72
66
  }
73
67
 
@@ -122,7 +116,9 @@ export async function ensureProject(ref: ProjectRef): Promise<Project> {
122
116
  if (!ref.remote || !REMOTE_URL.test(ref.remote))
123
117
  throw new Error(`${ref.name} isn't here and has no remote to clone`);
124
118
  fs.mkdirSync(path.dirname(dir), { recursive: true });
125
- await run("git", ["clone", "--", ref.remote, dir], os.homedir(), 600000);
119
+ await git(os.homedir(), ["clone", "--", ref.remote, dir], {
120
+ timeout: 600000,
121
+ });
126
122
  }
127
123
  return createProject({
128
124
  name: ref.name || path.basename(dir),
@@ -148,18 +144,15 @@ export async function whyNotHere(ref: ProjectRef): Promise<string | null> {
148
144
  const remote = withoutCredentials(ref.remote);
149
145
  if (!remote || !REMOTE_URL.test(remote))
150
146
  return "Not there, and no remote to clone";
151
- try {
152
- await execFileAsync("git", ["ls-remote", "--heads", "--", remote], {
153
- cwd: os.homedir(),
147
+ const reachable = await gitOk(
148
+ os.homedir(),
149
+ ["ls-remote", "--heads", "--", remote],
150
+ {
154
151
  timeout: 15000,
155
152
  env: {
156
- ...process.env,
157
- GIT_TERMINAL_PROMPT: "0",
158
153
  GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
159
154
  },
160
- });
161
- return null;
162
- } catch {
163
- return "Can't clone it there";
164
- }
155
+ }
156
+ );
157
+ return reachable ? null : "Can't clone it there";
165
158
  }
@@ -3,7 +3,8 @@ import os from "os";
3
3
  import { db, queries, type Session } from "../db";
4
4
  import { isBranchName } from "../git";
5
5
  import { getProject } from "../projects";
6
- import { findPRStrict, run } from "./gh";
6
+ import { findPRStrict } from "./gh";
7
+ import { currentBranch, git } from "../git-exec";
7
8
  import { lookupPR } from "./pr-poll";
8
9
  import type { TaskPR } from "./state";
9
10
 
@@ -43,15 +44,14 @@ async function worktreeBranch(session: Session): Promise<string | null> {
43
44
  const dir = localWorktree(session);
44
45
  if (!dir) return null;
45
46
  try {
46
- const out = await run("git", ["branch", "--show-current"], dir, 5000);
47
- return out.trim() || null;
47
+ return (await currentBranch(dir, { timeout: 5000 })) || null;
48
48
  } catch {
49
49
  return null;
50
50
  }
51
51
  }
52
52
 
53
53
  const hasRef = (dir: string, ref: string) =>
54
- run("git", ["rev-parse", "--verify", "--quiet", ref], dir, 5000).then(
54
+ git(dir, ["rev-parse", "--verify", "--quiet", ref], { timeout: 5000 }).then(
55
55
  () => true,
56
56
  // Exit 1 is "no such branch"; a timeout or a lock isn't an answer.
57
57
  (e: { code?: unknown }) => e?.code !== 1
@@ -94,7 +94,7 @@ function moveBranch(session: Session, branch: string): void {
94
94
 
95
95
  // git merge-base --is-ancestor: exit 0 yes, 1 no, anything else can't say.
96
96
  const isAncestor = (dir: string, sha: string, ref: string) =>
97
- run("git", ["merge-base", "--is-ancestor", sha, ref], dir, 5000).then(
97
+ git(dir, ["merge-base", "--is-ancestor", sha, ref], { timeout: 5000 }).then(
98
98
  () => true as const,
99
99
  (e: { code?: unknown }) => (e?.code === 1 ? (false as const) : null)
100
100
  );