@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 +12 -0
- package/dist/db/migrations.js +19 -0
- package/dist/db/schema.js +9 -0
- package/dist/server.js +149 -17
- package/dist/workspace-store.js +52 -1
- package/dist/workspaces.js +209 -29
- package/docs/chatgpt-coding-workflow.md +65 -8
- package/docs/configuration.md +29 -6
- package/docs/roadmap.md +12 -1
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +60 -3
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
|
package/dist/db/migrations.js
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
|
|
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("
|
|
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("
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
?
|
|
916
|
-
:
|
|
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
|
? {
|
package/dist/workspace-store.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/workspaces.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
@@ -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
|
|
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 {
|
|
149
|
+
return {
|
|
150
|
+
...context,
|
|
151
|
+
includeBootstrapContext: bootstrapContext !== "none",
|
|
152
|
+
};
|
|
47
153
|
}
|
|
48
154
|
const targetKeys = await this.workspaceTargetKeys(workspace);
|
|
49
|
-
const
|
|
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
|
|
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.
|
|
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 {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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.
|
|
135
|
-
|
|
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.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
package/scripts/debug/accept.mjs
CHANGED
|
@@ -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: {
|
|
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`);
|