@dotdrelle/wiki-manager 0.14.13 → 0.14.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +29 -4
  2. package/README.md +19 -0
  3. package/docker-compose.yml +6 -1
  4. package/package.json +1 -1
  5. package/src/activity/activityAggregator.js +50 -16
  6. package/src/activity/activityAggregator.test.js +67 -4
  7. package/src/agent/graph.js +79 -11
  8. package/src/agent/graph.test.js +43 -3
  9. package/src/cli/wiki-manager.js +74 -11
  10. package/src/cli/wiki-manager.test.js +40 -1
  11. package/src/commands/slash.js +10 -3
  12. package/src/commands/slash.test.js +24 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/dockerCompose.test.js +4 -0
  15. package/src/core/env.test.js +3 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/wikiSetup.js +35 -0
  18. package/src/core/wikiWorkspace.test.js +20 -0
  19. package/src/core/workflow.js +72 -0
  20. package/src/core/workflow.test.js +57 -0
  21. package/src/core/workspaces.js +10 -2
  22. package/src/orchestrator/dependencyResolver.js +19 -1
  23. package/src/orchestrator/objectiveResolver.js +24 -0
  24. package/src/orchestrator/objectiveResolver.test.js +23 -1
  25. package/src/orchestrator/scheduler.js +27 -7
  26. package/src/orchestrator/scheduler.test.js +45 -1
  27. package/src/runtime/auth.test.js +65 -1
  28. package/src/runtime/client.js +4 -0
  29. package/src/runtime/donna-contract.test.js +2 -0
  30. package/src/runtime/lifecycle.js +21 -12
  31. package/src/runtime/runner.js +141 -18
  32. package/src/runtime/runner.test.js +30 -0
  33. package/src/runtime/server.js +13 -2
  34. package/src/runtime/server.test.js +30 -1
  35. package/src/runtime/store.js +54 -0
  36. package/src/runtime/store.test.js +42 -0
  37. package/src/shell/FileEditorDialog.tsx +2 -2
  38. package/src/shell/LeftPane.tsx +60 -15
  39. package/src/shell/RightPane.tsx +168 -56
  40. package/src/shell/StartupScreen.tsx +3 -7
  41. package/src/shell/renderer.ts +1 -0
  42. package/src/shell/repl.js +7 -81
  43. package/src/shell/repl.test.js +141 -38
  44. package/src/shell/tui.tsx +65 -65
  45. package/src/shell/useSession.ts +92 -6
  46. package/wiki-workspace +28 -0
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { createMemo, Index, Show } from 'solid-js';
2
+ import { createMemo, createSignal, Index, Show } from 'solid-js';
3
3
  import { filterRuntimeLogs } from '../core/runtimeLog.js';
4
4
  import { fit } from './textFit';
5
5
 
@@ -19,7 +19,6 @@ type LogLineParts = { time: string | null; message: string };
19
19
  // 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
20
20
  // wrapped status/error), so fewer, readable entries beat more, truncated ones.
21
21
  const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
22
- const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
23
22
  const PLAN_VIEWPORT_ROWS = 12;
24
23
 
25
24
  function wrapLine(value: string, width: number) {
@@ -70,13 +69,59 @@ function queueColor(status: string) {
70
69
  return activityColor(status);
71
70
  }
72
71
 
73
- function activityLine(activity: any) {
74
- const detail = activity?.progress?.detail ?? null;
75
- const phase = activity?.progress?.step ?? activity?.progress?.phase ?? activity?.progress?.currentStep ?? null;
76
- // On failure the error message is the information that matters — surface
77
- // it instead of repeating the bare status the label color already conveys.
72
+ function activityDetailText(activity: any) {
78
73
  const error = activityErrorText(activity);
79
- return [activity?.status ?? 'unknown', error ?? detail ?? phase].filter(Boolean).join(' · ');
74
+ if (error) return error;
75
+ const progress = activity?.progress ?? {};
76
+ const stepIndex = Number(progress.stepIndex);
77
+ const stepTotal = Number(progress.stepTotal);
78
+ const batchIndex = Number(progress.batchIndex);
79
+ const batchCount = Number(progress.batchCount);
80
+ const instructionCount = Number(progress.instructionCount);
81
+ const taskIndex = Number(progress.taskIndex);
82
+ const taskTotal = Number(progress.taskTotal);
83
+ const taskProgress = Number.isFinite(taskIndex) && Number.isFinite(taskTotal) && taskTotal > 0
84
+ ? `Task ${Math.max(1, taskIndex)}/${taskTotal}`
85
+ : null;
86
+ const stepProgress = Number.isFinite(stepIndex) && Number.isFinite(stepTotal) && stepTotal > 0
87
+ ? `Step ${Math.max(1, stepIndex)}/${stepTotal}`
88
+ : null;
89
+ const batchProgress = Number.isFinite(batchIndex) && Number.isFinite(batchCount) && batchCount > 0
90
+ ? `Batch ${Math.min(batchCount, Math.max(1, batchIndex + 1))}/${batchCount}`
91
+ : null;
92
+ const instructions = Number.isFinite(instructionCount) && instructionCount > 0
93
+ ? `${instructionCount} instructions`
94
+ : null;
95
+ const detail = batchProgress && /^batch\s+\d+\/\d+/i.test(String(progress.detail ?? ''))
96
+ ? null
97
+ : progress.detail;
98
+ const stabilize = [
99
+ ['kept', progress.stabilizeKept],
100
+ ['merged', progress.stabilizeMerged],
101
+ ['inserted', progress.stabilizeInserted],
102
+ ['removed', progress.stabilizeRemoved],
103
+ ].filter(([, value]) => Number.isFinite(Number(value)))
104
+ .map(([label, value]) => `${label} ${Number(value)}`)
105
+ .join(', ');
106
+ const values = [
107
+ taskProgress,
108
+ stepProgress,
109
+ batchProgress,
110
+ progress.label,
111
+ detail,
112
+ instructions,
113
+ stabilize || null,
114
+ progress.currentStep ?? progress.step ?? progress.phase,
115
+ ].map((value) => String(value ?? '').trim()).filter(Boolean);
116
+ return [...new Set(values)].join(' · ');
117
+ }
118
+
119
+ function activityDisplayLabel(activity: any) {
120
+ const businessLabel = activity?._aggregated ? activity?.progress?.label : null;
121
+ const raw = String(businessLabel ?? activity?.label ?? `${activity?.source ?? 'mcp'} ${activity?.kind ?? 'job'}`);
122
+ // The percentage has its own right-aligned badge; do not repeat it in the
123
+ // label (aggregate activities often arrive as "knowledge.update - 85 %").
124
+ return raw.replace(/\s*[-–—·]\s*\d+\s*%\s*$/, '').trim();
80
125
  }
81
126
 
82
127
  function activityErrorText(activity: any): string | null {
@@ -95,18 +140,22 @@ function activityPercentBadge(activity: any): { text: string; bg: string; fg: st
95
140
  return { text: ` ${pct}% `, bg: '#1e3a5f', fg: '#93c5fd' };
96
141
  }
97
142
 
98
- function updatedLine(activity: any) {
99
- const source = activity?.source ?? 'mcp';
100
- const id = activity?.id ? `#${activity.id}` : null;
101
- if (!activity?.updatedAt) return [source, id].filter(Boolean).join(' ');
102
- const age = Math.max(0, Math.round((Date.now() - Date.parse(activity.updatedAt)) / 1000));
103
- return [source, id, `${age}s ago`].filter(Boolean).join(' · ');
104
- }
143
+ // Single source of truth for terminal-status aliases so a plan step's icon and
144
+ // its color can never disagree (e.g. a 'succeeded' step showing done-green but a
145
+ // pending icon). Shared by planStepColor and PlanPanel's icon().
146
+ const DONE_STATUSES = ['done', 'complete', 'completed', 'success', 'succeeded'];
147
+ const FAILED_STATUSES = ['failed', 'error'];
148
+ // A task blocked on a human decision — distinct from both running and pending,
149
+ // so it must not render as a neutral gray "[ ]" (indistinguishable from
150
+ // not-started) nor as a running step.
151
+ const APPROVAL_STATUSES = ['pending_approval', 'waiting_approval'];
105
152
 
106
153
  function planStepColor(step: PlanStep, firstPendingStep: number | null) {
107
- if (step.status === 'done') return '#8BD5CA';
108
- if (step.status === 'failed') return '#F38BA8';
109
- if (step.status === 'running') return '#89B4FA';
154
+ const status = String(step.status ?? '').toLowerCase();
155
+ if (DONE_STATUSES.includes(status)) return '#8BD5CA';
156
+ if (FAILED_STATUSES.includes(status)) return '#F38BA8';
157
+ if (APPROVAL_STATUSES.includes(status)) return '#FBBF24';
158
+ if (status === 'running') return '#89B4FA';
110
159
  if (step.step === firstPendingStep) return '#89B4FA';
111
160
  return '#7F8C8D';
112
161
  }
@@ -131,12 +180,20 @@ function queueSummary(item: QueueItem) {
131
180
  return parts.join(' ');
132
181
  }
133
182
 
134
- export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string }) {
183
+ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string; summary?: string | null; spinnerFrame?: string }) {
135
184
  // Keep one column for the native vertical scrollbar when the plan is long.
136
185
  const lineWidth = () => Math.max(8, props.width - 3);
137
186
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
138
- const icon = (status: string) =>
139
- status === 'done' ? '[✓]' : status === 'failed' ? '[✗]' : status === 'running' ? '[…]' : '[ ]';
187
+ const icon = (rawStatus: string) => {
188
+ const status = String(rawStatus ?? '').toLowerCase();
189
+ if (DONE_STATUSES.includes(status)) return '[✓]';
190
+ if (FAILED_STATUSES.includes(status)) return '[✗]';
191
+ if (APPROVAL_STATUSES.includes(status)) return '[⏸]';
192
+ // Animated braille spinner on the running step: a long production job (LLM
193
+ // building a template) otherwise leaves the whole plan visually frozen. Same
194
+ // frames as the chat spinner. Falls back to a static glyph if not supplied.
195
+ return status === 'running' ? `[${props.spinnerFrame ?? '…'}]` : '[ ]';
196
+ };
140
197
  const visualRows = createMemo(() => props.plan.reduce((total, step) =>
141
198
  total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
142
199
  const title = () => {
@@ -144,11 +201,20 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
144
201
  return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
145
202
  };
146
203
  const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
204
+ const summaryLines = () => props.summary ? wrapLine(props.summary, lineWidth()).slice(0, 2) : [];
147
205
  return (
148
206
  <box flexShrink={0} flexDirection="column" padding={1}>
149
207
  <text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
208
+ <Show when={summaryLines().length > 0}>
209
+ <box flexShrink={0} flexDirection="column">
210
+ <Index each={summaryLines()}>
211
+ {(line) => <text width={lineWidth()} fg="#89B4FA" content={line()} />}
212
+ </Index>
213
+ </box>
214
+ </Show>
150
215
  <scrollbox
151
216
  height={viewportRows()}
217
+ focusable={false}
152
218
  scrollY={true}
153
219
  scrollX={false}
154
220
  stickyStart="top"
@@ -178,12 +244,13 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
178
244
  export function ActivityPanel(props: { activities: any[]; width: number }) {
179
245
  const lineWidth = () => Math.max(8, props.width - 2);
180
246
  const visible = () => props.activities.slice(-ACTIVITY_SLOTS.length).reverse();
247
+ const visibleSlots = () => visible().map((_activity, index) => index);
181
248
  const activityAt = (index: number) => visible()[index] ?? null;
182
249
  return (
183
- <box flexShrink={0} flexDirection="column" padding={1}>
250
+ <box flexShrink={0} flexDirection="column" paddingX={1}>
184
251
  <text width={lineWidth()} fg="#D6DEE8" content="Activity" />
185
252
  <Show when={visible().length > 0} fallback={<text width={lineWidth()} fg="#7F8C8D" content="no active jobs" />}>
186
- <Index each={ACTIVITY_SLOTS}>
253
+ <Index each={visibleSlots()}>
187
254
  {(slot) => {
188
255
  const activity = () => activityAt(slot());
189
256
  // Wrap instead of hard-truncating: a 40-column pane cut labels to
@@ -193,8 +260,8 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
193
260
  const labelLines = () => {
194
261
  const item = activity();
195
262
  if (!item) return [''];
196
- const label = String(item.progress?.label ?? item.label ?? `${item.source ?? 'mcp'} ${item.kind ?? 'job'}`);
197
- const lines = wrapLine(label, lineWidth()).slice(0, 2);
263
+ const label = activityDisplayLabel(item);
264
+ const lines = wrapLine(label, Math.max(8, lineWidth() - badgeLen())).slice(0, 2);
198
265
  if (lines.length === 2 && String(label).length > lines[0].length + lines[1].length) {
199
266
  lines[1] = fit(lines[1], lineWidth());
200
267
  }
@@ -202,23 +269,19 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
202
269
  };
203
270
  const badge = () => activityPercentBadge(activity());
204
271
  const badgeLen = () => badge()?.text.length ?? 0;
205
- const statusLines = () => {
272
+ const detailLines = () => {
206
273
  const item = activity();
207
- if (!item) return [''];
208
- return wrapLine(activityLine(item), Math.max(8, lineWidth() - badgeLen())).slice(0, 2);
274
+ if (!item) return [];
275
+ return wrapLine(activityDetailText(item), lineWidth()).slice(0, 2);
209
276
  };
210
277
  const statusColor = () => (activityErrorText(activity()) ? '#F38BA8' : '#AAB7C4');
211
278
  return (
212
- <box flexDirection="column" marginTop={slot() === 0 ? 1 : 0}>
213
- <text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[0]} />
214
- <Show when={labelLines()[1]}>
215
- <text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[1]} />
216
- </Show>
279
+ <box flexDirection="column">
217
280
  <box height={1} flexDirection="row">
218
281
  <text
219
282
  width={lineWidth() - badgeLen()}
220
- fg={statusColor()}
221
- content={activity() ? fit(statusLines()[0], lineWidth() - badgeLen()) : ''}
283
+ fg={activityColor(activity()?.status)}
284
+ content={fit(labelLines()[0], lineWidth() - badgeLen())}
222
285
  />
223
286
  <text
224
287
  width={badgeLen()}
@@ -227,10 +290,12 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
227
290
  content={badge()?.text ?? ''}
228
291
  />
229
292
  </box>
230
- <Show when={statusLines()[1]}>
231
- <text width={lineWidth()} fg={statusColor()} content={fit(statusLines()[1], lineWidth())} />
293
+ <Show when={labelLines()[1]}>
294
+ <text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[1]} />
232
295
  </Show>
233
- <text width={lineWidth()} fg="#7F8C8D" content={activity() ? fit(updatedLine(activity()), lineWidth()) : ''} />
296
+ <Index each={detailLines()}>
297
+ {(line) => <text width={lineWidth()} fg={statusColor()} content={fit(line(), lineWidth())} />}
298
+ </Index>
234
299
  </box>
235
300
  );
236
301
  }}
@@ -254,9 +319,9 @@ function logMessageColor(message: string): string {
254
319
  }
255
320
 
256
321
  function logRenderLines(logs: string[], width: number): LogSegment[][] {
257
- // Newest entry FIRST (descending): the freshest information belongs at the
258
- // top of the pane. Wrapped continuation lines stay attached below their
259
- // entry's first line.
322
+ // Runtime history arrives oldest-first. Reverse complete entry blocks once
323
+ // so the newest event is at the top while wrapped continuation lines remain
324
+ // attached below their own entry.
260
325
  const blocks: LogSegment[][][] = [];
261
326
  for (const raw of logs) {
262
327
  blocks.push(logEntryLines(raw, width));
@@ -269,11 +334,12 @@ function logEntryLines(raw: string, width: number): LogSegment[][] {
269
334
  for (const item of [raw]) {
270
335
  const rawLine = item;
271
336
  const sourceMatch = String(rawLine).match(/^(runtime)\s+(.*)$/);
272
- const source = sourceMatch ? sourceMatch[1] : null;
273
337
  const rest = sourceMatch ? sourceMatch[2] : String(rawLine);
274
338
  const parts = logLineParts(rest);
275
339
  const prefix: LogSegment[] = [];
276
- if (source) prefix.push({ text: `${source} `, fg: '#C6A0F6' });
340
+ // This pane already contains runtime logs exclusively. Repeating the word
341
+ // "runtime" on every row consumed ~8 columns and squeezed the useful
342
+ // message into the middle of narrow terminals.
277
343
  if (parts.time) prefix.push({ text: `${parts.time} `, fg: '#89B4FA' });
278
344
  const prefixLength = prefix.reduce((total, segment) => total + segment.text.length, 0);
279
345
  const messageColor = logMessageColor(parts.message);
@@ -291,17 +357,44 @@ function logEntryLines(raw: string, width: number): LogSegment[][] {
291
357
  }
292
358
 
293
359
  export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
360
+ const [activeLogTab, setActiveLogTab] = createSignal<'flow' | 'agent-status'>('flow');
294
361
  const lineWidth = () => Math.max(8, props.width - 2);
295
- const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '');
296
- const visibleLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()).slice(0, 24));
297
- const segmentsAt = (index: number): LogSegment[] => visibleLines()[index] ?? [];
362
+ const isAgentStatus = (line: string) => /agent[_ -]?status/i.test(line);
363
+ // Filtering preserves the runtime history order. logRenderLines performs
364
+ // the single block-level reversal shared by both tabs.
365
+ const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '')
366
+ .filter((line) => activeLogTab() === 'agent-status' ? isAgentStatus(line) : !isAgentStatus(line));
367
+ const allLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()));
298
368
  return (
299
- <box flexGrow={1} flexDirection="column" padding={1}>
300
- <text width={lineWidth()} fg="#D6DEE8" content="Logs / Trace" />
301
- <box flexGrow={1} flexDirection="column" overflow="hidden">
302
- <Index each={LOG_SLOTS}>
303
- {(slot) => {
304
- const segments = () => segmentsAt(slot());
369
+ <box flexGrow={2} flexDirection="column" paddingX={1} focusable={false}>
370
+ <text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
371
+ <box height={1} flexDirection="row">
372
+ <text
373
+ fg={activeLogTab() === 'flow' ? '#0B1020' : '#D6DEE8'}
374
+ bg={activeLogTab() === 'flow' ? '#89B4FA' : undefined}
375
+ content=" Runtime "
376
+ onMouseUp={() => setActiveLogTab('flow')}
377
+ />
378
+ <text fg="#4B5563" content=" " />
379
+ <text
380
+ fg={activeLogTab() === 'agent-status' ? '#0B1020' : '#D6DEE8'}
381
+ bg={activeLogTab() === 'agent-status' ? '#FBBF24' : undefined}
382
+ content=" Agent status "
383
+ onMouseUp={() => setActiveLogTab('agent-status')}
384
+ />
385
+ </box>
386
+ <scrollbox
387
+ flexGrow={1}
388
+ focusable={false}
389
+ scrollY={true}
390
+ scrollX={false}
391
+ stickyStart="top"
392
+ viewportCulling={true}
393
+ verticalScrollbarOptions={{ visible: allLines().length > 0 }}
394
+ >
395
+ <Index each={allLines()}>
396
+ {(line) => {
397
+ const segments = () => line();
305
398
  const segmentAt = (position: number): LogSegment => segments()[position] ?? { text: '', fg: '#AAB7C4' };
306
399
  const usedWidth = (upTo: number) => segments().slice(0, upTo).reduce((total, segment) => total + segment.text.length, 0);
307
400
  const widthFor = (position: number) => {
@@ -313,7 +406,7 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
313
406
  : Math.min(segment.text.length, lineWidth() - usedWidth(position));
314
407
  };
315
408
  return (
316
- <box height={1} flexDirection="row" overflow="hidden">
409
+ <box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
317
410
  <text width={widthFor(0)} fg={segmentAt(0).fg} content={segmentAt(0).text} />
318
411
  <text width={widthFor(1)} fg={segmentAt(1).fg} content={segmentAt(1).text} />
319
412
  <text width={widthFor(2)} fg={segmentAt(2).fg} content={segmentAt(2).text} />
@@ -321,7 +414,7 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
321
414
  );
322
415
  }}
323
416
  </Index>
324
- </box>
417
+ </scrollbox>
325
418
  </box>
326
419
  );
327
420
  }
@@ -393,22 +486,41 @@ export function RightPane(props: {
393
486
  activities: any[];
394
487
  logs: string[];
395
488
  plan: PlanStep[] | null;
489
+ runSummary?: string | null;
396
490
  queueItems: QueueItem[];
397
491
  queueInfo: QueueInfo;
398
492
  activeTab: 'plan' | 'queue';
399
493
  logFilter?: string;
494
+ pendingApprovals: any[];
495
+ onApprove: () => void;
400
496
  onTabClick: (tab: 'plan' | 'queue') => void;
497
+ spinnerFrame?: string;
401
498
  }) {
402
499
  const planJobName = () => activityJobName(
403
500
  [...props.activities].reverse().find((activity) => !activity.terminal) ?? props.activities.at(-1),
404
501
  );
405
502
  return (
406
- <box width={props.width} height="100%" flexDirection="column" gap={1} padding={1} overflow="hidden">
503
+ <box
504
+ width={props.width}
505
+ height="100%"
506
+ flexDirection="column"
507
+ paddingLeft={1}
508
+ paddingTop={1}
509
+ paddingBottom={1}
510
+ overflow="hidden"
511
+ focusable={false}
512
+ >
407
513
  <TabHeader active={props.activeTab} queueCount={props.queueInfo.active} width={props.width} onTabClick={props.onTabClick} />
514
+ <Show when={props.pendingApprovals.length > 0}>
515
+ <box height={2} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#FBBF24" paddingX={1}>
516
+ <text fg="#FBBF24" content={`${props.pendingApprovals.length} approbation(s) requise(s)`} />
517
+ <text fg="#0B1020" bg="#FBBF24" content=" Approuver le run " onMouseUp={props.onApprove} />
518
+ </box>
519
+ </Show>
408
520
  <Show when={props.activeTab === 'queue'} fallback={(
409
521
  <>
410
522
  <Show when={props.plan && props.plan.length > 0}>
411
- <PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} />
523
+ <PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} summary={props.runSummary} spinnerFrame={props.spinnerFrame} />
412
524
  </Show>
413
525
  <ActivityPanel width={props.width} activities={props.activities} />
414
526
  </>
@@ -7,9 +7,7 @@ export type StartupAction =
7
7
  | 'open-workspace'
8
8
  | 'run-workflow'
9
9
  | 'init-workspace'
10
- | 'retry-preflight'
11
- | 'start-services'
12
- | 'open-logs';
10
+ | 'retry-preflight';
13
11
 
14
12
  type StartupItem = {
15
13
  action: StartupAction;
@@ -115,7 +113,7 @@ export function StartupScreen(props: {
115
113
  if (!props.hasWorkspace) {
116
114
  return [initWorkspaceItem('Create and configure the default workspace')];
117
115
  }
118
- const base = [
116
+ const base: StartupItem[] = [
119
117
  {
120
118
  action: 'new-conversation',
121
119
  label: 'Start a new conversation',
@@ -124,7 +122,7 @@ export function StartupScreen(props: {
124
122
  {
125
123
  action: 'open-workspace',
126
124
  label: 'Open workspace',
127
- detail: 'Select, load, then start agents and services',
125
+ detail: 'Select and load a workspace',
128
126
  },
129
127
  initWorkspaceItem('Create and configure a new workspace'),
130
128
  {
@@ -135,8 +133,6 @@ export function StartupScreen(props: {
135
133
  ];
136
134
  if (props.preflight?.status === 'degraded') {
137
135
  base.push({ action: 'retry-preflight', label: 'Retry pending checks', detail: 'Recheck Docker, Internet, containers and MCP' });
138
- base.push({ action: 'start-services', label: 'Start services', detail: 'Start agents and workspace containers, then recheck' });
139
- base.push({ action: 'open-logs', label: 'Open diagnostics', detail: 'Show service logs and detailed MCP status' });
140
136
  }
141
137
  return base;
142
138
  });
@@ -25,6 +25,7 @@ export function renderPlainMarkdown(value: string) {
25
25
  .replace(/\*\*([^*]+)\*\*/g, '$1')
26
26
  .replace(/__([^_]+)__/g, '$1')
27
27
  .replace(/`([^`]+)`/g, '$1')
28
+ .replace(/<((?:https?):\/\/[^>\s]+)>/g, '$1')
28
29
  .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
29
30
  // collapse consecutive blank lines left by removed elements
30
31
  .replace(/\n{3,}/g, '\n\n')
package/src/shell/repl.js CHANGED
@@ -822,73 +822,8 @@ function rememberProductionActivity(session, payload) {
822
822
  return true;
823
823
  }
824
824
 
825
- const TERMINAL_PLAN_STATUSES = new Set(['done', 'completed', 'succeeded', 'success', 'failed', 'error', 'cancelled', 'canceled']);
826
-
827
- function planTaskKey(task, index) {
828
- return String(task?.id ?? task?.step ?? `task-${index + 1}`);
829
- }
830
-
831
- function errorDescription(value) {
832
- if (!value) return null;
833
- if (typeof value === 'string') return value.trim() || null;
834
- if (value instanceof Error) return value.message;
835
- if (typeof value === 'object') {
836
- return errorDescription(value.message ?? value.reason ?? value.detail ?? value.description ?? value.error)
837
- ?? (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
838
- }
839
- return String(value);
840
- }
841
-
842
- function planTaskError(task, state) {
843
- const activityKey = task?.activityKey ?? task?._activityKey ?? task?.ownerActivityKey;
844
- const activity = (state?.activities ?? []).find((item) =>
845
- (activityKey && String(item?.key) === String(activityKey))
846
- || (task?.id != null && String(item?.taskId ?? '') === String(task.id)));
847
- return errorDescription(
848
- task?.error
849
- ?? task?.result?.error
850
- ?? task?.result?.reason
851
- ?? activity?.error
852
- ?? activity?.detail
853
- ?? (['failed', 'error'].includes(String(state?.status ?? '').toLowerCase()) ? state?.evaluation?.reason : null),
854
- );
855
- }
856
-
857
- export function appendRuntimePlanCompletionMessages(session, state) {
858
- const plan = Array.isArray(state?.plan) ? state.plan : [];
859
- const runId = String(state?.runId ?? 'runtime');
860
- const current = new Map(plan.map((task, index) => [planTaskKey(task, index), String(task?.status ?? 'pending').toLowerCase()]));
861
- if (session._runtimePlanRunId !== runId || !(session._runtimePlanStatuses instanceof Map)) {
862
- session._runtimePlanRunId = runId;
863
- session._runtimePlanStatuses = current;
864
- return 0;
865
- }
866
-
867
- let appended = 0;
868
- plan.forEach((task, index) => {
869
- const key = planTaskKey(task, index);
870
- const status = current.get(key);
871
- const previous = session._runtimePlanStatuses.get(key);
872
- if (!TERMINAL_PLAN_STATUSES.has(status) || TERMINAL_PLAN_STATUSES.has(previous)) return;
873
- const label = String(task?.label ?? task?.description ?? task?.name ?? `Job ${key}`);
874
- const failed = status === 'failed' || status === 'error';
875
- const cancelled = status === 'cancelled' || status === 'canceled';
876
- const error = failed ? planTaskError(task, state) : null;
877
- const content = failed
878
- ? `✗ Job terminé en erreur : ${label}\nStatus: ${status}${error ? `\nErreur: ${error}` : ''}`
879
- : cancelled
880
- ? `○ Job terminé : ${label}\nStatus: ${status}`
881
- : `✓ Job terminé : ${label}\nStatus: ${status}`;
882
- conversationMessages(session).push({ role: 'command', content });
883
- appended += 1;
884
- });
885
- session._runtimePlanStatuses = current;
886
- return appended;
887
- }
888
-
889
825
  export function applyRuntimeStateToShellSession(session, state) {
890
826
  if (!state || typeof state !== 'object') return false;
891
- appendRuntimePlanCompletionMessages(session, state);
892
827
  const displayState = sanitizeRuntimeStateForDisplay(state);
893
828
  session.agentProjection = {
894
829
  conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
@@ -937,21 +872,9 @@ export function applyRuntimeStateToShellSession(session, state) {
937
872
 
938
873
  export function sanitizeRuntimeStateForDisplay(state) {
939
874
  if (!state || typeof state !== 'object') return state;
940
- const status = String(state.status ?? 'idle').toLowerCase();
941
- const visible = ['running', 'queued', 'pending', 'waiting', 'pending_approval', 'error', 'failed']
942
- .includes(status);
943
- if (visible) return state;
944
- return {
945
- ...state,
946
- conversation: [],
947
- chain: [],
948
- plan: [],
949
- activities: [],
950
- workflow: null,
951
- logs: [],
952
- summary: null,
953
- planPatches: [],
954
- };
875
+ // Keep the last terminal run visible for post-run inspection. A new
876
+ // run_started event replaces these fields when the next run begins.
877
+ return state;
955
878
  }
956
879
 
957
880
  // In agent mode, Donna receives every free-text turn and decides whether to
@@ -1467,7 +1390,10 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1467
1390
  onUpdate?.();
1468
1391
  return { exit: false, oneShotAgent: true };
1469
1392
  }
1470
- conversationMessages(session).push({ role: 'user', content: oneShotAgentInput });
1393
+ // The runtime persists and returns this same user message through /state.
1394
+ // Mark the optimistic local copy so useSession can bind that runtime entry
1395
+ // to it instead of appending a duplicate after `/agent <instruction>`.
1396
+ conversationMessages(session).push({ role: 'user', content: oneShotAgentInput, _pending: true });
1471
1397
  onUpdate?.();
1472
1398
  const outcome = await submitRuntimeRun(oneShotAgentInput, { runtime, session });
1473
1399
  applyRuntimeOutcome(session, outcome, (msg) => onStep?.(msg));