@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.
- 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 +45 -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 +885 -42
- 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 +890 -62
- 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/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 +91 -0
- package/dist/relay/command_consumer.d.ts.map +1 -0
- package/dist/relay/command_consumer.js +129 -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/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,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
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
-
|
|
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 (
|
|
2422
|
-
*
|
|
2423
|
-
*
|
|
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
|
|
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: '
|
|
2435
|
-
color: { dark: '#1a1a1a', light: '#
|
|
3001
|
+
type: 'svg', margin: 1, errorCorrectionLevel: 'L',
|
|
3002
|
+
color: { dark: '#1a1a1a', light: '#ffffff' },
|
|
2436
3003
|
});
|
|
2437
|
-
sendJson(res, 200, { ok: true,
|
|
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
|
-
/*
|
|
2469
|
-
* (
|
|
2470
|
-
* '
|
|
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('
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
const
|
|
2517
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
3300
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3664
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)',
|