@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.
- package/CHANGELOG.md +29 -0
- package/capabilities/shell-processes/GUIDE.md +2 -2
- package/dist/db/migrations.js +19 -0
- package/dist/db/schema.js +9 -0
- package/dist/logger.js +16 -0
- package/dist/mcp-sessions.js +30 -0
- package/dist/oauth-provider.js +9 -0
- package/dist/process-sessions.js +98 -16
- package/dist/review-checkpoints.js +36 -1
- package/dist/server.js +234 -26
- package/dist/workspace-store.js +148 -20
- package/dist/workspaces.js +339 -111
- package/docs/chatgpt-coding-workflow.md +75 -12
- package/docs/configuration.md +47 -9
- package/docs/roadmap.md +23 -1
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +60 -3
package/dist/workspaces.js
CHANGED
|
@@ -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
|
|
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 {
|
|
153
|
+
return {
|
|
154
|
+
...context,
|
|
155
|
+
includeBootstrapContext: bootstrapContext !== "none",
|
|
156
|
+
};
|
|
47
157
|
}
|
|
48
158
|
const targetKeys = await this.workspaceTargetKeys(workspace);
|
|
49
|
-
const
|
|
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
|
|
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.
|
|
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 {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
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
|
|
678
|
-
const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
}
|