@akira-tl/forgerelay 0.7.4 → 0.8.0

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 CHANGED
@@ -4,6 +4,17 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] - 2026-08-30
8
+
9
+ ### Changed
10
+
11
+ - Workspace identity is now canonical per physical checkout or managed worktree, so different Host conversations reuse one persistent `workspaceId`; legacy duplicate IDs remain compatible aliases, and `newWorkspace` no longer creates same-target duplicates.
12
+
13
+ ### Fixed
14
+
15
+ - Composite member bootstrap now preserves capability guides and subagent provider/profile context exposed by the underlying Workspace.
16
+ - Composite member `context="none"` now truthfully reports suppressed bootstrap instead of claiming it was already delivered.
17
+
7
18
  ## [0.7.4] - 2026-08-30
8
19
 
9
20
  ### Added
@@ -74,6 +74,11 @@ const migrations = [
74
74
  name: "subagent-run-ownership",
75
75
  up: migrateSubagentRunOwnership,
76
76
  },
77
+ {
78
+ version: 16,
79
+ name: "workspace-session-aliases",
80
+ up: migrateWorkspaceSessionAliases,
81
+ },
77
82
  ];
78
83
  export function migrateDatabase(sqlite) {
79
84
  const migrate = sqlite.transaction(() => {
@@ -368,6 +373,20 @@ function migrateSubagentRunOwnership(sqlite) {
368
373
  addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_id", "text");
369
374
  addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_pid", "integer");
370
375
  }
376
+ function migrateWorkspaceSessionAliases(sqlite) {
377
+ sqlite.exec(`
378
+ create table if not exists workspace_session_aliases (
379
+ alias_id text primary key,
380
+ workspace_session_id text not null,
381
+ foreign key (workspace_session_id)
382
+ references workspace_sessions(id)
383
+ on delete cascade
384
+ );
385
+
386
+ create index if not exists workspace_session_aliases_workspace_idx
387
+ on workspace_session_aliases(workspace_session_id);
388
+ `);
389
+ }
371
390
  function migrateActivityHostTurnWorkspace(sqlite) {
372
391
  migrateActivityHostTurns(sqlite);
373
392
  addColumnIfMissing(sqlite, "activity_host_turns", "workspace_id", "text");
package/dist/db/schema.js CHANGED
@@ -16,6 +16,14 @@ export const workspaceSessions = sqliteTable("workspace_sessions", {
16
16
  index("workspace_sessions_root_idx").on(table.root, table.lastUsedAt),
17
17
  index("workspace_sessions_status_idx").on(table.status, table.lastUsedAt),
18
18
  ]);
19
+ export const workspaceSessionAliases = sqliteTable("workspace_session_aliases", {
20
+ aliasId: text("alias_id").primaryKey(),
21
+ workspaceSessionId: text("workspace_session_id")
22
+ .notNull()
23
+ .references(() => workspaceSessions.id, { onDelete: "cascade" }),
24
+ }, (table) => [
25
+ index("workspace_session_aliases_workspace_idx").on(table.workspaceSessionId),
26
+ ]);
19
27
  export const loadedAgentFiles = sqliteTable("loaded_agent_files", {
20
28
  workspaceSessionId: text("workspace_session_id")
21
29
  .notNull()
package/dist/server.js CHANGED
@@ -1362,6 +1362,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1362
1362
  const skills = workspace.skills
1363
1363
  .filter((skill) => !skill.disableModelInvocation)
1364
1364
  .map((skill) => ({ name: skill.name, description: skill.description }));
1365
+ const capabilityGuides = workspace.capabilityGuides.map((guide) => ({
1366
+ name: guide.name,
1367
+ description: guide.description,
1368
+ whenToRead: guide.whenToRead,
1369
+ path: formatPathForPrompt(guide.filePath),
1370
+ }));
1371
+ const agentProviders = config.subagents ? subagentProviders : [];
1372
+ const agents = workspace.agentProfiles.map((profile) => {
1373
+ const summary = summarizeSubagentProfile(profile);
1374
+ const availability = agentProviders.find((provider) => provider.name === summary.provider);
1375
+ return {
1376
+ ...summary,
1377
+ providerAvailable: availability?.available,
1378
+ providerUnavailableReason: availability?.reason,
1379
+ };
1380
+ });
1365
1381
  return {
1366
1382
  member: memberName,
1367
1383
  workspaceId: compositeWorkspaceId,
@@ -1373,15 +1389,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1373
1389
  includeBootstrapContext: opened.includeBootstrapContext,
1374
1390
  ...(opened.includeBootstrapContext
1375
1391
  ? {
1392
+ capabilityGuides,
1376
1393
  agentsFiles,
1377
1394
  availableAgentsFiles,
1378
1395
  skills,
1396
+ agentProviders,
1397
+ agents,
1379
1398
  skillDiagnostics: redactSkillDiagnosticPaths(workspace.skillDiagnostics),
1380
1399
  }
1381
1400
  : {}),
1382
1401
  instruction: opened.includeBootstrapContext
1383
1402
  ? `Bootstrap context for Composite member ${memberName}. Keep using Composite workspaceId ${compositeWorkspaceId} and pass member=${memberName} for work operations.`
1384
- : `Composite member ${memberName} context was already delivered for this Host context; keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}.`,
1403
+ : contextPolicy === "none"
1404
+ ? `Bootstrap context for Composite member ${memberName} was intentionally suppressed by context=none. Keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}; request context=auto or context=full when member bootstrap is needed.`
1405
+ : `Composite member ${memberName} context was already delivered for this Host context; keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}.`,
1385
1406
  };
1386
1407
  };
1387
1408
  const coreOperations = createCoreOperationExecutor({
@@ -1960,7 +1981,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1960
1981
  action: z
1961
1982
  .enum(["open", "list", "member"])
1962
1983
  .optional()
1963
- .describe("Defaults to open. Use list to inspect logical workspaces. Use member to add/remove a named execution member on an existing Composite Workspace."),
1984
+ .describe("Defaults to open. Use list to inspect known Workspaces. Use member to add/remove a named execution member on an existing Composite Workspace."),
1964
1985
  memberAction: z
1965
1986
  .enum(["add", "update", "remove"])
1966
1987
  .optional()
@@ -2000,7 +2021,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2000
2021
  workspaceId: z
2001
2022
  .string()
2002
2023
  .optional()
2003
- .describe("For action=open, an existing logical workspace ID to resume in this conversation. For action=list, filters inventory to one workspace ID."),
2024
+ .describe("For action=open, an existing Workspace ID to resume or reuse. Historical duplicate IDs from earlier ForgeRelay versions may resolve to the canonical Workspace ID. For action=list, filters inventory to one Workspace ID."),
2004
2025
  mode: z
2005
2026
  .enum(["checkout", "worktree"])
2006
2027
  .optional()
@@ -2016,7 +2037,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2016
2037
  newWorkspace: z
2017
2038
  .boolean()
2018
2039
  .optional()
2019
- .describe("When true, allocate a fresh logical workspaceId for the same physical checkout or worktree and bind this conversation to it. Use only after the user explicitly requests a new logical workspace."),
2040
+ .describe("Deprecated compatibility flag. It no longer creates another Workspace identity for the same physical checkout or managed worktree; ForgeRelay reuses that target's canonical Workspace. Use newWorktree=true when the user explicitly needs separate Git isolation."),
2020
2041
  context: z
2021
2042
  .enum(["auto", "full", "none"])
2022
2043
  .optional()
@@ -2036,7 +2057,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2036
2057
  staleOnly: z
2037
2058
  .boolean()
2038
2059
  .optional()
2039
- .describe("For action=list, return only active logical workspaces idle for more than two days."),
2060
+ .describe("For action=list, return only active Workspaces idle for more than two days."),
2040
2061
  offset: z
2041
2062
  .number()
2042
2063
  .int()
@@ -2570,7 +2591,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2570
2591
  const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
2571
2592
  const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
2572
2593
  const workspaceContextInstruction = "For later open_workspace calls, context=\"auto\" avoids repeating unchanged bootstrap context; use context=\"none\" when only the workspace handle/metadata is needed, or context=\"full\" to force a refresh.";
2573
- const workspaceManagementInstruction = "When you need to continue an earlier logical workspace or organize workspace state, use open_workspace(action=\"list\") to inspect candidates, then resume a selected workspaceId or ask the user before close_workspace cleanup.";
2594
+ const workspaceManagementInstruction = "When you need to inspect known Workspaces, continue earlier work, or organize Workspace state, use open_workspace(action=\"list\") to inspect candidates, then resume a selected workspaceId or ask the user before close_workspace cleanup.";
2574
2595
  const cardInstruction = config.skillsEnabled
2575
2596
  ? `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill, load it with read(path=\"skills://<name>\") before proceeding. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`
2576
2597
  : `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`;
@@ -2632,7 +2653,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2632
2653
  ? `Known worktrees: ${knownWorktrees.map((worktree) => `${worktree.path} [${worktree.workspaceId}]${worktree.branch ? ` branch=${worktree.branch}` : ""}${worktree.targetBranch ? ` target=${worktree.targetBranch}` : ""}${worktree.current ? " (current)" : ""}`).join(", ")}`
2633
2654
  : undefined,
2634
2655
  staleWorkspaces.length > 0
2635
- ? `Idle logical workspaces for this same physical workspace (>2 days): ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. Tell the user these are available to resume or explicitly close; do not clean them up automatically.`
2656
+ ? `This Workspace has been idle for more than 2 days: ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. It remains available to resume or explicitly close; do not clean it up automatically.`
2636
2657
  : undefined,
2637
2658
  `ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
2638
2659
  instruction,
@@ -2900,7 +2921,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2900
2921
  });
2901
2922
  registerAppTool(server, toolNames.closeWorkspace, {
2902
2923
  title: "Close workspace",
2903
- description: "Close one workspace after the user chooses cleanup. Composite Workspaces dissolve here: only the Composite identity and member links are removed; member Workspaces, files, processes, worktrees, and relay routes remain intact. Checkout-backed workspaces release only the logical handle. Managed-worktree-backed workspaces run the safe finalize lifecycle (hooks, commit, fast-forward integration, cleanup) and require commitMessage. Running processes block ordinary Workspace closure.",
2924
+ description: "Close one Workspace after the user chooses cleanup. In v0.8.0, Composite close still dissolves only the Composite identity/member links. Checkout close removes ForgeRelay state but never project files. Managed-worktree-backed Workspaces run the safe finalize lifecycle (hooks, commit, fast-forward integration, cleanup) and require commitMessage. Running processes block ordinary Workspace closure.",
2904
2925
  inputSchema: {
2905
2926
  workspaceId: z.string().describe("Workspace identifier to close."),
2906
2927
  commitMessage: z
@@ -2992,7 +3013,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2992
3013
  const busyWorkspaceIds = physicalWorkspaceIds
2993
3014
  .filter((id) => processSessions.activeWorkspaceIds().has(id));
2994
3015
  if (busyWorkspaceIds.length > 0) {
2995
- throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running: ${busyWorkspaceIds.join(", ")}.`);
3016
+ throw new Error(`Cannot close this worktree-backed Workspace while Workspace processes are still running: ${busyWorkspaceIds.join(", ")}.`);
2996
3017
  }
2997
3018
  const startedAt = performance.now();
2998
3019
  const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
@@ -1,6 +1,6 @@
1
1
  import { and, desc, eq } from "drizzle-orm";
2
2
  import { openDatabase } from "./db/client.js";
3
- import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
3
+ import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessionAliases, workspaceSessions, } from "./db/schema.js";
4
4
  const DEFAULT_TOUCH_FLUSH_INTERVAL_MS = 5 * 60 * 1_000;
5
5
  export class SqliteWorkspaceStore {
6
6
  database;
@@ -61,24 +61,33 @@ export class SqliteWorkspaceStore {
61
61
  return session;
62
62
  }
63
63
  getSession(id) {
64
+ const sessionId = this.resolveSessionId(id);
65
+ if (!sessionId)
66
+ return undefined;
64
67
  const row = this.database.db
65
68
  .select()
66
69
  .from(workspaceSessions)
67
- .where(eq(workspaceSessions.id, id))
70
+ .where(eq(workspaceSessions.id, sessionId))
68
71
  .get();
69
72
  if (!row)
70
73
  return undefined;
71
- return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(id));
74
+ return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(sessionId));
72
75
  }
73
76
  touchSession(id) {
74
- this.pendingSessionTouches.set(id, this.now().toISOString());
77
+ const sessionId = this.resolveSessionId(id);
78
+ if (!sessionId)
79
+ return;
80
+ this.pendingSessionTouches.set(sessionId, this.now().toISOString());
75
81
  }
76
82
  setSessionStatus(id, status) {
77
- this.pendingSessionTouches.delete(id);
83
+ const sessionId = this.resolveSessionId(id);
84
+ if (!sessionId)
85
+ return;
86
+ this.pendingSessionTouches.delete(sessionId);
78
87
  this.database.db
79
88
  .update(workspaceSessions)
80
89
  .set({ status, lastUsedAt: this.now().toISOString() })
81
- .where(eq(workspaceSessions.id, id))
90
+ .where(eq(workspaceSessions.id, sessionId))
82
91
  .run();
83
92
  }
84
93
  listSessions(input = {}) {
@@ -100,11 +109,56 @@ export class SqliteWorkspaceStore {
100
109
  .map((session) => applySessionTouch(session, this.pendingSessionTouches.get(session.id)))
101
110
  .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
102
111
  }
112
+ foldSessions(input) {
113
+ const aliases = input.aliasIds.filter((id) => id !== input.canonicalId);
114
+ if (aliases.length === 0)
115
+ return;
116
+ const fold = this.database.sqlite.transaction(() => {
117
+ this.database.sqlite.prepare(`
118
+ update workspace_sessions
119
+ set created_at = ?, last_used_at = ?, status = ?
120
+ where id = ?
121
+ `).run(input.createdAt, input.lastUsedAt, input.status, input.canonicalId);
122
+ const rebindConversation = this.database.sqlite.prepare(`
123
+ update workspace_conversation_bindings
124
+ set workspace_session_id = ?
125
+ where workspace_session_id = ?
126
+ `);
127
+ const rebindSubagents = this.database.sqlite.prepare(`
128
+ update local_agent_sessions
129
+ set workspace_id = ?
130
+ where workspace_id = ?
131
+ `);
132
+ const rebindAliases = this.database.sqlite.prepare(`
133
+ update workspace_session_aliases
134
+ set workspace_session_id = ?
135
+ where workspace_session_id = ?
136
+ `);
137
+ const deleteSession = this.database.sqlite.prepare("delete from workspace_sessions where id = ?");
138
+ const rememberAlias = this.database.sqlite.prepare(`
139
+ insert into workspace_session_aliases (alias_id, workspace_session_id)
140
+ values (?, ?)
141
+ on conflict(alias_id) do update set workspace_session_id = excluded.workspace_session_id
142
+ `);
143
+ for (const aliasId of aliases) {
144
+ rebindConversation.run(input.canonicalId, aliasId);
145
+ rebindSubagents.run(input.canonicalId, aliasId);
146
+ rebindAliases.run(input.canonicalId, aliasId);
147
+ deleteSession.run(aliasId);
148
+ rememberAlias.run(aliasId, input.canonicalId);
149
+ this.pendingSessionTouches.delete(aliasId);
150
+ }
151
+ });
152
+ fold.immediate();
153
+ }
103
154
  deleteSession(id) {
104
- this.pendingSessionTouches.delete(id);
155
+ const sessionId = this.resolveSessionId(id);
156
+ if (!sessionId)
157
+ return;
158
+ this.pendingSessionTouches.delete(sessionId);
105
159
  this.database.db
106
160
  .delete(workspaceSessions)
107
- .where(eq(workspaceSessions.id, id))
161
+ .where(eq(workspaceSessions.id, sessionId))
108
162
  .run();
109
163
  }
110
164
  listConversationBindings() {
@@ -212,6 +266,21 @@ export class SqliteWorkspaceStore {
212
266
  .where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
213
267
  .run();
214
268
  }
269
+ resolveSessionId(id) {
270
+ const direct = this.database.db
271
+ .select({ id: workspaceSessions.id })
272
+ .from(workspaceSessions)
273
+ .where(eq(workspaceSessions.id, id))
274
+ .get();
275
+ if (direct)
276
+ return direct.id;
277
+ return this.database.db
278
+ .select({ workspaceSessionId: workspaceSessionAliases.workspaceSessionId })
279
+ .from(workspaceSessionAliases)
280
+ .where(eq(workspaceSessionAliases.aliasId, id))
281
+ .get()
282
+ ?.workspaceSessionId;
283
+ }
215
284
  get pendingTouchCount() {
216
285
  return this.pendingSessionTouches.size + this.pendingConversationTouches.size;
217
286
  }
@@ -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, workspaceInput.newWorkspace ?? false, bootstrapContext);
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());
@@ -205,22 +207,23 @@ export class WorkspaceRegistry {
205
207
  }
206
208
  closeWorkspace(workspaceId) {
207
209
  const workspace = this.getWorkspace(workspaceId);
210
+ const canonicalWorkspaceId = workspace.id;
208
211
  if (workspace.mode === "worktree") {
209
212
  const aliases = this.activeSessions("worktree")
210
213
  .filter((session) => resolve(session.root) === resolve(workspace.root));
211
214
  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.`);
215
+ throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle instead of releasing the physical target as a separate handle.`);
213
216
  }
214
217
  }
215
218
  if (this.store) {
216
219
  for (const binding of this.store.listConversationBindings()) {
217
- if (binding.workspaceSessionId === workspaceId) {
220
+ if (binding.workspaceSessionId === canonicalWorkspaceId) {
218
221
  this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
219
222
  }
220
223
  }
221
- this.store.deleteSession(workspaceId);
224
+ this.store.deleteSession(canonicalWorkspaceId);
222
225
  }
223
- this.workspaces.delete(workspaceId);
226
+ this.workspaces.delete(canonicalWorkspaceId);
224
227
  }
225
228
  workspaceIdsForPhysicalWorkspace(workspace) {
226
229
  const root = resolve(workspace.root);
@@ -325,30 +328,18 @@ export class WorkspaceRegistry {
325
328
  }
326
329
  return { ...result, hookReports };
327
330
  }
328
- async openReusableCheckout(path, conversationScopeId, newWorkspace, bootstrapContext) {
331
+ async openReusableCheckout(path, conversationScopeId, bootstrapContext) {
329
332
  const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
330
333
  const projectKey = await canonicalPath(allowedPath);
331
334
  const targetKey = JSON.stringify(["checkout", projectKey, null]);
332
- if (!newWorkspace) {
333
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
334
- if (boundContext)
335
- return boundContext;
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 () => {
335
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
336
+ if (boundContext)
337
+ return boundContext;
338
+ const context = await this.openOnce(targetKey, async () => {
346
339
  const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
347
340
  if (!reusableWorkspace)
348
341
  return this.openCheckoutWorkspace(path);
349
- return conversationScopeId && this.store
350
- ? this.cloneWorkspaceContext(reusableWorkspace)
351
- : this.reusedWorkspaceContext(reusableWorkspace);
342
+ return this.reusedWorkspaceContext(reusableWorkspace);
352
343
  });
353
344
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
354
345
  }
@@ -361,27 +352,15 @@ export class WorkspaceRegistry {
361
352
  if (managedPath) {
362
353
  const worktreeKey = await canonicalPath(managedPath);
363
354
  const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
364
- if (!input.newWorkspace) {
365
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
366
- if (boundContext)
367
- return boundContext;
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 () => {
355
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
356
+ if (boundContext)
357
+ return boundContext;
358
+ const context = await this.openOnce(targetKey, async () => {
378
359
  const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
379
360
  if (!reusableWorkspace) {
380
361
  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
362
  }
382
- return conversationScopeId && this.store
383
- ? this.cloneWorkspaceContext(reusableWorkspace)
384
- : this.reusedWorkspaceContext(reusableWorkspace);
363
+ return this.reusedWorkspaceContext(reusableWorkspace);
385
364
  });
386
365
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
387
366
  }
@@ -396,29 +375,17 @@ export class WorkspaceRegistry {
396
375
  const context = await this.openWorktreeWorkspace(path, input.baseRef);
397
376
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
398
377
  }
399
- if (!input.newWorkspace) {
400
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
401
- session.sourceRoot !== undefined &&
402
- await canonicalPath(session.sourceRoot) === sourceKey &&
403
- session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
404
- if (boundContext)
405
- return boundContext;
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 () => {
378
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
379
+ session.sourceRoot !== undefined &&
380
+ await canonicalPath(session.sourceRoot) === sourceKey &&
381
+ session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
382
+ if (boundContext)
383
+ return boundContext;
384
+ const context = await this.openOnce(targetKey, async () => {
416
385
  const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
417
386
  if (!reusableWorkspace)
418
387
  return this.openWorktreeWorkspace(path, input.baseRef);
419
- return conversationScopeId && this.store
420
- ? this.cloneWorkspaceContext(reusableWorkspace)
421
- : this.reusedWorkspaceContext(reusableWorkspace);
388
+ return this.reusedWorkspaceContext(reusableWorkspace);
422
389
  });
423
390
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
424
391
  }
@@ -453,11 +420,6 @@ export class WorkspaceRegistry {
453
420
  }
454
421
  return keys;
455
422
  }
456
- conversationOpenKey(targetKey, conversationScopeId) {
457
- return conversationScopeId && this.store
458
- ? JSON.stringify(["conversation", conversationScopeId, targetKey])
459
- : targetKey;
460
- }
461
423
  async boundConversationContext(conversationScopeId, targetKey, mode, matches, bootstrapContext) {
462
424
  if (!conversationScopeId || !this.store)
463
425
  return undefined;
@@ -550,10 +512,41 @@ export class WorkspaceRegistry {
550
512
  worktreeAnchors.get(resolve(session.root))?.id === session.id) {
551
513
  continue;
552
514
  }
553
- this.store.deleteSession(session.id);
554
515
  this.workspaces.delete(session.id);
555
516
  }
556
517
  }
518
+ foldLegacyWorkspaceSessions() {
519
+ if (!this.store)
520
+ return;
521
+ const groups = new Map();
522
+ for (const session of this.store.listSessions()) {
523
+ const targetPath = canonicalPersistedWorkspacePath(session.root);
524
+ const key = JSON.stringify([session.mode, targetPath]);
525
+ const group = groups.get(key) ?? [];
526
+ group.push(session);
527
+ groups.set(key, group);
528
+ }
529
+ for (const sessions of groups.values()) {
530
+ if (sessions.length < 2)
531
+ continue;
532
+ const ordered = [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
533
+ const canonical = ordered[0];
534
+ if (!canonical)
535
+ continue;
536
+ const createdAt = ordered.reduce((earliest, session) => session.createdAt < earliest ? session.createdAt : earliest, canonical.createdAt);
537
+ const lastUsedAt = ordered.reduce((latest, session) => session.lastUsedAt > latest ? session.lastUsedAt : latest, canonical.lastUsedAt);
538
+ const status = ordered.some((session) => session.status === "active")
539
+ ? "active"
540
+ : canonical.status;
541
+ this.store.foldSessions({
542
+ canonicalId: canonical.id,
543
+ aliasIds: ordered.slice(1).map((session) => session.id),
544
+ createdAt,
545
+ lastUsedAt,
546
+ status,
547
+ });
548
+ }
549
+ }
557
550
  async findReusableWorkspaceByDirectory(directoryKey, mode) {
558
551
  for (const session of this.activeSessions(mode)) {
559
552
  const root = await this.validSessionRoot(session);
@@ -623,14 +616,6 @@ export class WorkspaceRegistry {
623
616
  throw error;
624
617
  }
625
618
  }
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
619
  async reusedWorkspaceContext(workspace) {
635
620
  Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
636
621
  workspace.capabilityGuides = loadCapabilityGuides(this.config);
@@ -1036,6 +1021,25 @@ function bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFile
1036
1021
  };
1037
1022
  return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1038
1023
  }
1024
+ function canonicalPersistedWorkspacePath(path) {
1025
+ const missingSegments = [];
1026
+ let candidate = path;
1027
+ while (true) {
1028
+ try {
1029
+ return resolve(realpathSync(candidate), ...missingSegments.slice().reverse());
1030
+ }
1031
+ catch (error) {
1032
+ if (!isErrnoException(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
1033
+ return resolve(path);
1034
+ }
1035
+ const parent = dirname(candidate);
1036
+ if (parent === candidate)
1037
+ return resolve(path);
1038
+ missingSegments.push(basename(candidate));
1039
+ candidate = parent;
1040
+ }
1041
+ }
1042
+ }
1039
1043
  async function canonicalPath(path) {
1040
1044
  const missingSegments = [];
1041
1045
  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` is a logical conversation handle, not the physical-directory
13
- identity. Reopening the same checkout in the same conversation keeps that
14
- logical ID stable. A different conversation normally receives a different
15
- `workspaceId` even when it points at the same checkout or worktree; pass an
16
- existing ID explicitly when the user wants to resume that logical workspace.
17
-
18
- A Git worktree directory is a separate physical workspace target from its source
19
- checkout, and each conversation can still have its own logical handle for it.
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
- workspace target and a content fingerprint, not by logical `workspaceId`. If the
32
- same conversation opens or resumes another logical handle for the same physical
33
- project and the fingerprint is unchanged, the response keeps only lightweight
34
- workspace metadata. If loaded instruction contents or relevant Skill, Capability
35
- guide, profile, diagnostic, or nested-instruction metadata changes, the next
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 workspace without
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 is independent from logical-workspace
48
- selection, so closing or switching one handle does not make the conversation
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 workspace state on every normal open. When the user wants to
52
- continue an earlier task, choose among logical workspaces, or clean up accumulated
53
- state, use the same Core tool in inventory mode:
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 logical workspace is currently selected by this
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,20 @@ 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 logical conversation handles rather than physical-directory
282
- identities. The same conversation keeps a stable ID for a project, while another
283
- conversation normally receives a different ID pointing at the same checkout or
284
- worktree. `open_workspace` can explicitly resume a known `workspaceId`, and a
285
- fresh logical ID is created only when the user asks for one. The normal open path
286
- may still include `staleWorkspaces` as a passive reminder for same-target handles
287
- idle for more than two days; use `open_workspace(action="list")` for complete,
288
- filtered inventory when continuation or cleanup actually requires it.
289
- `close_workspace` is the single public close operation: checkout-backed workspaces
290
- release the logical handle, while managed-worktree-backed workspaces require
291
- `commitMessage` and run the safe commit / fast-forward-only integration / cleanup
292
- lifecycle.
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
+ In v0.8.0, `close_workspace` is still the existing public close operation:
292
+ checkout-backed Workspaces remove their ForgeRelay record without deleting project
293
+ files, while managed-worktree-backed Workspaces require `commitMessage` and run the
294
+ safe commit / fast-forward-only integration / cleanup lifecycle. Persistent
295
+ closed/reopen semantics land in the following lifecycle stage.
293
296
 
294
297
  Shell commands are allowed to modify ordinary project files when that is a
295
298
  natural part of the user's requested development task; ForgeRelay does not apply
@@ -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
- Logical workspace IDs are conversation-scoped handles. Reopening the same project
290
- from the same conversation keeps its ID stable; a different conversation normally
291
- receives a different ID for the same physical checkout/worktree. Pass
292
- `workspaceId` to `open_workspace` to explicitly resume an existing handle in the
293
- current conversation. `newWorkspace: true` allocates a new logical handle without
294
- creating another checkout or Git worktree and should be used only on explicit user
295
- request.
296
-
297
- Bootstrap delivery is tracked separately from the selected logical workspace.
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 logical workspace without returning the full
303
- bootstrap and does not mark the current fingerprint as delivered. Closing or
304
- switching a logical workspace therefore does not by itself cause unchanged project
305
- context to be injected again, while changed context produces a new fingerprint and
306
- is delivered on the next `auto` open.
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,20 +327,21 @@ 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 continue an older
328
- logical workspace, choose among multiple handles, or organize workspace state. The
329
- inventory is paginated (50 records by default, at most 100) and can filter by
330
- workspace ID, persisted status, derived state, mode, canonical root/source root, or
331
- stale-only state. Reading inventory does not refresh `lastUsedAt`. Persisted
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"`. The existing
336
- `staleWorkspaces` field remains a passive same-workspace reminder for old handles;
337
- `action="list"` is the formal on-demand inventory path.
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.
338
341
 
339
- `close_workspace` removes a checkout-backed logical handle without deleting checkout
340
- files. For a managed-worktree-backed workspace, `close_workspace` requires
342
+ In v0.8.0, `close_workspace` still removes a checkout-backed Workspace record without
343
+ deleting checkout files; the persistent closed/reopen lifecycle is introduced by
344
+ the next lifecycle stage. For a managed-worktree-backed Workspace, `close_workspace` requires
341
345
  `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
342
346
  commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
343
347
  For a Composite Workspace, the same tool means dissolve: it removes only the
@@ -366,7 +370,7 @@ the initial run request terminates a not-yet-handed-off process so ForgeRelay do
366
370
  not leave an orphan process whose `processId` the Agent never received.
367
371
 
368
372
  Completed background processes are delivered once with a later tool result for the
369
- same logical workspace ID. Full buffered completion output is retained for five
373
+ same Workspace ID. Full buffered completion output is retained for five
370
374
  minutes; after that ForgeRelay compacts the completion to a bounded head/tail record
371
375
  and keeps it deliverable for up to 24 hours, still subject to the global completed
372
376
  process count bound. Completed processes no longer prevent `close_workspace`; the
package/docs/roadmap.md CHANGED
@@ -284,6 +284,34 @@ Git state, processes, Hooks, Skills, Language services, Activity, and Audit fact
284
284
  Workspace Relay is not file synchronization or failover, and Composite Workspace
285
285
  never silently chooses a member.
286
286
 
287
+ ## 0.8 — Persistent Workspace lifecycle and Task Lists
288
+
289
+ 0.8 turns Workspace identity from a disposable logical handle into a persistent
290
+ ForgeRelay work boundary, then layers lightweight Workspace-owned Task Lists and
291
+ safe cross-Workspace inspection on top. Development is release-gated: each stage
292
+ must be published successfully before work begins on the next stage.
293
+
294
+ - **0.8.0** — fix Composite bootstrap correctness and establish one canonical,
295
+ persistent Workspace identity per physical checkout/worktree target, including
296
+ migration compatibility for legacy aliases;
297
+ - **0.8.1** — make checkout close non-destructive and reversible, add explicit
298
+ Workspace deletion, and prevent GC from deleting persistent Workspace identity;
299
+ - **0.8.2** — carry the persistent lifecycle through managed worktrees and Composite
300
+ Workspaces, preserving state across close/reopen while keeping safe worktree
301
+ finalize semantics;
302
+ - **0.8.3** — add versioned file-backed Task Lists in ForgeRelay-owned Workspace
303
+ state, with mutations scoped to the current Workspace;
304
+ - **0.8.4** — add progressive Task disclosure, forgotten-update reminders, and
305
+ allowlist-based read-only inspection of other Workspaces and their safe Task
306
+ projections;
307
+ - **0.8.5** — verify the complete contract across Workspace Relay and publish the
308
+ accepted 0.8 lifecycle/Task model.
309
+
310
+ The release boundary is part of the dependency graph, not just a documentation
311
+ milestone: the next stage remains blocked until the previous version's tag-triggered
312
+ release workflow has completed successfully. Runtime acceptance uses only the
313
+ reserved 7677/7678 debug instances and never touches the normal 7676 installation.
314
+
287
315
  ## Later: first-class subagent MCP
288
316
 
289
317
  ForgeRelay already owns provider adapters and resumable local agent sessions. A
@@ -311,20 +339,20 @@ are ready:
311
339
  Checkpoint restore must protect concurrent/external user edits rather than
312
340
  blindly overwriting a working tree.
313
341
 
314
- ## Later: task orchestration
342
+ ## Workspace Task Lists
315
343
 
316
- After first-class subagents are stable, ForgeRelay may add a small persistent
317
- task graph for parent/worker coordination:
344
+ ForgeRelay may provide persistent, lightweight Task Lists owned by a Workspace for
345
+ work that should survive Host Turns, conversation changes, and Workspace close.
346
+ Task is a checklist/continuation primitive rather than an execution scheduler:
318
347
 
319
- - task identity/title/status;
320
- - dependencies;
321
- - assigned local-agent session;
322
- - workspace/worktree association;
323
- - structured result/error state.
348
+ - one Workspace may own multiple named Task Lists;
349
+ - a Task records a work requirement, lightweight progress state, and Agent-managed continuation text;
350
+ - Task does not imply a Queue, Goal object, dependency graph, Subagent assignment, Run binding, or autonomous execution policy;
351
+ - current-Workspace Task state may be mutated through a Capability, while information about another Workspace, including Task projections, is exposed only through the read-only Workspace inspection path;
352
+ - Task state is durable Workspace-owned local state and is not deleted by ordinary Workspace close or GC.
324
353
 
325
- Start with DAG-style parent/child orchestration. Peer-to-peer agent-team messaging
326
- should only be added if real workflows demonstrate that the simpler task model is
327
- insufficient.
354
+ Subagent coordination can reference Task content when useful, but ForgeRelay does
355
+ not bind Tasks to Subagent Sessions or infer Task completion from execution facts.
328
356
 
329
357
  ## Compatibility policy
330
358
 
package/docs/security.md CHANGED
@@ -144,10 +144,10 @@ process lifetime. When `bash` is still running after that window, ForgeRelay
144
144
  returns a canonical `processId` and leaves the process alive. Regular tool modes
145
145
  reuse `bash(action="process")` to poll, wait, write input, resize a PTY, or
146
146
  explicitly interrupt that process. An asynchronously completed process
147
- is reported on a later tool result for the same logical workspace ID, including
148
- error-result paths, and is never broadcast to another workspace ID. Explicitly
149
- resuming the same workspace ID in another conversation intentionally transfers
150
- that completion scope as well. Hook handlers keep their separate bounded timeout
147
+ is reported on a later tool result for the same persistent Workspace ID, including
148
+ error-result paths, and is never broadcast to another Workspace ID. Reusing that
149
+ Workspace from another conversation intentionally shares the same completion scope
150
+ as well. Hook handlers keep their separate bounded timeout
151
151
  policy because they are lifecycle gates rather than user-command execution.
152
152
 
153
153
  ## Lifecycle hooks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",