@nac3/forge-cli 0.2.0-alpha.59z.121 → 0.2.0-alpha.59z.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist/agents/bridge.d.ts +69 -0
  2. package/dist/agents/bridge.d.ts.map +1 -0
  3. package/dist/agents/bridge.js +295 -0
  4. package/dist/agents/bridge.js.map +1 -0
  5. package/dist/agents/cli_real.d.ts +38 -0
  6. package/dist/agents/cli_real.d.ts.map +1 -0
  7. package/dist/agents/cli_real.js +46 -0
  8. package/dist/agents/cli_real.js.map +1 -0
  9. package/dist/agents/config_schema.d.ts +1 -1
  10. package/dist/agents/config_schema.d.ts.map +1 -1
  11. package/dist/agents/config_schema.js +16 -0
  12. package/dist/agents/config_schema.js.map +1 -1
  13. package/dist/agents/defaults.d.ts.map +1 -1
  14. package/dist/agents/defaults.js.map +1 -1
  15. package/dist/agents/delegate.d.ts +36 -0
  16. package/dist/agents/delegate.d.ts.map +1 -0
  17. package/dist/agents/delegate.js +68 -0
  18. package/dist/agents/delegate.js.map +1 -0
  19. package/dist/agents/iterative_step.d.ts +95 -0
  20. package/dist/agents/iterative_step.d.ts.map +1 -0
  21. package/dist/agents/iterative_step.js +249 -0
  22. package/dist/agents/iterative_step.js.map +1 -0
  23. package/dist/agents/llm_step.d.ts +50 -0
  24. package/dist/agents/llm_step.d.ts.map +1 -0
  25. package/dist/agents/llm_step.js +28 -0
  26. package/dist/agents/llm_step.js.map +1 -0
  27. package/dist/agents/real_step_factory.d.ts +95 -0
  28. package/dist/agents/real_step_factory.d.ts.map +1 -0
  29. package/dist/agents/real_step_factory.js +51 -0
  30. package/dist/agents/real_step_factory.js.map +1 -0
  31. package/dist/agents/runner.d.ts +14 -0
  32. package/dist/agents/runner.d.ts.map +1 -1
  33. package/dist/agents/runner.js +10 -1
  34. package/dist/agents/runner.js.map +1 -1
  35. package/dist/agents/runtime.d.ts +115 -0
  36. package/dist/agents/runtime.d.ts.map +1 -0
  37. package/dist/agents/runtime.js +429 -0
  38. package/dist/agents/runtime.js.map +1 -0
  39. package/dist/agents/system_prompt.d.ts +36 -0
  40. package/dist/agents/system_prompt.d.ts.map +1 -0
  41. package/dist/agents/system_prompt.js +101 -0
  42. package/dist/agents/system_prompt.js.map +1 -0
  43. package/dist/agents/types.d.ts +12 -1
  44. package/dist/agents/types.d.ts.map +1 -1
  45. package/dist/agents/types.js.map +1 -1
  46. package/dist/bin/yf.d.ts.map +1 -1
  47. package/dist/bin/yf.js +9 -0
  48. package/dist/bin/yf.js.map +1 -1
  49. package/dist/chat/claude_cli.d.ts +42 -0
  50. package/dist/chat/claude_cli.d.ts.map +1 -0
  51. package/dist/chat/claude_cli.js +299 -0
  52. package/dist/chat/claude_cli.js.map +1 -0
  53. package/dist/chat/graph_snapshot.d.ts +38 -0
  54. package/dist/chat/graph_snapshot.d.ts.map +1 -0
  55. package/dist/chat/graph_snapshot.js +159 -0
  56. package/dist/chat/graph_snapshot.js.map +1 -0
  57. package/dist/chat/ingest_session.d.ts +24 -4
  58. package/dist/chat/ingest_session.d.ts.map +1 -1
  59. package/dist/chat/ingest_session.js +139 -4
  60. package/dist/chat/ingest_session.js.map +1 -1
  61. package/dist/chat/panel.d.ts.map +1 -1
  62. package/dist/chat/panel.js +1358 -0
  63. package/dist/chat/panel.js.map +1 -1
  64. package/dist/chat/server.d.ts +21 -0
  65. package/dist/chat/server.d.ts.map +1 -1
  66. package/dist/chat/server.js +904 -4
  67. package/dist/chat/server.js.map +1 -1
  68. package/dist/chat/tools/nac3_invoke.d.ts +5 -0
  69. package/dist/chat/tools/nac3_invoke.d.ts.map +1 -1
  70. package/dist/chat/tools/nac3_invoke.js +1 -0
  71. package/dist/chat/tools/nac3_invoke.js.map +1 -1
  72. package/dist/chat/tools.d.ts.map +1 -1
  73. package/dist/chat/tools.js +12 -0
  74. package/dist/chat/tools.js.map +1 -1
  75. package/dist/commands/agent.d.ts +12 -1
  76. package/dist/commands/agent.d.ts.map +1 -1
  77. package/dist/commands/agent.js +92 -1
  78. package/dist/commands/agent.js.map +1 -1
  79. package/dist/commands/chat.d.ts +10 -0
  80. package/dist/commands/chat.d.ts.map +1 -1
  81. package/dist/commands/chat.js +12 -1
  82. package/dist/commands/chat.js.map +1 -1
  83. package/dist/commands/director.d.ts +37 -0
  84. package/dist/commands/director.d.ts.map +1 -0
  85. package/dist/commands/director.js +290 -0
  86. package/dist/commands/director.js.map +1 -0
  87. package/dist/commands/keys_setup.d.ts +1 -0
  88. package/dist/commands/keys_setup.d.ts.map +1 -1
  89. package/dist/commands/keys_setup.js +61 -11
  90. package/dist/commands/keys_setup.js.map +1 -1
  91. package/dist/core/pilot_setup.d.ts +14 -1
  92. package/dist/core/pilot_setup.d.ts.map +1 -1
  93. package/dist/core/pilot_setup.js +62 -1
  94. package/dist/core/pilot_setup.js.map +1 -1
  95. package/dist/core/provider_keys.d.ts.map +1 -1
  96. package/dist/core/provider_keys.js +12 -5
  97. package/dist/core/provider_keys.js.map +1 -1
  98. package/dist/design/yujin_design_resolver.d.ts +77 -0
  99. package/dist/design/yujin_design_resolver.d.ts.map +1 -0
  100. package/dist/design/yujin_design_resolver.js +113 -0
  101. package/dist/design/yujin_design_resolver.js.map +1 -0
  102. package/dist/docs/doctrine/default-design-system.md +120 -0
  103. package/dist/docs/doctrine/language-sovereignty.md +143 -0
  104. package/dist/docs/doctrine/testing-policy.md +211 -0
  105. package/dist/nac3/approval_queue.d.ts +8 -0
  106. package/dist/nac3/approval_queue.d.ts.map +1 -1
  107. package/dist/nac3/approval_queue.js.map +1 -1
  108. package/dist/nac3/internal_manifest.d.ts.map +1 -1
  109. package/dist/nac3/internal_manifest.js +178 -0
  110. package/dist/nac3/internal_manifest.js.map +1 -1
  111. package/dist/office/excel.d.ts +22 -0
  112. package/dist/office/excel.d.ts.map +1 -1
  113. package/dist/office/excel.js +82 -0
  114. package/dist/office/excel.js.map +1 -1
  115. package/dist/qa/brain_matrix/fixtures/scenario_a11y_focus_after_open.json +22 -0
  116. package/dist/qa/brain_matrix/fixtures/scenario_a11y_label_unlabeled_button.json +26 -0
  117. package/dist/qa/brain_matrix/fixtures/scenario_agent_director_consults_workflow_before_dispatch.json +42 -0
  118. package/dist/qa/brain_matrix/fixtures/scenario_analyst_extracts_then_returns.json +28 -0
  119. package/dist/qa/brain_matrix/fixtures/scenario_coder_consults_nac_spec_before_writing.json +28 -0
  120. package/dist/qa/brain_matrix/fixtures/scenario_default_design_system_override_shadcn.json +45 -0
  121. package/dist/qa/brain_matrix/fixtures/scenario_default_design_system_use_card_template.json +44 -0
  122. package/dist/qa/brain_matrix/fixtures/scenario_director_delegates_to_analyst.json +26 -0
  123. package/dist/qa/brain_matrix/fixtures/scenario_director_no_self_delegation.json +20 -0
  124. package/dist/qa/brain_matrix/fixtures/scenario_director_terminal_done_marker.json +19 -0
  125. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_default_design_system.json +50 -0
  126. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_language_sovereignty.json +53 -0
  127. package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_testing_policy.json +55 -0
  128. package/dist/qa/brain_matrix/fixtures/scenario_language_sovereignty_not_conducive_spa_python.json +61 -0
  129. package/dist/qa/brain_matrix/fixtures/scenario_language_sovereignty_user_picks_python.json +48 -0
  130. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_destructive_pauses_for_r8.json +42 -0
  131. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_happy_path_writes_pizarron.json +76 -0
  132. package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_network_timeout_graceful.json +41 -0
  133. package/dist/qa/brain_matrix/fixtures/scenario_no_hardcoded_color_use_token.json +47 -0
  134. package/dist/qa/brain_matrix/fixtures/scenario_office_excel_update_cell_picks_right_verb.json +26 -0
  135. package/dist/qa/brain_matrix/fixtures/scenario_scaffolder_destructive_pauses_for_approval.json +23 -0
  136. package/dist/qa/brain_matrix/fixtures/scenario_sumie_icon_pick_from_catalog.json +56 -0
  137. package/dist/qa/brain_matrix/fixtures/scenario_tester_uses_shell_exec_only.json +27 -0
  138. package/dist/version.d.ts +1 -1
  139. package/dist/version.js +1 -1
  140. package/dist/workflow/graph.d.ts +59 -0
  141. package/dist/workflow/graph.d.ts.map +1 -0
  142. package/dist/workflow/graph.js +137 -0
  143. package/dist/workflow/graph.js.map +1 -0
  144. package/dist/workflow/state.d.ts +34 -0
  145. package/dist/workflow/state.d.ts.map +1 -1
  146. package/dist/workflow/state.js.map +1 -1
  147. package/docs/doctrine/default-design-system.md +120 -0
  148. package/docs/doctrine/language-sovereignty.md +143 -0
  149. package/docs/doctrine/testing-policy.md +211 -0
  150. package/package.json +102 -100
  151. package/src/i18n/catalogs/ar.json +20 -1
  152. package/src/i18n/catalogs/de.json +20 -1
  153. package/src/i18n/catalogs/en.json +20 -21
  154. package/src/i18n/catalogs/es.json +20 -21
  155. package/src/i18n/catalogs/fr.json +20 -1
  156. package/src/i18n/catalogs/hi.json +20 -1
  157. package/src/i18n/catalogs/it.json +20 -1
  158. package/src/i18n/catalogs/ja.json +20 -1
  159. package/src/i18n/catalogs/pt.json +20 -1
  160. package/src/i18n/catalogs/zh.json +20 -1
  161. package/templates/sumi-context.template.md +57 -0
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { createServer } from 'node:http';
15
15
  import { promises as fs } from 'node:fs';
16
+ import os from 'node:os';
16
17
  import path from 'node:path';
17
18
  import { ClaudeClient, ConfigurationError, ClaudeApiError } from './claude.js';
18
19
  import { renderPanelHtml } from './panel.js';
@@ -47,6 +48,19 @@ export async function startChatServer(opts) {
47
48
  * CLI + panel converge. */
48
49
  const { resolveProjectSlug } = await import('./project_slug.js');
49
50
  const projectSlug = await resolveProjectSlug(opts.projectRoot);
51
+ /* Director sprint slice 9 -- hydrate ingest sessions from disk
52
+ * for this project so spec extract+plan survive restarts. */
53
+ try {
54
+ const { setProjectSlugForIngest, loadProjectIngests } = await import('./ingest_session.js');
55
+ setProjectSlugForIngest(projectSlug);
56
+ await loadProjectIngests(projectSlug);
57
+ }
58
+ catch (err) {
59
+ try {
60
+ console.error('ingest_session boot hydrate failed: ' + (err instanceof Error ? err.message : String(err)));
61
+ }
62
+ catch { /* swallow */ }
63
+ }
50
64
  /* alpha.59v -- fire-and-forget auto-ingest of package.json deps
51
65
  * whenever the boot detects a changed (or never-ingested)
52
66
  * package.json. Never blocks the boot; never throws.
@@ -84,7 +98,27 @@ export async function startChatServer(opts) {
84
98
  });
85
99
  }
86
100
  catch { /* never break boot */ }
87
- const claude = opts.claude ?? new ClaudeClient();
101
+ /* Provider selection: 'anthropic-api' (default, Console billing) vs
102
+ * 'claude-code-cli' (subscription billing via `claude -p`
103
+ * subprocess). The ClaudeCliClient is interface-compatible at the
104
+ * .chat() shape used by this server -- runTool round-trips happen
105
+ * inside the subprocess, so the wrapper returns toolRounds=[].
106
+ * Tests inject `opts.claude` directly and bypass this branch. */
107
+ let claude;
108
+ if (opts.claude) {
109
+ claude = opts.claude;
110
+ }
111
+ else if (opts.chatProvider === 'claude-code-cli') {
112
+ const { ClaudeCliClient } = await import('./claude_cli.js');
113
+ const cliOpts = {};
114
+ if (opts.chatProviderAddDirs && opts.chatProviderAddDirs.length > 0) {
115
+ cliOpts['addDirs'] = opts.chatProviderAddDirs;
116
+ }
117
+ claude = new ClaudeCliClient(cliOpts);
118
+ }
119
+ else {
120
+ claude = new ClaudeClient();
121
+ }
88
122
  const voice = opts.voice ?? new VoiceRouter({
89
123
  configDir: configDir(),
90
124
  registry: buildDefaultRegistry(),
@@ -131,6 +165,8 @@ export async function startChatServer(opts) {
131
165
  claude,
132
166
  voice,
133
167
  store,
168
+ runtime: opts.runtime,
169
+ chatProvider: opts.chatProvider,
134
170
  });
135
171
  }
136
172
  catch (err) {
@@ -165,7 +201,19 @@ export async function startChatServer(opts) {
165
201
  if (componentWatcherHandle) {
166
202
  Promise.resolve(componentWatcherHandle.stop()).catch(() => undefined);
167
203
  }
168
- server.close(() => resolve());
204
+ /* alpha.59z.136 slice 14 -- flush in-flight ingest session
205
+ * writes BEFORE the server closes so test teardowns that
206
+ * fs.rm the home directory immediately after do not race
207
+ * with the fire-and-forget persist queue. */
208
+ (async () => {
209
+ try {
210
+ const { flushPendingIngestWrites, setProjectSlugForIngest } = await import('./ingest_session.js');
211
+ await flushPendingIngestWrites();
212
+ setProjectSlugForIngest(null);
213
+ }
214
+ catch { /* swallow */ }
215
+ server.close(() => resolve());
216
+ })();
169
217
  }),
170
218
  };
171
219
  }
@@ -195,6 +243,42 @@ async function route(req, res, ctx) {
195
243
  });
196
244
  return;
197
245
  }
246
+ /* ------------------------------------------------------------
247
+ * Director sprint slice 5 -- agent runtime panel endpoints.
248
+ * GET /api/agent/state -- runtime status + agent definitions
249
+ * POST /api/agent/run -- dispatch an agent through runtime
250
+ * GET /api/agent/events -- SSE stream of RuntimeEvent
251
+ * All return 503 when ctx.runtime is unwired so the UI can
252
+ * surface a clean "no runtime" state.
253
+ * ------------------------------------------------------------ */
254
+ if (req.method === 'GET' && url.pathname === '/api/agent/state') {
255
+ await handleAgentState(res, ctx);
256
+ return;
257
+ }
258
+ if (req.method === 'POST' && url.pathname === '/api/agent/run') {
259
+ await handleAgentRun(req, res, ctx);
260
+ return;
261
+ }
262
+ if (req.method === 'GET' && url.pathname === '/api/agent/events') {
263
+ await handleAgentEventsSse(req, res, ctx);
264
+ return;
265
+ }
266
+ if (req.method === 'POST' && url.pathname === '/api/agent/approve') {
267
+ await handleAgentApprove(req, res, ctx);
268
+ return;
269
+ }
270
+ if (req.method === 'GET' && url.pathname === '/api/agent/activity') {
271
+ await handleAgentActivity(req, res, ctx);
272
+ return;
273
+ }
274
+ if (req.method === 'GET' && url.pathname === '/api/agent/bridge-tail') {
275
+ await handleAgentBridgeTail(req, res, ctx);
276
+ return;
277
+ }
278
+ if (req.method === 'POST' && url.pathname === '/api/agent/inject') {
279
+ await handleAgentInject(req, res, ctx);
280
+ return;
281
+ }
198
282
  /* Legal docs surfaced inside the product (Config -> About).
199
283
  * Serves the raw markdown so the panel can render or pop a
200
284
  * dialog. Routes are static names so a future i18n swap can
@@ -455,6 +539,113 @@ async function route(req, res, ctx) {
455
539
  }
456
540
  return;
457
541
  }
542
+ /* alpha.59z.150 F101 slice 3 -- MCP bridges UI endpoints.
543
+ * GET /api/forge/mcp/bridges -> list of discovered bridges
544
+ * with id/endpoint/source/enabled/
545
+ * display_name/protocol.
546
+ * GET /api/forge/mcp/tools -> tool catalog for the bridge_id
547
+ * query param. Internally calls
548
+ * MCPClient.listTools() (slice 4
549
+ * FULL).
550
+ * POST /api/forge/mcp/toggle -> flip enabled flag on a bridge.
551
+ * Panel uses these to render the Config tab + tool catalog modal.
552
+ * Each one is best-effort; failures degrade to {ok:false, error}. */
553
+ if (req.method === 'GET' && url.pathname === '/api/forge/mcp/bridges') {
554
+ try {
555
+ const { getActiveBridges } = await import('../mcp/bridge_registry.js');
556
+ const bridges = await getActiveBridges({ projectRoot: ctx.projectRoot });
557
+ const shaped = bridges.map((b) => ({
558
+ id: b.id,
559
+ endpoint: b.endpoint,
560
+ source: b.source,
561
+ enabled: b.enabled,
562
+ display_name: b.display_name ?? b.id,
563
+ protocol: b.protocol ?? 'http_rest',
564
+ }));
565
+ sendJson(res, 200, { ok: true, bridges: shaped, count: shaped.length });
566
+ }
567
+ catch (err) {
568
+ sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err), bridges: [], count: 0 });
569
+ }
570
+ return;
571
+ }
572
+ if (req.method === 'GET' && url.pathname === '/api/forge/mcp/tools') {
573
+ const bridgeId = url.searchParams.get('bridge_id') ?? '';
574
+ if (!bridgeId) {
575
+ sendJson(res, 400, { ok: false, error: 'bridge_id required' });
576
+ return;
577
+ }
578
+ try {
579
+ const { getActiveBridges } = await import('../mcp/bridge_registry.js');
580
+ const bridges = await getActiveBridges({ projectRoot: ctx.projectRoot });
581
+ const bridge = bridges.find((b) => b.id === bridgeId);
582
+ if (!bridge) {
583
+ sendJson(res, 404, { ok: false, error: 'bridge_id "' + bridgeId + '" not found', tools: [] });
584
+ return;
585
+ }
586
+ if (!bridge.enabled) {
587
+ sendJson(res, 200, { ok: false, error: 'bridge disabled', bridge_id: bridgeId, tools: [], disabled: true });
588
+ return;
589
+ }
590
+ try {
591
+ const { getMCPClient } = await import('../mcp/protocol_client.js');
592
+ const client = getMCPClient(bridge);
593
+ const r = await client.listTools();
594
+ if (r.ok) {
595
+ sendJson(res, 200, { ok: true, bridge_id: bridgeId, tools: r.tools, count: r.count });
596
+ }
597
+ else {
598
+ sendJson(res, 200, {
599
+ ok: false,
600
+ error: r.error,
601
+ bridge_id: bridgeId,
602
+ bridge_unreachable: true,
603
+ tools: [],
604
+ });
605
+ }
606
+ }
607
+ catch (toolErr) {
608
+ sendJson(res, 200, {
609
+ ok: false,
610
+ error: toolErr instanceof Error ? toolErr.message : String(toolErr),
611
+ bridge_id: bridgeId,
612
+ bridge_unreachable: true,
613
+ tools: [],
614
+ });
615
+ }
616
+ }
617
+ catch (err) {
618
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
619
+ }
620
+ return;
621
+ }
622
+ if (req.method === 'POST' && url.pathname === '/api/forge/mcp/toggle') {
623
+ let body;
624
+ try {
625
+ body = JSON.parse(await readBody(req));
626
+ }
627
+ catch {
628
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
629
+ return;
630
+ }
631
+ const bridgeId = typeof body.bridge_id === 'string' ? body.bridge_id.trim() : '';
632
+ if (!bridgeId) {
633
+ sendJson(res, 400, { ok: false, error: 'bridge_id required' });
634
+ return;
635
+ }
636
+ const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
637
+ try {
638
+ const { setBridgeEnabled } = await import('../mcp/state.js');
639
+ await setBridgeEnabled(bridgeId, enabled);
640
+ const { resetBridgeCache } = await import('../mcp/bridge_registry.js');
641
+ resetBridgeCache();
642
+ sendJson(res, 200, { ok: true, bridge_id: bridgeId, enabled });
643
+ }
644
+ catch (err) {
645
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
646
+ }
647
+ return;
648
+ }
458
649
  /* alpha.59l -- "what changed in this version" notification.
459
650
  * Compares ~/.yujin-forge/last_seen_version vs current VERSION
460
651
  * + returns the cumulative bullet catalog entries. Panel shows
@@ -2485,7 +2676,14 @@ async function handleChat(req, res, ctx) {
2485
2676
  * cannot infinite-loop when Danger Zone is on. */
2486
2677
  const callSig = [];
2487
2678
  const LOOP_REPEAT_THRESHOLD = 3;
2488
- const unified = makeUnifiedClient(brainSelection);
2679
+ /* When the operator launched `yf chat --provider claude-code-cli`,
2680
+ * route EVERY turn through ctx.claude (a ClaudeCliClient) and
2681
+ * skip the unified cost_router branch -- otherwise the router
2682
+ * would pick Gemini/Haiku/etc and bill against Console. The
2683
+ * fallback branch below uses ctx.claude.chat() directly. */
2684
+ const unified = ctx.chatProvider === 'claude-code-cli'
2685
+ ? null
2686
+ : makeUnifiedClient(brainSelection);
2489
2687
  const callerForChat = unified
2490
2688
  ? unified
2491
2689
  : {
@@ -2494,6 +2692,40 @@ async function handleChat(req, res, ctx) {
2494
2692
  return { ...claudeReply, brain: { ...brainSelection, runtime_wired: false } };
2495
2693
  },
2496
2694
  };
2695
+ /* alpha.59z.135 slice 11 -- panoramic graph snapshot. Compact
2696
+ * structural view of the project graph, cached per-slug with
2697
+ * hash refresh. The chat also receives the relevance ranking
2698
+ * below (graphSection); snapshot is the panorama, relevance is
2699
+ * the zoom. Graceful no-op on worker / vault failure. */
2700
+ let snapshotSection = '';
2701
+ try {
2702
+ const { buildGraphSnapshotSection } = await import('./graph_snapshot.js');
2703
+ const r = await buildGraphSnapshotSection({
2704
+ project_slug: ctx.projectSlug || '',
2705
+ fetcher: async (slug) => {
2706
+ try {
2707
+ const { semanticGraphGet } = await import('../license/hito4_client.js');
2708
+ const resp = await semanticGraphGet('/v1/semantic-graph/nodes', {}, { project_slug: slug });
2709
+ if (!resp.ok || !Array.isArray(resp.body.nodes)) {
2710
+ return { ok: false, nodes: [] };
2711
+ }
2712
+ return {
2713
+ ok: true,
2714
+ nodes: resp.body
2715
+ .nodes
2716
+ .filter((n) => typeof n.id === 'string' && typeof n.kind === 'string')
2717
+ .map((n) => ({ id: n.id, kind: n.kind, label: n.label })),
2718
+ };
2719
+ }
2720
+ catch {
2721
+ return { ok: false, nodes: [] };
2722
+ }
2723
+ },
2724
+ });
2725
+ if (r.ok && r.text)
2726
+ snapshotSection = '\n\n' + r.text;
2727
+ }
2728
+ catch (_) { /* swallow -- chat continues without snapshot */ }
2497
2729
  /* alpha.59p -- semantic graph injection. Pulls "what primitives
2498
2730
  * exist in this project" from the worker + appends to system
2499
2731
  * prompt so the LLM does NOT reinvent libs/components/protocols
@@ -2581,7 +2813,7 @@ async function handleChat(req, res, ctx) {
2581
2813
  tier: brainSelection.tier,
2582
2814
  provider: brainSelection.provider,
2583
2815
  model: brainSelection.model,
2584
- }, globalLearnings, mcpAvailable, backendToolsSection) + graphSection + workflowSection,
2816
+ }, globalLearnings, mcpAvailable, backendToolsSection) + snapshotSection + graphSection + workflowSection,
2585
2817
  maxTokens: 1024,
2586
2818
  tools: FORGE_TOOL_SPECS,
2587
2819
  runTool: async (name, input) => {
@@ -3016,6 +3248,21 @@ backendToolsSection = '') {
3016
3248
  ' judgment ("looks good?"), $$/scope decision, or user-only creds.',
3017
3249
  ' Otherwise: diagnose yourself + report the diagnosis, not the path.',
3018
3250
  '',
3251
+ 'SQ 9 -- VERIFY FACTUAL CLAIMS ABOUT THE WORKSPACE (MANDATORY,',
3252
+ ' added 2026-06-06 alpha.59z.158). When the user states something',
3253
+ ' factual about the current workspace -- "no tiene X", "falta Y",',
3254
+ ' "ya existe Z", "el bug esta en archivo W" -- DO NOT take it at',
3255
+ ' face value if it would lead you to write code, add files, or',
3256
+ ' recommend an action. Verify with native verbs FIRST:',
3257
+ ' - "no tiene vault" -> yujin.fs.find({glob:"**/vault*"}) or fs.list-files',
3258
+ ' - "falta tests" -> yujin.fs.list-files({subdir:"tests"})',
3259
+ ' - "el bug en X.ts" -> yujin.fs.read-file({path:"X.ts"}) first',
3260
+ ' - "ya existe Z" -> yujin.fs.find then proceed only if confirmed',
3261
+ ' If the verification contradicts the user, REPORT the contradiction',
3262
+ ' in chat ("Veo que ya hay server/vault.ts -- queres reemplazarlo,',
3263
+ ' extenderlo, o algo mas especifico?"). Never proceed with an action',
3264
+ ' premised on an unverified factual claim about the workspace.',
3265
+ '',
3019
3266
  '=================================================================',
3020
3267
  'PRODUCT WORKFLOW (on-demand)',
3021
3268
  '=================================================================',
@@ -3241,6 +3488,659 @@ function sendJson(res, status, body) {
3241
3488
  res.setHeader('content-type', 'application/json; charset=utf-8');
3242
3489
  res.end(JSON.stringify(body));
3243
3490
  }
3491
+ /* ============================================================
3492
+ * Director sprint slice 5 -- agent runtime panel handlers.
3493
+ * ============================================================ */
3494
+ /** Project the AgentDefinition to a UI-safe shape. Drops the
3495
+ * system prompt + secret-ish fields, keeps the fields the panel
3496
+ * graph needs to render the topology + per-agent status. */
3497
+ function projectAgentForPanel(def) {
3498
+ return {
3499
+ slug: def.slug,
3500
+ name: def.name,
3501
+ description: def.description,
3502
+ status: def.status,
3503
+ brain_tier: def.brain_tier,
3504
+ can_call: def.can_call,
3505
+ can_be_called_by: def.can_be_called_by,
3506
+ allowed_verbs: def.allowed_verbs,
3507
+ trigger: def.trigger,
3508
+ };
3509
+ }
3510
+ async function handleAgentState(res, ctx) {
3511
+ if (!ctx.runtime) {
3512
+ sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
3513
+ return;
3514
+ }
3515
+ const { listAgentDefinitions } = await import('../agents/store.js');
3516
+ const defs = await listAgentDefinitions({ root: ctx.runtime.getAgentsRoot() });
3517
+ sendJson(res, 200, {
3518
+ ok: true,
3519
+ runtime: ctx.runtime.status(),
3520
+ agents: defs.map(projectAgentForPanel),
3521
+ });
3522
+ }
3523
+ async function handleAgentRun(req, res, ctx) {
3524
+ if (!ctx.runtime) {
3525
+ sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
3526
+ return;
3527
+ }
3528
+ let body;
3529
+ try {
3530
+ body = JSON.parse(await readBody(req));
3531
+ }
3532
+ catch {
3533
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
3534
+ return;
3535
+ }
3536
+ if (typeof body.slug !== 'string' || body.slug.length === 0) {
3537
+ sendJson(res, 400, { ok: false, error: 'missing required field: slug' });
3538
+ return;
3539
+ }
3540
+ const trigger_reason = typeof body.trigger_reason === 'string' && body.trigger_reason.length > 0
3541
+ ? body.trigger_reason
3542
+ : 'panel';
3543
+ /* Slice 1 N-projects sprint -- optional project_slug. */
3544
+ const project_slug = (typeof body.project_slug === 'string' && body.project_slug.trim())
3545
+ ? body.project_slug.trim()
3546
+ : 'self';
3547
+ try {
3548
+ const run = await ctx.runtime.dispatchAgent(body.slug, trigger_reason, { project_slug });
3549
+ sendJson(res, 200, { ok: true, run });
3550
+ }
3551
+ catch (err) {
3552
+ const msg = err instanceof Error ? err.message : String(err);
3553
+ /* Map known runtime errors to actionable HTTP codes. */
3554
+ if (/not registered/i.test(msg)) {
3555
+ sendJson(res, 404, { ok: false, error: msg });
3556
+ }
3557
+ else if (/not started/i.test(msg)) {
3558
+ sendJson(res, 503, { ok: false, error: msg });
3559
+ }
3560
+ else {
3561
+ sendJson(res, 500, { ok: false, error: msg });
3562
+ }
3563
+ }
3564
+ }
3565
+ async function handleAgentEventsSse(req, res, ctx) {
3566
+ if (!ctx.runtime) {
3567
+ sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
3568
+ return;
3569
+ }
3570
+ res.statusCode = 200;
3571
+ res.setHeader('content-type', 'text/event-stream; charset=utf-8');
3572
+ res.setHeader('cache-control', 'no-store, no-transform');
3573
+ res.setHeader('connection', 'keep-alive');
3574
+ /* Flush headers so the client can settle the response before
3575
+ * any event is emitted. */
3576
+ if (typeof res.flushHeaders === 'function') {
3577
+ res.flushHeaders();
3578
+ }
3579
+ /* Initial comment line keeps the proxy from buffering the
3580
+ * empty stream + signals the client the connection is live. */
3581
+ res.write(': connected to agent runtime event stream\n\n');
3582
+ const sendEvent = (e) => {
3583
+ try {
3584
+ res.write('event: ' + e.kind + '\n');
3585
+ res.write('data: ' + JSON.stringify(e) + '\n\n');
3586
+ }
3587
+ catch {
3588
+ /* Socket may be closed; ignore. The 'close' handler below
3589
+ * unsubscribes regardless. */
3590
+ }
3591
+ };
3592
+ ctx.runtime.on('event', sendEvent);
3593
+ /* Heartbeat keeps the connection alive through idle proxies.
3594
+ * 15s mirrors the existing chat SSE cadence. */
3595
+ const heartbeat = setInterval(() => {
3596
+ try {
3597
+ res.write(': hb\n\n');
3598
+ }
3599
+ catch { /* socket dead */ }
3600
+ }, 15_000);
3601
+ if (typeof heartbeat.unref === 'function') {
3602
+ heartbeat.unref();
3603
+ }
3604
+ const cleanup = () => {
3605
+ clearInterval(heartbeat);
3606
+ if (ctx.runtime)
3607
+ ctx.runtime.off('event', sendEvent);
3608
+ };
3609
+ req.on('close', cleanup);
3610
+ req.on('error', cleanup);
3611
+ }
3612
+ /**
3613
+ * Director sprint slice 10 -- POST /api/agent/approve.
3614
+ *
3615
+ * Single endpoint that resolves an R8-style approval envelope an
3616
+ * agent emitted (decision: 'approved' | 'rejected'). Delegates to
3617
+ * the existing approval queue + audit log. Independent of runtime
3618
+ * state -- a pending row can survive runtime restarts via the
3619
+ * queue's TTL, so we do NOT gate this endpoint on ctx.runtime.
3620
+ */
3621
+ async function handleAgentApprove(req, res, ctx) {
3622
+ let body;
3623
+ try {
3624
+ body = JSON.parse(await readBody(req));
3625
+ }
3626
+ catch {
3627
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
3628
+ return;
3629
+ }
3630
+ const traceId = typeof body.trace_id === 'string' ? body.trace_id.trim() : '';
3631
+ if (!traceId) {
3632
+ sendJson(res, 400, { ok: false, error: 'trace_id required' });
3633
+ return;
3634
+ }
3635
+ const decision = typeof body.decision === 'string' ? body.decision : '';
3636
+ if (decision !== 'approved' && decision !== 'rejected') {
3637
+ sendJson(res, 400, { ok: false, error: 'decision must be "approved" or "rejected"' });
3638
+ return;
3639
+ }
3640
+ const reason = typeof body.reason === 'string' ? body.reason.slice(0, 240) : undefined;
3641
+ const pending = popPending(traceId);
3642
+ if (!pending) {
3643
+ await logApproval({
3644
+ ts: new Date().toISOString(),
3645
+ trace_id: traceId,
3646
+ verb_id: '?',
3647
+ decision: 'expired',
3648
+ args_sample: {},
3649
+ policy: '?',
3650
+ reason: 'trace_id not in queue (expired or already resolved)',
3651
+ });
3652
+ sendJson(res, 404, { ok: false, error: 'no pending approval for trace_id (expired or already resolved)' });
3653
+ return;
3654
+ }
3655
+ if (decision === 'rejected') {
3656
+ await logApproval({
3657
+ ts: new Date().toISOString(),
3658
+ trace_id: traceId,
3659
+ verb_id: pending.verb.id,
3660
+ decision: 'reject',
3661
+ args_sample: pending.args,
3662
+ policy: pending.verb.policy,
3663
+ reason,
3664
+ });
3665
+ /* Director closure pendiente 5 followup -- fire the SSE event
3666
+ * so the panel SVG can clear the paused-for-approval marker on
3667
+ * the originating agent node. Best-effort: no runtime / no
3668
+ * agent_slug means nobody is listening for this event anyway. */
3669
+ if (ctx.runtime && pending.agent_slug) {
3670
+ ctx.runtime.emitExternal({
3671
+ kind: 'agent_run_paused_resolved',
3672
+ slug: pending.agent_slug,
3673
+ trace_id: traceId,
3674
+ decision: 'rejected',
3675
+ at: Date.now(),
3676
+ });
3677
+ }
3678
+ sendJson(res, 200, {
3679
+ ok: true,
3680
+ trace_id: traceId,
3681
+ verb_id: pending.verb.id,
3682
+ decision: 'rejected',
3683
+ });
3684
+ return;
3685
+ }
3686
+ /* Approve path: execute the bound verb via its captured legacy
3687
+ * alias, mirroring handleNac3Approve. */
3688
+ if (!pending.legacy_alias) {
3689
+ sendJson(res, 500, { ok: false, error: 'pending row has no legacy alias' });
3690
+ return;
3691
+ }
3692
+ let result;
3693
+ try {
3694
+ result = await pending.legacy_run(pending.legacy_alias, pending.args, ctx.projectRoot === pending.ctx.projectRoot ? pending.ctx : { projectRoot: ctx.projectRoot });
3695
+ }
3696
+ catch (err) {
3697
+ await logApproval({
3698
+ ts: new Date().toISOString(),
3699
+ trace_id: traceId,
3700
+ verb_id: pending.verb.id,
3701
+ decision: 'approve',
3702
+ args_sample: pending.args,
3703
+ policy: pending.verb.policy,
3704
+ reason: 'handler threw: ' + (err instanceof Error ? err.message : String(err)),
3705
+ });
3706
+ sendJson(res, 500, {
3707
+ ok: false,
3708
+ error: 'handler threw on approved verb: ' + (err instanceof Error ? err.message : String(err)),
3709
+ });
3710
+ return;
3711
+ }
3712
+ await logApproval({
3713
+ ts: new Date().toISOString(),
3714
+ trace_id: traceId,
3715
+ verb_id: pending.verb.id,
3716
+ decision: 'approve',
3717
+ args_sample: pending.args,
3718
+ policy: pending.verb.policy,
3719
+ reason,
3720
+ });
3721
+ /* Director closure pendiente 5 followup -- fire the SSE event so
3722
+ * the panel SVG can clear the paused-for-approval marker on the
3723
+ * originating agent node. */
3724
+ if (ctx.runtime && pending.agent_slug) {
3725
+ ctx.runtime.emitExternal({
3726
+ kind: 'agent_run_paused_resolved',
3727
+ slug: pending.agent_slug,
3728
+ trace_id: traceId,
3729
+ decision: 'approved',
3730
+ at: Date.now(),
3731
+ });
3732
+ }
3733
+ sendJson(res, 200, {
3734
+ ok: true,
3735
+ trace_id: traceId,
3736
+ verb_id: pending.verb.id,
3737
+ decision: 'approved',
3738
+ is_error: result.is_error ?? false,
3739
+ result: result.result,
3740
+ });
3741
+ }
3742
+ /* ===================================================================
3743
+ * Agent activity log + bridge inspector + chat-per-agent
3744
+ * (visibility sprint -- alpha.59z.153).
3745
+ *
3746
+ * Three endpoints that surface what the agents are actually doing
3747
+ * so the operator is not staring at a silent SVG:
3748
+ *
3749
+ * GET /api/agent/activity?since=<iso?>&limit=<n?>
3750
+ * -> recent timestamped events: agent runs (started/finished),
3751
+ * bridge sends, dispatch invokes, approval decisions.
3752
+ * Merge of 4 jsonl tails + 1 dir scan; sorted by ts desc.
3753
+ *
3754
+ * GET /api/agent/bridge-tail?pair_id=<from__to?>&limit=<n?>
3755
+ * -> messages out of bridge.json filtered + sorted desc.
3756
+ * When pair_id absent, lists all pairs with counts.
3757
+ *
3758
+ * POST /api/agent/inject {slug, text, dispatch?}
3759
+ * -> append a user-role message to the agent's active
3760
+ * conversation file + optionally dispatch a run.
3761
+ *
3762
+ * All three are best-effort: missing files are normal (no agents
3763
+ * yet) -- they return ok:true with empty arrays, not 404.
3764
+ * =============================================================== */
3765
+ async function tailJsonl(file, limit) {
3766
+ try {
3767
+ const raw = await fs.readFile(file, 'utf-8');
3768
+ const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
3769
+ const slice = lines.length > limit ? lines.slice(lines.length - limit) : lines;
3770
+ const out = [];
3771
+ for (const line of slice) {
3772
+ try {
3773
+ out.push(JSON.parse(line));
3774
+ }
3775
+ catch { /* skip corrupt line */ }
3776
+ }
3777
+ return out;
3778
+ }
3779
+ catch {
3780
+ return [];
3781
+ }
3782
+ }
3783
+ function agentsRootDefault(ctx) {
3784
+ if (ctx.runtime)
3785
+ return ctx.runtime.getAgentsRoot();
3786
+ /* Mirror the runtime default. YF_CONFIG_DIR is the same env var
3787
+ * approval_queue / dispatch_log honor; tests inject it to scope
3788
+ * the entire ~/.yujin-forge tree to a tmpdir. */
3789
+ if (process.env.YF_CONFIG_DIR)
3790
+ return process.env.YF_CONFIG_DIR;
3791
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
3792
+ return path.join(home, '.yujin-forge');
3793
+ }
3794
+ async function handleAgentActivity(req, res, ctx) {
3795
+ const url = new URL(req.url ?? '/', 'http://localhost');
3796
+ const limit = clampInt(url.searchParams.get('limit'), 200, 1, 2000);
3797
+ const since = url.searchParams.get('since') || '';
3798
+ /* Slice 1 N-projects sprint -- optional project filter. Empty
3799
+ * = all projects (default; back-compat with single-project
3800
+ * callers). Explicit slug filters events by project_slug. */
3801
+ const projectFilter = (url.searchParams.get('project_slug') || '').trim();
3802
+ const events = [];
3803
+ const agentsRoot = agentsRootDefault(ctx);
3804
+ const yfRoot = path.join(agentsRoot);
3805
+ /* 1. nac3 dispatch log -- every verb invoke / deny / panel-dispatch */
3806
+ const dispatchLog = path.join(yfRoot, 'nac3_log.jsonl');
3807
+ for (const e of await tailJsonl(dispatchLog, limit)) {
3808
+ const r = e;
3809
+ if (!r.ts)
3810
+ continue;
3811
+ const label = r.kind === 'invoke' ? ((r.result_ok ? 'ok' : 'err') + ' ' + (r.verb_id ?? '?'))
3812
+ : r.kind === 'verb-not-found' ? ('unknown verb ' + (r.verb_id ?? '?'))
3813
+ : r.kind === 'policy-deny' ? ('policy denied ' + (r.verb_id ?? '?'))
3814
+ : r.kind === 'panel-handler-error' ? ('panel error ' + (r.verb_id ?? '?'))
3815
+ : String(r.kind ?? 'event');
3816
+ events.push({
3817
+ ts: String(r.ts),
3818
+ kind: 'dispatch.' + String(r.kind ?? 'unknown'),
3819
+ text: label,
3820
+ trace_id: r.trace_id ? String(r.trace_id) : undefined,
3821
+ extra: {
3822
+ duration_ms: r.duration_ms,
3823
+ policy: r.policy,
3824
+ side_effects_declared: r.side_effects_declared,
3825
+ },
3826
+ });
3827
+ }
3828
+ /* 2. approvals log -- R8 modal decisions */
3829
+ const approvalLog = path.join(yfRoot, 'nac3_approvals.jsonl');
3830
+ for (const e of await tailJsonl(approvalLog, limit)) {
3831
+ const r = e;
3832
+ if (!r.ts)
3833
+ continue;
3834
+ events.push({
3835
+ ts: String(r.ts),
3836
+ kind: 'approval.' + String(r.decision ?? 'event'),
3837
+ text: 'R8 ' + String(r.decision ?? '?') + ' on ' + String(r.verb_id ?? '?'),
3838
+ trace_id: r.trace_id ? String(r.trace_id) : undefined,
3839
+ extra: { policy: r.policy, reason: r.reason },
3840
+ });
3841
+ }
3842
+ /* 3. agent runs -- scan ~/.yujin-forge/agents/by-slug/<slug>/runs/*.json */
3843
+ const bySlug = path.join(yfRoot, 'agents', 'by-slug');
3844
+ let slugs = [];
3845
+ try {
3846
+ slugs = await fs.readdir(bySlug);
3847
+ }
3848
+ catch { /* no agents yet -- ok */ }
3849
+ for (const slug of slugs) {
3850
+ const runsDir = path.join(bySlug, slug, 'runs');
3851
+ let runFiles = [];
3852
+ try {
3853
+ runFiles = (await fs.readdir(runsDir)).filter((n) => n.endsWith('.json'));
3854
+ }
3855
+ catch {
3856
+ continue;
3857
+ }
3858
+ /* Newest first; we cap at limit per agent so a noisy one
3859
+ * does not drown others. */
3860
+ runFiles.sort();
3861
+ const cap = runFiles.slice(-Math.min(50, limit));
3862
+ for (const fn of cap) {
3863
+ try {
3864
+ const raw = await fs.readFile(path.join(runsDir, fn), 'utf-8');
3865
+ const r = JSON.parse(raw);
3866
+ const startTs = r.started_at ? new Date(Number(r.started_at)).toISOString() : '';
3867
+ const endTs = r.ended_at ? new Date(Number(r.ended_at)).toISOString() : startTs;
3868
+ const runProject = (typeof r.project_slug === 'string' && r.project_slug) ? r.project_slug : 'self';
3869
+ events.push({
3870
+ ts: startTs || endTs,
3871
+ kind: 'agent.run_started',
3872
+ slug,
3873
+ text: '[' + runProject + '] ' + slug + ' started: ' + String(r.trigger_reason ?? '').slice(0, 120),
3874
+ project_slug: runProject,
3875
+ });
3876
+ if (r.ended_at) {
3877
+ events.push({
3878
+ ts: endTs,
3879
+ kind: 'agent.run_finished',
3880
+ slug,
3881
+ text: '[' + runProject + '] ' + slug + ' ' + String(r.status ?? '?')
3882
+ + ' (in=' + (r.tokens_in ?? 0) + ' out=' + (r.tokens_out ?? 0)
3883
+ + ' tools=' + (r.tool_calls ?? 0) + ')',
3884
+ project_slug: runProject,
3885
+ extra: {
3886
+ status: r.status,
3887
+ tokens_in: r.tokens_in,
3888
+ tokens_out: r.tokens_out,
3889
+ tool_calls: r.tool_calls,
3890
+ kill_reason: r.kill_reason,
3891
+ last_reply_summary: typeof r.last_reply_summary === 'string'
3892
+ ? String(r.last_reply_summary).slice(0, 240)
3893
+ : undefined,
3894
+ },
3895
+ });
3896
+ }
3897
+ }
3898
+ catch { /* skip */ }
3899
+ }
3900
+ }
3901
+ /* 4. bridge messages -- scan every shard under
3902
+ * ~/.yujin-forge/bridge/ (bridge.json + bridge-{slug}.json).
3903
+ * Each shard's messages tag themselves with project_slug
3904
+ * derived from the filename so the activity feed knows which
3905
+ * project a message belongs to. */
3906
+ const bridgeDir = path.join(yfRoot, 'bridge');
3907
+ let bridgeShards = [];
3908
+ try {
3909
+ bridgeShards = (await fs.readdir(bridgeDir)).filter((n) => n.endsWith('.json') && (n === 'bridge.json' || n.startsWith('bridge-')));
3910
+ }
3911
+ catch { /* no bridge dir -- ok */ }
3912
+ for (const fname of bridgeShards) {
3913
+ const projectFromFile = fname === 'bridge.json'
3914
+ ? 'self'
3915
+ : fname.slice('bridge-'.length, fname.length - '.json'.length) || 'self';
3916
+ try {
3917
+ const raw = await fs.readFile(path.join(bridgeDir, fname), 'utf-8');
3918
+ const b = JSON.parse(raw);
3919
+ const msgs = Array.isArray(b.messages) ? b.messages : [];
3920
+ /* Take last `limit` per shard to bound memory while not
3921
+ * losing recent activity. */
3922
+ const slice = msgs.length > limit ? msgs.slice(msgs.length - limit) : msgs;
3923
+ for (const m of slice) {
3924
+ const ts = m.sent_at ? new Date(Number(m.sent_at)).toISOString()
3925
+ : (m.created_at ? new Date(Number(m.created_at)).toISOString() : '');
3926
+ const msgProject = (typeof m.project_slug === 'string' && m.project_slug)
3927
+ ? m.project_slug
3928
+ : projectFromFile;
3929
+ events.push({
3930
+ ts,
3931
+ kind: 'bridge.message',
3932
+ from: String(m.from_slug ?? m.from ?? '?'),
3933
+ to: String(m.to_slug ?? m.to ?? '?'),
3934
+ text: '[' + msgProject + '] '
3935
+ + String(m.from_slug ?? m.from ?? '?') + ' -> ' + String(m.to_slug ?? m.to ?? '?')
3936
+ + ': ' + String(m.payload ?? '').slice(0, 160),
3937
+ project_slug: msgProject,
3938
+ extra: { tokens: m.tokens_used, status: m.status, id: m.id },
3939
+ });
3940
+ }
3941
+ }
3942
+ catch { /* skip unreadable shard */ }
3943
+ }
3944
+ /* Sort desc by ts (ISO strings sort correctly). Filter by since
3945
+ * + project_filter. */
3946
+ events.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
3947
+ let filtered = since ? events.filter((e) => e.ts > since) : events;
3948
+ if (projectFilter) {
3949
+ filtered = filtered.filter((e) => e.project_slug === projectFilter
3950
+ /* Events without a project_slug (legacy or non-project-
3951
+ * tagged kinds like dispatch.*) appear under 'self' so a
3952
+ * filter for the default project still sees them. */
3953
+ || (!e.project_slug && projectFilter === 'self'));
3954
+ }
3955
+ const final = filtered.slice(0, limit);
3956
+ sendJson(res, 200, { ok: true, count: final.length, project_filter: projectFilter || null, events: final });
3957
+ }
3958
+ async function handleAgentBridgeTail(req, res, ctx) {
3959
+ const url = new URL(req.url ?? '/', 'http://localhost');
3960
+ const limit = clampInt(url.searchParams.get('limit'), 100, 1, 1000);
3961
+ const pairId = (url.searchParams.get('pair_id') || '').trim();
3962
+ /* Slice 1 N-projects sprint -- optional project filter. Empty
3963
+ * means "scan every shard". 'self' means only the default
3964
+ * (legacy bridge.json). */
3965
+ const projectFilter = (url.searchParams.get('project_slug') || '').trim();
3966
+ const yfRoot = agentsRootDefault(ctx);
3967
+ const bridgeDir = path.join(yfRoot, 'bridge');
3968
+ /* Discover every shard file, then read whichever ones match
3969
+ * the filter. */
3970
+ let shardFiles = [];
3971
+ try {
3972
+ shardFiles = (await fs.readdir(bridgeDir)).filter((n) => n.endsWith('.json') && (n === 'bridge.json' || n.startsWith('bridge-')));
3973
+ }
3974
+ catch {
3975
+ sendJson(res, 200, { ok: true, pairs: [], messages: [], pair_state: {}, project_filter: projectFilter || null });
3976
+ return;
3977
+ }
3978
+ /* Load every shard. msgs carries an extra `_project_slug` for
3979
+ * downstream filtering. pair_state aggregated per-shard. */
3980
+ const allMsgs = [];
3981
+ const allPairState = {};
3982
+ for (const fname of shardFiles) {
3983
+ const project = fname === 'bridge.json'
3984
+ ? 'self'
3985
+ : fname.slice('bridge-'.length, fname.length - '.json'.length) || 'self';
3986
+ if (projectFilter && projectFilter !== project)
3987
+ continue;
3988
+ try {
3989
+ const raw = await fs.readFile(path.join(bridgeDir, fname), 'utf-8');
3990
+ const b = JSON.parse(raw);
3991
+ const ms = Array.isArray(b.messages) ? b.messages : [];
3992
+ for (const m of ms)
3993
+ allMsgs.push({ ...m, _project_slug: project });
3994
+ if (b.pair_state) {
3995
+ for (const [pid, st] of Object.entries(b.pair_state)) {
3996
+ allPairState[pid + '@' + project] = { project_slug: project, state: st };
3997
+ }
3998
+ }
3999
+ }
4000
+ catch { /* skip unreadable shard */ }
4001
+ }
4002
+ /* When pair_id absent: aggregate by pair (cross-shard). The
4003
+ * project_slug is part of the visible pair card so two pairs
4004
+ * with the same from/to but different projects don't merge. */
4005
+ if (!pairId) {
4006
+ const pairs = {};
4007
+ for (const m of allMsgs) {
4008
+ const fromSlug = String(m.from_slug ?? m.from ?? '?');
4009
+ const toSlug = String(m.to_slug ?? m.to ?? '?');
4010
+ const proj = m._project_slug;
4011
+ const key = fromSlug + '__' + toSlug + (proj === 'self' ? '' : '@' + proj);
4012
+ const ts = m.sent_at ? new Date(Number(m.sent_at)).toISOString()
4013
+ : (m.created_at ? new Date(Number(m.created_at)).toISOString() : '');
4014
+ if (!pairs[key]) {
4015
+ pairs[key] = { from: fromSlug, to: toSlug, count: 0, last_ts: ts, project_slug: proj };
4016
+ }
4017
+ pairs[key].count++;
4018
+ if (ts > pairs[key].last_ts)
4019
+ pairs[key].last_ts = ts;
4020
+ }
4021
+ const list = Object.entries(pairs).map(([k, v]) => ({ pair_id: k, ...v }))
4022
+ .sort((a, b) => (a.last_ts < b.last_ts ? 1 : -1));
4023
+ sendJson(res, 200, { ok: true, pairs: list, project_filter: projectFilter || null });
4024
+ return;
4025
+ }
4026
+ /* With pair_id: parse out optional @project suffix; filter
4027
+ * messages newest first, capped. */
4028
+ const atIdx = pairId.lastIndexOf('@');
4029
+ const pairCore = atIdx >= 0 ? pairId.slice(0, atIdx) : pairId;
4030
+ const pairProject = atIdx >= 0 ? pairId.slice(atIdx + 1) : 'self';
4031
+ const filtered = allMsgs.filter((m) => {
4032
+ const fromSlug = String(m.from_slug ?? m.from ?? '?');
4033
+ const toSlug = String(m.to_slug ?? m.to ?? '?');
4034
+ return (fromSlug + '__' + toSlug) === pairCore && m._project_slug === pairProject;
4035
+ });
4036
+ const slice = filtered.length > limit ? filtered.slice(filtered.length - limit) : filtered;
4037
+ slice.sort((a, b) => Number(b.sent_at ?? 0) - Number(a.sent_at ?? 0));
4038
+ /* Find matching pair_state from the per-shard map. */
4039
+ const pairStateKey = Object.keys(allPairState).find((k) => k.endsWith('@' + pairProject))
4040
+ || null;
4041
+ sendJson(res, 200, {
4042
+ ok: true,
4043
+ pair_id: pairId,
4044
+ project_slug: pairProject,
4045
+ count: slice.length,
4046
+ messages: slice,
4047
+ pair_state: pairStateKey ? allPairState[pairStateKey].state : null,
4048
+ });
4049
+ }
4050
+ async function handleAgentInject(req, res, ctx) {
4051
+ let body;
4052
+ try {
4053
+ body = JSON.parse(await readBody(req));
4054
+ }
4055
+ catch {
4056
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
4057
+ return;
4058
+ }
4059
+ const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
4060
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
4061
+ if (!slug) {
4062
+ sendJson(res, 400, { ok: false, error: 'slug required' });
4063
+ return;
4064
+ }
4065
+ if (!text) {
4066
+ sendJson(res, 400, { ok: false, error: 'text required' });
4067
+ return;
4068
+ }
4069
+ const doDispatch = body.dispatch !== false;
4070
+ /* Slice 1 N-projects sprint -- explicit project_slug routes
4071
+ * the user message to that project's conversation/run instance.
4072
+ * Defaults to 'self' for legacy single-project behaviour. */
4073
+ const project_slug = (typeof body.project_slug === 'string' && body.project_slug.trim())
4074
+ ? body.project_slug.trim()
4075
+ : 'self';
4076
+ const yfRoot = agentsRootDefault(ctx);
4077
+ const convDir = path.join(yfRoot, 'agents', 'by-slug', slug, 'conversations');
4078
+ /* Pick the most recently modified conversation file, or create
4079
+ * one named "manual" if none exists yet. */
4080
+ let convFile = path.join(convDir, 'manual.json');
4081
+ try {
4082
+ const files = (await fs.readdir(convDir)).filter((n) => n.endsWith('.json'));
4083
+ if (files.length > 0) {
4084
+ const stats = await Promise.all(files.map(async (n) => ({
4085
+ name: n,
4086
+ mtime: (await fs.stat(path.join(convDir, n))).mtimeMs,
4087
+ })));
4088
+ stats.sort((a, b) => b.mtime - a.mtime);
4089
+ convFile = path.join(convDir, stats[0].name);
4090
+ }
4091
+ }
4092
+ catch {
4093
+ /* convDir does not exist yet -- mkdir below + start manual. */
4094
+ }
4095
+ let messages = [];
4096
+ try {
4097
+ const raw = await fs.readFile(convFile, 'utf-8');
4098
+ const parsed = JSON.parse(raw);
4099
+ if (parsed && Array.isArray(parsed.messages))
4100
+ messages = parsed.messages;
4101
+ }
4102
+ catch { /* fresh */ }
4103
+ messages.push({ role: 'user', content: text });
4104
+ try {
4105
+ await fs.mkdir(convDir, { recursive: true });
4106
+ await fs.writeFile(convFile, JSON.stringify({ version: 1, messages }, null, 2), 'utf-8');
4107
+ }
4108
+ catch (err) {
4109
+ sendJson(res, 500, { ok: false, error: 'persist failed: ' + (err instanceof Error ? err.message : String(err)) });
4110
+ return;
4111
+ }
4112
+ if (doDispatch) {
4113
+ if (!ctx.runtime) {
4114
+ sendJson(res, 200, {
4115
+ ok: true, persisted: convFile, dispatched: false,
4116
+ note: 'runtime not wired -- message saved but no run fired',
4117
+ });
4118
+ return;
4119
+ }
4120
+ try {
4121
+ const run = await ctx.runtime.dispatchAgent(slug, 'user-injected: ' + text.slice(0, 80), { project_slug });
4122
+ sendJson(res, 200, {
4123
+ ok: true, persisted: convFile, dispatched: true,
4124
+ run_status: run.status, run_id: run.id, last_reply: run.last_reply_summary,
4125
+ project_slug: run.project_slug ?? project_slug,
4126
+ });
4127
+ }
4128
+ catch (err) {
4129
+ sendJson(res, 200, {
4130
+ ok: true, persisted: convFile, dispatched: false,
4131
+ error: err instanceof Error ? err.message : String(err),
4132
+ });
4133
+ }
4134
+ return;
4135
+ }
4136
+ sendJson(res, 200, { ok: true, persisted: convFile, dispatched: false });
4137
+ }
4138
+ function clampInt(raw, fallback, lo, hi) {
4139
+ const n = raw != null ? Number(raw) : fallback;
4140
+ if (!Number.isFinite(n))
4141
+ return fallback;
4142
+ return Math.max(lo, Math.min(hi, Math.floor(n)));
4143
+ }
3244
4144
  /** Serve a legal doc (privacy policy / limited responsibility) as
3245
4145
  * text/markdown. Reads from dist/docs/<basename> (copied at build
3246
4146
  * time by scripts/copy_qa_assets.mjs). Names are static + sanitised