@dotdrelle/wiki-manager 0.15.70 → 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 (38) hide show
  1. package/README.md +24 -21
  2. package/agents.docker-compose.yml +5 -1
  3. package/package.json +1 -1
  4. package/src/activity/activityAggregator.test.js +2 -2
  5. package/src/agent/graph.js +1 -0
  6. package/src/cli/wiki-manager.js +1 -1
  7. package/src/cli/wiki-manager.test.js +16 -16
  8. package/src/commands/slash.js +24 -5
  9. package/src/core/agentEvents.js +122 -25
  10. package/src/core/agentEvents.test.js +26 -1
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/commandFailure.test.js +2 -2
  13. package/src/core/currentArtifact.test.js +5 -5
  14. package/src/core/mcp.js +1 -1
  15. package/src/core/mcp.test.js +1 -1
  16. package/src/core/otherWorkspacesRunning.test.js +6 -6
  17. package/src/core/runtimeLog.js +35 -1
  18. package/src/core/runtimeLog.test.js +27 -2
  19. package/src/core/skillInvocation.test.js +1 -1
  20. package/src/core/wikiSetup.js +25 -0
  21. package/src/core/wikiSetup.test.js +35 -0
  22. package/src/core/wikirc.test.js +6 -6
  23. package/src/core/workspaceInherit.test.js +14 -14
  24. package/src/orchestrator/agentRegistry.test.js +6 -6
  25. package/src/orchestrator/dispatcher.js +70 -26
  26. package/src/orchestrator/dispatcher.test.js +46 -3
  27. package/src/orchestrator/providers/deepAgentsProvider.test.js +2 -2
  28. package/src/orchestrator/providers/runtimeProviders.js +58 -5
  29. package/src/orchestrator/providers/runtimeProviders.test.js +23 -0
  30. package/src/orchestrator/scheduler.test.js +4 -4
  31. package/src/runtime/delegation.test.js +11 -11
  32. package/src/runtime/runner.test.js +1 -1
  33. package/src/runtime/server.test.js +2 -2
  34. package/src/runtime/store.test.js +8 -5
  35. package/src/runtime/workspaceIsolation.test.js +26 -26
  36. package/src/shell/RightPane.tsx +14 -2
  37. package/src/shell/repl.js +24 -2
  38. package/wiki-workspace +34 -0
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { dispatchRuntimeLog } from '../../core/agentEvents.js';
4
- import { managerStateDir } from '../../core/env.js';
4
+ import { managerEnvFile, managerStateDir, readEnvFile } from '../../core/env.js';
5
5
  import { createDeepAgentsProvider } from './deepAgentsProvider.js';
6
6
  import { createFakeRuntimeProvider } from './fakeRuntimeProvider.js';
7
7
  import { assertRuntimeProvider } from './runtimeProvider.js';
@@ -65,7 +65,23 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
65
65
  *
66
66
  * Entrée : `{ id, type, endpoint?, enabled?, capabilities?, limits? }`.
67
67
  */
68
- export function loadAgentRuntimesConfig({ stateDir = managerStateDir(), log = () => {}, env = process.env } = {}) {
68
+ export function loadAgentRuntimesConfig({
69
+ stateDir = managerStateDir(),
70
+ log = () => {},
71
+ env = null,
72
+ } = {}) {
73
+ // GATEWAY_ENABLED / GATEWAY_AUTH_TOKEN live in the manager .env FILE, not in
74
+ // the process environment (the runtime child is spawned without them). The
75
+ // manager's canonical policy is the same as resolvedManagerEnv: the file
76
+ // wins over stale process values, so a token generated later by `agents up`
77
+ // is honoured without a restart.
78
+ const resolvedEnv = env ?? (() => {
79
+ try {
80
+ return { ...process.env, ...readEnvFile(managerEnvFile()) };
81
+ } catch {
82
+ return process.env;
83
+ }
84
+ })();
69
85
  const file = join(stateDir, 'agent-runtimes.json');
70
86
  let entries = [];
71
87
  if (existsSync(file)) {
@@ -78,7 +94,7 @@ export function loadAgentRuntimesConfig({ stateDir = managerStateDir(), log = ()
78
94
  log(`agent-runtimes.json unreadable: ${error instanceof Error ? error.message : String(error)}`);
79
95
  }
80
96
  }
81
- return withImpliedGateway(entries, env, log);
97
+ return withImpliedGateway(entries, resolvedEnv, log);
82
98
  }
83
99
 
84
100
  // One switch: when the operator starts the gateway container
@@ -90,6 +106,25 @@ export function loadAgentRuntimesConfig({ stateDir = managerStateDir(), log = ()
90
106
  // case worth stating, not assuming: an operator who wrote `enabled: false`
91
107
  // deliberately would otherwise have no way to learn why deepagents ran anyway.
92
108
  function withImpliedGateway(entries, env = process.env, log = () => {}) {
109
+ const token = String(env.GATEWAY_AUTH_TOKEN ?? '').trim();
110
+ const gatewayPort = String(env.GATEWAY_PORT ?? '7789');
111
+ if (isTruthy(env.GATEWAY_ENABLED) && token) {
112
+ // The manager owns THIS gateway (host-local, GATEWAY_PORT): its bearer
113
+ // token must reach the EXPLICIT entry too. The scaffolded
114
+ // agent-runtimes.json declares the capabilities but no headers, so
115
+ // discovery probed /health without the token and the gateway answered 401
116
+ // forever — "enabled by default" plus "explicit entry" must not combine
117
+ // into a permanently unavailable runtime. An operator who pinned explicit
118
+ // headers keeps them; a `deepagents` entry pointing at a foreign or shared
119
+ // host gets NOTHING — the local gateway secret must never leave the box.
120
+ for (const entry of entries) {
121
+ if (entry?.type !== 'deepagents' || entry?.enabled === false) continue;
122
+ if (!isManagerOwnedGatewayEndpoint(entry.endpoint, gatewayPort)) continue;
123
+ const headers = entry.headers && typeof entry.headers === 'object' ? entry.headers : {};
124
+ if ('Authorization' in headers || 'authorization' in headers) continue;
125
+ entry.headers = { ...headers, Authorization: `Bearer ${token}` };
126
+ }
127
+ }
93
128
  if (!isTruthy(env.GATEWAY_ENABLED)) return entries;
94
129
  const explicitEnabled = entries.some((entry) =>
95
130
  entry?.type === 'deepagents' && entry?.enabled !== false);
@@ -98,8 +133,7 @@ function withImpliedGateway(entries, env = process.env, log = () => {}) {
98
133
  if (disabledEntry) {
99
134
  log('agent-runtimes: GATEWAY_ENABLED=true implies a "deepagents" runtime despite an explicit enabled:false entry in agent-runtimes.json');
100
135
  }
101
- const port = String(env.GATEWAY_PORT ?? '7789');
102
- const token = String(env.GATEWAY_AUTH_TOKEN ?? '').trim();
136
+ const port = gatewayPort;
103
137
  // The implied entry inherits the disabled entry's declared capability shape
104
138
  // (approval classes, alias operations, descriptions): the scaffolded
105
139
  // agent-runtimes.json ships the full list with enabled:false, and dropping it
@@ -133,6 +167,25 @@ function isTruthy(value) {
133
167
  return /^(1|true|yes|on)$/i.test(String(value ?? '').trim());
134
168
  }
135
169
 
170
+ // The manager-owned gateway is the one the manager itself starts: host-local,
171
+ // on GATEWAY_PORT. An entry with no endpoint relies on the implied host-local
172
+ // one, so it counts too. Anything else — a shared or third-party gateway host
173
+ // an operator pointed a second `deepagents` entry at — must not be handed the
174
+ // manager's local GATEWAY_AUTH_TOKEN.
175
+ function isManagerOwnedGatewayEndpoint(endpoint, gatewayPort) {
176
+ if (!endpoint) return true;
177
+ let url;
178
+ try {
179
+ url = new URL(String(endpoint));
180
+ } catch {
181
+ return false;
182
+ }
183
+ const localHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]', 'host.docker.internal']);
184
+ if (!localHosts.has(url.hostname)) return false;
185
+ const entryPort = url.port || (url.protocol === 'https:' ? '443' : '80');
186
+ return entryPort === String(gatewayPort);
187
+ }
188
+
136
189
  /**
137
190
  * Usines de providers par `type`. `fake` sert les tests/plomberie ; `deepagents`
138
191
  * parle HTTP à un runtime externe (Phase 5). Un type inconnu est ignoré et
@@ -194,6 +194,29 @@ test('GATEWAY_ENABLED implies the deepagents runtime with the bearer token (one
194
194
  }
195
195
  });
196
196
 
197
+ test('the local gateway bearer is injected only into the manager-owned host-local entry', () => {
198
+ const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
199
+ try {
200
+ writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
201
+ runtimes: [
202
+ { id: 'local', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true },
203
+ { id: 'shared', type: 'deepagents', endpoint: 'https://gateway.partner.example', enabled: true },
204
+ { id: 'otherport', type: 'deepagents', endpoint: 'http://localhost:9999', enabled: true },
205
+ ],
206
+ }));
207
+ const config = loadAgentRuntimesConfig({
208
+ stateDir: dir,
209
+ env: { GATEWAY_ENABLED: 'true', GATEWAY_PORT: '7789', GATEWAY_AUTH_TOKEN: 'tok-local' },
210
+ });
211
+ const byId = Object.fromEntries(config.map((entry) => [entry.id, entry]));
212
+ assert.deepEqual(byId.local.headers, { Authorization: 'Bearer tok-local' });
213
+ assert.equal(byId.shared.headers, undefined, 'a foreign gateway host must never receive the local token');
214
+ assert.equal(byId.otherport.headers, undefined, 'a non-gateway port is not the manager-owned gateway');
215
+ } finally {
216
+ rmSync(dir, { recursive: true, force: true });
217
+ }
218
+ });
219
+
197
220
  test('an explicit enabled deepagents entry wins over the implied one', () => {
198
221
  const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
199
222
  try {
@@ -246,7 +246,7 @@ function task(id, overrides = {}) {
246
246
  }
247
247
 
248
248
  /*
249
- Cas observé le 2026-08-22 (workspace acpi) : `/wiki-ingest` planifie 13
249
+ Cas observé le 2026-08-22 (workspace acme) : `/wiki-ingest` planifie 13
250
250
  ingest_plan (groupe `ingest`) + 13 ingest_apply (groupe `apply`, sérialisés
251
251
  sur le lock `workspace-write`, derrière la barrière `ingest`) + 1 taxonomy
252
252
  (barrière `apply`). Le grant run-scope émis par le bouton Approve est « nu » :
@@ -260,7 +260,7 @@ function task(id, overrides = {}) {
260
260
  test('un grant run-scope « nu » (sans classes ni révision) débloque les apply derrière une barrière', () => {
261
261
  const plan = {
262
262
  runId: 'run-1',
263
- workspace: 'acpi',
263
+ workspace: 'acme',
264
264
  planRevision: 1,
265
265
  tasks: [
266
266
  // 13 ingest_plan du groupe ingest, tous done.
@@ -307,7 +307,7 @@ test('un grant run-scope « nu » (sans classes ni révision) débloque les appl
307
307
  status: 'approved',
308
308
  scope: 'run',
309
309
  runId: 'run-1',
310
- workspaceId: 'acpi',
310
+ workspaceId: 'acme',
311
311
  planRevision: null,
312
312
  approvalClasses: [],
313
313
  }],
@@ -322,7 +322,7 @@ test('un grant run-scope « nu » (sans classes ni révision) débloque les appl
322
322
  });
323
323
 
324
324
  /*
325
- Cas observé le 2026-08-04 (workspace juno) : une ingestion de dix fichiers,
325
+ Cas observé le 2026-08-04 (workspace demo) : une ingestion de dix fichiers,
326
326
  neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe
327
327
  exigeait que TOUS les membres soient `done` : elle ne s'est jamais ouverte, le
328
328
  planificateur n'a plus trouvé de tâche prête, et le run est resté `running`
@@ -47,11 +47,11 @@ function preparedDelegation(taskId) {
47
47
 
48
48
  function runningSession(runId = 'run-conversational') {
49
49
  return {
50
- workspace: 'juno',
50
+ workspace: 'demo',
51
51
  activities: {},
52
52
  agentEvents: [],
53
53
  headlessPlan: null,
54
- _currentRunIdentity: { runId, workspace: 'juno' },
54
+ _currentRunIdentity: { runId, workspace: 'demo' },
55
55
  };
56
56
  }
57
57
 
@@ -98,9 +98,9 @@ test('une demande de build délègue dans le run courant, sans en démarrer un s
98
98
  const session = runningSession('run-fr-build');
99
99
  const started = [];
100
100
 
101
- const result = await delegateWithinRun(session, 'construis les livrables du workspace juno', {
101
+ const result = await delegateWithinRun(session, 'build the deliverables of the demo workspace', {
102
102
  prepare: async ({ objective }) => {
103
- assert.match(objective, /construis les livrables/);
103
+ assert.match(objective, /build the deliverables/);
104
104
  return preparedDelegation('build-fr');
105
105
  },
106
106
  registry: registryDouble(),
@@ -118,10 +118,10 @@ test('une demande de build délègue dans le run courant, sans en démarrer un s
118
118
  test('la délégation interne exige un run actif', async () => {
119
119
  // Hors run, il n'y a pas d'exécution à transformer : c'est un vrai démarrage
120
120
  // de run, et il doit passer par le chemin normal plutôt que par ici.
121
- const session = { workspace: 'juno', agentEvents: [] };
121
+ const session = { workspace: 'demo', agentEvents: [] };
122
122
 
123
123
  await assert.rejects(
124
- () => delegateWithinRun(session, 'construis les livrables', {
124
+ () => delegateWithinRun(session, 'build the deliverables', {
125
125
  prepare: async () => preparedDelegation(),
126
126
  registry: registryDouble(),
127
127
  }),
@@ -184,12 +184,12 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
184
184
  const { runRuntimeAgenticWorkflow } = await import('./runner.js');
185
185
  const runId = 'run-fr-integration';
186
186
  const session = {
187
- workspace: 'juno',
187
+ workspace: 'demo',
188
188
  activities: {},
189
189
  agentEvents: [],
190
190
  headlessPlan: null,
191
191
  // Posée par executeRun avant l'appel au workflow, comme en production.
192
- _currentRunIdentity: { runId, workspace: 'juno' },
192
+ _currentRunIdentity: { runId, workspace: 'demo' },
193
193
  llm: { async completeWithTools() { assert.fail('aucune évaluation ne doit avoir lieu ici'); } },
194
194
  };
195
195
  const fiveTasks = {
@@ -205,7 +205,7 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
205
205
  async invoke({ session: turnSession }) {
206
206
  conversationalTurns += 1;
207
207
  // Le tour conversationnel appelle l'outil de délégation, comme Donna.
208
- const result = await delegateWithinRun(turnSession, 'construis les livrables du workspace juno', {
208
+ const result = await delegateWithinRun(turnSession, 'build the deliverables of the demo workspace', {
209
209
  prepare: async () => {
210
210
  delegations += 1;
211
211
  return { ...preparedDelegation(), fragment: fiveTasks };
@@ -242,7 +242,7 @@ test('une demande française délègue une fois et bascule, sans jamais duplique
242
242
  // Borne l'attente d'approbation comme en headless : un test ne doit jamais
243
243
  // pouvoir se figer sur une décision humaine qui ne viendra pas.
244
244
  session._approvalTimeoutMs = 200;
245
- await runRuntimeAgenticWorkflow(agent, session, 'construis les livrables du workspace juno', {
245
+ await runRuntimeAgenticWorkflow(agent, session, 'build the deliverables of the demo workspace', {
246
246
  runId,
247
247
  timeoutMs: 2000,
248
248
  maxTurns: 4,
@@ -272,7 +272,7 @@ test('un run qui porte déjà un plan validé refuse une seconde délégation',
272
272
  session.headlessPlan = [task('build-a')];
273
273
 
274
274
  await assert.rejects(
275
- () => delegateWithinRun(session, 'construis les livrables', {
275
+ () => delegateWithinRun(session, 'build the deliverables', {
276
276
  prepare: async () => preparedDelegation(),
277
277
  registry: registryDouble(),
278
278
  }),
@@ -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
  }
@@ -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');
@@ -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 };
@@ -344,6 +344,11 @@ type LogSegment = { text: string; fg: string };
344
344
  // Continuation lines of a wrapped entry are indented and dimmed so each
345
345
  // entry reads as one visual block instead of an undifferentiated wall.
346
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';
347
352
  // A doctor summary with ZERO errors is a warning by construction
348
353
  // ("⚠ 0 error(s), 2 warning(s)") — amber, never red, even though it
349
354
  // mentions the word "error".
@@ -396,7 +401,14 @@ function logEntryLines(raw: string, width: number): LogSegment[][] {
396
401
  export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
397
402
  const [activeLogTab, setActiveLogTab] = createSignal<'flow' | 'agent-status'>('flow');
398
403
  const lineWidth = () => Math.max(8, props.width - 2);
399
- 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);
400
412
  // Filtering preserves the runtime history order. logRenderLines performs
401
413
  // the single block-level reversal shared by both tabs.
402
414
  const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '')
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);
package/wiki-workspace CHANGED
@@ -343,6 +343,24 @@ agents_compose() {
343
343
  if [[ -n "$CACERT_PATH" ]]; then
344
344
  up_args+=(--force-recreate)
345
345
  fi
346
+ # Same lifecycle as the 7788 runtime: an agentic gateway already serving
347
+ # on the port is REUSED — a container started over it cannot bind, and
348
+ # the whole `up` would fail. Reuse applies only when the serving process
349
+ # is EXTERNAL to this compose project (a native runner): if the compose
350
+ # gateway container is the one running, the normal `up` leaves it alone.
351
+ # Only an EXPLICIT `agents up gateway` asks for the container; then a
352
+ # bind conflict is the honest answer.
353
+ local gateway_scale=()
354
+ local requested_list="${only_services[*]:-}"
355
+ local gateway_container_running=false
356
+ if _agents_dc ps --format '{{.Service}}' 2>/dev/null | grep -qx 'gateway'; then
357
+ gateway_container_running=true
358
+ fi
359
+ if gateway_enabled && ! $gateway_container_running && ! [[ " $requested_list " == *" gateway "* ]] && gateway_serving; then
360
+ printf 'Agentic gateway: already serving on :%s — reusing the existing runner (no container started).\n' "${GATEWAY_PORT:-7789}"
361
+ gateway_scale=(--scale gateway=0)
362
+ fi
363
+ up_args+=(${gateway_scale[@]+"${gateway_scale[@]}"})
346
364
  up_args+=(${only_services[@]+"${only_services[@]}"})
347
365
  _agents_dc "${up_args[@]}"
348
366
  printf 'Workspaces root: %s\n' "$workspaces_root"
@@ -574,6 +592,22 @@ gateway_enabled() {
574
592
  esac
575
593
  }
576
594
 
595
+ # Same lifecycle discipline as the 7788 runtime: an agentic gateway ALREADY
596
+ # serving on the port is REUSED, never shadowed by a container that cannot
597
+ # bind. A native `node bin/wiki-agentic-gateway.js` answers without auth
598
+ # (dev); a token-configured gateway (the container, or a native one with
599
+ # GATEWAY_AUTH_TOKEN) answers only to the Bearer — probe both.
600
+ gateway_serving() {
601
+ local port="${GATEWAY_PORT:-7789}"
602
+ local token url
603
+ url="http://localhost:${port}/health"
604
+ token="$(env_value "$MANAGER_ENV_FILE" GATEWAY_AUTH_TOKEN "")"
605
+ if [[ -n "$token" ]]; then
606
+ curl -fsS -m 3 -H "Authorization: Bearer $token" "$url" >/dev/null 2>&1 && return 0
607
+ fi
608
+ curl -fsS -m 3 "$url" >/dev/null 2>&1
609
+ }
610
+
577
611
  ensure_endpoints_file() {
578
612
  # Without mcp.endpoints.json the shell never connects to the agents it
579
613
  # just started (and `agents status` refuses to run). Seed it from the