@dotdrelle/wiki-manager 0.15.29 → 0.15.34

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.
package/.env.example CHANGED
@@ -56,8 +56,11 @@ CONNECTORS_MCP_AUTH_TOKEN=
56
56
  # Enable the opt-in agent-connectors service:
57
57
  CONNECTORS_ENABLED=false
58
58
  #
59
- # The public wikiLLM Desktop/PKCE Client ID is built into agent-connectors.
60
- # Optional advanced/self-hosted override:
59
+ # The wikiLLM Google OAuth application is baked into the agent-connectors image
60
+ # at build time, from agent-external/agent-connectors/.env.build.local. When you
61
+ # build the image locally, that file must exist — otherwise the container starts
62
+ # with empty credentials.
63
+ # Optional runtime override (no rebuild needed):
61
64
  # GOOGLE_OAUTH_CLIENT_ID=
62
65
  # Optional confidential-client compatibility override:
63
66
  # GOOGLE_OAUTH_CLIENT_SECRET=
@@ -100,6 +100,16 @@ services:
100
100
  build:
101
101
  context: ../agent-external/agent-connectors
102
102
  dockerfile: Dockerfile
103
+ args:
104
+ # Without these the ARGs stay unset and the image ships empty
105
+ # WIKILLM_GOOGLE_OAUTH_* values, so the container ends up with no Google
106
+ # client at all. `wiki-workspace agents up` exports them from the
107
+ # connectors repo's .env.build.local before invoking Compose.
108
+ # Deliberately value-less: Compose then forwards each host variable only
109
+ # when it is defined. Never write `${VAR:-}` here — an empty
110
+ # --build-arg pins the ARG to the empty string.
111
+ WIKILLM_GOOGLE_OAUTH_CLIENT_ID:
112
+ WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET:
103
113
  image: dotdrelle/agent-connectors:latest
104
114
  user: "${UID:-1000}:${GID:-1000}"
105
115
  ports:
@@ -80,7 +80,7 @@ async function main() {
80
80
  // Fallback for already-bootstrapped direct invocations; the shell wrapper
81
81
  // exports these before Bun starts.
82
82
  if (parsed.cacert) Object.assign(process.env, cacertEnvVars(parsed.cacert));
83
- const interactive = process.stdout.isTTY && process.stdin.isTTY && argv[0] !== 'runtime' && !argv.includes('--setup-wizard') && !argv.includes('--headless') && !argv.includes('--once') && !argv.includes('--version') && !argv.includes('-v') && !argv.includes('--help') && !argv.includes('-h');
83
+ const interactive = process.stdout.isTTY && process.stdin.isTTY && argv[0] !== 'runtime' && !argv.includes('--refresh') && !argv.includes('--setup-wizard') && !argv.includes('--headless') && !argv.includes('--once') && !argv.includes('--version') && !argv.includes('-v') && !argv.includes('--help') && !argv.includes('-h');
84
84
  if (interactive || argv.includes('--setup-wizard')) await import('@opentui/solid/preload');
85
85
  if (interactive) process.stdout.write('Starting wiki-manager…\r');
86
86
  const { runCli } = await import('../src/cli/wiki-manager.js');
@@ -40,6 +40,7 @@
40
40
  # services:
41
41
  # serve:
42
42
  # environment:
43
+ # - NODE_USE_ENV_PROXY=${NODE_USE_ENV_PROXY:-1}
43
44
  # - HTTP_PROXY=${HTTP_PROXY:-}
44
45
  # - HTTPS_PROXY=${HTTPS_PROXY:-}
45
46
  # - http_proxy=${HTTP_PROXY:-}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.29",
3
+ "version": "0.15.34",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -203,8 +203,10 @@ function toolCallingLlm() {
203
203
  test('agent graph waits for run-level approval before first MCP action', async () => {
204
204
  const originalFetch = globalThis.fetch;
205
205
  let fetchCalls = 0;
206
- globalThis.fetch = async () => {
207
- fetchCalls += 1;
206
+ globalThis.fetch = async (_url, init) => {
207
+ // Count tool traffic only: the MCP session handshake is transport
208
+ // plumbing, not an action the user needs to approve or observe.
209
+ if (JSON.parse(init.body).method === 'tools/call') fetchCalls += 1;
208
210
  return {
209
211
  ok: true,
210
212
  status: 200,
@@ -488,6 +490,15 @@ test('Donna refuses to delegate connector authentication to an export capability
488
490
  globalThis.fetch = async (url, options = {}) => {
489
491
  fetchedUrls.push(String(url));
490
492
  const body = JSON.parse(String(options.body ?? '{}'));
493
+ // MCP session handshake: answer it, then assert on the real tool call.
494
+ if (body.method === 'initialize') {
495
+ return {
496
+ ok: true,
497
+ status: 200,
498
+ headers: { get: () => null },
499
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
500
+ };
501
+ }
491
502
  assert.equal(body.params?.name, 'connectors_google_oauth_start');
492
503
  assert.deepEqual(body.params?.arguments, { workspace: 'docs' });
493
504
  return {
@@ -1302,8 +1313,10 @@ test('agent graph executes action inputs inside a runtime run instead of asking
1302
1313
  // returned a canned clarification — "lance l'ingestion" did nothing.
1303
1314
  const originalFetch = globalThis.fetch;
1304
1315
  let fetchCalls = 0;
1305
- globalThis.fetch = async () => {
1306
- fetchCalls += 1;
1316
+ globalThis.fetch = async (_url, init) => {
1317
+ // Count tool traffic only: the MCP session handshake is transport
1318
+ // plumbing, not an action the user needs to approve or observe.
1319
+ if (JSON.parse(init.body).method === 'tools/call') fetchCalls += 1;
1307
1320
  return {
1308
1321
  ok: true,
1309
1322
  status: 200,
@@ -12,3 +12,17 @@ test('runtime startup is not blocked by optional Docker image maintenance', asyn
12
12
  assert.match(runtimeBranch, /await runRuntime\(argv\.slice\(1\), agent\)/);
13
13
  assert.doesNotMatch(runtimeBranch, /refreshRunningContainers/);
14
14
  });
15
+
16
+ test('shell exit leaves the shared runtime alive and refresh is explicit', async () => {
17
+ const cli = await readFile(new URL('./wiki-manager.js', import.meta.url), 'utf8');
18
+ const tui = await readFile(new URL('../shell/tui.tsx', import.meta.url), 'utf8');
19
+ const bin = await readFile(new URL('../../bin/wiki-manager.js', import.meta.url), 'utf8');
20
+
21
+ assert.doesNotMatch(cli, /await shutdownOwnedRuntime\(runtime/);
22
+ assert.doesNotMatch(tui, /shutdownOwnedRuntime/);
23
+ assert.match(tui, /shell closed; shared runtime left running/);
24
+ assert.match(tui, /process\.exit\(0\)/);
25
+ assert.match(cli, /argv\.includes\('--refresh'\)/);
26
+ assert.match(cli, /spawnSync\(workspaceCliPath, \['refresh'\]/);
27
+ assert.match(bin, /!argv\.includes\('--refresh'\)/);
28
+ });
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { mkdir, writeFile } from 'node:fs/promises';
4
5
  import { dirname, join, resolve } from 'node:path';
@@ -25,6 +26,7 @@ import { listWorkspaces } from '../core/workspaces.js';
25
26
 
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  const packageJsonPath = resolve(__dirname, '../../package.json');
29
+ const workspaceCliPath = resolve(__dirname, '../../wiki-workspace');
28
30
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
29
31
  const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'connector', 'wiki', 'skills', 'clear', 'chat', 'agent', 'approve'];
30
32
 
@@ -106,7 +108,13 @@ export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
106
108
  * then a JSON-text completion, then no arguments — the executor uses its own
107
109
  * defaults. It never throws and never invents identifiers.
108
110
  */
109
- export async function resolveExecutorArguments({ llm, objective, capability, signal } = {}) {
111
+ export async function resolveExecutorArguments({
112
+ llm,
113
+ objective,
114
+ capability,
115
+ workspace,
116
+ signal,
117
+ } = {}) {
110
118
  const schema = capability?.inputSchema;
111
119
  const objectiveText = String(objective ?? '').trim();
112
120
  if (
@@ -121,10 +129,22 @@ export async function resolveExecutorArguments({ llm, objective, capability, sig
121
129
  ) {
122
130
  return {};
123
131
  }
132
+ const workspaceName = String(workspace ?? '').trim();
124
133
  const system = [
125
134
  'Extract structured arguments for a task from the user objective.',
126
135
  'Only fill a field when the objective explicitly states or clearly implies its value.',
127
136
  'Omit every field that is not stated. Never invent identifiers, queries, filters or counts.',
137
+ // The objective almost always names the workspace ("export the pages of
138
+ // workspace acpi"), and the orchestrator already binds it out of band. With
139
+ // one free-text field in the schema and no field for the workspace, a model
140
+ // reliably misbinds the two — that is how a workspace name ended up as a
141
+ // source name and failed the task.
142
+ ...(workspaceName
143
+ ? [
144
+ `The task already runs against workspace "${workspaceName}"; the orchestrator supplies it separately.`,
145
+ `Never use "${workspaceName}" as the value of any field. If the objective only names the workspace, return {}.`,
146
+ ]
147
+ : []),
128
148
  'Return the arguments object only.',
129
149
  ].join('\n');
130
150
  const tool = {
@@ -150,9 +170,9 @@ export async function resolveExecutorArguments({ llm, objective, capability, sig
150
170
  });
151
171
  const call = (result?.tool_calls ?? []).find((item) => item?.function?.name === 'set_task_arguments');
152
172
  const fromCall = call ? safeParseArgumentObject(call.function?.arguments) : null;
153
- if (fromCall) return pruneArgumentsToSchema(fromCall, schema);
173
+ if (fromCall) return pruneArgumentsToSchema(fromCall, schema, workspaceName);
154
174
  const fromText = safeParseArgumentObject(result?.content);
155
- if (fromText) return pruneArgumentsToSchema(fromText, schema);
175
+ if (fromText) return pruneArgumentsToSchema(fromText, schema, workspaceName);
156
176
  } catch {
157
177
  // Fall through to the tool-less path.
158
178
  }
@@ -164,13 +184,36 @@ export async function resolveExecutorArguments({ llm, objective, capability, sig
164
184
  signal,
165
185
  });
166
186
  const fromText = safeParseArgumentObject(result?.content);
167
- if (fromText) return pruneArgumentsToSchema(fromText, schema);
187
+ if (fromText) return pruneArgumentsToSchema(fromText, schema, workspaceName);
168
188
  } catch {
169
189
  // Give up: the executor will use its own defaults.
170
190
  }
171
191
  return {};
172
192
  }
173
193
 
194
+ // The system prompt above is advice a model may ignore; this is not. A value
195
+ // that merely echoes the workspace name carries no information the orchestrator
196
+ // does not already hold, so dropping it can only widen the task to the
197
+ // executor's own default — never narrow it to something wrong. (A source
198
+ // genuinely named after its workspace degrades to "process everything", which
199
+ // still includes it.)
200
+ function echoesWorkspace(key, value, workspaceName) {
201
+ if (!workspaceName || typeof value !== 'string') return false;
202
+ if (/workspace/i.test(key)) return false;
203
+ return value.trim().toLowerCase() === String(workspaceName).trim().toLowerCase();
204
+ }
205
+
206
+ // A field with an `enum` declares a closed vocabulary, so an extracted value
207
+ // outside it is checkably wrong — not a judgement call about meaning. This is
208
+ // the only class of hallucinated identifier the orchestrator can reject
209
+ // without knowing anything about the business domain, which is exactly why
210
+ // agents must publish the vocabulary instead of a bare string.
211
+ function violatesEnum(schemaEntry, value) {
212
+ const values = schemaEntry?.enum;
213
+ if (!Array.isArray(values) || values.length === 0) return false;
214
+ return !values.some((allowed) => allowed === value);
215
+ }
216
+
174
217
  export function missingRequiredArguments(schema, args) {
175
218
  const required = Array.isArray(schema?.required) ? schema.required.map(String) : [];
176
219
  const values = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
@@ -194,12 +237,14 @@ function safeParseArgumentObject(text) {
194
237
  }
195
238
  }
196
239
 
197
- function pruneArgumentsToSchema(value, schema) {
240
+ function pruneArgumentsToSchema(value, schema, workspaceName = '') {
198
241
  const allowed = schema.properties ?? {};
199
242
  const acceptsExtra = schema.additionalProperties !== false;
200
243
  const out = {};
201
244
  for (const [key, entry] of Object.entries(value)) {
202
245
  if (entry === undefined || entry === null) continue;
246
+ if (echoesWorkspace(key, entry, workspaceName)) continue;
247
+ if (violatesEnum(allowed[key], entry)) continue;
203
248
  if (Object.hasOwn(allowed, key) || acceptsExtra) out[key] = entry;
204
249
  }
205
250
  return out;
@@ -260,6 +305,12 @@ export function ensureInteractiveAssistantMessage(session, response, { turnId, w
260
305
  return true;
261
306
  }
262
307
 
308
+ export function mcpStatusNeedsRefresh(mcpStatus) {
309
+ return Object.values(mcpStatus ?? {}).some(
310
+ (endpoint) => endpoint?.url && endpoint.status !== 'connected',
311
+ );
312
+ }
313
+
263
314
  export async function forwardRuntimeApproval(getWorkspaceContext, request = {}) {
264
315
  const context = await getWorkspaceContext(request.workspace ?? null);
265
316
  return context.approvalManager?.approve(request) ?? { approved: false };
@@ -1023,6 +1074,7 @@ async function runRuntime(argv, agent) {
1023
1074
  llm: session.llm,
1024
1075
  objective,
1025
1076
  capability: provider.capability,
1077
+ workspace: session.workspace ?? context.workspace ?? '',
1026
1078
  signal: session._abortSignal,
1027
1079
  });
1028
1080
  const missingArguments = missingRequiredArguments(
@@ -1255,6 +1307,14 @@ async function runRuntime(argv, agent) {
1255
1307
  async function executeInteractiveTurn(context, body, { signal, turnId } = {}) {
1256
1308
  const input = String(body.input ?? body.prompt ?? '').trim();
1257
1309
  if (!input) throw new Error('Missing input.');
1310
+ // The runtime may start while optional agents are still stopped. `/start
1311
+ // agents` happens in the shell process, so its refreshed MCP snapshot does
1312
+ // not mutate this long-lived runtime context. Re-probe only while at least
1313
+ // one configured endpoint is disconnected; once connected, mcpRequest's
1314
+ // stale-session recovery handles later server restarts cheaply.
1315
+ if (mcpStatusNeedsRefresh(context.session.mcp)) {
1316
+ await refreshMcpRuntimeStatus(context.session);
1317
+ }
1258
1318
  const ephemeral = createInteractiveSession(context, { runtimeUrl: selfRuntimeUrl, turnId, signal });
1259
1319
  // Seed from a freshly reduced COPY of persisted events. Interactive turn
1260
1320
  // events deliberately do not mutate the canonical run projection, so the
@@ -1387,6 +1447,18 @@ function logImageRefreshErrors(imageRefresh) {
1387
1447
  }
1388
1448
 
1389
1449
  export async function runCli(argv) {
1450
+ if (argv.includes('--refresh')) {
1451
+ if (argv.length !== 1) throw new Error('--refresh does not accept other options.');
1452
+ const result = spawnSync(workspaceCliPath, ['refresh'], {
1453
+ cwd: process.cwd(),
1454
+ env: process.env,
1455
+ stdio: 'inherit',
1456
+ });
1457
+ if (result.error) throw result.error;
1458
+ if (result.status !== 0) throw new Error(`Refresh failed with exit code ${result.status ?? 1}.`);
1459
+ return;
1460
+ }
1461
+
1390
1462
  if (argv[0] === 'runtime') {
1391
1463
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
1392
1464
  if (scaffolded.length > 0) loadManagerEnv();
@@ -1506,10 +1578,8 @@ export async function runCli(argv) {
1506
1578
  console.error(`Runtime unavailable: ${runtime.error}`);
1507
1579
  }
1508
1580
  preflight = withRuntimePreflight(preflight, runtime);
1509
- // The owned-runtime shutdown happens inside the TUI's own exit paths
1510
- // (see tui.tsx onShellExit): render() resolves at MOUNT, so anything
1511
- // after this await would run while the shell is still on screen —
1512
- // 0.12.9 shipped exactly that bug and killed the runtime under the user.
1581
+ // render() resolves at mount; the TUI owns renderer teardown. The shared
1582
+ // runtime deliberately survives shell exit because `serve` may use it.
1513
1583
  await runOpenTuiShell({
1514
1584
  agent,
1515
1585
  packageJson,
@@ -1531,10 +1601,6 @@ export async function runCli(argv) {
1531
1601
  }
1532
1602
  }
1533
1603
  await runShell({ agent, packageJson, runtime });
1534
- if (runtime?.url) {
1535
- const { shutdownOwnedRuntime } = await import('../runtime/lifecycle.js');
1536
- await shutdownOwnedRuntime(runtime, { log: (message) => console.log(`[wiki-manager] ${message}`) });
1537
- }
1538
1604
  }
1539
1605
 
1540
1606
  // Missing/stopped agents are status information, never a reason to interrupt
@@ -3,6 +3,7 @@ import test from 'node:test';
3
3
  import {
4
4
  buildExecutorOnlyFragment,
5
5
  forwardRuntimeApproval,
6
+ mcpStatusNeedsRefresh,
6
7
  missingRequiredArguments,
7
8
  resolveExecutorArguments,
8
9
  resolvePreparedDelegationApproval,
@@ -33,6 +34,20 @@ test('startup never opens the setup wizard just because agents are stopped', ()
33
34
  assert.deepEqual(startupWizardGaps([{ kind: 'agents' }]), []);
34
35
  });
35
36
 
37
+ test('interactive runtime refreshes configured MCP endpoints that started late', () => {
38
+ assert.equal(mcpStatusNeedsRefresh({
39
+ wiki: { url: 'http://127.0.0.1:3201/mcp', status: 'connected' },
40
+ cme: { url: 'http://127.0.0.1:3336/mcp/', status: 'configured' },
41
+ }), true);
42
+ assert.equal(mcpStatusNeedsRefresh({
43
+ wiki: { url: 'http://127.0.0.1:3201/mcp', status: 'connected' },
44
+ cme: { url: 'http://127.0.0.1:3336/mcp/', status: 'connected' },
45
+ }), false);
46
+ assert.equal(mcpStatusNeedsRefresh({
47
+ disabled: { url: null, status: 'missing' },
48
+ }), false);
49
+ });
50
+
36
51
  test('executor-only capabilities receive one manager-authored executable task', () => {
37
52
  const fragment = buildExecutorOnlyFragment({
38
53
  objective: 'donne-moi mes derniers mails',
@@ -191,3 +206,145 @@ test('prepared delegation only approves when autoApprove is explicitly true', ()
191
206
  result: { approved: true },
192
207
  });
193
208
  });
209
+
210
+ const EXPORT_CAPABILITY = {
211
+ description: 'Export configured sources to workspace markdown files.',
212
+ inputSchema: {
213
+ type: 'object',
214
+ additionalProperties: true,
215
+ properties: { source_name: { type: 'string' } },
216
+ },
217
+ };
218
+
219
+ test('argument extraction drops a value that only echoes the active workspace', async () => {
220
+ // Regression: "exporter les pages Confluence du workspace acpi" against a
221
+ // schema whose single free-text field is source_name. The model binds the
222
+ // workspace name to it, and the executor fails with "source 'acpi' not
223
+ // found". The workspace is already bound out of band, so the echo is noise.
224
+ const llm = {
225
+ completeWithTools: async () => ({
226
+ tool_calls: [{
227
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'acpi' }) },
228
+ }],
229
+ }),
230
+ };
231
+
232
+ assert.deepEqual(
233
+ await resolveExecutorArguments({
234
+ llm,
235
+ objective: 'exporter les pages Confluence du workspace acpi',
236
+ capability: EXPORT_CAPABILITY,
237
+ workspace: 'acpi',
238
+ }),
239
+ {},
240
+ );
241
+ });
242
+
243
+ test('argument extraction keeps a real value that is not the workspace name', async () => {
244
+ const llm = {
245
+ completeWithTools: async () => ({
246
+ tool_calls: [{
247
+ function: {
248
+ name: 'set_task_arguments',
249
+ arguments: JSON.stringify({ source_name: 'EAS_Avant_projet_ACPI' }),
250
+ },
251
+ }],
252
+ }),
253
+ };
254
+
255
+ assert.deepEqual(
256
+ await resolveExecutorArguments({
257
+ llm,
258
+ objective: 'exporter la source EAS_Avant_projet_ACPI',
259
+ capability: EXPORT_CAPABILITY,
260
+ workspace: 'acpi',
261
+ }),
262
+ { source_name: 'EAS_Avant_projet_ACPI' },
263
+ );
264
+ });
265
+
266
+ test('argument extraction tells the model the workspace is already bound', async () => {
267
+ let seenSystem = '';
268
+ const llm = {
269
+ completeWithTools: async ({ system }) => {
270
+ seenSystem = system;
271
+ return { tool_calls: [] };
272
+ },
273
+ };
274
+
275
+ await resolveExecutorArguments({
276
+ llm,
277
+ objective: 'exporter les pages du workspace acpi',
278
+ capability: EXPORT_CAPABILITY,
279
+ workspace: 'acpi',
280
+ });
281
+
282
+ assert.match(seenSystem, /already runs against workspace "acpi"/);
283
+ });
284
+
285
+ test('argument extraction rejects a value outside the closed vocabulary', async () => {
286
+ // Regression: "exporter les pages Confluence du workspace juno" — the
287
+ // workspace guard removes "juno", so the model reaches for the next noun and
288
+ // emits "Confluence". Only the agent knows the valid names; once it publishes
289
+ // them as an enum, the orchestrator can check without guessing at meaning.
290
+ const capability = {
291
+ description: 'Export configured sources.',
292
+ inputSchema: {
293
+ type: 'object',
294
+ additionalProperties: true,
295
+ properties: {
296
+ source_name: { type: 'string', enum: ['EAS_Avant_projet_ACPI'] },
297
+ },
298
+ },
299
+ };
300
+ const llm = {
301
+ completeWithTools: async () => ({
302
+ tool_calls: [{
303
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'Confluence' }) },
304
+ }],
305
+ }),
306
+ };
307
+
308
+ assert.deepEqual(
309
+ await resolveExecutorArguments({
310
+ llm,
311
+ objective: 'exporter les pages Confluence du workspace juno',
312
+ capability,
313
+ workspace: 'juno',
314
+ }),
315
+ {},
316
+ );
317
+ });
318
+
319
+ test('argument extraction keeps a value the vocabulary allows', async () => {
320
+ const capability = {
321
+ description: 'Export configured sources.',
322
+ inputSchema: {
323
+ type: 'object',
324
+ additionalProperties: true,
325
+ properties: {
326
+ source_name: { type: 'string', enum: ['EAS_Avant_projet_ACPI', 'autre'] },
327
+ },
328
+ },
329
+ };
330
+ const llm = {
331
+ completeWithTools: async () => ({
332
+ tool_calls: [{
333
+ function: {
334
+ name: 'set_task_arguments',
335
+ arguments: JSON.stringify({ source_name: 'EAS_Avant_projet_ACPI' }),
336
+ },
337
+ }],
338
+ }),
339
+ };
340
+
341
+ assert.deepEqual(
342
+ await resolveExecutorArguments({
343
+ llm,
344
+ objective: 'exporter la source EAS_Avant_projet_ACPI',
345
+ capability,
346
+ workspace: 'acpi',
347
+ }),
348
+ { source_name: 'EAS_Avant_projet_ACPI' },
349
+ );
350
+ });
@@ -432,6 +432,14 @@ export function compactMcpStatus(mcpStatus) {
432
432
  return entries
433
433
  .sort(([left], [right]) => left.localeCompare(right))
434
434
  .map(([name, endpoint]) => {
435
+ // `configured` covers two very different situations: an endpoint we
436
+ // never probed (service stopped) and one whose probe failed. Reporting
437
+ // both as "configured" is what makes a live agent look unconfigured —
438
+ // surface the failure as its own state, with the cause one command away
439
+ // (`/mcp status` prints the full toolsError).
440
+ if (endpoint.status !== 'connected' && endpoint.toolError) {
441
+ return `✕ ${name} :${mcpPort(endpoint)} unreachable (/mcp status)`;
442
+ }
435
443
  const marker = endpoint.status === 'connected'
436
444
  ? '●'
437
445
  : endpoint.status === 'configured'
@@ -684,6 +692,7 @@ Usage:
684
692
  Options:
685
693
  -v, --version Print version
686
694
  -h, --help Print help
695
+ --refresh Stop runtime and project containers; remove project images
687
696
  --cacert <path> Trust a local CA; Docker must be able to read this host path
688
697
  --once <prompt> Run one agent turn and exit
689
698
  --headless Run a workspace task non-interactively
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.29",
3
- "commit": "2be3fe0"
2
+ "version": "0.15.34",
3
+ "commit": "8b6b1aa"
4
4
  }