@dotdrelle/wiki-manager 0.11.4 → 0.11.7
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 +118 -5
- package/src/agent/graph.test.js +158 -1
- package/src/cli/wiki-manager.js +9 -2
- package/src/commands/slash.js +36 -11
- 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/core/profile.js +111 -0
- package/src/core/skills.js +1 -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 +118 -23
- package/src/shell/repl.js +95 -42
- package/src/shell/repl.test.js +59 -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/runner.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
2
2
|
import { activityKey, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
3
3
|
import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
|
|
4
4
|
import { formatPlanStatus } from '../core/plan.js';
|
|
5
|
-
import { formatReadyTaskPrompt, readyPlanTasks } from '../core/planPatch.js';
|
|
5
|
+
import { formatReadyTaskPrompt, readyPlanTasks, sanitizePlanForExecution } from '../core/planPatch.js';
|
|
6
6
|
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
7
7
|
|
|
8
8
|
const DEFAULT_MAX_REPLANS = 2;
|
|
@@ -92,6 +92,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
92
92
|
let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
|
|
93
93
|
|
|
94
94
|
while (true) {
|
|
95
|
+
sanitizeSessionPlanForExecution(session, runId);
|
|
95
96
|
const result = shouldUseParallelScheduler(session.headlessPlan)
|
|
96
97
|
? await runRuntimeParallelPlan(agent, session, input, {
|
|
97
98
|
signal,
|
|
@@ -134,7 +135,9 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
134
135
|
return { ok: false, result };
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
const evaluation =
|
|
138
|
+
const evaluation = session.headlessPlan
|
|
139
|
+
? await evaluateRuntimeRun(session, input, { runId, signal, evaluate })
|
|
140
|
+
: null;
|
|
138
141
|
if (evaluation) {
|
|
139
142
|
dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
|
|
140
143
|
origin: 'runtime',
|
|
@@ -147,6 +150,19 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
147
150
|
},
|
|
148
151
|
}));
|
|
149
152
|
if (!evaluation.ok) {
|
|
153
|
+
if (isUndefinedObjectiveEvaluation(evaluation)) {
|
|
154
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
155
|
+
origin: 'runtime',
|
|
156
|
+
runId,
|
|
157
|
+
payload: { content: clarificationMessageForEvaluation(evaluation) },
|
|
158
|
+
}));
|
|
159
|
+
dispatchAgentEvent(session, createAgentEvent('run_done', {
|
|
160
|
+
origin: 'runtime',
|
|
161
|
+
runId,
|
|
162
|
+
payload: { runId },
|
|
163
|
+
}));
|
|
164
|
+
return { ok: true, evaluation, clarified: true };
|
|
165
|
+
}
|
|
150
166
|
if (replansLeft > 0) {
|
|
151
167
|
const trigger = {
|
|
152
168
|
kind: 'evaluation',
|
|
@@ -209,6 +225,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
209
225
|
previousPlanUpdate?.();
|
|
210
226
|
abortCancelledActiveTasks(session, active);
|
|
211
227
|
};
|
|
228
|
+
sanitizeSessionPlanForExecution(session, runId);
|
|
212
229
|
ensurePlanProjection(session, runId);
|
|
213
230
|
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
214
231
|
|
|
@@ -264,6 +281,20 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
264
281
|
}
|
|
265
282
|
}
|
|
266
283
|
|
|
284
|
+
function sanitizeSessionPlanForExecution(session, runId = null) {
|
|
285
|
+
if (!session.headlessPlan) return;
|
|
286
|
+
const sanitized = sanitizePlanForExecution(session.headlessPlan);
|
|
287
|
+
if (sanitized.warnings.length === 0) return;
|
|
288
|
+
session.headlessPlan = sanitized.plan;
|
|
289
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
290
|
+
origin: 'runtime',
|
|
291
|
+
runId,
|
|
292
|
+
payload: {
|
|
293
|
+
message: `plan warning: ${sanitized.warnings.join('; ')}`,
|
|
294
|
+
},
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
|
|
267
298
|
async function drainActive(active, locks) {
|
|
268
299
|
if (active.size === 0) return;
|
|
269
300
|
const entries = [...active.values()];
|
|
@@ -570,7 +601,9 @@ export async function finishRuntimeRun(session, input, {
|
|
|
570
601
|
signal = null,
|
|
571
602
|
evaluate = true,
|
|
572
603
|
} = {}) {
|
|
573
|
-
const evaluation =
|
|
604
|
+
const evaluation = session.headlessPlan
|
|
605
|
+
? await evaluateRuntimeRun(session, input, { runId, signal, evaluate })
|
|
606
|
+
: null;
|
|
574
607
|
if (evaluation) {
|
|
575
608
|
dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
|
|
576
609
|
origin: 'runtime',
|
|
@@ -583,6 +616,19 @@ export async function finishRuntimeRun(session, input, {
|
|
|
583
616
|
},
|
|
584
617
|
}));
|
|
585
618
|
if (!evaluation.ok) {
|
|
619
|
+
if (isUndefinedObjectiveEvaluation(evaluation)) {
|
|
620
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
621
|
+
origin: 'runtime',
|
|
622
|
+
runId,
|
|
623
|
+
payload: { content: clarificationMessageForEvaluation(evaluation) },
|
|
624
|
+
}));
|
|
625
|
+
dispatchAgentEvent(session, createAgentEvent('run_done', {
|
|
626
|
+
origin: 'runtime',
|
|
627
|
+
runId,
|
|
628
|
+
payload: { runId },
|
|
629
|
+
}));
|
|
630
|
+
return { ok: true, evaluation, clarified: true };
|
|
631
|
+
}
|
|
586
632
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
587
633
|
origin: 'runtime',
|
|
588
634
|
runId,
|
|
@@ -672,6 +718,21 @@ function normalizeEvaluation(value) {
|
|
|
672
718
|
};
|
|
673
719
|
}
|
|
674
720
|
|
|
721
|
+
function isUndefinedObjectiveEvaluation(evaluation) {
|
|
722
|
+
const text = `${evaluation?.reason ?? ''} ${evaluation?.suggestedAction ?? ''}`
|
|
723
|
+
.toLowerCase()
|
|
724
|
+
.normalize('NFD')
|
|
725
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
726
|
+
return /\b(vague|undefined|indefini|unclear|clarif|ambiguous|missing objective|no objective)\b/.test(text);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function clarificationMessageForEvaluation(evaluation) {
|
|
730
|
+
const reason = String(evaluation?.reason ?? '').trim();
|
|
731
|
+
return reason
|
|
732
|
+
? `Je dois clarifier la demande avant d'agir : ${reason}`
|
|
733
|
+
: "Je dois clarifier la demande avant d'agir.";
|
|
734
|
+
}
|
|
735
|
+
|
|
675
736
|
function fallbackEvaluation(reason) {
|
|
676
737
|
return {
|
|
677
738
|
ok: true,
|
|
@@ -723,6 +784,10 @@ export async function replanRuntimeRun(session, input, trigger, {
|
|
|
723
784
|
steps: mergedSteps,
|
|
724
785
|
},
|
|
725
786
|
}));
|
|
787
|
+
if (hasMutatingReplanStep(steps)) {
|
|
788
|
+
session._runApprovalRequired = true;
|
|
789
|
+
session._runApprovalResolved = false;
|
|
790
|
+
}
|
|
726
791
|
return { ok: true, steps };
|
|
727
792
|
} catch (err) {
|
|
728
793
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
@@ -811,6 +876,10 @@ function normalizeReplan(steps) {
|
|
|
811
876
|
.slice(0, 12);
|
|
812
877
|
}
|
|
813
878
|
|
|
879
|
+
function hasMutatingReplanStep(steps) {
|
|
880
|
+
return steps.some((step) => /\b(build|copy|ingest|import|export|polish|pipeline|write|create|delete|update|send|deploy|publish|generate|construire|copier|importer|exporter|publier|envoyer|supprimer|modifier|creer|générer|generer)\b/i.test(step));
|
|
881
|
+
}
|
|
882
|
+
|
|
814
883
|
function mergeReplanWithCompleted(plan, steps) {
|
|
815
884
|
const completed = (plan ?? [])
|
|
816
885
|
.filter((step) => step.status === 'done')
|
|
@@ -3,6 +3,41 @@ import test from 'node:test';
|
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
4
|
import { finishRuntimeRun, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
|
|
5
5
|
|
|
6
|
+
test('runRuntimeAgenticWorkflow completes conversational turns without evaluation or replan', async () => {
|
|
7
|
+
const events = [];
|
|
8
|
+
const session = {
|
|
9
|
+
activities: {},
|
|
10
|
+
headlessPlan: null,
|
|
11
|
+
llm: {
|
|
12
|
+
async completeWithTools() {
|
|
13
|
+
assert.fail('conversation-only turn must not call evaluator or replanner');
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
_onAgentEvent: (event) => events.push(event),
|
|
17
|
+
};
|
|
18
|
+
const agent = {
|
|
19
|
+
async invoke() {
|
|
20
|
+
return { response: 'Salut.' };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const started = Date.now();
|
|
25
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'salut', {
|
|
26
|
+
runId: 'run-chat',
|
|
27
|
+
timeoutMs: 1000,
|
|
28
|
+
maxTurns: 1,
|
|
29
|
+
maxReplans: 1,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(result.ok, true);
|
|
33
|
+
assert.ok(Date.now() - started < 5000);
|
|
34
|
+
assert.equal(session.headlessPlan, null);
|
|
35
|
+
assert.equal(Object.keys(session.activities).length, 0);
|
|
36
|
+
assert.ok(events.some((event) => event.type === 'run_done'));
|
|
37
|
+
assert.equal(events.some((event) => event.type === 'run_evaluated'), false);
|
|
38
|
+
assert.equal(events.some((event) => event.type === 'run_replanned'), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
6
41
|
test('finishRuntimeRun emits evaluation before run_done', async () => {
|
|
7
42
|
const events = [];
|
|
8
43
|
const session = {
|
|
@@ -33,6 +68,47 @@ test('finishRuntimeRun emits evaluation before run_done', async () => {
|
|
|
33
68
|
assert.equal(session.agentProjection.status, 'done');
|
|
34
69
|
});
|
|
35
70
|
|
|
71
|
+
test('runRuntimeAgenticWorkflow clarifies vague evaluations without replan', async () => {
|
|
72
|
+
const events = [];
|
|
73
|
+
const session = {
|
|
74
|
+
activities: {},
|
|
75
|
+
headlessPlan: [{ step: 1, id: 'task', description: 'Task', status: 'done' }],
|
|
76
|
+
llm: {
|
|
77
|
+
async completeWithTools({ system }) {
|
|
78
|
+
assert.match(system, /strict evaluator/);
|
|
79
|
+
return { content: '{"ok":false,"reason":"demande vague / objectif indefini","suggestedAction":"clarifier l objectif"}' };
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
_onAgentEvent: (event) => events.push(event),
|
|
83
|
+
};
|
|
84
|
+
const agent = {
|
|
85
|
+
async invoke({ session: turnSession }) {
|
|
86
|
+
if (turnSession.headlessPlan === null) {
|
|
87
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
88
|
+
origin: 'tool',
|
|
89
|
+
payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
return { response: 'Terminé.' };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const result = await runRuntimeAgenticWorkflow(agent, session, 'fais le truc', {
|
|
97
|
+
runId: 'run-vague',
|
|
98
|
+
timeoutMs: 1000,
|
|
99
|
+
maxTurns: 1,
|
|
100
|
+
maxReplans: 1,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.equal(result.ok, true);
|
|
104
|
+
assert.equal(result.clarified, true);
|
|
105
|
+
assert.ok(session.agentProjection.conversation.at(-1).content.includes('clarifier'));
|
|
106
|
+
assert.ok(events.some((event) => event.type === 'run_evaluated'));
|
|
107
|
+
assert.equal(events.some((event) => event.type === 'run_replanned'), false);
|
|
108
|
+
assert.equal(events.some((event) => event.type === 'tool_call_started'), false);
|
|
109
|
+
assert.equal(session.agentProjection.status, 'done');
|
|
110
|
+
});
|
|
111
|
+
|
|
36
112
|
test('finishRuntimeRun turns negative evaluation into run_error', async () => {
|
|
37
113
|
const events = [];
|
|
38
114
|
const session = {
|
|
@@ -62,7 +138,7 @@ test('finishRuntimeRun turns negative evaluation into run_error', async () => {
|
|
|
62
138
|
test('finishRuntimeRun falls back open when evaluator response is invalid', async () => {
|
|
63
139
|
const session = {
|
|
64
140
|
activities: {},
|
|
65
|
-
headlessPlan:
|
|
141
|
+
headlessPlan: [{ step: 1, description: 'Do work', status: 'done' }],
|
|
66
142
|
agentProjection: { conversation: [] },
|
|
67
143
|
llm: {
|
|
68
144
|
async completeWithTools() {
|
|
@@ -126,13 +202,20 @@ test('runRuntimeAgenticWorkflow replans after negative evaluation', async () =>
|
|
|
126
202
|
const agent = {
|
|
127
203
|
async invoke({ session: turnSession }) {
|
|
128
204
|
turns += 1;
|
|
205
|
+
if (turnSession.headlessPlan === null) {
|
|
206
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
207
|
+
origin: 'tool',
|
|
208
|
+
payload: { steps: [{ step: 1, id: 'initial', description: 'Initial work', status: 'done' }] },
|
|
209
|
+
}));
|
|
210
|
+
return { response: 'Initial done.' };
|
|
211
|
+
}
|
|
129
212
|
if (turnSession.headlessPlan?.[0]?.status === 'pending') {
|
|
130
213
|
dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
|
|
131
214
|
origin: 'tool',
|
|
132
215
|
payload: { step: 1, status: 'done' },
|
|
133
216
|
}));
|
|
134
217
|
}
|
|
135
|
-
return { response:
|
|
218
|
+
return { response: 'Export done.' };
|
|
136
219
|
},
|
|
137
220
|
};
|
|
138
221
|
|
|
@@ -175,6 +258,27 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
|
|
|
175
258
|
assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
|
|
176
259
|
});
|
|
177
260
|
|
|
261
|
+
test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
|
|
262
|
+
const session = {
|
|
263
|
+
activities: {},
|
|
264
|
+
headlessPlan: [{ step: 1, id: 'build', description: 'Build', status: 'failed' }],
|
|
265
|
+
llm: {
|
|
266
|
+
async completeWithTools() {
|
|
267
|
+
return { content: '{"steps":["Run production build"]}' };
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const result = await replanRuntimeRun(session, 'Build deliverable', {
|
|
273
|
+
kind: 'evaluation',
|
|
274
|
+
reason: 'Build missing.',
|
|
275
|
+
}, { runId: 'run-replan-approval', replansLeft: 1 });
|
|
276
|
+
|
|
277
|
+
assert.equal(result.ok, true);
|
|
278
|
+
assert.equal(session._runApprovalRequired, true);
|
|
279
|
+
assert.equal(session._runApprovalResolved, false);
|
|
280
|
+
});
|
|
281
|
+
|
|
178
282
|
test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
|
|
179
283
|
const originalFetch = globalThis.fetch;
|
|
180
284
|
let pollAttempts = 0;
|
|
@@ -276,7 +380,13 @@ test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async (
|
|
|
276
380
|
},
|
|
277
381
|
};
|
|
278
382
|
const agent = {
|
|
279
|
-
async invoke() {
|
|
383
|
+
async invoke({ session: turnSession }) {
|
|
384
|
+
if (turnSession.headlessPlan === null) {
|
|
385
|
+
dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
|
|
386
|
+
origin: 'tool',
|
|
387
|
+
payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
280
390
|
return { response: 'Done.' };
|
|
281
391
|
},
|
|
282
392
|
};
|
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 =
|
|
@@ -15,6 +14,15 @@ type StartupItem = {
|
|
|
15
14
|
detail: string;
|
|
16
15
|
};
|
|
17
16
|
|
|
17
|
+
const DONNA_LOGO = `
|
|
18
|
+
██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗
|
|
19
|
+
██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗
|
|
20
|
+
██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║
|
|
21
|
+
██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║
|
|
22
|
+
██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║
|
|
23
|
+
╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝
|
|
24
|
+
`.trim();
|
|
25
|
+
|
|
18
26
|
function statusDot(ready: boolean) {
|
|
19
27
|
return ready ? '●' : '○';
|
|
20
28
|
}
|
|
@@ -23,6 +31,58 @@ function initWorkspaceItem(detail: string): StartupItem {
|
|
|
23
31
|
return { action: 'init-workspace', label: 'Initialize workspace', detail };
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
function keyNameOf(key: any) {
|
|
35
|
+
return String(key?.name ?? key?.key ?? '').toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function keySequenceOf(key: any) {
|
|
39
|
+
return String(key?.sequence ?? key?.raw ?? key?.seq ?? key?.input ?? '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isEnterKey(key: any) {
|
|
43
|
+
const keyName = keyNameOf(key);
|
|
44
|
+
const sequence = keySequenceOf(key);
|
|
45
|
+
return Boolean(
|
|
46
|
+
key?.return ||
|
|
47
|
+
key?.enter ||
|
|
48
|
+
key?.linefeed ||
|
|
49
|
+
keyName === 'return' ||
|
|
50
|
+
keyName === 'enter' ||
|
|
51
|
+
keyName === 'linefeed' ||
|
|
52
|
+
keyName === 'kpenter' ||
|
|
53
|
+
keyName === 'keypadenter' ||
|
|
54
|
+
keyName === 'numenter' ||
|
|
55
|
+
(key?.ctrl && keyName === 'm') ||
|
|
56
|
+
sequence === '\r' ||
|
|
57
|
+
sequence === '\n' ||
|
|
58
|
+
sequence === '\r\n' ||
|
|
59
|
+
sequence === '\x1bOM' ||
|
|
60
|
+
/^\x1b\[13(?:;\d+)?u$/.test(sequence),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isEscapeKey(key: any) {
|
|
65
|
+
const keyName = keyNameOf(key);
|
|
66
|
+
return keyName === 'escape' || keySequenceOf(key) === '\x1b';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isUpKey(key: any) {
|
|
70
|
+
const keyName = keyNameOf(key);
|
|
71
|
+
const sequence = keySequenceOf(key);
|
|
72
|
+
return keyName === 'up' || keyName === 'arrow_up' || sequence === '\x1b[A' || /^\x1b\[57352(?:;\d+)?u$/.test(sequence);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isDownKey(key: any) {
|
|
76
|
+
const keyName = keyNameOf(key);
|
|
77
|
+
const sequence = keySequenceOf(key);
|
|
78
|
+
return keyName === 'down' || keyName === 'arrow_down' || sequence === '\x1b[B' || /^\x1b\[57353(?:;\d+)?u$/.test(sequence);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function consumeKey(key: any) {
|
|
82
|
+
key?.preventDefault?.();
|
|
83
|
+
key?.stopPropagation?.();
|
|
84
|
+
}
|
|
85
|
+
|
|
26
86
|
export function StartupScreen(props: {
|
|
27
87
|
version: string;
|
|
28
88
|
model: string;
|
|
@@ -34,13 +94,15 @@ export function StartupScreen(props: {
|
|
|
34
94
|
hasWorkspace: boolean;
|
|
35
95
|
width: number;
|
|
36
96
|
height: number;
|
|
97
|
+
keyboardEvent?: { id: number; key: any } | null;
|
|
37
98
|
onSelect: (action: StartupAction, workspaceName?: string) => void;
|
|
38
99
|
onQuit: () => void;
|
|
39
100
|
}) {
|
|
40
101
|
const [mode, setMode] = createSignal<'home' | 'workspace-select'>('home');
|
|
41
102
|
const [selected, setSelected] = createSignal(0);
|
|
42
|
-
|
|
43
|
-
const
|
|
103
|
+
let lastKeyboardEventId = 0;
|
|
104
|
+
const panelWidth = createMemo(() => Math.max(70, Math.min(96, props.width - 4)));
|
|
105
|
+
const panelHeight = createMemo(() => Math.max(27, Math.min(35, props.height - 2)));
|
|
44
106
|
const left = createMemo(() => Math.max(1, Math.floor((props.width - panelWidth()) / 2)));
|
|
45
107
|
const top = createMemo(() => Math.max(1, Math.floor((props.height - panelHeight()) / 2)));
|
|
46
108
|
|
|
@@ -74,9 +136,23 @@ export function StartupScreen(props: {
|
|
|
74
136
|
const currentLength = createMemo(() => mode() === 'workspace-select' ? workspaceItems().length : items().length);
|
|
75
137
|
|
|
76
138
|
const move = (delta: number) => {
|
|
77
|
-
|
|
139
|
+
const length = currentLength();
|
|
140
|
+
if (length <= 0) {
|
|
141
|
+
setSelected(0);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setSelected((value) => (value + delta + length) % length);
|
|
78
145
|
};
|
|
79
146
|
|
|
147
|
+
createEffect(() => {
|
|
148
|
+
const length = currentLength();
|
|
149
|
+
if (length <= 0) {
|
|
150
|
+
setSelected(0);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (selected() >= length) setSelected(length - 1);
|
|
154
|
+
});
|
|
155
|
+
|
|
80
156
|
const choose = (index = selected()) => {
|
|
81
157
|
if (mode() === 'workspace-select') {
|
|
82
158
|
const item = workspaceItems()[index];
|
|
@@ -93,38 +169,52 @@ export function StartupScreen(props: {
|
|
|
93
169
|
props.onSelect(item.action);
|
|
94
170
|
};
|
|
95
171
|
|
|
96
|
-
|
|
97
|
-
const keyName =
|
|
98
|
-
const sequence =
|
|
172
|
+
const handleKey = (key: any) => {
|
|
173
|
+
const keyName = keyNameOf(key);
|
|
174
|
+
const sequence = keySequenceOf(key);
|
|
99
175
|
if ((key.ctrl || key.meta) && keyName === 'c') {
|
|
176
|
+
consumeKey(key);
|
|
100
177
|
props.onQuit();
|
|
101
178
|
return;
|
|
102
179
|
}
|
|
103
|
-
if (
|
|
180
|
+
if (isEscapeKey(key) && mode() === 'workspace-select') {
|
|
181
|
+
consumeKey(key);
|
|
104
182
|
setMode('home');
|
|
105
183
|
setSelected(0);
|
|
106
184
|
return;
|
|
107
185
|
}
|
|
108
|
-
if (
|
|
186
|
+
if (isEscapeKey(key)) {
|
|
187
|
+
consumeKey(key);
|
|
109
188
|
props.onQuit();
|
|
110
189
|
return;
|
|
111
190
|
}
|
|
112
|
-
if (
|
|
113
|
-
|
|
191
|
+
if (isEnterKey(key)) {
|
|
192
|
+
consumeKey(key);
|
|
193
|
+
choose();
|
|
114
194
|
return;
|
|
115
195
|
}
|
|
116
|
-
if (
|
|
117
|
-
|
|
196
|
+
if (isUpKey(key)) {
|
|
197
|
+
consumeKey(key);
|
|
198
|
+
move(-1);
|
|
118
199
|
return;
|
|
119
200
|
}
|
|
120
|
-
if (
|
|
121
|
-
|
|
201
|
+
if (isDownKey(key)) {
|
|
202
|
+
consumeKey(key);
|
|
203
|
+
move(1);
|
|
122
204
|
return;
|
|
123
205
|
}
|
|
124
206
|
const numeric = Number.parseInt(sequence, 10);
|
|
125
207
|
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= currentLength()) {
|
|
208
|
+
consumeKey(key);
|
|
126
209
|
choose(numeric - 1);
|
|
127
210
|
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
createEffect(() => {
|
|
214
|
+
const event = props.keyboardEvent;
|
|
215
|
+
if (!event || event.id === lastKeyboardEventId) return;
|
|
216
|
+
lastKeyboardEventId = event.id;
|
|
217
|
+
untrack(() => handleKey(event.key));
|
|
128
218
|
});
|
|
129
219
|
|
|
130
220
|
const status = createMemo(() => {
|
|
@@ -144,7 +234,7 @@ export function StartupScreen(props: {
|
|
|
144
234
|
const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
|
|
145
235
|
return mode() === 'workspace-select'
|
|
146
236
|
? `↑/↓ select Enter open ${quick} Esc back`
|
|
147
|
-
: `↑/↓ select Enter
|
|
237
|
+
: `↑/↓ select Enter choose ${quick} Esc quit`;
|
|
148
238
|
});
|
|
149
239
|
const menuTitle = createMemo(() => mode() === 'workspace-select' ? 'Select workspace' : '');
|
|
150
240
|
|
|
@@ -170,12 +260,17 @@ export function StartupScreen(props: {
|
|
|
170
260
|
</box>
|
|
171
261
|
<text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
|
|
172
262
|
<text height={1}>{''}</text>
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
263
|
+
<box
|
|
264
|
+
height={8}
|
|
265
|
+
flexDirection="column"
|
|
266
|
+
justifyContent="center"
|
|
267
|
+
alignItems="center"
|
|
268
|
+
gap={1}
|
|
269
|
+
overflow="hidden"
|
|
270
|
+
>
|
|
271
|
+
<text fg="#d6a85f">{DONNA_LOGO}</text>
|
|
272
|
+
<text fg="#888888">Intelligent workspace</text>
|
|
273
|
+
</box>
|
|
179
274
|
<text height={1}>{''}</text>
|
|
180
275
|
<text height={1} fg="#7F8C8D" content={menuTitle()} />
|
|
181
276
|
<box height={Math.max(1, currentLength())} flexDirection="column">
|