@dotdrelle/wiki-manager 0.15.41 → 0.15.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.41",
3
+ "version": "0.15.42",
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/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/taskStatuses.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/delegation.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/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.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/taskStatuses.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/setupWizardModality.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/delegation.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",
@@ -306,11 +306,28 @@ export function createInteractiveSession(context, { runtimeUrl, turnId, signal =
306
306
  return session;
307
307
  }
308
308
 
309
+ /*
310
+ Un tour interactif se termine TOUJOURS par un assistant_message.
311
+
312
+ C'est la condition de fin que les deux interfaces attendent : côté `serve`, la
313
+ bulle « Request received · Donna is preparing… » n'est retirée que lorsqu'un
314
+ message assistant non vide arrive. La garde `!content` renvoyait donc `false`
315
+ en silence quand le tour ne produisait rien — modèle qui répond vide, boucle
316
+ d'outils qui s'arrête sans conclure — et le point d'attente tournait
317
+ indéfiniment, sans erreur nulle part.
318
+
319
+ Une réponse vide est un résultat, pas une raison de ne rien dire.
320
+ */
309
321
  export function ensureInteractiveAssistantMessage(session, response, { turnId, workspace } = {}) {
322
+ if (session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
310
323
  const content = String(response ?? '').trim();
311
- if (!content || session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
312
324
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
313
- origin: 'runtime_turn', turnId, workspace, payload: { content: String(response) },
325
+ origin: 'runtime_turn',
326
+ turnId,
327
+ workspace,
328
+ payload: {
329
+ content: content || 'No answer was produced for this turn. The model returned nothing — try rephrasing, or switch to /agent if the request needs an action.',
330
+ },
314
331
  }));
315
332
  return true;
316
333
  }
@@ -1365,6 +1382,22 @@ async function runRuntime(argv, agent) {
1365
1382
  response = await runHeadlessChatTurn(ephemeral, input, {
1366
1383
  history,
1367
1384
  onStep: ephemeral._onStep,
1385
+ // Fragments de réponse publiés au fil de l'eau. Le réducteur les
1386
+ // agrège dans la dernière entrée de conversation (`assistant_delta`),
1387
+ // que `assistant_message` vient ensuite figer : les deux interfaces
1388
+ // voient la réponse s'écrire, au lieu d'attendre le tour complet.
1389
+ onTextDelta: (delta) => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta', {
1390
+ origin: 'runtime_turn',
1391
+ turnId,
1392
+ workspace: context.workspace ?? null,
1393
+ payload: { delta },
1394
+ })),
1395
+ onTextReset: () => dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
1396
+ origin: 'runtime_turn',
1397
+ turnId,
1398
+ workspace: context.workspace ?? null,
1399
+ payload: {},
1400
+ })),
1368
1401
  // UI context from `wiki serve`: up to five selected wiki or raw
1369
1402
  // documents. Only paths are prompted; Donna reads through tools.
1370
1403
  openWikiPages: body.context?.openWikiPages ?? body.context?.openWikiPage,
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
12
  import { openExternalUrl } from '../shell/openExternal.js';
13
13
  import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
14
14
  import { join, relative } from 'node:path';
15
- import { composeServices, listServices, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
15
+ import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
16
16
  import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
17
17
  import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
18
18
  import {
@@ -568,12 +568,23 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
568
568
  try {
569
569
  context.onStep?.(`Workspace: creating ${workspaceName}…`);
570
570
  const output = await createWorkspace(workspaceName, targetPath, { timeout: 600_000 });
571
- finalizeCreatedWorkspace(workspaceName);
571
+ // Seed the new workspace from the one in use: the LLM endpoint, key and
572
+ // model are almost always the same, and re-entering them by hand was the
573
+ // first thing to do after every /new.
574
+ const { inherited } = await finalizeCreatedWorkspace(workspaceName, {
575
+ inheritFrom: context.session?.workspace ?? null,
576
+ });
572
577
  return {
573
578
  output: [
574
579
  output,
575
580
  '',
576
581
  `Workspace created: ${workspaceName}`,
582
+ // State what was carried over. Inheriting silently would make a wrong
583
+ // endpoint look like a scaffold default and send the operator hunting
584
+ // in the wrong file.
585
+ inherited.length > 0
586
+ ? `Inherited from ${context.session.workspace}: ${inherited.join(', ')}`
587
+ : null,
577
588
  `Use /use ${workspaceName} to load it.`,
578
589
  ].filter(Boolean).join('\n'),
579
590
  };
@@ -725,7 +736,7 @@ ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
725
736
  ${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
726
737
  ${helpPair('/config edit <n>', 'Edit config', '/workspace delete <n>', 'Delete workspace')}
727
738
  ${helpPair('/services', 'Services', '/start [all|agents|services]', 'all = services + agents')}
728
- ${helpPair('/stop [all|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
739
+ ${helpPair('/stop [all|everything|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
729
740
  ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
730
741
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
731
742
  ${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
@@ -1128,14 +1139,31 @@ export async function handleSlashCommand(line, context) {
1128
1139
  // compris. Il ne stoppait que les services du workspace et laissait les
1129
1140
  // agents debout — donc `/start all` puis `/stop all` ne revenait pas à
1130
1141
  // l'état de départ.
1131
- const stopsAgents = service === 'all';
1132
- const stopTarget = service === 'services' ? undefined : service;
1142
+ //
1143
+ // Cette symétrie ne tient que tant qu'un seul workspace tourne. Les
1144
+ // agents externes sont UNE pile partagée : les arrêter depuis un
1145
+ // workspace coupait les autres, qui n'avaient rien demandé et ne
1146
+ // voyaient qu'une panne. « all » reste donc « toute ma pile », et les
1147
+ // agents ne tombent que s'ils ne servent plus personne. `/stop
1148
+ // everything` garde la coupure franche, explicitement demandée.
1149
+ const stopsEverything = service === 'everything';
1150
+ const stopsAgents = service === 'all' || stopsEverything;
1151
+ const stopTarget = service === 'services' || stopsEverything ? undefined : service;
1133
1152
  try {
1134
- step(`Services: stopping ${service ?? 'workspace services'}…`);
1153
+ step(`Services: stopping ${stopsEverything ? 'all workspaces and agents' : (service ?? 'workspace services')}…`);
1135
1154
  await stopService(context.session, stopTarget);
1136
1155
  if (stopsAgents) {
1137
- const agentsResult = await runAgentCommand(stopAgents, 'stop');
1138
- if (agentsResult?.failed) return agentsResult;
1156
+ const busy = stopsEverything
1157
+ ? []
1158
+ : await otherWorkspacesRunning(context.session, listWorkspaces());
1159
+ if (busy.length > 0) {
1160
+ // Say who is holding them, and how to override. A silent skip
1161
+ // would look exactly like the bug we just fixed.
1162
+ step(`Services: agents left running for ${busy.join(', ')} — use /stop everything to stop them anyway.`);
1163
+ } else {
1164
+ const agentsResult = await runAgentCommand(stopAgents, 'stop');
1165
+ if (agentsResult?.failed) return agentsResult;
1166
+ }
1139
1167
  }
1140
1168
  step('Services: refreshing MCP runtime…');
1141
1169
  await refreshMcpRuntimeStatus(context.session);
@@ -268,6 +268,9 @@ function applyEvent(state, event) {
268
268
  case 'assistant_delta':
269
269
  appendAssistantDelta(state, String(event.payload?.delta ?? ''));
270
270
  return;
271
+ case 'assistant_delta_reset':
272
+ discardStreamingAssistantMessage(state);
273
+ return;
271
274
  case 'tool_call_started':
272
275
  state.chain.push({
273
276
  type: 'tool',
@@ -666,6 +669,26 @@ function appendAssistantDelta(state, delta) {
666
669
  }
667
670
  }
668
671
 
672
+ /*
673
+ Jeter une réponse en cours d'écriture, sans retirer son entrée.
674
+
675
+ Une itération de la boucle d'outils peut produire du texte puis décider
676
+ d'appeler un outil : ce texte est un raisonnement intermédiaire que le tour
677
+ suivant remplace, il ne doit pas rester à l'écran.
678
+
679
+ L'entrée est vidée, jamais dépilée. La réconciliation de `serve`
680
+ (`chatHtml.ts`) suppose une conversation en ajout seul — « le serveur ne mute
681
+ que la dernière entrée, tout ce qui précède est acquis » — et n'indexe la
682
+ boucle que sur la longueur croissante. Un `pop` la ferait passer sous le
683
+ nombre de références déjà rendues : l'élément DOM en trop resterait affiché
684
+ avec le texte qu'on voulait justement effacer, et tous les messages suivants
685
+ se décaleraient d'un cran.
686
+ */
687
+ function discardStreamingAssistantMessage(state) {
688
+ const last = state.conversation.at(-1);
689
+ if (last?.role === 'assistant' && last.streaming) last.content = '';
690
+ }
691
+
669
692
  function finalizeAssistantMessage(state, content) {
670
693
  const last = state.conversation.at(-1);
671
694
  if (last?.role === 'assistant' && last.streaming) {
@@ -591,3 +591,35 @@ test('reduceAgentEvents: les alias de statut tombent sur le canonique', () => {
591
591
  assert.equal(projection.plan[2].status, 'done');
592
592
  assert.equal(projection.logs.some((line) => /unknown status/.test(line)), false);
593
593
  });
594
+
595
+ // La bulle « Request received · Donna is preparing… » n'est retirée côté serve
596
+ // que lorsqu'un message assistant NON VIDE arrive. Un tour sans réponse ne
597
+ // publiait rien : le point d'attente tournait jusqu'au rechargement de la page.
598
+ test('a discarded stream is emptied, never popped', () => {
599
+ const projection = reduceAgentEvents([
600
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
601
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
602
+ createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
603
+ ]);
604
+
605
+ // La réconciliation de serve suppose une conversation en ajout seul : dépiler
606
+ // la ferait passer sous le nombre d'éléments déjà rendus, laissant à l'écran
607
+ // le texte qu'on voulait effacer et décalant tous les messages suivants.
608
+ assert.equal(projection.conversation.length, 2);
609
+ assert.deepEqual(projection.conversation[1], { role: 'assistant', content: '', streaming: true });
610
+ });
611
+
612
+ test('a stream resumed after a discard carries only the final text', () => {
613
+ const projection = reduceAgentEvents([
614
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
615
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Je vais regarder…' } }),
616
+ createAgentEvent('assistant_delta_reset', { origin: 'runtime', payload: {} }),
617
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: '12 ' } }),
618
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'pages.' } }),
619
+ createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: '12 pages.' } }),
620
+ ]);
621
+
622
+ assert.equal(projection.conversation.length, 2);
623
+ assert.equal(projection.conversation[1].content, '12 pages.');
624
+ assert.equal(projection.conversation[1].streaming, undefined, 'le message doit être figé');
625
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.41",
2
+ "version": "0.15.42",
3
3
  "commit": "d0180aa"
4
4
  }
@@ -321,6 +321,38 @@ export async function serviceStates(session) {
321
321
  return states;
322
322
  }
323
323
 
324
+ /**
325
+ * Names of OTHER registered workspaces that still have containers running.
326
+ *
327
+ * The external agents (cme, documents, connectors, mailer) are a single
328
+ * stack shared by every workspace — one Compose project, one set of
329
+ * containers. `/stop all` took them down unconditionally, so stopping one
330
+ * workspace silently cut the agents out from under all the others.
331
+ *
332
+ * Best effort by design: a workspace whose Compose project cannot be queried
333
+ * (registry entry pointing at a path that no longer exists, docker refusing)
334
+ * is reported as NOT running. Erring the other way would make the agents
335
+ * impossible to stop as soon as one stale registry entry existed.
336
+ */
337
+ export async function otherWorkspacesRunning(session, workspaces = []) {
338
+ const current = session?.workspace ?? null;
339
+ const others = workspaces.filter((workspace) => workspace?.name && workspace.name !== current);
340
+ const results = await Promise.all(others.map(async (workspace) => {
341
+ try {
342
+ const states = await serviceStates({
343
+ workspace: workspace.name,
344
+ workspacePath: workspace.workspacePath,
345
+ workspaceEnvFile: workspace.envFile,
346
+ workspaceEnv: workspace.env,
347
+ });
348
+ return Object.values(states).some((state) => state.running) ? workspace.name : null;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }));
353
+ return results.filter(Boolean);
354
+ }
355
+
324
356
  export async function missingServiceImages(service) {
325
357
  const aliases = serviceAliases();
326
358
  const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
@@ -150,3 +150,35 @@ test('missing-image checks ignore agents behind inactive Compose profiles', () =
150
150
  ['example/cme:latest', 'example/connectors:latest'],
151
151
  );
152
152
  });
153
+
154
+ // Trois variables DOCUMENT_* pour deux conteneurs, chacun n'en déclarant que
155
+ // deux : la question « à quoi ça sert » revient à chaque lecture. Ce test fige
156
+ // le partage pour que la doc reste vraie.
157
+ test('the document handoff keeps input shared and the rest separate', async () => {
158
+ const workspace = YAML.parse(await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8'));
159
+ const agents = YAML.parse(await readFile(new URL('../../agents.docker-compose.yml', import.meta.url), 'utf8'));
160
+ const envOf = (service) => Object.fromEntries(
161
+ (service.environment ?? []).map((entry) => String(entry).split('=', 2)),
162
+ );
163
+
164
+ const serve = envOf(workspace.services.serve);
165
+ const documents = envOf(agents.services.documents);
166
+
167
+ // `input` est le point de passage : serve y écrit, l'agent y lit.
168
+ assert.equal(serve.DOCUMENT_INPUT_DIR, '/documents/input');
169
+ assert.equal(documents.DOCUMENT_INPUT_DIR, '/documents/input');
170
+ assert.ok(workspace.services.serve.volumes.some((v) => String(v).endsWith(':/documents/input')));
171
+ assert.ok(agents.services.documents.volumes.some((v) => String(v).endsWith(':/documents/input')));
172
+
173
+ // Le manifeste des téléversements n'appartient qu'à serve ; la sortie de
174
+ // conversion n'appartient qu'à l'agent. Déclarer l'un chez l'autre laisserait
175
+ // croire à un partage qui n'existe pas.
176
+ assert.equal(serve.DOCUMENT_UPLOADS_DIR, '/documents/uploads');
177
+ assert.equal(documents.DOCUMENT_UPLOADS_DIR, undefined);
178
+ assert.equal(documents.DOCUMENT_OUTPUT_DIR, '/documents/output');
179
+ assert.equal(serve.DOCUMENT_OUTPUT_DIR, undefined);
180
+
181
+ // Le plafond est vérifié des deux côtés, donc déclaré des deux côtés.
182
+ assert.ok(serve.DOCUMENT_MAX_UPLOAD_BYTES);
183
+ assert.ok(documents.DOCUMENT_MAX_UPLOAD_BYTES);
184
+ });
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.41';
4
+ const WIKI_MANAGER_VERSION = '0.15.42';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -0,0 +1,51 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { otherWorkspacesRunning } from './compose.js';
7
+
8
+ // `otherWorkspacesRunning` shells out to `docker compose ps` per workspace.
9
+ // Docker is not available in the test environment, so every probe throws —
10
+ // which is itself the behaviour worth pinning: a workspace that cannot be
11
+ // queried must read as NOT running, or one stale registry entry would make
12
+ // the shared agents impossible to stop for good.
13
+
14
+ function workspaceEntry(root, name) {
15
+ const registryPath = join(root, name);
16
+ mkdirSync(registryPath, { recursive: true });
17
+ const envFile = join(registryPath, '.env');
18
+ writeFileSync(envFile, `WORKSPACE_NAME=${name}\nWIKI_WORKSPACE_PATH=${registryPath}\n`, 'utf8');
19
+ return {
20
+ name,
21
+ registryPath,
22
+ envFile,
23
+ workspacePath: registryPath,
24
+ env: { WORKSPACE_NAME: name, WIKI_WORKSPACE_PATH: registryPath },
25
+ };
26
+ }
27
+
28
+ test('the current workspace is never counted as another one', async () => {
29
+ const root = mkdtempSync(join(tmpdir(), 'ws-running-self-'));
30
+ const acpi = workspaceEntry(root, 'acpi');
31
+
32
+ const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, [acpi]);
33
+
34
+ assert.deepEqual(busy, [], 'stopping a workspace must not be blocked by itself');
35
+ });
36
+
37
+ test('an unqueryable workspace does not hold the shared agents hostage', async () => {
38
+ const root = mkdtempSync(join(tmpdir(), 'ws-running-unknown-'));
39
+ const workspaces = [workspaceEntry(root, 'acpi'), workspaceEntry(root, 'stale')];
40
+
41
+ const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, workspaces);
42
+
43
+ assert.deepEqual(busy, []);
44
+ });
45
+
46
+ test('a single workspace, or none at all, never blocks', async () => {
47
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, []), []);
48
+ assert.deepEqual(await otherWorkspacesRunning({}, []), []);
49
+ // Entries without a name are registry noise, not workspaces.
50
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, [{}, null]), []);
51
+ });
@@ -82,8 +82,32 @@ test('"all" means the same thing to start and to stop', () => {
82
82
  // `/start all` démarrait les agents, `/stop all` ne les arrêtait pas : la
83
83
  // séquence start/stop ne revenait donc pas à l'état de départ.
84
84
  assert.match(slash, /const startsAgents = service === 'all';/);
85
- assert.match(slash, /const stopsAgents = service === 'all';/);
86
- assert.match(slash, /if \(stopsAgents\) \{\s*\n\s*const agentsResult = await runAgentCommand\(stopAgents, 'stop'\);/);
85
+ assert.match(slash, /const stopsAgents = service === 'all' \|\| stopsEverything;/);
86
+ });
87
+
88
+ test('stopping one workspace never takes the shared agents from another', () => {
89
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
90
+ // Les agents externes sont UNE pile partagée. `/stop all` les arrêtait sans
91
+ // condition : couper un workspace coupait tous les autres, qui ne voyaient
92
+ // qu'une panne.
93
+ assert.match(slash, /await otherWorkspacesRunning\(context\.session, listWorkspaces\(\)\)/);
94
+ assert.match(slash, /if \(busy\.length > 0\) \{/);
95
+ // Le renoncement doit se dire : un saut silencieux ressemble exactement au
96
+ // bug d'origine.
97
+ assert.match(slash, /agents left running for \$\{busy\.join\(', '\)\}/);
98
+ assert.match(slash, /use \/stop everything to stop them anyway/);
99
+ // `everything` court-circuite la garde, et c'est son seul intérêt.
100
+ assert.match(slash, /const busy = stopsEverything\s*\n\s*\? \[\]/);
101
+ });
102
+
103
+ test('the everything escape hatch is discoverable', () => {
104
+ const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
105
+ const repl = readFileSync(fileURLToPath(new URL('../shell/repl.js', import.meta.url)), 'utf8');
106
+ // Une porte de sortie qui n'apparaît ni dans l'aide ni dans la complétion
107
+ // n'existe pas pour l'opérateur qui en a besoin.
108
+ assert.match(slash, /\/stop \[all\|everything\|service\|agents\]/);
109
+ assert.match(repl, /command === '\/stop' && tokenIndex === 1\) return \['all', 'everything'/);
110
+ assert.match(repl, /if \(value === 'everything'\)/);
87
111
  });
88
112
 
89
113
  test('the addressable agents are read from the compose file, never hard-coded', async () => {
@@ -12,6 +12,13 @@
12
12
  //
13
13
  // `executeCall` may throw to abort the whole loop (e.g. an AbortError on
14
14
  // cancel); anything it returns is treated as the tool result for that call.
15
+ /**
16
+ * @param onTextDelta appelé au fil de la génération. Une itération qui finit
17
+ * par des appels d'outils ne produit pas de réponse lisible : ses fragments
18
+ * sont donc rejetés a posteriori via `onTextReset`, pour ne pas afficher un
19
+ * raisonnement intermédiaire que le tour suivant remplacera.
20
+ * @param onTextReset appelé quand les fragments déjà émis sont à jeter.
21
+ */
15
22
  export async function runBoundedToolLoop({
16
23
  llm,
17
24
  system,
@@ -21,19 +28,37 @@ export async function runBoundedToolLoop({
21
28
  maxIterations = 4,
22
29
  signal,
23
30
  onStep,
31
+ onTextDelta,
32
+ onTextReset,
24
33
  } = {}) {
25
34
  const cap = Math.max(1, Math.floor(maxIterations) || 1);
26
35
  const convo = [...(messages ?? [])];
36
+ // `streamWithTools` accumule les appels d'outils exactement comme
37
+ // `completeWithTools` et renvoie la même forme : le seul écart est qu'il
38
+ // livre le texte au fil de l'eau. Sans lui, la réponse finale n'apparaissait
39
+ // qu'une fois complète — le tour paraissait figé pendant toute sa durée.
40
+ const canStream = typeof onTextDelta === 'function' && typeof llm?.streamWithTools === 'function';
27
41
  for (let i = 0; i < cap; i += 1) {
28
42
  onStep?.(i + 1, cap);
29
- const result = await llm.completeWithTools({
30
- system,
31
- tools,
32
- messages: convo,
33
- toolChoice: 'auto',
34
- signal,
35
- });
43
+ let streamedText = false;
44
+ const result = canStream
45
+ ? await llm.streamWithTools({
46
+ system,
47
+ tools,
48
+ messages: convo,
49
+ toolChoice: 'auto',
50
+ onTextDelta: (delta) => { streamedText = true; onTextDelta(delta); },
51
+ signal,
52
+ })
53
+ : await llm.completeWithTools({
54
+ system,
55
+ tools,
56
+ messages: convo,
57
+ toolChoice: 'auto',
58
+ signal,
59
+ });
36
60
  const calls = result?.tool_calls ?? [];
61
+ if (calls.length > 0 && streamedText) onTextReset?.();
37
62
  if (calls.length === 0) {
38
63
  return {
39
64
  content: result?.content ?? result?.message?.content ?? '',
@@ -86,3 +86,98 @@ test('propagates an abort thrown by executeCall', async () => {
86
86
  /aborted/,
87
87
  );
88
88
  });
89
+
90
+ // Le tour paraissait figé pendant toute sa durée : la réponse n'apparaissait
91
+ // qu'une fois complète, parce que la boucle n'appelait que completeWithTools.
92
+ test('streams the answer when the caller asks for deltas', async () => {
93
+ const deltas = [];
94
+ const llm = {
95
+ async completeWithTools() { throw new Error('streamWithTools devait être préféré'); },
96
+ async streamWithTools({ onTextDelta }) {
97
+ onTextDelta('Le wiki ');
98
+ onTextDelta('contient 12 pages.');
99
+ return { content: 'Le wiki contient 12 pages.', tool_calls: [] };
100
+ },
101
+ };
102
+
103
+ const out = await runBoundedToolLoop({
104
+ llm,
105
+ tools: [],
106
+ executeCall: async () => 'unused',
107
+ onTextDelta: (delta) => deltas.push(delta),
108
+ });
109
+
110
+ assert.deepEqual(deltas, ['Le wiki ', 'contient 12 pages.']);
111
+ assert.equal(out.content, 'Le wiki contient 12 pages.');
112
+ });
113
+
114
+ test('keeps the non-streaming path when no delta callback is given', async () => {
115
+ let streamed = false;
116
+ const llm = {
117
+ async completeWithTools() { return { content: 'plain', tool_calls: [] }; },
118
+ async streamWithTools() { streamed = true; return { content: 'streamed', tool_calls: [] }; },
119
+ };
120
+
121
+ const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'unused' });
122
+
123
+ assert.equal(streamed, false, 'sans onTextDelta, rien ne doit changer');
124
+ assert.equal(out.content, 'plain');
125
+ });
126
+
127
+ test('discards text emitted by an iteration that ends in tool calls', async () => {
128
+ // Un modèle peut écrire un raisonnement puis décider d'appeler un outil. Ce
129
+ // texte est remplacé par le tour suivant : le laisser afficherait des
130
+ // paragraphes qui disparaissent, pire que pas de streaming du tout.
131
+ let round = 0;
132
+ const deltas = [];
133
+ let resets = 0;
134
+ const llm = {
135
+ async streamWithTools({ onTextDelta }) {
136
+ round += 1;
137
+ if (round === 1) {
138
+ onTextDelta('Je vais regarder…');
139
+ const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
140
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
141
+ }
142
+ onTextDelta('12 pages.');
143
+ return { content: '12 pages.', tool_calls: [] };
144
+ },
145
+ };
146
+
147
+ const out = await runBoundedToolLoop({
148
+ llm,
149
+ tools: [],
150
+ executeCall: async () => 'ok',
151
+ onTextDelta: (delta) => deltas.push(delta),
152
+ onTextReset: () => { resets += 1; },
153
+ });
154
+
155
+ assert.deepEqual(deltas, ['Je vais regarder…', '12 pages.']);
156
+ assert.equal(resets, 1, 'le texte intermédiaire doit être annulé, une fois');
157
+ assert.equal(out.content, '12 pages.');
158
+ });
159
+
160
+ test('never asks to discard text that was never emitted', async () => {
161
+ let resets = 0;
162
+ let round = 0;
163
+ const llm = {
164
+ async streamWithTools() {
165
+ round += 1;
166
+ if (round === 1) {
167
+ const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
168
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
169
+ }
170
+ return { content: 'fini', tool_calls: [] };
171
+ },
172
+ };
173
+
174
+ await runBoundedToolLoop({
175
+ llm,
176
+ tools: [],
177
+ executeCall: async () => 'ok',
178
+ onTextDelta: () => {},
179
+ onTextReset: () => { resets += 1; },
180
+ });
181
+
182
+ assert.equal(resets, 0);
183
+ });
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import YAML from 'yaml';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
- import { patchWikircProfile } from './wikirc.js';
8
+ import { loadWikircProfile, patchWikircProfile } from './wikirc.js';
9
+ import { buildInheritedWikircPatch, copyCmeCredentials } from './workspaceInherit.js';
9
10
  import { resolveAgentsComposeContext } from './agentsCompose.js';
10
11
  import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
11
12
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
@@ -208,20 +209,62 @@ export async function refreshRunningContainers(options = {}) {
208
209
  return { skipped: false, refreshed, errors };
209
210
  }
210
211
 
211
- export async function createNewWorkspace(name, targetPath) {
212
+ export async function createNewWorkspace(name, targetPath, options = {}) {
212
213
  try {
213
214
  const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
214
- const workspace = finalizeCreatedWorkspace(name);
215
- return { output, workspace };
215
+ const { workspace, inherited } = await finalizeCreatedWorkspace(name, options);
216
+ return { output, workspace, inherited };
216
217
  } catch (err) {
217
218
  throw wrapDockerError(err);
218
219
  }
219
220
  }
220
221
 
221
- export function finalizeCreatedWorkspace(name) {
222
+ /**
223
+ * @param options.inheritFrom name of the workspace to copy LLM config and CME
224
+ * credentials from — normally the session's current workspace. Omitted, or
225
+ * unknown, means "scaffold defaults only", the previous behaviour.
226
+ */
227
+ export async function finalizeCreatedWorkspace(name, options = {}) {
222
228
  const workspace = findWorkspace(name);
223
- if (workspace) initializeWorkspaceWikirc(workspace);
224
- return workspace;
229
+ if (!workspace) return { workspace: null, inherited: [] };
230
+ // Order matters: the access key is this workspace's own credential and must
231
+ // land before anything else touches the file.
232
+ initializeWorkspaceWikirc(workspace);
233
+ const inherited = await inheritWorkspaceSetup(workspace, options.inheritFrom ?? null);
234
+ return { workspace, inherited };
235
+ }
236
+
237
+ /**
238
+ * Carry an existing workspace's proven setup over to a new one. Best effort by
239
+ * design: a workspace that exists and works must never fail to be created
240
+ * because the workspace next door has an unreadable config.
241
+ */
242
+ export async function inheritWorkspaceSetup(workspace, sourceName) {
243
+ if (!workspace?.workspacePath || !sourceName || sourceName === workspace.name) return [];
244
+ const source = findWorkspace(sourceName);
245
+ if (!source?.workspacePath) return [];
246
+
247
+ const inherited = [];
248
+ try {
249
+ const { config: sourceConfig } = loadWikircProfile(source.workspacePath, 'default');
250
+ const { config: targetConfig } = loadWikircProfile(workspace.workspacePath, 'default');
251
+ const { patch, inherited: keys } = buildInheritedWikircPatch(sourceConfig, targetConfig);
252
+ if (keys.length > 0) {
253
+ patchWikircProfile(workspace.workspacePath, 'default', patch);
254
+ inherited.push(...keys);
255
+ }
256
+ } catch {
257
+ // Unreadable or absent profile on either side — nothing to inherit.
258
+ }
259
+
260
+ try {
261
+ const copied = await copyCmeCredentials(resolveAgentsDataDir(), source.name, workspace.name);
262
+ if (copied) inherited.push('cme.app_data.json');
263
+ } catch {
264
+ // Credentials are a convenience, not a prerequisite.
265
+ }
266
+
267
+ return inherited;
225
268
  }
226
269
 
227
270
  export function initializeWorkspaceWikirc(workspace) {