@dotdrelle/wiki-manager 0.14.12 → 0.14.16

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 (43) hide show
  1. package/.env.example +11 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/src/activity/activityAggregator.js +43 -13
  5. package/src/activity/activityAggregator.test.js +51 -2
  6. package/src/agent/graph.js +79 -11
  7. package/src/agent/graph.test.js +43 -3
  8. package/src/cli/wiki-manager.js +44 -3
  9. package/src/commands/slash.js +10 -3
  10. package/src/commands/slash.test.js +24 -0
  11. package/src/core/activity.js +4 -0
  12. package/src/core/activity.test.js +31 -0
  13. package/src/core/agentEvents.js +11 -2
  14. package/src/core/buildInfo.json +2 -2
  15. package/src/core/dockerCompose.test.js +4 -0
  16. package/src/core/env.js +16 -4
  17. package/src/core/env.test.js +3 -0
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/wikiSetup.js +43 -1
  20. package/src/core/wikiWorkspace.test.js +20 -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.test.js +22 -1
  26. package/src/runtime/auth.test.js +65 -1
  27. package/src/runtime/client.js +4 -0
  28. package/src/runtime/donna-contract.test.js +2 -0
  29. package/src/runtime/lifecycle.js +21 -12
  30. package/src/runtime/runner.js +130 -17
  31. package/src/runtime/runner.test.js +62 -1
  32. package/src/runtime/server.js +13 -2
  33. package/src/runtime/server.test.js +30 -1
  34. package/src/shell/FileEditorDialog.tsx +2 -2
  35. package/src/shell/LeftPane.tsx +60 -15
  36. package/src/shell/RightPane.tsx +147 -54
  37. package/src/shell/StartupScreen.tsx +3 -7
  38. package/src/shell/renderer.ts +1 -0
  39. package/src/shell/repl.js +7 -81
  40. package/src/shell/repl.test.js +128 -38
  41. package/src/shell/tui.tsx +59 -64
  42. package/src/shell/useSession.ts +45 -6
  43. package/wiki-workspace +36 -3
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  applyRuntimeStateToShellSession,
8
- appendRuntimePlanCompletionMessages,
9
8
  chatReadTools,
10
9
  createSession,
11
10
  runHeadlessChatTurn,
@@ -20,8 +19,109 @@ import {
20
19
  shouldHandleFreeTextLocally,
21
20
  submitRuntimeRun,
22
21
  } from './repl.js';
22
+ import { readFile } from 'node:fs/promises';
23
23
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
24
24
 
25
+ test('ShellUI inserts StyledText as a child instead of stringifying it through content', async () => {
26
+ const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
27
+ assert.doesNotMatch(source, /content=\{styledSegments\(/);
28
+ assert.match(source, /<text[^>]*>\{styledSegments\(line\.segments\)\}<\/text>/);
29
+ });
30
+
31
+ test('ShellUI renders the user header full-width before constraining only its body', async () => {
32
+ const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
33
+ const headerBranch = source.indexOf("line.copyContent !== undefined ? (");
34
+ const userBodyBranch = source.indexOf("line.role === 'user' && line.userBubbleWidth ? (", headerBranch);
35
+ assert.ok(headerBranch >= 0 && userBodyBranch > headerBranch);
36
+ assert.match(source, /const userBubbleWidth = message\.role === 'user'/);
37
+ assert.match(source, /Math\.floor\(columns \* 0\.6\)/);
38
+ assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.5\)/);
39
+ assert.match(source.slice(userBodyBranch), /width=\{line\.userBubbleWidth\}/);
40
+ assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
41
+ assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
42
+ assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
43
+ assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
44
+ });
45
+
46
+ test('ShellUI renders newest-first order in both Runtime and Agent status', async () => {
47
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
48
+ const filteredLogs = source.slice(
49
+ source.indexOf('const filteredLogs ='),
50
+ source.indexOf('const allLines ='),
51
+ );
52
+ assert.match(filteredLogs, /activeLogTab\(\) === 'agent-status'/);
53
+ assert.match(filteredLogs, /isAgentStatus\(line\)/);
54
+ assert.doesNotMatch(filteredLogs, /\.reverse\(\)/);
55
+ const renderedLogs = source.slice(
56
+ source.indexOf('function logRenderLines'),
57
+ source.indexOf('function logEntryLines'),
58
+ );
59
+ assert.match(renderedLogs, /return blocks\.reverse\(\)\.flat\(\)/);
60
+ });
61
+
62
+ test('Activity uses only visible jobs and leaves remaining height to Flow/Trace', async () => {
63
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
64
+ const activityPanel = source.slice(
65
+ source.indexOf('export function ActivityPanel'),
66
+ source.indexOf('type LogSegment'),
67
+ );
68
+ assert.match(activityPanel, /<Index each=\{visibleSlots\(\)\}>/);
69
+ assert.doesNotMatch(activityPanel, /<Index each=\{ACTIVITY_SLOTS\}>/);
70
+ assert.match(activityPanel, /paddingX=\{1\}/);
71
+ assert.doesNotMatch(activityPanel, /updatedLine\(activity\(\)\)/);
72
+ assert.doesNotMatch(activityPanel, /marginTop=/);
73
+ const logPanel = source.slice(
74
+ source.indexOf('export function LogPanel'),
75
+ source.indexOf('export function QueuePanel'),
76
+ );
77
+ assert.doesNotMatch(logPanel, /marginTop=\{1\}/);
78
+ assert.match(activityPanel, /wrapLine\(activityDetailText\(item\), lineWidth\(\)\)\.slice\(0, 2\)/);
79
+ assert.match(source, /`Task \$\{Math\.max\(1, taskIndex\)\}\/\$\{taskTotal\}`/);
80
+ assert.match(source, /`Step \$\{Math\.max\(1, stepIndex\)\}\/\$\{stepTotal\}`/);
81
+ assert.match(source, /`Batch \$\{Math\.min\(batchCount, Math\.max\(1, batchIndex \+ 1\)\)\}\/\$\{batchCount\}`/);
82
+ assert.match(source, /instructionCount/);
83
+ assert.match(source, /stabilizeKept/);
84
+ });
85
+
86
+ test('ShellUI launcher never starts agents or workspace services implicitly', async () => {
87
+ const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
88
+ const startup = await readFile(new URL('./StartupScreen.tsx', import.meta.url), 'utf8');
89
+ assert.doesNotMatch(tui, /submitInput\('\/start (?:agents|agent|all)'\)/);
90
+ assert.doesNotMatch(startup, /start-services|Start services|start agents and services/i);
91
+ assert.doesNotMatch(startup, /open-logs|Open diagnostics/i);
92
+ assert.match(startup, /Select and load a workspace/);
93
+ });
94
+
95
+ test('ShellUI lets the right pane extend to the terminal edge', async () => {
96
+ const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
97
+ const pane = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
98
+ assert.match(tui, /Math\.min\(58, Math\.floor\(width \* 0\.38\) \+ 2\)/);
99
+ assert.match(pane, /paddingLeft=\{1\}/);
100
+ assert.doesNotMatch(pane, /height="100%" flexDirection="column" padding=\{1\}/);
101
+ });
102
+
103
+ test('Flow/Trace does not repeat the runtime source prefix on every line', async () => {
104
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
105
+ const entryRenderer = source.slice(
106
+ source.indexOf('function logEntryLines'),
107
+ source.indexOf('export function LogPanel'),
108
+ );
109
+ assert.match(entryRenderer, /sourceMatch/);
110
+ assert.doesNotMatch(entryRenderer, /prefix\.push\(\{ text: `\$\{source\}/);
111
+ assert.match(entryRenderer, /prefix\.push\(\{ text: `\$\{parts\.time\} /);
112
+ });
113
+
114
+ test('runtime logs have a separator and a concise Runtime tab label', async () => {
115
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
116
+ const logPanel = source.slice(
117
+ source.indexOf('export function LogPanel'),
118
+ source.indexOf('export function QueuePanel'),
119
+ );
120
+ assert.match(logPanel, /content=\{'─'\.repeat\(lineWidth\(\)\)\}/);
121
+ assert.match(logPanel, /content=" Runtime "/);
122
+ assert.doesNotMatch(logPanel, /Flow \/ Trace/);
123
+ });
124
+
25
125
  test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
26
126
  assert.deepEqual(httpLinkParts('Voir https://example.test/docs?q=ok.'), [
27
127
  { text: 'Voir ' },
@@ -47,6 +147,16 @@ test('ShellUI leaves malformed URLs as plain text', () => {
47
147
  assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
48
148
  });
49
149
 
150
+ test('ShellUI preserves every URL when several links share one line', () => {
151
+ const links = wrapHttpLinks('Docs https://one.example/a puis https://two.example/b', 80)
152
+ .flat()
153
+ .filter((part) => part.url);
154
+ assert.deepEqual(links, [
155
+ { text: '[link: one.example]', url: 'https://one.example/a' },
156
+ { text: '[link: two.example]', url: 'https://two.example/b' },
157
+ ]);
158
+ });
159
+
50
160
  test('runtime display preserves a failed plan and its diagnostic evidence', () => {
51
161
  const state = {
52
162
  status: 'error',
@@ -59,7 +169,7 @@ test('runtime display preserves a failed plan and its diagnostic evidence', () =
59
169
  assert.equal(sanitizeRuntimeStateForDisplay(state), state);
60
170
  });
61
171
 
62
- test('runtime display still clears completed historical execution state', () => {
172
+ test('runtime display preserves completed plan and logs for post-run inspection', () => {
63
173
  const display = sanitizeRuntimeStateForDisplay({
64
174
  status: 'done',
65
175
  plan: [{ id: 'old', status: 'done' }],
@@ -68,10 +178,10 @@ test('runtime display still clears completed historical execution state', () =>
68
178
  conversation: [{ role: 'assistant', content: 'Old run.' }],
69
179
  });
70
180
 
71
- assert.deepEqual(display.plan, []);
72
- assert.deepEqual(display.activities, []);
73
- assert.deepEqual(display.logs, []);
74
- assert.deepEqual(display.conversation, []);
181
+ assert.deepEqual(display.plan, [{ id: 'old', status: 'done' }]);
182
+ assert.deepEqual(display.activities, [{ id: 'old-job', status: 'done' }]);
183
+ assert.deepEqual(display.logs, ['old log']);
184
+ assert.deepEqual(display.conversation, [{ role: 'assistant', content: 'Old run.' }]);
75
185
  });
76
186
 
77
187
  function stubFetch(handler) {
@@ -133,7 +243,7 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
133
243
  assert.deepEqual(conversationMessages(session), []);
134
244
  });
135
245
 
136
- test('applyRuntimeStateToShellSession clears terminal plan and activities when runtime is idle', () => {
246
+ test('applyRuntimeStateToShellSession preserves terminal diagnostics when runtime is idle', () => {
137
247
  const session = createSession();
138
248
  session.headlessPlan = [{ step: 1, description: 'Old read', status: 'failed' }];
139
249
  session.activities = { old: { key: 'old', status: 'failed', terminal: true } };
@@ -150,17 +260,17 @@ test('applyRuntimeStateToShellSession clears terminal plan and activities when r
150
260
  planPatches: [{ id: 'old-patch' }],
151
261
  });
152
262
 
153
- assert.equal(session.headlessPlan, null);
154
- assert.deepEqual(session.activities, {});
155
- assert.equal(session.workflow, null);
156
- assert.deepEqual(session.agentProjection.logs, []);
157
- assert.equal(session.agentProjection.summary, null);
158
- assert.deepEqual(session.agentProjection.conversation, []);
159
- assert.deepEqual(session.agentProjection.chain, []);
160
- assert.deepEqual(session.agentProjection.planPatches, []);
263
+ assert.deepEqual(session.headlessPlan, [{ step: 1, description: 'Old read', status: 'failed' }]);
264
+ assert.deepEqual(session.activities, { old: { key: 'old', status: 'failed', terminal: true } });
265
+ assert.deepEqual(session.workflow.nodes, [{ id: 'task:old' }]);
266
+ assert.deepEqual(session.agentProjection.logs, ['Runtime evaluator rejected the old run']);
267
+ assert.equal(session.agentProjection.summary, 'Old run failed');
268
+ assert.deepEqual(session.agentProjection.conversation, [{ role: 'assistant', content: 'Old failed answer' }]);
269
+ assert.deepEqual(session.agentProjection.chain, [{ id: 'old-step' }]);
270
+ assert.deepEqual(session.agentProjection.planPatches, [{ id: 'old-patch' }]);
161
271
  });
162
272
 
163
- test('runtime plan completion is appended once to the ShellUI conversation', () => {
273
+ test('legacy runtime sync does not synthesize per-job or canned completion messages', () => {
164
274
  const session = createSession();
165
275
  applyRuntimeStateToShellSession(session, {
166
276
  runId: 'run-1',
@@ -178,27 +288,7 @@ test('runtime plan completion is appended once to the ShellUI conversation', ()
178
288
  plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
179
289
  });
180
290
 
181
- assert.deepEqual(conversationMessages(session), [{
182
- role: 'command',
183
- content: '✓ Job terminé : Build documentation\nStatus: done',
184
- }]);
185
- });
186
-
187
- test('runtime plan failure includes its available error description', () => {
188
- const session = createSession();
189
- appendRuntimePlanCompletionMessages(session, {
190
- runId: 'run-2',
191
- plan: [{ id: 'ingest', description: 'Ingest pages', status: 'running' }],
192
- });
193
- appendRuntimePlanCompletionMessages(session, {
194
- runId: 'run-2',
195
- plan: [{ id: 'ingest', description: 'Ingest pages', status: 'failed', result: { error: { message: 'Index unavailable' } } }],
196
- });
197
-
198
- assert.deepEqual(conversationMessages(session), [{
199
- role: 'command',
200
- content: '✗ Job terminé en erreur : Ingest pages\nStatus: failed\nErreur: Index unavailable',
201
- }]);
291
+ assert.deepEqual(conversationMessages(session), []);
202
292
  });
203
293
 
204
294
  test('direct chat system prompt forbids unsolicited next steps', async () => {
@@ -312,7 +402,7 @@ test('/agent <question> submits one runtime request and remains in chat mode', a
312
402
  assert.equal(session.chatMode, true);
313
403
  assert.equal(result.oneShotAgent, true);
314
404
  assert.equal(result.runtimeOutcome.kind, 'accepted');
315
- assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion' }]);
405
+ assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion', _pending: true }]);
316
406
  } finally {
317
407
  restore();
318
408
  }
package/src/shell/tui.tsx CHANGED
@@ -12,7 +12,6 @@ import { useSession } from './useSession';
12
12
  import { buildMcpStatus } from '../core/mcp.js';
13
13
  import { loadWikircProfile, summarizeWikircConfig } from '../core/wikirc.js';
14
14
  import { listWorkspaces } from '../core/workspaces.js';
15
- import { postRuntimeShutdown } from '../runtime/client.js';
16
15
  import { runPreflightChecks, withRuntimePreflight } from '../core/startupCheck.js';
17
16
 
18
17
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -30,11 +29,13 @@ function emptyStartupInfo(version: string, workspace: { name: string } | null, w
30
29
  };
31
30
  }
32
31
 
33
- function startupInfo(packageJson: Record<string, unknown>) {
32
+ function startupInfo(packageJson: Record<string, unknown>, preferredWorkspaceName?: string | null) {
34
33
  // listWorkspaces() order is filesystem-dependent (readdirSync), not stable —
35
34
  // sort so "the default workspace" is deterministic across runs/platforms.
36
35
  const workspaces = [...listWorkspaces()].sort((a, b) => a.name.localeCompare(b.name));
37
- const workspace = workspaces[0] ?? null;
36
+ const workspace = preferredWorkspaceName
37
+ ? workspaces.find((item) => item.name === preferredWorkspaceName) ?? null
38
+ : workspaces[0] ?? null;
38
39
  const version = String(packageJson.version ?? '');
39
40
  if (!workspace) return emptyStartupInfo(version, null, []);
40
41
 
@@ -75,7 +76,11 @@ function startupInfo(packageJson: Record<string, unknown>) {
75
76
 
76
77
  function copyToClipboard(text: string, renderer: unknown) {
77
78
  try {
78
- if ((renderer as any).copyToClipboardOSC52?.(text)) return true;
79
+ const osc52 = (renderer as any).copyToClipboardOSC52;
80
+ if (typeof osc52 === 'function') {
81
+ osc52.call(renderer, text);
82
+ return true;
83
+ }
79
84
  } catch {
80
85
  // Fall through to platform clipboard tools.
81
86
  }
@@ -105,11 +110,16 @@ function App(props: {
105
110
  packageJson: Record<string, unknown>;
106
111
  runtime?: any;
107
112
  preflight?: any;
113
+ initialWorkspaceName?: string | null;
114
+ // Shared handle the shell awaits after onDestroy resolves. One ref instead of
115
+ // two same-named `exitTask` bindings wired through a setter callback.
116
+ exitRef?: { current: Promise<void> };
108
117
  }) {
109
118
  const renderer = useRenderer();
110
119
  const dimensions = useTerminalDimensions();
111
120
  const [spinnerIndex, setSpinnerIndex] = createSignal(0);
112
121
  const [exitHint, setExitHint] = createSignal(false);
122
+ const [exitStatus, setExitStatus] = createSignal<string | null>(null);
113
123
  const [copyHint, setCopyHint] = createSignal<string | null>(null);
114
124
  const [chatInputHeight, setChatInputHeight] = createSignal(3);
115
125
  const [startupKeyboardEvent, setStartupKeyboardEvent] = createSignal<{ id: number; key: any } | null>(null);
@@ -128,7 +138,12 @@ function App(props: {
128
138
  const exitShell = () => {
129
139
  if (exiting) return;
130
140
  exiting = true;
131
- void (async () => {
141
+ setExitStatus('Fermeture enclenchée…');
142
+ // Register the complete shutdown before destroying the renderer: onDestroy
143
+ // resolves runOpenTuiShell, which must still await this cleanup task.
144
+ const task = Promise.resolve().then(async () => {
145
+ renderer.destroy();
146
+ console.log('[wiki-manager] fermeture enclenchée — nettoyage en cours…');
132
147
  const messages: string[] = [];
133
148
  try {
134
149
  if (props.runtime?.url) {
@@ -138,25 +153,25 @@ function App(props: {
138
153
  } catch {
139
154
  // Best effort: never block the exit on runtime cleanup.
140
155
  }
141
- renderer.destroy();
142
- // Print AFTER destroy so the note survives on the restored terminal.
143
156
  for (const message of messages) console.log(`[wiki-manager] ${message}`);
144
- })();
157
+ console.log('[wiki-manager] fermeture terminée — shell clos proprement.');
158
+ });
159
+ if (props.exitRef) props.exitRef.current = task;
145
160
  };
146
161
  let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
147
162
  let selectionCopyTimer: ReturnType<typeof setTimeout> | null = null;
148
163
  let startupKeyboardEventId = 0;
149
164
  let lastCopiedSelection = '';
150
- let runtimeShutdownRequested = false;
151
165
  const state = useSession(props);
152
- const startup = createMemo(() => startupInfo(props.packageJson));
166
+ const startup = createMemo(() => startupInfo(props.packageJson, props.initialWorkspaceName));
153
167
  const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
154
168
  const rightColumns = createMemo(() => {
155
169
  const width = dimensions().width;
156
- // 38% / cap 56 (was 32% / cap 44): the Plan/Activity/Logs panes carry job
170
+ // 38% + 2 columns / cap 58: the Plan/Activity/Logs panes carry job
157
171
  // labels, file names and error messages — 40 columns truncated everything
158
- // into unreadable stubs.
159
- return Math.max(30, Math.min(56, Math.floor(width * 0.38)));
172
+ // into unreadable stubs. The small addition uses the terminal's right-side
173
+ // slack without making the conversation pane noticeably narrower.
174
+ return Math.max(32, Math.min(58, Math.floor(width * 0.38) + 2));
160
175
  });
161
176
  const leftColumns = createMemo(() => Math.max(32, dimensions().width - rightColumns() - 1));
162
177
  const conversationColumns = createMemo(() => {
@@ -187,7 +202,7 @@ function App(props: {
187
202
  // decide *which* workspace to load must re-read current state directly
188
203
  // (startupInfo(...)) rather than trust the frozen memo, the same way
189
204
  // closeSetup() already does.
190
- const loadDefaultWorkspace = async () => loadWorkspace(startupInfo(props.packageJson).workspaceName);
205
+ const loadDefaultWorkspace = async () => loadWorkspace(startupInfo(props.packageJson, props.initialWorkspaceName).workspaceName);
191
206
 
192
207
  // The canonical status is the first useful view after entering ShellUI.
193
208
  // Run it only after /use has established the workspace so config, services,
@@ -196,18 +211,6 @@ function App(props: {
196
211
  if (workspaceLoaded) await state.submitInput('/status');
197
212
  };
198
213
 
199
- // "Open a workspace" is really one composite action (load it, then bring
200
- // its services up) — named here so it has one definition instead of being
201
- // inlined as three sequential submitInput calls in openAction.
202
- const openWorkspaceAndStartServices = async (workspaceName?: string | null) => {
203
- const loaded = await loadWorkspace(workspaceName);
204
- if (loaded) {
205
- await state.submitInput('/start agents');
206
- await state.submitInput('/start all');
207
- }
208
- return loaded;
209
- };
210
-
211
214
  const refreshPreflight = async () => {
212
215
  setPreflightBusy(true);
213
216
  try {
@@ -227,37 +230,11 @@ function App(props: {
227
230
  void refreshPreflight();
228
231
  return;
229
232
  }
230
- if (action === 'start-services') {
231
- void (async () => {
232
- setPreflightBusy(true);
233
- try {
234
- await openWorkspaceAndStartServices(workspaceName ?? startupInfo(props.packageJson).workspaceName);
235
- const next = await runPreflightChecks();
236
- setPreflight(withRuntimePreflight(next, props.runtime));
237
- } finally {
238
- setPreflightBusy(false);
239
- }
240
- })();
241
- return;
242
- }
243
- if (action === 'open-logs') {
244
- setScreen('main');
245
- void (async () => {
246
- const loaded = await loadDefaultWorkspace();
247
- if (!loaded) return;
248
- const containerCheck = preflight().checks?.find((check: any) => check.kind === 'containers');
249
- const service = containerCheck?.context?.unavailable?.[0];
250
- if (service) await state.submitInput(`/logs ${service} 120`);
251
- else await state.submitInput('/services');
252
- await state.submitInput('/mcp status');
253
- })();
254
- return;
255
- }
256
233
  setScreen('main');
257
234
  void (async () => {
258
235
  try {
259
236
  if (action === 'open-workspace') {
260
- const loaded = await openWorkspaceAndStartServices(workspaceName ?? startupInfo(props.packageJson).workspaceName);
237
+ const loaded = await loadWorkspace(workspaceName ?? startupInfo(props.packageJson, props.initialWorkspaceName).workspaceName);
261
238
  await showDefaultStatus(loaded);
262
239
  } else if (action === 'new-conversation' || action === 'run-workflow') {
263
240
  const loaded = await loadDefaultWorkspace();
@@ -275,7 +252,7 @@ function App(props: {
275
252
  };
276
253
 
277
254
  const closeSetup = () => {
278
- const info = startupInfo(props.packageJson);
255
+ const info = startupInfo(props.packageJson, props.initialWorkspaceName);
279
256
  if (!info.hasWorkspace) {
280
257
  setScreen('startup');
281
258
  return;
@@ -307,10 +284,6 @@ function App(props: {
307
284
  onCleanup(() => {
308
285
  if (copyHintTimer) clearTimeout(copyHintTimer);
309
286
  if (selectionCopyTimer) clearTimeout(selectionCopyTimer);
310
- if (props.runtime?.url && !runtimeShutdownRequested) {
311
- runtimeShutdownRequested = true;
312
- void postRuntimeShutdown({ url: props.runtime.url }).catch(() => {});
313
- }
314
287
  });
315
288
 
316
289
  const spinnerTimer = setInterval(() => {
@@ -358,17 +331,24 @@ function App(props: {
358
331
  if (keyName === 'tab') state.completeSelected();
359
332
  if (keyName === 'pageup') state.scrollConversation(conversationRows());
360
333
  else if (keyName === 'pagedown') state.scrollConversation(-conversationRows());
361
- if (keyName === 'up' && state.slash()) state.moveCompletion(-1);
362
- else if (keyName === 'down' && state.slash()) state.moveCompletion(1);
363
- else if (keyName === 'up' && !state.input().includes('\n')) state.historyUp();
364
- else if (keyName === 'down' && !state.input().includes('\n')) state.historyDown();
365
- else if (keyName === 'escape') {
334
+ if ((keyName === 'up' || keyName === 'down') && (state.slash() || !state.input().includes('\n'))) {
335
+ // The Plan scrollbox also listens to global arrow keys. Consume arrows
336
+ // handled by the chat input so one key press cannot move both widgets.
337
+ key.preventDefault?.();
338
+ key.stopPropagation?.();
339
+ if (state.slash()) state.moveCompletion(keyName === 'up' ? -1 : 1);
340
+ else if (keyName === 'up') state.historyUp();
341
+ else state.historyDown();
342
+ return;
343
+ }
344
+ if (keyName === 'escape') {
366
345
  if (state.slash()) state.dismissSlash();
367
346
  else state.setInput('');
368
347
  }
369
348
  });
370
349
 
371
350
  const hintLine = () => {
351
+ if (exitStatus()) return exitStatus();
372
352
  if (copyHint()) return copyHint();
373
353
  if (exitHint()) return 'Press Ctrl+C again to exit.';
374
354
  if (state.runtimeHint()) return state.runtimeHint();
@@ -416,6 +396,8 @@ function App(props: {
416
396
  queueInfo={state.queueInfo()}
417
397
  activeTab={state.rightTab()}
418
398
  logFilter={state.runtimeLogFilter()}
399
+ pendingApprovals={state.pendingApprovals()}
400
+ onApprove={() => { void state.submitInput('/approve'); }}
419
401
  onTabClick={state.selectRightTab}
420
402
  />
421
403
  <SlashDialog context={state.activeEditor() ? null : state.slash()} />
@@ -467,20 +449,33 @@ export async function runOpenTuiShell({
467
449
  packageJson,
468
450
  runtime = null,
469
451
  preflight = null,
452
+ initialWorkspaceName = null,
470
453
  }: {
471
454
  agent: unknown;
472
455
  packageJson: Record<string, unknown>;
473
456
  runtime?: any;
474
457
  preflight?: any;
458
+ initialWorkspaceName?: string | null;
475
459
  }) {
460
+ const exitRef = { current: Promise.resolve() as Promise<void> };
476
461
  await new Promise<void>((resolve, reject) => {
477
- render(() => <App agent={agent} packageJson={packageJson} runtime={runtime} preflight={preflight} />, {
462
+ render(() => (
463
+ <App
464
+ agent={agent}
465
+ packageJson={packageJson}
466
+ runtime={runtime}
467
+ preflight={preflight}
468
+ initialWorkspaceName={initialWorkspaceName}
469
+ exitRef={exitRef}
470
+ />
471
+ ), {
478
472
  exitOnCtrlC: false,
479
473
  useMouse: true,
480
474
  targetFps: 30,
481
475
  onDestroy: resolve,
482
476
  }).catch(reject);
483
477
  });
478
+ await exitRef.current;
484
479
  return {};
485
480
  }
486
481
 
@@ -329,6 +329,11 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
329
329
  const tagged = filterRuntimeLogs(runtimeLogs.map((line: any) => `runtime ${String(line)}`), filter);
330
330
  return [...logs(), ...tagged];
331
331
  });
332
+ const pendingApprovals = createMemo(() => {
333
+ version();
334
+ return (Array.isArray(runtimeState()?.approvals) ? runtimeState().approvals : [])
335
+ .filter((approval: any) => approval.status === 'pending_approval');
336
+ });
332
337
 
333
338
  // Index-aligned with each workspace's runtimeConversation array — not just
334
339
  // a length count. The server sometimes finalizes a streaming placeholder
@@ -379,16 +384,23 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
379
384
  continue;
380
385
  }
381
386
  if (role === 'user') {
382
- // The user's own message was already pushed optimistically (see
383
- // useAgent.ts) and marked _pending — confirm that entry instead of
384
- // adding a second copy. No matching pending entry (e.g. history
385
- // restored after a fresh reconnect) falls through to a normal push.
387
+ // The user's own message was already pushed optimistically — either
388
+ // marked _pending (web / runtime-submit path, useAgent.ts) OR as a plain
389
+ // entry by the interactive agent turn (repl.js runAgentTurn) that then
390
+ // delegated. Adopt whichever already exists instead of appending a
391
+ // second identical copy (the delegation seeds the run with the same
392
+ // objective, which comes back through this merge).
386
393
  const pendingIndex = target.findIndex((entry: any) => entry._pending && entry.role === 'user' && entry.content === content);
387
394
  if (pendingIndex !== -1) {
388
395
  delete target[pendingIndex]._pending;
389
396
  backed.push(target[pendingIndex]);
390
397
  continue;
391
398
  }
399
+ const localIndex = target.findIndex((entry: any) => entry.role === 'user' && entry.content === content && !backed.includes(entry));
400
+ if (localIndex !== -1) {
401
+ backed.push(target[localIndex]);
402
+ continue;
403
+ }
392
404
  }
393
405
  const entry = { role, content };
394
406
  target.push(entry);
@@ -397,19 +409,37 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
397
409
  }
398
410
 
399
411
  function syncRuntimeState() {
400
- void fetchRuntimeState({ url: props.runtime.url, workspace: (session as any).workspace ?? null })
412
+ const workspace = (session as any).workspace ?? null;
413
+ const workspaceKey = workspace ?? '__global__';
414
+ if (runtimeSyncInFlight.has(workspaceKey)) return;
415
+ runtimeSyncInFlight.add(workspaceKey);
416
+ void fetchRuntimeState({ url: props.runtime.url, workspace })
401
417
  .then((state) => {
418
+ if (((session as any).workspace ?? null) !== workspace) return;
402
419
  const displayState = sanitizeRuntimeStateForDisplay(state);
403
420
  setRuntimeState(displayState);
421
+ // The per-job "Job terminé" lines and the canned plan summary are gone:
422
+ // the runtime now emits ONE natural-language Donna message at completion
423
+ // (see announceRunOutcome in runner.js), which arrives through the
424
+ // conversation merge below. Detect that the chat grew to keep the
425
+ // welcome-hidden / scroll-to-latest behavior.
426
+ const beforeLength = conversationMessages(session).length;
404
427
  mergeRuntimeConversation(displayState);
428
+ if (conversationMessages(session).length > beforeLength) {
429
+ setShowWelcome(false);
430
+ setConversationScroll(0);
431
+ }
405
432
  setRuntimeStatus('connected');
406
433
  refresh();
407
434
  })
408
435
  .catch(() => {
436
+ if (((session as any).workspace ?? null) !== workspace) return;
409
437
  setRuntimeStatus('disconnected');
410
438
  refresh();
411
- });
439
+ })
440
+ .finally(() => { runtimeSyncInFlight.delete(workspaceKey); });
412
441
  }
442
+ const runtimeSyncInFlight = new Set<string>();
413
443
  let runtimeStreamAbort: AbortController | null = null;
414
444
  let runtimeSyncDebounce: ReturnType<typeof setTimeout> | null = null;
415
445
  let runtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -446,6 +476,13 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
446
476
  void subscribeRuntimeEvents();
447
477
  }
448
478
 
479
+ // Safety net only — the SSE stream (subscribeRuntimeEvents) is the primary
480
+ // sync path and already debounces on every agent_event. This just catches
481
+ // a missed/dropped event, so it can poll far less often than that.
482
+ const runtimeCompletionWatchdog = setInterval(() => {
483
+ if (props.runtime?.url && runtimeRunStatus() === 'running') syncRuntimeState();
484
+ }, 10000);
485
+
449
486
  let currentRuntimeWorkspace = (session as any).workspace ?? null;
450
487
  function resyncRuntimeWorkspaceIfChanged() {
451
488
  const workspace = (session as any).workspace ?? null;
@@ -466,6 +503,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
466
503
  runtimeStreamAbort?.abort();
467
504
  if (runtimeSyncDebounce) clearTimeout(runtimeSyncDebounce);
468
505
  if (runtimeReconnectTimer) clearTimeout(runtimeReconnectTimer);
506
+ clearInterval(runtimeCompletionWatchdog);
469
507
  });
470
508
 
471
509
  const activityPollTimer = setInterval(() => {
@@ -690,6 +728,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
690
728
  toggleRightTab,
691
729
  selectRightTab,
692
730
  plan,
731
+ pendingApprovals,
693
732
  conversationScroll,
694
733
  scrollConversation,
695
734
  conversationBusy,