@akira-tl/forgerelay 0.5.6 → 0.6.0

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 (152) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +7 -3
  3. package/capabilities/host-integration/GUIDE.md +15 -9
  4. package/dist/activity/host-turn-store.js +9 -7
  5. package/dist/activity/lifecycle.js +1 -1
  6. package/dist/activity/mcp-query-tools.js +110 -18
  7. package/dist/activity/query-service.js +4 -4
  8. package/dist/activity/ui/contract.js +2 -0
  9. package/dist/cli.js +47 -32
  10. package/dist/config.js +31 -3
  11. package/dist/db/migrations.js +30 -0
  12. package/dist/db/schema.js +2 -0
  13. package/dist/logger.js +6 -5
  14. package/dist/lsp/test-support/server-fixture.js +1 -1
  15. package/dist/mcp/server-instructions.js +7 -6
  16. package/dist/mcp-app-template.js +108 -12
  17. package/dist/oauth/public-url.js +14 -0
  18. package/dist/oauth/router.js +49 -0
  19. package/dist/process-sessions.js +2 -1
  20. package/dist/request-meta.js +4 -0
  21. package/dist/server.js +250 -84
  22. package/dist/skills.js +58 -1
  23. package/dist/ui/.vite/manifest.json +360 -312
  24. package/dist/ui/activity-panel-app.html +17 -0
  25. package/dist/ui/assets/activity-panel-app-CjZVvVNc.js +2 -0
  26. package/dist/ui/assets/angular-html-BkgDQl-G.js +1 -0
  27. package/dist/ui/assets/{angular-ts-BwPE8YDt.js → angular-ts-Bxh4-O1-.js} +1 -1
  28. package/dist/ui/assets/{apl-CGbONVvU.js → apl-BmYHKQ6m.js} +1 -1
  29. package/dist/ui/assets/{astro-DIAPy7cj.js → astro-C51vqDKq.js} +1 -1
  30. package/dist/ui/assets/{blade-Cgtd19Ky.js → blade-CdhVsmwq.js} +1 -1
  31. package/dist/ui/assets/c-IBzBpxAO.js +1 -0
  32. package/dist/ui/assets/chunk-EyZ2wyi3.js +1 -0
  33. package/dist/ui/assets/{cobol-G-7HLVKW.js → cobol-Cf6azoN_.js} +1 -1
  34. package/dist/ui/assets/{coffee-DQFyKxEB.js → coffee-vVdGzlmt.js} +1 -1
  35. package/dist/ui/assets/cpp-CVHeO-Jk.js +1 -0
  36. package/dist/ui/assets/{crystal-DuiWhEO3.js → crystal-5UZy6KIX.js} +1 -1
  37. package/dist/ui/assets/css-DEfus9J5.js +1 -0
  38. package/dist/ui/assets/{edge-COkalhFi.js → edge-CESgo4ia.js} +1 -1
  39. package/dist/ui/assets/{elixir-cdlzbYR1.js → elixir-9FKfw4cJ.js} +1 -1
  40. package/dist/ui/assets/{elm-ZwDYuYpb.js → elm-Dv7A0aLa.js} +1 -1
  41. package/dist/ui/assets/{erb-C8WG9TtC.js → erb-DHFmDvM0.js} +1 -1
  42. package/dist/ui/assets/{git-rebase-CMlDVJBT.js → git-rebase-utbU6CzV.js} +1 -1
  43. package/dist/ui/assets/{glimmer-js-DeLOQlol.js → glimmer-js-t2kiCkyB.js} +1 -1
  44. package/dist/ui/assets/{glimmer-ts-DTEM04TK.js → glimmer-ts-F0Jv5okV.js} +1 -1
  45. package/dist/ui/assets/glsl-_ooCjaeB.js +1 -0
  46. package/dist/ui/assets/graphql-B9LqP-cY.js +1 -0
  47. package/dist/ui/assets/{hack-C14sh6Lh.js → hack-8yjdbaSA.js} +1 -1
  48. package/dist/ui/assets/haml-BU5EoZBn.js +1 -0
  49. package/dist/ui/assets/{handlebars-eS-J0O-M.js → handlebars-BBFEg4Lo.js} +1 -1
  50. package/dist/ui/assets/{heavy-payload-BKB0QDOI.js → heavy-payload-vGgBRvNX.js} +1 -1
  51. package/dist/ui/assets/html-D-C6YZk4.js +1 -0
  52. package/dist/ui/assets/{html-derivative-BlVZU2id.js → html-derivative-DJAQcZyc.js} +1 -1
  53. package/dist/ui/assets/{http-BmbZzA5x.js → http-Dg5LSYRK.js} +1 -1
  54. package/dist/ui/assets/{hurl-Dnd_YpOD.js → hurl-Y5uYzMZR.js} +1 -1
  55. package/dist/ui/assets/java-ppHvycj5.js +1 -0
  56. package/dist/ui/assets/javascript-DDTjKLlQ.js +1 -0
  57. package/dist/ui/assets/{jinja-DTvkZdHA.js → jinja-Bt30h8Ik.js} +1 -1
  58. package/dist/ui/assets/{jison-DUJG-tmh.js → jison-D5uZ1Nyo.js} +1 -1
  59. package/dist/ui/assets/json-Cw91r8h1.js +1 -0
  60. package/dist/ui/assets/jsx-04X2FT_1.js +1 -0
  61. package/dist/ui/assets/{julia-P5B4pHFa.js → julia-76hRaaGz.js} +1 -1
  62. package/dist/ui/assets/{just-DYaoh7C6.js → just-CDE7W0vW.js} +1 -1
  63. package/dist/ui/assets/{latex-CI5lnkWV.js → latex-CVO2Byab.js} +1 -1
  64. package/dist/ui/assets/{liquid-D3LnAs_z.js → liquid-CA-AS3EX.js} +1 -1
  65. package/dist/ui/assets/lua-CiyBp52Y.js +1 -0
  66. package/dist/ui/assets/{marko-CRDrjqfS.js → marko-_BWrPfny.js} +1 -1
  67. package/dist/ui/assets/{mdc-ZzwgmJVn.js → mdc-PyJv85eS.js} +1 -1
  68. package/dist/ui/assets/{nginx-BAvFXGSV.js → nginx-DAWVw8yu.js} +1 -1
  69. package/dist/ui/assets/{nim-BHIFawe9.js → nim-CKE1dzpU.js} +1 -1
  70. package/dist/ui/assets/{perl-C_f5AnLa.js → perl-BBhWva19.js} +1 -1
  71. package/dist/ui/assets/{php-DDqpegL5.js → php-ChIeaAoF.js} +1 -1
  72. package/dist/ui/assets/{pug-CSaAZXyd.js → pug-CMgI5afi.js} +1 -1
  73. package/dist/ui/assets/{qml-CNHvRs_z.js → qml-CjxTkcFH.js} +1 -1
  74. package/dist/ui/assets/r-DfFga4si.js +1 -0
  75. package/dist/ui/assets/{razor-BDfAPy7I.js → razor-i4DbHr8p.js} +1 -1
  76. package/dist/ui/assets/regexp-iC9_nbSI.js +1 -0
  77. package/dist/ui/assets/{review-payload-D7GIdYDj.js → review-payload-4erWKckt.js} +1 -1
  78. package/dist/ui/assets/{rst-BzPP2gle.js → rst-oy_97Kl4.js} +1 -1
  79. package/dist/ui/assets/{ruby-CQjzokzX.js → ruby-C5zYZIyl.js} +1 -1
  80. package/dist/ui/assets/{sas-Bp37KGr7.js → sas-BnMEh2S3.js} +1 -1
  81. package/dist/ui/assets/{scrollbar-HUMBdLLK.js → scrollbar-CaOPzUJd.js} +6 -6
  82. package/dist/ui/assets/scss-BfZXsAWQ.js +1 -0
  83. package/dist/ui/assets/shellscript-BC2srNaB.js +1 -0
  84. package/dist/ui/assets/{shellsession-CNc62UhY.js → shellsession-BpE5o7_3.js} +1 -1
  85. package/dist/ui/assets/{soy-CF44SpBo.js → soy-COesmhTY.js} +1 -1
  86. package/dist/ui/assets/sql-Ccv_yeUm.js +1 -0
  87. package/dist/ui/assets/{stata-DVeMA68W.js → stata-CGQEuSZq.js} +1 -1
  88. package/dist/ui/assets/{surrealql-52sF96Yv.js → surrealql-Bz4JbU-9.js} +1 -1
  89. package/dist/ui/assets/{svelte-ydwT7UbF.js → svelte-1jM2ejXj.js} +1 -1
  90. package/dist/ui/assets/{templ-BXcoVoGh.js → templ-C-VwELcp.js} +1 -1
  91. package/dist/ui/assets/{tex-COsYFp7O.js → tex-t1rUiQCm.js} +1 -1
  92. package/dist/ui/assets/{ts-tags-CF--LE1s.js → ts-tags-Bewoa6pS.js} +1 -1
  93. package/dist/ui/assets/tsx-CTXzrBkN.js +1 -0
  94. package/dist/ui/assets/{twig-YoM-DiI5.js → twig-DVg-vELl.js} +1 -1
  95. package/dist/ui/assets/typescript--ElHoFkw.js +1 -0
  96. package/dist/ui/assets/{vue-html-CPdGGk4T.js → vue-html-BHtJ8geX.js} +1 -1
  97. package/dist/ui/assets/{vue-vine-B5bjgk0a.js → vue-vine-CNSGAWwc.js} +1 -1
  98. package/dist/ui/assets/{vue-B5LDEy4l.js → vue-wPQ7UlMy.js} +1 -1
  99. package/dist/ui/assets/workspace-app-CcrHAUIn.css +1 -0
  100. package/dist/ui/assets/workspace-app-DJmkPYJC.js +1 -0
  101. package/dist/ui/assets/workspace-app-DkAiSl_0.js +111 -0
  102. package/dist/ui/assets/workspace-app-QyauBrJX.js +5 -0
  103. package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +1 -0
  104. package/dist/ui/assets/xml-JnmX6vyS.js +1 -0
  105. package/dist/ui/assets/{xsl-wls_CEK_.js → xsl-3_vJ27aE.js} +1 -1
  106. package/dist/ui/assets/yaml-OAcZkP-w.js +1 -0
  107. package/dist/ui/workspace-app.html +5 -2
  108. package/dist/ui/workspace-lifecycle-app.html +18 -0
  109. package/dist/workspaces.js +11 -0
  110. package/docs/configuration.md +36 -4
  111. package/docs/debugging.md +75 -17
  112. package/docs/gotchas.md +15 -9
  113. package/docs/setup.md +24 -10
  114. package/package.json +4 -3
  115. package/scripts/debug/accept.mjs +79 -27
  116. package/scripts/debug/host/run.sh +180 -0
  117. package/scripts/debug/host/serve.mjs +64 -0
  118. package/scripts/debug/runtime.mjs +57 -2
  119. package/scripts/debug/runtime.test.mjs +74 -0
  120. package/scripts/debug/serve.mjs +4 -5
  121. package/scripts/dev-server.mjs +32 -3
  122. package/scripts/release/release-gate.test.mjs +43 -0
  123. package/scripts/release/release-version.test.mjs +107 -0
  124. package/scripts/release-parity.mjs +70 -46
  125. package/scripts/release-proof.mjs +2 -2
  126. package/scripts/release-proof.test.mjs +18 -0
  127. package/scripts/release-version.mjs +17 -8
  128. package/dist/ui/assets/angular-html-BT2Z6jee.js +0 -1
  129. package/dist/ui/assets/c-acnvZ8tm.js +0 -1
  130. package/dist/ui/assets/cpp-UyrmHXan.js +0 -1
  131. package/dist/ui/assets/css-D_WCbY6m.js +0 -1
  132. package/dist/ui/assets/glsl-IJV4SB-M.js +0 -1
  133. package/dist/ui/assets/graphql-PM-WDnoB.js +0 -1
  134. package/dist/ui/assets/haml-B_5nS6Kx.js +0 -1
  135. package/dist/ui/assets/html-C3TPjrC5.js +0 -1
  136. package/dist/ui/assets/java-ne9HdIVn.js +0 -1
  137. package/dist/ui/assets/javascript-DpHT58rr.js +0 -1
  138. package/dist/ui/assets/json-DJmnBdxW.js +0 -1
  139. package/dist/ui/assets/jsx-BclJnZnN.js +0 -1
  140. package/dist/ui/assets/lua-dZC7ZQIY.js +0 -1
  141. package/dist/ui/assets/r-CAfoQND2.js +0 -1
  142. package/dist/ui/assets/regexp-Mug5jltQ.js +0 -1
  143. package/dist/ui/assets/scss-C_Z1DYnw.js +0 -1
  144. package/dist/ui/assets/shellscript-CLfJpn3I.js +0 -1
  145. package/dist/ui/assets/sql-CYnzfiQ1.js +0 -1
  146. package/dist/ui/assets/tsx-DLBt2-2f.js +0 -1
  147. package/dist/ui/assets/typescript-Ce9JGkNI.js +0 -1
  148. package/dist/ui/assets/workspace-app-Cm9EX5x4.js +0 -117
  149. package/dist/ui/assets/workspace-app-DHvE2ojW.css +0 -1
  150. package/dist/ui/assets/xml-Cun8xb0T.js +0 -1
  151. package/dist/ui/assets/yaml-BxuXn9OL.js +0 -1
  152. package/scripts/release/tag-local-ci.mjs +0 -144
@@ -120,7 +120,7 @@ try {
120
120
  assert.match(serverInstructions, /Shell commands may modify ordinary project files/);
121
121
  assert.match(serverInstructions, /\/etc\/sudoers/);
122
122
  assert.match(serverInstructions, /configuration files through shell only when the user's request explicitly calls for that configuration change/);
123
- assert.match(serverInstructions, /managed-worktrees capability guide/);
123
+ assert.match(serverInstructions, /Project-work order: open_workspace if needed → activity_panel\(workspaceId\) once → work tools/);
124
124
  assert.ok(serverInstructions.length < 3_000, `server instructions should stay compact, got ${serverInstructions.length} characters`);
125
125
  assert.doesNotMatch(serverInstructions, /fast-forwards the original target branch/);
126
126
  assert.doesNotMatch(serverInstructions, /Do not create or modify files with bash/);
@@ -146,6 +146,10 @@ try {
146
146
  const toolNames = tools.map((tool) => tool.name);
147
147
  assert.deepEqual(toolNames, [
148
148
  "open_workspace",
149
+ "activity_panel",
150
+ "activity_snapshot",
151
+ "activity_detail",
152
+ "activity_output",
149
153
  "capability",
150
154
  "close_workspace",
151
155
  "read",
@@ -176,14 +180,13 @@ try {
176
180
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityGuides);
177
181
  const capabilityTool = tools.find((tool) => tool.name === "capability");
178
182
  assert.deepEqual(capabilityTool?._meta?.["openai/fileParams"], ["file"]);
179
- const templateUri = capabilityTool?._meta?.ui?.resourceUri;
183
+ const activityPanelTool = tools.find((tool) => tool.name === "activity_panel");
184
+ const activityTemplateUri = activityPanelTool?._meta?.ui?.resourceUri;
180
185
  assert.match(
181
- templateUri ?? "",
182
- /^ui:\/\/forgerelay\/workspace-app-[0-9a-f]{12}\.html$/,
183
- JSON.stringify(bashTool ?? {}),
186
+ activityTemplateUri ?? "",
187
+ /^ui:\/\/forgerelay\/activity-panel-app-[0-9a-f]{12}\.html$/,
184
188
  );
185
- assert.deepEqual(capabilityTool?._meta?.ui?.visibility, ["model", "app"]);
186
- assert.equal(capabilityTool?._meta?.["openai/outputTemplate"], templateUri);
189
+ assert.deepEqual(activityPanelTool?._meta?.ui?.visibility, ["model", "app"]);
187
190
  pass("MCP tools/list", `${toolNames.length} tools: ${toolNames.join(", ")}`);
188
191
 
189
192
  const resources = mcpRequest(oauth.accessToken, sessionId, {
@@ -192,9 +195,13 @@ try {
192
195
  method: "resources/list",
193
196
  params: {},
194
197
  }).message.result.resources;
195
- const currentResource = resources.find((resource) => resource.uri === templateUri);
196
- assert.ok(currentResource);
197
- assert.equal(currentResource._meta?.ui?.domain, debugBaseUrl);
198
+ const currentActivityResource = resources.find((resource) => resource.uri === activityTemplateUri);
199
+ assert.ok(currentActivityResource);
200
+ assert.equal(currentActivityResource._meta?.ui?.domain, debugBaseUrl);
201
+ assert.equal(
202
+ resources.some((resource) => /^ui:\/\/forgerelay\/workspace-lifecycle-app-/.test(resource.uri)),
203
+ false,
204
+ );
198
205
  assert.ok(resources.some((resource) => resource.uri === "ui://forgerelay/workspace-app.html"));
199
206
 
200
207
  const resourceTemplates = mcpRequest(oauth.accessToken, sessionId, {
@@ -204,7 +211,10 @@ try {
204
211
  params: {},
205
212
  }).message.result.resourceTemplates;
206
213
  assert.ok(resourceTemplates.some(
207
- (resourceTemplate) => resourceTemplate.uriTemplate === "ui://forgerelay/workspace-app-{revision}.html",
214
+ (resourceTemplate) => resourceTemplate.uriTemplate === "ui://forgerelay/workspace-lifecycle-app-{revision}.html",
215
+ ));
216
+ assert.ok(resourceTemplates.some(
217
+ (resourceTemplate) => resourceTemplate.uriTemplate === "ui://forgerelay/activity-panel-app-{revision}.html",
208
218
  ));
209
219
 
210
220
  const readTemplate = (id, uri) => mcpRequest(oauth.accessToken, sessionId, {
@@ -214,28 +224,21 @@ try {
214
224
  params: { uri },
215
225
  }).message.result.contents[0];
216
226
 
217
- const template = readTemplate(23, templateUri);
218
- assert.equal(template.uri, templateUri);
219
- assert.equal(template.mimeType, "text/html;profile=mcp-app");
220
- assert.match(template.text ?? "", /<script type="module" crossorigin src="[^"]+\/mcp-app-assets\//);
221
- assert.equal(template._meta?.ui?.domain, debugBaseUrl);
222
- assert.ok(template._meta?.ui?.csp?.resourceDomains?.includes(debugBaseUrl));
223
- const scriptUrl = template.text?.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
227
+ const activityTemplate = readTemplate(24, activityTemplateUri);
228
+ assert.equal(activityTemplate.uri, activityTemplateUri);
229
+ assert.equal(activityTemplate.mimeType, "text/html;profile=mcp-app");
230
+ assert.match(activityTemplate.text ?? "", /activity-panel-app-[^\"]+\.js/);
231
+ assert.equal(activityTemplate._meta?.ui?.domain, debugBaseUrl);
232
+ assert.ok(activityTemplate._meta?.ui?.csp?.resourceDomains?.includes(debugBaseUrl));
233
+ const scriptUrl = activityTemplate.text?.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
224
234
  assert.ok(scriptUrl);
225
235
  const scriptAsset = curlRequest({ method: "GET", url: scriptUrl });
226
236
  assert.equal(scriptAsset.status, 200, scriptAsset.body);
227
237
 
228
- const legacyTemplate = readTemplate(24, "ui://forgerelay/workspace-app.html");
238
+ const legacyTemplate = readTemplate(25, "ui://forgerelay/workspace-app.html");
229
239
  assert.equal(legacyTemplate.uri, "ui://forgerelay/workspace-app.html");
230
240
  assert.equal(legacyTemplate.mimeType, "text/html;profile=mcp-app");
231
- assert.equal(legacyTemplate.text, template.text);
232
-
233
- const historicalUri = "ui://forgerelay/workspace-app-0.2.4.html";
234
- const historicalTemplate = readTemplate(25, historicalUri);
235
- assert.equal(historicalTemplate.uri, historicalUri);
236
- assert.equal(historicalTemplate.mimeType, "text/html;profile=mcp-app");
237
- assert.equal(historicalTemplate.text, template.text);
238
- pass("MCP app template", `${templateUri} + legacy/history compatibility -> ${scriptUrl}`);
241
+ pass("MCP app templates", `${activityTemplateUri} + historical compatibility templates`);
239
242
 
240
243
  const workspaceConversationMeta = { "openai/session": "acceptance-workspace" };
241
244
  const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
@@ -259,6 +262,7 @@ try {
259
262
  "hooks.lifecycle",
260
263
  "capability-guides.read",
261
264
  "code.intelligence",
265
+ "batch.execute",
262
266
  ...(process.platform === "linux" ? ["artifact.native-download"] : []),
263
267
  "ui.mcp-app",
264
268
  "review.changes",
@@ -269,6 +273,7 @@ try {
269
273
  "hooks.check",
270
274
  "review.changes",
271
275
  "code.intelligence",
276
+ "batch.execute",
272
277
  ...(process.platform === "linux" ? ["artifact.download"] : []),
273
278
  ]);
274
279
  assert.equal(capabilityCatalog[0].available, true);
@@ -398,6 +403,7 @@ try {
398
403
  "host-integration",
399
404
  "shell-processes",
400
405
  "code-intelligence",
406
+ "batch-execution",
401
407
  ]);
402
408
  const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
403
409
  workspaceId,
@@ -407,6 +413,52 @@ try {
407
413
  assert.match(hooksGuide.structuredContent.result, /BeforeWorktreeClose/);
408
414
  pass("open_workspace", `${workspaceId} -> ${capabilityCatalog.length} capabilities + ${capabilityGuides.length} capability guides`);
409
415
 
416
+ const unifiedPanel = callTool(oauth.accessToken, sessionId, 89, "activity_panel", {
417
+ workspaceId,
418
+ }, workspaceConversationMeta);
419
+ assert.equal(unifiedPanel.isError, undefined);
420
+ assert.equal(
421
+ unifiedPanel._meta?.["forgerelay/activityPanelWorkspace"]?.workspaceId,
422
+ workspaceId,
423
+ );
424
+ assert.equal(
425
+ unifiedPanel._meta?.["forgerelay/activityPanelWorkspace"]?.root,
426
+ checkoutWorkspace,
427
+ );
428
+ pass("unified ForgeRelay Panel", `${workspaceId} -> Workspace + Activity`);
429
+
430
+ const inspectorActivityPath = join(checkoutWorkspace, "inspector-activity.txt");
431
+ writeFileSync(inspectorActivityPath, "inspector transport-scoped activity\n");
432
+ try {
433
+ const inspectorPanel = callTool(oauth.accessToken, sessionId, 90, "activity_panel", {
434
+ workspaceId,
435
+ });
436
+ const inspectorTurnId = inspectorPanel.structuredContent.turnId;
437
+ const inspectorRead = callTool(oauth.accessToken, sessionId, 91, "read", {
438
+ workspaceId,
439
+ path: "inspector-activity.txt",
440
+ offset: 1,
441
+ limit: 2,
442
+ });
443
+ assert.equal(inspectorRead.isError, undefined);
444
+ const inspectorSnapshot = callTool(oauth.accessToken, sessionId, 92, "activity_snapshot", {
445
+ turnId: inspectorTurnId,
446
+ });
447
+ assert.equal(inspectorSnapshot.isError, undefined);
448
+ assert.ok(inspectorSnapshot.structuredContent.revision > 0);
449
+ assert.deepEqual(
450
+ inspectorSnapshot.structuredContent.activities.map(({ tool, workspaceId: activityWorkspaceId, target }) => ({
451
+ tool,
452
+ workspaceId: activityWorkspaceId,
453
+ target,
454
+ })),
455
+ [{ tool: "read", workspaceId, target: "inspector-activity.txt" }],
456
+ );
457
+ pass("Inspector-style Activity scope", `${sessionId} -> ${inspectorTurnId} -> read captured`);
458
+ } finally {
459
+ rmSync(inspectorActivityPath, { force: true });
460
+ }
461
+
410
462
  const written = callTool(oauth.accessToken, sessionId, 4, "write", {
411
463
  workspaceId,
412
464
  path: "acceptance.txt",
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
5
+ cd "$repo_root"
6
+
7
+ debug_host_port="${FORGERELAY_DEBUG_HOST_PORT:-7677}"
8
+ inspector_client_port="${MCP_INSPECTOR_CLIENT_PORT:-6274}"
9
+ inspector_sandbox_port="${MCP_INSPECTOR_SANDBOX_PORT:-6275}"
10
+ for port_value in "$debug_host_port" "$inspector_client_port" "$inspector_sandbox_port"; do
11
+ if [[ ! "$port_value" =~ ^[0-9]+$ ]] || (( port_value < 1 || port_value > 65535 )); then
12
+ echo "[forgerelay:debug-host] debug/Inspector ports must be integers from 1 to 65535." >&2
13
+ exit 1
14
+ fi
15
+ done
16
+
17
+ health_url="http://127.0.0.1:${debug_host_port}/healthz"
18
+ mcp_url="http://127.0.0.1:${debug_host_port}/mcp"
19
+ inspector_url="http://127.0.0.1:${inspector_client_port}"
20
+ inspector_package="${MCP_INSPECTOR_PACKAGE:-@modelcontextprotocol/inspector@2.3.0}"
21
+ inspector_node_dir="${MCP_INSPECTOR_NODE_DIR:-}"
22
+
23
+ if [[ -z "$inspector_node_dir" ]]; then
24
+ current_node_major="$(node -p 'Number(process.versions.node.split(".")[0])')"
25
+ if (( current_node_major >= 25 )) && [[ -d "$HOME/.nvm/versions/node" ]]; then
26
+ inspector_node_dir="$(find "$HOME/.nvm/versions/node" -mindepth 1 -maxdepth 1 -type d -name 'v24.*' -printf '%p\n' 2>/dev/null | sort -V | tail -n 1)/bin"
27
+ if [[ "$inspector_node_dir" == "/bin" || ! -x "$inspector_node_dir/node" || ! -x "$inspector_node_dir/npx" ]]; then
28
+ inspector_node_dir=""
29
+ fi
30
+ fi
31
+ fi
32
+
33
+ inspector_path="$PATH"
34
+ if [[ -n "$inspector_node_dir" ]]; then
35
+ if [[ ! -x "$inspector_node_dir/node" || ! -x "$inspector_node_dir/npx" ]]; then
36
+ echo "[forgerelay:debug-host] MCP_INSPECTOR_NODE_DIR must contain executable node and npx binaries." >&2
37
+ exit 1
38
+ fi
39
+ inspector_path="$inspector_node_dir:$PATH"
40
+ fi
41
+
42
+ if ! PATH="$inspector_path" command -v npx >/dev/null 2>&1; then
43
+ echo "[forgerelay:debug-host] npx is required to launch MCP Inspector." >&2
44
+ exit 1
45
+ fi
46
+
47
+ port_in_use() {
48
+ ss -H -ltn "sport = :$1" 2>/dev/null | grep -q .
49
+ }
50
+
51
+ if port_in_use "$debug_host_port"; then
52
+ cat >&2 <<EOF
53
+ [forgerelay:debug-host] port ${debug_host_port} is already in use.
54
+ [forgerelay:debug-host] refusing to replace or reuse that process because its publicBaseUrl/widgets may not match local Host mode.
55
+ [forgerelay:debug-host] stop it, or temporarily set FORGERELAY_DEBUG_HOST_PORT to another loopback port.
56
+ EOF
57
+ exit 1
58
+ fi
59
+
60
+ for inspector_port in "$inspector_client_port" "$inspector_sandbox_port"; do
61
+ if port_in_use "$inspector_port"; then
62
+ echo "[forgerelay:debug-host] Inspector port ${inspector_port} is already in use; refusing to replace that process." >&2
63
+ exit 1
64
+ fi
65
+ done
66
+
67
+ log_dir="$repo_root/logs"
68
+ server_log="$log_dir/debug-host-server.log"
69
+ inspector_log="$log_dir/debug-host-inspector.log"
70
+ mkdir -p "$log_dir"
71
+ : >"$server_log"
72
+ : >"$inspector_log"
73
+
74
+ server_pid=""
75
+ inspector_pid=""
76
+ cleanup() {
77
+ local status=$?
78
+ trap - EXIT INT TERM
79
+ if [[ -n "$inspector_pid" ]] && kill -0 "$inspector_pid" >/dev/null 2>&1; then
80
+ kill "$inspector_pid" >/dev/null 2>&1 || true
81
+ wait "$inspector_pid" >/dev/null 2>&1 || true
82
+ fi
83
+ if [[ -n "$server_pid" ]] && kill -0 "$server_pid" >/dev/null 2>&1; then
84
+ kill "$server_pid" >/dev/null 2>&1 || true
85
+ wait "$server_pid" >/dev/null 2>&1 || true
86
+ fi
87
+ exit "$status"
88
+ }
89
+ trap cleanup EXIT INT TERM
90
+
91
+ FORGERELAY_DEBUG_HOST_PORT="$debug_host_port" \
92
+ node scripts/debug/host/serve.mjs > >(tee -a "$server_log") 2>&1 &
93
+ server_pid=$!
94
+
95
+ ready=0
96
+ for _ in $(seq 1 120); do
97
+ if curl --silent --fail --max-time 1 "$health_url" >/dev/null 2>&1; then
98
+ ready=1
99
+ break
100
+ fi
101
+ if ! kill -0 "$server_pid" >/dev/null 2>&1; then
102
+ echo "[forgerelay:debug-host] ForgeRelay debug server exited before becoming ready." >&2
103
+ echo "[forgerelay:debug-host] see $server_log" >&2
104
+ exit 1
105
+ fi
106
+ sleep 0.25
107
+ done
108
+
109
+ if [[ "$ready" != "1" ]]; then
110
+ echo "[forgerelay:debug-host] ForgeRelay debug server did not become ready on ${debug_host_port}." >&2
111
+ echo "[forgerelay:debug-host] see $server_log" >&2
112
+ exit 1
113
+ fi
114
+
115
+ inspector_token="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
116
+
117
+ echo "[forgerelay:debug-host] ForgeRelay ready: $mcp_url"
118
+ echo "[forgerelay:debug-host] launching MCP Inspector Web Host via $inspector_package"
119
+ if [[ -n "$inspector_node_dir" ]]; then
120
+ inspector_node_version="$(PATH="$inspector_path" node --version)"
121
+ echo "[forgerelay:debug-host] Inspector compatibility runtime: $inspector_node_version ($inspector_node_dir)"
122
+ fi
123
+ echo "[forgerelay:debug-host] complete OAuth with the persisted debug Owner password when prompted."
124
+ echo "[forgerelay:debug-host] logs: $server_log and $inspector_log (Inspector tokens are redacted from the log)"
125
+
126
+ HOST=127.0.0.1 \
127
+ CLIENT_PORT="$inspector_client_port" \
128
+ MCP_SANDBOX_PORT="$inspector_sandbox_port" \
129
+ MCP_AUTO_OPEN_ENABLED=false \
130
+ MCP_INSPECTOR_API_TOKEN="$inspector_token" \
131
+ PATH="$inspector_path" \
132
+ npx -y "$inspector_package" --web --server-url "$mcp_url" --transport http \
133
+ > >(tee >(sed -E \
134
+ -e 's/(MCP_INSPECTOR_API_TOKEN=)[[:alnum:]]+/\1<redacted>/g' \
135
+ -e 's/(Auth token: )[[:alnum:]]+/\1<redacted>/g' \
136
+ >"$inspector_log")) 2>&1 &
137
+ inspector_pid=$!
138
+
139
+ inspector_ready=0
140
+ for _ in $(seq 1 160); do
141
+ if curl --silent --fail --max-time 1 "$inspector_url/" >/dev/null 2>&1; then
142
+ inspector_ready=1
143
+ break
144
+ fi
145
+ if ! kill -0 "$inspector_pid" >/dev/null 2>&1; then
146
+ echo "[forgerelay:debug-host] MCP Inspector exited before becoming ready." >&2
147
+ echo "[forgerelay:debug-host] see $inspector_log" >&2
148
+ wait "$inspector_pid" || true
149
+ exit 1
150
+ fi
151
+ sleep 0.25
152
+ done
153
+
154
+ if [[ "$inspector_ready" != "1" ]]; then
155
+ echo "[forgerelay:debug-host] MCP Inspector did not become ready on ${inspector_client_port}." >&2
156
+ echo "[forgerelay:debug-host] see $inspector_log" >&2
157
+ exit 1
158
+ fi
159
+
160
+ encoded_mcp_url="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$mcp_url")"
161
+ deep_link="${inspector_url}/?serverUrl=${encoded_mcp_url}&transport=http&autoConnect=${inspector_token}"
162
+
163
+ echo "[forgerelay:debug-host] Inspector ready: $inspector_url"
164
+ echo "[forgerelay:debug-host] MCP Apps sandbox: http://127.0.0.1:${inspector_sandbox_port}/sandbox"
165
+ echo "[forgerelay:debug-host] open this one-shot local Host link:"
166
+ echo "$deep_link"
167
+
168
+ if [[ "${MCP_INSPECTOR_AUTO_OPEN:-1}" == "1" ]]; then
169
+ if command -v wslview >/dev/null 2>&1; then
170
+ wslview "$deep_link" >/dev/null 2>&1 || true
171
+ elif command -v xdg-open >/dev/null 2>&1; then
172
+ xdg-open "$deep_link" >/dev/null 2>&1 || true
173
+ fi
174
+ fi
175
+
176
+ set +e
177
+ wait "$inspector_pid"
178
+ inspector_status=$?
179
+ set -e
180
+ exit "$inspector_status"
@@ -0,0 +1,64 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ createInteractiveDebugEnvironment,
4
+ repoRoot,
5
+ } from "../runtime.mjs";
6
+
7
+ const { configDir, env } = createInteractiveDebugEnvironment();
8
+ const hostPort = parseHostPort(process.env.FORGERELAY_DEBUG_HOST_PORT);
9
+ const localBaseUrl = `http://127.0.0.1:${hostPort}`;
10
+ const localMcpUrl = `${localBaseUrl}/mcp`;
11
+
12
+ // The normal interactive debug profile may mirror the product's public tunnel.
13
+ // A local MCP Apps Host must instead receive localhost resource and asset URLs,
14
+ // otherwise the Inspector ends up exercising the remote deployment again.
15
+ env.HOST = "127.0.0.1";
16
+ env.PORT = String(hostPort);
17
+ env.FORGERELAY_PUBLIC_BASE_URL = localBaseUrl;
18
+ env.FORGERELAY_WIDGETS = process.env.FORGERELAY_DEBUG_WIDGETS ?? "full";
19
+
20
+ console.error("[forgerelay:debug-host] local MCP Apps Host server");
21
+ console.error(`[forgerelay:debug-host] debug config: ${configDir}/config.json`);
22
+ console.error(`[forgerelay:debug-host] health: ${localBaseUrl}/healthz`);
23
+ console.error(`[forgerelay:debug-host] MCP: ${localMcpUrl}`);
24
+ console.error(`[forgerelay:debug-host] public base override: ${localBaseUrl}`);
25
+ console.error(`[forgerelay:debug-host] widgets: ${env.FORGERELAY_WIDGETS}`);
26
+ console.error(`[forgerelay:debug-host] Owner password loaded from ${configDir}/auth.json`);
27
+ console.error("[forgerelay:debug-host] all runtime state is under .forgerelay-debug/");
28
+
29
+ const child = spawn(process.execPath, ["scripts/dev-server.mjs"], {
30
+ cwd: repoRoot,
31
+ env,
32
+ stdio: "inherit",
33
+ });
34
+
35
+ let stopping = false;
36
+ function stop(signal = "SIGTERM") {
37
+ if (stopping) return;
38
+ stopping = true;
39
+ child.kill(signal);
40
+ }
41
+
42
+ for (const signal of ["SIGINT", "SIGTERM"]) {
43
+ process.on(signal, () => stop(signal));
44
+ }
45
+
46
+ child.on("exit", (code, signal) => {
47
+ if (signal) {
48
+ process.kill(process.pid, signal);
49
+ return;
50
+ }
51
+ process.exitCode = code ?? 1;
52
+ });
53
+
54
+ function parseHostPort(value) {
55
+ if (value === undefined || value === "") return 7677;
56
+ if (!/^\d+$/.test(value)) {
57
+ throw new Error("FORGERELAY_DEBUG_HOST_PORT must be an integer from 1 to 65535.");
58
+ }
59
+ const port = Number(value);
60
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
61
+ throw new Error("FORGERELAY_DEBUG_HOST_PORT must be an integer from 1 to 65535.");
62
+ }
63
+ return port;
64
+ }
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdirSync } from "node:fs";
3
- import { resolve } from "node:path";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
 
6
7
  export const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url)));
@@ -15,6 +16,60 @@ export function createDebugOwnerToken() {
15
16
  return randomBytes(32).toString("base64url");
16
17
  }
17
18
 
19
+ export function interactiveDebugConfigDir({ env = process.env, home = homedir() } = {}) {
20
+ const explicit = env.FORGERELAY_DEBUG_CONFIG_DIR;
21
+ if (explicit) return resolve(explicit.startsWith("~/") ? join(home, explicit.slice(2)) : explicit);
22
+ return resolve(join(home, ".forgerelay", "debug"));
23
+ }
24
+
25
+ function interactiveDebugOwnerToken(configDir) {
26
+ const authPath = join(configDir, "auth.json");
27
+ let auth;
28
+ try {
29
+ auth = JSON.parse(readFileSync(authPath, "utf8"));
30
+ } catch (error) {
31
+ const reason = error instanceof Error ? error.message : String(error);
32
+ throw new Error(`Unable to load interactive debug Owner password from ${authPath}: ${reason}`);
33
+ }
34
+
35
+ const token = typeof auth.ownerToken === "string" ? auth.ownerToken.trim() : "";
36
+ if (!token) {
37
+ throw new Error(`Interactive debug Owner password is missing from ${authPath}.`);
38
+ }
39
+ return token;
40
+ }
41
+
42
+ export function createInteractiveDebugEnvironment({
43
+ env = process.env,
44
+ home = homedir(),
45
+ } = {}) {
46
+ const configDir = interactiveDebugConfigDir({ env, home });
47
+ const ownerToken = interactiveDebugOwnerToken(configDir);
48
+ mkdirSync(debugRoot, { recursive: true });
49
+
50
+ const debugEnv = { ...env };
51
+ for (const key of Object.keys(debugEnv)) {
52
+ if (key === "HOST" || key === "PORT") {
53
+ delete debugEnv[key];
54
+ continue;
55
+ }
56
+ if (key.startsWith("DEVSPACE_")) {
57
+ delete debugEnv[key];
58
+ continue;
59
+ }
60
+ if (
61
+ key.startsWith("FORGERELAY_")
62
+ && !key.startsWith("FORGERELAY_LOG_")
63
+ && !key.startsWith("FORGERELAY_DEBUG_")
64
+ ) {
65
+ delete debugEnv[key];
66
+ }
67
+ }
68
+ debugEnv.FORGERELAY_CONFIG_DIR = configDir;
69
+
70
+ return { ownerToken, configDir, env: debugEnv };
71
+ }
72
+
18
73
  export function createDebugEnvironment({
19
74
  ownerToken,
20
75
  stateDir = resolve(debugRoot, "state"),
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { createInteractiveDebugEnvironment, interactiveDebugConfigDir } from "./runtime.mjs";
8
+
9
+ async function withDebugConfig(t) {
10
+ const home = await mkdtemp(join(tmpdir(), "forgerelay-interactive-debug-"));
11
+ t.after(() => rm(home, { recursive: true, force: true }));
12
+ const configDir = join(home, ".forgerelay", "debug");
13
+ await mkdir(configDir, { recursive: true });
14
+ await writeFile(join(configDir, "config.json"), JSON.stringify({
15
+ host: "127.0.0.1",
16
+ port: 7677,
17
+ allowedRoots: [home],
18
+ allowedHosts: ["localhost", "127.0.0.1", "::1", "debug.example.test"],
19
+ publicBaseUrl: "https://debug.example.test/forgerelay/debug",
20
+ }) + "\n");
21
+ await writeFile(join(configDir, "auth.json"), JSON.stringify({
22
+ ownerToken: "debug-owner-password-123456",
23
+ }) + "\n");
24
+ return { home, configDir };
25
+ }
26
+
27
+ test("interactive debug uses one dedicated persisted config under ~/.forgerelay/debug", async (t) => {
28
+ const { home, configDir } = await withDebugConfig(t);
29
+ const result = createInteractiveDebugEnvironment({
30
+ env: {
31
+ HOST: "0.0.0.0",
32
+ PORT: "9999",
33
+ FORGERELAY_CONFIG_DIR: "/wrong/product/config",
34
+ FORGERELAY_PUBLIC_BASE_URL: "https://wrong.example.test",
35
+ FORGERELAY_ALLOWED_HOSTS: "wrong.example.test",
36
+ FORGERELAY_OAUTH_OWNER_TOKEN: "wrong-owner-password-123456",
37
+ FORGERELAY_WIDGETS: "off",
38
+ DEVSPACE_ALLOWED_ROOTS: "/wrong/root",
39
+ FORGERELAY_LOG_LEVEL: "debug",
40
+ },
41
+ home,
42
+ });
43
+
44
+ assert.equal(interactiveDebugConfigDir({ env: {}, home }), configDir);
45
+ assert.equal(result.ownerToken, "debug-owner-password-123456");
46
+ assert.equal(result.configDir, configDir);
47
+ assert.equal(result.env.FORGERELAY_CONFIG_DIR, configDir);
48
+ assert.equal(result.env.HOST, undefined);
49
+ assert.equal(result.env.PORT, undefined);
50
+ assert.equal(result.env.FORGERELAY_PUBLIC_BASE_URL, undefined);
51
+ assert.equal(result.env.FORGERELAY_ALLOWED_ROOTS, undefined);
52
+ assert.equal(result.env.FORGERELAY_ALLOWED_HOSTS, undefined);
53
+ assert.equal(result.env.FORGERELAY_OAUTH_OWNER_TOKEN, undefined);
54
+ assert.equal(result.env.FORGERELAY_WIDGETS, undefined);
55
+ assert.equal(result.env.DEVSPACE_ALLOWED_ROOTS, undefined);
56
+ assert.equal(result.env.FORGERELAY_LOG_LEVEL, "debug");
57
+ });
58
+
59
+ test("interactive debug config directory may be explicitly relocated without falling back to product config", async (t) => {
60
+ const { home } = await withDebugConfig(t);
61
+ const customDir = join(home, "custom-debug-config");
62
+ await mkdir(customDir, { recursive: true });
63
+ await writeFile(join(customDir, "config.json"), "{}\n");
64
+ await writeFile(join(customDir, "auth.json"), JSON.stringify({ ownerToken: "custom-debug-password-123456" }) + "\n");
65
+
66
+ const result = createInteractiveDebugEnvironment({
67
+ env: { FORGERELAY_DEBUG_CONFIG_DIR: customDir },
68
+ home,
69
+ });
70
+
71
+ assert.equal(result.configDir, customDir);
72
+ assert.equal(result.ownerToken, "custom-debug-password-123456");
73
+ assert.equal(result.env.FORGERELAY_CONFIG_DIR, customDir);
74
+ });
@@ -1,14 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createDebugEnvironment, debugBaseUrl, debugConfigDir, debugHookLog, debugMcpUrl, repoRoot } from "./runtime.mjs";
2
+ import { createInteractiveDebugEnvironment, debugBaseUrl, debugMcpUrl, repoRoot } from "./runtime.mjs";
3
3
 
4
- const { ownerToken, env } = createDebugEnvironment();
4
+ const { configDir, env } = createInteractiveDebugEnvironment();
5
5
 
6
6
  console.error("[forgerelay:debug] local debug server");
7
- console.error(`[forgerelay:debug] config: ${debugConfigDir}/config.json`);
7
+ console.error(`[forgerelay:debug] debug config: ${configDir}/config.json`);
8
8
  console.error(`[forgerelay:debug] health: ${debugBaseUrl}/healthz`);
9
9
  console.error(`[forgerelay:debug] MCP: ${debugMcpUrl}`);
10
- console.error(`[forgerelay:debug] hook log: ${debugHookLog}`);
11
- console.error(`[forgerelay:debug] Owner password: ${ownerToken}`);
10
+ console.error(`[forgerelay:debug] Owner password loaded from ${configDir}/auth.json`);
12
11
  console.error("[forgerelay:debug] all runtime state is under .forgerelay-debug/");
13
12
 
14
13
  const child = spawn(process.execPath, ["scripts/dev-server.mjs"], {
@@ -1,10 +1,11 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { readdirSync, statSync, watch } from "node:fs";
3
- import { join, resolve } from "node:path";
3
+ import { join, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
7
7
  const watchRoots = ["src"].map((entry) => join(repoRoot, entry));
8
+ const uiRoot = join(repoRoot, "src", "ui");
8
9
  const restartDelayMs = 750;
9
10
  const crashDelayMs = 1500;
10
11
 
@@ -12,11 +13,32 @@ let child;
12
13
  let restartTimer;
13
14
  let stoppingForRestart = false;
14
15
  let shuttingDown = false;
16
+ let uiBuildDirty = false;
15
17
 
16
18
  function log(message) {
17
19
  console.error(`[forgerelay:dev] ${message}`);
18
20
  }
19
21
 
22
+ function buildUi() {
23
+ log("building MCP App UI");
24
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
25
+ const result = spawnSync(npm, ["run", "build:app"], {
26
+ cwd: repoRoot,
27
+ env: process.env,
28
+ stdio: "inherit",
29
+ });
30
+ if (result.status === 0) {
31
+ uiBuildDirty = false;
32
+ return true;
33
+ }
34
+ log(`MCP App UI build failed (${result.signal ?? result.status ?? "unknown"})`);
35
+ return false;
36
+ }
37
+
38
+ function isUiSourcePath(path) {
39
+ return path === uiRoot || path.startsWith(`${uiRoot}${sep}`);
40
+ }
41
+
20
42
  function start() {
21
43
  stoppingForRestart = false;
22
44
  child = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
@@ -44,6 +66,11 @@ function restart() {
44
66
  if (shuttingDown) return;
45
67
  clearTimeout(restartTimer);
46
68
 
69
+ if (uiBuildDirty && !buildUi()) {
70
+ scheduleRestart(crashDelayMs);
71
+ return;
72
+ }
73
+
47
74
  if (!child) {
48
75
  start();
49
76
  return;
@@ -76,6 +103,7 @@ function watchDirectory(root) {
76
103
 
77
104
  const path = join(dir, filename.toString());
78
105
  if (event === "rename") maybeAddDirectory(path);
106
+ if (isUiSourcePath(path)) uiBuildDirty = true;
79
107
  scheduleRestart();
80
108
  });
81
109
  watchers.push(watcher);
@@ -116,5 +144,6 @@ for (const root of watchRoots) {
116
144
  watchDirectory(root);
117
145
  }
118
146
 
119
- log("watching src; server restarts on changes and after crashes");
147
+ log("watching src; UI changes rebuild MCP App assets before server restart");
148
+ if (!buildUi()) process.exit(1);
120
149
  start();