@dotdrelle/wiki-manager 0.15.34 → 0.15.38

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 (41) hide show
  1. package/.env.example +8 -5
  2. package/README.md +42 -0
  3. package/agents.docker-compose.yml +11 -16
  4. package/docker-compose.yml +9 -8
  5. package/package.json +2 -2
  6. package/src/agent/graph.js +20 -0
  7. package/src/cli/wiki-manager.js +20 -0
  8. package/src/commands/slash.js +85 -13
  9. package/src/commands/slash.test.js +85 -3
  10. package/src/core/agentsCompose.js +86 -1
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +24 -2
  13. package/src/core/dockerCompose.test.js +18 -0
  14. package/src/core/env.js +2 -1
  15. package/src/core/env.test.js +14 -0
  16. package/src/core/googleGrants.js +38 -0
  17. package/src/core/googleGrants.test.js +59 -0
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/mcp.test.js +1 -0
  20. package/src/core/mcpEndpoints.js +96 -0
  21. package/src/core/mcpEndpoints.test.js +126 -0
  22. package/src/core/modelFetch.js +241 -27
  23. package/src/core/modelFetch.test.js +213 -0
  24. package/src/core/profileServiceStatus.test.js +146 -0
  25. package/src/core/wikiSetup.js +11 -2
  26. package/src/core/wikiWorkspace.test.js +16 -3
  27. package/src/orchestrator/agentRegistry.js +13 -1
  28. package/src/orchestrator/agentRegistry.test.js +20 -0
  29. package/src/runtime/lifecycle.js +105 -3
  30. package/src/runtime/lifecycle.test.js +68 -0
  31. package/src/runtime/server.js +28 -0
  32. package/src/shell/LeftPane.tsx +55 -41
  33. package/src/shell/SetupWizard.tsx +416 -116
  34. package/src/shell/repl.js +61 -12
  35. package/src/shell/repl.test.js +48 -19
  36. package/src/shell/setupWizardDiscovery.test.js +48 -0
  37. package/src/shell/setupWizardPlaceholders.test.js +15 -2
  38. package/src/shell/setupWizardSuggestions.test.js +36 -2
  39. package/src/shell/wrapText.js +57 -0
  40. package/src/shell/wrapText.test.js +48 -0
  41. package/wiki-workspace +122 -32
package/.env.example CHANGED
@@ -57,12 +57,15 @@ CONNECTORS_MCP_AUTH_TOKEN=
57
57
  CONNECTORS_ENABLED=false
58
58
  #
59
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):
60
+ # at build time, from agent-external/agent-connectors/.env.build.local. Neither
61
+ # value is a security boundary (public Desktop-type client); baking them is what
62
+ # makes the connector work out of the box.
63
+ #
64
+ # Set either key below to use YOUR OWN Google application instead — it overrides
65
+ # the baked default at runtime, no rebuild needed. LEAVE THEM COMMENTED to keep
66
+ # the baked one: an empty value here is forwarded as an empty value and disables
67
+ # the client entirely.
64
68
  # GOOGLE_OAUTH_CLIENT_ID=
65
- # Optional confidential-client compatibility override:
66
69
  # GOOGLE_OAUTH_CLIENT_SECRET=
67
70
  #
68
71
  # The callback URL is generated automatically
package/README.md CHANGED
@@ -556,6 +556,48 @@ including under `"*"`. Multi-step work belongs to `/agent`.
556
556
  An `allowActions` key written by an older manager is folded into `allow` on
557
557
  read and removed on the next `agents up`.
558
558
 
559
+ ### Adding a connector from the served chat UI
560
+
561
+ `mcp.endpoints.json` stays hand-editable, but the Connectors panel of
562
+ `llm-wiki serve` can now write it. Connecting a card there upserts the endpoint
563
+ through the runtime (`POST /mcp/endpoints`), and the runtime immediately
564
+ re-reads the file and rediscovers tools and agents — no restart, and the new
565
+ tools are usable in the same breath by `/chat`, `/agent` and any subsequent
566
+ plan.
567
+
568
+ Because a server absent from `chatAccess` gets **zero** tools in `/chat`, the
569
+ upsert writes `"allow": "*"` for it. That is the deliberate difference between
570
+ a connector declared by hand — where you choose the tool list — and one added
571
+ from the UI, where the person adding it is the person who will use it. Narrow
572
+ it afterwards by editing the file.
573
+
574
+ Three origins are distinguished, and the UI labels each card:
575
+
576
+ | origin | shown as | who owns it |
577
+ | --- | --- | --- |
578
+ | `wiki`, `production`, `llm-wiki`, `wiki-production` | `internal` | the workspace stack. Fields read-only, no delete — the runtime rejects any change to these names |
579
+ | declared in `mcp.endpoints.json` by hand or by `agents up` | `global config` | the operator. CME, Documents, Mailer, Connectors, Exa… |
580
+ | added from the UI | `added here` | carries `"managedBy": "serve-ui"` in the file |
581
+
582
+ Removing a `global config` connector is a workspace-wide act — it leaves every
583
+ chat, agent and future plan — so the UI says so before confirming. The
584
+ container and its data are untouched; only the wiring is removed. The name is
585
+ also pushed into `disabledMcpServers`, which the scaffold honours, so a
586
+ connector you removed on purpose is not silently restored by the next
587
+ `agents up` merging the packaged example back in.
588
+
589
+ Renaming is atomic: the UI sends `previousName`, and the endpoint, its
590
+ `Authorization` header and its `chatAccess` entry move together under the new
591
+ key. A rename onto an existing name, or from a name that is not there, is
592
+ rejected rather than half-applied.
593
+
594
+ `POST /mcp/endpoints` returns **409 while a plan is running** — connector
595
+ wiring must not change under a run that already resolved its agents. The chat
596
+ UI treats that as what it is: the MCP handshake succeeded, so the card stays
597
+ connected and usable in this browser, badged `local only` with
598
+ "runtime synchronization pending", and the write is retried on the next
599
+ reconnect. A busy runtime never presents itself as a broken connector.
600
+
559
601
  MCP `tools/call` requests retry transient HTTP/MCP failures before the run fails.
560
602
  They also share a per-endpoint outbound control budget (45 RPM by default,
561
603
  configurable with `WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE`). This budget is
@@ -94,22 +94,13 @@ services:
94
94
 
95
95
  connectors:
96
96
  profiles: [connectors]
97
- # Local iteration: build the image from the connectors repo instead of
98
- # pulling the published tag. `image:` is kept so the build is tagged with the
99
- # same name; restore image-only before publishing a release.
100
- build:
101
- context: ../agent-external/agent-connectors
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:
97
+ # Image only, jamais de `build:` ici. Ce fichier est livré dans le paquet
98
+ # npm, où le dépôt agent-connectors n'existe pas : un contexte de build
99
+ # pointant vers `../agent-external/…` y est irrésolvable. La construction
100
+ # appartient à `build-and-push.sh`, qui cuit l'application OAuth wikiLLM
101
+ # dans l'image publiée. Pour utiliser sa propre application Google, on
102
+ # surcharge GOOGLE_OAUTH_CLIENT_ID / _SECRET dans le .env du manager — sans
103
+ # reconstruire quoi que ce soit.
113
104
  image: dotdrelle/agent-connectors:latest
114
105
  user: "${UID:-1000}:${GID:-1000}"
115
106
  ports:
@@ -119,6 +110,10 @@ services:
119
110
  - MCP_AUTH_TOKEN=${CONNECTORS_MCP_AUTH_TOKEN:-}
120
111
  - WORKSPACES_ROOT=/workspaces
121
112
  - AGENT_DATA_DIR=/data
113
+ # Vides par défaut, et c'est sans conséquence : l'application OAuth
114
+ # embarquée dans l'image est lue depuis un fichier, pas depuis
115
+ # l'environnement. Renseigner l'une de ces deux clés dans le .env du
116
+ # manager la fait gagner sur la valeur embarquée.
122
117
  - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
123
118
  - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
124
119
  - GOOGLE_OAUTH_CALLBACK_URL=${GOOGLE_OAUTH_CALLBACK_URL:-}
@@ -144,13 +144,14 @@ x-wiki-manager:
144
144
  description: "Full workspace service set."
145
145
  ui:
146
146
  targets: [serve]
147
- description: "Alias for serve: workspace web UI."
148
- wiki:
149
- targets: [mcp-http]
150
- description: "Alias for mcp-http: wiki MCP server."
151
- mcp:
152
- targets: [mcp-http]
153
- description: "Alias for mcp-http: wiki MCP server."
147
+ description: "Chat, wiki browser and Donna's web UI."
148
+ # Pas d'alias `wiki` ici : ce nom est deja celui d'un service Compose (le
149
+ # CLI one-shot de `/wiki run`). L'alias le masquait, et `/start wiki`
150
+ # demarrait mcp-http sans le dire.
151
+ #
152
+ # Pas d'alias `mcp` non plus : mcp-http fait partie du socle demarre par
153
+ # `all` et `services`, personne ne le demarre seul. Il reste adressable
154
+ # sous son nom Compose.
154
155
  production:
155
156
  targets: [production-mcp]
156
- description: "Alias for production-mcp: production jobs."
157
+ description: "Ingest, build, export and polish jobs."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.34",
3
+ "version": "0.15.38",
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/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",
14
+ "test": "node --test src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
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",
@@ -1019,12 +1019,32 @@ export function buildAgentSystemPrompt(state) {
1019
1019
  ? 'The runtime is connected and runtime__delegate is available for any requested capability action that has no matching direct tool. When a matching direct tool is offered, call it directly; otherwise let the runtime resolve the objective from discovered capability contracts.'
1020
1020
  : 'No runtime is connected, so you cannot execute actions. State that plainly and name the runtime connection as the missing capability — do not invent a workaround.',
1021
1021
  'If the connector or service needed for a requested read or action is absent from the Connected MCP tools above (its service is not running — e.g. CME, documents, or production), say plainly that this service is not connected and name it as the missing capability. Never redirect a simple read (e.g. "give me the CME config") to an "agent action", never invent its result, and never propose a workaround. Only requests you can actually serve with a listed tool are answered with data.',
1022
+ // Cas observé : « récupère ce mail » et « envoie un mail » refusés comme
1023
+ // impossibles, alors que l'agent connectors déclare `external-source.collect`
1024
+ // (écrit dans raw/untracked/) et `communication.send-email`. Les outils de
1025
+ // lecture directs ne rendent que des métadonnées : les prendre pour la
1026
+ // limite de l'agent transforme un travail délégable en refus.
1027
+ 'The direct read tools of a connector are a preview, not the measure of what its agent can do. Bringing external content INTO the workspace (retrieve, import, fetch, save, "récupère") is a collect capability, and acting on the outside world (send, publish, notify) is an action capability: both are delegated through runtime__delegate, not answered from a read tool. Never conclude that something is impossible because the listed read tool returns only metadata — check the capabilities the agents declare, and delegate the objective as stated.',
1028
+ // Un droit manquant n'est pas une fonctionnalité absente : l'un se
1029
+ // réautorise en une commande, l'autre n'existe pas. Les confondre envoie
1030
+ // l'utilisateur croire que le produit ne sait pas faire.
1031
+ 'When an action fails or is refused for lack of an authorization grant or scope (rather than a missing capability), say exactly that and name the primitive that grants it. Do not describe the feature as unavailable.',
1022
1032
  'For an action with no matching direct tool, call runtime__delegate with the user objective only. The runtime chooses the capability, operation, agent and plan, including a validated single task for executor-only agents. Never choose those identifiers yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
1023
1033
  'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
1024
1034
  'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
1025
1035
  'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
1026
1036
  'For workspace inventory and page listings, use the connected wiki MCP read tools. Never invent or call a /wiki shell command through shell__run_command. Use /workspace init <name> [path] for low-level non-interactive workspace creation; in the interactive TUI, /new <name> opens the setup wizard.',
1027
1037
  'If an action requires tools or skills not available yet, explain the limitation and name the expected primitive.',
1038
+ // Les outils help_* étaient exposés — ils font partie de l'allow-list du
1039
+ // mode chat — mais rien dans ce prompt ne disait qu'ils sont LA source des
1040
+ // questions produit. Donna répondait donc de mémoire. Cas observé :
1041
+ // « comment activer les connecteurs ? » → un `cme.yaml`, un « manifeste des
1042
+ // services actifs » et un redémarrage de service, tous inventés, là où la
1043
+ // réponse tient dans un drapeau du `.env` du manager.
1044
+ 'The bundled product documentation is your source for how llm-wiki works: what a feature is, how to enable or configure it, where a setting lives, what a message means, how to get started, how to troubleshoot. For any such question, call the documentation search/read tools FIRST and answer from what they return. Do not answer these from memory, even when you feel certain.',
1045
+ // Une réponse fausse et assurée sur la configuration coûte plus cher qu'un
1046
+ // « je ne sais pas » : elle envoie éditer des fichiers qui n'existent pas.
1047
+ 'Configuration facts are never answered from memory. File names, environment variables, config keys, directory layouts and enabling procedures must come from a tool result or from the documentation you actually read in this conversation. If the documentation does not cover it, say plainly that you could not find where it is configured — never reconstruct a plausible-looking file name, key or procedure. A confident wrong answer sends the user editing files that do not exist.',
1028
1048
  workspaceProfile
1029
1049
  ? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
1030
1050
  : null,
@@ -14,6 +14,7 @@ import { refreshRunningContainers } from '../core/wikiSetup.js';
14
14
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
15
15
  import { listWikircProfiles } from '../core/wikirc.js';
16
16
  import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
17
+ import { deleteManagedMcpEndpoint, listManagedMcpEndpoints, upsertManagedMcpEndpoint } from '../core/mcpEndpoints.js';
17
18
  import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
18
19
  import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
19
20
  import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
@@ -740,6 +741,14 @@ async function runRuntime(argv, agent) {
740
741
  let serverHandle = null;
741
742
  const contexts = new Map();
742
743
 
744
+ async function refreshAllMcpContexts() {
745
+ const resolved = await Promise.all([...new Set(contexts.values())].map((value) => Promise.resolve(value)));
746
+ await Promise.all(resolved.map(async (context) => {
747
+ await refreshMcpRuntimeStatus(context.session);
748
+ await discoverAgentsOnce(context.session);
749
+ }));
750
+ }
751
+
743
752
  async function getWorkspaceContext(workspaceName = null) {
744
753
  const requestedWorkspace = workspaceName ? String(workspaceName).trim() : null;
745
754
  const key = requestedWorkspace ?? '__default__';
@@ -1416,6 +1425,17 @@ async function runRuntime(argv, agent) {
1416
1425
  config,
1417
1426
  };
1418
1427
  },
1428
+ listMcpEndpoints: async () => ({ endpoints: listManagedMcpEndpoints() }),
1429
+ upsertMcpEndpoint: async (_context, body) => {
1430
+ const endpoint = upsertManagedMcpEndpoint(body);
1431
+ await refreshAllMcpContexts();
1432
+ return { ok: true, endpoint };
1433
+ },
1434
+ deleteMcpEndpoint: async (_context, body) => {
1435
+ const endpoint = deleteManagedMcpEndpoint(body.name);
1436
+ await refreshAllMcpContexts();
1437
+ return { ok: true, endpoint };
1438
+ },
1419
1439
  token: auth.token,
1420
1440
  });
1421
1441
  const recovery = await recoverRuntime();
@@ -3,6 +3,8 @@ import { openExternalUrl } from '../shell/openExternal.js';
3
3
  import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
4
4
  import { join, relative } from 'node:path';
5
5
  import { composeServices, listServices, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
6
+ import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
7
+ import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
6
8
  import {
7
9
  applyMcpRuntimeStatus,
8
10
  buildMcpStatus,
@@ -336,7 +338,7 @@ function workspaceStatsColumns(stats, session) {
336
338
  };
337
339
  }
338
340
 
339
- function workspaceLoadedText(workspace, summary, session) {
341
+ function workspaceLoadedText(workspace, summary, session, mcpError = null) {
340
342
  const profiles = listWikircProfiles(workspace.workspacePath);
341
343
  const profileLines = profiles.length > 0
342
344
  ? profiles.map((profile) => {
@@ -372,6 +374,7 @@ function workspaceLoadedText(workspace, summary, session) {
372
374
  '',
373
375
  `llm: ${session.llm ? 'configured' : 'missing config'}`,
374
376
  `mcp: ${Object.values(session.mcp ?? {}).filter((value) => value.status === 'connected').length} connected`,
377
+ ...(mcpError ? ['', `MCP discovery failed: ${mcpError}`] : []),
375
378
  ].join('\n');
376
379
  }
377
380
 
@@ -939,9 +942,17 @@ export async function handleSlashCommand(line, context) {
939
942
  context.session.workspaceEnv = workspace.env;
940
943
  context.session.workspaceEnvFile = workspace.envFile;
941
944
  context.session.systemPrompt = loadWorkspaceSystemPrompt(workspace.workspacePath);
945
+ let summary;
942
946
  try {
943
947
  step(`Workspace: loading ${workspace.name} config…`);
944
- const { summary } = applySessionWikircProfile(context.session, 'default');
948
+ ({ summary } = applySessionWikircProfile(context.session, 'default'));
949
+ } catch (err) {
950
+ const message = err instanceof Error ? err.message : String(err);
951
+ return {
952
+ output: workspaceLoadedWithoutConfigText(workspace, message),
953
+ };
954
+ }
955
+ try {
945
956
  step(`Workspace: discovering ${workspace.name} MCP tools…`);
946
957
  await refreshMcpRuntimeStatus(context.session);
947
958
  return {
@@ -950,7 +961,7 @@ export async function handleSlashCommand(line, context) {
950
961
  } catch (err) {
951
962
  const message = err instanceof Error ? err.message : String(err);
952
963
  return {
953
- output: workspaceLoadedWithoutConfigText(workspace, message),
964
+ output: workspaceLoadedText(workspace, summary, context.session, message),
954
965
  };
955
966
  }
956
967
  }
@@ -1058,6 +1069,12 @@ export async function handleSlashCommand(line, context) {
1058
1069
  // falls back to the hardcoded COMPOSE_SERVICES constant instead.
1059
1070
  const service = args[1];
1060
1071
  if (service === 'agents' || service === 'agent') return runAgentCommand(startAgents, 'start');
1072
+ // Un agent nommé appartient à la pile agents (projet Compose distinct),
1073
+ // pas à celle du workspace : le router ici évite un « no such service »
1074
+ // sur un nom que la complétion propose pourtant.
1075
+ if (agentServiceNames().includes(service)) {
1076
+ return runAgentCommand((options) => startAgents({ ...options, services: [service] }), 'start');
1077
+ }
1061
1078
  // "all" used to mean "the workspace services", which left the external
1062
1079
  // agents down and looked like nothing had happened. It now means what an
1063
1080
  // operator reads into it: the whole stack. `/start services` keeps the
@@ -1094,9 +1111,22 @@ export async function handleSlashCommand(line, context) {
1094
1111
  case 'stop': {
1095
1112
  const service = args[1];
1096
1113
  if (service === 'agents') return runAgentCommand(stopAgents, 'stop');
1114
+ if (agentServiceNames().includes(service)) {
1115
+ return runAgentCommand((options) => stopAgents({ ...options, services: [service] }), 'stop');
1116
+ }
1117
+ // Symétrique de `/start all` : « all » désigne toute la pile, agents
1118
+ // compris. Il ne stoppait que les services du workspace et laissait les
1119
+ // agents debout — donc `/start all` puis `/stop all` ne revenait pas à
1120
+ // l'état de départ.
1121
+ const stopsAgents = service === 'all';
1122
+ const stopTarget = service === 'services' ? undefined : service;
1097
1123
  try {
1098
1124
  step(`Services: stopping ${service ?? 'workspace services'}…`);
1099
- await stopService(context.session, service);
1125
+ await stopService(context.session, stopTarget);
1126
+ if (stopsAgents) {
1127
+ const agentsResult = await runAgentCommand(stopAgents, 'stop');
1128
+ if (agentsResult?.failed) return agentsResult;
1129
+ }
1100
1130
  step('Services: refreshing MCP runtime…');
1101
1131
  await refreshMcpRuntimeStatus(context.session);
1102
1132
  return localizedOperationResult({
@@ -1186,7 +1216,9 @@ export async function handleSlashCommand(line, context) {
1186
1216
  await refreshMcpRuntimeStatus(context.session);
1187
1217
  const connectorMcp = context.session.mcp?.connectors;
1188
1218
  if (!connectorMcp || connectorMcp.status !== 'connected') {
1189
- return connectorResult('The connectors service is unavailable or disabled.');
1219
+ // Le manager sait exactement pourquoi : ne pas renvoyer un constat
1220
+ // vague que Donna comblerait en inventant.
1221
+ return connectorResult(profileServiceStatus('connectors').message);
1190
1222
  }
1191
1223
  if (subcommand === 'list') {
1192
1224
  if (args[2]) return connectorResult('The connector list request has invalid extra arguments.');
@@ -1198,38 +1230,78 @@ export async function handleSlashCommand(line, context) {
1198
1230
  { workspace: context.session.workspace },
1199
1231
  );
1200
1232
  const payload = parseJsonText(formatMcpToolResult(result));
1201
- const status = payload?.status === 'configured' ? 'authorized' : 'not authorized';
1202
- return connectorResult(`google (Gmail read-only): ${status}`);
1233
+ if (payload?.status !== 'configured') {
1234
+ return connectorResult('google (Gmail): not authorized. Run `/connector auth google` to authorize reading and sending.');
1235
+ }
1236
+ // Le libellé annonçait « read-only » quels que soient les droits
1237
+ // réellement accordés — donc il mentait dès qu'on autorisait l'envoi,
1238
+ // et n'aidait pas à comprendre pourquoi l'envoi échouait sinon.
1239
+ const grants = Array.isArray(payload?.grants) ? payload.grants : [];
1240
+ const missing = GOOGLE_GRANTS.filter((grant) => !grants.includes(grant));
1241
+ const held = grants.map((grant) => `${grant} — ${GOOGLE_GRANT_LABELS[grant] ?? 'unknown grant'}`);
1242
+ const lines = [
1243
+ `google (Gmail): authorized for ${grants.join(', ') || 'nothing'}`,
1244
+ ...held.map((line) => ` ✓ ${line}`),
1245
+ ...missing.map((grant) => ` ✗ ${grant} — ${GOOGLE_GRANT_LABELS[grant]}`),
1246
+ ];
1247
+ if (missing.length > 0) {
1248
+ lines.push(`Run \`/connector auth google ${missing.join(' ')}\` to add the missing grant(s); existing ones are kept.`);
1249
+ }
1250
+ return connectorResult(lines.join('\n'));
1203
1251
  } catch (err) {
1204
- return connectorResult(`google (Gmail read-only): unavailable (${err instanceof Error ? err.message : String(err)})`);
1252
+ return connectorResult(`google (Gmail): unavailable (${err instanceof Error ? err.message : String(err)})`);
1205
1253
  }
1206
1254
  }
1207
1255
  if (subcommand === 'auth') {
1208
1256
  const connector = String(args[2] ?? '').toLowerCase();
1209
1257
  if (!['google', 'gmail'].includes(connector)) {
1210
- return connectorResult('The requested connector is unsupported. The available connector is google (Gmail read-only).');
1258
+ return connectorResult('The requested connector is unsupported. The available connector is google (Gmail).');
1259
+ }
1260
+ // Les droits demandés à Google. L'appel ne les passait pas, et le
1261
+ // serveur retombait sur son défaut `["read"]` : l'agent sait envoyer un
1262
+ // courriel, l'autorisation obtenue ne le permettait pas, et le refus
1263
+ // ressemblait à une fonctionnalité absente. On demande donc lecture ET
1264
+ // envoi par défaut, et les droits restants s'ajoutent à la demande.
1265
+ const requested = args.slice(3).map((value) => String(value).toLowerCase());
1266
+ const unknown = requested.filter((grant) => !GOOGLE_GRANTS.includes(grant));
1267
+ if (unknown.length > 0) {
1268
+ const available = GOOGLE_GRANTS.map((grant) => `${grant} (${GOOGLE_GRANT_LABELS[grant]})`).join('; ');
1269
+ return connectorResult(`Unsupported grant(s): ${unknown.join(', ')}. Available grants: ${available}.`);
1211
1270
  }
1271
+ // Par défaut, tout ce que l'agent sait faire — y compris `modify`, sans
1272
+ // quoi les actions que Donna propose d'elle-même (« marquer comme lu »,
1273
+ // « archiver ») échouent après coup. C'est la même incohérence que
1274
+ // l'envoi : promettre une action que l'autorisation ne couvre pas.
1275
+ const grants = requested.length > 0 ? [...new Set(requested)] : defaultGoogleGrants();
1212
1276
  try {
1213
1277
  const result = await callMcpTool(
1214
1278
  context.session.mcp,
1215
1279
  'connectors',
1216
1280
  'connectors_google_oauth_start',
1217
- { workspace: context.session.workspace },
1281
+ { workspace: context.session.workspace, grants },
1218
1282
  );
1219
1283
  const payload = parseJsonText(formatMcpToolResult(result));
1220
1284
  const authorizationUrl = payload?.authorizationUrl;
1285
+ if (payload?.error === 'send_capability_disabled') {
1286
+ return connectorResult(
1287
+ 'Sending is disabled in this deployment (CONNECTORS_SEND_ENABLED=false in the manager .env), so the send grant cannot be authorized. Run `/connector auth google read` for read-only access, or enable sending and restart the connectors agent.',
1288
+ );
1289
+ }
1221
1290
  if (payload?.ok !== true || typeof authorizationUrl !== 'string') {
1222
1291
  return connectorResult(`Google authorization could not start (${payload?.error ?? 'missing authorization URL'}).`);
1223
1292
  }
1293
+ // L'autorisation est incrémentale côté Google : redemander avec un
1294
+ // droit de plus ne révoque pas les précédents.
1295
+ const scopeNote = `Requested grants: ${grants.join(', ')}.`;
1224
1296
  if (openExternalUrl(authorizationUrl)) {
1225
- return connectorResult('Google authorization opened successfully in the user browser.');
1297
+ return connectorResult(`Google authorization opened successfully in the user browser. ${scopeNote}`);
1226
1298
  }
1227
- return connectorResult(`Google authorization requires the user to open this URL: ${authorizationUrl}`);
1299
+ return connectorResult(`Google authorization requires the user to open this URL: ${authorizationUrl} — ${scopeNote}`);
1228
1300
  } catch (err) {
1229
1301
  return connectorResult(`Google authorization could not start (${err instanceof Error ? err.message : String(err)}).`);
1230
1302
  }
1231
1303
  }
1232
- return connectorResult('The requested connector action is unsupported. Available actions: list and auth google.');
1304
+ return connectorResult(`The requested connector action is unsupported. Available actions: list, and auth google [${GOOGLE_GRANTS.join('|')}].`);
1233
1305
  }
1234
1306
  case 'cancel': {
1235
1307
  // Alias of /run cancel — people type /cancel when they want out.
@@ -126,9 +126,43 @@ test('/start completes to the three documented targets', async () => {
126
126
  for (const expected of ['all', 'agents', 'services']) {
127
127
  assert.ok(matches.includes(expected), `${expected} missing from ${JSON.stringify(matches)}`);
128
128
  }
129
- assert.match(completionDescription('all', ['/start']), /services AND the external agents/);
130
- assert.match(completionDescription('agents', ['/start']), /agents only/);
131
- assert.match(completionDescription('services', ['/start']), /workspace services only/);
129
+ assert.match(completionDescription('all', ['/start']), /services AND external agents/);
130
+ assert.match(completionDescription('agents', ['/start']), /agents only/i);
131
+ assert.match(completionDescription('services', ['/start']), /workspace services only/i);
132
+ });
133
+
134
+ test('/start offers the operator vocabulary, not the Compose service names twice', async () => {
135
+ const { completionDescription } = await import('../shell/repl.js');
136
+ const { matches } = completionContext('/start ', {});
137
+
138
+ // `all` apparaissait deux fois — mot-clé du shell ET alias Compose — et
139
+ // chaque alias était doublé du service qu'il désigne : dix entrées pour cinq
140
+ // actions.
141
+ assert.equal(new Set(matches).size, matches.length, `duplicates in ${JSON.stringify(matches)}`);
142
+ for (const raw of ['serve', 'mcp-http', 'production-mcp']) {
143
+ assert.ok(!matches.includes(raw), `${raw} is already covered by an alias`);
144
+ }
145
+ for (const alias of ['ui', 'production']) {
146
+ assert.ok(matches.includes(alias), `${alias} missing from ${JSON.stringify(matches)}`);
147
+ assert.notEqual(
148
+ completionDescription(alias, ['/start']),
149
+ 'Start this Docker Compose service.',
150
+ `${alias} must explain what it starts`,
151
+ );
152
+ }
153
+ // `wiki` est le service one-shot de `/wiki run` : en faire un alias de
154
+ // mcp-http le masquait silencieusement.
155
+ assert.ok(!matches.includes('wiki'), 'wiki must not shadow the one-shot CLI service');
156
+ // mcp-http fait partie du socle demarre par `all` et `services` : son alias
157
+ // n'ajoutait qu'une ligne a lire pour un service que personne ne lance seul.
158
+ assert.ok(!matches.includes('mcp'), 'mcp is covered by services/all');
159
+ // Les agents du socle ne s'adressent pas un par un, et `mailer` n'existe
160
+ // meme pas tant qu'il n'est pas decommente dans l'override de l'operateur.
161
+ for (const agent of ['cme', 'documents', 'mailer']) {
162
+ assert.ok(!matches.includes(agent), `${agent} is started by "agents", not on its own`);
163
+ }
164
+ // Seuls les agents derriere un drapeau de profil se pilotent separement.
165
+ assert.ok(matches.includes('connectors'), 'connectors is opt-in, so it must be addressable');
132
166
  });
133
167
 
134
168
  test('/workspace delete removes files and clears current session context after confirmation', async () => {
@@ -311,6 +345,54 @@ test('/use loads only workspaces and /config use switches wikirc profiles', asyn
311
345
  }
312
346
  });
313
347
 
348
+ test('/use keeps a loaded wikirc when MCP discovery fails', async () => {
349
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-use-mcp-error-'));
350
+ const registryRoot = join(root, 'registry');
351
+ const workspacePath = join(root, 'workspace');
352
+ const registryPath = join(registryRoot, 'demo');
353
+ mkdirSync(registryPath, { recursive: true });
354
+ mkdirSync(workspacePath, { recursive: true });
355
+ mkdirSync(join(root, 'mcp.endpoints.json'));
356
+ writeFileSync(join(root, '.env'), '', 'utf8');
357
+ writeFileSync(join(registryPath, '.env'), [
358
+ 'WORKSPACE_NAME=demo',
359
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
360
+ '',
361
+ ].join('\n'), 'utf8');
362
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [
363
+ 'language: fr',
364
+ 'llm:',
365
+ ' provider: openai-compatible',
366
+ ' engine: openai',
367
+ ' model: test-model',
368
+ ' apiKey: test-key',
369
+ '',
370
+ ].join('\n'), 'utf8');
371
+
372
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
373
+ const previousEnvFile = process.env.WIKI_MANAGER_ENV_FILE;
374
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
375
+ process.env.WIKI_MANAGER_ENV_FILE = join(root, '.env');
376
+ try {
377
+ const session = {};
378
+ const result = await handleSlashCommand('/use demo', {
379
+ packageJson: { version: 'test' },
380
+ session,
381
+ });
382
+
383
+ assert.equal(session.wikirc?.profile, 'default');
384
+ assert.equal(session.wikircConfig?.llm?.model, 'test-model');
385
+ assert.match(result.output ?? '', /profile: default/);
386
+ assert.match(result.output ?? '', /MCP discovery failed:.*EISDIR/s);
387
+ assert.doesNotMatch(result.output ?? '', /Wikirc not loaded/);
388
+ } finally {
389
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
390
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
391
+ if (previousEnvFile === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
392
+ else process.env.WIKI_MANAGER_ENV_FILE = previousEnvFile;
393
+ }
394
+ });
395
+
314
396
  test('/queue cancel refuses runtime-managed items instead of fake-cancelling locally', async () => {
315
397
  // syncRuntimeState replaces session.jobQueue with the runtime queue and tags
316
398
  // origin:'runtime' — a local cancel would be reverted by the next SSE sync,
@@ -1,5 +1,6 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import YAML from 'yaml';
3
4
 
4
5
  import { managerComposeOverrideFile, managerEnvFile, readEnvFile } from './env.js';
5
6
  import { managerRoot } from './workspaces.js';
@@ -16,10 +17,94 @@ export const PROFILE_FLAGS = Object.freeze({
16
17
  connectors: 'CONNECTORS_ENABLED',
17
18
  });
18
19
 
20
+ /**
21
+ * Services réellement déclarés dans la pile agents, override compris.
22
+ *
23
+ * Lue du fichier plutôt que codée en dur : une liste figée avait fini par
24
+ * annoncer `mailer`, qui ne vit que dans l'exemple d'override et n'existe donc
25
+ * pas tant que l'opérateur ne l'a pas décommenté. Proposer un service absent,
26
+ * c'est promettre un « no such service ».
27
+ *
28
+ * Sert au ROUTAGE : la pile agents est un projet Compose distinct de celui du
29
+ * workspace, donc `/start <nom>` doit partir vers `wiki-workspace agents`.
30
+ */
31
+ export function agentServiceNames({ env = resolvedManagerEnv() } = {}) {
32
+ const { composeFiles } = resolveAgentsComposeContext({ env });
33
+ const names = new Set();
34
+ for (const file of composeFiles) {
35
+ try {
36
+ const parsed = YAML.parse(readFileSync(file, 'utf8')) ?? {};
37
+ for (const name of Object.keys(parsed.services ?? {})) names.add(name);
38
+ } catch { /* fichier illisible : il ne déclare rien d'adressable */ }
39
+ }
40
+ return [...names].sort();
41
+ }
42
+
43
+ /**
44
+ * Agents qu'un opérateur pilote un par un, proposés dans les complétions.
45
+ *
46
+ * Uniquement ceux placés derrière un drapeau de profil : ce sont les seuls
47
+ * qu'on active et désactive délibérément. `cme` et `documents` font partie du
48
+ * socle — on les démarre avec `agents`, les distinguer n'apporte rien et
49
+ * allonge une liste que l'opérateur doit lire à chaque fois.
50
+ */
51
+ export function togglableAgentNames() {
52
+ return Object.keys(PROFILE_FLAGS).sort();
53
+ }
54
+
19
55
  export function isEnabled(value) {
20
56
  return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
21
57
  }
22
58
 
59
+ /**
60
+ * Pourquoi un service à profil est indisponible, et comment l'activer.
61
+ *
62
+ * Le message d'origine — « le service des connecteurs est indisponible ou
63
+ * désactivé » — ne disait ni lequel des deux, ni où regarder. Donna, à qui on
64
+ * demandait ensuite « comment l'activer », n'avait aucun fait à citer et
65
+ * inventait des fichiers (`cme.yaml`, un « manifeste des services actifs »)
66
+ * qui n'existent nulle part. Une réponse fausse et confiante coûte plus cher
67
+ * qu'un « je ne sais pas » : c'est le message qu'il faut rendre suffisant,
68
+ * pas le modèle qu'il faut espérer plus prudent.
69
+ *
70
+ * @returns {{ enabled: boolean, flag: string, envFile: string, message: string }}
71
+ */
72
+ export function profileServiceStatus(profile, { env = resolvedManagerEnv() } = {}) {
73
+ const flag = PROFILE_FLAGS[profile];
74
+ const envFile = managerEnvFile();
75
+ if (!flag) {
76
+ return {
77
+ enabled: false,
78
+ flag: null,
79
+ envFile,
80
+ message: `There is no "${profile}" service in this deployment.`,
81
+ };
82
+ }
83
+ const enabled = isEnabled(env[flag]);
84
+ if (enabled) {
85
+ return {
86
+ enabled: true,
87
+ flag,
88
+ envFile,
89
+ message: `The ${profile} service is enabled (${flag}) but not reachable — its container is probably not running. Start it with \`/start agents\`.`,
90
+ };
91
+ }
92
+ const current = String(env[flag] ?? '').trim();
93
+ return {
94
+ enabled: false,
95
+ flag,
96
+ envFile,
97
+ message: [
98
+ `The ${profile} service is disabled: ${flag} is ${current === '' ? 'not set' : `"${current}"`} in ${envFile}.`,
99
+ 'To enable it:',
100
+ ` 1. set ${flag}=true in ${envFile}`,
101
+ ' 2. run `/start agents` (or `wiki-workspace agents up`) to start its container',
102
+ ` 3. run \`/connector list\` to check it answers`,
103
+ `The ${profile} service runs behind a Compose profile, so while ${flag} is off its container does not exist at all — it will not appear in \`docker compose ps\`.`,
104
+ ].join('\n'),
105
+ };
106
+ }
107
+
23
108
  /**
24
109
  * The manager `.env` wins over the ambient process environment.
25
110
  *