@akira-tl/forgerelay 0.8.4 → 0.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +10 -10
  3. package/dist/activity/audit-store.js +44 -6
  4. package/dist/activity/mcp-query-tools.js +53 -36
  5. package/dist/activity/query-service.js +41 -11
  6. package/dist/cli.js +16 -17
  7. package/dist/composite-activity.js +124 -33
  8. package/dist/config.js +8 -13
  9. package/dist/lsp/test-support/server-fixture.js +7 -7
  10. package/dist/process-sessions.js +2 -2
  11. package/dist/remote-auth.js +11 -1
  12. package/dist/remote-mcp-connection-pool.js +90 -0
  13. package/dist/remote-transport.js +28 -14
  14. package/dist/remote-workspace-relay.js +203 -45
  15. package/dist/server.js +134 -64
  16. package/dist/skills.js +1 -1
  17. package/dist/subagents/profiles.js +1 -2
  18. package/dist/subagents/providers/adapters/pi.js +2 -2
  19. package/dist/subagents/providers/availability.js +2 -2
  20. package/dist/subagents/providers/path.js +4 -4
  21. package/dist/ui/.vite/manifest.json +32 -32
  22. package/dist/ui/activity-panel-app.html +3 -3
  23. package/dist/ui/assets/{activity-panel-app-E1ju2dqI.js → activity-panel-app-CUAN6zyW.js} +1 -1
  24. package/dist/ui/assets/{heavy-payload-CeW-n9w5.js → heavy-payload-CgzrutLm.js} +1 -1
  25. package/dist/ui/assets/{review-payload-B9CO298v.js → review-payload-BrLbezbq.js} +1 -1
  26. package/dist/ui/assets/{scrollbar-C2twAENW.js → scrollbar-CbhpdW05.js} +1 -1
  27. package/dist/ui/assets/workspace-app-Bhj96tsR.js +1 -0
  28. package/dist/ui/assets/{workspace-app-CwbJnb_w.js → workspace-app-CxwJuZyS.js} +1 -1
  29. package/dist/ui/assets/{workspace-app-BztEvZIC.js → workspace-app-D6UR0AFl.js} +3 -3
  30. package/dist/ui/assets/workspace-app-ldjBmCJR.css +1 -0
  31. package/dist/ui/assets/workspace-lifecycle-app-Cqfhx9pV.js +1 -0
  32. package/dist/ui/workspace-app.html +4 -4
  33. package/dist/ui/workspace-lifecycle-app.html +4 -4
  34. package/dist/user-config.js +3 -19
  35. package/dist/workspace-presentation.js +69 -0
  36. package/docs/agent-profile-schema.md +4 -9
  37. package/docs/artifact-exchange.md +2 -1
  38. package/docs/chatgpt-coding-workflow.md +9 -9
  39. package/docs/configuration.md +20 -29
  40. package/docs/gotchas.md +10 -7
  41. package/docs/roadmap.md +6 -3
  42. package/docs/security.md +3 -2
  43. package/docs/setup.md +8 -5
  44. package/docs/versioning.md +1 -1
  45. package/package.json +6 -2
  46. package/scripts/debug/accept.mjs +7 -1
  47. package/scripts/debug/relay-accept.mjs +889 -0
  48. package/scripts/debug/runtime.mjs +0 -4
  49. package/scripts/debug/runtime.test.mjs +0 -2
  50. package/scripts/debug/traffic/run.sh +5 -0
  51. package/scripts/debug/traffic/traffic-audit.mjs +907 -0
  52. package/scripts/release/push-ready.mjs +124 -0
  53. package/scripts/release/push-ready.test.mjs +108 -0
  54. package/scripts/release/release-gate.test.mjs +1 -0
  55. package/scripts/release-proof.mjs +16 -1
  56. package/scripts/wiki/sync.mjs +246 -0
  57. package/dist/ui/assets/workspace-app-YnUST8IP.css +0 -1
  58. package/dist/ui/assets/workspace-app-rKuhdae8.js +0 -1
  59. package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +0 -1
@@ -2,7 +2,8 @@ import { randomBytes } from "node:crypto";
2
2
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { isRemoteMcpUnauthorized, refreshRemoteAuthentication, withRemoteMcpClient, } from "./remote-auth.js";
5
+ import { isRemoteMcpUnauthorized, refreshRemoteAuthentication, } from "./remote-auth.js";
6
+ import { RemoteMcpConnectionPool } from "./remote-mcp-connection-pool.js";
6
7
  import { withRemoteServiceEndpoint } from "./remote-transport.js";
7
8
  import { loadForgeRelayFiles, writeForgeRelayRemote, } from "./user-config.js";
8
9
  const ROUTE_LOCK_RETRY_MS = 10;
@@ -15,6 +16,7 @@ export class RemoteWorkspaceRelay {
15
16
  authEnv;
16
17
  routeStateDir;
17
18
  routeStatePath;
19
+ mcpConnections = new RemoteMcpConnectionPool();
18
20
  constructor(configDir, stateDir) {
19
21
  this.authEnv = { FORGERELAY_CONFIG_DIR: configDir };
20
22
  this.routeStateDir = stateDir;
@@ -27,20 +29,60 @@ export class RemoteWorkspaceRelay {
27
29
  this.loadRoutes();
28
30
  return this.routes.has(workspaceId);
29
31
  }
30
- inspectWorkspace(gatewayWorkspaceId) {
32
+ async shutdown() {
33
+ await this.mcpConnections.closeAll();
34
+ }
35
+ async inspectWorkspace(gatewayWorkspaceId) {
31
36
  const route = this.requireRoute(gatewayWorkspaceId);
32
37
  const resolved = this.remoteByInstance(route.remoteInstanceId);
33
- return {
38
+ let result;
39
+ try {
40
+ result = await this.callRemoteTool(resolved.alias, resolved.remote, "open_workspace", {
41
+ action: "inspect",
42
+ workspaceId: route.remoteWorkspaceId,
43
+ });
44
+ assertRemoteToolSucceeded(resolved.alias, "open_workspace", result);
45
+ }
46
+ catch (error) {
47
+ throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
48
+ }
49
+ const structured = result.structuredContent;
50
+ const remoteInspection = structured?.inspection;
51
+ if (!remoteInspection || remoteInspection.kind !== "workspace") {
52
+ throw new Error(`Remote ForgeRelay ${resolved.alias} inspection did not return a Workspace projection.`);
53
+ }
54
+ const root = stringField(remoteInspection, "root", "Remote Workspace inspection");
55
+ const mode = remoteInspection.mode;
56
+ if (mode !== "checkout" && mode !== "worktree") {
57
+ throw new Error(`Remote ForgeRelay ${resolved.alias} inspection did not return a valid Workspace mode.`);
58
+ }
59
+ const projection = {
34
60
  workspaceId: route.gatewayWorkspaceId,
35
61
  kind: "workspace",
36
62
  location: "relay",
37
- root: route.root,
63
+ root,
38
64
  routeState: "known",
39
- mode: route.mode,
40
- ...(route.sourceRoot ? { sourceRoot: route.sourceRoot } : {}),
65
+ mode,
41
66
  relay: resolved.alias,
42
67
  executionLocation: `remote:${resolved.alias}`,
43
68
  };
69
+ copyStringField(remoteInspection, projection, "status");
70
+ const state = remoteInspection.state;
71
+ if (state === "active" || state === "stale" || state === "invalid" || state === "closed") {
72
+ projection.state = state;
73
+ }
74
+ copyStringField(remoteInspection, projection, "sourceRoot");
75
+ copyStringField(remoteInspection, projection, "branch");
76
+ copyStringField(remoteInspection, projection, "targetBranch");
77
+ copyBooleanField(remoteInspection, projection, "managed");
78
+ copyStringField(remoteInspection, projection, "createdAt");
79
+ copyStringField(remoteInspection, projection, "lastUsedAt");
80
+ copyNumberField(remoteInspection, projection, "idleMs");
81
+ copyBooleanField(remoteInspection, projection, "rootValid");
82
+ const taskSummary = safeTaskSummary(remoteInspection.taskSummary);
83
+ if (taskSummary)
84
+ projection.taskSummary = taskSummary;
85
+ return projection;
44
86
  }
45
87
  async openWorkspace(alias, input, conversationScopeId) {
46
88
  const resolved = this.remoteByAlias(alias);
@@ -66,18 +108,15 @@ export class RemoteWorkspaceRelay {
66
108
  if (mode !== "checkout" && mode !== "worktree") {
67
109
  throw new Error("Remote open_workspace response did not include a valid workspace mode.");
68
110
  }
69
- const gatewayWorkspaceId = this.allocateGatewayWorkspaceId();
70
111
  const sourceRoot = typeof structured?.sourceRoot === "string" ? structured.sourceRoot : undefined;
71
- const route = {
72
- gatewayWorkspaceId,
112
+ const route = this.findOrCreateRoute({
73
113
  remoteInstanceId: resolved.remote.instanceId,
74
114
  remoteWorkspaceId,
75
115
  root,
76
116
  mode,
77
117
  ...(sourceRoot ? { sourceRoot } : {}),
78
- };
79
- this.routes.set(gatewayWorkspaceId, route);
80
- this.persistRoute(route);
118
+ });
119
+ const gatewayWorkspaceId = route.gatewayWorkspaceId;
81
120
  const remapContext = (value) => replaceExactWorkspaceId(value, remoteWorkspaceId, gatewayWorkspaceId);
82
121
  const remoteInstruction = typeof structured?.instruction === "string"
83
122
  ? String(remapContext(structured.instruction))
@@ -117,10 +156,24 @@ export class RemoteWorkspaceRelay {
117
156
  };
118
157
  }
119
158
  async resumeWorkspace(gatewayWorkspaceId, context = "auto", conversationScopeId) {
159
+ const route = this.requireRoute(gatewayWorkspaceId);
120
160
  const result = await this.callWorkspaceTool(gatewayWorkspaceId, "open_workspace", { context }, conversationScopeId);
121
161
  if (result.isError === true) {
122
162
  throw new Error(`Remote open_workspace failed: ${toolResultText(result)}`);
123
163
  }
164
+ const structured = result.structuredContent;
165
+ const root = typeof structured?.root === "string" ? structured.root : route.root;
166
+ const mode = structured?.mode === "checkout" || structured?.mode === "worktree"
167
+ ? structured.mode
168
+ : route.mode;
169
+ const sourceRoot = typeof structured?.sourceRoot === "string" ? structured.sourceRoot : route.sourceRoot;
170
+ this.findOrCreateRoute({
171
+ remoteInstanceId: route.remoteInstanceId,
172
+ remoteWorkspaceId: route.remoteWorkspaceId,
173
+ root,
174
+ mode,
175
+ ...(sourceRoot ? { sourceRoot } : {}),
176
+ });
124
177
  return result;
125
178
  }
126
179
  async read(gatewayWorkspaceId, input, conversationScopeId) {
@@ -187,11 +240,14 @@ export class RemoteWorkspaceRelay {
187
240
  throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
188
241
  }
189
242
  }
243
+ async activityIndex(turnId, knownRevision, conversationScopeId) {
244
+ return this.callTurnTool(turnId, "activity_index", { turnId, ...(knownRevision !== undefined ? { knownRevision } : {}) }, conversationScopeId);
245
+ }
190
246
  async activityDetail(turnId, activityId, conversationScopeId) {
191
247
  return this.callTurnTool(turnId, "activity_detail", { turnId, activityId }, conversationScopeId);
192
248
  }
193
- async activityOutput(turnId, outputId, conversationScopeId) {
194
- return this.callTurnTool(turnId, "activity_output", { turnId, outputId }, conversationScopeId);
249
+ async activityOutput(turnId, outputId, conversationScopeId, cursor) {
250
+ return this.callTurnTool(turnId, "activity_output", { turnId, outputId, ...(cursor !== undefined ? { cursor } : {}) }, conversationScopeId);
195
251
  }
196
252
  async callTurnTool(turnId, name, args, conversationScopeId) {
197
253
  const gatewayWorkspaceId = this.turnRoutes.get(turnId);
@@ -221,14 +277,16 @@ export class RemoteWorkspaceRelay {
221
277
  throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
222
278
  }
223
279
  }
224
- async closeWorkspace(gatewayWorkspaceId, commitMessage, conversationScopeId) {
280
+ async closeWorkspace(gatewayWorkspaceId, input = {}, conversationScopeId) {
225
281
  const route = this.requireRoute(gatewayWorkspaceId);
226
282
  const resolved = this.remoteByInstance(route.remoteInstanceId);
283
+ const action = input.action ?? "close";
227
284
  let result;
228
285
  try {
229
286
  result = await this.callRemoteTool(resolved.alias, resolved.remote, "close_workspace", {
230
287
  workspaceId: route.remoteWorkspaceId,
231
- ...(commitMessage !== undefined ? { commitMessage } : {}),
288
+ action,
289
+ ...(input.commitMessage !== undefined ? { commitMessage: input.commitMessage } : {}),
232
290
  }, conversationScopeId);
233
291
  assertRemoteToolSucceeded(resolved.alias, "close_workspace", result);
234
292
  }
@@ -236,21 +294,26 @@ export class RemoteWorkspaceRelay {
236
294
  throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
237
295
  }
238
296
  const remoteStructured = result.structuredContent;
239
- this.routes.delete(gatewayWorkspaceId);
240
- this.deletePersistedRoute(gatewayWorkspaceId);
297
+ if (action === "delete") {
298
+ this.routes.delete(gatewayWorkspaceId);
299
+ this.deletePersistedRoute(gatewayWorkspaceId);
300
+ }
241
301
  for (const [turnId, routedWorkspaceId] of this.turnRoutes) {
242
302
  if (routedWorkspaceId === gatewayWorkspaceId)
243
303
  this.turnRoutes.delete(turnId);
244
304
  }
305
+ const actionText = action === "delete" ? "Deleted" : "Closed";
245
306
  const text = route.mode === "worktree"
246
- ? `Closed relayed worktree workspace ${gatewayWorkspaceId} on remote ${resolved.alias}.`
247
- : `Closed relayed checkout workspace ${gatewayWorkspaceId} on remote ${resolved.alias}.`;
307
+ ? `${actionText} relayed worktree workspace ${gatewayWorkspaceId} on remote ${resolved.alias}.`
308
+ : `${actionText} relayed checkout workspace ${gatewayWorkspaceId} on remote ${resolved.alias}.`;
248
309
  const structuredContent = {
249
310
  result: text,
250
311
  workspaceId: gatewayWorkspaceId,
312
+ action,
251
313
  mode: route.mode,
252
314
  };
253
315
  for (const field of [
316
+ "status",
254
317
  "sourceRoot",
255
318
  "branch",
256
319
  "targetBranch",
@@ -270,6 +333,7 @@ export class RemoteWorkspaceRelay {
270
333
  tool: "close_workspace",
271
334
  card: {
272
335
  workspaceId: gatewayWorkspaceId,
336
+ action,
273
337
  mode: route.mode,
274
338
  payload: { content: [{ type: "text", text }] },
275
339
  },
@@ -292,10 +356,26 @@ export class RemoteWorkspaceRelay {
292
356
  this.routes.set(workspaceId, route);
293
357
  }
294
358
  }
295
- persistRoute(route) {
296
- this.updatePersistedRoutes((routes) => {
297
- routes.set(route.gatewayWorkspaceId, route);
359
+ findOrCreateRoute(input) {
360
+ let selected;
361
+ mkdirSync(this.routeStateDir, { recursive: true });
362
+ this.withRouteFileLock(() => {
363
+ const routes = this.readRoutesFromDisk();
364
+ const existing = [...routes.values()]
365
+ .filter((route) => route.remoteInstanceId === input.remoteInstanceId &&
366
+ route.remoteWorkspaceId === input.remoteWorkspaceId)
367
+ .sort((left, right) => left.gatewayWorkspaceId.localeCompare(right.gatewayWorkspaceId))[0];
368
+ selected = {
369
+ gatewayWorkspaceId: existing?.gatewayWorkspaceId ?? this.allocateGatewayWorkspaceId(routes),
370
+ ...input,
371
+ };
372
+ routes.set(selected.gatewayWorkspaceId, selected);
373
+ this.writeRoutesToDisk(routes);
298
374
  });
375
+ if (!selected)
376
+ throw new Error("Failed to persist relayed Workspace route.");
377
+ this.routes.set(selected.gatewayWorkspaceId, selected);
378
+ return selected;
299
379
  }
300
380
  deletePersistedRoute(workspaceId) {
301
381
  this.updatePersistedRoutes((routes) => {
@@ -392,42 +472,64 @@ export class RemoteWorkspaceRelay {
392
472
  return { alias: entry[0], remote: entry[1] };
393
473
  }
394
474
  async callRemoteTool(alias, initialRemote, name, args, conversationScopeId) {
395
- return withRemoteServiceEndpoint(initialRemote.target, initialRemote.sshRoute, async (endpoint) => {
396
- let remote = initialRemote;
397
- let refreshed = false;
398
- if (remote.accessTokenExpiresAt <= Math.floor(Date.now() / 1000)) {
399
- remote = await this.refreshRemote(alias, remote, endpoint);
475
+ let remote = initialRemote;
476
+ let refreshed = false;
477
+ if (remote.accessTokenExpiresAt <= Math.floor(Date.now() / 1000)) {
478
+ remote = await withRemoteServiceEndpoint(remote.target, remote.sshRoute, (endpoint) => this.refreshRemote(alias, remote, endpoint));
479
+ refreshed = true;
480
+ }
481
+ let connection;
482
+ try {
483
+ connection = await this.mcpConnections.get(remote);
484
+ }
485
+ catch (error) {
486
+ if (!refreshed && isRemoteMcpUnauthorized(error)) {
487
+ remote = await withRemoteServiceEndpoint(remote.target, remote.sshRoute, (endpoint) => this.refreshRemote(alias, remote, endpoint));
400
488
  refreshed = true;
489
+ connection = await this.mcpConnections.get(remote);
401
490
  }
402
- const invoke = () => withRemoteMcpClient(remote, endpoint, async (client) => CallToolResultSchema.parse(await client.callTool({
403
- name,
404
- arguments: args,
405
- ...(conversationScopeId
406
- ? { _meta: { "openai/session": conversationScopeId } }
407
- : {}),
408
- })));
409
- try {
410
- return await invoke();
491
+ else {
492
+ throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(error)}`, { cause: error });
411
493
  }
412
- catch (error) {
413
- if (!refreshed && isRemoteMcpUnauthorized(error)) {
414
- remote = await this.refreshRemote(alias, remote, endpoint);
415
- return invoke();
494
+ }
495
+ const invoke = async (active) => CallToolResultSchema.parse(await active.client.callTool({
496
+ name,
497
+ arguments: args,
498
+ ...(conversationScopeId
499
+ ? { _meta: { "openai/session": conversationScopeId } }
500
+ : {}),
501
+ }));
502
+ try {
503
+ return await invoke(connection);
504
+ }
505
+ catch (error) {
506
+ if (!refreshed && isRemoteMcpUnauthorized(error)) {
507
+ remote = await this.refreshRemote(alias, remote, connection.endpoint);
508
+ refreshed = true;
509
+ await this.mcpConnections.invalidate(remote.instanceId, connection);
510
+ const refreshedConnection = await this.mcpConnections.get(remote);
511
+ try {
512
+ return await invoke(refreshedConnection);
513
+ }
514
+ catch (retryError) {
515
+ await this.mcpConnections.invalidate(remote.instanceId, refreshedConnection);
516
+ throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(retryError)}`, { cause: retryError });
416
517
  }
417
- throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(error)}`, { cause: error });
418
518
  }
419
- });
519
+ await this.mcpConnections.invalidate(remote.instanceId, connection);
520
+ throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(error)}`, { cause: error });
521
+ }
420
522
  }
421
523
  async refreshRemote(alias, remote, endpoint) {
422
524
  const refreshed = await refreshRemoteAuthentication(remote, endpoint);
423
525
  writeForgeRelayRemote(alias, refreshed, this.authEnv);
424
526
  return refreshed;
425
527
  }
426
- allocateGatewayWorkspaceId() {
528
+ allocateGatewayWorkspaceId(routes = this.routes) {
427
529
  let workspaceId;
428
530
  do {
429
531
  workspaceId = `rws_${randomBytes(5).toString("hex")}`;
430
- } while (this.routes.has(workspaceId));
532
+ } while (routes.has(workspaceId));
431
533
  return workspaceId;
432
534
  }
433
535
  }
@@ -443,6 +545,62 @@ function stringField(structured, field, label) {
443
545
  }
444
546
  return value;
445
547
  }
548
+ function copyStringField(source, target, field) {
549
+ const value = source[field];
550
+ if (typeof value === "string")
551
+ target[field] = value;
552
+ }
553
+ function copyBooleanField(source, target, field) {
554
+ const value = source[field];
555
+ if (typeof value === "boolean")
556
+ target[field] = value;
557
+ }
558
+ function copyNumberField(source, target, field) {
559
+ const value = source[field];
560
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
561
+ target[field] = value;
562
+ }
563
+ function safeTaskSummary(value) {
564
+ if (!value || typeof value !== "object")
565
+ return undefined;
566
+ const summary = value;
567
+ if (summary.level !== "summary" ||
568
+ summary.version !== 1 ||
569
+ typeof summary.revision !== "number" ||
570
+ !Number.isInteger(summary.revision) ||
571
+ summary.revision < 0 ||
572
+ !Array.isArray(summary.lists)) {
573
+ return undefined;
574
+ }
575
+ const lists = [];
576
+ for (const value of summary.lists) {
577
+ if (!value || typeof value !== "object")
578
+ return undefined;
579
+ const list = value;
580
+ if (typeof list.id !== "string" ||
581
+ typeof list.name !== "string" ||
582
+ (list.state !== "active" && list.state !== "archived") ||
583
+ typeof list.revision !== "number" || !Number.isInteger(list.revision) || list.revision <= 0 ||
584
+ typeof list.taskCount !== "number" || !Number.isInteger(list.taskCount) || list.taskCount < 0 ||
585
+ typeof list.unfinishedTaskCount !== "number" || !Number.isInteger(list.unfinishedTaskCount) || list.unfinishedTaskCount < 0) {
586
+ return undefined;
587
+ }
588
+ lists.push({
589
+ id: list.id,
590
+ name: list.name,
591
+ state: list.state,
592
+ revision: list.revision,
593
+ taskCount: list.taskCount,
594
+ unfinishedTaskCount: list.unfinishedTaskCount,
595
+ });
596
+ }
597
+ return {
598
+ level: "summary",
599
+ version: 1,
600
+ revision: summary.revision,
601
+ lists,
602
+ };
603
+ }
446
604
  function toolResultText(result) {
447
605
  return (result.content ?? [])
448
606
  .filter((entry) => entry.type === "text")