@saadnvd1/agent-os 0.3.603 → 0.3.606

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 (46) hide show
  1. package/app/page.tsx +3 -8
  2. package/components/Bookmarks/ChatBookmarksButton.tsx +4 -0
  3. package/components/Bus/MessagesPanel.tsx +155 -0
  4. package/components/Bus/index.ts +1 -2
  5. package/components/Chat/ChatPanel.tsx +9 -1
  6. package/components/CommandPalette/useAppCommands.ts +12 -4
  7. package/components/Features/FeaturesSection.tsx +2 -2
  8. package/components/Inbox/InboxButton.tsx +39 -0
  9. package/components/Inbox/InboxDialog.tsx +139 -0
  10. package/components/Inbox/NeedsYouList.tsx +120 -0
  11. package/components/Inbox/index.ts +2 -0
  12. package/components/LumifyHub/index.ts +0 -1
  13. package/components/Pane/DesktopTabBar.tsx +2 -0
  14. package/components/Pane/PaneMenu.test.ts +118 -0
  15. package/components/Pane/PaneMenu.tsx +37 -0
  16. package/components/SessionList/SidebarHeader.tsx +7 -4
  17. package/components/Tasks/TaskRow.tsx +3 -3
  18. package/components/Tasks/TasksPanel.tsx +63 -0
  19. package/components/Tasks/index.ts +1 -1
  20. package/components/views/DesktopAppActions.tsx +10 -63
  21. package/components/views/DesktopView.tsx +1 -11
  22. package/hooks/useInbox.ts +69 -0
  23. package/lib/env-setup.ts +27 -12
  24. package/lib/inbox/counts.ts +140 -0
  25. package/lib/project-config/agentos.schema.json +14 -0
  26. package/lib/project-config/schema.ts +10 -0
  27. package/lib/worktree-deps/caches.ts +250 -0
  28. package/lib/worktree-deps/clone.ts +94 -9
  29. package/lib/worktree-deps/fastcopy.ts +89 -0
  30. package/lib/worktree-deps/index.ts +14 -5
  31. package/lib/worktree-deps/paths.ts +20 -0
  32. package/lib/worktree-deps/spare.ts +26 -8
  33. package/package.json +1 -1
  34. package/scripts/bench/worktree-setup.ts +101 -0
  35. package/scripts/check-bundle.mjs +25 -10
  36. package/scripts/check-bundle.test.ts +2 -2
  37. package/stores/inboxUi.ts +60 -0
  38. package/stores/needsYouFocus.ts +11 -3
  39. package/components/Bus/MessagesButton.tsx +0 -25
  40. package/components/Bus/MessagesDialog.tsx +0 -176
  41. package/components/LumifyHub/Docs/DocsButton.tsx +0 -31
  42. package/components/NotificationsMenu.tsx +0 -86
  43. package/components/Tasks/TasksButton.tsx +0 -28
  44. package/components/Tasks/TasksDialog.tsx +0 -75
  45. package/stores/busUi.ts +0 -12
  46. package/stores/tasksUi.ts +0 -14
@@ -0,0 +1,140 @@
1
+ // What the Inbox lists and counts. Pure and client-safe, so the button, the
2
+ // panel and the tests share one rule.
3
+
4
+ import {
5
+ needsYouItems,
6
+ type NeedsYouItem,
7
+ } from "../orchestrator/needs-you-items";
8
+ import type { AskView, QuestionView } from "../orchestrator/ask-view";
9
+
10
+ export type InboxTab = "needs" | "tasks" | "messages";
11
+ export const INBOX_TABS: readonly InboxTab[] = ["needs", "tasks", "messages"];
12
+
13
+ export interface NeedsRow {
14
+ key: string;
15
+ kind: "input" | "ask" | "question";
16
+ title: string;
17
+ sub: string;
18
+ at: number;
19
+ // The session or task this is about, when known; what keeps one session
20
+ // from being counted twice.
21
+ sessionId: string | null;
22
+ workspaceId: string | null;
23
+ ask?: AskView;
24
+ question?: QuestionView;
25
+ }
26
+
27
+ export interface OverviewLike {
28
+ workspaceId: string;
29
+ asks: AskView[];
30
+ questions: QuestionView[];
31
+ dismissed: string[];
32
+ }
33
+
34
+ const TASK_PREFIX = "task:";
35
+
36
+ export function needsYouRows(input: {
37
+ waiting: { id: string; name: string }[];
38
+ overviews: OverviewLike[];
39
+ }): NeedsRow[] {
40
+ const rows: NeedsRow[] = [];
41
+ for (const o of input.overviews) {
42
+ const items = needsYouItems({
43
+ asks: o.asks,
44
+ questions: o.questions,
45
+ dismissed: o.dismissed,
46
+ });
47
+ for (const item of items) {
48
+ const row = fromItem(item, o.workspaceId);
49
+ if (row) rows.push(row);
50
+ }
51
+ }
52
+ const covered = new Set(
53
+ rows.flatMap((r) => (r.sessionId ? [r.sessionId] : []))
54
+ );
55
+ for (const s of input.waiting)
56
+ if (!covered.has(s.id))
57
+ rows.push({
58
+ key: `input:${s.id}`,
59
+ kind: "input",
60
+ title: s.name,
61
+ sub: "Waiting for input",
62
+ at: 0,
63
+ sessionId: s.id,
64
+ workspaceId: null,
65
+ });
66
+ return rows.sort((a, b) => b.at - a.at);
67
+ }
68
+
69
+ function fromItem(item: NeedsYouItem, workspaceId: string): NeedsRow | null {
70
+ if (item.kind === "ask")
71
+ return {
72
+ key: item.key,
73
+ kind: "ask",
74
+ title: item.title,
75
+ sub: item.sub,
76
+ at: item.at,
77
+ sessionId: item.ask.subject.startsWith(TASK_PREFIX)
78
+ ? item.ask.subject.slice(TASK_PREFIX.length)
79
+ : null,
80
+ workspaceId,
81
+ ask: item.ask,
82
+ };
83
+ if (item.kind === "question")
84
+ return {
85
+ key: item.key,
86
+ kind: "question",
87
+ title: item.title,
88
+ sub: item.sub,
89
+ at: item.at,
90
+ sessionId: item.question.task,
91
+ workspaceId,
92
+ question: item.question,
93
+ };
94
+ return null;
95
+ }
96
+
97
+ export function unreadCount(
98
+ messages: { id: number }[],
99
+ seenId: number | null
100
+ ): number {
101
+ if (seenId === null) return 0;
102
+ return messages.filter((m) => m.id > seenId).length;
103
+ }
104
+
105
+ export interface InboxCounts {
106
+ needs: number;
107
+ tasks: number;
108
+ messages: number;
109
+ total: number;
110
+ }
111
+
112
+ // The badge counts each session once: a task needing you that is already a
113
+ // Needs-you row (waiting for input, or the subject of an ask) adds nothing.
114
+ export function inboxCounts(input: {
115
+ rows: NeedsRow[];
116
+ tasksNeedingYou: string[];
117
+ unread: number;
118
+ }): InboxCounts {
119
+ const covered = new Set(
120
+ input.rows.flatMap((r) => (r.sessionId ? [r.sessionId] : []))
121
+ );
122
+ const extraTasks = input.tasksNeedingYou.filter((id) => !covered.has(id));
123
+ return {
124
+ needs: input.rows.length,
125
+ tasks: input.tasksNeedingYou.length,
126
+ messages: input.unread,
127
+ total: input.rows.length + extraTasks.length + input.unread,
128
+ };
129
+ }
130
+
131
+ // Needs you when it has items, else the tab he used last.
132
+ export function pickTab(input: {
133
+ explicit?: InboxTab;
134
+ needs: number;
135
+ last: InboxTab | null;
136
+ }): InboxTab {
137
+ if (input.explicit) return input.explicit;
138
+ if (input.needs > 0) return "needs";
139
+ return input.last ?? "needs";
140
+ }
@@ -48,6 +48,20 @@
48
48
  "pattern": "^(?![/~\\\\])(?!(.*[/\\\\])?\\.\\.([/\\\\]|$)).+$"
49
49
  }
50
50
  },
51
+ "caches": {
52
+ "type": "array",
53
+ "items": {
54
+ "type": "string",
55
+ "allOf": [
56
+ {
57
+ "pattern": "^(?![/~\\\\])(?!(.*[/\\\\])?\\.\\.([/\\\\]|$)).+$"
58
+ },
59
+ {
60
+ "pattern": "^([^*]*[/\\\\])?[^*/\\\\]*\\*?[^*/\\\\]*$"
61
+ }
62
+ ]
63
+ }
64
+ },
51
65
  "dev": {
52
66
  "type": "string",
53
67
  "minLength": 1
@@ -28,6 +28,15 @@ const projectPath = z
28
28
  "must be a path inside the project (relative, no '..')"
29
29
  );
30
30
 
31
+ // Build caches to seed into a worktree: a path inside the project, with `*`
32
+ // allowed in the last segment (`*.tsbuildinfo`), never `.git`.
33
+ const cachePath = projectPath
34
+ .regex(
35
+ /^([^*]*[/\\])?[^*/\\]*\*?[^*/\\]*$/,
36
+ "`*` is allowed in the last segment only"
37
+ )
38
+ .refine((p) => !/(^|[/\\])\.git([/\\]|$)/.test(p), "not .git");
39
+
31
40
  const port = z.number().int().min(1024).max(65000);
32
41
  const notes = z.union([z.string(), z.array(z.string())]);
33
42
 
@@ -120,6 +129,7 @@ const shape = {
120
129
  setup: z.array(z.string().min(1)).optional(),
121
130
  copy: z.array(projectPath).optional(),
122
131
  clone: z.array(projectPath).optional(),
132
+ caches: z.array(cachePath).optional(),
123
133
  dev: z.string().min(1).optional(),
124
134
  ready: ready.optional(),
125
135
  deploy: deploy.optional(),
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Build caches seeded into a new worktree from the main checkout, so its
3
+ * first typecheck, test and build start warm: `.next/cache`, `.next/dev/cache`
4
+ * and `*.tsbuildinfo` for a Node project, or exactly agentos.json's `caches`.
5
+ * (Vitest's own cache is not a default: it only orders tests, and seeding it
6
+ * measured no gain.) A cache is seeded only when it is fresh, built since the
7
+ * worktree's base commit, and not huge: an old one makes the first run
8
+ * slower, not faster. Best effort: a cache that can't be seeded is skipped and never
9
+ * fails a setup.
10
+ *
11
+ * Only ever a copy: an APFS clone, a reflink, or (on a filesystem with
12
+ * neither) a plain copy of a small cache. Never a hardlink or a symlink, so
13
+ * nothing a worktree writes can reach the main checkout, and never from a
14
+ * place other than the main checkout.
15
+ */
16
+
17
+ import { execFile } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import { promisify } from "util";
21
+ import { gitResult, isGitError } from "../git-exec";
22
+ import { findNodeModules } from "./clone";
23
+ import { fastCopyMode } from "./fastcopy";
24
+ import { inside, realExisting } from "./paths";
25
+ import { copyTree } from "./spare";
26
+ import type { CopyMode } from "./spare";
27
+
28
+ const execFileAsync = promisify(execFile);
29
+
30
+ // Past these sizes a cache is skipped. A copy for real (no clone, no reflink)
31
+ // costs the disk; a clone of a huge one costs time, since the build then
32
+ // loads it: a main checkout's .next/cache that has grown to gigabytes built
33
+ // slower for a new worktree than starting empty (measured).
34
+ const DEFAULT_MAX_COPY_MB = 512;
35
+ const DEFAULT_MAX_CLONE_MB = 1024;
36
+
37
+ interface Stack {
38
+ name: string;
39
+ // Cache paths, relative to the checkout, for a project of this stack.
40
+ paths(sourcePath: string): Promise<string[]>;
41
+ }
42
+
43
+ const hasNext = (dir: string) => {
44
+ if (fs.readdirSync(dir).some((f) => /^next\.config\./.test(f))) return true;
45
+ try {
46
+ const pkg = JSON.parse(
47
+ fs.readFileSync(path.join(dir, "package.json"), "utf-8")
48
+ );
49
+ return !!(pkg.dependencies?.next ?? pkg.devDependencies?.next);
50
+ } catch {
51
+ return false;
52
+ }
53
+ };
54
+
55
+ async function nodePaths(sourcePath: string): Promise<string[]> {
56
+ const dirs = new Set<string>();
57
+ for (const rel of await findNodeModules(sourcePath))
58
+ dirs.add(path.dirname(rel));
59
+ if (fs.existsSync(path.join(sourcePath, "package.json"))) dirs.add(".");
60
+ const out: string[] = [];
61
+ for (const dir of dirs) {
62
+ const abs = path.join(sourcePath, dir);
63
+ if (!fs.existsSync(path.join(abs, "package.json"))) continue;
64
+ out.push(path.join(dir, "*.tsbuildinfo"));
65
+ if (hasNext(abs))
66
+ out.push(
67
+ path.join(dir, ".next", "cache"),
68
+ // Next 16's dev server keeps its Turbopack cache here.
69
+ path.join(dir, ".next", "dev", "cache")
70
+ );
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // One entry per stack. Add a stack here to give it defaults; a project can
76
+ // always name its own in agentos.json's `caches`.
77
+ export const STACKS: Stack[] = [
78
+ { name: "node", paths: nodePaths },
79
+ // iOS (DerivedData, Pods, ccache) is the agentos-mobile "Faster native iOS
80
+ // builds" task's: it plugs in here.
81
+ { name: "ios", paths: async () => [] },
82
+ // Rails is deliberately absent: bootsnap's load-path cache holds absolute
83
+ // paths, so a copy in another directory is stale on arrival.
84
+ ];
85
+
86
+ export async function defaultCaches(sourcePath: string): Promise<string[]> {
87
+ const all = await Promise.all(STACKS.map((s) => s.paths(sourcePath)));
88
+ return all.flat();
89
+ }
90
+
91
+ // `*` is allowed in the last segment only.
92
+ async function expand(sourcePath: string, rel: string): Promise<string[]> {
93
+ const base = path.basename(rel);
94
+ if (!base.includes("*")) return [rel];
95
+ const re = new RegExp(
96
+ "^" + base.split("*").map(escapeRe).join("[^/]*") + "$"
97
+ );
98
+ const dir = path.dirname(rel);
99
+ const names = await fs.promises
100
+ .readdir(path.join(sourcePath, dir))
101
+ .catch(() => [] as string[]);
102
+ return names.filter((n) => re.test(n)).map((n) => path.join(dir, n));
103
+ }
104
+ const escapeRe = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
105
+
106
+ // When the worktree's HEAD was committed; null when it can't be told.
107
+ async function headTimeMs(worktreePath: string): Promise<number | null> {
108
+ try {
109
+ const { stdout } = await gitResult(worktreePath, [
110
+ "log",
111
+ "-1",
112
+ "--format=%ct",
113
+ ]);
114
+ const seconds = parseInt(stdout, 10);
115
+ return Number.isFinite(seconds) ? seconds * 1000 : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ // The last write to a file, or to a folder or its direct children.
122
+ async function newestMs(p: string): Promise<number> {
123
+ const st = await fs.promises.stat(p);
124
+ let newest = st.mtimeMs;
125
+ if (st.isDirectory())
126
+ for (const name of await fs.promises.readdir(p)) {
127
+ const child = await fs.promises
128
+ .stat(path.join(p, name))
129
+ .catch(() => null);
130
+ if (child) newest = Math.max(newest, child.mtimeMs);
131
+ }
132
+ return newest;
133
+ }
134
+
135
+ async function sizeMb(p: string): Promise<number> {
136
+ const { stdout } = await execFileAsync("du", ["-sk", p]);
137
+ return parseInt(stdout, 10) / 1024;
138
+ }
139
+
140
+ // Git ignores it, or seeding it would leave an uncommitted file behind.
141
+ // Outside a repository there is no answer, and it is allowed.
142
+ async function notIgnored(worktreePath: string, rel: string): Promise<boolean> {
143
+ try {
144
+ await gitResult(worktreePath, ["check-ignore", "-q", "--", rel]);
145
+ return false;
146
+ } catch (error) {
147
+ return isGitError(error) && error.exitCode === 1;
148
+ }
149
+ }
150
+
151
+ export interface SeedResult {
152
+ seeded: Array<{ rel: string; how: CopyMode }>;
153
+ skipped: Array<{ rel: string; why: string }>;
154
+ }
155
+
156
+ export async function seedBuildCaches(opts: {
157
+ sourcePath: string;
158
+ worktreePath: string;
159
+ // agentos.json's `caches`; the stack defaults without it.
160
+ paths?: string[];
161
+ mode?: CopyMode;
162
+ platform?: NodeJS.Platform;
163
+ maxCopyMb?: number;
164
+ maxCloneMb?: number;
165
+ // Caches last written before this (ms) are stale. Default: the worktree's
166
+ // HEAD commit time; without one every cache counts as fresh.
167
+ freshAfterMs?: number;
168
+ }): Promise<SeedResult> {
169
+ const { sourcePath, worktreePath } = opts;
170
+ const result: SeedResult = { seeded: [], skipped: [] };
171
+ // The switch for measuring a start without seeding.
172
+ if (process.env.AGENTOS_NO_BUILD_CACHES) return result;
173
+ const declared = opts.paths ?? (await defaultCaches(sourcePath));
174
+ if (!declared.length) return result;
175
+
176
+ const fast = await fastCopyMode({
177
+ sourcePath,
178
+ worktreePath,
179
+ platform: opts.platform,
180
+ });
181
+ // Hardlinks are for dependencies from the store, never for a cache.
182
+ const mode: CopyMode =
183
+ opts.mode ?? (fast === "clone" || fast === "reflink" ? fast : "copy");
184
+ const max =
185
+ opts.maxCopyMb ??
186
+ Number(process.env.AGENTOS_CACHE_COPY_MAX_MB || DEFAULT_MAX_COPY_MB);
187
+ const maxClone =
188
+ opts.maxCloneMb ??
189
+ Number(process.env.AGENTOS_CACHE_CLONE_MAX_MB || DEFAULT_MAX_CLONE_MB);
190
+ const freshAfter = opts.freshAfterMs ?? (await headTimeMs(worktreePath));
191
+ const root = await fs.promises.realpath(sourcePath);
192
+ const target = await fs.promises.realpath(worktreePath);
193
+
194
+ const skip = (rel: string, why: string) => result.skipped.push({ rel, why });
195
+
196
+ for (const rel of (
197
+ await Promise.all(declared.map((d) => expand(sourcePath, d)))
198
+ ).flat()) {
199
+ const real = await fs.promises
200
+ .realpath(path.join(sourcePath, rel))
201
+ .catch(() => null);
202
+ if (!real) continue; // not built in the main checkout yet
203
+ if (!inside(root, real)) {
204
+ skip(rel, "resolves outside the project");
205
+ continue;
206
+ }
207
+ const dir = path.join(target, path.dirname(rel));
208
+ if (!inside(target, await realExisting(dir))) {
209
+ skip(rel, "its folder in the worktree resolves outside it");
210
+ continue;
211
+ }
212
+ const dest = path.join(dir, path.basename(rel));
213
+ if (await fs.promises.lstat(dest).catch(() => null)) continue; // already there
214
+ if (await notIgnored(worktreePath, rel)) {
215
+ skip(rel, "not ignored by git");
216
+ continue;
217
+ }
218
+ if (freshAfter !== null && (await newestMs(real)) < freshAfter) {
219
+ skip(rel, "older than the worktree's base commit");
220
+ continue;
221
+ }
222
+ const limit = mode === "copy" ? max : maxClone;
223
+ if ((await sizeMb(real).catch(() => Infinity)) > limit) {
224
+ skip(
225
+ rel,
226
+ mode === "copy"
227
+ ? `bigger than ${limit} MB and this filesystem can't clone`
228
+ : `bigger than ${limit} MB`
229
+ );
230
+ continue;
231
+ }
232
+ await fs.promises.mkdir(dir, { recursive: true });
233
+ if (!inside(target, await fs.promises.realpath(dir))) {
234
+ skip(rel, "its folder in the worktree resolves outside it");
235
+ continue;
236
+ }
237
+ const partial = `${dest}.aos-partial`;
238
+ await fs.promises.rm(partial, { recursive: true, force: true });
239
+ const ok =
240
+ (await copyTree(real, partial, mode)) &&
241
+ (await fs.promises.rename(partial, dest).then(
242
+ () => true,
243
+ () => false
244
+ ));
245
+ await fs.promises.rm(partial, { recursive: true, force: true });
246
+ if (ok) result.seeded.push({ rel, how: mode });
247
+ else skip(rel, "the copy failed");
248
+ }
249
+ return result;
250
+ }
@@ -10,6 +10,8 @@ import { createHash } from "crypto";
10
10
  import * as fs from "fs";
11
11
  import * as path from "path";
12
12
  import { LOCKFILES } from "./install";
13
+ import { STORE_ROOT, fastCopyMode, unlinkMutable } from "./fastcopy";
14
+ import type { FastMode } from "./fastcopy";
13
15
  import {
14
16
  SPARE_ROOT,
15
17
  copyTree,
@@ -72,11 +74,15 @@ export async function findNodeModules(sourcePath: string): Promise<string[]> {
72
74
 
73
75
  // Cloned beside the target and renamed into place, so a clone cut off by a
74
76
  // restart or a failed cp never passes for a finished one.
75
- async function cloneWhole(from: string, target: string): Promise<boolean> {
77
+ async function cloneWhole(
78
+ from: string,
79
+ target: string,
80
+ mode: FastMode
81
+ ): Promise<boolean> {
76
82
  const partial = `${target}.aos-partial`;
77
83
  await fs.promises.rm(partial, { recursive: true, force: true });
78
84
  const ok =
79
- (await copyTree(from, partial)) && (await takeSpare(partial, target));
85
+ (await copyTree(from, partial, mode)) && (await takeSpare(partial, target));
80
86
  await fs.promises.rm(partial, { recursive: true, force: true });
81
87
  return ok;
82
88
  }
@@ -86,11 +92,14 @@ export interface CloneResult {
86
92
  // Why it didn't clone, when it didn't.
87
93
  reason?: string;
88
94
  cloned: Array<{ rel: string; from: "spare" | "clone" }>;
95
+ mode?: FastMode;
96
+ skipped?: string[];
89
97
  // The spares being made for the next start. Nothing waits on it but tests.
90
98
  refill?: Promise<unknown>;
91
99
  }
92
100
 
93
- export const NOT_MAC = "clones are macOS only";
101
+ export const NO_FAST_COPY =
102
+ "no clone, reflink or hardlink store is possible here";
94
103
 
95
104
  export async function cloneDeps(opts: {
96
105
  sourcePath: string;
@@ -99,13 +108,26 @@ export async function cloneDeps(opts: {
99
108
  // node_modules findNodeModules finds.
100
109
  only?: string[];
101
110
  spareRoot?: string;
111
+ // Where Linux keeps the hardlinked store (fastcopy.ts).
112
+ storeRoot?: string;
102
113
  platform?: NodeJS.Platform;
114
+ // Forced instead of detected; null is "none works" (tests).
115
+ mode?: FastMode | null;
103
116
  }): Promise<CloneResult> {
104
117
  const { sourcePath, worktreePath, only } = opts;
105
118
  const root = opts.spareRoot ?? SPARE_ROOT;
106
119
  const cloned: CloneResult["cloned"] = [];
107
- if ((opts.platform ?? process.platform) !== "darwin")
108
- return { ok: false, reason: NOT_MAC, cloned };
120
+ const storeRoot = opts.storeRoot ?? STORE_ROOT;
121
+ const mode =
122
+ opts.mode !== undefined
123
+ ? opts.mode
124
+ : await fastCopyMode({
125
+ sourcePath,
126
+ worktreePath,
127
+ storeRoot,
128
+ platform: opts.platform,
129
+ });
130
+ if (!mode) return { ok: false, reason: NO_FAST_COPY, cloned };
109
131
 
110
132
  const isModules = (rel: string) => path.basename(rel) === "node_modules";
111
133
  const all = only
@@ -124,6 +146,13 @@ export async function cloneDeps(opts: {
124
146
  };
125
147
  if (!lock)
126
148
  return { ok: false, reason: "the main checkout has no lockfile", cloned };
149
+ if (!(await nodeModulesMatchLock(sourcePath)))
150
+ return {
151
+ ok: false,
152
+ reason:
153
+ "the main checkout's node_modules is not what its lockfile lists",
154
+ cloned,
155
+ };
127
156
  if (lock !== (await lockfileHash(worktreePath)))
128
157
  return {
129
158
  ok: false,
@@ -133,16 +162,22 @@ export async function cloneDeps(opts: {
133
162
  }
134
163
 
135
164
  const spares: Array<{ rel: string; lock: string }> = [];
165
+ const skipped: string[] = [];
136
166
  for (const rel of all) {
137
167
  const from = path.join(sourcePath, rel);
138
168
  const target = path.join(worktreePath, rel);
139
169
  if (!(await isDir(from))) continue;
140
170
  // A directory this branch doesn't have, or the files already there.
141
171
  if (!(await isDir(path.dirname(target))) || fs.existsSync(target)) continue;
172
+ if (!isModules(rel) && mode === "hardlink") {
173
+ // Mutable, and hardlinks would write back: left to the setup commands.
174
+ skipped.push(`${rel} (not linked)`);
175
+ continue;
176
+ }
142
177
  if (!isModules(rel)) {
143
178
  // Anything else declared (a virtualenv, vendor/bundle) is cloned as
144
179
  // is: there is no lockfile AgentOS knows to check it against.
145
- if (!(await cloneWhole(from, target)))
180
+ if (!(await cloneWhole(from, target, mode)))
146
181
  return { ok: false, reason: `cloning ${rel} failed`, cloned };
147
182
  cloned.push({ rel, from: "clone" });
148
183
  continue;
@@ -154,10 +189,30 @@ export async function cloneDeps(opts: {
154
189
  if (rel !== "node_modules" && own !== ownHere) continue;
155
190
  const key = own ?? lock;
156
191
  if (!key) continue;
192
+ if (mode === "hardlink") {
193
+ // Linked from the store, never from the checkout; the store is made
194
+ // once per lockfile with a plain copy.
195
+ const stored = sparePath(storeRoot, sourcePath, rel, key);
196
+ if (!fs.existsSync(stored))
197
+ await replenishSpare(storeRoot, sourcePath, rel, key, "copy").catch(
198
+ () => undefined
199
+ );
200
+ if (!fs.existsSync(stored))
201
+ return {
202
+ ok: false,
203
+ reason: `making the store for ${rel} failed`,
204
+ cloned,
205
+ };
206
+ if (!(await cloneWhole(stored, target, mode)))
207
+ return { ok: false, reason: `linking ${rel} failed`, cloned };
208
+ await unlinkMutable(target).catch(() => undefined);
209
+ cloned.push({ rel, from: "clone" });
210
+ continue;
211
+ }
157
212
  const spare = sparePath(root, sourcePath, rel, key);
158
213
  if (await takeSpare(spare, target)) {
159
214
  cloned.push({ rel, from: "spare" });
160
- } else if (await cloneWhole(from, target)) {
215
+ } else if (await cloneWhole(from, target, mode)) {
161
216
  cloned.push({ rel, from: "clone" });
162
217
  } else {
163
218
  return { ok: false, reason: `cloning ${rel} failed`, cloned };
@@ -167,10 +222,40 @@ export async function cloneDeps(opts: {
167
222
 
168
223
  const refill = Promise.all(
169
224
  spares.map(({ rel, lock: key }) =>
170
- replenishSpare(root, sourcePath, rel, key).catch((error) =>
225
+ replenishSpare(root, sourcePath, rel, key, mode).catch((error) =>
171
226
  console.error(`Spare for ${rel} failed:`, error)
172
227
  )
173
228
  )
174
229
  );
175
- return { ok: true, cloned, refill };
230
+ return { ok: true, cloned, refill, mode, skipped };
231
+ }
232
+
233
+ // Whether the checkout's node_modules is what its npm lockfile says: every
234
+ // package the lockfile lists (optional ones, which a platform may skip, aside)
235
+ // is installed at that version. A checkout pulled but never reinstalled is
236
+ // not, and cloning it would hand the worktree missing dependencies. Other
237
+ // package managers have no hidden lockfile to check, so they pass.
238
+ export async function nodeModulesMatchLock(dir: string): Promise<boolean> {
239
+ const read = (file: string) =>
240
+ fs.promises
241
+ .readFile(file, "utf-8")
242
+ .then(
243
+ (text) =>
244
+ JSON.parse(text) as {
245
+ packages?: Record<string, { version?: string; optional?: boolean }>;
246
+ }
247
+ )
248
+ .catch(() => null);
249
+ const lock = await read(path.join(dir, "package-lock.json"));
250
+ if (!lock?.packages) return true;
251
+ const installed = await read(
252
+ path.join(dir, "node_modules", ".package-lock.json")
253
+ );
254
+ if (!installed?.packages) return false;
255
+ return Object.entries(lock.packages).every(
256
+ ([name, pkg]) =>
257
+ name === "" ||
258
+ pkg.optional ||
259
+ installed.packages![name]?.version === pkg.version
260
+ );
176
261
  }