@dotdrelle/wiki-manager 0.15.28 → 0.15.32

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 (43) hide show
  1. package/.env.example +5 -2
  2. package/README.md +5 -4
  3. package/agents.docker-compose.override.example.yml +1 -1
  4. package/agents.docker-compose.yml +10 -0
  5. package/bin/wiki-manager.js +1 -1
  6. package/docker-compose.override.example.yml +3 -2
  7. package/mcp.endpoints.example.json +1 -1
  8. package/package.json +2 -2
  9. package/src/agent/graph.js +77 -13
  10. package/src/agent/graph.test.js +165 -8
  11. package/src/cli/runtimeStartup.test.js +14 -0
  12. package/src/cli/wiki-manager.js +118 -19
  13. package/src/cli/wiki-manager.test.js +192 -0
  14. package/src/commands/slash.js +168 -39
  15. package/src/commands/slash.test.js +100 -1
  16. package/src/core/agentsCompose.js +79 -0
  17. package/src/core/agentsCompose.test.js +99 -0
  18. package/src/core/buildInfo.json +2 -2
  19. package/src/core/composeOverrides.test.js +17 -3
  20. package/src/core/env.js +27 -10
  21. package/src/core/mcp.js +113 -38
  22. package/src/core/mcp.test.js +181 -20
  23. package/src/core/startupCheck.js +64 -27
  24. package/src/core/startupCheck.test.js +49 -1
  25. package/src/core/wikiSetup.js +57 -22
  26. package/src/core/wikiSetup.test.js +87 -0
  27. package/src/core/wikiWorkspace.test.js +16 -0
  28. package/src/orchestrator/agentRegistry.js +35 -7
  29. package/src/orchestrator/agentRegistry.test.js +73 -0
  30. package/src/orchestrator/dispatcher.js +6 -1
  31. package/src/orchestrator/dispatcher.test.js +24 -1
  32. package/src/orchestrator/objectiveResolver.js +24 -0
  33. package/src/orchestrator/objectiveResolver.test.js +27 -0
  34. package/src/runtime/auth.test.js +1 -65
  35. package/src/runtime/donna-contract.test.js +2 -1
  36. package/src/runtime/lifecycle.js +0 -33
  37. package/src/runtime/runner.test.js +10 -1
  38. package/src/runtime/supervisor.test.js +11 -1
  39. package/src/shell/LeftPane.tsx +49 -24
  40. package/src/shell/repl.js +114 -21
  41. package/src/shell/repl.test.js +88 -17
  42. package/src/shell/tui.tsx +10 -19
  43. package/wiki-workspace +98 -12
@@ -64,6 +64,33 @@ test('resolveObjective uses an unambiguously mentioned registry operation withou
64
64
  assert.equal(result.provider.agentInstanceId, 'production-1');
65
65
  });
66
66
 
67
+ test('resolveObjective declines an objective no listed capability covers', async () => {
68
+ // Forcing a pick is how "cree l'auth pour le gmail" became a doctor run on
69
+ // the production agent. Declining must be reported as such, not as a defect.
70
+ const session = sessionWithSelection({ capability: null, reason: 'Gmail authorization is not an orchestrable capability.' });
71
+ await assert.rejects(
72
+ resolveObjective("cree l'auth pour le gmail", session),
73
+ (err) => {
74
+ assert.equal(err.name, 'ObjectiveNotOrchestrableError');
75
+ assert.match(err.message, /No connected agent can do that\./);
76
+ assert.match(err.message, /Available capabilities: knowledge\.update\./);
77
+ assert.deepEqual(err.candidates, ['knowledge.update']);
78
+ return true;
79
+ },
80
+ );
81
+ });
82
+
83
+ test('resolveObjective treats a malformed selection as a resolution defect, not a decline', async () => {
84
+ await assert.rejects(
85
+ resolveObjective('Traite tout', sessionWithSelection({ reason: 'missing capability' })),
86
+ (err) => {
87
+ assert.notEqual(err.name, 'ObjectiveNotOrchestrableError');
88
+ assert.match(err.message, /unknown capability/);
89
+ return true;
90
+ },
91
+ );
92
+ });
93
+
67
94
  test('resolveObjective rejects invented capability and operation', async () => {
68
95
  await assert.rejects(
69
96
  resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { resolveRuntimeAuthToken } from './auth.js';
7
- import { assertRuntimeNode, runtimeNodeExecutable, shutdownOwnedRuntime } from './lifecycle.js';
7
+ import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
8
8
 
9
9
  test('resolveRuntimeAuthToken: loopback host does not require token', () => {
10
10
  const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
@@ -27,67 +27,3 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
27
27
  assert.ok(runtimeNode.executable);
28
28
  assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
29
29
  });
30
-
31
- test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
32
- const originalFetch = globalThis.fetch;
33
- const calls = [];
34
- const logs = [];
35
- globalThis.fetch = async (url, options = {}) => {
36
- calls.push({ url: String(url), method: options.method ?? 'GET' });
37
- return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
38
- status: calls.length === 1 ? 200 : 202,
39
- headers: { 'content-type': 'application/json' },
40
- });
41
- };
42
- try {
43
- const result = await shutdownOwnedRuntime(
44
- { url: 'http://127.0.0.1:7788', started: true, token: null },
45
- { log: (message) => logs.push(message), timeoutMs: 100 },
46
- );
47
- assert.equal(result.action, 'shutdown');
48
- assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
49
- assert.match(logs.join('\n'), /runtime arrêté/);
50
- } finally {
51
- globalThis.fetch = originalFetch;
52
- }
53
- });
54
-
55
- test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
56
- const originalFetch = globalThis.fetch;
57
- const calls = [];
58
- globalThis.fetch = async (_url, options = {}) => {
59
- calls.push(options.method ?? 'GET');
60
- return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
61
- status: calls.length === 1 ? 200 : 202,
62
- headers: { 'content-type': 'application/json' },
63
- });
64
- };
65
- try {
66
- const result = await shutdownOwnedRuntime(
67
- { url: 'http://127.0.0.1:7788', started: false, token: null },
68
- { timeoutMs: 100 },
69
- );
70
- assert.equal(result.action, 'shutdown');
71
- assert.deepEqual(calls, ['GET', 'POST']);
72
- } finally {
73
- globalThis.fetch = originalFetch;
74
- }
75
- });
76
-
77
- test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
78
- const originalFetch = globalThis.fetch;
79
- const logs = [];
80
- globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
81
- signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
82
- });
83
- try {
84
- const result = await shutdownOwnedRuntime(
85
- { url: 'http://127.0.0.1:7788', started: true, token: null },
86
- { log: (message) => logs.push(message), timeoutMs: 5 },
87
- );
88
- assert.equal(result.action, 'timeout');
89
- assert.match(logs.join('\n'), /délai de fermeture/);
90
- } finally {
91
- globalThis.fetch = originalFetch;
92
- }
93
- });
@@ -146,7 +146,8 @@ test('CME setup and source configuration stay outside export orchestration', asy
146
146
  const calledTools = [];
147
147
  globalThis.fetch = async (_url, options = {}) => {
148
148
  const body = JSON.parse(String(options.body ?? '{}'));
149
- calledTools.push(body.params?.name);
149
+ // Record tool calls only — the MCP session handshake is not a tool call.
150
+ if (body.method === 'tools/call') calledTools.push(body.params?.name);
150
151
  return {
151
152
  ok: true,
152
153
  status: 200,
@@ -139,36 +139,3 @@ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = run
139
139
  return null;
140
140
  }
141
141
  }
142
-
143
- // Close the runtime attached to the shell when it is idle, including a process
144
- // reused at startup. Restricting cleanup to `started: true` left ownerless
145
- // runtimes occupying port 7788 with another manager directory/token.
146
- // An active run still survives the shell.
147
- export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
148
- if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
149
- const controller = new AbortController();
150
- const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
151
- try {
152
- const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
153
- if (!health) return { action: 'kept', reason: 'unreachable' };
154
- const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
155
- if (activeRuns.length > 0) {
156
- const labels = activeRuns
157
- .map((run) => [run.workspace, run.runId ? String(run.runId).slice(0, 8) : null].filter(Boolean).join('/'))
158
- .join(', ');
159
- log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
160
- return { action: 'kept', reason: 'run_active', activeRuns };
161
- }
162
- await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
163
- log('runtime arrêté (aucun run en cours).');
164
- return { action: 'shutdown' };
165
- } catch (err) {
166
- if (controller.signal.aborted) {
167
- log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
168
- return { action: 'timeout', reason: 'shutdown_timeout' };
169
- }
170
- return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
171
- } finally {
172
- clearTimeout(timeout);
173
- }
174
- }
@@ -401,7 +401,16 @@ test('replanRuntimeRun requires runtime approval for mutating replanned work', a
401
401
  test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
402
402
  const originalFetch = globalThis.fetch;
403
403
  let pollAttempts = 0;
404
- globalThis.fetch = async () => {
404
+ globalThis.fetch = async (_url, init) => {
405
+ // Count polls, not the one-off MCP session handshake.
406
+ if (JSON.parse(init.body).method === 'initialize') {
407
+ return {
408
+ ok: true,
409
+ status: 200,
410
+ headers: { get: () => null },
411
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
412
+ };
413
+ }
405
414
  pollAttempts += 1;
406
415
  return {
407
416
  ok: true,
@@ -93,7 +93,17 @@ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt m
93
93
  test('pollActivitiesOnce retries transient MCP poll failures', async () => {
94
94
  const originalFetch = globalThis.fetch;
95
95
  let attempts = 0;
96
- globalThis.fetch = async () => {
96
+ globalThis.fetch = async (_url, init) => {
97
+ // The session handshake is not the thing under test: answer it, and make
98
+ // the first *poll* the transient failure.
99
+ if (JSON.parse(init.body).method === 'initialize') {
100
+ return {
101
+ ok: true,
102
+ status: 200,
103
+ headers: { get: () => null },
104
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
105
+ };
106
+ }
97
107
  attempts += 1;
98
108
  if (attempts === 1) {
99
109
  return {
@@ -77,8 +77,6 @@ type RenderedLine = {
77
77
  statusLeft?: string;
78
78
  statusRight?: string;
79
79
  copyContent?: string;
80
- redoContent?: string;
81
- redoIndex?: number;
82
80
  };
83
81
  const STATUS_COLUMN_GAP = 2;
84
82
  type HelpCard = { title: string; text: string; example: string };
@@ -142,14 +140,11 @@ function WelcomeHelpPanels(props: { width: number }) {
142
140
  }
143
141
 
144
142
  const COPY_BTN = ' [ copy ]';
145
- // Only user messages carry it: redo re-asks *this* question, so the action has
146
- // to sit on the message it acts upon.
147
- const REDO_BTN = ' [ redo ]';
148
143
 
149
- function messageHeaderSegments(role: string, columns: number, withRedo = false): Segment[] {
144
+ function messageHeaderSegments(role: string, columns: number): Segment[] {
150
145
  const label = `[${roleLabel(role)}]`;
151
146
  const left = '── ';
152
- const actionsLength = COPY_BTN.length + (withRedo ? REDO_BTN.length : 0);
147
+ const actionsLength = COPY_BTN.length;
153
148
  const rightLength = Math.max(2, columns - left.length - label.length - 1 - actionsLength);
154
149
  return [
155
150
  { text: left, color: '#4B5563' },
@@ -370,6 +365,13 @@ function statusValueColor(value: string) {
370
365
  function statusSegments(value: string): Segment[] {
371
366
  const text = value || ' ';
372
367
  const trimmed = text.trim();
368
+ const linkedValue = text.match(/^(\s*[A-Za-z][A-Za-z0-9 _./-]{0,34}:\s*)\[([^\]]+)\]\((https?:\/\/[^)]+)\)\s*$/);
369
+ if (linkedValue) {
370
+ return [
371
+ { text: linkedValue[1], color: '#8BD5CA' },
372
+ { text: linkedValue[2], color: '#5DADE2', url: linkedValue[3] },
373
+ ];
374
+ }
373
375
  if (trimmed && !text.startsWith(' ') && !trimmed.includes(':') && !/^[●◐○-]/.test(trimmed)) {
374
376
  return [{ text, color: '#FBBF24' }];
375
377
  }
@@ -416,13 +418,11 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
416
418
  const raw = String(message.content || '');
417
419
  const previous = index > 0 ? messages[index - 1] : null;
418
420
  const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
419
- const canRedo = message.role === 'user' && raw.trim().length > 0;
420
421
  const headerLines: RenderedLine[] = showHeader
421
422
  ? [
422
423
  {
423
- segments: messageHeaderSegments(message.role, columns, canRedo),
424
+ segments: messageHeaderSegments(message.role, columns),
424
425
  copyContent: raw,
425
- ...(canRedo ? { redoContent: raw, redoIndex: index } : {}),
426
426
  },
427
427
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
428
428
  ]
@@ -433,6 +433,11 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
433
433
  ...headerLines,
434
434
  ...raw.split('\n').map((line) => {
435
435
  const { left, right } = statusColumns(line || ' ');
436
+ // A row with nothing on either side is vertical padding, not data.
437
+ // Rendering it as a status row drew an empty blue-bordered box under
438
+ // the last real line — the leftover visible at the bottom right of
439
+ // /status. Emit a plain spacer so the boxes stop with the content.
440
+ if (!left.trim() && !right.trim()) return { segments: [{ text: ' ', color: '#D6DEE8' }] };
436
441
  return { status: true, statusLeft: left, statusRight: right, segments: [] };
437
442
  }),
438
443
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
@@ -472,15 +477,18 @@ export function ConversationView(props: {
472
477
  onScroll: (delta: number) => void;
473
478
  spinnerFrame: string;
474
479
  onCopy?: (content: string) => void;
475
- onRedo?: (index: number, content: string) => void;
476
480
  onOpenLink?: (url: string) => void;
477
481
  }) {
478
482
  const renderer = useRenderer();
483
+ const [hoveredUrl, setHoveredUrl] = createSignal<string | null>(null);
479
484
  // Terminals have no CSS cursor, but OpenTUI can ask the emulator to swap the
480
485
  // mouse pointer shape. Without it nothing on screen distinguishes a clickable
481
486
  // link from ordinary blue text, so operators never discovered links were
482
487
  // clickable at all.
483
- const hoverLink = (url: string | null) => renderer?.setMousePointer?.(url ? 'pointer' : 'default');
488
+ const hoverLink = (url: string | null) => {
489
+ setHoveredUrl(url);
490
+ renderer?.setMousePointer?.(url ? 'pointer' : 'default');
491
+ };
484
492
  const allLines = createMemo(() => conversationLines(props.messages, props.columns));
485
493
  const visibleLines = () => {
486
494
  const lines = allLines();
@@ -516,7 +524,9 @@ export function ConversationView(props: {
516
524
  event.stopPropagation?.();
517
525
  }}
518
526
  >
519
- <text height={1} fg="#7F8C8D">{scrollHint()}</text>
527
+ <text height={1} fg={hoveredUrl() ? '#5DADE2' : '#7F8C8D'}>
528
+ {hoveredUrl() ? `↗ ${hoveredUrl()}` : scrollHint()}
529
+ </text>
520
530
  <For each={visibleLines()}>
521
531
  {(line) => (
522
532
  line.copyContent !== undefined ? (
@@ -525,13 +535,6 @@ export function ConversationView(props: {
525
535
  {(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
526
536
  </For>
527
537
  <text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
528
- <Show when={line.redoContent !== undefined}>
529
- <text
530
- fg="#FBBF24"
531
- content={REDO_BTN}
532
- onMouseUp={() => props.onRedo?.(line.redoIndex!, line.redoContent!)}
533
- />
534
- </Show>
535
538
  </box>
536
539
  ) : line.role === 'user' && line.userBubbleWidth ? (
537
540
  <box height={1} flexDirection="row" overflow="hidden">
@@ -562,7 +565,19 @@ export function ConversationView(props: {
562
565
  overflow="hidden"
563
566
  >
564
567
  <For each={statusSegments(line.statusLeft ?? '')}>
565
- {(seg) => <text width={seg.width} fg={seg.color} bg="#111318">{seg.text}</text>}
568
+ {(seg) => (
569
+ <text
570
+ width={seg.width}
571
+ fg={seg.color}
572
+ bg="#111318"
573
+ onMouseOver={() => hoverLink(seg.url ? normalizeExternalUrl(seg.url) : null)}
574
+ onMouseOut={() => hoverLink(null)}
575
+ onMouseUp={() => {
576
+ const url = seg.url ? normalizeExternalUrl(seg.url) : null;
577
+ if (url) props.onOpenLink?.(url);
578
+ }}
579
+ >{seg.text}</text>
580
+ )}
566
581
  </For>
567
582
  </box>
568
583
  {line.statusRight ? (
@@ -578,7 +593,19 @@ export function ConversationView(props: {
578
593
  overflow="hidden"
579
594
  >
580
595
  <For each={statusSegments(line.statusRight)}>
581
- {(seg) => <text width={seg.width} fg={seg.color} bg="#111318">{seg.text}</text>}
596
+ {(seg) => (
597
+ <text
598
+ width={seg.width}
599
+ fg={seg.color}
600
+ bg="#111318"
601
+ onMouseOver={() => hoverLink(seg.url ? normalizeExternalUrl(seg.url) : null)}
602
+ onMouseOut={() => hoverLink(null)}
603
+ onMouseUp={() => {
604
+ const url = seg.url ? normalizeExternalUrl(seg.url) : null;
605
+ if (url) props.onOpenLink?.(url);
606
+ }}
607
+ >{seg.text}</text>
608
+ )}
582
609
  </For>
583
610
  </box>
584
611
  ) : null}
@@ -774,7 +801,6 @@ export function LeftPane(props: {
774
801
  spinnerFrame: string;
775
802
  onInputHeightChange: (height: number) => void;
776
803
  onCopy?: (content: string) => void;
777
- onRedo?: (index: number, content: string) => void;
778
804
  onOpenLink?: (url: string) => void;
779
805
  }) {
780
806
  const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
@@ -805,7 +831,6 @@ export function LeftPane(props: {
805
831
  onScroll={props.scrollConversation}
806
832
  spinnerFrame={props.spinnerFrame}
807
833
  onCopy={props.onCopy}
808
- onRedo={props.onRedo}
809
834
  onOpenLink={props.onOpenLink}
810
835
  />
811
836
  )}
package/src/shell/repl.js CHANGED
@@ -8,7 +8,7 @@ import { stdin as input, stdout as output } from 'node:process';
8
8
  import { marked } from 'marked';
9
9
  import { markedTerminal } from 'marked-terminal';
10
10
  import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
11
- import { handleSlashCommand } from '../commands/slash.js';
11
+ import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
12
12
  import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
13
13
  import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
14
14
  import { syncActivitiesToPlan } from '../core/plan.js';
@@ -74,7 +74,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
74
74
  '/config': 'Inspect or switch .wikirc.yaml profiles.',
75
75
  '/status': 'Show the current workspace and session state.',
76
76
  '/services': 'List workspace Docker Compose services.',
77
- '/start': 'Start one service or the workspace service set.',
77
+ '/start': 'Start everything (all), the agents only, or one service.',
78
78
  '/stop': 'Stop one service or the workspace service set.',
79
79
  '/logs': 'Show recent logs for a service.',
80
80
  '/mcp': 'Inspect or call workspace MCP servers.',
@@ -301,7 +301,8 @@ function completionValuesFor(parts, inputBuffer, session) {
301
301
  if (command === '/wiki' && tokenIndex === 1) return ['run'];
302
302
  if (command === '/skills' && tokenIndex === 1) return ['edit', 'list', 'run', 'show'];
303
303
  if (command === '/skills' && ['edit', 'run', 'show'].includes(previousToken ?? '')) return skillNames(session);
304
- if ((command === '/start' || command === '/stop') && tokenIndex === 1) return ['agents', ...serviceNames()];
304
+ if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceNames()];
305
+ if (command === '/stop' && tokenIndex === 1) return ['agents', ...serviceNames()];
305
306
  if (command === '/logs' && tokenIndex === 1) return serviceNames();
306
307
  return [];
307
308
  }
@@ -434,6 +435,7 @@ function buildDirectChatSystemPrompt(session, rawOpenWikiPages) {
434
435
  return [
435
436
  'You are Donna, the llm-wiki-manager chat assistant: warm, plain-spoken, and helpful — like an attentive colleague, never a raw status dump.',
436
437
  'You have a small READ-ONLY toolset — the tools provided to you for this turn, which may be none. Use them to answer questions about live state (e.g. "le CME est-il configuré", "quelles pages sont en attente"), and answer only from their results.',
438
+ 'Questions about Donna, wikiLLM, the manager, its interfaces, commands, configuration, agents, concurrency, or troubleshooting are product-help questions, not action requests. When PRODUCT HELP REFERENCE content is attached, answer directly from it in chat mode; never redirect such a question to /agent.',
437
439
  'When the conversation already contains attached document content (delimited by BEGIN/END ATTACHED DOCUMENT markers), read and summarize or answer from that content directly — you do NOT need a tool for it, and must not claim you cannot read the document.',
438
440
  'If no provided tool covers the request and no attached content answers it — or the request is an action or mutation (ingest, build, export, configure, send, delete…), or needs a service that is not connected — say plainly you cannot do it in chat mode and to switch to agent mode (/agent). Do not pretend to execute it and never guess.',
439
441
  'Answer directly and concisely. Do not claim to have called tools or changed files beyond the tools actually provided.',
@@ -449,6 +451,50 @@ function buildDirectChatSystemPrompt(session, rawOpenWikiPages) {
449
451
  ].join('\n');
450
452
  }
451
453
 
454
+ export function isProductHelpQuestion(input) {
455
+ const text = String(input ?? '')
456
+ .normalize('NFKD')
457
+ .replace(/\p{Diacritic}/gu, '')
458
+ .toLowerCase();
459
+ if (!text.trim()) return false;
460
+ if (/\b(donna|wikillm|llm-wiki|wiki-manager)\b/.test(text)) return true;
461
+ if (/\/(status|help|chat|agent|start|services|mcp|run|approve|queue)\b/.test(text)) return true;
462
+ if (/\b(manager ceiling|parallelism|throughput|collection concurrency|scheduler workers?)\b/.test(text)) return true;
463
+ const productConcept = /\b(workspaces?|agents?|connecteurs?|connectors?|mcp|runtime|approbations?|approvals?|ingestion|deliverables?|parallelisme|concurrence)\b/.test(text);
464
+ const explanatoryQuestion = /\b(comment|pourquoi|a quoi|qu est ce|que signifie|explique|fonctionne|difference|combien)\b/.test(text);
465
+ return productConcept && explanatoryQuestion;
466
+ }
467
+
468
+ async function productHelpContextMessages(input, session, onStep) {
469
+ if (!isProductHelpQuestion(input)) return [];
470
+ const provider = Object.entries(session?.mcp ?? {}).find(([, entry]) =>
471
+ entry?.status === 'connected'
472
+ && (entry.tools ?? []).some((tool) => String(tool?.name ?? '') === 'help_search'));
473
+ if (!provider) return [];
474
+ const [server] = provider;
475
+ try {
476
+ onStep?.('Chat: consulting product documentation…');
477
+ const result = await callMcpTool(
478
+ session.mcp,
479
+ server,
480
+ 'help_search',
481
+ { query: input },
482
+ session._abortSignal,
483
+ );
484
+ const content = formatMcpToolResult(result).trim();
485
+ if (!content) return [];
486
+ return [{
487
+ role: 'user',
488
+ content:
489
+ 'PRODUCT HELP REFERENCE follows. It is read-only product documentation. '
490
+ + 'Use it to answer the question directly and do not redirect me to agent mode:\n\n'
491
+ + `--- BEGIN PRODUCT HELP REFERENCE ---\n${content}\n--- END PRODUCT HELP REFERENCE ---`,
492
+ }];
493
+ } catch {
494
+ return [];
495
+ }
496
+ }
497
+
452
498
  function commonPrefix(values) {
453
499
  if (values.length === 0) return '';
454
500
  let prefix = values[0];
@@ -486,7 +532,12 @@ export function completionDescription(value, parts) {
486
532
  const subcommand = SUBCOMMAND_COMPLETION_DESCRIPTIONS[`${command}:${value}`];
487
533
  if (subcommand) return subcommand;
488
534
  if (command === '/use') return 'Load this workspace.';
489
- if (command === '/start') return serviceDescription(value) ?? 'Start this Docker Compose service.';
535
+ if (command === '/start') {
536
+ if (value === 'all') return 'Start the workspace services AND the external agents.';
537
+ if (value === 'agents' || value === 'agent') return 'Start the external agents only.';
538
+ if (value === 'services') return 'Start the workspace services only.';
539
+ return serviceDescription(value) ?? 'Start this Docker Compose service.';
540
+ }
490
541
  if (command === '/stop') return serviceDescription(value) ?? 'Stop this Docker Compose service.';
491
542
  if (command === '/logs') return serviceDescription(value) ?? 'Show logs for this Docker Compose service.';
492
543
  if (command === '/workspace') {
@@ -925,20 +976,24 @@ function rememberProductionActivity(session, payload) {
925
976
  export function applyRuntimeStateToShellSession(session, state) {
926
977
  if (!state || typeof state !== 'object') return false;
927
978
  const displayState = sanitizeRuntimeStateForDisplay(state);
979
+ const terminalStateDismissed = session._dismissedTerminalRunId
980
+ && state.runId === session._dismissedTerminalRunId
981
+ && ['done', 'error', 'failed', 'cancelled'].includes(String(displayState.status ?? '').toLowerCase());
982
+ session._lastRuntimeRunId = state.runId ?? session._lastRuntimeRunId ?? null;
928
983
  session.agentProjection = {
929
984
  conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
930
985
  chain: Array.isArray(displayState.chain) ? displayState.chain.map((step) => ({ ...step })) : [],
931
- plan: Array.isArray(displayState.plan) && displayState.plan.length > 0
986
+ plan: !terminalStateDismissed && Array.isArray(displayState.plan) && displayState.plan.length > 0
932
987
  ? displayState.plan.map((step) => ({ ...step }))
933
988
  : null,
934
- activities: Array.isArray(displayState.activities) ? displayState.activities.map((activity) => ({ ...activity })) : [],
935
- logs: Array.isArray(displayState.logs) ? [...displayState.logs] : [],
936
- summary: displayState.summary ?? null,
989
+ activities: !terminalStateDismissed && Array.isArray(displayState.activities) ? displayState.activities.map((activity) => ({ ...activity })) : [],
990
+ logs: !terminalStateDismissed && Array.isArray(displayState.logs) ? [...displayState.logs] : [],
991
+ summary: terminalStateDismissed ? null : displayState.summary ?? null,
937
992
  status: displayState.status ?? 'idle',
938
993
  planRevision: displayState.planRevision ?? 0,
939
- planPatches: Array.isArray(displayState.planPatches) ? displayState.planPatches.map((patch) => ({ ...patch })) : [],
994
+ planPatches: !terminalStateDismissed && Array.isArray(displayState.planPatches) ? displayState.planPatches.map((patch) => ({ ...patch })) : [],
940
995
  };
941
- session.workflow = displayState.workflow && typeof displayState.workflow === 'object'
996
+ session.workflow = !terminalStateDismissed && displayState.workflow && typeof displayState.workflow === 'object'
942
997
  ? {
943
998
  ...state.workflow,
944
999
  nodes: Array.isArray(state.workflow.nodes) ? state.workflow.nodes.map((node) => ({ ...node })) : [],
@@ -1206,11 +1261,26 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
1206
1261
  output.write(buf);
1207
1262
  }
1208
1263
 
1209
- async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayInput = input }) {
1264
+ async function runAgentTurn(input, {
1265
+ agent,
1266
+ session,
1267
+ onUpdate,
1268
+ onStep,
1269
+ displayInput = input,
1270
+ synthesisOnly = false,
1271
+ }) {
1210
1272
  const messages = conversationMessages(session);
1211
1273
  const history = toConversationHistory(messages);
1212
1274
  session._onStep = onStep ?? null;
1213
1275
  session.packageJson = session.packageJson ?? {};
1276
+ if (
1277
+ session._lastRuntimeRunId
1278
+ && ['done', 'error', 'failed', 'cancelled'].includes(
1279
+ String(session.agentProjection?.status ?? '').toLowerCase(),
1280
+ )
1281
+ ) {
1282
+ session._dismissedTerminalRunId = session._lastRuntimeRunId;
1283
+ }
1214
1284
  dispatchAgentEvent(session, createAgentEvent('run_started', {
1215
1285
  origin: 'user',
1216
1286
  payload: { input: displayInput },
@@ -1252,6 +1322,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
1252
1322
 
1253
1323
  let agentResult;
1254
1324
  try {
1325
+ if (synthesisOnly) session._responseSynthesisOnly = true;
1255
1326
  agentResult = await agent.invoke({ input, session, messages: history });
1256
1327
  } catch (err) {
1257
1328
  if (err.name === 'AbortError') {
@@ -1272,6 +1343,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
1272
1343
  delete session._onStep;
1273
1344
  delete session._onStream;
1274
1345
  delete session._onStreamReset;
1346
+ delete session._responseSynthesisOnly;
1275
1347
  }
1276
1348
 
1277
1349
  if (agentResult.streamedInline) {
@@ -1403,15 +1475,25 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1403
1475
  messages.push(donnaMessage);
1404
1476
  onUpdate?.();
1405
1477
  const allowedTools = chatAllowedTools(session);
1478
+ const productHelpMessages = await productHelpContextMessages(input, session, onStep);
1406
1479
  const canUseTools = allowedTools.length > 0 && typeof session.llm.completeWithTools === 'function';
1407
1480
  try {
1408
1481
  if (canUseTools) {
1409
- await runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools });
1482
+ await runChatToolLoop({
1483
+ input,
1484
+ session,
1485
+ history,
1486
+ donnaMessage,
1487
+ onUpdate,
1488
+ onStep,
1489
+ allowedTools,
1490
+ contextMessages: productHelpMessages,
1491
+ });
1410
1492
  } else {
1411
1493
  onStep?.('Chat: streaming direct answer…');
1412
1494
  for await (const delta of session.llm.stream({
1413
1495
  system: buildDirectChatSystemPrompt(session),
1414
- messages: [...history, { role: 'user', content: input }],
1496
+ messages: [...history, ...productHelpMessages, { role: 'user', content: input }],
1415
1497
  signal: session._abortSignal,
1416
1498
  })) {
1417
1499
  const cleanDelta = stripDsmlArtifacts(delta);
@@ -1454,7 +1536,10 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
1454
1536
  // a read tool is offered or the model chooses to call it. Multiple documents
1455
1537
  // (up to five) are each folded in as their own delimited block.
1456
1538
  const attachedDocs = await readSelectedPageDocuments(session, selectedPages);
1457
- const contextMessages = buildAttachedDocMessages(attachedDocs);
1539
+ const contextMessages = [
1540
+ ...buildAttachedDocMessages(attachedDocs),
1541
+ ...await productHelpContextMessages(input, session, onStep),
1542
+ ];
1458
1543
  const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
1459
1544
  if (canUseTools) {
1460
1545
  await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
@@ -1532,7 +1617,11 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1532
1617
  // and only for slash commands — free text got a single row.
1533
1618
  const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
1534
1619
  const messages = conversationMessages(session);
1535
- const handoffRawToAgent = Boolean(result.rawOutput && result.agentTrigger && agent);
1620
+ const deterministicDisplayOnly = /^\/status(?:\s|$)/.test(trimmed);
1621
+ const commandAgentTrigger = result.output && agent && !deterministicDisplayOnly
1622
+ ? (result.agentTrigger ?? rawCommandAgentPrompt(trimmed, result.output))
1623
+ : null;
1624
+ const handoffToAgent = Boolean(commandAgentTrigger);
1536
1625
  if (result.output) {
1537
1626
  const parts = trimmed.split(/\s+/);
1538
1627
  if (parts[0] === '/mcp' && parts[1] === 'call' && parts[2]) {
@@ -1548,15 +1637,19 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1548
1637
  }
1549
1638
  }
1550
1639
  }
1551
- if (result.output && !handoffRawToAgent) {
1640
+ if (result.output && !handoffToAgent) {
1552
1641
  messages.push({ role: 'command', content: result.output });
1553
1642
  onUpdate?.();
1554
1643
  }
1555
- if (handoffRawToAgent) {
1556
- const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep, displayInput: trimmed });
1557
- if (agentResult.aborted) return { exit: false, aborted: true };
1558
- } else if (result.agentTrigger && agent) {
1559
- const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep });
1644
+ if (handoffToAgent) {
1645
+ const agentResult = await runAgentTurn(commandAgentTrigger, {
1646
+ agent,
1647
+ session,
1648
+ onUpdate,
1649
+ onStep,
1650
+ displayInput: trimmed,
1651
+ synthesisOnly: true,
1652
+ });
1560
1653
  if (agentResult.aborted) return { exit: false, aborted: true };
1561
1654
  }
1562
1655
  return { exit: Boolean(result.exit), setMode: result.setMode };