@dotdrelle/wiki-manager 0.15.97 → 0.15.99

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.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/src/agent/graph.js +45 -7
  3. package/src/agent/graph.test.js +45 -0
  4. package/src/cli/wiki-manager.js +47 -33
  5. package/src/commands/slash.js +7 -2
  6. package/src/contracts/schemas.js +8 -17
  7. package/src/contracts/schemas.test.js +15 -0
  8. package/src/core/agentEvents.js +45 -7
  9. package/src/core/agentEvents.test.js +65 -0
  10. package/src/core/buildInfo.json +2 -2
  11. package/src/core/mcp.js +1 -1
  12. package/src/core/runtimeEventAdapter.js +99 -1
  13. package/src/core/runtimeEventAdapter.test.js +92 -2
  14. package/src/core/skillCompiler.test.js +1 -1
  15. package/src/core/testGate.test.js +33 -0
  16. package/src/core/toolLoop.js +14 -2
  17. package/src/core/toolLoop.test.js +28 -0
  18. package/src/orchestrator/dispatcher.js +19 -0
  19. package/src/orchestrator/knowledgeSignals.js +260 -0
  20. package/src/orchestrator/knowledgeSignals.test.js +193 -0
  21. package/src/orchestrator/proactiveReviewScheduler.js +240 -0
  22. package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
  23. package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
  24. package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
  25. package/src/orchestrator/resultAggregator.js +115 -1
  26. package/src/orchestrator/resultAggregator.test.js +138 -0
  27. package/src/runtime/controlClassify.test.js +31 -0
  28. package/src/runtime/runner.js +13 -4
  29. package/src/runtime/runner.test.js +20 -0
  30. package/src/runtime/server.js +256 -4
  31. package/src/runtime/server.test.js +13 -1
  32. package/src/runtime/store.js +1 -1
  33. package/src/runtime/store.test.js +5 -1
  34. package/src/shell/openExternal.js +43 -0
  35. package/src/shell/repl.js +1 -1
  36. package/wiki-workspace +0 -1
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.99",
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/proactiveReviewScheduler.test.js src/orchestrator/knowledgeSignals.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",
@@ -1905,7 +1905,11 @@ export function createAgentGraph(options = {}) {
1905
1905
  let terminalFailure = null;
1906
1906
  let skillLaunch = null;
1907
1907
 
1908
- for (const call of toolCalls) {
1908
+ // The index is kept so a break can name the calls that never ran: every
1909
+ // iteration pushes a tool result before it continues or completes, so
1910
+ // everything AFTER the break is exactly the unexecuted set.
1911
+ let stoppedAt = toolCalls.length;
1912
+ for (const [callIndex, call] of toolCalls.entries()) {
1909
1913
  const resolved = resolveToolCallName(state.session.mcp, call.function.name, INTERNAL_TOOL_SERVERS);
1910
1914
  const { server, tool } = resolved;
1911
1915
  const argsSummary = summarizeToolArguments(call.function.arguments);
@@ -2031,10 +2035,17 @@ export function createAgentGraph(options = {}) {
2031
2035
  ),
2032
2036
  objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
2033
2037
  };
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.
2038
+ } else if (skillResult?.ok === false && skillResult?.needsInput !== true) {
2039
+ // A recoverable refusal (a guessed skill that does not exist):
2040
+ // keep the turn alive so the model can correct itself or
2041
+ // delegate, but do not let the progress note call it a success.
2042
+ //
2043
+ // `needsInput` is excluded on purpose: "ask the user for the
2044
+ // missing scope" is a conversational blocker, and marking it
2045
+ // failed published `runtime__run_skill failed: {…}` into the
2046
+ // progress surfaces while the turn was working exactly as
2047
+ // intended. The `delegate` branch below already treats the
2048
+ // identical case that way; the two must not disagree.
2038
2049
  ok = false;
2039
2050
  }
2040
2051
  }
@@ -2186,7 +2197,34 @@ export function createAgentGraph(options = {}) {
2186
2197
  tool_call_id: call.id,
2187
2198
  content: boundedResult,
2188
2199
  });
2189
- if (terminalFailure) break;
2200
+ if (terminalFailure || skillLaunch) { stoppedAt = callIndex + 1; break; }
2201
+ }
2202
+
2203
+ // A skill launch owns execution and ends the turn, so the rest of the
2204
+ // batch is NOT executed — a companion runtime__delegate in the same batch
2205
+ // used to start a second, independent run with nothing in the thread
2206
+ // naming it. Every unexecuted call still gets its tool result: a provider
2207
+ // that sees tool_calls without matching results on a replayed history
2208
+ // rejects the conversation.
2209
+ if (skillLaunch) {
2210
+ const dropped = toolCalls.slice(stoppedAt);
2211
+ const notRun = `Not executed: ${skillLaunch.publicInput} was launched earlier in this `
2212
+ + 'turn and owns execution from here. Do not start a second run for the same objective.';
2213
+ for (const call of dropped) {
2214
+ emitAgentEvent(state.session, 'tool_call_result', 'tool', {
2215
+ callId: call.id,
2216
+ name: call.function?.name ?? 'tool',
2217
+ ok: false,
2218
+ result: notRun,
2219
+ summary: 'skipped',
2220
+ });
2221
+ toolResultMessages.push({ role: 'tool', tool_call_id: call.id, content: notRun });
2222
+ }
2223
+ if (dropped.length > 0) {
2224
+ state.session._onStep?.(
2225
+ `${dropped.length} tool call(s) skipped: ${skillLaunch.publicInput} owns execution`,
2226
+ );
2227
+ }
2190
2228
  }
2191
2229
 
2192
2230
  if (terminalFailure) {
@@ -2205,7 +2243,7 @@ export function createAgentGraph(options = {}) {
2205
2243
  // language like the `/turn` skill path. No further model turn: the skill
2206
2244
  // is launched and owns execution, so nothing can contradict it.
2207
2245
  const response = await generateSkillAcknowledgment(state.session, skillLaunch).catch(
2208
- () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) in progress.`,
2246
+ () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) queued.`,
2209
2247
  );
2210
2248
  return {
2211
2249
  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
  }
@@ -369,23 +369,14 @@ const runtimeEventSchema = {
369
369
  required: ['type'],
370
370
  additionalProperties: true,
371
371
  properties: {
372
- type: {
373
- type: 'string',
374
- enum: [
375
- 'run_created',
376
- 'run_started',
377
- 'agent_thinking',
378
- 'tool_started',
379
- 'tool_finished',
380
- 'subagent_started',
381
- 'subagent_finished',
382
- 'message',
383
- 'approval_required',
384
- 'run_completed',
385
- 'run_failed',
386
- 'run_cancelled',
387
- ],
388
- },
372
+ // Deliberately OPEN: a gateway newer than the manager emits event types
373
+ // this version has never heard of, and a closed enum here threw inside
374
+ // `normalizeRuntimeEvent` before `runtimeEventAdapter` could decide what to
375
+ // do with them — the provider's catch swallowed the frame, so the whole
376
+ // activity contract was invisible in production while its unit tests
377
+ // passed. The adapter owns the vocabulary and journals an unknown type;
378
+ // the schema must let it see one.
379
+ type: { type: 'string' },
389
380
  runId: { type: 'string' },
390
381
  tool: { type: 'string' },
391
382
  durationMs: { type: 'number' },
@@ -250,3 +250,18 @@ test('capability status contract carries dynamic pending inputs without prescrib
250
250
  assert.equal(validateContract('capabilityStatus', status).ok, true);
251
251
  assert.equal(validateContract('capabilityStatus', { ...status, pendingInputs: [{ type: 'file' }] }).ok, false);
252
252
  });
253
+
254
+ test('the runtime event contract tolerates a type this version does not know', () => {
255
+ // A newer gateway emits types this manager has never heard of. A closed enum
256
+ // made `normalizeRuntimeEvent` throw before the adapter could journal it, so
257
+ // the whole activity contract was silently invisible. The schema must let an
258
+ // unknown type through; deciding what to do with it belongs to the adapter.
259
+ for (const type of ['phase_started', 'progress', 'heartbeat', 'finding', 'degraded', 'notice', 'stream_epoch', 'a_future_type']) {
260
+ assert.equal(
261
+ validateContract('runtimeEvent', { type, runId: 'r1' }).ok,
262
+ true,
263
+ `${type} must be accepted`,
264
+ );
265
+ }
266
+ assert.equal(validateContract('runtimeEvent', { runId: 'r1' }).ok, false, 'a missing type is still refused');
267
+ });
@@ -231,6 +231,10 @@ function createProjectionState() {
231
231
  agents: {},
232
232
  summary: null,
233
233
  status: 'idle',
234
+ // Liveness from the external runtime's heartbeat (lot 2). Display-only:
235
+ // never persisted, never in the conversation.
236
+ lastHeartbeatAt: null,
237
+ lastHeartbeatElapsedMs: 0,
234
238
  };
235
239
  }
236
240
 
@@ -263,6 +267,8 @@ function publicProjection(state) {
263
267
  .sort((a, b) => a.agentInstanceId.localeCompare(b.agentInstanceId)),
264
268
  summary: state.summary,
265
269
  status: state.status,
270
+ lastHeartbeatAt: state.lastHeartbeatAt ?? null,
271
+ lastHeartbeatElapsedMs: state.lastHeartbeatElapsedMs ?? 0,
266
272
  };
267
273
  return {
268
274
  ...projection,
@@ -292,6 +298,21 @@ export function applyAgentProjectionToSession(session, projection) {
292
298
  } : session.productionActivity ?? null;
293
299
  }
294
300
 
301
+ /**
302
+ * Clears the `pending_approval` latch once no approval is outstanding.
303
+ *
304
+ * Called after EVERY approval decision, granted or rejected. Only `granted`
305
+ * used to clear it, so a refusal left the projection reporting
306
+ * `pending_approval` for the rest of the run: both UIs kept asking for a
307
+ * decision already made, while `explainControlState` found nothing pending and
308
+ * answered "run is active". One function so the two verdicts cannot drift.
309
+ */
310
+ function releaseApprovalLatch(state) {
311
+ if (state.status !== 'pending_approval') return;
312
+ if ((state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) return;
313
+ state.status = 'running';
314
+ }
315
+
295
316
  function hasRunningPlanStep(state) {
296
317
  return (Array.isArray(state.plan) ? state.plan : [])
297
318
  .some((step) => isActive(step?.status));
@@ -318,8 +339,17 @@ function applyEvent(state, event) {
318
339
  state.planPatches = [];
319
340
  state.summary = null;
320
341
  state.subagents = [];
342
+ state.lastHeartbeatAt = null;
343
+ state.lastHeartbeatElapsedMs = 0;
321
344
  pruneTerminalControlItems(state.controlQueue);
322
345
  return;
346
+ case 'runtime_heartbeat':
347
+ // Liveness only: the external runtime saying "still working" during a
348
+ // long, tool-less phase. A timestamp the run strip reads; never a
349
+ // conversation entry, never persisted (store.js NON_PERSISTED_EVENT_TYPES).
350
+ state.lastHeartbeatAt = event.ts ?? new Date().toISOString();
351
+ state.lastHeartbeatElapsedMs = Number(event.payload?.elapsedMs) || 0;
352
+ return;
323
353
  case 'user_message':
324
354
  state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
325
355
  return;
@@ -450,6 +480,15 @@ function applyEvent(state, event) {
450
480
  case 'task.failed':
451
481
  appendLog(state, taskLogLine(state, event, 'failed'));
452
482
  return;
483
+ // Stable business facts: a workspace's knowledge changed. They are
484
+ // published for the proactive scheduler and shown as one journal line.
485
+ case 'knowledge.ingested':
486
+ case 'knowledge.rebuilt': {
487
+ const workspace = String(event.payload?.workspace ?? 'workspace');
488
+ const version = String(event.payload?.sourceVersion ?? 'unknown version');
489
+ appendLog(state, `${logTime(event.ts)} ${event.type} — ${workspace} (${version})`.trim());
490
+ return;
491
+ }
453
492
  case 'plan.revision_changed':
454
493
  if (Array.isArray(event.payload?.tasks)) {
455
494
  state.plan = normalizePlan(event.payload.tasks, { owner: 'orchestrator', planRevision: state.planRevision });
@@ -629,13 +668,7 @@ function applyEvent(state, event) {
629
668
  };
630
669
  upsertApproval(state, grant);
631
670
  markCoveredApprovalsApproved(state.approvals, grant, event.ts);
632
- // The decision is in: the run goes back to running unless another
633
- // approval is still outstanding (a run-scoped grant clears its covered
634
- // ones, markCoveredApprovalsApproved above).
635
- if (state.status === 'pending_approval'
636
- && !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
637
- state.status = 'running';
638
- }
671
+ releaseApprovalLatch(state);
639
672
  return;
640
673
  }
641
674
  case 'approval.rejected':
@@ -654,6 +687,7 @@ function applyEvent(state, event) {
654
687
  reason: event.payload?.reason ?? null,
655
688
  rejectedAt: event.ts,
656
689
  });
690
+ releaseApprovalLatch(state);
657
691
  return;
658
692
  case 'run_done':
659
693
  state.status = 'done';
@@ -720,6 +754,10 @@ function applyEvent(state, event) {
720
754
  : {}),
721
755
  ...(event.payload?.selectionKind ? { selectionKind: event.payload.selectionKind } : {}),
722
756
  ...(Number.isInteger(event.payload?.chainSequence) ? { chainSequence: event.payload.chainSequence } : {}),
757
+ // A proactive review's identity must survive projection and replay: the
758
+ // drain hands it back to the run it starts, which is what lets the
759
+ // result be filed as a review rather than lost as an anonymous audit.
760
+ ...(event.payload?.proactiveReview ? { proactiveReview: event.payload.proactiveReview } : {}),
723
761
  optional: event.payload?.optional === true,
724
762
  continueOnFailure: event.payload?.continueOnFailure === true,
725
763
  });
@@ -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' }),
@@ -874,3 +899,43 @@ test('subagent_started/finished track the collective timeline, reset per run', (
874
899
  dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r2', payload: {} }));
875
900
  assert.equal(session.agentProjection.subagents.length, 0, 'a new run starts a fresh timeline');
876
901
  });
902
+
903
+ test('a runtime heartbeat sets liveness only, and a new run clears it', () => {
904
+ const session = {};
905
+ dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r1', payload: {} }));
906
+ dispatchAgentEvent(session, createAgentEvent('runtime_heartbeat', {
907
+ origin: 'runtime_provider', runId: 'r1', payload: { elapsedMs: 30_000 },
908
+ }));
909
+
910
+ assert.ok(session.agentProjection.lastHeartbeatAt, 'the beat is visible to the strip');
911
+ assert.equal(session.agentProjection.lastHeartbeatElapsedMs, 30_000);
912
+ // A heartbeat is not an event the conversation projection can seed from.
913
+ assert.equal(session.agentProjection.conversation.length, 0);
914
+
915
+ dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime', runId: 'r2', payload: {} }));
916
+ assert.equal(session.agentProjection.lastHeartbeatAt, null, 'a new run starts with no stale beat');
917
+ });
918
+
919
+ test('a queued control item keeps its proactive-review marker across projection', () => {
920
+ const session = {};
921
+ const marker = { id: 'review-1', workspace: 'docs', trigger: 'knowledge.ingested', sourceVersion: 'v1' };
922
+ dispatchAgentEvent(session, createAgentEvent('control_enqueued', {
923
+ origin: 'runtime',
924
+ workspace: 'docs',
925
+ payload: { id: 'control-1', workspace: 'docs', input: 'audit the workspace', proactiveReview: marker },
926
+ }));
927
+
928
+ assert.deepEqual(session.agentProjection.controlQueue[0].proactiveReview, marker);
929
+ });
930
+
931
+ test('streamed deltas are replaced by the final message, never duplicated', () => {
932
+ const session = {};
933
+ dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'Hello ' } }));
934
+ dispatchAgentEvent(session, createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'world' } }));
935
+ assert.equal(session.agentProjection.conversation.at(-1).content, 'Hello world');
936
+
937
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'Hello world' } }));
938
+ assert.equal(session.agentProjection.conversation.length, 1, 'the final message replaces the streamed one');
939
+ assert.equal(session.agentProjection.conversation[0].content, 'Hello world');
940
+ assert.ok(!session.agentProjection.conversation[0].streaming);
941
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.97",
3
- "commit": "b90e735"
2
+ "version": "0.15.99",
3
+ "commit": "8a91b62"
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.99';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();