@dotdrelle/wiki-manager 0.11.4 → 0.11.6

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.
@@ -18,6 +18,7 @@ export function startRuntimeServer({
18
18
  approve,
19
19
  configProfiles,
20
20
  useConfigProfile,
21
+ exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
21
22
  } = {}) {
22
23
  const clients = new Set();
23
24
  const defaultContext = { workspace: null, session, running: false, currentAbortController: null, currentRunId: null };
@@ -31,6 +32,13 @@ export function startRuntimeServer({
31
32
  }
32
33
  }
33
34
 
35
+ function publishState(workspace = null, context = null) {
36
+ for (const client of clients) {
37
+ if (client.workspace && client.workspace !== workspace) continue;
38
+ client.response.write(`event: state\ndata: ${JSON.stringify(runtimeState(context, store, { workspace, session }))}\n\n`);
39
+ }
40
+ }
41
+
34
42
  const server = createServer(async (request, response) => {
35
43
  try {
36
44
  if (!isAuthorized(request, token)) {
@@ -223,6 +231,23 @@ export function startRuntimeServer({
223
231
  sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
224
232
  return;
225
233
  }
234
+ if (request.method === 'POST' && url.pathname === '/shutdown') {
235
+ const workspace = workspaceFromUrl(url);
236
+ const context = await resolveContext({ workspace });
237
+ if (context?.running && context.currentAbortController) {
238
+ context.currentAbortController.abort();
239
+ await cancel?.(context);
240
+ }
241
+ sendJson(response, 202, { shutdown: true });
242
+ setImmediate(() => {
243
+ for (const client of clients) client.response.end();
244
+ clients.clear();
245
+ server.close(() => {
246
+ if (exitOnShutdown) process.exit(0);
247
+ });
248
+ });
249
+ return;
250
+ }
226
251
  if (request.method === 'POST' && url.pathname === '/resume') {
227
252
  const workspace = workspaceFromUrl(url);
228
253
  const result = await resume?.({ workspace });
@@ -307,6 +332,7 @@ export function startRuntimeServer({
307
332
  context.currentAbortController = null;
308
333
  context.currentRunId = null;
309
334
  context.currentRunWorkspace = null;
335
+ publishState(runWorkspace, context);
310
336
  void startNextControlRequest(context);
311
337
  });
312
338
  return { accepted: true, runId, workspace: runWorkspace };
@@ -610,7 +636,7 @@ function classifyControlMessage(input, status, forcedIntent = null) {
610
636
  if (['observe', 'converse', 'mutate', 'enqueue'].includes(intent)) {
611
637
  return { kind: intent, confidence: 1, reason: 'explicit_intent' };
612
638
  }
613
- if (/\b(o[uù] en est|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
639
+ if (/\b(o[uù] en es[t-]|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
614
640
  return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
615
641
  }
616
642
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
@@ -46,6 +46,44 @@ test('runtime server checks bearer and x-runtime-token credentials', async (t) =
46
46
  }
47
47
  });
48
48
 
49
+ test('runtime server shutdown endpoint closes the listener', async (t) => {
50
+ let handle;
51
+ try {
52
+ handle = await startRuntimeServer({
53
+ host: '127.0.0.1',
54
+ port: 0,
55
+ token: 'runtime-secret',
56
+ exitOnShutdown: false,
57
+ store: {
58
+ dbPath: ':memory:',
59
+ getState: () => ({ status: 'idle' }),
60
+ listEvents: () => [],
61
+ },
62
+ session: {},
63
+ run: async () => {},
64
+ });
65
+ } catch (err) {
66
+ if (err?.code === 'EPERM') {
67
+ t.skip('network listen is not permitted in this sandbox');
68
+ return;
69
+ }
70
+ throw err;
71
+ }
72
+
73
+ const url = `http://127.0.0.1:${handle.port}`;
74
+ const response = await fetch(`${url}/shutdown`, {
75
+ method: 'POST',
76
+ headers: { authorization: 'Bearer runtime-secret' },
77
+ });
78
+ assert.equal(response.status, 202);
79
+ assert.equal((await response.json()).shutdown, true);
80
+
81
+ await new Promise((resolve) => setTimeout(resolve, 20));
82
+ await assert.rejects(fetch(`${url}/health`, {
83
+ headers: { authorization: 'Bearer runtime-secret' },
84
+ }));
85
+ });
86
+
49
87
  test('runtime server accepts only one active run', async (t) => {
50
88
  let releaseRun;
51
89
  let runCount = 0;
@@ -662,6 +662,7 @@ export function LeftPane(props: {
662
662
  }) {
663
663
  const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
664
664
  const modeLabel = () => props.chatMode ? 'CHAT MODE direct LLM, no tools' : 'AGENTIC MODE LangGraph + MCP tools';
665
+ const showWelcome = () => props.showWelcome && props.messages.length === 0;
665
666
  return (
666
667
  <box width={props.width} height="100%" flexDirection="column" padding={1} overflow="hidden">
667
668
  <box height={3} flexDirection="column">
@@ -676,7 +677,7 @@ export function LeftPane(props: {
676
677
  {props.hintLine ? <text fg="#FBBF24">[ {props.hintLine} ]</text> : null}
677
678
  </box>
678
679
  </box>
679
- {props.showWelcome ? (
680
+ {showWelcome() ? (
680
681
  <WelcomeHelpPanels width={props.conversationColumns} />
681
682
  ) : (
682
683
  <ConversationView
@@ -1,6 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { useKeyboard } from '@opentui/solid';
3
- import { createMemo, createSignal, For } from 'solid-js';
2
+ import { createEffect, createMemo, createSignal, For, untrack } from 'solid-js';
4
3
  import { fit } from './textFit';
5
4
 
6
5
  export type StartupAction =
@@ -23,6 +22,58 @@ function initWorkspaceItem(detail: string): StartupItem {
23
22
  return { action: 'init-workspace', label: 'Initialize workspace', detail };
24
23
  }
25
24
 
25
+ function keyNameOf(key: any) {
26
+ return String(key?.name ?? key?.key ?? '').toLowerCase();
27
+ }
28
+
29
+ function keySequenceOf(key: any) {
30
+ return String(key?.sequence ?? key?.raw ?? key?.seq ?? key?.input ?? '');
31
+ }
32
+
33
+ function isEnterKey(key: any) {
34
+ const keyName = keyNameOf(key);
35
+ const sequence = keySequenceOf(key);
36
+ return Boolean(
37
+ key?.return ||
38
+ key?.enter ||
39
+ key?.linefeed ||
40
+ keyName === 'return' ||
41
+ keyName === 'enter' ||
42
+ keyName === 'linefeed' ||
43
+ keyName === 'kpenter' ||
44
+ keyName === 'keypadenter' ||
45
+ keyName === 'numenter' ||
46
+ (key?.ctrl && keyName === 'm') ||
47
+ sequence === '\r' ||
48
+ sequence === '\n' ||
49
+ sequence === '\r\n' ||
50
+ sequence === '\x1bOM' ||
51
+ /^\x1b\[13(?:;\d+)?u$/.test(sequence),
52
+ );
53
+ }
54
+
55
+ function isEscapeKey(key: any) {
56
+ const keyName = keyNameOf(key);
57
+ return keyName === 'escape' || keySequenceOf(key) === '\x1b';
58
+ }
59
+
60
+ function isUpKey(key: any) {
61
+ const keyName = keyNameOf(key);
62
+ const sequence = keySequenceOf(key);
63
+ return keyName === 'up' || keyName === 'arrow_up' || sequence === '\x1b[A' || /^\x1b\[57352(?:;\d+)?u$/.test(sequence);
64
+ }
65
+
66
+ function isDownKey(key: any) {
67
+ const keyName = keyNameOf(key);
68
+ const sequence = keySequenceOf(key);
69
+ return keyName === 'down' || keyName === 'arrow_down' || sequence === '\x1b[B' || /^\x1b\[57353(?:;\d+)?u$/.test(sequence);
70
+ }
71
+
72
+ function consumeKey(key: any) {
73
+ key?.preventDefault?.();
74
+ key?.stopPropagation?.();
75
+ }
76
+
26
77
  export function StartupScreen(props: {
27
78
  version: string;
28
79
  model: string;
@@ -34,11 +85,13 @@ export function StartupScreen(props: {
34
85
  hasWorkspace: boolean;
35
86
  width: number;
36
87
  height: number;
88
+ keyboardEvent?: { id: number; key: any } | null;
37
89
  onSelect: (action: StartupAction, workspaceName?: string) => void;
38
90
  onQuit: () => void;
39
91
  }) {
40
92
  const [mode, setMode] = createSignal<'home' | 'workspace-select'>('home');
41
93
  const [selected, setSelected] = createSignal(0);
94
+ let lastKeyboardEventId = 0;
42
95
  const panelWidth = createMemo(() => Math.max(54, Math.min(86, props.width - 4)));
43
96
  const panelHeight = createMemo(() => Math.max(24, Math.min(32, props.height - 2)));
44
97
  const left = createMemo(() => Math.max(1, Math.floor((props.width - panelWidth()) / 2)));
@@ -74,9 +127,23 @@ export function StartupScreen(props: {
74
127
  const currentLength = createMemo(() => mode() === 'workspace-select' ? workspaceItems().length : items().length);
75
128
 
76
129
  const move = (delta: number) => {
77
- setSelected((value) => (value + delta + currentLength()) % currentLength());
130
+ const length = currentLength();
131
+ if (length <= 0) {
132
+ setSelected(0);
133
+ return;
134
+ }
135
+ setSelected((value) => (value + delta + length) % length);
78
136
  };
79
137
 
138
+ createEffect(() => {
139
+ const length = currentLength();
140
+ if (length <= 0) {
141
+ setSelected(0);
142
+ return;
143
+ }
144
+ if (selected() >= length) setSelected(length - 1);
145
+ });
146
+
80
147
  const choose = (index = selected()) => {
81
148
  if (mode() === 'workspace-select') {
82
149
  const item = workspaceItems()[index];
@@ -93,38 +160,52 @@ export function StartupScreen(props: {
93
160
  props.onSelect(item.action);
94
161
  };
95
162
 
96
- useKeyboard((key: any) => {
97
- const keyName = String(key.name ?? '').toLowerCase();
98
- const sequence = String(key.sequence ?? '');
163
+ const handleKey = (key: any) => {
164
+ const keyName = keyNameOf(key);
165
+ const sequence = keySequenceOf(key);
99
166
  if ((key.ctrl || key.meta) && keyName === 'c') {
167
+ consumeKey(key);
100
168
  props.onQuit();
101
169
  return;
102
170
  }
103
- if (keyName === 'escape' && mode() === 'workspace-select') {
171
+ if (isEscapeKey(key) && mode() === 'workspace-select') {
172
+ consumeKey(key);
104
173
  setMode('home');
105
174
  setSelected(0);
106
175
  return;
107
176
  }
108
- if (keyName === 'escape') {
177
+ if (isEscapeKey(key)) {
178
+ consumeKey(key);
109
179
  props.onQuit();
110
180
  return;
111
181
  }
112
- if (keyName === 'up') {
113
- move(-1);
182
+ if (isEnterKey(key)) {
183
+ consumeKey(key);
184
+ choose();
114
185
  return;
115
186
  }
116
- if (keyName === 'down') {
117
- move(1);
187
+ if (isUpKey(key)) {
188
+ consumeKey(key);
189
+ move(-1);
118
190
  return;
119
191
  }
120
- if (keyName === 'return' || keyName === 'enter') {
121
- choose();
192
+ if (isDownKey(key)) {
193
+ consumeKey(key);
194
+ move(1);
122
195
  return;
123
196
  }
124
197
  const numeric = Number.parseInt(sequence, 10);
125
198
  if (Number.isInteger(numeric) && numeric >= 1 && numeric <= currentLength()) {
199
+ consumeKey(key);
126
200
  choose(numeric - 1);
127
201
  }
202
+ };
203
+
204
+ createEffect(() => {
205
+ const event = props.keyboardEvent;
206
+ if (!event || event.id === lastKeyboardEventId) return;
207
+ lastKeyboardEventId = event.id;
208
+ untrack(() => handleKey(event.key));
128
209
  });
129
210
 
130
211
  const status = createMemo(() => {
@@ -144,7 +225,7 @@ export function StartupScreen(props: {
144
225
  const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
145
226
  return mode() === 'workspace-select'
146
227
  ? `↑/↓ select Enter open ${quick} Esc back`
147
- : `↑/↓ select Enter open ${quick} Esc quit`;
228
+ : `↑/↓ select Enter choose ${quick} Esc quit`;
148
229
  });
149
230
  const menuTitle = createMemo(() => mode() === 'workspace-select' ? 'Select workspace' : '');
150
231
 
@@ -170,12 +251,11 @@ export function StartupScreen(props: {
170
251
  </box>
171
252
  <text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
172
253
  <text height={1}>{''}</text>
173
- <text height={1} fg="#D6DEE8">██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗</text>
174
- <text height={1} fg="#D6DEE8">██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗</text>
175
- <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║</text>
176
- <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║</text>
177
- <text height={1} fg="#D6DEE8">██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║</text>
178
- <text height={1} fg="#D6DEE8">╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝</text>
254
+ <box height={4} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#8BD5CA" paddingX={1}>
255
+ <text height={1} fg="#EAF2F8" content={fit('DONNA', innerWidth() - 2)} />
256
+ <text height={1} fg="#8BD5CA" content={fit('Living knowledge for AI agents', innerWidth() - 2)} />
257
+ <text height={1} fg="#7F8C8D" content={fit('Local workspaces · MCP agents · traceable runs', innerWidth() - 2)} />
258
+ </box>
179
259
  <text height={1}>{''}</text>
180
260
  <text height={1} fg="#7F8C8D" content={menuTitle()} />
181
261
  <box height={Math.max(1, currentLength())} flexDirection="column">
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, buildLimitedAgentResponse } from '../agent/graph.js';
8
+ import { buildAgentSystemPrompt, 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';
@@ -15,7 +15,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
15
15
  import { listSkills } from '../core/skills.js';
16
16
  import { listWikircProfiles } from '../core/wikirc.js';
17
17
  import { listWorkspaces } from '../core/workspaces.js';
18
- import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
18
+ import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, postRuntimeShutdown, streamRuntimeEvents } from '../runtime/client.js';
19
19
 
20
20
  marked.use(markedTerminal());
21
21
  // marked-terminal's text renderer extracts token.text (raw string) instead of
@@ -102,6 +102,31 @@ const SUBCOMMAND_COMPLETION_DESCRIPTIONS = {
102
102
  '/skills:show': 'Show one workspace skill.',
103
103
  };
104
104
 
105
+ export function runtimeUnavailableReason(runtime) {
106
+ if (runtime?.url) return null;
107
+ const reason = runtime?.error ?? runtime?.unavailableReason ?? runtime?.reason ?? null;
108
+ return reason ? String(reason) : 'runtime introuvable';
109
+ }
110
+
111
+ export function runtimeUnavailableAgentMessage(runtime) {
112
+ const reason = runtimeUnavailableReason(runtime);
113
+ return reason ? `⚠ Runtime indisponible : ${reason} — /agent désactivé, /chat reste possible` : null;
114
+ }
115
+
116
+ export function runtimeStatusLine(runtime, session) {
117
+ if (runtime?.url) return `runtime: connected (${session?.workspace ?? 'no workspace'})`;
118
+ const reason = runtimeUnavailableReason(runtime);
119
+ if (reason) return `runtime: disconnected: ${reason}`;
120
+ return 'runtime: off';
121
+ }
122
+
123
+ export function recordRuntimeUnavailableAgentInput(session, line, runtime) {
124
+ const message = runtimeUnavailableAgentMessage(runtime);
125
+ conversationMessages(session).push({ role: 'user', content: line });
126
+ conversationMessages(session).push({ role: 'command', content: message ?? 'Runtime indisponible.' });
127
+ return message;
128
+ }
129
+
105
130
  export function createSession() {
106
131
  return {
107
132
  workspace: null,
@@ -839,17 +864,18 @@ function isDurableActivityLine(label) {
839
864
  return /^[a-z0-9_-]+\.[a-z0-9_-]+:/i.test(String(label).trim());
840
865
  }
841
866
 
842
- function renderScreen({ packageJson, session, messages, inputBuffer, busy = false, spinnerFrame = 0, scrollOffset = 0, spinnerLabel = 'Thinking…', activityLines = [] }) {
867
+ function renderScreen({ packageJson, session, messages, inputBuffer, busy = false, spinnerFrame = 0, scrollOffset = 0, spinnerLabel = 'Thinking…', activityLines = [], runtime = null }) {
843
868
  const columns = output.columns || 100;
844
869
  const rows = output.rows || 30;
845
870
  const banner = renderBannerWithMcpPanel(columns, session);
871
+ const runtimeBanner = runtimeUnavailableAgentMessage(runtime);
846
872
  const completions = busy ? [] : completionLines(inputBuffer, session, columns);
847
873
  const visibleCompletions = completions.slice(0, COMPLETION_PANEL_ROWS);
848
874
  const completionRows = visibleCompletions.length > 0 ? COMPLETION_PANEL_ROWS : 0;
849
875
  const activityRows = LOWER_DETAIL_ROWS - completionRows;
850
876
  const activity = renderActivityLines(activityLines, columns, activityRows);
851
877
  const productionActivityRows = 1;
852
- const fixedRows = 4 + banner.length + productionActivityRows + 1 + LOWER_PANEL_ROWS + BOTTOM_PADDING_ROWS;
878
+ const fixedRows = 4 + banner.length + (runtimeBanner ? 1 : 0) + productionActivityRows + 1 + LOWER_PANEL_ROWS + BOTTOM_PADDING_ROWS;
853
879
  const middleHeight = Math.max(5, rows - fixedRows);
854
880
  lastMiddleHeight = middleHeight;
855
881
  const prompt = promptFor(session);
@@ -860,6 +886,7 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
860
886
  session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
861
887
  session.language ? session.language : 'no language',
862
888
  session.llm ? 'llm ready' : 'llm limited',
889
+ runtimeStatusLine(runtime, session),
863
890
  ].join(' ');
864
891
  const header = `${title}${' '.repeat(Math.max(0, columns - stripAnsi(title).length - context.length))}${context}`;
865
892
  const divider = '─'.repeat(columns);
@@ -910,6 +937,9 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
910
937
  if (banner.length > 0) {
911
938
  buf += `${banner.map((line) => line.padEnd(columns, ' ')).join('\n')}\n`;
912
939
  }
940
+ if (runtimeBanner) {
941
+ buf += `${padVisible(`${styles.yellow}${truncateAnsi(runtimeBanner, columns)}${styles.reset}`, columns)}\n`;
942
+ }
913
943
  buf += `${topDivider}\n`;
914
944
  buf += `${visibleBody.map((line) => padVisible(line, columns)).join('\n')}\n`;
915
945
  // Keep this line reserved: production jobs publish their progress here.
@@ -994,7 +1024,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
994
1024
  // Non-abort error: surface it in the bubble rather than leaving "Thinking…" stuck.
995
1025
  if (donnaMessage) {
996
1026
  const msg = err instanceof Error ? err.message : String(err);
997
- donnaMessage.content = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${msg}`);
1027
+ donnaMessage.content = formatLlmUnavailableMessage(msg);
998
1028
  onUpdate?.();
999
1029
  }
1000
1030
  throw err;
@@ -1008,10 +1038,10 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
1008
1038
  if (donnaMessage) {
1009
1039
  donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
1010
1040
  if (!donnaMessage.content.trim()) {
1011
- donnaMessage.content = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
1041
+ donnaMessage.content = formatLlmUnavailableMessage('flux vide');
1012
1042
  }
1013
1043
  } else {
1014
- messages.push({ role: 'donna', content: buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content') });
1044
+ messages.push({ role: 'donna', content: formatLlmUnavailableMessage('flux vide') });
1015
1045
  }
1016
1046
  onUpdate?.();
1017
1047
  return {};
@@ -1050,7 +1080,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
1050
1080
  }
1051
1081
  donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
1052
1082
  if (!donnaMessage.content.trim()) {
1053
- donnaMessage.content = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
1083
+ donnaMessage.content = formatLlmUnavailableMessage('flux vide');
1054
1084
  onUpdate?.();
1055
1085
  }
1056
1086
  } catch (err) {
@@ -1060,16 +1090,16 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
1060
1090
  return { aborted: true };
1061
1091
  }
1062
1092
  const message = err instanceof Error ? err.message : String(err);
1063
- donnaMessage.content = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${message}`);
1093
+ donnaMessage.content = formatLlmUnavailableMessage(message);
1064
1094
  onUpdate?.();
1065
1095
  }
1066
1096
  return {};
1067
1097
  }
1068
1098
 
1069
1099
  if (donnaMessage) {
1070
- donnaMessage.content = buildLimitedAgentResponse({ input, session });
1100
+ donnaMessage.content = formatLlmUnavailableMessage('reponse vide');
1071
1101
  } else {
1072
- messages.push({ role: 'donna', content: buildLimitedAgentResponse({ input, session }) });
1102
+ messages.push({ role: 'donna', content: formatLlmUnavailableMessage('reponse vide') });
1073
1103
  }
1074
1104
  onUpdate?.();
1075
1105
  return {};
@@ -1102,7 +1132,7 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1102
1132
  }
1103
1133
  donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
1104
1134
  if (!donnaMessage.content.trim()) {
1105
- donnaMessage.content = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
1135
+ donnaMessage.content = formatLlmUnavailableMessage('flux vide');
1106
1136
  onUpdate?.();
1107
1137
  }
1108
1138
  } catch (err) {
@@ -1111,7 +1141,7 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1111
1141
  return { exit: false, aborted: true };
1112
1142
  }
1113
1143
  const message = err instanceof Error ? err.message : String(err);
1114
- donnaMessage.content = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${message}`);
1144
+ donnaMessage.content = formatLlmUnavailableMessage(message);
1115
1145
  onUpdate?.();
1116
1146
  }
1117
1147
  return { exit: false };
@@ -1243,16 +1273,28 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1243
1273
  let runtimeReconnectTimer = null;
1244
1274
  let runtimeSyncTimer = null;
1245
1275
  let runtimeStreamStopped = false;
1276
+ let runtimeShutdownRequested = false;
1246
1277
  let finish;
1247
1278
  const finished = new Promise((resolve) => {
1248
1279
  finish = resolve;
1249
1280
  });
1250
1281
 
1282
+ async function shutdownRuntimeOnExit() {
1283
+ if (!runtime?.url || runtimeShutdownRequested) return;
1284
+ runtimeShutdownRequested = true;
1285
+ try {
1286
+ await postRuntimeShutdown({ url: runtime.url });
1287
+ } catch {
1288
+ // The shell is already exiting; an unavailable runtime should not block
1289
+ // terminal restoration.
1290
+ }
1291
+ }
1292
+
1251
1293
  input.setRawMode(true);
1252
1294
  input.resume();
1253
1295
  output.write('\u001b[?1049h');
1254
1296
 
1255
- const rerender = () => renderScreen({ packageJson, session, messages: conversationMessages(session), inputBuffer, busy, spinnerFrame, scrollOffset, spinnerLabel, activityLines });
1297
+ const rerender = () => renderScreen({ packageJson, session, messages: conversationMessages(session), inputBuffer, busy, spinnerFrame, scrollOffset, spinnerLabel, activityLines, runtime });
1256
1298
  busy = true;
1257
1299
  spinnerLabel = 'Loading wiki-manager shell...';
1258
1300
  spinnerInterval = setInterval(() => {
@@ -1534,7 +1576,6 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1534
1576
  conversationMessages(session).push({ role: 'user', content: line });
1535
1577
  const outcome = await submitRuntimeRun(line, { runtime, session });
1536
1578
  if (outcome.kind === 'accepted') {
1537
- conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
1538
1579
  activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1539
1580
  } else if (outcome.kind === 'queued') {
1540
1581
  conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
@@ -1551,6 +1592,9 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1551
1592
  activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
1552
1593
  }
1553
1594
  syncRuntimeState();
1595
+ } else if (!session.chatMode && !line.trim().startsWith('/')) {
1596
+ const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
1597
+ activityLines = [...activityLines, message ?? 'runtime: disconnected'].slice(-LOWER_DETAIL_ROWS);
1554
1598
  } else {
1555
1599
  const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1556
1600
  done = result.exit;
@@ -1664,6 +1708,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1664
1708
  input.setRawMode(false);
1665
1709
  input.pause();
1666
1710
  output.write('\u001b[?25h\u001b[H\u001b[2J\u001b[?1049l');
1711
+ await shutdownRuntimeOnExit();
1667
1712
  }
1668
1713
  }
1669
1714
 
@@ -1,6 +1,14 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { applyRuntimeStateToShellSession, createSession, conversationMessages, submitRuntimeRun } from './repl.js';
3
+ import {
4
+ applyRuntimeStateToShellSession,
5
+ createSession,
6
+ conversationMessages,
7
+ recordRuntimeUnavailableAgentInput,
8
+ runtimeStatusLine,
9
+ runtimeUnavailableAgentMessage,
10
+ submitRuntimeRun,
11
+ } from './repl.js';
4
12
 
5
13
  function stubFetch(handler) {
6
14
  const original = globalThis.fetch;
@@ -112,3 +120,27 @@ test('submitRuntimeRun reports a non-409 error without throwing or calling /cont
112
120
  restore();
113
121
  }
114
122
  });
123
+
124
+ test('agent mode without runtime records a visible error instead of falling back locally', () => {
125
+ const session = createSession();
126
+ session.chatMode = false;
127
+
128
+ const message = recordRuntimeUnavailableAgentInput(session, 'salut', { error: 'port 7788 already in use' });
129
+
130
+ assert.equal(message, '⚠ Runtime indisponible : port 7788 already in use — /agent désactivé, /chat reste possible');
131
+ assert.deepEqual(conversationMessages(session), [
132
+ { role: 'user', content: 'salut' },
133
+ { role: 'command', content: message },
134
+ ]);
135
+ });
136
+
137
+ test('runtime status exposes the disconnected reason', () => {
138
+ assert.equal(
139
+ runtimeStatusLine({ error: 'token mismatch' }, { workspace: 'juno' }),
140
+ 'runtime: disconnected: token mismatch',
141
+ );
142
+ assert.equal(
143
+ runtimeUnavailableAgentMessage({ error: 'token mismatch' }),
144
+ '⚠ Runtime indisponible : token mismatch — /agent désactivé, /chat reste possible',
145
+ );
146
+ });