@dotdrelle/wiki-manager 0.12.1 → 0.12.8

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/README.md +49 -8
  2. package/bin/wiki-manager +23 -12
  3. package/docker-compose.yml +0 -1
  4. package/mcp.endpoints.example.json +0 -6
  5. package/package.json +3 -2
  6. package/src/activity/activityAggregator.js +34 -3
  7. package/src/activity/activityAggregator.test.js +32 -0
  8. package/src/agent/graph.js +272 -22
  9. package/src/agent/graph.test.js +357 -1
  10. package/src/cli/wiki-manager.js +33 -2
  11. package/src/commands/slash.js +74 -1
  12. package/src/commands/slash.test.js +36 -0
  13. package/src/core/activity.js +4 -0
  14. package/src/core/activity.test.js +9 -1
  15. package/src/core/agentEvents.js +30 -0
  16. package/src/core/agentEvents.test.js +42 -1
  17. package/src/core/buildInfo.js +58 -0
  18. package/src/core/buildInfo.json +4 -0
  19. package/src/core/buildInfo.test.js +14 -0
  20. package/src/core/env.js +30 -1
  21. package/src/core/mcp.js +21 -1
  22. package/src/core/mcp.test.js +25 -0
  23. package/src/core/runtimeLog.js +6 -1
  24. package/src/core/runtimeLog.test.js +3 -1
  25. package/src/runtime/client.js +22 -0
  26. package/src/runtime/controlMessages.js +2 -2
  27. package/src/runtime/recoveryManager.js +54 -0
  28. package/src/runtime/recoveryManager.test.js +73 -0
  29. package/src/runtime/runner.js +49 -13
  30. package/src/runtime/runner.test.js +196 -0
  31. package/src/runtime/server.js +67 -8
  32. package/src/runtime/server.test.js +223 -6
  33. package/src/runtime/store.js +48 -3
  34. package/src/runtime/store.test.js +32 -0
  35. package/src/runtime/supervisor.js +77 -1
  36. package/src/shell/RightPane.tsx +124 -31
  37. package/src/shell/SetupWizard.tsx +13 -1
  38. package/src/shell/repl.js +78 -9
  39. package/src/shell/repl.test.js +114 -1
  40. package/src/shell/tui.tsx +4 -1
  41. package/src/shell/useAgent.ts +32 -3
  42. package/src/shell/useSession.ts +14 -1
  43. package/wiki-workspace +33 -0
@@ -16,7 +16,9 @@ type QueueItem = {
16
16
  type QueueInfo = { active: number; current: number; frozen: number };
17
17
  type LogLineParts = { time: string | null; message: string };
18
18
 
19
- const ACTIVITY_SLOTS = Array.from({ length: 6 }, (_, index) => index);
19
+ // 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
20
+ // wrapped status/error), so fewer, readable entries beat more, truncated ones.
21
+ const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
20
22
  const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
21
23
 
22
24
  function wrapLine(value: string, width: number) {
@@ -70,7 +72,18 @@ function queueColor(status: string) {
70
72
  function activityLine(activity: any) {
71
73
  const detail = activity?.progress?.detail ?? null;
72
74
  const phase = activity?.progress?.step ?? activity?.progress?.phase ?? activity?.progress?.currentStep ?? null;
73
- return [activity?.status ?? 'unknown', detail ?? phase].filter(Boolean).join(' · ');
75
+ // On failure the error message is the information that matters — surface
76
+ // it instead of repeating the bare status the label color already conveys.
77
+ const error = activityErrorText(activity);
78
+ return [activity?.status ?? 'unknown', error ?? detail ?? phase].filter(Boolean).join(' · ');
79
+ }
80
+
81
+ function activityErrorText(activity: any): string | null {
82
+ const status = String(activity?.status ?? '').toLowerCase();
83
+ if (!['failed', 'error', 'cancelled', 'canceled'].includes(status)) return null;
84
+ const error = activity?.error ?? activity?.result?.error ?? activity?.progress?.detail ?? null;
85
+ const text = error == null ? '' : String(error).replace(/\s+/g, ' ').trim();
86
+ return text || null;
74
87
  }
75
88
 
76
89
  function activityPercentBadge(activity: any): { text: string; bg: string; fg: string } | null {
@@ -127,13 +140,19 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
127
140
  <box flexShrink={0} flexDirection="column" padding={1}>
128
141
  <text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
129
142
  <Index each={props.plan}>
130
- {(step) => (
131
- <text
132
- width={lineWidth()}
133
- fg={planStepColor(step(), firstPending())}
134
- content={fit(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth())}
135
- />
136
- )}
143
+ {(step) => {
144
+ // Wrap step descriptions over up to 2 lines instead of truncating —
145
+ // "Ingest des 39 documents raw/untrac…" hid the actual target.
146
+ const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
147
+ return (
148
+ <box flexDirection="column">
149
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
150
+ <Show when={lines()[1]}>
151
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
152
+ </Show>
153
+ </box>
154
+ );
155
+ }}
137
156
  </Index>
138
157
  </box>
139
158
  );
@@ -141,7 +160,7 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
141
160
 
142
161
  export function ActivityPanel(props: { activities: any[]; width: number }) {
143
162
  const lineWidth = () => Math.max(8, props.width - 2);
144
- const visible = () => props.activities.slice(-6).reverse();
163
+ const visible = () => props.activities.slice(-ACTIVITY_SLOTS.length).reverse();
145
164
  const activityAt = (index: number) => visible()[index] ?? null;
146
165
  return (
147
166
  <box flexShrink={0} flexDirection="column" padding={1}>
@@ -150,21 +169,39 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
150
169
  <Index each={ACTIVITY_SLOTS}>
151
170
  {(slot) => {
152
171
  const activity = () => activityAt(slot());
153
- const label = () => {
172
+ // Wrap instead of hard-truncating: a 40-column pane cut labels to
173
+ // "Appliquer la config recommandée (doct…" and hid the one thing
174
+ // that mattered. Labels get up to 2 lines, the status/error line
175
+ // up to 2 lines; empty continuation lines are not rendered.
176
+ const labelLines = () => {
154
177
  const item = activity();
155
- if (!item) return '';
156
- return fit(item.progress?.label ?? item.label ?? `${item.source ?? 'mcp'} ${item.kind ?? 'job'}`, lineWidth());
178
+ if (!item) return [''];
179
+ const label = String(item.progress?.label ?? item.label ?? `${item.source ?? 'mcp'} ${item.kind ?? 'job'}`);
180
+ const lines = wrapLine(label, lineWidth()).slice(0, 2);
181
+ if (lines.length === 2 && String(label).length > lines[0].length + lines[1].length) {
182
+ lines[1] = fit(lines[1], lineWidth());
183
+ }
184
+ return lines;
157
185
  };
158
186
  const badge = () => activityPercentBadge(activity());
159
187
  const badgeLen = () => badge()?.text.length ?? 0;
188
+ const statusLines = () => {
189
+ const item = activity();
190
+ if (!item) return [''];
191
+ return wrapLine(activityLine(item), Math.max(8, lineWidth() - badgeLen())).slice(0, 2);
192
+ };
193
+ const statusColor = () => (activityErrorText(activity()) ? '#F38BA8' : '#AAB7C4');
160
194
  return (
161
195
  <box flexDirection="column" marginTop={slot() === 0 ? 1 : 0}>
162
- <text width={lineWidth()} fg={activityColor(activity()?.status)} content={label()} />
196
+ <text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[0]} />
197
+ <Show when={labelLines()[1]}>
198
+ <text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[1]} />
199
+ </Show>
163
200
  <box height={1} flexDirection="row">
164
201
  <text
165
202
  width={lineWidth() - badgeLen()}
166
- fg="#AAB7C4"
167
- content={activity() ? fit(activityLine(activity()), lineWidth() - badgeLen()) : ''}
203
+ fg={statusColor()}
204
+ content={activity() ? fit(statusLines()[0], lineWidth() - badgeLen()) : ''}
168
205
  />
169
206
  <text
170
207
  width={badgeLen()}
@@ -173,6 +210,9 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
173
210
  content={badge()?.text ?? ''}
174
211
  />
175
212
  </box>
213
+ <Show when={statusLines()[1]}>
214
+ <text width={lineWidth()} fg={statusColor()} content={fit(statusLines()[1], lineWidth())} />
215
+ </Show>
176
216
  <text width={lineWidth()} fg="#7F8C8D" content={activity() ? fit(updatedLine(activity()), lineWidth()) : ''} />
177
217
  </box>
178
218
  );
@@ -183,31 +223,84 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
183
223
  );
184
224
  }
185
225
 
226
+ type LogSegment = { text: string; fg: string };
227
+
228
+ // Per-line coloring: 'runtime' source tag in violet, HH:MM:SS in blue,
229
+ // message tinted by nature (errors red, warnings amber, activity teal).
230
+ // Continuation lines of a wrapped entry are indented and dimmed so each
231
+ // entry reads as one visual block instead of an undifferentiated wall.
232
+ function logMessageColor(message: string): string {
233
+ if (/\b(error|failed|exception|unavailable|introuvable|HTTP 4\d\d|HTTP 5\d\d)\b/i.test(message)) return '#F38BA8';
234
+ if (/\b(warn|warning|avertissement|fallback|retry|expired|stale)\b/i.test(message)) return '#FBBF24';
235
+ if (/^(activity|job)\b/i.test(message)) return '#8BD5CA';
236
+ return '#AAB7C4';
237
+ }
238
+
239
+ function logRenderLines(logs: string[], width: number): LogSegment[][] {
240
+ // Newest entry FIRST (descending): the freshest information belongs at the
241
+ // top of the pane. Wrapped continuation lines stay attached below their
242
+ // entry's first line.
243
+ const blocks: LogSegment[][][] = [];
244
+ for (const raw of logs) {
245
+ blocks.push(logEntryLines(raw, width));
246
+ }
247
+ return blocks.reverse().flat();
248
+ }
249
+
250
+ function logEntryLines(raw: string, width: number): LogSegment[][] {
251
+ const out: LogSegment[][] = [];
252
+ for (const item of [raw]) {
253
+ const rawLine = item;
254
+ const sourceMatch = String(rawLine).match(/^(runtime)\s+(.*)$/);
255
+ const source = sourceMatch ? sourceMatch[1] : null;
256
+ const rest = sourceMatch ? sourceMatch[2] : String(rawLine);
257
+ const parts = logLineParts(rest);
258
+ const prefix: LogSegment[] = [];
259
+ if (source) prefix.push({ text: `${source} `, fg: '#C6A0F6' });
260
+ if (parts.time) prefix.push({ text: `${parts.time} `, fg: '#89B4FA' });
261
+ const prefixLength = prefix.reduce((total, segment) => total + segment.text.length, 0);
262
+ const messageColor = logMessageColor(parts.message);
263
+ const wrapped = wrapLine(parts.message, Math.max(8, width - prefixLength));
264
+ wrapped.forEach((text, index) => {
265
+ if (index === 0) {
266
+ out.push([...prefix, { text, fg: messageColor }]);
267
+ } else {
268
+ const indent = ' '.repeat(Math.min(prefixLength, 4));
269
+ out.push([{ text: `${indent}${text}`, fg: messageColor === '#F38BA8' ? messageColor : '#7F8C8D' }]);
270
+ }
271
+ });
272
+ }
273
+ return out;
274
+ }
275
+
186
276
  export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
187
277
  const lineWidth = () => Math.max(8, props.width - 2);
188
278
  const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '');
189
- const visibleLines = () => filteredLogs().flatMap((line) => wrapLine(line, lineWidth())).slice(-24);
190
- const logLineAt = (index: number) => visibleLines()[index] ?? '';
279
+ const visibleLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()).slice(0, 24));
280
+ const segmentsAt = (index: number): LogSegment[] => visibleLines()[index] ?? [];
191
281
  return (
192
282
  <box flexGrow={1} flexDirection="column" padding={1}>
193
283
  <text width={lineWidth()} fg="#D6DEE8" content="Logs / Trace" />
194
284
  <box flexGrow={1} flexDirection="column" overflow="hidden">
195
285
  <Index each={LOG_SLOTS}>
196
286
  {(slot) => {
197
- const line = () => logLineAt(slot());
198
- const parts = createMemo(() => logLineParts(line()));
199
- const timeWidth = () => parts().time ? Math.min(parts().time!.length + 1, lineWidth()) : 0;
200
- const messageWidth = () => Math.max(1, lineWidth() - timeWidth());
287
+ const segments = () => segmentsAt(slot());
288
+ const segmentAt = (position: number): LogSegment => segments()[position] ?? { text: '', fg: '#AAB7C4' };
289
+ const usedWidth = (upTo: number) => segments().slice(0, upTo).reduce((total, segment) => total + segment.text.length, 0);
290
+ const widthFor = (position: number) => {
291
+ const segment = segmentAt(position);
292
+ if (!segment.text) return 0;
293
+ // Last visible segment absorbs the remaining width.
294
+ return position === segments().length - 1
295
+ ? Math.max(1, lineWidth() - usedWidth(position))
296
+ : Math.min(segment.text.length, lineWidth() - usedWidth(position));
297
+ };
201
298
  return (
202
- <Show
203
- when={parts().time}
204
- fallback={<text width={lineWidth()} fg="#AAB7C4" content={line()} />}
205
- >
206
- <box height={1} flexDirection="row" overflow="hidden">
207
- <text width={timeWidth()} fg="#89B4FA" content={`${parts().time} `} />
208
- <text width={messageWidth()} fg="#AAB7C4" content={parts().message} />
209
- </box>
210
- </Show>
299
+ <box height={1} flexDirection="row" overflow="hidden">
300
+ <text width={widthFor(0)} fg={segmentAt(0).fg} content={segmentAt(0).text} />
301
+ <text width={widthFor(1)} fg={segmentAt(1).fg} content={segmentAt(1).text} />
302
+ <text width={widthFor(2)} fg={segmentAt(2).fg} content={segmentAt(2).text} />
303
+ </box>
211
304
  );
212
305
  }}
213
306
  </Index>
@@ -1,7 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
- import { useKeyboard } from '@opentui/solid';
4
+ import { useKeyboard, usePaste } from '@opentui/solid';
5
5
  import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
6
6
  import { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
7
7
  import {
@@ -606,6 +606,18 @@ export function SetupWizard(props: {
606
606
  } catch { return ''; }
607
607
  }
608
608
 
609
+ // Terminal paste (Cmd+V/middle-click) arrives as a bracketed-paste block.
610
+ // openTUI's key parser swallows the \x1b[200~/\x1b[201~ markers and emits a
611
+ // dedicated `paste` event instead of key events — the text NEVER reaches
612
+ // useKeyboard, so the legacy bracketed-paste branch below can't fire.
613
+ usePaste((event: any) => {
614
+ if (busy() || step().kind !== 'text') return;
615
+ const raw = event?.text
616
+ ?? (event?.bytes != null ? Buffer.from(event.bytes).toString('utf8') : '');
617
+ const pasted = String(raw).replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n').join(' ');
618
+ if (pasted) setInput((value) => value + pasted);
619
+ });
620
+
609
621
  useKeyboard((key: any) => {
610
622
  if (busy()) return;
611
623
  const s = step();
package/src/shell/repl.js CHANGED
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import { stdin as input, stdout as output } from 'node:process';
6
6
  import { marked } from 'marked';
7
7
  import { markedTerminal } from 'marked-terminal';
8
- import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
8
+ import { buildAgentSystemPrompt, classifyAgentInput, formatLlmUnavailableMessage } from '../agent/graph.js';
9
9
  import { handleSlashCommand } from '../commands/slash.js';
10
10
  import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
11
11
  import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
@@ -16,6 +16,7 @@ import { listSkills } from '../core/skills.js';
16
16
  import { listWikircProfiles } from '../core/wikirc.js';
17
17
  import { listWorkspaces } from '../core/workspaces.js';
18
18
  import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, postRuntimeShutdown, streamRuntimeEvents } from '../runtime/client.js';
19
+ import { versionWithBuild } from '../core/buildInfo.js';
19
20
 
20
21
  marked.use(markedTerminal());
21
22
  // marked-terminal's text renderer extracts token.text (raw string) instead of
@@ -74,6 +75,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
74
75
  '/chat': 'Switch free text to direct LLM chat without tools.',
75
76
  '/agent': 'Switch free text to the LangGraph agent with tools.',
76
77
  '/openui': 'Open the workspace web UI in the browser.',
78
+ '/run': 'Inspect, cancel, or kill runtime runs.',
77
79
  '/approve': 'Approve a pending runtime run or tool.',
78
80
  };
79
81
 
@@ -89,6 +91,9 @@ const SUBCOMMAND_COMPLETION_DESCRIPTIONS = {
89
91
  '/upload:convert': 'Convert one stored upload or all pending uploads.',
90
92
  '/uploads:clean': 'Clean old stored document uploads.',
91
93
  '/uploads:list': 'List uploaded documents.',
94
+ '/run:status': 'Show runtime run status.',
95
+ '/run:cancel': 'Cancel the active runtime run.',
96
+ '/run:kill': 'Hard-kill runtime run(s).',
92
97
  '/queue': 'Inspect or cancel queued MCP jobs.',
93
98
  '/queue:cancel': 'Cancel a queued or running queue item.',
94
99
  '/queue:clear': 'Clear finished queue items.',
@@ -136,7 +141,7 @@ export function createSession() {
136
141
  wikircConfig: null,
137
142
  language: null,
138
143
  mcp: null,
139
- commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'queue', 'approve'],
144
+ commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'run', 'queue', 'approve'],
140
145
  chatMode: true,
141
146
  llm: null,
142
147
  activities: {},
@@ -241,6 +246,7 @@ function completionValuesFor(parts, inputBuffer, session) {
241
246
  if (command === '/upload' && parts[1] === 'convert' && tokenIndex === 2) return ['pending'];
242
247
  if (command === '/uploads' && tokenIndex === 1) return ['clean', 'list'];
243
248
  if (command === '/uploads' && previousToken === 'clean') return ['--older-than'];
249
+ if (command === '/run' && tokenIndex === 1) return ['status', 'cancel', 'kill'];
244
250
  if (command === '/queue' && tokenIndex === 1) return ['cancel', 'clear'];
245
251
  if (command === '/queue' && previousToken === 'cancel') {
246
252
  return (session.jobQueue ?? [])
@@ -786,7 +792,10 @@ export function applyRuntimeStateToShellSession(session, state) {
786
792
  session.activities = Object.fromEntries(
787
793
  session.agentProjection.activities.map((activity) => [activity.key, { ...activity }]),
788
794
  );
789
- if (Array.isArray(state.queue)) session.jobQueue = state.queue.map((item) => ({ ...item }));
795
+ // Runtime queue items replace the local jobQueue wholesale on every sync.
796
+ // Tag their origin so /queue cancel can refuse to fake-cancel them locally
797
+ // (a local status flip would be silently reverted by the next SSE sync).
798
+ if (Array.isArray(state.queue)) session.jobQueue = state.queue.map((item) => ({ ...item, origin: 'runtime' }));
790
799
  const production = session.agentProjection.activities.filter((activity) => activity.source === 'production').at(-1);
791
800
  if (production) {
792
801
  session.productionActivity = {
@@ -803,14 +812,54 @@ export function applyRuntimeStateToShellSession(session, state) {
803
812
  // Submits a prompt to the shared runtime. If the workspace is already busy
804
813
  // (HTTP 409 from POST /run), route the input through the runtime control lane
805
814
  // so status questions and plan-change proposals do not become future runs.
815
+ // A plain question or small talk must never start a runtime run (nor be
816
+ // enqueued as a future one): it only needs an answer. Route converse/observe
817
+ // to the local agent EVEN during an active run — the chat is supposed to stay
818
+ // available, and the graph already restricts tools to read-only in that case.
819
+ // Actions/cancels/approvals still go to the runtime.
820
+ export function shouldHandleFreeTextLocally(line, session, { llmAvailable = Boolean(session?.llm) } = {}) {
821
+ const classification = classifyAgentInput(line, session);
822
+ // cancel intents included: Donna interprets "supprime le job et la queue"
823
+ // and calls runtime__kill / runtime__cancel herself — no hardcoded regex
824
+ // deciding between soft and hard stop. The control lane remains the
825
+ // deterministic fallback when the local LLM is down.
826
+ if (!['converse', 'observe', 'cancel', 'approve', 'enqueue_run', 'ambiguous'].includes(classification.kind)) return { local: false, classification };
827
+ if (!llmAvailable) return { local: false, classification, fallbackReason: 'local LLM unavailable' };
828
+ return { local: true, classification };
829
+ }
830
+
806
831
  export async function submitRuntimeRun(line, { runtime, session }) {
807
832
  const workspace = session.workspace ?? null;
808
833
  try {
834
+ if (runtimeRunActive(session)) {
835
+ // POST /run during an active run blindly ENQUEUES a future run server-
836
+ // side (202 queued) — the 409→control fallback below never fires, so
837
+ // "stop le job" / "annule" ended up in the queue instead of being
838
+ // classified. Send an explicit control message: the server classifies
839
+ // it (cancel aborts the run, approve grants, modify proposes a patch).
840
+ const result = await postRuntimeControl('message', { url: runtime.url, workspace, input: line });
841
+ // The client-side "run active" flag can be STALE (projection still says
842
+ // running right after a run ended). The server's answer carries the
843
+ // truth: if the runtime is actually idle and only produced a canned
844
+ // read-only reply ("Runtime is idle."), the user's input was meant to
845
+ // START a run — fall through to POST /run instead of dead-ending.
846
+ const idleCannedReply = result?.running === false
847
+ && ['converse', 'observe', 'ambiguous'].includes(String(result?.kind ?? ''));
848
+ if (!idleCannedReply) {
849
+ return { kind: result?.kind ?? 'control', result };
850
+ }
851
+ }
809
852
  const result = await postRuntimeRun(line, { url: runtime.url, workspace });
810
853
  if (result?.queued || result?.kind === 'enqueue_run' || result?.kind === 'enqueue') {
811
854
  return { kind: 'queued', result };
812
855
  }
813
- return { kind: 'accepted' };
856
+ if (result?.kind && !result?.runId) {
857
+ // The server was actually running (stale idle flag client-side) and
858
+ // classified our input as a control message — surface its kind and
859
+ // localized explanation instead of pretending a run was accepted.
860
+ return { kind: result.kind, result };
861
+ }
862
+ return { kind: 'accepted', result };
814
863
  } catch (err) {
815
864
  if (err?.status !== 409) {
816
865
  return { kind: 'error', message: err instanceof Error ? err.message : String(err) };
@@ -876,7 +925,7 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
876
925
  const prompt = promptFor(session);
877
926
  const title = '';
878
927
  const context = [
879
- `wiki-manager ${packageJson.version}`,
928
+ `wiki-manager ${versionWithBuild(packageJson)}`,
880
929
  session.workspace ? session.workspace : 'no workspace',
881
930
  session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
882
931
  session.language ? session.language : 'no language',
@@ -1164,7 +1213,7 @@ function directChatUnavailableText(session) {
1164
1213
  return 'Direct chat unavailable: no streaming LLM configured.';
1165
1214
  }
1166
1215
 
1167
- export async function runLine(line, { agent, packageJson, session, onUpdate, onStep, chatMode = session.chatMode ?? true }) {
1216
+ export async function runLine(line, { agent, packageJson, session, onUpdate, onStep, chatMode = session.chatMode ?? true, runtime = null }) {
1168
1217
  const trimmed = stripHtml(line).trim();
1169
1218
  if (!trimmed) return { exit: false };
1170
1219
 
@@ -1175,7 +1224,7 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1175
1224
 
1176
1225
  if (trimmed.startsWith('/')) {
1177
1226
  onStep?.(`Shell: ${trimmed}`);
1178
- const result = await handleSlashCommand(trimmed, { packageJson, session, onStep });
1227
+ const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
1179
1228
  const messages = conversationMessages(session);
1180
1229
  const handoffRawToAgent = Boolean(result.rawOutput && result.agentTrigger && agent);
1181
1230
  if (result.output) {
@@ -1574,7 +1623,19 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1574
1623
  syncRuntimeState();
1575
1624
  }
1576
1625
  } else {
1577
- if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1626
+ const freeTextRouting = (runtime?.url && !session.chatMode && !line.trim().startsWith('/'))
1627
+ ? shouldHandleFreeTextLocally(line, session)
1628
+ : null;
1629
+ if (freeTextRouting?.local) {
1630
+ // Question/small talk → local agent (read tools), no runtime run.
1631
+ activityLines = [...activityLines, `agent: ${freeTextRouting.classification.kind} handled locally`].slice(-LOWER_DETAIL_ROWS);
1632
+ const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep, runtime });
1633
+ done = result.exit;
1634
+ aborted = result.aborted ?? false;
1635
+ } else if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1636
+ if (freeTextRouting?.fallbackReason) {
1637
+ activityLines = [...activityLines, `runtime: ${freeTextRouting.fallbackReason}, routing to runtime run`].slice(-LOWER_DETAIL_ROWS);
1638
+ }
1578
1639
  conversationMessages(session).push({ role: 'user', content: line });
1579
1640
  const outcome = await submitRuntimeRun(line, { runtime, session });
1580
1641
  if (outcome.kind === 'accepted') {
@@ -1590,6 +1651,11 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1590
1651
  } else if (outcome.kind === 'ambiguous') {
1591
1652
  conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.') });
1592
1653
  activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
1654
+ } else if (outcome.result?.explanation) {
1655
+ // cancel / approve / modify_run and any future control kinds:
1656
+ // always surface the server's localized explanation.
1657
+ conversationMessages(session).push({ role: 'command', content: String(outcome.result.explanation) });
1658
+ activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
1593
1659
  } else {
1594
1660
  conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
1595
1661
  activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
@@ -1599,7 +1665,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1599
1665
  const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
1600
1666
  activityLines = [...activityLines, message ?? 'runtime: disconnected'].slice(-LOWER_DETAIL_ROWS);
1601
1667
  } else {
1602
- const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1668
+ const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep, runtime });
1603
1669
  done = result.exit;
1604
1670
  aborted = result.aborted ?? false;
1605
1671
  }
@@ -1718,6 +1784,9 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1718
1784
 
1719
1785
  export async function runShell({ agent, packageJson, runtime = null }) {
1720
1786
  const session = createSession();
1787
+ // Donna's runtime control tools (runtime__kill/cancel/status) read the
1788
+ // endpoint from the session.
1789
+ session.runtime = runtime;
1721
1790
  if (!input.isTTY || !output.isTTY) {
1722
1791
  await runPipeShell({ agent, packageJson, session });
1723
1792
  return;
@@ -11,6 +11,7 @@ import {
11
11
  runLine,
12
12
  runtimeStatusLine,
13
13
  runtimeUnavailableAgentMessage,
14
+ shouldHandleFreeTextLocally,
14
15
  submitRuntimeRun,
15
16
  } from './repl.js';
16
17
 
@@ -81,7 +82,9 @@ test('submitRuntimeRun reports acceptance without throwing', async () => {
81
82
  try {
82
83
  const session = createSession();
83
84
  const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
84
- assert.deepEqual(outcome, { kind: 'accepted' });
85
+ // The accepted payload is passed through so callers can surface the runId
86
+ // in the chat (immediate feedback that the run started).
87
+ assert.deepEqual(outcome, { kind: 'accepted', result: { accepted: true, runId: 'run-1' } });
85
88
  } finally {
86
89
  restore();
87
90
  }
@@ -191,3 +194,113 @@ test('runtime status exposes the disconnected reason', () => {
191
194
  '⚠ Runtime indisponible : token mismatch — /agent désactivé, /chat reste possible',
192
195
  );
193
196
  });
197
+
198
+ test('/run kill posts to the runtime kill endpoint', async () => {
199
+ let calledUrl = null;
200
+ const restore = stubFetch(async (url) => {
201
+ calledUrl = new URL(String(url));
202
+ return jsonResponse(202, { killed: true, runs: 1, tasks: 2 });
203
+ });
204
+ try {
205
+ const session = createSession();
206
+ session.workspace = 'docs';
207
+
208
+ const result = await runLine('/run kill', {
209
+ agent: null,
210
+ packageJson: { version: 'test' },
211
+ session,
212
+ runtime: { url: 'http://runtime.test' },
213
+ });
214
+
215
+ assert.equal(result.exit, false);
216
+ assert.equal(calledUrl.pathname, '/kill');
217
+ assert.equal(calledUrl.searchParams.get('workspace'), 'docs');
218
+ assert.match(conversationMessages(session).at(-1).content, /Runtime kill requested: 1 run, 2 tasks cancelled/);
219
+ } finally {
220
+ restore();
221
+ }
222
+ });
223
+
224
+ test('/queue cancel on a runtime workflow id points to run cancellation commands', async () => {
225
+ const session = createSession();
226
+ session.workspace = 'docs';
227
+ session.jobQueue = [];
228
+ session.workflow = {
229
+ nodes: [{ id: 'task:runtime-a', type: 'task', label: 'Runtime task', status: 'pending' }],
230
+ relations: [],
231
+ };
232
+
233
+ const result = await runLine('/queue cancel task:runtime-a', {
234
+ agent: null,
235
+ packageJson: { version: 'test' },
236
+ session,
237
+ });
238
+
239
+ assert.equal(result.exit, false);
240
+ assert.match(conversationMessages(session).at(-1).content, /Item géré par le runtime/);
241
+ assert.match(conversationMessages(session).at(-1).content, /\/run kill/);
242
+ });
243
+
244
+ test('free text routing keeps questions local and sends actions to the runtime', () => {
245
+ const session = createSession();
246
+ session.llm = { completeWithTools: () => {} };
247
+
248
+ // The original incident: a config question must never start a run.
249
+ const question = shouldHandleFreeTextLocally('donne moi la config du cme', session);
250
+ assert.equal(question.local, true);
251
+ assert.equal(question.classification.kind, 'observe');
252
+
253
+ const smallTalk = shouldHandleFreeTextLocally('bonjour', session);
254
+ assert.equal(smallTalk.local, true);
255
+
256
+ const action = shouldHandleFreeTextLocally('lance le pipeline complet', session);
257
+ assert.equal(action.local, false);
258
+ assert.equal(action.classification.kind, 'start_run');
259
+ });
260
+
261
+ test('free text routing keeps questions local even during an active run', () => {
262
+ // The chat must stay available during a run: a status question or small
263
+ // talk answered locally (read-only tools) — never enqueued as a future run.
264
+ const session = createSession();
265
+ session.llm = { completeWithTools: () => {} };
266
+ session.agentProjection = { status: 'running', activities: [], conversation: [] };
267
+ assert.equal(shouldHandleFreeTextLocally('où en est le run', session).local, true);
268
+ assert.equal(shouldHandleFreeTextLocally('salut', session).local, true);
269
+ // Cancel intents are handled by Donna locally (runtime__kill/cancel tools);
270
+ // approvals stay on the deterministic control lane.
271
+ assert.equal(shouldHandleFreeTextLocally('stop le job', session).local, true);
272
+ assert.equal(shouldHandleFreeTextLocally('supprime le job et la queue', session).local, true);
273
+ // Approvals and "later" requests too: Donna owns runtime__approve and
274
+ // runtime__enqueue. Only plan modifications and new runs bypass her.
275
+ assert.equal(shouldHandleFreeTextLocally('approuve le run', session).local, true);
276
+ assert.equal(shouldHandleFreeTextLocally('fais le build plus tard', session).local, true);
277
+
278
+ const offline = createSession();
279
+ offline.llm = null;
280
+ const fallback = shouldHandleFreeTextLocally('donne moi la config du cme', offline);
281
+ assert.equal(fallback.local, false);
282
+ assert.match(fallback.fallbackReason ?? '', /LLM unavailable/);
283
+ });
284
+
285
+ test('submitRuntimeRun sends a control message instead of /run while a run is active', async () => {
286
+ const paths = [];
287
+ const restore = stubFetch(async (url) => {
288
+ paths.push(pathOf(url));
289
+ return jsonResponse(200, {
290
+ accepted: true,
291
+ kind: 'cancel',
292
+ classification: { kind: 'cancel' },
293
+ explanation: 'Runtime cancellation requested.',
294
+ });
295
+ });
296
+ try {
297
+ const session = createSession();
298
+ session.agentProjection = { status: 'running', activities: [], conversation: [] };
299
+ const outcome = await submitRuntimeRun('stop le job', { runtime: { url: 'http://runtime.test' }, session });
300
+ assert.deepEqual(paths, ['/control'], 'active run must use the control lane, not POST /run');
301
+ assert.equal(outcome.kind, 'cancel');
302
+ assert.match(outcome.result?.explanation ?? '', /cancellation requested/i);
303
+ } finally {
304
+ restore();
305
+ }
306
+ });
package/src/shell/tui.tsx CHANGED
@@ -126,7 +126,10 @@ function App(props: {
126
126
  const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
127
127
  const rightColumns = createMemo(() => {
128
128
  const width = dimensions().width;
129
- return Math.max(26, Math.min(44, Math.floor(width * 0.32)));
129
+ // 38% / cap 56 (was 32% / cap 44): the Plan/Activity/Logs panes carry job
130
+ // labels, file names and error messages — 40 columns truncated everything
131
+ // into unreadable stubs.
132
+ return Math.max(30, Math.min(56, Math.floor(width * 0.38)));
130
133
  });
131
134
  const leftColumns = createMemo(() => Math.max(32, dimensions().width - rightColumns() - 1));
132
135
  const conversationColumns = createMemo(() => {