@dotdrelle/wiki-manager 0.15.49 → 0.15.50

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/README.md CHANGED
@@ -637,9 +637,15 @@ with `"replans": 1` in the `/run` body.
637
637
  Runtime approvals are bounded to a run, plan revision and approval class.
638
638
  Mutating orchestrated tasks **wait for approval by default**, including tasks
639
639
  created by a skill or a directly selected capability such as ingest or
640
- pipeline. Approve them by replying "valide tout", running `/approve`, or
641
- clicking Approve in either UI (Shell right-pane banner, or the `serve` banner
642
- above the composer). `POST /approve` also accepts an explicit run scope.
640
+ pipeline. Approve them by running `/approve`, or clicking Approve in either UI:
641
+ the Shell right-pane banner, or the `serve` banner.
642
+
643
+ In `serve` that banner is a **fixed overlay, visible in every centre view**. It
644
+ used to sit inside the composer, which the layout hides in the wiki, connectors
645
+ and execution views — so a restore launched from `/history` waited on an
646
+ approval nobody could see, and the Execution view, the one meant for monitoring,
647
+ could not show it either. The Shell never had that gap because its plan pane is
648
+ always on screen. `POST /approve` also accepts an explicit run scope.
643
649
 
644
650
  An explicitly launched skill is also approval-gated. For an orchestrated skill,
645
651
  the scheduler blocks each uncovered mutating task; for a `direct` skill, the
@@ -907,10 +913,35 @@ The shared `docker-compose.yml` starts one workspace stack:
907
913
  Use `wiki-workspace` whenever possible so Compose receives the right project
908
914
  name, env file, ports, and volume mounts.
909
915
 
916
+ `PRODUCTION_ALLOWED_STEPS` gates what `production-mcp` will accept, and an
917
+ omission from it is **silent**: `agent_plan` simply leaves the step's task out of
918
+ the fragment instead of failing. `taxonomy` was missing from the shipped default
919
+ for several releases, so every compose-deployed ingest ran without the taxonomy
920
+ barrier and left the published map stale. Keep the variable in step with the
921
+ in-code default of `production_mcp_server.py`: a test here asserts `taxonomy` is
922
+ present, and one in `agent-wiki-production` compares the whole list against that
923
+ in-code reference. Remember that an explicit value in your `.env` overrides the
924
+ default entirely.
925
+
910
926
  Runtime split: the host manager/runtime uses Node.js 22+ for `node:sqlite`; the
911
927
  interactive OpenTUI shell uses Bun 1.2+; workspace Docker services run from the
912
928
  published images and do not depend on host `node_modules`.
913
929
 
930
+ Two consequences worth knowing before debugging anything:
931
+
932
+ - **The runtime is not a container.** `runtime/lifecycle.js` spawns it locally,
933
+ detached, from the manager sources — no Compose file declares it. Changing
934
+ runtime or shell code therefore needs a **restart**, never an image rebuild;
935
+ changing `llm-wiki` or an agent needs the image rebuilt.
936
+ - **The runtime starts before the workspace containers.** Its first agent
937
+ discovery legitimately finds them absent. `agentRegistry` keeps a known
938
+ agent's capabilities when a probe fails — it only refreshes `lastSeenAt`, and
939
+ says so in the runtime log — and the periodic re-scan re-probes the MCP
940
+ endpoints instead of reusing a cached status. Without both, a capability the
941
+ agent really has stayed missing from the registry until the next successful
942
+ discovery, and the only symptom was a run failing much later with
943
+ `No agent provides capability …`.
944
+
914
945
  As of 0.11.4, the host runtime store carries a minimal format guard:
915
946
  `PRAGMA user_version = 1` in SQLite plus `.wiki/meta.json` with
916
947
  `schemaVersion: 1`. Unknown future versions stop startup with a clear error.
@@ -124,7 +124,13 @@ services:
124
124
  - WORKSPACE_NAME=${WORKSPACE_NAME:-workspace}
125
125
  - WIKI_WORKSPACE_PATH=/workspace
126
126
  - WIKI_CONFIG_PATH=${WIKI_CONFIG_PATH:-}
127
- - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,restore,pipeline}
127
+ # `taxonomy` belongs here, and its absence is SILENT: agent_plan drops the
128
+ # taxonomy task from an ingest fragment when the step is not allowed
129
+ # (production_mcp_server.py, "taxonomy" in _ALLOWED_STEPS), without an
130
+ # error. Every compose-deployed ingest then ran without the Lot 4 barrier
131
+ # and left the published map stale — the very defect that work fixed.
132
+ # `copy` stays out on purpose: it is the legacy step, opt-in only.
133
+ - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,taxonomy,build,export,polish,restore,pipeline}
128
134
  - PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
129
135
  # Parallelism levers — effective concurrency ≈ recommendedConcurrency.
130
136
  # Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.49",
3
+ "version": "0.15.50",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -26,7 +26,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
26
26
  import { enqueueProductionJob, ensureJobQueue, formatQueue, productionLockBusy } from '../core/jobQueue.js';
27
27
  import { loadWorkspaceProfile, updateWorkspaceProfilePreference } from '../core/profile.js';
28
28
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
29
- import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
29
+ import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
30
30
  import { controlLanguage } from '../runtime/controlMessages.js';
31
31
 
32
32
  const MAX_TOOL_ITERATIONS = 80;
@@ -46,7 +46,7 @@ const MAX_SPINNER_ARG_LENGTH = 96;
46
46
  const INTERNAL_TOOL_SERVERS = {
47
47
  wiki: ['plan_set', 'plan_done'],
48
48
  shell: ['run_command', 'read_command', 'profile_update'],
49
- runtime: ['kill', 'cancel', 'status', 'approve', 'enqueue', 'delegate', 'run_skill'],
49
+ runtime: ['kill', 'cancel', 'status', 'enqueue', 'delegate', 'run_skill'],
50
50
  };
51
51
 
52
52
  const AGENT_SLASH_COMMANDS = new Set([
@@ -164,15 +164,6 @@ const RUNTIME_STATUS_TOOL = {
164
164
  },
165
165
  };
166
166
 
167
- const RUNTIME_APPROVE_TOOL = {
168
- type: 'function',
169
- function: {
170
- name: 'runtime__approve',
171
- description: 'Grant the pending approval of the active runtime run (mutating tasks wait on it). Use when the user consents in ANY phrasing: "vas-y", "ok pour l\'export", "approuve", "valide". Confirm what was approved.',
172
- parameters: { type: 'object', additionalProperties: false, properties: {} },
173
- },
174
- };
175
-
176
167
  const RUNTIME_ENQUEUE_TOOL = {
177
168
  type: 'function',
178
169
  function: {
@@ -364,7 +355,6 @@ function toolDefinitionForCall(session, callName) {
364
355
  RUNTIME_STATUS_TOOL,
365
356
  RUNTIME_CANCEL_TOOL,
366
357
  RUNTIME_KILL_TOOL,
367
- RUNTIME_APPROVE_TOOL,
368
358
  RUNTIME_ENQUEUE_TOOL,
369
359
  RUNTIME_DELEGATE_TOOL,
370
360
  RUNTIME_RUN_SKILL_TOOL,
@@ -852,28 +842,6 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
852
842
  const result = await postRuntimeCancel({ url, workspace });
853
843
  return result.cancelled ? 'Runtime run cancellation requested.' : `No active run to cancel${result.reason ? ` (${result.reason})` : ''}.`;
854
844
  }
855
- if (tool === 'approve') {
856
- const state = await fetchRuntimeState({ url, workspace });
857
- const pending = (Array.isArray(state?.approvals) ? state.approvals : [])
858
- .filter((approval) => approval.status === 'pending_approval');
859
- const runId = state?.runId
860
- ?? state?.runs?.find((run) => ['running', 'pending_approval'].includes(run.status))?.id
861
- ?? null;
862
- if (!runId || pending.length === 0) return 'No pending approval found.';
863
- const approvalClasses = [...new Set(pending.flatMap((approval) => {
864
- const value = approval.approvalClasses ?? approval.approvalClass ?? [];
865
- return Array.isArray(value) ? value : [value];
866
- }).map(String).filter(Boolean))];
867
- const result = await postRuntimeApprove({
868
- url,
869
- workspace,
870
- runId,
871
- scope: 'run',
872
- planRevision: state?.planRevision ?? null,
873
- approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
874
- });
875
- return result?.approved ? 'Current validated plan approved.' : 'No pending approval found.';
876
- }
877
845
  if (tool === 'delegate') {
878
846
  const objective = String(args.objective ?? '').trim();
879
847
  if (!objective) return 'Delegation rejected: missing objective.';
@@ -1250,7 +1218,7 @@ export function buildAgentSystemPrompt(state) {
1250
1218
  workspaceProfile
1251
1219
  ? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
1252
1220
  : null,
1253
- 'Runtime control: you have runtime__status, runtime__cancel, runtime__kill, runtime__approve and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. When the user explicitly asks to delete, reset, abandon or replace the current plan, call runtime__kill with purge=true; never set purge=true for a simple stop. For questions about what is running or queued, call runtime__status and answer from its data. When the user consents to a pending approval in any phrasing ("vas-y", "ok pour l\'export"), call runtime__approve. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
1221
+ 'Runtime control: you have runtime__status, runtime__cancel, runtime__kill and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. When the user explicitly asks to delete, reset, abandon or replace the current plan, call runtime__kill with purge=true; never set purge=true for a simple stop. For questions about what is running or queued, call runtime__status and answer from its data. You have no approval tool: a pending approval is granted only by the user through the approval button or the /approve command. Never grant, claim or report an approval yourself; when the user asks to proceed with pending mutations, tell them to use those controls. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
1254
1222
  'When the user asks to refresh, show, or update the displayed plan or status, call runtime__status. This is a state refresh request, not a new business capability, and must never be delegated.',
1255
1223
  'Report every runtime control outcome exactly as the tool returned it \u2014 never embellish. If runtime__kill reports 0 run(s)/0 task(s)/0 purged, say there was nothing active to stop or purge; do NOT claim a run, plan, pending approval or queue item was removed. If runtime__status returns an error or could not be read, say the runtime state could not be retrieved and do not describe a state you never obtained. Never assert that something was cleaned, cancelled, approved or purged unless that specific tool result confirms it.',
1256
1224
  'Durable profile updates are actions in this stabilized version: delegate them instead of writing directly.',
@@ -1285,7 +1253,7 @@ export function formatLlmUnavailableMessage(reason) {
1285
1253
 
1286
1254
  function toolsForClassification(classification, writeTools, session = null) {
1287
1255
  const controlTools = session?.runtime?.url
1288
- ? [RUNTIME_STATUS_TOOL, RUNTIME_CANCEL_TOOL, RUNTIME_KILL_TOOL, RUNTIME_APPROVE_TOOL, RUNTIME_ENQUEUE_TOOL]
1256
+ ? [RUNTIME_STATUS_TOOL, RUNTIME_CANCEL_TOOL, RUNTIME_KILL_TOOL, RUNTIME_ENQUEUE_TOOL]
1289
1257
  : [];
1290
1258
  // Provider discovery and validation belong to the runtime. Hiding
1291
1259
  // delegation while the shell snapshot is temporarily empty forced Donna
@@ -782,67 +782,25 @@ test('runtime status does not manufacture a plan', async () => {
782
782
  }
783
783
  });
784
784
 
785
- test('one Donna approval grants the complete validated run revision', async () => {
786
- const originalFetch = globalThis.fetch;
787
- const requests = [];
788
- globalThis.fetch = async (url, options = {}) => {
789
- requests.push({ url: String(url), method: options.method ?? 'GET', body: options.body ? JSON.parse(options.body) : null });
790
- if ((options.method ?? 'GET') === 'GET') {
791
- return {
792
- ok: true,
793
- status: 200,
794
- json: async () => ({
795
- running: true,
796
- runId: 'run-approval',
797
- planRevision: 3,
798
- approvals: [
799
- { status: 'pending_approval', approvalClasses: ['workspace'] },
800
- { status: 'pending_approval', approvalClasses: ['workspace'] },
801
- ],
802
- }),
803
- };
804
- }
805
- return { ok: true, status: 202, json: async () => ({ approved: true, runId: 'run-approval' }) };
806
- };
807
- let calls = 0;
785
+ test('Donna has no self-approval tool during an active run', async () => {
786
+ // Donna must never grant her own pending approval (the recurring "spontaneous
787
+ // approval" regression). Approval is the user's action, expressed through the
788
+ // banner button or /approve — never through an LLM tool call.
789
+ let seenTools = [];
808
790
  const session = sessionBase({
809
791
  runtime: { url: 'http://runtime.test' },
810
792
  agentProjection: { status: 'running', conversation: [], activities: [] },
811
793
  llm: {
812
- async completeWithTools() {
813
- calls += 1;
814
- if (calls === 1) {
815
- return {
816
- content: null,
817
- message: { role: 'assistant', content: null },
818
- tool_calls: [{
819
- id: 'approve-run',
820
- type: 'function',
821
- function: { name: 'runtime__approve', arguments: '{}' },
822
- }],
823
- };
824
- }
825
- return { content: 'Plan approuvé.', message: { role: 'assistant', content: 'Plan approuvé.' }, tool_calls: null };
794
+ async completeWithTools({ tools }) {
795
+ seenTools = tools.map((tool) => tool.function.name);
796
+ return { content: 'ok', message: { role: 'assistant', content: 'ok' }, tool_calls: null };
826
797
  },
827
798
  },
828
799
  });
829
800
 
830
- try {
831
- const result = await createAgentGraph().invoke({ input: 'oui', session });
832
- assert.equal(result.response, 'Plan approuvé.');
833
- const approval = requests.find((request) => request.url.includes('/approve'));
834
- assert.deepEqual(approval.body, {
835
- workspace: 'docs',
836
- runId: 'run-approval',
837
- itemId: null,
838
- approvalId: null,
839
- scope: 'run',
840
- planRevision: 3,
841
- approvalClasses: ['workspace'],
842
- });
843
- } finally {
844
- globalThis.fetch = originalFetch;
845
- }
801
+ await createAgentGraph().invoke({ input: 'oui', session });
802
+ assert.ok(seenTools.includes('runtime__status'), 'control tools still bound during an active run');
803
+ assert.ok(!seenTools.includes('runtime__approve'), 'no runtime__approve tool for Donna');
846
804
  });
847
805
 
848
806
  test('agent graph binds the full toolset for a "remember my preference" request, not just read-only tools', async () => {
@@ -1773,7 +1731,7 @@ test('agent graph lets Donna handle ambiguous input during a run with the contro
1773
1731
  assert.match(result.response, /mettre en file/);
1774
1732
  assert.ok(seenTools.includes('runtime__enqueue'));
1775
1733
  assert.ok(seenTools.includes('runtime__status'));
1776
- assert.ok(seenTools.includes('runtime__approve'));
1734
+ assert.ok(!seenTools.includes('runtime__approve'), 'no self-approval tool for Donna');
1777
1735
  assert.ok(!seenTools.includes('production__production_start_job'), 'no write MCP tools during an active run for ambiguous intents');
1778
1736
  });
1779
1737
 
@@ -567,6 +567,7 @@ async function waitForRuntimeRun(session, log, { timeoutMs, pollMs = 1500, autoA
567
567
  scope: 'run',
568
568
  planRevision,
569
569
  approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
570
+ caller: 'headless-auto-approve',
570
571
  });
571
572
  const line = `runtime-wait: auto-approved run ${currentRun.id} (revision ${planRevision})${result?.approved ? '' : ' [no pending approval matched]'}`;
572
573
  log.push(line); console.log(line);
@@ -845,6 +846,7 @@ export async function waitForRuntimeChain(session, log, {
845
846
  scope: 'run',
846
847
  planRevision,
847
848
  approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
849
+ caller: 'chain-wait-auto-approve',
848
850
  });
849
851
  const line = `chain-wait: auto-approved run ${active.runId} (revision ${planRevision})`;
850
852
  log.push(line); console.log(line);
@@ -978,11 +980,12 @@ async function runRuntime(argv, agent) {
978
980
  payload: { message: `runtime: expired ${staleControl.length} stale queued control request(s) from a previous session` },
979
981
  }));
980
982
  }
981
- session._onRuntimeError = (err) => {
983
+ session._onRuntimeError = (err, runId = null) => {
982
984
  const message = err instanceof Error ? err.message : String(err);
983
985
  dispatchAgentEvent(session, createAgentEvent('run_error', {
984
986
  origin: 'runtime',
985
- payload: { message, workspace },
987
+ ...(runId ? { runId } : {}),
988
+ payload: { message, workspace, ...(runId ? { runId } : {}) },
986
989
  }));
987
990
  };
988
991
  context.approvalManager = createApprovalManager(session, {
@@ -991,7 +994,9 @@ async function runRuntime(argv, agent) {
991
994
  : undefined,
992
995
  });
993
996
  session._requestApproval = (request) => context.approvalManager.requestApproval(request);
994
- context.supervisor = startActivitySupervisor(session);
997
+ context.supervisor = startActivitySupervisor(session, {
998
+ refreshMcp: () => refreshMcpRuntimeStatus(session),
999
+ });
995
1000
  contexts.set(key, context);
996
1001
  if (workspace && workspace !== key) contexts.set(workspace, context);
997
1002
  return context;
@@ -1422,7 +1427,26 @@ async function runRuntime(argv, agent) {
1422
1427
  const provider = agents.find((item) => (item.description?.capabilities ?? [])
1423
1428
  .some((capability) => capability.id === body.capabilityPlan.capability));
1424
1429
  if (!provider?.serverName) {
1425
- throw new Error(`No agent provides capability ${body.capabilityPlan.capability}.`);
1430
+ /*
1431
+ Say what WAS seen, not only what was missing.
1432
+
1433
+ "No agent provides capability X." leaves three very different causes
1434
+ indistinguishable: no agent registered at all, the right agent
1435
+ registered but advertising a narrower set (an operation excluded by
1436
+ PRODUCTION_ALLOWED_STEPS drops its whole capability from
1437
+ agent_describe, silently), or a name mismatch. Listing the registry
1438
+ turns the next occurrence into its own diagnosis instead of a guess.
1439
+ */
1440
+ const seen = agents.map((item) => {
1441
+ const ids = (item.description?.capabilities ?? []).map((capability) => capability.id);
1442
+ return `${item.serverName ?? '?'}[${ids.join(', ') || 'none'}]`;
1443
+ });
1444
+ throw new Error(
1445
+ `No agent provides capability ${body.capabilityPlan.capability}. `
1446
+ + (seen.length
1447
+ ? `Registered agents: ${seen.join('; ')}.`
1448
+ : 'No agent is registered: none answered agent_describe.'),
1449
+ );
1426
1450
  }
1427
1451
  const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
1428
1452
  capability: body.capabilityPlan.capability,
@@ -1402,7 +1402,7 @@ export async function handleSlashCommand(line, context) {
1402
1402
  },
1403
1403
  });
1404
1404
  if (result?.runId) {
1405
- return { output: `▶ Run de capability accepté (${String(result.runId).slice(0, 8)}) — le plan de l'agent sera intégré et dispatché en parallèle ; approbation demandée avant les mutations (« valide tout » ou /approve).` };
1405
+ return { output: `▶ Run de capability accepté (${String(result.runId).slice(0, 8)}) — le plan de l'agent sera intégré et dispatché en parallèle ; approbation demandée avant les mutations (/approve).` };
1406
1406
  }
1407
1407
  return { output: `Run non démarré: ${result?.explanation ?? result?.error ?? JSON.stringify(result)}` };
1408
1408
  }
@@ -561,7 +561,18 @@ function applyEvent(state, event) {
561
561
  return;
562
562
  case 'run_error':
563
563
  state.status = 'error';
564
- state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
564
+ /*
565
+ "Run failed:" is part of the line, not decoration.
566
+
567
+ The serve journal keeps only the entries matching a keyword list
568
+ (failed, error, done, approval…). A run killed by a message that uses
569
+ none of those words — "No agent provides capability workspace.restore."
570
+ — was therefore filtered out as unimportant, and the panel showed "No
571
+ essential run event yet" over a run that had just died with its reason
572
+ already in hand. What ends a run is essential by construction; the
573
+ prefix states that instead of hoping the wording says so.
574
+ */
575
+ state.logs.push(`Run failed: ${String(event.payload?.message ?? 'Agent run failed.')}`);
565
576
  // A dead run must not leave "pending" plan steps and spinning
566
577
  // activities in the persisted projection: they reappeared as ghosts
567
578
  // at every relaunch ("des trucs dans le plan qui n'existent pas") and
@@ -675,3 +675,24 @@ test('a stream resumed after a discard carries only the final text', () => {
675
675
  assert.equal(projection.conversation[1].content, '12 pages.');
676
676
  assert.equal(projection.conversation[1].streaming, undefined, 'le message doit être figé');
677
677
  });
678
+
679
+ test('run_error names the failure so the essential journal cannot filter it out', () => {
680
+ // La liste de mots-clés du journal serve (failed, error, done, approval…)
681
+ // rejetait un message qui n'en contient aucun — « No agent provides
682
+ // capability workspace.restore. » — et le panneau affichait « No essential
683
+ // run event yet » au-dessus d'un run mort avec sa raison déjà connue.
684
+ const state = reduceAgentEvents([
685
+ createAgentEvent('run_error', {
686
+ origin: 'runtime',
687
+ runId: 'run-1',
688
+ payload: { message: 'No agent provides capability workspace.restore.' },
689
+ }),
690
+ ]);
691
+
692
+ assert.equal(state.status, 'error');
693
+ const line = state.logs.at(-1);
694
+ assert.match(line, /^Run failed: /);
695
+ assert.match(line, /workspace\.restore/);
696
+ // Le mot qui rend l'entrée « essentielle » pour le journal serve.
697
+ assert.match(line, /failed/i);
698
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.49",
3
- "commit": "a0da71c"
2
+ "version": "0.15.50",
3
+ "commit": "844cf7e"
4
4
  }
@@ -27,6 +27,24 @@ test('workspace production agent enables restore by default', async () => {
27
27
  assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
28
28
  });
29
29
 
30
+ test('every shipped default allows the taxonomy step', async () => {
31
+ /*
32
+ L'absence de `taxonomy` est SILENCIEUSE.
33
+
34
+ `agent_plan` retire la tâche taxonomique du fragment d'ingestion quand
35
+ l'étape n'est pas autorisée (`"taxonomy" in _ALLOWED_STEPS`), sans erreur ni
36
+ avertissement. Un déploiement par compose ingérait donc sans jamais publier
37
+ de taxonomie, et la carte restait périmée — exactement le défaut que le Lot 4
38
+ avait corrigé côté moteur. Le défaut de Python porte `taxonomy` ; un compose
39
+ POSITIONNE toujours la variable, donc ce défaut ne s'applique jamais là.
40
+ */
41
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
42
+ const compose = YAML.parse(raw);
43
+ const allowed = compose.services['production-mcp'].environment
44
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
45
+ assert.match(String(allowed), /(?:^|,)taxonomy(?:,|})/);
46
+ });
47
+
30
48
  test('shipped compose files never carry a build context', async () => {
31
49
  // Ces deux fichiers partent dans le paquet npm, où les dépôts frères
32
50
  // (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
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.49';
4
+ const WIKI_MANAGER_VERSION = '0.15.50';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -0,0 +1,316 @@
1
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
+ import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
3
+ import { normalizeRuntimeLog } from '../core/runtimeLog.js';
4
+ import { assertContract } from '../contracts/schemas.js';
5
+
6
+ const AVAILABLE = 'available';
7
+ const UNAVAILABLE = 'unavailable';
8
+ /**
9
+ * Santé d'un agent restauré depuis le journal, tant qu'aucun `agent_describe`
10
+ * n'a réussi dans le processus courant.
11
+ *
12
+ * Ni `available` ni `unavailable` : on ne SAIT pas. Le distinguer de
13
+ * `unavailable` a une conséquence pratique — un agent inconnu redevient
14
+ * disponible en silence dès le premier scan réussi, là où un agent déclaré
15
+ * indisponible mériterait d'être signalé comme tel à l'utilisateur.
16
+ */
17
+ const UNKNOWN = 'unknown';
18
+
19
+ /**
20
+ * Un agent persisté n'est pas un agent joignable.
21
+ *
22
+ * Au redémarrage, `hydrateSession` rejoue le journal d'événements et
23
+ * reconstruit les agents avec la santé qu'ils avaient AU MOMENT où l'événement
24
+ * a été écrit. `cme-main` réapparaissait donc `available` alors que son
25
+ * endpoint n'existe plus, était retenu comme fournisseur, et la tâche partait
26
+ * vers un agent absent — la panne observée le 2026-08-04.
27
+ *
28
+ * La persistance dit ce qui a existé, pas ce qui répond maintenant. Seul un
29
+ * `agent_describe` réussi dans ce processus autorise à parler de disponibilité.
30
+ */
31
+ export function markPersistedAgentsStale(session) {
32
+ if (!session || typeof session !== 'object') return [];
33
+ const stale = (agent) => ({
34
+ ...agent,
35
+ health: UNKNOWN,
36
+ stale: true,
37
+ // La santé d'origine est conservée : elle raconte ce qu'on savait avant
38
+ // l'arrêt, ce qui aide à lire un journal, sans jamais servir au routage.
39
+ healthBeforeRestart: agent?.health ?? null,
40
+ });
41
+ /*
42
+ TOUTES les représentations restaurées, `agentRegistrySnapshot` compris.
43
+
44
+ J'avais d'abord épargné le snapshot, au motif qu'il pouvait porter un scan
45
+ vivant. C'était une inversion : à l'hydratation, aucun scan n'a encore eu
46
+ lieu — l'ordre est hydrate → invalidate → discover, et rien ne s'exécute
47
+ entre les deux premiers. Le snapshot vient donc de la même projection
48
+ persistée que `session.agents`. L'épargner laissait `cme-main` routable avec
49
+ son endpoint éteint, ce que la validation à chaud a montré.
50
+
51
+ Le seul scan qui compte est celui qui suivra : `discover()` réécrit le
52
+ snapshot en entier à partir des `agent_describe` réussis.
53
+ */
54
+ session.agents = (session.agents ?? []).map(stale);
55
+ session.agentRegistrySnapshot = (session.agentRegistrySnapshot ?? []).map(stale);
56
+ return session.agentRegistrySnapshot;
57
+ }
58
+
59
+ export function createAgentRegistry({
60
+ callTool = callMcpTool,
61
+ now = () => new Date(),
62
+ } = {}) {
63
+ const agentsByInstance = new Map();
64
+ const instanceByServer = new Map();
65
+
66
+ return {
67
+ async discover(session, { signal = null } = {}) {
68
+ const discovered = [];
69
+ const endpoints = Object.entries(session?.mcp ?? {});
70
+ const activeServers = new Set(endpoints.map(([serverName]) => serverName));
71
+ for (const [serverName, endpoint] of endpoints) {
72
+ const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
73
+ discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
74
+ }
75
+ for (const [serverName, instanceId] of instanceByServer) {
76
+ if (activeServers.has(serverName)) continue;
77
+ const previous = agentsByInstance.get(instanceId);
78
+ instanceByServer.delete(serverName);
79
+ agentsByInstance.delete(instanceId);
80
+ if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
81
+ agentInstanceId: instanceId,
82
+ serverName,
83
+ });
84
+ }
85
+ session.agentRegistry = this;
86
+ session.agentRegistrySnapshot = this.snapshot();
87
+ return discovered;
88
+ },
89
+ snapshot() {
90
+ return [...agentsByInstance.values()]
91
+ .map((agent) => cloneAgent(agent))
92
+ .sort((a, b) => a.agentInstanceId.localeCompare(b.agentInstanceId));
93
+ },
94
+ get(agentInstanceId) {
95
+ const agent = agentsByInstance.get(String(agentInstanceId));
96
+ return agent ? cloneAgent(agent) : null;
97
+ },
98
+ };
99
+ }
100
+
101
+ async function discoverServerAgent(session, serverName, endpoint = {}, { callTool, signal, now }) {
102
+ const lastSeenAt = now().toISOString();
103
+ if (endpoint.status !== 'connected') {
104
+ return legacyAgent(serverName, endpoint, { health: UNAVAILABLE, lastSeenAt });
105
+ }
106
+
107
+ const tool = findAgentDescribeTool(serverName, endpoint.tools ?? []);
108
+ if (!tool) {
109
+ return legacyAgent(serverName, endpoint, { health: AVAILABLE, lastSeenAt });
110
+ }
111
+ const toolName = tool.name;
112
+
113
+ try {
114
+ const result = await callTool(
115
+ session.mcp,
116
+ serverName,
117
+ toolName,
118
+ describeArguments(tool, session?.workspace),
119
+ signal,
120
+ );
121
+ const description = assertContract('agentDescription', parseToolJsonResult(result));
122
+ return {
123
+ serverName,
124
+ toolName,
125
+ agentInstanceId: description.agentInstanceId,
126
+ description,
127
+ health: description.health?.status ?? AVAILABLE,
128
+ firstSeenAt: lastSeenAt,
129
+ lastSeenAt,
130
+ legacy: false,
131
+ orchestrable: true,
132
+ };
133
+ } catch (error) {
134
+ return legacyAgent(serverName, endpoint, {
135
+ health: UNAVAILABLE,
136
+ lastSeenAt,
137
+ toolName,
138
+ error: error instanceof Error ? error.message : String(error),
139
+ });
140
+ }
141
+ }
142
+
143
+ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
144
+ const previousInstanceId = instanceByServer.get(agent.serverName);
145
+ const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
146
+
147
+ /*
148
+ A failed discovery must not erase a known orchestrator agent.
149
+
150
+ When the endpoint is transiently unreachable — the runtime boots before its
151
+ containers (the normal boot order), or a single probe times out —
152
+ `discoverServerAgent` falls back to a legacy agent with no capabilities. The
153
+ old code replaced the orchestrator agent with that fallback, so every
154
+ capability silently vanished from the registry and did not come back until a
155
+ LATER successful discovery. Keep the orchestrator agent and only refresh its
156
+ probe timestamp: its capabilities are still real, only the endpoint is down.
157
+ */
158
+ if (agent.legacy && previous && !previous.legacy) {
159
+ agentsByInstance.set(previous.agentInstanceId, { ...previous, lastSeenAt: agent.lastSeenAt });
160
+ /*
161
+ Preserving is right; preserving in silence is what caused the hunt.
162
+
163
+ Every defect this registry produced was invisible: capabilities vanished
164
+ without an event, and the resolver could only report the consequence ("no
165
+ agent provides X") long afterwards. Keeping the agent fixes the loss, not
166
+ the blindness — a probe that failed is a fact worth stating, once, where
167
+ the panels and the shell already read.
168
+
169
+ Deliberately NOT a health change: the endpoint is down but the agent stays
170
+ usable by design here, and moving `health` would make `capabilityResolver`
171
+ refuse it — trading a silent loss for a silent refusal.
172
+ */
173
+ dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
174
+ + `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
175
+ + ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
176
+ return cloneAgent(previous);
177
+ }
178
+
179
+ const firstSeenAt = previous?.firstSeenAt ?? agent.firstSeenAt ?? agent.lastSeenAt;
180
+ const next = {
181
+ ...agent,
182
+ firstSeenAt,
183
+ };
184
+
185
+ if (previous && previous.agentInstanceId !== next.agentInstanceId) {
186
+ agentsByInstance.delete(previous.agentInstanceId);
187
+ }
188
+ agentsByInstance.set(next.agentInstanceId, next);
189
+ instanceByServer.set(next.serverName, next.agentInstanceId);
190
+
191
+ if (!previous || previous.agentInstanceId !== next.agentInstanceId) {
192
+ dispatchRegistryEvent(session, 'agent.registered', { agent: next });
193
+ } else if (previous.health !== next.health) {
194
+ dispatchRegistryEvent(session, 'agent.health_changed', {
195
+ agent: next,
196
+ agentInstanceId: next.agentInstanceId,
197
+ previousHealth: previous.health,
198
+ health: next.health,
199
+ });
200
+ }
201
+ return cloneAgent(next);
202
+ }
203
+
204
+ /**
205
+ * Runtime log line, emitted without importing the supervisor.
206
+ *
207
+ * `emitRuntimeLog` lives in `runtime/supervisor.js`, which already imports THIS
208
+ * module: importing it back would close a cycle for one log line. The event
209
+ * shape is the contract, not the helper, so we build it from the same
210
+ * normalizer the supervisor uses.
211
+ */
212
+ function dispatchRuntimeLog(session, message) {
213
+ if (!session) return;
214
+ const payload = normalizeRuntimeLog(message, { session });
215
+ dispatchAgentEvent(session, createAgentEvent('runtime_log', {
216
+ origin: 'runtime',
217
+ runId: payload.runId ?? null,
218
+ taskId: payload.taskId ?? null,
219
+ workspace: payload.workspaceId ?? null,
220
+ payload,
221
+ }));
222
+ }
223
+
224
+ function dispatchRegistryEvent(session, type, payload) {
225
+ if (!session) return;
226
+ dispatchAgentEvent(session, createAgentEvent(type, {
227
+ origin: 'agent_registry',
228
+ workspace: session.workspace ?? null,
229
+ payload,
230
+ }));
231
+ }
232
+
233
+ function findAgentDescribeTool(serverName, tools) {
234
+ const named = tools.filter((tool) => String(tool?.name ?? ''));
235
+ const byName = (predicate) => named.find((tool) => predicate(String(tool.name)));
236
+ return byName((name) => name === 'agent_describe')
237
+ ?? byName((name) => name === `${serverName}__agent_describe`)
238
+ ?? byName((name) => name.endsWith('__agent_describe'))
239
+ ?? null;
240
+ }
241
+
242
+ // Parts of a contract are workspace-scoped — typically the closed vocabulary of
243
+ // an argument (the sources declared in THIS workspace). Published as a bare
244
+ // string, such a field is unverifiable and a planner fills it with any noun
245
+ // from the objective, so it is worth telling the agent which workspace we are
246
+ // asking about.
247
+ //
248
+ // But the orchestrator must not assume an agent accepts an argument it never
249
+ // declared: an agent whose agent_describe schema is `additionalProperties:
250
+ // false` REJECTS the call, drops out of the registry, and its capabilities
251
+ // silently vanish — the objective then resolves to whatever agent is left.
252
+ // Send the workspace only to agents whose own schema says they can take it.
253
+ function describeArguments(tool, workspace) {
254
+ if (!workspace) return {};
255
+ const schema = tool?.inputSchema;
256
+ if (!schema || typeof schema !== 'object') return {};
257
+ const declaresWorkspace = Object.hasOwn(schema.properties ?? {}, 'workspace');
258
+ const acceptsExtra = schema.additionalProperties !== false;
259
+ return declaresWorkspace || acceptsExtra ? { workspace: String(workspace) } : {};
260
+ }
261
+
262
+ function parseToolJsonResult(result) {
263
+ if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) {
264
+ return result;
265
+ }
266
+ const text = formatMcpToolResult(result);
267
+ return JSON.parse(text);
268
+ }
269
+
270
+ function legacyAgent(serverName, endpoint = {}, { health, lastSeenAt, toolName = null, error = null }) {
271
+ const displayName = endpoint.displayName ?? serverName;
272
+ const description = {
273
+ contractVersion: 'legacy',
274
+ agentType: serverName,
275
+ agentInstanceId: `${serverName}-legacy`,
276
+ displayName,
277
+ capabilities: [],
278
+ orchestration: {
279
+ canPlan: false,
280
+ canExpandPlan: false,
281
+ canExecute: false,
282
+ canCancel: false,
283
+ canResume: false,
284
+ supportsIdempotency: false,
285
+ supportsParallelWorkers: false,
286
+ },
287
+ limits: {
288
+ recommendedConcurrency: 0,
289
+ maxConcurrency: 0,
290
+ },
291
+ health: { status: health },
292
+ };
293
+ return {
294
+ serverName,
295
+ toolName,
296
+ agentInstanceId: description.agentInstanceId,
297
+ description,
298
+ health,
299
+ firstSeenAt: lastSeenAt,
300
+ lastSeenAt,
301
+ legacy: true,
302
+ orchestrable: false,
303
+ error,
304
+ };
305
+ }
306
+
307
+ function cloneAgent(agent) {
308
+ return {
309
+ ...agent,
310
+ description: cloneJson(agent.description),
311
+ };
312
+ }
313
+
314
+ function cloneJson(value) {
315
+ return value == null ? value : JSON.parse(JSON.stringify(value));
316
+ }
@@ -1,5 +1,6 @@
1
1
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
2
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
3
+ import { normalizeRuntimeLog } from '../core/runtimeLog.js';
3
4
  import { assertContract } from '../contracts/schemas.js';
4
5
 
5
6
  const AVAILABLE = 'available';
@@ -142,6 +143,39 @@ async function discoverServerAgent(session, serverName, endpoint = {}, { callToo
142
143
  function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
143
144
  const previousInstanceId = instanceByServer.get(agent.serverName);
144
145
  const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
146
+
147
+ /*
148
+ A failed discovery must not erase a known orchestrator agent.
149
+
150
+ When the endpoint is transiently unreachable — the runtime boots before its
151
+ containers (the normal boot order), or a single probe times out —
152
+ `discoverServerAgent` falls back to a legacy agent with no capabilities. The
153
+ old code replaced the orchestrator agent with that fallback, so every
154
+ capability silently vanished from the registry and did not come back until a
155
+ LATER successful discovery. Keep the orchestrator agent and only refresh its
156
+ probe timestamp: its capabilities are still real, only the endpoint is down.
157
+ */
158
+ if (agent.legacy && previous && !previous.legacy) {
159
+ agentsByInstance.set(previous.agentInstanceId, { ...previous, lastSeenAt: agent.lastSeenAt });
160
+ /*
161
+ Preserving is right; preserving in silence is what caused the hunt.
162
+
163
+ Every defect this registry produced was invisible: capabilities vanished
164
+ without an event, and the resolver could only report the consequence ("no
165
+ agent provides X") long afterwards. Keeping the agent fixes the loss, not
166
+ the blindness — a probe that failed is a fact worth stating, once, where
167
+ the panels and the shell already read.
168
+
169
+ Deliberately NOT a health change: the endpoint is down but the agent stays
170
+ usable by design here, and moving `health` would make `capabilityResolver`
171
+ refuse it — trading a silent loss for a silent refusal.
172
+ */
173
+ dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
174
+ + `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
175
+ + ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
176
+ return cloneAgent(previous);
177
+ }
178
+
145
179
  const firstSeenAt = previous?.firstSeenAt ?? agent.firstSeenAt ?? agent.lastSeenAt;
146
180
  const next = {
147
181
  ...agent,
@@ -167,6 +201,26 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
167
201
  return cloneAgent(next);
168
202
  }
169
203
 
204
+ /**
205
+ * Runtime log line, emitted without importing the supervisor.
206
+ *
207
+ * `emitRuntimeLog` lives in `runtime/supervisor.js`, which already imports THIS
208
+ * module: importing it back would close a cycle for one log line. The event
209
+ * shape is the contract, not the helper, so we build it from the same
210
+ * normalizer the supervisor uses.
211
+ */
212
+ function dispatchRuntimeLog(session, message) {
213
+ if (!session) return;
214
+ const payload = normalizeRuntimeLog(message, { session });
215
+ dispatchAgentEvent(session, createAgentEvent('runtime_log', {
216
+ origin: 'runtime',
217
+ runId: payload.runId ?? null,
218
+ taskId: payload.taskId ?? null,
219
+ workspace: payload.workspaceId ?? null,
220
+ payload,
221
+ }));
222
+ }
223
+
170
224
  function dispatchRegistryEvent(session, type, payload) {
171
225
  if (!session) return;
172
226
  dispatchAgentEvent(session, createAgentEvent(type, {
@@ -134,6 +134,81 @@ test('agentRegistry marks unavailable boot agents and emits health changes on re
134
134
  assert.equal(registry.snapshot()[0].health, 'available');
135
135
  });
136
136
 
137
+ test('a failed re-discovery keeps the orchestrator agent, never erases its capabilities', async () => {
138
+ /*
139
+ Boot order: the runtime starts before its containers. The first scan sees the
140
+ production endpoint "unavailable" and would register a legacy placeholder —
141
+ and the old code replaced the orchestrator agent with it, silently dropping
142
+ every capability until a LATER successful discovery. The agent must survive a
143
+ transient probe failure, because its capabilities did not change.
144
+ */
145
+ const events = [];
146
+ const session = {
147
+ workspace: 'acpi',
148
+ mcp: {
149
+ production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
150
+ },
151
+ _onAgentEvent: (event) => events.push(event),
152
+ };
153
+ const registry = createAgentRegistry({
154
+ callTool: async () => ({ content: [{ type: 'text', text: JSON.stringify(description()) }] }),
155
+ });
156
+
157
+ await registry.discover(session);
158
+ assert.equal(registry.snapshot()[0].agentInstanceId, 'production-main');
159
+
160
+ // The endpoint goes down: discovery now falls back to a legacy placeholder.
161
+ session.mcp.production.status = 'unavailable';
162
+ await registry.discover(session);
163
+
164
+ const [agent] = registry.snapshot();
165
+ assert.equal(agent.agentInstanceId, 'production-main');
166
+ assert.equal(agent.legacy, false);
167
+ assert.equal(agent.description.capabilities.length, 1);
168
+ // And it is still routable: the capability was not erased.
169
+ const capability = createCapabilityRegistry({ agents: registry.snapshot() });
170
+ assert.equal(capability.providersFor('knowledge.update').length, 1);
171
+
172
+ /*
173
+ Preserving must not be silent.
174
+
175
+ Every defect this registry produced was invisible, and that is what turned a
176
+ boot-order race into a debugging session: the capability vanished with no
177
+ event, and the only report came much later, from the resolver, as "no agent
178
+ provides X". A probe that failed is a fact, and it belongs where the shell
179
+ and the panels already read.
180
+ */
181
+ const kept = events.find((event) => event.type === 'runtime_log'
182
+ && String(event.payload?.message ?? '').includes('agent-registry:'));
183
+ assert.ok(kept, 'a preserved agent must leave a runtime log');
184
+ assert.match(String(kept.payload.message), /did not answer agent_describe/);
185
+ assert.match(String(kept.payload.message), /knowledge\.update/);
186
+ });
187
+
188
+ test('a failed re-discovery keeps a degraded orchestrator agent too', async () => {
189
+ // Same protection, but when the agent was discovered healthy then the probe
190
+ // throws (endpoint "connected" but agent_describe fails mid-flight).
191
+ const session = {
192
+ mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
193
+ };
194
+ let fail = false;
195
+ const registry = createAgentRegistry({
196
+ callTool: async () => {
197
+ if (fail) throw new Error('agent_describe timeout');
198
+ return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
199
+ },
200
+ });
201
+
202
+ await registry.discover(session);
203
+ fail = true;
204
+ await registry.discover(session);
205
+
206
+ const [agent] = registry.snapshot();
207
+ assert.equal(agent.agentInstanceId, 'production-main');
208
+ assert.equal(agent.legacy, false);
209
+ assert.equal(agent.description.capabilities.length, 1);
210
+ });
211
+
137
212
  test('discovery sends the workspace only to agents whose schema declares it', async () => {
138
213
  const seen = {};
139
214
  const registry = createAgentRegistry({
@@ -261,6 +261,7 @@ export async function postRuntimeApprove({
261
261
  scope = null,
262
262
  planRevision = null,
263
263
  approvalClasses = null,
264
+ caller = null,
264
265
  } = {}) {
265
266
  const endpoint = runtimeEndpoint(url, '/approve', workspace);
266
267
  const parsed = new URL(endpoint);
@@ -278,6 +279,7 @@ export async function postRuntimeApprove({
278
279
  scope,
279
280
  planRevision,
280
281
  approvalClasses,
282
+ caller,
281
283
  }),
282
284
  });
283
285
  if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
@@ -94,7 +94,7 @@ export function integratePreparedDelegation({
94
94
  session,
95
95
  approval.approved
96
96
  ? `approval: run ${runId} auto-approved (autoApprove opt-in)`
97
- : `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`,
97
+ : `approval: run ${runId} awaiting explicit approval before mutations (/approve)`,
98
98
  );
99
99
  return { integrated, approval };
100
100
  }
@@ -407,8 +407,8 @@ export async function runRuntimeParallelPlan(agent, session, input, {
407
407
  sanitizeSessionPlanForExecution(session, runId);
408
408
  ensurePlanProjection(session, runId);
409
409
  emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit}; agent=${concurrencyDetail.agentLimit ?? 'n/a'}, ceiling=${concurrencyDetail.ceiling ?? 'none'}${concurrencyDetail.cappedByCeiling ? ' → capped by manager ceiling' : ''})`);
410
- // Interactive approvals do NOT expire: the user has /approve, "valide
411
- // tout", /cancel and /run kill — an arbitrary timer only created mystery
410
+ // Interactive approvals do NOT expire: the user has /approve, /cancel and
411
+ // /run kill — an arbitrary timer only created mystery
412
412
  // failures. A deadline exists only when explicitly configured (headless
413
413
  // runs, CI) via the session or the env escape hatch.
414
414
  const configuredApprovalWait = Number(session._approvalTimeoutMs) > 0
@@ -572,7 +572,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
572
572
  `⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
573
573
  ...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
574
574
  newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
575
- 'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
575
+ 'Tape /approve (ou clique sur « Approuver ») pour lancer, « annule » pour abandonner.',
576
576
  ].filter(Boolean).join('\n'),
577
577
  },
578
578
  }));
@@ -642,6 +642,7 @@ export function startRuntimeServer({
642
642
  groupId: url.searchParams.get('groupId') ?? body.groupId ?? null,
643
643
  planRevision: readOptionalNumber(url.searchParams.get('planRevision') ?? body.planRevision),
644
644
  approvalClasses: readOptionalList(body.approvalClasses ?? body.approvalClass ?? url.searchParams.get('approvalClass')),
645
+ caller: body.caller ?? url.searchParams.get('caller') ?? null,
645
646
  });
646
647
  sendJson(response, result?.approved ? 202 : 404, result ?? { approved: false });
647
648
  return;
@@ -805,7 +806,10 @@ export function startRuntimeServer({
805
806
  runPromise
806
807
  .catch((err) => {
807
808
  rejectReady?.(err);
808
- context.session?._onRuntimeError?.(err);
809
+ // The runId travels with the failure: without it `finishControlByRun`
810
+ // had nothing to match, so a queued restore stayed "pending" forever
811
+ // while the run that carried it was already dead.
812
+ context.session?._onRuntimeError?.(err, runId);
809
813
  })
810
814
  .finally(() => {
811
815
  context.running = false;
@@ -1371,9 +1375,6 @@ function classifyControlMessage(input, status, forcedIntent = null) {
1371
1375
  if (explicit) {
1372
1376
  return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
1373
1377
  }
1374
- if (/\b(valide tout|approve all|approve|approuve|valid[eé]|ok pour tout|go pour tout)\b/i.test(lower)) {
1375
- return { kind: 'approve', confidence: 0.86, reason: 'approval_request' };
1376
- }
1377
1378
  if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
1378
1379
  return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
1379
1380
  }
@@ -1082,6 +1082,7 @@ test('runtime server exposes approval endpoint', async (t) => {
1082
1082
  groupId: null,
1083
1083
  planRevision: null,
1084
1084
  approvalClasses: [],
1085
+ caller: null,
1085
1086
  });
1086
1087
  assert.deepEqual(await response.json(), { approved: true, runId: 'run-1', itemId: 'item-1' });
1087
1088
  } finally {
@@ -1307,11 +1308,21 @@ test('runtime server control message handles approve and cancel intents during a
1307
1308
  const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
1308
1309
  method: 'POST',
1309
1310
  headers: { 'Content-Type': 'application/json' },
1310
- body: JSON.stringify({ action: 'message', input: 'valide tout' }),
1311
+ body: JSON.stringify({ action: 'message', input: 'valide tout', intent: 'approve' }),
1311
1312
  });
1312
1313
  assert.equal(approveResponse.status, 200);
1313
1314
  assert.equal((await approveResponse.json()).kind, 'approve');
1314
1315
 
1316
+ // Free-text approval phrasing is NOT honored anymore: approval is an
1317
+ // explicit /approve (or the approval button), never a keyword match.
1318
+ const freeTextResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
1319
+ method: 'POST',
1320
+ headers: { 'Content-Type': 'application/json' },
1321
+ body: JSON.stringify({ action: 'message', input: 'valide tout' }),
1322
+ });
1323
+ assert.equal(freeTextResponse.status, 200);
1324
+ assert.notEqual((await freeTextResponse.json()).kind, 'approve');
1325
+
1315
1326
  const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
1316
1327
  method: 'POST',
1317
1328
  headers: { 'Content-Type': 'application/json' },
@@ -13,6 +13,7 @@ export function startActivitySupervisor(session, {
13
13
  agentRegistryIntervalMs = registryIntervalFromEnv(),
14
14
  agentRegistry = null,
15
15
  callTool = callMcpTool,
16
+ refreshMcp = null,
16
17
  } = {}) {
17
18
  const pollBusy = new Set();
18
19
  let stopped = false;
@@ -32,8 +33,24 @@ export function startActivitySupervisor(session, {
32
33
  }
33
34
  }, queueIntervalMs);
34
35
 
36
+ /*
37
+ The re-scan must probe the endpoints again, not trust a stale status.
38
+
39
+ The first discovery ran while the containers were still coming up, so the
40
+ production endpoint was marked "not connected" and its agent fell back to a
41
+ legacy placeholder. Re-scanning against that cached status kept the fallback
42
+ forever. `refreshMcp` re-resolves the endpoint states (and their tools)
43
+ before each discovery, so a container that came up is actually discovered.
44
+ */
35
45
  const agentRegistryTimer = agentRegistryIntervalMs > 0
36
- ? setInterval(() => {
46
+ ? setInterval(async () => {
47
+ if (refreshMcp) {
48
+ try {
49
+ await refreshMcp(session);
50
+ } catch {
51
+ /* the discovery below still runs on the cached status */
52
+ }
53
+ }
37
54
  void discoverAgentsOnce(session, { registry, signal: runSignal });
38
55
  }, agentRegistryIntervalMs)
39
56
  : null;
@@ -246,6 +246,49 @@ test('startActivitySupervisor periodically re-scans the agent registry', async (
246
246
  }
247
247
  });
248
248
 
249
+ test('startActivitySupervisor refreshes MCP before each periodic re-scan', async () => {
250
+ /*
251
+ Without the refresh, the re-scan re-reads a cached "not connected" endpoint
252
+ status and keeps a legacy placeholder forever, so an agent whose container
253
+ came up after boot is never discovered. The refresh probes the endpoints
254
+ again before the scan.
255
+ */
256
+ const session = {
257
+ mcp: {},
258
+ activities: {},
259
+ headlessPlan: null,
260
+ jobQueue: [],
261
+ };
262
+ let refreshes = 0;
263
+ let discoveries = 0;
264
+ const registry = {
265
+ async discover() {
266
+ discoveries += 1;
267
+ return [];
268
+ },
269
+ snapshot() {
270
+ return [];
271
+ },
272
+ };
273
+
274
+ const supervisor = startActivitySupervisor(session, {
275
+ intervalMs: 1000,
276
+ queueIntervalMs: 1000,
277
+ agentRegistryIntervalMs: 10,
278
+ agentRegistry: registry,
279
+ refreshMcp: async () => {
280
+ refreshes += 1;
281
+ },
282
+ });
283
+
284
+ try {
285
+ await waitFor(() => refreshes >= 2);
286
+ assert.ok(discoveries >= 2);
287
+ } finally {
288
+ supervisor.stop();
289
+ }
290
+ });
291
+
249
292
  test('discoverAgentsOnce uses the session registry and returns discovered agents', async () => {
250
293
  const expected = [{ agentInstanceId: 'a' }];
251
294
  const registry = {
@@ -822,14 +822,17 @@ export function LeftPane(props: {
822
822
  const showWelcome = () => props.showWelcome && props.messages.length === 0;
823
823
  return (
824
824
  <box width={props.width} height="100%" flexDirection="column" padding={1} overflow="hidden">
825
- <box height={3} flexDirection="column">
825
+ {/*
826
+ The header keeps what changes the MEANING of what is typed — the mode,
827
+ and the transient hint. Workspace, version and connection states moved
828
+ to the foot of the pane: they are an ambient state one glances at, not
829
+ something read before every message, and at the top they pushed the
830
+ conversation down by a line for standing information.
831
+ */}
832
+ <box height={2} flexDirection="column">
826
833
  <box height={1} flexDirection="row" backgroundColor={modeColor()} paddingX={1}>
827
834
  <text fg="#0B1020">{modeLabel()}</text>
828
835
  </box>
829
- <box height={1} flexDirection="row">
830
- <text fg="#D6DEE8">{props.title}</text>
831
- <text fg="#7F8C8D"> {props.statusLine}</text>
832
- </box>
833
836
  <box height={1} flexDirection="row">
834
837
  {props.hintLine ? <text fg="#FBBF24">[ {props.hintLine} ]</text> : null}
835
838
  </box>
@@ -860,6 +863,15 @@ export function LeftPane(props: {
860
863
  onSubmit={props.submit}
861
864
  onHeightChange={props.onInputHeightChange}
862
865
  />
866
+ {/*
867
+ Status foot. One line, as in the header before it, so the row budget of
868
+ `conversationRows` (height - 5 - input) is unchanged: the line moved,
869
+ it was not added.
870
+ */}
871
+ <box height={1} flexDirection="row">
872
+ <text fg="#D6DEE8">{props.title}</text>
873
+ <text fg="#7F8C8D"> {props.statusLine}</text>
874
+ </box>
863
875
  </box>
864
876
  );
865
877
  }
@@ -249,7 +249,15 @@ export function StartupScreen(props: {
249
249
  return `${workspace} / ${profile}`;
250
250
  });
251
251
 
252
- const innerWidth = createMemo(() => Math.max(40, panelWidth() - 6));
252
+ /*
253
+ Usable width inside the panel.
254
+
255
+ The panel has no border any more, only `padding={2}`: 4 columns, not 6. The
256
+ two extra columns were the border's, and keeping them would leave the text
257
+ measured for a frame that is no longer drawn — every `fit()` truncating two
258
+ characters early for nothing.
259
+ */
260
+ const innerWidth = createMemo(() => Math.max(40, panelWidth() - 4));
253
261
  const shortcutHint = createMemo(() => {
254
262
  const count = currentLength();
255
263
  const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
@@ -267,9 +275,6 @@ export function StartupScreen(props: {
267
275
  top={top()}
268
276
  width={panelWidth()}
269
277
  height={panelHeight()}
270
- border
271
- borderStyle="rounded"
272
- borderColor="#8BD5CA"
273
278
  backgroundColor="#111318"
274
279
  padding={2}
275
280
  flexDirection="column"
package/src/shell/repl.js CHANGED
@@ -2115,6 +2115,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
2115
2115
  url: runtime.url,
2116
2116
  workspace: session.workspace ?? null,
2117
2117
  ...(kind === 'item' ? { itemId: id } : kind === 'approval' ? { approvalId: id } : { runId: id }),
2118
+ caller: 'shell-/approve',
2118
2119
  });
2119
2120
  conversationMessages(session).push({ role: 'command', content: `Approval ${result.approved ? 'accepted' : 'not found'}: ${id}` });
2120
2121
  syncRuntimeState();