@dotdrelle/wiki-manager 0.15.72 → 0.15.73

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.73",
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",
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.72",
3
- "commit": "fccddba"
2
+ "version": "0.15.73",
3
+ "commit": "b7de9bb"
4
4
  }
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.73';
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);
@@ -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');
@@ -311,6 +311,20 @@ export async function discoverRuntimeProvidersOnce(session, {
311
311
  currentDown.add(key);
312
312
  if (!session._runtimeProviderDown.has(key)) {
313
313
  const kept = preservedByRuntime.get(item.runtimeId)?.length ?? 0;
314
+ // Transient states get the ⚠ glyph, which both UIs render in blue: the
315
+ // gateway answering HTTP 401 before its agents are loaded (workspace
316
+ // switch, boot order), or not listening yet (fetch failed, refused,
317
+ // timeout). An HTTP 4xx/5xx other than 401 — the server answers but is
318
+ // broken — stays a red "unavailable", like the non-HTTP agent contract
319
+ // errors.
320
+ const errorText = String(item.error ?? '');
321
+ const httpMatch = errorText.match(/\bHTTP (\d{3})\b/);
322
+ const connectionFailure = /\b(fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up|connection refused|network error|timeout|timed out)\b/i.test(errorText);
323
+ const notReady = (httpMatch != null && httpMatch[1] === '401') || (httpMatch == null && connectionFailure);
324
+ if (notReady) {
325
+ 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`);
326
+ continue;
327
+ }
314
328
  dispatchRuntimeLog(session, kept > 0
315
329
  ? `agent-runtimes: ${item.runtimeId} unavailable (${item.error}) — keeping ${kept} last-known capabilit${kept === 1 ? 'y' : 'ies'} until it recovers`
316
330
  : `agent-runtimes: ${item.runtimeId} unavailable (${item.error})`);
@@ -265,6 +265,70 @@ 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('an HTTP 401 gateway probe is announced as not-ready with the ⚠ glyph, not as a failure', async () => {
269
+ const session = sessionWithEvents();
270
+ const originalFetch = globalThis.fetch;
271
+ globalThis.fetch = async (url) => {
272
+ if (String(url).includes('/health')) return new Response('{}', { status: 401 });
273
+ throw new Error('unexpected probe ' + String(url));
274
+ };
275
+ try {
276
+ await discoverRuntimeProvidersOnce(session, {
277
+ config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
278
+ });
279
+ } finally {
280
+ globalThis.fetch = originalFetch;
281
+ }
282
+
283
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
284
+ && String(event.payload?.message ?? '').includes('deepagents'));
285
+ assert.equal(logs.length, 1, 'the transient auth state is announced once');
286
+ const message = String(logs[0].payload?.message ?? '');
287
+ assert.match(message, /^⚠ agent-runtimes: deepagents not ready yet \(HTTP 401/);
288
+ assert.match(message, /retrying on the next scan/);
289
+ assert.doesNotMatch(message, /unavailable/, 'a gateway answering before its agents load is not a failure');
290
+ });
291
+
292
+ test('a gateway that is not listening yet (fetch failed) is also announced as not-ready', async () => {
293
+ const session = sessionWithEvents();
294
+ const originalFetch = globalThis.fetch;
295
+ globalThis.fetch = async () => {
296
+ throw new TypeError('fetch failed');
297
+ };
298
+ try {
299
+ await discoverRuntimeProvidersOnce(session, {
300
+ config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
301
+ });
302
+ } finally {
303
+ globalThis.fetch = originalFetch;
304
+ }
305
+
306
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
307
+ && String(event.payload?.message ?? '').includes('deepagents'));
308
+ assert.equal(logs.length, 1);
309
+ const message = String(logs[0].payload?.message ?? '');
310
+ assert.match(message, /^⚠ agent-runtimes: deepagents not ready yet \(fetch failed\)/);
311
+ assert.doesNotMatch(message, /unavailable/);
312
+ });
313
+
314
+ test('an HTTP 500 gateway probe stays a red unavailable announcement', async () => {
315
+ const session = sessionWithEvents();
316
+ const originalFetch = globalThis.fetch;
317
+ globalThis.fetch = async () => new Response('{}', { status: 500 });
318
+ try {
319
+ await discoverRuntimeProvidersOnce(session, {
320
+ config: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://127.0.0.1:7789', enabled: true }],
321
+ });
322
+ } finally {
323
+ globalThis.fetch = originalFetch;
324
+ }
325
+
326
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
327
+ && String(event.payload?.message ?? '').includes('deepagents'));
328
+ assert.equal(logs.length, 1);
329
+ assert.match(String(logs[0].payload?.message ?? ''), /^agent-runtimes: deepagents unavailable \(HTTP 500/);
330
+ });
331
+
268
332
  test('a transient probe failure keeps the last-known capability set (a failed probe is not a lost agent)', async () => {
269
333
  const session = sessionWithEvents();
270
334
  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