@dotdrelle/wiki-manager 0.15.42 → 0.15.48

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 (46) hide show
  1. package/README.md +139 -27
  2. package/mcp.endpoints.example.json +1 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +290 -29
  5. package/src/agent/graph.test.js +551 -1
  6. package/src/agent/skillRecursion.test.js +98 -0
  7. package/src/cli/wiki-manager.js +209 -7
  8. package/src/cli/wiki-manager.test.js +89 -0
  9. package/src/commands/slash.js +28 -10
  10. package/src/contracts/schemas.js +1 -1
  11. package/src/core/agentEvents.js +50 -0
  12. package/src/core/agentEvents.test.js +52 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/env.js +20 -1
  15. package/src/core/env.test.js +34 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/profile.js +19 -0
  18. package/src/core/runtimeLog.js +15 -0
  19. package/src/core/runtimeLog.test.js +15 -1
  20. package/src/core/skillChainView.js +84 -0
  21. package/src/core/skillChainView.test.js +50 -0
  22. package/src/core/skillCompiler.js +135 -0
  23. package/src/core/skillCompiler.test.js +91 -0
  24. package/src/core/skillInvocation.js +79 -0
  25. package/src/core/skillInvocation.test.js +73 -0
  26. package/src/core/skills.js +81 -19
  27. package/src/core/wikiWorkspace.test.js +34 -0
  28. package/src/core/workspaceProfile.test.js +55 -0
  29. package/src/runtime/client.js +45 -4
  30. package/src/runtime/controlCancellation.js +33 -0
  31. package/src/runtime/controlCancellation.test.js +49 -0
  32. package/src/runtime/controlDrain.js +50 -0
  33. package/src/runtime/controlDrain.test.js +38 -0
  34. package/src/runtime/server.js +341 -20
  35. package/src/runtime/server.test.js +344 -2
  36. package/src/runtime/skillChain.e2e.test.js +394 -0
  37. package/src/runtime/skillRun.js +104 -0
  38. package/src/runtime/skillRun.test.js +84 -0
  39. package/src/runtime/store.js +69 -0
  40. package/src/runtime/store.test.js +11 -0
  41. package/src/runtime/workspaceIsolation.test.js +178 -0
  42. package/src/shell/RightPane.tsx +3 -2
  43. package/src/shell/repl.js +51 -6
  44. package/src/shell/repl.test.js +41 -0
  45. package/src/shell/useSession.ts +43 -9
  46. package/wiki-workspace +137 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @statuses-vocabulary
3
+ * Control items are queued run requests, not orchestrator tasks. Their
4
+ * terminal vocabulary deliberately includes chain-level `skipped` and is
5
+ * projected by core/agentEvents.js rather than taskStatuses.js.
6
+ */
7
+ const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
8
+
9
+ export function reconcileControlQueue(context, { startItem, skipItem } = {}) {
10
+ if (!context?.session || context.running || context.controlDrainActive) return false;
11
+ context.controlDrainActive = true;
12
+ try {
13
+ const queue = Array.isArray(context.session.controlQueue) ? context.session.controlQueue : [];
14
+ applySkipPropagation(queue, skipItem);
15
+ // Event dispatch replaces the projection array and its objects. Re-read it
16
+ // after propagation so eligibility never runs against stale queued items.
17
+ const refreshed = Array.isArray(context.session.controlQueue) ? context.session.controlQueue : [];
18
+ const next = refreshed.find((item) => isRunnableControlItem(item, refreshed));
19
+ if (!next) return false;
20
+ startItem?.(next);
21
+ return true;
22
+ } finally {
23
+ context.controlDrainActive = false;
24
+ }
25
+ }
26
+
27
+ export function isRunnableControlItem(item, queue) {
28
+ if (item?.status !== 'queued') return false;
29
+ if (!item.chainId) return true;
30
+ const predecessors = queue.filter((candidate) => candidate.chainId === item.chainId && Number(candidate.chainSequence) < Number(item.chainSequence));
31
+ if (predecessors.some((candidate) => !TERMINAL.has(candidate.status))) return false;
32
+ return !predecessors.some((candidate) => candidate.optional !== true && candidate.continueOnFailure !== true && ['failed', 'cancelled', 'skipped'].includes(candidate.status));
33
+ }
34
+
35
+ export function applySkipPropagation(queue, skipItem = null) {
36
+ let changed = 0;
37
+ for (const item of queue) {
38
+ if (item.status !== 'queued' || !item.chainId) continue;
39
+ const predecessors = queue.filter((candidate) => candidate.chainId === item.chainId && Number(candidate.chainSequence) < Number(item.chainSequence));
40
+ const blocker = predecessors.find((candidate) => candidate.optional !== true && candidate.continueOnFailure !== true && ['failed', 'cancelled', 'skipped'].includes(candidate.status));
41
+ if (!blocker) continue;
42
+ skipItem?.(item, blocker.status === 'cancelled' ? 'chain_cancelled' : 'required_predecessor_failed');
43
+ // Event dispatch replaces the projected queue instead of mutating this
44
+ // snapshot. Mirror the transition locally so later siblings in this same
45
+ // pass can observe the cascade (failed -> skipped -> skipped).
46
+ item.status = 'skipped';
47
+ changed += 1;
48
+ }
49
+ return changed;
50
+ }
@@ -0,0 +1,38 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { applySkipPropagation, reconcileControlQueue } from './controlDrain.js';
4
+
5
+ test('drain starts only the first runnable chain item', async () => {
6
+ const started = [];
7
+ const context = { running: false, session: { controlQueue: [
8
+ { id: 'a1', chainId: 'a', chainSequence: 0, status: 'queued' },
9
+ { id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
10
+ ] } };
11
+ assert.equal(await reconcileControlQueue(context, { startItem: (item) => { started.push(item.id); context.running = true; } }), true);
12
+ assert.deepEqual(started, ['a1']);
13
+ });
14
+
15
+ test('failed required predecessors skip remaining items but preserve another chain', () => {
16
+ const queue = [
17
+ { id: 'a1', chainId: 'a', chainSequence: 0, status: 'failed' },
18
+ { id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
19
+ { id: 'b1', chainId: 'b', chainSequence: 0, status: 'queued' },
20
+ ];
21
+ const skipped = [];
22
+ applySkipPropagation(queue, (item) => { item.status = 'skipped'; skipped.push(item.id); });
23
+ assert.deepEqual(skipped, ['a2']);
24
+ assert.equal(queue[2].status, 'queued');
25
+ });
26
+
27
+ test('one propagation pass skips every required descendant in a long chain', () => {
28
+ const queue = [
29
+ { id: 'a1', chainId: 'a', chainSequence: 0, status: 'failed' },
30
+ { id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
31
+ { id: 'a3', chainId: 'a', chainSequence: 2, status: 'queued' },
32
+ { id: 'a4', chainId: 'a', chainSequence: 3, status: 'queued' },
33
+ ];
34
+ const skipped = [];
35
+ assert.equal(applySkipPropagation(queue, (item) => skipped.push(item.id)), 3);
36
+ assert.deepEqual(skipped, ['a2', 'a3', 'a4']);
37
+ assert.deepEqual(queue.map((item) => item.status), ['failed', 'skipped', 'skipped', 'skipped']);
38
+ });
@@ -1,5 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
- import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import { conversationEventSequences, createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
4
4
  import { activeCacertPath } from '../core/cacert.js';
5
5
  import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
@@ -8,6 +8,23 @@ import { runtimeTokenFromEnv } from './auth.js';
8
8
  import { controlMessage } from './controlMessages.js';
9
9
  import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
10
  import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
11
+ import { matchSkillInvocation } from '../core/skillInvocation.js';
12
+ import { reconcileControlQueue } from './controlDrain.js';
13
+ import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
14
+ import { runSkillChain } from './skillRun.js';
15
+ import { findSkill, listSkills } from '../core/skills.js';
16
+
17
+ const PRIVATE_CONTROL_INPUTS = new WeakMap();
18
+
19
+ function privateControlInputsFor(session) {
20
+ if (!session || (typeof session !== 'object' && typeof session !== 'function')) return new Map();
21
+ let inputs = PRIVATE_CONTROL_INPUTS.get(session);
22
+ if (!inputs) {
23
+ inputs = new Map();
24
+ PRIVATE_CONTROL_INPUTS.set(session, inputs);
25
+ }
26
+ return inputs;
27
+ }
11
28
 
12
29
  export function startRuntimeServer({
13
30
  host = '127.0.0.1',
@@ -31,6 +48,8 @@ export function startRuntimeServer({
31
48
  exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
32
49
  } = {}) {
33
50
  const clients = new Set();
51
+ // Compiled objectives are private execution material. They deliberately do
52
+ // not enter events, projections, SSE, audit output or the runs table.
34
53
  // When this runtime process started — used by ensureRuntime to detect that
35
54
  // the manager source has been edited since (dev staleness) and auto-restart.
36
55
  const runtimeStartedAtMs = Date.now();
@@ -200,6 +219,21 @@ export function startRuntimeServer({
200
219
  });
201
220
  return;
202
221
  }
222
+ if (action === 'cancel_item') {
223
+ const itemId = String(body.itemId ?? body.id ?? '').trim();
224
+ if (!itemId) {
225
+ sendJson(response, 400, { error: 'Missing itemId.' });
226
+ return;
227
+ }
228
+ validateContractInDev('controlMessage', { ...body, action, id: itemId });
229
+ const result = cancelQueuedControlItem(context.session, itemId, {
230
+ cancelItem: (item, reason) => emitControlCancelled(context, item, reason),
231
+ skipItem: (item, reason) => emitControlSkipped(context, item, reason),
232
+ });
233
+ void drainControlQueue(context);
234
+ sendJson(response, result.cancelled ? 202 : 200, { ...result, ...controlStatus(context, store) });
235
+ return;
236
+ }
203
237
  sendJson(response, 400, { error: 'Unsupported control action.' });
204
238
  return;
205
239
  }
@@ -282,6 +316,45 @@ export function startRuntimeServer({
282
316
  return;
283
317
  }
284
318
  validateContractInDev('runRequest', { ...body, input });
319
+ const explicitSkillName = String(body.skillName ?? '').trim().toLowerCase();
320
+ const invokedSkillName = /^\/([A-Za-z0-9_-]+)/.exec(input)?.[1]?.toLowerCase() ?? '';
321
+ if (body.skillArguments !== undefined && explicitSkillName !== invokedSkillName) {
322
+ sendJson(response, 400, { ok: false, terminal: true, code: 'skill_name_mismatch' });
323
+ return;
324
+ }
325
+ // This structured API call is itself the caller's explicit skill
326
+ // designation, including for reserved names. The equality check
327
+ // above prevents `skillName: status` from turning unrelated prose
328
+ // into the homonymous skill. Model-originated calls additionally
329
+ // pass explicitSkillReference() before reaching this endpoint.
330
+ if (explicitSkillName && body.skillArguments !== undefined) {
331
+ const result = await context.session._runSkillWithinRun(explicitSkillName, body.skillArguments, {
332
+ idempotencyKey: body.idempotencyKey ?? null,
333
+ turnId: body.turnId ?? null,
334
+ selectionKind: body.selectionKind ?? null,
335
+ // Pile de l'appelant : c'est le seul canal par lequel elle peut
336
+ // franchir la frontière HTTP.
337
+ skillStack: Array.isArray(body.skillStack) ? body.skillStack : [],
338
+ });
339
+ sendJson(response, result.accepted ? 202 : skillResultErrorStatus(result), result);
340
+ return;
341
+ }
342
+ const skillMatch = matchSkillInvocation(context.session, input, {
343
+ // Reserved names are unlocked only by a matching structured skill
344
+ // invocation, never by an unrelated truthy marker in the body.
345
+ allowReserved: Boolean(explicitSkillName && explicitSkillName === invokedSkillName),
346
+ });
347
+ if (skillMatch) {
348
+ try {
349
+ const result = await enqueueSkillInvocation(context, skillMatch);
350
+ sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
351
+ } catch (err) {
352
+ const error = skillInvocationErrorMessage(err);
353
+ publishSkillInvocationFailure(context, input, error);
354
+ sendJson(response, skillInvocationErrorStatus(err), { error, code: err?.code ?? 'skill_compile_failed' });
355
+ }
356
+ return;
357
+ }
285
358
  if (context.running) {
286
359
  // A structured capability plan must never degrade into a free-text
287
360
  // control message: its exact arguments and approval policy would
@@ -339,6 +412,23 @@ export function startRuntimeServer({
339
412
  // run is active. Other interactive turns still become control
340
413
  // messages so they cannot start a competing agent decision.
341
414
  const readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
415
+ // An explicit /skill invocation has deterministic meaning. Keep the
416
+ // conversational /turn boundary, but do not ask the LLM to rediscover
417
+ // the skill from prose: it could choose a direct mutation instead and
418
+ // silently bypass the private workflow. Informational prose merely
419
+ // mentioning a skill never matches this anchored invocation parser.
420
+ const skillMatch = !readOnlyChat ? matchSkillInvocation(context.session, input) : null;
421
+ if (skillMatch) {
422
+ try {
423
+ const result = await enqueueSkillInvocation(context, skillMatch);
424
+ sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
425
+ } catch (err) {
426
+ const error = skillInvocationErrorMessage(err);
427
+ publishSkillInvocationFailure(context, input, error);
428
+ sendJson(response, skillInvocationErrorStatus(err), { error, code: err?.code ?? 'skill_compile_failed' });
429
+ }
430
+ return;
431
+ }
342
432
  if (context.running && !readOnlyChat) {
343
433
  const result = await handleControlMessage(context, store, input, {
344
434
  intent: body.intent,
@@ -436,6 +526,10 @@ export function startRuntimeServer({
436
526
  sendJson(response, 200, { cancelled: false, reason: 'no active run' });
437
527
  return;
438
528
  }
529
+ cancelControlChain(context.session, {
530
+ runId: context.currentRunId,
531
+ cancelItem: (item, reason) => emitControlSkipped(context, item, reason),
532
+ });
439
533
  context.currentAbortController.abort();
440
534
  await cancel?.(context);
441
535
  sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
@@ -469,10 +563,16 @@ export function startRuntimeServer({
469
563
  });
470
564
  return;
471
565
  }
472
- // Redo: keep the conversation entry at `index` (the question) and drop
473
- // everything the runtime recorded after it. The conversation is derived
474
- // from the event log, so a UI-side deletion alone would be undone by the
475
- // next /state merge — the truncation has to happen here.
566
+ // Redo: drop the conversation entry at `index` (the question) AND
567
+ // everything recorded after it. The conversation is derived from the
568
+ // event log, so a UI-side deletion alone would be undone by the next
569
+ // /state merge — the truncation has to happen here.
570
+ //
571
+ // The question goes too because every caller of this route immediately
572
+ // resubmits it. Keeping it server-side meant the resubmission appended a
573
+ // second copy, and the next /state merge brought the first one back into
574
+ // a UI that had just removed it — the question ended up displayed twice
575
+ // in both the served chat and the shell.
476
576
  if (request.method === 'POST' && url.pathname === '/conversation/truncate') {
477
577
  const { body, workspace, context } = await resolveBodyContext(request, url);
478
578
  if (context?.running) {
@@ -500,7 +600,11 @@ export function startRuntimeServer({
500
600
  sendJson(response, 400, { truncated: false, reason: 'index_out_of_range' });
501
601
  return;
502
602
  }
503
- const removedEvents = store.deleteEventsAfter(boundary, { workspace: resolvedWorkspace });
603
+ // `boundary` is the sequence of the question's own event; deleting
604
+ // strictly after `boundary - 1` removes it along with its answers.
605
+ // Sequences are integers and strictly increasing, so this cannot catch
606
+ // an unrelated event between the two values.
607
+ const removedEvents = store.deleteEventsAfter(boundary - 1, { workspace: resolvedWorkspace });
504
608
  // getState prefers the in-memory projection over the event log, so the
505
609
  // deleted answers would survive in RAM without this rehydration.
506
610
  if (context?.session) {
@@ -551,7 +655,7 @@ export function startRuntimeServer({
551
655
  host,
552
656
  port: typeof address === 'object' && address ? address.port : port,
553
657
  publish,
554
- drainControl: (context) => startNextControlRequest(context),
658
+ drainControl: (context) => drainControlQueue(context),
555
659
  close: () => new Promise((closeResolve, closeReject) => {
556
660
  for (const client of clients) client.response.end();
557
661
  clients.clear();
@@ -562,7 +666,70 @@ export function startRuntimeServer({
562
666
  });
563
667
 
564
668
  async function resolveContext({ workspace = null } = {}) {
565
- return resolvedGetContext(workspace);
669
+ const context = await resolvedGetContext(workspace);
670
+ if (context?.session) {
671
+ context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
672
+ const skill = findSkill(context.session, skillName);
673
+ if (!skill) return {
674
+ ok: false,
675
+ terminal: true,
676
+ code: 'skill_not_found',
677
+ availableSkills: listSkills(context.session).map((item) => item.name),
678
+ };
679
+ try {
680
+ const idempotencyKey = metadata.idempotencyKey
681
+ ? String(metadata.idempotencyKey)
682
+ : metadata.turnId
683
+ ? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
684
+ : null;
685
+ context.skillRuns ??= new Map();
686
+ context.pendingSkillRuns ??= new Map();
687
+ const persistedChainId = idempotencyKey && typeof store.findSkillRun === 'function'
688
+ ? store.findSkillRun({ workspace: context.workspace ?? null, idempotencyKey })
689
+ : null;
690
+ const existingChainId = persistedChainId ?? (idempotencyKey ? context.skillRuns.get(idempotencyKey) : null);
691
+ if (existingChainId) return publicSkillRunProjection(context, skill.name, existingChainId, metadata.selectionKind, true);
692
+ if (idempotencyKey && context.pendingSkillRuns.has(idempotencyKey)) {
693
+ const pendingChainId = await context.pendingSkillRuns.get(idempotencyKey);
694
+ return publicSkillRunProjection(context, skill.name, pendingChainId, metadata.selectionKind, true);
695
+ }
696
+ const creation = runSkillChain(context, skill, {
697
+ args,
698
+ enqueueControlRequest,
699
+ drainControlQueue,
700
+ selectionKind: metadata.selectionKind,
701
+ // La pile du run appelant : elle sera empilée sur chaque élément mis
702
+ // en file, et c'est elle seule qui survit au hand-off.
703
+ skillStack: Array.isArray(metadata.skillStack) ? metadata.skillStack : [],
704
+ });
705
+ if (idempotencyKey) context.pendingSkillRuns.set(idempotencyKey, creation.then((item) => item.chainId));
706
+ const result = await creation;
707
+ if (idempotencyKey) {
708
+ context.skillRuns.set(idempotencyKey, result.chainId);
709
+ store.persistSkillRun?.({
710
+ workspace: context.workspace ?? context.session.workspace ?? null,
711
+ idempotencyKey,
712
+ chainId: result.chainId,
713
+ });
714
+ }
715
+ return publicSkillRunProjection(context, skill.name, result.chainId, metadata.selectionKind, false);
716
+ } catch (error) {
717
+ return {
718
+ ok: false,
719
+ terminal: true,
720
+ code: error?.code ?? 'skill_compile_failed',
721
+ };
722
+ } finally {
723
+ const key = metadata.idempotencyKey
724
+ ? String(metadata.idempotencyKey)
725
+ : metadata.turnId
726
+ ? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
727
+ : null;
728
+ if (key) context.pendingSkillRuns?.delete(key);
729
+ }
730
+ };
731
+ }
732
+ return context;
566
733
  }
567
734
 
568
735
  // Shared by POST handlers that take a JSON body carrying an optional
@@ -639,22 +806,103 @@ export function startRuntimeServer({
639
806
  context.currentRunId = null;
640
807
  context.currentRunWorkspace = null;
641
808
  publishState(runWorkspace, context);
642
- void startNextControlRequest(context);
809
+ void drainControlQueue(context);
643
810
  });
644
811
  return { accepted: true, runId, workspace: runWorkspace, ...(ready ? { ready } : {}) };
645
812
  }
646
813
 
647
814
  function startNextControlRequest(context) {
648
- if (!context?.session || context.running) return false;
649
- const item = controlQueueFor(context.session).find((entry) => entry.status === 'queued');
650
- if (!item) return false;
651
- startRuntimeRun(context, {
652
- input: item.input,
653
- workspace: item.workspace ?? context.workspace ?? null,
654
- ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
655
- }, { controlItemId: item.id });
656
- return true;
815
+ return drainControlQueue(context);
816
+ }
817
+
818
+ function drainControlQueue(context) {
819
+ return reconcileControlQueue(context, {
820
+ /*
821
+ La provenance de chaîne suit le run.
822
+
823
+ `chainId` et `skillName` vivaient sur l'item de contrôle et s'arrêtaient
824
+ là : le run ne savait pas qu'il exécutait une intention compilée depuis
825
+ une compétence. L'agent voyait donc un objectif métier — « ingérer les
826
+ fichiers en attente » — qui ressemble par construction à la description
827
+ de la compétence dont il sort, et la resélectionnait. En headless, où
828
+ personne n'interrompt, cela boucle jusqu'à épuisement du budget LLM.
829
+ */
830
+ startItem: (item) => startRuntimeRun(context, {
831
+ input: takePrivateControlInput(context.session, item),
832
+ publicInput: item.input,
833
+ workspace: item.workspace ?? context.workspace ?? null,
834
+ // Interactive skill runs never approve their own mutations. Headless
835
+ // may still grant the pending run explicitly through --auto-approve.
836
+ ...(item.chainId ? { requireApproval: true } : {}),
837
+ ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
838
+ ...(item.chainId
839
+ ? {
840
+ skillChain: {
841
+ chainId: item.chainId,
842
+ skillName: item.skillName ?? null,
843
+ execution: item.skillExecution === 'direct' ? 'direct' : 'orchestrated',
844
+ // La pile des ancêtres, sans quoi le run ne peut pas savoir qu'il
845
+ // referme un cycle commencé deux hand-offs plus tôt.
846
+ skillStack: Array.isArray(item.skillStack) ? item.skillStack : [],
847
+ },
848
+ }
849
+ : {}),
850
+ }, { controlItemId: item.id }),
851
+ skipItem: (item, reason) => {
852
+ privateControlInputsFor(context.session).delete(item.id);
853
+ emitControlSkipped(context, item, reason);
854
+ },
855
+ });
657
856
  }
857
+
858
+ function takePrivateControlInput(session, item) {
859
+ const privateControlInputs = privateControlInputsFor(session);
860
+ const input = privateControlInputs.get(item.id) ?? item.input;
861
+ privateControlInputs.delete(item.id);
862
+ return input;
863
+ }
864
+
865
+ async function enqueueSkillInvocation(context, match) {
866
+ const result = await runSkillChain(context, match.skill, {
867
+ rawArgs: match.rawArgs,
868
+ enqueueControlRequest,
869
+ drainControlQueue,
870
+ selectionKind: 'explicit_name',
871
+ });
872
+ // Publish only after compilation succeeds. The marker prevents an
873
+ // invocation queued during another run from inheriting that run's id.
874
+ dispatchAgentEvent(context.session, createAgentEvent('user_message', {
875
+ origin: 'user',
876
+ workspace: context.workspace ?? null,
877
+ payload: { content: `/${match.skill.name}${match.rawArgs ? ` ${match.rawArgs}` : ''}`, independent: true },
878
+ }));
879
+ return result;
880
+ }
881
+
882
+ function publishSkillInvocationFailure(context, input, error) {
883
+ const workspace = context.workspace ?? context.session?.workspace ?? null;
884
+ dispatchAgentEvent(context.session, createAgentEvent('user_message', {
885
+ origin: 'user', workspace, payload: { content: input, independent: true },
886
+ }));
887
+ dispatchAgentEvent(context.session, createAgentEvent('assistant_message', {
888
+ origin: 'runtime', workspace, payload: { content: error, independent: true },
889
+ }));
890
+ }
891
+ }
892
+
893
+ function skillInvocationErrorStatus(error) {
894
+ return ['skill_compile_failed', 'skill_arguments_invalid'].includes(error?.code) ? 400 : 422;
895
+ }
896
+
897
+ function skillResultErrorStatus(result) {
898
+ if (result?.code === 'skill_not_found') return 404;
899
+ if (result?.code === 'skill_arguments_invalid') return 400;
900
+ return 422;
901
+ }
902
+
903
+ function skillInvocationErrorMessage(error) {
904
+ const message = error instanceof Error ? error.message : String(error);
905
+ return `Skill invocation could not be compiled: ${message}`;
658
906
  }
659
907
 
660
908
  function workspaceFromUrl(url) {
@@ -738,6 +986,37 @@ function controlQueueFor(session) {
738
986
  return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
739
987
  }
740
988
 
989
+ function skillIdempotencyKey(workspace, turnId, skillName, args) {
990
+ const canonicalArguments = JSON.stringify(Object.fromEntries(
991
+ Object.entries(args ?? {}).sort(([left], [right]) => left.localeCompare(right)),
992
+ ));
993
+ return createHash('sha256')
994
+ .update(`${workspace ?? ''}\0${turnId}\0${String(skillName).toLowerCase()}\0${canonicalArguments}`)
995
+ .digest('hex');
996
+ }
997
+
998
+ function publicSkillRunProjection(context, skillName, chainId, selectionKind, deduplicated) {
999
+ const chainItems = controlQueueFor(context?.session).filter((item) => item.chainId === chainId);
1000
+ const resolvedSelectionKind = selectionKind ?? chainItems.find((item) => item.selectionKind)?.selectionKind ?? null;
1001
+ const items = chainItems
1002
+ .sort((left, right) => Number(left.chainSequence ?? 0) - Number(right.chainSequence ?? 0))
1003
+ .map((item) => ({
1004
+ id: item.id,
1005
+ sequence: Number(item.chainSequence ?? 0),
1006
+ status: item.status,
1007
+ optional: item.optional === true,
1008
+ }));
1009
+ return {
1010
+ accepted: true,
1011
+ skill: skillName,
1012
+ chainId,
1013
+ objectiveCount: items.length,
1014
+ items,
1015
+ ...(resolvedSelectionKind ? { selectionKind: resolvedSelectionKind } : {}),
1016
+ ...(deduplicated ? { deduplicated: true } : {}),
1017
+ };
1018
+ }
1019
+
741
1020
  function cancelQueuedControlItems(session, workspace = null) {
742
1021
  const now = new Date().toISOString();
743
1022
  const counted = new Set();
@@ -750,6 +1029,7 @@ function cancelQueuedControlItems(session, workspace = null) {
750
1029
  item.finishedAt = now;
751
1030
  item.updatedAt = now;
752
1031
  const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
1032
+ privateControlInputsFor(session).delete(item.id);
753
1033
  if (!counted.has(id)) {
754
1034
  counted.add(id);
755
1035
  if (session) {
@@ -865,18 +1145,42 @@ async function handleControlMessage(context, store, input, { intent = null, star
865
1145
  : controlMessage(context?.session, 'converse_while_idle'));
866
1146
  }
867
1147
 
868
- function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
1148
+ /*
1149
+ `skillStack` accompagne l'élément, il ne vit pas sur la session.
1150
+
1151
+ La pile des compétences en cours était posée sur la session pour la durée d'UN
1152
+ run, et restaurée par son `finally`. Or une compétence imbriquée n'est pas
1153
+ exécutée en ligne : elle est MISE EN FILE, et son run démarre après que le
1154
+ parent a fini de se nettoyer. La pile qu'elle lisait était donc déjà vide.
1155
+
1156
+ Conséquence : la garde n'attrapait que le cas pour lequel elle avait été
1157
+ écrite — une compétence qui se relance dans son propre run — et laissait
1158
+ passer A→B→A, qui boucle jusqu'à épuisement du budget en headless.
1159
+
1160
+ Une file est un passage de témoin : ce qui doit survivre au parent voyage avec
1161
+ le message, pas dans l'état de celui qui l'a posté.
1162
+ */
1163
+ function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false } = {}) {
869
1164
  const now = new Date().toISOString();
870
1165
  const item = {
871
1166
  id: `control-${randomUUID()}`,
872
1167
  workspace: context?.workspace ?? context?.session?.workspace ?? null,
873
1168
  type: 'run_request',
874
- input,
1169
+ input: publicInput ?? input,
875
1170
  status: 'queued',
876
1171
  createdAt: now,
877
1172
  updatedAt: now,
878
1173
  ...(capabilityPlan !== undefined ? { capabilityPlan } : {}),
1174
+ ...(chainId ? { chainId } : {}),
1175
+ ...(Number.isInteger(chainSequence) ? { chainSequence } : {}),
1176
+ ...(skillName ? { skillName } : {}),
1177
+ ...(skillExecution ? { skillExecution } : {}),
1178
+ ...(Array.isArray(skillStack) && skillStack.length ? { skillStack: [...skillStack] } : {}),
1179
+ ...(selectionKind ? { selectionKind } : {}),
1180
+ optional: optional === true,
1181
+ continueOnFailure: continueOnFailure === true,
879
1182
  };
1183
+ privateControlInputsFor(context?.session).set(item.id, input);
880
1184
  dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
881
1185
  origin: 'runtime',
882
1186
  workspace: item.workspace,
@@ -885,6 +1189,23 @@ function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
885
1189
  return item;
886
1190
  }
887
1191
 
1192
+ function emitControlSkipped(context, item, reason) {
1193
+ dispatchAgentEvent(context.session, createAgentEvent('control_skipped', {
1194
+ origin: 'runtime',
1195
+ workspace: item.workspace ?? context.workspace ?? null,
1196
+ payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
1197
+ }));
1198
+ }
1199
+
1200
+ function emitControlCancelled(context, item, reason) {
1201
+ privateControlInputsFor(context?.session).delete(item.id);
1202
+ dispatchAgentEvent(context.session, createAgentEvent('control_cancelled', {
1203
+ origin: 'runtime',
1204
+ workspace: item.workspace ?? context.workspace ?? null,
1205
+ payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
1206
+ }));
1207
+ }
1208
+
888
1209
  function storeControlProposal(context, input, classification, status) {
889
1210
  const now = new Date().toISOString();
890
1211
  const patch = buildPlanPatchFromInput(input, status);