@akira-tl/forgerelay 0.3.5 → 0.3.6

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,18 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.6] - 2026-08-10
8
+
9
+ ### Added
10
+
11
+ - `open_workspace(action="list")` now provides paginated logical-workspace inventory without adding a tenth Core tool. Inventory entries include a compact `project/workspaceId` label, persisted status, derived lifecycle state, checkout/worktree backing metadata, creation/last-used timestamps, idle duration, root validity, current-conversation selection, and filters for workspace ID, status, state, mode, root, and stale-only views.
12
+
13
+ ### Changed
14
+
15
+ - Workspace bootstrap context is now deduplicated by conversation scope, canonical workspace target, and a content fingerprint instead of by logical `workspaceId`. `context="auto"` remains the default, `context="full"` forces a refresh, and `context="none"` opens or resumes a workspace without returning the full AGENTS/Skills/guide/profile bootstrap.
16
+ - Context-delivery state is persisted independently from logical-workspace selection, so switching or closing one logical handle does not make the same conversation forget already-delivered project context. Changes to loaded instruction contents or relevant Skill, guide, profile, diagnostic, or nested-instruction metadata change the fingerprint and cause `auto` to deliver the refreshed context again.
17
+ - Workspace inventory is read-only with respect to workspace activity timestamps and runs the existing idle-session GC before listing. Persisted `status="active"` continues to mean the session has not been explicitly closed, while the derived `state` distinguishes currently active, stale-but-valid, invalid/missing-root, and closed records.
18
+
7
19
  ## [0.3.5] - 2026-08-10
8
20
 
9
21
  ### Changed
@@ -29,6 +29,11 @@ const migrations = [
29
29
  name: "local-agent-hook-reports",
30
30
  up: migrateLocalAgentHookReports,
31
31
  },
32
+ {
33
+ version: 7,
34
+ name: "workspace-context-deliveries",
35
+ up: migrateWorkspaceContextDeliveries,
36
+ },
32
37
  ];
33
38
  export function migrateDatabase(sqlite) {
34
39
  const migrate = sqlite.transaction(() => {
@@ -189,6 +194,20 @@ function migrateWorkspaceWorktreeBranches(sqlite) {
189
194
  function migrateLocalAgentHookReports(sqlite) {
190
195
  addColumnIfMissing(sqlite, "local_agent_sessions", "hook_reports_json", "text");
191
196
  }
197
+ function migrateWorkspaceContextDeliveries(sqlite) {
198
+ sqlite.exec(`
199
+ create table if not exists workspace_context_deliveries (
200
+ conversation_scope_id text not null,
201
+ target_key text not null,
202
+ context_fingerprint text not null,
203
+ delivered_at text not null,
204
+ primary key (conversation_scope_id, target_key)
205
+ );
206
+
207
+ create index if not exists workspace_context_deliveries_delivered_idx
208
+ on workspace_context_deliveries(delivered_at desc);
209
+ `);
210
+ }
192
211
  function addColumnIfMissing(sqlite, table, column, definition) {
193
212
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
194
213
  if (columns.some((existingColumn) => existingColumn.name === column))
package/dist/db/schema.js CHANGED
@@ -41,6 +41,15 @@ export const workspaceConversationBindings = sqliteTable("workspace_conversation
41
41
  primaryKey({ columns: [table.conversationScopeId, table.targetKey] }),
42
42
  index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId),
43
43
  ]);
44
+ export const workspaceContextDeliveries = sqliteTable("workspace_context_deliveries", {
45
+ conversationScopeId: text("conversation_scope_id").notNull(),
46
+ targetKey: text("target_key").notNull(),
47
+ contextFingerprint: text("context_fingerprint").notNull(),
48
+ deliveredAt: text("delivered_at").notNull(),
49
+ }, (table) => [
50
+ primaryKey({ columns: [table.conversationScopeId, table.targetKey] }),
51
+ index("workspace_context_deliveries_delivered_idx").on(table.deliveredAt),
52
+ ]);
44
53
  export const oauthClients = sqliteTable("oauth_clients", {
45
54
  clientId: text("client_id").primaryKey(),
46
55
  clientJson: text("client_json").notNull(),
package/dist/server.js CHANGED
@@ -164,6 +164,36 @@ const workspaceLocalAgentProviderOutputSchema = z.object({
164
164
  const workspaceAvailableAgentsFileOutputSchema = z.object({
165
165
  path: z.string(),
166
166
  });
167
+ const workspaceInventoryEntryOutputSchema = z.object({
168
+ label: z.string(),
169
+ workspaceId: z.string(),
170
+ root: z.string(),
171
+ status: z.string(),
172
+ state: z.enum(["active", "stale", "invalid", "closed"]),
173
+ mode: z.enum(["checkout", "worktree"]),
174
+ sourceRoot: z.string().optional(),
175
+ branch: z.string().optional(),
176
+ targetBranch: z.string().optional(),
177
+ managed: z.boolean(),
178
+ createdAt: z.string(),
179
+ lastUsedAt: z.string(),
180
+ idleMs: z.number().nonnegative(),
181
+ rootValid: z.boolean(),
182
+ current: z.boolean(),
183
+ });
184
+ const workspaceInventorySummaryOutputSchema = z.object({
185
+ total: z.number().int().nonnegative(),
186
+ matching: z.number().int().nonnegative(),
187
+ active: z.number().int().nonnegative(),
188
+ stale: z.number().int().nonnegative(),
189
+ invalid: z.number().int().nonnegative(),
190
+ closed: z.number().int().nonnegative(),
191
+ });
192
+ const workspaceInventoryPageOutputSchema = z.object({
193
+ offset: z.number().int().nonnegative(),
194
+ limit: z.number().int().positive(),
195
+ hasMore: z.boolean(),
196
+ });
167
197
  const reviewFileOutputSchema = z.object({
168
198
  path: z.string(),
169
199
  previousPath: z.string().optional(),
@@ -775,20 +805,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
775
805
  }, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
776
806
  registerAppTool(server, "open_workspace", {
777
807
  title: "Open workspace",
778
- description: "Open or resume a local coding workspace. Reuse the returned workspaceId for later calls. Default to checkout; use mode=\"worktree\" only when the user explicitly requests isolated or parallel Git work. Every call returns a capability fingerprint; bootstrap calls also expose project context, skills, and capability guides when needed.",
808
+ description: "Open or resume a local coding workspace. Reuse the returned workspaceId for later calls. Default to checkout; use mode=\"worktree\" only when the user explicitly requests isolated or parallel Git work. Every call returns lightweight workspace metadata; bootstrap context is delivered automatically only when needed and can be explicitly suppressed or refreshed.",
779
809
  inputSchema: {
810
+ action: z
811
+ .enum(["open", "list"])
812
+ .optional()
813
+ .describe("Defaults to open. Use list only when you need to inspect or choose logical workspaces before resuming or cleaning them up."),
780
814
  path: z
781
815
  .string()
782
816
  .optional()
783
- .describe("Project path to open. Required unless workspaceId is supplied. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
817
+ .describe("Project path to open. Required for action=open unless workspaceId is supplied. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
784
818
  workspaceId: z
785
819
  .string()
786
820
  .optional()
787
- .describe("Existing logical workspace ID to resume in this conversation. When supplied, ForgeRelay resumes that workspace rather than allocating another ID."),
821
+ .describe("For action=open, an existing logical workspace ID to resume in this conversation. For action=list, filters inventory to one workspace ID."),
788
822
  mode: z
789
823
  .enum(["checkout", "worktree"])
790
824
  .optional()
791
- .describe("Defaults to checkout, which works in the actual directory. Use worktree only when the user explicitly requests isolated or parallel Git work."),
825
+ .describe("For action=open, defaults to checkout and uses the actual directory unless worktree isolation is explicitly requested. For action=list, filters by workspace mode."),
792
826
  baseRef: z
793
827
  .string()
794
828
  .optional()
@@ -801,11 +835,45 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
801
835
  .boolean()
802
836
  .optional()
803
837
  .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."),
838
+ context: z
839
+ .enum(["auto", "full", "none"])
840
+ .optional()
841
+ .describe("Bootstrap context policy for action=open. auto (default) sends full project context only when this conversation has not received the current context fingerprint; full forces a refresh; none opens/resumes without returning the full bootstrap context."),
842
+ root: z
843
+ .string()
844
+ .optional()
845
+ .describe("For action=list, filter by canonical workspace root or source root."),
846
+ status: z
847
+ .string()
848
+ .optional()
849
+ .describe("For action=list, filter by persisted workspace status such as active or closed."),
850
+ state: z
851
+ .enum(["active", "stale", "invalid", "closed"])
852
+ .optional()
853
+ .describe("For action=list, filter by derived lifecycle state."),
854
+ staleOnly: z
855
+ .boolean()
856
+ .optional()
857
+ .describe("For action=list, return only active logical workspaces idle for more than two days."),
858
+ offset: z
859
+ .number()
860
+ .int()
861
+ .nonnegative()
862
+ .optional()
863
+ .describe("For action=list, zero-based inventory offset. Defaults to 0."),
864
+ limit: z
865
+ .number()
866
+ .int()
867
+ .min(1)
868
+ .max(100)
869
+ .optional()
870
+ .describe("For action=list, maximum records to return. Defaults to 50; maximum 100."),
804
871
  },
805
872
  outputSchema: {
806
- workspaceId: z.string(),
807
- root: z.string(),
808
- mode: z.enum(["checkout", "worktree"]),
873
+ action: z.enum(["open", "list"]),
874
+ workspaceId: z.string().optional(),
875
+ root: z.string().optional(),
876
+ mode: z.enum(["checkout", "worktree"]).optional(),
809
877
  sourceRoot: z.string().optional(),
810
878
  worktree: z
811
879
  .object({
@@ -828,7 +896,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
828
896
  targetBranch: z.string().optional(),
829
897
  managed: z.boolean(),
830
898
  current: z.boolean(),
831
- })),
899
+ })).optional(),
832
900
  staleWorkspaces: z.array(z.object({
833
901
  workspaceId: z.string(),
834
902
  root: z.string(),
@@ -838,9 +906,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
838
906
  branch: z.string().optional(),
839
907
  targetBranch: z.string().optional(),
840
908
  managed: z.boolean(),
841
- })),
842
- capabilityFingerprint: capabilityFingerprintOutputSchema,
843
- capabilityCatalog: z.array(capabilityCatalogOutputSchema),
909
+ })).optional(),
910
+ capabilityFingerprint: capabilityFingerprintOutputSchema.optional(),
911
+ contextFingerprint: z.string().optional(),
912
+ capabilityCatalog: z.array(capabilityCatalogOutputSchema).optional(),
844
913
  capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
845
914
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
846
915
  availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
@@ -848,6 +917,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
848
917
  agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(),
849
918
  agents: z.array(workspaceLocalAgentOutputSchema).optional(),
850
919
  skillDiagnostics: z.array(z.unknown()).optional(),
920
+ workspaces: z.array(workspaceInventoryEntryOutputSchema).optional(),
921
+ summary: workspaceInventorySummaryOutputSchema.optional(),
922
+ page: workspaceInventoryPageOutputSchema.optional(),
851
923
  instruction: z.string(),
852
924
  },
853
925
  ...toolWidgetDescriptorMeta(config, "workspace"),
@@ -857,11 +929,62 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
857
929
  idempotentHint: false,
858
930
  openWorldHint: false,
859
931
  },
860
- }, async ({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, { _meta, sessionId }) => {
932
+ }, async ({ action = "open", path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
861
933
  const startedAt = performance.now();
862
- const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, {
863
- conversationScopeId: openAiConversationScopeId(_meta),
864
- protectedWorkspaceIds: processSessions.activeWorkspaceIds(),
934
+ const conversationScopeId = openAiConversationScopeId(_meta);
935
+ const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
936
+ if (action === "list") {
937
+ if (path !== undefined || baseRef !== undefined || newWorktree !== undefined ||
938
+ newWorkspace !== undefined || context !== undefined) {
939
+ throw new Error("open_workspace action=list does not accept path, baseRef, newWorktree, newWorkspace, or context. Use root/workspaceId/mode/status/state/staleOnly for inventory filters.");
940
+ }
941
+ const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
942
+ const nextOffset = inventory.page.offset + inventory.page.limit;
943
+ const instruction = [
944
+ "Resume a selected workspaceId with open_workspace(action=\"open\", workspaceId=...).",
945
+ "Use close_workspace only after the user chooses cleanup; never close inventory entries automatically.",
946
+ inventory.page.hasMore
947
+ ? `More matching workspaces are available; continue with offset=${nextOffset}.`
948
+ : undefined,
949
+ ].filter(Boolean).join(" ");
950
+ const result = [
951
+ `Logical workspace inventory: ${inventory.summary.matching} matching of ${inventory.summary.total} stored records.`,
952
+ `States: active=${inventory.summary.active}, stale=${inventory.summary.stale}, invalid=${inventory.summary.invalid}, closed=${inventory.summary.closed}.`,
953
+ ...inventory.workspaces.map((entry) => [
954
+ entry.label,
955
+ `state=${entry.state}`,
956
+ `status=${entry.status}`,
957
+ `mode=${entry.mode}`,
958
+ entry.managed ? "managed" : undefined,
959
+ entry.current ? "current" : undefined,
960
+ `root=${entry.root}`,
961
+ `last-used=${entry.lastUsedAt}`,
962
+ ].filter(Boolean).join(" ")),
963
+ instruction,
964
+ ].join("\n");
965
+ logToolCall(config, {
966
+ tool: "open_workspace",
967
+ action: "list",
968
+ path: root,
969
+ success: true,
970
+ durationMs: Math.round(performance.now() - startedAt),
971
+ });
972
+ return {
973
+ content: [textBlock(result)],
974
+ structuredContent: {
975
+ action: "list",
976
+ ...inventory,
977
+ instruction,
978
+ },
979
+ };
980
+ }
981
+ if (root !== undefined || status !== undefined || state !== undefined ||
982
+ staleOnly !== undefined || offset !== undefined || limit !== undefined) {
983
+ throw new Error("open_workspace inventory filters root, status, state, staleOnly, offset, and limit are only valid with action=list.");
984
+ }
985
+ const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, contextFingerprint, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context }, {
986
+ conversationScopeId,
987
+ protectedWorkspaceIds,
865
988
  });
866
989
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
867
990
  const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
@@ -911,20 +1034,26 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
911
1034
  const visibleAgents = includeBootstrapContext ? cardAgents : [];
912
1035
  const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
913
1036
  const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
1037
+ 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.";
1038
+ 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.";
914
1039
  const cardInstruction = config.skillsEnabled
915
- ? "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 or capability guide, read its advertised path before proceeding."
916
- : "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.";
1040
+ ? `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 or capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`
1041
+ : `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}`;
917
1042
  const instruction = workspaceReused
918
1043
  ? includeBootstrapContext
919
1044
  ? [
920
1045
  `Workspace already exists as ${workspace.id} for this directory.`,
921
1046
  "Reuse this workspaceId for subsequent tool calls.",
922
1047
  "The complete project context is included because it has not yet been provided in this conversation or host context.",
1048
+ workspaceContextInstruction,
1049
+ workspaceManagementInstruction,
923
1050
  ].join("\n\n")
924
1051
  : [
925
1052
  `Workspace already open as ${workspace.id}.`,
926
1053
  "Reuse this workspaceId for subsequent tool calls. This is the same directory previously opened in this conversation.",
927
1054
  "Continue following the project instructions, nested instruction files, skills, capability guides, agent profiles, and diagnostics previously provided for this workspace. They remain active and are not repeated here.",
1055
+ workspaceContextInstruction,
1056
+ workspaceManagementInstruction,
928
1057
  ].join("\n\n")
929
1058
  : workspace.mode === "worktree"
930
1059
  ? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree."
@@ -998,6 +1127,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
998
1127
  worktrees: knownWorktrees,
999
1128
  staleWorkspaces,
1000
1129
  capabilityFingerprint,
1130
+ contextFingerprint,
1001
1131
  capabilityCatalog,
1002
1132
  agentsFiles: cardAgentsFiles,
1003
1133
  availableAgentsFiles: cardAvailableAgentsFiles,
@@ -1017,6 +1147,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1017
1147
  },
1018
1148
  },
1019
1149
  structuredContent: {
1150
+ action: "open",
1020
1151
  workspaceId: workspace.id,
1021
1152
  root: workspace.root,
1022
1153
  mode: workspace.mode,
@@ -1025,6 +1156,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1025
1156
  worktrees: knownWorktrees,
1026
1157
  staleWorkspaces,
1027
1158
  capabilityFingerprint,
1159
+ contextFingerprint,
1028
1160
  capabilityCatalog,
1029
1161
  ...(includeBootstrapContext
1030
1162
  ? {
@@ -1,6 +1,6 @@
1
1
  import { and, desc, eq } from "drizzle-orm";
2
2
  import { openDatabase } from "./db/client.js";
3
- import { workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
3
+ import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
4
4
  export class SqliteWorkspaceStore {
5
5
  database;
6
6
  constructor(stateDir) {
@@ -141,6 +141,49 @@ export class SqliteWorkspaceStore {
141
141
  .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
142
142
  .run();
143
143
  }
144
+ listContextDeliveries() {
145
+ return this.database.db
146
+ .select()
147
+ .from(workspaceContextDeliveries)
148
+ .orderBy(desc(workspaceContextDeliveries.deliveredAt))
149
+ .all()
150
+ .map(rowToWorkspaceContextDelivery);
151
+ }
152
+ getContextDelivery(conversationScopeId, targetKey) {
153
+ const row = this.database.db
154
+ .select()
155
+ .from(workspaceContextDeliveries)
156
+ .where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
157
+ .get();
158
+ return row ? rowToWorkspaceContextDelivery(row) : undefined;
159
+ }
160
+ setContextDelivery(input) {
161
+ const deliveredAt = new Date().toISOString();
162
+ const row = this.database.db
163
+ .insert(workspaceContextDeliveries)
164
+ .values({ ...input, deliveredAt })
165
+ .onConflictDoUpdate({
166
+ target: [
167
+ workspaceContextDeliveries.conversationScopeId,
168
+ workspaceContextDeliveries.targetKey,
169
+ ],
170
+ set: {
171
+ contextFingerprint: input.contextFingerprint,
172
+ deliveredAt,
173
+ },
174
+ })
175
+ .returning()
176
+ .get();
177
+ if (!row)
178
+ throw new Error("Workspace context delivery upsert returned no row.");
179
+ return rowToWorkspaceContextDelivery(row);
180
+ }
181
+ deleteContextDelivery(conversationScopeId, targetKey) {
182
+ this.database.db
183
+ .delete(workspaceContextDeliveries)
184
+ .where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
185
+ .run();
186
+ }
144
187
  close() {
145
188
  this.database.close();
146
189
  }
@@ -173,3 +216,11 @@ function rowToWorkspaceConversationBinding(row) {
173
216
  lastUsedAt: row.lastUsedAt,
174
217
  };
175
218
  }
219
+ function rowToWorkspaceContextDelivery(row) {
220
+ return {
221
+ conversationScopeId: row.conversationScopeId,
222
+ targetKey: row.targetKey,
223
+ contextFingerprint: row.contextFingerprint,
224
+ deliveredAt: row.deliveredAt,
225
+ };
226
+ }
@@ -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";
@@ -27,8 +27,9 @@ export class WorkspaceRegistry {
27
27
  async openWorkspace(input, openOptions = {}) {
28
28
  this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
29
29
  const workspaceInput = typeof input === "string" ? { path: input } : input;
30
+ const bootstrapContext = workspaceInput.context ?? "auto";
30
31
  if (workspaceInput.workspaceId) {
31
- return this.resumeWorkspace(workspaceInput.workspaceId, openOptions.conversationScopeId);
32
+ return this.resumeWorkspace(workspaceInput.workspaceId, openOptions.conversationScopeId, bootstrapContext);
32
33
  }
33
34
  if (!workspaceInput.path) {
34
35
  throw new Error("open_workspace requires either path or workspaceId.");
@@ -37,24 +38,138 @@ export class WorkspaceRegistry {
37
38
  if (mode === "worktree") {
38
39
  return this.openReusableWorktree(workspaceInput, openOptions.conversationScopeId);
39
40
  }
40
- return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false);
41
+ return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false, bootstrapContext);
41
42
  }
42
- async resumeWorkspace(workspaceId, conversationScopeId) {
43
+ async listWorkspaces(input = {}, openOptions = {}) {
44
+ this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
45
+ const now = Date.now();
46
+ const sessions = this.store
47
+ ? this.store.listSessions()
48
+ : [...this.workspaces.values()].map((workspace) => ({
49
+ id: workspace.id,
50
+ root: workspace.root,
51
+ status: "active",
52
+ mode: workspace.mode,
53
+ sourceRoot: workspace.sourceRoot,
54
+ baseRef: workspace.worktree?.baseRef,
55
+ baseSha: workspace.worktree?.baseSha,
56
+ branch: workspace.worktree?.branch,
57
+ targetBranch: workspace.worktree?.targetBranch,
58
+ managed: workspace.worktree?.managed ?? false,
59
+ createdAt: "",
60
+ lastUsedAt: "",
61
+ }));
62
+ const currentWorkspaceIds = new Set(openOptions.conversationScopeId && this.store
63
+ ? this.store
64
+ .listConversationBindings()
65
+ .filter((binding) => binding.conversationScopeId === openOptions.conversationScopeId)
66
+ .map((binding) => binding.workspaceSessionId)
67
+ : []);
68
+ const rootKey = input.root
69
+ ? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
70
+ : undefined;
71
+ const entries = await Promise.all(sessions.map(async (session) => {
72
+ const rootValid = await this.validSessionRoot(session) !== undefined;
73
+ const lastUsedAt = Date.parse(session.lastUsedAt);
74
+ const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
75
+ const state = session.status !== "active"
76
+ ? "closed"
77
+ : !rootValid
78
+ ? "invalid"
79
+ : idleMs >= WORKSPACE_STALE_REMINDER_MS
80
+ ? "stale"
81
+ : "active";
82
+ const projectRoot = session.sourceRoot ?? session.root;
83
+ return {
84
+ label: `${basename(resolve(projectRoot)) || "workspace"}/${session.id}`,
85
+ workspaceId: session.id,
86
+ root: session.root,
87
+ status: session.status,
88
+ state,
89
+ mode: session.mode,
90
+ sourceRoot: session.sourceRoot,
91
+ branch: session.branch,
92
+ targetBranch: session.targetBranch,
93
+ managed: session.managed,
94
+ createdAt: session.createdAt,
95
+ lastUsedAt: session.lastUsedAt,
96
+ idleMs,
97
+ rootValid,
98
+ current: currentWorkspaceIds.has(session.id),
99
+ };
100
+ }));
101
+ const filtered = [];
102
+ for (let index = 0; index < sessions.length; index += 1) {
103
+ const session = sessions[index];
104
+ const entry = entries[index];
105
+ if (!session || !entry)
106
+ continue;
107
+ if (input.workspaceId && entry.workspaceId !== input.workspaceId)
108
+ continue;
109
+ if (input.status && entry.status !== input.status)
110
+ continue;
111
+ if (input.state && entry.state !== input.state)
112
+ continue;
113
+ if (input.mode && entry.mode !== input.mode)
114
+ continue;
115
+ if (input.staleOnly && entry.state !== "stale")
116
+ continue;
117
+ if (rootKey) {
118
+ const sessionRootKey = await canonicalPath(session.root);
119
+ const sourceRootKey = session.sourceRoot ? await canonicalPath(session.sourceRoot) : undefined;
120
+ if (sessionRootKey !== rootKey && sourceRootKey !== rootKey)
121
+ continue;
122
+ }
123
+ filtered.push(entry);
124
+ }
125
+ const summary = filtered.reduce((counts, entry) => {
126
+ counts[entry.state] += 1;
127
+ return counts;
128
+ }, { active: 0, stale: 0, invalid: 0, closed: 0 });
129
+ const offset = Math.max(0, input.offset ?? 0);
130
+ const limit = Math.min(100, Math.max(1, input.limit ?? 50));
131
+ return {
132
+ workspaces: filtered.slice(offset, offset + limit),
133
+ summary: {
134
+ total: entries.length,
135
+ matching: filtered.length,
136
+ ...summary,
137
+ },
138
+ page: {
139
+ offset,
140
+ limit,
141
+ hasMore: offset + limit < filtered.length,
142
+ },
143
+ };
144
+ }
145
+ async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
43
146
  const workspace = this.getWorkspace(workspaceId);
44
147
  const context = await this.reusedWorkspaceContext(workspace);
45
148
  if (!conversationScopeId || !this.store) {
46
- return { ...context, includeBootstrapContext: true };
149
+ return {
150
+ ...context,
151
+ includeBootstrapContext: bootstrapContext !== "none",
152
+ };
47
153
  }
48
154
  const targetKeys = await this.workspaceTargetKeys(workspace);
49
- const alreadyBound = targetKeys.some((targetKey) => this.store?.getConversationBinding(conversationScopeId, targetKey)?.workspaceSessionId === workspace.id);
155
+ const contextAlreadyDelivered = targetKeys.some((targetKey) => this.store?.getContextDelivery(conversationScopeId, targetKey)?.contextFingerprint ===
156
+ context.contextFingerprint);
157
+ const includeBootstrapContext = resolveBootstrapContextVisibility(bootstrapContext, contextAlreadyDelivered);
50
158
  for (const targetKey of targetKeys) {
51
159
  this.store.setConversationBinding({
52
160
  conversationScopeId,
53
161
  targetKey,
54
162
  workspaceSessionId: workspace.id,
55
163
  });
164
+ if (includeBootstrapContext) {
165
+ this.store.setContextDelivery({
166
+ conversationScopeId,
167
+ targetKey,
168
+ contextFingerprint: context.contextFingerprint,
169
+ });
170
+ }
56
171
  }
57
- return { ...context, includeBootstrapContext: !alreadyBound };
172
+ return { ...context, includeBootstrapContext };
58
173
  }
59
174
  async listStaleWorkspaces(workspace) {
60
175
  if (!this.store)
@@ -206,12 +321,12 @@ export class WorkspaceRegistry {
206
321
  }
207
322
  return { ...result, hookReports };
208
323
  }
209
- async openReusableCheckout(path, conversationScopeId, newWorkspace) {
324
+ async openReusableCheckout(path, conversationScopeId, newWorkspace, bootstrapContext) {
210
325
  const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
211
326
  const projectKey = await canonicalPath(allowedPath);
212
327
  const targetKey = JSON.stringify(["checkout", projectKey, null]);
213
328
  if (!newWorkspace) {
214
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey);
329
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
215
330
  if (boundContext)
216
331
  return boundContext;
217
332
  }
@@ -220,7 +335,7 @@ export class WorkspaceRegistry {
220
335
  const freshContext = reusableWorkspace
221
336
  ? await this.cloneWorkspaceContext(reusableWorkspace)
222
337
  : await this.openCheckoutWorkspace(path);
223
- return this.withConversationContext(freshContext, conversationScopeId, targetKey);
338
+ return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
224
339
  }
225
340
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
226
341
  const context = await this.openOnce(operationKey, async () => {
@@ -231,18 +346,19 @@ export class WorkspaceRegistry {
231
346
  ? this.cloneWorkspaceContext(reusableWorkspace)
232
347
  : this.reusedWorkspaceContext(reusableWorkspace);
233
348
  });
234
- return this.withConversationContext(context, conversationScopeId, targetKey);
349
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
235
350
  }
236
351
  async openReusableWorktree(input, conversationScopeId) {
237
352
  const path = input.path;
238
353
  if (!path)
239
354
  throw new Error("Worktree mode requires path.");
355
+ const bootstrapContext = input.context ?? "auto";
240
356
  const managedPath = this.tryManagedWorktreePath(path);
241
357
  if (managedPath) {
242
358
  const worktreeKey = await canonicalPath(managedPath);
243
359
  const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
244
360
  if (!input.newWorkspace) {
245
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey);
361
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
246
362
  if (boundContext)
247
363
  return boundContext;
248
364
  }
@@ -251,7 +367,7 @@ export class WorkspaceRegistry {
251
367
  if (!reusableWorkspace) {
252
368
  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
369
  }
254
- return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey);
370
+ return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey, bootstrapContext);
255
371
  }
256
372
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
257
373
  const context = await this.openOnce(operationKey, async () => {
@@ -263,7 +379,7 @@ export class WorkspaceRegistry {
263
379
  ? this.cloneWorkspaceContext(reusableWorkspace)
264
380
  : this.reusedWorkspaceContext(reusableWorkspace);
265
381
  });
266
- return this.withConversationContext(context, conversationScopeId, targetKey);
382
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
267
383
  }
268
384
  const resolvedBase = await resolveManagedWorktreeBase({
269
385
  sourcePath: path,
@@ -274,13 +390,13 @@ export class WorkspaceRegistry {
274
390
  const targetKey = JSON.stringify(["worktree", sourceKey, resolvedBase.targetBranch]);
275
391
  if (input.newWorktree) {
276
392
  const context = await this.openWorktreeWorkspace(path, input.baseRef);
277
- return this.withConversationContext(context, conversationScopeId, targetKey);
393
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
278
394
  }
279
395
  if (!input.newWorkspace) {
280
396
  const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
281
397
  session.sourceRoot !== undefined &&
282
398
  await canonicalPath(session.sourceRoot) === sourceKey &&
283
- session.targetBranch === resolvedBase.targetBranch);
399
+ session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
284
400
  if (boundContext)
285
401
  return boundContext;
286
402
  }
@@ -289,7 +405,7 @@ export class WorkspaceRegistry {
289
405
  const freshContext = reusableWorkspace
290
406
  ? await this.cloneWorkspaceContext(reusableWorkspace)
291
407
  : await this.openWorktreeWorkspace(path, input.baseRef);
292
- return this.withConversationContext(freshContext, conversationScopeId, targetKey);
408
+ return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
293
409
  }
294
410
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
295
411
  const context = await this.openOnce(operationKey, async () => {
@@ -300,7 +416,7 @@ export class WorkspaceRegistry {
300
416
  ? this.cloneWorkspaceContext(reusableWorkspace)
301
417
  : this.reusedWorkspaceContext(reusableWorkspace);
302
418
  });
303
- return this.withConversationContext(context, conversationScopeId, targetKey);
419
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
304
420
  }
305
421
  async openOnce(operationKey, open) {
306
422
  const pending = this.pendingOpens.get(operationKey);
@@ -338,7 +454,7 @@ export class WorkspaceRegistry {
338
454
  ? JSON.stringify(["conversation", conversationScopeId, targetKey])
339
455
  : targetKey;
340
456
  }
341
- async boundConversationContext(conversationScopeId, targetKey, mode, matches) {
457
+ async boundConversationContext(conversationScopeId, targetKey, mode, matches, bootstrapContext) {
342
458
  if (!conversationScopeId || !this.store)
343
459
  return undefined;
344
460
  const binding = this.store.getConversationBinding(conversationScopeId, targetKey);
@@ -359,24 +475,30 @@ export class WorkspaceRegistry {
359
475
  return undefined;
360
476
  }
361
477
  const context = await this.reusedWorkspaceContext(this.getWorkspace(session.id));
362
- this.store.touchConversationBinding(conversationScopeId, targetKey);
363
- return { ...context, includeBootstrapContext: false };
478
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
364
479
  }
365
- withConversationContext(context, conversationScopeId, targetKey) {
480
+ withConversationContext(context, conversationScopeId, targetKey, bootstrapContext) {
366
481
  if (!conversationScopeId || !this.store) {
367
- return { ...context, includeBootstrapContext: true };
368
- }
369
- const binding = this.store.getConversationBinding(conversationScopeId, targetKey);
370
- if (binding?.workspaceSessionId === context.workspace.id) {
371
- this.store.touchConversationBinding(conversationScopeId, targetKey);
372
- return { ...context, includeBootstrapContext: false };
482
+ return {
483
+ ...context,
484
+ includeBootstrapContext: bootstrapContext !== "none",
485
+ };
373
486
  }
487
+ const delivery = this.store.getContextDelivery(conversationScopeId, targetKey);
488
+ const includeBootstrapContext = resolveBootstrapContextVisibility(bootstrapContext, delivery?.contextFingerprint === context.contextFingerprint);
374
489
  this.store.setConversationBinding({
375
490
  conversationScopeId,
376
491
  targetKey,
377
492
  workspaceSessionId: context.workspace.id,
378
493
  });
379
- return { ...context, includeBootstrapContext: true };
494
+ if (includeBootstrapContext) {
495
+ this.store.setContextDelivery({
496
+ conversationScopeId,
497
+ targetKey,
498
+ contextFingerprint: context.contextFingerprint,
499
+ });
500
+ }
501
+ return { ...context, includeBootstrapContext };
380
502
  }
381
503
  pruneIdleWorkspaceSessions(protectedWorkspaceIds, force = false) {
382
504
  if (!this.store)
@@ -397,6 +519,13 @@ export class WorkspaceRegistry {
397
519
  this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
398
520
  }
399
521
  }
522
+ for (const delivery of this.store.listContextDeliveries()) {
523
+ const deliveredAt = Date.parse(delivery.deliveredAt);
524
+ if (Number.isFinite(deliveredAt) &&
525
+ now - deliveredAt >= WORKSPACE_SESSION_IDLE_TTL_MS) {
526
+ this.store.deleteContextDelivery(delivery.conversationScopeId, delivery.targetKey);
527
+ }
528
+ }
400
529
  const boundWorkspaceIds = new Set(this.store.listConversationBindings().map((binding) => binding.workspaceSessionId));
401
530
  const worktreeAnchors = new Map();
402
531
  for (const session of activeSessions) {
@@ -499,13 +628,17 @@ export class WorkspaceRegistry {
499
628
  });
500
629
  }
501
630
  async reusedWorkspaceContext(workspace) {
631
+ Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
632
+ workspace.capabilityGuides = loadCapabilityGuides(this.config);
502
633
  workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root);
503
634
  const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
504
635
  const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
636
+ const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
505
637
  return {
506
638
  workspace,
507
639
  agentsFiles,
508
640
  availableAgentsFiles,
641
+ contextFingerprint,
509
642
  hookReports: [],
510
643
  workspaceReused: true,
511
644
  includeBootstrapContext: true,
@@ -676,10 +809,12 @@ export class WorkspaceRegistry {
676
809
  });
677
810
  const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
678
811
  const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
812
+ const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
679
813
  return {
680
814
  workspace,
681
815
  agentsFiles,
682
816
  availableAgentsFiles,
817
+ contextFingerprint,
683
818
  hookReports,
684
819
  workspaceReused: false,
685
820
  includeBootstrapContext: true,
@@ -761,6 +896,51 @@ export class WorkspaceRegistry {
761
896
  return discovered.sort((a, b) => a.path.localeCompare(b.path));
762
897
  }
763
898
  }
899
+ function resolveBootstrapContextVisibility(mode, contextAlreadyDelivered) {
900
+ if (mode === "full")
901
+ return true;
902
+ if (mode === "none")
903
+ return false;
904
+ return !contextAlreadyDelivered;
905
+ }
906
+ function bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles) {
907
+ const payload = {
908
+ agentsFiles: agentsFiles
909
+ .map((file) => ({ path: resolve(file.path), content: file.content }))
910
+ .sort((left, right) => left.path.localeCompare(right.path)),
911
+ availableAgentsFiles: availableAgentsFiles
912
+ .map((file) => resolve(file.path))
913
+ .sort((left, right) => left.localeCompare(right)),
914
+ skills: workspace.skills
915
+ .map((skill) => ({
916
+ name: skill.name,
917
+ description: skill.description,
918
+ filePath: resolve(skill.filePath),
919
+ disableModelInvocation: skill.disableModelInvocation ?? false,
920
+ }))
921
+ .sort((left, right) => left.name.localeCompare(right.name) || left.filePath.localeCompare(right.filePath)),
922
+ skillDiagnostics: workspace.skillDiagnostics,
923
+ capabilityGuides: workspace.capabilityGuides
924
+ .map((guide) => ({
925
+ name: guide.name,
926
+ description: guide.description,
927
+ whenToRead: guide.whenToRead,
928
+ filePath: resolve(guide.filePath),
929
+ }))
930
+ .sort((left, right) => left.name.localeCompare(right.name)),
931
+ agentProfiles: workspace.agentProfiles
932
+ .map((profile) => ({
933
+ name: profile.name,
934
+ description: profile.description,
935
+ provider: profile.provider,
936
+ model: profile.model,
937
+ thinking: profile.thinking,
938
+ filePath: resolve(profile.filePath),
939
+ }))
940
+ .sort((left, right) => left.name.localeCompare(right.name)),
941
+ };
942
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
943
+ }
764
944
  async function canonicalPath(path) {
765
945
  const missingSegments = [];
766
946
  let candidate = path;
@@ -18,6 +18,60 @@ existing ID explicitly when the user wants to resume that logical workspace.
18
18
  A Git worktree directory is a separate physical workspace target from its source
19
19
  checkout, and each conversation can still have its own logical handle for it.
20
20
 
21
+ ### Bootstrap context and workspace inventory
22
+
23
+ Normal coding still starts with the shortest path:
24
+
25
+ ```text
26
+ open_workspace(path="~/project")
27
+ ```
28
+
29
+ The default `context="auto"` keeps the first useful bootstrap while avoiding
30
+ 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.
37
+
38
+ Two explicit controls are available for exceptional cases:
39
+
40
+ ```text
41
+ open_workspace(workspaceId="ws_...", context="full")
42
+ open_workspace(workspaceId="ws_...", context="none")
43
+ ```
44
+
45
+ `full` forces a bootstrap refresh. `none` opens/resumes the workspace without
46
+ 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.
50
+
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:
54
+
55
+ ```text
56
+ open_workspace(action="list")
57
+ open_workspace(action="list", root="~/project")
58
+ open_workspace(action="list", staleOnly=true)
59
+ ```
60
+
61
+ Inventory is paginated, defaults to 50 entries, and caps each page at 100. It can
62
+ filter by `workspaceId`, persisted `status`, derived `state`, `mode`, canonical
63
+ root/source root, or stale-only state. Entries include a compact label such as
64
+ `project/ws_...`, checkout/worktree backing metadata, timestamps, idle duration,
65
+ root validity, and whether that logical workspace is currently selected by this
66
+ conversation. Listing is observational and does not refresh `lastUsedAt`.
67
+
68
+ Treat persisted status and derived state separately. `status="active"` means the
69
+ record has not been explicitly closed. A valid recent record has `state="active"`;
70
+ a valid active record idle for more than two days has `state="stale"`; an active
71
+ record whose root is missing or unusable has `state="invalid"`; and an explicitly
72
+ finalized persisted record has `state="closed"`. Ask the user before cleanup, then
73
+ use the existing `close_workspace` lifecycle on the selected `workspaceId`.
74
+
21
75
  ## Checkout-first behavior
22
76
 
23
77
  Checkout mode is the default:
@@ -131,8 +185,9 @@ worktrees, subagents, artifact/change-review workflows, Host/OAuth/MCP App
131
185
  integration, and long-running shell/PTY/process behavior. Optional guides are
132
186
  advertised only when their feature is enabled; for example, disabled subagents
133
187
  and artifact/change-review features do not add those descriptors to bootstrap
134
- context. Reopening a workspace in the same Host context does not repeat the
135
- descriptors, but the previously advertised guides remain valid.
188
+ context. Bootstrap replay follows the conversation/canonical-target context
189
+ fingerprint described above, so changing logical `workspaceId` alone does not
190
+ repeat unchanged descriptors; previously advertised guides remain valid.
136
191
 
137
192
  The fingerprint is also a stale-Host-schema diagnostic. If `open_workspace`
138
193
  reports a capability such as `filesystem.rename-move` but the Host's current
@@ -223,12 +278,14 @@ Workspace IDs are logical conversation handles rather than physical-directory
223
278
  identities. The same conversation keeps a stable ID for a project, while another
224
279
  conversation normally receives a different ID pointing at the same checkout or
225
280
  worktree. `open_workspace` can explicitly resume a known `workspaceId`, and a
226
- fresh logical ID is created only when the user asks for one. When a project has
227
- other logical workspaces idle for more than two days, `open_workspace` reports
228
- all of them so the user can choose to resume or clean them up. `close_workspace`
229
- is the single public close operation: checkout-backed workspaces release the logical
230
- handle, while managed-worktree-backed workspaces require `commitMessage` and run the
231
- safe commit / fast-forward-only integration / cleanup lifecycle.
281
+ fresh logical ID is created only when the user asks for one. The normal open path
282
+ may still include `staleWorkspaces` as a passive reminder for same-target handles
283
+ idle for more than two days; use `open_workspace(action="list")` for complete,
284
+ filtered inventory when continuation or cleanup actually requires it.
285
+ `close_workspace` is the single public close operation: checkout-backed workspaces
286
+ release the logical handle, while managed-worktree-backed workspaces require
287
+ `commitMessage` and run the safe commit / fast-forward-only integration / cleanup
288
+ lifecycle.
232
289
 
233
290
  Shell commands are allowed to modify ordinary project files when that is a
234
291
  natural part of the user's requested development task; ForgeRelay does not apply
@@ -163,12 +163,35 @@ receives a different ID for the same physical checkout/worktree. Pass
163
163
  `workspaceId` to `open_workspace` to explicitly resume an existing handle in the
164
164
  current conversation. `newWorkspace: true` allocates a new logical handle without
165
165
  creating another checkout or Git worktree and should be used only on explicit user
166
- request. Logical workspaces idle for more than two days are returned in `staleWorkspaces` so
167
- the user can choose whether to resume or release them. `close_workspace` removes a
168
- checkout-backed logical handle without deleting checkout files. For a managed-worktree-backed
169
- workspace, `close_workspace` requires `commitMessage` and runs the existing safe
170
- worktree finalize lifecycle: close Hooks, commit when needed, fast-forward-only
171
- integration, cleanup, and alias invalidation.
166
+ request.
167
+
168
+ Bootstrap delivery is tracked separately from the selected logical workspace.
169
+ `open_workspace` defaults to `context="auto"`: ForgeRelay fingerprints the current
170
+ project context and returns the full AGENTS/Skills/Capability-guide/profile bootstrap
171
+ only when that conversation has not already received the current fingerprint for
172
+ the canonical workspace target. `context="full"` forces a refresh;
173
+ `context="none"` opens or resumes the logical workspace without returning the full
174
+ bootstrap and does not mark the current fingerprint as delivered. Closing or
175
+ switching a logical workspace therefore does not by itself cause unchanged project
176
+ context to be injected again, while changed context produces a new fingerprint and
177
+ is delivered on the next `auto` open.
178
+
179
+ Use `open_workspace(action="list")` only when the Agent needs to continue an older
180
+ logical workspace, choose among multiple handles, or organize workspace state. The
181
+ inventory is paginated (50 records by default, at most 100) and can filter by
182
+ workspace ID, persisted status, derived state, mode, canonical root/source root, or
183
+ stale-only state. Reading inventory does not refresh `lastUsedAt`. Persisted
184
+ `status="active"` means the record has not been explicitly closed; the derived
185
+ `state` distinguishes `active`, `stale`, `invalid`, and `closed`. A missing checkout
186
+ or externally removed managed-worktree root can therefore remain diagnostically
187
+ `status="active"` while appearing as `state="invalid"`. The existing
188
+ `staleWorkspaces` field remains a passive same-workspace reminder for old handles;
189
+ `action="list"` is the formal on-demand inventory path.
190
+
191
+ `close_workspace` removes a checkout-backed logical handle without deleting checkout
192
+ files. For a managed-worktree-backed workspace, `close_workspace` requires
193
+ `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
194
+ commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
172
195
 
173
196
  Regular `bash` has no execution-timeout input. `action="run"` (the default) waits
174
197
  in the foreground for at most 300 seconds; if the process is still alive, the
package/docs/roadmap.md CHANGED
@@ -170,7 +170,18 @@ capability
170
170
  - 删除已经完成迁移的 dedicated low-frequency tool aliases,确保新增 Capability 不再扩大常驻 tool count;
171
171
  - 简化 fingerprint,使其用于版本/运行时能力摘要与 stale-Host 诊断,而不是重新枚举 tool implementation;
172
172
  - 对 `open_workspace → catalog → capability describe/read/run`、managed worktree close、长进程 interaction、review/artifact capability、MCP App 与 stale-schema 情况做 7677 acceptance 和新 Host 会话验收;
173
- - 0.3.5 通过后,0.3 的 MCP progressive-disclosure 主题视为完成,0.4 回到原定 LSP code intelligence v1。
173
+ - 0.3.5 完成 canonical MCP surface 与 fresh-Host 主验收后,0.3 的 progressive-disclosure 主体设计视为稳定;后续 0.3.x 只接收验收暴露出的兼容性或 lifecycle 补丁,0.4 仍回到原定 LSP code intelligence v1。
174
+
175
+ ### 0.3.6 — Workspace bootstrap 与 inventory 补丁
176
+
177
+ 0.3.6 处理 0.3.5 fresh-Host 验收后暴露的 Workspace 上下文与 logical-workspace 管理问题,不增加新的常驻 MCP tool,也不改变 0.4 的 LSP 主路线:
178
+
179
+ - `open_workspace` 的 bootstrap 去重从 logical `workspaceId` 身份提升为 conversation scope + canonical workspace target + context fingerprint;同一 conversation 切换到同一物理 checkout/worktree 的其他 logical workspace 时,只要 AGENTS、Skills、Capability guide/profile 等相关上下文没有变化,就不重复注入完整 bootstrap;
180
+ - `open_workspace` 提供 `context="auto" | "full" | "none"`。`auto` 默认只在当前 fingerprint 尚未交付或已变化时返回完整上下文,`full` 强制刷新,`none` 只取得 workspace handle/metadata 且不会把该 fingerprint 标记为已交付;
181
+ - context-delivery 状态与 conversation 当前绑定的 logical workspace 分离持久化,因此关闭或切换 logical handle 不会让 Agent 在同一 conversation 中忘记已经收到的项目上下文;delivery 状态继续服从有限生命周期清理,而不是永久缓存;
182
+ - `open_workspace(action="list")` 成为按需 logical-workspace inventory 入口,不增加 `list_workspaces`/`workspace.list` 第十个 Core tool;普通开发仍直接使用默认 `action="open"`,只有续接旧任务、选择 workspace 或整理状态时才读取 inventory;
183
+ - inventory 区分持久化 `status` 与派生 `state`:`status="active"` 表示尚未显式关闭,`state` 再区分 active、stale、invalid 与 closed;missing root 或外部删除的 managed worktree 可以保持可诊断的 active record,同时显示为 invalid;
184
+ - inventory 查看本身不刷新 workspace `lastUsedAt`,支持过滤与分页,并继续让现有 `close_workspace` 承担用户确认后的实际清理/finalize lifecycle。
174
185
 
175
186
  必要安全语义始终留在 Core tool interface、Capability contract 或自动 Hook report 中;渐进式披露不能成为隐藏权限、隐式 autonomous workflow 或绕过 allowed roots/auth 的机制。`rename` 继续作为文件和目录 move/rename 的统一 primitive。
176
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -223,13 +223,16 @@ try {
223
223
  assert.equal(historicalTemplate.text, template.text);
224
224
  pass("MCP app template", `${templateUri} + legacy/history compatibility -> ${scriptUrl}`);
225
225
 
226
+ const workspaceConversationMeta = { "openai/session": "acceptance-workspace" };
226
227
  const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
227
228
  path: checkoutWorkspace,
228
- });
229
+ }, workspaceConversationMeta);
229
230
  const workspaceId = opened.structuredContent.workspaceId;
230
231
  assert.match(workspaceId, /^ws_/);
232
+ assert.equal(opened.structuredContent.action, "open");
231
233
  assert.equal(opened.structuredContent.root, checkoutWorkspace);
232
234
  assert.equal(opened.structuredContent.mode, "checkout");
235
+ assert.equal(typeof opened.structuredContent.contextFingerprint, "string");
233
236
  assert.deepEqual(opened.structuredContent.capabilityFingerprint, {
234
237
  version: packageJson.version,
235
238
  toolMode: "full",
@@ -254,6 +257,56 @@ try {
254
257
  ]);
255
258
  assert.equal(capabilityCatalog[0].available, true);
256
259
  assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
260
+
261
+ const freshLogical = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
262
+ path: checkoutWorkspace,
263
+ newWorkspace: true,
264
+ }, workspaceConversationMeta);
265
+ const freshLogicalId = freshLogical.structuredContent.workspaceId;
266
+ assert.notEqual(freshLogicalId, workspaceId);
267
+ assert.equal(freshLogical.structuredContent.action, "open");
268
+ assert.equal(
269
+ freshLogical.structuredContent.contextFingerprint,
270
+ opened.structuredContent.contextFingerprint,
271
+ );
272
+ assert.equal(freshLogical.structuredContent.agentsFiles, undefined);
273
+ assert.equal(freshLogical.structuredContent.capabilityGuides, undefined);
274
+
275
+ const workspaceInventory = callTool(oauth.accessToken, sessionId, 85, "open_workspace", {
276
+ action: "list",
277
+ root: checkoutWorkspace,
278
+ }, workspaceConversationMeta);
279
+ assert.equal(workspaceInventory.structuredContent.action, "list");
280
+ assert.equal(workspaceInventory.structuredContent.summary.matching, 2);
281
+ const inventoryEntries = workspaceInventory.structuredContent.workspaces;
282
+ assert.equal(inventoryEntries.length, 2);
283
+ assert.equal(
284
+ inventoryEntries.find((entry) => entry.workspaceId === freshLogicalId)?.current,
285
+ true,
286
+ );
287
+ assert.equal(
288
+ inventoryEntries.find((entry) => entry.workspaceId === workspaceId)?.current,
289
+ false,
290
+ );
291
+
292
+ const closedFreshLogical = callTool(oauth.accessToken, sessionId, 86, "close_workspace", {
293
+ workspaceId: freshLogicalId,
294
+ });
295
+ assert.equal(closedFreshLogical.isError, undefined);
296
+ const resumedOriginal = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
297
+ workspaceId,
298
+ }, workspaceConversationMeta);
299
+ assert.equal(resumedOriginal.structuredContent.workspaceId, workspaceId);
300
+ assert.equal(resumedOriginal.structuredContent.agentsFiles, undefined);
301
+ assert.equal(
302
+ resumedOriginal.structuredContent.contextFingerprint,
303
+ opened.structuredContent.contextFingerprint,
304
+ );
305
+ pass(
306
+ "workspace context + inventory",
307
+ `${workspaceId} -> ${freshLogicalId} -> list -> close -> resume without bootstrap replay`,
308
+ );
309
+
257
310
  const directCapability = callTool(oauth.accessToken, sessionId, 79, "capability", {
258
311
  workspaceId,
259
312
  name: "hooks.check",
@@ -590,12 +643,16 @@ function mcpRequest(accessToken, sessionId, request) {
590
643
  return { response, message: parseMcpMessage(response.body, request.id) };
591
644
  }
592
645
 
593
- function callTool(accessToken, sessionId, id, name, args) {
646
+ function callTool(accessToken, sessionId, id, name, args, meta) {
594
647
  const message = mcpRequest(accessToken, sessionId, {
595
648
  jsonrpc: "2.0",
596
649
  id,
597
650
  method: "tools/call",
598
- params: { name, arguments: args },
651
+ params: {
652
+ name,
653
+ arguments: args,
654
+ ...(meta ? { _meta: meta } : {}),
655
+ },
599
656
  }).message;
600
657
  assert.equal(message.id, id);
601
658
  assert.ok(message.result, `tool ${name} did not return a result`);