@akira-tl/forgerelay 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.1] - 2026-08-30
8
+
9
+ ### Added
10
+
11
+ - `close_workspace(action="delete")` explicitly removes ForgeRelay-owned checkout Workspace state without deleting or mutating project files.
12
+
13
+ ### Changed
14
+
15
+ - Checkout Workspace close is now reversible: it preserves the canonical identity for reopen by path or ID, closed Workspaces remain listable but non-executable, legacy aliases resolve canonically, and idle GC no longer deletes persistent identity.
16
+
17
+ ## [0.8.0] - 2026-08-30
18
+
19
+ ### Changed
20
+
21
+ - 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.
22
+
23
+ ### Fixed
24
+
25
+ - Composite member bootstrap now preserves capability guides and subagent provider/profile context exposed by the underlying Workspace.
26
+ - Composite member `context="none"` now truthfully reports suppressed bootstrap instead of claiming it was already delivered.
27
+
7
28
  ## [0.7.4] - 2026-08-30
8
29
 
9
30
  ### 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()
@@ -38,7 +38,7 @@ function capabilityContractInstructions(config) {
38
38
  const staleWorkspacePolicy = config.toolMode === "codex"
39
39
  ? ""
40
40
  : ` If ${toolNames.openWorkspace} reports stale workspaces, let the user choose resume or ${toolNames.closeWorkspace}; never auto-close.`;
41
- const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases checkout workspaces or finalizes managed worktrees; managed close requires commitMessage.`;
41
+ const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} defaults to closing checkout Workspaces for later reopen; action=delete removes only ForgeRelay-owned checkout state, never project files. Managed close finalizes the worktree and requires commitMessage.`;
42
42
  const activityPanel = `Project-work order: ${toolNames.openWorkspace} if needed → activity_panel(workspaceId) once → work tools. activity_panel is the single ForgeRelay UI render tool: Workspace above Activity. A new workspaceId creates a new card. Never call activity_panel before needed ${toolNames.openWorkspace}.`;
43
43
  const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
44
44
  const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
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,9 +2921,13 @@ 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 or explicitly delete one Workspace after the user chooses cleanup. action=close (default) preserves checkout identity for later reopen. action=delete permanently removes ForgeRelay-owned checkout state but never project files. Managed-worktree-backed Workspaces still use the safe finalize lifecycle and require commitMessage; Composite and relayed delete semantics are not available in this stage.",
2904
2925
  inputSchema: {
2905
- workspaceId: z.string().describe("Workspace identifier to close."),
2926
+ workspaceId: z.string().describe("Workspace identifier to close or delete."),
2927
+ action: z
2928
+ .enum(["close", "delete"])
2929
+ .optional()
2930
+ .describe("Defaults to close. close preserves checkout identity for later reopen; delete permanently removes ForgeRelay-owned checkout state without deleting project files."),
2906
2931
  commitMessage: z
2907
2932
  .string()
2908
2933
  .min(1)
@@ -2911,6 +2936,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2911
2936
  },
2912
2937
  outputSchema: resultOutputSchema({
2913
2938
  workspaceId: z.string(),
2939
+ action: z.enum(["close", "delete"]).optional(),
2914
2940
  kind: z.enum(["workspace", "composite"]).optional(),
2915
2941
  mode: z.enum(["checkout", "worktree"]).optional(),
2916
2942
  name: z.string().optional(),
@@ -2930,8 +2956,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2930
2956
  }),
2931
2957
  _meta: {},
2932
2958
  annotations: WRITE_TOOL_ANNOTATIONS,
2933
- }, async ({ workspaceId, commitMessage }, extra) => {
2959
+ }, async ({ workspaceId, action = "close", commitMessage }, extra) => {
2934
2960
  if (compositeWorkspaces.has(workspaceId)) {
2961
+ if (action === "delete") {
2962
+ throw new Error("close_workspace action=delete is not available for Composite Workspaces until the Composite persistent lifecycle stage.");
2963
+ }
2935
2964
  if (commitMessage !== undefined) {
2936
2965
  throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
2937
2966
  }
@@ -2951,6 +2980,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2951
2980
  tool: toolNames.closeWorkspace,
2952
2981
  card: {
2953
2982
  workspaceId,
2983
+ action: "close",
2954
2984
  kind: "composite",
2955
2985
  name: composite.name,
2956
2986
  members: composite.members,
@@ -2961,6 +2991,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2961
2991
  structuredContent: {
2962
2992
  result,
2963
2993
  workspaceId,
2994
+ action: "close",
2964
2995
  kind: "composite",
2965
2996
  name: composite.name,
2966
2997
  members: composite.members,
@@ -2969,16 +3000,67 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2969
3000
  };
2970
3001
  }
2971
3002
  if (remoteWorkspaces.has(workspaceId)) {
3003
+ if (action === "delete") {
3004
+ throw new Error("close_workspace action=delete is not available for relayed Workspaces until Workspace Relay lifecycle parity is implemented.");
3005
+ }
2972
3006
  const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
2973
3007
  workspacePanelStates.delete(workspaceId);
2974
3008
  return response;
2975
3009
  }
3010
+ if (action === "delete") {
3011
+ if (commitMessage !== undefined) {
3012
+ throw new Error("close_workspace commitMessage is not valid with action=delete for a checkout Workspace.");
3013
+ }
3014
+ const session = workspaces.getWorkspaceSession(workspaceId);
3015
+ if (session.mode !== "checkout") {
3016
+ throw new Error("close_workspace action=delete is not available for managed-worktree-backed Workspaces until their persistent lifecycle stage.");
3017
+ }
3018
+ if (processSessions.activeWorkspaceIds().has(session.id)) {
3019
+ throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
3020
+ }
3021
+ const response = await runToolWithHooks(hooks, {
3022
+ signal: extra.signal,
3023
+ tool: toolNames.closeWorkspace,
3024
+ invocation: {
3025
+ workspaceId: session.id,
3026
+ workspaceRoot: session.root,
3027
+ workspaceMode: session.mode,
3028
+ sourceRoot: session.sourceRoot,
3029
+ },
3030
+ payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3031
+ operation: async () => {
3032
+ workspaces.deleteWorkspace(session.id);
3033
+ await reviewCheckpoints.releaseWorkspace(session.id);
3034
+ const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
3035
+ return {
3036
+ content: [textBlock(result)],
3037
+ _meta: {
3038
+ tool: toolNames.closeWorkspace,
3039
+ card: {
3040
+ workspaceId: session.id,
3041
+ action: "delete",
3042
+ mode: "checkout",
3043
+ payload: { content: [textBlock(result)] },
3044
+ },
3045
+ },
3046
+ structuredContent: {
3047
+ result,
3048
+ workspaceId: session.id,
3049
+ action: "delete",
3050
+ mode: "checkout",
3051
+ },
3052
+ };
3053
+ },
3054
+ });
3055
+ workspacePanelStates.delete(session.id);
3056
+ return response;
3057
+ }
2976
3058
  const workspace = workspaces.getWorkspace(workspaceId);
2977
3059
  const response = await runToolWithHooks(hooks, {
2978
3060
  signal: extra.signal,
2979
3061
  tool: toolNames.closeWorkspace,
2980
3062
  invocation: workspaceHookInvocation(workspace),
2981
- payload: { workspaceId, commitMessage, mode: workspace.mode },
3063
+ payload: { workspaceId, action: "close", commitMessage, mode: workspace.mode },
2982
3064
  afterCwd: (response) => "sourceRoot" in response.structuredContent &&
2983
3065
  typeof response.structuredContent.sourceRoot === "string"
2984
3066
  ? response.structuredContent.sourceRoot
@@ -2992,7 +3074,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2992
3074
  const busyWorkspaceIds = physicalWorkspaceIds
2993
3075
  .filter((id) => processSessions.activeWorkspaceIds().has(id));
2994
3076
  if (busyWorkspaceIds.length > 0) {
2995
- throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running: ${busyWorkspaceIds.join(", ")}.`);
3077
+ throw new Error(`Cannot close this worktree-backed Workspace while Workspace processes are still running: ${busyWorkspaceIds.join(", ")}.`);
2996
3078
  }
2997
3079
  const startedAt = performance.now();
2998
3080
  const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
@@ -3026,6 +3108,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3026
3108
  tool: toolNames.closeWorkspace,
3027
3109
  card: {
3028
3110
  workspaceId,
3111
+ action: "close",
3029
3112
  mode: "worktree",
3030
3113
  sourceRoot: closed.sourceRoot,
3031
3114
  branch: closed.branch,
@@ -3040,6 +3123,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3040
3123
  structuredContent: {
3041
3124
  result,
3042
3125
  workspaceId,
3126
+ action: "close",
3043
3127
  mode: "worktree",
3044
3128
  sourceRoot: closed.sourceRoot,
3045
3129
  branch: closed.branch,
@@ -3054,27 +3138,34 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3054
3138
  if (commitMessage !== undefined) {
3055
3139
  throw new Error("close_workspace commitMessage is only valid for managed-worktree-backed workspaces.");
3056
3140
  }
3057
- if (processSessions.activeWorkspaceIds().has(workspaceId)) {
3058
- throw new Error(`Workspace ${workspaceId} still owns a running process. Poll, interrupt, or wait for it before closing this workspace.`);
3141
+ const checkoutWorkspaceId = workspace.id;
3142
+ if (processSessions.activeWorkspaceIds().has(checkoutWorkspaceId)) {
3143
+ throw new Error(`Workspace ${checkoutWorkspaceId} still owns a running process. Poll, interrupt, or wait for it before closing this workspace.`);
3059
3144
  }
3060
- workspaces.closeWorkspace(workspaceId);
3061
- await reviewCheckpoints.releaseWorkspace(workspaceId);
3062
- const result = `Closed checkout-backed workspace ${workspaceId}. Physical project files were not removed.`;
3145
+ workspaces.closeWorkspace(checkoutWorkspaceId);
3146
+ await reviewCheckpoints.releaseWorkspace(checkoutWorkspaceId);
3147
+ const result = `Closed checkout-backed Workspace ${checkoutWorkspaceId}; its ForgeRelay identity was preserved for later reopen. Physical project files were not removed.`;
3063
3148
  return {
3064
3149
  content: [textBlock(result)],
3065
3150
  _meta: {
3066
3151
  tool: toolNames.closeWorkspace,
3067
3152
  card: {
3068
- workspaceId,
3153
+ workspaceId: checkoutWorkspaceId,
3154
+ action: "close",
3069
3155
  mode: "checkout",
3070
3156
  payload: { content: [textBlock(result)] },
3071
3157
  },
3072
3158
  },
3073
- structuredContent: { result, workspaceId, mode: "checkout" },
3159
+ structuredContent: {
3160
+ result,
3161
+ workspaceId: checkoutWorkspaceId,
3162
+ action: "close",
3163
+ mode: "checkout",
3164
+ },
3074
3165
  };
3075
3166
  },
3076
3167
  });
3077
- workspacePanelStates.delete(workspaceId);
3168
+ workspacePanelStates.delete(workspace.id);
3078
3169
  return response;
3079
3170
  });
3080
3171
  registerAppTool(server, toolNames.read, {
@@ -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
  }