@dotdrelle/wiki-manager 0.8.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -460,6 +460,21 @@ or the shell command `/approve item <id>`. The approval timeout defaults to 10
460
460
  minutes and can be changed with `WIKI_MANAGER_APPROVAL_TIMEOUT_MS` or
461
461
  `approvalTimeoutMs` in the `/run` body.
462
462
 
463
+ While a run is active, `GET`/`POST /control` still answers without waiting for
464
+ it to finish: `{"action":"status"}` returns the current run/plan/queue state,
465
+ `{"action":"explain"}` adds a one-line plain-language summary, and
466
+ `{"action":"enqueue","input":"..."}` accepts a new request without touching the
467
+ active plan. A queued request starts automatically as soon as the workspace
468
+ goes idle — either because the enqueue call itself found the workspace free,
469
+ or because the run in progress finished and drained the next queued item.
470
+
471
+ `GET /config/profiles` lists the `.wikirc` profiles for a workspace and
472
+ `POST /config/use {"profile":"..."}` switches the active one — the same
473
+ switch as the shell's `/config use`, rejected with 409 while a run is active.
474
+ The manager is the source of truth for which profile is active; `llm-wiki
475
+ serve`'s config-profile picker mirrors whatever the manager reports rather
476
+ than tracking its own state.
477
+
463
478
  ### Starting external agents
464
479
 
465
480
  Start CME, documents, and mailer once for all workspaces:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.8.2",
3
+ "version": "0.9.3",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -8,6 +8,8 @@ import { createAgentGraph } from '../agent/graph.js';
8
8
  import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
9
9
  import { runShell } from '../shell/repl.js';
10
10
  import { runChecks } from '../core/startupCheck.js';
11
+ import { applySessionWikircProfile } from '../core/sessionConfig.js';
12
+ import { listWikircProfiles } from '../core/wikirc.js';
11
13
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
12
14
  import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
13
15
  import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
@@ -512,11 +514,14 @@ async function runRuntime(argv, agent) {
512
514
  };
513
515
  }
514
516
  emitRuntimeLog(context.session, manual ? 'runtime: manual resume completed' : 'runtime: recovery completed');
517
+ const controlStarted = pollingActivities.length === 0
518
+ ? serverHandle?.drainControl?.(context) === true
519
+ : false;
515
520
  return {
516
521
  workspace: context.workspace ?? workspace ?? null,
517
522
  resumed: true,
518
523
  interrupted: 0,
519
- mode: pollingActivities.length > 0 ? 'activity_poll' : 'context',
524
+ mode: controlStarted ? 'control_queue' : pollingActivities.length > 0 ? 'activity_poll' : 'context',
520
525
  };
521
526
  } catch (err) {
522
527
  const interrupted = store.interruptRuns({ workspace });
@@ -629,6 +634,33 @@ async function runRuntime(argv, agent) {
629
634
  const context = await getWorkspaceContext(workspace);
630
635
  return context.approvalManager?.approve({ runId, itemId, approvalId }) ?? { approved: false };
631
636
  },
637
+ configProfiles: async (context) => {
638
+ const profiles = listWikircProfiles(context.session.workspacePath);
639
+ return {
640
+ profiles: profiles.map((profile) => profile.name),
641
+ active: context.session.wikirc?.profile ?? null,
642
+ items: profiles.map((profile) => ({
643
+ name: profile.name,
644
+ fileName: profile.fileName,
645
+ default: Boolean(profile.default),
646
+ })),
647
+ };
648
+ },
649
+ useConfigProfile: async (context, profile) => {
650
+ const { summary, config } = applySessionWikircProfile(context.session, profile);
651
+ await refreshMcpRuntimeStatus(context.session);
652
+ dispatchAgentEvent(context.session, createAgentEvent('runtime_log', {
653
+ origin: 'runtime',
654
+ payload: { message: `runtime: config profile switched to ${context.session.wikirc?.profile ?? profile}` },
655
+ }));
656
+ return {
657
+ ok: true,
658
+ active: context.session.wikirc?.profile ?? profile,
659
+ fileName: context.session.wikirc?.fileName ?? null,
660
+ summary,
661
+ config,
662
+ };
663
+ },
632
664
  token: auth.token,
633
665
  });
634
666
  const recovery = await recoverRuntime();
@@ -1,7 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
- import { createLlmClientFromWikiConfig } from '../agent/llm.js';
5
4
  import { composeServices, listServices, runWikiCli, serviceLogs, serviceStates, startService, stopService } from '../core/compose.js';
6
5
  import {
7
6
  applyMcpRuntimeStatus,
@@ -26,10 +25,10 @@ import {
26
25
  } from '../core/jobQueue.js';
27
26
  import {
28
27
  listWikircProfiles,
29
- loadWikircProfile,
30
28
  resolveWikircProfile,
31
29
  summarizeWikircConfig,
32
30
  } from '../core/wikirc.js';
31
+ import { applySessionWikircProfile } from '../core/sessionConfig.js';
33
32
  import { deleteWorkspaceAndFiles, startAgents, stopAgents } from '../core/wikiSetup.js';
34
33
  import {
35
34
  cleanDocumentUploads,
@@ -546,25 +545,6 @@ function loadWorkspaceSystemPrompt(workspacePath) {
546
545
  return existsSync(promptPath) ? readFileSync(promptPath, 'utf8').trim() || null : null;
547
546
  }
548
547
 
549
- function loadSessionWikirc(session, profileName = 'default') {
550
- if (!session.workspacePath) {
551
- throw new Error('No workspace loaded. Use /use <workspace>.');
552
- }
553
- const loaded = loadWikircProfile(session.workspacePath, profileName);
554
- session.wikirc = {
555
- profile: loaded.profile.name,
556
- fileName: loaded.profile.fileName,
557
- path: loaded.profile.path,
558
- };
559
- session.wikircConfig = loaded.config;
560
- session.language = loaded.config?.language ?? null;
561
- session.llm = createLlmClientFromWikiConfig(loaded.config);
562
- if (session.mcp?.production) {
563
- session.mcp.production.activeConfigPath = loaded.profile.fileName;
564
- }
565
- return summarizeWikircConfig(loaded.profile, loaded.config);
566
- }
567
-
568
548
  function clearWorkspaceSession(session) {
569
549
  session.workspace = null;
570
550
  session.workspacePath = null;
@@ -720,7 +700,7 @@ export async function handleSlashCommand(line, context) {
720
700
  context.session.systemPrompt = loadWorkspaceSystemPrompt(workspace.workspacePath);
721
701
  try {
722
702
  step(`Workspace: loading ${workspace.name} config…`);
723
- const summary = loadSessionWikirc(context.session, 'default');
703
+ const { summary } = applySessionWikircProfile(context.session, 'default');
724
704
  step(`Workspace: discovering ${workspace.name} MCP tools…`);
725
705
  await refreshMcpRuntimeStatus(context.session);
726
706
  return {
@@ -759,7 +739,7 @@ export async function handleSlashCommand(line, context) {
759
739
  return { output: 'Usage: /config use <default|name>' };
760
740
  }
761
741
  try {
762
- const summary = loadSessionWikirc(context.session, profileName);
742
+ const { summary } = applySessionWikircProfile(context.session, profileName);
763
743
  await refreshMcpRuntimeStatus(context.session);
764
744
  return {
765
745
  output: [
@@ -12,6 +12,8 @@ const SESSION_PROJECTION_EVENTS = new Set([
12
12
  'run_approved',
13
13
  'tool_pending_approval',
14
14
  'tool_approved',
15
+ 'control_enqueued',
16
+ 'control_started',
15
17
  'run_done',
16
18
  'run_error',
17
19
  'run_cancelled',
@@ -101,6 +103,7 @@ function createProjectionState() {
101
103
  evaluation: null,
102
104
  replans: [],
103
105
  approvals: [],
106
+ controlQueue: [],
104
107
  summary: null,
105
108
  status: 'idle',
106
109
  };
@@ -116,6 +119,7 @@ function publicProjection(state) {
116
119
  evaluation: state.evaluation ? { ...state.evaluation } : null,
117
120
  replans: state.replans.map((replan) => ({ ...replan, plan: [...(replan.plan ?? [])] })),
118
121
  approvals: state.approvals.map((approval) => ({ ...approval })),
122
+ controlQueue: state.controlQueue.map((item) => ({ ...item })),
119
123
  summary: state.summary,
120
124
  status: state.status,
121
125
  };
@@ -124,6 +128,7 @@ function publicProjection(state) {
124
128
  export function applyAgentProjectionToSession(session, projection) {
125
129
  session.headlessPlan = projection.plan ? projection.plan.map((step) => ({ ...step })) : null;
126
130
  session.activities = Object.fromEntries((projection.activities ?? []).map((activity) => [activity.key, { ...activity }]));
131
+ session.controlQueue = (projection.controlQueue ?? []).map((item) => ({ ...item }));
127
132
  const production = (projection.activities ?? []).filter((activity) => activity.source === 'production').at(-1);
128
133
  session.productionActivity = production ? {
129
134
  jobId: production.id,
@@ -226,14 +231,36 @@ function applyEvent(state, event) {
226
231
  case 'run_done':
227
232
  state.status = 'done';
228
233
  finishPendingPlanSteps(state.plan);
234
+ finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'done', event.ts);
229
235
  return;
230
236
  case 'run_cancelled':
231
237
  state.status = 'cancelled';
232
238
  state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
239
+ finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'cancelled', event.ts);
233
240
  return;
234
241
  case 'run_error':
235
242
  state.status = 'error';
236
243
  state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
244
+ finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'failed', event.ts);
245
+ return;
246
+ case 'control_enqueued':
247
+ upsertControlItem(state.controlQueue, {
248
+ id: event.payload?.id ?? event.id,
249
+ workspace: event.workspace ?? event.payload?.workspace ?? null,
250
+ input: String(event.payload?.input ?? ''),
251
+ status: 'queued',
252
+ createdAt: event.payload?.createdAt ?? event.ts,
253
+ updatedAt: event.ts,
254
+ });
255
+ return;
256
+ case 'control_started':
257
+ upsertControlItem(state.controlQueue, {
258
+ id: event.payload?.id ?? null,
259
+ runId: event.runId ?? event.payload?.runId ?? null,
260
+ status: 'running',
261
+ startedAt: event.payload?.startedAt ?? event.ts,
262
+ updatedAt: event.ts,
263
+ });
237
264
  return;
238
265
  case 'runtime_log':
239
266
  state.logs.push(String(event.payload?.message ?? ''));
@@ -244,6 +271,32 @@ function applyEvent(state, event) {
244
271
  }
245
272
  }
246
273
 
274
+ function upsertById(list, next) {
275
+ const index = list.findIndex((item) => item.id === next.id);
276
+ if (index === -1) {
277
+ list.push(next);
278
+ return;
279
+ }
280
+ list[index] = {
281
+ ...list[index],
282
+ ...next,
283
+ };
284
+ }
285
+
286
+ function upsertControlItem(queue, next) {
287
+ if (!next.id) return;
288
+ upsertById(queue, next);
289
+ }
290
+
291
+ function finishControlByRun(queue, runId, status, finishedAt) {
292
+ if (!runId) return;
293
+ const item = queue.find((entry) => entry.runId === runId && entry.status === 'running');
294
+ if (!item) return;
295
+ item.status = status;
296
+ item.finishedAt = finishedAt;
297
+ item.updatedAt = finishedAt;
298
+ }
299
+
247
300
  function appendAssistantDelta(state, delta) {
248
301
  if (!delta) return;
249
302
  const last = state.conversation.at(-1);
@@ -279,15 +332,7 @@ function finishToolCall(state, payload = {}) {
279
332
  }
280
333
 
281
334
  function upsertApproval(state, next) {
282
- const index = state.approvals.findIndex((approval) => approval.id === next.id);
283
- if (index === -1) {
284
- state.approvals.push(next);
285
- return;
286
- }
287
- state.approvals[index] = {
288
- ...state.approvals[index],
289
- ...next,
290
- };
335
+ upsertById(state.approvals, next);
291
336
  }
292
337
 
293
338
  function finishPendingPlanSteps(plan) {
@@ -210,6 +210,37 @@ test('reduceAgentEvents: approvals move from pending to approved', () => {
210
210
  assert.deepEqual(projection.approvals[0].plan, ['Build']);
211
211
  });
212
212
 
213
+ test('reduceAgentEvents: control queue is event sourced and follows run status', () => {
214
+ const projection = reduceAgentEvents([
215
+ createAgentEvent('control_enqueued', {
216
+ origin: 'runtime',
217
+ workspace: 'docs',
218
+ payload: {
219
+ id: 'control-1',
220
+ workspace: 'docs',
221
+ input: 'Run after current task',
222
+ createdAt: '2026-01-01T00:00:00.000Z',
223
+ },
224
+ }),
225
+ createAgentEvent('control_started', {
226
+ origin: 'runtime',
227
+ runId: 'run-control-1',
228
+ workspace: 'docs',
229
+ payload: { id: 'control-1', runId: 'run-control-1' },
230
+ }),
231
+ createAgentEvent('run_done', {
232
+ origin: 'runtime',
233
+ runId: 'run-control-1',
234
+ workspace: 'docs',
235
+ }),
236
+ ]);
237
+
238
+ assert.equal(projection.controlQueue.length, 1);
239
+ assert.equal(projection.controlQueue[0].id, 'control-1');
240
+ assert.equal(projection.controlQueue[0].status, 'done');
241
+ assert.equal(projection.controlQueue[0].runId, 'run-control-1');
242
+ });
243
+
213
244
  test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
214
245
  const projection = reduceAgentEvents([
215
246
  createAgentEvent('activity_upserted', {
package/src/core/mcp.js CHANGED
@@ -230,7 +230,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
230
230
  params: {
231
231
  protocolVersion: '2025-06-18',
232
232
  capabilities: {},
233
- clientInfo: { name: 'wiki-manager', version: '0.8.2' },
233
+ clientInfo: { name: 'wiki-manager', version: '0.9.3' },
234
234
  },
235
235
  }),
236
236
  });
@@ -0,0 +1,24 @@
1
+ import { createLlmClientFromWikiConfig } from '../agent/llm.js';
2
+ import { loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
3
+
4
+ export function applySessionWikircProfile(session, profileName = 'default') {
5
+ if (!session.workspacePath) {
6
+ throw new Error('No workspace loaded. Use /use <workspace>.');
7
+ }
8
+ const loaded = loadWikircProfile(session.workspacePath, profileName);
9
+ session.wikirc = {
10
+ profile: loaded.profile.name,
11
+ fileName: loaded.profile.fileName,
12
+ path: loaded.profile.path,
13
+ };
14
+ session.wikircConfig = loaded.config;
15
+ session.language = loaded.config?.language ?? null;
16
+ session.llm = createLlmClientFromWikiConfig(loaded.config);
17
+ if (session.mcp?.production) {
18
+ session.mcp.production.activeConfigPath = loaded.profile.fileName;
19
+ }
20
+ return {
21
+ summary: summarizeWikircConfig(loaded.profile, loaded.config),
22
+ config: loaded.config,
23
+ };
24
+ }
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
4
  import { runtimeTokenFromEnv } from './auth.js';
4
5
 
5
6
  export function startRuntimeServer({
@@ -13,6 +14,8 @@ export function startRuntimeServer({
13
14
  cancel,
14
15
  resume,
15
16
  approve,
17
+ configProfiles,
18
+ useConfigProfile,
16
19
  } = {}) {
17
20
  const clients = new Set();
18
21
  const defaultContext = { workspace: null, session, running: false, currentAbortController: null };
@@ -56,6 +59,72 @@ export function startRuntimeServer({
56
59
  sendJson(response, 200, { events: store.listEvents({ workspace }) });
57
60
  return;
58
61
  }
62
+ if (request.method === 'GET' && url.pathname === '/control') {
63
+ const workspace = workspaceFromUrl(url);
64
+ const context = await resolveContext({ workspace });
65
+ sendJson(response, 200, controlStatus(context, store));
66
+ return;
67
+ }
68
+ if (request.method === 'POST' && url.pathname === '/control') {
69
+ const { body, context } = await resolveBodyContext(request, url);
70
+ const action = String(body.action ?? 'status').trim().toLowerCase();
71
+ if (action === 'status') {
72
+ sendJson(response, 200, controlStatus(context, store));
73
+ return;
74
+ }
75
+ if (action === 'explain') {
76
+ const status = controlStatus(context, store);
77
+ sendJson(response, 200, { ...status, explanation: explainControlState(status) });
78
+ return;
79
+ }
80
+ if (action === 'enqueue') {
81
+ const input = String(body.input ?? body.prompt ?? body.request ?? '').trim();
82
+ if (!input) {
83
+ sendJson(response, 400, { error: 'Missing input.' });
84
+ return;
85
+ }
86
+ const item = enqueueControlRequest(context, input);
87
+ void startNextControlRequest(context);
88
+ sendJson(response, 202, {
89
+ accepted: true,
90
+ item,
91
+ ...controlStatus(context, store),
92
+ });
93
+ return;
94
+ }
95
+ sendJson(response, 400, { error: 'Unsupported control action.' });
96
+ return;
97
+ }
98
+ if (request.method === 'GET' && url.pathname === '/config/profiles') {
99
+ if (typeof configProfiles !== 'function') {
100
+ sendJson(response, 501, { error: 'Config profiles are not supported.' });
101
+ return;
102
+ }
103
+ const workspace = workspaceFromUrl(url);
104
+ const context = await resolveContext({ workspace });
105
+ const result = await configProfiles(context);
106
+ sendJson(response, 200, result);
107
+ return;
108
+ }
109
+ if (request.method === 'POST' && url.pathname === '/config/use') {
110
+ if (typeof useConfigProfile !== 'function') {
111
+ sendJson(response, 501, { error: 'Config profile switching is not supported.' });
112
+ return;
113
+ }
114
+ const { body, context } = await resolveBodyContext(request, url);
115
+ if (context.running) {
116
+ sendJson(response, 409, { error: 'Cannot switch config while a runtime run is active.' });
117
+ return;
118
+ }
119
+ const profile = String(body.profile ?? '').trim();
120
+ if (!profile) {
121
+ sendJson(response, 400, { error: 'Missing profile.' });
122
+ return;
123
+ }
124
+ const result = await useConfigProfile(context, profile);
125
+ sendJson(response, 200, result);
126
+ return;
127
+ }
59
128
  if (request.method === 'GET' && url.pathname === '/events/stream') {
60
129
  const workspace = workspaceFromUrl(url);
61
130
  const context = workspace ? await resolveContext({ workspace }) : null;
@@ -72,9 +141,7 @@ export function startRuntimeServer({
72
141
  return;
73
142
  }
74
143
  if (request.method === 'POST' && url.pathname === '/run') {
75
- const body = await readJson(request);
76
- const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
77
- const context = await resolveContext({ workspace });
144
+ const { body, context } = await resolveBodyContext(request, url);
78
145
  if (context.running) {
79
146
  sendJson(response, 409, { error: 'A runtime run is already active.' });
80
147
  return;
@@ -85,21 +152,8 @@ export function startRuntimeServer({
85
152
  sendJson(response, 400, { error: 'Missing input.' });
86
153
  return;
87
154
  }
88
- const runId = randomUUID();
89
- const runWorkspace = context.workspace ?? workspace ?? null;
90
- context.running = true;
91
- context.currentAbortController = new AbortController();
92
- const runBody = { ...body, workspace: runWorkspace, runId };
93
- const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
94
- runPromise
95
- .catch((err) => {
96
- context.session?._onRuntimeError?.(err);
97
- })
98
- .finally(() => {
99
- context.running = false;
100
- context.currentAbortController = null;
101
- });
102
- sendJson(response, 202, { accepted: true, runId, workspace: runWorkspace });
155
+ const accepted = startRuntimeRun(context, body);
156
+ sendJson(response, 202, accepted);
103
157
  } catch (err) {
104
158
  context.running = false;
105
159
  context.currentAbortController = null;
@@ -153,6 +207,7 @@ export function startRuntimeServer({
153
207
  host,
154
208
  port: typeof address === 'object' && address ? address.port : port,
155
209
  publish,
210
+ drainControl: (context) => startNextControlRequest(context),
156
211
  close: () => new Promise((closeResolve, closeReject) => {
157
212
  for (const client of clients) client.response.end();
158
213
  clients.clear();
@@ -165,6 +220,54 @@ export function startRuntimeServer({
165
220
  async function resolveContext({ workspace = null } = {}) {
166
221
  return resolvedGetContext(workspace);
167
222
  }
223
+
224
+ // Shared by POST handlers that take a JSON body carrying an optional
225
+ // `workspace` field: read the body, resolve the target workspace (body
226
+ // wins over the `?workspace=` query param), then resolve its context.
227
+ async function resolveBodyContext(request, url) {
228
+ const body = await readJson(request);
229
+ const workspace = workspaceFromBody(body) ?? workspaceFromUrl(url);
230
+ const context = await resolveContext({ workspace });
231
+ return { body, workspace, context };
232
+ }
233
+
234
+ function startRuntimeRun(context, body, { controlItemId = null } = {}) {
235
+ const runId = randomUUID();
236
+ const runWorkspace = context.workspace ?? body.workspace ?? null;
237
+ context.running = true;
238
+ context.currentAbortController = new AbortController();
239
+ const runBody = { ...body, workspace: runWorkspace, runId };
240
+ if (controlItemId) {
241
+ dispatchAgentEvent(context.session, createAgentEvent('control_started', {
242
+ origin: 'runtime',
243
+ runId,
244
+ workspace: runWorkspace,
245
+ payload: { id: controlItemId, runId },
246
+ }));
247
+ }
248
+ const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
249
+ runPromise
250
+ .catch((err) => {
251
+ context.session?._onRuntimeError?.(err);
252
+ })
253
+ .finally(() => {
254
+ context.running = false;
255
+ context.currentAbortController = null;
256
+ void startNextControlRequest(context);
257
+ });
258
+ return { accepted: true, runId, workspace: runWorkspace };
259
+ }
260
+
261
+ function startNextControlRequest(context) {
262
+ if (!context?.session || context.running) return false;
263
+ const item = controlQueueFor(context.session).find((entry) => entry.status === 'queued');
264
+ if (!item) return false;
265
+ startRuntimeRun(context, {
266
+ input: item.input,
267
+ workspace: item.workspace ?? context.workspace ?? null,
268
+ }, { controlItemId: item.id });
269
+ return true;
270
+ }
168
271
  }
169
272
 
170
273
  function workspaceFromUrl(url) {
@@ -177,6 +280,66 @@ function workspaceFromBody(body) {
177
280
  return workspace == null ? null : String(workspace).trim() || null;
178
281
  }
179
282
 
283
+ function controlStatus(context, store) {
284
+ const workspace = context?.workspace ?? context?.session?.workspace ?? null;
285
+ const state = store.getState(context?.session ?? null, { workspace });
286
+ return {
287
+ ok: true,
288
+ workspace,
289
+ status: context?.running ? 'running' : state.status ?? 'idle',
290
+ running: Boolean(context?.running),
291
+ plan: Array.isArray(state.plan) ? state.plan : [],
292
+ queue: Array.isArray(state.queue) ? state.queue : [],
293
+ controlQueue: controlQueueFor(context?.session),
294
+ approvals: Array.isArray(state.approvals) ? state.approvals : [],
295
+ summary: state.summary ?? null,
296
+ };
297
+ }
298
+
299
+ function explainControlState(status) {
300
+ if (status.running) {
301
+ const runningStep = status.plan.find((step) => step.status === 'running');
302
+ return runningStep
303
+ ? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.`
304
+ : 'Runtime run is active. No current plan step is available yet.';
305
+ }
306
+ const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
307
+ if (pendingApproval) {
308
+ return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
309
+ }
310
+ const queued = status.controlQueue.filter((item) => item.status === 'queued');
311
+ if (queued.length > 0) {
312
+ return `${queued.length} control request${queued.length === 1 ? '' : 's'} queued. They are not applied to the active plan automatically.`;
313
+ }
314
+ if (status.plan.some((step) => step.status === 'pending')) {
315
+ return 'Runtime is idle with pending plan steps visible from the last run.';
316
+ }
317
+ return 'Runtime is idle.';
318
+ }
319
+
320
+ function controlQueueFor(session) {
321
+ return Array.isArray(session?.controlQueue) ? session.controlQueue : [];
322
+ }
323
+
324
+ function enqueueControlRequest(context, input) {
325
+ const now = new Date().toISOString();
326
+ const item = {
327
+ id: `control-${randomUUID()}`,
328
+ workspace: context?.workspace ?? context?.session?.workspace ?? null,
329
+ type: 'run_request',
330
+ input,
331
+ status: 'queued',
332
+ createdAt: now,
333
+ updatedAt: now,
334
+ };
335
+ dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
336
+ origin: 'runtime',
337
+ workspace: item.workspace,
338
+ payload: item,
339
+ }));
340
+ return item;
341
+ }
342
+
180
343
  function isAuthorized(request, token) {
181
344
  if (!token) return true;
182
345
  const authorization = request.headers.authorization ?? '';
@@ -306,3 +306,325 @@ test('runtime server exposes approval endpoint', async (t) => {
306
306
  await handle.close();
307
307
  }
308
308
  });
309
+
310
+ test('runtime server exposes control status and explanation', async (t) => {
311
+ const session = {
312
+ workspace: 'juno',
313
+ controlQueue: [{ id: 'control-1', workspace: 'juno', status: 'queued', input: 'later' }],
314
+ };
315
+ let handle;
316
+ try {
317
+ handle = await startRuntimeServer({
318
+ host: '127.0.0.1',
319
+ port: 0,
320
+ store: {
321
+ dbPath: ':memory:',
322
+ getState: () => ({
323
+ status: 'idle',
324
+ plan: [{ step: 1, description: 'Check status', status: 'pending' }],
325
+ queue: [],
326
+ controlQueue: session.controlQueue,
327
+ approvals: [],
328
+ summary: null,
329
+ }),
330
+ listEvents: () => [],
331
+ },
332
+ getContext: async () => ({
333
+ workspace: 'juno',
334
+ session,
335
+ running: false,
336
+ currentAbortController: null,
337
+ }),
338
+ run: async () => {},
339
+ });
340
+ } catch (err) {
341
+ if (err?.code === 'EPERM') {
342
+ t.skip('network listen is not permitted in this sandbox');
343
+ return;
344
+ }
345
+ throw err;
346
+ }
347
+
348
+ try {
349
+ const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`);
350
+ assert.equal(status.status, 200);
351
+ const statusBody = await status.json();
352
+ assert.equal(statusBody.status, 'idle');
353
+ assert.equal(statusBody.running, false);
354
+ assert.equal(statusBody.controlQueue[0].id, 'control-1');
355
+
356
+ const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({ action: 'explain' }),
360
+ });
361
+ assert.equal(explain.status, 200);
362
+ assert.match((await explain.json()).explanation, /control request/);
363
+ } finally {
364
+ await handle.close();
365
+ }
366
+ });
367
+
368
+ test('runtime server control enqueue emits events but does not patch an active plan or start a run', async (t) => {
369
+ const session = {
370
+ workspace: 'juno',
371
+ controlQueue: [],
372
+ };
373
+ const events = [];
374
+ session._onAgentEvent = (event) => events.push(event);
375
+ let runCount = 0;
376
+ let handle;
377
+ try {
378
+ handle = await startRuntimeServer({
379
+ host: '127.0.0.1',
380
+ port: 0,
381
+ store: {
382
+ dbPath: ':memory:',
383
+ getState: () => ({
384
+ status: 'running',
385
+ plan: [{ step: 1, description: 'Active step', status: 'running' }],
386
+ queue: [],
387
+ controlQueue: session.controlQueue,
388
+ approvals: [],
389
+ summary: null,
390
+ }),
391
+ listEvents: () => [],
392
+ },
393
+ getContext: async () => ({
394
+ workspace: 'juno',
395
+ session,
396
+ running: true,
397
+ currentAbortController: new AbortController(),
398
+ }),
399
+ run: async () => { runCount += 1; },
400
+ });
401
+ } catch (err) {
402
+ if (err?.code === 'EPERM') {
403
+ t.skip('network listen is not permitted in this sandbox');
404
+ return;
405
+ }
406
+ throw err;
407
+ }
408
+
409
+ try {
410
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
411
+ method: 'POST',
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify({ action: 'enqueue', input: 'run this after current work' }),
414
+ });
415
+ assert.equal(response.status, 202);
416
+ const body = await response.json();
417
+ assert.equal(body.accepted, true);
418
+ assert.equal(body.item.status, 'queued');
419
+ assert.equal(body.controlQueue.length, 1);
420
+ assert.equal(body.plan[0].description, 'Active step');
421
+ assert.equal(session.controlQueue[0].input, 'run this after current work');
422
+ assert.equal(events.filter((event) => event.type === 'control_enqueued').length, 1);
423
+ assert.equal(runCount, 0);
424
+ } finally {
425
+ await handle.close();
426
+ }
427
+ });
428
+
429
+ test('runtime server drains queued control requests when idle', async (t) => {
430
+ const session = {
431
+ workspace: 'juno',
432
+ controlQueue: [],
433
+ };
434
+ const events = [];
435
+ session._onAgentEvent = (event) => events.push(event);
436
+ let receivedBody = null;
437
+ let handle;
438
+ try {
439
+ handle = await startRuntimeServer({
440
+ host: '127.0.0.1',
441
+ port: 0,
442
+ store: {
443
+ dbPath: ':memory:',
444
+ getState: () => ({
445
+ status: 'idle',
446
+ plan: [],
447
+ queue: [],
448
+ controlQueue: session.controlQueue,
449
+ approvals: [],
450
+ summary: null,
451
+ }),
452
+ listEvents: () => [],
453
+ },
454
+ getContext: async () => ({
455
+ workspace: 'juno',
456
+ session,
457
+ running: false,
458
+ currentAbortController: null,
459
+ }),
460
+ run: async (_context, body) => {
461
+ receivedBody = body;
462
+ },
463
+ });
464
+ } catch (err) {
465
+ if (err?.code === 'EPERM') {
466
+ t.skip('network listen is not permitted in this sandbox');
467
+ return;
468
+ }
469
+ throw err;
470
+ }
471
+
472
+ try {
473
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
474
+ method: 'POST',
475
+ headers: { 'Content-Type': 'application/json' },
476
+ body: JSON.stringify({ action: 'enqueue', input: 'run from control queue' }),
477
+ });
478
+ assert.equal(response.status, 202);
479
+ await new Promise((resolve) => setImmediate(resolve));
480
+ assert.equal(receivedBody.input, 'run from control queue');
481
+ assert.equal(receivedBody.workspace, 'juno');
482
+ assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
483
+ assert.equal(session.controlQueue[0].status, 'running');
484
+ assert.equal(session.controlQueue[0].runId, receivedBody.runId);
485
+ assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started']);
486
+ } finally {
487
+ await handle.close();
488
+ }
489
+ });
490
+
491
+ test('runtime server handle drains a pre-existing hydrated control request', async (t) => {
492
+ const session = {
493
+ workspace: 'juno',
494
+ controlQueue: [{ id: 'control-existing', workspace: 'juno', status: 'queued', input: 'resume queued control' }],
495
+ };
496
+ const events = [];
497
+ session._onAgentEvent = (event) => events.push(event);
498
+ const context = {
499
+ workspace: 'juno',
500
+ session,
501
+ running: false,
502
+ currentAbortController: null,
503
+ };
504
+ let receivedBody = null;
505
+ let handle;
506
+ try {
507
+ handle = await startRuntimeServer({
508
+ host: '127.0.0.1',
509
+ port: 0,
510
+ store: {
511
+ dbPath: ':memory:',
512
+ getState: () => ({ status: 'idle' }),
513
+ listEvents: () => [],
514
+ },
515
+ getContext: async () => context,
516
+ run: async (_context, body) => {
517
+ receivedBody = body;
518
+ },
519
+ });
520
+ } catch (err) {
521
+ if (err?.code === 'EPERM') {
522
+ t.skip('network listen is not permitted in this sandbox');
523
+ return;
524
+ }
525
+ throw err;
526
+ }
527
+
528
+ try {
529
+ assert.equal(handle.drainControl(context), true);
530
+ await new Promise((resolve) => setImmediate(resolve));
531
+ assert.equal(receivedBody.input, 'resume queued control');
532
+ assert.equal(session.controlQueue[0].status, 'running');
533
+ assert.equal(events[0].type, 'control_started');
534
+ } finally {
535
+ await handle.close();
536
+ }
537
+ });
538
+
539
+ test('runtime server exposes config profile list and switch endpoints', async (t) => {
540
+ const context = {
541
+ workspace: 'juno',
542
+ session: { workspace: 'juno', wikirc: { profile: 'default' } },
543
+ running: false,
544
+ currentAbortController: null,
545
+ };
546
+ let switchedProfile = null;
547
+ let handle;
548
+ try {
549
+ handle = await startRuntimeServer({
550
+ host: '127.0.0.1',
551
+ port: 0,
552
+ store: {
553
+ dbPath: ':memory:',
554
+ getState: () => ({ status: 'idle' }),
555
+ listEvents: () => [],
556
+ },
557
+ getContext: async () => context,
558
+ run: async () => {},
559
+ configProfiles: async () => ({ profiles: ['default', 'vpn'], active: context.session.wikirc.profile }),
560
+ useConfigProfile: async (_context, profile) => {
561
+ switchedProfile = profile;
562
+ context.session.wikirc.profile = profile;
563
+ return { ok: true, active: profile, config: { llm: { model: 'model-vpn' } } };
564
+ },
565
+ });
566
+ } catch (err) {
567
+ if (err?.code === 'EPERM') {
568
+ t.skip('network listen is not permitted in this sandbox');
569
+ return;
570
+ }
571
+ throw err;
572
+ }
573
+
574
+ try {
575
+ const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=juno`);
576
+ assert.equal(profiles.status, 200);
577
+ assert.deepEqual(await profiles.json(), { profiles: ['default', 'vpn'], active: 'default' });
578
+
579
+ const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
580
+ method: 'POST',
581
+ headers: { 'Content-Type': 'application/json' },
582
+ body: JSON.stringify({ profile: 'vpn' }),
583
+ });
584
+ assert.equal(use.status, 200);
585
+ assert.equal(switchedProfile, 'vpn');
586
+ assert.deepEqual(await use.json(), { ok: true, active: 'vpn', config: { llm: { model: 'model-vpn' } } });
587
+ } finally {
588
+ await handle.close();
589
+ }
590
+ });
591
+
592
+ test('runtime server rejects config switching while a run is active', async (t) => {
593
+ let handle;
594
+ try {
595
+ handle = await startRuntimeServer({
596
+ host: '127.0.0.1',
597
+ port: 0,
598
+ store: {
599
+ dbPath: ':memory:',
600
+ getState: () => ({ status: 'running' }),
601
+ listEvents: () => [],
602
+ },
603
+ getContext: async () => ({
604
+ workspace: 'juno',
605
+ session: { workspace: 'juno' },
606
+ running: true,
607
+ currentAbortController: null,
608
+ }),
609
+ run: async () => {},
610
+ useConfigProfile: async () => ({ ok: true }),
611
+ });
612
+ } catch (err) {
613
+ if (err?.code === 'EPERM') {
614
+ t.skip('network listen is not permitted in this sandbox');
615
+ return;
616
+ }
617
+ throw err;
618
+ }
619
+
620
+ try {
621
+ const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
622
+ method: 'POST',
623
+ headers: { 'Content-Type': 'application/json' },
624
+ body: JSON.stringify({ profile: 'vpn' }),
625
+ });
626
+ assert.equal(response.status, 409);
627
+ } finally {
628
+ await handle.close();
629
+ }
630
+ });
@@ -366,6 +366,9 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
366
366
  ...projection,
367
367
  runs: listRuns({ workspace }),
368
368
  queue: projectQueue(projection.plan, rawQueue, { workspace }),
369
+ controlQueue: Array.isArray(projection.controlQueue)
370
+ ? projection.controlQueue.filter((item) => !workspace || item.workspace === workspace || !item.workspace).map((item) => ({ ...item }))
371
+ : [],
369
372
  eventsCursor: session?.agentEvents?.at(-1)?.sequence ?? events?.at(-1)?.sequence ?? lastEventSequence,
370
373
  };
371
374
  }
@@ -412,6 +412,31 @@ test('runtime state exposes queue as blocked jobs and pending plan steps', () =>
412
412
  store.close();
413
413
  });
414
414
 
415
+ test('runtime store persists and replays control queue events without mixing MCP queue', () => {
416
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
417
+ const store = openRuntimeStore({ stateDir });
418
+ store.persistEvent(createAgentEvent('control_enqueued', {
419
+ origin: 'runtime',
420
+ workspace: 'docs',
421
+ payload: { id: 'control-docs', workspace: 'docs', input: 'later' },
422
+ }));
423
+ store.persistEvent(createAgentEvent('control_enqueued', {
424
+ origin: 'runtime',
425
+ workspace: 'juno',
426
+ payload: { id: 'control-juno', workspace: 'juno', input: 'other' },
427
+ }));
428
+ store.close();
429
+
430
+ const reopened = openRuntimeStore({ stateDir });
431
+ const session = { activities: {}, headlessPlan: null };
432
+ reopened.hydrateSession(session, { workspace: 'docs' });
433
+ const state = reopened.getState(session, { workspace: 'docs' });
434
+ assert.deepEqual(state.controlQueue.map((item) => item.id), ['control-docs']);
435
+ assert.deepEqual(state.queue, []);
436
+ assert.deepEqual(session.controlQueue.map((item) => item.id), ['control-docs']);
437
+ reopened.close();
438
+ });
439
+
415
440
  test('runtime store identifies recoverable workspaces and interrupts stale runs', () => {
416
441
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
417
442
  const store = openRuntimeStore({ stateDir });