@dotdrelle/wiki-manager 0.15.97 → 0.15.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.97",
3
+ "version": "0.15.98",
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/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/deltaCoalescer.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 src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.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/testGate.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/mcpEndpoints.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/controlClassify.test.js src/runtime/deltaCoalescer.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 src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.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",
@@ -2031,10 +2031,17 @@ export function createAgentGraph(options = {}) {
2031
2031
  ),
2032
2032
  objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
2033
2033
  };
2034
- } else if (skillResult?.ok === false) {
2035
- // A recoverable refusal (guessed skill, missing input): keep the
2036
- // turn alive so the model can correct itself or delegate, but do
2037
- // not let the progress note call it a success.
2034
+ } else if (skillResult?.ok === false && skillResult?.needsInput !== true) {
2035
+ // A recoverable refusal (a guessed skill that does not exist):
2036
+ // keep the turn alive so the model can correct itself or
2037
+ // delegate, but do not let the progress note call it a success.
2038
+ //
2039
+ // `needsInput` is excluded on purpose: "ask the user for the
2040
+ // missing scope" is a conversational blocker, and marking it
2041
+ // failed published `runtime__run_skill failed: {…}` into the
2042
+ // progress surfaces while the turn was working exactly as
2043
+ // intended. The `delegate` branch below already treats the
2044
+ // identical case that way; the two must not disagree.
2038
2045
  ok = false;
2039
2046
  }
2040
2047
  }
@@ -2186,7 +2193,35 @@ export function createAgentGraph(options = {}) {
2186
2193
  tool_call_id: call.id,
2187
2194
  content: boundedResult,
2188
2195
  });
2189
- if (terminalFailure) break;
2196
+ if (terminalFailure || skillLaunch) break;
2197
+ }
2198
+
2199
+ // A skill launch owns execution and ends the turn, so the rest of the
2200
+ // batch is NOT executed — a companion runtime__delegate in the same batch
2201
+ // used to start a second, independent run with nothing in the thread
2202
+ // naming it. Every unexecuted call still gets its tool result: a provider
2203
+ // that sees tool_calls without matching results on a replayed history
2204
+ // rejects the conversation.
2205
+ if (skillLaunch) {
2206
+ const executed = new Set(toolResultMessages.map((message) => message.tool_call_id));
2207
+ const dropped = toolCalls.filter((call) => !executed.has(call.id));
2208
+ const notRun = `Not executed: ${skillLaunch.publicInput} was launched earlier in this `
2209
+ + 'turn and owns execution from here. Do not start a second run for the same objective.';
2210
+ for (const call of dropped) {
2211
+ emitAgentEvent(state.session, 'tool_call_result', 'tool', {
2212
+ callId: call.id,
2213
+ name: call.function?.name ?? 'tool',
2214
+ ok: false,
2215
+ result: notRun,
2216
+ summary: 'skipped',
2217
+ });
2218
+ toolResultMessages.push({ role: 'tool', tool_call_id: call.id, content: notRun });
2219
+ }
2220
+ if (dropped.length > 0) {
2221
+ state.session._onStep?.(
2222
+ `${dropped.length} tool call(s) skipped: ${skillLaunch.publicInput} owns execution`,
2223
+ );
2224
+ }
2190
2225
  }
2191
2226
 
2192
2227
  if (terminalFailure) {
@@ -2205,7 +2240,7 @@ export function createAgentGraph(options = {}) {
2205
2240
  // language like the `/turn` skill path. No further model turn: the skill
2206
2241
  // is launched and owns execution, so nothing can contradict it.
2207
2242
  const response = await generateSkillAcknowledgment(state.session, skillLaunch).catch(
2208
- () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) in progress.`,
2243
+ () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) queued.`,
2209
2244
  );
2210
2245
  return {
2211
2246
  messages: toolResultMessages,
@@ -678,6 +678,14 @@ test('a natural-language skill match cannot drop declared scope and fall back to
678
678
  const toolResult = session.agentEvents.find((event) => event.type === 'tool_call_result');
679
679
  assert.match(toolResult?.payload?.result ?? '', /missingParameters/);
680
680
  assert.match(toolResult?.payload?.result ?? '', /never replace a missing parameter with an unscoped/);
681
+ // "Ask the user for the missing scope" is a conversational blocker, not an
682
+ // execution failure — the turn is working exactly as intended. Marking it
683
+ // failed published `runtime__run_skill failed: {…}` into the progress and
684
+ // activity surfaces; the `delegate` branch handles the identical case as a
685
+ // non-failure, and the two must not disagree.
686
+ assert.equal(toolResult?.payload?.ok, true);
687
+ const progress = session.agentEvents.find((event) => event.type === 'assistant_progress');
688
+ assert.doesNotMatch(progress?.payload?.message ?? '', /failed/i);
681
689
  } finally {
682
690
  rmSync(root, { recursive: true, force: true });
683
691
  }
@@ -776,6 +784,43 @@ test('a recoverable skill refusal lets the delegate fallback run in the same tur
776
784
  assert.notEqual(result.terminalToolFailure, true);
777
785
  });
778
786
 
787
+ test('an ACCEPTED skill launch stops the rest of the batch, and says what it dropped', async () => {
788
+ // The mirror of the test above: a refusal must let the fallback run, an
789
+ // acceptance must not. The skill owns execution from there, but the loop
790
+ // kept going — a companion runtime__delegate in the same batch started a
791
+ // second, independent run while only the skill was acknowledged, with
792
+ // nothing in the thread naming the delegated one.
793
+ let delegated = false;
794
+ const session = sessionBase({
795
+ runtime: { url: 'http://runtime.test' },
796
+ _runSkillWithinRun: async () => ({ accepted: true, skill: 'pipeline', objectiveCount: 1 }),
797
+ _delegateWithinRun: async () => { delegated = true; return { runId: 'run-2', summary: { tasks: 1 } }; },
798
+ llm: {
799
+ async completeWithTools({ tools }) {
800
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
801
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
802
+ }
803
+ return {
804
+ content: null, message: { role: 'assistant', content: null },
805
+ tool_calls: [
806
+ { id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"pipeline","selectionKind":"explicit_name"}' } },
807
+ { id: 'also', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"construire le livrable"}' } },
808
+ ],
809
+ };
810
+ },
811
+ },
812
+ });
813
+ await createAgentGraph().invoke({ input: '/pipeline', session });
814
+ assert.equal(delegated, false, 'the delegate must not start a second run behind the skill');
815
+ // …and the skipped call is announced rather than silently dropped, with a
816
+ // tool result so a replayed history is not missing one.
817
+ const skipped = session.agentEvents.find(
818
+ (event) => event.type === 'tool_call_result' && event.payload?.callId === 'also',
819
+ );
820
+ assert.equal(skipped?.payload?.summary, 'skipped');
821
+ assert.match(skipped?.payload?.result ?? '', /owns execution/);
822
+ });
823
+
779
824
  test('tool argument normalization repairs only an unambiguous schema-compatible field name', () => {
780
825
  const schema = {
781
826
  type: 'object',
@@ -1725,6 +1725,13 @@ async function runRuntime(argv, agent) {
1725
1725
  async function executeInteractiveTurn(context, body, { signal, turnId } = {}) {
1726
1726
  const input = String(body.input ?? body.prompt ?? '').trim();
1727
1727
  if (!input) throw new Error('Missing input.');
1728
+ // The reader's own words, when the caller augmented `input` with system
1729
+ // facts (a status question gets the runtime's fact block appended for the
1730
+ // model). The thread, the SSE stream and the replayed history must all
1731
+ // show what was typed — publishing the fact block as the user's message
1732
+ // put a raw English dump in their bubble and seeded every later turn with
1733
+ // it.
1734
+ const displayInput = String(body.displayInput ?? '').trim() || input;
1728
1735
  // The runtime may start while optional agents are still stopped. `/start
1729
1736
  // agents` happens in the shell process, so its refreshed MCP snapshot does
1730
1737
  // not mutate this long-lived runtime context. Re-probe only while at least
@@ -1776,7 +1783,7 @@ async function runRuntime(argv, agent) {
1776
1783
  origin: 'runtime_turn',
1777
1784
  turnId,
1778
1785
  workspace: context.workspace ?? null,
1779
- payload: { content: input },
1786
+ payload: { content: displayInput },
1780
1787
  }));
1781
1788
  // Read-only chat turn: same chatAccess policy as the Shell UI's /chat, now
1782
1789
  // reachable over HTTP so `wiki serve` chat mode gets read tools without
@@ -1792,39 +1799,46 @@ async function runRuntime(argv, agent) {
1792
1799
  body.context?.openWikiPages ?? body.context?.openWikiPage,
1793
1800
  );
1794
1801
  let response;
1795
- if (chatMode) {
1796
- ephemeral.chatMode = true;
1797
- ephemeral.chatAccess = readChatAccessConfig();
1798
- const history = messages.length && messages[messages.length - 1]?.role === 'user'
1799
- ? messages.slice(0, -1)
1800
- : messages;
1801
- response = await runHeadlessChatTurn(ephemeral, input, {
1802
- history,
1803
- onStep: ephemeral._onStep,
1804
- // Fragments de réponse publiés au fil de l'eau, coalescés (voir
1805
- // deltaCoalescer ci-dessus). Le réducteur les agrège dans la dernière
1806
- // entrée de conversation (`assistant_delta`), que `assistant_message`
1807
- // vient ensuite figer : les deux interfaces voient la réponse s'écrire
1808
- // sans qu'un insert SQLite par token ne bloque le flux.
1809
- onTextDelta: (delta) => deltaCoalescer.push(delta),
1810
- onTextReset: () => {
1811
- deltaCoalescer.reset();
1812
- dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
1813
- origin: 'runtime_turn',
1814
- turnId,
1815
- workspace: context.workspace ?? null,
1816
- payload: {},
1817
- }));
1818
- },
1819
- openWikiPages,
1820
- });
1821
- } else {
1822
- ephemeral.openWikiPages = openWikiPages;
1823
- response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
1802
+ try {
1803
+ if (chatMode) {
1804
+ ephemeral.chatMode = true;
1805
+ ephemeral.chatAccess = readChatAccessConfig();
1806
+ const history = messages.length && messages[messages.length - 1]?.role === 'user'
1807
+ ? messages.slice(0, -1)
1808
+ : messages;
1809
+ response = await runHeadlessChatTurn(ephemeral, input, {
1810
+ history,
1811
+ onStep: ephemeral._onStep,
1812
+ // Fragments de réponse publiés au fil de l'eau, coalescés (voir
1813
+ // deltaCoalescer ci-dessus). Le réducteur les agrège dans la dernière
1814
+ // entrée de conversation (`assistant_delta`), que `assistant_message`
1815
+ // vient ensuite figer : les deux interfaces voient la réponse s'écrire
1816
+ // sans qu'un insert SQLite par token ne bloque le flux.
1817
+ onTextDelta: (delta) => deltaCoalescer.push(delta),
1818
+ onTextReset: () => {
1819
+ deltaCoalescer.reset();
1820
+ dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
1821
+ origin: 'runtime_turn',
1822
+ turnId,
1823
+ workspace: context.workspace ?? null,
1824
+ payload: {},
1825
+ }));
1826
+ },
1827
+ openWikiPages,
1828
+ });
1829
+ } else {
1830
+ ephemeral.openWikiPages = openWikiPages;
1831
+ response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
1832
+ }
1833
+ } finally {
1834
+ // In a `finally`, not after the await: a throwing or aborted turn left
1835
+ // the 80 ms timer armed, so a stray assistant_delta fired AFTER the
1836
+ // "Runtime turn failed" message and appended orphan fragments to the
1837
+ // wrong conversation entry — and the handle kept the session closure
1838
+ // alive. Flush the tail first so ordering survives either way.
1839
+ deltaCoalescer.flush();
1840
+ deltaCoalescer.dispose();
1824
1841
  }
1825
- // Flush the tail before the turn is finalized, then stop the timer.
1826
- deltaCoalescer.flush();
1827
- deltaCoalescer.dispose();
1828
1842
  // Persist the artifact the turn may have opened/edited (template_write,
1829
1843
  // template_read, …) back onto the long-lived session, so the next /turn —
1830
1844
  // chat or agent — sees it. The ephemeral session is otherwise discarded.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { isTerminal } from '../orchestrator/taskStatuses.js';
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
- import { openExternalUrl } from '../shell/openExternal.js';
12
+ import { openAppWindowUrl, openExternalUrl } from '../shell/openExternal.js';
13
13
  import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
14
14
  import { basename, join, relative } from 'node:path';
15
15
  import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
@@ -845,7 +845,7 @@ ${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
845
845
  ${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
846
846
  ${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
847
847
  ${helpPair('/chat', 'Chat mode', '/agent [question]', 'Agent mode / one-shot')}
848
- ${helpPair('/openui', 'Open web UI in browser', '', '')}
848
+ ${helpPair('/openui', 'Open web UI as a desktop window', '', '')}
849
849
  ${helpPair('/run status', 'Runtime status', '/run kill', 'Kill runtime run(s)')}
850
850
  ${helpPair('/run capability <id>', 'Deterministic capability run', '/approve', 'Grant pending approval')}
851
851
  ${helpPair('/cancel', 'Cancel active run', '', '')}
@@ -1770,6 +1770,11 @@ export async function handleSlashCommand(line, context) {
1770
1770
  // No readable session (gate off, or a custom state dir): plain URL.
1771
1771
  }
1772
1772
  const note = context.session.workspaceEnv ? '' : ' (no workspace loaded — using default port)';
1773
+ // App-mode first: a chromeless Chrome/Edge window picks up serve's own
1774
+ // manifest (name, icon, window-controls-overlay) with no prior "Install"
1775
+ // step. Falls back to the plain default-browser tab when neither is
1776
+ // found (Safari/Firefox-only machines).
1777
+ if (openAppWindowUrl(openUrl)) return { output: `Opening web UI: ${url}${note}` };
1773
1778
  if (openExternalUrl(openUrl)) return { output: `Opening web UI: ${url}${note}` };
1774
1779
  return { output: `Web UI: ${url}${note}` };
1775
1780
  }
@@ -654,6 +654,17 @@ function applyEvent(state, event) {
654
654
  reason: event.payload?.reason ?? null,
655
655
  rejectedAt: event.ts,
656
656
  });
657
+ // Same latch release as `approval.granted`: a refusal IS a decision. Only
658
+ // `granted` cleared the status, so a rejected approval left the
659
+ // projection reporting `pending_approval` for the rest of the run —
660
+ // both UIs kept asking for a decision the user had already made, while
661
+ // `explainControlState` found no pending approval and answered "run is
662
+ // active". The two surfaces disagreed until some later run_done
663
+ // overwrote it.
664
+ if (state.status === 'pending_approval'
665
+ && !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
666
+ state.status = 'running';
667
+ }
657
668
  return;
658
669
  case 'run_done':
659
670
  state.status = 'done';
@@ -151,6 +151,31 @@ test('reduceAgentEvents: granting the approval puts the run back to running', ()
151
151
  assert.equal(projection.status, 'running');
152
152
  });
153
153
 
154
+ test('reduceAgentEvents: REJECTING the approval also puts the run back to running', () => {
155
+ // A refusal is a decision. Only `granted` cleared the latch, so a rejected
156
+ // approval left both UIs asking for a decision already made, for the rest of
157
+ // the run.
158
+ const projection = reduceAgentEvents([
159
+ createAgentEvent('run_started', { origin: 'runtime' }),
160
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
161
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
162
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
163
+ createAgentEvent('approval.rejected', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
164
+ ]);
165
+ assert.equal(projection.status, 'running');
166
+ });
167
+
168
+ test('reduceAgentEvents: rejecting one of two approvals keeps the run blocked', () => {
169
+ const projection = reduceAgentEvents([
170
+ createAgentEvent('run_started', { origin: 'runtime' }),
171
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Export', 'Build'] } }),
172
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
173
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a2', scope: 'task', taskId: 't2' } }),
174
+ createAgentEvent('approval.rejected', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
175
+ ]);
176
+ assert.equal(projection.status, 'pending_approval');
177
+ });
178
+
154
179
  test('reduceAgentEvents: an approval request does not hide a genuinely running task', () => {
155
180
  const projection = reduceAgentEvents([
156
181
  createAgentEvent('run_started', { origin: 'runtime' }),
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.97",
3
- "commit": "b90e735"
2
+ "version": "0.15.98",
3
+ "commit": "acf1986"
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.97';
4
+ const WIKI_MANAGER_VERSION = '0.15.98';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -0,0 +1,33 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
9
+
10
+ // The `npm test` argument list is hand-maintained, and it has drifted twice:
11
+ // four files were once found outside the gate, and mcpEndpoints.test.js was
12
+ // green on disk while nothing ran it. A test nobody runs is worse than no
13
+ // test — it reads as coverage. This is the check that says so.
14
+ test('every test file on disk is in the npm test gate', () => {
15
+ const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
16
+ const listed = new Set(
17
+ pkg.scripts.test.split(/\s+/).filter((argument) => argument.endsWith('.test.js')),
18
+ );
19
+ const onDisk = execFileSync('find', ['src', '-name', '*.test.js'], {
20
+ cwd: repoRoot,
21
+ encoding: 'utf8',
22
+ })
23
+ .trim()
24
+ .split('\n')
25
+ .filter(Boolean)
26
+ .sort();
27
+
28
+ const missing = onDisk.filter((file) => !listed.has(file));
29
+ assert.deepEqual(missing, [], `test files not run by \`npm test\`:\n${missing.join('\n')}`);
30
+
31
+ const stale = [...listed].filter((file) => !onDisk.includes(file));
32
+ assert.deepEqual(stale, [], `\`npm test\` names files that no longer exist:\n${stale.join('\n')}`);
33
+ });
@@ -129,7 +129,13 @@ async function finalAnswerWithoutTools({
129
129
  });
130
130
  if (result?.tool_calls?.length) return '';
131
131
  return String(result?.content ?? result?.message?.content ?? '').trim();
132
- } catch {
132
+ } catch (err) {
133
+ // An abort is the user cancelling, not an empty answer. Swallowing it here
134
+ // made `runBoundedToolLoop` return `{ content: '', capped: true }`, and the
135
+ // caller printed the iteration-limit notice for a turn that was cancelled
136
+ // — the loop's contract is that an abort escapes, and this was the one
137
+ // call that broke it.
138
+ if (err?.name === 'AbortError' || signal?.aborted) throw err;
133
139
  return '';
134
140
  }
135
141
  }
@@ -0,0 +1,31 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classifyControlMessage } from './server.js';
4
+
5
+ const running = { running: true };
6
+
7
+ // A bare "yes" answers the runtime's own last prompt. Anything MORE than a
8
+ // bare yes is a request, and the rule used to be prefix-anchored only: every
9
+ // message merely STARTING on a yes was classified `observe` and answered with
10
+ // a status report, shadowing the modify_run and enqueue_run branches below it.
11
+ test('a bare confirmation during a run is a status check', async () => {
12
+ for (const input of ['oui', 'OK', 'vas-y', "d'accord", 'yes.', 'entendu !']) {
13
+ const result = await classifyControlMessage(input, running);
14
+ assert.equal(result.kind, 'observe', `expected observe for ${JSON.stringify(input)}`);
15
+ }
16
+ });
17
+
18
+ test('a plan change that merely opens on a yes is still a plan change', async () => {
19
+ const result = await classifyControlMessage(
20
+ 'oui, ajoute une étape de polish après le build',
21
+ running,
22
+ );
23
+ assert.equal(result.kind, 'modify_run');
24
+ });
25
+
26
+ test('a new task that opens on a yes reaches the model classifier, not the status branch', async () => {
27
+ const result = await classifyControlMessage("vas-y lance l'export", running, {
28
+ llm: { complete: async () => 'action' },
29
+ });
30
+ assert.equal(result.kind, 'enqueue_run');
31
+ });
@@ -9,7 +9,7 @@ import { createBudgetManager, BudgetExceededError } from '../orchestrator/budget
9
9
  import { createDispatcher } from '../orchestrator/dispatcher.js';
10
10
  import { approvalCovered, approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
11
  import { blockedByFailedDependency, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
12
- import { isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
12
+ import { isCancelled, isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
13
13
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
14
14
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
15
15
  import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
@@ -308,6 +308,7 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
308
308
  if (plan.length === 0) return;
309
309
  let failed = 0;
310
310
  let cancelled = 0;
311
+ let skipped = 0;
311
312
  let completed = 0;
312
313
  let pending = 0;
313
314
  let firstError = null;
@@ -319,8 +320,14 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
319
320
  step?.error?.message ?? step?.error?.code ?? step?.error
320
321
  ?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
321
322
  ).trim() || null;
322
- } else if (['cancelled', 'canceled'].includes(status)) {
323
+ } else if (isCancelled(status)) {
323
324
  cancelled += 1;
325
+ } else if (isSkipped(status)) {
326
+ // A chain step abandoned because an earlier required one failed. It fell
327
+ // through every bucket, so a 3-step chain that failed at step 1
328
+ // announced "0/3 réussie(s), 1 en erreur" and never mentioned the two
329
+ // steps nobody ran — precisely the silence the announce rule forbids.
330
+ skipped += 1;
324
331
  } else if (isSuccessful(status)) {
325
332
  completed += 1;
326
333
  } else if (isPending(status) || !isTerminal(status)) {
@@ -333,13 +340,15 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
333
340
  }
334
341
  }
335
342
  const total = plan.length;
336
- const finished = ok && failed === 0 && cancelled === 0 && pending === 0 && completed === total;
343
+ const finished = ok && failed === 0 && cancelled === 0 && skipped === 0
344
+ && pending === 0 && completed === total;
337
345
  const factLine = finished
338
346
  ? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
339
347
  : `Plan non terminé — ${completed}/${total} tâche(s) réussie(s)` +
340
348
  `${pending ? `, ${pending} en attente (approbation ou exécution)` : ''}` +
341
349
  `${failed ? `, ${failed} en erreur` : ''}` +
342
- `${cancelled ? `, ${cancelled} annulée(s)` : ''}.` +
350
+ `${cancelled ? `, ${cancelled} annulée(s)` : ''}` +
351
+ `${skipped ? `, ${skipped} abandonnée(s) faute d'une étape précédente` : ''}.` +
343
352
  `${firstError ? ` Première erreur : ${firstError}.` : ''}`;
344
353
  let content = factLine;
345
354
  const llm = session.llm;
@@ -1306,6 +1306,26 @@ test('announceRunOutcome never calls a plan with pending tasks a success', async
1306
1306
  assert.doesNotMatch(message.payload.content, /succès/i);
1307
1307
  });
1308
1308
 
1309
+ test('announceRunOutcome names the steps a failed chain abandoned', async () => {
1310
+ // `skipped` fell through every bucket: a 3-step chain failing at step 1
1311
+ // announced "0/3 réussie(s), 1 en erreur" and never mentioned the two steps
1312
+ // nobody ran. "When something is skipped, say so where the panels read."
1313
+ const session = {
1314
+ agentEvents: [],
1315
+ agentProjection: null,
1316
+ headlessPlan: [
1317
+ { id: 'a', description: 'Sync', status: 'failed' },
1318
+ { id: 'b', description: 'Ingest', status: 'skipped' },
1319
+ { id: 'c', description: 'Build', status: 'skipped' },
1320
+ ],
1321
+ };
1322
+ await announceRunOutcome(session, { runId: 'run-3', ok: false });
1323
+ const message = session.agentEvents.find((event) => event.type === 'assistant_message');
1324
+ assert.match(message.payload.content, /non terminé/i);
1325
+ assert.match(message.payload.content, /1 en erreur/);
1326
+ assert.match(message.payload.content, /2 abandonnée\(s\)/);
1327
+ });
1328
+
1309
1329
  test('announceRunOutcome reports success only when every task finished', async () => {
1310
1330
  const session = {
1311
1331
  agentEvents: [],
@@ -508,6 +508,12 @@ export function startRuntimeServer({
508
508
  sendJson(response, 400, { error: 'Missing input.' });
509
509
  return;
510
510
  }
511
+ // What the reader actually typed. `input` below may be replaced by a
512
+ // system fact block for the model; the THREAD must still show the
513
+ // reader's own words — the replacement was persisted as the
514
+ // `user_message` and replayed as history on every later turn, which is
515
+ // exactly the "raw facts never enter the thread" rule it broke.
516
+ const displayInput = input;
511
517
  // Read-only chat turns intentionally remain available while an agent
512
518
  // run is active. Other interactive turns still become control
513
519
  // messages so they cannot start a competing agent decision.
@@ -603,7 +609,12 @@ export function startRuntimeServer({
603
609
  return result.body;
604
610
  }
605
611
  }
606
- return turn(context, { ...body, input, mode: readOnlyChat ? 'chat' : body.mode }, {
612
+ return turn(context, {
613
+ ...body,
614
+ input,
615
+ displayInput,
616
+ mode: readOnlyChat ? 'chat' : body.mode,
617
+ }, {
607
618
  signal: controller.signal,
608
619
  turnId,
609
620
  });
@@ -1789,7 +1800,7 @@ function asksForRunStatus(input) {
1789
1800
  // semantic judgement about the workspace's domain, so it is never a keyword
1790
1801
  // list here — it goes to the model, bounded, and falls back to the choice menu
1791
1802
  // (`ambiguous`) rather than guessing when no model is available.
1792
- async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
1803
+ export async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
1793
1804
  // Caller (the /control message route) already trims and rejects empty input.
1794
1805
  const lower = String(input ?? '').toLowerCase();
1795
1806
  const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
@@ -1823,7 +1834,10 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
1823
1834
  // active, the only thing the runtime can act on is a status check: treating
1824
1835
  // the word as ordinary conversation made the read-only chat fallback lecture
1825
1836
  // the user about switching modes instead of answering.
1826
- if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\b/i.test(lower)) {
1837
+ // Anchored at BOTH ends: "oui" is a confirmation, "oui, ajoute une étape de
1838
+ // polish" is a plan change. Without the end anchor this branch shadowed
1839
+ // modify_run and enqueue_run for every message merely STARTING on a yes.
1840
+ if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\s*[.!…]*\s*$/i.test(lower)) {
1827
1841
  return { kind: 'observe', confidence: 0.7, reason: 'confirmation_of_runtime_prompt' };
1828
1842
  }
1829
1843
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
@@ -1747,6 +1747,7 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
1747
1747
  };
1748
1748
  let turns = 0;
1749
1749
  let turnInput = '';
1750
+ let turnDisplayInput = '';
1750
1751
  let turnMode = null;
1751
1752
  let handle;
1752
1753
  try {
@@ -1755,7 +1756,13 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
1755
1756
  store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1756
1757
  getContext: async () => context,
1757
1758
  run: async () => new Promise(() => {}),
1758
- turn: async (_context, options) => { turns += 1; turnInput = options.input; turnMode = options.mode; return { ok: true }; },
1759
+ turn: async (_context, options) => {
1760
+ turns += 1;
1761
+ turnInput = options.input;
1762
+ turnDisplayInput = options.displayInput;
1763
+ turnMode = options.mode;
1764
+ return { ok: true };
1765
+ },
1759
1766
  });
1760
1767
  } catch (err) {
1761
1768
  if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
@@ -1778,6 +1785,11 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
1778
1785
  assert.equal(turnMode, 'chat');
1779
1786
  assert.match(turnInput, /Build TechSections/);
1780
1787
  assert.match(turnInput, /runtime run, not a production job/i);
1788
+ // …and the THREAD still shows what the reader typed. `executeInteractiveTurn`
1789
+ // persists `displayInput` as the user_message; feeding it the fact block
1790
+ // put a raw English dump in the reader's own bubble and replayed it as
1791
+ // history on every later turn.
1792
+ assert.equal(turnDisplayInput, 'donne le status du job en cours');
1781
1793
  } finally {
1782
1794
  context.currentAbortController?.abort();
1783
1795
  await handle.close();
@@ -51,3 +51,46 @@ export function openExternalUrl(value, { run = execFileSync } = {}) {
51
51
  }
52
52
  return null;
53
53
  }
54
+
55
+ // A Chromium `--app=<url>` window is chromeless from the start — no tab
56
+ // strip, no address bar — and, when the origin serves a manifest (llm-wiki's
57
+ // `/manifest.webmanifest`), it picks up the site's name/icon/theme-color and
58
+ // window-controls-overlay exactly like a formally "installed" PWA, with no
59
+ // prior install step required. `/openui`'s whole point is a desktop-feeling
60
+ // window, so it tries this before falling back to `openExternalUrl`'s plain
61
+ // tab. Same candidate/fallback shape as `openerCandidates` above, and the same
62
+ // contract: returns the opened URL, or null when no Chromium browser answered.
63
+ function appModeCandidates() {
64
+ if (process.platform === 'darwin') {
65
+ return [
66
+ ['open', ['-na', 'Google Chrome', '--args']],
67
+ ['open', ['-na', 'Microsoft Edge', '--args']],
68
+ ];
69
+ }
70
+ if (process.platform === 'win32') {
71
+ return [
72
+ ['cmd', ['/c', 'start', '', 'chrome']],
73
+ ['cmd', ['/c', 'start', '', 'msedge']],
74
+ ];
75
+ }
76
+ return [
77
+ ['google-chrome', []],
78
+ ['chromium', []],
79
+ ['chromium-browser', []],
80
+ ['microsoft-edge', []],
81
+ ];
82
+ }
83
+
84
+ export function openAppWindowUrl(value, { run = execFileSync } = {}) {
85
+ const url = normalizeExternalUrl(value);
86
+ if (!url) return null;
87
+ for (const [command, args] of appModeCandidates()) {
88
+ try {
89
+ run(command, [...args, `--app=${url}`], { stdio: 'ignore', timeout: 5_000 });
90
+ return url;
91
+ } catch {
92
+ // Missing binary or non-zero exit: try the next candidate.
93
+ }
94
+ }
95
+ return null;
96
+ }
package/src/shell/repl.js CHANGED
@@ -94,7 +94,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
94
94
  '/clear': 'Clear the conversation screen.',
95
95
  '/chat': 'Switch free text to direct LLM chat without tools.',
96
96
  '/agent': 'Switch to agent mode, or run one agent request with /agent <question>.',
97
- '/openui': 'Open the workspace web UI in the browser.',
97
+ '/openui': 'Open the workspace web UI as a chromeless desktop window (Chrome/Edge), or a browser tab as fallback.',
98
98
  '/run': 'Inspect, cancel, kill runtime runs, or start a capability run.',
99
99
  '/approve': 'Approve a pending runtime run or tool.',
100
100
  };