@akira-tl/forgerelay 0.7.4 → 0.8.1
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 +21 -0
- package/dist/db/migrations.js +19 -0
- package/dist/db/schema.js +8 -0
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/server.js +111 -20
- package/dist/workspace-store.js +77 -8
- package/dist/workspaces.js +153 -87
- package/docs/chatgpt-coding-workflow.md +41 -34
- package/docs/configuration.md +38 -32
- package/docs/roadmap.md +39 -11
- package/docs/security.md +4 -4
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +73 -31
package/dist/workspaces.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
@@ -23,6 +24,7 @@ export class WorkspaceRegistry {
|
|
|
23
24
|
this.config = config;
|
|
24
25
|
this.store = store;
|
|
25
26
|
this.hooks = new HookRunner(config.hooks, config.logging);
|
|
27
|
+
this.foldLegacyWorkspaceSessions();
|
|
26
28
|
this.pruneIdleWorkspaceSessions(new Set(), true);
|
|
27
29
|
}
|
|
28
30
|
get cachedWorkspaceCount() {
|
|
@@ -42,7 +44,7 @@ export class WorkspaceRegistry {
|
|
|
42
44
|
if (mode === "worktree") {
|
|
43
45
|
return this.openReusableWorktree(workspaceInput, openOptions.conversationScopeId);
|
|
44
46
|
}
|
|
45
|
-
return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId,
|
|
47
|
+
return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, bootstrapContext);
|
|
46
48
|
}
|
|
47
49
|
async listWorkspaces(input = {}, openOptions = {}) {
|
|
48
50
|
this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
|
|
@@ -69,6 +71,9 @@ export class WorkspaceRegistry {
|
|
|
69
71
|
.filter((binding) => binding.conversationScopeId === openOptions.conversationScopeId)
|
|
70
72
|
.map((binding) => binding.workspaceSessionId)
|
|
71
73
|
: []);
|
|
74
|
+
const workspaceIdFilter = input.workspaceId
|
|
75
|
+
? this.store?.getSession(input.workspaceId)?.id ?? input.workspaceId
|
|
76
|
+
: undefined;
|
|
72
77
|
const rootKey = input.root
|
|
73
78
|
? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
|
|
74
79
|
: undefined;
|
|
@@ -108,7 +113,7 @@ export class WorkspaceRegistry {
|
|
|
108
113
|
const entry = entries[index];
|
|
109
114
|
if (!session || !entry)
|
|
110
115
|
continue;
|
|
111
|
-
if (
|
|
116
|
+
if (workspaceIdFilter && entry.workspaceId !== workspaceIdFilter)
|
|
112
117
|
continue;
|
|
113
118
|
if (input.status && entry.status !== input.status)
|
|
114
119
|
continue;
|
|
@@ -147,7 +152,7 @@ export class WorkspaceRegistry {
|
|
|
147
152
|
};
|
|
148
153
|
}
|
|
149
154
|
async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
|
|
150
|
-
const workspace = this.
|
|
155
|
+
const workspace = this.workspaceForOpen(workspaceId);
|
|
151
156
|
const context = await this.reusedWorkspaceContext(workspace);
|
|
152
157
|
if (!conversationScopeId || !this.store) {
|
|
153
158
|
return {
|
|
@@ -205,22 +210,24 @@ export class WorkspaceRegistry {
|
|
|
205
210
|
}
|
|
206
211
|
closeWorkspace(workspaceId) {
|
|
207
212
|
const workspace = this.getWorkspace(workspaceId);
|
|
213
|
+
const canonicalWorkspaceId = workspace.id;
|
|
208
214
|
if (workspace.mode === "worktree") {
|
|
209
|
-
|
|
210
|
-
.filter((session) => resolve(session.root) === resolve(workspace.root));
|
|
211
|
-
if (aliases.length <= 1) {
|
|
212
|
-
throw new Error(`Workspace ${workspaceId} is the last active handle for a worktree. Use close_worktree to finalize and remove the physical worktree, or keep this handle as its anchor.`);
|
|
213
|
-
}
|
|
215
|
+
throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle.`);
|
|
214
216
|
}
|
|
215
217
|
if (this.store) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
this.store.deleteSession(workspaceId);
|
|
218
|
+
this.deleteConversationBindingsForWorkspace(canonicalWorkspaceId);
|
|
219
|
+
this.store.setSessionStatus(canonicalWorkspaceId, "closed");
|
|
222
220
|
}
|
|
223
|
-
this.workspaces.delete(
|
|
221
|
+
this.workspaces.delete(canonicalWorkspaceId);
|
|
222
|
+
}
|
|
223
|
+
deleteWorkspace(workspaceId) {
|
|
224
|
+
const session = this.getWorkspaceSession(workspaceId);
|
|
225
|
+
if (session.mode !== "checkout") {
|
|
226
|
+
throw new Error(`Workspace ${session.id} is not a checkout Workspace. Delete semantics for ${session.mode} Workspaces are handled by their own lifecycle.`);
|
|
227
|
+
}
|
|
228
|
+
this.deleteConversationBindingsForWorkspace(session.id);
|
|
229
|
+
this.store?.deleteSession(session.id);
|
|
230
|
+
this.workspaces.delete(session.id);
|
|
224
231
|
}
|
|
225
232
|
workspaceIdsForPhysicalWorkspace(workspace) {
|
|
226
233
|
const root = resolve(workspace.root);
|
|
@@ -325,30 +332,18 @@ export class WorkspaceRegistry {
|
|
|
325
332
|
}
|
|
326
333
|
return { ...result, hookReports };
|
|
327
334
|
}
|
|
328
|
-
async openReusableCheckout(path, conversationScopeId,
|
|
335
|
+
async openReusableCheckout(path, conversationScopeId, bootstrapContext) {
|
|
329
336
|
const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
|
|
330
337
|
const projectKey = await canonicalPath(allowedPath);
|
|
331
338
|
const targetKey = JSON.stringify(["checkout", projectKey, null]);
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (newWorkspace) {
|
|
338
|
-
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
|
|
339
|
-
const freshContext = reusableWorkspace
|
|
340
|
-
? await this.cloneWorkspaceContext(reusableWorkspace)
|
|
341
|
-
: await this.openCheckoutWorkspace(path);
|
|
342
|
-
return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
|
|
343
|
-
}
|
|
344
|
-
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
345
|
-
const context = await this.openOnce(operationKey, async () => {
|
|
346
|
-
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
|
|
339
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
|
|
340
|
+
if (boundContext)
|
|
341
|
+
return boundContext;
|
|
342
|
+
const context = await this.openOnce(targetKey, async () => {
|
|
343
|
+
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout", true);
|
|
347
344
|
if (!reusableWorkspace)
|
|
348
345
|
return this.openCheckoutWorkspace(path);
|
|
349
|
-
return
|
|
350
|
-
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
351
|
-
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
346
|
+
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
352
347
|
});
|
|
353
348
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
354
349
|
}
|
|
@@ -361,27 +356,15 @@ export class WorkspaceRegistry {
|
|
|
361
356
|
if (managedPath) {
|
|
362
357
|
const worktreeKey = await canonicalPath(managedPath);
|
|
363
358
|
const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
if (input.newWorkspace) {
|
|
370
|
-
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
|
|
371
|
-
if (!reusableWorkspace) {
|
|
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.`);
|
|
373
|
-
}
|
|
374
|
-
return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey, bootstrapContext);
|
|
375
|
-
}
|
|
376
|
-
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
377
|
-
const context = await this.openOnce(operationKey, async () => {
|
|
359
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
|
|
360
|
+
if (boundContext)
|
|
361
|
+
return boundContext;
|
|
362
|
+
const context = await this.openOnce(targetKey, async () => {
|
|
378
363
|
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
|
|
379
364
|
if (!reusableWorkspace) {
|
|
380
365
|
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.`);
|
|
381
366
|
}
|
|
382
|
-
return
|
|
383
|
-
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
384
|
-
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
367
|
+
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
385
368
|
});
|
|
386
369
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
387
370
|
}
|
|
@@ -396,29 +379,17 @@ export class WorkspaceRegistry {
|
|
|
396
379
|
const context = await this.openWorktreeWorkspace(path, input.baseRef);
|
|
397
380
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
398
381
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
if (input.newWorkspace) {
|
|
408
|
-
const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
|
|
409
|
-
const freshContext = reusableWorkspace
|
|
410
|
-
? await this.cloneWorkspaceContext(reusableWorkspace)
|
|
411
|
-
: await this.openWorktreeWorkspace(path, input.baseRef);
|
|
412
|
-
return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
|
|
413
|
-
}
|
|
414
|
-
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
415
|
-
const context = await this.openOnce(operationKey, async () => {
|
|
382
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
|
|
383
|
+
session.sourceRoot !== undefined &&
|
|
384
|
+
await canonicalPath(session.sourceRoot) === sourceKey &&
|
|
385
|
+
session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
|
|
386
|
+
if (boundContext)
|
|
387
|
+
return boundContext;
|
|
388
|
+
const context = await this.openOnce(targetKey, async () => {
|
|
416
389
|
const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
|
|
417
390
|
if (!reusableWorkspace)
|
|
418
391
|
return this.openWorktreeWorkspace(path, input.baseRef);
|
|
419
|
-
return
|
|
420
|
-
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
421
|
-
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
392
|
+
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
422
393
|
});
|
|
423
394
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
424
395
|
}
|
|
@@ -453,11 +424,6 @@ export class WorkspaceRegistry {
|
|
|
453
424
|
}
|
|
454
425
|
return keys;
|
|
455
426
|
}
|
|
456
|
-
conversationOpenKey(targetKey, conversationScopeId) {
|
|
457
|
-
return conversationScopeId && this.store
|
|
458
|
-
? JSON.stringify(["conversation", conversationScopeId, targetKey])
|
|
459
|
-
: targetKey;
|
|
460
|
-
}
|
|
461
427
|
async boundConversationContext(conversationScopeId, targetKey, mode, matches, bootstrapContext) {
|
|
462
428
|
if (!conversationScopeId || !this.store)
|
|
463
429
|
return undefined;
|
|
@@ -550,17 +516,62 @@ export class WorkspaceRegistry {
|
|
|
550
516
|
worktreeAnchors.get(resolve(session.root))?.id === session.id) {
|
|
551
517
|
continue;
|
|
552
518
|
}
|
|
553
|
-
this.store.deleteSession(session.id);
|
|
554
519
|
this.workspaces.delete(session.id);
|
|
555
520
|
}
|
|
556
521
|
}
|
|
557
|
-
|
|
558
|
-
|
|
522
|
+
foldLegacyWorkspaceSessions() {
|
|
523
|
+
if (!this.store)
|
|
524
|
+
return;
|
|
525
|
+
const groups = new Map();
|
|
526
|
+
for (const session of this.store.listSessions()) {
|
|
527
|
+
const targetPath = canonicalPersistedWorkspacePath(session.root);
|
|
528
|
+
const key = JSON.stringify([session.mode, targetPath]);
|
|
529
|
+
const group = groups.get(key) ?? [];
|
|
530
|
+
group.push(session);
|
|
531
|
+
groups.set(key, group);
|
|
532
|
+
}
|
|
533
|
+
for (const sessions of groups.values()) {
|
|
534
|
+
if (sessions.length < 2)
|
|
535
|
+
continue;
|
|
536
|
+
const ordered = [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
537
|
+
const canonical = ordered[0];
|
|
538
|
+
if (!canonical)
|
|
539
|
+
continue;
|
|
540
|
+
const createdAt = ordered.reduce((earliest, session) => session.createdAt < earliest ? session.createdAt : earliest, canonical.createdAt);
|
|
541
|
+
const lastUsedAt = ordered.reduce((latest, session) => session.lastUsedAt > latest ? session.lastUsedAt : latest, canonical.lastUsedAt);
|
|
542
|
+
const status = ordered.some((session) => session.status === "active")
|
|
543
|
+
? "active"
|
|
544
|
+
: canonical.status;
|
|
545
|
+
this.store.foldSessions({
|
|
546
|
+
canonicalId: canonical.id,
|
|
547
|
+
aliasIds: ordered.slice(1).map((session) => session.id),
|
|
548
|
+
createdAt,
|
|
549
|
+
lastUsedAt,
|
|
550
|
+
status,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async findReusableWorkspaceByDirectory(directoryKey, mode, reopenClosedCheckout = false) {
|
|
555
|
+
const sessions = this.store
|
|
556
|
+
? this.store.listSessions({ mode })
|
|
557
|
+
: this.activeSessions(mode);
|
|
558
|
+
for (const session of sessions) {
|
|
559
|
+
const reusable = session.status === "active" ||
|
|
560
|
+
(reopenClosedCheckout && mode === "checkout" && session.status === "closed");
|
|
561
|
+
if (!reusable)
|
|
562
|
+
continue;
|
|
559
563
|
const root = await this.validSessionRoot(session);
|
|
560
564
|
if (!root)
|
|
561
565
|
continue;
|
|
562
566
|
if (await canonicalPath(root) !== directoryKey)
|
|
563
567
|
continue;
|
|
568
|
+
if (session.status === "closed") {
|
|
569
|
+
this.store?.setSessionStatus(session.id, "active");
|
|
570
|
+
const reopened = this.store?.getSession(session.id);
|
|
571
|
+
if (!reopened)
|
|
572
|
+
continue;
|
|
573
|
+
return this.workspaceFromSession(reopened, false);
|
|
574
|
+
}
|
|
564
575
|
return this.workspaceFromSession(session, false);
|
|
565
576
|
}
|
|
566
577
|
return undefined;
|
|
@@ -623,14 +634,6 @@ export class WorkspaceRegistry {
|
|
|
623
634
|
throw error;
|
|
624
635
|
}
|
|
625
636
|
}
|
|
626
|
-
async cloneWorkspaceContext(workspace) {
|
|
627
|
-
return this.createWorkspaceContext({
|
|
628
|
-
root: workspace.root,
|
|
629
|
-
mode: workspace.mode,
|
|
630
|
-
sourceRoot: workspace.sourceRoot,
|
|
631
|
-
worktree: workspace.worktree ? { ...workspace.worktree } : undefined,
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
637
|
async reusedWorkspaceContext(workspace) {
|
|
635
638
|
Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
|
|
636
639
|
workspace.capabilityGuides = loadCapabilityGuides(this.config);
|
|
@@ -651,6 +654,50 @@ export class WorkspaceRegistry {
|
|
|
651
654
|
includeBootstrapContext: true,
|
|
652
655
|
};
|
|
653
656
|
}
|
|
657
|
+
workspaceForOpen(workspaceId) {
|
|
658
|
+
const session = this.store?.getSession(workspaceId);
|
|
659
|
+
if (session?.status === "closed" && session.mode === "checkout") {
|
|
660
|
+
this.store?.setSessionStatus(session.id, "active");
|
|
661
|
+
const reopened = this.store?.getSession(session.id);
|
|
662
|
+
if (!reopened) {
|
|
663
|
+
throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
|
|
664
|
+
}
|
|
665
|
+
return this.workspaceFromSession(reopened, true);
|
|
666
|
+
}
|
|
667
|
+
return this.getWorkspace(workspaceId);
|
|
668
|
+
}
|
|
669
|
+
getWorkspaceSession(workspaceId) {
|
|
670
|
+
const session = this.store?.getSession(workspaceId);
|
|
671
|
+
if (session)
|
|
672
|
+
return session;
|
|
673
|
+
const workspace = this.workspaces.get(workspaceId);
|
|
674
|
+
if (!workspace) {
|
|
675
|
+
throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
id: workspace.id,
|
|
679
|
+
root: workspace.root,
|
|
680
|
+
status: "active",
|
|
681
|
+
mode: workspace.mode,
|
|
682
|
+
sourceRoot: workspace.sourceRoot,
|
|
683
|
+
baseRef: workspace.worktree?.baseRef,
|
|
684
|
+
baseSha: workspace.worktree?.baseSha,
|
|
685
|
+
branch: workspace.worktree?.branch,
|
|
686
|
+
targetBranch: workspace.worktree?.targetBranch,
|
|
687
|
+
managed: workspace.worktree?.managed ?? false,
|
|
688
|
+
createdAt: "",
|
|
689
|
+
lastUsedAt: "",
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
deleteConversationBindingsForWorkspace(workspaceId) {
|
|
693
|
+
if (!this.store)
|
|
694
|
+
return;
|
|
695
|
+
for (const binding of this.store.listConversationBindings()) {
|
|
696
|
+
if (binding.workspaceSessionId === workspaceId) {
|
|
697
|
+
this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
654
701
|
getWorkspace(workspaceId) {
|
|
655
702
|
const workspace = this.workspaces.get(workspaceId);
|
|
656
703
|
if (workspace) {
|
|
@@ -1036,6 +1083,25 @@ function bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFile
|
|
|
1036
1083
|
};
|
|
1037
1084
|
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
1038
1085
|
}
|
|
1086
|
+
function canonicalPersistedWorkspacePath(path) {
|
|
1087
|
+
const missingSegments = [];
|
|
1088
|
+
let candidate = path;
|
|
1089
|
+
while (true) {
|
|
1090
|
+
try {
|
|
1091
|
+
return resolve(realpathSync(candidate), ...missingSegments.slice().reverse());
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
if (!isErrnoException(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
|
|
1095
|
+
return resolve(path);
|
|
1096
|
+
}
|
|
1097
|
+
const parent = dirname(candidate);
|
|
1098
|
+
if (parent === candidate)
|
|
1099
|
+
return resolve(path);
|
|
1100
|
+
missingSegments.push(basename(candidate));
|
|
1101
|
+
candidate = parent;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1039
1105
|
async function canonicalPath(path) {
|
|
1040
1106
|
const missingSegments = [];
|
|
1041
1107
|
let candidate = path;
|
|
@@ -9,14 +9,17 @@ checkout.
|
|
|
9
9
|
`open_workspace` returns a `workspaceId`. Continue using that ID for later tools
|
|
10
10
|
in the same directory.
|
|
11
11
|
|
|
12
|
-
`workspaceId`
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
`workspaceId` identifies a persistent ForgeRelay Workspace rather than a
|
|
13
|
+
conversation-scoped handle. A canonical checkout path maps to one checkout
|
|
14
|
+
Workspace, and a managed worktree path maps to one managed-worktree Workspace.
|
|
15
|
+
Different Host conversations may bind to and reuse the same `workspaceId`; pass a
|
|
16
|
+
known ID explicitly when the user wants to resume that Workspace. Historical
|
|
17
|
+
duplicate IDs created by older ForgeRelay versions are accepted during migration
|
|
18
|
+
and resolve to the canonical Workspace identity.
|
|
19
|
+
|
|
20
|
+
A Git worktree directory is a separate physical Workspace target from its source
|
|
21
|
+
checkout. Separate parallel identities require separate managed worktrees rather
|
|
22
|
+
than multiple logical handles pointing at one physical target.
|
|
20
23
|
|
|
21
24
|
### Bootstrap context and workspace inventory
|
|
22
25
|
|
|
@@ -28,12 +31,11 @@ open_workspace(path="~/project")
|
|
|
28
31
|
|
|
29
32
|
The default `context="auto"` keeps the first useful bootstrap while avoiding
|
|
30
33
|
replay. ForgeRelay tracks delivered context by conversation plus canonical
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
automatic open returns the refreshed bootstrap.
|
|
34
|
+
Workspace target and a content fingerprint, independently from persistent
|
|
35
|
+
Workspace identity. Different conversations may reuse the same `workspaceId` while
|
|
36
|
+
each receives the current bootstrap once. If loaded instruction contents or
|
|
37
|
+
relevant Skill, Capability guide, profile, diagnostic, or nested-instruction
|
|
38
|
+
metadata changes, the next automatic open returns the refreshed bootstrap.
|
|
37
39
|
|
|
38
40
|
Two explicit controls are available for exceptional cases:
|
|
39
41
|
|
|
@@ -42,15 +44,14 @@ open_workspace(workspaceId="ws_...", context="full")
|
|
|
42
44
|
open_workspace(workspaceId="ws_...", context="none")
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
`full` forces a bootstrap refresh. `none` opens/resumes the
|
|
47
|
+
`full` forces a bootstrap refresh. `none` opens/resumes the Workspace without
|
|
46
48
|
returning the full project context and does not record the current fingerprint as
|
|
47
|
-
already delivered. Context-delivery state
|
|
48
|
-
|
|
49
|
-
forget unchanged project context it already received.
|
|
49
|
+
already delivered. Context-delivery state remains conversation-scoped and does not
|
|
50
|
+
change the persistent Workspace identity.
|
|
50
51
|
|
|
51
|
-
Do not enumerate
|
|
52
|
-
continue
|
|
53
|
-
|
|
52
|
+
Do not enumerate Workspace state on every normal open. When the user wants to
|
|
53
|
+
continue earlier work, inspect known Workspaces, or clean up accumulated state, use
|
|
54
|
+
the same Core tool in inventory mode:
|
|
54
55
|
|
|
55
56
|
```text
|
|
56
57
|
open_workspace(action="list")
|
|
@@ -62,7 +63,7 @@ Inventory is paginated, defaults to 50 entries, and caps each page at 100. It ca
|
|
|
62
63
|
filter by `workspaceId`, persisted `status`, derived `state`, `mode`, canonical
|
|
63
64
|
root/source root, or stale-only state. Entries include a compact label such as
|
|
64
65
|
`project/ws_...`, checkout/worktree backing metadata, timestamps, idle duration,
|
|
65
|
-
root validity, and whether that
|
|
66
|
+
root validity, and whether that Workspace is currently selected by this
|
|
66
67
|
conversation. Listing is observational and does not refresh `lastUsedAt`.
|
|
67
68
|
|
|
68
69
|
Treat persisted status and derived state separately. `status="active"` means the
|
|
@@ -278,18 +279,24 @@ surface, including direct `rename`/`delete` path mutations alongside `apply_patc
|
|
|
278
279
|
unified move/rename primitive for both files and directories; ForgeRelay does not
|
|
279
280
|
expose a separate `move` tool.
|
|
280
281
|
|
|
281
|
-
Workspace IDs are
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
may still include `staleWorkspaces`
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
282
|
+
Workspace IDs are persistent ForgeRelay identities. The same canonical checkout
|
|
283
|
+
or managed worktree is reused across conversations; older duplicate IDs remain
|
|
284
|
+
compatibility aliases that resolve to the canonical Workspace. `newWorkspace` is
|
|
285
|
+
deprecated and no longer allocates a second identity for the same physical target;
|
|
286
|
+
use `newWorktree=true` when genuinely separate Git isolation is required. The
|
|
287
|
+
normal open path may still include `staleWorkspaces` for an idle persistent
|
|
288
|
+
Workspace; use `open_workspace(action="list")` for complete, filtered inventory
|
|
289
|
+
when continuation or cleanup actually requires it.
|
|
290
|
+
|
|
291
|
+
For checkout-backed Workspaces, `close_workspace` defaults to `action="close"` and
|
|
292
|
+
preserves the Workspace identity for later reopen. A closed Workspace stays visible
|
|
293
|
+
through `open_workspace(action="list")`, while ordinary execution tools reject it
|
|
294
|
+
until `open_workspace` reactivates the same ID by path or by `workspaceId`.
|
|
295
|
+
`close_workspace(action="delete")` is the explicit permanent checkout cleanup path:
|
|
296
|
+
it removes ForgeRelay-owned Workspace state but never deletes or mutates project
|
|
297
|
+
files. Managed-worktree close still requires `commitMessage` and runs the safe commit /
|
|
298
|
+
fast-forward-only integration / cleanup lifecycle; managed-worktree, Composite, and
|
|
299
|
+
relayed delete semantics are completed by their later lifecycle stages.
|
|
293
300
|
|
|
294
301
|
Shell commands are allowed to modify ordinary project files when that is a
|
|
295
302
|
natural part of the user's requested development task; ForgeRelay does not apply
|
package/docs/configuration.md
CHANGED
|
@@ -286,24 +286,27 @@ is no separate `move` MCP tool.
|
|
|
286
286
|
Codex-mode commands run without a PTY by default. `tty: true` enables interactive
|
|
287
287
|
programs when the optional `node-pty` dependency is available.
|
|
288
288
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
289
|
+
Workspace IDs identify persistent ForgeRelay Workspaces rather than individual
|
|
290
|
+
conversation handles. A canonical checkout path maps to one checkout Workspace, and
|
|
291
|
+
a managed worktree path maps to one managed-worktree Workspace; different Host
|
|
292
|
+
conversations can bind to and reuse the same `workspaceId`. Pass `workspaceId` to
|
|
293
|
+
`open_workspace` to resume that known Workspace explicitly. Historical duplicate
|
|
294
|
+
IDs created by older ForgeRelay versions remain accepted during migration and
|
|
295
|
+
resolve to the canonical Workspace. `newWorkspace: true` is retained only as a
|
|
296
|
+
deprecated compatibility input and no longer allocates another identity for the
|
|
297
|
+
same physical target; use `newWorktree: true` for genuinely separate Git isolation.
|
|
298
|
+
|
|
299
|
+
Bootstrap delivery is tracked separately from Workspace identity.
|
|
298
300
|
`open_workspace` defaults to `context="auto"`: ForgeRelay fingerprints the current
|
|
299
301
|
project context and returns the full AGENTS/Skills/Capability-guide/profile bootstrap
|
|
300
302
|
only when that conversation has not already received the current fingerprint for
|
|
301
303
|
the canonical workspace target. `context="full"` forces a refresh;
|
|
302
|
-
`context="none"` opens or resumes the
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
304
|
+
`context="none"` opens or resumes the Workspace without returning the full bootstrap
|
|
305
|
+
and does not mark the current fingerprint as delivered. Conversation-scoped
|
|
306
|
+
bootstrap delivery therefore remains independent from the persistent Workspace
|
|
307
|
+
identity: another conversation may reuse the same Workspace while independently
|
|
308
|
+
receiving the current bootstrap once, and changed context produces a new fingerprint
|
|
309
|
+
for the next `auto` open.
|
|
307
310
|
|
|
308
311
|
Composite Workspaces use the same `open_workspace` entry point with
|
|
309
312
|
`kind="composite"` and a human-readable `name`. They have no filesystem root of
|
|
@@ -324,26 +327,29 @@ Hooks, Skills, language services, and Activity facts remain owned by the underly
|
|
|
324
327
|
Workspace. The Composite Activity Panel only aggregates their presentation into one
|
|
325
328
|
Host Turn.
|
|
326
329
|
|
|
327
|
-
Use `open_workspace(action="list")` only when the Agent needs to
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
Use `open_workspace(action="list")` only when the Agent needs to inspect known
|
|
331
|
+
Workspaces, continue earlier work, or organize Workspace state. The inventory is
|
|
332
|
+
paginated (50 records by default, at most 100) and can filter by Workspace ID,
|
|
333
|
+
persisted status, derived state, mode, canonical root/source root, or stale-only
|
|
334
|
+
state. Reading inventory does not refresh `lastUsedAt`. Persisted
|
|
332
335
|
`status="active"` means the record has not been explicitly closed; the derived
|
|
333
336
|
`state` distinguishes `active`, `stale`, `invalid`, and `closed`. A missing checkout
|
|
334
337
|
or externally removed managed-worktree root can therefore remain diagnostically
|
|
335
|
-
`status="active"` while appearing as `state="invalid"`.
|
|
336
|
-
|
|
337
|
-
`action="list"`
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
338
|
+
`status="active"` while appearing as `state="invalid"`. Canonical identity means
|
|
339
|
+
ordinary same-target opens no longer accumulate duplicate inventory rows;
|
|
340
|
+
`action="list"` remains the formal on-demand inventory path.
|
|
341
|
+
|
|
342
|
+
For checkout-backed Workspaces, `close_workspace` now defaults to `action="close"`:
|
|
343
|
+
it marks the persistent Workspace closed, removes current conversation bindings, and
|
|
344
|
+
keeps the same Workspace identity available for later `open_workspace` by path or ID.
|
|
345
|
+
Closed Workspaces remain visible in inventory but ordinary execution tools reject them
|
|
346
|
+
until reopened. `action="delete"` permanently removes ForgeRelay-owned checkout
|
|
347
|
+
identity/state while never deleting or mutating the user's checkout directory.
|
|
348
|
+
Managed-worktree close still requires `commitMessage` and runs the existing safe
|
|
349
|
+
finalize lifecycle; managed-worktree delete semantics land in the next lifecycle
|
|
350
|
+
stage. Composite close still dissolves in this stage, and Composite delete remains
|
|
351
|
+
unavailable until its persistent lifecycle stage. Relayed delete is likewise deferred
|
|
352
|
+
to Workspace Relay lifecycle parity.
|
|
347
353
|
|
|
348
354
|
Hot workspace/session activity timestamps are coalesced in memory and flushed to the
|
|
349
355
|
SQLite state database in a transaction at most every five minutes; normal shutdown
|
|
@@ -366,7 +372,7 @@ the initial run request terminates a not-yet-handed-off process so ForgeRelay do
|
|
|
366
372
|
not leave an orphan process whose `processId` the Agent never received.
|
|
367
373
|
|
|
368
374
|
Completed background processes are delivered once with a later tool result for the
|
|
369
|
-
same
|
|
375
|
+
same Workspace ID. Full buffered completion output is retained for five
|
|
370
376
|
minutes; after that ForgeRelay compacts the completion to a bounded head/tail record
|
|
371
377
|
and keeps it deliverable for up to 24 hours, still subject to the global completed
|
|
372
378
|
process count bound. Completed processes no longer prevent `close_workspace`; the
|