@akira-tl/forgerelay 0.8.3 → 0.8.4

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/dist/server.js CHANGED
@@ -53,6 +53,7 @@ import { ACTIVITY_PANEL_APP_LEGACY_URI, ACTIVITY_PANEL_APP_URI_TEMPLATE, MCP_APP
53
53
  import { shutdownHttpServer } from "./server-shutdown.js";
54
54
  import { formatPathForPrompt } from "./skills.js";
55
55
  import { createWorkspaceStore } from "./workspace-store.js";
56
+ import { WorkspaceTaskReminderTracker } from "./workspace-task-reminders.js";
56
57
  import { WorkspaceTaskStore } from "./workspace-tasks.js";
57
58
  import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
58
59
  import { formatAvailableSubagentProfile, summarizeSubagentProfile } from "./subagents/profiles.js";
@@ -259,6 +260,74 @@ const workspaceInventoryPageOutputSchema = z.object({
259
260
  limit: z.number().int().positive(),
260
261
  hasMore: z.boolean(),
261
262
  });
263
+ const workspaceTaskInspectionSummaryOutputSchema = z.object({
264
+ level: z.literal("summary"),
265
+ version: z.literal(1),
266
+ revision: z.number().int().nonnegative(),
267
+ lists: z.array(z.object({
268
+ id: z.string(),
269
+ name: z.string(),
270
+ state: z.enum(["active", "archived"]),
271
+ revision: z.number().int().positive(),
272
+ taskCount: z.number().int().nonnegative(),
273
+ unfinishedTaskCount: z.number().int().nonnegative(),
274
+ })),
275
+ });
276
+ const workspaceInspectionMemberOutputSchema = z.object({
277
+ name: z.string(),
278
+ purpose: z.string(),
279
+ workspaceId: z.string(),
280
+ known: z.boolean(),
281
+ location: z.enum(["local", "relay"]).optional(),
282
+ state: z.enum(["active", "stale", "invalid", "closed"]).optional(),
283
+ status: z.string().optional(),
284
+ routeState: z.literal("known").optional(),
285
+ mode: z.enum(["checkout", "worktree"]).optional(),
286
+ rootValid: z.boolean().optional(),
287
+ });
288
+ const workspaceInspectionOutputSchema = z.union([
289
+ z.object({
290
+ workspaceId: z.string(),
291
+ kind: z.literal("workspace"),
292
+ location: z.literal("local"),
293
+ label: z.string(),
294
+ root: z.string(),
295
+ status: z.string(),
296
+ state: z.enum(["active", "stale", "invalid", "closed"]),
297
+ mode: z.enum(["checkout", "worktree"]),
298
+ sourceRoot: z.string().optional(),
299
+ branch: z.string().optional(),
300
+ targetBranch: z.string().optional(),
301
+ managed: z.boolean(),
302
+ createdAt: z.string(),
303
+ lastUsedAt: z.string(),
304
+ idleMs: z.number().nonnegative(),
305
+ rootValid: z.boolean(),
306
+ taskSummary: workspaceTaskInspectionSummaryOutputSchema.optional(),
307
+ }),
308
+ z.object({
309
+ workspaceId: z.string(),
310
+ kind: z.literal("workspace"),
311
+ location: z.literal("relay"),
312
+ root: z.string(),
313
+ routeState: z.literal("known"),
314
+ mode: z.enum(["checkout", "worktree"]),
315
+ sourceRoot: z.string().optional(),
316
+ relay: z.string(),
317
+ executionLocation: z.string(),
318
+ }),
319
+ z.object({
320
+ workspaceId: z.string(),
321
+ kind: z.literal("composite"),
322
+ name: z.string(),
323
+ status: z.enum(["active", "closed"]),
324
+ state: z.enum(["active", "closed"]),
325
+ createdAt: z.string(),
326
+ lastUsedAt: z.string(),
327
+ members: z.array(workspaceInspectionMemberOutputSchema),
328
+ taskSummary: workspaceTaskInspectionSummaryOutputSchema.optional(),
329
+ }),
330
+ ]);
262
331
  const reviewFileOutputSchema = z.object({
263
332
  path: z.string(),
264
333
  previousPath: z.string().optional(),
@@ -345,6 +414,17 @@ function logFailedToolResponse(config, fields, content, startedAt) {
345
414
  function textBlock(text) {
346
415
  return { type: "text", text };
347
416
  }
417
+ function attachWorkspaceTaskReminder(result, reminder) {
418
+ if (!reminder || toolResultIsError(result) || typeof result !== "object" || result === null)
419
+ return result;
420
+ const content = result.content;
421
+ if (!Array.isArray(content))
422
+ return result;
423
+ return {
424
+ ...result,
425
+ content: [...content, textBlock(reminder)],
426
+ };
427
+ }
348
428
  function textSummary(content) {
349
429
  const text = contentText(content);
350
430
  return {
@@ -853,33 +933,43 @@ function requireCapabilityWorkspaceRoot(context) {
853
933
  function runWorkspaceTasksCapability(store, workspaceId, input) {
854
934
  switch (input.operation) {
855
935
  case "get":
856
- return store.read(workspaceId);
936
+ if (input.level === "headers")
937
+ return store.readHeaders(workspaceId, input.listId);
938
+ if (input.level === "detail")
939
+ return store.readTaskDetail(workspaceId, input.listId, input.taskId);
940
+ return store.readSummary(workspaceId);
857
941
  case "list.create":
858
- return store.createList(workspaceId, { name: input.name, position: input.position });
942
+ store.createList(workspaceId, { name: input.name, position: input.position });
943
+ return store.readSummary(workspaceId);
859
944
  case "list.update":
860
- return store.updateList(workspaceId, input.listId, {
945
+ store.updateList(workspaceId, input.listId, {
861
946
  name: input.name,
862
947
  state: input.state,
863
948
  position: input.position,
864
949
  });
950
+ return store.readSummary(workspaceId);
865
951
  case "list.delete":
866
- return store.deleteList(workspaceId, input.listId);
952
+ store.deleteList(workspaceId, input.listId);
953
+ return store.readSummary(workspaceId);
867
954
  case "task.create":
868
- return store.createTask(workspaceId, input.listId, {
955
+ store.createTask(workspaceId, input.listId, {
869
956
  subject: input.subject,
870
957
  content: input.content,
871
958
  status: input.status,
872
959
  position: input.position,
873
960
  });
961
+ return store.readHeaders(workspaceId, input.listId);
874
962
  case "task.update":
875
- return store.updateTask(workspaceId, input.listId, input.taskId, {
963
+ store.updateTask(workspaceId, input.listId, input.taskId, {
876
964
  subject: input.subject,
877
965
  content: input.content,
878
966
  status: input.status,
879
967
  position: input.position,
880
968
  });
969
+ return store.readHeaders(workspaceId, input.listId);
881
970
  case "task.delete":
882
- return store.deleteTask(workspaceId, input.listId, input.taskId);
971
+ store.deleteTask(workspaceId, input.listId, input.taskId);
972
+ return store.readHeaders(workspaceId, input.listId);
883
973
  }
884
974
  }
885
975
  async function reviewWorkspaceChanges(reviewCheckpoints, workspace) {
@@ -1106,7 +1196,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1106
1196
  const target = routing.resolve(workspaceId, member);
1107
1197
  const context = await routing.prepare(target, extra._meta, extra.signal, extra.sessionId);
1108
1198
  if (routing.isRemote(target.executionWorkspaceId)) {
1109
- return routing.present(await routing.execCommandRemote(target.executionWorkspaceId, {
1199
+ return routing.presentSemantic(await routing.execCommandRemote(target.executionWorkspaceId, {
1110
1200
  cmd,
1111
1201
  ...(tty !== undefined ? { tty } : {}),
1112
1202
  ...(columns !== undefined ? { columns } : {}),
@@ -1117,7 +1207,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1117
1207
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
1118
1208
  }, routing.hostScopeIdFor(extra._meta, extra.sessionId)), target);
1119
1209
  }
1120
- return routing.present(await shellRun({
1210
+ return routing.presentSemantic(await shellRun({
1121
1211
  workspaceId: target.executionWorkspaceId,
1122
1212
  command: cmd,
1123
1213
  surface: "exec_command",
@@ -1245,6 +1335,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1245
1335
  const remoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
1246
1336
  const compositeWorkspaces = new CompositeWorkspaceRegistry(config.stateDir);
1247
1337
  const workspaceTasks = new WorkspaceTaskStore(config.stateDir);
1338
+ const taskReminders = new WorkspaceTaskReminderTracker(config.taskReminderInterval, workspaceTasks);
1248
1339
  const compositeTaskGuides = loadCapabilityGuides(config).filter((guide) => guide.name === "workspace-tasks");
1249
1340
  const compositeActivity = new CompositeActivityCoordinator(compositeWorkspaces, activityQueries, remoteWorkspaces);
1250
1341
  const resolveExecutionTarget = (workspaceId, memberName) => {
@@ -1278,6 +1369,25 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1278
1369
  : remapCompositeToolResult(result, target.executionWorkspaceId, target.compositeWorkspaceId, target.memberName);
1279
1370
  return subagentMcp.decorateResult(target.executionWorkspaceId, presented);
1280
1371
  };
1372
+ const taskReminderWorkspaceIdFor = (target) => {
1373
+ if (target.compositeWorkspaceId)
1374
+ return target.compositeWorkspaceId;
1375
+ if (remoteWorkspaces.has(target.executionWorkspaceId))
1376
+ return undefined;
1377
+ try {
1378
+ return workspaces.getWorkspace(target.executionWorkspaceId).id;
1379
+ }
1380
+ catch {
1381
+ return undefined;
1382
+ }
1383
+ };
1384
+ const presentSemanticWorkResult = (result, target) => {
1385
+ const presented = presentExecutionResult(result, target);
1386
+ if (toolResultIsError(presented))
1387
+ return presented;
1388
+ const reminderWorkspaceId = taskReminderWorkspaceIdFor(target);
1389
+ return attachWorkspaceTaskReminder(presented, reminderWorkspaceId ? taskReminders.recordWork(reminderWorkspaceId) : undefined);
1390
+ };
1281
1391
  const hostScopeIdFor = (requestMeta, transportSessionId) => hostConversationScopeId(requestMeta, transportSessionId, connectionScopeId);
1282
1392
  const prepareExecutionContext = async (target, requestMeta, signal, sessionId) => {
1283
1393
  const conversationScopeId = hostScopeIdFor(requestMeta, sessionId);
@@ -1304,9 +1414,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1304
1414
  ...subagentMcp.registryDependencies,
1305
1415
  workspaceTasks: {
1306
1416
  available: true,
1307
- run: async (input, context) => ({
1308
- value: runWorkspaceTasksCapability(workspaceTasks, context.workspaceId, input),
1309
- }),
1417
+ run: async (input, context) => {
1418
+ const value = runWorkspaceTasksCapability(workspaceTasks, context.workspaceId, input);
1419
+ if (input.operation !== "get")
1420
+ taskReminders.reset(context.workspaceId);
1421
+ return { value };
1422
+ },
1310
1423
  },
1311
1424
  batchExecute: {
1312
1425
  available: batchExecuteAvailable,
@@ -2039,9 +2152,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2039
2152
  description: "Open or resume a ForgeRelay Workspace. Ordinary workspaces default to local execution; relay may name a registered remote ForgeRelay. Composite Workspaces use the same open lifecycle but have kind=\"composite\" and a name instead of a mounted root. Reuse the returned workspaceId for later calls. Bootstrap context is delivered automatically only when needed and can be suppressed or refreshed.",
2040
2153
  inputSchema: {
2041
2154
  action: z
2042
- .enum(["open", "list", "member"])
2155
+ .enum(["open", "list", "inspect", "member"])
2043
2156
  .optional()
2044
- .describe("Defaults to open. Use list to inspect known Workspaces. Use member to add/remove a named execution member on an existing Composite Workspace."),
2157
+ .describe("Defaults to open. Use list for lightweight inventory, inspect for bounded read-only metadata about one known Workspace without opening/resuming it, or member to change Composite membership."),
2045
2158
  memberAction: z
2046
2159
  .enum(["add", "update", "remove"])
2047
2160
  .optional()
@@ -2081,7 +2194,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2081
2194
  workspaceId: z
2082
2195
  .string()
2083
2196
  .optional()
2084
- .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."),
2197
+ .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. For action=inspect, identifies the single Workspace to inspect without opening or binding it."),
2085
2198
  mode: z
2086
2199
  .enum(["checkout", "worktree"])
2087
2200
  .optional()
@@ -2133,7 +2246,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2133
2246
  .describe("For action=list, maximum records to return. Defaults to 50; maximum 100."),
2134
2247
  },
2135
2248
  outputSchema: {
2136
- action: z.enum(["open", "list", "member"]),
2249
+ action: z.enum(["open", "list", "inspect", "member"]),
2137
2250
  workspaceId: z.string().optional(),
2138
2251
  memberAction: z.enum(["add", "update", "remove"]).optional(),
2139
2252
  kind: z.enum(["workspace", "composite"]).optional(),
@@ -2208,6 +2321,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2208
2321
  })).optional(),
2209
2322
  summary: workspaceInventorySummaryOutputSchema.optional(),
2210
2323
  page: workspaceInventoryPageOutputSchema.optional(),
2324
+ inspection: workspaceInspectionOutputSchema.optional(),
2211
2325
  instruction: z.string(),
2212
2326
  },
2213
2327
  _meta: {},
@@ -2221,6 +2335,121 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2221
2335
  const startedAt = performance.now();
2222
2336
  const conversationScopeId = openAiConversationScopeId(_meta);
2223
2337
  const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
2338
+ const inspectTaskSummary = (targetWorkspaceId) => {
2339
+ try {
2340
+ const summary = workspaceTasks.inspectSummary(targetWorkspaceId);
2341
+ if (!summary)
2342
+ return undefined;
2343
+ const { fingerprint: _fingerprint, ...inspectionSummary } = summary;
2344
+ return inspectionSummary;
2345
+ }
2346
+ catch {
2347
+ return undefined;
2348
+ }
2349
+ };
2350
+ const inspectCompositeMember = async (entry) => {
2351
+ if (remoteWorkspaces.has(entry.workspaceId)) {
2352
+ const inspected = remoteWorkspaces.inspectWorkspace(entry.workspaceId);
2353
+ return {
2354
+ name: entry.name,
2355
+ purpose: entry.purpose,
2356
+ workspaceId: entry.workspaceId,
2357
+ known: true,
2358
+ location: inspected.location,
2359
+ routeState: inspected.routeState,
2360
+ mode: inspected.mode,
2361
+ };
2362
+ }
2363
+ try {
2364
+ const inspected = await workspaces.inspectWorkspace(entry.workspaceId);
2365
+ return {
2366
+ name: entry.name,
2367
+ purpose: entry.purpose,
2368
+ workspaceId: entry.workspaceId,
2369
+ known: true,
2370
+ location: inspected.location,
2371
+ state: inspected.state,
2372
+ status: inspected.status,
2373
+ mode: inspected.mode,
2374
+ rootValid: inspected.rootValid,
2375
+ };
2376
+ }
2377
+ catch {
2378
+ return {
2379
+ name: entry.name,
2380
+ purpose: entry.purpose,
2381
+ workspaceId: entry.workspaceId,
2382
+ known: false,
2383
+ };
2384
+ }
2385
+ };
2386
+ if (action === "inspect") {
2387
+ if (!workspaceId) {
2388
+ throw new Error("open_workspace action=inspect requires workspaceId.");
2389
+ }
2390
+ if (memberAction !== undefined || member !== undefined || kind !== undefined || name !== undefined ||
2391
+ memberName !== undefined || path !== undefined || relay !== undefined || mode !== undefined ||
2392
+ baseRef !== undefined || newWorktree !== undefined || newWorkspace !== undefined || context !== undefined ||
2393
+ root !== undefined || status !== undefined || state !== undefined || staleOnly !== undefined ||
2394
+ offset !== undefined || limit !== undefined) {
2395
+ throw new Error("open_workspace action=inspect accepts only workspaceId. It never opens, resumes, binds, or mutates the inspected Workspace.");
2396
+ }
2397
+ let inspection;
2398
+ if (compositeWorkspaces.has(workspaceId)) {
2399
+ const composite = compositeWorkspaces.get(workspaceId);
2400
+ const members = await Promise.all(composite.members.map(inspectCompositeMember));
2401
+ const taskSummary = inspectTaskSummary(composite.id);
2402
+ inspection = {
2403
+ workspaceId: composite.id,
2404
+ kind: "composite",
2405
+ name: composite.name,
2406
+ status: composite.status,
2407
+ state: composite.status,
2408
+ createdAt: composite.createdAt,
2409
+ lastUsedAt: composite.lastUsedAt,
2410
+ members,
2411
+ ...(taskSummary ? { taskSummary } : {}),
2412
+ };
2413
+ }
2414
+ else if (remoteWorkspaces.has(workspaceId)) {
2415
+ inspection = remoteWorkspaces.inspectWorkspace(workspaceId);
2416
+ }
2417
+ else {
2418
+ const inspected = await workspaces.inspectWorkspace(workspaceId);
2419
+ const taskSummary = inspectTaskSummary(inspected.workspaceId);
2420
+ inspection = {
2421
+ ...inspected,
2422
+ ...(taskSummary ? { taskSummary } : {}),
2423
+ };
2424
+ }
2425
+ const instruction = "This is a bounded read-only Workspace inspection. It does not open/resume the target, deliver bootstrap context, bind this conversation, or grant file/process/Git/Capability authority. Explicitly open the Workspace before modifying or executing against it.";
2426
+ const result = [
2427
+ `Inspected Workspace ${inspection.workspaceId} (${inspection.kind}).`,
2428
+ inspection.kind === "composite"
2429
+ ? `State: ${inspection.state}; members=${inspection.members.length}.`
2430
+ : inspection.location === "relay"
2431
+ ? `Route: ${inspection.routeState}; mode=${inspection.mode}; location=${inspection.location}. Remote lifecycle is not probed by inspection.`
2432
+ : `State: ${inspection.state}; mode=${inspection.mode}; location=${inspection.location}.`,
2433
+ "Task summary is included only when durable local Task state already exists; Task bodies are never returned.",
2434
+ instruction,
2435
+ ].join("\n");
2436
+ logToolCall(config, {
2437
+ tool: "open_workspace",
2438
+ action: "inspect",
2439
+ success: true,
2440
+ durationMs: Math.round(performance.now() - startedAt),
2441
+ });
2442
+ return {
2443
+ content: [textBlock(result)],
2444
+ structuredContent: {
2445
+ action: "inspect",
2446
+ workspaceId: inspection.workspaceId,
2447
+ kind: inspection.kind,
2448
+ inspection,
2449
+ instruction,
2450
+ },
2451
+ };
2452
+ }
2224
2453
  if (action === "member") {
2225
2454
  if (!workspaceId || !compositeWorkspaces.has(workspaceId)) {
2226
2455
  throw new Error("open_workspace action=member requires an existing Composite Workspace workspaceId.");
@@ -2673,7 +2902,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2673
2902
  const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
2674
2903
  const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
2675
2904
  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.";
2676
- 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.";
2905
+ const workspaceManagementInstruction = "Use open_workspace(action=\"list\") for lightweight Workspace inventory. Use action=\"inspect\" with one known workspaceId for bounded read-only metadata without opening/resuming it. Explicitly open a Workspace before executing or mutating against it, and ask the user before close_workspace cleanup.";
2677
2906
  const cardInstruction = config.skillsEnabled
2678
2907
  ? `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}`
2679
2908
  : `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}`;
@@ -2955,12 +3184,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2955
3184
  const executionWorkspaceId = target.executionWorkspaceId;
2956
3185
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
2957
3186
  if (remoteWorkspaces.has(executionWorkspaceId)) {
2958
- return presentExecutionResult(await remoteWorkspaces.capability(executionWorkspaceId, {
3187
+ const response = await remoteWorkspaces.capability(executionWorkspaceId, {
2959
3188
  name,
2960
3189
  action,
2961
3190
  ...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
2962
3191
  ...(file !== undefined ? { file } : {}),
2963
- }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3192
+ }, hostScopeIdFor(extra._meta, extra.sessionId));
3193
+ return action === "run" && name !== "workspace.tasks"
3194
+ ? presentSemanticWorkResult(response, target)
3195
+ : presentExecutionResult(response, target);
2964
3196
  }
2965
3197
  if (action === "run" && name === "batch.execute") {
2966
3198
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
@@ -2984,7 +3216,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2984
3216
  success: true,
2985
3217
  durationMs: Math.round(performance.now() - startedAt),
2986
3218
  });
2987
- return presentExecutionResult(result, target);
3219
+ return presentSemanticWorkResult(result, target);
2988
3220
  }
2989
3221
  catch (error) {
2990
3222
  if (extra.signal.aborted)
@@ -3011,7 +3243,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3011
3243
  }
3012
3244
  }
3013
3245
  if (action === "run") {
3014
- return presentExecutionResult(await coreOperations.capabilityRun({ workspaceId: executionWorkspaceId, name, arguments: capabilityArguments, file }, executionContext), target);
3246
+ const response = await coreOperations.capabilityRun({ workspaceId: executionWorkspaceId, name, arguments: capabilityArguments, file }, executionContext);
3247
+ return name === "workspace.tasks"
3248
+ ? presentExecutionResult(response, target)
3249
+ : presentSemanticWorkResult(response, target);
3015
3250
  }
3016
3251
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
3017
3252
  return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(extra._meta, extra.sessionId), activityRequestFor({ workspaceId: executionWorkspaceId, name, action, arguments: capabilityArguments, file }, executionContext), {
@@ -3115,8 +3350,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3115
3350
  const composite = action === "delete"
3116
3351
  ? compositeWorkspaces.dissolve(workspaceId)
3117
3352
  : compositeWorkspaces.close(workspaceId);
3118
- if (action === "delete")
3353
+ if (action === "delete") {
3119
3354
  workspaceTasks.deleteWorkspace(workspaceId);
3355
+ taskReminders.forget(workspaceId);
3356
+ }
3120
3357
  compositeActivity.forgetComposite(workspaceId);
3121
3358
  workspacePanelStates.delete(workspaceId);
3122
3359
  const result = [
@@ -3184,6 +3421,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3184
3421
  operation: async () => {
3185
3422
  workspaces.deleteWorkspace(session.id);
3186
3423
  workspaceTasks.deleteWorkspace(session.id);
3424
+ taskReminders.forget(session.id);
3187
3425
  await reviewCheckpoints.releaseWorkspace(session.id);
3188
3426
  const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
3189
3427
  return {
@@ -3227,6 +3465,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3227
3465
  operation: async () => {
3228
3466
  workspaces.deleteWorkspace(session.id);
3229
3467
  workspaceTasks.deleteWorkspace(session.id);
3468
+ taskReminders.forget(session.id);
3230
3469
  await reviewCheckpoints.releaseWorkspace(session.id);
3231
3470
  const result = `Deleted closed managed-worktree Workspace ${session.id}. Its already-removed worktree backing was not recreated.`;
3232
3471
  return {
@@ -3290,6 +3529,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3290
3529
  if (action === "delete") {
3291
3530
  workspaces.deleteWorkspace(workspace.id);
3292
3531
  workspaceTasks.deleteWorkspace(workspace.id);
3532
+ taskReminders.forget(workspace.id);
3293
3533
  }
3294
3534
  const result = [
3295
3535
  action === "delete"
@@ -3454,10 +3694,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3454
3694
  const executionWorkspaceId = target.executionWorkspaceId;
3455
3695
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3456
3696
  if (remoteWorkspaces.has(executionWorkspaceId)) {
3457
- return presentExecutionResult(await remoteWorkspaces.read(executionWorkspaceId, { path, paths, offset, limit }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3697
+ return presentSemanticWorkResult(await remoteWorkspaces.read(executionWorkspaceId, { path, paths, offset, limit }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3458
3698
  }
3459
3699
  if (path !== undefined) {
3460
- return presentExecutionResult(await coreOperations.read({ workspaceId: executionWorkspaceId, path, offset, limit }, executionContext), target);
3700
+ return presentSemanticWorkResult(await coreOperations.read({ workspaceId: executionWorkspaceId, path, offset, limit }, executionContext), target);
3461
3701
  }
3462
3702
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
3463
3703
  let response;
@@ -3510,7 +3750,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3510
3750
  : { type: "succeeded" }, activityRelationFor(executionContext));
3511
3751
  if (!response)
3512
3752
  throw new Error("Bulk Read completed without a response.");
3513
- return presentExecutionResult(response, target);
3753
+ return presentSemanticWorkResult(response, target);
3514
3754
  });
3515
3755
  if (config.toolMode !== "codex") {
3516
3756
  registerAppTool(server, toolNames.write, {
@@ -3534,9 +3774,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3534
3774
  const executionWorkspaceId = target.executionWorkspaceId;
3535
3775
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3536
3776
  if (remoteWorkspaces.has(executionWorkspaceId)) {
3537
- return presentExecutionResult(await remoteWorkspaces.write(executionWorkspaceId, input, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3777
+ return presentSemanticWorkResult(await remoteWorkspaces.write(executionWorkspaceId, input, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3538
3778
  }
3539
- return presentExecutionResult(await coreOperations.write({ workspaceId: executionWorkspaceId, ...input }, executionContext), target);
3779
+ return presentSemanticWorkResult(await coreOperations.write({ workspaceId: executionWorkspaceId, ...input }, executionContext), target);
3540
3780
  });
3541
3781
  registerAppTool(server, toolNames.edit, {
3542
3782
  title: "Edit file",
@@ -3587,12 +3827,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3587
3827
  const executionWorkspaceId = target.executionWorkspaceId;
3588
3828
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3589
3829
  if (remoteWorkspaces.has(executionWorkspaceId)) {
3590
- return presentExecutionResult(await remoteWorkspaces.edit(executionWorkspaceId, { path, paths, edits }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3830
+ return presentSemanticWorkResult(await remoteWorkspaces.edit(executionWorkspaceId, { path, paths, edits }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3591
3831
  }
3592
3832
  if (path !== undefined) {
3593
- return presentExecutionResult(await coreOperations.edit({ workspaceId: executionWorkspaceId, path, edits }, executionContext), target);
3833
+ return presentSemanticWorkResult(await coreOperations.edit({ workspaceId: executionWorkspaceId, path, edits }, executionContext), target);
3594
3834
  }
3595
- return presentExecutionResult(await nativeBulkMutations.edit({ workspaceId: executionWorkspaceId, paths: paths, edits }, executionContext), target);
3835
+ return presentSemanticWorkResult(await nativeBulkMutations.edit({ workspaceId: executionWorkspaceId, paths: paths, edits }, executionContext), target);
3596
3836
  });
3597
3837
  }
3598
3838
  registerAppTool(server, toolNames.rename, {
@@ -3616,9 +3856,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3616
3856
  const executionWorkspaceId = target.executionWorkspaceId;
3617
3857
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3618
3858
  if (remoteWorkspaces.has(executionWorkspaceId)) {
3619
- return presentExecutionResult(await remoteWorkspaces.rename(executionWorkspaceId, { path, newPath }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3859
+ return presentSemanticWorkResult(await remoteWorkspaces.rename(executionWorkspaceId, { path, newPath }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3620
3860
  }
3621
- return presentExecutionResult(await coreOperations.rename({ workspaceId: executionWorkspaceId, path, newPath }, executionContext), target);
3861
+ return presentSemanticWorkResult(await coreOperations.rename({ workspaceId: executionWorkspaceId, path, newPath }, executionContext), target);
3622
3862
  });
3623
3863
  registerAppTool(server, toolNames.delete, {
3624
3864
  title: "Delete path",
@@ -3659,12 +3899,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3659
3899
  const executionWorkspaceId = target.executionWorkspaceId;
3660
3900
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3661
3901
  if (remoteWorkspaces.has(executionWorkspaceId)) {
3662
- return presentExecutionResult(await remoteWorkspaces.delete(executionWorkspaceId, { path, paths, recursive }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3902
+ return presentSemanticWorkResult(await remoteWorkspaces.delete(executionWorkspaceId, { path, paths, recursive }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3663
3903
  }
3664
3904
  if (path !== undefined) {
3665
- return presentExecutionResult(await coreOperations.delete({ workspaceId: executionWorkspaceId, path, recursive }, executionContext), target);
3905
+ return presentSemanticWorkResult(await coreOperations.delete({ workspaceId: executionWorkspaceId, path, recursive }, executionContext), target);
3666
3906
  }
3667
- return presentExecutionResult(await nativeBulkMutations.delete({ workspaceId: executionWorkspaceId, paths: paths, recursive }, executionContext), target);
3907
+ return presentSemanticWorkResult(await nativeBulkMutations.delete({ workspaceId: executionWorkspaceId, paths: paths, recursive }, executionContext), target);
3668
3908
  });
3669
3909
  if (config.toolMode === "codex") {
3670
3910
  registerAppTool(server, "apply_patch", {
@@ -3695,7 +3935,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3695
3935
  const executionWorkspaceId = target.executionWorkspaceId;
3696
3936
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3697
3937
  if (remoteWorkspaces.has(executionWorkspaceId)) {
3698
- return presentExecutionResult(await remoteWorkspaces.applyPatch(executionWorkspaceId, { patch }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3938
+ return presentSemanticWorkResult(await remoteWorkspaces.applyPatch(executionWorkspaceId, { patch }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3699
3939
  }
3700
3940
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
3701
3941
  return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(extra._meta, extra.sessionId), activityRequestFor({ workspaceId: executionWorkspaceId, patch }, executionContext), {
@@ -3745,7 +3985,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3745
3985
  },
3746
3986
  };
3747
3987
  },
3748
- }, activityRelationFor(executionContext)).then((result) => presentExecutionResult(result, target));
3988
+ }, activityRelationFor(executionContext)).then((result) => presentSemanticWorkResult(result, target));
3749
3989
  });
3750
3990
  }
3751
3991
  if (config.toolMode !== "codex") {
@@ -3835,7 +4075,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3835
4075
  const executionWorkspaceId = target.executionWorkspaceId;
3836
4076
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3837
4077
  if (remoteWorkspaces.has(executionWorkspaceId)) {
3838
- return presentExecutionResult(await remoteWorkspaces.bash(executionWorkspaceId, {
4078
+ const response = await remoteWorkspaces.bash(executionWorkspaceId, {
3839
4079
  action,
3840
4080
  ...(command !== undefined ? { command } : {}),
3841
4081
  ...(processId !== undefined ? { processId } : {}),
@@ -3849,7 +4089,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3849
4089
  ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
3850
4090
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3851
4091
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
3852
- }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
4092
+ }, hostScopeIdFor(extra._meta, extra.sessionId));
4093
+ return action === "run"
4094
+ ? presentSemanticWorkResult(response, target)
4095
+ : presentExecutionResult(response, target);
3853
4096
  }
3854
4097
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
3855
4098
  if (action === "run") {
@@ -3858,7 +4101,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3858
4101
  if (processId !== undefined || outputId !== undefined || input !== undefined || interrupt !== undefined) {
3859
4102
  throw new Error("bash action=run does not accept processId, outputId, input, or interrupt.");
3860
4103
  }
3861
- return presentExecutionResult(await coreOperations.shellRun({
4104
+ return presentSemanticWorkResult(await coreOperations.shellRun({
3862
4105
  workspaceId: executionWorkspaceId,
3863
4106
  command,
3864
4107
  surface: "bash",
@@ -3952,6 +4195,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3952
4195
  resolve: resolveExecutionTarget,
3953
4196
  prepare: prepareExecutionContext,
3954
4197
  present: presentExecutionResult,
4198
+ presentSemantic: presentSemanticWorkResult,
3955
4199
  isRemote: (workspaceId) => remoteWorkspaces.has(workspaceId),
3956
4200
  execCommandRemote: (workspaceId, input, conversationScopeId) => remoteWorkspaces.execCommand(workspaceId, input, conversationScopeId),
3957
4201
  writeStdinRemote: (workspaceId, input, conversationScopeId) => remoteWorkspaces.writeStdin(workspaceId, input, conversationScopeId),
@@ -0,0 +1,42 @@
1
+ const TASK_REMINDER = "Reminder: this Workspace has unfinished active Tasks. Review workspace.tasks and update Task state when material progress, requirements, blockers, or conclusions changed.";
2
+ export class WorkspaceTaskReminderTracker {
3
+ interval;
4
+ tasks;
5
+ callsSinceUpdate = new Map();
6
+ constructor(interval, tasks) {
7
+ this.interval = interval;
8
+ this.tasks = tasks;
9
+ }
10
+ reset(workspaceId) {
11
+ this.callsSinceUpdate.delete(workspaceId);
12
+ }
13
+ forget(workspaceId) {
14
+ this.callsSinceUpdate.delete(workspaceId);
15
+ }
16
+ recordWork(workspaceId) {
17
+ if (this.interval === 0)
18
+ return undefined;
19
+ let summary;
20
+ try {
21
+ summary = this.tasks.readSummary(workspaceId);
22
+ }
23
+ catch {
24
+ // Reminder delivery is advisory. Invalid external Task state must still be
25
+ // surfaced by workspace.tasks itself, not turn unrelated work into failure.
26
+ this.callsSinceUpdate.delete(workspaceId);
27
+ return undefined;
28
+ }
29
+ const hasUnfinishedActiveTasks = summary.lists.some((list) => list.state === "active" && list.unfinishedTaskCount > 0);
30
+ if (!hasUnfinishedActiveTasks) {
31
+ this.callsSinceUpdate.delete(workspaceId);
32
+ return undefined;
33
+ }
34
+ const next = (this.callsSinceUpdate.get(workspaceId) ?? 0) + 1;
35
+ if (next < this.interval) {
36
+ this.callsSinceUpdate.set(workspaceId, next);
37
+ return undefined;
38
+ }
39
+ this.callsSinceUpdate.set(workspaceId, 0);
40
+ return TASK_REMINDER;
41
+ }
42
+ }