@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.
- package/README.md +5 -1
- package/dist/agents/bridge.d.ts.map +1 -1
- package/dist/agents/bridge.js +27 -0
- package/dist/agents/bridge.js.map +1 -1
- package/dist/agents/config_resolver.d.ts.map +1 -1
- package/dist/agents/config_resolver.js +15 -15
- package/dist/agents/config_resolver.js.map +1 -1
- package/dist/agents/runner.d.ts +8 -0
- package/dist/agents/runner.d.ts.map +1 -1
- package/dist/agents/runner.js +58 -11
- package/dist/agents/runner.js.map +1 -1
- package/dist/agents/system_prompt.d.ts.map +1 -1
- package/dist/agents/system_prompt.js +2 -1
- package/dist/agents/system_prompt.js.map +1 -1
- package/dist/bin/yf.d.ts.map +1 -1
- package/dist/bin/yf.js +18 -0
- package/dist/bin/yf.js.map +1 -1
- package/dist/chat/claude.d.ts +13 -0
- package/dist/chat/claude.d.ts.map +1 -1
- package/dist/chat/claude.js +95 -10
- package/dist/chat/claude.js.map +1 -1
- package/dist/chat/claude_cli.d.ts +10 -0
- package/dist/chat/claude_cli.d.ts.map +1 -1
- package/dist/chat/claude_cli.js +97 -16
- package/dist/chat/claude_cli.js.map +1 -1
- package/dist/chat/completion_hallucination_detector.d.ts +11 -0
- package/dist/chat/completion_hallucination_detector.d.ts.map +1 -1
- package/dist/chat/completion_hallucination_detector.js +37 -2
- package/dist/chat/completion_hallucination_detector.js.map +1 -1
- package/dist/chat/doctrine_digest.d.ts.map +1 -1
- package/dist/chat/doctrine_digest.js +10 -6
- package/dist/chat/doctrine_digest.js.map +1 -1
- package/dist/chat/panel.d.ts.map +1 -1
- package/dist/chat/panel.js +923 -50
- package/dist/chat/panel.js.map +1 -1
- package/dist/chat/refusal_detector.d.ts.map +1 -1
- package/dist/chat/refusal_detector.js +2 -1
- package/dist/chat/refusal_detector.js.map +1 -1
- package/dist/chat/server.d.ts +1 -0
- package/dist/chat/server.d.ts.map +1 -1
- package/dist/chat/server.js +960 -73
- package/dist/chat/server.js.map +1 -1
- package/dist/chat/session_projects.d.ts +31 -0
- package/dist/chat/session_projects.d.ts.map +1 -0
- package/dist/chat/session_projects.js +36 -0
- package/dist/chat/session_projects.js.map +1 -0
- package/dist/chat/session_store.d.ts +32 -0
- package/dist/chat/session_store.d.ts.map +1 -0
- package/dist/chat/session_store.js +58 -0
- package/dist/chat/session_store.js.map +1 -0
- package/dist/chat/tooling_section.d.ts +11 -0
- package/dist/chat/tooling_section.d.ts.map +1 -0
- package/dist/chat/tooling_section.js +70 -0
- package/dist/chat/tooling_section.js.map +1 -0
- package/dist/chat/tools/app_backend_dispatch.d.ts.map +1 -1
- package/dist/chat/tools/app_backend_dispatch.js +44 -1
- package/dist/chat/tools/app_backend_dispatch.js.map +1 -1
- package/dist/chat/tools/fs_editor.d.ts.map +1 -1
- package/dist/chat/tools/fs_editor.js +53 -3
- package/dist/chat/tools/fs_editor.js.map +1 -1
- package/dist/chat/tools/graph.js +2 -2
- package/dist/chat/tools/graph.js.map +1 -1
- package/dist/chat/tools/keys.d.ts.map +1 -1
- package/dist/chat/tools/keys.js +5 -18
- package/dist/chat/tools/keys.js.map +1 -1
- package/dist/chat/tools/nac3_invoke.d.ts.map +1 -1
- package/dist/chat/tools/nac3_invoke.js +16 -7
- package/dist/chat/tools/nac3_invoke.js.map +1 -1
- package/dist/chat/tools/shell.d.ts +6 -0
- package/dist/chat/tools/shell.d.ts.map +1 -1
- package/dist/chat/tools/shell.js +39 -0
- package/dist/chat/tools/shell.js.map +1 -1
- package/dist/chat/tools/workflow.d.ts +20 -0
- package/dist/chat/tools/workflow.d.ts.map +1 -1
- package/dist/chat/tools/workflow.js +98 -0
- package/dist/chat/tools/workflow.js.map +1 -1
- package/dist/chat/tools.d.ts.map +1 -1
- package/dist/chat/tools.js +79 -1
- package/dist/chat/tools.js.map +1 -1
- package/dist/chat/turn_control.d.ts +42 -0
- package/dist/chat/turn_control.d.ts.map +1 -0
- package/dist/chat/turn_control.js +53 -0
- package/dist/chat/turn_control.js.map +1 -0
- package/dist/commands/brain.js +1 -1
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +61 -10
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/graph.js +9 -9
- package/dist/commands/graph.js.map +1 -1
- package/dist/commands/keys_setup.d.ts +10 -28
- package/dist/commands/keys_setup.d.ts.map +1 -1
- package/dist/commands/keys_setup.js +54 -228
- package/dist/commands/keys_setup.js.map +1 -1
- package/dist/commands/license.d.ts.map +1 -1
- package/dist/commands/license.js +52 -28
- package/dist/commands/license.js.map +1 -1
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +62 -6
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/mobile.d.ts +1 -1
- package/dist/commands/mobile.d.ts.map +1 -1
- package/dist/commands/mobile.js +32 -28
- package/dist/commands/mobile.js.map +1 -1
- package/dist/commands/onboard.d.ts +19 -0
- package/dist/commands/onboard.d.ts.map +1 -0
- package/dist/commands/onboard.js +126 -0
- package/dist/commands/onboard.js.map +1 -0
- package/dist/commands/pending.d.ts.map +1 -1
- package/dist/commands/pending.js +25 -1
- package/dist/commands/pending.js.map +1 -1
- package/dist/commands/projects.js +25 -1
- package/dist/commands/projects.js.map +1 -1
- package/dist/commands/review-screens.js +28 -1
- package/dist/commands/review-screens.js.map +1 -1
- package/dist/commands/workflow.d.ts +18 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +253 -0
- package/dist/commands/workflow.js.map +1 -0
- package/dist/core/brain_config.d.ts +2 -0
- package/dist/core/brain_config.d.ts.map +1 -1
- package/dist/core/brain_config.js +18 -3
- package/dist/core/brain_config.js.map +1 -1
- package/dist/core/cf_tunnel.d.ts.map +1 -1
- package/dist/core/cf_tunnel.js +8 -2
- package/dist/core/cf_tunnel.js.map +1 -1
- package/dist/core/cost_router.d.ts.map +1 -1
- package/dist/core/cost_router.js +11 -2
- package/dist/core/cost_router.js.map +1 -1
- package/dist/core/desktop_open.d.ts +16 -0
- package/dist/core/desktop_open.d.ts.map +1 -0
- package/dist/core/desktop_open.js +114 -0
- package/dist/core/desktop_open.js.map +1 -0
- package/dist/core/keys_envelope.d.ts.map +1 -1
- package/dist/core/keys_envelope.js +5 -1
- package/dist/core/keys_envelope.js.map +1 -1
- package/dist/core/limits_config.d.ts +5 -0
- package/dist/core/limits_config.d.ts.map +1 -1
- package/dist/core/limits_config.js +5 -0
- package/dist/core/limits_config.js.map +1 -1
- package/dist/core/logger.d.ts.map +1 -1
- package/dist/core/logger.js +3 -0
- package/dist/core/logger.js.map +1 -1
- package/dist/core/pairing.d.ts +6 -0
- package/dist/core/pairing.d.ts.map +1 -1
- package/dist/core/pairing.js +41 -2
- package/dist/core/pairing.js.map +1 -1
- package/dist/core/provider_keys.d.ts.map +1 -1
- package/dist/core/provider_keys.js +25 -12
- package/dist/core/provider_keys.js.map +1 -1
- package/dist/core/provider_models.js +1 -1
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +13 -2
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/design/yujin_design_resolver.d.ts +16 -0
- package/dist/design/yujin_design_resolver.d.ts.map +1 -1
- package/dist/design/yujin_design_resolver.js +103 -20
- package/dist/design/yujin_design_resolver.js.map +1 -1
- package/dist/docs/doctrine/default-design-system.md +32 -0
- package/dist/docs/doctrine/office-tools.md +20 -0
- package/dist/docs/doctrine/pizarron.md +40 -0
- package/dist/docs/doctrine/verb-composition.md +16 -0
- package/dist/docs/doctrine/workflow-kinds.md +39 -22
- package/dist/docs/doctrine/workflow.md +12 -4
- package/dist/license/auto_sync.d.ts +22 -0
- package/dist/license/auto_sync.d.ts.map +1 -0
- package/dist/license/auto_sync.js +91 -0
- package/dist/license/auto_sync.js.map +1 -0
- package/dist/license/handle_key.d.ts +32 -0
- package/dist/license/handle_key.d.ts.map +1 -0
- package/dist/license/handle_key.js +56 -0
- package/dist/license/handle_key.js.map +1 -0
- package/dist/license/hito4_client.d.ts.map +1 -1
- package/dist/license/hito4_client.js +63 -25
- package/dist/license/hito4_client.js.map +1 -1
- package/dist/llm/gemini_client.d.ts.map +1 -1
- package/dist/llm/gemini_client.js +5 -2
- package/dist/llm/gemini_client.js.map +1 -1
- package/dist/llm/managed_client.d.ts.map +1 -1
- package/dist/llm/managed_client.js +14 -9
- package/dist/llm/managed_client.js.map +1 -1
- package/dist/mcp/plan_bridges.d.ts +17 -0
- package/dist/mcp/plan_bridges.d.ts.map +1 -0
- package/dist/mcp/plan_bridges.js +119 -0
- package/dist/mcp/plan_bridges.js.map +1 -0
- package/dist/migrate/ai-apply.d.ts.map +1 -1
- package/dist/migrate/ai-apply.js +25 -2
- package/dist/migrate/ai-apply.js.map +1 -1
- package/dist/nac3/internal_manifest.d.ts.map +1 -1
- package/dist/nac3/internal_manifest.js +200 -10
- package/dist/nac3/internal_manifest.js.map +1 -1
- package/dist/nac3/verb_tier_map.d.ts.map +1 -1
- package/dist/nac3/verb_tier_map.js +3 -0
- package/dist/nac3/verb_tier_map.js.map +1 -1
- package/dist/onboarding/concierge_client.d.ts +34 -0
- package/dist/onboarding/concierge_client.d.ts.map +1 -0
- package/dist/onboarding/concierge_client.js +80 -0
- package/dist/onboarding/concierge_client.js.map +1 -0
- package/dist/onboarding/next_step.d.ts +32 -0
- package/dist/onboarding/next_step.d.ts.map +1 -0
- package/dist/onboarding/next_step.js +60 -0
- package/dist/onboarding/next_step.js.map +1 -0
- package/dist/pending/store.d.ts +7 -0
- package/dist/pending/store.d.ts.map +1 -1
- package/dist/pending/store.js +19 -0
- package/dist/pending/store.js.map +1 -1
- package/dist/pending/types.d.ts +20 -0
- package/dist/pending/types.d.ts.map +1 -1
- package/dist/pending/types.js.map +1 -1
- package/dist/reader/parsers/epub.d.ts.map +1 -1
- package/dist/reader/parsers/epub.js +6 -1
- package/dist/reader/parsers/epub.js.map +1 -1
- package/dist/relay/board_snapshot.d.ts +69 -0
- package/dist/relay/board_snapshot.d.ts.map +1 -0
- package/dist/relay/board_snapshot.js +149 -0
- package/dist/relay/board_snapshot.js.map +1 -0
- package/dist/relay/command_consumer.d.ts +97 -0
- package/dist/relay/command_consumer.d.ts.map +1 -0
- package/dist/relay/command_consumer.js +135 -0
- package/dist/relay/command_consumer.js.map +1 -0
- package/dist/relay/mock_relay.d.ts +34 -0
- package/dist/relay/mock_relay.d.ts.map +1 -0
- package/dist/relay/mock_relay.js +93 -0
- package/dist/relay/mock_relay.js.map +1 -0
- package/dist/relay/relay_client.d.ts +28 -0
- package/dist/relay/relay_client.d.ts.map +1 -0
- package/dist/relay/relay_client.js +80 -0
- package/dist/relay/relay_client.js.map +1 -0
- package/dist/relay/relay_publisher.d.ts +59 -0
- package/dist/relay/relay_publisher.d.ts.map +1 -0
- package/dist/relay/relay_publisher.js +101 -0
- package/dist/relay/relay_publisher.js.map +1 -0
- package/dist/relay/types.d.ts +54 -0
- package/dist/relay/types.d.ts.map +1 -0
- package/dist/relay/types.js +8 -0
- package/dist/relay/types.js.map +1 -0
- package/dist/semantic_graph/ast_extract.d.ts +5 -0
- package/dist/semantic_graph/ast_extract.d.ts.map +1 -1
- package/dist/semantic_graph/ast_extract.js +46 -0
- package/dist/semantic_graph/ast_extract.js.map +1 -1
- package/dist/semantic_graph/derive.d.ts +12 -0
- package/dist/semantic_graph/derive.d.ts.map +1 -1
- package/dist/semantic_graph/derive.js +325 -6
- package/dist/semantic_graph/derive.js.map +1 -1
- package/dist/semantic_graph/store.d.ts +9 -0
- package/dist/semantic_graph/store.d.ts.map +1 -1
- package/dist/semantic_graph/store.js +23 -1
- package/dist/semantic_graph/store.js.map +1 -1
- package/dist/semantic_graph/types.d.ts +1 -1
- package/dist/semantic_graph/types.d.ts.map +1 -1
- package/dist/support/github_dispatcher.d.ts.map +1 -1
- package/dist/support/github_dispatcher.js +30 -2
- package/dist/support/github_dispatcher.js.map +1 -1
- package/dist/tasks/supervisor.d.ts +88 -0
- package/dist/tasks/supervisor.d.ts.map +1 -0
- package/dist/tasks/supervisor.js +308 -0
- package/dist/tasks/supervisor.js.map +1 -0
- package/dist/tasks/task_type.d.ts +11 -0
- package/dist/tasks/task_type.d.ts.map +1 -0
- package/dist/tasks/task_type.js +21 -0
- package/dist/tasks/task_type.js.map +1 -0
- package/dist/vault/store.d.ts.map +1 -1
- package/dist/vault/store.js +6 -1
- package/dist/vault/store.js.map +1 -1
- package/dist/version.d.ts +3 -5
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +3 -5
- package/dist/version.js.map +1 -1
- package/dist/voice/providers/google.d.ts.map +1 -1
- package/dist/voice/providers/google.js +6 -4
- package/dist/voice/providers/google.js.map +1 -1
- package/dist/workflow/config.d.ts +43 -0
- package/dist/workflow/config.d.ts.map +1 -0
- package/dist/workflow/config.js +199 -0
- package/dist/workflow/config.js.map +1 -0
- package/dist/workflow/instances.d.ts +40 -0
- package/dist/workflow/instances.d.ts.map +1 -0
- package/dist/workflow/instances.js +122 -0
- package/dist/workflow/instances.js.map +1 -0
- package/dist/workflow/multi_view.d.ts +27 -0
- package/dist/workflow/multi_view.d.ts.map +1 -0
- package/dist/workflow/multi_view.js +76 -0
- package/dist/workflow/multi_view.js.map +1 -0
- package/dist/workflow/orchestrator.d.ts +111 -0
- package/dist/workflow/orchestrator.d.ts.map +1 -0
- package/dist/workflow/orchestrator.js +241 -0
- package/dist/workflow/orchestrator.js.map +1 -0
- package/docs/doctrine/default-design-system.md +32 -0
- package/docs/doctrine/office-tools.md +20 -0
- package/docs/doctrine/pizarron.md +40 -0
- package/docs/doctrine/verb-composition.md +16 -0
- package/docs/doctrine/workflow-kinds.md +39 -22
- package/docs/doctrine/workflow.md +12 -4
- package/package.json +2 -1
- package/src/i18n/catalogs/ar.json +223 -210
- package/src/i18n/catalogs/de.json +223 -210
- package/src/i18n/catalogs/en.json +223 -210
- package/src/i18n/catalogs/es.json +223 -210
- package/src/i18n/catalogs/fr.json +223 -210
- package/src/i18n/catalogs/hi.json +223 -210
- package/src/i18n/catalogs/it.json +223 -210
- package/src/i18n/catalogs/ja.json +223 -210
- package/src/i18n/catalogs/pt.json +223 -210
- package/src/i18n/catalogs/zh.json +223 -210
package/dist/chat/server.js
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
-
|
|
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 (
|
|
2422
|
-
*
|
|
2423
|
-
*
|
|
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
|
|
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: '
|
|
2435
|
-
color: { dark: '#1a1a1a', light: '#
|
|
3002
|
+
type: 'svg', margin: 1, errorCorrectionLevel: 'L',
|
|
3003
|
+
color: { dark: '#1a1a1a', light: '#ffffff' },
|
|
2436
3004
|
});
|
|
2437
|
-
sendJson(res, 200, { ok: true,
|
|
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
|
-
/*
|
|
2469
|
-
* (
|
|
2470
|
-
* '
|
|
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('
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
const
|
|
2517
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
3300
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3664
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4334
|
-
'GRAPHS: to show a node-and-edge graph
|
|
4335
|
-
'
|
|
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
|
-
'
|
|
4343
|
-
'
|
|
4344
|
-
'
|
|
4345
|
-
'
|
|
4346
|
-
'
|
|
4347
|
-
'
|
|
4348
|
-
|
|
4349
|
-
'
|
|
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' ? [
|