@dotdrelle/wiki-manager 0.15.96 → 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.96",
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",
@@ -436,6 +436,19 @@ export function bareToolCallJson(content, tools = []) {
436
436
  return hasArguments ? name : null;
437
437
  }
438
438
 
439
+ /**
440
+ * A model that has NO tool to call sometimes writes the call as text:
441
+ * `runtime__delegate{"objective":"…"}`. Unlike `bareToolCallJson` it is not
442
+ * JSON and cannot be validated against the offered set (there is none), but the
443
+ * `<namespace>__<tool>{` shape is never legitimate prose. Observed after a
444
+ * terminal failure stripped the tools from the synthesis turn: the raw call
445
+ * reached the user and nothing ran.
446
+ */
447
+ export function narratedToolCallText(content) {
448
+ const match = String(content ?? '').trim().match(/^([a-z][a-z0-9_-]*__[a-z][a-z0-9_-]*)\s*\{/i);
449
+ return match ? match[1] : null;
450
+ }
451
+
439
452
  function parseActionJson(text) {
440
453
  const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
441
454
  if (!cleaned) return null;
@@ -979,7 +992,7 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
979
992
  }
980
993
  if (tool === 'run_skill') {
981
994
  const skillName = String(args.skillName ?? '').trim();
982
- if (!skillName) return JSON.stringify({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] });
995
+ if (!skillName) return JSON.stringify({ ok: false, terminal: false, code: 'skill_not_found', message: 'skillName is required: pass the exact skill name, or delegate the objective with runtime__delegate.', availableSkills: [] });
983
996
  const selectedSkill = findSkill(session, skillName);
984
997
  const suppliedArguments = args.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments)
985
998
  ? args.arguments
@@ -1696,10 +1709,14 @@ export function createAgentGraph(options = {}) {
1696
1709
  // text really is a call to one of them: a legitimate answer that happens
1697
1710
  // to contain JSON (a config excerpt, an API sample) must go through
1698
1711
  // untouched, which is why this is not a "content starts with {" test.
1699
- const bareCall = tools.length > 0 ? bareToolCallJson(result.content, tools) : null;
1712
+ const bareCall = (tools.length > 0 ? bareToolCallJson(result.content, tools) : null)
1713
+ ?? narratedToolCallText(result.content);
1700
1714
  if (bareCall) {
1701
1715
  const retries = Number(state.invalidToolCallRetries ?? 0);
1702
- if (retries < 2) {
1716
+ // Retry only when a tool can still be called; when none are offered (the
1717
+ // synthesis turn after a terminal failure) the call is unexecutable and
1718
+ // the honest failure below is the whole answer.
1719
+ if (tools.length > 0 && retries < 2) {
1703
1720
  state.session._onStreamReset?.();
1704
1721
  state.session._onStep?.('Agent: tool call written as JSON text rejected; retrying…');
1705
1722
  return {
@@ -2014,6 +2031,18 @@ export function createAgentGraph(options = {}) {
2014
2031
  ),
2015
2032
  objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
2016
2033
  };
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.
2045
+ ok = false;
2017
2046
  }
2018
2047
  }
2019
2048
  if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
@@ -2164,7 +2193,35 @@ export function createAgentGraph(options = {}) {
2164
2193
  tool_call_id: call.id,
2165
2194
  content: boundedResult,
2166
2195
  });
2167
- 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
+ }
2168
2225
  }
2169
2226
 
2170
2227
  if (terminalFailure) {
@@ -2183,7 +2240,7 @@ export function createAgentGraph(options = {}) {
2183
2240
  // language like the `/turn` skill path. No further model turn: the skill
2184
2241
  // is launched and owns execution, so nothing can contradict it.
2185
2242
  const response = await generateSkillAcknowledgment(state.session, skillLaunch).catch(
2186
- () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) in progress.`,
2243
+ () => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) queued.`,
2187
2244
  );
2188
2245
  return {
2189
2246
  messages: toolResultMessages,
@@ -626,7 +626,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
626
626
  const result = await createAgentGraph().invoke({ input: 'lance le skill deliver avec le template Quarterly report', session });
627
627
  // Launching a skill ends the turn: the acknowledgement is generated once and
628
628
  // the model is not given a second chance to re-delegate or contradict it.
629
- assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s) in progress.');
629
+ assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s) queued.');
630
630
  assert.equal(mainCalls, 1);
631
631
  // `skillStack` accompagne désormais la demande : le run imbriqué démarre après
632
632
  // le nettoyage de celui-ci, et c'est le seul canal par lequel il peut savoir
@@ -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
  }
@@ -709,6 +717,110 @@ test('a terminal skill refusal stops the whole turn before a delegate fallback',
709
717
  assert.equal(delegated, false);
710
718
  });
711
719
 
720
+ test('a narrated tool call on the tool-less synthesis turn is never shown, and nothing runs', async () => {
721
+ // After a terminal failure the synthesis turn offers no tools; a model that
722
+ // still wants to act writes the call as text (`runtime__delegate{"…"}`) and
723
+ // the turn did nothing. That raw call must never reach the user.
724
+ let delegated = false;
725
+ const narrated = 'runtime__delegate{"objective":"nettoyer le wiki"}';
726
+ const session = sessionBase({
727
+ runtime: { url: 'http://runtime.test' },
728
+ _runSkillWithinRun: async () => ({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] }),
729
+ _delegateWithinRun: async () => { delegated = true; return { runId: 'bad' }; },
730
+ llm: {
731
+ async completeWithTools({ tools }) {
732
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
733
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
734
+ }
735
+ if (tools.length > 0) {
736
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [
737
+ { id: 'missing', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"missing","selectionKind":"explicit_name"}' } },
738
+ ] };
739
+ }
740
+ return { content: narrated, message: { role: 'assistant', content: narrated }, tool_calls: null };
741
+ },
742
+ },
743
+ });
744
+ const result = await createAgentGraph().invoke({ input: 'nettoie le wiki', session });
745
+ assert.equal(delegated, false);
746
+ assert.match(result.response, /printed an internal tool request/);
747
+ assert.doesNotMatch(result.response, /runtime__delegate/);
748
+ });
749
+
750
+ test('a recoverable skill refusal lets the delegate fallback run in the same turn', async () => {
751
+ // The observed defect: the model guessed `/diagnose`, the skill runner
752
+ // answered skill_not_found, and because that was terminal the tools were
753
+ // stripped from the next turn — the model then wrote
754
+ // `runtime__delegate{...}` as plain text and the turn did nothing. A guessed
755
+ // skill must be recoverable so the fallback can actually run.
756
+ let delegated = false;
757
+ const session = sessionBase({
758
+ runtime: { url: 'http://runtime.test' },
759
+ _runSkillWithinRun: async () => ({
760
+ ok: false,
761
+ terminal: false,
762
+ code: 'skill_not_found',
763
+ message: 'No skill named "/diagnose". Pass the exact name without a leading slash, or delegate the objective with runtime__delegate.',
764
+ availableSkills: ['diagnose'],
765
+ }),
766
+ _delegateWithinRun: async () => { delegated = true; return { runId: 'run-1', summary: { tasks: 1, agent: 'gateway' } }; },
767
+ llm: {
768
+ async completeWithTools({ tools }) {
769
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
770
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
771
+ }
772
+ return {
773
+ content: null, message: { role: 'assistant', content: null },
774
+ tool_calls: [
775
+ { id: 'guess', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"/diagnose","arguments":{}}' } },
776
+ { id: 'fallback', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"nettoyer le wiki, corriger les doublons et les affirmations non sourcées"}' } },
777
+ ],
778
+ };
779
+ },
780
+ },
781
+ });
782
+ const result = await createAgentGraph().invoke({ input: 'nettoie le wiki, corrige les doublons et les affirmations non sourcées', session });
783
+ assert.equal(delegated, true);
784
+ assert.notEqual(result.terminalToolFailure, true);
785
+ });
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
+
712
824
  test('tool argument normalization repairs only an unambiguous schema-compatible field name', () => {
713
825
  const schema = {
714
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
  }
@@ -6,7 +6,7 @@ import { formatRuntimeLogPayload, isDispatchPlumbingLine, normalizeRuntimeLog, s
6
6
  import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
7
7
  import { projectWorkflow } from './workflow.js';
8
8
  import { validateContractInDev } from '../contracts/schemas.js';
9
- import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
9
+ import { isActive, isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
10
10
 
11
11
  const SESSION_PROJECTION_EVENTS = new Set([
12
12
  'run_started',
@@ -292,6 +292,11 @@ export function applyAgentProjectionToSession(session, projection) {
292
292
  } : session.productionActivity ?? null;
293
293
  }
294
294
 
295
+ function hasRunningPlanStep(state) {
296
+ return (Array.isArray(state.plan) ? state.plan : [])
297
+ .some((step) => isActive(step?.status));
298
+ }
299
+
295
300
  function applyEvent(state, event) {
296
301
  switch (event.type) {
297
302
  case 'run_started':
@@ -598,6 +603,13 @@ function applyEvent(state, event) {
598
603
  reason: event.payload?.reason ?? null,
599
604
  createdAt: event.ts,
600
605
  });
606
+ // A run waiting for a human is not "running": showing it as running is
607
+ // how the chat could claim a rebuild was executing before anyone
608
+ // approved it. Mirror `run_pending_approval` (AGENTS.md: the run status
609
+ // is pending_approval while the decision is outstanding), but only when
610
+ // no task is actually executing — a parallel run may have work in flight
611
+ // while one branch waits.
612
+ if (!hasRunningPlanStep(state)) state.status = 'pending_approval';
601
613
  return;
602
614
  case 'approval.granted': {
603
615
  const grant = {
@@ -617,6 +629,13 @@ function applyEvent(state, event) {
617
629
  };
618
630
  upsertApproval(state, grant);
619
631
  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
+ }
620
639
  return;
621
640
  }
622
641
  case 'approval.rejected':
@@ -635,6 +654,17 @@ function applyEvent(state, event) {
635
654
  reason: event.payload?.reason ?? null,
636
655
  rejectedAt: event.ts,
637
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
+ }
638
668
  return;
639
669
  case 'run_done':
640
670
  state.status = 'done';
@@ -127,6 +127,65 @@ test('reduceAgentEvents: interactive (user) run_started clears state but is not
127
127
  assert.notEqual(projection.status, 'running');
128
128
  });
129
129
 
130
+ test('reduceAgentEvents: a run blocked on approval is not shown as running', () => {
131
+ // The chat claimed a rebuild was executing while its only task was still
132
+ // waiting for a human: the run status stayed 'running' through the per-task
133
+ // approval request. It must mirror run_pending_approval.
134
+ const projection = reduceAgentEvents([
135
+ createAgentEvent('run_started', { origin: 'runtime' }),
136
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
137
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
138
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
139
+ ]);
140
+ assert.equal(projection.status, 'pending_approval');
141
+ });
142
+
143
+ test('reduceAgentEvents: granting the approval puts the run back to running', () => {
144
+ const projection = reduceAgentEvents([
145
+ createAgentEvent('run_started', { origin: 'runtime' }),
146
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
147
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
148
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
149
+ createAgentEvent('approval.granted', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
150
+ ]);
151
+ assert.equal(projection.status, 'running');
152
+ });
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
+
179
+ test('reduceAgentEvents: an approval request does not hide a genuinely running task', () => {
180
+ const projection = reduceAgentEvents([
181
+ createAgentEvent('run_started', { origin: 'runtime' }),
182
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Export', 'Build'] } }),
183
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'running' } }),
184
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't2' } }),
185
+ ]);
186
+ assert.equal(projection.status, 'running');
187
+ });
188
+
130
189
  test('reduceAgentEvents: tracks manual plan and step updates', () => {
131
190
  const projection = reduceAgentEvents([
132
191
  createAgentEvent('plan_set', {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.96",
3
- "commit": "fbeddf2"
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.96';
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
  }