@dotdrelle/wiki-manager 0.15.40 → 0.15.42

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 (51) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +3 -3
  3. package/src/activity/progressCalculator.js +3 -4
  4. package/src/agent/graph.js +40 -1
  5. package/src/cli/wiki-manager.js +64 -38
  6. package/src/commands/slash.js +47 -9
  7. package/src/core/activity.js +3 -2
  8. package/src/core/agentEvents.js +68 -11
  9. package/src/core/agentEvents.test.js +76 -0
  10. package/src/core/agentLoop.js +32 -6
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +32 -0
  13. package/src/core/dockerCompose.test.js +32 -0
  14. package/src/core/jobQueue.js +9 -0
  15. package/src/core/mcp.js +1 -1
  16. package/src/core/otherWorkspacesRunning.test.js +51 -0
  17. package/src/core/plan.js +3 -2
  18. package/src/core/planPatch.js +2 -1
  19. package/src/core/profileServiceStatus.test.js +26 -2
  20. package/src/core/toolLoop.js +32 -7
  21. package/src/core/toolLoop.test.js +95 -0
  22. package/src/core/wikiSetup.js +50 -7
  23. package/src/core/wikiWorkspace.test.js +30 -0
  24. package/src/core/wikirc.test.js +121 -2
  25. package/src/core/workflow.js +11 -2
  26. package/src/core/workspaceInherit.js +149 -0
  27. package/src/core/workspaceInherit.test.js +181 -0
  28. package/src/graph/graphVisibilityPolicy.js +2 -2
  29. package/src/orchestrator/agentRegistry.js +50 -0
  30. package/src/orchestrator/agentRegistry.test.js +76 -1
  31. package/src/orchestrator/approvalPolicy.js +2 -2
  32. package/src/orchestrator/dependencyResolver.js +52 -12
  33. package/src/orchestrator/dispatcher.js +2 -5
  34. package/src/orchestrator/planIntegrator.js +5 -5
  35. package/src/orchestrator/resultAggregator.js +2 -1
  36. package/src/orchestrator/scheduler.test.js +62 -1
  37. package/src/orchestrator/taskStatuses.js +99 -0
  38. package/src/orchestrator/taskStatuses.test.js +112 -0
  39. package/src/runtime/delegation.js +158 -0
  40. package/src/runtime/delegation.test.js +281 -0
  41. package/src/runtime/recoveryManager.js +2 -5
  42. package/src/runtime/recoveryManager.test.js +5 -1
  43. package/src/runtime/runner.js +129 -15
  44. package/src/runtime/runner.test.js +153 -6
  45. package/src/runtime/server.test.js +28 -0
  46. package/src/runtime/store.js +16 -1
  47. package/src/runtime/store.test.js +50 -0
  48. package/src/shell/repl.js +21 -11
  49. package/src/shell/setupWizardModality.test.js +55 -0
  50. package/src/shell/tui.tsx +29 -9
  51. package/wiki-workspace +10 -4
@@ -1,7 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
- import { ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
3
+ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
4
+ import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
5
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
6
+ import { readyPlanTasks } from '../core/planPatch.js';
7
+ import { skipImpossibleTasks, structuredPlanEvaluation, ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
5
8
 
6
9
  test('ensurePlanProjection re-projects when the chained plan changes shape (step 2)', () => {
7
10
  const session = { agentEvents: [], agentProjection: null };
@@ -821,7 +824,17 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
821
824
  assert.equal(session.headlessPlan[0].status, 'cancelled');
822
825
  });
823
826
 
824
- test('runRuntimeParallelPlan does not wait for approval behind a failed dependency', async () => {
827
+ /*
828
+ Ce test gardait l'ancien comportement : la tâche bloquée derrière un échec
829
+ laissait le run « stalled ». Ne pas attendre d'approbation était déjà acquis,
830
+ mais s'arrêter là déclenchait une replanification et laissait le run vivant.
831
+
832
+ Cas observé le 2026-08-04 (workspace juno) : dix fichiers à ingérer, neuf
833
+ réussis, un en échec sur du JSON malformé — le run n'est jamais retombé.
834
+ Une tâche qui ne deviendra jamais exécutable est donc marquée `skipped` avec
835
+ le nom de la dépendance fautive, et le run se termine sur un résultat partiel.
836
+ */
837
+ test('runRuntimeParallelPlan skips work stuck behind a failed dependency instead of stalling', async () => {
825
838
  const session = {
826
839
  workspace: 'demo-workspace',
827
840
  mcp: { tools: {} },
@@ -844,11 +857,17 @@ test('runRuntimeParallelPlan does not wait for approval behind a failed dependen
844
857
  { runId: 'run-failed-dependency', timeoutMs: 1000, maxTurns: 1 },
845
858
  );
846
859
 
847
- assert.equal(result.ok, false);
848
- assert.equal(result.stalled, true);
849
- assert.equal(result.reason, 'no_ready_plan_task');
860
+ // Plus de blocage : le run retombe au lieu de rester actif indéfiniment.
861
+ assert.notEqual(result.stalled, true);
862
+ assert.equal(session.headlessPlan[1].status, 'skipped');
863
+ assert.equal(session.headlessPlan[1].error?.code, 'dependency_failed');
864
+ // Le motif nomme la dépendance fautive : sans lui, l'utilisateur voit une
865
+ // tâche disparaître sans savoir pourquoi.
866
+ assert.match(session.headlessPlan[1].error.message, /\ba\b/);
850
867
  assert.equal(session.agentEvents.some((event) => event.type === 'assistant_message'
851
868
  && /Approbation requise/.test(event.payload?.content ?? '')), false);
869
+ assert.equal(session.agentEvents.some((event) => event.type === 'plan_step_updated'
870
+ && event.payload?.status === 'skipped'), true);
852
871
  });
853
872
 
854
873
  test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
@@ -1083,3 +1102,131 @@ test('runtime runs are seeded with the chat that preceded them', async () => {
1083
1102
  assert.equal(clipped.length, 1);
1084
1103
  assert.equal(clipped[0].content.length, 2000);
1085
1104
  });
1105
+
1106
+ test('skipImpossibleTasks propage un échec jusqu’au point fixe', () => {
1107
+ /*
1108
+ Marquer les seules tâches directement bloquées laissait un résidu : A en
1109
+ échec rend B impossible, mais C dépend de B et serait resté en attente
1110
+ d'une tâche désormais terminale sans succès. La propagation doit descendre
1111
+ toute la chaîne en une fois.
1112
+ */
1113
+ const session = { workspace: 'demo-workspace', agentEvents: [] };
1114
+ const steps = [
1115
+ { id: 'a', step: 1, status: 'failed' },
1116
+ { id: 'b', step: 2, status: 'pending', dependsOn: ['a'] },
1117
+ { id: 'c', step: 3, status: 'pending', dependsOn: ['b'] },
1118
+ { id: 'independent', step: 4, status: 'pending', dependsOn: [] },
1119
+ ];
1120
+ // Le plan passe par la projection, comme en production : chaque dépêche
1121
+ // remplace session.headlessPlan, et c'est précisément ce qui rendait une
1122
+ // référence capturée orpheline.
1123
+ dispatchAgentEvent(session, createAgentEvent('plan_set', { origin: 'tool', payload: { steps } }));
1124
+
1125
+ const skipped = skipImpossibleTasks(session, 'run-fixpoint');
1126
+
1127
+ assert.equal(skipped, 2);
1128
+ assert.equal(session.headlessPlan[1].status, 'skipped');
1129
+ assert.equal(session.headlessPlan[2].status, 'skipped');
1130
+ // La branche indépendante n'est pas touchée : c'est tout l'objet du
1131
+ // correctif, laisser finir ce qui peut finir.
1132
+ assert.equal(session.headlessPlan[3].status, 'pending');
1133
+ assert.equal(session.agentEvents.filter((event) => event.payload?.status === 'skipped').length, 2);
1134
+ });
1135
+
1136
+ test('skipImpossibleTasks s’arrête au lieu de tourner sans rien changer', () => {
1137
+ // Sans garde-fou, une incohérence de statut se paierait en boucle infinie,
1138
+ // c'est-à-dire en run figé — exactement ce que l'on répare.
1139
+ const session = { workspace: 'demo-workspace', agentEvents: [] };
1140
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
1141
+ origin: 'tool',
1142
+ payload: {
1143
+ steps: [
1144
+ { id: 'a', step: 1, status: 'failed' },
1145
+ { id: 'b', step: 2, status: 'skipped', dependsOn: ['a'] },
1146
+ ],
1147
+ },
1148
+ }));
1149
+ const eventsBefore = session.agentEvents.length;
1150
+
1151
+ assert.equal(skipImpossibleTasks(session, 'run-guard', { maxPasses: 3 }), 0);
1152
+ assert.equal(session.agentEvents.length, eventsBefore);
1153
+ });
1154
+
1155
+ test('structuredPlanEvaluation compte séparément, sans fraction trompeuse', () => {
1156
+ const task = (id, status) => ({
1157
+ id, status, label: `Task ${id}`, requiredCapability: 'knowledge.update', operation: 'ingest',
1158
+ });
1159
+
1160
+ const allDone = structuredPlanEvaluation([task('a', 'done'), task('b', 'succeeded')]);
1161
+ assert.equal(allDone.ok, true);
1162
+
1163
+ const partial = structuredPlanEvaluation([
1164
+ task('a', 'done'), task('b', 'failed'), task('c', 'skipped'),
1165
+ ]);
1166
+ assert.equal(partial.ok, false);
1167
+ // Trois compteurs qui s'additionnent, pas un « 1/3 » dont le dénominateur
1168
+ // mêlerait tâches métier et étapes techniques.
1169
+ assert.match(partial.reason, /1 réussie\(s\)/);
1170
+ assert.match(partial.reason, /1 en échec/);
1171
+ assert.match(partial.reason, /1 ignorée\(s\)/);
1172
+ assert.doesNotMatch(partial.reason, /\d+\/\d+/);
1173
+
1174
+ // Uniquement des ignorées : jamais un succès.
1175
+ const onlySkipped = structuredPlanEvaluation([task('a', 'skipped')]);
1176
+ assert.equal(onlySkipped.ok, false);
1177
+
1178
+ // Succès + ignorée sans échec : toujours pas un succès.
1179
+ const doneAndSkipped = structuredPlanEvaluation([task('a', 'done'), task('b', 'skipped')]);
1180
+ assert.equal(doneAndSkipped.ok, false);
1181
+
1182
+ // Statut actif ou inconnu : incomplet, jamais réussi.
1183
+ for (const status of ['running', 'brouette']) {
1184
+ const pending = structuredPlanEvaluation([task('a', 'done'), task('b', status)]);
1185
+ assert.equal(pending.ok, false, status);
1186
+ assert.match(pending.reason, /non terminée/);
1187
+ }
1188
+ });
1189
+
1190
+ test('une tâche ignorée est terminale pour la reprise et pour les approbations', () => {
1191
+ /*
1192
+ Vérifications transversales sur le nouveau statut : il ne suffit pas que
1193
+ l'ordonnanceur le comprenne. S'il n'est pas terminal pour la reprise, le
1194
+ redémarrage réattache un run fantôme ; s'il n'est pas terminal pour la
1195
+ politique d'approbation, on demande un accord pour une tâche qui ne sera
1196
+ jamais exécutée.
1197
+ */
1198
+ const skippedTask = {
1199
+ id: 'ingest-b',
1200
+ status: 'skipped',
1201
+ requiresApproval: true,
1202
+ requiredCapability: 'knowledge.update',
1203
+ operation: 'ingest_apply',
1204
+ };
1205
+
1206
+ assert.equal(isTerminal(skippedTask.status), true);
1207
+ assert.deepEqual(tasksAwaitingApproval([skippedTask], { approvals: [] }), []);
1208
+ assert.deepEqual(readyPlanTasks([skippedTask]).map((task) => task.id), []);
1209
+ });
1210
+
1211
+ test('rejouer les événements redonne exactement les mêmes statuts', () => {
1212
+ // La projection est reconstruite au démarrage à partir du journal : si le
1213
+ // rejeu ne rendait pas le même état, une tâche ignorée pourrait réapparaître
1214
+ // en attente et relancer un travail déjà abandonné.
1215
+ const events = [
1216
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['A', 'B'] } }),
1217
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'failed' } }),
1218
+ createAgentEvent('plan_step_updated', {
1219
+ origin: 'runtime',
1220
+ payload: { step: 2, status: 'skipped', reason: 'dependency_failed:A' },
1221
+ }),
1222
+ ];
1223
+
1224
+ const first = reduceAgentEvents(events);
1225
+ const replayed = reduceAgentEvents(events);
1226
+
1227
+ assert.deepEqual(
1228
+ replayed.plan.map((step) => step.status),
1229
+ first.plan.map((step) => step.status),
1230
+ );
1231
+ assert.deepEqual(replayed.plan.map((step) => step.status), ['failed', 'skipped']);
1232
+ });
@@ -76,6 +76,34 @@ test('interactive turns publish a fallback assistant message exactly once', () =
76
76
  assert.equal(published[0].payload.content, 'Réponse concise.');
77
77
  });
78
78
 
79
+ test('a turn that produces nothing still ends with an assistant message', () => {
80
+ // C'est la condition de fin que serve attend : la bulle « Request received ·
81
+ // Donna is preparing… » n'est retirée qu'à l'arrivée d'un message assistant
82
+ // non vide. Sans message, le point d'attente tournait indéfiniment, sans
83
+ // erreur nulle part et sans autre issue qu'un rechargement de page.
84
+ const published = [];
85
+ const session = { agentEvents: [], _onAgentEvent: (event) => published.push(event) };
86
+
87
+ assert.equal(ensureInteractiveAssistantMessage(session, '', { turnId: 'turn-1', workspace: 'demo' }), true);
88
+
89
+ assert.equal(published.length, 1);
90
+ assert.equal(published[0].type, 'assistant_message');
91
+ assert.match(published[0].payload.content, /No answer was produced/);
92
+ // Le message doit dire quoi faire, pas seulement constater.
93
+ assert.match(published[0].payload.content, /\/agent/);
94
+ });
95
+
96
+ test('a turn whose loop already answered does not answer twice', () => {
97
+ const published = [];
98
+ const session = {
99
+ agentEvents: [{ type: 'assistant_message' }],
100
+ _onAgentEvent: (event) => published.push(event),
101
+ };
102
+
103
+ assert.equal(ensureInteractiveAssistantMessage(session, '', { turnId: 'turn-1' }), false);
104
+ assert.equal(published.length, 0);
105
+ });
106
+
79
107
  test('runtime state rebuilds interactive conversation from persisted events', () => {
80
108
  const user = {
81
109
  ...createAgentEvent('user_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Bonjour' } }),
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * RUN lifecycle statuses, not task statuses: a run can be `interrupted`,
5
+ * a task never is.
6
+ *
7
+ * Declared here rather than in a central exception list so the waiver
8
+ * travels with the code it excuses (see orchestrator/taskStatuses.test.js).
9
+ */
1
10
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
11
  import { dirname, join, resolve } from 'node:path';
3
12
  import { DatabaseSync } from 'node:sqlite';
@@ -6,6 +15,8 @@ import { defaultRuntimeStateDir } from '../core/env.js';
6
15
  import { projectQueue } from '../core/jobQueue.js';
7
16
  import { projectWorkflow } from '../core/workflow.js';
8
17
  import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
18
+ import { isSuccessful } from '../orchestrator/taskStatuses.js';
19
+ import { markPersistedAgentsStale } from '../orchestrator/agentRegistry.js';
9
20
 
10
21
  export { defaultRuntimeStateDir };
11
22
 
@@ -1121,7 +1132,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1121
1132
 
1122
1133
  function attemptStatusForResult(status, eventType) {
1123
1134
  const normalized = String(status ?? '').toLowerCase();
1124
- if (['succeeded', 'success', 'done', 'complete', 'completed'].includes(normalized)) return 'done';
1135
+ if (isSuccessful(normalized)) return 'done';
1125
1136
  if (['cancelled', 'canceled'].includes(normalized)) return 'cancelled';
1126
1137
  if (eventType === 'task.failed') return 'failed';
1127
1138
  return normalized || 'finished';
@@ -1265,6 +1276,10 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1265
1276
  function hydrateSession(session, { workspace = null } = {}) {
1266
1277
  const projection = replayEvents(session, { workspace });
1267
1278
  applyAgentProjectionToSession(session, projection);
1279
+ // Le journal restitue les agents avec la santé qu'ils avaient à l'écriture
1280
+ // de l'événement. Les reprendre tels quels les faisait réapparaître
1281
+ // « disponibles » après un redémarrage, endpoint éteint compris.
1282
+ markPersistedAgentsStale(session);
1268
1283
  session.jobQueue = listQueue({ workspace });
1269
1284
  return projection;
1270
1285
  }
@@ -6,6 +6,7 @@ import { DatabaseSync } from 'node:sqlite';
6
6
  import test from 'node:test';
7
7
  import { conversationEventSequences, createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
8
8
  import { openRuntimeStore } from './store.js';
9
+ import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
9
10
 
10
11
  function runtimeStateDir() {
11
12
  return join(mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-')), '.wiki', 'runtime');
@@ -1106,3 +1107,52 @@ test('deleteEventsAfter removes bookkeeping only for runs entirely produced by t
1106
1107
  assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_attempts').get().n, 1);
1107
1108
  store.close();
1108
1109
  });
1110
+
1111
+ test('un agent restauré par hydrateSession n’est plus routable tant qu’aucun scan n’a réussi', () => {
1112
+ /*
1113
+ Validation à chaud du 2026-08-04 : après redémarrage, `cme-main` restait
1114
+ `available` et `external-source.export` gardait un fournisseur, alors que
1115
+ l'endpoint CME était arrêté. Le test précédent construisait `session.agents`
1116
+ à la main et ratait donc le vrai chemin — celui où l'état vient du journal.
1117
+
1118
+ On hydrate ici depuis de véritables événements persistés, comme au
1119
+ démarrage : hydrate → invalidate → discover, sans scan entre les deux
1120
+ premiers.
1121
+ */
1122
+ const store = openRuntimeStore({ stateDir: mkdtempSync(join(tmpdir(), 'wiki-manager-agent-staleness-')) });
1123
+ const writer = { agentEvents: [], workspace: 'juno' };
1124
+ dispatchAgentEvent(writer, createAgentEvent('agent.registered', {
1125
+ origin: 'runtime',
1126
+ workspace: 'juno',
1127
+ payload: {
1128
+ agent: {
1129
+ agentInstanceId: 'cme-main',
1130
+ serverName: 'cme',
1131
+ health: 'available',
1132
+ description: {
1133
+ agentType: 'cme',
1134
+ contractVersion: '1',
1135
+ agentInstanceId: 'cme-main',
1136
+ capabilities: [{ id: 'external-source.export', version: '1' }],
1137
+ },
1138
+ },
1139
+ },
1140
+ }));
1141
+ for (const event of writer.agentEvents) store.persistEvent(event);
1142
+
1143
+ // Redémarrage : une session neuve, aucun scan encore effectué.
1144
+ const rebooted = { agentEvents: [], workspace: 'juno' };
1145
+ store.hydrateSession(rebooted, { workspace: 'juno' });
1146
+
1147
+ const restored = [...(rebooted.agents ?? []), ...(rebooted.agentRegistrySnapshot ?? [])];
1148
+ assert.ok(restored.length > 0, 'the agent must be restored, only not trusted');
1149
+ for (const agent of restored) {
1150
+ assert.equal(agent.health, 'unknown');
1151
+ assert.equal(agent.stale, true);
1152
+ }
1153
+ // Le seul point qui compte vraiment : plus aucun fournisseur sélectionnable.
1154
+ assert.deepEqual(
1155
+ capabilityRegistryForSession(rebooted).providersFor('external-source.export'),
1156
+ [],
1157
+ );
1158
+ });
package/src/shell/repl.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
1
2
  import { createInterface } from 'node:readline';
2
3
  import { emitKeypressEvents } from 'node:readline';
3
4
  import { Transform } from 'node:stream';
@@ -344,7 +345,7 @@ function completionValuesFor(parts, inputBuffer, session) {
344
345
  // Vocabulaire de l'opérateur uniquement : `serviceChoices()` écarte les noms
345
346
  // Compose bruts que les alias désignent déjà. Ils restent tapables.
346
347
  if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
347
- if (command === '/stop' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
348
+ if (command === '/stop' && tokenIndex === 1) return ['all', 'everything', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
348
349
  if (command === '/logs' && tokenIndex === 1) return ['all', ...serviceChoices()];
349
350
  return [];
350
351
  }
@@ -582,7 +583,8 @@ export function completionDescription(value, parts) {
582
583
  return serviceDescription(value) ?? 'Start this Docker Compose service.';
583
584
  }
584
585
  if (command === '/stop') {
585
- if (value === 'all') return 'Everything: workspace services AND external agents.';
586
+ if (value === 'all') return 'This workspace, agents included — unless another workspace still needs them.';
587
+ if (value === 'everything') return 'This workspace AND the shared agents, even if other workspaces are using them.';
586
588
  if (value === 'agents' || value === 'agent') return 'External agents only.';
587
589
  if (value === 'services') return 'Workspace services only.';
588
590
  if (togglableAgents().includes(value)) return `Stop only the ${value} agent, leaving the other agents running.`;
@@ -1006,7 +1008,7 @@ function productionActivityFromPayload(payload) {
1006
1008
  jobId: jobId ?? null,
1007
1009
  status,
1008
1010
  label: detail ? `Production: ${detail}` : `Production: ${status}`,
1009
- terminal: ['done', 'failed', 'cancelled'].includes(String(status)),
1011
+ terminal: isTerminal(status),
1010
1012
  updatedAt: new Date().toISOString(),
1011
1013
  };
1012
1014
  }
@@ -1027,7 +1029,7 @@ export function applyRuntimeStateToShellSession(session, state) {
1027
1029
  const displayState = sanitizeRuntimeStateForDisplay(state);
1028
1030
  const terminalStateDismissed = session._dismissedTerminalRunId
1029
1031
  && state.runId === session._dismissedTerminalRunId
1030
- && ['done', 'error', 'failed', 'cancelled'].includes(String(displayState.status ?? '').toLowerCase());
1032
+ && isTerminal(displayState.status);
1031
1033
  session._lastRuntimeRunId = state.runId ?? session._lastRuntimeRunId ?? null;
1032
1034
  session.agentProjection = {
1033
1035
  conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
@@ -1324,9 +1326,7 @@ async function runAgentTurn(input, {
1324
1326
  session.packageJson = session.packageJson ?? {};
1325
1327
  if (
1326
1328
  session._lastRuntimeRunId
1327
- && ['done', 'error', 'failed', 'cancelled'].includes(
1328
- String(session.agentProjection?.status ?? '').toLowerCase(),
1329
- )
1329
+ && isTerminal(session.agentProjection?.status)
1330
1330
  ) {
1331
1331
  session._dismissedTerminalRunId = session._lastRuntimeRunId;
1332
1332
  }
@@ -1472,7 +1472,7 @@ async function runAgentTurn(input, {
1472
1472
  // unitary actions only — it never plans or delegates, which is why the
1473
1473
  // orchestration tools are filtered out upstream. maxToolIterations caps the
1474
1474
  // loop.
1475
- async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools, openWikiPages, contextMessages = [] }) {
1475
+ async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, onTextDelta, onTextReset, allowedTools, openWikiPages, contextMessages = [] }) {
1476
1476
  const allowed = new Set(allowedTools.map((item) => item.function.name));
1477
1477
  // /chat only supplies the policy; the loop mechanic lives in runBoundedToolLoop.
1478
1478
  // executeCall enforces the allow-list and turns each call into a text result;
@@ -1504,6 +1504,8 @@ async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate
1504
1504
  maxIterations: Math.min(8, Number(session?.chatAccess?.maxToolIterations) || 4),
1505
1505
  signal: session._abortSignal,
1506
1506
  onStep: (i, cap) => onStep?.(`Chat: consulting… [${i}/${cap}]`),
1507
+ onTextDelta,
1508
+ onTextReset,
1507
1509
  });
1508
1510
  donnaMessage.content = capped
1509
1511
  ? 'Je n’ai pas pu conclure dans la limite d’itérations du mode chat. Passe en /agent si besoin.'
@@ -1575,7 +1577,15 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1575
1577
  // implementation of chat access; it just returns the final text instead of
1576
1578
  // driving a live repl bubble. The caller must have seeded session.chatAccess
1577
1579
  // (and session.mcp) so chatAllowedTools can resolve the allow-listed tools.
1578
- export async function runHeadlessChatTurn(session, input, { history = [], onStep, openWikiPages, openWikiPage } = {}) {
1580
+ /**
1581
+ * @param onTextDelta fragments de la réponse au fil de la génération. Fourni
1582
+ * par le tour runtime, qui les publie en `assistant_delta` : le réducteur
1583
+ * fait grandir la dernière entrée de conversation, et les deux interfaces
1584
+ * affichent la réponse en train de s'écrire au lieu d'un point d'attente.
1585
+ * @param onTextReset fragments à jeter (itération qui s'est terminée par des
1586
+ * appels d'outils plutôt que par une réponse).
1587
+ */
1588
+ export async function runHeadlessChatTurn(session, input, { history = [], onStep, onTextDelta, onTextReset, openWikiPages, openWikiPage } = {}) {
1579
1589
  const donnaMessage = { role: 'donna', content: '' };
1580
1590
  const allowedTools = chatAllowedTools(session);
1581
1591
  // Keep the singular option as a compatibility input for older serve builds.
@@ -1591,7 +1601,7 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
1591
1601
  ];
1592
1602
  const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
1593
1603
  if (canUseTools) {
1594
- await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
1604
+ await runChatToolLoop({ input, session, history, donnaMessage, onStep, onTextDelta, onTextReset, allowedTools, openWikiPages: selectedPages, contextMessages });
1595
1605
  return donnaMessage.content;
1596
1606
  }
1597
1607
  if (typeof session.llm?.stream === 'function') {
@@ -1602,7 +1612,7 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
1602
1612
  signal: session._abortSignal,
1603
1613
  })) {
1604
1614
  const clean = stripDsmlArtifacts(delta);
1605
- if (clean) content += clean;
1615
+ if (clean) { content += clean; onTextDelta?.(clean); }
1606
1616
  }
1607
1617
  return stripDsmlArtifacts(content).trimEnd() || formatLlmUnavailableMessage('flux vide');
1608
1618
  }
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // The shell renders in a terminal, so there is no DOM to drive and no
7
+ // typechecker on these .tsx files (bun strips the types at run time). Source
8
+ // assertions are the only mechanized guard available here — they are narrow on
9
+ // purpose: each one names the exact invariant that broke.
10
+ const tuiPath = fileURLToPath(new URL('./tui.tsx', import.meta.url));
11
+
12
+ function tuiSource() {
13
+ return readFileSync(tuiPath, 'utf8');
14
+ }
15
+
16
+ test('the chat composer loses focus while a modal is open', () => {
17
+ const source = tuiSource();
18
+
19
+ // The setup wizard used to leave `chatFocused` true underneath itself, so
20
+ // every keystroke answering a wizard question was ALSO typed into the chat
21
+ // input at the bottom of the window — visible, and submitted on Enter.
22
+ assert.match(
23
+ source,
24
+ /chatFocused=\{!state\.activeEditor\(\) && screen\(\) === 'main'\}/,
25
+ 'chatFocused must exclude every non-main screen, not just the file editor',
26
+ );
27
+ assert.doesNotMatch(
28
+ source,
29
+ /chatFocused=\{!state\.activeEditor\(\)\}/,
30
+ 'the editor-only form of chatFocused lets wizard keystrokes reach the composer',
31
+ );
32
+ });
33
+
34
+ test('the setup wizard renders over an opaque backdrop', () => {
35
+ const source = tuiSource();
36
+ const setupBranch = source.slice(source.indexOf("{screen() === 'setup' ?"));
37
+
38
+ assert.ok(setupBranch, "the setup branch must exist in tui.tsx");
39
+ // Without a full-bleed backdrop the wizard floats over a live two-pane
40
+ // layout and the panes show through around its border.
41
+ assert.match(setupBranch, /position="absolute"[\s\S]{0,200}backgroundColor="#0B0D12"/);
42
+ assert.match(setupBranch, /width=\{dimensions\(\)\.width\}[\s\S]{0,120}height=\{dimensions\(\)\.height\}/);
43
+ // The backdrop must sit under the wizard dialog (zIndex 40 in SetupWizard).
44
+ const zIndex = setupBranch.match(/zIndex=\{(\d+)\}/);
45
+ assert.ok(zIndex, 'the backdrop must declare a zIndex');
46
+ assert.ok(Number(zIndex[1]) < 40, 'the backdrop must sit below the wizard dialog');
47
+ });
48
+
49
+ test('the global key handler ignores every screen but main', () => {
50
+ const source = tuiSource();
51
+
52
+ // Belt to the chatFocused braces: even if a future modal forgets the prop,
53
+ // the shared shortcuts (history, slash completion, Esc) stay out of it.
54
+ assert.match(source, /if \(screen\(\) !== 'main'\) return;/);
55
+ });
package/src/shell/tui.tsx CHANGED
@@ -381,7 +381,11 @@ function App(props: {
381
381
  input={state.input()}
382
382
  busy={state.busy()}
383
383
  chatMode={state.chatMode()}
384
- chatFocused={!state.activeEditor()}
384
+ // The composer must lose focus for EVERY modal, not just the file
385
+ // editor. While the setup wizard was open the input stayed focused
386
+ // underneath it, so answering a wizard question also typed into the
387
+ // chat box at the bottom of the window.
388
+ chatFocused={!state.activeEditor() && screen() === 'main'}
385
389
  setInput={state.setInput}
386
390
  submit={submit}
387
391
  conversationRows={conversationRows()}
@@ -433,17 +437,33 @@ function App(props: {
433
437
  onSave={state.saveEditor}
434
438
  onCancel={state.closeEditor}
435
439
  />
440
+ {/*
441
+ Opaque backdrop, same treatment WizardApp gives the standalone
442
+ wizard. Without it the dialog floated over a live two-pane layout:
443
+ the panes showed through around its border and the whole thing read
444
+ as one garbled screen rather than as a modal.
445
+ */}
436
446
  {screen() === 'setup' ? (
437
- <SetupWizard
438
- mode="setup"
439
- session={state.session}
447
+ <box
448
+ position="absolute"
449
+ left={0}
450
+ top={0}
440
451
  width={dimensions().width}
441
452
  height={dimensions().height}
442
- initialRoute="workspace-name"
443
- closeOnDone
444
- onComplete={closeSetup}
445
- onClose={closeSetup}
446
- />
453
+ zIndex={39}
454
+ backgroundColor="#0B0D12"
455
+ >
456
+ <SetupWizard
457
+ mode="setup"
458
+ session={state.session}
459
+ width={dimensions().width}
460
+ height={dimensions().height}
461
+ initialRoute="workspace-name"
462
+ closeOnDone
463
+ onComplete={closeSetup}
464
+ onClose={closeSetup}
465
+ />
466
+ </box>
447
467
  ) : null}
448
468
  </box>
449
469
  }
package/wiki-workspace CHANGED
@@ -70,13 +70,19 @@ parse_global_options() {
70
70
  export REQUESTS_CA_BUNDLE="$CACERT_PATH"
71
71
  export CURL_CA_BUNDLE="$CACERT_PATH"
72
72
  fi
73
- set -- "${parsed[@]}"
73
+ # `${arr[@]}` on an EMPTY array is an unbound-variable error under `set -u`
74
+ # in bash <= 4.3 — which includes the /bin/bash 3.2 macOS still ships. Every
75
+ # optional array in this script therefore goes through the `${arr[@]+…}`
76
+ # guard (see compose_env_args, override_args, only_services…); these two were
77
+ # missed, so any invocation without a global option died on this line before
78
+ # reaching its subcommand.
79
+ set -- ${parsed[@]+"${parsed[@]}"}
74
80
  PARSED_ARGS=("$@")
75
81
  }
76
82
 
77
83
  PARSED_ARGS=()
78
84
  parse_global_options "$@"
79
- set -- "${PARSED_ARGS[@]}"
85
+ set -- ${PARSED_ARGS[@]+"${PARSED_ARGS[@]}"}
80
86
 
81
87
  usage() {
82
88
  cat <<'EOF'
@@ -338,7 +344,7 @@ agents_compose() {
338
344
  logs)
339
345
  local log_args=()
340
346
  read_lines_into_array log_args logs_args "$@"
341
- _agents_dc logs "${log_args[@]}"
347
+ _agents_dc logs ${log_args[@]+"${log_args[@]}"}
342
348
  ;;
343
349
  pull)
344
350
  [[ $# -eq 0 ]] || die "agents pull does not take arguments"
@@ -1452,7 +1458,7 @@ main() {
1452
1458
  logs)
1453
1459
  local log_args=()
1454
1460
  read_lines_into_array log_args logs_args "$@"
1455
- compose_for_workspace "$workspace" logs "${log_args[@]}" serve mcp-http production-mcp
1461
+ compose_for_workspace "$workspace" logs ${log_args[@]+"${log_args[@]}"} serve mcp-http production-mcp
1456
1462
  ;;
1457
1463
  serve)
1458
1464
  local open_browser=0