@dotdrelle/wiki-manager 0.15.66 → 0.15.71

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 (72) hide show
  1. package/.env.example +10 -3
  2. package/README.md +57 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +39 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/activity/activityAggregator.test.js +2 -2
  8. package/src/agent/graph.js +13 -11
  9. package/src/agent/skillRecursion.test.js +13 -12
  10. package/src/cli/wiki-manager.js +125 -37
  11. package/src/cli/wiki-manager.test.js +16 -16
  12. package/src/commands/slash.js +59 -5
  13. package/src/contracts/schemas.js +67 -0
  14. package/src/core/activity.js +5 -0
  15. package/src/core/agentEvents.js +139 -25
  16. package/src/core/agentEvents.test.js +26 -1
  17. package/src/core/buildInfo.json +2 -2
  18. package/src/core/commandFailure.test.js +2 -2
  19. package/src/core/currentArtifact.test.js +5 -5
  20. package/src/core/dockerCompose.test.js +8 -40
  21. package/src/core/env.js +14 -0
  22. package/src/core/env.test.js +19 -0
  23. package/src/core/googleGrants.test.js +1 -1
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/mcp.test.js +1 -1
  26. package/src/core/otherWorkspacesRunning.test.js +6 -6
  27. package/src/core/runtimeEventAdapter.js +81 -0
  28. package/src/core/runtimeEventAdapter.test.js +61 -0
  29. package/src/core/runtimeLog.js +35 -1
  30. package/src/core/runtimeLog.test.js +27 -2
  31. package/src/core/skillChainView.test.js +2 -2
  32. package/src/core/skillCompiler.test.js +1 -1
  33. package/src/core/skillInvocation.js +13 -8
  34. package/src/core/skillInvocation.test.js +1 -1
  35. package/src/core/startupCheck.js +58 -0
  36. package/src/core/startupCheck.test.js +29 -1
  37. package/src/core/wikiSetup.js +25 -0
  38. package/src/core/wikiSetup.test.js +35 -0
  39. package/src/core/wikirc.test.js +6 -6
  40. package/src/core/workspaceInherit.test.js +14 -14
  41. package/src/orchestrator/agentRegistry.js +1 -22
  42. package/src/orchestrator/agentRegistry.test.js +6 -6
  43. package/src/orchestrator/assignmentManager.js +16 -4
  44. package/src/orchestrator/capabilityRegistry.js +8 -1
  45. package/src/orchestrator/dispatcher.js +405 -2
  46. package/src/orchestrator/dispatcher.test.js +158 -4
  47. package/src/orchestrator/objectiveResolver.js +10 -6
  48. package/src/orchestrator/objectiveResolver.test.js +26 -27
  49. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  50. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  51. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  52. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  53. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  54. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  55. package/src/orchestrator/providers/runtimeProviders.js +378 -0
  56. package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
  57. package/src/orchestrator/resultAggregator.js +35 -2
  58. package/src/orchestrator/resultAggregator.test.js +62 -0
  59. package/src/orchestrator/scheduler.test.js +4 -4
  60. package/src/runtime/delegation.test.js +11 -11
  61. package/src/runtime/recoveryManager.js +70 -5
  62. package/src/runtime/runner.test.js +1 -1
  63. package/src/runtime/server.test.js +2 -2
  64. package/src/runtime/skillChain.e2e.test.js +2 -2
  65. package/src/runtime/store.test.js +8 -5
  66. package/src/runtime/supervisor.js +5 -10
  67. package/src/runtime/workspaceIsolation.test.js +26 -26
  68. package/src/shell/RightPane.tsx +23 -3
  69. package/src/shell/StartupScreen.tsx +44 -7
  70. package/src/shell/repl.js +24 -2
  71. package/src/shell/repl.test.js +13 -0
  72. package/wiki-workspace +53 -3
@@ -1,7 +1,7 @@
1
1
  import { parseJsonText } from '../core/activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
4
- import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
4
+ import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
5
5
  import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
6
6
  import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
7
7
 
@@ -100,6 +100,9 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
100
100
  }
101
101
 
102
102
  const agent = agentFor(session, assignment.agentInstanceId);
103
+ if (agent?.providerKind === 'external-runtime' && typeof agent?.runtimeProvider?.status === 'function') {
104
+ return recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator });
105
+ }
103
106
  const serverName = agent?.serverName ?? assignment.agentId ?? assignment.agentInstanceId;
104
107
  const statusTool = toolNameFor(session, serverName, 'agent_status');
105
108
  const status = parseToolPayload(await callTool(session.mcp, serverName, statusTool, { jobId: attempt.jobId }));
@@ -146,6 +149,70 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
146
149
  return interruptTask({ store, session, run, task, reason: 'active job is non-terminal and task has no idempotencyKey' });
147
150
  }
148
151
 
152
+ // An MCP job survives a manager restart on the agent's own side and reports
153
+ // its status through agent_status. An external-runtime job has no such
154
+ // side-channel here: the only way to check on it, or to give it up, is the
155
+ // same RuntimeProvider the dispatcher used to start it, re-resolved by
156
+ // agentInstanceId from the live registry rather than replayed from storage.
157
+ async function recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator }) {
158
+ const runtimeProvider = agent.runtimeProvider;
159
+ let status;
160
+ try {
161
+ status = await runtimeProvider.status(attempt.jobId);
162
+ } catch (error) {
163
+ return interruptTask({
164
+ store,
165
+ session,
166
+ run,
167
+ task,
168
+ reason: `external runtime status check failed: ${error instanceof Error ? error.message : String(error)}`,
169
+ });
170
+ }
171
+
172
+ if (isTerminal(status?.status)) {
173
+ const result = {
174
+ ok: isSuccessful(String(status?.status ?? '').toLowerCase()),
175
+ taskId: task.id,
176
+ attemptId: attempt.attemptId ?? null,
177
+ jobId: attempt.jobId,
178
+ agentInstanceId: assignment.agentInstanceId,
179
+ status: status?.status,
180
+ outputRefs: Array.isArray(status?.result?.outputRefs) ? status.result.outputRefs : [],
181
+ metrics: status?.result?.metrics ?? {},
182
+ // The gateway reports its failure at the TOP level of the status
183
+ // payload ({ runId, status, error }), not inside `result` — same fix
184
+ // already applied in dispatcher.js's taskResultFromStatus and
185
+ // deepAgentsProvider.js's status().
186
+ error: status?.result?.error ?? status?.error ?? null,
187
+ rawStatus: status,
188
+ };
189
+ await resultAggregator(result, {
190
+ session,
191
+ runId: run.id,
192
+ task,
193
+ assignment: { agentInstanceId: assignment.agentInstanceId, serverName: null, agent },
194
+ store,
195
+ registry: capabilityRegistryForSession(session),
196
+ workspaceConfig: session.wikircConfig ?? session.wikirc?.config ?? {},
197
+ });
198
+ return { status: 'recovered', runId: run.id, taskId: task.id, jobId: attempt.jobId };
199
+ }
200
+
201
+ // The runtime declares supportsIdempotency: false (runtimeProviders.js), so
202
+ // a fresh invocation cannot be deduped against the one still running on the
203
+ // external side. Requeuing it to `pending` like the MCP path below would
204
+ // start a duplicate while the orphaned original keeps running/billing.
205
+ // Cancel it explicitly instead of leaving it to run unattended.
206
+ await runtimeProvider.cancel(attempt.jobId).catch(() => null);
207
+ return interruptTask({
208
+ store,
209
+ session,
210
+ run,
211
+ task,
212
+ reason: 'active external-runtime job cancelled on recovery (no idempotency support)',
213
+ });
214
+ }
215
+
149
216
  function interruptTask({ store, session, run, task, reason }) {
150
217
  dispatch(session, store, 'runtime_log', {
151
218
  origin: 'recovery_manager',
@@ -171,10 +238,7 @@ function latestAssignment(assignments, attemptId) {
171
238
 
172
239
 
173
240
  function capabilityResolvable(session, capability) {
174
- const registry = session.capabilityRegistry
175
- ?? ((session.agentRegistrySnapshot ?? []).length > 0
176
- ? createCapabilityRegistry({ agents: session.agentRegistrySnapshot })
177
- : null);
241
+ const registry = capabilityRegistryForSession(session);
178
242
  if (!registry || typeof registry.providersFor !== 'function') return true;
179
243
  // Only trust a registry that actually knows about capabilities. An empty
180
244
  // one (discovery not finished, or agents described without capability
@@ -211,6 +275,7 @@ function agentFor(session, agentInstanceId) {
211
275
  return [
212
276
  ...(session.agentRegistrySnapshot ?? []),
213
277
  ...(session.agents ?? []),
278
+ ...(session.runtimeProviderAgents ?? []),
214
279
  ].find((agent) => agent?.agentInstanceId === agentInstanceId) ?? null;
215
280
  }
216
281
 
@@ -829,7 +829,7 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
829
829
  laissait le run « stalled ». Ne pas attendre d'approbation était déjà acquis,
830
830
  mais s'arrêter là déclenchait une replanification et laissait le run vivant.
831
831
 
832
- Cas observé le 2026-08-04 (workspace juno) : dix fichiers à ingérer, neuf
832
+ Cas observé le 2026-08-04 (workspace demo) : dix fichiers à ingérer, neuf
833
833
  réussis, un en échec sur du JSON malformé — le run n'est jamais retombé.
834
834
  Une tâche qui ne deviendra jamais exécutable est donc marquée `skipped` avec
835
835
  le nom de la dépendance fautive, et le run se termine sur un résultat partiel.
@@ -2154,7 +2154,7 @@ test('runtime health reports active runs across workspaces', async (t) => {
2154
2154
  session: {},
2155
2155
  // The shell reads this at exit: shutting down its own runtime must not
2156
2156
  // kill a run that is supposed to survive the shell.
2157
- listActiveRuns: () => [{ workspace: 'juno', runId: 'run-1234abcd' }],
2157
+ listActiveRuns: () => [{ workspace: 'demo', runId: 'run-1234abcd' }],
2158
2158
  });
2159
2159
  } catch (err) {
2160
2160
  if (err?.code === 'EPERM') {
@@ -2166,7 +2166,7 @@ test('runtime health reports active runs across workspaces', async (t) => {
2166
2166
 
2167
2167
  try {
2168
2168
  const health = await (await fetch(`http://127.0.0.1:${handle.port}/health`)).json();
2169
- assert.deepEqual(health.activeRuns, [{ workspace: 'juno', runId: 'run-1234abcd' }]);
2169
+ assert.deepEqual(health.activeRuns, [{ workspace: 'demo', runId: 'run-1234abcd' }]);
2170
2170
  } finally {
2171
2171
  await handle.close();
2172
2172
  }
@@ -142,7 +142,7 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
142
142
  assert.equal(body.objectives, 2);
143
143
  assert.equal(env.runs.length, 2, 'the second objective must run after the first');
144
144
  assert.match(env.runs[0].input, /^Export the requested Confluence source/);
145
- assert.match(env.runs[1].input, /^Run the production pipeline over the newly exported Markdown/);
145
+ assert.match(env.runs[1].input, /^Run the production pipeline step ingest over the newly exported Markdown/);
146
146
  // CME first, Production second — and the parameter reaches the step that
147
147
  // consumes it, not only the last objective.
148
148
  for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
@@ -202,7 +202,7 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
202
202
  // that silently fragments would show up as extra runs, not as extra objectives.
203
203
  const PERFORMANCE_TABLE = {
204
204
  pipeline: 1,
205
- 'wiki-ingest': 2,
205
+ 'wiki-ingest': 1,
206
206
  'wiki-build': 1,
207
207
  deliver: 1,
208
208
  diagnose: 1,
@@ -216,7 +216,10 @@ test('runtime store persists task assignments attempts and results from events',
216
216
  const reopened = openRuntimeStore({ stateDir });
217
217
  const session = { activities: {}, headlessPlan: null };
218
218
  reopened.hydrateSession(session, { workspace: 'docs' });
219
- assert.ok(reopened.getState(session, { workspace: 'docs' }).logs.some((line) => line === `Task assigned: ${taskId}`));
219
+ const assignedLine = reopened.getState(session, { workspace: 'docs' }).logs.find((line) => /▸ Build A — started/.test(line));
220
+ assert.ok(assignedLine, 'expected a readable task-started line carrying the plan label');
221
+ assert.match(assignedLine, /document\.build/);
222
+ assert.match(assignedLine, /production-main/);
220
223
  assert.equal(reopened.listTaskAttempts({ taskId })[0].jobId, 'job-1');
221
224
  assert.equal(reopened.getTaskResult({ taskId }).status, 'succeeded');
222
225
  reopened.close();
@@ -1131,10 +1134,10 @@ test('un agent restauré par hydrateSession n’est plus routable tant qu’aucu
1131
1134
  premiers.
1132
1135
  */
1133
1136
  const store = openRuntimeStore({ stateDir: mkdtempSync(join(tmpdir(), 'wiki-manager-agent-staleness-')) });
1134
- const writer = { agentEvents: [], workspace: 'juno' };
1137
+ const writer = { agentEvents: [], workspace: 'demo' };
1135
1138
  dispatchAgentEvent(writer, createAgentEvent('agent.registered', {
1136
1139
  origin: 'runtime',
1137
- workspace: 'juno',
1140
+ workspace: 'demo',
1138
1141
  payload: {
1139
1142
  agent: {
1140
1143
  agentInstanceId: 'cme-main',
@@ -1152,8 +1155,8 @@ test('un agent restauré par hydrateSession n’est plus routable tant qu’aucu
1152
1155
  for (const event of writer.agentEvents) store.persistEvent(event);
1153
1156
 
1154
1157
  // Redémarrage : une session neuve, aucun scan encore effectué.
1155
- const rebooted = { agentEvents: [], workspace: 'juno' };
1156
- store.hydrateSession(rebooted, { workspace: 'juno' });
1158
+ const rebooted = { agentEvents: [], workspace: 'demo' };
1159
+ store.hydrateSession(rebooted, { workspace: 'demo' });
1157
1160
 
1158
1161
  const restored = [...(rebooted.agents ?? []), ...(rebooted.agentRegistrySnapshot ?? [])];
1159
1162
  assert.ok(restored.length > 0, 'the agent must be restored, only not trusted');
@@ -1,11 +1,11 @@
1
1
  import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
2
2
  import { isAbsolute, join, normalize, resolve } from 'node:path';
3
- import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
+ import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
4
4
  import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
5
5
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
6
- import { normalizeRuntimeLog } from '../core/runtimeLog.js';
7
6
  import { startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
8
7
  import { createAgentRegistry } from '../orchestrator/agentRegistry.js';
8
+ import { discoverRuntimeProvidersOnce } from '../orchestrator/providers/runtimeProviders.js';
9
9
 
10
10
  export function startActivitySupervisor(session, {
11
11
  intervalMs = 1000,
@@ -52,11 +52,13 @@ export function startActivitySupervisor(session, {
52
52
  }
53
53
  }
54
54
  void discoverAgentsOnce(session, { registry, signal: runSignal });
55
+ void discoverRuntimeProvidersOnce(session, { signal: runSignal });
55
56
  }, agentRegistryIntervalMs)
56
57
  : null;
57
58
 
58
59
  void pollActivitiesOnce(session, { pollBusy, callTool, signal: runSignal });
59
60
  void discoverAgentsOnce(session, { registry, signal: runSignal });
61
+ void discoverRuntimeProvidersOnce(session, { signal: runSignal });
60
62
 
61
63
  return {
62
64
  pollBusy,
@@ -199,14 +201,7 @@ export async function pollActivitiesOnce(session, {
199
201
  }
200
202
 
201
203
  export function emitRuntimeLog(session, message) {
202
- const payload = normalizeRuntimeLog(message, { session });
203
- dispatchAgentEvent(session, createAgentEvent('runtime_log', {
204
- origin: 'runtime',
205
- runId: payload.runId ?? null,
206
- taskId: payload.taskId ?? null,
207
- workspace: payload.workspaceId ?? null,
208
- payload,
209
- }));
204
+ dispatchRuntimeLog(session, message);
210
205
  }
211
206
 
212
207
  function registryIntervalFromEnv() {
@@ -34,39 +34,39 @@ test('a session stamps its workspace on every event it dispatches', () => {
34
34
  // ne le retrouverait jamais. La plan/activité d'un run serait perdue au
35
35
  // redémarrage, pour tout le monde.
36
36
  const store = freshStore();
37
- const acpi = sessionFor(store, 'acpi');
37
+ const acmeSession = sessionFor(store, 'acme');
38
38
 
39
- dispatchAgentEvent(acpi, createAgentEvent('user_message', {
39
+ dispatchAgentEvent(acmeSession, createAgentEvent('user_message', {
40
40
  origin: 'user',
41
41
  payload: { content: 'ingest démarré' },
42
42
  }));
43
43
 
44
- const [event] = store.listEvents({ workspace: 'acpi' });
45
- assert.equal(event.workspace, 'acpi', "l'événement doit porter son workspace");
44
+ const [event] = store.listEvents({ workspace: 'acme' });
45
+ assert.equal(event.workspace, 'acme', "l'événement doit porter son workspace");
46
46
  });
47
47
 
48
48
  test('two workspaces writing at the same time never see each other', () => {
49
49
  const store = freshStore();
50
- const acpi = sessionFor(store, 'acpi');
50
+ const acmeSession = sessionFor(store, 'acme');
51
51
  const demo = sessionFor(store, 'demo');
52
52
 
53
53
  // Entrelacé volontairement : c'est la situation réelle de deux `serve`
54
54
  // ouverts côte à côte, pas deux runs successifs.
55
55
  for (let i = 0; i < 5; i += 1) {
56
- dispatchAgentEvent(acpi, createAgentEvent('user_message', {
57
- origin: 'user', payload: { content: `acpi-${i}` },
56
+ dispatchAgentEvent(acmeSession, createAgentEvent('user_message', {
57
+ origin: 'user', payload: { content: `acme-${i}` },
58
58
  }));
59
59
  dispatchAgentEvent(demo, createAgentEvent('user_message', {
60
60
  origin: 'user', payload: { content: `demo-${i}` },
61
61
  }));
62
62
  }
63
63
 
64
- const acpiEvents = store.listEvents({ workspace: 'acpi' });
64
+ const acmeEvents = store.listEvents({ workspace: 'acme' });
65
65
  const demoEvents = store.listEvents({ workspace: 'demo' });
66
66
 
67
- assert.equal(acpiEvents.length, 5);
67
+ assert.equal(acmeEvents.length, 5);
68
68
  assert.equal(demoEvents.length, 5);
69
- assert.ok(acpiEvents.every((event) => event.payload.content.startsWith('acpi-')));
69
+ assert.ok(acmeEvents.every((event) => event.payload.content.startsWith('acme-')));
70
70
  assert.ok(demoEvents.every((event) => event.payload.content.startsWith('demo-')));
71
71
  });
72
72
 
@@ -74,32 +74,32 @@ test('a conversation is rebuilt from its own workspace only', () => {
74
74
  // C'est ce qui décide de ce qu'affiche un `serve` au chargement. Un mélange
75
75
  // ici afficherait les échanges du voisin dans sa fenêtre de chat.
76
76
  const store = freshStore();
77
- const acpi = sessionFor(store, 'acpi');
77
+ const acmeSession = sessionFor(store, 'acme');
78
78
  const demo = sessionFor(store, 'demo');
79
79
 
80
- dispatchAgentEvent(acpi, createAgentEvent('user_message', { origin: 'user', payload: { content: 'question acpi' } }));
80
+ dispatchAgentEvent(acmeSession, createAgentEvent('user_message', { origin: 'user', payload: { content: 'acme question' } }));
81
81
  dispatchAgentEvent(demo, createAgentEvent('user_message', { origin: 'user', payload: { content: 'question demo' } }));
82
- dispatchAgentEvent(acpi, createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'réponse acpi' } }));
82
+ dispatchAgentEvent(acmeSession, createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'acme answer' } }));
83
83
 
84
- const projection = reduceAgentEvents(store.listEvents({ workspace: 'acpi' }));
84
+ const projection = reduceAgentEvents(store.listEvents({ workspace: 'acme' }));
85
85
 
86
86
  assert.deepEqual(projection.conversation.map((entry) => entry.content), [
87
- 'question acpi',
88
- 'réponse acpi',
87
+ 'acme question',
88
+ 'acme answer',
89
89
  ]);
90
90
  });
91
91
 
92
92
  test('purging one workspace leaves the others intact', () => {
93
93
  // `/clear --all` depuis un `serve` ne doit pas vider le runtime du voisin.
94
94
  const store = freshStore();
95
- const acpi = sessionFor(store, 'acpi');
95
+ const acmeSession = sessionFor(store, 'acme');
96
96
  const demo = sessionFor(store, 'demo');
97
- dispatchAgentEvent(acpi, createAgentEvent('user_message', { origin: 'user', payload: { content: 'a' } }));
97
+ dispatchAgentEvent(acmeSession, createAgentEvent('user_message', { origin: 'user', payload: { content: 'a' } }));
98
98
  dispatchAgentEvent(demo, createAgentEvent('user_message', { origin: 'user', payload: { content: 'd' } }));
99
99
 
100
- store.clearWorkspaceState({ workspace: 'acpi' });
100
+ store.clearWorkspaceState({ workspace: 'acme' });
101
101
 
102
- assert.equal(store.listEvents({ workspace: 'acpi' }).length, 0);
102
+ assert.equal(store.listEvents({ workspace: 'acme' }).length, 0);
103
103
  assert.equal(store.listEvents({ workspace: 'demo' }).length, 1);
104
104
  });
105
105
 
@@ -119,12 +119,12 @@ test('a purge without a workspace wipes EVERY workspace', () => {
119
119
  où on décide de refuser plutôt que d'élargir, ce soit un choix explicite.
120
120
  */
121
121
  const store = freshStore();
122
- dispatchAgentEvent(sessionFor(store, 'acpi'), createAgentEvent('user_message', { origin: 'user', payload: { content: 'a' } }));
122
+ dispatchAgentEvent(sessionFor(store, 'acme'), createAgentEvent('user_message', { origin: 'user', payload: { content: 'a' } }));
123
123
  dispatchAgentEvent(sessionFor(store, 'demo'), createAgentEvent('user_message', { origin: 'user', payload: { content: 'd' } }));
124
124
 
125
125
  store.clearWorkspaceState({ workspace: null });
126
126
 
127
- assert.equal(store.listEvents({ workspace: 'acpi' }).length, 0);
127
+ assert.equal(store.listEvents({ workspace: 'acme' }).length, 0);
128
128
  assert.equal(store.listEvents({ workspace: 'demo' }).length, 0);
129
129
  });
130
130
 
@@ -135,11 +135,11 @@ test('the SSE publisher delivers an event only to its own workspace', () => {
135
135
  const deliver = (clientWorkspace, eventWorkspace) =>
136
136
  !(clientWorkspace && eventWorkspace !== clientWorkspace);
137
137
 
138
- assert.equal(deliver('acpi', 'acpi'), true);
139
- assert.equal(deliver('acpi', 'demo'), false, 'un client scopé ne doit rien recevoir du voisin');
140
- assert.equal(deliver('acpi', null), false);
138
+ assert.equal(deliver('acme', 'acme'), true);
139
+ assert.equal(deliver('acme', 'demo'), false, 'un client scopé ne doit rien recevoir du voisin');
140
+ assert.equal(deliver('acme', null), false);
141
141
  // Le cas qui fuit : un abonné SANS workspace reçoit tout.
142
- assert.equal(deliver(null, 'acpi'), true);
142
+ assert.equal(deliver(null, 'acme'), true);
143
143
  assert.equal(deliver(null, 'demo'), true);
144
144
  });
145
145
 
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { createMemo, createSignal, Index, Show } from 'solid-js';
3
- import { compactRuntimeLogForDisplay, filterRuntimeLogs } from '../core/runtimeLog.js';
3
+ import { compactRuntimeLogForDisplay, filterRuntimeLogs, isDispatchPlumbingLine } from '../core/runtimeLog.js';
4
4
  import { fit } from './textFit';
5
5
 
6
6
  type PlanStep = { step: number; description: string; status: string };
@@ -263,7 +263,11 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
263
263
 
264
264
  export function ActivityPanel(props: { activities: any[]; width: number }) {
265
265
  const lineWidth = () => Math.max(8, props.width - 2);
266
- const visible = () => props.activities.slice().reverse();
266
+ // session.activities is never pruned within a run (see core/agentEvents.js),
267
+ // so a long batch job's activity count is unbounded — render only the most
268
+ // recent slice, memoized like LogPanel's allLines, instead of copying and
269
+ // re-wrapping the entire lifetime history on every reactive tick.
270
+ const visible = createMemo(() => props.activities.slice(-200).reverse());
267
271
  return (
268
272
  <box flexGrow={1} flexDirection="column" paddingX={1} backgroundColor="#111318">
269
273
  <text width={lineWidth()} fg="#D6DEE8" content="Activity" />
@@ -340,6 +344,15 @@ type LogSegment = { text: string; fg: string };
340
344
  // Continuation lines of a wrapped entry are indented and dimmed so each
341
345
  // entry reads as one visual block instead of an undifferentiated wall.
342
346
  function logMessageColor(message: string): string {
347
+ // Task-lifecycle glyphs win outright: ✓ done (green), ✗ failed (red),
348
+ // ▸ started / ↻ retry keep the neutral/amber tone the keyword rules below
349
+ // would give them anyway.
350
+ if (/^\s*✓/.test(message)) return '#A6E3A1';
351
+ if (/^\s*✗/.test(message)) return '#F38BA8';
352
+ // A doctor summary with ZERO errors is a warning by construction
353
+ // ("⚠ 0 error(s), 2 warning(s)") — amber, never red, even though it
354
+ // mentions the word "error".
355
+ if (/(?<!\d)0 error\(s\)/i.test(message)) return '#FBBF24';
343
356
  if (/\b(?:trace:\s*)?WARN\b/i.test(message)) return '#FBBF24';
344
357
  if (/\b(error|failed|exception|unavailable|introuvable|HTTP 4\d\d|HTTP 5\d\d)\b/i.test(message)) return '#F38BA8';
345
358
  if (/\b(warn|warning|avertissement|fallback|retry|expired|stale)\b/i.test(message)) return '#FBBF24';
@@ -388,7 +401,14 @@ function logEntryLines(raw: string, width: number): LogSegment[][] {
388
401
  export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
389
402
  const [activeLogTab, setActiveLogTab] = createSignal<'flow' | 'agent-status'>('flow');
390
403
  const lineWidth = () => Math.max(8, props.width - 2);
391
- const isAgentStatus = (line: string) => /agent[_ -]?status/i.test(line);
404
+ // "Agent status" collects the dispatch plumbing — capability resolution,
405
+ // agent selection, agent_execute/agent_status polling, job acceptance — so
406
+ // the "Runtime" tab is left with the readable business flow (plan + the
407
+ // ▸/✓/✗/↻ task lines). isDispatchPlumbingLine (shared with agentEvents'
408
+ // dedup) recognises the formatRuntimeLogPayload shape structurally; the old
409
+ // token enumeration only ever matched agent_status/agent_execute and missed
410
+ // every dotted event ('job.accepted' → 'ACCEPTED', …).
411
+ const isAgentStatus = (line: string) => isDispatchPlumbingLine(line);
392
412
  // Filtering preserves the runtime history order. logRenderLines performs
393
413
  // the single block-level reversal shared by both tabs.
394
414
  const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '')
@@ -238,9 +238,36 @@ export function StartupScreen(props: {
238
238
  });
239
239
 
240
240
  const preflightChecks = createMemo(() => props.preflight?.checks ?? []);
241
+ const checkByKind = createMemo(() => {
242
+ const map = new Map<string, any>();
243
+ for (const check of preflightChecks()) map.set(check.kind, check);
244
+ return map;
245
+ });
246
+ // Docker and Internet leave the check list: they are ambient state, shown in
247
+ // the header (top right), not among the startup rows.
248
+ const dockerCheck = createMemo(() => checkByKind().get('docker'));
249
+ const internetCheck = createMemo(() => checkByKind().get('internet'));
250
+ const listChecks = createMemo(() => {
251
+ const order = ['workspace', 'runtime', 'agentic', 'agents', 'containers', 'mcp'];
252
+ const seen = new Set<string>(['docker', 'internet']);
253
+ const ordered: Array<{ kind: string; ok?: boolean; skipped?: boolean; pending?: boolean; detail?: string }> = [];
254
+ for (const kind of order) {
255
+ const check = checkByKind().get(kind);
256
+ if (!check) continue;
257
+ // An optional engine is not part of the startup story: when nothing is
258
+ // enabled, its row disappears instead of showing a misleading green ✓.
259
+ if (kind === 'agentic' && check.skipped) continue;
260
+ ordered.push(check);
261
+ seen.add(kind);
262
+ }
263
+ for (const check of preflightChecks()) {
264
+ if (!seen.has(check.kind)) ordered.push(check);
265
+ }
266
+ return ordered;
267
+ });
241
268
  const checkLabel = (kind: string) => ({
242
269
  docker: 'Docker', internet: 'Internet', agents: 'Agents', workspace: 'Workspaces',
243
- containers: 'Containers', mcp: 'MCP', runtime: 'Runtime',
270
+ containers: 'Containers', mcp: 'MCP', agentic: 'Agentic runtime', runtime: 'Runtime',
244
271
  } as Record<string, string>)[kind] ?? kind;
245
272
 
246
273
  const subtitle = createMemo(() => {
@@ -281,10 +308,20 @@ export function StartupScreen(props: {
281
308
  overflow="hidden"
282
309
  >
283
310
  <box height={1} flexDirection="row">
284
- <text fg="#8BD5CA" content={fit(`DONNA v${props.version}`, Math.floor(innerWidth() * 0.5))} />
285
- <text fg={statusColor()} content={fit(` ${props.preflightBusy ? '◐' : statusDot(props.preflight?.status === 'ready')} ${status()}`, Math.floor(innerWidth() * 0.45))} />
311
+ <text fg="#8BD5CA" content={fit(`DONNA v${props.version}`, Math.floor(innerWidth() * 0.4))} />
312
+ <text fg={statusColor()} content={fit(` ${props.preflightBusy ? '◐' : statusDot(props.preflight?.status === 'ready')} ${status()}`, Math.floor(innerWidth() * 0.32))} />
313
+ <box flexGrow={1} />
314
+ <text fg="#7F8C8D" content={dockerCheck() ? fit(`Docker — ${dockerCheck()?.detail ?? '—'}`, Math.floor(innerWidth() * 0.28)) : ''} />
315
+ </box>
316
+ <box height={1} flexDirection="row">
317
+ <text height={1} fg="#7F8C8D" content={fit(subtitle(), Math.floor(innerWidth() * 0.6))} />
318
+ <box flexGrow={1} />
319
+ <text
320
+ height={1}
321
+ fg={internetCheck() ? (internetCheck()?.ok ? '#8BD5CA' : '#FBBF24') : '#7F8C8D'}
322
+ content={internetCheck() ? fit(`Internet — ${internetCheck()?.ok ? 'OK' : 'KO'}`, Math.floor(innerWidth() * 0.28)) : ''}
323
+ />
286
324
  </box>
287
- <text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
288
325
  <text height={1}>{''}</text>
289
326
  <box
290
327
  height={8}
@@ -295,7 +332,7 @@ export function StartupScreen(props: {
295
332
  overflow="hidden"
296
333
  >
297
334
  <text fg="#d6a85f">{DONNA_LOGO}</text>
298
- <text fg="#888888">Intelligent workspace</text>
335
+ <text fg="#888888">Intelligent Agentic workspace(s)</text>
299
336
  </box>
300
337
  <text height={1}>{''}</text>
301
338
  <text height={1} fg="#7F8C8D" content={menuTitle()} />
@@ -320,8 +357,8 @@ export function StartupScreen(props: {
320
357
  </For>
321
358
  </box>
322
359
  <text height={1}>{''}</text>
323
- <box height={Math.max(3, preflightChecks().length)} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#5DADE2" paddingX={1} overflow="hidden">
324
- <For each={preflightChecks().length ? preflightChecks() : [
360
+ <box height={Math.max(3, listChecks().length)} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#5DADE2" paddingX={1} overflow="hidden">
361
+ <For each={listChecks().length ? listChecks() : [
325
362
  { kind: 'workspace', ok: props.wikiReady, detail: props.wikiReady ? 'default profile ready' : 'init required' },
326
363
  { kind: 'mcp', ok: props.connectedMcpServers > 0, detail: `${props.connectedMcpServers} server(s) configured` },
327
364
  { kind: 'llm', ok: Boolean(props.model), detail: props.model || 'not configured' },
package/src/shell/repl.js CHANGED
@@ -3,19 +3,21 @@ import { createInterface } from 'node:readline';
3
3
  import { emitKeypressEvents } from 'node:readline';
4
4
  import { Transform } from 'node:stream';
5
5
  import { execFileSync } from 'node:child_process';
6
+ import { statSync } from 'node:fs';
6
7
  import { readFile } from 'node:fs/promises';
7
8
  import path from 'node:path';
8
9
  import { stdin as input, stdout as output } from 'node:process';
9
10
  import { marked } from 'marked';
10
11
  import { markedTerminal } from 'marked-terminal';
11
12
  import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
12
- import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
13
+ import { handleSlashCommand, rawCommandAgentPrompt, refreshMcpRuntimeStatus } from '../commands/slash.js';
13
14
  import { serviceChoices as composeServiceChoices, serviceDescription } from '../core/compose.js';
14
15
  import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
15
16
  import { syncActivitiesToPlan } from '../core/plan.js';
16
17
  import { buildLlmTools, callMcpTool, formatMcpToolResult, parseToolCallName, resolveToolCallName } from '../core/mcp.js';
17
18
  import { runBoundedToolLoop } from '../core/toolLoop.js';
18
- import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
19
+ import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
20
+ import { managerMcpEndpointsFile } from '../core/env.js';
19
21
  import { togglableAgentNames } from '../core/agentsCompose.js';
20
22
  import { loadWorkspaceProfile } from '../core/profile.js';
21
23
  import { artifactFromToolCall, currentArtifactFor, currentArtifactPromptLine, rememberArtifact } from '../core/currentArtifact.js';
@@ -1922,6 +1924,25 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1922
1924
  void subscribeRuntimeEvents();
1923
1925
  }
1924
1926
 
1927
+ // Connectors added from the serve panel land in mcp.endpoints.json while the
1928
+ // shell keeps running. Without a re-read, the new server stays invisible in
1929
+ // /chat and /agent until a restart — the operator had to delete and re-add
1930
+ // the connector and still saw nothing. Watch the file's mtime: a change
1931
+ // refreshes MCP status and tools IN PLACE, no restart.
1932
+ let endpointsMtimeMs = null;
1933
+ const endpointsWatchInterval = setInterval(async () => {
1934
+ if (runtimePollingActive) return;
1935
+ try {
1936
+ const mtime = statSync(managerMcpEndpointsFile()).mtimeMs;
1937
+ if (endpointsMtimeMs === null) { endpointsMtimeMs = mtime; return; }
1938
+ if (mtime === endpointsMtimeMs) return;
1939
+ endpointsMtimeMs = mtime;
1940
+ await refreshMcpRuntimeStatus(session);
1941
+ dispatchRuntimeLog(session, 'mcp: endpoints file changed — connectors refreshed in place');
1942
+ rerender();
1943
+ } catch { /* file absent or mid-write: try again next tick */ }
1944
+ }, 3000);
1945
+
1925
1946
  const pollBusy = new Set();
1926
1947
  const productionPollInterval = setInterval(async () => {
1927
1948
  if (runtimePollingActive) return;
@@ -2259,6 +2280,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
2259
2280
  clearTimeout(runtimeReconnectTimer);
2260
2281
  clearTimeout(runtimeSyncTimer);
2261
2282
  clearInterval(productionPollInterval);
2283
+ clearInterval(endpointsWatchInterval);
2262
2284
  clearTimeout(ctrlCTimer);
2263
2285
  clearTimeout(mouseSelectionTimer);
2264
2286
  output.off('resize', onResize);
@@ -173,6 +173,19 @@ test('Flow/Trace does not repeat the runtime source prefix on every line', async
173
173
  assert.match(entryRenderer, /prefix\.push\(\{ text: `\$\{parts\.time\} /);
174
174
  });
175
175
 
176
+ test('a doctor summary with a nonzero error count is colored as an error, not a warning', async () => {
177
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
178
+ const colorFn = source.slice(
179
+ source.indexOf('function logMessageColor'),
180
+ source.indexOf('function logRenderLines'),
181
+ );
182
+ // The "0 error(s)" amber shortcut must be digit-anchored: "10 error(s)"
183
+ // ends in "0 error(s)" too, and an unanchored test painted a 10-error
184
+ // doctor failure the same colour as a clean run.
185
+ assert.match(colorFn, /\(\?<!\\d\)0 error\\\(s\\\)/);
186
+ assert.doesNotMatch(colorFn, /\/0 error\\\(s\\\)\/i\.test/);
187
+ });
188
+
176
189
  test('runtime logs have a separator and a concise Runtime tab label', async () => {
177
190
  const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
178
191
  const logPanel = source.slice(