@nac3/forge-cli 0.2.1-alpha.60 → 1.0.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 (301) 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 +45 -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 +885 -42
  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 +890 -62
  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/verb-composition.md +16 -0
  161. package/dist/docs/doctrine/workflow-kinds.md +39 -22
  162. package/dist/docs/doctrine/workflow.md +12 -4
  163. package/dist/license/auto_sync.d.ts +22 -0
  164. package/dist/license/auto_sync.d.ts.map +1 -0
  165. package/dist/license/auto_sync.js +91 -0
  166. package/dist/license/auto_sync.js.map +1 -0
  167. package/dist/license/handle_key.d.ts +32 -0
  168. package/dist/license/handle_key.d.ts.map +1 -0
  169. package/dist/license/handle_key.js +56 -0
  170. package/dist/license/handle_key.js.map +1 -0
  171. package/dist/license/hito4_client.d.ts.map +1 -1
  172. package/dist/license/hito4_client.js +63 -25
  173. package/dist/license/hito4_client.js.map +1 -1
  174. package/dist/llm/gemini_client.d.ts.map +1 -1
  175. package/dist/llm/gemini_client.js +5 -2
  176. package/dist/llm/gemini_client.js.map +1 -1
  177. package/dist/llm/managed_client.d.ts.map +1 -1
  178. package/dist/llm/managed_client.js +14 -9
  179. package/dist/llm/managed_client.js.map +1 -1
  180. package/dist/mcp/plan_bridges.d.ts +17 -0
  181. package/dist/mcp/plan_bridges.d.ts.map +1 -0
  182. package/dist/mcp/plan_bridges.js +119 -0
  183. package/dist/mcp/plan_bridges.js.map +1 -0
  184. package/dist/migrate/ai-apply.d.ts.map +1 -1
  185. package/dist/migrate/ai-apply.js +25 -2
  186. package/dist/migrate/ai-apply.js.map +1 -1
  187. package/dist/nac3/internal_manifest.d.ts.map +1 -1
  188. package/dist/nac3/internal_manifest.js +200 -10
  189. package/dist/nac3/internal_manifest.js.map +1 -1
  190. package/dist/nac3/verb_tier_map.d.ts.map +1 -1
  191. package/dist/nac3/verb_tier_map.js +3 -0
  192. package/dist/nac3/verb_tier_map.js.map +1 -1
  193. package/dist/onboarding/concierge_client.d.ts +34 -0
  194. package/dist/onboarding/concierge_client.d.ts.map +1 -0
  195. package/dist/onboarding/concierge_client.js +80 -0
  196. package/dist/onboarding/concierge_client.js.map +1 -0
  197. package/dist/onboarding/next_step.d.ts +32 -0
  198. package/dist/onboarding/next_step.d.ts.map +1 -0
  199. package/dist/onboarding/next_step.js +60 -0
  200. package/dist/onboarding/next_step.js.map +1 -0
  201. package/dist/pending/store.d.ts +7 -0
  202. package/dist/pending/store.d.ts.map +1 -1
  203. package/dist/pending/store.js +19 -0
  204. package/dist/pending/store.js.map +1 -1
  205. package/dist/pending/types.d.ts +20 -0
  206. package/dist/pending/types.d.ts.map +1 -1
  207. package/dist/pending/types.js.map +1 -1
  208. package/dist/reader/parsers/epub.d.ts.map +1 -1
  209. package/dist/reader/parsers/epub.js +6 -1
  210. package/dist/reader/parsers/epub.js.map +1 -1
  211. package/dist/relay/board_snapshot.d.ts +69 -0
  212. package/dist/relay/board_snapshot.d.ts.map +1 -0
  213. package/dist/relay/board_snapshot.js +149 -0
  214. package/dist/relay/board_snapshot.js.map +1 -0
  215. package/dist/relay/command_consumer.d.ts +91 -0
  216. package/dist/relay/command_consumer.d.ts.map +1 -0
  217. package/dist/relay/command_consumer.js +129 -0
  218. package/dist/relay/command_consumer.js.map +1 -0
  219. package/dist/relay/mock_relay.d.ts +34 -0
  220. package/dist/relay/mock_relay.d.ts.map +1 -0
  221. package/dist/relay/mock_relay.js +93 -0
  222. package/dist/relay/mock_relay.js.map +1 -0
  223. package/dist/relay/relay_client.d.ts +28 -0
  224. package/dist/relay/relay_client.d.ts.map +1 -0
  225. package/dist/relay/relay_client.js +80 -0
  226. package/dist/relay/relay_client.js.map +1 -0
  227. package/dist/relay/relay_publisher.d.ts +59 -0
  228. package/dist/relay/relay_publisher.d.ts.map +1 -0
  229. package/dist/relay/relay_publisher.js +101 -0
  230. package/dist/relay/relay_publisher.js.map +1 -0
  231. package/dist/relay/types.d.ts +54 -0
  232. package/dist/relay/types.d.ts.map +1 -0
  233. package/dist/relay/types.js +8 -0
  234. package/dist/relay/types.js.map +1 -0
  235. package/dist/semantic_graph/ast_extract.d.ts +5 -0
  236. package/dist/semantic_graph/ast_extract.d.ts.map +1 -1
  237. package/dist/semantic_graph/ast_extract.js +46 -0
  238. package/dist/semantic_graph/ast_extract.js.map +1 -1
  239. package/dist/semantic_graph/derive.d.ts +12 -0
  240. package/dist/semantic_graph/derive.d.ts.map +1 -1
  241. package/dist/semantic_graph/derive.js +325 -6
  242. package/dist/semantic_graph/derive.js.map +1 -1
  243. package/dist/semantic_graph/store.d.ts +9 -0
  244. package/dist/semantic_graph/store.d.ts.map +1 -1
  245. package/dist/semantic_graph/store.js +23 -1
  246. package/dist/semantic_graph/store.js.map +1 -1
  247. package/dist/semantic_graph/types.d.ts +1 -1
  248. package/dist/semantic_graph/types.d.ts.map +1 -1
  249. package/dist/support/github_dispatcher.d.ts.map +1 -1
  250. package/dist/support/github_dispatcher.js +30 -2
  251. package/dist/support/github_dispatcher.js.map +1 -1
  252. package/dist/tasks/supervisor.d.ts +88 -0
  253. package/dist/tasks/supervisor.d.ts.map +1 -0
  254. package/dist/tasks/supervisor.js +308 -0
  255. package/dist/tasks/supervisor.js.map +1 -0
  256. package/dist/tasks/task_type.d.ts +11 -0
  257. package/dist/tasks/task_type.d.ts.map +1 -0
  258. package/dist/tasks/task_type.js +21 -0
  259. package/dist/tasks/task_type.js.map +1 -0
  260. package/dist/vault/store.d.ts.map +1 -1
  261. package/dist/vault/store.js +6 -1
  262. package/dist/vault/store.js.map +1 -1
  263. package/dist/version.d.ts +3 -5
  264. package/dist/version.d.ts.map +1 -1
  265. package/dist/version.js +3 -5
  266. package/dist/version.js.map +1 -1
  267. package/dist/voice/providers/google.d.ts.map +1 -1
  268. package/dist/voice/providers/google.js +6 -4
  269. package/dist/voice/providers/google.js.map +1 -1
  270. package/dist/workflow/config.d.ts +43 -0
  271. package/dist/workflow/config.d.ts.map +1 -0
  272. package/dist/workflow/config.js +199 -0
  273. package/dist/workflow/config.js.map +1 -0
  274. package/dist/workflow/instances.d.ts +40 -0
  275. package/dist/workflow/instances.d.ts.map +1 -0
  276. package/dist/workflow/instances.js +122 -0
  277. package/dist/workflow/instances.js.map +1 -0
  278. package/dist/workflow/multi_view.d.ts +27 -0
  279. package/dist/workflow/multi_view.d.ts.map +1 -0
  280. package/dist/workflow/multi_view.js +76 -0
  281. package/dist/workflow/multi_view.js.map +1 -0
  282. package/dist/workflow/orchestrator.d.ts +111 -0
  283. package/dist/workflow/orchestrator.d.ts.map +1 -0
  284. package/dist/workflow/orchestrator.js +241 -0
  285. package/dist/workflow/orchestrator.js.map +1 -0
  286. package/docs/doctrine/default-design-system.md +32 -0
  287. package/docs/doctrine/office-tools.md +20 -0
  288. package/docs/doctrine/verb-composition.md +16 -0
  289. package/docs/doctrine/workflow-kinds.md +39 -22
  290. package/docs/doctrine/workflow.md +12 -4
  291. package/package.json +2 -1
  292. package/src/i18n/catalogs/ar.json +223 -210
  293. package/src/i18n/catalogs/de.json +223 -210
  294. package/src/i18n/catalogs/en.json +223 -210
  295. package/src/i18n/catalogs/es.json +223 -210
  296. package/src/i18n/catalogs/fr.json +223 -210
  297. package/src/i18n/catalogs/hi.json +223 -210
  298. package/src/i18n/catalogs/it.json +223 -210
  299. package/src/i18n/catalogs/ja.json +223 -210
  300. package/src/i18n/catalogs/pt.json +223 -210
  301. 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,373 @@ 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
+ onError: () => { },
580
+ });
581
+ const control = startCommandConsumer({
582
+ enabled: relayEnabled, intervalMs: 1200,
583
+ accept: (c) => !!c.control,
584
+ pollCommands, runTurn, onControl, reply,
585
+ onError: () => { },
586
+ });
587
+ return {
588
+ stop() { chat.stop(); control.stop(); },
589
+ tickOnce: async () => { await chat.tickOnce(); await control.tickOnce(); },
590
+ };
591
+ }
592
+ /* Project context for board snapshots, set per chat turn (handleChat). */
593
+ let _relayBoardCtx = null;
594
+ function setRelayBoardCtx(projectRoot, slug) {
595
+ _relayBoardCtx = { projectRoot, slug };
596
+ }
597
+ const _recentDocs = [];
598
+ function pushRecentDoc(title, kind, content) {
599
+ _recentDocs.push({
600
+ title: String(title || 'documento').slice(0, 120),
601
+ kind: String(kind || 'doc'),
602
+ content: String(content || '').slice(0, 40000),
603
+ ts: new Date().toISOString(),
604
+ });
605
+ while (_recentDocs.length > 12)
606
+ _recentDocs.shift();
607
+ }
608
+ /* Publish a compact board snapshot (workflow + pendings + recent docs)
609
+ * for the phone's Pizarron tab. Fire-and-forget; best-effort. */
610
+ function publishBoardSnapshot() {
611
+ const pub = getRelayPublisher();
612
+ if (!pub || !_relayBoardCtx)
613
+ return;
614
+ const cx = _relayBoardCtx;
615
+ void (async () => {
616
+ try {
617
+ const { buildBoardSnapshot } = await import('../relay/board_snapshot.js');
618
+ const snap = await buildBoardSnapshot(cx.projectRoot, cx.slug);
619
+ /* newest doc first */
620
+ snap.docs = _recentDocs.slice().reverse();
621
+ /* i18n (task #34) -- stamp the desktop's active language so the
622
+ * paired phone inherits it (the mobile UI has no language picker;
623
+ * it follows the notebook). One of the 10 canonical tags. */
624
+ try {
625
+ const { currentLanguage } = await import('../i18n/index.js');
626
+ snap.lang = currentLanguage();
627
+ }
628
+ catch { /* leave unset -> mobile keeps its fallback */ }
629
+ /* Project selector (task #33) -- relay the known projects + the
630
+ * active slug so the phone can switch the desktop's project (a
631
+ * control command flows back through the consumer). Compact: slug
632
+ * + name + path, capped. */
633
+ try {
634
+ const reg = await readRegistry();
635
+ snap.projects = (reg.projects || []).slice(0, 30).map((p) => ({
636
+ slug: p.slug, path: p.path || '',
637
+ }));
638
+ snap.activeProject = reg.active_slug || cx.slug || null;
639
+ }
640
+ catch { /* leave unset */ }
641
+ pub.board(snap);
642
+ /* PND-094 ext -- publish the semantic graph as its OWN event so it
643
+ * is not capped by the board's 32KB budget. */
644
+ try {
645
+ const { buildGraphSnapshot } = await import('../relay/board_snapshot.js');
646
+ const g = cx.slug ? await buildGraphSnapshot(cx.slug) : null;
647
+ if (g)
648
+ pub.graph(g);
649
+ }
650
+ catch { /* no graph -> skip */ }
651
+ void pub.flush();
652
+ }
653
+ catch { /* best-effort */ }
654
+ })();
655
+ }
656
+ /* PND-007 -- Task Supervisor HTTP surface for the panel Tareas tab.
657
+ * GET /api/forge/task -> list; POST /api/forge/task -> run. */
658
+ async function handleForgeTask(req, res, ctx, _url) {
659
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
660
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
661
+ if (req.method === 'GET') {
662
+ sendJson(res, 200, { ok: true, tasks: sup.list() });
663
+ return;
664
+ }
665
+ if (req.method === 'POST') {
666
+ let body;
667
+ try {
668
+ body = JSON.parse(await readBody(req));
669
+ }
670
+ catch {
671
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
672
+ return;
673
+ }
674
+ if (typeof body.cmd !== 'string' || !body.cmd) {
675
+ sendJson(res, 400, { ok: false, error: 'cmd (string) required' });
676
+ return;
677
+ }
678
+ const rec = await sup.run({
679
+ cmd: body.cmd,
680
+ args: Array.isArray(body.args) ? body.args.map(String) : [],
681
+ cwd: typeof body.cwd === 'string' && body.cwd ? body.cwd : ctx.projectRoot,
682
+ label: typeof body.label === 'string' ? body.label : undefined,
683
+ });
684
+ sendJson(res, 200, { ok: rec.status !== 'failed', task: rec });
685
+ return;
686
+ }
687
+ sendJson(res, 405, { ok: false, error: 'method not allowed' });
688
+ }
689
+ async function handleForgeTaskLog(_req, res, ctx, url) {
690
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
691
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
692
+ const id = url.searchParams.get('id') ?? '';
693
+ const lines = Number.parseInt(url.searchParams.get('lines') ?? '60', 10) || 60;
694
+ if (!sup.get(id)) {
695
+ sendJson(res, 404, { ok: false, error: 'unknown task id' });
696
+ return;
697
+ }
698
+ const tail = await sup.tailLog(id, Math.min(lines, 500));
699
+ sendJson(res, 200, { ok: true, id, tail });
700
+ }
701
+ async function handleForgeTaskKill(req, res, ctx) {
702
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
703
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
704
+ let body;
705
+ try {
706
+ body = JSON.parse(await readBody(req));
707
+ }
708
+ catch {
709
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
710
+ return;
711
+ }
712
+ const id = typeof body.id === 'string' ? body.id : '';
713
+ const killed = sup.kill(id);
714
+ sendJson(res, killed ? 200 : 404, { ok: killed, id });
715
+ }
716
+ async function handleForgeTaskDelete(req, res, ctx) {
717
+ const { getTaskSupervisor } = await import('../tasks/supervisor.js');
718
+ const sup = getTaskSupervisor(ctx.projectSlug ?? null);
719
+ let body;
720
+ try {
721
+ body = JSON.parse(await readBody(req));
722
+ }
723
+ catch {
724
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
725
+ return;
726
+ }
727
+ const id = typeof body.id === 'string' ? body.id : '';
728
+ const removed = await sup.remove(id);
729
+ sendJson(res, removed ? 200 : 404, { ok: removed, id });
730
+ }
731
+ function relayForward(event, data) {
732
+ const pub = getRelayPublisher();
733
+ if (!pub)
734
+ return;
735
+ try {
736
+ const d = (data && typeof data === 'object') ? data : {};
737
+ if (event === 'tool_round_start') {
738
+ pub.activity(String(d.summary || d.verb_hint || d.verb_id || 'trabajando'), d.detail);
739
+ }
740
+ else if (event === 'turn_start') {
741
+ pub.status('working', { tier: d.tier, provider: d.provider, model: d.model });
742
+ }
743
+ else if (event === 'turn_complete') {
744
+ pub.status('round_done');
745
+ /* refresh the phone's board after each turn (workflow/pendings may
746
+ * have changed). */
747
+ publishBoardSnapshot();
748
+ }
749
+ else if (event === 'error') {
750
+ pub.status('error', { message: d.message ?? d.error ?? '' });
751
+ }
752
+ }
753
+ catch { /* relay best-effort */ }
754
+ }
755
+ /* PND-045 (sec 1.1) -- CSRF / DNS-rebinding defense for the local
756
+ * panel server. Even bound to 127.0.0.1, the server is reachable
757
+ * from any web page the dev visits (a "simple" text/plain fetch
758
+ * needs no CORS preflight, and DNS rebinding sidesteps guessing the
759
+ * port). Two checks, applied to every state-mutating request:
760
+ * - Host: must name a loopback host. A rebound DNS name arrives as
761
+ * Host: attacker.com, so rejecting foreign Host kills rebinding.
762
+ * - Origin: when present (browsers always send it on cross-origin
763
+ * POST, including text/plain), it must be a loopback origin. The
764
+ * panel's own requests carry Origin http://127.0.0.1:<port>, so
765
+ * legitimate traffic passes; non-browser clients (tests, relay)
766
+ * send no Origin and are unaffected. No feature is lost. */
767
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
768
+ function hostnameOf(hostHeader) {
769
+ const h = hostHeader.trim().toLowerCase();
770
+ if (!h)
771
+ return '';
772
+ if (h.startsWith('[')) {
773
+ const i = h.indexOf(']');
774
+ return i >= 0 ? h.slice(0, i + 1) : h;
775
+ }
776
+ const c = h.indexOf(':');
777
+ return c >= 0 ? h.slice(0, c) : h;
778
+ }
779
+ function isLocalMutatingRequestOk(req) {
780
+ const host = hostnameOf(String(req.headers.host ?? ''));
781
+ if (host && !LOOPBACK_HOSTS.has(host)) {
782
+ return { ok: false, reason: 'Host no-loopback rechazado (posible DNS-rebinding): ' + host.slice(0, 60) };
783
+ }
784
+ const origin = req.headers.origin;
785
+ if (typeof origin === 'string' && origin && origin.toLowerCase() !== 'null') {
786
+ let oh;
787
+ try {
788
+ oh = new URL(origin).hostname.toLowerCase();
789
+ }
790
+ catch {
791
+ return { ok: false, reason: 'Origin malformado' };
792
+ }
793
+ const normalized = oh.startsWith('[') ? oh : oh; /* URL.hostname already strips ipv6 brackets to bare ::1 on Node */
794
+ if (!LOOPBACK_HOSTS.has(normalized) && !LOOPBACK_HOSTS.has('[' + normalized + ']')) {
795
+ return { ok: false, reason: 'Origin cross-site rechazado (CSRF): ' + origin.slice(0, 80) };
796
+ }
797
+ }
798
+ return { ok: true };
799
+ }
800
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
323
801
  async function route(req, res, ctx) {
324
802
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
803
+ if (MUTATING_METHODS.has(req.method ?? '')) {
804
+ const guard = isLocalMutatingRequestOk(req);
805
+ if (!guard.ok) {
806
+ sendJson(res, 403, { ok: false, error: 'forbidden', reason: guard.reason });
807
+ return;
808
+ }
809
+ }
325
810
  if (req.method === 'GET' && url.pathname === '/') {
326
811
  // Stateless lang resolution: query string > cookie >
327
812
  // server-wide setLanguage. This isolates per-request
@@ -419,6 +904,17 @@ async function route(req, res, ctx) {
419
904
  await handleChat(req, res, ctx);
420
905
  return;
421
906
  }
907
+ /* Interrupt (task #27) -- stop the in-flight turn. The panel's
908
+ * "Detener" button (and the mobile control path) POST here; we
909
+ * abort the active turn's signal so the caller unwinds (claude.ts
910
+ * breaks at the next round boundary; claude_cli kills `claude -p`).
911
+ * Idempotent: returns active:false when nothing is running. */
912
+ if (req.method === 'POST' && url.pathname === '/api/chat/interrupt') {
913
+ const wasActive = isTurnActive();
914
+ const r = interruptActiveTurn();
915
+ sendJson(res, 200, { ok: true, active: wasActive, interrupted: r.ok, turn_id: r.id });
916
+ return;
917
+ }
422
918
  /* Vault surface (V0.4 of HITO 0). Same-origin: the server only
423
919
  listens on 127.0.0.1 and the browser blocks cross-origin
424
920
  fetches from other sites by default (no CORS headers set).
@@ -996,7 +1492,7 @@ async function route(req, res, ctx) {
996
1492
  return;
997
1493
  }
998
1494
  if (req.method === 'POST' && url.pathname === '/api/forge/projects/active') {
999
- await handleProjectsSwitch(req, res);
1495
+ await handleProjectsSwitch(req, res, ctx);
1000
1496
  return;
1001
1497
  }
1002
1498
  if (req.method === 'GET' && url.pathname === '/api/forge/projects/active') {
@@ -1087,7 +1583,7 @@ async function route(req, res, ctx) {
1087
1583
  return;
1088
1584
  }
1089
1585
  if (req.method === 'POST' && url.pathname === '/api/forge/brain-config') {
1090
- await handleBrainConfigSet(req, res);
1586
+ await handleBrainConfigSet(req, res, ctx);
1091
1587
  return;
1092
1588
  }
1093
1589
  /* PND-069 -- modelos disponibles por provider (API en vivo con
@@ -1109,6 +1605,23 @@ async function route(req, res, ctx) {
1109
1605
  await handlePanelQueue(req, res, url);
1110
1606
  return;
1111
1607
  }
1608
+ /* PND-007 -- Task Supervisor: the panel Tareas tab polls + acts here. */
1609
+ if (url.pathname === '/api/forge/task') {
1610
+ await handleForgeTask(req, res, ctx, url);
1611
+ return;
1612
+ }
1613
+ if (req.method === 'GET' && url.pathname === '/api/forge/task/log') {
1614
+ await handleForgeTaskLog(req, res, ctx, url);
1615
+ return;
1616
+ }
1617
+ if (req.method === 'POST' && url.pathname === '/api/forge/task/kill') {
1618
+ await handleForgeTaskKill(req, res, ctx);
1619
+ return;
1620
+ }
1621
+ if (req.method === 'POST' && url.pathname === '/api/forge/task/delete') {
1622
+ await handleForgeTaskDelete(req, res, ctx);
1623
+ return;
1624
+ }
1112
1625
  /* GRAPH-1 -- regenerar el grafo semantico (nodos + aristas
1113
1626
  derivadas de imports). El boton Refresh del panel lo llama. */
1114
1627
  if (req.method === 'POST' && url.pathname === '/api/forge/graph-regenerate') {
@@ -2059,10 +2572,35 @@ async function handleVoiceTts(req, res, ctx) {
2059
2572
  * ============================================================ */
2060
2573
  /** Check that `target` is inside `root` (after resolving both).
2061
2574
  * Refuses path traversal like ../../../etc/passwd. */
2575
+ function realpathOrSelf(p) {
2576
+ try {
2577
+ return realpathSync.native(p);
2578
+ }
2579
+ catch {
2580
+ return p;
2581
+ }
2582
+ }
2583
+ /* PND-045 (sec 1.7) -- containment check that also defeats symlink
2584
+ * escapes. path.resolve does NOT follow symlinks, so a symlink that
2585
+ * lives INSIDE the root but points OUTSIDE would pass a naive
2586
+ * startsWith. We realpath both the root and the deepest EXISTING
2587
+ * ancestor of the target (the target itself may not exist yet on a
2588
+ * write), then re-append the not-yet-existing tail before comparing. */
2062
2589
  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);
2590
+ const rootReal = realpathOrSelf(path.resolve(root));
2591
+ let existing = path.resolve(target);
2592
+ const tail = [];
2593
+ while (!existsSync(existing)) {
2594
+ const parent = path.dirname(existing);
2595
+ if (parent === existing)
2596
+ break; /* reached fs root */
2597
+ tail.unshift(path.basename(existing));
2598
+ existing = parent;
2599
+ }
2600
+ const realBase = realpathOrSelf(existing);
2601
+ const targetReal = tail.length ? path.join(realBase, ...tail) : realBase;
2602
+ const rootWithSep = rootReal + path.sep;
2603
+ return targetReal === rootReal || targetReal.startsWith(rootWithSep);
2066
2604
  }
2067
2605
  /** Editor commands tried in order. First match wins. The args
2068
2606
  * templates are joined with the file path at runtime. All
@@ -2271,7 +2809,7 @@ async function handleBrainConfigGet(_req, res, ctx) {
2271
2809
  * campos; valida y persiste. anthropic_mode requiere reabrir el
2272
2810
  * panel para tomar efecto (el brain client se construye al boot);
2273
2811
  * eco_enabled y los bindings aplican en el proximo turno. */
2274
- async function handleBrainConfigSet(req, res) {
2812
+ async function handleBrainConfigSet(req, res, ctx) {
2275
2813
  const { readBrainConfig, writeBrainConfig, ALL_BRAIN_PROVIDERS, } = await import('../core/brain_config.js');
2276
2814
  let body;
2277
2815
  try {
@@ -2307,7 +2845,21 @@ async function handleBrainConfigSet(req, res) {
2307
2845
  cfg[tier].model = model;
2308
2846
  }
2309
2847
  await writeBrainConfig(cfg);
2310
- sendJson(res, 200, { ok: true, config: cfg, restart_needed_for: ['anthropic_mode'] });
2848
+ /* Apply the brain mode LIVE: hot-swap the provider so flipping
2849
+ * Plan/Console takes effect on the very next turn -- no panel restart.
2850
+ * (Fixes "clickeo Plan pero me dice que no tengo tokens de Console".) */
2851
+ let applied_live = false;
2852
+ if ((body.anthropic_mode === 'plan' || body.anthropic_mode === 'console') && ctx.setBrainProvider) {
2853
+ try {
2854
+ applied_live = await ctx.setBrainProvider(body.anthropic_mode);
2855
+ }
2856
+ catch { /* leave applied_live false -> client shows the restart hint */ }
2857
+ }
2858
+ sendJson(res, 200, {
2859
+ ok: true, config: cfg, applied_live,
2860
+ /* nothing needs a restart when the swap applied live */
2861
+ restart_needed_for: applied_live ? [] : ['anthropic_mode'],
2862
+ });
2311
2863
  }
2312
2864
  /** PND-064 -- POST /api/forge/panel-invoke. Paridad total de tools
2313
2865
  * en modo Plan: el MCP server local reenvia aca cada nac3_invoke
@@ -2367,6 +2919,16 @@ async function handlePanelInvoke(req, res, ctx) {
2367
2919
  const shortVerb = String(verb.id || '').replace(/^yujin\./, '');
2368
2920
  const { enqueuePanelDispatch } = await import('./panel_queue.js');
2369
2921
  const seq = enqueuePanelDispatch(shortVerb, payload ? { ...dispatchArgs, server_payload: payload } : dispatchArgs, new Date().toISOString());
2922
+ /* PND -- if it is a pizarron DOCUMENT, also relay it to the phone
2923
+ * (its Pizarron tab has no native doc canvas). Folded into the next
2924
+ * board snapshot. */
2925
+ if (shortVerb === 'pizarron.create-tab') {
2926
+ const k = String(dispatchArgs.kind || 'doc');
2927
+ if (['doc', 'document', 'md', 'markdown', 'html', 'table'].includes(k)) {
2928
+ pushRecentDoc(String(dispatchArgs.title || 'documento'), k, String(dispatchArgs.content || ''));
2929
+ publishBoardSnapshot();
2930
+ }
2931
+ }
2370
2932
  sendJson(res, 200, {
2371
2933
  ok: true, seq, panel_dispatch: true,
2372
2934
  text: 'Listo: enviado al pizarron (' + shortVerb + ').',
@@ -2418,23 +2980,28 @@ async function handlePanelQueue(_req, res, url) {
2418
2980
  /** Mobile pairing QR -- genera el codigo + envelope cifrado y lo
2419
2981
  * renderiza como QR SVG para que Forge Mobile lo escanee. El
2420
2982
  * 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). */
2983
+ * payload viaja DENTRO del QR (Option 3 -- code-less): el QR es
2984
+ * autocontenido, el usuario lo muestra en su propia pantalla y el
2985
+ * expires_at de 5 min acota un QR capturado. */
2424
2986
  async function handleMobilePair(_req, res) {
2425
2987
  try {
2426
2988
  const { assemblePairing } = await import('../commands/mobile.js');
2427
2989
  const built = await assemblePairing({});
2428
- if (!built.ok || !built.qr || !built.code) {
2990
+ if (!built.ok || !built.qr) {
2429
2991
  sendJson(res, 200, { ok: false, error: built.error ?? 'no pude generar el pairing', hint: built.hint });
2430
2992
  return;
2431
2993
  }
2432
2994
  const QRCode = (await import('qrcode')).default;
2995
+ /* PND-047 follow-up -- the pairing payload grew (the per-handle key is
2996
+ * 64 hex chars + key_kind), pushing the QR from 97x97 to 105x105 modules
2997
+ * at EC 'M', which made it hard to scan. EC 'L' brings it to 93x93 --
2998
+ * LESS dense than the old easy-to-scan QR. The envelope is short-lived
2999
+ * (5-min TTL) so the lighter error correction is fine for a screen scan. */
2433
3000
  const qrSvg = await QRCode.toString(built.qr, {
2434
- type: 'svg', margin: 1, errorCorrectionLevel: 'M',
2435
- color: { dark: '#1a1a1a', light: '#f5f0e6' },
3001
+ type: 'svg', margin: 1, errorCorrectionLevel: 'L',
3002
+ color: { dark: '#1a1a1a', light: '#ffffff' },
2436
3003
  });
2437
- sendJson(res, 200, { ok: true, code: built.code, expires_at: built.expires_at, qr_svg: qrSvg });
3004
+ sendJson(res, 200, { ok: true, expires_at: built.expires_at, qr_svg: qrSvg });
2438
3005
  }
2439
3006
  catch (err) {
2440
3007
  sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err) });
@@ -2465,25 +3032,30 @@ async function handleStartupTabs(_req, res, ctx, url) {
2465
3032
  /* 1 + 2: grafo (grafico + semantico). */
2466
3033
  try {
2467
3034
  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. */
3035
+ /* El grafo muestra TODO el conocimiento del proyecto: nodos
3036
+ * arquitectonicos (module./lib. prefijo) + los declarados en chat
3037
+ * (dominio, confidence 'claimed'). Solo se excluyen los comp. de
3038
+ * export fino del watcher, que saturan. Antes el filtro era por
3039
+ * prefijo module./lib., lo que ocultaba TODO lo declarado en chat
3040
+ * (el 87% del conocimiento real -- PND graph). Los 'claimed' van
3041
+ * atenuados + punteados via la confidence que pasamos al renderer. */
2471
3042
  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.'));
3043
+ const nodes = allNodes.filter((n) => !n.id.startsWith('comp.'));
2473
3044
  const allEdges = slug ? await listAllEdges(slug) : [];
2474
3045
  const nodeIdSet = new Set(nodes.map((n) => n.id));
2475
3046
  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 || '' }));
3047
+ 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
3048
  const gEdges = edges.map((e) => ({ from: e.from_id, to: e.to_id, kind: e.kind }));
2478
3049
  tabs.push({ key: 'startup_graph', title: t('startup.tab.graph'), kind: 'graph', content: JSON.stringify({ nodes: gNodes, edges: gEdges }) });
2479
3050
  /* 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);
3051
+ * peso + conteo de aristas), con la confianza para distinguir lo
3052
+ * declarado (claimed) de lo derivado del codigo (auto). */
3053
+ const top = [...nodes].sort((a, b) => (b.weight || 0) - (a.weight || 0)).slice(0, 60);
2482
3054
  let md = '# ' + t('startup.tab.graphSem') + ' -- ' + (slug || 'proyecto') + '\n\n'
2483
3055
  + 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';
3056
+ + '| ' + t('startup.tab.graphSem') + ' | | conf | ' + t('pizarron.graph.detail.weight') + ' |\n|---|---|---|---|\n';
2485
3057
  for (const n of top)
2486
- md += '| ' + (n.name || n.id) + ' | ' + n.kind + ' | ' + (Math.round((n.weight || 0) * 100) / 100) + ' |\n';
3058
+ md += '| ' + (n.name || n.id) + ' | ' + n.kind + ' | ' + n.confidence + ' | ' + (Math.round((n.weight || 0) * 100) / 100) + ' |\n';
2487
3059
  tabs.push({ key: 'startup_graph_sem', title: t('startup.tab.graphSem'), kind: 'md', content: md });
2488
3060
  }
2489
3061
  catch { /* sin grafo */ }
@@ -2510,15 +3082,31 @@ async function handleStartupTabs(_req, res, ctx, url) {
2510
3082
  try {
2511
3083
  /* PND-094 -- vista jerarquica de FASES + tareas (no grafo): cada
2512
3084
  * 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) });
3085
+ /* PND-094 ext -- multi-workflow: the coexisting WorkflowInstances
3086
+ * with their tasks (pendings) grouped by stage. Falls back to the
3087
+ * legacy single-workflow view when there are no instances yet. */
3088
+ const { computeWorkflowsView } = await import('../workflow/multi_view.js');
3089
+ const cards = await computeWorkflowsView(ctx.projectSlug ?? '', 'open');
3090
+ if (cards.length > 0) {
3091
+ tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'workflow', content: JSON.stringify({ workflows: cards }) });
3092
+ }
3093
+ else {
3094
+ const { readWorkflow } = await import('../workflow/state.js');
3095
+ const { computeWorkflowView } = await import('../workflow/view.js');
3096
+ const view = computeWorkflowView(await readWorkflow(ctx.projectRoot));
3097
+ tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'workflow', content: JSON.stringify(view) });
3098
+ }
2518
3099
  }
2519
3100
  catch {
2520
3101
  tabs.push({ key: 'startup_workflow', title: t('startup.tab.workflow'), kind: 'md', content: '# Workflow\n\nSin datos.\n' });
2521
3102
  }
3103
+ /* 5: PND-007 -- Tareas de fondo (se llena en vivo client-side). */
3104
+ tabs.push({
3105
+ key: 'startup_tasks',
3106
+ title: lang === 'en' ? 'Tasks' : 'Tareas',
3107
+ kind: 'tasks',
3108
+ content: '',
3109
+ });
2522
3110
  sendJson(res, 200, { ok: true, enabled: true, tabs });
2523
3111
  }
2524
3112
  /** Formato de id de pending para display (PND-NNN). */
@@ -2998,7 +3586,7 @@ async function handleProjectsActive(res) {
2998
3586
  const entry = slug ? reg.projects.find((p) => p.slug === slug) : null;
2999
3587
  sendJson(res, 200, { ok: true, active: slug, project: entry ?? null });
3000
3588
  }
3001
- async function handleProjectsSwitch(req, res) {
3589
+ async function handleProjectsSwitch(req, res, ctx) {
3002
3590
  let body;
3003
3591
  try {
3004
3592
  body = JSON.parse(await readBody(req));
@@ -3013,7 +3601,20 @@ async function handleProjectsSwitch(req, res) {
3013
3601
  }
3014
3602
  try {
3015
3603
  const reg = await setActive(body.slug);
3016
- sendJson(res, 200, { ok: true, active: reg.active_slug, registry: reg });
3604
+ /* Task #33 -- a switch must HOT-reanchor the live session, not just
3605
+ * persist active_slug (otherwise the running server keeps serving
3606
+ * the old project until restart). Look up the entry's path + call
3607
+ * ctx.reanchor so the panel + pizarron + graph follow immediately. */
3608
+ let reanchored = false;
3609
+ const entry = reg.projects.find((p) => p.slug === body.slug);
3610
+ if (entry && entry.path && ctx.reanchor) {
3611
+ try {
3612
+ await ctx.reanchor(entry.path);
3613
+ reanchored = true;
3614
+ }
3615
+ catch { /* reanchor failed -> active_slug still persisted */ }
3616
+ }
3617
+ sendJson(res, 200, { ok: true, active: reg.active_slug, reanchored, registry: reg });
3017
3618
  }
3018
3619
  catch (err) {
3019
3620
  sendJson(res, 404, { ok: false,
@@ -3175,6 +3776,9 @@ async function handleVaultHas(res, slot) {
3175
3776
  }
3176
3777
  }
3177
3778
  async function handleChat(req, res, ctx) {
3779
+ /* P2 -- remember the project context so turn_complete can publish a
3780
+ * board snapshot to the paired phone's Pizarron tab. */
3781
+ setRelayBoardCtx(ctx.projectRoot, ctx.projectSlug ?? null);
3178
3782
  const raw = await readBody(req);
3179
3783
  let body;
3180
3784
  try {
@@ -3189,6 +3793,81 @@ async function handleChat(req, res, ctx) {
3189
3793
  sendJson(res, 400, { ok: false, error: 'messages[] required' });
3190
3794
  return;
3191
3795
  }
3796
+ /* Multisession: the conversation THREAD this turn belongs to. The
3797
+ * default ('desktop-main', what the panel uses) reuses the server's
3798
+ * main store; a mobile "Sesion propia" passes its own id for an
3799
+ * independent parallel thread. Everything below reads/writes
3800
+ * `sessionStore` instead of `ctx.store`. */
3801
+ const sessionId = typeof body.session_id === 'string' && body.session_id
3802
+ ? body.session_id : DEFAULT_SESSION_ID;
3803
+ /* The transcript THREAD is keyed off the GLOBAL project root (a stable
3804
+ * single thread per session), resolved BEFORE any per-session project
3805
+ * override so switching the session's project does not fork the chat. */
3806
+ const sessionStore = getSessionStore(sessionId, ctx.store, ctx.projectRoot, VERSION);
3807
+ /* Per-session project (Pablo 2026-06-15) -- a "Sesion Propia"
3808
+ * (non-default session) can point at its OWN project without moving the
3809
+ * desktop's global project. Reassign ctx to a turn-local view pointed at
3810
+ * the override so EVERY downstream ctx.projectRoot/projectSlug use
3811
+ * (prompt context, graph, tooling, tool cwd) follows it. The default
3812
+ * 'notebook' session has no override -> unchanged global behavior. The
3813
+ * global board snapshot (set above, before this) stays on the desktop's
3814
+ * project; the phone tracks its own-session project locally. */
3815
+ const _sessProj = getSessionProject(sessionId);
3816
+ if (_sessProj) {
3817
+ ctx = { ...ctx, projectRoot: _sessProj.root, projectSlug: _sessProj.slug };
3818
+ }
3819
+ /* Project-switch verb routing (Pablo 2026-06-15) -- when the model
3820
+ * calls yujin.project.set-root in a NON-default session, switch only
3821
+ * THAT session's project (per-session override), NOT the global
3822
+ * desktop. The default 'notebook' session uses the global hot reanchor
3823
+ * (which also moves the desktop + republishes the board). */
3824
+ const effReanchor = sessionId !== DEFAULT_SESSION_ID
3825
+ ? async (newRoot) => {
3826
+ const abs = (await import('node:path')).resolve(newRoot);
3827
+ const { resolveProjectSlug } = await import('./project_slug.js');
3828
+ const slug = await resolveProjectSlug(abs);
3829
+ const name = await readProjectName(abs);
3830
+ setSessionProject(sessionId, abs, slug);
3831
+ /* tell the phone its session moved so the bubble follows (#2) */
3832
+ try {
3833
+ const pub = getRelayPublisher();
3834
+ if (pub) {
3835
+ pub.sessionProject(sessionId, slug);
3836
+ void pub.flush();
3837
+ }
3838
+ }
3839
+ catch { /* */ }
3840
+ return { projectRoot: abs, projectName: name, projectSlug: slug };
3841
+ }
3842
+ : ctx.reanchor;
3843
+ /* Diagnostic (Pablo 2026-06-15) -- tangible evidence of session
3844
+ * routing for the mobile bugs. Records WHICH thread + origin each turn
3845
+ * used, so we can SEE whether 'sesion propia' actually sent
3846
+ * session_id='mobile' and whether the unified turn was tagged
3847
+ * origin='mobile'. Fire-and-forget; ~/.yujin-forge/turn_routing.jsonl */
3848
+ void (async () => {
3849
+ try {
3850
+ const { appendFile } = await import('node:fs/promises');
3851
+ const pathMod = await import('node:path');
3852
+ const home = process.env.YF_CONFIG_DIR
3853
+ || pathMod.join(process.env.HOME || process.env.USERPROFILE || '', '.yujin-forge');
3854
+ const row = JSON.stringify({
3855
+ ts: new Date().toISOString(),
3856
+ session_id: sessionId,
3857
+ is_default: sessionId === DEFAULT_SESSION_ID,
3858
+ origin: typeof body.origin === 'string' ? body.origin : 'panel',
3859
+ project: ctx.projectSlug ?? null,
3860
+ }) + '\n';
3861
+ await appendFile(pathMod.join(home, 'turn_routing.jsonl'), row, 'utf-8');
3862
+ }
3863
+ catch { /* best-effort diagnostic */ }
3864
+ })();
3865
+ /* Interrupt (task #27): this turn registers itself in the single
3866
+ * active-turn registry just before the model call so POST
3867
+ * /api/chat/interrupt (or a mobile control event) can abort it
3868
+ * mid-flight. Declared out here so the outer try's finally can
3869
+ * clear it on every exit path. */
3870
+ let turnId = null;
3192
3871
  /* alpha.59e -- SSE streaming mode. Client opts in via
3193
3872
  * body.stream=true. We keep the response open + emit events
3194
3873
  * during the tool loop instead of bundling everything in a
@@ -3209,6 +3888,9 @@ async function handleChat(req, res, ctx) {
3209
3888
  res.write('data: ' + payload + '\n\n');
3210
3889
  }
3211
3890
  catch { /* ignore */ }
3891
+ /* P2 Fase 1 -- ademas del panel, espejar la actividad al relay
3892
+ * para el mobile pareado. Gateado (YF_RELAY=1) + best-effort. */
3893
+ relayForward(event, data);
3212
3894
  };
3213
3895
  }
3214
3896
  /* alpha.51 -- session-level brain tier override from slash
@@ -3260,7 +3942,7 @@ async function handleChat(req, res, ctx) {
3260
3942
  * via the store (best-effort). The store keeps the last
3261
3943
  * assistant reply; if it has a `brain_tier` annotation we use
3262
3944
  * it. When absent, the gate just skips the sticky check. */
3263
- const recent = ctx.store.messages();
3945
+ const recent = sessionStore.messages();
3264
3946
  const lastAssistant = recent.slice().reverse().find((m) => m.role === 'assistant');
3265
3947
  const previousTier = lastAssistant && (lastAssistant.brain_tier === 'eco' || lastAssistant.brain_tier === 'frontier')
3266
3948
  ? lastAssistant.brain_tier
@@ -3296,9 +3978,15 @@ async function handleChat(req, res, ctx) {
3296
3978
  * chose has zero remaining budget, return 429 BEFORE the
3297
3979
  * LLM call so the user never gets billed past their cap.
3298
3980
  * 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
- {
3981
+ * or switch tier.
3982
+ *
3983
+ * BYOK-STRICT (Pablo 2026-06-15): Forge no tiene plan managed --
3984
+ * cada turno BYOK lo paga el usuario directo al proveedor, asi que
3985
+ * el cap mensual NO debe bloquear. Solo se aplica a turnos REALMENTE
3986
+ * managed (provider 'yujin_managed'), que hoy no existen. Antes el
3987
+ * gate corria por tier sin mirar el provider y bloqueaba a un BYOK
3988
+ * con plan=pro_plus aunque tuviera saldo propio. */
3989
+ if (brainSelection.provider === 'yujin_managed') {
3302
3990
  const { checkCap } = await import('../core/budget.js');
3303
3991
  const capCheck = await checkCap(brainSelection.tier);
3304
3992
  if (!capCheck.allowed) {
@@ -3467,6 +4155,18 @@ async function handleChat(req, res, ctx) {
3467
4155
  wiringSection = '\n\n' + r.rendered_text;
3468
4156
  }
3469
4157
  catch (_) { /* swallow -- chat continues without wiring inject */ }
4158
+ /* PROJECT TOOLING -- the discovered CI/CD + npm scripts + scripts/
4159
+ * helpers, so the model uses the repo's real build/deploy chain
4160
+ * instead of inventing commands. Generic per repo (graph 'tooling'
4161
+ * nodes). Always present (not relevance-gated). */
4162
+ let toolingSection = '';
4163
+ try {
4164
+ const { buildToolingSection } = await import('./tooling_section.js');
4165
+ const r = await buildToolingSection(ctx.projectSlug || '');
4166
+ if (r.rendered_text)
4167
+ toolingSection = '\n\n' + r.rendered_text;
4168
+ }
4169
+ catch (_) { /* swallow -- chat continues without tooling inject */ }
3470
4170
  /* alpha.59z.187 PND-039 -- auto-context-load. Single compact
3471
4171
  * block with counts of graph nodes / wiring hooks / pending
3472
4172
  * items so the LLM can answer "que sabes del proyecto" / "subi
@@ -3517,8 +4217,46 @@ async function handleChat(req, res, ctx) {
3517
4217
  }
3518
4218
  }
3519
4219
  catch (_) { /* swallow */ }
4220
+ /* Server-canonical conversation context. The turn is built from the
4221
+ * SESSION store (the shared thread that accumulates BOTH desktop and
4222
+ * mobile turns) + the new user message -- NOT from body.messages.
4223
+ * Before, each client sent its own history (desktop = full browser
4224
+ * history; the relay consumer = a single message), so desktop and
4225
+ * mobile were two separate threads, blind to each other. Using the
4226
+ * store as the source of truth unifies them into one conversation. */
4227
+ const newUserMsg = normalized[normalized.length - 1];
4228
+ let turnMessages;
4229
+ if (newUserMsg && newUserMsg.role === 'user') {
4230
+ const prior = sessionStore.messages();
4231
+ const ptail = prior[prior.length - 1];
4232
+ const alreadyThere = !!(ptail && ptail.role === 'user' && ptail.content === newUserMsg.content);
4233
+ turnMessages = alreadyThere ? [...prior] : [...prior, newUserMsg];
4234
+ }
4235
+ else {
4236
+ /* defensive: the last normalized message should always be the new
4237
+ * user turn; fall back to the client view if not. */
4238
+ turnMessages = normalized;
4239
+ }
4240
+ /* Register THIS turn as the active one right before the model
4241
+ * call. beginTurn returns the AbortSignal the callers honor
4242
+ * (claude.ts at round boundary, claude_cli kills the subprocess).
4243
+ * A concurrent turn (desktop + mobile own-session) replaces the
4244
+ * registry entry; interrupt then targets the latest -- acceptable
4245
+ * until the queue (task #28) serializes turns. */
4246
+ turnId = sessionId + ':' + String(Date.now());
4247
+ const turnSignal = beginTurn(turnId);
3520
4248
  const chatRequest = {
3521
- messages: normalized,
4249
+ messages: turnMessages,
4250
+ signal: turnSignal,
4251
+ /* Pablo 2026-06-15 -- the model MUST follow the brain config in
4252
+ * BOTH paths. Console (unified client) already bakes it into the
4253
+ * client; Plan (`claude -p`) reads it per turn -> without this the
4254
+ * subprocess fell back to a default + ignored the user's eco/
4255
+ * frontier model selection. claude_cli maps it to the CLI alias. */
4256
+ model: brainSelection.model,
4257
+ /* Plan mode: the `claude -p` subprocess must run in the ACTIVE
4258
+ * project (follows a hot switch), not the launch cwd. (Pablo) */
4259
+ cwd: ctx.projectRoot,
3522
4260
  /* alpha.59z.163 PND-027 -- system prompt now split into a
3523
4261
  * stable prefix (manifest, doctrines, design system, mode +
3524
4262
  * tier headers from buildSystemPrompt) and a dynamic suffix
@@ -3538,7 +4276,7 @@ async function handleChat(req, res, ctx) {
3538
4276
  ctx.chatProvider === 'claude-code-cli'
3539
4277
  ? (ctx.panelPushToken ? 'mcp-bridge' : 'none')
3540
4278
  : 'native'),
3541
- systemDynamic: projectContextSection + snapshotSection + graphSection + wiringSection + workflowSection,
4279
+ systemDynamic: projectContextSection + snapshotSection + graphSection + wiringSection + toolingSection + workflowSection,
3542
4280
  maxTokens: 1024,
3543
4281
  tools: FORGE_TOOL_SPECS,
3544
4282
  runTool: async (name, input) => {
@@ -3607,7 +4345,8 @@ async function handleChat(req, res, ctx) {
3607
4345
  projectRoot: ctx.projectRoot,
3608
4346
  projectSlug: ctx.projectSlug,
3609
4347
  ...(sseEmit ? { streamEmit: sseEmit } : {}),
3610
- ...(ctx.reanchor ? { reanchor: ctx.reanchor } : {}),
4348
+ /* per-session reanchor for non-default sessions; global for notebook */
4349
+ ...(effReanchor ? { reanchor: effReanchor } : {}),
3611
4350
  };
3612
4351
  const r = await runForgeTool(name, input, toolCtx);
3613
4352
  /* alpha.59i -- if the result is an approval envelope,
@@ -3652,7 +4391,8 @@ async function handleChat(req, res, ctx) {
3652
4391
  * frontier still has budget. Cap 1 escalation per turn. */
3653
4392
  let escalated = false;
3654
4393
  let escalationSignals = [];
3655
- if (brainSelection.tier === 'eco' && !brainOverride) {
4394
+ if (brainSelection.tier === 'eco' && !brainOverride
4395
+ && !reply.interrupted) {
3656
4396
  const { detectIncomplete } = await import('../core/incomplete_detector.js');
3657
4397
  const detect = detectIncomplete({
3658
4398
  user_text: lastUserText,
@@ -3660,8 +4400,12 @@ async function handleChat(req, res, ctx) {
3660
4400
  tool_rounds: reply.toolRounds,
3661
4401
  });
3662
4402
  if (detect.incomplete) {
3663
- const { checkCap } = await import('../core/budget.js');
3664
- const capCheck = await checkCap('frontier');
4403
+ /* BYOK-strict: the frontier cap only gates MANAGED turns. A BYOK
4404
+ * eco turn escalates to a BYOK frontier turn -- the user pays the
4405
+ * provider directly, so no cap blocks the escalation. */
4406
+ const capCheck = brainSelection.provider === 'yujin_managed'
4407
+ ? await (await import('../core/budget.js')).checkCap('frontier')
4408
+ : { allowed: true };
3665
4409
  if (capCheck.allowed) {
3666
4410
  const frontierSel = await selectBrainForTurn({
3667
4411
  user_override: 'frontier',
@@ -3697,6 +4441,8 @@ async function handleChat(req, res, ctx) {
3697
4441
  * serving the turn. */
3698
4442
  const frontierRequest = {
3699
4443
  ...chatRequest,
4444
+ /* use the FRONTIER model, not the eco one inherited via spread */
4445
+ model: frontierSel.model,
3700
4446
  system: buildSystemPrompt(ctx, reqMode, pilotState, {
3701
4447
  tier: frontierSel.tier,
3702
4448
  provider: frontierSel.provider,
@@ -3722,6 +4468,43 @@ async function handleChat(req, res, ctx) {
3722
4468
  }
3723
4469
  }
3724
4470
  }
4471
+ /* Panel-action enforcement (Pablo 2026-06-15) -- if the model SAID it
4472
+ * opened a pizarron document / created a task but called NO such
4473
+ * tool, the action never happened (Opus 4.8 narra; Gemini ejecuta).
4474
+ * Re-issue the turn ONCE with a forcing nudge so it actually emits
4475
+ * the verb. API/Console only: in CLI/Plan mode the subprocess runs
4476
+ * tools natively and toolRounds is empty, so the check is N/A. */
4477
+ if (ctx.chatProvider !== 'claude-code-cli'
4478
+ && !reply.interrupted) {
4479
+ const { checkClaimedPanelAction } = await import('./completion_hallucination_detector.js');
4480
+ const firedPanelTool = (rounds) => rounds.some((r) => {
4481
+ const vid = String(r.input?.verb_id ?? r.tool ?? '');
4482
+ const res = r.result;
4483
+ return /pizarron|show_in_pizarron|\.task\./i.test(vid) || (res != null && res.nac3_panel_dispatch === true);
4484
+ });
4485
+ const panelClaim = checkClaimedPanelAction(reply.text);
4486
+ if (panelClaim.claimed && !firedPanelTool(reply.toolRounds)) {
4487
+ const nudge = panelClaim.kind === 'task'
4488
+ ? '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.'
4489
+ : '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.';
4490
+ try {
4491
+ const retryReq = {
4492
+ ...chatRequest,
4493
+ messages: [
4494
+ ...turnMessages,
4495
+ { role: 'assistant', content: reply.text },
4496
+ { role: 'user', content: '[SISTEMA] ' + nudge },
4497
+ ],
4498
+ };
4499
+ const retry = await callerForChat.chat(retryReq);
4500
+ if (retry && firedPanelTool(retry.toolRounds)) {
4501
+ reply = retry;
4502
+ escalationSignals = [...escalationSignals, 'panel_action_enforced[' + panelClaim.kind + ']'];
4503
+ }
4504
+ }
4505
+ catch { /* keep the original reply */ }
4506
+ }
4507
+ }
3725
4508
  // Append the latest user message + the assistant's reply to
3726
4509
  // the persistent transcript. Older messages may already be
3727
4510
  // present from previous turns; we only need to capture the
@@ -3730,10 +4513,10 @@ async function handleChat(req, res, ctx) {
3730
4513
  if (lastUser && lastUser.role === 'user') {
3731
4514
  // Only append if this user message isn't already the last
3732
4515
  // recorded one (prevents dup on retry).
3733
- const recent = ctx.store.messages();
4516
+ const recent = sessionStore.messages();
3734
4517
  const tail = recent[recent.length - 1];
3735
4518
  if (!tail || tail.role !== 'user' || tail.content !== lastUser.content) {
3736
- ctx.store.append(lastUser);
4519
+ sessionStore.append(lastUser);
3737
4520
  }
3738
4521
  }
3739
4522
  /* alpha.56 -- tag the assistant turn with the tier that
@@ -3760,13 +4543,13 @@ async function handleChat(req, res, ctx) {
3760
4543
  * way the next turn's sticky inertia + previous_tier check
3761
4544
  * see the canonical decision. */
3762
4545
  const finalTier = escalated ? 'frontier' : brainSelection.tier;
3763
- ctx.store.append({
4546
+ sessionStore.append({
3764
4547
  role: 'assistant',
3765
4548
  content: reply.text,
3766
4549
  brain_tier: finalTier,
3767
4550
  ...(docLoaded ? { doc_loaded: true } : {}),
3768
4551
  });
3769
- void ctx.store.flush();
4552
+ void sessionStore.flush();
3770
4553
  /* Pilot first-run auto-complete (SQ 0.10). If Pilot is
3771
4554
  not yet completed, decide based on the user's first
3772
4555
  message whether to mark setup done with defaults. */
@@ -3856,12 +4639,47 @@ async function handleChat(req, res, ctx) {
3856
4639
  }
3857
4640
  }
3858
4641
  catch { /* swallow */ }
4642
+ /* UI sync (task #32) -- if this turn came from the phone in the
4643
+ * UNIFIED thread, echo it to the panel so the laptop shows what was
4644
+ * asked + answered from the mobile (the desktop-main thread is the
4645
+ * shared 'notebook' session). 'own' mobile sessions stay private. */
4646
+ if (body.origin === 'mobile' && sessionId === DEFAULT_SESSION_ID) {
4647
+ try {
4648
+ const { enqueuePanelDispatch } = await import('./panel_queue.js');
4649
+ enqueuePanelDispatch('system.remote-turn', {
4650
+ source: 'mobile',
4651
+ user: lastUserText,
4652
+ reply: reply.text,
4653
+ }, new Date().toISOString());
4654
+ }
4655
+ catch { /* best-effort */ }
4656
+ }
4657
+ /* UI sync (task #32, reverse) -- a turn issued on the DESKTOP (panel)
4658
+ * in the unified thread is published to the relay so the phone's chat
4659
+ * reflects it too. Mobile-origin turns are skipped (the phone already
4660
+ * has them as its own bubbles). */
4661
+ if (body.origin !== 'mobile' && sessionId === DEFAULT_SESSION_ID
4662
+ && !reply.interrupted) {
4663
+ try {
4664
+ const pub = getRelayPublisher();
4665
+ if (pub) {
4666
+ pub.remoteTurn('dt-' + Date.now(), lastUserText, reply.text);
4667
+ void pub.flush();
4668
+ }
4669
+ }
4670
+ catch { /* best-effort */ }
4671
+ }
3859
4672
  /* alpha.59e -- build the response payload once, then either
3860
4673
  * stream it as a turn_complete SSE event OR send the legacy
3861
4674
  * JSON response. */
4675
+ const wasInterrupted = reply.interrupted === true;
3862
4676
  const responsePayload = {
3863
4677
  ok: true,
3864
4678
  message: { role: 'assistant', text: reply.text },
4679
+ /* Interrupt (task #27): tell the panel the turn was stopped
4680
+ * mid-flight so it can render the partial answer with a
4681
+ * "(interrumpido)" marker instead of treating it as complete. */
4682
+ ...(wasInterrupted ? { interrupted: true } : {}),
3865
4683
  tokens: { in: reply.tokensIn, out: reply.tokensOut },
3866
4684
  model: reply.model,
3867
4685
  /* alpha.43 -- manifest version stamp so the panel can detect
@@ -3966,6 +4784,14 @@ async function handleChat(req, res, ctx) {
3966
4784
  });
3967
4785
  }
3968
4786
  }
4787
+ finally {
4788
+ /* Interrupt (task #27): always clear this turn from the active
4789
+ * registry, no matter how the turn exits. endTurn is a no-op if a
4790
+ * newer turn has already replaced us, so it never clobbers a
4791
+ * concurrent turn's registration. */
4792
+ if (turnId)
4793
+ endTurn(turnId);
4794
+ }
3969
4795
  }
3970
4796
  export function buildSystemPrompt(ctx, mode = 'didactico', pilotState = {
3971
4797
  pilot_completed: true, target_pending: false, mode_pending: false,
@@ -4156,7 +4982,9 @@ panelMode = 'native') {
4156
4982
  ' Hallucinated completions are a credibility-destroyer; the user',
4157
4983
  ' finds out you did nothing the next message + loses trust in',
4158
4984
  ' every subsequent claim. When in doubt, ask. When sure, invoke.',
4159
- ' NEVER both promise AND defer.',
4985
+ ' NEVER both promise AND defer. Opening a pizarron doc',
4986
+ ' (yujin.pizarron.create-tab, HTML) or touching a task = CALL the',
4987
+ ' verb THIS turn; claiming it with no tool_use is a lie.',
4160
4988
  '',
4161
4989
  '=================================================================',
4162
4990
  'PRODUCT WORKFLOW (on-demand)',