@dotdrelle/wiki-manager 0.14.9 → 0.14.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -16,6 +16,18 @@ WORKSPACES_ROOT=/path/to/workspaces
16
16
  # Defaults to .agents-data/ next to wiki-workspace.
17
17
  # AGENTS_DATA_DIR=/path/to/.agents-data
18
18
 
19
+ # ── VPN / outbound HTTP proxy (optional) ──────────────────────────────────────
20
+ #
21
+ # Node fetch does not use HTTP_PROXY / HTTPS_PROXY unless environment proxy
22
+ # support is enabled. This matters for the manager runtime's own LLM calls
23
+ # (including runtime.delegate objective resolution) behind a corporate VPN.
24
+ # Keep these lines commented when no proxy is required.
25
+ # NODE_USE_ENV_PROXY=1
26
+ # HTTPS_PROXY=http://proxy.example:11011
27
+ # HTTP_PROXY=http://proxy.example:11011
28
+ # Always bypass the proxy for the local runtime, local MCPs, and Docker host.
29
+ # NO_PROXY=localhost,127.0.0.1,host.docker.internal
30
+
19
31
  # ── Agent auth tokens ──────────────────────────────────────────────────────────
20
32
  #
21
33
  # Each agent has its own bearer token. Set the matching Authorization header
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.9",
3
+ "version": "0.14.10",
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/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.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/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.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/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -31,6 +31,22 @@ function valueAfter(argv, flag) {
31
31
  return argv[index + 1];
32
32
  }
33
33
 
34
+ function errorDiagnostic(err) {
35
+ const parts = [];
36
+ let current = err;
37
+ const seen = new Set();
38
+ while (current && !seen.has(current) && parts.length < 4) {
39
+ seen.add(current);
40
+ const name = current?.name && current.name !== 'Error' ? String(current.name) : '';
41
+ const code = current?.code ? String(current.code) : '';
42
+ const message = current instanceof Error ? current.message : String(current);
43
+ const detail = [name, code, message].filter(Boolean).join(' ');
44
+ if (detail && !parts.includes(detail)) parts.push(detail);
45
+ current = current?.cause;
46
+ }
47
+ return parts.join(' <- ') || 'unknown error';
48
+ }
49
+
34
50
  function unavailableRuntime(err) {
35
51
  const reason = err instanceof Error ? err.message : String(err);
36
52
  return { url: null, error: reason };
@@ -787,27 +803,42 @@ async function runRuntime(argv, agent) {
787
803
  const { resolveObjective } = await import('../orchestrator/objectiveResolver.js');
788
804
  const { validateFragment } = await import('../orchestrator/planValidator.js');
789
805
  const session = context.session;
790
- const selection = await resolveObjective(objective, session);
806
+ let selection;
807
+ try {
808
+ selection = await resolveObjective(objective, session);
809
+ } catch (err) {
810
+ throw new Error(`Delegation failed during objective_resolution: ${errorDiagnostic(err)}`, { cause: err });
811
+ }
791
812
  const provider = selection.provider;
792
- const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(
793
- session.mcp,
794
- provider.serverName,
795
- 'agent_plan',
796
- {
797
- capability: selection.capability,
798
- operation: selection.operation,
799
- objective,
800
- workspace: { revision: String(Date.now()) },
801
- constraints: {
802
- maxConcurrency: resolveCapabilityConcurrency(
803
- provider,
804
- undefined,
805
- process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
806
- ),
807
- requireApprovalForMutations: true,
813
+ let planResult;
814
+ try {
815
+ planResult = await callMcpTool(
816
+ session.mcp,
817
+ provider.serverName,
818
+ 'agent_plan',
819
+ {
820
+ capability: selection.capability,
821
+ operation: selection.operation,
822
+ objective,
823
+ workspace: { revision: String(Date.now()) },
824
+ constraints: {
825
+ maxConcurrency: resolveCapabilityConcurrency(
826
+ provider,
827
+ undefined,
828
+ process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
829
+ ),
830
+ requireApprovalForMutations: true,
831
+ },
808
832
  },
809
- },
810
- )));
833
+ );
834
+ } catch (err) {
835
+ const endpoint = session.mcp?.[provider.serverName]?.url ?? 'unknown endpoint';
836
+ throw new Error(
837
+ `Delegation failed during agent_plan: provider=${provider.serverName} endpoint=${endpoint} ${errorDiagnostic(err)}`,
838
+ { cause: err },
839
+ );
840
+ }
841
+ const fragment = parseJsonText(formatMcpToolResult(planResult));
811
842
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
812
843
  throw new Error(fragment?.summary?.initialSynthesis?.[0] ?? `No task was planned for ${selection.capability}/${selection.operation}.`);
813
844
  }
@@ -627,7 +627,7 @@ ${helpPair('/mcp tools [mcp]', 'MCP tools', '/mcp call ...', 'Call MCP tool')}
627
627
  ${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
628
628
  ${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
629
629
  ${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
630
- ${helpPair('/chat', 'Chat mode', '/agent', 'Agent mode')}
630
+ ${helpPair('/chat', 'Chat mode', '/agent [question]', 'Agent mode / one-shot')}
631
631
  ${helpPair('/openui', 'Open web UI in browser', '', '')}
632
632
  ${helpPair('/run status', 'Runtime status', '/run kill', 'Kill runtime run(s)')}
633
633
  ${helpPair('/run capability <id>', 'Deterministic capability run', '/approve', 'Grant pending approval')}
@@ -678,8 +678,31 @@ function rawCommandResult(command, output) {
678
678
  };
679
679
  }
680
680
 
681
- export function localizedOperationResult({ operation, target, status = 'succeeded' }) {
682
- const facts = JSON.stringify({ operation, target, status });
681
+ // Shared by every deterministic command that can trigger an on-demand
682
+ // `docker pull` (agents up, service start): reports the images as they're
683
+ // found missing and hands back the final list for localizedOperationResult.
684
+ async function collectMissingImages(step, fn) {
685
+ let missingImages = [];
686
+ await fn({
687
+ onImagesMissing: (images) => {
688
+ missingImages = images;
689
+ step(`Donna: downloading and installing missing components: ${images.join(', ')}…`);
690
+ },
691
+ });
692
+ return missingImages;
693
+ }
694
+
695
+ function componentInstallAction(missingImages) {
696
+ return missingImages.length > 0 ? 'downloaded-and-installed-missing-components' : null;
697
+ }
698
+
699
+ export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [] }) {
700
+ const facts = JSON.stringify({
701
+ operation,
702
+ target,
703
+ status,
704
+ ...(componentAction ? { componentAction, images } : {}),
705
+ });
683
706
  return {
684
707
  output: facts,
685
708
  rawOutput: true,
@@ -730,10 +753,12 @@ export async function handleSlashCommand(line, context) {
730
753
  const runAgentCommand = async (fn, verb) => {
731
754
  try {
732
755
  step(`Agents: ${verb}ing external agents…`);
733
- await fn();
756
+ const missingImages = await collectMissingImages(step, fn);
734
757
  return localizedOperationResult({
735
758
  operation: verb,
736
759
  target: 'agents',
760
+ componentAction: componentInstallAction(missingImages),
761
+ images: missingImages,
737
762
  });
738
763
  } catch (err) {
739
764
  step(formatActivityError('agents', verb, err));
@@ -893,15 +918,17 @@ export async function handleSlashCommand(line, context) {
893
918
  // do not remap it to undefined, that bypasses any custom "all" target list and always
894
919
  // falls back to the hardcoded COMPOSE_SERVICES constant instead.
895
920
  const service = args[1];
896
- if (service === 'agents') return runAgentCommand(startAgents, 'start');
921
+ if (service === 'agents' || service === 'agent') return runAgentCommand(startAgents, 'start');
897
922
  try {
898
923
  step(`Services: starting ${service ?? 'workspace services'}…`);
899
- await startService(context.session, service);
924
+ const missingImages = await collectMissingImages(step, (opts) => startService(context.session, service, opts));
900
925
  step('Services: refreshing MCP runtime…');
901
926
  await refreshMcpRuntimeStatus(context.session);
902
927
  return localizedOperationResult({
903
928
  operation: 'start',
904
929
  target: service || 'workspace-services',
930
+ componentAction: componentInstallAction(missingImages),
931
+ images: missingImages,
905
932
  });
906
933
  } catch (err) {
907
934
  const message = err instanceof Error ? err.message : String(err);
@@ -15,6 +15,23 @@ test('deterministic operation results ask Donna to localize compact facts withou
15
15
  assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
16
16
  });
17
17
 
18
+ test('start result tells Donna when missing container components were installed', () => {
19
+ const result = localizedOperationResult({
20
+ operation: 'start',
21
+ target: 'all',
22
+ componentAction: 'downloaded-and-installed-missing-components',
23
+ images: ['dotdrelle/llm-wiki:latest'],
24
+ });
25
+ assert.deepEqual(JSON.parse(result.output), {
26
+ operation: 'start',
27
+ target: 'all',
28
+ status: 'succeeded',
29
+ componentAction: 'downloaded-and-installed-missing-components',
30
+ images: ['dotdrelle/llm-wiki:latest'],
31
+ });
32
+ assert.match(result.agentTrigger, /downloaded-and-installed-missing-components/);
33
+ });
34
+
18
35
  test('/workspace delete removes files and clears current session context after confirmation', async () => {
19
36
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-delete-workspace-'));
20
37
  const registryRoot = join(root, 'registry');
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.9",
3
- "commit": "768fb25"
2
+ "version": "0.14.10",
3
+ "commit": "79503aa"
4
4
  }
@@ -40,18 +40,15 @@ function composeOverrideContent(cacertPath, services) {
40
40
  services: serviceEntries,
41
41
  };
42
42
  return [
43
- '# Generated by wiki-manager. Edit freely; delete to regenerate.',
43
+ '# Generated by wiki-manager. Changes are overwritten on the next compose command.',
44
44
  YAML.stringify(doc).trimEnd(),
45
45
  '',
46
46
  ].join('\n');
47
47
  }
48
48
 
49
- let _cacertOverrideCache;
50
-
51
49
  export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.compose.yml') {
52
- if (_cacertOverrideCache !== undefined) return _cacertOverrideCache;
53
50
  const cacertPath = activeCacertPath();
54
- if (!cacertPath) return (_cacertOverrideCache = null);
51
+ if (!cacertPath) return null;
55
52
  if (!existsSync(cacertPath)) {
56
53
  throw new Error(`--cacert file not found: ${cacertPath}`);
57
54
  }
@@ -60,8 +57,14 @@ export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.
60
57
  const runtimeDir = managerRuntimeDir();
61
58
  mkdirSync(runtimeDir, { recursive: true });
62
59
  const overridePath = join(runtimeDir, fileName);
63
- if (!existsSync(overridePath)) {
64
- writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
60
+ const content = composeOverrideContent(cacertPath, services);
61
+ // The override is generated state, not user configuration. Always compare
62
+ // it with the active CLI value: keeping an existing file here left Docker
63
+ // mounting yesterday's CA after --cacert changed, despite all CA
64
+ // environment variables looking right.
65
+ const existing = existsSync(overridePath) ? readFileSync(overridePath, 'utf8') : null;
66
+ if (existing !== content) {
67
+ writeFileSync(overridePath, content, 'utf8');
65
68
  }
66
- return (_cacertOverrideCache = overridePath);
69
+ return overridePath;
67
70
  }
@@ -0,0 +1,35 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { ensureCacertComposeOverride } from './cacert.js';
7
+
8
+ test('cacert compose override follows the active certificate path', () => {
9
+ const root = mkdtempSync(join(tmpdir(), 'wiki-manager-cacert-'));
10
+ const compose = join(root, 'compose.yml');
11
+ const firstCa = join(root, 'first.pem');
12
+ const secondCa = join(root, 'second.pem');
13
+ writeFileSync(compose, 'services:\n serve:\n image: example\n', 'utf8');
14
+ writeFileSync(firstCa, 'first', 'utf8');
15
+ writeFileSync(secondCa, 'second', 'utf8');
16
+ const previousCa = process.env.WIKI_MANAGER_CACERT_PATH;
17
+ const previousEnvFile = process.env.WIKI_MANAGER_ENV_FILE;
18
+ process.env.WIKI_MANAGER_ENV_FILE = join(root, '.env');
19
+ try {
20
+ process.env.WIKI_MANAGER_CACERT_PATH = firstCa;
21
+ const override = ensureCacertComposeOverride(compose, 'test-cacert.compose.yml');
22
+ assert.match(readFileSync(override, 'utf8'), new RegExp(firstCa.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
23
+
24
+ process.env.WIKI_MANAGER_CACERT_PATH = secondCa;
25
+ ensureCacertComposeOverride(compose, 'test-cacert.compose.yml');
26
+ const updated = readFileSync(override, 'utf8');
27
+ assert.match(updated, new RegExp(secondCa.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
28
+ assert.doesNotMatch(updated, new RegExp(firstCa.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
29
+ } finally {
30
+ if (previousCa === undefined) delete process.env.WIKI_MANAGER_CACERT_PATH;
31
+ else process.env.WIKI_MANAGER_CACERT_PATH = previousCa;
32
+ if (previousEnvFile === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
33
+ else process.env.WIKI_MANAGER_ENV_FILE = previousEnvFile;
34
+ }
35
+ });
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import YAML from 'yaml';
6
6
  import { cacertEnv, ensureCacertComposeOverride } from './cacert.js';
7
+ import { checkMissingDockerImages } from './dockerImages.js';
7
8
  import { managerEnvFile, readEnvFile, resolveAgentsDataDir } from './env.js';
8
9
  import { managerRoot } from './workspaces.js';
9
10
 
@@ -281,11 +282,28 @@ export async function serviceStates(session) {
281
282
  return states;
282
283
  }
283
284
 
284
- export async function startService(session, service) {
285
+ export async function missingServiceImages(service) {
285
286
  const aliases = serviceAliases();
286
287
  const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
287
- const output = await runCompose(session, ['up', '-d', ...targets], { timeout: 180_000 });
288
- return [`Started: ${targets.join(', ')}`, output].filter(Boolean).join('\n');
288
+ const config = readComposeConfig();
289
+ const images = targets.map((target) => config.services?.[target]?.image).filter(Boolean);
290
+ return checkMissingDockerImages(images);
291
+ }
292
+
293
+ export async function startService(session, service, options = {}) {
294
+ const aliases = serviceAliases();
295
+ const targets = service ? (aliases[service] ?? [service]) : COMPOSE_SERVICES;
296
+ const absentImages = await missingServiceImages(service);
297
+ if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
298
+ const output = await runCompose(session, ['up', '-d', ...targets], {
299
+ timeout: 180_000,
300
+ onOutput: options.onOutput,
301
+ });
302
+ return {
303
+ output: [`Started: ${targets.join(', ')}`, output].filter(Boolean).join('\n'),
304
+ targets,
305
+ missingImages: absentImages,
306
+ };
289
307
  }
290
308
 
291
309
  export async function stopService(session, service) {
@@ -0,0 +1,22 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function checkMissingDockerImages(images) {
7
+ const unique = [...new Set(images.filter(Boolean))];
8
+ const checks = await Promise.all(unique.map(async (image) => {
9
+ try {
10
+ await execFileAsync('docker', ['image', 'inspect', image], {
11
+ timeout: 30_000,
12
+ maxBuffer: 1024 * 1024,
13
+ });
14
+ return null;
15
+ } catch (err) {
16
+ const detail = [err?.stderr, err?.stdout, err?.message].filter(Boolean).join('\n');
17
+ if (/no such (?:image|object)/i.test(detail)) return image;
18
+ throw err;
19
+ }
20
+ }));
21
+ return checks.filter(Boolean);
22
+ }
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.14.9';
4
+ const WIKI_MANAGER_VERSION = '0.14.10';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -1,13 +1,32 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
2
3
  import { readFile, rename, rm, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
4
5
  import { promisify } from 'node:util';
6
+ import YAML from 'yaml';
7
+ import { checkMissingDockerImages } from './dockerImages.js';
5
8
  import { patchWikircProfile } from './wikirc.js';
6
9
  import { managerEnvFile, managerMcpEndpointsFile } from './env.js';
7
10
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
8
11
 
9
12
  const execFileAsync = promisify(execFile);
10
13
 
14
+ async function missingAgentImages() {
15
+ const composeFiles = [
16
+ join(managerRoot(), 'agents.docker-compose.yml'),
17
+ join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
18
+ ].filter(existsSync);
19
+ const images = [...new Set(composeFiles.flatMap((filePath) => {
20
+ try {
21
+ const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
22
+ return Object.values(config.services ?? {}).map((service) => service?.image).filter(Boolean);
23
+ } catch {
24
+ return [];
25
+ }
26
+ }))];
27
+ return checkMissingDockerImages(images);
28
+ }
29
+
11
30
  function wrapDockerError(err) {
12
31
  if (err?.code === 'ENOENT') {
13
32
  return new Error('wiki-workspace script not found. Reinstall wiki-manager.');
@@ -35,6 +54,8 @@ function wrapDockerError(err) {
35
54
 
36
55
  export async function startAgents(options = {}) {
37
56
  try {
57
+ const absentImages = await missingAgentImages();
58
+ if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
38
59
  const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
39
60
  cwd: managerRoot(),
40
61
  env: {
@@ -51,7 +72,10 @@ export async function startAgents(options = {}) {
51
72
  timeout: options.timeout ?? 180_000,
52
73
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
53
74
  });
54
- return [stdout, stderr].filter(Boolean).join('\n').trim();
75
+ return {
76
+ output: [stdout, stderr].filter(Boolean).join('\n').trim(),
77
+ missingImages: absentImages,
78
+ };
55
79
  } catch (err) {
56
80
  throw wrapDockerError(err);
57
81
  }
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
3
3
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
4
+ import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
4
5
 
5
6
  const LEGACY_DONNA_ROLE = 'do' + 't';
6
7
 
@@ -47,7 +48,7 @@ function wrapLine(line: string, width: number) {
47
48
  return out;
48
49
  }
49
50
 
50
- type Segment = { text: string; color: string; width?: number; bg?: string };
51
+ type Segment = { text: string; color: string; width?: number; bg?: string; url?: string };
51
52
  type RenderedLine = {
52
53
  segments: Segment[];
53
54
  status?: boolean;
@@ -300,6 +301,16 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
300
301
  const rendered = renderPlainMarkdown(text);
301
302
  const isCmdDesc = splitCmdDesc(rendered) !== null;
302
303
  const fallback = isCmdDesc ? '#FFFFFF' : colorForRenderedLine(rendered, role);
304
+ if (httpLinkParts(rendered).some((part: { url?: string }) => part.url)) {
305
+ output.push(...wrapHttpLinks(rendered, columns).map((parts: Array<{ text: string; url?: string }>) => ({
306
+ segments: parts.map((part) => ({
307
+ text: part.text || ' ',
308
+ color: part.url ? '#5DADE2' : fallback,
309
+ url: part.url,
310
+ })),
311
+ })));
312
+ continue;
313
+ }
303
314
  output.push(...wrapLine(rendered, columns).map((piece, idx) => ({
304
315
  segments: idx === 0
305
316
  ? segmentsForLine(piece, role, columns)
@@ -489,7 +500,11 @@ export function ConversationView(props: {
489
500
  ) : (
490
501
  <box height={1} flexDirection="row" overflow="hidden">
491
502
  <For each={line.segments}>
492
- {(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
503
+ {(seg: Segment) => (
504
+ <text width={seg.width} fg={seg.color} bg={seg.bg}>
505
+ {seg.url ? <a href={seg.url}>{seg.text}</a> : seg.text}
506
+ </text>
507
+ )}
493
508
  </For>
494
509
  </box>
495
510
  )
@@ -20,6 +20,7 @@ type LogLineParts = { time: string | null; message: string };
20
20
  // wrapped status/error), so fewer, readable entries beat more, truncated ones.
21
21
  const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
22
22
  const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
23
+ const PLAN_VIEWPORT_ROWS = 12;
23
24
 
24
25
  function wrapLine(value: string, width: number) {
25
26
  const max = Math.max(8, width);
@@ -131,29 +132,45 @@ function queueSummary(item: QueueItem) {
131
132
  }
132
133
 
133
134
  export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string }) {
134
- const lineWidth = () => Math.max(8, props.width - 2);
135
+ // Keep one column for the native vertical scrollbar when the plan is long.
136
+ const lineWidth = () => Math.max(8, props.width - 3);
135
137
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
136
138
  const icon = (status: string) =>
137
139
  status === 'done' ? '[✓]' : status === 'failed' ? '[✗]' : status === 'running' ? '[…]' : '[ ]';
138
- const title = () => props.jobName ? `Plan : ${props.jobName}` : 'Plan';
140
+ const visualRows = createMemo(() => props.plan.reduce((total, step) =>
141
+ total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
142
+ const title = () => {
143
+ const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
144
+ return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
145
+ };
146
+ const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
139
147
  return (
140
148
  <box flexShrink={0} flexDirection="column" padding={1}>
141
149
  <text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
142
- <Index each={props.plan}>
143
- {(step) => {
144
- // Wrap step descriptions over up to 2 lines instead of truncating —
145
- // "Ingest des 39 documents raw/untrac…" hid the actual target.
146
- const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
147
- return (
148
- <box flexDirection="column">
149
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
150
- <Show when={lines()[1]}>
151
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
152
- </Show>
153
- </box>
154
- );
155
- }}
156
- </Index>
150
+ <scrollbox
151
+ height={viewportRows()}
152
+ scrollY={true}
153
+ scrollX={false}
154
+ stickyStart="top"
155
+ viewportCulling={true}
156
+ verticalScrollbarOptions={{ visible: visualRows() > PLAN_VIEWPORT_ROWS }}
157
+ >
158
+ <Index each={props.plan}>
159
+ {(step) => {
160
+ // Wrap step descriptions over up to 2 lines instead of truncating —
161
+ // "Ingest des 39 documents raw/untrac…" hid the actual target.
162
+ const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
163
+ return (
164
+ <box flexShrink={0} flexDirection="column">
165
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
166
+ <Show when={lines()[1]}>
167
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
168
+ </Show>
169
+ </box>
170
+ );
171
+ }}
172
+ </Index>
173
+ </scrollbox>
157
174
  </box>
158
175
  );
159
176
  }
@@ -23,7 +23,7 @@ type Step =
23
23
  | { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
24
24
  | { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
25
25
  | { kind: 'select'; title: string; label: string; options: string[]; note?: string }
26
- | { kind: 'text'; title: string; label: string; placeholder?: string; prefill?: string; secret?: boolean }
26
+ | { kind: 'text'; title: string; label: string; note?: string; placeholder?: string; prefill?: string; secret?: boolean }
27
27
  | { kind: 'done' };
28
28
  type LogEntry = { icon: string; label: string; detail?: string };
29
29
 
@@ -219,21 +219,32 @@ export function SetupWizard(props: {
219
219
  };
220
220
  }
221
221
  if (currentRoute === 'llm-baseurl') {
222
- const baseUrl = llm().baseUrl || defaultBaseUrl(llm().provider);
223
- return { kind: 'text', title: 'LLM configuration', label: 'Base URL', prefill: baseUrl, placeholder: baseUrl };
222
+ const example = defaultBaseUrl(llm().provider);
223
+ return {
224
+ kind: 'text',
225
+ title: 'LLM configuration',
226
+ label: `Base URL${example ? ` (example: ${example})` : ''}`,
227
+ prefill: llm().baseUrl || '',
228
+ };
224
229
  }
225
230
  if (currentRoute === 'llm-apikey') {
226
231
  return { kind: 'text', title: 'LLM configuration', label: 'API key (required)', secret: true };
227
232
  }
228
233
  if (currentRoute === 'llm-model') {
229
- const defaultModel = llm().model || fallbackModels(llm().provider)[0] || '';
230
- return { kind: 'text', title: 'LLM configuration', label: 'Model', prefill: defaultModel };
234
+ const example = fallbackModels(llm().provider)[0] || 'provider-agentic-model';
235
+ return {
236
+ kind: 'text',
237
+ title: 'LLM configuration',
238
+ label: `Model (example: ${example})`,
239
+ note: 'Required: an agentic model with tool/function calling support.',
240
+ prefill: llm().model || '',
241
+ };
231
242
  }
232
243
  if (currentRoute === 'vector-confirm') {
233
244
  return { kind: 'confirm', title: 'Vector search', message: 'Configure vector search?', yesLabel: 'Enable', noLabel: 'Skip' };
234
245
  }
235
246
  if (currentRoute === 'vector-baseurl') {
236
- const baseUrl = vector().baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider);
247
+ const baseUrl = vector().baseUrl || llm().baseUrl;
237
248
  return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
238
249
  }
239
250
  if (currentRoute === 'vector-apikey') {
@@ -462,7 +473,10 @@ export function SetupWizard(props: {
462
473
  if (currentRoute === 'llm-provider') {
463
474
  const provider = normalizeProvider(value);
464
475
  setLlm((old: any) => {
465
- const baseUrl = (old.provider === provider && old.baseUrl) ? old.baseUrl : defaultBaseUrl(provider);
476
+ const asksForBaseUrl = provider === 'ollama' || provider === 'openai-compatible';
477
+ const baseUrl = (old.provider === provider && old.baseUrl)
478
+ ? old.baseUrl
479
+ : asksForBaseUrl ? '' : defaultBaseUrl(provider);
466
480
  return { ...old, provider, baseUrl, ...(provider === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
467
481
  });
468
482
  if (provider === 'ollama' || provider === 'openai-compatible') return navigate('llm-baseurl');
@@ -560,7 +574,8 @@ export function SetupWizard(props: {
560
574
  return;
561
575
  }
562
576
  if (currentRoute === 'llm-baseurl') {
563
- setLlm((old: any) => ({ ...old, baseUrl: value || old.baseUrl || defaultBaseUrl(old.provider) }));
577
+ if (!value) return setError('Base URL is required.');
578
+ setLlm((old: any) => ({ ...old, baseUrl: value }));
564
579
  if (llm().provider === 'ollama') return navigate('llm-model');
565
580
  return navigate('llm-apikey');
566
581
  }
@@ -570,7 +585,8 @@ export function SetupWizard(props: {
570
585
  return navigate('llm-model');
571
586
  }
572
587
  if (currentRoute === 'vector-baseurl') {
573
- const baseUrl = value || vector().baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider);
588
+ const baseUrl = value || vector().baseUrl || llm().baseUrl;
589
+ if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
574
590
  setVector((old: any) => ({ ...old, provider: llm().provider, baseUrl }));
575
591
  return navigate('vector-apikey');
576
592
  }
@@ -750,6 +766,9 @@ export function SetupWizard(props: {
750
766
  <Show when={(step() as any).message || (step() as any).label}>
751
767
  <text height={1} fg="#D6DEE8">{(step() as any).message ?? (step() as any).label}</text>
752
768
  </Show>
769
+ <Show when={(step() as any).note}>
770
+ <text height={1} fg="#9CA3AF">{(step() as any).note}</text>
771
+ </Show>
753
772
  <Show when={step().kind === 'text'}>
754
773
  <box
755
774
  height={5}
@@ -0,0 +1,67 @@
1
+ const HTTP_URL = /https?:\/\/[^\s<>"'`]+/giu;
2
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/u;
3
+
4
+ function trimUrlEnd(value) {
5
+ let url = value.replace(TRAILING_PUNCTUATION, '');
6
+ while (url.endsWith(')') && (url.match(/\)/g)?.length ?? 0) > (url.match(/\(/g)?.length ?? 0)) {
7
+ url = url.slice(0, -1);
8
+ }
9
+ while (url.endsWith(']') && (url.match(/\]/g)?.length ?? 0) > (url.match(/\[/g)?.length ?? 0)) {
10
+ url = url.slice(0, -1);
11
+ }
12
+ return url;
13
+ }
14
+
15
+ export function httpLinkParts(value) {
16
+ const text = String(value ?? '');
17
+ const parts = [];
18
+ let cursor = 0;
19
+ for (const match of text.matchAll(HTTP_URL)) {
20
+ const start = match.index ?? 0;
21
+ const candidate = match[0];
22
+ const url = trimUrlEnd(candidate);
23
+ if (!url) continue;
24
+ try {
25
+ const parsed = new URL(url);
26
+ if (!parsed.hostname || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) continue;
27
+ } catch {
28
+ continue;
29
+ }
30
+ if (start > cursor) parts.push({ text: text.slice(cursor, start) });
31
+ parts.push({ text: url, url });
32
+ cursor = start + url.length;
33
+ }
34
+ if (cursor < text.length) parts.push({ text: text.slice(cursor) });
35
+ return parts.length > 0 ? parts : [{ text }];
36
+ }
37
+
38
+ export function wrapHttpLinks(value, width) {
39
+ const columns = Math.max(12, Number(width) || 12);
40
+ const rows = [[]];
41
+ let used = 0;
42
+ for (const part of httpLinkParts(value)) {
43
+ if (part.url) {
44
+ const parsed = new URL(part.url);
45
+ const label = `[link: ${parsed.hostname}]`;
46
+ if (used > 0 && used + label.length > columns) {
47
+ rows.push([]);
48
+ used = 0;
49
+ }
50
+ rows.at(-1).push({ text: label, url: part.url });
51
+ used += label.length;
52
+ continue;
53
+ }
54
+ let rest = part.text;
55
+ while (rest.length > 0) {
56
+ if (used === columns) {
57
+ rows.push([]);
58
+ used = 0;
59
+ }
60
+ const piece = rest.slice(0, columns - used);
61
+ rows.at(-1).push({ text: piece });
62
+ used += piece.length;
63
+ rest = rest.slice(piece.length);
64
+ }
65
+ }
66
+ return rows;
67
+ }
package/src/shell/repl.js CHANGED
@@ -82,7 +82,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
82
82
  '/uploads': 'List or clean uploaded documents.',
83
83
  '/clear': 'Clear the conversation screen.',
84
84
  '/chat': 'Switch free text to direct LLM chat without tools.',
85
- '/agent': 'Switch free text to the LangGraph agent with tools.',
85
+ '/agent': 'Switch to agent mode, or run one agent request with /agent <question>.',
86
86
  '/openui': 'Open the workspace web UI in the browser.',
87
87
  '/run': 'Inspect, cancel, kill runtime runs, or start a capability run.',
88
88
  '/approve': 'Approve a pending runtime run or tool.',
@@ -797,8 +797,73 @@ function rememberProductionActivity(session, payload) {
797
797
  return true;
798
798
  }
799
799
 
800
+ const TERMINAL_PLAN_STATUSES = new Set(['done', 'completed', 'succeeded', 'success', 'failed', 'error', 'cancelled', 'canceled']);
801
+
802
+ function planTaskKey(task, index) {
803
+ return String(task?.id ?? task?.step ?? `task-${index + 1}`);
804
+ }
805
+
806
+ function errorDescription(value) {
807
+ if (!value) return null;
808
+ if (typeof value === 'string') return value.trim() || null;
809
+ if (value instanceof Error) return value.message;
810
+ if (typeof value === 'object') {
811
+ return errorDescription(value.message ?? value.reason ?? value.detail ?? value.description ?? value.error)
812
+ ?? (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
813
+ }
814
+ return String(value);
815
+ }
816
+
817
+ function planTaskError(task, state) {
818
+ const activityKey = task?.activityKey ?? task?._activityKey ?? task?.ownerActivityKey;
819
+ const activity = (state?.activities ?? []).find((item) =>
820
+ (activityKey && String(item?.key) === String(activityKey))
821
+ || (task?.id != null && String(item?.taskId ?? '') === String(task.id)));
822
+ return errorDescription(
823
+ task?.error
824
+ ?? task?.result?.error
825
+ ?? task?.result?.reason
826
+ ?? activity?.error
827
+ ?? activity?.detail
828
+ ?? (['failed', 'error'].includes(String(state?.status ?? '').toLowerCase()) ? state?.evaluation?.reason : null),
829
+ );
830
+ }
831
+
832
+ export function appendRuntimePlanCompletionMessages(session, state) {
833
+ const plan = Array.isArray(state?.plan) ? state.plan : [];
834
+ const runId = String(state?.runId ?? 'runtime');
835
+ const current = new Map(plan.map((task, index) => [planTaskKey(task, index), String(task?.status ?? 'pending').toLowerCase()]));
836
+ if (session._runtimePlanRunId !== runId || !(session._runtimePlanStatuses instanceof Map)) {
837
+ session._runtimePlanRunId = runId;
838
+ session._runtimePlanStatuses = current;
839
+ return 0;
840
+ }
841
+
842
+ let appended = 0;
843
+ plan.forEach((task, index) => {
844
+ const key = planTaskKey(task, index);
845
+ const status = current.get(key);
846
+ const previous = session._runtimePlanStatuses.get(key);
847
+ if (!TERMINAL_PLAN_STATUSES.has(status) || TERMINAL_PLAN_STATUSES.has(previous)) return;
848
+ const label = String(task?.label ?? task?.description ?? task?.name ?? `Job ${key}`);
849
+ const failed = status === 'failed' || status === 'error';
850
+ const cancelled = status === 'cancelled' || status === 'canceled';
851
+ const error = failed ? planTaskError(task, state) : null;
852
+ const content = failed
853
+ ? `✗ Job terminé en erreur : ${label}\nStatus: ${status}${error ? `\nErreur: ${error}` : ''}`
854
+ : cancelled
855
+ ? `○ Job terminé : ${label}\nStatus: ${status}`
856
+ : `✓ Job terminé : ${label}\nStatus: ${status}`;
857
+ conversationMessages(session).push({ role: 'command', content });
858
+ appended += 1;
859
+ });
860
+ session._runtimePlanStatuses = current;
861
+ return appended;
862
+ }
863
+
800
864
  export function applyRuntimeStateToShellSession(session, state) {
801
865
  if (!state || typeof state !== 'object') return false;
866
+ appendRuntimePlanCompletionMessages(session, state);
802
867
  const displayState = sanitizeRuntimeStateForDisplay(state);
803
868
  session.agentProjection = {
804
869
  conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
@@ -872,6 +937,33 @@ export function shouldHandleFreeTextLocally(_line, session, { llmAvailable = Boo
872
937
  return { local: true, classification };
873
938
  }
874
939
 
940
+ // Shared by both the one-shot `/agent <question>` path in runLine and the
941
+ // interactive free-text-to-runtime path below: maps a submitRuntimeRun()
942
+ // outcome to the conversation message(s) it produces and a short log line,
943
+ // so the classification → message mapping isn't duplicated per call site.
944
+ function applyRuntimeOutcome(session, outcome, onLog, {
945
+ ambiguousFallback = 'Runtime could not classify that message.',
946
+ } = {}) {
947
+ if (outcome.kind === 'accepted') {
948
+ onLog('runtime: run accepted');
949
+ } else if (outcome.kind === 'queued') {
950
+ conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
951
+ onLog('runtime: control queued');
952
+ } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
953
+ conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime control message accepted.') });
954
+ onLog(`runtime: ${outcome.kind}`);
955
+ } else if (outcome.kind === 'ambiguous') {
956
+ conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? ambiguousFallback) });
957
+ onLog('runtime: ambiguous control');
958
+ } else if (outcome.result?.explanation) {
959
+ conversationMessages(session).push({ role: 'command', content: String(outcome.result.explanation) });
960
+ onLog(`runtime: ${outcome.kind}`);
961
+ } else {
962
+ conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
963
+ onLog(`runtime error: ${outcome.message}`);
964
+ }
965
+ }
966
+
875
967
  export async function submitRuntimeRun(line, { runtime, session }) {
876
968
  const workspace = session.workspace ?? null;
877
969
  try {
@@ -1339,6 +1431,22 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1339
1431
  return { exit: false };
1340
1432
  }
1341
1433
 
1434
+ const oneShotAgentInput = /^\/agent\s+(.+)$/s.exec(trimmed)?.[1]?.trim();
1435
+ if (oneShotAgentInput) {
1436
+ if (!runtime?.url) {
1437
+ const message = recordRuntimeUnavailableAgentInput(session, oneShotAgentInput, runtime);
1438
+ onStep?.(message ?? 'runtime: disconnected');
1439
+ onUpdate?.();
1440
+ return { exit: false, oneShotAgent: true };
1441
+ }
1442
+ conversationMessages(session).push({ role: 'user', content: oneShotAgentInput });
1443
+ onUpdate?.();
1444
+ const outcome = await submitRuntimeRun(oneShotAgentInput, { runtime, session });
1445
+ applyRuntimeOutcome(session, outcome, (msg) => onStep?.(msg));
1446
+ onUpdate?.();
1447
+ return { exit: false, oneShotAgent: true, runtimeOutcome: outcome };
1448
+ }
1449
+
1342
1450
  if (trimmed.startsWith('/')) {
1343
1451
  onStep?.(`Shell: ${trimmed}`);
1344
1452
  const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
@@ -1755,28 +1863,12 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1755
1863
  }
1756
1864
  conversationMessages(session).push({ role: 'user', content: line });
1757
1865
  const outcome = await submitRuntimeRun(line, { runtime, session });
1758
- if (outcome.kind === 'accepted') {
1759
- activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1760
- } else if (outcome.kind === 'queued') {
1761
- // Server-localized acknowledgement (src/runtime/controlMessages.js).
1762
- conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
1763
- activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
1764
- } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
1765
- const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
1766
- conversationMessages(session).push({ role: 'command', content: explanation });
1767
- activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
1768
- } else if (outcome.kind === 'ambiguous') {
1769
- conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.') });
1770
- activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
1771
- } else if (outcome.result?.explanation) {
1772
- // cancel / approve / modify_run and any future control kinds:
1773
- // always surface the server's localized explanation.
1774
- conversationMessages(session).push({ role: 'command', content: String(outcome.result.explanation) });
1775
- activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
1776
- } else {
1777
- conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
1778
- activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
1779
- }
1866
+ applyRuntimeOutcome(
1867
+ session,
1868
+ outcome,
1869
+ (msg) => { activityLines = [...activityLines, msg].slice(-LOWER_DETAIL_ROWS); },
1870
+ { ambiguousFallback: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' },
1871
+ );
1780
1872
  syncRuntimeState();
1781
1873
  } else if (!session.chatMode && !line.trim().startsWith('/')) {
1782
1874
  const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  applyRuntimeStateToShellSession,
8
+ appendRuntimePlanCompletionMessages,
8
9
  chatReadTools,
9
10
  createSession,
10
11
  runHeadlessChatTurn,
@@ -17,6 +18,32 @@ import {
17
18
  shouldHandleFreeTextLocally,
18
19
  submitRuntimeRun,
19
20
  } from './repl.js';
21
+ import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
22
+
23
+ test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
24
+ assert.deepEqual(httpLinkParts('Voir https://example.test/docs?q=ok.'), [
25
+ { text: 'Voir ' },
26
+ { text: 'https://example.test/docs?q=ok', url: 'https://example.test/docs?q=ok' },
27
+ { text: '.' },
28
+ ]);
29
+ });
30
+
31
+ test('long ShellUI URLs use one short label with the complete link target', () => {
32
+ const url = 'https://example.test/a/very/long/document';
33
+ const links = wrapHttpLinks(url, 12).flat().filter((part) => part.url);
34
+ assert.deepEqual(links, [{ text: '[link: example.test]', url }]);
35
+ });
36
+
37
+ test('ShellUI never splits a link label at the end of a line', () => {
38
+ const url = 'https://example.test/a/very/long/document';
39
+ const rows = wrapHttpLinks(`Voir maintenant ${url}`, 20);
40
+ assert.equal(rows.length, 2);
41
+ assert.deepEqual(rows[1], [{ text: '[link: example.test]', url }]);
42
+ });
43
+
44
+ test('ShellUI leaves malformed URLs as plain text', () => {
45
+ assert.deepEqual(httpLinkParts('Erreur: https://'), [{ text: 'Erreur: https://' }]);
46
+ });
20
47
 
21
48
  test('runtime display preserves a failed plan and its diagnostic evidence', () => {
22
49
  const state = {
@@ -131,6 +158,47 @@ test('applyRuntimeStateToShellSession clears terminal plan and activities when r
131
158
  assert.deepEqual(session.agentProjection.planPatches, []);
132
159
  });
133
160
 
161
+ test('runtime plan completion is appended once to the ShellUI conversation', () => {
162
+ const session = createSession();
163
+ applyRuntimeStateToShellSession(session, {
164
+ runId: 'run-1',
165
+ status: 'running',
166
+ plan: [{ id: 'build', label: 'Build documentation', status: 'running' }],
167
+ });
168
+ applyRuntimeStateToShellSession(session, {
169
+ runId: 'run-1',
170
+ status: 'running',
171
+ plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
172
+ });
173
+ applyRuntimeStateToShellSession(session, {
174
+ runId: 'run-1',
175
+ status: 'done',
176
+ plan: [{ id: 'build', label: 'Build documentation', status: 'done' }],
177
+ });
178
+
179
+ assert.deepEqual(conversationMessages(session), [{
180
+ role: 'command',
181
+ content: '✓ Job terminé : Build documentation\nStatus: done',
182
+ }]);
183
+ });
184
+
185
+ test('runtime plan failure includes its available error description', () => {
186
+ const session = createSession();
187
+ appendRuntimePlanCompletionMessages(session, {
188
+ runId: 'run-2',
189
+ plan: [{ id: 'ingest', description: 'Ingest pages', status: 'running' }],
190
+ });
191
+ appendRuntimePlanCompletionMessages(session, {
192
+ runId: 'run-2',
193
+ plan: [{ id: 'ingest', description: 'Ingest pages', status: 'failed', result: { error: { message: 'Index unavailable' } } }],
194
+ });
195
+
196
+ assert.deepEqual(conversationMessages(session), [{
197
+ role: 'command',
198
+ content: '✗ Job terminé en erreur : Ingest pages\nStatus: failed\nErreur: Index unavailable',
199
+ }]);
200
+ });
201
+
134
202
  test('direct chat system prompt forbids unsolicited next steps', async () => {
135
203
  const session = createSession();
136
204
  let systemPrompt = '';
@@ -223,6 +291,48 @@ test('submitRuntimeRun reports a non-409 error without throwing or calling /cont
223
291
  }
224
292
  });
225
293
 
294
+ test('/agent <question> submits one runtime request and remains in chat mode', async () => {
295
+ const restore = stubFetch(async (url, init) => {
296
+ assert.equal(pathOf(url), '/run');
297
+ assert.equal(JSON.parse(String(init.body)).input, 'lance ingestion');
298
+ return jsonResponse(202, { accepted: true, runId: 'run-one-shot' });
299
+ });
300
+ try {
301
+ const session = createSession();
302
+ session.chatMode = true;
303
+ const result = await runLine('/agent lance ingestion', {
304
+ agent: null,
305
+ packageJson: { version: 'test' },
306
+ session,
307
+ runtime: { url: 'http://runtime.test' },
308
+ });
309
+
310
+ assert.equal(session.chatMode, true);
311
+ assert.equal(result.oneShotAgent, true);
312
+ assert.equal(result.runtimeOutcome.kind, 'accepted');
313
+ assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion' }]);
314
+ } finally {
315
+ restore();
316
+ }
317
+ });
318
+
319
+ test('/agent <question> reports an unavailable runtime without leaving chat mode', async () => {
320
+ const session = createSession();
321
+ session.chatMode = true;
322
+
323
+ const result = await runLine('/agent lance ingestion', {
324
+ agent: null,
325
+ packageJson: { version: 'test' },
326
+ session,
327
+ runtime: { error: 'runtime stopped' },
328
+ });
329
+
330
+ assert.equal(session.chatMode, true);
331
+ assert.equal(result.oneShotAgent, true);
332
+ assert.deepEqual(conversationMessages(session).map((message) => message.role), ['user', 'command']);
333
+ assert.match(conversationMessages(session).at(-1).content, /runtime stopped/);
334
+ });
335
+
226
336
  test('runLine does not update workspace profile before Donna handles the request', async () => {
227
337
  const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-shell-'));
228
338
  mkdirSync(join(workspacePath, '.wiki'), { recursive: true });