@dotdrelle/wiki-manager 0.15.72 → 0.15.74

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
@@ -310,6 +310,14 @@ wiki-workspace agents up # start cme, documents…
310
310
  wiki-workspace agents status # check they respond
311
311
  ```
312
312
 
313
+ One command starts the whole deployment — agent-runtime, agents and every
314
+ configured workspace — and is safe to repeat while things are already up:
315
+
316
+ ```bash
317
+ wiki-workspace start # runtime + agents + all workspaces
318
+ wiki-workspace start --open # …and open the first workspace chat
319
+ ```
320
+
313
321
  **Step 4 — Move to `wiki-manager` or `serve`.**
314
322
  Two entry doors, your choice:
315
323
 
@@ -31,7 +31,7 @@
31
31
  "servers": {
32
32
  "llm-wiki": { "allow": ["help_list", "help_read", "help_search", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source", "wiki_outline", "template_read", "template_write", "build_context_write", "wiki_read_deliverable"] },
33
33
  "wiki-production": { "allow": ["production_job_status", "production_jobs_list"] },
34
- "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
34
+ "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status", "cme_confluence_search", "cme_wiki_search"] }
35
35
  }
36
36
  }
37
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.72",
3
+ "version": "0.15.74",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "start": "bun ./bin/wiki-manager.js",
22
- "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js",
22
+ "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js",
23
23
  "check-versions": "node scripts/check-versions.js",
24
24
  "prepack": "node scripts/check-versions.js",
25
25
  "prepublishOnly": "node scripts/check-versions.js",
@@ -682,6 +682,14 @@ function runtimeProvidersSection(session) {
682
682
  // never truncates and stays scannable.
683
683
  lines.push('capabilities:');
684
684
  for (const capability of capabilities) lines.push(` ${capability}`);
685
+ // Configured in agent-runtimes.json but not served by the gateway (the
686
+ // list above is the intersection): say so, one per line, or the operator
687
+ // reads a shorter list without knowing the gateway lost its /config.
688
+ const drift = (session.runtimeProviderDrift ?? []).find((item) => item.runtimeId === runtimeId);
689
+ if (drift && drift.missing.length > 0) {
690
+ lines.push('not served by the gateway:');
691
+ for (const capability of drift.missing) lines.push(` ${capability}`);
692
+ }
685
693
  }
686
694
  return sectionBlock('Agentic runtime', lines);
687
695
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.72",
3
- "commit": "fccddba"
2
+ "version": "0.15.74",
3
+ "commit": "4ca493b"
4
4
  }
package/src/core/env.js CHANGED
@@ -11,6 +11,13 @@ const TEMPLATE_AUTHORING_CHAT_TOOLS = [
11
11
  'wiki_outline', 'template_read', 'template_write', 'build_context_write',
12
12
  'wiki_read_deliverable',
13
13
  ];
14
+ // Same additive rule for the packaged cme allow-list: an install scaffolded
15
+ // before the live search tools existed keeps the three legacy reads forever,
16
+ // and /chat can never call cme_confluence_search — the LLM is offered a
17
+ // server with no search and loops. A hand-edited list (not a superset of the
18
+ // legacy default) is the operator's and stays untouched.
19
+ const LEGACY_DEFAULT_CME_CHAT_TOOLS = ['cme_status', 'cme_sources_list', 'cme_export_status'];
20
+ const CME_SEARCH_CHAT_TOOLS = ['cme_confluence_search', 'cme_wiki_search'];
14
21
 
15
22
  export function userManagerDir() {
16
23
  return process.cwd();
@@ -148,17 +155,25 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
148
155
  const missingWikiChatTools = migrateWikiChatTools
149
156
  ? TEMPLATE_AUTHORING_CHAT_TOOLS.filter((tool) => !wikiAllow.includes(tool))
150
157
  : [];
158
+ const cmeAllow = current.chatAccess?.servers?.cme?.allow;
159
+ const migrateCmeChatTools = Array.isArray(cmeAllow)
160
+ && LEGACY_DEFAULT_CME_CHAT_TOOLS.every((tool) => cmeAllow.includes(tool));
161
+ const missingCmeChatTools = migrateCmeChatTools
162
+ ? CME_SEARCH_CHAT_TOOLS.filter((tool) => !cmeAllow.includes(tool))
163
+ : [];
151
164
  if (missing.length > 0) {
152
165
  for (const key of missing) current[key] = example[key];
153
166
  }
154
167
  for (const key of missingServers) currentServers[key] = exampleServers[key];
155
168
  wikiAllow?.push(...missingWikiChatTools);
156
- if (missing.length > 0 || missingServers.length > 0 || missingWikiChatTools.length > 0) {
169
+ cmeAllow?.push(...missingCmeChatTools);
170
+ const missingChatTools = [...missingWikiChatTools, ...missingCmeChatTools];
171
+ if (missing.length > 0 || missingServers.length > 0 || missingChatTools.length > 0) {
157
172
  writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
158
173
  const changes = [
159
174
  missing.length > 0 ? `keys: ${missing.join(', ')}` : '',
160
175
  missingServers.length > 0 ? `servers: ${missingServers.join(', ')}` : '',
161
- missingWikiChatTools.length > 0 ? `chat tools: ${missingWikiChatTools.join(', ')}` : '',
176
+ missingChatTools.length > 0 ? `chat tools: ${missingChatTools.join(', ')}` : '',
162
177
  ].filter(Boolean).join('; ');
163
178
  created.push(`mcp.endpoints.json ${changes}`);
164
179
  }
@@ -141,6 +141,37 @@ test('scaffold upgrades the packaged wiki chat allow-list with template authorin
141
141
  });
142
142
  });
143
143
 
144
+ test('scaffold upgrades the packaged cme chat allow-list with the live search tools', () => {
145
+ withTempManagerDir((dir) => {
146
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
147
+ const example = JSON.parse(readFileSync('mcp.endpoints.example.json', 'utf8'));
148
+ example.chatAccess.servers.cme.allow = ['cme_status', 'cme_sources_list', 'cme_export_status'];
149
+ writeFileSync(endpointsFile, JSON.stringify(example, null, 2));
150
+
151
+ const changes = ensureManagerScaffold();
152
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
153
+
154
+ assert.ok(changes.some((item) => item.includes('cme_confluence_search')));
155
+ assert.deepEqual(after.chatAccess.servers.cme.allow, [
156
+ 'cme_status', 'cme_sources_list', 'cme_export_status', 'cme_confluence_search', 'cme_wiki_search',
157
+ ]);
158
+ });
159
+ });
160
+
161
+ test('scaffold leaves a custom cme chat allow-list untouched', () => {
162
+ withTempManagerDir((dir) => {
163
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
164
+ writeFileSync(endpointsFile, JSON.stringify({
165
+ mcpServers: {},
166
+ chatAccess: { servers: { cme: { allow: ['cme_status'] } } },
167
+ }, null, 2));
168
+
169
+ ensureManagerScaffold();
170
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
171
+ assert.deepEqual(after.chatAccess.servers.cme.allow, ['cme_status']);
172
+ });
173
+ });
174
+
144
175
  test('scaffold leaves a custom wiki chat allow-list untouched', () => {
145
176
  withTempManagerDir((dir) => {
146
177
  const endpointsFile = join(dir, 'mcp.endpoints.json');
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.72';
4
+ const WIKI_MANAGER_VERSION = '0.15.74';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -171,7 +171,10 @@ export function shortLogId(value, { maxLength = 40 } = {}) {
171
171
  const DISPATCH_PLUMBING_LINE = /^(?:\d{1,2}:\d{2}(?::\d{2})?\s*(?:·\s*)?)?[A-Z][A-Z0-9_]{2,}(?:\s|$)/;
172
172
 
173
173
  export function isDispatchPlumbingLine(line) {
174
- return DISPATCH_PLUMBING_LINE.test(String(line ?? ''));
174
+ // The Shell tags every runtime line with a "runtime " prefix before it
175
+ // reaches the Log panel; classification must look past it, or AGENT_STATUS
176
+ // rows land in the Runtime tab instead of Agent status.
177
+ return DISPATCH_PLUMBING_LINE.test(String(line ?? '').replace(/^runtime\s+/, ''));
175
178
  }
176
179
 
177
180
  export function runtimeLogMatchesFilter(line, filter = '') {
@@ -146,6 +146,16 @@ test('isDispatchPlumbingLine leaves the business flow lines for the Runtime tab'
146
146
  }
147
147
  });
148
148
 
149
+ test('isDispatchPlumbingLine recognises the shell-tagged "runtime " lines for the Agent status tab', () => {
150
+ // The Shell prepends "runtime " to every runtime line (useSession
151
+ // visibleLogs): AGENT_STATUS rows must still classify as plumbing, or they
152
+ // end up in the Runtime tab instead of Agent status.
153
+ const tagged = `runtime ${formatRuntimeLogPayload({ event: 'agent_status', runId: 'r1', taskId: 't1' }, '2026-07-08T14:42:18.000Z')}`;
154
+ assert.equal(isDispatchPlumbingLine(tagged), true, 'a tagged AGENT_STATUS line is dispatch plumbing');
155
+ assert.equal(isDispatchPlumbingLine('runtime 14:42:21 Run failed: No agent provides capability workspace.restore.'), false);
156
+ assert.equal(isDispatchPlumbingLine('runtime 14:42:22 Plan validated for run r1'), false);
157
+ });
158
+
149
159
  test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
150
160
  const long = `${'x'.repeat(48)}-deadbeef`;
151
161
  assert.match(shortLogId(long), /…$/);
@@ -5,7 +5,7 @@ import { chainStepLabel, projectSkillChains, renderSkillChain, selectionKindLabe
5
5
  const WIKI_SYNC = [
6
6
  {
7
7
  id: 'c0', chainId: 'chain-1', chainSequence: 0, skillName: 'wiki-sync', selectionKind: 'description_match', status: 'done',
8
- input: 'Export the requested Confluence source, or all configured sources when none is specified. Check configuration first.\n\nUser parameters:\nsource: docs',
8
+ input: 'Export every configured Confluence source exactly as the connector is currently configured. Check configuration first.',
9
9
  },
10
10
  {
11
11
  id: 'c1', chainId: 'chain-1', chainSequence: 1, skillName: 'wiki-sync', status: 'running', runId: 'run-b',
@@ -7,10 +7,11 @@ export async function compileSkillObjectives(skill, args = {}, { llmFallback = n
7
7
  const body = String(skill?.body ?? '').trim();
8
8
  if (!body) throw compileError('Skill body is empty.');
9
9
  // Splitting must happen on the body alone. Appending the parameters first
10
- // makes them part of the last objective only — `/wiki-sync ESPACE` would hand
11
- // `source: ESPACE` to the ingest step and leave the export step, the one that
12
- // actually needs it, exporting everything. The compiler cannot know which
13
- // step consumes which parameter, so every objective carries them.
10
+ // makes them part of the last objective only — a skill that compiles into
11
+ // several steps would hand its arguments to the final step and leave the
12
+ // earlier ones, the ones that actually consume them, without them. The
13
+ // compiler cannot know which step consumes which parameter, so every
14
+ // objective carries them.
14
15
  const deterministic = deterministicObjectives(body);
15
16
  if (!deterministic.ambiguous) {
16
17
  return withNaturalArguments(validateCompiledObjectives(deterministic.objectives), args);
@@ -357,13 +357,22 @@ export async function checkAgenticRuntimes({
357
357
  for (const { id, provider } of providers) {
358
358
  try {
359
359
  const description = await provider.describe();
360
+ const down = description.health === 'unavailable';
361
+ // Ask the runtime what it serves — describe() alone reflects /health,
362
+ // not the capability set, and a gateway that lost its config answers
363
+ // healthy with one default capability.
364
+ const capabilities = down ? [] : await provider.discoverCapabilities();
365
+ const missing = provider?.lastDiscovery?.missing ?? [];
360
366
  runtimes.push({
361
367
  name: id,
362
- status: description.health === 'unavailable' ? 'failed' : 'connected',
363
- capabilities: (description.capabilities ?? [])
368
+ status: down ? 'failed' : 'connected',
369
+ capabilities: (capabilities ?? [])
364
370
  .map((capability) => capability?.name ?? capability?.id ?? '')
365
371
  .filter(Boolean),
366
- reason: description.health === 'unavailable' ? (description.error ?? 'unavailable') : null,
372
+ ...(missing.length > 0 ? { missingCapabilities: [...missing] } : {}),
373
+ reason: down
374
+ ? (description.error ?? 'unavailable')
375
+ : (missing.length > 0 ? `does not serve configured: ${missing.join(', ')}` : null),
367
376
  });
368
377
  } catch (err) {
369
378
  runtimes.push({ name: id, status: 'failed', reason: commandError(err) });
@@ -327,3 +327,31 @@ test('checkAgenticRuntimes reports a down runtime as failed', async () => {
327
327
  assert.equal(result.context.runtimes[0].status, 'failed');
328
328
  assert.match(result.detail, /pending/);
329
329
  });
330
+
331
+ test('checkAgenticRuntimes asks the runtime what it serves and reports configured capabilities it does not', async () => {
332
+ const provider = {
333
+ runtime: 'deepagents',
334
+ lastDiscovery: null,
335
+ async describe() { return { runtime: 'deepagents', health: 'available', version: 't', protocolVersion: '1' }; },
336
+ async discoverCapabilities() {
337
+ this.lastDiscovery = { served: ['agent.review'], configured: ['agent.review', 'agent.notify'], missing: ['agent.notify'], extra: [] };
338
+ return [{ name: 'agent.review', operations: ['run'] }];
339
+ },
340
+ async execute() { throw new Error('unused'); },
341
+ async status() { throw new Error('unused'); },
342
+ async cancel() {},
343
+ async approve() {},
344
+ subscribe() { return () => {}; },
345
+ };
346
+ const result = await checkAgenticRuntimes({
347
+ loadConfig: () => [{ id: 'deepagents', type: 'deepagents', enabled: true }],
348
+ resolve: () => ({ providers: [{ id: 'deepagents', type: 'deepagents', provider }], skipped: [] }),
349
+ env: {},
350
+ });
351
+ assert.equal(result.ok, true);
352
+ const runtime = result.context.runtimes[0];
353
+ assert.equal(runtime.status, 'connected');
354
+ assert.deepEqual(runtime.capabilities, ['agent.review'], 'the served set, not the configured one');
355
+ assert.deepEqual(runtime.missingCapabilities, ['agent.notify']);
356
+ assert.match(runtime.reason, /does not serve configured: agent\.notify/);
357
+ });
@@ -0,0 +1,36 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import test from 'node:test';
5
+
6
+ const script = readFileSync(fileURLToPath(new URL('../../wiki-workspace', import.meta.url)), 'utf8');
7
+
8
+ test('wiki-workspace start advertises itself in the usage text', () => {
9
+ const usage = script.slice(script.indexOf('Commands:'), script.indexOf('Configuration:'));
10
+ assert.match(usage, /start \[--open\]\s+Start everything: agent-runtime, agents and every/);
11
+ assert.match(usage, /configured workspace/);
12
+ });
13
+
14
+ test('wiki-workspace start routes to a start_all that composes the three idempotent steps', () => {
15
+ const fn = script.slice(script.indexOf("# `start` — the whole deployment"), script.indexOf('ensure_workspace_dirs()'));
16
+ // The whole deployment is the same three commands an operator runs by hand,
17
+ // in the boot order the runtime expects: runtime, agents, then workspaces.
18
+ assert.match(fn, /runtime_command up/);
19
+ assert.match(fn, /agents_compose up/);
20
+ assert.match(fn, /up_workspace "\$name"/);
21
+ // Every configured workspace joins, discovered exactly like list_workspaces:
22
+ // a directory under WORKSPACES_DIR carrying a .env.
23
+ assert.match(fn, /for entry in "\$WORKSPACES_DIR"\/\*/);
24
+ assert.match(fn, /env_value "\$file" WORKSPACE_NAME/);
25
+ // Without workspaces the command still completes, telling the operator how
26
+ // to create one.
27
+ assert.match(fn, /wiki-workspace config <name>/);
28
+ // Repeatable: each step is idempotent, so `start` is safe on a live stack.
29
+ assert.match(fn, /idempotent/);
30
+ });
31
+
32
+ test('the start dispatch accepts only an optional --open', () => {
33
+ const dispatch = script.slice(script.indexOf('# start [--open]'), script.indexOf('# up <workspace> [--open]'));
34
+ assert.match(dispatch, /\[\[ "\$\{2:-\}" == "" \|\| "\$\{2:-\}" == "--open" \]\]/);
35
+ assert.match(dispatch, /start_all "\$start_open"/);
36
+ });
@@ -86,7 +86,7 @@ test('resolveObjective disambiguates "export" of a Confluence source via alias,
86
86
  session.llm.completeWithTools = async () => {
87
87
  throw new Error('the explicit alias must not depend on LLM selection');
88
88
  };
89
- const result = await resolveObjective('Export the requested Confluence source', session);
89
+ const result = await resolveObjective('Export every configured Confluence source exactly as the connector is currently configured', session);
90
90
  assert.equal(result.capability, 'external-source.export');
91
91
  assert.equal(result.operation, 'export');
92
92
  assert.equal(result.provider.agentInstanceId, 'cme-1');
@@ -56,7 +56,10 @@ export function createDeepAgentsProvider({
56
56
  }
57
57
  }
58
58
 
59
- return {
59
+ const provider = {
60
+ // Filled by discoverCapabilities: what the gateway served vs what the
61
+ // config declares. `null` until the first discovery.
62
+ lastDiscovery: null,
60
63
  async describe() {
61
64
  let health = 'available';
62
65
  let runtimeVersion = version ?? null;
@@ -77,10 +80,33 @@ export function createDeepAgentsProvider({
77
80
  ...(lastError ? { error: lastError } : {}),
78
81
  };
79
82
  },
83
+ // The gateway is ALWAYS asked what it serves. The static config (the
84
+ // manager's agent-runtimes.json entry) carries the richer metadata the
85
+ // resolver routes on — mutationClass, aliases, aliasOperations — but it is
86
+ // a declaration, not an observation: a gateway that lost its /config
87
+ // mount degrades to its built-in default and the manager, trusting the
88
+ // config alone, kept listing eight capabilities it could not govern.
89
+ // Only the capabilities BOTH declare are offered; the difference is
90
+ // exposed as `lastDiscovery` so /status and the runtime log can say why a
91
+ // configured capability is missing.
80
92
  async discoverCapabilities() {
81
- if (Array.isArray(capabilities)) return capabilities;
82
93
  const list = await httpJson('/capabilities');
83
- return Array.isArray(list) ? list : [];
94
+ const served = (Array.isArray(list) ? list : [])
95
+ .filter((item) => item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim());
96
+ const servedNames = served.map((item) => item.name);
97
+ if (!Array.isArray(capabilities)) {
98
+ provider.lastDiscovery = { served: servedNames, configured: null, missing: [], extra: [] };
99
+ return served;
100
+ }
101
+ const configuredNames = capabilities.map((item) => String(item?.name ?? ''));
102
+ const offered = capabilities.filter((item) => servedNames.includes(String(item?.name ?? '')));
103
+ provider.lastDiscovery = {
104
+ served: servedNames,
105
+ configured: configuredNames,
106
+ missing: configuredNames.filter((name) => !servedNames.includes(name)),
107
+ extra: servedNames.filter((name) => !configuredNames.includes(name)),
108
+ };
109
+ return offered;
84
110
  },
85
111
  async execute(request = {}) {
86
112
  const accepted = await httpJson('/runs', {
@@ -165,4 +191,5 @@ export function createDeepAgentsProvider({
165
191
  return () => controller.abort();
166
192
  },
167
193
  };
194
+ return provider;
168
195
  }
@@ -74,16 +74,44 @@ test('discoverCapabilities fetches /capabilities', async () => {
74
74
  assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
75
75
  });
76
76
 
77
- test('discoverCapabilities uses the static config when provided', async () => {
78
- const fetchImpl = mockFetch({});
77
+ test('discoverCapabilities keeps the configured metadata but only for capabilities the gateway serves', async () => {
78
+ const fetchImpl = mockFetch({
79
+ 'GET /capabilities': () => jsonResponse(200, [
80
+ { name: 'agent.review', operations: ['run'] },
81
+ { name: 'agent.notify', operations: ['run'] },
82
+ ]),
83
+ });
79
84
  const provider = createDeepAgentsProvider({
80
85
  endpoint: 'http://agent-runtime:8080',
81
- capabilities: [{ name: 'agent.review', operations: ['run'] }],
86
+ capabilities: [
87
+ { name: 'agent.review', operations: ['run'], aliases: ['audit'] },
88
+ { name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
89
+ { name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
90
+ ],
82
91
  fetchImpl,
83
92
  });
84
93
 
94
+ const offered = await provider.discoverCapabilities();
95
+ assert.equal(fetchImpl.calls.length, 1, 'the gateway is always asked what it serves');
96
+ assert.deepEqual(offered.map((item) => item.name), ['agent.review', 'agent.notify']);
97
+ assert.equal(offered[0].aliases[0], 'audit', 'the configured metadata is what the resolver routes on');
98
+ assert.deepEqual(provider.lastDiscovery, {
99
+ served: ['agent.review', 'agent.notify'],
100
+ configured: ['agent.review', 'agent.research', 'agent.notify'],
101
+ missing: ['agent.research'],
102
+ extra: [],
103
+ });
104
+ });
105
+
106
+ test('discoverCapabilities with no static config serves the gateway list as-is', async () => {
107
+ const fetchImpl = mockFetch({
108
+ 'GET /capabilities': () => jsonResponse(200, [{ name: 'agent.review', operations: ['run'] }]),
109
+ });
110
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
111
+
85
112
  assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
86
- assert.equal(fetchImpl.calls.length, 0, 'no HTTP call when capabilities are static');
113
+ assert.deepEqual(provider.lastDiscovery.missing, []);
114
+ assert.equal(provider.lastDiscovery.configured, null);
87
115
  });
88
116
 
89
117
  test('execute POSTs /runs and returns the runId', async () => {
@@ -24,6 +24,11 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
24
24
  : (runtimeProviders?.list?.() ?? []);
25
25
  const agents = [];
26
26
  const unavailable = [];
27
+ // Configured-but-not-served capabilities, per runtime (deepagents providers
28
+ // fill `lastDiscovery`). A drift is not an outage: the runtime stays
29
+ // available with the capabilities it really serves, and the difference is
30
+ // reported so an operator can see WHY agent.research is not routable.
31
+ const drift = [];
27
32
 
28
33
  for (const entry of providers) {
29
34
  const provider = entry?.provider ?? entry;
@@ -53,9 +58,13 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
53
58
  for (const capability of capabilities ?? []) {
54
59
  agents.push(runtimeProviderAgent(runtimeId, provider, description, capability, health));
55
60
  }
61
+ const discovery = provider?.lastDiscovery;
62
+ if (discovery && Array.isArray(discovery.missing) && discovery.missing.length > 0) {
63
+ drift.push({ runtimeId, missing: [...discovery.missing], served: [...(discovery.served ?? [])] });
64
+ }
56
65
  }
57
66
 
58
- return { agents, unavailable };
67
+ return { agents, unavailable, drift };
59
68
  }
60
69
 
61
70
  /**
@@ -254,7 +263,10 @@ export async function discoverRuntimeProvidersOnce(session, {
254
263
  },
255
264
  });
256
265
  const resolved = resolveRuntimeProviders(entries);
257
- const { agents, unavailable } = await discoverRuntimeProviderAgents(resolved.providers);
266
+ const { agents, unavailable, drift } = await discoverRuntimeProviderAgents(resolved.providers);
267
+ // Exposed for /status: which configured capabilities the runtime does not
268
+ // serve right now. Replaced on every scan — a recovered gateway clears it.
269
+ session.runtimeProviderDrift = drift;
258
270
 
259
271
  // A failed probe is not a lost agent (same rule as agentRegistry): a runtime
260
272
  // that reported unavailable this round but for which we hold a last-known-good
@@ -311,11 +323,39 @@ export async function discoverRuntimeProvidersOnce(session, {
311
323
  currentDown.add(key);
312
324
  if (!session._runtimeProviderDown.has(key)) {
313
325
  const kept = preservedByRuntime.get(item.runtimeId)?.length ?? 0;
326
+ // Transient states get the ⚠ glyph, which both UIs render in blue: the
327
+ // gateway answering HTTP 401 before its agents are loaded (workspace
328
+ // switch, boot order), or not listening yet (fetch failed, refused,
329
+ // timeout). An HTTP 4xx/5xx other than 401 — the server answers but is
330
+ // broken — stays a red "unavailable", like the non-HTTP agent contract
331
+ // errors.
332
+ const errorText = String(item.error ?? '');
333
+ const httpMatch = errorText.match(/\bHTTP (\d{3})\b/);
334
+ const connectionFailure = /\b(fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up|connection refused|network error|timeout|timed out)\b/i.test(errorText);
335
+ const notReady = (httpMatch != null && httpMatch[1] === '401') || (httpMatch == null && connectionFailure);
336
+ if (notReady) {
337
+ dispatchRuntimeLog(session, `⚠ agent-runtimes: ${item.runtimeId} not ready yet (${item.error})${kept > 0 ? ` — keeping ${kept} last-known capabilit${kept === 1 ? 'y' : 'ies'} until it recovers` : ''} — retrying on the next scan`);
338
+ continue;
339
+ }
314
340
  dispatchRuntimeLog(session, kept > 0
315
341
  ? `agent-runtimes: ${item.runtimeId} unavailable (${item.error}) — keeping ${kept} last-known capabilit${kept === 1 ? 'y' : 'ies'} until it recovers`
316
342
  : `agent-runtimes: ${item.runtimeId} unavailable (${item.error})`);
317
343
  }
318
344
  }
345
+ // Same edge-triggered rule for a capability drift: announced when it
346
+ // appears (or changes), silent while it persists, announced again after a
347
+ // recovery.
348
+ session._runtimeProviderDriftAnnounced ??= new Map();
349
+ const announced = session._runtimeProviderDriftAnnounced;
350
+ const currentDrift = new Map(drift.map((item) => [item.runtimeId, item.missing.join(',')]));
351
+ for (const item of drift) {
352
+ if (announced.get(item.runtimeId) === currentDrift.get(item.runtimeId)) continue;
353
+ dispatchRuntimeLog(session, `agent-runtimes: ${item.runtimeId} does not serve ${item.missing.length} configured capabilit${item.missing.length === 1 ? 'y' : 'ies'} (${item.missing.join(', ')}) — it serves ${item.served.join(', ') || 'nothing'}; check the gateway's /config mount (agent-runtimes.json)`);
354
+ }
355
+ for (const id of [...announced.keys()]) {
356
+ if (!currentDrift.has(id)) announced.delete(id);
357
+ }
358
+ for (const [id, key] of currentDrift) announced.set(id, key);
319
359
  session._runtimeProviderDown = currentDown;
320
360
  return effectiveAgents;
321
361
  }
@@ -265,6 +265,118 @@ test('discoverRuntimeProvidersOnce announces a down runtime only once across re-
265
265
  assert.equal(logs.length, 1, 'the degradation is announced once, not on every re-scan');
266
266
  });
267
267
 
268
+ test('a gateway serving fewer capabilities than configured is a drift: offered = intersection, announced once, cleared on recovery', async () => {
269
+ const session = sessionWithEvents();
270
+ const config = [{
271
+ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true,
272
+ capabilities: [
273
+ { name: 'agent.review', operations: ['run'] },
274
+ { name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
275
+ { name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
276
+ ],
277
+ }];
278
+ const originalFetch = globalThis.fetch;
279
+ let served = [{ name: 'agent.review', operations: ['run'] }];
280
+ globalThis.fetch = async (url) => {
281
+ if (String(url).includes('/health')) return new Response(JSON.stringify({ ok: true, version: 't' }), { status: 200, headers: { 'content-type': 'application/json' } });
282
+ if (String(url).includes('/capabilities')) return new Response(JSON.stringify(served), { status: 200, headers: { 'content-type': 'application/json' } });
283
+ throw new Error('unexpected probe ' + String(url));
284
+ };
285
+ try {
286
+ await discoverRuntimeProvidersOnce(session, { config });
287
+ await discoverRuntimeProvidersOnce(session, { config });
288
+ const driftLogs = () => (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
289
+ && String(event.payload?.message ?? '').includes('does not serve'));
290
+
291
+ // Only what the gateway serves is routable — a governed capability the
292
+ // gateway would run ungoverned is not offered at all.
293
+ assert.deepEqual(session.runtimeProviderAgents.map((agent) => agent.agentInstanceId), ['deepagents::agent.review']);
294
+ assert.deepEqual(session.runtimeProviderDrift, [{
295
+ runtimeId: 'deepagents', missing: ['agent.research', 'agent.notify'], served: ['agent.review'],
296
+ }]);
297
+ assert.equal(driftLogs().length, 1, 'announced once across re-scans');
298
+ assert.match(String(driftLogs()[0].payload.message), /does not serve 2 configured capabilities \(agent\.research, agent\.notify\)/);
299
+ assert.match(String(driftLogs()[0].payload.message), /\/config mount/);
300
+
301
+ // The gateway recovers its config: no drift, all three routable.
302
+ served = config[0].capabilities.map(({ name, operations }) => ({ name, operations }));
303
+ await discoverRuntimeProvidersOnce(session, { config });
304
+ assert.equal(session.runtimeProviderAgents.length, 3);
305
+ assert.deepEqual(session.runtimeProviderDrift, []);
306
+
307
+ // …and a later drift is announced again.
308
+ served = [{ name: 'agent.review', operations: ['run'] }];
309
+ await discoverRuntimeProvidersOnce(session, { config });
310
+ assert.equal(driftLogs().length, 2);
311
+ } finally {
312
+ globalThis.fetch = originalFetch;
313
+ }
314
+ });
315
+
316
+ test('an HTTP 401 gateway probe is announced as not-ready with the ⚠ glyph, not as a failure', async () => {
317
+ const session = sessionWithEvents();
318
+ const originalFetch = globalThis.fetch;
319
+ globalThis.fetch = async (url) => {
320
+ if (String(url).includes('/health')) return new Response('{}', { status: 401 });
321
+ throw new Error('unexpected probe ' + String(url));
322
+ };
323
+ try {
324
+ await discoverRuntimeProvidersOnce(session, {
325
+ config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
326
+ });
327
+ } finally {
328
+ globalThis.fetch = originalFetch;
329
+ }
330
+
331
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
332
+ && String(event.payload?.message ?? '').includes('deepagents'));
333
+ assert.equal(logs.length, 1, 'the transient auth state is announced once');
334
+ const message = String(logs[0].payload?.message ?? '');
335
+ assert.match(message, /^⚠ agent-runtimes: deepagents not ready yet \(HTTP 401/);
336
+ assert.match(message, /retrying on the next scan/);
337
+ assert.doesNotMatch(message, /unavailable/, 'a gateway answering before its agents load is not a failure');
338
+ });
339
+
340
+ test('a gateway that is not listening yet (fetch failed) is also announced as not-ready', async () => {
341
+ const session = sessionWithEvents();
342
+ const originalFetch = globalThis.fetch;
343
+ globalThis.fetch = async () => {
344
+ throw new TypeError('fetch failed');
345
+ };
346
+ try {
347
+ await discoverRuntimeProvidersOnce(session, {
348
+ config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
349
+ });
350
+ } finally {
351
+ globalThis.fetch = originalFetch;
352
+ }
353
+
354
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
355
+ && String(event.payload?.message ?? '').includes('deepagents'));
356
+ assert.equal(logs.length, 1);
357
+ const message = String(logs[0].payload?.message ?? '');
358
+ assert.match(message, /^⚠ agent-runtimes: deepagents not ready yet \(fetch failed\)/);
359
+ assert.doesNotMatch(message, /unavailable/);
360
+ });
361
+
362
+ test('an HTTP 500 gateway probe stays a red unavailable announcement', async () => {
363
+ const session = sessionWithEvents();
364
+ const originalFetch = globalThis.fetch;
365
+ globalThis.fetch = async () => new Response('{}', { status: 500 });
366
+ try {
367
+ await discoverRuntimeProvidersOnce(session, {
368
+ config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
369
+ });
370
+ } finally {
371
+ globalThis.fetch = originalFetch;
372
+ }
373
+
374
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
375
+ && String(event.payload?.message ?? '').includes('deepagents'));
376
+ assert.equal(logs.length, 1);
377
+ assert.match(String(logs[0].payload?.message ?? ''), /^agent-runtimes: deepagents unavailable \(HTTP 500/);
378
+ });
379
+
268
380
  test('a transient probe failure keeps the last-known capability set (a failed probe is not a lost agent)', async () => {
269
381
  const session = sessionWithEvents();
270
382
  const up = [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }];
@@ -136,16 +136,16 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
136
136
  const env = await harness(t, { skills: { 'wiki-sync': null } });
137
137
  if (!env) return;
138
138
 
139
- const { body } = await env.post('/run?workspace=acme', { input: '/wiki-sync docs' });
139
+ const { body } = await env.post('/run?workspace=acme', { input: '/wiki-sync' });
140
140
  await env.settle();
141
141
 
142
142
  assert.equal(body.objectives, 2);
143
143
  assert.equal(env.runs.length, 2, 'the second objective must run after the first');
144
- assert.match(env.runs[0].input, /^Export the requested Confluence source/);
144
+ assert.match(env.runs[0].input, /^Export every configured Confluence source/);
145
145
  assert.match(env.runs[1].input, /^Run the production pipeline step ingest over the newly exported Markdown/);
146
- // CME first, Production second — and the parameter reaches the step that
147
- // consumes it, not only the last objective.
148
- for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
146
+ // CME first, Production second — the skill carries no source parameter, so
147
+ // no run may receive a source selector (the export step must stay "all").
148
+ for (const run of env.runs) assert.doesNotMatch(run.input, /User parameters:/);
149
149
  const items = env.chain();
150
150
  assert.equal(items.length, 2);
151
151
  assert.equal(items[0].chainId, items[1].chainId);
@@ -154,6 +154,24 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
154
154
  assert.deepEqual(items.map((item) => item.skillName), ['wiki-sync', 'wiki-sync']);
155
155
  });
156
156
 
157
+ test('E2E-002b parameters reach every objective of a multi-step skill', async (t) => {
158
+ const env = await harness(t, {
159
+ skills: {
160
+ 'two-step': '---\nname: two-step\nparams:\n - source\n---\nCollect the sources.\n\nThen ingest them.',
161
+ },
162
+ });
163
+ if (!env) return;
164
+
165
+ const { body } = await env.post('/run?workspace=acme', { input: '/two-step docs' });
166
+ await env.settle();
167
+
168
+ assert.equal(body.objectives, 2);
169
+ assert.equal(env.runs.length, 2);
170
+ // The parameter reaches every step that consumes it, not only the last
171
+ // objective of the chain.
172
+ for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
173
+ });
174
+
157
175
  test('E2E-003 cancel: the running step and its chain stop, unrelated queue survives', async (t) => {
158
176
  const env = await harness(t, {
159
177
  autoFinish: false,
@@ -308,7 +326,7 @@ test('E2E-010 skill stack: every run receives the ancestors of its chain', async
308
326
  const env = await harness(t, { skills: { 'wiki-sync': null } });
309
327
  if (!env) return;
310
328
 
311
- await env.post('/run?workspace=acme', { input: '/wiki-sync docs' });
329
+ await env.post('/run?workspace=acme', { input: '/wiki-sync' });
312
330
  await env.settle();
313
331
 
314
332
  assert.ok(env.runs.length >= 1);
@@ -830,14 +830,21 @@ export function LeftPane(props: {
830
830
  to the foot of the pane: they are an ambient state one glances at, not
831
831
  something read before every message, and at the top they pushed the
832
832
  conversation down by a line for standing information.
833
+
834
+ The hint row exists only while there is a hint. It is null almost
835
+ always, so reserving a blank line for it left a dead gap between the
836
+ mode bar and the thread once the standing info moved to the foot;
837
+ `conversationRows` in tui.tsx adds the row back only when it shows.
833
838
  */}
834
- <box height={2} flexDirection="column">
839
+ <box height={props.hintLine ? 2 : 1} flexDirection="column">
835
840
  <box height={1} flexDirection="row" backgroundColor={modeColor()} paddingX={1}>
836
841
  <text fg="#0B1020">{modeLabel()}</text>
837
842
  </box>
838
- <box height={1} flexDirection="row">
839
- {props.hintLine ? <text fg="#FBBF24">[ {props.hintLine} ]</text> : null}
840
- </box>
843
+ {props.hintLine ? (
844
+ <box height={1} flexDirection="row">
845
+ <text fg="#FBBF24">[ {props.hintLine} ]</text>
846
+ </box>
847
+ ) : null}
841
848
  </box>
842
849
  {showWelcome() ? (
843
850
  <WelcomeHelpPanels width={props.conversationColumns} />
@@ -875,9 +882,9 @@ export function LeftPane(props: {
875
882
  onHeightChange={props.onInputHeightChange}
876
883
  />
877
884
  {/*
878
- Status foot. One line, as in the header before it, so the row budget of
879
- `conversationRows` (height - 5 - input) is unchanged: the line moved,
880
- it was not added.
885
+ Status foot. One line: it replaced the standing header line rather than
886
+ adding one. `conversationRows` (tui.tsx) counts padding (2) + mode bar
887
+ (1) + this foot (1), plus the hint row only when it is present.
881
888
  */}
882
889
  <box height={1} flexDirection="row">
883
890
  <text fg="#D6DEE8">{props.title}</text>
@@ -349,6 +349,13 @@ function logMessageColor(message: string): string {
349
349
  // would give them anyway.
350
350
  if (/^\s*✓/.test(message)) return '#A6E3A1';
351
351
  if (/^\s*✗/.test(message)) return '#F38BA8';
352
+ // ⚠ announces a state the reader should know about, not an error: the
353
+ // gateway answering 401 before its agents are loaded, a re-scan retry.
354
+ // Blue, with the glyph carrying the warning — the red rules below must not
355
+ // claim it (the text may still contain "HTTP 401"). Keyed on the glyph
356
+ // alone: every legitimate "not ready" line already carries it, whereas a
357
+ // genuine failure ("Run failed: agent X not ready") must stay red.
358
+ if (/^\s*⚠/.test(message)) return '#89B4FA';
352
359
  // A doctor summary with ZERO errors is a warning by construction
353
360
  // ("⚠ 0 error(s), 2 warning(s)") — amber, never red, even though it
354
361
  // mentions the word "error".
@@ -419,7 +426,11 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
419
426
  // above the Runtime/Agent status tabs left a dead gap under a short Plan
420
427
  // and made the two panels look detached. The Plan/Queue panel now grows
421
428
  // into that space instead, so its bottom edge meets this panel's header.
422
- <box flexGrow={2} flexDirection="column" paddingX={1} focusable={false}>
429
+ // minHeight keeps this panel on screen while a long plan is running: with
430
+ // no floor the flex column shrank it to zero rows during a treatment — the
431
+ // plan grew, the Runtime/Agent status tabs vanished — while the Queue tab
432
+ // (fixed height, no shrink) kept them visible.
433
+ <box flexGrow={2} minHeight={10} flexDirection="column" paddingX={1} focusable={false}>
423
434
  <text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
424
435
  <box height={1} flexDirection="row">
425
436
  <text
@@ -438,6 +449,7 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
438
449
  </box>
439
450
  <scrollbox
440
451
  flexGrow={1}
452
+ minHeight={4}
441
453
  focusable={false}
442
454
  scrollY={true}
443
455
  scrollX={false}
@@ -186,6 +186,28 @@ test('a doctor summary with a nonzero error count is colored as an error, not a
186
186
  assert.doesNotMatch(colorFn, /\/0 error\\\(s\\\)\/i\.test/);
187
187
  });
188
188
 
189
+ test('a ⚠ not-ready announcement is blue, even when its text mentions HTTP 401', async () => {
190
+ const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
191
+ const colorFn = source.slice(
192
+ source.indexOf('function logMessageColor'),
193
+ source.indexOf('function logRenderLines'),
194
+ );
195
+ const blueRule = colorFn.indexOf("return '#89B4FA'");
196
+ const redRule = colorFn.indexOf('HTTP 4\\d\\d');
197
+ assert.ok(blueRule !== -1 && redRule !== -1, 'the blue and red rules both exist');
198
+ assert.ok(blueRule < redRule, 'the ⚠ rule must win over the HTTP 4xx red rule');
199
+ assert.match(colorFn, /\/\^\\s\*⚠\//);
200
+ });
201
+
202
+ test('local log lines carry a fixed 24h timestamp like the runtime lines', async () => {
203
+ const source = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
204
+ // A locale default rendered "9:50:37 PM" on some machines while runtime
205
+ // lines read "21:50:37" — two time dialects in one panel, and the
206
+ // Runtime/Agent-status classification reads the time prefix structurally.
207
+ assert.match(source, /toLocaleTimeString\('en-GB', \{ hour12: false \}\)/);
208
+ assert.doesNotMatch(source, /toLocaleTimeString\(\)/);
209
+ });
210
+
189
211
  test('runtime logs have a separator and a concise Runtime tab label', async () => {
190
212
  const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
191
213
  const logPanel = source.slice(
@@ -195,6 +217,11 @@ test('runtime logs have a separator and a concise Runtime tab label', async () =
195
217
  assert.match(logPanel, /content=\{'─'\.repeat\(lineWidth\(\)\)\}/);
196
218
  assert.match(logPanel, /content=" Runtime "/);
197
219
  assert.doesNotMatch(logPanel, /Flow \/ Trace/);
220
+ // The panel keeps a floor so a long plan running above it cannot shrink it
221
+ // to zero rows — the Queue tab's fixed height is what used to keep it
222
+ // visible, and a treatment must not make the tabs vanish.
223
+ assert.match(logPanel, /flexGrow=\{2\} minHeight=\{10\}/);
224
+ assert.match(logPanel, /minHeight=\{4\}/);
198
225
  });
199
226
 
200
227
  test('ShellUI turns HTTP URLs into valid links without trailing punctuation', () => {
package/src/shell/tui.tsx CHANGED
@@ -175,9 +175,22 @@ function App(props: {
175
175
  let lastCopiedSelection = '';
176
176
  const state = useSession(props);
177
177
  const startup = createMemo(() => startupInfo(props.packageJson, props.initialWorkspaceName));
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));
178
+ // Header line that changes the meaning of what is typed. Null most of the
179
+ // time — a transient exit/copy/runtime prompt only — so the header collapses
180
+ // to the single mode-bar row when it is absent (see LeftPane).
181
+ const hintLine = () => {
182
+ if (exitStatus()) return exitStatus();
183
+ if (copyHint()) return copyHint();
184
+ if (exitHint()) return 'Press Ctrl+C again to exit.';
185
+ if (state.runtimeHint()) return state.runtimeHint();
186
+ return null;
187
+ };
188
+ // Non-conversation rows: outer padding (2) + mode bar (1) + status foot (1),
189
+ // plus the hint row only when a hint is showing, plus the composer height and
190
+ // the Activity strip (4 rows, now above the composer).
191
+ const conversationRows = createMemo(() =>
192
+ Math.max(4, dimensions().height - 4 - (hintLine() ? 1 : 0) - chatInputHeight() - 4),
193
+ );
181
194
  const rightColumns = createMemo(() => {
182
195
  const width = dimensions().width;
183
196
  // 38% + 2 columns: the Plan/Activity/Logs panes carry job labels, file
@@ -359,14 +372,6 @@ function App(props: {
359
372
  }
360
373
  });
361
374
 
362
- const hintLine = () => {
363
- if (exitStatus()) return exitStatus();
364
- if (copyHint()) return copyHint();
365
- if (exitHint()) return 'Press Ctrl+C again to exit.';
366
- if (state.runtimeHint()) return state.runtimeHint();
367
- return null;
368
- };
369
-
370
375
  return (
371
376
  <Show
372
377
  when={screen() === 'startup'}
@@ -82,7 +82,10 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
82
82
  setActiveEditor(editor);
83
83
  };
84
84
  const addLog = (line: string) => {
85
- setLogs((items) => [...items, `${new Date().toLocaleTimeString()} ${line}`].slice(-200));
85
+ // Fixed 24h HH:MM:SS, like the runtime's own log lines: a locale default
86
+ // rendered "9:50:37 PM" on some machines, and the Runtime/Agent-status
87
+ // split classifies lines structurally from that time prefix.
88
+ setLogs((items) => [...items, `${new Date().toLocaleTimeString('en-GB', { hour12: false })} ${line}`].slice(-200));
86
89
  };
87
90
  const runtimeUnavailableReason = createMemo(() => {
88
91
  if (props.runtime?.url) return null;
package/wiki-workspace CHANGED
@@ -97,6 +97,8 @@ Usage:
97
97
  Commands:
98
98
  config <workspace> [path] Create workspace folder, auto-select ports, run wiki init
99
99
  up <workspace> [--open] Start workspace stack: wiki + production
100
+ start [--open] Start everything: agent-runtime, agents and every
101
+ configured workspace (--open opens the first chat)
100
102
  list List configured workspaces
101
103
  refresh Stop runtime/stacks and remove their Compose images
102
104
 
@@ -1438,6 +1440,49 @@ wait_and_open() {
1438
1440
  open_app_mode "${url}"
1439
1441
  }
1440
1442
 
1443
+ # `start` — the whole deployment in one command: the host agent-runtime, the
1444
+ # external agents, then every configured workspace stack. Each step is the
1445
+ # same idempotent command the operator would run by hand, so `start` is safe
1446
+ # to repeat while things are already up. A workspace failure stops the run —
1447
+ # the failing compose command already says why.
1448
+ start_all() {
1449
+ local open_browser="${1:-0}"
1450
+
1451
+ printf -- '── agent-runtime ──\n'
1452
+ runtime_command up
1453
+ printf '\n'
1454
+
1455
+ printf -- '── external agents ──\n'
1456
+ agents_compose up
1457
+ printf '\n'
1458
+
1459
+ mkdir -p "$WORKSPACES_DIR"
1460
+ local found=0 first_name="" entry file name
1461
+ for entry in "$WORKSPACES_DIR"/*; do
1462
+ [[ -d "$entry" ]] || continue
1463
+ file="$entry/.env"
1464
+ [[ -f "$file" ]] || continue
1465
+ name="$(env_value "$file" WORKSPACE_NAME "$(basename "$entry")")"
1466
+ found=1
1467
+ [[ -n "$first_name" ]] || first_name="$name"
1468
+ printf -- '── workspace: %s ──\n' "$name"
1469
+ up_workspace "$name"
1470
+ printf '\n'
1471
+ done
1472
+
1473
+ if [[ "$found" -eq 0 ]]; then
1474
+ printf 'No workspaces configured in %s\n' "$WORKSPACES_DIR"
1475
+ printf 'Create one with: wiki-workspace config <name>\n'
1476
+ return 0
1477
+ fi
1478
+
1479
+ if [[ "$open_browser" == "1" ]]; then
1480
+ local serve_port
1481
+ serve_port="$(workspace_value "$first_name" WIKI_SERVE_PORT 3100)"
1482
+ wait_and_open "http://localhost:${serve_port}/chat" &
1483
+ fi
1484
+ }
1485
+
1441
1486
  ensure_workspace_dirs() {
1442
1487
  local ws_path="$1"
1443
1488
  mkdir -p "$ws_path/.wiki/vector-index"
@@ -1575,6 +1620,16 @@ main() {
1575
1620
  exit 0
1576
1621
  fi
1577
1622
 
1623
+ # start [--open] — everything: runtime, agents, every configured workspace
1624
+ if [[ $# -ge 1 && "$1" == "start" ]]; then
1625
+ [[ $# -le 2 ]] || die "start takes at most --open"
1626
+ [[ "${2:-}" == "" || "${2:-}" == "--open" ]] || die "start takes at most --open"
1627
+ local start_open=0
1628
+ [[ "${2:-}" == "--open" ]] && start_open=1
1629
+ start_all "$start_open"
1630
+ exit 0
1631
+ fi
1632
+
1578
1633
  # up <workspace> [--open]
1579
1634
  if [[ $# -ge 2 && "$1" == "up" ]]; then
1580
1635
  local up_open=0