@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.
- package/agents.docker-compose.yml +3 -3
- package/package.json +2 -2
- package/src/agent/graph.js +99 -3
- package/src/agent/graph.test.js +81 -0
- package/src/cli/wiki-manager.js +9 -2
- package/src/core/agentLoop.js +20 -5
- package/src/core/agentLoop.test.js +19 -1
- package/src/core/mcp.js +1 -1
- package/src/core/planPatch.js +39 -1
- package/src/core/planPatch.test.js +23 -1
- package/src/runtime/client.js +12 -0
- package/src/runtime/donna-contract.test.js +359 -0
- package/src/runtime/runner.js +72 -3
- package/src/runtime/runner.test.js +113 -3
- package/src/runtime/server.js +27 -1
- package/src/runtime/server.test.js +38 -0
- package/src/shell/LeftPane.tsx +2 -1
- package/src/shell/StartupScreen.tsx +101 -21
- package/src/shell/repl.js +60 -15
- package/src/shell/repl.test.js +33 -1
- package/src/shell/tui.tsx +90 -75
- package/src/shell/useAgent.ts +14 -4
- package/src/shell/useSession.ts +92 -17
package/src/runtime/server.js
CHANGED
|
@@ -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
|
|
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;
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -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
|
-
{
|
|
680
|
+
{showWelcome() ? (
|
|
680
681
|
<WelcomeHelpPanels width={props.conversationColumns} />
|
|
681
682
|
) : (
|
|
682
683
|
<ConversationView
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
const keyName =
|
|
98
|
-
const 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 (
|
|
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 (
|
|
177
|
+
if (isEscapeKey(key)) {
|
|
178
|
+
consumeKey(key);
|
|
109
179
|
props.onQuit();
|
|
110
180
|
return;
|
|
111
181
|
}
|
|
112
|
-
if (
|
|
113
|
-
|
|
182
|
+
if (isEnterKey(key)) {
|
|
183
|
+
consumeKey(key);
|
|
184
|
+
choose();
|
|
114
185
|
return;
|
|
115
186
|
}
|
|
116
|
-
if (
|
|
117
|
-
|
|
187
|
+
if (isUpKey(key)) {
|
|
188
|
+
consumeKey(key);
|
|
189
|
+
move(-1);
|
|
118
190
|
return;
|
|
119
191
|
}
|
|
120
|
-
if (
|
|
121
|
-
|
|
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
|
|
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
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
1041
|
+
donnaMessage.content = formatLlmUnavailableMessage('flux vide');
|
|
1012
1042
|
}
|
|
1013
1043
|
} else {
|
|
1014
|
-
messages.push({ role: 'donna', 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 =
|
|
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 =
|
|
1093
|
+
donnaMessage.content = formatLlmUnavailableMessage(message);
|
|
1064
1094
|
onUpdate?.();
|
|
1065
1095
|
}
|
|
1066
1096
|
return {};
|
|
1067
1097
|
}
|
|
1068
1098
|
|
|
1069
1099
|
if (donnaMessage) {
|
|
1070
|
-
donnaMessage.content =
|
|
1100
|
+
donnaMessage.content = formatLlmUnavailableMessage('reponse vide');
|
|
1071
1101
|
} else {
|
|
1072
|
-
messages.push({ role: 'donna', content:
|
|
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 =
|
|
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 =
|
|
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
|
|
package/src/shell/repl.test.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
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
|
+
});
|