@akira-tl/forgerelay 0.3.5 → 0.3.7

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.
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
@@ -11,6 +11,7 @@ import { loadLocalAgentProfiles, } from "./local-agent-profiles.js";
11
11
  const WORKSPACE_STALE_REMINDER_MS = 2 * 24 * 60 * 60 * 1_000;
12
12
  const WORKSPACE_SESSION_IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
13
13
  const WORKSPACE_GC_INTERVAL_MS = 60 * 60 * 1_000;
14
+ const INITIAL_INSTRUCTION_DISCOVERY_DEPTH = 1;
14
15
  export class WorkspaceRegistry {
15
16
  config;
16
17
  store;
@@ -24,11 +25,15 @@ export class WorkspaceRegistry {
24
25
  this.hooks = new HookRunner(config.hooks, config.logging);
25
26
  this.pruneIdleWorkspaceSessions(new Set(), true);
26
27
  }
28
+ get cachedWorkspaceCount() {
29
+ return this.workspaces.size;
30
+ }
27
31
  async openWorkspace(input, openOptions = {}) {
28
32
  this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
29
33
  const workspaceInput = typeof input === "string" ? { path: input } : input;
34
+ const bootstrapContext = workspaceInput.context ?? "auto";
30
35
  if (workspaceInput.workspaceId) {
31
- return this.resumeWorkspace(workspaceInput.workspaceId, openOptions.conversationScopeId);
36
+ return this.resumeWorkspace(workspaceInput.workspaceId, openOptions.conversationScopeId, bootstrapContext);
32
37
  }
33
38
  if (!workspaceInput.path) {
34
39
  throw new Error("open_workspace requires either path or workspaceId.");
@@ -37,24 +42,138 @@ export class WorkspaceRegistry {
37
42
  if (mode === "worktree") {
38
43
  return this.openReusableWorktree(workspaceInput, openOptions.conversationScopeId);
39
44
  }
40
- return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false);
45
+ return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false, bootstrapContext);
41
46
  }
42
- async resumeWorkspace(workspaceId, conversationScopeId) {
47
+ async listWorkspaces(input = {}, openOptions = {}) {
48
+ this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
49
+ const now = Date.now();
50
+ const sessions = this.store
51
+ ? this.store.listSessions()
52
+ : [...this.workspaces.values()].map((workspace) => ({
53
+ id: workspace.id,
54
+ root: workspace.root,
55
+ status: "active",
56
+ mode: workspace.mode,
57
+ sourceRoot: workspace.sourceRoot,
58
+ baseRef: workspace.worktree?.baseRef,
59
+ baseSha: workspace.worktree?.baseSha,
60
+ branch: workspace.worktree?.branch,
61
+ targetBranch: workspace.worktree?.targetBranch,
62
+ managed: workspace.worktree?.managed ?? false,
63
+ createdAt: "",
64
+ lastUsedAt: "",
65
+ }));
66
+ const currentWorkspaceIds = new Set(openOptions.conversationScopeId && this.store
67
+ ? this.store
68
+ .listConversationBindings()
69
+ .filter((binding) => binding.conversationScopeId === openOptions.conversationScopeId)
70
+ .map((binding) => binding.workspaceSessionId)
71
+ : []);
72
+ const rootKey = input.root
73
+ ? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
74
+ : undefined;
75
+ const entries = await Promise.all(sessions.map(async (session) => {
76
+ const rootValid = await this.validSessionRoot(session) !== undefined;
77
+ const lastUsedAt = Date.parse(session.lastUsedAt);
78
+ const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
79
+ const state = session.status !== "active"
80
+ ? "closed"
81
+ : !rootValid
82
+ ? "invalid"
83
+ : idleMs >= WORKSPACE_STALE_REMINDER_MS
84
+ ? "stale"
85
+ : "active";
86
+ const projectRoot = session.sourceRoot ?? session.root;
87
+ return {
88
+ label: `${basename(resolve(projectRoot)) || "workspace"}/${session.id}`,
89
+ workspaceId: session.id,
90
+ root: session.root,
91
+ status: session.status,
92
+ state,
93
+ mode: session.mode,
94
+ sourceRoot: session.sourceRoot,
95
+ branch: session.branch,
96
+ targetBranch: session.targetBranch,
97
+ managed: session.managed,
98
+ createdAt: session.createdAt,
99
+ lastUsedAt: session.lastUsedAt,
100
+ idleMs,
101
+ rootValid,
102
+ current: currentWorkspaceIds.has(session.id),
103
+ };
104
+ }));
105
+ const filtered = [];
106
+ for (let index = 0; index < sessions.length; index += 1) {
107
+ const session = sessions[index];
108
+ const entry = entries[index];
109
+ if (!session || !entry)
110
+ continue;
111
+ if (input.workspaceId && entry.workspaceId !== input.workspaceId)
112
+ continue;
113
+ if (input.status && entry.status !== input.status)
114
+ continue;
115
+ if (input.state && entry.state !== input.state)
116
+ continue;
117
+ if (input.mode && entry.mode !== input.mode)
118
+ continue;
119
+ if (input.staleOnly && entry.state !== "stale")
120
+ continue;
121
+ if (rootKey) {
122
+ const sessionRootKey = await canonicalPath(session.root);
123
+ const sourceRootKey = session.sourceRoot ? await canonicalPath(session.sourceRoot) : undefined;
124
+ if (sessionRootKey !== rootKey && sourceRootKey !== rootKey)
125
+ continue;
126
+ }
127
+ filtered.push(entry);
128
+ }
129
+ const summary = filtered.reduce((counts, entry) => {
130
+ counts[entry.state] += 1;
131
+ return counts;
132
+ }, { active: 0, stale: 0, invalid: 0, closed: 0 });
133
+ const offset = Math.max(0, input.offset ?? 0);
134
+ const limit = Math.min(100, Math.max(1, input.limit ?? 50));
135
+ return {
136
+ workspaces: filtered.slice(offset, offset + limit),
137
+ summary: {
138
+ total: entries.length,
139
+ matching: filtered.length,
140
+ ...summary,
141
+ },
142
+ page: {
143
+ offset,
144
+ limit,
145
+ hasMore: offset + limit < filtered.length,
146
+ },
147
+ };
148
+ }
149
+ async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
43
150
  const workspace = this.getWorkspace(workspaceId);
44
151
  const context = await this.reusedWorkspaceContext(workspace);
45
152
  if (!conversationScopeId || !this.store) {
46
- return { ...context, includeBootstrapContext: true };
153
+ return {
154
+ ...context,
155
+ includeBootstrapContext: bootstrapContext !== "none",
156
+ };
47
157
  }
48
158
  const targetKeys = await this.workspaceTargetKeys(workspace);
49
- const alreadyBound = targetKeys.some((targetKey) => this.store?.getConversationBinding(conversationScopeId, targetKey)?.workspaceSessionId === workspace.id);
159
+ const contextAlreadyDelivered = targetKeys.some((targetKey) => this.store?.getContextDelivery(conversationScopeId, targetKey)?.contextFingerprint ===
160
+ context.contextFingerprint);
161
+ const includeBootstrapContext = resolveBootstrapContextVisibility(bootstrapContext, contextAlreadyDelivered);
50
162
  for (const targetKey of targetKeys) {
51
163
  this.store.setConversationBinding({
52
164
  conversationScopeId,
53
165
  targetKey,
54
166
  workspaceSessionId: workspace.id,
55
167
  });
168
+ if (includeBootstrapContext) {
169
+ this.store.setContextDelivery({
170
+ conversationScopeId,
171
+ targetKey,
172
+ contextFingerprint: context.contextFingerprint,
173
+ });
174
+ }
56
175
  }
57
- return { ...context, includeBootstrapContext: !alreadyBound };
176
+ return { ...context, includeBootstrapContext };
58
177
  }
59
178
  async listStaleWorkspaces(workspace) {
60
179
  if (!this.store)
@@ -206,12 +325,12 @@ export class WorkspaceRegistry {
206
325
  }
207
326
  return { ...result, hookReports };
208
327
  }
209
- async openReusableCheckout(path, conversationScopeId, newWorkspace) {
328
+ async openReusableCheckout(path, conversationScopeId, newWorkspace, bootstrapContext) {
210
329
  const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
211
330
  const projectKey = await canonicalPath(allowedPath);
212
331
  const targetKey = JSON.stringify(["checkout", projectKey, null]);
213
332
  if (!newWorkspace) {
214
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey);
333
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
215
334
  if (boundContext)
216
335
  return boundContext;
217
336
  }
@@ -220,7 +339,7 @@ export class WorkspaceRegistry {
220
339
  const freshContext = reusableWorkspace
221
340
  ? await this.cloneWorkspaceContext(reusableWorkspace)
222
341
  : await this.openCheckoutWorkspace(path);
223
- return this.withConversationContext(freshContext, conversationScopeId, targetKey);
342
+ return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
224
343
  }
225
344
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
226
345
  const context = await this.openOnce(operationKey, async () => {
@@ -231,18 +350,19 @@ export class WorkspaceRegistry {
231
350
  ? this.cloneWorkspaceContext(reusableWorkspace)
232
351
  : this.reusedWorkspaceContext(reusableWorkspace);
233
352
  });
234
- return this.withConversationContext(context, conversationScopeId, targetKey);
353
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
235
354
  }
236
355
  async openReusableWorktree(input, conversationScopeId) {
237
356
  const path = input.path;
238
357
  if (!path)
239
358
  throw new Error("Worktree mode requires path.");
359
+ const bootstrapContext = input.context ?? "auto";
240
360
  const managedPath = this.tryManagedWorktreePath(path);
241
361
  if (managedPath) {
242
362
  const worktreeKey = await canonicalPath(managedPath);
243
363
  const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
244
364
  if (!input.newWorkspace) {
245
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey);
365
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
246
366
  if (boundContext)
247
367
  return boundContext;
248
368
  }
@@ -251,7 +371,7 @@ export class WorkspaceRegistry {
251
371
  if (!reusableWorkspace) {
252
372
  throw new Error(`Managed worktree is not registered as an active ForgeRelay workspace: ${managedPath}. Open the source project in worktree mode to create or recover a managed worktree first.`);
253
373
  }
254
- return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey);
374
+ return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey, bootstrapContext);
255
375
  }
256
376
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
257
377
  const context = await this.openOnce(operationKey, async () => {
@@ -263,7 +383,7 @@ export class WorkspaceRegistry {
263
383
  ? this.cloneWorkspaceContext(reusableWorkspace)
264
384
  : this.reusedWorkspaceContext(reusableWorkspace);
265
385
  });
266
- return this.withConversationContext(context, conversationScopeId, targetKey);
386
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
267
387
  }
268
388
  const resolvedBase = await resolveManagedWorktreeBase({
269
389
  sourcePath: path,
@@ -274,13 +394,13 @@ export class WorkspaceRegistry {
274
394
  const targetKey = JSON.stringify(["worktree", sourceKey, resolvedBase.targetBranch]);
275
395
  if (input.newWorktree) {
276
396
  const context = await this.openWorktreeWorkspace(path, input.baseRef);
277
- return this.withConversationContext(context, conversationScopeId, targetKey);
397
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
278
398
  }
279
399
  if (!input.newWorkspace) {
280
400
  const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
281
401
  session.sourceRoot !== undefined &&
282
402
  await canonicalPath(session.sourceRoot) === sourceKey &&
283
- session.targetBranch === resolvedBase.targetBranch);
403
+ session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
284
404
  if (boundContext)
285
405
  return boundContext;
286
406
  }
@@ -289,7 +409,7 @@ export class WorkspaceRegistry {
289
409
  const freshContext = reusableWorkspace
290
410
  ? await this.cloneWorkspaceContext(reusableWorkspace)
291
411
  : await this.openWorktreeWorkspace(path, input.baseRef);
292
- return this.withConversationContext(freshContext, conversationScopeId, targetKey);
412
+ return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
293
413
  }
294
414
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
295
415
  const context = await this.openOnce(operationKey, async () => {
@@ -300,7 +420,7 @@ export class WorkspaceRegistry {
300
420
  ? this.cloneWorkspaceContext(reusableWorkspace)
301
421
  : this.reusedWorkspaceContext(reusableWorkspace);
302
422
  });
303
- return this.withConversationContext(context, conversationScopeId, targetKey);
423
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
304
424
  }
305
425
  async openOnce(operationKey, open) {
306
426
  const pending = this.pendingOpens.get(operationKey);
@@ -338,7 +458,7 @@ export class WorkspaceRegistry {
338
458
  ? JSON.stringify(["conversation", conversationScopeId, targetKey])
339
459
  : targetKey;
340
460
  }
341
- async boundConversationContext(conversationScopeId, targetKey, mode, matches) {
461
+ async boundConversationContext(conversationScopeId, targetKey, mode, matches, bootstrapContext) {
342
462
  if (!conversationScopeId || !this.store)
343
463
  return undefined;
344
464
  const binding = this.store.getConversationBinding(conversationScopeId, targetKey);
@@ -359,24 +479,30 @@ export class WorkspaceRegistry {
359
479
  return undefined;
360
480
  }
361
481
  const context = await this.reusedWorkspaceContext(this.getWorkspace(session.id));
362
- this.store.touchConversationBinding(conversationScopeId, targetKey);
363
- return { ...context, includeBootstrapContext: false };
482
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
364
483
  }
365
- withConversationContext(context, conversationScopeId, targetKey) {
484
+ withConversationContext(context, conversationScopeId, targetKey, bootstrapContext) {
366
485
  if (!conversationScopeId || !this.store) {
367
- return { ...context, includeBootstrapContext: true };
368
- }
369
- const binding = this.store.getConversationBinding(conversationScopeId, targetKey);
370
- if (binding?.workspaceSessionId === context.workspace.id) {
371
- this.store.touchConversationBinding(conversationScopeId, targetKey);
372
- return { ...context, includeBootstrapContext: false };
486
+ return {
487
+ ...context,
488
+ includeBootstrapContext: bootstrapContext !== "none",
489
+ };
373
490
  }
491
+ const delivery = this.store.getContextDelivery(conversationScopeId, targetKey);
492
+ const includeBootstrapContext = resolveBootstrapContextVisibility(bootstrapContext, delivery?.contextFingerprint === context.contextFingerprint);
374
493
  this.store.setConversationBinding({
375
494
  conversationScopeId,
376
495
  targetKey,
377
496
  workspaceSessionId: context.workspace.id,
378
497
  });
379
- return { ...context, includeBootstrapContext: true };
498
+ if (includeBootstrapContext) {
499
+ this.store.setContextDelivery({
500
+ conversationScopeId,
501
+ targetKey,
502
+ contextFingerprint: context.contextFingerprint,
503
+ });
504
+ }
505
+ return { ...context, includeBootstrapContext };
380
506
  }
381
507
  pruneIdleWorkspaceSessions(protectedWorkspaceIds, force = false) {
382
508
  if (!this.store)
@@ -397,6 +523,13 @@ export class WorkspaceRegistry {
397
523
  this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
398
524
  }
399
525
  }
526
+ for (const delivery of this.store.listContextDeliveries()) {
527
+ const deliveredAt = Date.parse(delivery.deliveredAt);
528
+ if (Number.isFinite(deliveredAt) &&
529
+ now - deliveredAt >= WORKSPACE_SESSION_IDLE_TTL_MS) {
530
+ this.store.deleteContextDelivery(delivery.conversationScopeId, delivery.targetKey);
531
+ }
532
+ }
400
533
  const boundWorkspaceIds = new Set(this.store.listConversationBindings().map((binding) => binding.workspaceSessionId));
401
534
  const worktreeAnchors = new Map();
402
535
  for (const session of activeSessions) {
@@ -499,13 +632,20 @@ export class WorkspaceRegistry {
499
632
  });
500
633
  }
501
634
  async reusedWorkspaceContext(workspace) {
635
+ Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
636
+ workspace.capabilityGuides = loadCapabilityGuides(this.config);
502
637
  workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root);
503
- const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
504
- const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
638
+ workspace.scannedInstructionDirs.clear();
639
+ workspace.knownInstructionPathsByDir.clear();
640
+ workspace.loadedInstructionRealPaths.clear();
641
+ const agentsFiles = await this.loadInitialAgentsFiles(workspace);
642
+ const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace, agentsFiles);
643
+ const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
505
644
  return {
506
645
  workspace,
507
646
  agentsFiles,
508
647
  availableAgentsFiles,
648
+ contextFingerprint,
509
649
  hookReports: [],
510
650
  workspaceReused: true,
511
651
  includeBootstrapContext: true,
@@ -553,6 +693,9 @@ export class WorkspaceRegistry {
553
693
  agentProfiles: [],
554
694
  activatedSkillDirs: new Set(),
555
695
  activatedCapabilityGuideDirs: new Set(),
696
+ scannedInstructionDirs: new Set(),
697
+ knownInstructionPathsByDir: new Map(),
698
+ loadedInstructionRealPaths: new Set(),
556
699
  };
557
700
  if (touch)
558
701
  this.store?.touchSession(session.id);
@@ -649,6 +792,9 @@ export class WorkspaceRegistry {
649
792
  agentProfiles: await loadLocalAgentProfiles(this.config, input.root),
650
793
  activatedSkillDirs: new Set(),
651
794
  activatedCapabilityGuideDirs: new Set(),
795
+ scannedInstructionDirs: new Set(),
796
+ knownInstructionPathsByDir: new Map(),
797
+ loadedInstructionRealPaths: new Set(),
652
798
  };
653
799
  this.store?.createSession({
654
800
  id: workspace.id,
@@ -674,12 +820,14 @@ export class WorkspaceRegistry {
674
820
  targetBranch: workspace.worktree?.targetBranch,
675
821
  },
676
822
  });
677
- const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
678
- const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
823
+ const agentsFiles = await this.loadInitialAgentsFiles(workspace);
824
+ const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace, agentsFiles);
825
+ const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
679
826
  return {
680
827
  workspace,
681
828
  agentsFiles,
682
829
  availableAgentsFiles,
830
+ contextFingerprint,
683
831
  hookReports,
684
832
  workspaceReused: false,
685
833
  includeBootstrapContext: true,
@@ -702,11 +850,9 @@ export class WorkspaceRegistry {
702
850
  }
703
851
  return assertAllowedPath(root, this.config.allowedRoots);
704
852
  }
705
- async loadInitialAgentsFiles(root) {
706
- const resolvedRoot = (await tryRealpath(root)) ?? root;
853
+ async loadInitialAgentsFiles(workspace) {
707
854
  const systemInstructionsPath = resolve(this.config.systemInstructionsPath);
708
855
  const loadedFiles = [];
709
- const loadedRealPaths = new Set();
710
856
  const systemInstructions = await readSystemInstructions(systemInstructionsPath);
711
857
  const systemInstructionsRealPath = await tryRealpath(systemInstructionsPath);
712
858
  if (systemInstructions !== undefined) {
@@ -714,52 +860,170 @@ export class WorkspaceRegistry {
714
860
  path: systemInstructionsPath,
715
861
  content: systemInstructions,
716
862
  });
717
- if (systemInstructionsRealPath)
718
- loadedRealPaths.add(systemInstructionsRealPath);
719
- }
720
- for (const fileName of CONTEXT_FILE_NAMES) {
721
- const path = join(root, fileName);
722
- const content = await readResolvedProjectContextFile(path, resolvedRoot);
723
- if (content === undefined)
724
- continue;
725
- const realPath = await tryRealpath(path);
726
- if (realPath && loadedRealPaths.has(realPath))
727
- continue;
728
- loadedFiles.push({
729
- path,
730
- content,
731
- });
732
- if (realPath)
733
- loadedRealPaths.add(realPath);
863
+ if (systemInstructionsRealPath) {
864
+ workspace.loadedInstructionRealPaths.add(systemInstructionsRealPath);
865
+ }
734
866
  }
867
+ await this.discoverInstructionTree(workspace, workspace.root, INITIAL_INSTRUCTION_DISCOVERY_DEPTH);
868
+ loadedFiles.push(...await this.loadKnownInstructionsInDirectory(workspace, workspace.root));
735
869
  return loadedFiles;
736
870
  }
737
- async findAvailableAgentsFiles(root, loadedFiles) {
871
+ async findAvailableAgentsFiles(workspace, loadedFiles) {
738
872
  const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path)));
739
- const loadedRealPaths = new Set();
740
- for (const file of loadedFiles) {
741
- const realPath = await tryRealpath(file.path);
742
- if (realPath)
743
- loadedRealPaths.add(realPath);
744
- }
745
873
  const discovered = [];
746
- const agentDir = resolve(this.config.agentDir);
747
- await walkWorkspace(root, async (path, entry) => {
748
- if (isPathInsideRoot(path, agentDir))
749
- return;
750
- if (!entry.isFile())
751
- return;
752
- if (!CONTEXT_FILE_NAMES.has(entry.name))
753
- return;
754
- if (loadedPaths.has(path))
755
- return;
756
- const realPath = await tryRealpath(path);
757
- if (realPath && loadedRealPaths.has(realPath))
758
- return;
759
- discovered.push({ path });
760
- });
874
+ for (const paths of workspace.knownInstructionPathsByDir.values()) {
875
+ for (const path of paths) {
876
+ if (loadedPaths.has(path))
877
+ continue;
878
+ const realPath = await tryRealpath(path);
879
+ if (realPath && workspace.loadedInstructionRealPaths.has(realPath))
880
+ continue;
881
+ discovered.push({ path });
882
+ }
883
+ }
761
884
  return discovered.sort((a, b) => a.path.localeCompare(b.path));
762
885
  }
886
+ async discoverPathInstructions(workspace, inputPath) {
887
+ const absolutePath = resolve(inputPath);
888
+ if (!isPathInsideRoot(absolutePath, workspace.root))
889
+ return [];
890
+ const targetDirectory = dirname(absolutePath);
891
+ const relationship = relative(workspace.root, targetDirectory);
892
+ if (relationship === ".." ||
893
+ relationship.startsWith(`..${sep}`) ||
894
+ resolve(targetDirectory) === resolve(this.config.agentDir) ||
895
+ isPathInsideRoot(targetDirectory, resolve(this.config.agentDir))) {
896
+ return [];
897
+ }
898
+ const directories = [resolve(workspace.root)];
899
+ if (relationship) {
900
+ let current = resolve(workspace.root);
901
+ for (const segment of relationship.split(sep).filter(Boolean)) {
902
+ if (SKIPPED_CONTEXT_DIRS.has(segment))
903
+ break;
904
+ current = join(current, segment);
905
+ directories.push(current);
906
+ }
907
+ }
908
+ const loaded = [];
909
+ for (const directory of directories) {
910
+ await this.discoverInstructionTree(workspace, directory, 0);
911
+ loaded.push(...await this.loadKnownInstructionsInDirectory(workspace, directory));
912
+ }
913
+ return loaded;
914
+ }
915
+ async discoverInstructionTree(workspace, directory, remainingDepth) {
916
+ const resolvedDirectory = resolve(directory);
917
+ if (workspace.scannedInstructionDirs.has(resolvedDirectory))
918
+ return;
919
+ workspace.scannedInstructionDirs.add(resolvedDirectory);
920
+ if (resolvedDirectory !== resolve(workspace.root) &&
921
+ isPathInsideRoot(resolvedDirectory, resolve(this.config.agentDir))) {
922
+ return;
923
+ }
924
+ let entries;
925
+ try {
926
+ entries = await opendir(resolvedDirectory);
927
+ }
928
+ catch {
929
+ return;
930
+ }
931
+ const instructionPaths = [];
932
+ const childDirectories = [];
933
+ for await (const entry of entries) {
934
+ const path = join(resolvedDirectory, entry.name);
935
+ if (entry.isFile() && CONTEXT_FILE_NAMES.has(entry.name)) {
936
+ instructionPaths.push(path);
937
+ continue;
938
+ }
939
+ if (remainingDepth > 0 &&
940
+ entry.isDirectory() &&
941
+ !SKIPPED_CONTEXT_DIRS.has(entry.name)) {
942
+ childDirectories.push(path);
943
+ }
944
+ }
945
+ workspace.knownInstructionPathsByDir.set(resolvedDirectory, instructionPaths.sort((left, right) => left.localeCompare(right)));
946
+ if (remainingDepth <= 0)
947
+ return;
948
+ for (const childDirectory of childDirectories) {
949
+ await this.discoverInstructionTree(workspace, childDirectory, remainingDepth - 1);
950
+ }
951
+ }
952
+ async loadKnownInstructionsInDirectory(workspace, directory) {
953
+ const resolvedDirectory = resolve(directory);
954
+ const paths = workspace.knownInstructionPathsByDir.get(resolvedDirectory) ?? [];
955
+ const loaded = [];
956
+ const resolvedRoot = (await tryRealpath(workspace.root)) ?? resolve(workspace.root);
957
+ const realDirectory = (await tryRealpath(resolvedDirectory)) ?? resolvedDirectory;
958
+ for (const path of paths) {
959
+ const realPath = await tryRealpath(path);
960
+ if (!realPath)
961
+ continue;
962
+ if (!isPathInsideRoot(realPath, resolvedRoot))
963
+ continue;
964
+ if (dirname(realPath) !== realDirectory)
965
+ continue;
966
+ if (workspace.loadedInstructionRealPaths.has(realPath))
967
+ continue;
968
+ let content;
969
+ try {
970
+ content = await readFile(realPath, "utf8");
971
+ }
972
+ catch (error) {
973
+ if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
974
+ continue;
975
+ throw error;
976
+ }
977
+ workspace.loadedInstructionRealPaths.add(realPath);
978
+ loaded.push({ path, content });
979
+ }
980
+ return loaded;
981
+ }
982
+ }
983
+ function resolveBootstrapContextVisibility(mode, contextAlreadyDelivered) {
984
+ if (mode === "full")
985
+ return true;
986
+ if (mode === "none")
987
+ return false;
988
+ return !contextAlreadyDelivered;
989
+ }
990
+ function bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles) {
991
+ const payload = {
992
+ agentsFiles: agentsFiles
993
+ .map((file) => ({ path: resolve(file.path), content: file.content }))
994
+ .sort((left, right) => left.path.localeCompare(right.path)),
995
+ availableAgentsFiles: availableAgentsFiles
996
+ .map((file) => resolve(file.path))
997
+ .sort((left, right) => left.localeCompare(right)),
998
+ skills: workspace.skills
999
+ .map((skill) => ({
1000
+ name: skill.name,
1001
+ description: skill.description,
1002
+ filePath: resolve(skill.filePath),
1003
+ disableModelInvocation: skill.disableModelInvocation ?? false,
1004
+ }))
1005
+ .sort((left, right) => left.name.localeCompare(right.name) || left.filePath.localeCompare(right.filePath)),
1006
+ skillDiagnostics: workspace.skillDiagnostics,
1007
+ capabilityGuides: workspace.capabilityGuides
1008
+ .map((guide) => ({
1009
+ name: guide.name,
1010
+ description: guide.description,
1011
+ whenToRead: guide.whenToRead,
1012
+ filePath: resolve(guide.filePath),
1013
+ }))
1014
+ .sort((left, right) => left.name.localeCompare(right.name)),
1015
+ agentProfiles: workspace.agentProfiles
1016
+ .map((profile) => ({
1017
+ name: profile.name,
1018
+ description: profile.description,
1019
+ provider: profile.provider,
1020
+ model: profile.model,
1021
+ thinking: profile.thinking,
1022
+ filePath: resolve(profile.filePath),
1023
+ }))
1024
+ .sort((left, right) => left.name.localeCompare(right.name)),
1025
+ };
1026
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
763
1027
  }
764
1028
  async function canonicalPath(path) {
765
1029
  const missingSegments = [];
@@ -818,9 +1082,6 @@ export function formatAgentsPath(path, workspaceRoot) {
818
1082
  }
819
1083
  return relationship.split(sep).join("/");
820
1084
  }
821
- function isProjectRootInstructionPath(path, root) {
822
- return isPathInsideRoot(path, root) && dirname(path) === root;
823
- }
824
1085
  async function readSystemInstructions(path) {
825
1086
  try {
826
1087
  return await readFile(path, "utf8");
@@ -832,20 +1093,6 @@ async function readSystemInstructions(path) {
832
1093
  throw error;
833
1094
  }
834
1095
  }
835
- async function readResolvedProjectContextFile(path, root) {
836
- try {
837
- const resolvedPath = await realpath(path);
838
- if (!isProjectRootInstructionPath(resolvedPath, root))
839
- return undefined;
840
- return await readFile(resolvedPath, "utf8");
841
- }
842
- catch (error) {
843
- if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
844
- return undefined;
845
- }
846
- throw error;
847
- }
848
- }
849
1096
  async function tryRealpath(path) {
850
1097
  try {
851
1098
  return await realpath(path);
@@ -854,25 +1101,6 @@ async function tryRealpath(path) {
854
1101
  return undefined;
855
1102
  }
856
1103
  }
857
- async function walkWorkspace(directory, visit) {
858
- let entries;
859
- try {
860
- entries = await opendir(directory);
861
- }
862
- catch {
863
- return;
864
- }
865
- for await (const entry of entries) {
866
- const path = join(directory, entry.name);
867
- if (entry.isDirectory()) {
868
- if (!SKIPPED_CONTEXT_DIRS.has(entry.name)) {
869
- await walkWorkspace(path, visit);
870
- }
871
- continue;
872
- }
873
- await visit(path, entry);
874
- }
875
- }
876
1104
  function isErrnoException(error) {
877
1105
  return error instanceof Error && "code" in error;
878
1106
  }