@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.40",
3
+ "version": "0.15.42",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -1,8 +1,8 @@
1
1
  import { calculateWeightedProgress } from './progressCalculator.js';
2
2
  import { deduplicateActivities } from './activityDeduplicator.js';
3
3
  import { initialSynthesisFromState } from './runSynthesis.js';
4
+ import { isSuccessful } from '../orchestrator/taskStatuses.js';
4
5
 
5
- const DONE = new Set(['done', 'complete', 'completed', 'success', 'succeeded']);
6
6
  const ACTIVE = new Set(['running', 'starting', 'queued']);
7
7
 
8
8
  export function aggregateActivity(state = {}, events = []) {
@@ -67,7 +67,7 @@ function groupLabel(task) {
67
67
 
68
68
  function groupLine(group, activities) {
69
69
  const total = group.tasks.length;
70
- const done = group.tasks.filter((task) => DONE.has(statusOf(task))).length;
70
+ const done = group.tasks.filter((task) => isSuccessful(statusOf(task))).length;
71
71
  const running = group.tasks.filter((task) => ACTIVE.has(statusOf(task)));
72
72
  const failed = group.tasks.find((task) => statusOf(task) === 'failed');
73
73
  const waitingApproval = group.tasks.some((task) => ['pending_approval', 'waiting_approval'].includes(statusOf(task)));
@@ -76,7 +76,7 @@ function groupLine(group, activities) {
76
76
  // the whole group as "validation 0%" while a worker visibly runs at 35%.
77
77
  const activePair = group.tasks
78
78
  .map((task) => ({ task, activity: activityForTask(task, activities) }))
79
- .find(({ activity }) => activity && !activity.terminal && !DONE.has(statusOf(activity)));
79
+ .find(({ activity }) => activity && !activity.terminal && !isSuccessful(statusOf(activity)));
80
80
  const activeTask = activePair?.task ?? running[0] ?? null;
81
81
  const activeActivity = activePair?.activity
82
82
  ?? running.map((task) => activityForTask(task, activities)).find(Boolean);
@@ -1,5 +1,4 @@
1
- const TERMINAL_DONE = new Set(['done', 'complete', 'completed', 'success', 'succeeded']);
2
- const TERMINAL_ANY = new Set([...TERMINAL_DONE, 'failed', 'cancelled', 'canceled', 'error']);
1
+ import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
3
2
 
4
3
  export function calculateWeightedProgress(tasks = [], activities = []) {
5
4
  const items = Array.isArray(tasks) ? tasks : [];
@@ -10,10 +9,10 @@ export function calculateWeightedProgress(tasks = [], activities = []) {
10
9
  for (const task of items) {
11
10
  const weight = taskWeight(task);
12
11
  const status = normalizeStatus(task.status);
13
- if (TERMINAL_DONE.has(status)) {
12
+ if (isSuccessful(status)) {
14
13
  completedWeight += weight;
15
14
  done += 1;
16
- } else if (!TERMINAL_ANY.has(status)) {
15
+ } else if (!isTerminal(status)) {
17
16
  completedWeight += weight * taskProgressRatio(task, activities);
18
17
  }
19
18
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * JSON schema enumeration exposed to models. A schema declares what it
5
+ * accepts, including values the orchestrator no longer produces.
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
+ */
10
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
1
11
  import { join } from 'node:path';
2
12
  import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
3
13
  import {
@@ -640,7 +650,7 @@ function rememberProductionProgress(session, payload, label) {
640
650
  jobId: jobId ?? session.productionActivity?.jobId ?? null,
641
651
  status,
642
652
  label: label ?? `Production: ${status}`,
643
- terminal: ['done', 'failed', 'cancelled'].includes(String(status)),
653
+ terminal: isTerminal(status),
644
654
  updatedAt: new Date().toISOString(),
645
655
  };
646
656
  }
@@ -804,6 +814,35 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
804
814
  if (connectorConfig) {
805
815
  return `Delegation rejected: ${connectorConfig.serverName} advertises no setup or authentication tool. Do not call an unrelated data tool and do not delegate to export. Explain conversationally that authentication must be completed outside MCP, using only configuration instructions already available in the current context.`;
806
816
  }
817
+ /*
818
+ Un run ne passe pas par le réseau pour se déléguer à lui-même.
819
+
820
+ Donna délègue DEPUIS l'intérieur du run conversationnel qui vient d'être
821
+ marqué actif. Passer par POST /delegate revenait à demander au runtime
822
+ l'autorisation de démarrer un run alors qu'un run tourne déjà — le sien —
823
+ et l'endpoint répondait 409, à juste titre de son point de vue. Le run
824
+ refusait sa propre délégation.
825
+
826
+ Déléguer n'est pas démarrer un second run : c'est faire passer celui-ci
827
+ de la décision à l'exécution. Quand ce chemin interne existe (agent
828
+ exécuté dans le processus du runtime), on l'emprunte : même `runId`,
829
+ aucun run concurrent, aucun 409. Le chemin HTTP reste pour les appelants
830
+ réellement extérieurs — le Shell, un client tiers —, et c'est là que le
831
+ 409 garde tout son sens.
832
+ */
833
+ if (typeof session?._delegateWithinRun === 'function') {
834
+ try {
835
+ const inRun = await session._delegateWithinRun(objective);
836
+ return JSON.stringify({
837
+ delegated: true,
838
+ runId: inRun.runId,
839
+ summary: inRun.summary ?? null,
840
+ message: `Action lancée (${String(inRun.runId).slice(0, 8)}) après validation du plan réel : ${inRun.summary?.tasks ?? 0} tâche(s), ${inRun.summary?.agent ?? 'agent résolu'}. Exécution en cours.`,
841
+ });
842
+ } catch (err) {
843
+ return `Délégation refusée : ${err instanceof Error ? err.message : String(err)}`;
844
+ }
845
+ }
807
846
  const result = await postRuntimeDelegate(objective, { url, workspace });
808
847
  return result?.runId
809
848
  ? JSON.stringify({
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * JOB statuses as reported by headless polling, mapped to a process exit
5
+ * code — a different contract from the orchestrator's task vocabulary.
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 { randomUUID } from 'node:crypto';
2
11
  import { spawnSync } from 'node:child_process';
3
12
  import { readFileSync } from 'node:fs';
@@ -297,11 +306,28 @@ export function createInteractiveSession(context, { runtimeUrl, turnId, signal =
297
306
  return session;
298
307
  }
299
308
 
309
+ /*
310
+ Un tour interactif se termine TOUJOURS par un assistant_message.
311
+
312
+ C'est la condition de fin que les deux interfaces attendent : côté `serve`, la
313
+ bulle « Request received · Donna is preparing… » n'est retirée que lorsqu'un
314
+ message assistant non vide arrive. La garde `!content` renvoyait donc `false`
315
+ en silence quand le tour ne produisait rien — modèle qui répond vide, boucle
316
+ d'outils qui s'arrête sans conclure — et le point d'attente tournait
317
+ indéfiniment, sans erreur nulle part.
318
+
319
+ Une réponse vide est un résultat, pas une raison de ne rien dire.
320
+ */
300
321
  export function ensureInteractiveAssistantMessage(session, response, { turnId, workspace } = {}) {
322
+ if (session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
301
323
  const content = String(response ?? '').trim();
302
- if (!content || session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
303
324
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
304
- origin: 'runtime_turn', turnId, workspace, payload: { content: String(response) },
325
+ origin: 'runtime_turn',
326
+ turnId,
327
+ workspace,
328
+ payload: {
329
+ content: content || 'No answer was produced for this turn. The model returned nothing — try rephrasing, or switch to /agent if the request needs an action.',
330
+ },
305
331
  }));
306
332
  return true;
307
333
  }
@@ -317,17 +343,7 @@ export async function forwardRuntimeApproval(getWorkspaceContext, request = {})
317
343
  return context.approvalManager?.approve(request) ?? { approved: false };
318
344
  }
319
345
 
320
- export function resolvePreparedDelegationApproval({
321
- autoApprove = false,
322
- approvalManager = null,
323
- runId,
324
- } = {}) {
325
- if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
326
- return { approved: false, awaitingApproval: true };
327
- }
328
- const result = approvalManager.approve({ scope: 'run', runId });
329
- return { approved: true, awaitingApproval: false, result };
330
- }
346
+ export { resolvePreparedDelegationApproval } from '../runtime/delegation.js';
331
347
 
332
348
  function timestampForFile() {
333
349
  return new Date().toISOString().replace(/[:.]/g, '-');
@@ -1168,39 +1184,33 @@ async function runRuntime(argv, agent) {
1168
1184
  : undefined;
1169
1185
  supervisor?.setRunSignal(signal);
1170
1186
  session._onStep = (message) => emitRuntimeLog(session, message);
1171
- if (body.preparedDelegation?.fragment) {
1172
- const { integrate } = await import('../orchestrator/planIntegrator.js');
1173
- const prepared = body.preparedDelegation;
1174
- const integrated = integrate(runId, prepared.fragment, {
1187
+ session._delegateWithinRun = async (objective) => {
1188
+ const { delegateWithinRun } = await import('../runtime/delegation.js');
1189
+ return delegateWithinRun(session, objective, {
1190
+ prepare: ({ objective: goal }) => prepareDelegation(context, { objective: goal }),
1175
1191
  registry: capabilityRegistryForSession(session),
1176
- session,
1177
1192
  store,
1178
- workspace: session.workspace ?? null,
1179
- enforceApprovalCoverage: true,
1193
+ approvalManager: context.approvalManager,
1194
+ autoApprove: body.autoApprove === true,
1180
1195
  });
1181
- if (!integrated.ok) {
1182
- throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
1183
- }
1184
- emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
1196
+ };
1197
+ if (body.preparedDelegation?.fragment) {
1198
+ const { integratePreparedDelegation } = await import('../runtime/delegation.js');
1199
+ const prepared = body.preparedDelegation;
1185
1200
  // Real approval gate (opt-out): a directly-delegated run only skips the
1186
1201
  // human approval step when the caller explicitly opts in via
1187
1202
  // `autoApprove` (e.g. headless/CI, or a future "trust this run" toggle).
1188
- // By default the run WAITS: integrate() above created the per-task
1189
- // approval requests, and the scheduler's approvalCovered() filter blocks
1190
- // the mutating tasks until a run-scope grant arrives (/approve or
1191
- // "valide tout"). This keeps a visible pending_approval window instead of
1192
- // resolving it programmatically ~30ms after launch, which no polled UI
1193
- // could ever render.
1194
- const approval = resolvePreparedDelegationApproval({
1195
- autoApprove: body.autoApprove,
1196
- approvalManager: context.approvalManager,
1203
+ // By default the run WAITS, so the pending_approval window stays
1204
+ // visible instead of being resolved ~30ms after launch.
1205
+ integratePreparedDelegation({
1206
+ session,
1207
+ store,
1197
1208
  runId,
1209
+ prepared,
1210
+ registry: capabilityRegistryForSession(session),
1211
+ approvalManager: context.approvalManager,
1212
+ autoApprove: body.autoApprove === true,
1198
1213
  });
1199
- if (approval.approved) {
1200
- emitRuntimeLog(session, `approval: run ${runId} auto-approved (autoApprove opt-in)`);
1201
- } else {
1202
- emitRuntimeLog(session, `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`);
1203
- }
1204
1214
  body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
1205
1215
  }
1206
1216
  // Deterministic capability run (/ingest): ask the capable agent for its
@@ -1372,6 +1382,22 @@ async function runRuntime(argv, agent) {
1372
1382
  response = await runHeadlessChatTurn(ephemeral, input, {
1373
1383
  history,
1374
1384
  onStep: ephemeral._onStep,
1385
+ // Fragments de réponse publiés au fil de l'eau. Le réducteur les
1386
+ // agrège dans la dernière entrée de conversation (`assistant_delta`),
1387
+ // que `assistant_message` vient ensuite figer : les deux interfaces
1388
+ // voient la réponse s'écrire, au lieu d'attendre le tour complet.
1389
+ onTextDelta: (delta) => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta', {
1390
+ origin: 'runtime_turn',
1391
+ turnId,
1392
+ workspace: context.workspace ?? null,
1393
+ payload: { delta },
1394
+ })),
1395
+ onTextReset: () => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
1396
+ origin: 'runtime_turn',
1397
+ turnId,
1398
+ workspace: context.workspace ?? null,
1399
+ payload: {},
1400
+ })),
1375
1401
  // UI context from `wiki serve`: up to five selected wiki or raw
1376
1402
  // documents. Only paths are prompted; Donna reads through tools.
1377
1403
  openWikiPages: body.context?.openWikiPages ?? body.context?.openWikiPage,
@@ -1,8 +1,18 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ *
4
+ * CONTROL QUEUE item statuses, which include `expired`. A control request
5
+ * is not a task and does not share its lifecycle.
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
+ */
10
+ import { isTerminal } from '../orchestrator/taskStatuses.js';
1
11
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
12
  import { openExternalUrl } from '../shell/openExternal.js';
3
13
  import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
4
14
  import { join, relative } from 'node:path';
5
- import { composeServices, listServices, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
15
+ import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
6
16
  import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
7
17
  import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
8
18
  import {
@@ -558,12 +568,23 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
558
568
  try {
559
569
  context.onStep?.(`Workspace: creating ${workspaceName}…`);
560
570
  const output = await createWorkspace(workspaceName, targetPath, { timeout: 600_000 });
561
- finalizeCreatedWorkspace(workspaceName);
571
+ // Seed the new workspace from the one in use: the LLM endpoint, key and
572
+ // model are almost always the same, and re-entering them by hand was the
573
+ // first thing to do after every /new.
574
+ const { inherited } = await finalizeCreatedWorkspace(workspaceName, {
575
+ inheritFrom: context.session?.workspace ?? null,
576
+ });
562
577
  return {
563
578
  output: [
564
579
  output,
565
580
  '',
566
581
  `Workspace created: ${workspaceName}`,
582
+ // State what was carried over. Inheriting silently would make a wrong
583
+ // endpoint look like a scaffold default and send the operator hunting
584
+ // in the wrong file.
585
+ inherited.length > 0
586
+ ? `Inherited from ${context.session.workspace}: ${inherited.join(', ')}`
587
+ : null,
567
588
  `Use /use ${workspaceName} to load it.`,
568
589
  ].filter(Boolean).join('\n'),
569
590
  };
@@ -715,7 +736,7 @@ ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
715
736
  ${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
716
737
  ${helpPair('/config edit <n>', 'Edit config', '/workspace delete <n>', 'Delete workspace')}
717
738
  ${helpPair('/services', 'Services', '/start [all|agents|services]', 'all = services + agents')}
718
- ${helpPair('/stop [all|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
739
+ ${helpPair('/stop [all|everything|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
719
740
  ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
720
741
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
721
742
  ${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
@@ -850,7 +871,7 @@ function formatRuntimeRunStatus(state) {
850
871
  ? state.controlQueue.filter((item) => item.status === 'queued').length
851
872
  : 0;
852
873
  const tasks = Array.isArray(state?.workflow?.nodes)
853
- ? state.workflow.nodes.filter((node) => node.type === 'task' && !['done', 'failed', 'cancelled'].includes(String(node.status))).length
874
+ ? state.workflow.nodes.filter((node) => node.type === 'task' && !isTerminal(node.status)).length
854
875
  : 0;
855
876
  return `runtime: ${status}${runId} · queued=${queued} · activeTasks=${tasks}`;
856
877
  }
@@ -1118,14 +1139,31 @@ export async function handleSlashCommand(line, context) {
1118
1139
  // compris. Il ne stoppait que les services du workspace et laissait les
1119
1140
  // agents debout — donc `/start all` puis `/stop all` ne revenait pas à
1120
1141
  // l'état de départ.
1121
- const stopsAgents = service === 'all';
1122
- const stopTarget = service === 'services' ? undefined : service;
1142
+ //
1143
+ // Cette symétrie ne tient que tant qu'un seul workspace tourne. Les
1144
+ // agents externes sont UNE pile partagée : les arrêter depuis un
1145
+ // workspace coupait les autres, qui n'avaient rien demandé et ne
1146
+ // voyaient qu'une panne. « all » reste donc « toute ma pile », et les
1147
+ // agents ne tombent que s'ils ne servent plus personne. `/stop
1148
+ // everything` garde la coupure franche, explicitement demandée.
1149
+ const stopsEverything = service === 'everything';
1150
+ const stopsAgents = service === 'all' || stopsEverything;
1151
+ const stopTarget = service === 'services' || stopsEverything ? undefined : service;
1123
1152
  try {
1124
- step(`Services: stopping ${service ?? 'workspace services'}…`);
1153
+ step(`Services: stopping ${stopsEverything ? 'all workspaces and agents' : (service ?? 'workspace services')}…`);
1125
1154
  await stopService(context.session, stopTarget);
1126
1155
  if (stopsAgents) {
1127
- const agentsResult = await runAgentCommand(stopAgents, 'stop');
1128
- if (agentsResult?.failed) return agentsResult;
1156
+ const busy = stopsEverything
1157
+ ? []
1158
+ : await otherWorkspacesRunning(context.session, listWorkspaces());
1159
+ if (busy.length > 0) {
1160
+ // Say who is holding them, and how to override. A silent skip
1161
+ // would look exactly like the bug we just fixed.
1162
+ step(`Services: agents left running for ${busy.join(', ')} — use /stop everything to stop them anyway.`);
1163
+ } else {
1164
+ const agentsResult = await runAgentCommand(stopAgents, 'stop');
1165
+ if (agentsResult?.failed) return agentsResult;
1166
+ }
1129
1167
  }
1130
1168
  step('Services: refreshing MCP runtime…');
1131
1169
  await refreshMcpRuntimeStatus(context.session);
@@ -1,4 +1,5 @@
1
1
  import { validateContractInDev } from '../contracts/schemas.js';
2
+ import { isTerminal, isUnsuccessfulTerminal } from '../orchestrator/taskStatuses.js';
2
3
 
3
4
  export function parseJsonText(text) {
4
5
  try {
@@ -13,7 +14,7 @@ function basename(value) {
13
14
  }
14
15
 
15
16
  function terminalStatus(status) {
16
- return ['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error'].includes(String(status ?? '').toLowerCase());
17
+ return isTerminal(status);
17
18
  }
18
19
 
19
20
  export function activityKey(activity) {
@@ -251,7 +252,7 @@ export function newNonTerminalActivities(snapshotBefore, session) {
251
252
 
252
253
  export function terminalFailures(activities) {
253
254
  return activities.filter(
254
- (a) => a.terminal && ['failed', 'error', 'cancelled', 'canceled'].includes(String(a.status).toLowerCase()),
255
+ (a) => a.terminal && isUnsuccessfulTerminal(a.status),
255
256
  );
256
257
  }
257
258
 
@@ -4,6 +4,7 @@ import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPa
4
4
  import { formatRuntimeLogPayload } from './runtimeLog.js';
5
5
  import { projectWorkflow } from './workflow.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
7
+ import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
7
8
 
8
9
  const SESSION_PROJECTION_EVENTS = new Set([
9
10
  'run_started',
@@ -267,6 +268,9 @@ function applyEvent(state, event) {
267
268
  case 'assistant_delta':
268
269
  appendAssistantDelta(state, String(event.payload?.delta ?? ''));
269
270
  return;
271
+ case 'assistant_delta_reset':
272
+ discardStreamingAssistantMessage(state);
273
+ return;
270
274
  case 'tool_call_started':
271
275
  state.chain.push({
272
276
  type: 'tool',
@@ -343,7 +347,13 @@ function applyEvent(state, event) {
343
347
  state.planRevision = normalizePlanRevision(event.payload?.planRevision ?? state.planRevision + 1);
344
348
  return;
345
349
  case 'plan_step_updated':
346
- updatePlanStep(state.plan, event.payload ?? {});
350
+ {
351
+ // L'anomalie remonte par la valeur de retour plutôt que par une
352
+ // référence au state : `updatePlanStep` reste une fonction sur un
353
+ // plan, et le journal reste la responsabilité de l'appelant.
354
+ const anomaly = updatePlanStep(state.plan, event.payload ?? {});
355
+ if (anomaly) state.logs.push(anomaly);
356
+ }
347
357
  return;
348
358
  case 'control_message_received':
349
359
  state.logs.push(`Control message: ${String(event.payload?.input ?? '')}`);
@@ -659,6 +669,26 @@ function appendAssistantDelta(state, delta) {
659
669
  }
660
670
  }
661
671
 
672
+ /*
673
+ Jeter une réponse en cours d'écriture, sans retirer son entrée.
674
+
675
+ Une itération de la boucle d'outils peut produire du texte puis décider
676
+ d'appeler un outil : ce texte est un raisonnement intermédiaire que le tour
677
+ suivant remplace, il ne doit pas rester à l'écran.
678
+
679
+ L'entrée est vidée, jamais dépilée. La réconciliation de `serve`
680
+ (`chatHtml.ts`) suppose une conversation en ajout seul — « le serveur ne mute
681
+ que la dernière entrée, tout ce qui précède est acquis » — et n'indexe la
682
+ boucle que sur la longueur croissante. Un `pop` la ferait passer sous le
683
+ nombre de références déjà rendues : l'élément DOM en trop resterait affiché
684
+ avec le texte qu'on voulait justement effacer, et tous les messages suivants
685
+ se décaleraient d'un cran.
686
+ */
687
+ function discardStreamingAssistantMessage(state) {
688
+ const last = state.conversation.at(-1);
689
+ if (last?.role === 'assistant' && last.streaming) last.content = '';
690
+ }
691
+
662
692
  function finalizeAssistantMessage(state, content) {
663
693
  const last = state.conversation.at(-1);
664
694
  if (last?.role === 'assistant' && last.streaming) {
@@ -725,7 +755,7 @@ function markCoveredApprovalsApproved(approvals, grant, ts) {
725
755
 
726
756
  function cancelPendingPlanSteps(plan) {
727
757
  for (const step of plan ?? []) {
728
- if (!['done', 'failed', 'cancelled'].includes(String(step.status ?? ''))) step.status = 'cancelled';
758
+ if (!isTerminal(step.status)) step.status = 'cancelled';
729
759
  }
730
760
  }
731
761
 
@@ -852,24 +882,51 @@ function normalizePlanTask(raw, index, { owner = 'orchestrator', ownerActivityKe
852
882
  }
853
883
 
854
884
  function updatePlanStep(plan, payload) {
855
- if (!plan) return;
885
+ let anomaly = null;
886
+ if (!plan) return anomaly;
856
887
  const requestedTaskId = payload.taskId ?? payload.id ?? payload.targetTaskId;
857
888
  const step = requestedTaskId != null
858
889
  ? plan.find((item) => String(item.id ?? item.step) === String(requestedTaskId))
859
890
  : plan.find((item) => item.step === Number(payload.step));
860
- if (!step) return;
861
- if (payload.status === 'failed') step.status = 'failed';
862
- else if (payload.status === 'running') step.status = 'running';
863
- else if (payload.status === 'pending') step.status = 'pending';
864
- else if (payload.status === 'pending_approval') step.status = 'pending_approval';
865
- else if (payload.status === 'waiting_approval') step.status = 'waiting_approval';
866
- else if (payload.status === 'cancelled') step.status = 'cancelled';
867
- else step.status = 'done';
891
+ if (!step) return anomaly;
892
+ /*
893
+ Un statut inconnu ne vaut pas « réussi ».
894
+
895
+ La cascade se terminait par `else step.status = 'done'` : tout statut non
896
+ énuméré — `skipped`, par exemple — était projeté en succès. Le runner
897
+ marquait bien une tâche ignorée, la projection la déclarait faite, et le
898
+ résumé de run comptait une réussite qui n'a jamais eu lieu. Le défaut le
899
+ plus dangereux est celui qui transforme une inconnue en bonne nouvelle.
900
+
901
+ Trois cas, et un seul mène à `done` :
902
+
903
+ - un statut reconnu par le vocabulaire commun est repris tel quel, alias
904
+ compris (`succeeded` → `done`, `error` → `failed`) ;
905
+ - l'ABSENCE de statut garde le contrat historique — un événement de fin
906
+ sans précision signifie « terminé » ;
907
+ - un statut présent mais incompréhensible laisse l'étape dans l'état où
908
+ elle était, et signale une anomalie de projection. On ne sait pas ce qui
909
+ s'est passé : le dire est plus utile que d'inventer une réponse.
910
+ */
911
+ const canonical = normalizeTaskStatus(payload.status);
912
+ if (canonical) {
913
+ step.status = canonical;
914
+ } else if (isUnknownStatus(payload.status)) {
915
+ anomaly = `projection: unknown status "${String(payload.status)}" for plan step ${String(requestedTaskId ?? payload.step ?? '?')} — kept "${String(step.status ?? 'pending')}"`;
916
+ } else {
917
+ step.status = 'done';
918
+ }
919
+ // Le motif d'un abandon est la seule chose qui le rende actionnable :
920
+ // « ignorée » sans « parce que » n'apprend rien à qui relance.
921
+ if (step.status === 'skipped' && payload.reason && !step.error) {
922
+ step.error = { code: 'dependency_failed', message: String(payload.reason) };
923
+ }
868
924
  if (payload.activityKey) step.activityKey = payload.activityKey;
869
925
  if (Array.isArray(payload.outputRefs)) step.outputRefs = payload.outputRefs.map(cloneRef);
870
926
  if (payload.result) step.result = cloneJson(payload.result);
871
927
  if (payload.retryState) step.retryState = cloneJson(payload.retryState);
872
928
  if (payload.retryAssignment) step.retryAssignment = cloneJson(payload.retryAssignment);
929
+ return anomaly;
873
930
  }
874
931
 
875
932
  function formatPlanErrors(errors) {
@@ -547,3 +547,79 @@ test('run_error cancels pending plan steps and active activities (no ghosts at r
547
547
  assert.equal(activity.status, 'cancelled');
548
548
  assert.equal(activity.terminal, true);
549
549
  });
550
+
551
+ /*
552
+ La cascade de statuts se terminait par `else step.status = 'done'` : un statut
553
+ non énuméré était projeté en succès. Le runner marquait une tâche `skipped`
554
+ faute de dépendance, la projection la déclarait faite, et le résumé comptait
555
+ une réussite qui n'avait jamais eu lieu — le pire des défauts, celui qui
556
+ transforme une inconnue en bonne nouvelle.
557
+ */
558
+ test('reduceAgentEvents: un statut de plan inconnu ne devient pas un succès', () => {
559
+ const projection = reduceAgentEvents([
560
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Ingest a', 'Ingest b'] } }),
561
+ createAgentEvent('plan_step_updated', {
562
+ origin: 'runtime',
563
+ payload: { step: 1, status: 'skipped', reason: 'dependency_failed:convert' },
564
+ }),
565
+ createAgentEvent('plan_step_updated', {
566
+ origin: 'runtime',
567
+ payload: { step: 2, status: 'brouette' },
568
+ }),
569
+ ]);
570
+
571
+ assert.equal(projection.plan[0].status, 'skipped');
572
+ assert.equal(projection.plan[0].error?.code, 'dependency_failed');
573
+ assert.match(projection.plan[0].error.message, /convert/);
574
+ // Un statut incompréhensible laisse l'étape où elle était et se signale :
575
+ // on ne sait pas ce qui s'est passé, le dire vaut mieux que d'inventer.
576
+ assert.equal(projection.plan[1].status, 'pending');
577
+ assert.equal(projection.logs.some((line) => /unknown status "brouette"/.test(line)), true);
578
+ });
579
+
580
+ test('reduceAgentEvents: les alias de statut tombent sur le canonique', () => {
581
+ const projection = reduceAgentEvents([
582
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['A', 'B', 'C'] } }),
583
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'succeeded' } }),
584
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 2, status: 'error' } }),
585
+ // Contrat historique : un événement de fin sans statut vaut « terminé ».
586
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 3 } }),
587
+ ]);
588
+
589
+ assert.equal(projection.plan[0].status, 'done');
590
+ assert.equal(projection.plan[1].status, 'failed');
591
+ assert.equal(projection.plan[2].status, 'done');
592
+ assert.equal(projection.logs.some((line) => /unknown status/.test(line)), false);
593
+ });
594
+
595
+ // La bulle « Request received · Donna is preparing… » n'est retirée côté serve
596
+ // que lorsqu'un message assistant NON VIDE arrive. Un tour sans réponse ne
597
+ // publiait rien : le point d'attente tournait jusqu'au rechargement de la page.
598
+ test('a discarded stream is emptied, never popped', () => {
599
+ const projection = reduceAgentEvents([
600
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
601
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
602
+ createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
603
+ ]);
604
+
605
+ // La réconciliation de serve suppose une conversation en ajout seul : dépiler
606
+ // la ferait passer sous le nombre d'éléments déjà rendus, laissant à l'écran
607
+ // le texte qu'on voulait effacer et décalant tous les messages suivants.
608
+ assert.equal(projection.conversation.length, 2);
609
+ assert.deepEqual(projection.conversation[1], { role: 'assistant', content: '', streaming: true });
610
+ });
611
+
612
+ test('a stream resumed after a discard carries only the final text', () => {
613
+ const projection = reduceAgentEvents([
614
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
615
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
616
+ createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
617
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: '12 ' } }),
618
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'pages.' } }),
619
+ createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: '12 pages.' } }),
620
+ ]);
621
+
622
+ assert.equal(projection.conversation.length, 2);
623
+ assert.equal(projection.conversation[1].content, '12 pages.');
624
+ assert.equal(projection.conversation[1].streaming, undefined, 'le message doit être figé');
625
+ });