@dotdrelle/wiki-manager 0.15.70 → 0.15.72

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 +134 -26
  10. package/src/core/agentEvents.test.js +43 -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 +80 -1
  18. package/src/core/runtimeLog.test.js +47 -19
  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 +7 -7
  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
@@ -38,10 +38,10 @@ test('artifactFromToolCall ignores read tools and tools without a path', () => {
38
38
  });
39
39
 
40
40
  test('currentArtifactFor is workspace-scoped', () => {
41
- const artifact = { workspace: 'acpi', path: 'templates/notes/basic.md', kind: 'template' };
42
- assert.equal(currentArtifactFor({ workspace: 'acpi', currentArtifact: artifact }), artifact);
41
+ const artifact = { workspace: 'acme', path: 'templates/notes/basic.md', kind: 'template' };
42
+ assert.equal(currentArtifactFor({ workspace: 'acme', currentArtifact: artifact }), artifact);
43
43
  assert.equal(currentArtifactFor({ workspace: 'other', currentArtifact: artifact }), null);
44
- assert.equal(currentArtifactFor({ workspace: 'acpi' }), null);
44
+ assert.equal(currentArtifactFor({ workspace: 'acme' }), null);
45
45
  });
46
46
 
47
47
  test('currentArtifactPromptLine names the artifact for follow-up edits', () => {
@@ -52,10 +52,10 @@ test('currentArtifactPromptLine names the artifact for follow-up edits', () => {
52
52
  });
53
53
 
54
54
  test('rememberArtifact records a workspace-scoped artifact and ignores empty paths', () => {
55
- const session = { workspace: 'acpi' };
55
+ const session = { workspace: 'acme' };
56
56
  rememberArtifact(session, { path: 'templates/notes/basic.md', kind: 'template' });
57
57
  assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
58
- assert.equal(session.currentArtifact.workspace, 'acpi');
58
+ assert.equal(session.currentArtifact.workspace, 'acme');
59
59
  assert.equal(session.currentArtifact.kind, 'template');
60
60
  rememberArtifact(session, { path: ' ', kind: 'template' });
61
61
  assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.70';
4
+ const WIKI_MANAGER_VERSION = '0.15.72';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -830,7 +830,7 @@ test('callMcpTool re-negotiates and replays once when the agent drops the sessio
830
830
 
831
831
  try {
832
832
  const endpoint = { status: 'connected', url: 'http://127.0.0.1:3336/mcp/' };
833
- const result = await callMcpTool({ cme: endpoint }, 'cme', 'cme_status', { workspace: 'juno' });
833
+ const result = await callMcpTool({ cme: endpoint }, 'cme', 'cme_status', { workspace: 'demo' });
834
834
 
835
835
  assert.equal(result.content[0].text, 'status: configured');
836
836
  assert.deepEqual(requests, [
@@ -27,25 +27,25 @@ function workspaceEntry(root, name) {
27
27
 
28
28
  test('the current workspace is never counted as another one', async () => {
29
29
  const root = mkdtempSync(join(tmpdir(), 'ws-running-self-'));
30
- const acpi = workspaceEntry(root, 'acpi');
30
+ const acme = workspaceEntry(root, 'acme');
31
31
 
32
- const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, [acpi]);
32
+ const busy = await otherWorkspacesRunning({ workspace: 'acme' }, [acme]);
33
33
 
34
34
  assert.deepEqual(busy, [], 'stopping a workspace must not be blocked by itself');
35
35
  });
36
36
 
37
37
  test('an unqueryable workspace does not hold the shared agents hostage', async () => {
38
38
  const root = mkdtempSync(join(tmpdir(), 'ws-running-unknown-'));
39
- const workspaces = [workspaceEntry(root, 'acpi'), workspaceEntry(root, 'stale')];
39
+ const workspaces = [workspaceEntry(root, 'acme'), workspaceEntry(root, 'stale')];
40
40
 
41
- const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, workspaces);
41
+ const busy = await otherWorkspacesRunning({ workspace: 'acme' }, workspaces);
42
42
 
43
43
  assert.deepEqual(busy, []);
44
44
  });
45
45
 
46
46
  test('a single workspace, or none at all, never blocks', async () => {
47
- assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, []), []);
47
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acme' }, []), []);
48
48
  assert.deepEqual(await otherWorkspacesRunning({}, []), []);
49
49
  // Entries without a name are registry noise, not workspaces.
50
- assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, [{}, null]), []);
50
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acme' }, [{}, null]), []);
51
51
  });
@@ -32,6 +32,29 @@ const ORDERED_FIELDS = [
32
32
  'error',
33
33
  ];
34
34
 
35
+ // The dispatch events whose payload is routing plumbing, not business content:
36
+ // rendered compactly (see formatRuntimeLogPayload). Business events keep the
37
+ // full field=value form.
38
+ const COMPACT_EVENTS = new Set([
39
+ 'agent_status',
40
+ 'agent_execute',
41
+ 'job.accepted',
42
+ 'task.ready',
43
+ 'task.starting',
44
+ 'task.started',
45
+ 'task.completed',
46
+ 'task.failed',
47
+ 'attempt.created',
48
+ 'lock.acquired',
49
+ 'lock.released',
50
+ 'runtime.execute',
51
+ 'runtime.accepted',
52
+ 'runtime.result_returned',
53
+ 'task.result_returned',
54
+ 'runtime.params_refused',
55
+ 'runtime.blind',
56
+ ]);
57
+
35
58
  export function normalizeRuntimeLog(input, { session = null } = {}) {
36
59
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
37
60
  return { message: String(input ?? '') };
@@ -69,8 +92,32 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
69
92
  }
70
93
  const time = timeLabel(ts);
71
94
  const event = eventLabel(payload.event);
95
+ // Dispatch plumbing is rendered as a readable sentence, not a field dump:
96
+ // `AGENT_STATUS run=… plan=… group=… attempt=… agentType=… workspace=…`
97
+ // buried the one thing the reader wants — WHO does WHAT on WHICH task, on
98
+ // WHICH job. The verbosity is kept for business payloads, where fields are
99
+ // the content.
100
+ if (COMPACT_EVENTS.has(String(payload.event ?? ''))) {
101
+ const parts = [time, event];
102
+ const who = payload.agentInstanceId ?? payload.agentId ?? payload.agentType;
103
+ if (who) parts.push(shortenUuids(String(who)));
104
+ const what = [payload.capability, payload.operation].filter(Boolean).join('/');
105
+ if (what) parts.push(what);
106
+ const task = shortTaskLabel(payload.taskId);
107
+ if (task) parts.push(task);
108
+ if (payload.jobId) parts.push(shortenUuids(String(payload.jobId)));
109
+ if (payload.status != null) parts.push(String(payload.status));
110
+ if (payload.error) parts.push(shortenUuids(String(payload.error)).slice(0, 120));
111
+ if (payload.detail != null && payload.detail !== ''
112
+ && String(payload.detail).toUpperCase() !== event) {
113
+ parts.push(shortenUuids(String(payload.detail)));
114
+ }
115
+ return parts.join(' · ');
116
+ }
72
117
  const fields = ORDERED_FIELDS
73
- .map((key) => formatField(FIELD_ALIASES[key], payload[key]))
118
+ .map((key) => (key === 'taskId'
119
+ ? formatField(FIELD_ALIASES[key], shortTaskLabel(payload[key]))
120
+ : formatField(FIELD_ALIASES[key], payload[key])))
74
121
  .filter(Boolean);
75
122
  if (payload.status != null) fields.push(formatField('status', payload.status));
76
123
  if (payload.percent != null) fields.push(formatField('percent', payload.percent));
@@ -90,11 +137,43 @@ function shortenUuids(text) {
90
137
  return String(text ?? '').replace(UUID_RE, (uuid) => `${uuid.slice(0, 8)}…`);
91
138
  }
92
139
 
140
+ // A structured taskId is `<runId-uuid>:<slug>-<hash8>`. The UUID and the hash
141
+ // carry no meaning for a reader; keep the human-readable slug in between so
142
+ // `task=…` in a runtime log line names the work instead of an opaque id. A
143
+ // plain id that is neither prefixed nor hash-suffixed (`task-build`, `a`, a
144
+ // legacy step number) is left exactly as it is — only the UUID is collapsed.
145
+ const TASK_HASH_SUFFIX = /-[0-9a-f]{8,}$/i;
146
+
147
+ export function shortTaskLabel(value) {
148
+ const raw = String(value ?? '').trim();
149
+ if (!raw) return raw;
150
+ const hasColon = raw.includes(':');
151
+ if (!hasColon && !TASK_HASH_SUFFIX.test(raw)) return shortenUuids(raw);
152
+ const tail = hasColon ? raw.slice(raw.indexOf(':') + 1) : raw;
153
+ const pretty = tail.replace(TASK_HASH_SUFFIX, '').replace(/[-_]+/g, ' ').trim();
154
+ return pretty || shortenUuids(raw);
155
+ }
156
+
93
157
  export function shortLogId(value, { maxLength = 40 } = {}) {
94
158
  const shortened = shortenUuids(value);
95
159
  return shortened.length > maxLength ? `${shortened.slice(0, maxLength - 1)}…` : shortened;
96
160
  }
97
161
 
162
+ // A line emitted by formatRuntimeLogPayload for a structured event: optional
163
+ // HH:MM:SS, then the ALL-CAPS event token eventLabel() produces from the LAST
164
+ // dotted segment ('job.accepted' → 'ACCEPTED', 'capability.resolving' →
165
+ // 'RESOLVING', 'agent_status' → 'AGENT_STATUS'). These are the dispatch
166
+ // plumbing. A business line the reducer writes starts with a ▸/✓/✗/↻ glyph or
167
+ // a capitalised word ("Plan received", "Run failed:") — never an all-caps
168
+ // token — so this one shape separates the two without an event-name list
169
+ // (which is what an earlier enumeration got wrong: it only ever matched the
170
+ // two underscore-form events and missed every dotted one).
171
+ const DISPATCH_PLUMBING_LINE = /^(?:\d{1,2}:\d{2}(?::\d{2})?\s*(?:·\s*)?)?[A-Z][A-Z0-9_]{2,}(?:\s|$)/;
172
+
173
+ export function isDispatchPlumbingLine(line) {
174
+ return DISPATCH_PLUMBING_LINE.test(String(line ?? ''));
175
+ }
176
+
98
177
  export function runtimeLogMatchesFilter(line, filter = '') {
99
178
  const query = String(filter ?? '').trim();
100
179
  if (!query) return true;
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
5
- import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, shortLogId } from './runtimeLog.js';
5
+ import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, isDispatchPlumbingLine, shortLogId } from './runtimeLog.js';
6
6
  import { emitRuntimeLog } from '../runtime/supervisor.js';
7
7
 
8
8
  const CYCLE_EVENTS = [
@@ -39,22 +39,27 @@ function payload(event) {
39
39
  };
40
40
  }
41
41
 
42
- test('formatRuntimeLogPayload formats every dispatcher cycle event with required ids', () => {
42
+ test('formatRuntimeLogPayload renders dispatch plumbing as a compact sentence, business events keep their fields', () => {
43
+ // Dispatch plumbing: who · what · task · job — no run/plan/group/attempt
44
+ // soup. Business events (capability.resolving, agent.selected,
45
+ // task.assigned) keep the full field=value form: those fields ARE the
46
+ // content.
43
47
  for (const event of CYCLE_EVENTS) {
44
48
  const line = formatRuntimeLogPayload(payload(event), '2026-07-08T14:42:18.000Z');
45
- assert.match(line, /^14:42:18 [A-Z_]+ /);
46
- assert.match(line, /run=run-123/);
47
- assert.match(line, /plan=4/);
48
- assert.match(line, /group=group-build/);
49
- assert.match(line, /task=task-build/);
50
- assert.match(line, /attempt=attempt-1/);
51
- assert.match(line, /agentType=production/);
52
- assert.match(line, /agentInstance=production-main/);
53
- assert.match(line, /agent=worker-02/);
54
- assert.match(line, /job=job-789/);
55
- assert.match(line, /workspace=docs/);
56
- assert.match(line, /capability=document\.build/);
57
- assert.match(line, /operation=build/);
49
+ if (['capability.resolving', 'agent.selected', 'task.assigned'].includes(event)) {
50
+ assert.match(line, /run=run-123/);
51
+ assert.match(line, /workspace=docs/);
52
+ assert.match(line, /capability=document\.build/);
53
+ assert.match(line, /operation=build/);
54
+ continue;
55
+ }
56
+ assert.match(line, /^14:42:18 · [A-Z_]+ · /);
57
+ assert.match(line, /production-main/);
58
+ assert.match(line, /document\.build\/build/);
59
+ assert.match(line, /task-build/);
60
+ assert.match(line, /job-789/);
61
+ assert.match(line, /cycle detail/);
62
+ assert.doesNotMatch(line, /run=|plan=|group=|attempt=|agentType=|agent=|workspace=|capability=|operation=/);
58
63
  }
59
64
  });
60
65
 
@@ -111,13 +116,36 @@ test('long UUIDs collapse to a short prefix so log lines stay on one line', () =
111
116
  operation: 'build',
112
117
  }, '2026-07-08T14:42:18.000Z');
113
118
 
114
- assert.match(line, /run=7fadad27…/);
115
- assert.match(line, /task=7fadad27…:taxonomy-synthesis/);
116
- assert.match(line, /attempt=attempt-7fadad27…/);
117
- assert.match(line, /agentInstance=production-7fadad27…/);
119
+ assert.match(line, /production-7fadad27…/);
120
+ // The taskId's UUID prefix and hash suffix are dropped entirely: the line
121
+ // names the work ("taxonomy synthesis"), not an opaque id.
122
+ assert.match(line, /taxonomy synthesis/);
118
123
  assert.doesNotMatch(line, /7fadad27-0be6-4d08-96e5-664fe7ee841e/);
119
124
  });
120
125
 
126
+ test('isDispatchPlumbingLine recognises every formatted event token, dotted ones included', () => {
127
+ // eventLabel() keeps only the last dotted segment, so these are the tokens
128
+ // that actually reach the panel — an event-name enumeration missed them.
129
+ for (const event of ['job.accepted', 'agent.selected', 'capability.resolving', 'runtime.accepted', 'agent_status', 'agent_execute', 'task.result_returned']) {
130
+ const line = formatRuntimeLogPayload({ event, runId: 'r1', taskId: 't1' }, '2026-07-08T14:42:18.000Z');
131
+ assert.equal(isDispatchPlumbingLine(line), true, `expected plumbing: ${line}`);
132
+ }
133
+ });
134
+
135
+ test('isDispatchPlumbingLine leaves the business flow lines for the Runtime tab', () => {
136
+ for (const line of [
137
+ '14:42:18 ▸ Polish proposition — started (knowledge.polish → agent-production)',
138
+ '14:42:19 ✓ Polish proposition — done (1 output)',
139
+ '14:42:19 ✗ Ingest DSI — failed: dependency_failed',
140
+ '14:42:20 ↻ Build overview — retry 2/3 (rate_limit)',
141
+ '14:42:21 Run failed: No agent provides capability workspace.restore.',
142
+ '14:42:22 Plan validated for run r1',
143
+ '14:42:23 Control message: où en est le build ?',
144
+ ]) {
145
+ assert.equal(isDispatchPlumbingLine(line), false, `expected business flow: ${line}`);
146
+ }
147
+ });
148
+
121
149
  test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
122
150
  const long = `${'x'.repeat(48)}-deadbeef`;
123
151
  assert.match(shortLogId(long), /…$/);
@@ -18,7 +18,7 @@ test('matchSkillInvocation resolves only a real workspace skill', () => {
18
18
 
19
19
  test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
20
20
  assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
21
- assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { deliverable: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
21
+ assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-demo" "improve the network security"'), { deliverable: 'architecture-demo', polish: 'improve the network security' });
22
22
  });
23
23
 
24
24
  test('legacy placeholders remain supported and are reported', () => {
@@ -115,6 +115,31 @@ export async function startAgents(options = {}) {
115
115
  profiles: composeContext.profiles,
116
116
  };
117
117
  } catch (err) {
118
+ // One optional agent failing to start (today: the agentic gateway, whose
119
+ // image is not pulled yet) makes `docker compose up` exit non-zero — but
120
+ // the base stack usually DID come up. Aborting here turned `/start all`
121
+ // into "gateway image missing ⇒ workspace never starts", which is the
122
+ // optional tail wagging the whole dog. If the base agents are up, degrade
123
+ // loudly instead of failing: the caller continues, and the absent agent
124
+ // announces itself through its own check (/status, preflight, discovery).
125
+ const verification = await verifyAgentsStarted({
126
+ context: composeContext,
127
+ agentsCheck: options.agentsCheck,
128
+ }).catch(() => null);
129
+ if (verification?.ok) {
130
+ return {
131
+ output: [
132
+ (options.services ?? []).length > 0
133
+ ? 'A requested agent failed to start — the rest of the stack is up.'
134
+ : 'The agents stack started degraded — one optional agent failed (see /status).',
135
+ err instanceof Error ? (err.message ?? String(err)) : String(err),
136
+ ].filter(Boolean).join('\n').trim(),
137
+ missingImages: [],
138
+ profiles: composeContext.profiles,
139
+ degraded: true,
140
+ degradedError: wrapDockerError(err),
141
+ };
142
+ }
118
143
  throw wrapDockerError(err);
119
144
  }
120
145
  }
@@ -85,3 +85,38 @@ test('the manager .env values override stale blank process values for agents up'
85
85
  }));
86
86
  assert.equal(childEnv.GOOGLE_OAUTH_CLIENT_SECRET, 'fresh-secret');
87
87
  });
88
+
89
+ test('an optional agent that refuses to start degrades instead of failing /start all', async () => {
90
+ // The gateway image not being pulled yet makes `docker compose up` exit
91
+ // non-zero while the base stack still comes up. Aborting there made
92
+ // `/start all` stop before the workspace services — the optional tail
93
+ // wagging the whole dog. With the base agents verified up, the start
94
+ // returns a degraded result the caller can continue from.
95
+ const result = await startAgents(startOptions({
96
+ exec: async () => {
97
+ const err = new Error('pull access denied for dotdrelle/wiki-agentic-gateway:latest');
98
+ err.code = 1;
99
+ throw err;
100
+ },
101
+ agentsCheck: async () => null,
102
+ }));
103
+
104
+ assert.equal(result.degraded, true);
105
+ assert.match(result.output, /started degraded/);
106
+ assert.match(result.output, /wiki-agentic-gateway/);
107
+ assert.deepEqual(result.profiles, ['connectors']);
108
+ });
109
+
110
+ test('a docker failure that leaves the base agents down still fails the start', async () => {
111
+ await assert.rejects(
112
+ startAgents(startOptions({
113
+ exec: async () => {
114
+ const err = new Error('Cannot connect to the Docker daemon');
115
+ err.code = 1;
116
+ throw err;
117
+ },
118
+ agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['cme', 'documents'] } }),
119
+ })),
120
+ /Docker daemon is not running/,
121
+ );
122
+ });
@@ -273,7 +273,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
273
273
  return workspacePath;
274
274
  };
275
275
 
276
- makeWorkspace('acpi', [
276
+ makeWorkspace('acme', [
277
277
  'language: en',
278
278
  'llm:',
279
279
  ' provider: ai-gateway',
@@ -287,7 +287,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
287
287
  ].join('\n'));
288
288
 
289
289
  // Exactly what the scaffold writes: placeholders everywhere.
290
- const targetPath = makeWorkspace('nouveau', [
290
+ const targetPath = makeWorkspace('fresh', [
291
291
  'language: en',
292
292
  'llm:',
293
293
  ' provider: openai-compatible',
@@ -300,15 +300,15 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
300
300
  '',
301
301
  ].join('\n'));
302
302
 
303
- mkdirSync(join(agentsData, 'cme', 'acpi', 'cme'), { recursive: true });
304
- writeFileSync(join(agentsData, 'cme', 'acpi', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
303
+ mkdirSync(join(agentsData, 'cme', 'acme', 'cme'), { recursive: true });
304
+ writeFileSync(join(agentsData, 'cme', 'acme', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
305
305
 
306
306
  const previousDir = process.env.WIKI_WORKSPACES_DIR;
307
307
  const previousData = process.env.AGENTS_DATA_DIR;
308
308
  process.env.WIKI_WORKSPACES_DIR = registryRoot;
309
309
  process.env.AGENTS_DATA_DIR = agentsData;
310
310
  try {
311
- const { inherited } = await finalizeCreatedWorkspace('nouveau', { inheritFrom: 'acpi' });
311
+ const { inherited } = await finalizeCreatedWorkspace('fresh', { inheritFrom: 'acme' });
312
312
  const parsed = YAML.parse(readFileSync(join(targetPath, '.wikirc.yaml'), 'utf8'));
313
313
 
314
314
  assert.equal(parsed.llm.baseUrl, 'https://itsdonna.events/v1');
@@ -320,7 +320,7 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
320
320
  assert.ok(inherited.includes('llm.baseUrl'));
321
321
  assert.ok(inherited.includes('cme.app_data.json'));
322
322
  assert.equal(
323
- readFileSync(join(agentsData, 'cme', 'nouveau', 'cme', 'app_data.json'), 'utf8'),
323
+ readFileSync(join(agentsData, 'cme', 'fresh', 'cme', 'app_data.json'), 'utf8'),
324
324
  '{"pat":"x"}',
325
325
  );
326
326
  } finally {
@@ -146,36 +146,36 @@ test('nothing to inherit yields an empty patch', () => {
146
146
 
147
147
  test('CME credentials are copied, and the source manifest is not', async () => {
148
148
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-'));
149
- const sourceDir = join(root, 'cme', 'acpi', 'cme');
149
+ const sourceDir = join(root, 'cme', 'acme', 'cme');
150
150
  mkdirSync(sourceDir, { recursive: true });
151
151
  writeFileSync(join(sourceDir, 'app_data.json'), '{"auth":{"pat":"secret"}}', 'utf8');
152
152
  // Export scope is what makes a workspace different — it must NOT travel.
153
- writeFileSync(join(root, 'cme', 'acpi', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
153
+ writeFileSync(join(root, 'cme', 'acme', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
154
154
 
155
- const copied = await copyCmeCredentials(root, 'acpi', 'nouveau');
155
+ const copied = await copyCmeCredentials(root, 'acme', 'fresh');
156
156
 
157
- assert.equal(copied, cmeCredentialsPath(root, 'nouveau'));
157
+ assert.equal(copied, cmeCredentialsPath(root, 'fresh'));
158
158
  assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
159
- assert.equal(existsSync(join(root, 'cme', 'nouveau', 'sources-manifest.yaml')), false);
159
+ assert.equal(existsSync(join(root, 'cme', 'fresh', 'sources-manifest.yaml')), false);
160
160
  });
161
161
 
162
162
  test('existing CME credentials on the target are never clobbered', async () => {
163
163
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-keep-'));
164
- mkdirSync(join(root, 'cme', 'acpi', 'cme'), { recursive: true });
165
- mkdirSync(join(root, 'cme', 'nouveau', 'cme'), { recursive: true });
166
- writeFileSync(join(root, 'cme', 'acpi', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
- writeFileSync(join(root, 'cme', 'nouveau', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
164
+ mkdirSync(join(root, 'cme', 'acme', 'cme'), { recursive: true });
165
+ mkdirSync(join(root, 'cme', 'fresh', 'cme'), { recursive: true });
166
+ writeFileSync(join(root, 'cme', 'acme', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
+ writeFileSync(join(root, 'cme', 'fresh', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
168
168
 
169
- assert.equal(await copyCmeCredentials(root, 'acpi', 'nouveau'), null);
169
+ assert.equal(await copyCmeCredentials(root, 'acme', 'fresh'), null);
170
170
  assert.equal(
171
- readFileSync(cmeCredentialsPath(root, 'nouveau'), 'utf8'),
171
+ readFileSync(cmeCredentialsPath(root, 'fresh'), 'utf8'),
172
172
  '{"from":"target"}',
173
173
  );
174
174
  });
175
175
 
176
176
  test('copying is a no-op without a source, a target, or a source file', async () => {
177
177
  const root = mkdtempSync(join(tmpdir(), 'cme-inherit-noop-'));
178
- assert.equal(await copyCmeCredentials(root, 'absent', 'nouveau'), null);
179
- assert.equal(await copyCmeCredentials(root, null, 'nouveau'), null);
180
- assert.equal(await copyCmeCredentials(root, 'acpi', 'acpi'), null);
178
+ assert.equal(await copyCmeCredentials(root, 'absent', 'fresh'), null);
179
+ assert.equal(await copyCmeCredentials(root, null, 'fresh'), null);
180
+ assert.equal(await copyCmeCredentials(root, 'acme', 'acme'), null);
181
181
  });
@@ -144,7 +144,7 @@ test('a failed re-discovery keeps the orchestrator agent, never erases its capab
144
144
  */
145
145
  const events = [];
146
146
  const session = {
147
- workspace: 'acpi',
147
+ workspace: 'acme',
148
148
  mcp: {
149
149
  production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
150
150
  },
@@ -216,7 +216,7 @@ test('a stopped agent is reported once, not on every re-scan', async () => {
216
216
  // until it answers again.
217
217
  const events = [];
218
218
  const session = {
219
- workspace: 'acpi',
219
+ workspace: 'acme',
220
220
  mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
221
221
  _onAgentEvent: (event) => events.push(event),
222
222
  };
@@ -260,7 +260,7 @@ test('discovery sends the workspace only to agents whose schema declares it', as
260
260
  });
261
261
 
262
262
  await registry.discover({
263
- workspace: 'acpi',
263
+ workspace: 'acme',
264
264
  mcp: {
265
265
  // Declares workspace: gets it, and can scope its vocabulary.
266
266
  cme: {
@@ -293,9 +293,9 @@ test('discovery sends the workspace only to agents whose schema declares it', as
293
293
  },
294
294
  });
295
295
 
296
- assert.deepEqual(seen.cme, { workspace: 'acpi' });
296
+ assert.deepEqual(seen.cme, { workspace: 'acme' });
297
297
  assert.deepEqual(seen.production, {});
298
- assert.deepEqual(seen.connectors, { workspace: 'acpi' });
298
+ assert.deepEqual(seen.connectors, { workspace: 'acme' });
299
299
  assert.deepEqual(seen.legacyish, {});
300
300
  });
301
301
 
@@ -374,7 +374,7 @@ test('markPersistedAgentsStale invalide aussi l’instantané routable', () => {
374
374
 
375
375
  test('un agent redevient sélectionnable après un agent_describe réussi', async () => {
376
376
  const session = {
377
- workspace: 'juno',
377
+ workspace: 'demo',
378
378
  agentEvents: [],
379
379
  agents: [{ agentInstanceId: 'production-main', serverName: 'production', health: 'available', description: { contractVersion: '1', capabilities: [{ id: 'knowledge.update', version: '1' }] } }],
380
380
  mcp: {
@@ -474,36 +474,80 @@ const READ_ONLY_WIKI_TOOLS = new Set([
474
474
  'wiki_workspace_status',
475
475
  ]);
476
476
 
477
- // The runtime's EYES, per run: the active workspace's wiki MCP, read tools
478
- // only. Workspace-scoped endpoints are per-run by nature — they cannot live
479
- // in a static gateway file. The allow-list here is the authority: nothing
477
+ // An arbitrary external connector's tool names cannot be enumerated ahead of
478
+ // time the way READ_ONLY_WIKI_TOOLS can, so this stays a denylist — but a
479
+ // word-boundary one. The old test was a raw substring match
480
+ // (/write|send|create|update|add|remove/), which dropped safe reads whose name
481
+ // merely contained a mutating verb (`list_recent_updates`, `get_created_at`,
482
+ // `search_addresses`) and missed side-effecting tools that use another verb
483
+ // (`crawl_site`, `post_message`, `run_report`, `publish_draft`). The real
484
+ // guardrails against "hands on the workspace" are elsewhere —
485
+ // EXCLUDED_EXTERNAL_SERVERS covers every workspace-writing agent, and a
486
+ // per-tool `requireApproval` entry drops the whole server — this filter only
487
+ // keeps an obviously-mutating tool of an otherwise-safe connector out of the
488
+ // runtime's eyes.
489
+ const EXTERNAL_MUTATING_VERB = /(?:^|[_-])(?:write|send|post|create|delete|destroy|remove|drop|modify|edit|update|patch|put|upload|publish|submit|execute|run|crawl|trigger|cancel|approve|move|rename|set|add|insert|append|archive)(?:[_-]|$)/i;
490
+
491
+ function isReadOnlyExternalTool(toolName) {
492
+ const base = toolName.includes('__') ? toolName.slice(toolName.lastIndexOf('__') + 2) : toolName;
493
+ return !EXTERNAL_MUTATING_VERB.test(base);
494
+ }
495
+
496
+ // The runtime's EYES, per run: the active workspace's wiki MCP (read tools
497
+ // only) PLUS the declared external MCP endpoints that are safe to hand over
498
+ // (connected, no approval-gated tools, not a workspace-mutating server) —
499
+ // typically web search (exa). The allow-list here is the authority: nothing
480
500
  // else reaches the runtime.
481
501
  export function activeProfileMcp(session) {
502
+ const blocks = [];
482
503
  const wiki = session?.mcp?.wiki;
483
- if (!wiki?.url || wiki.status !== 'connected') return null;
484
- const tools = (wiki.tools ?? [])
485
- .map((tool) => String(tool.name ?? ''))
486
- .filter((name) => {
487
- if (!name) return false;
488
- const base = name.includes('__') ? name.slice(name.lastIndexOf('__') + 2) : name;
489
- return READ_ONLY_WIKI_TOOLS.has(base);
504
+ if (wiki?.url && wiki.status === 'connected') {
505
+ const tools = (wiki.tools ?? [])
506
+ .map((tool) => String(tool.name ?? ''))
507
+ .filter((name) => {
508
+ if (!name) return false;
509
+ const base = name.includes('__') ? name.slice(name.lastIndexOf('__') + 2) : name;
510
+ return READ_ONLY_WIKI_TOOLS.has(base);
511
+ });
512
+ if (tools.length > 0) {
513
+ // Same credential contract as the manager's own MCP client (mcp.js
514
+ // `authorization: Bearer ${endpoint.token}`): the wiki detail carries
515
+ // `token`, not `headers` — without it the gateway's MCP connection is
516
+ // rejected by the workspace MCP server ("invalid or missing bearer token")
517
+ // and the Deep Agent runs blind.
518
+ const headers = {
519
+ ...(wiki.headers && typeof wiki.headers === 'object' ? wiki.headers : {}),
520
+ ...(wiki.token ? { Authorization: `Bearer ${wiki.token}` } : {}),
521
+ };
522
+ blocks.push({
523
+ name: 'wiki',
524
+ url: String(wiki.url),
525
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
526
+ tools,
527
+ });
528
+ }
529
+ }
530
+ // External connectors ride along ONLY when the operator declared them safe
531
+ // for the runtime's eyes. A connector added from the serve panel lands here
532
+ // too — without this, exa was offered in chat but the agentic path
533
+ // delegated to the gateway and the Deep Agent answered it had no web tools.
534
+ const EXCLUDED_EXTERNAL_SERVERS = new Set(['cme', 'documents', 'connectors', 'production']);
535
+ for (const [name, entry] of Object.entries(session?.mcp ?? {})) {
536
+ if (!entry?.external || entry.status !== 'connected') continue;
537
+ if (EXCLUDED_EXTERNAL_SERVERS.has(name)) continue;
538
+ if (Array.isArray(entry.requireApproval) && entry.requireApproval.length > 0) continue;
539
+ const tools = (entry.tools ?? [])
540
+ .map((tool) => String(tool.name ?? ''))
541
+ .filter((toolName) => toolName && isReadOnlyExternalTool(toolName));
542
+ if (tools.length === 0) continue;
543
+ blocks.push({
544
+ name,
545
+ url: String(entry.url),
546
+ ...(entry.headers && typeof entry.headers === 'object' ? { headers: entry.headers } : {}),
547
+ tools,
490
548
  });
491
- if (tools.length === 0) return null;
492
- // Same credential contract as the manager's own MCP client (mcp.js
493
- // `authorization: Bearer ${endpoint.token}`): the wiki detail carries
494
- // `token`, not `headers` — without it the gateway's MCP connection is
495
- // rejected by the workspace MCP server ("invalid or missing bearer token")
496
- // and the Deep Agent runs blind.
497
- const headers = {
498
- ...(wiki.headers && typeof wiki.headers === 'object' ? wiki.headers : {}),
499
- ...(wiki.token ? { Authorization: `Bearer ${wiki.token}` } : {}),
500
- };
501
- return [{
502
- name: 'wiki',
503
- url: String(wiki.url),
504
- ...(Object.keys(headers).length > 0 ? { headers } : {}),
505
- tools,
506
- }];
549
+ }
550
+ return blocks.length > 0 ? blocks : null;
507
551
  }
508
552
 
509
553
  // The Deep Agent's system prompt, built per run from the same ingredients