@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
@@ -160,3 +160,76 @@ function recoverySession() {
160
160
  }],
161
161
  };
162
162
  }
163
+
164
+ test('recoveryManager fails unresolvable-capability tasks and interrupts the run', async () => {
165
+ const { store, root, runId, taskId } = storeWithActiveTask();
166
+ const session = recoverySession();
167
+ // A registry that DOES know capabilities, but not the task's one: the plan
168
+ // came from a hallucinated capability (the 0.12.1 incident) — re-attaching
169
+ // it would recreate a forever-waiting queue on every boot.
170
+ session.agentRegistrySnapshot = [{
171
+ agentInstanceId: 'production-main',
172
+ serverName: 'production',
173
+ health: 'available',
174
+ description: {
175
+ agentType: 'production',
176
+ contractVersion: '1',
177
+ capabilities: [{ id: 'knowledge.pipeline', version: '1' }],
178
+ },
179
+ }];
180
+ store.hydrateSession(session, { workspace: 'docs' });
181
+ const statusCalls = [];
182
+
183
+ try {
184
+ const result = await recoverActiveRuns({
185
+ store,
186
+ session,
187
+ workspace: 'docs',
188
+ callTool: async (_mcp, serverName, toolName, args) => {
189
+ statusCalls.push({ serverName, toolName, args });
190
+ return { content: [{ type: 'text', text: '{}' }] };
191
+ },
192
+ });
193
+
194
+ assert.equal(result.ok, true);
195
+ assert.equal(result.recovered.length, 0);
196
+ assert.equal(result.rescheduled.length, 0);
197
+ assert.equal(result.interrupted.length, 1);
198
+ assert.match(result.interrupted[0].reason, /unresolvable capability: document\.build/);
199
+ assert.deepEqual(statusCalls, [], 'no agent_status poll for an unresolvable task');
200
+ assert.equal(store.listTasks({ runId })[0].status, 'failed');
201
+ // The run must not come back as a zombie on the next boot.
202
+ assert.deepEqual(store.listRecoverableRuns({ workspace: 'docs' }), []);
203
+ } finally {
204
+ store.close();
205
+ rmSync(root, { recursive: true, force: true });
206
+ }
207
+ });
208
+
209
+ test('recoveryManager keeps recovering when the registry has no capability information', async () => {
210
+ const { store, root, runId, taskId } = storeWithActiveTask();
211
+ const session = recoverySession(); // snapshot without capability lists
212
+ store.hydrateSession(session, { workspace: 'docs' });
213
+
214
+ try {
215
+ const result = await recoverActiveRuns({
216
+ store,
217
+ session,
218
+ workspace: 'docs',
219
+ callTool: async (_mcp, _serverName, _toolName, args) => ({
220
+ content: [{ type: 'text', text: JSON.stringify({
221
+ jobId: args.jobId,
222
+ taskId,
223
+ status: 'done',
224
+ result: { status: 'succeeded', outputRefs: [], metrics: {} },
225
+ }) }],
226
+ }),
227
+ });
228
+
229
+ assert.equal(result.recovered.length, 1, 'uninformative registry must not block recovery');
230
+ assert.equal(store.listTasks({ runId })[0].status, 'done');
231
+ } finally {
232
+ store.close();
233
+ rmSync(root, { recursive: true, force: true });
234
+ }
235
+ });
@@ -1,7 +1,7 @@
1
1
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
- import { sessionActivities, terminalFailures } from '../core/activity.js';
2
+ import { isCancelledStatus, sessionActivities, terminalFailures } from '../core/activity.js';
3
3
  import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
4
- import { formatPlanStatus } from '../core/plan.js';
4
+ import { formatPlanStatus, formatPlanStep } from '../core/plan.js';
5
5
  import { readyPlanTasks, sanitizePlanForExecution } from '../core/planPatch.js';
6
6
  import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
7
7
  import { createAttemptManager } from '../orchestrator/attemptManager.js';
@@ -134,6 +134,19 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
134
134
  continue;
135
135
  }
136
136
  }
137
+ if (!trigger && isCancelledOnlyLoopResult(result)) {
138
+ dispatchAgentEvent(session, createAgentEvent('run_cancelled', {
139
+ origin: 'runtime',
140
+ runId,
141
+ payload: {
142
+ runId,
143
+ cancelled: true,
144
+ message: 'Runtime run cancelled by user.',
145
+ },
146
+ }));
147
+ emitRuntimeLog(session, 'runtime: run ended by user cancellation (no replan)');
148
+ return { ok: false, result, cancelled: true };
149
+ }
137
150
  dispatchAgentEvent(session, createAgentEvent('run_error', {
138
151
  origin: 'runtime',
139
152
  runId,
@@ -800,6 +813,7 @@ export async function replanRuntimeRun(session, input, trigger, {
800
813
  'Given the original objective, current plan, and failure reason, return only the remaining steps required.',
801
814
  'Do not include steps that are already done.',
802
815
  'Return only JSON with this exact shape: {"steps":["..."]}.',
816
+ 'Each step MUST be a plain string, not an object.',
803
817
  ].join('\n'),
804
818
  tools: [],
805
819
  messages: [{ role: 'user', content: buildReplanPrompt(input, session, trigger) }],
@@ -825,10 +839,12 @@ export async function replanRuntimeRun(session, input, trigger, {
825
839
  steps: mergedSteps,
826
840
  },
827
841
  }));
828
- if (hasMutatingReplanStep(steps)) {
829
- session._runApprovalRequired = true;
830
- session._runApprovalResolved = false;
831
- }
842
+ // Every replan requires approval: deciding "is this step mutating?" with
843
+ // a verb regex was a safety judgement made by pattern-matching — a
844
+ // missed verb silently skipped the approval gate. Replans are rare
845
+ // (technical failures only) and re-executing work deserves a human OK.
846
+ session._runApprovalRequired = true;
847
+ session._runApprovalResolved = false;
832
848
  return { ok: true, steps };
833
849
  } catch (err) {
834
850
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
@@ -847,7 +863,12 @@ function replanTriggerFromLoopResult(result) {
847
863
  };
848
864
  }
849
865
  const failures = terminalFailures(result.completed ?? []);
850
- const failure = failures[0];
866
+ const technical = failures.filter((failure) => !isCancelledStatus(failure.status));
867
+ const cancelled = failures.filter((failure) => isCancelledStatus(failure.status));
868
+ if (cancelled.length > 0 && technical.length === 0 && (result.failures ?? []).every(isCancelledTaskFailure)) {
869
+ return null;
870
+ }
871
+ const failure = technical[0];
851
872
  if (failure) {
852
873
  return {
853
874
  kind: 'activity_error',
@@ -859,7 +880,7 @@ function replanTriggerFromLoopResult(result) {
859
880
  // The parallel scheduler can fail a task before it ever produces an
860
881
  // _activity (e.g. a thrown error on the first turn) — that failure lives
861
882
  // in result.failures, not in any activity, so it must be checked too.
862
- const taskFailure = (result.failures ?? [])[0];
883
+ const taskFailure = (result.failures ?? []).find((failure) => !isCancelledTaskFailure(failure));
863
884
  if (!taskFailure) return null;
864
885
  return {
865
886
  kind: 'task_error',
@@ -869,6 +890,25 @@ function replanTriggerFromLoopResult(result) {
869
890
  };
870
891
  }
871
892
 
893
+ function isCancelledTaskFailure(failure) {
894
+ return failure?.cancelled === true
895
+ || failure?.result?.cancelled === true
896
+ || isCancelledStatus(failure?.status)
897
+ || isCancelledStatus(failure?.result?.status);
898
+ }
899
+
900
+ function isCancelledOnlyLoopResult(result) {
901
+ const failures = terminalFailures(result.completed ?? []);
902
+ const technicalActivities = failures.filter((failure) => !isCancelledStatus(failure.status));
903
+ const cancelledActivities = failures.filter((failure) => isCancelledStatus(failure.status));
904
+ const taskFailures = result.failures ?? [];
905
+ const technicalTasks = taskFailures.filter((failure) => !isCancelledTaskFailure(failure));
906
+ const cancelledTasks = taskFailures.filter(isCancelledTaskFailure);
907
+ return technicalActivities.length === 0
908
+ && technicalTasks.length === 0
909
+ && (cancelledActivities.length > 0 || cancelledTasks.length > 0);
910
+ }
911
+
872
912
  function runtimeLoopErrorMessage(result) {
873
913
  if (result.timedOut) return 'Runtime agentic loop timed out.';
874
914
  if (result.maxTurns) return 'Runtime agentic loop reached max turns.';
@@ -912,15 +952,11 @@ function buildReplannedRunPrompt(input, trigger, steps) {
912
952
  function normalizeReplan(steps) {
913
953
  if (!Array.isArray(steps)) return [];
914
954
  return steps
915
- .map((step) => String(step ?? '').trim())
955
+ .map((step) => formatPlanStep(step).trim())
916
956
  .filter(Boolean)
917
957
  .slice(0, 12);
918
958
  }
919
959
 
920
- function hasMutatingReplanStep(steps) {
921
- return steps.some((step) => /\b(build|copy|ingest|import|export|polish|pipeline|write|create|delete|update|send|deploy|publish|generate|construire|copier|importer|exporter|publier|envoyer|supprimer|modifier|creer|générer|generer)\b/i.test(step));
922
- }
923
-
924
960
  function mergeReplanWithCompleted(plan, steps) {
925
961
  const completed = (plan ?? [])
926
962
  .filter((step) => step.status === 'done')
@@ -259,6 +259,30 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
259
259
  assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
260
260
  });
261
261
 
262
+ test('replanRuntimeRun normalizes object steps without leaking object strings', async () => {
263
+ const session = {
264
+ activities: {},
265
+ headlessPlan: [{ step: 1, id: 'done', description: 'Done', status: 'done' }],
266
+ llm: {
267
+ async completeWithTools({ system }) {
268
+ assert.match(system, /Each step MUST be a plain string, not an object\./);
269
+ return { content: '{"steps":["a",{"description":"b"},{"id":"c"},{},null]}' };
270
+ },
271
+ },
272
+ };
273
+
274
+ const result = await replanRuntimeRun(session, 'Build deliverable', {
275
+ kind: 'activity_error',
276
+ reason: 'Build failed.',
277
+ }, { runId: 'run-replan-object-steps', replansLeft: 1 });
278
+
279
+ assert.equal(result.ok, true);
280
+ assert.deepEqual(result.steps, ['a', 'b', 'c']);
281
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['a', 'b', 'c']);
282
+ assert.deepEqual(session.headlessPlan.slice(1).map((step) => step.description), ['a', 'b', 'c']);
283
+ assert.equal(JSON.stringify(session.headlessPlan).includes('[object Object]'), false);
284
+ });
285
+
262
286
  test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
263
287
  const session = {
264
288
  activities: {},
@@ -370,6 +394,154 @@ test('runRuntimeAgenticWorkflow replans after terminal activity error', async ()
370
394
  }
371
395
  });
372
396
 
397
+ test('runRuntimeAgenticWorkflow treats user-cancelled activity as terminal cancellation without replan', async () => {
398
+ const originalFetch = globalThis.fetch;
399
+ let pollAttempts = 0;
400
+ const events = [];
401
+ globalThis.fetch = async () => {
402
+ pollAttempts += 1;
403
+ return mcpActivityResponse({
404
+ id: 'job-cancelled',
405
+ source: 'production',
406
+ label: 'Production build',
407
+ status: 'cancelled',
408
+ terminal: true,
409
+ });
410
+ };
411
+ const session = {
412
+ mcp: {
413
+ production: {
414
+ status: 'connected',
415
+ url: 'http://127.0.0.1:3000/mcp/',
416
+ retry: { maxAttempts: 1, backoffMs: 0 },
417
+ },
418
+ },
419
+ activities: {},
420
+ headlessPlan: null,
421
+ llm: {
422
+ async completeWithTools() {
423
+ assert.fail('cancelled activity must not call evaluator or replanner');
424
+ },
425
+ },
426
+ _onAgentEvent: (event) => events.push(event),
427
+ };
428
+ const agent = {
429
+ async invoke({ session: turnSession }) {
430
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
431
+ payload: {
432
+ activity: runningPollableActivity('job-cancelled', 'Production build'),
433
+ },
434
+ }));
435
+ return { response: 'Started build.' };
436
+ },
437
+ };
438
+
439
+ try {
440
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
441
+ runId: 'run-cancelled-activity',
442
+ timeoutMs: 1000,
443
+ maxTurns: 1,
444
+ maxReplans: 1,
445
+ });
446
+
447
+ assert.equal(result.ok, false);
448
+ assert.equal(result.cancelled, true);
449
+ assert.equal(pollAttempts, 1);
450
+ assert.equal(events.some((event) => event.type === 'run_replanned'), false);
451
+ assert.ok(events.some((event) => event.type === 'run_cancelled' && event.payload?.cancelled === true));
452
+ assert.equal(session.agentProjection.status, 'cancelled');
453
+ assert.match(session.agentProjection.logs.join('\n'), /run ended by user cancellation \(no replan\)/);
454
+ } finally {
455
+ globalThis.fetch = originalFetch;
456
+ }
457
+ });
458
+
459
+ test('runRuntimeAgenticWorkflow replans from failed activity when another activity is cancelled', async () => {
460
+ const originalFetch = globalThis.fetch;
461
+ const events = [];
462
+ const pollStatuses = new Map([
463
+ ['job-failed', {
464
+ id: 'job-failed',
465
+ source: 'production',
466
+ label: 'Production build',
467
+ status: 'failed',
468
+ terminal: true,
469
+ error: 'build failed',
470
+ }],
471
+ ['job-cancelled', {
472
+ id: 'job-cancelled',
473
+ source: 'production',
474
+ label: 'Production cleanup',
475
+ status: 'cancelled',
476
+ terminal: true,
477
+ }],
478
+ ]);
479
+ globalThis.fetch = async (_url, options = {}) => {
480
+ const body = JSON.parse(String(options.body ?? '{}'));
481
+ return mcpActivityResponse(pollStatuses.get(body.params?.arguments?.jobId));
482
+ };
483
+ const session = {
484
+ mcp: {
485
+ production: {
486
+ status: 'connected',
487
+ url: 'http://127.0.0.1:3000/mcp/',
488
+ retry: { maxAttempts: 1, backoffMs: 0 },
489
+ },
490
+ },
491
+ activities: {},
492
+ headlessPlan: null,
493
+ llm: {
494
+ async completeWithTools({ system }) {
495
+ if (/replanner/.test(system)) return { content: '{"steps":["Retry build"]}' };
496
+ return { content: '{"ok":true,"reason":"Build complete.","suggestedAction":null}' };
497
+ },
498
+ },
499
+ _onAgentEvent: (event) => events.push(event),
500
+ };
501
+ let turns = 0;
502
+ const agent = {
503
+ async invoke({ session: turnSession }) {
504
+ turns += 1;
505
+ if (turns === 1) {
506
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
507
+ payload: { activity: runningPollableActivity('job-failed', 'Production build') },
508
+ }));
509
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
510
+ payload: { activity: runningPollableActivity('job-cancelled', 'Production cleanup') },
511
+ }));
512
+ return { response: 'Started build.' };
513
+ }
514
+ const pending = turnSession.headlessPlan?.find((step) => step.status === 'pending');
515
+ if (pending) {
516
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
517
+ origin: 'tool',
518
+ payload: { taskId: pending.id, status: 'done' },
519
+ }));
520
+ }
521
+ return { response: 'Retry done.' };
522
+ },
523
+ };
524
+
525
+ try {
526
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'Build workspace', {
527
+ runId: 'run-failed-and-cancelled',
528
+ timeoutMs: 1000,
529
+ maxTurns: 3,
530
+ maxReplans: 1,
531
+ });
532
+
533
+ assert.equal(result.ok, true);
534
+ assert.equal(turns, 2);
535
+ assert.equal(events.some((event) => event.type === 'run_cancelled'), false);
536
+ assert.ok(events.some((event) => event.type === 'run_replanned'));
537
+ assert.equal(session.agentProjection.replans[0].reason, 'Production build ended with failed: build failed');
538
+ assert.deepEqual(session.agentProjection.replans[0].plan, ['Retry build']);
539
+ assert.equal(session.agentProjection.status, 'done');
540
+ } finally {
541
+ globalThis.fetch = originalFetch;
542
+ }
543
+ });
544
+
373
545
  test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async () => {
374
546
  const session = {
375
547
  activities: {},
@@ -707,6 +879,30 @@ function productionAgent() {
707
879
  };
708
880
  }
709
881
 
882
+ function runningPollableActivity(id, label) {
883
+ return {
884
+ id,
885
+ source: 'production',
886
+ label,
887
+ status: 'running',
888
+ terminal: false,
889
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: id }, intervalMs: 0 },
890
+ };
891
+ }
892
+
893
+ function mcpActivityResponse(activity) {
894
+ return {
895
+ ok: true,
896
+ status: 200,
897
+ headers: { get: () => null },
898
+ text: async () => JSON.stringify({
899
+ result: {
900
+ content: [{ type: 'text', text: JSON.stringify({ _activity: activity }) }],
901
+ },
902
+ }),
903
+ };
904
+ }
905
+
710
906
  function toolResult(payload) {
711
907
  return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
712
908
  }
@@ -5,6 +5,7 @@ import { activeCacertPath } from '../core/cacert.js';
5
5
  import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
7
7
  import { runtimeTokenFromEnv } from './auth.js';
8
+ import { controlMessage } from './controlMessages.js';
8
9
 
9
10
  export function startRuntimeServer({
10
11
  host = '127.0.0.1',
@@ -241,15 +242,19 @@ export function startRuntimeServer({
241
242
  }
242
243
  validateContractInDev('runRequest', { ...body, input });
243
244
  if (context.running) {
244
- const item = enqueueControlRequest(context, input);
245
- sendJson(response, 202, {
246
- accepted: true,
247
- queued: true,
248
- kind: 'enqueue_run',
249
- item,
250
- ...controlStatus(context, store),
251
- explanation: 'Request queued for a future run.',
245
+ // Blind enqueueing made every message typed during a run
246
+ // invisible until the run ended (serve UI symptom: "no result
247
+ // until the job finished or was stopped"). Classify instead:
248
+ // observe → immediate status explanation, cancel → abort,
249
+ // ambiguous action → explicit choices, explicit "later" intent
250
+ // (body.intent = 'enqueue') → enqueue as before.
251
+ const result = await handleControlMessage(context, store, input, {
252
+ intent: body.intent,
253
+ startNextControlRequest,
254
+ cancel,
255
+ approve,
252
256
  });
257
+ sendJson(response, result.statusCode, result.body);
253
258
  return;
254
259
  }
255
260
  const accepted = startRuntimeRun(context, body);
@@ -273,6 +278,16 @@ export function startRuntimeServer({
273
278
  sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
274
279
  return;
275
280
  }
281
+ if (request.method === 'POST' && url.pathname === '/kill') {
282
+ const body = await readJson(request);
283
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
284
+ const runId = url.searchParams.get('runId') ?? body.runId ?? null;
285
+ const context = await resolveContext({ workspace });
286
+ const result = await killRuntimeRuns(context, { workspace, runId });
287
+ publishState(context.workspace ?? workspace ?? null, context);
288
+ sendJson(response, 202, result);
289
+ return;
290
+ }
276
291
  if (request.method === 'POST' && url.pathname === '/shutdown') {
277
292
  const workspace = workspaceFromUrl(url);
278
293
  const context = await resolveContext({ workspace });
@@ -354,6 +369,23 @@ export function startRuntimeServer({
354
369
  return { body, workspace, context };
355
370
  }
356
371
 
372
+ async function killRuntimeRuns(context, { workspace = null, runId = null } = {}) {
373
+ const targetWorkspace = context?.workspace ?? workspace ?? null;
374
+ const targetRunId = runId ? String(runId) : null;
375
+ if (!targetRunId || targetRunId === context?.currentRunId) {
376
+ context?.currentAbortController?.abort();
377
+ await cancel?.(context);
378
+ }
379
+ const runs = typeof store.interruptRuns === 'function'
380
+ ? store.interruptRuns({ workspace: targetWorkspace, runId: targetRunId, reason: 'Runtime run killed by user.' })
381
+ : 0;
382
+ const tasks = typeof store.cancelActiveTasksForInterruptedRuns === 'function'
383
+ ? store.cancelActiveTasksForInterruptedRuns({ workspace: targetWorkspace, runId: targetRunId })
384
+ : 0;
385
+ const queued = cancelQueuedControlItems(context?.session, targetWorkspace);
386
+ return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued };
387
+ }
388
+
357
389
  function startRuntimeRun(context, body, { controlItemId = null } = {}) {
358
390
  const runId = randomUUID();
359
391
  const runWorkspace = context.workspace ?? body.workspace ?? null;
@@ -471,6 +503,34 @@ function controlQueueFor(session) {
471
503
  return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
472
504
  }
473
505
 
506
+ function cancelQueuedControlItems(session, workspace = null) {
507
+ const now = new Date().toISOString();
508
+ const counted = new Set();
509
+ let count = 0;
510
+ for (const queue of [controlQueueFor(session), session?.agentProjection?.controlQueue].filter(Array.isArray)) {
511
+ for (const item of queue) {
512
+ if (item.status !== 'queued') continue;
513
+ if (workspace && item.workspace && item.workspace !== workspace) continue;
514
+ item.status = 'cancelled';
515
+ item.finishedAt = now;
516
+ item.updatedAt = now;
517
+ const id = item.id ?? item.runId ?? `${item.input}:${item.createdAt}`;
518
+ if (!counted.has(id)) {
519
+ counted.add(id);
520
+ if (session) {
521
+ dispatchAgentEvent(session, createAgentEvent('control_cancelled', {
522
+ origin: 'runtime',
523
+ workspace: item.workspace ?? workspace ?? null,
524
+ payload: { id, finishedAt: now },
525
+ }));
526
+ }
527
+ count += 1;
528
+ }
529
+ }
530
+ }
531
+ return count;
532
+ }
533
+
474
534
  function readOnlyControlResponse(kind, classification, status, explanation, { accepted = true, extra = {} } = {}) {
475
535
  return {
476
536
  statusCode: 200,
@@ -522,7 +582,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
522
582
  classification,
523
583
  proposal,
524
584
  ...controlStatus(context, store),
525
- explanation: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
585
+ explanation: controlMessage(context?.session, 'plan_patch_proposed'),
526
586
  },
527
587
  };
528
588
  }
@@ -540,12 +600,12 @@ async function handleControlMessage(context, store, input, { intent = null, star
540
600
  classification,
541
601
  item,
542
602
  ...controlStatus(context, store),
543
- explanation: 'Request queued for a future run.',
603
+ explanation: controlMessage(context?.session, 'queued_for_future_run'),
544
604
  },
545
605
  };
546
606
  }
547
607
  if (classification.kind === 'ambiguous') {
548
- return readOnlyControlResponse('ambiguous', classification, status, 'The runtime cannot safely classify this message.', {
608
+ return readOnlyControlResponse('ambiguous', classification, status, controlMessage(context?.session, 'ambiguous_control'), {
549
609
  accepted: false,
550
610
  extra: {
551
611
  choices: [
@@ -557,8 +617,8 @@ async function handleControlMessage(context, store, input, { intent = null, star
557
617
  });
558
618
  }
559
619
  return readOnlyControlResponse('converse', classification, status, status.running
560
- ? 'Runtime run is still active. This message was treated as conversation and did not create a queued run.'
561
- : 'Runtime is idle. This message was treated as conversation and did not create a run.');
620
+ ? controlMessage(context?.session, 'converse_while_running')
621
+ : controlMessage(context?.session, 'converse_while_idle'));
562
622
  }
563
623
 
564
624
  function enqueueControlRequest(context, input) {