@dotdrelle/wiki-manager 0.12.1 → 0.12.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/package.json +2 -2
- package/src/activity/activityAggregator.js +34 -3
- package/src/activity/activityAggregator.test.js +32 -0
- package/src/agent/graph.js +272 -22
- package/src/agent/graph.test.js +357 -1
- package/src/cli/wiki-manager.js +24 -1
- package/src/commands/slash.js +74 -1
- package/src/commands/slash.test.js +36 -0
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +9 -1
- package/src/core/agentEvents.js +30 -0
- package/src/core/agentEvents.test.js +42 -1
- package/src/core/buildInfo.js +58 -0
- package/src/core/buildInfo.json +4 -0
- package/src/core/buildInfo.test.js +14 -0
- package/src/core/mcp.js +21 -1
- package/src/core/mcp.test.js +25 -0
- package/src/core/runtimeLog.js +6 -1
- package/src/core/runtimeLog.test.js +3 -1
- package/src/runtime/client.js +22 -0
- package/src/runtime/controlMessages.js +2 -2
- package/src/runtime/recoveryManager.js +54 -0
- package/src/runtime/recoveryManager.test.js +73 -0
- package/src/runtime/runner.js +49 -13
- package/src/runtime/runner.test.js +196 -0
- package/src/runtime/server.js +67 -8
- package/src/runtime/server.test.js +223 -6
- package/src/runtime/store.js +48 -3
- package/src/runtime/store.test.js +32 -0
- package/src/runtime/supervisor.js +77 -1
- package/src/shell/RightPane.tsx +124 -31
- package/src/shell/SetupWizard.tsx +13 -1
- package/src/shell/repl.js +78 -9
- package/src/shell/repl.test.js +114 -1
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +32 -3
- package/src/shell/useSession.ts +14 -1
|
@@ -606,6 +606,38 @@ test('runtime store persists cancelled run status', () => {
|
|
|
606
606
|
store.close();
|
|
607
607
|
});
|
|
608
608
|
|
|
609
|
+
test('runtime store interrupts runs by runId and does not recover killed runs after reopen', () => {
|
|
610
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
611
|
+
const store = openRuntimeStore({ stateDir });
|
|
612
|
+
for (const runId of ['run-kill', 'run-keep']) {
|
|
613
|
+
store.persistEvent(createAgentEvent('run_started', {
|
|
614
|
+
origin: 'test',
|
|
615
|
+
runId,
|
|
616
|
+
workspace: 'docs',
|
|
617
|
+
payload: { input: runId, workspace: 'docs' },
|
|
618
|
+
}));
|
|
619
|
+
store.persistEvent(createAgentEvent('task.created', {
|
|
620
|
+
origin: 'test',
|
|
621
|
+
runId,
|
|
622
|
+
taskId: `${runId}:task`,
|
|
623
|
+
workspace: 'docs',
|
|
624
|
+
payload: { runId, task: { ...plannedTask(`${runId}:task`), status: 'running' } },
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
assert.equal(store.interruptRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
|
|
629
|
+
assert.equal(store.cancelActiveTasksForInterruptedRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
|
|
630
|
+
assert.equal(store.listRuns().find((run) => run.id === 'run-kill').status, 'interrupted');
|
|
631
|
+
assert.equal(store.listRuns().find((run) => run.id === 'run-keep').status, 'running');
|
|
632
|
+
assert.equal(store.listTasks({ runId: 'run-kill' })[0].status, 'cancelled');
|
|
633
|
+
assert.equal(store.listTasks({ runId: 'run-keep' })[0].status, 'running');
|
|
634
|
+
store.close();
|
|
635
|
+
|
|
636
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
637
|
+
assert.deepEqual(reopened.listRecoverableRuns({ workspace: 'docs' }).map((run) => run.id), ['run-keep']);
|
|
638
|
+
reopened.close();
|
|
639
|
+
});
|
|
640
|
+
|
|
609
641
|
test('runtime store migrates legacy databases without workspace columns', () => {
|
|
610
642
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
611
643
|
const db = new DatabaseSync(join(stateDir, 'runtime.db'));
|
|
@@ -98,8 +98,55 @@ export async function pollActivitiesOnce(session, {
|
|
|
98
98
|
payload: { activity: polledActivity },
|
|
99
99
|
}));
|
|
100
100
|
syncQueueWithActivity(session, polledActivity);
|
|
101
|
-
|
|
101
|
+
// Only log when something actually changed: the poll fires every
|
|
102
|
+
// second and an unchanged "activity: X -> running" line repeated
|
|
103
|
+
// dozens of times drowned Logs/Trace and looked like a frozen system.
|
|
104
|
+
// The line itself carries the operational signal (current file,
|
|
105
|
+
// percent, batch, last trace event, retry/backoff) so the panel
|
|
106
|
+
// reads as a narrative of the job, not a heartbeat.
|
|
107
|
+
const progress = payload?.progress ?? polledActivity.progress ?? {};
|
|
108
|
+
const percent = Number(progress.percent ?? polledActivity.progress?.percent);
|
|
109
|
+
const detail = progress.detail ?? progress.step ?? '';
|
|
110
|
+
// The trace's batchIndex is 0-based and the human detail already says
|
|
111
|
+
// "Batch 1/2" — printing both gave "Batch 1/2 · batch 0/2". Display
|
|
112
|
+
// 1-based, and only when the detail doesn't already carry it.
|
|
113
|
+
const batch = progress.batchIndex != null && progress.batchCount != null && !/batch/i.test(String(detail))
|
|
114
|
+
? `batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`
|
|
115
|
+
: null;
|
|
116
|
+
const lastEvent = progress.lastEvent ? String(progress.lastEvent) : null;
|
|
117
|
+
const retry = progress.retryAt
|
|
118
|
+
? `retry ${progress.retryAt}`
|
|
119
|
+
: (progress.waitMs ? `wait ${progress.waitMs}ms` : null);
|
|
120
|
+
const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}:${retry ?? ''}`;
|
|
121
|
+
session._activityLogKeys ??= {};
|
|
122
|
+
if (session._activityLogKeys[key] !== progressKey) {
|
|
123
|
+
session._activityLogKeys[key] = progressKey;
|
|
124
|
+
const progressLabel = [
|
|
125
|
+
Number.isFinite(percent) ? `${Math.round(percent)}%` : null,
|
|
126
|
+
detail ? String(detail) : null,
|
|
127
|
+
batch,
|
|
128
|
+
lastEvent,
|
|
129
|
+
retry,
|
|
130
|
+
].filter(Boolean).join(' · ');
|
|
131
|
+
emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}${progressLabel ? ` (${progressLabel})` : ''}`);
|
|
132
|
+
}
|
|
133
|
+
// Stream NEW job-log lines into Logs/Trace: document names, chunking,
|
|
134
|
+
// LLM call/token summaries and errors live in the job log, not in the
|
|
135
|
+
// status fields. Diff against the last line already shown.
|
|
136
|
+
const logTail = Array.isArray(payload?.logTail) ? payload.logTail.map(String) : [];
|
|
137
|
+
if (logTail.length > 0) {
|
|
138
|
+
session._activityLogCursors ??= {};
|
|
139
|
+
const lastSeen = session._activityLogCursors[key];
|
|
140
|
+
const lastIndex = lastSeen ? logTail.lastIndexOf(lastSeen) : -1;
|
|
141
|
+
for (const line of logTail.slice(lastIndex + 1)) {
|
|
142
|
+
const clean = line.trim();
|
|
143
|
+
if (clean) emitRuntimeLog(session, `job ${polledActivity.id ?? key}: ${clean}`);
|
|
144
|
+
}
|
|
145
|
+
session._activityLogCursors[key] = logTail.at(-1);
|
|
146
|
+
}
|
|
102
147
|
if (polledActivity.terminal) {
|
|
148
|
+
delete session._activityLogKeys[key];
|
|
149
|
+
delete session._activityLogCursors?.[key];
|
|
103
150
|
await startNextQueuedJob(session, {
|
|
104
151
|
addLog: (message) => emitRuntimeLog(session, message),
|
|
105
152
|
});
|
|
@@ -134,3 +181,32 @@ function registryIntervalFromEnv() {
|
|
|
134
181
|
const value = Number(process.env.WIKI_MANAGER_AGENT_REGISTRY_INTERVAL_MS);
|
|
135
182
|
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 60000;
|
|
136
183
|
}
|
|
184
|
+
|
|
185
|
+
// Cancelling a runtime run must also cancel the asynchronous jobs it started:
|
|
186
|
+
// the run's abort only stopped the manager-side loop while the agent kept
|
|
187
|
+
// executing (an ingest subprocess ran on for minutes after "cancel requested",
|
|
188
|
+
// with the panels frozen on its last known state). Best effort: prefer the
|
|
189
|
+
// orchestration contract's agent_cancel, fall back to legacy *_cancel_job.
|
|
190
|
+
export async function cancelActiveActivityJobs(session, { callTool = callMcpTool } = {}) {
|
|
191
|
+
const cancelled = [];
|
|
192
|
+
for (const activity of sessionActivities(session)) {
|
|
193
|
+
if (activity.terminal || !activity.poll) continue;
|
|
194
|
+
const jobId = activity.poll.args?.jobId ?? activity.id ?? null;
|
|
195
|
+
const server = activity.poll.server;
|
|
196
|
+
const endpoint = session.mcp?.[server];
|
|
197
|
+
if (!jobId || !endpoint || endpoint.status !== 'connected') continue;
|
|
198
|
+
const names = (endpoint.tools ?? []).map((tool) => String(tool.name ?? ''));
|
|
199
|
+
const cancelTool = names.find((name) => /(^|__)agent_cancel$/.test(name))
|
|
200
|
+
?? names.find((name) => /_cancel_job$/.test(name))
|
|
201
|
+
?? names.find((name) => /(^|__)[a-z]*_?cancel$/.test(name));
|
|
202
|
+
if (!cancelTool) continue;
|
|
203
|
+
try {
|
|
204
|
+
await callTool(session.mcp, server, cancelTool, { jobId });
|
|
205
|
+
emitRuntimeLog(session, `cancel: requested ${server}.${cancelTool} for job ${jobId}`);
|
|
206
|
+
cancelled.push({ server, jobId });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
emitRuntimeLog(session, `cancel: ${server}.${cancelTool} failed for ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return cancelled;
|
|
212
|
+
}
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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=
|
|
167
|
-
content={activity() ? fit(
|
|
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()
|
|
190
|
-
const
|
|
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
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
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
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|