@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
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The one way AgentOS runs git. Every git process starts here, so they all
3
+ * share the same hardening:
4
+ *
5
+ * - argv only, never a shell: a branch name, path or message is always data;
6
+ * - off the event loop, with a ceiling (a hung ssh, a stalled mount or a
7
+ * stuck hook is killed at it, with SIGKILL);
8
+ * - no prompt can ever wait for an answer (GIT_TERMINAL_PROMPT=0), and stdin
9
+ * is always closed;
10
+ * - no optional locks, so a read never takes the index lock a commit or
11
+ * rebase needs;
12
+ * - the server's own tmux (TMUX, TMUX_PANE) never reaches a hook;
13
+ * - a failure is a GitError carrying the exit code, stderr and stdout.
14
+ *
15
+ * A lint rule (eslint.config.mjs) refuses git spawned anywhere else.
16
+ */
17
+
18
+ import { execFile, execFileSync } from "child_process";
19
+
20
+ export const GIT_TIMEOUT_MS = 60_000;
21
+ const MAX_BUFFER = 16 * 1024 * 1024;
22
+
23
+ export interface GitOptions {
24
+ // Milliseconds before git is killed. Default GIT_TIMEOUT_MS.
25
+ timeout?: number;
26
+ // Written to stdin, which is closed either way.
27
+ input?: string;
28
+ // Added to the shared environment.
29
+ env?: Record<string, string>;
30
+ // Paths are names, never globs or pathspec magic: "[draft].md" or "*"
31
+ // must not reach other files.
32
+ literalPathspecs?: boolean;
33
+ // Bytes of output before git is killed. Default 16MB.
34
+ maxBuffer?: number;
35
+ signal?: AbortSignal;
36
+ }
37
+
38
+ export class GitError extends Error {
39
+ override readonly name = "GitError";
40
+ constructor(
41
+ message: string,
42
+ readonly args: string[],
43
+ readonly cwd: string,
44
+ // git's exit status; null when it never exited on its own.
45
+ readonly exitCode: number | null,
46
+ readonly signal: NodeJS.Signals | null,
47
+ readonly stdout: string,
48
+ readonly stderr: string,
49
+ readonly timedOut: boolean,
50
+ options?: { cause?: unknown }
51
+ ) {
52
+ super(message, options);
53
+ }
54
+
55
+ // What execFile's errors carry, for callers written against them.
56
+ get code(): number | string | null {
57
+ const cause = this.cause as { code?: unknown } | undefined;
58
+ if (this.exitCode !== null) return this.exitCode;
59
+ return typeof cause?.code === "string" ? cause.code : null;
60
+ }
61
+
62
+ get killed(): boolean {
63
+ return this.timedOut || this.signal !== null;
64
+ }
65
+ }
66
+
67
+ export const isGitError = (error: unknown): error is GitError =>
68
+ error instanceof GitError;
69
+
70
+ /** The environment every git runs with. */
71
+ export function gitEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
72
+ const env: NodeJS.ProcessEnv = {
73
+ ...process.env,
74
+ GIT_TERMINAL_PROMPT: "0",
75
+ GIT_OPTIONAL_LOCKS: "0",
76
+ ...extra,
77
+ };
78
+ delete env.TMUX;
79
+ delete env.TMUX_PANE;
80
+ return env;
81
+ }
82
+
83
+ const argv = (args: string[], opts: GitOptions) =>
84
+ opts.literalPathspecs ? ["--literal-pathspecs", ...args] : args;
85
+
86
+ // The subcommand, for messages: "git worktree add", "git push".
87
+ const named = (args: string[]) => {
88
+ const words = args.filter((a) => !a.startsWith("-")).slice(0, 2);
89
+ return ["git", ...(words[0] === "worktree" ? words : words.slice(0, 1))]
90
+ .join(" ")
91
+ .trim();
92
+ };
93
+
94
+ function toGitError(
95
+ error: unknown,
96
+ args: string[],
97
+ cwd: string,
98
+ stdout: string,
99
+ stderr: string,
100
+ timeout: number
101
+ ): GitError {
102
+ const e = error as {
103
+ code?: unknown;
104
+ status?: unknown;
105
+ killed?: boolean;
106
+ signal?: NodeJS.Signals | null;
107
+ name?: string;
108
+ message?: string;
109
+ };
110
+ // execFile reports the exit status as `code`, execFileSync as `status`.
111
+ const exitCode =
112
+ typeof e.code === "number"
113
+ ? e.code
114
+ : typeof e.status === "number"
115
+ ? e.status
116
+ : null;
117
+ const aborted = e.name === "AbortError" || e.code === "ABORT_ERR";
118
+ const overflow = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
119
+ const timedOut =
120
+ e.code === "ETIMEDOUT" ||
121
+ (exitCode === null && !aborted && !overflow && e.killed === true);
122
+ const what = named(args);
123
+ const reason =
124
+ stderr.trim() || stdout.trim() || e.message || String(error ?? "");
125
+ const message = timedOut
126
+ ? `${what} timed out after ${Math.round(timeout / 1000)}s`
127
+ : `${what} failed: ${reason}`;
128
+ return new GitError(
129
+ message,
130
+ args,
131
+ cwd,
132
+ exitCode,
133
+ e.signal ?? null,
134
+ stdout,
135
+ stderr,
136
+ timedOut,
137
+ { cause: error }
138
+ );
139
+ }
140
+
141
+ /** Runs git in `cwd`; resolves stdout and stderr, rejects with a GitError. */
142
+ export function gitResult(
143
+ cwd: string,
144
+ args: string[],
145
+ opts: GitOptions = {}
146
+ ): Promise<{ stdout: string; stderr: string }> {
147
+ const full = argv(args, opts);
148
+ const timeout = opts.timeout ?? GIT_TIMEOUT_MS;
149
+ return new Promise((resolve, reject) => {
150
+ const child = execFile(
151
+ "git",
152
+ full,
153
+ {
154
+ cwd,
155
+ encoding: "utf-8",
156
+ maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
157
+ timeout,
158
+ killSignal: "SIGKILL",
159
+ env: gitEnv(opts.env),
160
+ signal: opts.signal,
161
+ },
162
+ (error, stdout, stderr) => {
163
+ if (!error) return resolve({ stdout, stderr });
164
+ reject(toGitError(error, args, cwd, stdout, stderr, timeout));
165
+ }
166
+ );
167
+ // git exiting before it read its input is its failure, not a crash.
168
+ child.stdin?.on("error", () => {});
169
+ child.stdin?.end(opts.input ?? "");
170
+ });
171
+ }
172
+
173
+ /** Runs git in `cwd` and resolves what it printed, untrimmed. */
174
+ export async function git(
175
+ cwd: string,
176
+ args: string[],
177
+ opts: GitOptions = {}
178
+ ): Promise<string> {
179
+ return (await gitResult(cwd, args, opts)).stdout;
180
+ }
181
+
182
+ /** Whether git exits 0. Never throws. */
183
+ export function gitOk(
184
+ cwd: string,
185
+ args: string[],
186
+ opts: GitOptions = {}
187
+ ): Promise<boolean> {
188
+ return gitResult(cwd, args, opts).then(
189
+ () => true,
190
+ () => false
191
+ );
192
+ }
193
+
194
+ /**
195
+ * The blocking form, for what runs once outside a request (the build id at
196
+ * startup) and test setup. Never in a request handler.
197
+ */
198
+ export function gitSync(
199
+ cwd: string,
200
+ args: string[],
201
+ opts: Omit<GitOptions, "signal"> = {}
202
+ ): string {
203
+ const full = argv(args, opts);
204
+ const timeout = opts.timeout ?? GIT_TIMEOUT_MS;
205
+ try {
206
+ return execFileSync("git", full, {
207
+ cwd,
208
+ encoding: "utf-8",
209
+ input: opts.input ?? "",
210
+ stdio: ["pipe", "pipe", "pipe"],
211
+ maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
212
+ timeout,
213
+ killSignal: "SIGKILL",
214
+ env: gitEnv(opts.env),
215
+ });
216
+ } catch (error) {
217
+ const e = error as { stdout?: unknown; stderr?: unknown };
218
+ const text = (v: unknown) =>
219
+ typeof v === "string" ? v : Buffer.isBuffer(v) ? v.toString() : "";
220
+ throw toGitError(error, args, cwd, text(e.stdout), text(e.stderr), timeout);
221
+ }
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // The operations AgentOS actually runs. Names reach git as arguments after
226
+ // "--" or --end-of-options wherever git allows it, so none is read as an
227
+ // option.
228
+
229
+ const trimmed = (p: Promise<string>) => p.then((s) => s.trim());
230
+
231
+ /** `rev-parse <ref>`, trimmed. Throws when it doesn't resolve. */
232
+ export const revParse = (cwd: string, ref: string, opts?: GitOptions) =>
233
+ trimmed(git(cwd, ["rev-parse", ref], opts));
234
+
235
+ /** The object `ref` names, or null when it names none. */
236
+ export const resolveRef = (cwd: string, ref: string, opts?: GitOptions) =>
237
+ git(cwd, ["rev-parse", "--verify", "--quiet", ref], opts).then(
238
+ (out) => out.trim() || null,
239
+ () => null
240
+ );
241
+
242
+ /** The checked-out branch, "" when HEAD is detached. */
243
+ export const currentBranch = (cwd: string, opts?: GitOptions) =>
244
+ trimmed(git(cwd, ["branch", "--show-current"], opts));
245
+
246
+ /** `status --porcelain` as printed. */
247
+ export const statusPorcelain = (cwd: string, opts?: GitOptions) =>
248
+ git(cwd, ["status", "--porcelain"], opts);
249
+
250
+ /** Whether `ancestor` is reachable from `ref`; false when git can't say. */
251
+ export const isAncestor = (
252
+ cwd: string,
253
+ ancestor: string,
254
+ ref: string,
255
+ opts?: GitOptions
256
+ ) => gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, ref], opts);
257
+
258
+ /** The best common ancestor of `a` and `b`. */
259
+ export const mergeBase = (
260
+ cwd: string,
261
+ a: string,
262
+ b: string,
263
+ opts?: GitOptions
264
+ ) => trimmed(git(cwd, ["merge-base", "--end-of-options", a, b], opts));
265
+
266
+ export const gitFetch = (cwd: string, args: string[] = [], opts?: GitOptions) =>
267
+ git(cwd, ["fetch", ...args], opts);
268
+
269
+ /** Pushes; resolves stdout then stderr, where git reports what it did. */
270
+ export const gitPush = (cwd: string, args: string[], opts?: GitOptions) =>
271
+ gitResult(cwd, ["push", ...args], opts).then(
272
+ ({ stdout, stderr }) => `${stdout}${stderr}`
273
+ );
274
+
275
+ export const gitRebase = (cwd: string, args: string[], opts?: GitOptions) =>
276
+ git(cwd, ["rebase", ...args], opts);
277
+
278
+ /** `diff --stat` of `args` (a range, paths), as printed. */
279
+ export const diffStat = (cwd: string, args: string[], opts?: GitOptions) =>
280
+ git(cwd, ["diff", "--stat", ...args], opts);
281
+
282
+ /** Deletes a local branch; `force` deletes it unmerged (-D). */
283
+ export const deleteBranch = (
284
+ cwd: string,
285
+ branch: string,
286
+ { force = false, ...opts }: GitOptions & { force?: boolean } = {}
287
+ ) => git(cwd, ["branch", force ? "-D" : "-d", "--", branch], opts);
288
+
289
+ export const worktreeAdd = (repo: string, args: string[], opts?: GitOptions) =>
290
+ git(repo, ["worktree", "add", ...args], opts);
291
+
292
+ export const worktreeRemove = (
293
+ repo: string,
294
+ dir: string,
295
+ { force = 1, ...opts }: GitOptions & { force?: 0 | 1 | 2 } = {}
296
+ ) =>
297
+ git(
298
+ repo,
299
+ ["worktree", "remove", ...Array(force).fill("--force"), "--", dir],
300
+ opts
301
+ );
302
+
303
+ export interface WorktreeEntry {
304
+ path: string;
305
+ // Short name, "" when detached.
306
+ branch: string;
307
+ head: string;
308
+ }
309
+
310
+ /** `worktree list --porcelain`, parsed. The main checkout is first. */
311
+ export async function worktreeList(
312
+ repo: string,
313
+ opts?: GitOptions
314
+ ): Promise<WorktreeEntry[]> {
315
+ const out = await git(repo, ["worktree", "list", "--porcelain"], opts);
316
+ return parseWorktreeList(out);
317
+ }
318
+
319
+ export function parseWorktreeList(out: string): WorktreeEntry[] {
320
+ const entries: WorktreeEntry[] = [];
321
+ for (const block of out.split("\n\n")) {
322
+ let path = "";
323
+ let branch = "";
324
+ let head = "";
325
+ for (const line of block.split("\n")) {
326
+ if (line.startsWith("worktree ")) path = line.slice(9);
327
+ else if (line.startsWith("branch "))
328
+ branch = line.slice(7).replace(/^refs\/heads\//, "");
329
+ else if (line.startsWith("HEAD ")) head = line.slice(5);
330
+ }
331
+ if (path) entries.push({ path, branch, head });
332
+ }
333
+ return entries;
334
+ }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "child_process";
1
+ import { git } from "./git-exec";
2
2
  import { expandPath } from "./git-status";
3
3
 
4
4
  export interface CommitSummary {
@@ -51,27 +51,23 @@ function getRelativeTime(timestamp: number): string {
51
51
  /**
52
52
  * Get commit history
53
53
  */
54
- export function getCommitHistory(
54
+ export async function getCommitHistory(
55
55
  workingDir: string,
56
56
  limit: number = 30
57
- ): CommitSummary[] {
57
+ ): Promise<CommitSummary[]> {
58
58
  const cwd = expandPath(workingDir);
59
59
 
60
60
  try {
61
61
  // Format: hash|shortHash|subject|body|author|email|timestamp
62
62
  // Using %x00 as separator to handle commit messages with |
63
63
  const format = "%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%at";
64
- const output = execFileSync(
65
- "git",
66
- [
67
- "log",
68
- `--format=${format}`,
69
- "-n",
70
- String(Math.trunc(limit) || 30),
71
- "--shortstat",
72
- ],
73
- { cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
74
- );
64
+ const output = await git(cwd, [
65
+ "log",
66
+ `--format=${format}`,
67
+ "-n",
68
+ String(Math.trunc(limit) || 30),
69
+ "--shortstat",
70
+ ]);
75
71
 
76
72
  const commits: CommitSummary[] = [];
77
73
  const lines = output.split("\n");
@@ -160,20 +156,18 @@ export function getCommitHistory(
160
156
  // A commit as git names it: 7 to 40 hex digits, nothing an option could be.
161
157
  export const isCommitHash = (hash: string) => /^[0-9a-f]{7,40}$/.test(hash);
162
158
 
163
- export function getCommitDetail(
159
+ export async function getCommitDetail(
164
160
  workingDir: string,
165
161
  commitHash: string
166
- ): CommitDetail | null {
162
+ ): Promise<CommitDetail | null> {
167
163
  if (!isCommitHash(commitHash)) return null;
168
164
  const cwd = expandPath(workingDir);
169
165
 
170
166
  try {
171
167
  // Get commit info
172
168
  const format = "%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%at";
173
- const infoOutput = execFileSync(
174
- "git",
175
- ["show", `--format=${format}`, "-s", commitHash, "--"],
176
- { cwd, encoding: "utf-8" }
169
+ const infoOutput = (
170
+ await git(cwd, ["show", `--format=${format}`, "-s", commitHash, "--"])
177
171
  ).trim();
178
172
 
179
173
  const parts = infoOutput.split("\x00");
@@ -183,19 +177,11 @@ export function getCommitDetail(
183
177
  parts;
184
178
  const timestamp = parseInt(timestampStr, 10);
185
179
 
186
- // Get file stats using numstat
187
- const statOutput = execFileSync(
188
- "git",
189
- ["show", "--numstat", "--format=", commitHash, "--"],
190
- { cwd, encoding: "utf-8" }
191
- );
192
-
193
- // Get name-status for detecting renames
194
- const nameStatusOutput = execFileSync(
195
- "git",
196
- ["show", "--name-status", "--format=", commitHash, "--"],
197
- { cwd, encoding: "utf-8" }
198
- );
180
+ // File stats from numstat; name-status for detecting renames
181
+ const [statOutput, nameStatusOutput] = await Promise.all([
182
+ git(cwd, ["show", "--numstat", "--format=", commitHash, "--"]),
183
+ git(cwd, ["show", "--name-status", "--format=", commitHash, "--"]),
184
+ ]);
199
185
 
200
186
  const files: CommitFile[] = [];
201
187
  const statLines = statOutput.trim().split("\n").filter(Boolean);
@@ -283,23 +269,25 @@ export function getCommitDetail(
283
269
  /**
284
270
  * Get diff for a specific file in a commit
285
271
  */
286
- export function getCommitFileDiff(
272
+ export async function getCommitFileDiff(
287
273
  workingDir: string,
288
274
  commitHash: string,
289
275
  filePath: string
290
- ): string {
276
+ ): Promise<string> {
291
277
  if (!isCommitHash(commitHash)) return "";
292
278
  const cwd = expandPath(workingDir);
293
279
 
294
280
  try {
295
281
  // Get diff for the specific file in this commit
296
282
  // Use -m to handle merge commits (shows diff against first parent)
297
- const diff = execFileSync(
298
- "git",
299
- ["show", "-m", "--first-parent", commitHash, "--", filePath],
300
- { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 }
301
- );
302
- return diff;
283
+ return await git(cwd, [
284
+ "show",
285
+ "-m",
286
+ "--first-parent",
287
+ commitHash,
288
+ "--",
289
+ filePath,
290
+ ]);
303
291
  } catch (error) {
304
292
  console.error("Failed to get commit file diff:", error);
305
293
  return "";