@dotdrelle/wiki-manager 0.15.43 → 0.15.49

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 (43) hide show
  1. package/README.md +92 -24
  2. package/mcp.endpoints.example.json +1 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +288 -22
  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/runtimeLog.js +15 -0
  18. package/src/core/runtimeLog.test.js +15 -1
  19. package/src/core/skillChainView.js +84 -0
  20. package/src/core/skillChainView.test.js +50 -0
  21. package/src/core/skillCompiler.js +135 -0
  22. package/src/core/skillCompiler.test.js +91 -0
  23. package/src/core/skillInvocation.js +79 -0
  24. package/src/core/skillInvocation.test.js +73 -0
  25. package/src/core/skills.js +81 -19
  26. package/src/runtime/approvals.js +13 -1
  27. package/src/runtime/approvals.test.js +44 -0
  28. package/src/runtime/client.js +45 -4
  29. package/src/runtime/controlCancellation.js +33 -0
  30. package/src/runtime/controlCancellation.test.js +49 -0
  31. package/src/runtime/controlDrain.js +50 -0
  32. package/src/runtime/controlDrain.test.js +38 -0
  33. package/src/runtime/server.js +333 -15
  34. package/src/runtime/server.test.js +392 -0
  35. package/src/runtime/skillChain.e2e.test.js +394 -0
  36. package/src/runtime/skillRun.js +104 -0
  37. package/src/runtime/skillRun.test.js +84 -0
  38. package/src/runtime/store.js +69 -0
  39. package/src/runtime/store.test.js +11 -0
  40. package/src/shell/RightPane.tsx +3 -2
  41. package/src/shell/repl.js +40 -5
  42. package/src/shell/repl.test.js +41 -0
  43. package/src/shell/useSession.ts +43 -9
@@ -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 });
@@ -447,6 +541,13 @@ export function startRuntimeServer({
447
541
  const runId = url.searchParams.get('runId') ?? body.runId ?? null;
448
542
  const purge = body.purge === true || url.searchParams.get('purge') === 'true';
449
543
  const context = await resolveContext({ workspace });
544
+ const resolvedWorkspace = context?.workspace ?? workspace ?? null;
545
+ if (purge && !runId && !resolvedWorkspace) {
546
+ // Purge is workspace-wide and destructive: without a scope it would
547
+ // wipe every workspace's history. Mirror /conversation/truncate.
548
+ sendJson(response, 400, { killed: false, reason: 'workspace_required' });
549
+ return;
550
+ }
450
551
  const result = await killRuntimeRuns(context, { workspace, runId, purge });
451
552
  publishState(context.workspace ?? workspace ?? null, context);
452
553
  sendJson(response, 202, result);
@@ -561,7 +662,7 @@ export function startRuntimeServer({
561
662
  host,
562
663
  port: typeof address === 'object' && address ? address.port : port,
563
664
  publish,
564
- drainControl: (context) => startNextControlRequest(context),
665
+ drainControl: (context) => drainControlQueue(context),
565
666
  close: () => new Promise((closeResolve, closeReject) => {
566
667
  for (const client of clients) client.response.end();
567
668
  clients.clear();
@@ -572,7 +673,70 @@ export function startRuntimeServer({
572
673
  });
573
674
 
574
675
  async function resolveContext({ workspace = null } = {}) {
575
- return resolvedGetContext(workspace);
676
+ const context = await resolvedGetContext(workspace);
677
+ if (context?.session) {
678
+ context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
679
+ const skill = findSkill(context.session, skillName);
680
+ if (!skill) return {
681
+ ok: false,
682
+ terminal: true,
683
+ code: 'skill_not_found',
684
+ availableSkills: listSkills(context.session).map((item) => item.name),
685
+ };
686
+ try {
687
+ const idempotencyKey = metadata.idempotencyKey
688
+ ? String(metadata.idempotencyKey)
689
+ : metadata.turnId
690
+ ? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
691
+ : null;
692
+ context.skillRuns ??= new Map();
693
+ context.pendingSkillRuns ??= new Map();
694
+ const persistedChainId = idempotencyKey && typeof store.findSkillRun === 'function'
695
+ ? store.findSkillRun({ workspace: context.workspace ?? null, idempotencyKey })
696
+ : null;
697
+ const existingChainId = persistedChainId ?? (idempotencyKey ? context.skillRuns.get(idempotencyKey) : null);
698
+ if (existingChainId) return publicSkillRunProjection(context, skill.name, existingChainId, metadata.selectionKind, true);
699
+ if (idempotencyKey && context.pendingSkillRuns.has(idempotencyKey)) {
700
+ const pendingChainId = await context.pendingSkillRuns.get(idempotencyKey);
701
+ return publicSkillRunProjection(context, skill.name, pendingChainId, metadata.selectionKind, true);
702
+ }
703
+ const creation = runSkillChain(context, skill, {
704
+ args,
705
+ enqueueControlRequest,
706
+ drainControlQueue,
707
+ selectionKind: metadata.selectionKind,
708
+ // La pile du run appelant : elle sera empilée sur chaque élément mis
709
+ // en file, et c'est elle seule qui survit au hand-off.
710
+ skillStack: Array.isArray(metadata.skillStack) ? metadata.skillStack : [],
711
+ });
712
+ if (idempotencyKey) context.pendingSkillRuns.set(idempotencyKey, creation.then((item) => item.chainId));
713
+ const result = await creation;
714
+ if (idempotencyKey) {
715
+ context.skillRuns.set(idempotencyKey, result.chainId);
716
+ store.persistSkillRun?.({
717
+ workspace: context.workspace ?? context.session.workspace ?? null,
718
+ idempotencyKey,
719
+ chainId: result.chainId,
720
+ });
721
+ }
722
+ return publicSkillRunProjection(context, skill.name, result.chainId, metadata.selectionKind, false);
723
+ } catch (error) {
724
+ return {
725
+ ok: false,
726
+ terminal: true,
727
+ code: error?.code ?? 'skill_compile_failed',
728
+ };
729
+ } finally {
730
+ const key = metadata.idempotencyKey
731
+ ? String(metadata.idempotencyKey)
732
+ : metadata.turnId
733
+ ? skillIdempotencyKey(context.workspace ?? context.session.workspace, metadata.turnId, skill.name, args)
734
+ : null;
735
+ if (key) context.pendingSkillRuns?.delete(key);
736
+ }
737
+ };
738
+ }
739
+ return context;
576
740
  }
577
741
 
578
742
  // Shared by POST handlers that take a JSON body carrying an optional
@@ -649,24 +813,105 @@ export function startRuntimeServer({
649
813
  context.currentRunId = null;
650
814
  context.currentRunWorkspace = null;
651
815
  publishState(runWorkspace, context);
652
- void startNextControlRequest(context);
816
+ void drainControlQueue(context);
653
817
  });
654
818
  return { accepted: true, runId, workspace: runWorkspace, ...(ready ? { ready } : {}) };
655
819
  }
656
820
 
657
821
  function startNextControlRequest(context) {
658
- if (!context?.session || context.running) return false;
659
- const item = controlQueueFor(context.session).find((entry) => entry.status === 'queued');
660
- if (!item) return false;
661
- startRuntimeRun(context, {
662
- input: item.input,
663
- workspace: item.workspace ?? context.workspace ?? null,
664
- ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
665
- }, { controlItemId: item.id });
666
- return true;
822
+ return drainControlQueue(context);
823
+ }
824
+
825
+ function drainControlQueue(context) {
826
+ return reconcileControlQueue(context, {
827
+ /*
828
+ La provenance de chaîne suit le run.
829
+
830
+ `chainId` et `skillName` vivaient sur l'item de contrôle et s'arrêtaient
831
+ là : le run ne savait pas qu'il exécutait une intention compilée depuis
832
+ une compétence. L'agent voyait donc un objectif métier — « ingérer les
833
+ fichiers en attente » — qui ressemble par construction à la description
834
+ de la compétence dont il sort, et la resélectionnait. En headless, où
835
+ personne n'interrompt, cela boucle jusqu'à épuisement du budget LLM.
836
+ */
837
+ startItem: (item) => startRuntimeRun(context, {
838
+ input: takePrivateControlInput(context.session, item),
839
+ publicInput: item.input,
840
+ workspace: item.workspace ?? context.workspace ?? null,
841
+ // Interactive skill runs never approve their own mutations. Headless
842
+ // may still grant the pending run explicitly through --auto-approve.
843
+ ...(item.chainId ? { requireApproval: true } : {}),
844
+ ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
845
+ ...(item.chainId
846
+ ? {
847
+ skillChain: {
848
+ chainId: item.chainId,
849
+ skillName: item.skillName ?? null,
850
+ execution: item.skillExecution === 'direct' ? 'direct' : 'orchestrated',
851
+ // La pile des ancêtres, sans quoi le run ne peut pas savoir qu'il
852
+ // referme un cycle commencé deux hand-offs plus tôt.
853
+ skillStack: Array.isArray(item.skillStack) ? item.skillStack : [],
854
+ },
855
+ }
856
+ : {}),
857
+ }, { controlItemId: item.id }),
858
+ skipItem: (item, reason) => {
859
+ privateControlInputsFor(context.session).delete(item.id);
860
+ emitControlSkipped(context, item, reason);
861
+ },
862
+ });
863
+ }
864
+
865
+ function takePrivateControlInput(session, item) {
866
+ const privateControlInputs = privateControlInputsFor(session);
867
+ const input = privateControlInputs.get(item.id) ?? item.input;
868
+ privateControlInputs.delete(item.id);
869
+ return input;
870
+ }
871
+
872
+ async function enqueueSkillInvocation(context, match) {
873
+ const result = await runSkillChain(context, match.skill, {
874
+ rawArgs: match.rawArgs,
875
+ enqueueControlRequest,
876
+ drainControlQueue,
877
+ selectionKind: 'explicit_name',
878
+ });
879
+ // Publish only after compilation succeeds. The marker prevents an
880
+ // invocation queued during another run from inheriting that run's id.
881
+ dispatchAgentEvent(context.session, createAgentEvent('user_message', {
882
+ origin: 'user',
883
+ workspace: context.workspace ?? null,
884
+ payload: { content: `/${match.skill.name}${match.rawArgs ? ` ${match.rawArgs}` : ''}`, independent: true },
885
+ }));
886
+ return result;
887
+ }
888
+
889
+ function publishSkillInvocationFailure(context, input, error) {
890
+ const workspace = context.workspace ?? context.session?.workspace ?? null;
891
+ dispatchAgentEvent(context.session, createAgentEvent('user_message', {
892
+ origin: 'user', workspace, payload: { content: input, independent: true },
893
+ }));
894
+ dispatchAgentEvent(context.session, createAgentEvent('assistant_message', {
895
+ origin: 'runtime', workspace, payload: { content: error, independent: true },
896
+ }));
667
897
  }
668
898
  }
669
899
 
900
+ function skillInvocationErrorStatus(error) {
901
+ return ['skill_compile_failed', 'skill_arguments_invalid'].includes(error?.code) ? 400 : 422;
902
+ }
903
+
904
+ function skillResultErrorStatus(result) {
905
+ if (result?.code === 'skill_not_found') return 404;
906
+ if (result?.code === 'skill_arguments_invalid') return 400;
907
+ return 422;
908
+ }
909
+
910
+ function skillInvocationErrorMessage(error) {
911
+ const message = error instanceof Error ? error.message : String(error);
912
+ return `Skill invocation could not be compiled: ${message}`;
913
+ }
914
+
670
915
  function workspaceFromUrl(url) {
671
916
  const workspace = url.searchParams.get('workspace');
672
917
  return workspace ? workspace.trim() || null : null;
@@ -748,6 +993,37 @@ function controlQueueFor(session) {
748
993
  return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
749
994
  }
750
995
 
996
+ function skillIdempotencyKey(workspace, turnId, skillName, args) {
997
+ const canonicalArguments = JSON.stringify(Object.fromEntries(
998
+ Object.entries(args ?? {}).sort(([left], [right]) => left.localeCompare(right)),
999
+ ));
1000
+ return createHash('sha256')
1001
+ .update(`${workspace ?? ''}\0${turnId}\0${String(skillName).toLowerCase()}\0${canonicalArguments}`)
1002
+ .digest('hex');
1003
+ }
1004
+
1005
+ function publicSkillRunProjection(context, skillName, chainId, selectionKind, deduplicated) {
1006
+ const chainItems = controlQueueFor(context?.session).filter((item) => item.chainId === chainId);
1007
+ const resolvedSelectionKind = selectionKind ?? chainItems.find((item) => item.selectionKind)?.selectionKind ?? null;
1008
+ const items = chainItems
1009
+ .sort((left, right) => Number(left.chainSequence ?? 0) - Number(right.chainSequence ?? 0))
1010
+ .map((item) => ({
1011
+ id: item.id,
1012
+ sequence: Number(item.chainSequence ?? 0),
1013
+ status: item.status,
1014
+ optional: item.optional === true,
1015
+ }));
1016
+ return {
1017
+ accepted: true,
1018
+ skill: skillName,
1019
+ chainId,
1020
+ objectiveCount: items.length,
1021
+ items,
1022
+ ...(resolvedSelectionKind ? { selectionKind: resolvedSelectionKind } : {}),
1023
+ ...(deduplicated ? { deduplicated: true } : {}),
1024
+ };
1025
+ }
1026
+
751
1027
  function cancelQueuedControlItems(session, workspace = null) {
752
1028
  const now = new Date().toISOString();
753
1029
  const counted = new Set();
@@ -760,6 +1036,7 @@ function cancelQueuedControlItems(session, workspace = null) {
760
1036
  item.finishedAt = now;
761
1037
  item.updatedAt = now;
762
1038
  const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
1039
+ privateControlInputsFor(session).delete(item.id);
763
1040
  if (!counted.has(id)) {
764
1041
  counted.add(id);
765
1042
  if (session) {
@@ -875,18 +1152,42 @@ async function handleControlMessage(context, store, input, { intent = null, star
875
1152
  : controlMessage(context?.session, 'converse_while_idle'));
876
1153
  }
877
1154
 
878
- function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
1155
+ /*
1156
+ `skillStack` accompagne l'élément, il ne vit pas sur la session.
1157
+
1158
+ La pile des compétences en cours était posée sur la session pour la durée d'UN
1159
+ run, et restaurée par son `finally`. Or une compétence imbriquée n'est pas
1160
+ exécutée en ligne : elle est MISE EN FILE, et son run démarre après que le
1161
+ parent a fini de se nettoyer. La pile qu'elle lisait était donc déjà vide.
1162
+
1163
+ Conséquence : la garde n'attrapait que le cas pour lequel elle avait été
1164
+ écrite — une compétence qui se relance dans son propre run — et laissait
1165
+ passer A→B→A, qui boucle jusqu'à épuisement du budget en headless.
1166
+
1167
+ Une file est un passage de témoin : ce qui doit survivre au parent voyage avec
1168
+ le message, pas dans l'état de celui qui l'a posté.
1169
+ */
1170
+ function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false } = {}) {
879
1171
  const now = new Date().toISOString();
880
1172
  const item = {
881
1173
  id: `control-${randomUUID()}`,
882
1174
  workspace: context?.workspace ?? context?.session?.workspace ?? null,
883
1175
  type: 'run_request',
884
- input,
1176
+ input: publicInput ?? input,
885
1177
  status: 'queued',
886
1178
  createdAt: now,
887
1179
  updatedAt: now,
888
1180
  ...(capabilityPlan !== undefined ? { capabilityPlan } : {}),
1181
+ ...(chainId ? { chainId } : {}),
1182
+ ...(Number.isInteger(chainSequence) ? { chainSequence } : {}),
1183
+ ...(skillName ? { skillName } : {}),
1184
+ ...(skillExecution ? { skillExecution } : {}),
1185
+ ...(Array.isArray(skillStack) && skillStack.length ? { skillStack: [...skillStack] } : {}),
1186
+ ...(selectionKind ? { selectionKind } : {}),
1187
+ optional: optional === true,
1188
+ continueOnFailure: continueOnFailure === true,
889
1189
  };
1190
+ privateControlInputsFor(context?.session).set(item.id, input);
890
1191
  dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
891
1192
  origin: 'runtime',
892
1193
  workspace: item.workspace,
@@ -895,6 +1196,23 @@ function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
895
1196
  return item;
896
1197
  }
897
1198
 
1199
+ function emitControlSkipped(context, item, reason) {
1200
+ dispatchAgentEvent(context.session, createAgentEvent('control_skipped', {
1201
+ origin: 'runtime',
1202
+ workspace: item.workspace ?? context.workspace ?? null,
1203
+ payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
1204
+ }));
1205
+ }
1206
+
1207
+ function emitControlCancelled(context, item, reason) {
1208
+ privateControlInputsFor(context?.session).delete(item.id);
1209
+ dispatchAgentEvent(context.session, createAgentEvent('control_cancelled', {
1210
+ origin: 'runtime',
1211
+ workspace: item.workspace ?? context.workspace ?? null,
1212
+ payload: { id: item.id, reason, finishedAt: new Date().toISOString() },
1213
+ }));
1214
+ }
1215
+
898
1216
  function storeControlProposal(context, input, classification, status) {
899
1217
  const now = new Date().toISOString();
900
1218
  const patch = buildPlanPatchFromInput(input, status);