@nac3/forge-cli 0.2.1-alpha.60 → 1.0.1

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 (303) hide show
  1. package/README.md +5 -1
  2. package/dist/agents/bridge.d.ts.map +1 -1
  3. package/dist/agents/bridge.js +27 -0
  4. package/dist/agents/bridge.js.map +1 -1
  5. package/dist/agents/config_resolver.d.ts.map +1 -1
  6. package/dist/agents/config_resolver.js +15 -15
  7. package/dist/agents/config_resolver.js.map +1 -1
  8. package/dist/agents/runner.d.ts +8 -0
  9. package/dist/agents/runner.d.ts.map +1 -1
  10. package/dist/agents/runner.js +58 -11
  11. package/dist/agents/runner.js.map +1 -1
  12. package/dist/agents/system_prompt.d.ts.map +1 -1
  13. package/dist/agents/system_prompt.js +2 -1
  14. package/dist/agents/system_prompt.js.map +1 -1
  15. package/dist/bin/yf.d.ts.map +1 -1
  16. package/dist/bin/yf.js +18 -0
  17. package/dist/bin/yf.js.map +1 -1
  18. package/dist/chat/claude.d.ts +13 -0
  19. package/dist/chat/claude.d.ts.map +1 -1
  20. package/dist/chat/claude.js +95 -10
  21. package/dist/chat/claude.js.map +1 -1
  22. package/dist/chat/claude_cli.d.ts +10 -0
  23. package/dist/chat/claude_cli.d.ts.map +1 -1
  24. package/dist/chat/claude_cli.js +97 -16
  25. package/dist/chat/claude_cli.js.map +1 -1
  26. package/dist/chat/completion_hallucination_detector.d.ts +11 -0
  27. package/dist/chat/completion_hallucination_detector.d.ts.map +1 -1
  28. package/dist/chat/completion_hallucination_detector.js +37 -2
  29. package/dist/chat/completion_hallucination_detector.js.map +1 -1
  30. package/dist/chat/doctrine_digest.d.ts.map +1 -1
  31. package/dist/chat/doctrine_digest.js +10 -6
  32. package/dist/chat/doctrine_digest.js.map +1 -1
  33. package/dist/chat/panel.d.ts.map +1 -1
  34. package/dist/chat/panel.js +923 -50
  35. package/dist/chat/panel.js.map +1 -1
  36. package/dist/chat/refusal_detector.d.ts.map +1 -1
  37. package/dist/chat/refusal_detector.js +2 -1
  38. package/dist/chat/refusal_detector.js.map +1 -1
  39. package/dist/chat/server.d.ts +1 -0
  40. package/dist/chat/server.d.ts.map +1 -1
  41. package/dist/chat/server.js +960 -73
  42. package/dist/chat/server.js.map +1 -1
  43. package/dist/chat/session_projects.d.ts +31 -0
  44. package/dist/chat/session_projects.d.ts.map +1 -0
  45. package/dist/chat/session_projects.js +36 -0
  46. package/dist/chat/session_projects.js.map +1 -0
  47. package/dist/chat/session_store.d.ts +32 -0
  48. package/dist/chat/session_store.d.ts.map +1 -0
  49. package/dist/chat/session_store.js +58 -0
  50. package/dist/chat/session_store.js.map +1 -0
  51. package/dist/chat/tooling_section.d.ts +11 -0
  52. package/dist/chat/tooling_section.d.ts.map +1 -0
  53. package/dist/chat/tooling_section.js +70 -0
  54. package/dist/chat/tooling_section.js.map +1 -0
  55. package/dist/chat/tools/app_backend_dispatch.d.ts.map +1 -1
  56. package/dist/chat/tools/app_backend_dispatch.js +44 -1
  57. package/dist/chat/tools/app_backend_dispatch.js.map +1 -1
  58. package/dist/chat/tools/fs_editor.d.ts.map +1 -1
  59. package/dist/chat/tools/fs_editor.js +53 -3
  60. package/dist/chat/tools/fs_editor.js.map +1 -1
  61. package/dist/chat/tools/graph.js +2 -2
  62. package/dist/chat/tools/graph.js.map +1 -1
  63. package/dist/chat/tools/keys.d.ts.map +1 -1
  64. package/dist/chat/tools/keys.js +5 -18
  65. package/dist/chat/tools/keys.js.map +1 -1
  66. package/dist/chat/tools/nac3_invoke.d.ts.map +1 -1
  67. package/dist/chat/tools/nac3_invoke.js +16 -7
  68. package/dist/chat/tools/nac3_invoke.js.map +1 -1
  69. package/dist/chat/tools/shell.d.ts +6 -0
  70. package/dist/chat/tools/shell.d.ts.map +1 -1
  71. package/dist/chat/tools/shell.js +39 -0
  72. package/dist/chat/tools/shell.js.map +1 -1
  73. package/dist/chat/tools/workflow.d.ts +20 -0
  74. package/dist/chat/tools/workflow.d.ts.map +1 -1
  75. package/dist/chat/tools/workflow.js +98 -0
  76. package/dist/chat/tools/workflow.js.map +1 -1
  77. package/dist/chat/tools.d.ts.map +1 -1
  78. package/dist/chat/tools.js +79 -1
  79. package/dist/chat/tools.js.map +1 -1
  80. package/dist/chat/turn_control.d.ts +42 -0
  81. package/dist/chat/turn_control.d.ts.map +1 -0
  82. package/dist/chat/turn_control.js +53 -0
  83. package/dist/chat/turn_control.js.map +1 -0
  84. package/dist/commands/brain.js +1 -1
  85. package/dist/commands/chat.d.ts.map +1 -1
  86. package/dist/commands/chat.js +61 -10
  87. package/dist/commands/chat.js.map +1 -1
  88. package/dist/commands/graph.js +9 -9
  89. package/dist/commands/graph.js.map +1 -1
  90. package/dist/commands/keys_setup.d.ts +10 -28
  91. package/dist/commands/keys_setup.d.ts.map +1 -1
  92. package/dist/commands/keys_setup.js +54 -228
  93. package/dist/commands/keys_setup.js.map +1 -1
  94. package/dist/commands/license.d.ts.map +1 -1
  95. package/dist/commands/license.js +52 -28
  96. package/dist/commands/license.js.map +1 -1
  97. package/dist/commands/mcp.d.ts.map +1 -1
  98. package/dist/commands/mcp.js +62 -6
  99. package/dist/commands/mcp.js.map +1 -1
  100. package/dist/commands/mobile.d.ts +1 -1
  101. package/dist/commands/mobile.d.ts.map +1 -1
  102. package/dist/commands/mobile.js +32 -28
  103. package/dist/commands/mobile.js.map +1 -1
  104. package/dist/commands/onboard.d.ts +19 -0
  105. package/dist/commands/onboard.d.ts.map +1 -0
  106. package/dist/commands/onboard.js +126 -0
  107. package/dist/commands/onboard.js.map +1 -0
  108. package/dist/commands/pending.d.ts.map +1 -1
  109. package/dist/commands/pending.js +25 -1
  110. package/dist/commands/pending.js.map +1 -1
  111. package/dist/commands/projects.js +25 -1
  112. package/dist/commands/projects.js.map +1 -1
  113. package/dist/commands/review-screens.js +28 -1
  114. package/dist/commands/review-screens.js.map +1 -1
  115. package/dist/commands/workflow.d.ts +18 -0
  116. package/dist/commands/workflow.d.ts.map +1 -0
  117. package/dist/commands/workflow.js +253 -0
  118. package/dist/commands/workflow.js.map +1 -0
  119. package/dist/core/brain_config.d.ts +2 -0
  120. package/dist/core/brain_config.d.ts.map +1 -1
  121. package/dist/core/brain_config.js +18 -3
  122. package/dist/core/brain_config.js.map +1 -1
  123. package/dist/core/cf_tunnel.d.ts.map +1 -1
  124. package/dist/core/cf_tunnel.js +8 -2
  125. package/dist/core/cf_tunnel.js.map +1 -1
  126. package/dist/core/cost_router.d.ts.map +1 -1
  127. package/dist/core/cost_router.js +11 -2
  128. package/dist/core/cost_router.js.map +1 -1
  129. package/dist/core/desktop_open.d.ts +16 -0
  130. package/dist/core/desktop_open.d.ts.map +1 -0
  131. package/dist/core/desktop_open.js +114 -0
  132. package/dist/core/desktop_open.js.map +1 -0
  133. package/dist/core/keys_envelope.d.ts.map +1 -1
  134. package/dist/core/keys_envelope.js +5 -1
  135. package/dist/core/keys_envelope.js.map +1 -1
  136. package/dist/core/limits_config.d.ts +5 -0
  137. package/dist/core/limits_config.d.ts.map +1 -1
  138. package/dist/core/limits_config.js +5 -0
  139. package/dist/core/limits_config.js.map +1 -1
  140. package/dist/core/logger.d.ts.map +1 -1
  141. package/dist/core/logger.js +3 -0
  142. package/dist/core/logger.js.map +1 -1
  143. package/dist/core/pairing.d.ts +6 -0
  144. package/dist/core/pairing.d.ts.map +1 -1
  145. package/dist/core/pairing.js +41 -2
  146. package/dist/core/pairing.js.map +1 -1
  147. package/dist/core/provider_keys.d.ts.map +1 -1
  148. package/dist/core/provider_keys.js +25 -12
  149. package/dist/core/provider_keys.js.map +1 -1
  150. package/dist/core/provider_models.js +1 -1
  151. package/dist/deploy/cloudflare.d.ts.map +1 -1
  152. package/dist/deploy/cloudflare.js +13 -2
  153. package/dist/deploy/cloudflare.js.map +1 -1
  154. package/dist/design/yujin_design_resolver.d.ts +16 -0
  155. package/dist/design/yujin_design_resolver.d.ts.map +1 -1
  156. package/dist/design/yujin_design_resolver.js +103 -20
  157. package/dist/design/yujin_design_resolver.js.map +1 -1
  158. package/dist/docs/doctrine/default-design-system.md +32 -0
  159. package/dist/docs/doctrine/office-tools.md +20 -0
  160. package/dist/docs/doctrine/pizarron.md +40 -0
  161. package/dist/docs/doctrine/verb-composition.md +16 -0
  162. package/dist/docs/doctrine/workflow-kinds.md +39 -22
  163. package/dist/docs/doctrine/workflow.md +12 -4
  164. package/dist/license/auto_sync.d.ts +22 -0
  165. package/dist/license/auto_sync.d.ts.map +1 -0
  166. package/dist/license/auto_sync.js +91 -0
  167. package/dist/license/auto_sync.js.map +1 -0
  168. package/dist/license/handle_key.d.ts +32 -0
  169. package/dist/license/handle_key.d.ts.map +1 -0
  170. package/dist/license/handle_key.js +56 -0
  171. package/dist/license/handle_key.js.map +1 -0
  172. package/dist/license/hito4_client.d.ts.map +1 -1
  173. package/dist/license/hito4_client.js +63 -25
  174. package/dist/license/hito4_client.js.map +1 -1
  175. package/dist/llm/gemini_client.d.ts.map +1 -1
  176. package/dist/llm/gemini_client.js +5 -2
  177. package/dist/llm/gemini_client.js.map +1 -1
  178. package/dist/llm/managed_client.d.ts.map +1 -1
  179. package/dist/llm/managed_client.js +14 -9
  180. package/dist/llm/managed_client.js.map +1 -1
  181. package/dist/mcp/plan_bridges.d.ts +17 -0
  182. package/dist/mcp/plan_bridges.d.ts.map +1 -0
  183. package/dist/mcp/plan_bridges.js +119 -0
  184. package/dist/mcp/plan_bridges.js.map +1 -0
  185. package/dist/migrate/ai-apply.d.ts.map +1 -1
  186. package/dist/migrate/ai-apply.js +25 -2
  187. package/dist/migrate/ai-apply.js.map +1 -1
  188. package/dist/nac3/internal_manifest.d.ts.map +1 -1
  189. package/dist/nac3/internal_manifest.js +200 -10
  190. package/dist/nac3/internal_manifest.js.map +1 -1
  191. package/dist/nac3/verb_tier_map.d.ts.map +1 -1
  192. package/dist/nac3/verb_tier_map.js +3 -0
  193. package/dist/nac3/verb_tier_map.js.map +1 -1
  194. package/dist/onboarding/concierge_client.d.ts +34 -0
  195. package/dist/onboarding/concierge_client.d.ts.map +1 -0
  196. package/dist/onboarding/concierge_client.js +80 -0
  197. package/dist/onboarding/concierge_client.js.map +1 -0
  198. package/dist/onboarding/next_step.d.ts +32 -0
  199. package/dist/onboarding/next_step.d.ts.map +1 -0
  200. package/dist/onboarding/next_step.js +60 -0
  201. package/dist/onboarding/next_step.js.map +1 -0
  202. package/dist/pending/store.d.ts +7 -0
  203. package/dist/pending/store.d.ts.map +1 -1
  204. package/dist/pending/store.js +19 -0
  205. package/dist/pending/store.js.map +1 -1
  206. package/dist/pending/types.d.ts +20 -0
  207. package/dist/pending/types.d.ts.map +1 -1
  208. package/dist/pending/types.js.map +1 -1
  209. package/dist/reader/parsers/epub.d.ts.map +1 -1
  210. package/dist/reader/parsers/epub.js +6 -1
  211. package/dist/reader/parsers/epub.js.map +1 -1
  212. package/dist/relay/board_snapshot.d.ts +69 -0
  213. package/dist/relay/board_snapshot.d.ts.map +1 -0
  214. package/dist/relay/board_snapshot.js +149 -0
  215. package/dist/relay/board_snapshot.js.map +1 -0
  216. package/dist/relay/command_consumer.d.ts +97 -0
  217. package/dist/relay/command_consumer.d.ts.map +1 -0
  218. package/dist/relay/command_consumer.js +135 -0
  219. package/dist/relay/command_consumer.js.map +1 -0
  220. package/dist/relay/mock_relay.d.ts +34 -0
  221. package/dist/relay/mock_relay.d.ts.map +1 -0
  222. package/dist/relay/mock_relay.js +93 -0
  223. package/dist/relay/mock_relay.js.map +1 -0
  224. package/dist/relay/relay_client.d.ts +28 -0
  225. package/dist/relay/relay_client.d.ts.map +1 -0
  226. package/dist/relay/relay_client.js +80 -0
  227. package/dist/relay/relay_client.js.map +1 -0
  228. package/dist/relay/relay_publisher.d.ts +59 -0
  229. package/dist/relay/relay_publisher.d.ts.map +1 -0
  230. package/dist/relay/relay_publisher.js +101 -0
  231. package/dist/relay/relay_publisher.js.map +1 -0
  232. package/dist/relay/types.d.ts +54 -0
  233. package/dist/relay/types.d.ts.map +1 -0
  234. package/dist/relay/types.js +8 -0
  235. package/dist/relay/types.js.map +1 -0
  236. package/dist/semantic_graph/ast_extract.d.ts +5 -0
  237. package/dist/semantic_graph/ast_extract.d.ts.map +1 -1
  238. package/dist/semantic_graph/ast_extract.js +46 -0
  239. package/dist/semantic_graph/ast_extract.js.map +1 -1
  240. package/dist/semantic_graph/derive.d.ts +12 -0
  241. package/dist/semantic_graph/derive.d.ts.map +1 -1
  242. package/dist/semantic_graph/derive.js +325 -6
  243. package/dist/semantic_graph/derive.js.map +1 -1
  244. package/dist/semantic_graph/store.d.ts +9 -0
  245. package/dist/semantic_graph/store.d.ts.map +1 -1
  246. package/dist/semantic_graph/store.js +23 -1
  247. package/dist/semantic_graph/store.js.map +1 -1
  248. package/dist/semantic_graph/types.d.ts +1 -1
  249. package/dist/semantic_graph/types.d.ts.map +1 -1
  250. package/dist/support/github_dispatcher.d.ts.map +1 -1
  251. package/dist/support/github_dispatcher.js +30 -2
  252. package/dist/support/github_dispatcher.js.map +1 -1
  253. package/dist/tasks/supervisor.d.ts +88 -0
  254. package/dist/tasks/supervisor.d.ts.map +1 -0
  255. package/dist/tasks/supervisor.js +308 -0
  256. package/dist/tasks/supervisor.js.map +1 -0
  257. package/dist/tasks/task_type.d.ts +11 -0
  258. package/dist/tasks/task_type.d.ts.map +1 -0
  259. package/dist/tasks/task_type.js +21 -0
  260. package/dist/tasks/task_type.js.map +1 -0
  261. package/dist/vault/store.d.ts.map +1 -1
  262. package/dist/vault/store.js +6 -1
  263. package/dist/vault/store.js.map +1 -1
  264. package/dist/version.d.ts +3 -5
  265. package/dist/version.d.ts.map +1 -1
  266. package/dist/version.js +3 -5
  267. package/dist/version.js.map +1 -1
  268. package/dist/voice/providers/google.d.ts.map +1 -1
  269. package/dist/voice/providers/google.js +6 -4
  270. package/dist/voice/providers/google.js.map +1 -1
  271. package/dist/workflow/config.d.ts +43 -0
  272. package/dist/workflow/config.d.ts.map +1 -0
  273. package/dist/workflow/config.js +199 -0
  274. package/dist/workflow/config.js.map +1 -0
  275. package/dist/workflow/instances.d.ts +40 -0
  276. package/dist/workflow/instances.d.ts.map +1 -0
  277. package/dist/workflow/instances.js +122 -0
  278. package/dist/workflow/instances.js.map +1 -0
  279. package/dist/workflow/multi_view.d.ts +27 -0
  280. package/dist/workflow/multi_view.d.ts.map +1 -0
  281. package/dist/workflow/multi_view.js +76 -0
  282. package/dist/workflow/multi_view.js.map +1 -0
  283. package/dist/workflow/orchestrator.d.ts +111 -0
  284. package/dist/workflow/orchestrator.d.ts.map +1 -0
  285. package/dist/workflow/orchestrator.js +241 -0
  286. package/dist/workflow/orchestrator.js.map +1 -0
  287. package/docs/doctrine/default-design-system.md +32 -0
  288. package/docs/doctrine/office-tools.md +20 -0
  289. package/docs/doctrine/pizarron.md +40 -0
  290. package/docs/doctrine/verb-composition.md +16 -0
  291. package/docs/doctrine/workflow-kinds.md +39 -22
  292. package/docs/doctrine/workflow.md +12 -4
  293. package/package.json +2 -1
  294. package/src/i18n/catalogs/ar.json +223 -210
  295. package/src/i18n/catalogs/de.json +223 -210
  296. package/src/i18n/catalogs/en.json +223 -210
  297. package/src/i18n/catalogs/es.json +223 -210
  298. package/src/i18n/catalogs/fr.json +223 -210
  299. package/src/i18n/catalogs/hi.json +223 -210
  300. package/src/i18n/catalogs/it.json +223 -210
  301. package/src/i18n/catalogs/ja.json +223 -210
  302. package/src/i18n/catalogs/pt.json +223 -210
  303. package/src/i18n/catalogs/zh.json +223 -210
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { createServer } from 'node:http';
15
15
  import { promises as fs } from 'node:fs';
16
+ import { existsSync, realpathSync } from 'node:fs';
16
17
  import os from 'node:os';
17
18
  import path from 'node:path';
18
19
  import { ClaudeClient, ConfigurationError, ClaudeApiError } from './claude.js';
@@ -39,6 +40,12 @@ import { generatePlan } from './spec_plan.js';
39
40
  import { executeScaffold, rollbackScaffold } from './spec_scaffold.js';
40
41
  import { VERSION } from '../version.js';
41
42
  import { buildDoctrineDigestLines } from './doctrine_digest.js';
43
+ import { RelayPublisher } from '../relay/relay_publisher.js';
44
+ import { relayPoll } from '../relay/relay_client.js';
45
+ import { startCommandConsumer } from '../relay/command_consumer.js';
46
+ import { getSessionStore, DEFAULT_SESSION_ID } from './session_store.js';
47
+ import { getSessionProject, setSessionProject } from './session_projects.js';
48
+ import { beginTurn, endTurn, interruptActiveTurn, isTurnActive } from './turn_control.js';
42
49
  import { manifest as nac3Manifest } from '../nac3/internal_manifest.js';
43
50
  import { buildManifestForPrompt } from '../nac3/manifest_lazy.js';
44
51
  import { popPending, logApproval } from '../nac3/approval_queue.js';
@@ -126,23 +133,53 @@ export async function startChatServer(opts) {
126
133
  * (to hand to its MCP server) share it. Only meaningful in CLI
127
134
  * mode; harmless otherwise. */
128
135
  const panelPushToken = (await import('node:crypto')).randomBytes(32).toString('hex');
129
- let claude;
130
- if (opts.claude) {
131
- claude = opts.claude;
132
- }
133
- else if (opts.chatProvider === 'claude-code-cli') {
134
- const { ClaudeCliClient } = await import('./claude_cli.js');
135
- const cliOpts = {};
136
- if (opts.chatProviderAddDirs && opts.chatProviderAddDirs.length > 0) {
137
- cliOpts['addDirs'] = opts.chatProviderAddDirs;
136
+ /* Build the chat LLM client for a provider. Extracted so the brain
137
+ * mode (Plan = subscription via `claude -p`, Console = BYOK Anthropic
138
+ * HTTP) can HOT-SWAP at runtime when the user flips it in the panel --
139
+ * no panel restart. */
140
+ async function buildClaudeClient(provider) {
141
+ if (provider === 'claude-code-cli') {
142
+ const { ClaudeCliClient } = await import('./claude_cli.js');
143
+ const cliOpts = {};
144
+ if (opts.chatProviderAddDirs && opts.chatProviderAddDirs.length > 0) {
145
+ cliOpts['addDirs'] = opts.chatProviderAddDirs;
146
+ }
147
+ /* Configurable Plan-mode turn cap (NADA hardcode): the 30-min default
148
+ * lives in limits.json and is tunable via `yf limits set
149
+ * claude_cli_timeout_ms <ms>` or the panel. */
150
+ try {
151
+ const { readLimitsConfig } = await import('../core/limits_config.js');
152
+ cliOpts['timeoutMs'] = (await readLimitsConfig()).claude_cli_timeout_ms;
153
+ }
154
+ catch { /* fall back to the ClaudeCliClient default (30 min) */ }
155
+ /* Wire the panel MCP bridge: the subprocess gets the forge_panel
156
+ * MCP server so it can create pizarron tabs. */
157
+ cliOpts['panelBridge'] = { port: opts.port, token: panelPushToken };
158
+ return new ClaudeCliClient(cliOpts);
138
159
  }
139
- /* Wire the panel MCP bridge: the subprocess gets the
140
- * forge_panel MCP server so it can create pizarron tabs. */
141
- cliOpts['panelBridge'] = { port: opts.port, token: panelPushToken };
142
- claude = new ClaudeCliClient(cliOpts);
143
- }
144
- else {
145
- claude = new ClaudeClient();
160
+ return new ClaudeClient();
161
+ }
162
+ /* MUTABLE per-session provider state, so flipping Plan/Console in the
163
+ * panel applies LIVE (mirrors the reanchor pattern). */
164
+ let activeChatProvider = opts.chatProvider ?? 'anthropic-api';
165
+ let claude = opts.claude ?? await buildClaudeClient(activeChatProvider);
166
+ /** Hot-swap the brain provider when the user flips the mode in the
167
+ * panel. 'plan' -> claude-code-cli (subscription); 'console' ->
168
+ * anthropic-api (BYOK). No-op for a test-injected client. Returns
169
+ * true if the live provider actually changed. */
170
+ async function setBrainProvider(mode) {
171
+ if (opts.claude)
172
+ return false; /* test harness owns the client */
173
+ const target = mode === 'plan' ? 'claude-code-cli' : 'anthropic-api';
174
+ if (target === activeChatProvider)
175
+ return false;
176
+ claude = await buildClaudeClient(target);
177
+ activeChatProvider = target;
178
+ try {
179
+ console.error('[brain] hot-swap provider -> ' + target + ' (' + mode + ')');
180
+ }
181
+ catch { /* */ }
182
+ return true;
146
183
  }
147
184
  const voice = opts.voice ?? new VoiceRouter({
148
185
  configDir: configDir(),
@@ -177,6 +214,26 @@ export async function startChatServer(opts) {
177
214
  * handlers (que leen de `session`) pasan a apuntar al nuevo root
178
215
  * sin reiniciar el panel. */
179
216
  const session = { projectRoot: opts.projectRoot, projectName, projectSlug };
217
+ /* Project registry (task #33) -- record the project we just opened in
218
+ * ~/.yujin-forge/projects.json + mark it active, so the project
219
+ * selector (panel + mobile) always lists at least the current one.
220
+ *
221
+ * GUARD (Pablo 2026-06-15, log evidence): every server boot here wrote
222
+ * to the GLOBAL registry, and the many tests that boot the server do
223
+ * NOT isolate the config dir -- so `pnpm test` was clobbering the real
224
+ * ~/.yujin-forge/projects.json with temp fixture dirs. Skip in test /
225
+ * ephemeral runs (vitest sets VITEST; also skip an obvious temp root). */
226
+ const isTestEnv = !!process.env.VITEST || process.env.NODE_ENV === 'test';
227
+ const isTempRoot = /[\\/](?:te?mp|yf-[a-z-]*proj|yf-startup)/i.test(opts.projectRoot);
228
+ if (!isTestEnv && !isTempRoot) {
229
+ void (async () => {
230
+ try {
231
+ await upsertProject({ slug: projectSlug, path: opts.projectRoot });
232
+ await setActive(projectSlug);
233
+ }
234
+ catch { /* registry is best-effort */ }
235
+ })();
236
+ }
180
237
  async function reanchor(newRoot) {
181
238
  const abs = (await import('node:path')).resolve(newRoot);
182
239
  const { enqueuePanelDispatch } = await import('./panel_queue.js');
@@ -193,6 +250,12 @@ export async function startChatServer(opts) {
193
250
  session.projectName = await readProjectName(abs);
194
251
  const { resolveProjectSlug } = await import('./project_slug.js');
195
252
  session.projectSlug = await resolveProjectSlug(abs);
253
+ /* keep the registry in sync after a hot switch (task #33) */
254
+ try {
255
+ await upsertProject({ slug: session.projectSlug, path: abs });
256
+ await setActive(session.projectSlug);
257
+ }
258
+ catch { /* best-effort */ }
196
259
  chatLog('Cambiando al proyecto ' + session.projectName + ' (' + abs + ')...');
197
260
  /* re-hidratar ingest + reiniciar el watcher sobre el nuevo dir */
198
261
  try {
@@ -220,6 +283,16 @@ export async function startChatServer(opts) {
220
283
  console.error('[reanchor] active project -> ' + abs + ' (slug=' + session.projectSlug + ')');
221
284
  }
222
285
  catch { /* */ }
286
+ /* Mobile project bubble (Pablo 2026-06-15) -- a project switch is a
287
+ * CONTROL command (no chat turn), and the board snapshot the phone
288
+ * reads `activeProject` from is only published on a chat turn. So
289
+ * republish it NOW with the new project, otherwise the phone's bubble
290
+ * stays on the old project after a switch (from either side). */
291
+ try {
292
+ setRelayBoardCtx(abs, session.projectSlug);
293
+ publishBoardSnapshot();
294
+ }
295
+ catch { /* best-effort */ }
223
296
  /* Regenerar el grafo del nuevo proyecto (crea los objetos si no
224
297
  * existen) en BACKGROUND -- no bloquear la respuesta HTTP del
225
298
  * verbo (sino el panel-invoke del subproceso da timeout =
@@ -267,9 +340,10 @@ export async function startChatServer(opts) {
267
340
  voice,
268
341
  store,
269
342
  runtime: opts.runtime,
270
- chatProvider: opts.chatProvider,
343
+ chatProvider: activeChatProvider,
271
344
  panelPushToken,
272
345
  reanchor,
346
+ setBrainProvider,
273
347
  });
274
348
  }
275
349
  catch (err) {
@@ -293,6 +367,45 @@ export async function startChatServer(opts) {
293
367
  server.once('error', reject);
294
368
  server.listen(opts.port, '127.0.0.1', () => resolve());
295
369
  });
370
+ /* P2 Fase 2 -- start the relay command consumer once we are listening
371
+ * (it loops back to /api/chat on this port). Gated YF_RELAY=1. */
372
+ let commandConsumerHandle = null;
373
+ if (relayEnabled()) {
374
+ try {
375
+ commandConsumerHandle = startDesktopCommandConsumer(opts.port);
376
+ }
377
+ catch {
378
+ commandConsumerHandle = null;
379
+ }
380
+ }
381
+ /* PND-007 -- Task Supervisor: reconcile persisted tasks on boot, and
382
+ * report completions back to the user (panel SSE + the paired phone via
383
+ * the relay). This is the "the SERVER tells you when it's done" that
384
+ * replaces the impossible in-turn watcher. */
385
+ try {
386
+ const { getTaskSupervisor, setTaskReporter } = await import('../tasks/supervisor.js');
387
+ setTaskReporter((rec) => {
388
+ /* relay to the phone (best-effort) */
389
+ try {
390
+ const pub = getRelayPublisher();
391
+ if (pub) {
392
+ const verb = rec.status === 'done' ? 'termino'
393
+ : rec.status === 'failed' ? 'fallo' : rec.status;
394
+ pub.activity('Tarea ' + verb + ': ' + rec.label, { task_id: rec.id, status: rec.status, exit_code: rec.exit_code ?? null });
395
+ pub.status('task_' + rec.status, { task_id: rec.id, label: rec.label });
396
+ void pub.flush();
397
+ }
398
+ }
399
+ catch { /* noop */ }
400
+ /* nudge the panel (it polls /api/forge/task; this is a log marker) */
401
+ try {
402
+ console.error('[task] ' + rec.id + ' ' + rec.status + ' (' + rec.label + ')');
403
+ }
404
+ catch { /* noop */ }
405
+ });
406
+ getTaskSupervisor(projectSlug); /* triggers init()+reconcile() */
407
+ }
408
+ catch { /* supervisor optional */ }
296
409
  const url = 'http://127.0.0.1:' + opts.port + '/';
297
410
  return {
298
411
  server,
@@ -304,6 +417,13 @@ export async function startChatServer(opts) {
304
417
  if (componentWatcherHandle) {
305
418
  Promise.resolve(componentWatcherHandle.stop()).catch(() => undefined);
306
419
  }
420
+ /* P2 Fase 2 -- stop the relay command consumer. */
421
+ if (commandConsumerHandle) {
422
+ try {
423
+ commandConsumerHandle.stop();
424
+ }
425
+ catch { /* noop */ }
426
+ }
307
427
  /* alpha.59z.136 slice 14 -- flush in-flight ingest session
308
428
  * writes BEFORE the server closes so test teardowns that
309
429
  * fs.rm the home directory immediately after do not race
@@ -320,8 +440,374 @@ export async function startChatServer(opts) {
320
440
  }),
321
441
  };
322
442
  }
443
+ /* P2 (mobile remote control) -- the desktop mirrors its Coding-feed
444
+ * activity to the worker relay so a paired phone can watch the work.
445
+ * OFF by default; opt in with env YF_RELAY=1 (the worker must exist).
446
+ * Best-effort: the publisher swallows every error, so an unreachable
447
+ * relay never affects a chat turn. */
448
+ function relayEnabled() {
449
+ const v = process.env.YF_RELAY;
450
+ return v === '1' || v === 'true' || v === 'yes';
451
+ }
452
+ let _relayPub = null;
453
+ function getRelayPublisher() {
454
+ if (!relayEnabled())
455
+ return null;
456
+ if (!_relayPub) {
457
+ _relayPub = new RelayPublisher({
458
+ deps: { deviceId: 'desktop-' + process.pid }, /* default auth = vault + license */
459
+ enabled: relayEnabled,
460
+ });
461
+ }
462
+ return _relayPub;
463
+ }
464
+ /* P2 Fase 2 -- desktop command consumer. Polls the worker relay for the
465
+ * paired phone's chat commands, runs each through THIS server's own
466
+ * /api/chat (loopback, so the full BYOK brain + project context apply),
467
+ * and publishes the reply back as chat_reply. Gated YF_RELAY=1. */
468
+ function startDesktopCommandConsumer(port) {
469
+ const deviceId = 'desktop-' + process.pid;
470
+ const pollCommands = async (since) => {
471
+ const r = await relayPoll({ deviceId }, since, ['command']);
472
+ return {
473
+ ok: r.ok !== false,
474
+ events: (r.events ?? []).map((e) => ({
475
+ seq: e.seq,
476
+ payload: (e.payload ?? {}),
477
+ ts: e.ts,
478
+ })),
479
+ cursor: typeof r.cursor === 'number' ? r.cursor : since,
480
+ };
481
+ };
482
+ const reply = (commandId, text, done) => {
483
+ const pub = getRelayPublisher();
484
+ if (!pub)
485
+ return;
486
+ pub.chatReply(commandId, text, done);
487
+ void pub.flush(); /* push the reply promptly, do not wait the window */
488
+ };
489
+ const runTurn = async (text, sessionId) => {
490
+ const resp = await fetch('http://127.0.0.1:' + port + '/api/chat', {
491
+ method: 'POST',
492
+ headers: { 'content-type': 'application/json' },
493
+ body: JSON.stringify({
494
+ messages: [{ role: 'user', content: text }],
495
+ ...(sessionId ? { session_id: sessionId } : {}),
496
+ /* UI sync (task #32) -- tag relay turns so handleChat can echo
497
+ * the unified-thread ones to the panel (the laptop sees what was
498
+ * asked from the phone). */
499
+ origin: 'mobile',
500
+ }),
501
+ });
502
+ const j = await resp.json();
503
+ if (!j || j.ok === false)
504
+ throw new Error(j?.error || 'turn failed');
505
+ return String(j.message?.text ?? '');
506
+ };
507
+ const onControl = async (action, args) => {
508
+ /* Control commands from the phone (tasks #33, #29) go through the
509
+ * same loopback the chat turns use -- no extra coupling. */
510
+ if (action === 'switch_project') {
511
+ const slug = typeof args.slug === 'string' ? args.slug : '';
512
+ if (!slug)
513
+ return 'Falta el slug del proyecto.';
514
+ const sid = typeof args.session_id === 'string' ? args.session_id : '';
515
+ /* Resolve the target path up front so we can ALWAYS re-point the
516
+ * phone's mirrored board at the new project (Pablo 2026-06-16: the
517
+ * board/bubble were staying on the old project because the switch
518
+ * moved the session/registry but did not always update the relay
519
+ * board ctx). */
520
+ const { readRegistry } = await import('../core/projects.js');
521
+ const reg = await readRegistry();
522
+ const entry = reg.projects.find((p) => p.slug === slug);
523
+ if (!entry || !entry.path)
524
+ return 'Proyecto no encontrado: ' + slug;
525
+ /* The phone mirrors ONE board; whichever project the phone switches
526
+ * to (own session OR notebook) is what its Pizarron + bubble must
527
+ * show. Re-point + republish directly, independent of reanchor. */
528
+ const followBoard = () => {
529
+ try {
530
+ setRelayBoardCtx(entry.path, slug);
531
+ publishBoardSnapshot();
532
+ }
533
+ catch { /* best-effort */ }
534
+ };
535
+ /* Per-session switch (Pablo 2026-06-15): a "Sesion Propia" points
536
+ * its OWN turns at this project WITHOUT moving the desktop's global
537
+ * project. Default/notebook session -> the global hot reanchor. */
538
+ if (sid && sid !== DEFAULT_SESSION_ID) {
539
+ setSessionProject(sid, entry.path, slug);
540
+ try {
541
+ const pub = getRelayPublisher();
542
+ if (pub) {
543
+ pub.sessionProject(sid, slug);
544
+ void pub.flush();
545
+ }
546
+ }
547
+ catch { /* */ }
548
+ followBoard();
549
+ return 'Sesion propia -> ' + slug;
550
+ }
551
+ const resp = await fetch('http://127.0.0.1:' + port + '/api/forge/projects/active', {
552
+ method: 'POST', headers: { 'content-type': 'application/json' },
553
+ body: JSON.stringify({ slug }),
554
+ });
555
+ const j = await resp.json();
556
+ if (!j || j.ok === false)
557
+ throw new Error(j?.error || 'switch failed');
558
+ followBoard();
559
+ return 'Proyecto activo: ' + (j.active || slug)
560
+ + (j.reanchored === false ? ' (reinicia para aplicar)' : '');
561
+ }
562
+ if (action === 'interrupt') {
563
+ const resp = await fetch('http://127.0.0.1:' + port + '/api/chat/interrupt', { method: 'POST' });
564
+ const j = await resp.json().catch(() => ({}));
565
+ return j && j.active ? 'Turno interrumpido.' : 'No habia ningun turno en curso.';
566
+ }
567
+ return 'Accion no soportada: ' + action;
568
+ };
569
+ /* Two consumers over the same stream (task #29): the CHAT consumer
570
+ * runs turns (and is blocked while a turn is in flight); the fast
571
+ * CONTROL consumer handles interrupt/switch_project independently, so
572
+ * a phone interrupt lands MID-TURN instead of waiting for it to end.
573
+ * Each filters to its own subset; the other still advances its cursor
574
+ * past the skipped events. */
575
+ const chat = startCommandConsumer({
576
+ enabled: relayEnabled, intervalMs: 3000,
577
+ accept: (c) => !c.control,
578
+ pollCommands, runTurn, reply,
579
+ ackImmediate: true, /* confirm receipt before the turn runs */
580
+ onError: () => { },
581
+ });
582
+ const control = startCommandConsumer({
583
+ enabled: relayEnabled, intervalMs: 1200,
584
+ accept: (c) => !!c.control,
585
+ pollCommands, runTurn, onControl, reply,
586
+ onError: () => { },
587
+ });
588
+ return {
589
+ stop() { chat.stop(); control.stop(); },
590
+ tickOnce: async () => { await chat.tickOnce(); await control.tickOnce(); },
591
+ };
592
+ }
593
+ /* Project context for board snapshots, set per chat turn (handleChat). */
594
+ let _relayBoardCtx = null;
595
+ function setRelayBoardCtx(projectRoot, slug) {
596
+ _relayBoardCtx = { projectRoot, slug };
597
+ }
598
+ const _recentDocs = [];
599
+ function pushRecentDoc(title, kind, content) {
600
+ _recentDocs.push({
601
+ title: String(title || 'documento').slice(0, 120),
602
+ kind: String(kind || 'doc'),
603
+ content: String(content || '').slice(0, 40000),
604
+ ts: new Date().toISOString(),
605
+ });
606
+ while (_recentDocs.length > 12)
607
+ _recentDocs.shift();
608
+ }
609
+ /* Publish a compact board snapshot (workflow + pendings + recent docs)
610
+ * for the phone's Pizarron tab. Fire-and-forget; best-effort. */
611
+ function publishBoardSnapshot() {
612
+ const pub = getRelayPublisher();
613
+ if (!pub || !_relayBoardCtx)
614
+ return;
615
+ const cx = _relayBoardCtx;
616
+ void (async () => {
617
+ try {
618
+ const { buildBoardSnapshot } = await import('../relay/board_snapshot.js');
619
+ const snap = await buildBoardSnapshot(cx.projectRoot, cx.slug);
620
+ /* newest doc first */
621
+ snap.docs = _recentDocs.slice().reverse();
622
+ /* i18n (task #34) -- stamp the desktop's active language so the
623
+ * paired phone inherits it (the mobile UI has no language picker;
624
+ * it follows the notebook). One of the 10 canonical tags. */
625
+ try {
626
+ const { currentLanguage } = await import('../i18n/index.js');
627
+ snap.lang = currentLanguage();
628
+ }
629
+ catch { /* leave unset -> mobile keeps its fallback */ }
630
+ /* Project selector (task #33) -- relay the known projects + the
631
+ * active slug so the phone can switch the desktop's project (a
632
+ * control command flows back through the consumer). Compact: slug
633
+ * + name + path, capped. */
634
+ try {
635
+ const reg = await readRegistry();
636
+ snap.projects = (reg.projects || []).slice(0, 30).map((p) => ({
637
+ slug: p.slug, path: p.path || '',
638
+ }));
639
+ snap.activeProject = reg.active_slug || cx.slug || null;
640
+ }
641
+ catch { /* leave unset */ }
642
+ pub.board(snap);
643
+ /* PND-094 ext -- publish the semantic graph as its OWN event so it
644
+ * is not capped by the board's 32KB budget. */
645
+ try {
646
+ const { buildGraphSnapshot } = await import('../relay/board_snapshot.js');
647
+ const g = cx.slug ? await buildGraphSnapshot(cx.slug) : null;
648
+ if (g)
649
+ pub.graph(g);
650
+ }
651
+ catch { /* no graph -> skip */ }
652
+ void pub.flush();
653
+ }
654
+ catch { /* best-effort */ }
655
+ })();
656
+ }
657
+ /* PND-007 -- Task Supervisor HTTP surface for the panel Tareas tab.
658
+ * GET /api/forge/task -> list; POST /api/forge/task -> run. */
659
+ async function handleForgeTask(req, res, ctx, _url) {
660
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
661
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
662
+ if (req.method === 'GET') {
663
+ sendJson(res, 200, { ok: true, tasks: sup.list() });
664
+ return;
665
+ }
666
+ if (req.method === 'POST') {
667
+ let body;
668
+ try {
669
+ body = JSON.parse(await readBody(req));
670
+ }
671
+ catch {
672
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
673
+ return;
674
+ }
675
+ if (typeof body.cmd !== 'string' || !body.cmd) {
676
+ sendJson(res, 400, { ok: false, error: 'cmd (string) required' });
677
+ return;
678
+ }
679
+ const rec = await sup.run({
680
+ cmd: body.cmd,
681
+ args: Array.isArray(body.args) ? body.args.map(String) : [],
682
+ cwd: typeof body.cwd === 'string' && body.cwd ? body.cwd : ctx.projectRoot,
683
+ label: typeof body.label === 'string' ? body.label : undefined,
684
+ });
685
+ sendJson(res, 200, { ok: rec.status !== 'failed', task: rec });
686
+ return;
687
+ }
688
+ sendJson(res, 405, { ok: false, error: 'method not allowed' });
689
+ }
690
+ async function handleForgeTaskLog(_req, res, ctx, url) {
691
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
692
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
693
+ const id = url.searchParams.get('id') ?? '';
694
+ const lines = Number.parseInt(url.searchParams.get('lines') ?? '60', 10) || 60;
695
+ if (!sup.get(id)) {
696
+ sendJson(res, 404, { ok: false, error: 'unknown task id' });
697
+ return;
698
+ }
699
+ const tail = await sup.tailLog(id, Math.min(lines, 500));
700
+ sendJson(res, 200, { ok: true, id, tail });
701
+ }
702
+ async function handleForgeTaskKill(req, res, ctx) {
703
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
704
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
705
+ let body;
706
+ try {
707
+ body = JSON.parse(await readBody(req));
708
+ }
709
+ catch {
710
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
711
+ return;
712
+ }
713
+ const id = typeof body.id === 'string' ? body.id : '';
714
+ const killed = sup.kill(id);
715
+ sendJson(res, killed ? 200 : 404, { ok: killed, id });
716
+ }
717
+ async function handleForgeTaskDelete(req, res, ctx) {
718
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
719
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
720
+ let body;
721
+ try {
722
+ body = JSON.parse(await readBody(req));
723
+ }
724
+ catch {
725
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
726
+ return;
727
+ }
728
+ const id = typeof body.id === 'string' ? body.id : '';
729
+ const removed = await sup.remove(id);
730
+ sendJson(res, removed ? 200 : 404, { ok: removed, id });
731
+ }
732
+ function relayForward(event, data) {
733
+ const pub = getRelayPublisher();
734
+ if (!pub)
735
+ return;
736
+ try {
737
+ const d = (data && typeof data === 'object') ? data : {};
738
+ if (event === 'tool_round_start') {
739
+ pub.activity(String(d.summary || d.verb_hint || d.verb_id || 'trabajando'), d.detail);
740
+ }
741
+ else if (event === 'turn_start') {
742
+ pub.status('working', { tier: d.tier, provider: d.provider, model: d.model });
743
+ }
744
+ else if (event === 'turn_complete') {
745
+ pub.status('round_done');
746
+ /* refresh the phone's board after each turn (workflow/pendings may
747
+ * have changed). */
748
+ publishBoardSnapshot();
749
+ }
750
+ else if (event === 'error') {
751
+ pub.status('error', { message: d.message ?? d.error ?? '' });
752
+ }
753
+ }
754
+ catch { /* relay best-effort */ }
755
+ }
756
+ /* PND-045 (sec 1.1) -- CSRF / DNS-rebinding defense for the local
757
+ * panel server. Even bound to 127.0.0.1, the server is reachable
758
+ * from any web page the dev visits (a "simple" text/plain fetch
759
+ * needs no CORS preflight, and DNS rebinding sidesteps guessing the
760
+ * port). Two checks, applied to every state-mutating request:
761
+ * - Host: must name a loopback host. A rebound DNS name arrives as
762
+ * Host: attacker.com, so rejecting foreign Host kills rebinding.
763
+ * - Origin: when present (browsers always send it on cross-origin
764
+ * POST, including text/plain), it must be a loopback origin. The
765
+ * panel's own requests carry Origin http://127.0.0.1:<port>, so
766
+ * legitimate traffic passes; non-browser clients (tests, relay)
767
+ * send no Origin and are unaffected. No feature is lost. */
768
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
769
+ function hostnameOf(hostHeader) {
770
+ const h = hostHeader.trim().toLowerCase();
771
+ if (!h)
772
+ return '';
773
+ if (h.startsWith('[')) {
774
+ const i = h.indexOf(']');
775
+ return i >= 0 ? h.slice(0, i + 1) : h;
776
+ }
777
+ const c = h.indexOf(':');
778
+ return c >= 0 ? h.slice(0, c) : h;
779
+ }
780
+ function isLocalMutatingRequestOk(req) {
781
+ const host = hostnameOf(String(req.headers.host ?? ''));
782
+ if (host && !LOOPBACK_HOSTS.has(host)) {
783
+ return { ok: false, reason: 'Host no-loopback rechazado (posible DNS-rebinding): ' + host.slice(0, 60) };
784
+ }
785
+ const origin = req.headers.origin;
786
+ if (typeof origin === 'string' && origin && origin.toLowerCase() !== 'null') {
787
+ let oh;
788
+ try {
789
+ oh = new URL(origin).hostname.toLowerCase();
790
+ }
791
+ catch {
792
+ return { ok: false, reason: 'Origin malformado' };
793
+ }
794
+ const normalized = oh.startsWith('[') ? oh : oh; /* URL.hostname already strips ipv6 brackets to bare ::1 on Node */
795
+ if (!LOOPBACK_HOSTS.has(normalized) && !LOOPBACK_HOSTS.has('[' + normalized + ']')) {
796
+ return { ok: false, reason: 'Origin cross-site rechazado (CSRF): ' + origin.slice(0, 80) };
797
+ }
798
+ }
799
+ return { ok: true };
800
+ }
801
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
323
802
  async function route(req, res, ctx) {
324
803
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
804
+ if (MUTATING_METHODS.has(req.method ?? '')) {
805
+ const guard = isLocalMutatingRequestOk(req);
806
+ if (!guard.ok) {
807
+ sendJson(res, 403, { ok: false, error: 'forbidden', reason: guard.reason });
808
+ return;
809
+ }
810
+ }
325
811
  if (req.method === 'GET' && url.pathname === '/') {
326
812
  // Stateless lang resolution: query string > cookie >
327
813
  // server-wide setLanguage. This isolates per-request
@@ -419,6 +905,17 @@ async function route(req, res, ctx) {
419
905
  await handleChat(req, res, ctx);
420
906
  return;
421
907
  }
908
+ /* Interrupt (task #27) -- stop the in-flight turn. The panel's
909
+ * "Detener" button (and the mobile control path) POST here; we
910
+ * abort the active turn's signal so the caller unwinds (claude.ts
911
+ * breaks at the next round boundary; claude_cli kills `claude -p`).
912
+ * Idempotent: returns active:false when nothing is running. */
913
+ if (req.method === 'POST' && url.pathname === '/api/chat/interrupt') {
914
+ const wasActive = isTurnActive();
915
+ const r = interruptActiveTurn();
916
+ sendJson(res, 200, { ok: true, active: wasActive, interrupted: r.ok, turn_id: r.id });
917
+ return;
918
+ }
422
919
  /* Vault surface (V0.4 of HITO 0). Same-origin: the server only
423
920
  listens on 127.0.0.1 and the browser blocks cross-origin
424
921
  fetches from other sites by default (no CORS headers set).
@@ -996,7 +1493,7 @@ async function route(req, res, ctx) {
996
1493
  return;
997
1494
  }
998
1495
  if (req.method === 'POST' && url.pathname === '/api/forge/projects/active') {
999
- await handleProjectsSwitch(req, res);
1496
+ await handleProjectsSwitch(req, res, ctx);
1000
1497
  return;
1001
1498
  }
1002
1499
  if (req.method === 'GET' && url.pathname === '/api/forge/projects/active') {
@@ -1087,7 +1584,7 @@ async function route(req, res, ctx) {
1087
1584
  return;
1088
1585
  }
1089
1586
  if (req.method === 'POST' && url.pathname === '/api/forge/brain-config') {
1090
- await handleBrainConfigSet(req, res);
1587
+ await handleBrainConfigSet(req, res, ctx);
1091
1588
  return;
1092
1589
  }
1093
1590
  /* PND-069 -- modelos disponibles por provider (API en vivo con
@@ -1109,6 +1606,23 @@ async function route(req, res, ctx) {
1109
1606
  await handlePanelQueue(req, res, url);
1110
1607
  return;
1111
1608
  }
1609
+ /* PND-007 -- Task Supervisor: the panel Tareas tab polls + acts here. */
1610
+ if (url.pathname === '/api/forge/task') {
1611
+ await handleForgeTask(req, res, ctx, url);
1612
+ return;
1613
+ }
1614
+ if (req.method === 'GET' && url.pathname === '/api/forge/task/log') {
1615
+ await handleForgeTaskLog(req, res, ctx, url);
1616
+ return;
1617
+ }
1618
+ if (req.method === 'POST' && url.pathname === '/api/forge/task/kill') {
1619
+ await handleForgeTaskKill(req, res, ctx);
1620
+ return;
1621
+ }
1622
+ if (req.method === 'POST' && url.pathname === '/api/forge/task/delete') {
1623
+ await handleForgeTaskDelete(req, res, ctx);
1624
+ return;
1625
+ }
1112
1626
  /* GRAPH-1 -- regenerar el grafo semantico (nodos + aristas
1113
1627
  derivadas de imports). El boton Refresh del panel lo llama. */
1114
1628
  if (req.method === 'POST' && url.pathname === '/api/forge/graph-regenerate') {
@@ -2059,10 +2573,35 @@ async function handleVoiceTts(req, res, ctx) {
2059
2573
  * ============================================================ */
2060
2574
  /** Check that `target` is inside `root` (after resolving both).
2061
2575
  * Refuses path traversal like ../../../etc/passwd. */
2576
+ function realpathOrSelf(p) {
2577
+ try {
2578
+ return realpathSync.native(p);
2579
+ }
2580
+ catch {
2581
+ return p;
2582
+ }
2583
+ }
2584
+ /* PND-045 (sec 1.7) -- containment check that also defeats symlink
2585
+ * escapes. path.resolve does NOT follow symlinks, so a symlink that
2586
+ * lives INSIDE the root but points OUTSIDE would pass a naive
2587
+ * startsWith. We realpath both the root and the deepest EXISTING
2588
+ * ancestor of the target (the target itself may not exist yet on a
2589
+ * write), then re-append the not-yet-existing tail before comparing. */
2062
2590
  function isInside(target, root) {
2063
- const targetAbs = path.resolve(target);
2064
- const rootAbs = path.resolve(root) + path.sep;
2065
- return targetAbs === path.resolve(root) || targetAbs.startsWith(rootAbs);
2591
+ const rootReal = realpathOrSelf(path.resolve(root));
2592
+ let existing = path.resolve(target);
2593
+ const tail = [];
2594
+ while (!existsSync(existing)) {
2595
+ const parent = path.dirname(existing);
2596
+ if (parent === existing)
2597
+ break; /* reached fs root */
2598
+ tail.unshift(path.basename(existing));
2599
+ existing = parent;
2600
+ }
2601
+ const realBase = realpathOrSelf(existing);
2602
+ const targetReal = tail.length ? path.join(realBase, ...tail) : realBase;
2603
+ const rootWithSep = rootReal + path.sep;
2604
+ return targetReal === rootReal || targetReal.startsWith(rootWithSep);
2066
2605
  }
2067
2606
  /** Editor commands tried in order. First match wins. The args
2068
2607
  * templates are joined with the file path at runtime. All
@@ -2271,7 +2810,7 @@ async function handleBrainConfigGet(_req, res, ctx) {
2271
2810
  * campos; valida y persiste. anthropic_mode requiere reabrir el
2272
2811
  * panel para tomar efecto (el brain client se construye al boot);
2273
2812
  * eco_enabled y los bindings aplican en el proximo turno. */
2274
- async function handleBrainConfigSet(req, res) {
2813
+ async function handleBrainConfigSet(req, res, ctx) {
2275
2814
  const { readBrainConfig, writeBrainConfig, ALL_BRAIN_PROVIDERS, } = await import('../core/brain_config.js');
2276
2815
  let body;
2277
2816
  try {
@@ -2307,7 +2846,21 @@ async function handleBrainConfigSet(req, res) {
2307
2846
  cfg[tier].model = model;
2308
2847
  }
2309
2848
  await writeBrainConfig(cfg);
2310
- sendJson(res, 200, { ok: true, config: cfg, restart_needed_for: ['anthropic_mode'] });
2849
+ /* Apply the brain mode LIVE: hot-swap the provider so flipping
2850
+ * Plan/Console takes effect on the very next turn -- no panel restart.
2851
+ * (Fixes "clickeo Plan pero me dice que no tengo tokens de Console".) */
2852
+ let applied_live = false;
2853
+ if ((body.anthropic_mode === 'plan' || body.anthropic_mode === 'console') && ctx.setBrainProvider) {
2854
+ try {
2855
+ applied_live = await ctx.setBrainProvider(body.anthropic_mode);
2856
+ }
2857
+ catch { /* leave applied_live false -> client shows the restart hint */ }
2858
+ }
2859
+ sendJson(res, 200, {
2860
+ ok: true, config: cfg, applied_live,
2861
+ /* nothing needs a restart when the swap applied live */
2862
+ restart_needed_for: applied_live ? [] : ['anthropic_mode'],
2863
+ });
2311
2864
  }
2312
2865
  /** PND-064 -- POST /api/forge/panel-invoke. Paridad total de tools
2313
2866
  * en modo Plan: el MCP server local reenvia aca cada nac3_invoke
@@ -2367,6 +2920,16 @@ async function handlePanelInvoke(req, res, ctx) {
2367
2920
  const shortVerb = String(verb.id || '').replace(/^yujin\./, '');
2368
2921
  const { enqueuePanelDispatch } = await import('./panel_queue.js');
2369
2922
  const seq = enqueuePanelDispatch(shortVerb, payload ? { ...dispatchArgs, server_payload: payload } : dispatchArgs, new Date().toISOString());
2923
+ /* PND -- if it is a pizarron DOCUMENT, also relay it to the phone
2924
+ * (its Pizarron tab has no native doc canvas). Folded into the next
2925
+ * board snapshot. */
2926
+ if (shortVerb === 'pizarron.create-tab') {
2927
+ const k = String(dispatchArgs.kind || 'doc');
2928
+ if (['doc', 'document', 'md', 'markdown', 'html', 'table'].includes(k)) {
2929
+ pushRecentDoc(String(dispatchArgs.title || 'documento'), k, String(dispatchArgs.content || ''));
2930
+ publishBoardSnapshot();
2931
+ }
2932
+ }
2370
2933
  sendJson(res, 200, {
2371
2934
  ok: true, seq, panel_dispatch: true,
2372
2935
  text: 'Listo: enviado al pizarron (' + shortVerb + ').',
@@ -2418,23 +2981,28 @@ async function handlePanelQueue(_req, res, url) {
2418
2981
  /** Mobile pairing QR -- genera el codigo + envelope cifrado y lo
2419
2982
  * renderiza como QR SVG para que Forge Mobile lo escanee. El
2420
2983
  * usuario solo tipea el codigo de 6 digitos en el telefono. El
2421
- * payload (que lleva el server_api_key) NUNCA sale por aca en
2422
- * claro: solo va el QR cifrado + el codigo (que el usuario ya ve
2423
- * en su propia pantalla). */
2984
+ * payload viaja DENTRO del QR (Option 3 -- code-less): el QR es
2985
+ * autocontenido, el usuario lo muestra en su propia pantalla y el
2986
+ * expires_at de 5 min acota un QR capturado. */
2424
2987
  async function handleMobilePair(_req, res) {
2425
2988
  try {
2426
2989
  const { assemblePairing } = await import('../commands/mobile.js');
2427
2990
  const built = await assemblePairing({});
2428
- if (!built.ok || !built.qr || !built.code) {
2991
+ if (!built.ok || !built.qr) {
2429
2992
  sendJson(res, 200, { ok: false, error: built.error ?? 'no pude generar el pairing', hint: built.hint });
2430
2993
  return;
2431
2994
  }
2432
2995
  const QRCode = (await import('qrcode')).default;
2996
+ /* PND-047 follow-up -- the pairing payload grew (the per-handle key is
2997
+ * 64 hex chars + key_kind), pushing the QR from 97x97 to 105x105 modules
2998
+ * at EC 'M', which made it hard to scan. EC 'L' brings it to 93x93 --
2999
+ * LESS dense than the old easy-to-scan QR. The envelope is short-lived
3000
+ * (5-min TTL) so the lighter error correction is fine for a screen scan. */
2433
3001
  const qrSvg = await QRCode.toString(built.qr, {
2434
- type: 'svg', margin: 1, errorCorrectionLevel: 'M',
2435
- color: { dark: '#1a1a1a', light: '#f5f0e6' },
3002
+ type: 'svg', margin: 1, errorCorrectionLevel: 'L',
3003
+ color: { dark: '#1a1a1a', light: '#ffffff' },
2436
3004
  });
2437
- sendJson(res, 200, { ok: true, code: built.code, expires_at: built.expires_at, qr_svg: qrSvg });
3005
+ sendJson(res, 200, { ok: true, expires_at: built.expires_at, qr_svg: qrSvg });
2438
3006
  }
2439
3007
  catch (err) {
2440
3008
  sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err) });
@@ -2465,25 +3033,30 @@ async function handleStartupTabs(_req, res, ctx, url) {
2465
3033
  /* 1 + 2: grafo (grafico + semantico). */
2466
3034
  try {
2467
3035
  const { listRelevant, listAllEdges } = await import('../semantic_graph/store.js');
2468
- /* Vista arquitectonica: los nodos derivados module.* y lib.*
2469
- * (no los comp.* de export fino del watcher, que tienen kind
2470
- * 'module' pero saturan el grafo). Filtro por prefijo de id. */
3036
+ /* El grafo muestra TODO el conocimiento del proyecto: nodos
3037
+ * arquitectonicos (module./lib. prefijo) + los declarados en chat
3038
+ * (dominio, confidence 'claimed'). Solo se excluyen los comp. de
3039
+ * export fino del watcher, que saturan. Antes el filtro era por
3040
+ * prefijo module./lib., lo que ocultaba TODO lo declarado en chat
3041
+ * (el 87% del conocimiento real -- PND graph). Los 'claimed' van
3042
+ * atenuados + punteados via la confidence que pasamos al renderer. */
2471
3043
  const allNodes = slug ? await listRelevant(slug, { limit: 100000, min_confidence: 'claimed' }) : [];
2472
- const nodes = allNodes.filter((n) => n.id.startsWith('module.') || n.id.startsWith('lib.'));
3044
+ const nodes = allNodes.filter((n) => !n.id.startsWith('comp.'));
2473
3045
  const allEdges = slug ? await listAllEdges(slug) : [];
2474
3046
  const nodeIdSet = new Set(nodes.map((n) => n.id));
2475
3047
  const edges = allEdges.filter((e) => nodeIdSet.has(e.from_id) && nodeIdSet.has(e.to_id));
2476
- const gNodes = nodes.map((n) => ({ id: n.id, label: n.name || n.id, weight: n.weight, kind: n.kind, detail: n.purpose || '' }));
3048
+ const gNodes = nodes.map((n) => ({ id: n.id, label: n.name || n.id, weight: n.weight, kind: n.kind, confidence: n.confidence, detail: n.purpose || '' }));
2477
3049
  const gEdges = edges.map((e) => ({ from: e.from_id, to: e.to_id, kind: e.kind }));
2478
3050
  tabs.push({ key: 'startup_graph', title: t('startup.tab.graph'), kind: 'graph', content: JSON.stringify({ nodes: gNodes, edges: gEdges }) });
2479
3051
  /* Grafo semantico = misma data en markdown (tabla de nodos por
2480
- * peso + conteo de aristas). */
2481
- const top = [...nodes].sort((a, b) => (b.weight || 0) - (a.weight || 0)).slice(0, 40);
3052
+ * peso + conteo de aristas), con la confianza para distinguir lo
3053
+ * declarado (claimed) de lo derivado del codigo (auto). */
3054
+ const top = [...nodes].sort((a, b) => (b.weight || 0) - (a.weight || 0)).slice(0, 60);
2482
3055
  let md = '# ' + t('startup.tab.graphSem') + ' -- ' + (slug || 'proyecto') + '\n\n'
2483
3056
  + nodes.length + ' ' + t('graph.term.nodes') + ', ' + edges.length + ' ' + t('graph.term.edges') + '.\n\n'
2484
- + '| ' + t('startup.tab.graphSem') + ' | | ' + t('pizarron.graph.detail.weight') + ' |\n|---|---|---|\n';
3057
+ + '| ' + t('startup.tab.graphSem') + ' | | conf | ' + t('pizarron.graph.detail.weight') + ' |\n|---|---|---|---|\n';
2485
3058
  for (const n of top)
2486
- md += '| ' + (n.name || n.id) + ' | ' + n.kind + ' | ' + (Math.round((n.weight || 0) * 100) / 100) + ' |\n';
3059
+ md += '| ' + (n.name || n.id) + ' | ' + n.kind + ' | ' + n.confidence + ' | ' + (Math.round((n.weight || 0) * 100) / 100) + ' |\n';
2487
3060
  tabs.push({ key: 'startup_graph_sem', title: t('startup.tab.graphSem'), kind: 'md', content: md });
2488
3061
  }
2489
3062
  catch { /* sin grafo */ }
@@ -2510,15 +3083,31 @@ async function handleStartupTabs(_req, res, ctx, url) {
2510
3083
  try {
2511
3084
  /* PND-094 -- vista jerarquica de FASES + tareas (no grafo): cada
2512
3085
  * fase con su avance + sus tareas debajo con su estado. */
2513
- const { readWorkflow } = await import('../workflow/state.js');
2514
- const { computeWorkflowView } = await import('../workflow/view.js');
2515
- const wf = await readWorkflow(ctx.projectRoot);
2516
- const view = computeWorkflowView(wf);
2517
- tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'workflow', content: JSON.stringify(view) });
3086
+ /* PND-094 ext -- multi-workflow: the coexisting WorkflowInstances
3087
+ * with their tasks (pendings) grouped by stage. Falls back to the
3088
+ * legacy single-workflow view when there are no instances yet. */
3089
+ const { computeWorkflowsView } = await import('../workflow/multi_view.js');
3090
+ const cards = await computeWorkflowsView(ctx.projectSlug ?? '', 'open');
3091
+ if (cards.length > 0) {
3092
+ tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'workflow', content: JSON.stringify({ workflows: cards }) });
3093
+ }
3094
+ else {
3095
+ const { readWorkflow } = await import('../workflow/state.js');
3096
+ const { computeWorkflowView } = await import('../workflow/view.js');
3097
+ const view = computeWorkflowView(await readWorkflow(ctx.projectRoot));
3098
+ tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'workflow', content: JSON.stringify(view) });
3099
+ }
2518
3100
  }
2519
3101
  catch {
2520
3102
  tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'md', content: '# Workflow\n\nSin datos.\n' });
2521
3103
  }
3104
+ /* 5: PND-007 -- Tareas de fondo (se llena en vivo client-side). */
3105
+ tabs.push({
3106
+ key: 'startup_tasks',
3107
+ title: lang === 'en' ? 'Tasks' : 'Tareas',
3108
+ kind: 'tasks',
3109
+ content: '',
3110
+ });
2522
3111
  sendJson(res, 200, { ok: true, enabled: true, tabs });
2523
3112
  }
2524
3113
  /** Formato de id de pending para display (PND-NNN). */
@@ -2998,7 +3587,7 @@ async function handleProjectsActive(res) {
2998
3587
  const entry = slug ? reg.projects.find((p) => p.slug === slug) : null;
2999
3588
  sendJson(res, 200, { ok: true, active: slug, project: entry ?? null });
3000
3589
  }
3001
- async function handleProjectsSwitch(req, res) {
3590
+ async function handleProjectsSwitch(req, res, ctx) {
3002
3591
  let body;
3003
3592
  try {
3004
3593
  body = JSON.parse(await readBody(req));
@@ -3013,7 +3602,20 @@ async function handleProjectsSwitch(req, res) {
3013
3602
  }
3014
3603
  try {
3015
3604
  const reg = await setActive(body.slug);
3016
- sendJson(res, 200, { ok: true, active: reg.active_slug, registry: reg });
3605
+ /* Task #33 -- a switch must HOT-reanchor the live session, not just
3606
+ * persist active_slug (otherwise the running server keeps serving
3607
+ * the old project until restart). Look up the entry's path + call
3608
+ * ctx.reanchor so the panel + pizarron + graph follow immediately. */
3609
+ let reanchored = false;
3610
+ const entry = reg.projects.find((p) => p.slug === body.slug);
3611
+ if (entry && entry.path && ctx.reanchor) {
3612
+ try {
3613
+ await ctx.reanchor(entry.path);
3614
+ reanchored = true;
3615
+ }
3616
+ catch { /* reanchor failed -> active_slug still persisted */ }
3617
+ }
3618
+ sendJson(res, 200, { ok: true, active: reg.active_slug, reanchored, registry: reg });
3017
3619
  }
3018
3620
  catch (err) {
3019
3621
  sendJson(res, 404, { ok: false,
@@ -3175,6 +3777,9 @@ async function handleVaultHas(res, slot) {
3175
3777
  }
3176
3778
  }
3177
3779
  async function handleChat(req, res, ctx) {
3780
+ /* P2 -- remember the project context so turn_complete can publish a
3781
+ * board snapshot to the paired phone's Pizarron tab. */
3782
+ setRelayBoardCtx(ctx.projectRoot, ctx.projectSlug ?? null);
3178
3783
  const raw = await readBody(req);
3179
3784
  let body;
3180
3785
  try {
@@ -3189,6 +3794,81 @@ async function handleChat(req, res, ctx) {
3189
3794
  sendJson(res, 400, { ok: false, error: 'messages[] required' });
3190
3795
  return;
3191
3796
  }
3797
+ /* Multisession: the conversation THREAD this turn belongs to. The
3798
+ * default ('desktop-main', what the panel uses) reuses the server's
3799
+ * main store; a mobile "Sesion propia" passes its own id for an
3800
+ * independent parallel thread. Everything below reads/writes
3801
+ * `sessionStore` instead of `ctx.store`. */
3802
+ const sessionId = typeof body.session_id === 'string' && body.session_id
3803
+ ? body.session_id : DEFAULT_SESSION_ID;
3804
+ /* The transcript THREAD is keyed off the GLOBAL project root (a stable
3805
+ * single thread per session), resolved BEFORE any per-session project
3806
+ * override so switching the session's project does not fork the chat. */
3807
+ const sessionStore = getSessionStore(sessionId, ctx.store, ctx.projectRoot, VERSION);
3808
+ /* Per-session project (Pablo 2026-06-15) -- a "Sesion Propia"
3809
+ * (non-default session) can point at its OWN project without moving the
3810
+ * desktop's global project. Reassign ctx to a turn-local view pointed at
3811
+ * the override so EVERY downstream ctx.projectRoot/projectSlug use
3812
+ * (prompt context, graph, tooling, tool cwd) follows it. The default
3813
+ * 'notebook' session has no override -> unchanged global behavior. The
3814
+ * global board snapshot (set above, before this) stays on the desktop's
3815
+ * project; the phone tracks its own-session project locally. */
3816
+ const _sessProj = getSessionProject(sessionId);
3817
+ if (_sessProj) {
3818
+ ctx = { ...ctx, projectRoot: _sessProj.root, projectSlug: _sessProj.slug };
3819
+ }
3820
+ /* Project-switch verb routing (Pablo 2026-06-15) -- when the model
3821
+ * calls yujin.project.set-root in a NON-default session, switch only
3822
+ * THAT session's project (per-session override), NOT the global
3823
+ * desktop. The default 'notebook' session uses the global hot reanchor
3824
+ * (which also moves the desktop + republishes the board). */
3825
+ const effReanchor = sessionId !== DEFAULT_SESSION_ID
3826
+ ? async (newRoot) => {
3827
+ const abs = (await import('node:path')).resolve(newRoot);
3828
+ const { resolveProjectSlug } = await import('./project_slug.js');
3829
+ const slug = await resolveProjectSlug(abs);
3830
+ const name = await readProjectName(abs);
3831
+ setSessionProject(sessionId, abs, slug);
3832
+ /* tell the phone its session moved so the bubble follows (#2) */
3833
+ try {
3834
+ const pub = getRelayPublisher();
3835
+ if (pub) {
3836
+ pub.sessionProject(sessionId, slug);
3837
+ void pub.flush();
3838
+ }
3839
+ }
3840
+ catch { /* */ }
3841
+ return { projectRoot: abs, projectName: name, projectSlug: slug };
3842
+ }
3843
+ : ctx.reanchor;
3844
+ /* Diagnostic (Pablo 2026-06-15) -- tangible evidence of session
3845
+ * routing for the mobile bugs. Records WHICH thread + origin each turn
3846
+ * used, so we can SEE whether 'sesion propia' actually sent
3847
+ * session_id='mobile' and whether the unified turn was tagged
3848
+ * origin='mobile'. Fire-and-forget; ~/.yujin-forge/turn_routing.jsonl */
3849
+ void (async () => {
3850
+ try {
3851
+ const { appendFile } = await import('node:fs/promises');
3852
+ const pathMod = await import('node:path');
3853
+ const home = process.env.YF_CONFIG_DIR
3854
+ || pathMod.join(process.env.HOME || process.env.USERPROFILE || '', '.yujin-forge');
3855
+ const row = JSON.stringify({
3856
+ ts: new Date().toISOString(),
3857
+ session_id: sessionId,
3858
+ is_default: sessionId === DEFAULT_SESSION_ID,
3859
+ origin: typeof body.origin === 'string' ? body.origin : 'panel',
3860
+ project: ctx.projectSlug ?? null,
3861
+ }) + '\n';
3862
+ await appendFile(pathMod.join(home, 'turn_routing.jsonl'), row, 'utf-8');
3863
+ }
3864
+ catch { /* best-effort diagnostic */ }
3865
+ })();
3866
+ /* Interrupt (task #27): this turn registers itself in the single
3867
+ * active-turn registry just before the model call so POST
3868
+ * /api/chat/interrupt (or a mobile control event) can abort it
3869
+ * mid-flight. Declared out here so the outer try's finally can
3870
+ * clear it on every exit path. */
3871
+ let turnId = null;
3192
3872
  /* alpha.59e -- SSE streaming mode. Client opts in via
3193
3873
  * body.stream=true. We keep the response open + emit events
3194
3874
  * during the tool loop instead of bundling everything in a
@@ -3209,6 +3889,9 @@ async function handleChat(req, res, ctx) {
3209
3889
  res.write('data: ' + payload + '\n\n');
3210
3890
  }
3211
3891
  catch { /* ignore */ }
3892
+ /* P2 Fase 1 -- ademas del panel, espejar la actividad al relay
3893
+ * para el mobile pareado. Gateado (YF_RELAY=1) + best-effort. */
3894
+ relayForward(event, data);
3212
3895
  };
3213
3896
  }
3214
3897
  /* alpha.51 -- session-level brain tier override from slash
@@ -3246,6 +3929,36 @@ async function handleChat(req, res, ctx) {
3246
3929
  const pilotState = await readPilotState(ctx.projectRoot);
3247
3930
  const lastUserMsg = normalized.filter((m) => m.role === 'user').slice(-1)[0];
3248
3931
  const lastUserText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
3932
+ /* Immediate cross-device ack (PND -- "duda si esta trabajando"). The
3933
+ * RECEIVING device must see the incoming message + a working state the
3934
+ * instant the turn STARTS, not when it finishes. Shared id correlates
3935
+ * the start echo with the final reply below. Backward-compatible:
3936
+ * - mobile-origin: push the phone's user bubble to the desktop panel
3937
+ * NOW (the END block adds only the reply, no duplicate user).
3938
+ * - desktop-origin: flush status('working') carrying the user text +
3939
+ * turn id so a paired phone can show a pending bubble immediately;
3940
+ * the END remoteTurn (same id) fills the reply. Old phones ignore
3941
+ * the extra fields and keep their current end-of-turn behavior. */
3942
+ const _xTurnId = (body.origin === 'mobile' ? 'mb-' : 'dt-') + Date.now().toString(36);
3943
+ if (sessionId === DEFAULT_SESSION_ID && lastUserText) {
3944
+ if (body.origin === 'mobile') {
3945
+ try {
3946
+ const { enqueuePanelDispatch } = await import('./panel_queue.js');
3947
+ enqueuePanelDispatch('system.remote-turn', { source: 'mobile', id: _xTurnId, user: lastUserText, reply: '' }, new Date().toISOString());
3948
+ }
3949
+ catch { /* best-effort */ }
3950
+ }
3951
+ else {
3952
+ try {
3953
+ const pub = getRelayPublisher();
3954
+ if (pub) {
3955
+ pub.status('working', { user: lastUserText, turn_id: _xTurnId });
3956
+ void pub.flush();
3957
+ }
3958
+ }
3959
+ catch { /* best-effort */ }
3960
+ }
3961
+ }
3249
3962
  /* alpha.51 -- ask the cost router which brain tier should
3250
3963
  * serve this turn. alpha.52 -- runtime clients for anthropic
3251
3964
  * + google_ai are wired. alpha.55 -- the router now runs a
@@ -3260,7 +3973,7 @@ async function handleChat(req, res, ctx) {
3260
3973
  * via the store (best-effort). The store keeps the last
3261
3974
  * assistant reply; if it has a `brain_tier` annotation we use
3262
3975
  * it. When absent, the gate just skips the sticky check. */
3263
- const recent = ctx.store.messages();
3976
+ const recent = sessionStore.messages();
3264
3977
  const lastAssistant = recent.slice().reverse().find((m) => m.role === 'assistant');
3265
3978
  const previousTier = lastAssistant && (lastAssistant.brain_tier === 'eco' || lastAssistant.brain_tier === 'frontier')
3266
3979
  ? lastAssistant.brain_tier
@@ -3296,9 +4009,15 @@ async function handleChat(req, res, ctx) {
3296
4009
  * chose has zero remaining budget, return 429 BEFORE the
3297
4010
  * LLM call so the user never gets billed past their cap.
3298
4011
  * The panel modal (plan_limit_exceeded) lets them upgrade
3299
- * or switch tier. BYOK plans have Infinity cap so this
3300
- * is a no-op there. */
3301
- {
4012
+ * or switch tier.
4013
+ *
4014
+ * BYOK-STRICT (Pablo 2026-06-15): Forge no tiene plan managed --
4015
+ * cada turno BYOK lo paga el usuario directo al proveedor, asi que
4016
+ * el cap mensual NO debe bloquear. Solo se aplica a turnos REALMENTE
4017
+ * managed (provider 'yujin_managed'), que hoy no existen. Antes el
4018
+ * gate corria por tier sin mirar el provider y bloqueaba a un BYOK
4019
+ * con plan=pro_plus aunque tuviera saldo propio. */
4020
+ if (brainSelection.provider === 'yujin_managed') {
3302
4021
  const { checkCap } = await import('../core/budget.js');
3303
4022
  const capCheck = await checkCap(brainSelection.tier);
3304
4023
  if (!capCheck.allowed) {
@@ -3467,6 +4186,18 @@ async function handleChat(req, res, ctx) {
3467
4186
  wiringSection = '\n\n' + r.rendered_text;
3468
4187
  }
3469
4188
  catch (_) { /* swallow -- chat continues without wiring inject */ }
4189
+ /* PROJECT TOOLING -- the discovered CI/CD + npm scripts + scripts/
4190
+ * helpers, so the model uses the repo's real build/deploy chain
4191
+ * instead of inventing commands. Generic per repo (graph 'tooling'
4192
+ * nodes). Always present (not relevance-gated). */
4193
+ let toolingSection = '';
4194
+ try {
4195
+ const { buildToolingSection } = await import('./tooling_section.js');
4196
+ const r = await buildToolingSection(ctx.projectSlug || '');
4197
+ if (r.rendered_text)
4198
+ toolingSection = '\n\n' + r.rendered_text;
4199
+ }
4200
+ catch (_) { /* swallow -- chat continues without tooling inject */ }
3470
4201
  /* alpha.59z.187 PND-039 -- auto-context-load. Single compact
3471
4202
  * block with counts of graph nodes / wiring hooks / pending
3472
4203
  * items so the LLM can answer "que sabes del proyecto" / "subi
@@ -3517,8 +4248,46 @@ async function handleChat(req, res, ctx) {
3517
4248
  }
3518
4249
  }
3519
4250
  catch (_) { /* swallow */ }
4251
+ /* Server-canonical conversation context. The turn is built from the
4252
+ * SESSION store (the shared thread that accumulates BOTH desktop and
4253
+ * mobile turns) + the new user message -- NOT from body.messages.
4254
+ * Before, each client sent its own history (desktop = full browser
4255
+ * history; the relay consumer = a single message), so desktop and
4256
+ * mobile were two separate threads, blind to each other. Using the
4257
+ * store as the source of truth unifies them into one conversation. */
4258
+ const newUserMsg = normalized[normalized.length - 1];
4259
+ let turnMessages;
4260
+ if (newUserMsg && newUserMsg.role === 'user') {
4261
+ const prior = sessionStore.messages();
4262
+ const ptail = prior[prior.length - 1];
4263
+ const alreadyThere = !!(ptail && ptail.role === 'user' && ptail.content === newUserMsg.content);
4264
+ turnMessages = alreadyThere ? [...prior] : [...prior, newUserMsg];
4265
+ }
4266
+ else {
4267
+ /* defensive: the last normalized message should always be the new
4268
+ * user turn; fall back to the client view if not. */
4269
+ turnMessages = normalized;
4270
+ }
4271
+ /* Register THIS turn as the active one right before the model
4272
+ * call. beginTurn returns the AbortSignal the callers honor
4273
+ * (claude.ts at round boundary, claude_cli kills the subprocess).
4274
+ * A concurrent turn (desktop + mobile own-session) replaces the
4275
+ * registry entry; interrupt then targets the latest -- acceptable
4276
+ * until the queue (task #28) serializes turns. */
4277
+ turnId = sessionId + ':' + String(Date.now());
4278
+ const turnSignal = beginTurn(turnId);
3520
4279
  const chatRequest = {
3521
- messages: normalized,
4280
+ messages: turnMessages,
4281
+ signal: turnSignal,
4282
+ /* Pablo 2026-06-15 -- the model MUST follow the brain config in
4283
+ * BOTH paths. Console (unified client) already bakes it into the
4284
+ * client; Plan (`claude -p`) reads it per turn -> without this the
4285
+ * subprocess fell back to a default + ignored the user's eco/
4286
+ * frontier model selection. claude_cli maps it to the CLI alias. */
4287
+ model: brainSelection.model,
4288
+ /* Plan mode: the `claude -p` subprocess must run in the ACTIVE
4289
+ * project (follows a hot switch), not the launch cwd. (Pablo) */
4290
+ cwd: ctx.projectRoot,
3522
4291
  /* alpha.59z.163 PND-027 -- system prompt now split into a
3523
4292
  * stable prefix (manifest, doctrines, design system, mode +
3524
4293
  * tier headers from buildSystemPrompt) and a dynamic suffix
@@ -3538,7 +4307,7 @@ async function handleChat(req, res, ctx) {
3538
4307
  ctx.chatProvider === 'claude-code-cli'
3539
4308
  ? (ctx.panelPushToken ? 'mcp-bridge' : 'none')
3540
4309
  : 'native'),
3541
- systemDynamic: projectContextSection + snapshotSection + graphSection + wiringSection + workflowSection,
4310
+ systemDynamic: projectContextSection + snapshotSection + graphSection + wiringSection + toolingSection + workflowSection,
3542
4311
  maxTokens: 1024,
3543
4312
  tools: FORGE_TOOL_SPECS,
3544
4313
  runTool: async (name, input) => {
@@ -3607,7 +4376,8 @@ async function handleChat(req, res, ctx) {
3607
4376
  projectRoot: ctx.projectRoot,
3608
4377
  projectSlug: ctx.projectSlug,
3609
4378
  ...(sseEmit ? { streamEmit: sseEmit } : {}),
3610
- ...(ctx.reanchor ? { reanchor: ctx.reanchor } : {}),
4379
+ /* per-session reanchor for non-default sessions; global for notebook */
4380
+ ...(effReanchor ? { reanchor: effReanchor } : {}),
3611
4381
  };
3612
4382
  const r = await runForgeTool(name, input, toolCtx);
3613
4383
  /* alpha.59i -- if the result is an approval envelope,
@@ -3652,7 +4422,8 @@ async function handleChat(req, res, ctx) {
3652
4422
  * frontier still has budget. Cap 1 escalation per turn. */
3653
4423
  let escalated = false;
3654
4424
  let escalationSignals = [];
3655
- if (brainSelection.tier === 'eco' && !brainOverride) {
4425
+ if (brainSelection.tier === 'eco' && !brainOverride
4426
+ && !reply.interrupted) {
3656
4427
  const { detectIncomplete } = await import('../core/incomplete_detector.js');
3657
4428
  const detect = detectIncomplete({
3658
4429
  user_text: lastUserText,
@@ -3660,8 +4431,12 @@ async function handleChat(req, res, ctx) {
3660
4431
  tool_rounds: reply.toolRounds,
3661
4432
  });
3662
4433
  if (detect.incomplete) {
3663
- const { checkCap } = await import('../core/budget.js');
3664
- const capCheck = await checkCap('frontier');
4434
+ /* BYOK-strict: the frontier cap only gates MANAGED turns. A BYOK
4435
+ * eco turn escalates to a BYOK frontier turn -- the user pays the
4436
+ * provider directly, so no cap blocks the escalation. */
4437
+ const capCheck = brainSelection.provider === 'yujin_managed'
4438
+ ? await (await import('../core/budget.js')).checkCap('frontier')
4439
+ : { allowed: true };
3665
4440
  if (capCheck.allowed) {
3666
4441
  const frontierSel = await selectBrainForTurn({
3667
4442
  user_override: 'frontier',
@@ -3697,6 +4472,8 @@ async function handleChat(req, res, ctx) {
3697
4472
  * serving the turn. */
3698
4473
  const frontierRequest = {
3699
4474
  ...chatRequest,
4475
+ /* use the FRONTIER model, not the eco one inherited via spread */
4476
+ model: frontierSel.model,
3700
4477
  system: buildSystemPrompt(ctx, reqMode, pilotState, {
3701
4478
  tier: frontierSel.tier,
3702
4479
  provider: frontierSel.provider,
@@ -3722,6 +4499,43 @@ async function handleChat(req, res, ctx) {
3722
4499
  }
3723
4500
  }
3724
4501
  }
4502
+ /* Panel-action enforcement (Pablo 2026-06-15) -- if the model SAID it
4503
+ * opened a pizarron document / created a task but called NO such
4504
+ * tool, the action never happened (Opus 4.8 narra; Gemini ejecuta).
4505
+ * Re-issue the turn ONCE with a forcing nudge so it actually emits
4506
+ * the verb. API/Console only: in CLI/Plan mode the subprocess runs
4507
+ * tools natively and toolRounds is empty, so the check is N/A. */
4508
+ if (ctx.chatProvider !== 'claude-code-cli'
4509
+ && !reply.interrupted) {
4510
+ const { checkClaimedPanelAction } = await import('./completion_hallucination_detector.js');
4511
+ const firedPanelTool = (rounds) => rounds.some((r) => {
4512
+ const vid = String(r.input?.verb_id ?? r.tool ?? '');
4513
+ const res = r.result;
4514
+ return /pizarron|show_in_pizarron|\.task\./i.test(vid) || (res != null && res.nac3_panel_dispatch === true);
4515
+ });
4516
+ const panelClaim = checkClaimedPanelAction(reply.text);
4517
+ if (panelClaim.claimed && !firedPanelTool(reply.toolRounds)) {
4518
+ const nudge = panelClaim.kind === 'task'
4519
+ ? 'Dijiste que creaste o avanzaste una tarea pero NO invocaste ninguna verb (yujin.task.*), asi que NO paso nada real. En ESTA respuesta tenes que invocar la verb ahora -- no la describas, ejecutala. Si no es posible, decilo con honestidad.'
4520
+ : 'Dijiste que abriste o generaste un documento en el pizarron pero NO invocaste ninguna verb (yujin.pizarron.create-tab), asi que el pizarron sigue vacio. En ESTA respuesta tenes que invocar nac3_invoke con yujin.pizarron.create-tab y el contenido real (HTML por defecto) ahora -- no lo describas, ejecutalo. Si no es posible, decilo con honestidad.';
4521
+ try {
4522
+ const retryReq = {
4523
+ ...chatRequest,
4524
+ messages: [
4525
+ ...turnMessages,
4526
+ { role: 'assistant', content: reply.text },
4527
+ { role: 'user', content: '[SISTEMA] ' + nudge },
4528
+ ],
4529
+ };
4530
+ const retry = await callerForChat.chat(retryReq);
4531
+ if (retry && firedPanelTool(retry.toolRounds)) {
4532
+ reply = retry;
4533
+ escalationSignals = [...escalationSignals, 'panel_action_enforced[' + panelClaim.kind + ']'];
4534
+ }
4535
+ }
4536
+ catch { /* keep the original reply */ }
4537
+ }
4538
+ }
3725
4539
  // Append the latest user message + the assistant's reply to
3726
4540
  // the persistent transcript. Older messages may already be
3727
4541
  // present from previous turns; we only need to capture the
@@ -3730,10 +4544,10 @@ async function handleChat(req, res, ctx) {
3730
4544
  if (lastUser && lastUser.role === 'user') {
3731
4545
  // Only append if this user message isn't already the last
3732
4546
  // recorded one (prevents dup on retry).
3733
- const recent = ctx.store.messages();
4547
+ const recent = sessionStore.messages();
3734
4548
  const tail = recent[recent.length - 1];
3735
4549
  if (!tail || tail.role !== 'user' || tail.content !== lastUser.content) {
3736
- ctx.store.append(lastUser);
4550
+ sessionStore.append(lastUser);
3737
4551
  }
3738
4552
  }
3739
4553
  /* alpha.56 -- tag the assistant turn with the tier that
@@ -3760,13 +4574,13 @@ async function handleChat(req, res, ctx) {
3760
4574
  * way the next turn's sticky inertia + previous_tier check
3761
4575
  * see the canonical decision. */
3762
4576
  const finalTier = escalated ? 'frontier' : brainSelection.tier;
3763
- ctx.store.append({
4577
+ sessionStore.append({
3764
4578
  role: 'assistant',
3765
4579
  content: reply.text,
3766
4580
  brain_tier: finalTier,
3767
4581
  ...(docLoaded ? { doc_loaded: true } : {}),
3768
4582
  });
3769
- void ctx.store.flush();
4583
+ void sessionStore.flush();
3770
4584
  /* Pilot first-run auto-complete (SQ 0.10). If Pilot is
3771
4585
  not yet completed, decide based on the user's first
3772
4586
  message whether to mark setup done with defaults. */
@@ -3856,12 +4670,51 @@ async function handleChat(req, res, ctx) {
3856
4670
  }
3857
4671
  }
3858
4672
  catch { /* swallow */ }
4673
+ /* UI sync (task #32) -- if this turn came from the phone in the
4674
+ * UNIFIED thread, echo it to the panel so the laptop shows what was
4675
+ * asked + answered from the mobile (the desktop-main thread is the
4676
+ * shared 'notebook' session). 'own' mobile sessions stay private. */
4677
+ if (body.origin === 'mobile' && sessionId === DEFAULT_SESSION_ID) {
4678
+ try {
4679
+ const { enqueuePanelDispatch } = await import('./panel_queue.js');
4680
+ /* The user bubble was already echoed at turn START; here we add
4681
+ * only the reply (empty user -> the panel does not re-push it). */
4682
+ enqueuePanelDispatch('system.remote-turn', {
4683
+ source: 'mobile',
4684
+ id: _xTurnId,
4685
+ user: '',
4686
+ reply: reply.text,
4687
+ }, new Date().toISOString());
4688
+ }
4689
+ catch { /* best-effort */ }
4690
+ }
4691
+ /* UI sync (task #32, reverse) -- a turn issued on the DESKTOP (panel)
4692
+ * in the unified thread is published to the relay so the phone's chat
4693
+ * reflects it too. Mobile-origin turns are skipped (the phone already
4694
+ * has them as its own bubbles). Same id as the start 'working' echo so
4695
+ * a new phone replaces its pending bubble instead of appending. */
4696
+ if (body.origin !== 'mobile' && sessionId === DEFAULT_SESSION_ID
4697
+ && !reply.interrupted) {
4698
+ try {
4699
+ const pub = getRelayPublisher();
4700
+ if (pub) {
4701
+ pub.remoteTurn(_xTurnId, lastUserText, reply.text);
4702
+ void pub.flush();
4703
+ }
4704
+ }
4705
+ catch { /* best-effort */ }
4706
+ }
3859
4707
  /* alpha.59e -- build the response payload once, then either
3860
4708
  * stream it as a turn_complete SSE event OR send the legacy
3861
4709
  * JSON response. */
4710
+ const wasInterrupted = reply.interrupted === true;
3862
4711
  const responsePayload = {
3863
4712
  ok: true,
3864
4713
  message: { role: 'assistant', text: reply.text },
4714
+ /* Interrupt (task #27): tell the panel the turn was stopped
4715
+ * mid-flight so it can render the partial answer with a
4716
+ * "(interrumpido)" marker instead of treating it as complete. */
4717
+ ...(wasInterrupted ? { interrupted: true } : {}),
3865
4718
  tokens: { in: reply.tokensIn, out: reply.tokensOut },
3866
4719
  model: reply.model,
3867
4720
  /* alpha.43 -- manifest version stamp so the panel can detect
@@ -3966,6 +4819,14 @@ async function handleChat(req, res, ctx) {
3966
4819
  });
3967
4820
  }
3968
4821
  }
4822
+ finally {
4823
+ /* Interrupt (task #27): always clear this turn from the active
4824
+ * registry, no matter how the turn exits. endTurn is a no-op if a
4825
+ * newer turn has already replaced us, so it never clobbers a
4826
+ * concurrent turn's registration. */
4827
+ if (turnId)
4828
+ endTurn(turnId);
4829
+ }
3969
4830
  }
3970
4831
  export function buildSystemPrompt(ctx, mode = 'didactico', pilotState = {
3971
4832
  pilot_completed: true, target_pending: false, mode_pending: false,
@@ -4156,7 +5017,9 @@ panelMode = 'native') {
4156
5017
  ' Hallucinated completions are a credibility-destroyer; the user',
4157
5018
  ' finds out you did nothing the next message + loses trust in',
4158
5019
  ' every subsequent claim. When in doubt, ask. When sure, invoke.',
4159
- ' NEVER both promise AND defer.',
5020
+ ' NEVER both promise AND defer. Opening a pizarron doc',
5021
+ ' (yujin.pizarron.create-tab, HTML) or touching a task = CALL the',
5022
+ ' verb THIS turn; claiming it with no tool_use is a lie.',
4160
5023
  '',
4161
5024
  '=================================================================',
4162
5025
  'PRODUCT WORKFLOW (on-demand)',
@@ -4289,6 +5152,19 @@ panelMode = 'native') {
4289
5152
  ' AND open as editor tab. filesystem + dom. Opening does NOT',
4290
5153
  ' save -- pair with yujin.fs.write-project-file to persist.',
4291
5154
  '',
5155
+ 'KIND SELECTION (MANDATORY -- classify BEFORE create-tab, like',
5156
+ 'R1-R8; no exceptions). Map the request word -> kind:',
5157
+ ' Flow / flujo / flowchart / proceso / circuito / "los pasos',
5158
+ ' de X" / diagrama de proceso / BPMN -> kind="bpmn".',
5159
+ ' PROHIBITED: kind="graph" for a process. (see FLOWS)',
5160
+ ' Relaciones / dependencias / "que usa que" / grafo del',
5161
+ ' proyecto / nodos sin orden -> kind="graph".',
5162
+ ' Documento / reporte / spec / analisis / propuesta -> "doc".',
5163
+ ' Codigo fuente -> "code". Datos tabulares -> "table".',
5164
+ ' Tie-breaker bpmn vs graph: does it have a START, an END and',
5165
+ ' ordered STEPS? -> bpmn. A web of relations with no order? ->',
5166
+ ' graph.',
5167
+ '',
4292
5168
  'CRITICAL: "abrir en pizarra" NEVER means "saved". For',
4293
5169
  '"edit X and commit": (a) open-file, (b) write-project-file,',
4294
5170
  '(c) git.commit. THREE invokes. Do NOT ask user to do (b).',
@@ -4330,23 +5206,34 @@ panelMode = 'native') {
4330
5206
  'you opened it. Use kind="doc" by DEFAULT for documents;',
4331
5207
  'kind="code" only for source files, kind="graph" for diagrams.',
4332
5208
  '',
4333
- /* PIZ-3 (PND-073) -- kind=graph para grafos de nodos. */
4334
- 'GRAPHS: to show a node-and-edge graph (project graph, a',
4335
- 'workflow as nodes+connectors, a component design flow), use',
5209
+ /* PIZ-3 (PND-073) -- kind=graph para RELACIONES (no procesos). */
5210
+ 'GRAPHS: to show a node-and-edge graph of RELATIONS /',
5211
+ 'DEPENDENCIES (project graph, "que usa que", a component and its',
5212
+ 'props / state / hooks / children / data sources as nodes), use',
4336
5213
  'yujin.pizarron.create-tab with kind="graph" and content = a',
4337
5214
  'JSON string: {"nodes":[{"id","label","weight"(0..1 optional),',
4338
5215
  '"kind"(optional),"detail"(optional markdown shown on click)}],',
4339
5216
  '"edges":[{"from","to","kind"("uses"|"implements"|...),',
4340
5217
  '"label"(optional)}]}. The panel renders it as an interactive',
4341
5218
  'force-directed graph (drag, zoom, click a node for its detail).',
4342
- 'Prefer kind="graph" over hand-drawn SVG for any graph/diagram.',
4343
- 'Use kind="graph" ON DEMAND for: (a) the DESIGN FLOW of a',
4344
- 'component the user asks about -- nodes = the component + its',
4345
- 'props / state / hooks / children / data sources, edges = the',
4346
- 'data + render flow, each node detail explaining its role;',
4347
- '(b) the project WORKFLOW as phases+steps; (c) any "graficame /',
4348
- 'diagrama / flujo de X" request. Put each node explanation in',
4349
- 'its "detail" (markdown) so the click shows it.',
5219
+ 'kind="graph" is for RELATIONS WITHOUT ORDER. A business',
5220
+ 'PROCESS / FLUJO / flowchart (start -> ordered steps + decisions',
5221
+ '-> end) is NOT a graph -- use kind="bpmn" (see FLOWS below).',
5222
+ 'Prefer kind="graph" over hand-drawn SVG for a relations graph.',
5223
+ 'Put each node explanation in its "detail" (markdown).',
5224
+ '',
5225
+ /* PIZ-4 -- kind=bpmn para PROCESOS/FLUJOS (NO el grafo). */
5226
+ 'FLOWS / PROCESSES: for a business FLOW / flujo / flowchart /',
5227
+ 'proceso / circuito / "los pasos de X" -- anything with a start,',
5228
+ 'ordered steps, decisions and an end -- use',
5229
+ 'yujin.pizarron.create-tab with kind="bpmn" (BPMN 2.0). NEVER',
5230
+ 'use kind="graph" for a process. content = a JSON string:',
5231
+ '{"lanes"(optional):[{"id","label"}],"nodes":[{"id","type",',
5232
+ '"label","lane"(opt),"gateway"(opt)}],"flows":[{"from","to",',
5233
+ '"label"(opt)}]}; type in startEvent|endEvent|intermediate|',
5234
+ 'task|userTask|serviceTask|scriptTask|sendTask|receiveTask|',
5235
+ 'gateway; gateway in exclusive|parallel|inclusive. The panel',
5236
+ 'renders swimlanes, events, tasks, gateways and arrows.',
4350
5237
  '',
4351
5238
  /* PND-064 -- capability honesty per mode. */
4352
5239
  ...(panelMode === 'mcp-bridge' ? [