@dotdrelle/wiki-manager 0.15.52 → 0.15.54

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
@@ -118,7 +118,7 @@ and a *replaceable* toolbox of *external* MCP servers — to produce the **core
118
118
  wiki** outputs, all driven by an agentic, multi-model orchestrator and grounded
119
119
  in isolated workspaces.
120
120
 
121
- ![wikiLLM functional diagram — inputs, MCP calls and outputs around the agentic orchestrator and workspaces](docs/architecture.svg)
121
+ ![wikiLLM functional diagram — inputs, MCP calls and outputs around the agentic orchestrator and workspaces](https://raw.githubusercontent.com/dotdrelle/llm-wiki-manager/main/docs/architecture.png)
122
122
 
123
123
  ## Quick start — your first wiki in ~5 minutes
124
124
 
@@ -78,5 +78,24 @@
78
78
  # resources:
79
79
  # limits:
80
80
  # cpus: '2.0'
81
+ #
82
+ # ── Host timezone (opt-in) ────────────────────────────────────────────────────
83
+ #
84
+ # Containers default to UTC, so runtime/production logs can read an hour or two
85
+ # off the host's wall clock. Mount the host's timezone to make them agree. This
86
+ # is deliberately NOT in the packaged file: the mount assumes /etc/localtime
87
+ # exists on the host (true on most Linux/macOS hosts, but not all), and a
88
+ # timezone is a property of the machine, not of the workspace.
89
+ #
90
+ # services:
91
+ # serve:
92
+ # volumes:
93
+ # - /etc/localtime:/etc/localtime:ro
94
+ # mcp-http:
95
+ # volumes:
96
+ # - /etc/localtime:/etc/localtime:ro
97
+ # production-mcp:
98
+ # volumes:
99
+ # - /etc/localtime:/etc/localtime:ro
81
100
 
82
101
  services: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.52",
3
+ "version": "0.15.54",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -70,7 +70,7 @@ const SHELL_RUN_COMMAND_TOOL = {
70
70
  description: [
71
71
  'Run a deterministic wiki-manager slash command inside the current shell session.',
72
72
  'Allowed commands: /workspace list, /workspace init <name> [path], /use <workspace>, /config, /status, /services, /skills, /skills show <name>, /skills run <name>, /upload <path>, /upload convert <id|pending>.',
73
- 'Do not use for arbitrary system shell commands, /workspace delete, /mcp call, /wiki run, /start, /stop, /logs, or /exit.',
73
+ 'Do not use for arbitrary system shell commands, /workspace delete, /wiki run, /start, /stop, /logs, or /exit.',
74
74
  ].join(' '),
75
75
  parameters: {
76
76
  type: 'object',
@@ -522,6 +522,27 @@ function delegationBlockerForDonna(rawFailure) {
522
522
  });
523
523
  }
524
524
 
525
+ function isUnresolvedTargetFailure(rawFailure) {
526
+ return /file does not exist|does not exist|no files match/i.test(rawFailure);
527
+ }
528
+
529
+ function unresolvedTargetForDonna(rawFailure) {
530
+ const cleaned = String(rawFailure ?? '')
531
+ .replace(/\b(?:provider|endpoint)=[^\s,]+/g, '')
532
+ .replace(/\[[^\]]*\.(?:agent_plan|agent_execute)\]/g, '')
533
+ .replace(/\s*Available capabilities:\s*[\s\S]*$/i, '')
534
+ .replace(/\s*<-\s*[\s\S]*$/s, '')
535
+ .replace(/\s{2,}/g, ' ')
536
+ .trim();
537
+ return JSON.stringify({
538
+ delegated: false,
539
+ blocker: 'unresolved_target',
540
+ reason: cleaned,
541
+ instruction:
542
+ 'The target the user named does not match an existing file. Look up the available targets with the read-only list tools, then retry the delegation with the exact resolved path, or ask the user to confirm which target they meant. Never widen to an all-targets operation, and never expose exception names, tool names, or internal routing details.',
543
+ });
544
+ }
545
+
525
546
  function summarizeToolArguments(rawArguments) {
526
547
  if (!rawArguments || rawArguments === '{}') return '';
527
548
  try {
@@ -869,17 +890,13 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
869
890
  409 garde tout son sens.
870
891
  */
871
892
  if (typeof session?._delegateWithinRun === 'function') {
872
- try {
873
- const inRun = await session._delegateWithinRun(objective);
874
- return JSON.stringify({
875
- delegated: true,
876
- runId: inRun.runId,
877
- summary: inRun.summary ?? null,
878
- message: `Action lancée (${String(inRun.runId).slice(0, 8)}) après validation du plan réel : ${inRun.summary?.tasks ?? 0} tâche(s), ${inRun.summary?.agent ?? 'agent résolu'}. Exécution en cours.`,
879
- });
880
- } catch (err) {
881
- return `Délégation refusée : ${err instanceof Error ? err.message : String(err)}`;
882
- }
893
+ const inRun = await session._delegateWithinRun(objective);
894
+ return JSON.stringify({
895
+ delegated: true,
896
+ runId: inRun.runId,
897
+ summary: inRun.summary ?? null,
898
+ message: `Action lancée (${String(inRun.runId).slice(0, 8)}) après validation du plan réel : ${inRun.summary?.tasks ?? 0} tâche(s), ${inRun.summary?.agent ?? 'agent résolu'}. Exécution en cours.`,
899
+ });
883
900
  }
884
901
  const result = await postRuntimeDelegate(objective, { url, workspace });
885
902
  return result?.runId
@@ -1877,7 +1894,8 @@ export function createAgentGraph(options = {}) {
1877
1894
  if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
1878
1895
  const delegationFailure = resultText
1879
1896
  .replace(/^Runtime control error \(delegate\):\s*/i, '')
1880
- .replace(/^Delegation failed during objective_resolution:\s*/i, '');
1897
+ .replace(/^Delegation failed during objective_resolution:\s*/i, '')
1898
+ .replace(/^Delegation failed during agent_plan:\s*/i, '');
1881
1899
  const needsInput = delegationFailure.match(/^Delegation requires input:\s*(.+)$/i);
1882
1900
  if (needsInput) {
1883
1901
  // Missing provider-required fields are a conversational blocker,
@@ -1889,6 +1907,11 @@ export function createAgentGraph(options = {}) {
1889
1907
  missingRequiredFields: needsInput[1].split(',').map((item) => item.trim()).filter(Boolean),
1890
1908
  instruction: 'Ask the user for the missing required information. Do not expose internal validation details.',
1891
1909
  });
1910
+ } else if (isUnresolvedTargetFailure(delegationFailure)) {
1911
+ // A named target that resolves to nothing is not an unsupported
1912
+ // action: Donna can look it up and retry (or ask), so the turn
1913
+ // must not be marked terminal here.
1914
+ resultText = unresolvedTargetForDonna(delegationFailure);
1892
1915
  } else {
1893
1916
  terminalFailure = delegationFailure;
1894
1917
  resultText = delegationBlockerForDonna(delegationFailure);
@@ -476,7 +476,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
476
476
  mainCalls += 1;
477
477
  if (mainCalls === 1) return {
478
478
  content: null, message: { role: 'assistant', content: null },
479
- tool_calls: [{ id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"deliver","arguments":{"template":"Quarterly report"},"selectionKind":"explicit_name"}' } }],
479
+ tool_calls: [{ id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"deliver","arguments":{"deliverable":"Quarterly report"},"selectionKind":"explicit_name"}' } }],
480
480
  };
481
481
  return { content: 'Skill mis en file.', message: { role: 'assistant', content: 'Skill mis en file.' }, tool_calls: null };
482
482
  },
@@ -489,7 +489,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
489
489
  // quelles compétences sont déjà ouvertes au-dessus de lui.
490
490
  assert.deepEqual(calls, [[
491
491
  'deliver',
492
- { template: 'Quarterly report' },
492
+ { deliverable: 'Quarterly report' },
493
493
  { selectionKind: 'explicit_name', turnId: 'turn-skill-1', skillStack: [] },
494
494
  ]]);
495
495
  });
@@ -1334,6 +1334,42 @@ test('a delegation missing required provider inputs returns to Donna for clarifi
1334
1334
  }
1335
1335
  });
1336
1336
 
1337
+ test('a delegation whose named target does not resolve returns to Donna to resolve, not as a terminal refusal', async () => {
1338
+ let calls = 0;
1339
+ const session = sessionBase({
1340
+ runtime: { url: 'http://runtime.test' },
1341
+ _delegateWithinRun: async () => {
1342
+ throw new Error('Delegation failed during agent_plan: provider=production endpoint=http://127.0.0.1:3000/mcp/ templates file does not exist: basic note');
1343
+ },
1344
+ llm: {
1345
+ async completeWithTools() {
1346
+ calls += 1;
1347
+ if (calls === 1) {
1348
+ return {
1349
+ content: null,
1350
+ message: { role: 'assistant', content: null },
1351
+ tool_calls: [{
1352
+ id: 'delegate-target',
1353
+ type: 'function',
1354
+ function: { name: 'runtime__delegate', arguments: '{"objective":"build basic note"}' },
1355
+ }],
1356
+ };
1357
+ }
1358
+ return {
1359
+ content: 'Je n’ai trouvé aucun template « basic note ».',
1360
+ message: { role: 'assistant', content: 'Je n’ai trouvé aucun template « basic note ».' },
1361
+ tool_calls: null,
1362
+ };
1363
+ },
1364
+ },
1365
+ });
1366
+
1367
+ const result = await createAgentGraph().invoke({ input: 'build basic note', session });
1368
+ assert.equal(result.terminalToolFailure, false);
1369
+ assert.equal(result.response, 'Je n’ai trouvé aucun template « basic note ».');
1370
+ assert.doesNotMatch(result.response, /provider=|endpoint=|file does not exist|agent_plan/i);
1371
+ });
1372
+
1337
1373
  // Guard: the system prompt must never show a connected tool's bare name
1338
1374
  // outside its qualified server__tool form. Bare mentions are what teach the
1339
1375
  // model to emit unqualified tool calls (the cme_status incident). The bare
@@ -109,12 +109,20 @@ export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
109
109
  }
110
110
 
111
111
  /**
112
- * Fill a single-task executor's arguments from the natural-language objective,
112
+ * Fill a task's structured arguments from the natural-language objective,
113
113
  * generically — against the capability's own declared `inputSchema`, with no
114
114
  * per-agent or per-provider knowledge in the manager. This lets Donna honour
115
115
  * stated constraints ("les 10 derniers mails", "de LinkedIn") while keeping
116
116
  * `runtime__delegate` agnostic (it still only carries the objective).
117
117
  *
118
+ * Used on BOTH delegation branches:
119
+ * - executor-only (`singleTaskOnly`) agents: the extracted arguments become the
120
+ * single task's `arguments`;
121
+ * - planner (`canPlan`) agents: the extracted arguments are forwarded to
122
+ * `agent_plan` as its `arguments`, so a targeted selector (a template, a
123
+ * deliverable, a source) reaches the plan instead of widening to "all"
124
+ * (a `/wiki-build <template>` that built every template).
125
+ *
118
126
  * Degrades gracefully (cf. provider compatibility): forced tool_choice first,
119
127
  * then a JSON-text completion, then no arguments — the executor uses its own
120
128
  * defaults. It never throws and never invents identifiers.
@@ -1234,6 +1242,13 @@ async function runRuntime(argv, agent) {
1234
1242
  let fragment;
1235
1243
  if (canPlan) {
1236
1244
  try {
1245
+ const extractedArguments = await resolveExecutorArguments({
1246
+ llm: session.llm,
1247
+ objective,
1248
+ capability: provider.capability,
1249
+ workspace: session.workspace ?? context.workspace ?? '',
1250
+ signal: session._abortSignal,
1251
+ });
1237
1252
  planResult = await callMcpTool(
1238
1253
  session.mcp,
1239
1254
  provider.serverName,
@@ -1242,6 +1257,9 @@ async function runRuntime(argv, agent) {
1242
1257
  capability: selection.capability,
1243
1258
  operation: selection.operation,
1244
1259
  objective,
1260
+ ...(extractedArguments && Object.keys(extractedArguments).length > 0
1261
+ ? { arguments: extractedArguments }
1262
+ : {}),
1245
1263
  workspace: { revision: String(Date.now()) },
1246
1264
  constraints: {
1247
1265
  maxConcurrency: resolveCapabilityConcurrency(
@@ -126,6 +126,37 @@ test('argument extraction falls back to a JSON-text completion', async () => {
126
126
  assert.deepEqual(args, { query: 'from:linkedin.com' });
127
127
  });
128
128
 
129
+ const BUILD_CAPABILITY = {
130
+ description: 'Build llm-wiki deliverables from templates in templates/.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ additionalProperties: true,
134
+ properties: {
135
+ templates: { type: 'array', items: { type: 'string' } },
136
+ stabilize: { type: 'boolean' },
137
+ },
138
+ },
139
+ };
140
+
141
+ test('argument extraction maps a skill template selector to the templates array', async () => {
142
+ // Regression: a skill carries its `template` parameter as a natural-language
143
+ // "User parameters:" block. The delegation path must turn it back into the
144
+ // structured `templates` array, or a targeted build widens to every template.
145
+ const llm = {
146
+ completeWithTools: async () => ({
147
+ tool_calls: [{
148
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ templates: ['overview'] }) },
149
+ }],
150
+ }),
151
+ };
152
+ const args = await resolveExecutorArguments({
153
+ llm,
154
+ objective: 'Build deliverables from the current wiki within the exact scope requested by the template parameter.\n\nUser parameters:\ntemplate: overview',
155
+ capability: BUILD_CAPABILITY,
156
+ });
157
+ assert.deepEqual(args, { templates: ['overview'] });
158
+ });
159
+
129
160
  test('argument extraction stays agnostic and safe when it cannot extract', async () => {
130
161
  // No inputSchema → no extraction attempted at all.
131
162
  assert.deepEqual(
@@ -32,9 +32,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
32
32
  import {
33
33
  cancelQueueItem,
34
34
  clearFinishedQueueItems,
35
- enqueueProductionJob,
36
35
  formatQueue,
37
- productionLockBusy,
38
36
  } from '../core/jobQueue.js';
39
37
  import {
40
38
  listWikircProfiles,
@@ -599,11 +597,6 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
599
597
  }
600
598
 
601
599
 
602
- function formatMcpCallActivity(serverName, toolName, resultText) {
603
- if (serverName === 'production') return null;
604
- return formatActivitySummary(serverName, toolName, resultText);
605
- }
606
-
607
600
  function publishPayloadActivity(session, payload, context = {}) {
608
601
  const activity = extractActivity(payload, context);
609
602
  if (!activity) return null;
@@ -748,7 +741,7 @@ ${helpPair('/stop [all|everything|service|agents]', 'Stop service(s)', '/logs <s
748
741
  ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
749
742
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
750
743
  ${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
751
- ${helpPair('/mcp tools [mcp]', 'MCP tools', '/mcp call ...', 'Call MCP tool')}
744
+ ${helpPair('/mcp tools [mcp]', 'MCP tools', '', '')}
752
745
  ${helpPair('/connector list', 'Connector auth status', '/connector auth <n>', 'Authorize connector')}
753
746
  ${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
754
747
  ${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
@@ -1218,40 +1211,7 @@ export async function handleSlashCommand(line, context) {
1218
1211
  }
1219
1212
  return { output: formatMcpTools(context.session.mcp, filterName) };
1220
1213
  }
1221
- if (subcommand === 'call') {
1222
- const serverName = args[2];
1223
- const toolName = args[3];
1224
- if (!serverName || !toolName) {
1225
- return { output: 'Usage: /mcp call <mcp> <tool> [json]' };
1226
- }
1227
- try {
1228
- const rawArgs = args.slice(4).join(' ');
1229
- let toolArgs = rawArgs ? JSON.parse(rawArgs) : {};
1230
- if (serverName === 'production' && toolName === 'production_start_job' && context.session.workspace && !toolArgs.callerLabel) {
1231
- toolArgs = { ...toolArgs, callerLabel: `${context.session.workspace}/wiki-manager` };
1232
- }
1233
- if (serverName === 'production' && toolName === 'production_start_job' && productionLockBusy(context.session)) {
1234
- const item = enqueueProductionJob(context.session, toolArgs, 'production lock busy');
1235
- return { output: `Queued ${item.id}: waiting ${item.workspace ?? 'no-workspace'} ${item.tool}` };
1236
- }
1237
- step(`MCP: calling ${serverName}.${toolName}…`);
1238
- const result = await callMcpTool(context.session.mcp, serverName, toolName, toolArgs);
1239
- const output = formatMcpToolResult(result);
1240
- const payload = parseJsonText(output);
1241
- if (serverName === 'production' && toolName === 'production_start_job' && payload?.ok === false && payload?.error === 'workspace_busy') {
1242
- const item = enqueueProductionJob(context.session, toolArgs, 'workspace_busy');
1243
- return { output: `Queued ${item.id}: waiting for production lock (${payload.activeJobId ?? 'active job'})` };
1244
- }
1245
- const activity = formatMcpCallActivity(serverName, toolName, output);
1246
- if (activity) step(activity);
1247
- return rawCommandResult(`/mcp call ${serverName} ${toolName}`, output);
1248
- } catch (err) {
1249
- const message = err instanceof Error ? err.message : String(err);
1250
- step(formatActivityError(serverName, toolName, err));
1251
- return { output: message };
1252
- }
1253
- }
1254
- return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
1214
+ return { output: 'Usage: /mcp <status|endpoints|tools> [mcp]' };
1255
1215
  }
1256
1216
  case 'connector': {
1257
1217
  const subcommand = args[1] ?? 'list';
@@ -34,6 +34,15 @@ test('/status MCP overview contains only connector name, port and status', () =>
34
34
  assert.doesNotMatch(output, /tools|error|detail|http/i);
35
35
  });
36
36
 
37
+ test('/mcp exposes diagnostics only and cannot directly execute arbitrary MCP tools', async () => {
38
+ const result = await handleSlashCommand('/mcp call production production_start_job {"step":"ingest"}', {
39
+ packageJson: { version: 'test' },
40
+ session: { mcp: {} },
41
+ });
42
+
43
+ assert.equal(result.output, 'Usage: /mcp <status|endpoints|tools> [mcp]');
44
+ });
45
+
37
46
  test('/status base URL displays only its domain while retaining the full link', () => {
38
47
  assert.equal(
39
48
  compactBaseUrl('https://albert.api.etalab.gouv.fr/v1'),
@@ -2,7 +2,7 @@ import { normalizeActivity } from './activity.js';
2
2
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
3
  import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
4
  import { formatRuntimeLogPayload } from './runtimeLog.js';
5
- import { projectSkillChains } from './skillChainView.js';
5
+ import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
6
6
  import { projectWorkflow } from './workflow.js';
7
7
  import { validateContractInDev } from '../contracts/schemas.js';
8
8
  import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
@@ -269,6 +269,7 @@ function applyEvent(state, event) {
269
269
  state.planRevision = 0;
270
270
  state.planPatches = [];
271
271
  state.summary = null;
272
+ pruneTerminalControlItems(state.controlQueue);
272
273
  return;
273
274
  case 'user_message':
274
275
  state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
@@ -711,6 +712,44 @@ function finishControlByRun(queue, runId, status, finishedAt) {
711
712
  item.updatedAt = finishedAt;
712
713
  }
713
714
 
715
+ /*
716
+ A new run makes the previous control items history.
717
+
718
+ `run_started` already resets plan, activities and logs, but the control queue
719
+ was left to accumulate: a cancelled or failed chain stayed in the CHAIN panel
720
+ across the next plan, and its terminal items kept counting in the queue ("Queue
721
+ (14)" over 4 live items). Prune here, not in the UI, so both projections agree.
722
+
723
+ A chain is dropped only once EVERY item is terminal — the active chain always
724
+ has a running/queued item and is therefore never pruned mid-flight. Standalone
725
+ control items (no chainId) are dropped as soon as they are terminal.
726
+ */
727
+ function pruneTerminalControlItems(queue) {
728
+ const byChain = new Map();
729
+ const standalone = [];
730
+ for (const item of queue) {
731
+ if (item.chainId) {
732
+ if (!byChain.has(item.chainId)) byChain.set(item.chainId, []);
733
+ byChain.get(item.chainId).push(item);
734
+ } else {
735
+ standalone.push(item);
736
+ }
737
+ }
738
+ const drop = new Set();
739
+ for (const items of byChain.values()) {
740
+ if (items.every((item) => CONTROL_TERMINAL_STATUSES.has(String(item.status ?? '').toLowerCase()))) {
741
+ for (const item of items) drop.add(item.id);
742
+ }
743
+ }
744
+ for (const item of standalone) {
745
+ if (CONTROL_TERMINAL_STATUSES.has(String(item.status ?? '').toLowerCase())) drop.add(item.id);
746
+ }
747
+ if (!drop.size) return;
748
+ for (let i = queue.length - 1; i >= 0; i--) {
749
+ if (drop.has(queue[i].id)) queue.splice(i, 1);
750
+ }
751
+ }
752
+
714
753
  function appendAssistantDelta(state, delta) {
715
754
  if (!delta) return;
716
755
  const last = state.conversation.at(-1);
@@ -401,6 +401,35 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
401
401
  assert.equal(projection.controlQueue[1].status, 'cancelled');
402
402
  });
403
403
 
404
+ test('reduceAgentEvents: run_started prunes terminal control items and fully terminal chains', () => {
405
+ const ts = '2026-01-01T00:00:00.000Z';
406
+ const enqueue = (id, extra = {}) => createAgentEvent('control_enqueued', {
407
+ origin: 'runtime',
408
+ workspace: 'docs',
409
+ payload: { id, workspace: 'docs', input: 'objective', createdAt: ts, ...extra },
410
+ });
411
+ const projection = reduceAgentEvents([
412
+ // Fully terminal chain (done + skipped): becomes history, pruned.
413
+ enqueue('chain-old-1', { chainId: 'chain-old', chainSequence: 1 }),
414
+ enqueue('chain-old-2', { chainId: 'chain-old', chainSequence: 2 }),
415
+ createAgentEvent('control_started', { origin: 'runtime', runId: 'run-old-1', workspace: 'docs', payload: { id: 'chain-old-1', runId: 'run-old-1' } }),
416
+ createAgentEvent('run_done', { origin: 'runtime', runId: 'run-old-1', workspace: 'docs' }),
417
+ createAgentEvent('control_skipped', { origin: 'runtime', workspace: 'docs', payload: { id: 'chain-old-2', reason: 'required_predecessor_failed' } }),
418
+ // Standalone terminal item: pruned.
419
+ enqueue('standalone-old'),
420
+ createAgentEvent('control_cancelled', { origin: 'runtime', workspace: 'docs', payload: { id: 'standalone-old' } }),
421
+ // Active chain (done + queued): must survive the prune.
422
+ enqueue('chain-active-1', { chainId: 'chain-active', chainSequence: 1 }),
423
+ enqueue('chain-active-2', { chainId: 'chain-active', chainSequence: 2 }),
424
+ createAgentEvent('control_started', { origin: 'runtime', runId: 'run-active-1', workspace: 'docs', payload: { id: 'chain-active-1', runId: 'run-active-1' } }),
425
+ createAgentEvent('run_done', { origin: 'runtime', runId: 'run-active-1', workspace: 'docs' }),
426
+ // A new run starts: terminal relics are history, the active chain is not.
427
+ createAgentEvent('run_started', { origin: 'runtime', runId: 'run-new', workspace: 'docs' }),
428
+ ]);
429
+
430
+ assert.deepEqual(projection.controlQueue.map((item) => item.id), ['chain-active-1', 'chain-active-2']);
431
+ });
432
+
404
433
  test('reduceAgentEvents: control_enqueued preserves a structured capabilityPlan across replay', () => {
405
434
  const capabilityPlan = {
406
435
  capability: 'workspace.restore',
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.52",
3
- "commit": "2b8e3e3"
2
+ "version": "0.15.54",
3
+ "commit": "7a84126"
4
4
  }
@@ -1,13 +1,23 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { readFileSync } from 'node:fs';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from './googleGrants.js';
6
6
 
7
+ // `agent-connectors` n'est pas cloné par le CI du manager, qui ne tire que
8
+ // llm-wiki, agent-wiki-production, agent-cme et agent-wiki-documents (voir
9
+ // check-versions.js). Les contrôles de cohérence croisée ci-dessous lisent la
10
+ // source de l'agent connectors : sans elle, on les saute plutôt que d'échouer
11
+ // sur un ENOENT. Le flux de release complet (build-and-push.sh) la fournit,
12
+ // donc le contrôle y reste effectif.
13
+ const connectorsPresent = existsSync(
14
+ fileURLToPath(new URL('../../../agent-external/agent-connectors', import.meta.url)),
15
+ );
16
+
7
17
  const connectorsSrc = (file) =>
8
18
  readFileSync(fileURLToPath(new URL(`../../../agent-external/agent-connectors/src/${file}`, import.meta.url)), 'utf8');
9
19
 
10
- test('the grant names mirror the agent, spelling included', () => {
20
+ test('the grant names mirror the agent, spelling included', { skip: !connectorsPresent }, () => {
11
21
  // `modify` est le nom de Google (scope gmail.modify) et celui de l'agent.
12
22
  // Un synonyme côté manager créerait une troisième orthographe à tenir à jour
13
23
  // — le travers qui avait déjà donné une seconde paire de variables OAuth.
@@ -26,7 +36,7 @@ test('every grant is described in plain words, never left as a bare token', () =
26
36
  }
27
37
  });
28
38
 
29
- test('the default asks for everything the agent can actually do', () => {
39
+ test('the default asks for everything the agent can actually do', { skip: !connectorsPresent }, () => {
30
40
  // Un défaut plus étroit promet des actions que l'autorisation ne couvre pas :
31
41
  // `/connector auth google` ne demandait que `read`, et l'envoi comme le
32
42
  // marquage échouaient après coup, en ressemblant à des fonctions absentes.
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.52';
4
+ const WIKI_MANAGER_VERSION = '0.15.54';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -65,7 +65,7 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
65
65
  // lines ended up visually glued at the bottom of Logs/Trace, out of
66
66
  // chronology with the shell's own timestamped lines.
67
67
  if (payload?.message != null && !payload.event) {
68
- return [timeLabel(ts), String(payload.message)].filter(Boolean).join(' ');
68
+ return [timeLabel(ts), shortenUuids(String(payload.message))].filter(Boolean).join(' ');
69
69
  }
70
70
  const time = timeLabel(ts);
71
71
  const event = eventLabel(payload.event);
@@ -75,10 +75,26 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
75
75
  if (payload.status != null) fields.push(formatField('status', payload.status));
76
76
  if (payload.percent != null) fields.push(formatField('percent', payload.percent));
77
77
  if (payload.outputs != null) fields.push(formatField('outputs', payload.outputs));
78
- if (payload.detail != null && payload.detail !== '') fields.push(`detail=${quoteIfNeeded(payload.detail)}`);
78
+ if (payload.detail != null && payload.detail !== '') fields.push(`detail=${quoteIfNeeded(shortenUuids(payload.detail))}`);
79
79
  return [time, event, ...fields].filter(Boolean).join(' ');
80
80
  }
81
81
 
82
+ // Runtime/agent ids are long UUIDs (run, task, attempt, agent instance). A full
83
+ // UUID pushed the readable fields off the line and wrapped mid-id, which is what
84
+ // made the Logs/Trace panel illegible. Collapse the UUID to its first 8 hex
85
+ // characters — the same disambiguating prefix every UI already shows — and cap
86
+ // over-long slugs so a single field never monopolises the line.
87
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
88
+
89
+ function shortenUuids(text) {
90
+ return String(text ?? '').replace(UUID_RE, (uuid) => `${uuid.slice(0, 8)}…`);
91
+ }
92
+
93
+ export function shortLogId(value, { maxLength = 40 } = {}) {
94
+ const shortened = shortenUuids(value);
95
+ return shortened.length > maxLength ? `${shortened.slice(0, maxLength - 1)}…` : shortened;
96
+ }
97
+
82
98
  export function runtimeLogMatchesFilter(line, filter = '') {
83
99
  const query = String(filter ?? '').trim();
84
100
  if (!query) return true;
@@ -122,7 +138,7 @@ function eventLabel(event) {
122
138
 
123
139
  function formatField(key, value) {
124
140
  if (value == null || value === '') return null;
125
- return `${key}=${quoteIfNeeded(value)}`;
141
+ return `${key}=${quoteIfNeeded(shortenUuids(value))}`;
126
142
  }
127
143
 
128
144
  function quoteIfNeeded(value) {
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
5
- import { compactRuntimeLogForDisplay, formatRuntimeLogPayload } from './runtimeLog.js';
5
+ import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, shortLogId } from './runtimeLog.js';
6
6
  import { emitRuntimeLog } from '../runtime/supervisor.js';
7
7
 
8
8
  const CYCLE_EVENTS = [
@@ -98,3 +98,29 @@ test('runtime display compaction leaves other log entries unchanged', () => {
98
98
  const line = '09:25:35 trace: ERROR retrieval failed message="broken"';
99
99
  assert.equal(compactRuntimeLogForDisplay(line), line);
100
100
  });
101
+
102
+ test('long UUIDs collapse to a short prefix so log lines stay on one line', () => {
103
+ const uuid = '7fadad27-0be6-4d08-96e5-664fe7ee841e';
104
+ const line = formatRuntimeLogPayload({
105
+ event: 'task.ready',
106
+ runId: uuid,
107
+ taskId: `${uuid}:taxonomy-synthesis`,
108
+ attemptId: `attempt-${uuid}`,
109
+ agentInstanceId: `production-${uuid}`,
110
+ capability: 'document.build',
111
+ operation: 'build',
112
+ }, '2026-07-08T14:42:18.000Z');
113
+
114
+ assert.match(line, /run=7fadad27…/);
115
+ assert.match(line, /task=7fadad27…:taxonomy-synthesis/);
116
+ assert.match(line, /attempt=attempt-7fadad27…/);
117
+ assert.match(line, /agentInstance=production-7fadad27…/);
118
+ assert.doesNotMatch(line, /7fadad27-0be6-4d08-96e5-664fe7ee841e/);
119
+ });
120
+
121
+ test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
122
+ const long = `${'x'.repeat(48)}-deadbeef`;
123
+ assert.match(shortLogId(long), /…$/);
124
+ assert.ok(shortLogId(long).length <= 40);
125
+ assert.equal(shortLogId('7fadad27-0be6-4d08-96e5-664fe7ee841e'), '7fadad27…');
126
+ });
@@ -16,7 +16,7 @@ const SYMBOLS = {
16
16
  skipped: '–',
17
17
  };
18
18
 
19
- const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
19
+ export const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
20
20
 
21
21
  // Objectives are whole paragraphs; a chain view needs a line. Keep the first
22
22
  // sentence, drop the parameter block the compiler appends, and never cut a word
@@ -39,19 +39,24 @@ export function projectSkillChains(controlQueue = []) {
39
39
  byChain.get(item.chainId).push(item);
40
40
  }
41
41
  return [...byChain.entries()].map(([chainId, chainItems]) => {
42
- const steps = chainItems
43
- .slice()
44
- .sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0))
45
- .map((item) => ({
46
- id: item.id,
47
- sequence: Number(item.chainSequence ?? 0),
48
- label: chainStepLabel(item.input),
49
- status: String(item.status ?? 'queued'),
50
- symbol: SYMBOLS[String(item.status ?? 'queued')] ?? '○',
51
- optional: item.optional === true,
52
- ...(item.skipReason ? { skipReason: item.skipReason } : {}),
53
- ...(item.runId ? { runId: item.runId } : {}),
54
- }));
42
+ const sorted = chainItems.slice().sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0));
43
+ const total = sorted.length;
44
+ // Compiled objectives are private execution material and never reach the
45
+ // projection, so a step cannot be labelled with its intent prose. When the
46
+ // chain has several steps, the public input is identical for all of them —
47
+ // labelling them all "/skill args" is what read as six tasks with the same
48
+ // name. Distinguish them by position instead; the skill name already sits
49
+ // in the chain head.
50
+ const steps = sorted.map((item, index) => ({
51
+ id: item.id,
52
+ sequence: Number(item.chainSequence ?? 0),
53
+ label: total > 1 ? `Step ${index + 1}/${total}` : chainStepLabel(item.input),
54
+ status: String(item.status ?? 'queued'),
55
+ symbol: SYMBOLS[String(item.status ?? 'queued')] ?? '○',
56
+ optional: item.optional === true,
57
+ ...(item.skipReason ? { skipReason: item.skipReason } : {}),
58
+ ...(item.runId ? { runId: item.runId } : {}),
59
+ }));
55
60
  return {
56
61
  chainId,
57
62
  skillName: chainItems.find((item) => item.skillName)?.skillName ?? null,
@@ -19,7 +19,7 @@ test('a chain reads as ordered steps with one short label each', () => {
19
19
  assert.equal(chain.selectionKind, 'description_match');
20
20
  assert.equal(chain.status, 'running');
21
21
  assert.deepEqual(chain.steps.map((step) => step.symbol), ['✓', '●']);
22
- assert.equal(chain.steps[0].label, 'Export the requested Confluence source, or all…');
22
+ assert.equal(chain.steps[0].label, 'Step 1/2');
23
23
  assert.equal(chain.steps[1].runId, 'run-b');
24
24
  });
25
25
 
@@ -40,7 +40,7 @@ test('after a cancel the chain shows the cancelled step and the skipped remainde
40
40
  assert.equal(chain.status, 'cancelled');
41
41
  assert.equal(
42
42
  renderSkillChain(chain),
43
- ['wiki-sync', '', '✓ Export source', ' done', '× Ingest files', ' cancelled', '– Publish results', ' skipped · chain_cancelled'].join('\n'),
43
+ ['wiki-sync', '', '✓ Step 1/3', ' done', '× Step 2/3', ' cancelled', '– Step 3/3', ' skipped · chain_cancelled'].join('\n'),
44
44
  );
45
45
  });
46
46
 
@@ -9,7 +9,7 @@ import { formatSkillsForAgent, inspectSkills } from './skills.js';
9
9
  test('matchSkillInvocation resolves only a real workspace skill', () => {
10
10
  const root = mkdtempSync(join(tmpdir(), 'skill-invocation-'));
11
11
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
12
- writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
12
+ writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver.');
13
13
  const match = matchSkillInvocation({ workspacePath: root }, '/deliver "architecture" "improve security"');
14
14
  assert.equal(match.skill.name, 'deliver');
15
15
  assert.equal(match.rawArgs, '"architecture" "improve security"');
@@ -18,7 +18,7 @@ test('matchSkillInvocation resolves only a real workspace skill', () => {
18
18
 
19
19
  test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
20
20
  assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
21
- assert.deepEqual(parseSkillArguments({ params: ['template', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { template: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
21
+ assert.deepEqual(parseSkillArguments({ params: ['deliverable', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { deliverable: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
22
22
  });
23
23
 
24
24
  test('legacy placeholders remain supported and are reported', () => {
@@ -66,8 +66,8 @@ test('catalog renders declared parameters and marks a missing description explic
66
66
  const root = mkdtempSync(join(tmpdir(), 'skill-catalog-'));
67
67
  const dir = join(root, '.wiki', 'skills');
68
68
  mkdirSync(dir, { recursive: true });
69
- writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
69
+ writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver.');
70
70
  const inspection = inspectSkills({ workspacePath: root });
71
71
  assert.deepEqual(inspection.warnings.map((item) => item.reason), ['missing_description']);
72
- assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<template> <polish>\]: workflow skill \[explicit name only\]/);
72
+ assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<deliverable> <polish>\]: workflow skill \[explicit name only\]/);
73
73
  });
@@ -14,6 +14,7 @@ import { assertValidatedFragment } from '../orchestrator/planValidator.js';
14
14
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
15
15
  import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
16
16
  import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
17
+ import { shortLogId } from '../core/runtimeLog.js';
17
18
 
18
19
  // 0 by default: automatic replans turn evaluator/replanner TEXT into
19
20
  // executable pseudo-tasks (no capability, no operation) that stall at 0%
@@ -713,7 +714,7 @@ export function skipImpossibleTasks(session, runId, { maxPasses = 50 } = {}) {
713
714
  taskId: skippedId,
714
715
  payload: { taskId: skippedId, status: 'skipped', reason: `dependency_failed:${because}` },
715
716
  }));
716
- emitRuntimeLog(session, `scheduler: skipping ${skippedId} (dependency failed: ${because})`);
717
+ emitRuntimeLog(session, `scheduler: skipping ${shortLogId(skippedId)} (dependency failed: ${because.split(', ').map((dep) => shortLogId(dep)).join(', ')})`);
717
718
  }
718
719
  total += changed;
719
720
  if (changed === 0) break;
@@ -1723,7 +1723,7 @@ test('POST /turn keeps informational skill and build questions conversational',
1723
1723
  test('POST /run accepts named skill arguments and deduplicates an explicit retry key', async (t) => {
1724
1724
  const root = mkdtempSync(join(tmpdir(), 'runtime-named-skill-'));
1725
1725
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
1726
- writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver the output.');
1726
+ writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - deliverable\n - polish\n---\nDeliver the output.');
1727
1727
  const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
1728
1728
  const context = { workspace: 'acme', session, running: false, currentAbortController: null };
1729
1729
  const persisted = new Map();
@@ -1746,7 +1746,7 @@ test('POST /run accepts named skill arguments and deduplicates an explicit retry
1746
1746
  try {
1747
1747
  const request = () => fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
1748
1748
  method: 'POST', headers: { 'content-type': 'application/json' },
1749
- body: JSON.stringify({ input: '/deliver', skillName: 'deliver', skillArguments: { template: 'Quarterly report' }, idempotencyKey: 'retry-1' }),
1749
+ body: JSON.stringify({ input: '/deliver', skillName: 'deliver', skillArguments: { deliverable: 'Quarterly report' }, idempotencyKey: 'retry-1' }),
1750
1750
  });
1751
1751
  const first = await (await request()).json();
1752
1752
  const second = await (await request()).json();
@@ -1756,7 +1756,7 @@ test('POST /run accepts named skill arguments and deduplicates an explicit retry
1756
1756
  assert.equal(session.controlQueue.length, 1);
1757
1757
  assert.equal('input' in first.items[0], false);
1758
1758
  assert.equal('objectives' in first, false);
1759
- assert.equal(session.controlQueue[0].input, '/deliver template="Quarterly report"');
1759
+ assert.equal(session.controlQueue[0].input, '/deliver deliverable="Quarterly report"');
1760
1760
  } finally {
1761
1761
  context.currentAbortController?.abort();
1762
1762
  await handle.close();
@@ -2,24 +2,24 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { formatPublicSkillInvocation, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
4
4
 
5
- const skill = { name: 'deliver', params: ['template', 'polish'], body: 'Deliver the requested output.' };
5
+ const skill = { name: 'deliver', params: ['deliverable', 'polish'], body: 'Deliver the requested output.' };
6
6
 
7
7
  test('named skill arguments preserve spaces and fill omitted declarations with empty strings', () => {
8
- const args = validateNamedSkillArguments(skill, { template: 'Quarterly report' });
8
+ const args = validateNamedSkillArguments(skill, { deliverable: 'Quarterly report' });
9
9
  assert.equal(Object.getPrototypeOf(args), null);
10
- assert.deepEqual({ ...args }, { template: 'Quarterly report', polish: '' });
10
+ assert.deepEqual({ ...args }, { deliverable: 'Quarterly report', polish: '' });
11
11
  });
12
12
 
13
13
  test('named skill arguments reject undeclared, non-string, and oversized values', () => {
14
14
  assert.throws(() => validateNamedSkillArguments(skill, { target: 'x' }), { code: 'skill_arguments_invalid' });
15
- assert.throws(() => validateNamedSkillArguments(skill, { template: 42 }), { code: 'skill_arguments_invalid' });
16
- assert.throws(() => validateNamedSkillArguments(skill, { template: 'x'.repeat(2_001) }), { code: 'skill_arguments_invalid' });
15
+ assert.throws(() => validateNamedSkillArguments(skill, { deliverable: 42 }), { code: 'skill_arguments_invalid' });
16
+ assert.throws(() => validateNamedSkillArguments(skill, { deliverable: 'x'.repeat(2_001) }), { code: 'skill_arguments_invalid' });
17
17
  });
18
18
 
19
19
  test('runSkillChain enqueues named arguments without exposing a skill body field', async () => {
20
20
  const queued = [];
21
21
  const result = await runSkillChain({ session: {} }, skill, {
22
- args: { template: 'Quarterly report' },
22
+ args: { deliverable: 'Quarterly report' },
23
23
  enqueueControlRequest(_context, input, metadata) {
24
24
  const item = { id: `item-${queued.length}`, input, status: 'queued', ...metadata };
25
25
  queued.push(item);
@@ -28,15 +28,15 @@ test('runSkillChain enqueues named arguments without exposing a skill body field
28
28
  drainControlQueue() {},
29
29
  });
30
30
  assert.equal(result.objectives, 1);
31
- assert.match(queued[0].input, /template: Quarterly report/);
32
- assert.equal(queued[0].publicInput, '/deliver template="Quarterly report"');
31
+ assert.match(queued[0].input, /deliverable: Quarterly report/);
32
+ assert.equal(queued[0].publicInput, '/deliver deliverable="Quarterly report"');
33
33
  assert.equal(queued[0].skillExecution, 'orchestrated');
34
34
  assert.equal('body' in queued[0], false);
35
35
  });
36
36
 
37
37
  test('public skill invocation contains arguments but never compiled objective prose', () => {
38
- const rendered = formatPublicSkillInvocation('deliver', { template: 'Quarterly report', polish: '' });
39
- assert.equal(rendered, '/deliver template="Quarterly report"');
38
+ const rendered = formatPublicSkillInvocation('deliver', { deliverable: 'Quarterly report', polish: '' });
39
+ assert.equal(rendered, '/deliver deliverable="Quarterly report"');
40
40
  assert.doesNotMatch(rendered, /Deliver the requested output/);
41
41
  });
42
42
 
@@ -5,6 +5,7 @@ import { useRenderer } from '@opentui/solid';
5
5
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
6
6
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
7
7
  import { normalizeExternalUrl } from './openExternal.js';
8
+ import { ActivityPanel } from './RightPane';
8
9
 
9
10
  const LEGACY_DONNA_ROLE = 'do' + 't';
10
11
 
@@ -806,6 +807,7 @@ export function LeftPane(props: {
806
807
  busy: boolean;
807
808
  chatMode: boolean;
808
809
  chatFocused: boolean;
810
+ activities: any[];
809
811
  setInput: (value: string) => void;
810
812
  submit: (value?: string) => void;
811
813
  conversationRows: number;
@@ -851,6 +853,15 @@ export function LeftPane(props: {
851
853
  onOpenLink={props.onOpenLink}
852
854
  />
853
855
  )}
856
+ {/*
857
+ Activity strip. The run status used to live only in the right pane,
858
+ one glance away from where the reader types. A compact 4-line strip
859
+ above the composer keeps the current job in view while composing; the
860
+ right pane keeps the full Plan/Queue/Logs detail.
861
+ */}
862
+ <box flexShrink={0} height={4} flexDirection="column" overflow="hidden">
863
+ <ActivityPanel activities={props.activities} width={props.width - 2} />
864
+ </box>
854
865
  <ChatInput
855
866
  width={props.width}
856
867
  prompt={props.prompt}
@@ -9,6 +9,7 @@ type QueueItem = {
9
9
  workspace?: string | null;
10
10
  status: string;
11
11
  args?: Record<string, any>;
12
+ label?: string;
12
13
  jobId?: string;
13
14
  error?: string;
14
15
  reason?: string;
@@ -170,9 +171,10 @@ function activityJobName(activity: any) {
170
171
  }
171
172
 
172
173
  function queueSummary(item: QueueItem) {
174
+ const label = item.label ? String(item.label).trim() : '';
173
175
  const args = item.args ?? {};
174
176
  const parts = [
175
- args.type ?? 'production',
177
+ label || args.type || 'production',
176
178
  Array.isArray(args.steps) && args.steps.length ? args.steps.join('+') : null,
177
179
  Array.isArray(args.templates) && args.templates.length ? `tpl:${args.templates.length}` : null,
178
180
  Array.isArray(args.deliverables) && args.deliverables.length ? `del:${args.deliverables.length}` : null,
@@ -180,10 +182,24 @@ function queueSummary(item: QueueItem) {
180
182
  return parts.join(' ');
181
183
  }
182
184
 
185
+ // A queue id is a long UUID (optionally `control-`-prefixed). The panel shows a
186
+ // short, recognisable prefix instead of the full string, which pushed the
187
+ // status and summary off the line and made the queue unreadable.
188
+ function shortQueueId(id: string) {
189
+ const value = String(id ?? '').replace(/^control-/, '');
190
+ return value.length > 12 ? `${value.slice(0, 12)}…` : value;
191
+ }
192
+
183
193
  export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string; summary?: string | null; spinnerFrame?: string }) {
184
194
  // Keep one column for the native vertical scrollbar when the plan is long.
185
195
  const lineWidth = () => Math.max(8, props.width - 3);
186
196
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
197
+ // A running step carries a thick left border, which costs one column: the
198
+ // wrap width is therefore one less for it. The same function feeds both the
199
+ // row-count memo and the render, so the viewport height can never undercount
200
+ // a running step's wrapped lines and clip the last one.
201
+ const isRunningStep = (step: PlanStep) => String(step.status ?? '').toLowerCase() === 'running';
202
+ const stepTextWidth = (step: PlanStep) => lineWidth() - (isRunningStep(step) ? 1 : 0);
187
203
  const icon = (rawStatus: string) => {
188
204
  const status = String(rawStatus ?? '').toLowerCase();
189
205
  if (DONE_STATUSES.includes(status)) return '[✓]';
@@ -195,7 +211,7 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
195
211
  return status === 'running' ? `[${props.spinnerFrame ?? '…'}]` : '[ ]';
196
212
  };
197
213
  const visualRows = createMemo(() => props.plan.reduce((total, step) =>
198
- total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
214
+ total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, stepTextWidth(step)).slice(0, 2).length, 0));
199
215
  const title = () => {
200
216
  const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
201
217
  return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
@@ -225,12 +241,14 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
225
241
  {(step) => {
226
242
  // Wrap step descriptions over up to 2 lines instead of truncating —
227
243
  // "Ingest des 39 documents raw/untrac…" hid the actual target.
228
- const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
244
+ const running = () => isRunningStep(step());
245
+ const textWidth = () => stepTextWidth(step());
246
+ const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, textWidth()).slice(0, 2);
229
247
  return (
230
- <box flexShrink={0} flexDirection="column">
231
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
248
+ <box flexShrink={0} flexDirection="column" border={running() ? ['left'] : undefined} borderStyle="heavy" borderColor="#89B4FA">
249
+ <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
232
250
  <Show when={lines()[1]}>
233
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
251
+ <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, textWidth() - 4))}`} />
234
252
  </Show>
235
253
  </box>
236
254
  );
@@ -438,12 +456,12 @@ export function QueuePanel(props: { items: QueueItem[]; info: QueueInfo; width:
438
456
  <text
439
457
  width={lineWidth()}
440
458
  fg={queueColor(item()?.status)}
441
- content={item() ? fit(`${item()!.id} ${item()!.status} ${queueSummary(item()!)}`, lineWidth()) : ''}
459
+ content={item() ? fit(`${item()!.status} · ${queueSummary(item()!)}`, lineWidth()) : ''}
442
460
  />
443
461
  <text
444
462
  width={lineWidth()}
445
463
  fg="#AAB7C4"
446
- content={item() ? fit([item()!.workspace, item()!.jobId ? `job ${item()!.jobId}` : item()!.reason].filter(Boolean).join(' · '), lineWidth()) : ''}
464
+ content={item() ? fit([shortQueueId(item()!.id), item()!.workspace, item()!.jobId ? `job ${item()!.jobId}` : item()!.reason].filter(Boolean).join(' · '), lineWidth()) : ''}
447
465
  />
448
466
  <text
449
467
  width={lineWidth()}
@@ -521,7 +539,6 @@ export function RightPane(props: {
521
539
  <Show when={props.plan && props.plan.length > 0}>
522
540
  <PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} summary={props.runSummary} spinnerFrame={props.spinnerFrame} />
523
541
  </Show>
524
- <ActivityPanel width={props.width} activities={props.activities} />
525
542
  </>
526
543
  )}>
527
544
  <QueuePanel width={props.width} items={props.queueItems} info={props.queueInfo} />
@@ -470,10 +470,10 @@ test('direct chat prompt exposes an escaped non-executable skill catalog with pa
470
470
  const root = mkdtempSync(join(tmpdir(), 'chat-skill-catalog-'));
471
471
  try {
472
472
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
473
- writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\ndescription: "</skill_catalog> deliver output"\nparams:\n - template\n---\nPRIVATE BODY');
473
+ writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\ndescription: "</skill_catalog> deliver output"\nparams:\n - deliverable\n---\nPRIVATE BODY');
474
474
  const prompt = buildDirectChatSystemPrompt({ workspacePath: root, commands: [], mcp: {} });
475
475
  assert.match(prompt, /<skill_catalog trusted="false" executable="false">/);
476
- assert.match(prompt, /\/deliver \[<template>\]/);
476
+ assert.match(prompt, /\/deliver \[<deliverable>\]/);
477
477
  assert.match(prompt, /&lt;\/skill_catalog&gt;/);
478
478
  assert.doesNotMatch(prompt, /PRIVATE BODY/);
479
479
  assert.match(prompt, /nothing was launched/);
package/src/shell/tui.tsx CHANGED
@@ -175,7 +175,9 @@ function App(props: {
175
175
  let lastCopiedSelection = '';
176
176
  const state = useSession(props);
177
177
  const startup = createMemo(() => startupInfo(props.packageJson, props.initialWorkspaceName));
178
- const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
178
+ // The Activity strip (4 rows) now sits above the composer, so it costs the
179
+ // conversation exactly those 4 rows.
180
+ const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight() - 4));
179
181
  const rightColumns = createMemo(() => {
180
182
  const width = dimensions().width;
181
183
  // 38% + 2 columns / cap 58: the Plan/Activity/Logs panes carry job
@@ -381,6 +383,7 @@ function App(props: {
381
383
  input={state.input()}
382
384
  busy={state.busy()}
383
385
  chatMode={state.chatMode()}
386
+ activities={state.activities()}
384
387
  // The composer must lose focus for EVERY modal, not just the file
385
388
  // editor. While the setup wizard was open the input stayed focused
386
389
  // underneath it, so answering a wizard question also typed into the