@dotdrelle/wiki-manager 0.12.0 → 0.12.7

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 (40) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +34 -3
  3. package/src/activity/activityAggregator.test.js +32 -0
  4. package/src/agent/graph.js +307 -29
  5. package/src/agent/graph.test.js +404 -1
  6. package/src/cli/wiki-manager.js +24 -1
  7. package/src/commands/slash.js +74 -1
  8. package/src/commands/slash.test.js +36 -0
  9. package/src/contracts/schemas.test.js +2 -2
  10. package/src/core/activity.js +4 -0
  11. package/src/core/activity.test.js +9 -1
  12. package/src/core/agentEvents.js +30 -0
  13. package/src/core/agentEvents.test.js +42 -1
  14. package/src/core/buildInfo.js +58 -0
  15. package/src/core/buildInfo.json +4 -0
  16. package/src/core/buildInfo.test.js +14 -0
  17. package/src/core/mcp.js +50 -3
  18. package/src/core/mcp.test.js +94 -1
  19. package/src/core/runtimeLog.js +6 -1
  20. package/src/core/runtimeLog.test.js +3 -1
  21. package/src/runtime/client.js +22 -0
  22. package/src/runtime/controlMessages.js +43 -0
  23. package/src/runtime/controlMessages.test.js +21 -0
  24. package/src/runtime/donna-contract.test.js +3 -3
  25. package/src/runtime/recoveryManager.js +54 -0
  26. package/src/runtime/recoveryManager.test.js +73 -0
  27. package/src/runtime/runner.js +49 -13
  28. package/src/runtime/runner.test.js +196 -0
  29. package/src/runtime/server.js +73 -13
  30. package/src/runtime/server.test.js +284 -67
  31. package/src/runtime/store.js +48 -3
  32. package/src/runtime/store.test.js +56 -24
  33. package/src/runtime/supervisor.js +77 -1
  34. package/src/shell/RightPane.tsx +124 -31
  35. package/src/shell/SetupWizard.tsx +13 -1
  36. package/src/shell/repl.js +81 -11
  37. package/src/shell/repl.test.js +115 -2
  38. package/src/shell/tui.tsx +4 -1
  39. package/src/shell/useAgent.ts +37 -4
  40. package/src/shell/useSession.ts +51 -10
@@ -3,7 +3,7 @@ import test from 'node:test';
3
3
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { buildAgentSystemPrompt, createAgentGraph } from './graph.js';
6
+ import { buildAgentSystemPrompt, classifyAgentInput, createAgentGraph, knownCapabilityIds } from './graph.js';
7
7
 
8
8
  function sessionBase(overrides = {}) {
9
9
  return {
@@ -387,3 +387,406 @@ test('buildAgentSystemPrompt forbids inventing slash commands or arguments', ()
387
387
  assert.doesNotMatch(prompt, /executorQuery/);
388
388
  assert.doesNotMatch(prompt, /executor:"/);
389
389
  });
390
+
391
+ // Guard: the system prompt must never show a connected tool's bare name
392
+ // outside its qualified server__tool form. Bare mentions are what teach the
393
+ // model to emit unqualified tool calls (the cme_status incident). The bare
394
+ // name list comes from the session's declared servers, never from a manual
395
+ // list (amendment A6). New prompt text or injected skill descriptions that
396
+ // reintroduce a bare name must fail here.
397
+ test('buildAgentSystemPrompt contains no unqualified tool names for connected servers', () => {
398
+ const session = sessionBase({
399
+ mcp: {
400
+ production: {
401
+ status: 'connected',
402
+ tools: [
403
+ { name: 'production_start_job' }, { name: 'production_job_status' },
404
+ { name: 'production_job_logs' }, { name: 'production_cancel_job' },
405
+ { name: 'production_list_jobs' }, { name: 'production_list_templates' },
406
+ { name: 'production_status' }, { name: 'agent_describe' },
407
+ { name: 'agent_plan' }, { name: 'agent_execute' },
408
+ { name: 'agent_status' }, { name: 'agent_cancel' },
409
+ ],
410
+ },
411
+ cme: {
412
+ status: 'connected',
413
+ tools: [
414
+ { name: 'cme_status' }, { name: 'cme_setup' },
415
+ { name: 'cme_sources_list' }, { name: 'cme_source_add' },
416
+ { name: 'cme_source_remove' }, { name: 'cme_export_run' },
417
+ { name: 'cme_export_status' }, { name: 'cme_export_cancel' },
418
+ { name: 'agent_describe' }, { name: 'agent_execute' },
419
+ { name: 'agent_status' }, { name: 'agent_cancel' },
420
+ ],
421
+ },
422
+ },
423
+ });
424
+ const prompt = buildAgentSystemPrompt({ session });
425
+ const offenders = [];
426
+ for (const [serverName, value] of Object.entries(session.mcp)) {
427
+ for (const tool of value.tools) {
428
+ // A bare occurrence is the tool name not embedded in a wider
429
+ // identifier: `production__production_start_job` does not match
430
+ // because the inner occurrence is preceded by `_`.
431
+ const bare = new RegExp(`(?<![\\w])${tool.name}(?![\\w])`);
432
+ if (bare.test(prompt)) offenders.push(`${serverName}:${tool.name}`);
433
+ }
434
+ }
435
+ assert.deepEqual(offenders, [], `Unqualified tool names found in system prompt: ${offenders.join(', ')}`);
436
+ });
437
+
438
+ test('classifyAgentInput routes information requests to observe, not runs', () => {
439
+ const session = sessionBase();
440
+ // The original incident: a config question must never become a run.
441
+ assert.equal(classifyAgentInput('donne moi la config du cme', session).kind, 'observe');
442
+ assert.equal(classifyAgentInput('montre la configuration du workspace', session).kind, 'observe');
443
+ assert.equal(classifyAgentInput('où en est le run', session).kind, 'observe');
444
+ assert.equal(classifyAgentInput('explique le build', session).kind, 'observe');
445
+ assert.equal(classifyAgentInput("qu'est-ce que le pipeline polish ?", session).kind, 'observe');
446
+ });
447
+
448
+ test('classifyAgentInput routes action requests to start_run without an active run', () => {
449
+ const session = sessionBase();
450
+ assert.equal(classifyAgentInput('lance le pipeline complet', session).kind, 'start_run');
451
+ assert.equal(classifyAgentInput('configure le cme avec ce token', session).kind, 'start_run');
452
+ assert.equal(classifyAgentInput('lance le run', session).kind, 'start_run');
453
+ assert.equal(classifyAgentInput('exporte les deliverables', session).kind, 'start_run');
454
+ });
455
+
456
+ test('classifyAgentInput keeps small talk as converse and active-run branches intact', () => {
457
+ const session = sessionBase();
458
+ assert.equal(classifyAgentInput('bonjour', session).kind, 'converse');
459
+ assert.equal(classifyAgentInput('merci beaucoup', session).kind, 'converse');
460
+ const activeSession = sessionBase({ agentProjection: { status: 'running' } });
461
+ assert.equal(classifyAgentInput('lance un build', activeSession).kind, 'ambiguous');
462
+ assert.equal(classifyAgentInput('annule tout', activeSession).kind, 'cancel');
463
+ });
464
+
465
+ function orchestrableAgentSnapshot() {
466
+ return [{
467
+ agentInstanceId: 'production-1',
468
+ serverName: 'production',
469
+ health: 'available',
470
+ description: {
471
+ contractVersion: '1',
472
+ agentType: 'production',
473
+ displayName: 'Production agent',
474
+ capabilities: [
475
+ { id: 'knowledge.pipeline', version: '1' },
476
+ { id: 'external-source.export', version: '1' },
477
+ ],
478
+ },
479
+ }];
480
+ }
481
+
482
+ test('knownCapabilityIds reflects the discovered registry snapshot', () => {
483
+ const session = sessionBase({ agentRegistrySnapshot: orchestrableAgentSnapshot() });
484
+ assert.deepEqual(knownCapabilityIds(session), ['external-source.export', 'knowledge.pipeline']);
485
+ assert.deepEqual(knownCapabilityIds(sessionBase()), []);
486
+ });
487
+
488
+ test('agent graph rejects plan steps declaring unknown capabilities', async () => {
489
+ let calls = 0;
490
+ let rejectionSeen = null;
491
+ const session = sessionBase({
492
+ agentRegistrySnapshot: orchestrableAgentSnapshot(),
493
+ llm: {
494
+ async completeWithTools({ messages }) {
495
+ calls += 1;
496
+ if (calls === 1) {
497
+ return {
498
+ content: null,
499
+ message: { role: 'assistant', content: null },
500
+ tool_calls: [{
501
+ id: 'plan-call',
502
+ type: 'function',
503
+ function: {
504
+ name: 'wiki__plan_set',
505
+ arguments: JSON.stringify({
506
+ steps: [
507
+ { id: 'create', description: 'Create config file', requiredCapability: 'file.creation' },
508
+ { id: 'validate', description: 'Validate config', requiredCapability: 'file.validation', dependsOn: ['create'] },
509
+ ],
510
+ }),
511
+ },
512
+ }],
513
+ };
514
+ }
515
+ rejectionSeen = messages.map((message) => String(message.content ?? '')).join('\n');
516
+ return {
517
+ content: 'Understood, no plan registered.',
518
+ message: { role: 'assistant', content: 'Understood, no plan registered.' },
519
+ tool_calls: null,
520
+ };
521
+ },
522
+ },
523
+ });
524
+
525
+ const agent = createAgentGraph();
526
+ await agent.invoke({ input: 'Configure CME', session });
527
+
528
+ assert.equal(session.headlessPlan ?? null, null, 'rejected plan must not be registered');
529
+ assert.match(rejectionSeen ?? '', /Plan rejected: unknown capabilities \[file\.creation, file\.validation\]/);
530
+ assert.match(rejectionSeen ?? '', /external-source\.export, knowledge\.pipeline/);
531
+ });
532
+
533
+ test('agent graph accepts plan steps with known capabilities and null capability', async () => {
534
+ let calls = 0;
535
+ const session = sessionBase({
536
+ agentRegistrySnapshot: orchestrableAgentSnapshot(),
537
+ llm: {
538
+ async completeWithTools() {
539
+ calls += 1;
540
+ if (calls === 1) {
541
+ return {
542
+ content: null,
543
+ message: { role: 'assistant', content: null },
544
+ tool_calls: [{
545
+ id: 'plan-call',
546
+ type: 'function',
547
+ function: {
548
+ name: 'wiki__plan_set',
549
+ arguments: JSON.stringify({
550
+ steps: [
551
+ { id: 'export', description: 'Export sources', requiredCapability: 'external-source.export' },
552
+ { id: 'report', description: 'Summarize results', requiredCapability: null, dependsOn: ['export'] },
553
+ ],
554
+ }),
555
+ },
556
+ }],
557
+ };
558
+ }
559
+ return {
560
+ content: 'Plan ready.',
561
+ message: { role: 'assistant', content: 'Plan ready.' },
562
+ tool_calls: null,
563
+ };
564
+ },
565
+ },
566
+ });
567
+
568
+ const agent = createAgentGraph();
569
+ const result = await agent.invoke({ input: 'Export then report', session });
570
+
571
+ assert.equal(result.response, 'Plan ready.');
572
+ assert.deepEqual(session.headlessPlan.map((step) => step.id), ['export', 'report']);
573
+ });
574
+
575
+ test('buildAgentSystemPrompt anchors the real capability list', () => {
576
+ const withAgents = buildAgentSystemPrompt({ session: sessionBase({ agentRegistrySnapshot: orchestrableAgentSnapshot() }) });
577
+ assert.match(withAgents, /Known orchestration capabilities — the ONLY values allowed in requiredCapability: external-source\.export, knowledge\.pipeline/);
578
+ assert.match(withAgents, /Never invent capability names/);
579
+
580
+ const withoutAgents = buildAgentSystemPrompt({ session: sessionBase() });
581
+ assert.match(withoutAgents, /No orchestration capabilities discovered yet/);
582
+ });
583
+
584
+ test('agent graph executes action inputs inside a runtime run instead of asking for clarification', async () => {
585
+ // Regression: during a runtime run agentProjection.status is 'running', so
586
+ // the interactive classifier turned every action verb into 'ambiguous' and
587
+ // returned a canned clarification — "lance l'ingestion" did nothing.
588
+ const originalFetch = globalThis.fetch;
589
+ let fetchCalls = 0;
590
+ globalThis.fetch = async () => {
591
+ fetchCalls += 1;
592
+ return {
593
+ ok: true,
594
+ status: 200,
595
+ headers: { get: () => null },
596
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
597
+ };
598
+ };
599
+ const session = sessionBase({
600
+ agentProjection: { status: 'running', conversation: [], activities: [] },
601
+ _currentRunIdentity: { runId: 'run-ingest', turnId: 'run-ingest:turn-1', workspace: 'docs' },
602
+ llm: toolCallingLlm(),
603
+ });
604
+
605
+ try {
606
+ const agent = createAgentGraph();
607
+ const result = await agent.invoke({ input: "lance l'ingestion des documents", session });
608
+
609
+ assert.equal(result.response, 'Done.');
610
+ assert.equal(fetchCalls, 1, 'the MCP tool must actually be called');
611
+ assert.doesNotMatch(result.response, /Peux-tu préciser/);
612
+ } finally {
613
+ globalThis.fetch = originalFetch;
614
+ }
615
+ });
616
+
617
+ test('agent graph lets Donna handle ambiguous input during a run with the control suite', async () => {
618
+ // The canned "Peux-tu préciser ?" regex answer is gone: Donna converses,
619
+ // armed with status/enqueue/cancel/kill/approve — and without write tools
620
+ // (a new MCP job must not fire alongside the active run).
621
+ const seenTools = [];
622
+ const session = sessionBase({
623
+ runtime: { url: 'http://runtime.test' },
624
+ agentProjection: { status: 'running', conversation: [], activities: [] },
625
+ llm: {
626
+ async completeWithTools({ tools }) {
627
+ seenTools.push(...tools.map((tool) => tool.function.name));
628
+ return {
629
+ content: 'Un run est en cours — je peux le mettre en file pour après, veux-tu ?',
630
+ message: { role: 'assistant', content: 'Un run est en cours — je peux le mettre en file pour après, veux-tu ?' },
631
+ tool_calls: null,
632
+ };
633
+ },
634
+ },
635
+ });
636
+
637
+ const agent = createAgentGraph();
638
+ const result = await agent.invoke({ input: 'lance un build', session });
639
+
640
+ assert.match(result.response, /mettre en file/);
641
+ assert.ok(seenTools.includes('runtime__enqueue'));
642
+ assert.ok(seenTools.includes('runtime__status'));
643
+ assert.ok(seenTools.includes('runtime__approve'));
644
+ assert.ok(!seenTools.includes('production__production_start_job'), 'no write MCP tools during an active run for ambiguous intents');
645
+ });
646
+
647
+ test('agent graph survives more than 12 tool iterations (recursion limit)', async () => {
648
+ // LangGraph's default recursionLimit (25) killed real runs around the 12th
649
+ // tool round with GRAPH_RECURSION_LIMIT. 20 rounds must now pass.
650
+ const originalFetch = globalThis.fetch;
651
+ globalThis.fetch = async () => ({
652
+ ok: true,
653
+ status: 200,
654
+ headers: { get: () => null },
655
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
656
+ });
657
+ let calls = 0;
658
+ const session = sessionBase({
659
+ _currentRunIdentity: { runId: 'run-long', turnId: 'run-long:turn-1', workspace: 'docs' },
660
+ llm: {
661
+ async completeWithTools() {
662
+ calls += 1;
663
+ if (calls <= 20) {
664
+ return {
665
+ content: null,
666
+ message: { role: 'assistant', content: null },
667
+ tool_calls: [{
668
+ id: `call-${calls}`,
669
+ type: 'function',
670
+ function: { name: 'production__production_start_job', arguments: '{"type":"doctor"}' },
671
+ }],
672
+ };
673
+ }
674
+ return { content: 'Terminé.', message: { role: 'assistant', content: 'Terminé.' }, tool_calls: null };
675
+ },
676
+ },
677
+ });
678
+
679
+ try {
680
+ const agent = createAgentGraph();
681
+ const result = await agent.invoke({ input: 'inspecte tout puis lance le doctor', session });
682
+ assert.equal(result.response, 'Terminé.');
683
+ assert.equal(calls, 21);
684
+ } finally {
685
+ globalThis.fetch = originalFetch;
686
+ }
687
+ });
688
+
689
+ test('agent graph auto-declares the plan from an agent_plan task-graph fragment', async () => {
690
+ // The bridge that makes parallel ingestion real: when the LLM calls
691
+ // production__agent_plan, the shell integrates the fragment as the plan
692
+ // deterministically (no lossy LLM copying) with the execution fields the
693
+ // dispatcher needs (operation, arguments, groups, barrier).
694
+ const fragment = {
695
+ capability: 'knowledge.update',
696
+ tasks: [
697
+ { id: 'ingest:a', label: 'Ingest a.md', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: { inputs: ['raw/untracked/a.md'] }, dependsOn: [], parallelizable: true, groupId: 'ingest', expectedOutputRefs: [{ type: 'file', ref: '.wiki/plans/a.json' }], requiresApproval: true, idempotencyKey: 'k'.repeat(64) },
698
+ { id: 'ingest:b', label: 'Ingest b.md', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: { inputs: ['raw/untracked/b.md'] }, dependsOn: [], parallelizable: true, groupId: 'ingest', expectedOutputRefs: [{ type: 'file', ref: '.wiki/plans/b.json' }], requiresApproval: true, idempotencyKey: 'k'.repeat(64) },
699
+ { id: 'ingest-apply', label: 'Apply ingest plans', requiredCapability: 'knowledge.update', operation: 'ingest_apply', arguments: { inputs: ['.wiki/plans/a.json', '.wiki/plans/b.json'] }, dependsOn: ['ingest:a', 'ingest:b'], barrier: true, dependsOnGroup: 'ingest', requiresApproval: true, idempotencyKey: 'k'.repeat(64) },
700
+ ],
701
+ };
702
+ const originalFetch = globalThis.fetch;
703
+ globalThis.fetch = async () => ({
704
+ ok: true,
705
+ status: 200,
706
+ headers: { get: () => null },
707
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: JSON.stringify(fragment) }] } }),
708
+ });
709
+ let calls = 0;
710
+ const session = sessionBase({
711
+ mcp: {
712
+ production: {
713
+ status: 'connected',
714
+ url: 'http://127.0.0.1:3000/mcp/',
715
+ tools: [{ name: 'agent_plan', description: 'Propose a task graph', inputSchema: { type: 'object', properties: {} } }],
716
+ },
717
+ },
718
+ llm: {
719
+ async completeWithTools() {
720
+ calls += 1;
721
+ if (calls === 1) {
722
+ return {
723
+ content: null,
724
+ message: { role: 'assistant', content: null },
725
+ tool_calls: [{
726
+ id: 'plan-call',
727
+ type: 'function',
728
+ function: { name: 'production__agent_plan', arguments: JSON.stringify({ capability: 'knowledge.update', operation: 'ingest' }) },
729
+ }],
730
+ };
731
+ }
732
+ return { content: 'Plan intégré, en attente du dispatch.', message: { role: 'assistant', content: 'Plan intégré, en attente du dispatch.' }, tool_calls: null };
733
+ },
734
+ },
735
+ });
736
+
737
+ try {
738
+ const agent = createAgentGraph();
739
+ const result = await agent.invoke({ input: 'ingère les documents en attente', session });
740
+
741
+ assert.equal(result.response, 'Plan intégré, en attente du dispatch.');
742
+ assert.equal(session.headlessPlan.length, 3);
743
+ assert.deepEqual(session.headlessPlan.map((step) => step.operation), ['ingest_plan', 'ingest_plan', 'ingest_apply']);
744
+ assert.deepEqual(session.headlessPlan[0].arguments, { inputs: ['raw/untracked/a.md'] });
745
+ assert.equal(session.headlessPlan[0].parallelizable, true);
746
+ assert.equal(session.headlessPlan[2].barrier, true);
747
+ assert.deepEqual(session.headlessPlan[2].dependsOn, ['ingest:a', 'ingest:b']);
748
+ assert.equal(session.headlessPlan[0].requiredCapability, 'knowledge.update');
749
+ } finally {
750
+ globalThis.fetch = originalFetch;
751
+ }
752
+ });
753
+
754
+ test('Donna interprets a cleanup request and calls runtime__kill herself', async () => {
755
+ // "supprime le job et la queue" previously hit a regex classifier that
756
+ // answered with canned text. Donna now owns runtime control tools.
757
+ let killPath = null;
758
+ const originalFetch = globalThis.fetch;
759
+ globalThis.fetch = async (url) => {
760
+ killPath = new URL(String(url)).pathname;
761
+ return { ok: true, status: 202, json: async () => ({ killed: true, runs: 1, tasks: 2, queued: 2 }), text: async () => '{}', headers: { get: () => null } };
762
+ };
763
+ let calls = 0;
764
+ const session = sessionBase({
765
+ runtime: { url: 'http://runtime.test' },
766
+ agentProjection: { status: 'running', activities: [], conversation: [] },
767
+ llm: {
768
+ async completeWithTools({ tools }) {
769
+ calls += 1;
770
+ if (calls === 1) {
771
+ const names = tools.map((tool) => tool.function.name);
772
+ assert.ok(names.includes('runtime__kill'), 'control tools must be bound during an active run');
773
+ return {
774
+ content: null,
775
+ message: { role: 'assistant', content: null },
776
+ tool_calls: [{ id: 'kill-call', type: 'function', function: { name: 'runtime__kill', arguments: '{}' } }],
777
+ };
778
+ }
779
+ return { content: 'Run et queue purgés.', message: { role: 'assistant', content: 'Run et queue purgés.' }, tool_calls: null };
780
+ },
781
+ },
782
+ });
783
+
784
+ try {
785
+ const agent = createAgentGraph();
786
+ const result = await agent.invoke({ input: 'supprime le job et la queue', session });
787
+ assert.equal(result.response, 'Run et queue purgés.');
788
+ assert.equal(killPath, '/kill');
789
+ } finally {
790
+ globalThis.fetch = originalFetch;
791
+ }
792
+ });
@@ -295,7 +295,7 @@ async function runRuntime(argv, agent) {
295
295
  const { defaultRuntimeStateDir, openRuntimeStore, RECOVERABLE_QUEUE_STATUSES } = await import('../runtime/store.js');
296
296
  const { startRuntimeServer } = await import('../runtime/server.js');
297
297
  const { recoverActiveRuns } = await import('../runtime/recoveryManager.js');
298
- const { emitRuntimeLog, startActivitySupervisor } = await import('../runtime/supervisor.js');
298
+ const { emitRuntimeLog, startActivitySupervisor, cancelActiveActivityJobs } = await import('../runtime/supervisor.js');
299
299
  const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
300
300
  const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
301
301
  const { createApprovalManager } = await import('../runtime/approvals.js');
@@ -345,6 +345,25 @@ async function runRuntime(argv, agent) {
345
345
  store.persistEvent(event);
346
346
  serverHandle?.publish(event);
347
347
  };
348
+ // Control requests queued in a PREVIOUS runtime process must not
349
+ // auto-run at boot: a "stop le job" typed last night starting hours
350
+ // later as a fresh run violates least surprise. Expire them — the
351
+ // user can always resubmit.
352
+ const staleControl = (session.controlQueue ?? []).filter((item) => item.status === 'queued');
353
+ for (const item of staleControl) {
354
+ dispatchAgentEvent(session, createAgentEvent('control_cancelled', {
355
+ origin: 'runtime',
356
+ workspace,
357
+ payload: { id: item.id, reason: 'stale_at_boot' },
358
+ }));
359
+ }
360
+ if (staleControl.length > 0) {
361
+ dispatchAgentEvent(session, createAgentEvent('runtime_log', {
362
+ origin: 'runtime',
363
+ workspace,
364
+ payload: { message: `runtime: expired ${staleControl.length} stale queued control request(s) from a previous session` },
365
+ }));
366
+ }
348
367
  session._onRuntimeError = (err) => {
349
368
  const message = err instanceof Error ? err.message : String(err);
350
369
  dispatchAgentEvent(session, createAgentEvent('run_error', {
@@ -620,6 +639,10 @@ async function runRuntime(argv, agent) {
620
639
  });
621
640
  } catch (err) {
622
641
  if (err?.name === 'AbortError') {
642
+ // Cancel the asynchronous agent jobs the run started: aborting only
643
+ // the manager loop left ingest subprocesses running for minutes with
644
+ // frozen panels.
645
+ await cancelActiveActivityJobs(session).catch(() => {});
623
646
  dispatchAgentEvent(session, createAgentEvent('run_cancelled', {
624
647
  origin: 'runtime',
625
648
  runId,
@@ -43,9 +43,11 @@ import {
43
43
  listDocumentUploads,
44
44
  storeAndMaybeConvertDocument,
45
45
  } from '../core/documentIntake.js';
46
+ import { fetchRuntimeState, postRuntimeCancel, postRuntimeKill } from '../runtime/client.js';
47
+ import { versionWithBuild } from '../core/buildInfo.js';
46
48
 
47
49
  export function printVersion(packageJson) {
48
- console.log(packageJson.version);
50
+ console.log(versionWithBuild(packageJson));
49
51
  }
50
52
 
51
53
  const styles = {
@@ -632,6 +634,8 @@ ${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clea
632
634
  ${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
633
635
  ${helpPair('/chat', 'Chat mode', '/agent', 'Agent mode')}
634
636
  ${helpPair('/openui', 'Open web UI in browser', '', '')}
637
+ ${helpPair('/run status', 'Runtime status', '/run kill', 'Kill runtime run(s)')}
638
+ ${helpPair('/run cancel', 'Cancel active run', '', '')}
635
639
  ${helpPair('/queue', 'MCP job queue', '/queue clear', 'Clear finished')}
636
640
  ${helpPair('/queue cancel <id>', 'Cancel queued/running', '', '')}
637
641
  ${helpPair('/clear', 'Clear screen', '/exit', 'Exit')}
@@ -676,6 +680,37 @@ function rawCommandResult(command, output) {
676
680
  };
677
681
  }
678
682
 
683
+ function formatRuntimeRunStatus(state) {
684
+ const status = state?.status ?? 'unknown';
685
+ const runId = state?.runId ? ` run=${state.runId}` : '';
686
+ const queued = Array.isArray(state?.controlQueue)
687
+ ? state.controlQueue.filter((item) => item.status === 'queued').length
688
+ : 0;
689
+ const tasks = Array.isArray(state?.workflow?.nodes)
690
+ ? state.workflow.nodes.filter((node) => node.type === 'task' && !['done', 'failed', 'cancelled'].includes(String(node.status))).length
691
+ : 0;
692
+ return `runtime: ${status}${runId} · queued=${queued} · activeTasks=${tasks}`;
693
+ }
694
+
695
+ function runtimeManagedItemId(context, id) {
696
+ const runtimeState = typeof context.runtimeState === 'function' ? context.runtimeState() : context.runtimeState;
697
+ const states = [runtimeState, context.session].filter(Boolean);
698
+ return states.some((state) =>
699
+ runtimeQueueMatches(state, id)
700
+ || runtimeWorkflowMatches(state.workflow, id));
701
+ }
702
+
703
+ function runtimeQueueMatches(state, id) {
704
+ return Array.isArray(state?.queue) && state.queue.some((item) => String(item.id) === String(id));
705
+ }
706
+
707
+ function runtimeWorkflowMatches(workflow, id) {
708
+ if (!workflow || typeof workflow !== 'object') return false;
709
+ const target = String(id);
710
+ return (Array.isArray(workflow.nodes) && workflow.nodes.some((node) => String(node.id) === target || String(node.itemId ?? node.taskId ?? '') === target))
711
+ || (Array.isArray(workflow.relations) && workflow.relations.some((relation) => String(relation.from) === target || String(relation.to) === target));
712
+ }
713
+
679
714
  export async function handleSlashCommand(line, context) {
680
715
  const args = line.slice(1).trim().split(/\s+/).filter(Boolean);
681
716
  const [command] = args;
@@ -937,16 +972,54 @@ export async function handleSlashCommand(line, context) {
937
972
  }
938
973
  return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
939
974
  }
975
+ case 'run': {
976
+ const subcommand = args[1] ?? 'status';
977
+ const runtime = context.runtime ?? {};
978
+ const url = runtime.url;
979
+ if (!url) return { output: 'Runtime unavailable. Start/connect the runtime before using /run.' };
980
+ if (subcommand === 'status') {
981
+ const state = await fetchRuntimeState({ url, workspace: context.session.workspace ?? null });
982
+ return { output: formatRuntimeRunStatus(state) };
983
+ }
984
+ if (subcommand === 'cancel') {
985
+ const result = await postRuntimeCancel({ url, workspace: context.session.workspace ?? null });
986
+ return { output: result.cancelled ? 'Runtime cancel requested.' : `Runtime cancel skipped: ${result.reason ?? 'no active run'}` };
987
+ }
988
+ if (subcommand === 'kill') {
989
+ const result = await postRuntimeKill({ url, workspace: context.session.workspace ?? null, runId: args[2] ?? null });
990
+ return { output: `Runtime kill requested: ${result.runs ?? 0} run${result.runs === 1 ? '' : 's'}, ${result.tasks ?? 0} task${result.tasks === 1 ? '' : 's'} cancelled.` };
991
+ }
992
+ return { output: 'Usage: /run [status|cancel|kill [runId]]' };
993
+ }
940
994
  case 'queue': {
941
995
  const subcommand = args[1] ?? 'list';
942
996
  if (subcommand === 'list') return { output: formatQueue(context.session) };
943
997
  if (subcommand === 'clear') {
944
998
  const count = clearFinishedQueueItems(context.session);
999
+ // "Cleared 0" with a busy runtime is a dead end: the items the user
1000
+ // wants gone are ACTIVE and runtime-managed — point at the commands
1001
+ // that actually stop them.
1002
+ const activeRuntimeItems = (context.session.jobQueue ?? [])
1003
+ .filter((item) => item.origin === 'runtime' && !['done', 'failed', 'cancelled', 'expired'].includes(String(item.status ?? '').toLowerCase())).length;
1004
+ const runActive = String(context.session.agentProjection?.status ?? '').toLowerCase() === 'running';
1005
+ if (count === 0 && (activeRuntimeItems > 0 || runActive)) {
1006
+ return {
1007
+ output: `Cleared 0 finished queue items — ${activeRuntimeItems || 'des'} item(s) actifs sont gérés par le runtime${runActive ? ' (run en cours)' : ''}. Utilisez /run cancel (arrêt doux) ou /run kill (abort + purge complète).`,
1008
+ };
1009
+ }
945
1010
  return { output: `Cleared ${count} finished queue item${count === 1 ? '' : 's'}.` };
946
1011
  }
947
1012
  if (subcommand === 'cancel') {
948
1013
  const id = args[2];
949
1014
  if (!id) return { output: 'Usage: /queue cancel <id>' };
1015
+ // Runtime-managed items must be refused BEFORE the local cancel:
1016
+ // syncRuntimeState replaces session.jobQueue with the runtime queue,
1017
+ // so cancelQueueItem would "succeed" locally and the next SSE sync
1018
+ // would silently revert the item to waiting (fake cancel).
1019
+ const localItem = (context.session.jobQueue ?? []).find((item) => String(item.id) === String(id));
1020
+ if (localItem?.origin === 'runtime' || (!localItem && runtimeManagedItemId(context, id))) {
1021
+ return { output: 'Item géré par le runtime — utilisez /run kill (global) ou /run cancel au lieu de /queue cancel.' };
1022
+ }
950
1023
  const result = await cancelQueueItem(context.session, id);
951
1024
  return { output: result.message };
952
1025
  }
@@ -144,3 +144,39 @@ test('/use loads only workspaces and /config use switches wikirc profiles', asyn
144
144
  else process.env.WIKI_WORKSPACES_DIR = previousDir;
145
145
  }
146
146
  });
147
+
148
+ test('/queue cancel refuses runtime-managed items instead of fake-cancelling locally', async () => {
149
+ // syncRuntimeState replaces session.jobQueue with the runtime queue and tags
150
+ // origin:'runtime' — a local cancel would be reverted by the next SSE sync,
151
+ // so the command must redirect the user to /run kill / /run cancel.
152
+ const session = {
153
+ jobQueue: [
154
+ { id: 'q-runtime-1', status: 'waiting', workspace: 'demo', tool: 'agent_execute', origin: 'runtime' },
155
+ { id: 'q-local-1', status: 'waiting', workspace: 'demo', server: 'production', tool: 'production_start_job' },
156
+ ],
157
+ };
158
+
159
+ const refused = await handleSlashCommand('/queue cancel q-runtime-1', {
160
+ packageJson: { version: 'test' },
161
+ session,
162
+ });
163
+ assert.match(refused.output ?? '', /runtime/i);
164
+ assert.match(refused.output ?? '', /\/run kill/);
165
+ assert.equal(session.jobQueue[0].status, 'waiting', 'runtime item must not be flipped locally');
166
+
167
+ const cancelled = await handleSlashCommand('/queue cancel q-local-1', {
168
+ packageJson: { version: 'test' },
169
+ session,
170
+ });
171
+ assert.match(cancelled.output ?? '', /Cancelled/i);
172
+ assert.equal(session.jobQueue[1].status, 'cancelled');
173
+ });
174
+
175
+ test('/queue cancel reports unknown ids that are not runtime-managed', async () => {
176
+ const session = { jobQueue: [] };
177
+ const result = await handleSlashCommand('/queue cancel nope', {
178
+ packageJson: { version: 'test' },
179
+ session,
180
+ });
181
+ assert.match(result.output ?? '', /Unknown queue item/i);
182
+ });
@@ -50,7 +50,7 @@ test('agent event contract carries the unified audit identity fields', () => {
50
50
  runId: 'run-1',
51
51
  turnId: 'turn-1',
52
52
  taskId: 'task-1',
53
- workspace: 'juno',
53
+ workspace: 'acme',
54
54
  payload: { toolCallId: 'call-1', ok: true },
55
55
  };
56
56
 
@@ -58,7 +58,7 @@ test('agent event contract carries the unified audit identity fields', () => {
58
58
  });
59
59
 
60
60
  test('run and control request contracts reject empty run input but accept explicit controls', () => {
61
- assert.equal(validateContract('runRequest', { input: 'Build docs', workspace: 'juno' }).ok, true);
61
+ assert.equal(validateContract('runRequest', { input: 'Build docs', workspace: 'acme' }).ok, true);
62
62
  assert.equal(validateContract('runRequest', { input: '' }).ok, false);
63
63
  assert.equal(validateContract('controlMessage', { action: 'message', input: 'Ou en est le build ?', intent: 'observe' }).ok, true);
64
64
  });
@@ -251,6 +251,10 @@ export function terminalFailures(activities) {
251
251
  );
252
252
  }
253
253
 
254
+ export function isCancelledStatus(status) {
255
+ return ['cancelled', 'canceled'].includes(String(status ?? '').toLowerCase());
256
+ }
257
+
254
258
  export function formatActivityError(source, action, err) {
255
259
  const message = err instanceof Error ? err.message : String(err);
256
260
  const lines = message
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { normalizeActivity, extractActivity, rememberActivity, rememberActivityFromPayload } from './activity.js';
3
+ import { normalizeActivity, extractActivity, isCancelledStatus, rememberActivity, rememberActivityFromPayload } from './activity.js';
4
4
 
5
5
  test('normalizeActivity: plan.steps preserved with id and label', () => {
6
6
  const a = normalizeActivity({
@@ -29,6 +29,14 @@ test('normalizeActivity: plan null when no plan field', () => {
29
29
  assert.equal(a.plan, null);
30
30
  });
31
31
 
32
+ test('isCancelledStatus recognizes both cancellation spellings', () => {
33
+ assert.equal(isCancelledStatus('cancelled'), true);
34
+ assert.equal(isCancelledStatus('canceled'), true);
35
+ assert.equal(isCancelledStatus('CANCELLED'), true);
36
+ assert.equal(isCancelledStatus('failed'), false);
37
+ assert.equal(isCancelledStatus(null), false);
38
+ });
39
+
32
40
  test('normalizeActivity: step id falls back to 1-based index when missing', () => {
33
41
  const a = normalizeActivity({
34
42
  id: '1',