@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-08-27
8
+
9
+ ### Added
10
+
11
+ - Added the unified ForgeRelay Panel for ChatGPT MCP App hosts: one Host Turn now keeps the Workspace Summary above a collapsible Activity Panel, groups live Activities, lazily loads detailed operation data, and streams durable Bash output without turning follow-up process control into duplicate top-level Activities.
12
+ - Added a local MCP Inspector debug host on the dedicated `127.0.0.1:7677` path so the real HTTP/OAuth/MCP App lifecycle can be inspected without touching the normal product port.
13
+ - Added release-candidate publication support: `vX.Y.Z-rc.N` tags run the normal release gate, publish npm packages on the `next` dist-tag, and create GitHub prereleases while stable releases continue to use `latest`.
14
+
15
+ ### Changed
16
+
17
+ - Promoted the validated `0.6.0-rc.1` build to the stable 0.6 line without additional runtime changes.
18
+ - Skills are now advertised by name and loaded through the virtual `skills://<name>` namespace; real local Skill paths stay out of normal Agent-facing metadata, and files inside an activated Skill use `skills://<name>/<relative-path>`.
19
+ - `publicBaseUrl` may now be either one routed URL or an ordered list of routed public URLs. The first remains canonical, every configured hostname joins the Host allowlist, and the complete ordered list participates in MCP App resource identity and CSP metadata.
20
+ - Host Turn scoping now prefers Host conversation metadata, then the MCP transport session, then the current connection, preserving one Workspace-specific panel even on Hosts such as Inspector that do not provide OpenAI conversation metadata.
21
+ - When an Agent only needs to wait for an already running Bash process, `bash(action="process", processId=..., yieldTimeMs=...)` now acts as one bounded wait and reuses the same process ID if the window expires, reducing repeated short polling.
22
+
23
+ ### Fixed
24
+
25
+ - Preserved routed public base paths through OAuth endpoints and local debug configuration instead of collapsing deployment URLs back to the origin.
26
+ - Preserved complete Workspace IDs in diagnostic logging and kept Activity Panel state attached across successive tool results.
27
+ - Explicit Bash process waits now honor `yieldTimeMs` even when the process has already produced buffered output, so continuously logging commands can still be efficiently awaited to completion.
28
+
29
+ ## [0.6.0-rc.1] - 2026-08-27
30
+
31
+ ### Added
32
+
33
+ - Added the unified ForgeRelay Panel for ChatGPT MCP App hosts: one Host Turn now keeps the Workspace Summary above a collapsible Activity Panel, groups live Activities, lazily loads detailed operation data, and streams durable Bash output without turning follow-up process control into duplicate top-level Activities.
34
+ - Added a local MCP Inspector debug host on the dedicated `127.0.0.1:7677` path so the real HTTP/OAuth/MCP App lifecycle can be inspected without touching the normal product port.
35
+ - Added release-candidate publication support: `vX.Y.Z-rc.N` tags run the normal release gate, publish npm packages on the `next` dist-tag, and create GitHub prereleases while stable releases continue to use `latest`.
36
+
37
+ ### Changed
38
+
39
+ - Skills are now advertised by name and loaded through the virtual `skills://<name>` namespace; real local Skill paths stay out of normal Agent-facing metadata, and files inside an activated Skill use `skills://<name>/<relative-path>`.
40
+ - `publicBaseUrl` may now be either one routed URL or an ordered list of routed public URLs. The first remains canonical, every configured hostname joins the Host allowlist, and the complete ordered list participates in MCP App resource identity and CSP metadata.
41
+ - Host Turn scoping now prefers Host conversation metadata, then the MCP transport session, then the current connection, preserving one Workspace-specific panel even on Hosts such as Inspector that do not provide OpenAI conversation metadata.
42
+ - When an Agent only needs to wait for an already running Bash process, `bash(action="process", processId=..., yieldTimeMs=...)` now acts as one bounded wait and reuses the same process ID if the window expires, reducing repeated short polling.
43
+
44
+ ### Fixed
45
+
46
+ - Preserved routed public base paths through OAuth endpoints and local debug configuration instead of collapsing deployment URLs back to the origin.
47
+ - Preserved complete Workspace IDs in diagnostic logging and kept Activity Panel state attached across successive tool results.
48
+ - Explicit Bash process waits now honor `yieldTimeMs` even when the process has already produced buffered output, so continuously logging commands can still be efficiently awaited to completion.
49
+
7
50
  ## [0.5.6] - 2026-08-17
8
51
 
9
52
  ### Fixed
package/README.md CHANGED
@@ -55,18 +55,22 @@ http://127.0.0.1:7676/mcp
55
55
 
56
56
  If the MCP host cannot reach localhost, put ForgeRelay behind a public HTTPS
57
57
  tunnel or reverse proxy such as Cloudflare Tunnel, ngrok, Pinggy, Tailscale
58
- Funnel, or your own proxy. During setup, enter the public origin without `/mcp`:
58
+ Funnel, or your own proxy. During setup, enter the public base URL before the
59
+ final `/mcp`; routed prefixes are allowed:
59
60
 
60
61
  ```text
61
- https://your-tunnel-host.example.com
62
+ https://your-tunnel-host.example.com/forgerelay/main
62
63
  ```
63
64
 
64
65
  The client then connects to:
65
66
 
66
67
  ```text
67
- https://your-tunnel-host.example.com/mcp
68
+ https://your-tunnel-host.example.com/forgerelay/main/mcp
68
69
  ```
69
70
 
71
+ `publicBaseUrl` may also be an ordered list when multiple public entries are
72
+ valid; each entry may use its own route and the first is canonical.
73
+
70
74
  ForgeRelay uses an Owner-password OAuth approval flow. `forgerelay init` prints
71
75
  the password and stores it in the active config directory. New installations use:
72
76
 
@@ -6,14 +6,20 @@
6
6
 
7
7
  ForgeRelay 本地监听一个 HTTP origin,MCP endpoint 是 `/mcp`。当 Host 无法直接访问 localhost 时,需要用户自己提供 public HTTPS tunnel/reverse proxy;ForgeRelay 不创建 tunnel。
8
8
 
9
- 持久化的 `publicBaseUrl` 必须是 origin,不要包含 `/mcp`:
10
-
11
- ```text
12
- publicBaseUrl: https://forge.example.com
13
- Host MCP URL: https://forge.example.com/mcp
9
+ 公网部署继续使用 `publicBaseUrl`;它既可以是单个字符串,也可以是多个 URL 的列表。每个 URL 可以带自己的路由前缀,第一项是 canonical:
10
+
11
+ ```json
12
+ {
13
+ "publicBaseUrl": [
14
+ "https://forge.example.com/forgerelay/main",
15
+ "https://forge-alt.example.com/relay"
16
+ ]
17
+ }
14
18
  ```
15
19
 
16
- 临时覆盖可使用 `FORGERELAY_PUBLIC_BASE_URL`。Host-header/403 问题先运行 `forgerelay doctor` 检查 resolved public URL allowed hosts;`FORGERELAY_ALLOWED_HOSTS="*"` 只适合明确的本地调试。
20
+ 对应 canonical Host MCP URL 为 `https://forge.example.com/forgerelay/main/mcp`。全部配置项的 hostname 都会进入派生 Host-header allowlist;MCP、OAuth MCP App asset URL 使用第一项作为 canonical base。单个字符串形式保持完全兼容,不需要迁移。
21
+
22
+ 临时覆盖使用 `FORGERELAY_PUBLIC_BASE_URL`;多个 URL 用逗号分隔。Host-header/403 问题先运行 `forgerelay doctor` 检查 resolved public base URLs 与 allowed hosts;`FORGERELAY_ALLOWED_HOSTS="*"` 只适合明确的本地调试。
17
23
 
18
24
  当 ForgeRelay 绑定 loopback、但 `publicBaseUrl` 指向公网 tunnel/reverse proxy 时,ForgeRelay 会自动信任恰好 1 个上游代理 hop,让 Express 与 OAuth rate limiter 使用一致的客户端 IP。不要把 Express `trust proxy` 设为无条件 `true`;直接监听 `0.0.0.0` 等非 loopback 地址时也不会自动开启代理信任。可用 `FORGERELAY_TRUST_PROXY=0|1` 显式覆盖。
19
25
 
@@ -47,13 +53,13 @@ npm run build
47
53
  npm run debug:accept
48
54
  ```
49
55
 
50
- ForgeRelay 正常会广告 content-hashed:
56
+ ForgeRelay 对新的项目工作只广告统一 Panel 的 content-hashed App resource
51
57
 
52
58
  ```text
53
- ui://forgerelay/workspace-app-<hash>.html
59
+ ui://forgerelay/activity-panel-app-<hash>.html
54
60
  ```
55
61
 
56
- `resources/list` 与 `resources/read` MCP App metadata 都应包含唯一 `_meta.ui.domain`,其值来自 resolved `publicBaseUrl` 的 originCSP 仍使用完整 public base URL 约束资源与连接域。`resources/read` 应返回 `text/html;profile=mcp-app`,并且 HTML 引用的 `/mcp-app-assets/` 资源必须可达。ForgeRelay 还保留 legacy `ui://forgerelay/workspace-app.html` 和历史 `workspace-app-*.html` 兼容指针,以容忍 Host 暂时持有旧 metadata snapshot
62
+ `activity_panel(workspaceId)` 是唯一的新 render tool;`open_workspace` 只返回 Workspace handle/context,不单独挂 UI。`resources/list` 与 `resources/read` 的当前 App metadata 都应包含 `_meta.ui.domain`,其值使用 canonical `publicBaseUrl` 的 origin;标准 `ui.domain` 本身不塞路由。CSP resource/connect entries 使用完整 `publicBaseUrl` 列表,因此每个入口可以有不同路由。完整、有序的 public base URL 列表与 bundle/template contract 一起进入 MCP App resource identity;修改任一域名或路由都必须生成新的 `ui://` URI,不能让 Host 继续命中旧 metadata cache。`resources/read` 应返回 `text/html;profile=mcp-app`,并且 HTML 引用的 canonical routed `/mcp-app-assets/` 资源必须可达。ForgeRelay 仍保留 legacy `workspace-app` Workspace Lifecycle URI/template 作为历史卡片读取兼容入口,但它们不代表当前产品存在第二个 App
57
63
 
58
64
  需要 live trace 时,可用:
59
65
 
@@ -9,14 +9,15 @@ export class HostTurnStore {
9
9
  this.now = options.now ?? (() => new Date());
10
10
  this.nextTurnId = options.turnId ?? (() => `turn_${randomUUID().replaceAll("-", "")}`);
11
11
  }
12
- begin(conversationScopeId) {
12
+ begin(conversationScopeId, workspaceId) {
13
13
  const turnId = this.nextTurnId();
14
14
  const createdAt = this.now().toISOString();
15
- this.database.sqlite.prepare(`insert into activity_host_turns (turn_id, conversation_scope_id, created_at)
16
- values (?, ?, ?)`).run(turnId, conversationScopeId ?? null, createdAt);
15
+ this.database.sqlite.prepare(`insert into activity_host_turns (turn_id, conversation_scope_id, workspace_id, created_at)
16
+ values (?, ?, ?, ?)`).run(turnId, conversationScopeId ?? null, workspaceId, createdAt);
17
17
  return {
18
18
  turnId,
19
19
  ...(conversationScopeId ? { conversationScopeId } : {}),
20
+ workspaceId,
20
21
  createdAt,
21
22
  };
22
23
  }
@@ -24,13 +25,13 @@ export class HostTurnStore {
24
25
  const row = this.database.sqlite.prepare("select * from activity_host_turns where turn_id = ?").get(turnId);
25
26
  return row ? rowToTurn(row) : undefined;
26
27
  }
27
- current(conversationScopeId) {
28
- if (!conversationScopeId)
28
+ current(conversationScopeId, workspaceId) {
29
+ if (!conversationScopeId || !workspaceId)
29
30
  return undefined;
30
31
  const row = this.database.sqlite.prepare(`select * from activity_host_turns
31
- where conversation_scope_id = ?
32
+ where conversation_scope_id = ? and workspace_id = ?
32
33
  order by rowid desc
33
- limit 1`).get(conversationScopeId);
34
+ limit 1`).get(conversationScopeId, workspaceId);
34
35
  return row ? rowToTurn(row) : undefined;
35
36
  }
36
37
  close() {
@@ -41,6 +42,7 @@ function rowToTurn(row) {
41
42
  return {
42
43
  turnId: row.turn_id,
43
44
  ...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
45
+ ...(row.workspace_id ? { workspaceId: row.workspace_id } : {}),
44
46
  createdAt: row.created_at,
45
47
  };
46
48
  }
@@ -46,7 +46,7 @@ export class ActivityLifecycle {
46
46
  start(options) {
47
47
  const activityId = options.activityId ?? this.activityId();
48
48
  const turnId = options.turnId
49
- ?? this.turnIdForConversation?.(options.conversationScopeId)
49
+ ?? this.turnIdForConversation?.(options.conversationScopeId, options.workspace.id)
50
50
  ?? this.turnId();
51
51
  const request = normalizeAuditValue(options.request);
52
52
  this.auditStore.append({
@@ -1,6 +1,8 @@
1
1
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
2
2
  import * as z from "zod/v4";
3
- import { openAiConversationScopeId } from "../request-meta.js";
3
+ import { logEvent, transportSessionIdPrefix } from "../logger.js";
4
+ import { hostConversationScopeId } from "../request-meta.js";
5
+ import { ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY, ACTIVITY_PANEL_WORKSPACE_META_KEY, } from "./ui/contract.js";
4
6
  const READ_ONLY_ANNOTATIONS = {
5
7
  readOnlyHint: true,
6
8
  destructiveHint: false,
@@ -9,6 +11,7 @@ const READ_ONLY_ANNOTATIONS = {
9
11
  };
10
12
  const activitySummarySchema = z.object({
11
13
  activityId: z.string(),
14
+ parentActivityId: z.string().optional(),
12
15
  tool: z.string(),
13
16
  kind: z.string(),
14
17
  status: z.enum(["working", "done", "error"]),
@@ -24,6 +27,12 @@ const activitySummarySchema = z.object({
24
27
  startedAt: z.string(),
25
28
  finishedAt: z.string().optional(),
26
29
  durationMs: z.number().nonnegative().optional(),
30
+ children: z.object({
31
+ total: z.number().int().nonnegative(),
32
+ working: z.number().int().nonnegative(),
33
+ done: z.number().int().nonnegative(),
34
+ error: z.number().int().nonnegative(),
35
+ }).optional(),
27
36
  });
28
37
  const snapshotOutputSchema = {
29
38
  turnId: z.string(),
@@ -31,48 +40,110 @@ const snapshotOutputSchema = {
31
40
  changed: z.boolean(),
32
41
  state: z.enum(["working", "done", "error"]),
33
42
  activities: z.array(activitySummarySchema),
43
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: z.record(z.string(), z.unknown()).optional(),
34
44
  };
35
- export function registerActivityQueryTools(server, queries) {
45
+ export function registerActivityQueryTools(server, queries, connectionScopeId, panelMeta = {}, panelDefaultExpanded = false, logging, workspacePanelState) {
46
+ const panelUi = typeof panelMeta.ui === "object" && panelMeta.ui !== null
47
+ ? panelMeta.ui
48
+ : {};
36
49
  registerAppTool(server, "activity_panel", {
37
- title: "Begin Activity Panel",
38
- description: "Begin one ForgeRelay Host Turn lifecycle for subsequent project work. This orchestration call does not read or modify project files. Call it once before the first ForgeRelay work operation in a Host Turn that performs project work.",
39
- inputSchema: {},
50
+ title: "Begin ForgeRelay Panel",
51
+ description: "Begin one ForgeRelay Host Turn for project work in workspaceId as the single ForgeRelay UI render tool for that Workspace. If this Host Turn calls open_workspace, open_workspace must run first and its returned workspaceId must be passed here. A different workspaceId creates a different card. Call exactly once after workspace resolution and before the first non-lifecycle ForgeRelay work tool.",
52
+ inputSchema: {
53
+ workspaceId: z.string().min(1).describe("Workspace identifier returned by open_workspace for the project work in this Host Turn."),
54
+ },
40
55
  outputSchema: snapshotOutputSchema,
41
- _meta: { ui: { visibility: ["model", "app"] } },
56
+ _meta: {
57
+ ...panelMeta,
58
+ ui: {
59
+ ...panelUi,
60
+ visibility: ["model", "app"],
61
+ },
62
+ },
42
63
  annotations: {
43
64
  ...READ_ONLY_ANNOTATIONS,
44
65
  idempotentHint: false,
45
66
  },
46
- }, async (_input, extra) => {
47
- const snapshot = queries.beginTurn(openAiConversationScopeId(extra._meta));
67
+ }, async ({ workspaceId }, extra) => {
68
+ const workspace = workspacePanelState?.(workspaceId);
69
+ if (!workspace) {
70
+ throw new Error(`No Workspace presentation is available for ${workspaceId}. Call open_workspace for that workspace before activity_panel.`);
71
+ }
72
+ const snapshot = queries.beginTurn(hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId), workspaceId);
73
+ if (logging) {
74
+ logEvent(logging, "debug", "activity_panel_call", {
75
+ turnId: snapshot.turnId,
76
+ revision: snapshot.revision,
77
+ state: snapshot.state,
78
+ activities: snapshot.activities.length,
79
+ workspaceId,
80
+ transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
81
+ });
82
+ }
48
83
  return {
84
+ _meta: {
85
+ [ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY]: panelDefaultExpanded,
86
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
87
+ },
49
88
  content: [{
50
89
  type: "text",
51
- text: `Started ForgeRelay Activity Panel Host Turn ${snapshot.turnId}.`,
90
+ text: `Started ForgeRelay Panel Host Turn ${snapshot.turnId} for ${workspaceId}.`,
52
91
  }],
53
- structuredContent: { ...snapshot },
92
+ structuredContent: {
93
+ ...snapshot,
94
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
95
+ },
54
96
  };
55
97
  });
56
98
  registerAppTool(server, "activity_snapshot", {
57
99
  title: "Read Activity snapshot",
58
100
  description: "App-only data source for lightweight Activity summaries in one durable Host Turn.",
59
101
  inputSchema: {
60
- turnId: z.string(),
102
+ turnId: z.string().optional().describe("Existing Host Turn identifier. Omit only for initial App bootstrap; ForgeRelay then resolves the current Host Turn from conversation metadata."),
103
+ workspaceId: z.string().min(1).optional().describe("Workspace identifier used by the App during initial bootstrap so ForgeRelay can restore the Workspace section."),
61
104
  knownRevision: z.number().int().nonnegative().optional(),
62
105
  },
63
106
  outputSchema: snapshotOutputSchema,
64
107
  _meta: { ui: { visibility: ["app"] } },
65
108
  annotations: READ_ONLY_ANNOTATIONS,
66
- }, async ({ turnId, knownRevision }) => {
67
- const snapshot = queries.snapshot(turnId, knownRevision);
109
+ }, async ({ turnId, workspaceId, knownRevision }, extra) => {
110
+ const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
111
+ const resolvedTurnId = turnId ?? queries.currentTurnId(conversationScopeId, workspaceId);
112
+ if (!resolvedTurnId) {
113
+ throw new Error("Activity snapshot bootstrap could not resolve the current Host Turn from conversation and workspace metadata.");
114
+ }
115
+ const snapshot = queries.snapshot(resolvedTurnId, knownRevision);
116
+ if (logging) {
117
+ logEvent(logging, "debug", "activity_snapshot_call", {
118
+ turnId: resolvedTurnId,
119
+ bootstrap: turnId === undefined,
120
+ knownRevision,
121
+ revision: snapshot.revision,
122
+ changed: snapshot.changed,
123
+ state: snapshot.state,
124
+ activities: snapshot.activities.length,
125
+ transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
126
+ });
127
+ }
128
+ const workspace = workspaceId ? workspacePanelState?.(workspaceId) : undefined;
129
+ if (workspaceId && !workspace) {
130
+ throw new Error(`No Workspace presentation is available for ${workspaceId}.`);
131
+ }
68
132
  return {
133
+ _meta: {
134
+ [ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY]: panelDefaultExpanded,
135
+ ...(workspace ? { [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace } : {}),
136
+ },
69
137
  content: [{
70
138
  type: "text",
71
139
  text: snapshot.changed
72
- ? `Activity snapshot ${turnId} revision ${snapshot.revision}.`
73
- : `Activity snapshot ${turnId} unchanged at revision ${snapshot.revision}.`,
140
+ ? `Activity snapshot ${resolvedTurnId} revision ${snapshot.revision}.`
141
+ : `Activity snapshot ${resolvedTurnId} unchanged at revision ${snapshot.revision}.`,
74
142
  }],
75
- structuredContent: { ...snapshot },
143
+ structuredContent: {
144
+ ...snapshot,
145
+ ...(workspace ? { [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace } : {}),
146
+ },
76
147
  };
77
148
  });
78
149
  registerAppTool(server, "activity_detail", {
@@ -90,8 +161,18 @@ export function registerActivityQueryTools(server, queries) {
90
161
  },
91
162
  _meta: { ui: { visibility: ["app"] } },
92
163
  annotations: READ_ONLY_ANNOTATIONS,
93
- }, async ({ turnId, activityId }) => {
164
+ }, async ({ turnId, activityId }, extra) => {
94
165
  const detail = queries.detail(turnId, activityId);
166
+ if (logging) {
167
+ logEvent(logging, "debug", "activity_detail_call", {
168
+ turnId,
169
+ activityId,
170
+ tool: detail.activity.tool,
171
+ kind: detail.activity.kind,
172
+ state: detail.activity.state,
173
+ transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
174
+ });
175
+ }
95
176
  return {
96
177
  content: [{ type: "text", text: `Activity detail ${activityId}.` }],
97
178
  structuredContent: { ...detail },
@@ -119,8 +200,19 @@ export function registerActivityQueryTools(server, queries) {
119
200
  },
120
201
  _meta: { ui: { visibility: ["app"] } },
121
202
  annotations: READ_ONLY_ANNOTATIONS,
122
- }, async ({ turnId, outputId }) => {
203
+ }, async ({ turnId, outputId }, extra) => {
123
204
  const output = queries.bashOutput(turnId, outputId);
205
+ if (logging) {
206
+ logEvent(logging, "debug", "activity_output_call", {
207
+ turnId,
208
+ outputId,
209
+ activityId: output.activityId,
210
+ processId: output.processId,
211
+ status: output.status,
212
+ outputBytes: Buffer.byteLength(output.output),
213
+ transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
214
+ });
215
+ }
124
216
  return {
125
217
  content: [{ type: "text", text: `Bash output ${outputId}.` }],
126
218
  structuredContent: { ...output },
@@ -7,12 +7,12 @@ export class ActivityQueryService {
7
7
  this.audit = audit;
8
8
  this.outputs = outputs;
9
9
  }
10
- beginTurn(conversationScopeId) {
11
- const turn = this.turns.begin(conversationScopeId);
10
+ beginTurn(conversationScopeId, workspaceId) {
11
+ const turn = this.turns.begin(conversationScopeId, workspaceId);
12
12
  return this.snapshot(turn.turnId);
13
13
  }
14
- currentTurnId(conversationScopeId) {
15
- return this.turns.current(conversationScopeId)?.turnId;
14
+ currentTurnId(conversationScopeId, workspaceId) {
15
+ return this.turns.current(conversationScopeId, workspaceId)?.turnId;
16
16
  }
17
17
  snapshot(turnId, knownRevision) {
18
18
  this.requireTurn(turnId);
@@ -0,0 +1,2 @@
1
+ export const ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY = "forgerelay/activityPanelDefaultExpanded";
2
+ export const ACTIVITY_PANEL_WORKSPACE_META_KEY = "forgerelay/activityPanelWorkspace";
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { createLocalAgentStore } from "./local-agent-store.js";
21
21
  import { generateOwnerToken, loadDevspaceFiles, resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, } from "./user-config.js";
22
22
  import { expandHomePath } from "./roots.js";
23
23
  import { shutdownHttpServer } from "./server-shutdown.js";
24
+ import { publicEndpointUrl } from "./oauth/public-url.js";
24
25
  const require = createRequire(import.meta.url);
25
26
  const SUPPORTED_NODE_RANGE = ">=20.12 <27";
26
27
  async function main(argv) {
@@ -113,25 +114,30 @@ async function runInit({ force }) {
113
114
  });
114
115
  const port = Number(portAnswer);
115
116
  prompts.note([
116
- "ForgeRelay needs a public base URL so ChatGPT or Claude can reach this MCP server.",
117
+ "ForgeRelay needs one or more public base URLs so ChatGPT or Claude can reach this MCP server.",
117
118
  "Create a tunnel or reverse proxy with Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS proxy.",
118
- "Paste the public origin here, without /mcp.",
119
+ "Each URL may include its own route prefix. Separate multiple URLs with commas; the first is canonical.",
119
120
  "",
120
- "Example: https://your-tunnel-host.example.com",
121
+ "Example: https://forge.example.com/forgerelay/main, https://alias.example.com/relay",
121
122
  ].join("\n"), "Public URL required");
122
- const publicBaseUrl = normalizePublicBaseUrl(await textPrompt({
123
- message: files.config.publicBaseUrl
124
- ? `What is the public base URL? Press Enter to keep ${files.config.publicBaseUrl}`
125
- : "What is the public base URL?",
126
- placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com",
127
- defaultValue: files.config.publicBaseUrl ?? "",
128
- validate: validateRequiredPublicBaseUrl,
123
+ const defaultPublicBaseUrls = Array.isArray(files.config.publicBaseUrl)
124
+ ? files.config.publicBaseUrl.join(", ")
125
+ : files.config.publicBaseUrl ?? "";
126
+ const publicBaseUrls = normalizePublicBaseUrlsInput(await textPrompt({
127
+ message: defaultPublicBaseUrls
128
+ ? `What are the public base URLs? Press Enter to keep ${defaultPublicBaseUrls}`
129
+ : "What are the public base URLs?",
130
+ placeholder: defaultPublicBaseUrls || "https://your-tunnel-host.example.com",
131
+ defaultValue: defaultPublicBaseUrls,
132
+ validate: validateRequiredPublicBaseUrls,
129
133
  }));
134
+ const publicBaseUrl = compactPublicBaseUrlConfig(publicBaseUrls);
130
135
  const config = {
131
136
  host: files.config.host ?? "127.0.0.1",
132
137
  port,
133
138
  allowedRoots,
134
139
  publicBaseUrl,
140
+ allowedHosts: files.config.allowedHosts,
135
141
  workflowInstructions: files.config.workflowInstructions,
136
142
  appendInstructions: files.config.appendInstructions,
137
143
  subagents: resolveSubagentsFlag(files.config),
@@ -145,7 +151,7 @@ async function runInit({ force }) {
145
151
  `Config: ${configPath}`,
146
152
  `Auth: ${authPath}`,
147
153
  `Local MCP URL: http://${config.host}:${config.port}/mcp`,
148
- ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []),
154
+ ...publicBaseUrls.map((baseUrl, index) => `${index === 0 ? "Public MCP URL" : "Public MCP alias"}: ${publicEndpointUrl(baseUrl, "mcp").toString()}`),
149
155
  ];
150
156
  prompts.note(lines.join("\n"), "ForgeRelay configured");
151
157
  prompts.note([
@@ -222,8 +228,9 @@ async function runDoctor() {
222
228
  try {
223
229
  const config = loadConfig();
224
230
  console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`);
231
+ console.log(`Public base URLs: ${config.publicBaseUrls.join(", ")}`);
225
232
  console.log(`Public base URL: ${config.publicBaseUrl}`);
226
- console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`);
233
+ console.log(`Public MCP URL: ${publicEndpointUrl(config.publicBaseUrl, "mcp").toString()}`);
227
234
  console.log(`Tool mode: ${config.toolMode}`);
228
235
  console.log(`Widgets: ${config.widgets}`);
229
236
  console.log(`Trust proxy: ${config.logging.trustProxy ? "one hop" : "off"}`);
@@ -248,12 +255,11 @@ function runConfigCommand(args) {
248
255
  throw new Error(`Unknown config command: ${subcommand}`);
249
256
  }
250
257
  if (key !== "publicBaseUrl") {
251
- throw new Error("Only `forgerelay config set publicBaseUrl <url|null>` is supported right now.");
258
+ throw new Error("Only `forgerelay config set publicBaseUrl <url[,url...]|null>` is supported right now.");
252
259
  }
253
260
  const value = rest.join(" ").trim();
254
- if (!value) {
261
+ if (!value)
255
262
  throw new Error("Missing publicBaseUrl value.");
256
- }
257
263
  writeDevspaceConfig({
258
264
  ...files.config,
259
265
  publicBaseUrl: normalizeOptionalPublicBaseUrl(value),
@@ -270,7 +276,7 @@ function printHelp() {
270
276
  " forgerelay init Create or update ~/.forgerelay/config.json and auth.json",
271
277
  " forgerelay doctor Show config, runtime, and native dependency status",
272
278
  " forgerelay config get Print persisted config",
273
- " forgerelay config set publicBaseUrl <url|null>",
279
+ " forgerelay config set publicBaseUrl <url[,url...]|null>",
274
280
  " forgerelay hooks list [--project <path>]",
275
281
  " forgerelay hooks check [--project <path>]",
276
282
  " forgerelay agents ls List subagent sessions",
@@ -279,7 +285,7 @@ function printHelp() {
279
285
  " forgerelay -v, --version Print the installed version",
280
286
  "",
281
287
  "For temporary tunnels:",
282
- " FORGERELAY_PUBLIC_BASE_URL=https://example.trycloudflare.com forgerelay serve",
288
+ " FORGERELAY_PUBLIC_BASE_URL=https://example.trycloudflare.com/forgerelay/debug forgerelay serve",
283
289
  ].join("\n"));
284
290
  }
285
291
  async function runAgentsCommand(args) {
@@ -542,11 +548,27 @@ function printVersion() {
542
548
  }
543
549
  console.log(packageJson.version);
544
550
  }
551
+ function isNullConfigValue(value) {
552
+ const normalized = value.trim().toLowerCase();
553
+ return !normalized || normalized === "null" || normalized === "none";
554
+ }
545
555
  function normalizeOptionalPublicBaseUrl(value) {
546
556
  const trimmed = value.trim();
547
- if (!trimmed || trimmed === "null" || trimmed === "none")
557
+ if (isNullConfigValue(trimmed))
548
558
  return null;
549
- return normalizePublicBaseUrl(trimmed);
559
+ return compactPublicBaseUrlConfig(normalizePublicBaseUrlsInput(trimmed));
560
+ }
561
+ function normalizePublicBaseUrlsInput(value) {
562
+ const entries = value
563
+ .split(",")
564
+ .map((entry) => entry.trim())
565
+ .filter(Boolean);
566
+ if (entries.length === 0)
567
+ throw new Error("Enter at least one public base URL.");
568
+ return Array.from(new Set(entries.map((entry) => normalizePublicBaseUrl(entry))));
569
+ }
570
+ function compactPublicBaseUrlConfig(baseUrls) {
571
+ return baseUrls.length === 1 ? baseUrls[0] : baseUrls;
550
572
  }
551
573
  function normalizePublicBaseUrl(value) {
552
574
  const trimmed = value.trim();
@@ -572,23 +594,16 @@ function validatePort(value) {
572
594
  ? undefined
573
595
  : "Enter a port between 1 and 65535.";
574
596
  }
575
- function validateRequiredPublicBaseUrl(value) {
597
+ function validateRequiredPublicBaseUrls(value) {
576
598
  const trimmed = value?.trim() ?? "";
577
599
  if (!trimmed)
578
- return "Enter the public URL from your tunnel or reverse proxy.";
579
- if (trimmed.endsWith("/mcp"))
580
- return "Enter the base URL only, without /mcp.";
581
- return validatePublicBaseUrl(trimmed);
582
- }
583
- function validatePublicBaseUrl(value) {
600
+ return "Enter at least one public base URL from your tunnel or reverse proxy.";
584
601
  try {
585
- const parsed = new URL(value);
586
- return parsed.protocol === "http:" || parsed.protocol === "https:"
587
- ? undefined
588
- : "Use an http or https URL.";
602
+ normalizePublicBaseUrlsInput(trimmed);
603
+ return undefined;
589
604
  }
590
- catch {
591
- return "Enter a valid URL, for example https://your-tunnel-host.example.com.";
605
+ catch (error) {
606
+ return error instanceof Error ? error.message : String(error);
592
607
  }
593
608
  }
594
609
  function assertSupportedNode() {
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { expandHomePath } from "./roots.js";
5
5
  import { mergeHookConfigs, parseHookConfig } from "./hooks.js";
6
- import { forgerelayAgentsDir, forgerelaySkillsDir, loadForgeRelayFiles } from "./user-config.js";
6
+ import { forgerelayAgentsDir, forgerelaySkillsDir, loadForgeRelayFiles, } from "./user-config.js";
7
7
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
8
8
  const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
9
9
  const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
@@ -186,17 +186,41 @@ function parseSystemInstructionsPath(value) {
186
186
  }
187
187
  return resolve(expandHomePath(value.trim()));
188
188
  }
189
+ function parsePublicBaseUrls(value, fallback) {
190
+ if (value === null)
191
+ return fallback;
192
+ const raw = Array.isArray(value)
193
+ ? value.map((entry) => entry.trim()).filter(Boolean)
194
+ : value?.split(",").map((entry) => entry.trim()).filter(Boolean);
195
+ if (value !== undefined && (!raw || raw.length === 0)) {
196
+ throw new Error("FORGERELAY_PUBLIC_BASE_URL must contain at least one public base URL.");
197
+ }
198
+ const normalized = (raw ?? fallback).map((entry) => parsePublicBaseUrl(entry));
199
+ return Array.from(new Set(normalized));
200
+ }
201
+ function resolvePublicDeployment(env, fileConfig, host, port) {
202
+ const localBaseUrls = [parsePublicBaseUrl(localPublicBaseUrl(host, port))];
203
+ const envBaseUrl = productEnv(env, "PUBLIC_BASE_URL");
204
+ const baseUrls = envBaseUrl !== undefined
205
+ ? parsePublicBaseUrls(envBaseUrl, localBaseUrls)
206
+ : parsePublicBaseUrls(fileConfig.publicBaseUrl, localBaseUrls);
207
+ return {
208
+ baseUrls,
209
+ canonicalBaseUrl: baseUrls[0],
210
+ };
211
+ }
189
212
  export function loadConfig(env = process.env) {
190
213
  const files = loadForgeRelayFiles(env);
191
214
  const host = env.HOST ?? files.config.host ?? "127.0.0.1";
192
215
  const port = parsePort(env.PORT ?? files.config.port);
193
- const publicBaseUrl = parsePublicBaseUrl(productEnv(env, "PUBLIC_BASE_URL") ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port));
216
+ const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
217
+ const publicBaseUrl = publicDeployment.canonicalBaseUrl;
194
218
  const derivedAllowedHosts = [
195
219
  "localhost",
196
220
  "127.0.0.1",
197
221
  "::1",
198
222
  host,
199
- new URL(publicBaseUrl).hostname,
223
+ ...publicDeployment.baseUrls.map((baseUrl) => new URL(baseUrl).hostname),
200
224
  ...(files.config.allowedHosts ?? []),
201
225
  ];
202
226
  return {
@@ -206,10 +230,14 @@ export function loadConfig(env = process.env) {
206
230
  allowedRoots: parseAllowedRoots(productEnv(env, "ALLOWED_ROOTS") ?? files.config.allowedRoots),
207
231
  allowedHosts: parseAllowedHosts(productEnv(env, "ALLOWED_HOSTS"), derivedAllowedHosts),
208
232
  publicBaseUrl,
233
+ publicBaseUrls: publicDeployment.baseUrls,
209
234
  toolMode: parseToolMode(env),
210
235
  workflowInstructions: parseWorkflowInstructions(productEnv(env, "WORKFLOW_INSTRUCTIONS"), files.config.workflowInstructions),
211
236
  appendInstructions: parseAppendInstructions(productEnv(env, "APPEND_INSTRUCTIONS"), files.config.appendInstructions),
212
237
  widgets: parseWidgetMode(productEnv(env, "WIDGETS")),
238
+ activityPanelExpanded: productEnv(env, "ACTIVITY_PANEL_EXPANDED") === undefined
239
+ ? files.config.activityPanelExpanded === true
240
+ : parseBoolean(productEnv(env, "ACTIVITY_PANEL_EXPANDED")),
213
241
  stateDir: resolve(expandHomePath(productEnv(env, "STATE_DIR") ?? files.config.stateDir ?? defaultStateDir())),
214
242
  worktreeRoot: resolve(expandHomePath(productEnv(env, "WORKTREE_ROOT") ?? files.config.worktreeRoot ?? defaultWorktreeRoot())),
215
243
  artifactsEnabled: productEnv(env, "ARTIFACTS") === undefined