@dotdrelle/wiki-manager 0.6.47 → 0.8.2

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 (48) hide show
  1. package/.env.example +20 -0
  2. package/README.md +35 -1
  3. package/bin/wiki-manager.js +2 -2
  4. package/docker-compose.yml +2 -0
  5. package/mcp.endpoints.example.json +13 -0
  6. package/package.json +2 -2
  7. package/src/agent/graph.js +101 -15
  8. package/src/agent/graph.test.js +145 -0
  9. package/src/cli/wiki-manager.js +459 -131
  10. package/src/commands/slash.js +1 -1
  11. package/src/core/activity.js +14 -0
  12. package/src/core/agentEvents.js +148 -6
  13. package/src/core/agentEvents.test.js +145 -4
  14. package/src/core/agentLoop.js +167 -0
  15. package/src/core/agentLoop.test.js +85 -0
  16. package/src/core/cacert.js +4 -3
  17. package/src/core/dockerCompose.test.js +14 -0
  18. package/src/core/documentIntake.test.js +7 -7
  19. package/src/core/env.js +6 -0
  20. package/src/core/jobQueue.js +47 -16
  21. package/src/core/mcp.js +120 -10
  22. package/src/core/mcp.test.js +121 -1
  23. package/src/core/plan.js +33 -0
  24. package/src/core/queueStore.js +27 -0
  25. package/src/core/queueStore.test.js +32 -0
  26. package/src/core/wikiWorkspace.test.js +24 -0
  27. package/src/core/workspaces.js +8 -1
  28. package/src/runtime/approvals.js +113 -0
  29. package/src/runtime/auth.js +35 -0
  30. package/src/runtime/auth.test.js +29 -0
  31. package/src/runtime/client.js +154 -0
  32. package/src/runtime/lifecycle.js +84 -0
  33. package/src/runtime/queueStore.js +6 -0
  34. package/src/runtime/runner.js +413 -0
  35. package/src/runtime/runner.test.js +270 -0
  36. package/src/runtime/server.js +216 -0
  37. package/src/runtime/server.test.js +308 -0
  38. package/src/runtime/store.js +431 -0
  39. package/src/runtime/store.test.js +437 -0
  40. package/src/runtime/supervisor.js +104 -0
  41. package/src/runtime/supervisor.test.js +240 -0
  42. package/src/shell/RightPane.tsx +1 -1
  43. package/src/shell/repl.js +148 -18
  44. package/src/shell/repl.test.js +38 -0
  45. package/src/shell/tui.tsx +4 -1
  46. package/src/shell/useAgent.ts +20 -2
  47. package/src/shell/useSession.ts +153 -13
  48. package/wiki-workspace +221 -22
@@ -4,19 +4,22 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { loadManagerEnv } from '../core/env.js';
6
6
  loadManagerEnv();
7
- import { createAgentGraph, buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
8
- import { handleSlashCommand, printHelp, printVersion } from '../commands/slash.js';
7
+ import { createAgentGraph } from '../agent/graph.js';
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
11
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
12
- import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
13
- import { extractHeadlessPlan, syncActivitiesToPlan, formatPlanStatus, formatCompletedActivities } from '../core/plan.js';
12
+ import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
13
+ import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
14
14
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
15
+ import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
16
+ // Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
17
+ // They are imported dynamically so the shell / TUI path never loads them.
15
18
 
16
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
20
  const packageJsonPath = resolve(__dirname, '../../package.json');
18
21
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
19
- const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'clear', 'chat', 'agent'];
22
+ const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'clear', 'chat', 'agent', 'approve'];
20
23
 
21
24
  function valueAfter(argv, flag) {
22
25
  const index = argv.indexOf(flag);
@@ -39,6 +42,7 @@ function createSession() {
39
42
  packageJson,
40
43
  conversations: { __global__: [] },
41
44
  activities: {},
45
+ jobQueue: [],
42
46
  productionActivity: null,
43
47
  headlessPlan: null,
44
48
  };
@@ -57,20 +61,6 @@ async function writeHeadlessLog(session, lines, explicitPath) {
57
61
  return logPath;
58
62
  }
59
63
 
60
- function activitySnapshot(session) {
61
- return new Set(sessionActivities(session).map((a) => a.key));
62
- }
63
-
64
- function newNonTerminalActivities(snapshotBefore, session) {
65
- return sessionActivities(session).filter((a) => !snapshotBefore.has(a.key) && !a.terminal);
66
- }
67
-
68
- function terminalFailures(activities) {
69
- return activities.filter(
70
- (a) => a.terminal && ['failed', 'error', 'cancelled', 'canceled'].includes(String(a.status).toLowerCase()),
71
- );
72
- }
73
-
74
64
 
75
65
  async function runHeadlessActivityLoop(session, log, { wait, timeoutMs }) {
76
66
  if (!wait) return { exitCode: 0, completed: [], timedOut: false };
@@ -150,118 +140,54 @@ async function runHeadlessActivityLoop(session, log, { wait, timeoutMs }) {
150
140
  return { exitCode: 1, completed, timedOut: true };
151
141
  }
152
142
 
153
- async function runHeadlessAgentTurn(agent, session, input, log, messages = []) {
154
- session.packageJson = packageJson;
155
- let streamedContent = '';
156
- session._onStream = (delta) => { streamedContent += delta; };
157
- session._onStreamReset = () => { streamedContent = ''; };
158
- let result;
159
- try {
160
- result = await agent.invoke({ input, session, messages });
161
- } finally {
162
- delete session._onStream;
163
- delete session._onStreamReset;
164
- }
165
- if (result.streamedInline) {
166
- return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
167
- }
168
- if (result.response != null) return result.response;
169
- if (result.readyToStream && session.llm?.stream) {
170
- const { system, messages: streamMessages = [] } = result.streamContext ?? {};
171
- let content = '';
172
- for await (const delta of session.llm.stream({
173
- system: system ?? buildAgentSystemPrompt({ input, session }),
174
- messages: streamMessages,
175
- })) {
176
- content += delta;
177
- }
178
- return content.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
179
- }
180
- return buildLimitedAgentResponse({ input, session });
181
- }
182
-
183
143
  async function runHeadlessAgenticLoop(agent, session, initialInput, log, { timeoutMs, maxTurns }) {
184
- const conversationHistory = [];
185
- let currentInput = initialInput;
186
-
187
- for (let turn = 1; turn <= maxTurns; turn++) {
188
- log.push(`agentic-loop: turn ${turn}/${maxTurns}`);
189
- console.log(`[headless] Agent turn ${turn}/${maxTurns}…`);
190
-
191
- const snapshot = activitySnapshot(session);
192
-
193
- const response = await runHeadlessAgentTurn(agent, session, currentInput, log, conversationHistory);
194
- log.push(`agentic-loop: turn ${turn} response:`);
195
- log.push(response);
196
- console.log(response);
197
-
198
- conversationHistory.push(
199
- { role: 'user', content: currentInput },
200
- { role: 'assistant', content: response },
201
- );
202
-
203
- // session.headlessPlan is set authoritatively by wiki__plan_set tool call.
204
- // Fall back to text extraction only if the agent didn't call the tool.
205
- if (turn === 1 && session.headlessPlan === null) {
206
- const extractedPlan = extractHeadlessPlan(response);
207
- if (extractedPlan) {
208
- dispatchAgentEvent(session, createAgentEvent('plan_set', {
209
- origin: 'llm',
210
- payload: { steps: extractedPlan },
211
- }));
212
- log.push(`agentic-loop: plan extracted from text (${session.headlessPlan.length} steps, fallback)`);
213
- }
214
- } else if (turn === 1 && session.headlessPlan) {
215
- log.push(`agentic-loop: plan set via tool (${session.headlessPlan.length} steps)`);
216
- }
217
-
218
- const newPending = newNonTerminalActivities(snapshot, session);
219
- if (newPending.length === 0) {
220
- const pendingSteps = (session.headlessPlan ?? []).filter((s) => s.status === 'pending');
221
- if (pendingSteps.length === 0) {
222
- log.push('agentic-loop: no new non-terminal activities — plan complete');
223
- console.log('[headless] Plan complete.');
224
- return { exitCode: 0 };
225
- }
144
+ const result = await runAgenticLoop(agent, session, initialInput, {
145
+ timeoutMs,
146
+ maxTurns,
147
+ waitForActivities: async (turnSession, _startedActivities, waitOptions) => {
148
+ const waitResult = await runHeadlessActivityLoop(turnSession, log, { wait: true, timeoutMs: waitOptions.timeoutMs });
149
+ return {
150
+ ok: waitResult.exitCode === 0 && !waitResult.timedOut,
151
+ ...waitResult,
152
+ };
153
+ },
154
+ onTurnStart: ({ turn, maxTurns: totalTurns }) => {
155
+ log.push(`agentic-loop: turn ${turn}/${totalTurns}`);
156
+ console.log(`[headless] Agent turn ${turn}/${totalTurns}…`);
157
+ },
158
+ onTurnResponse: ({ turn, response }) => {
159
+ log.push(`agentic-loop: turn ${turn} response:`);
160
+ log.push(response);
161
+ console.log(response);
162
+ },
163
+ onPlanExtracted: ({ steps }) => {
164
+ log.push(`agentic-loop: plan extracted from text (${steps.length} steps, fallback)`);
165
+ },
166
+ onPlanAlreadySet: ({ steps }) => {
167
+ log.push(`agentic-loop: plan set via tool (${steps.length} steps)`);
168
+ },
169
+ onComplete: () => {
170
+ log.push('agentic-loop: no new non-terminal activities — plan complete');
171
+ console.log('[headless] Plan complete.');
172
+ },
173
+ onPendingSteps: ({ pendingSteps }) => {
226
174
  log.push(`agentic-loop: no new async activity, ${pendingSteps.length} pending step(s) remain`);
227
175
  if (session.headlessPlan) log.push(`agentic-loop: plan status:\n${formatPlanStatus(session.headlessPlan)}`);
228
- currentInput = [
229
- 'Original task:',
230
- initialInput,
231
- '',
232
- `Plan status:\n${formatPlanStatus(session.headlessPlan)}`,
233
- '',
234
- 'No new background activity was started in the previous turn.',
235
- 'Continue the original plan. Start the next pending step only.',
236
- 'If required information is missing and cannot be inferred, stop with a clear blocker.',
237
- ].join('\n');
238
- continue;
239
- }
240
-
241
- log.push(`agentic-loop: ${newPending.length} new job(s) started, waiting…`);
242
- const { exitCode, completed, timedOut } = await runHeadlessActivityLoop(session, log, { wait: true, timeoutMs });
243
-
244
- if (timedOut || exitCode !== 0) return { exitCode };
245
-
246
- const summary = formatCompletedActivities(completed);
247
- log.push(`agentic-loop: completed activities:\n${summary}`);
248
- if (session.headlessPlan) log.push(`agentic-loop: plan status:\n${formatPlanStatus(session.headlessPlan)}`);
249
- currentInput = [
250
- 'Original task:',
251
- initialInput,
252
- '',
253
- session.headlessPlan ? `Plan status:\n${formatPlanStatus(session.headlessPlan)}\n` : null,
254
- 'Completed activities:',
255
- summary,
256
- '',
257
- 'Continue the original plan. Start the next required step only.',
258
- 'If all steps are complete, provide the final summary.',
259
- ].filter(Boolean).join('\n');
260
- }
261
-
262
- log.push(`agentic-loop: max turns (${maxTurns}) reached without completing`);
263
- console.error(`[headless] Max agent turns (${maxTurns}) reached.`);
264
- return { exitCode: 1 };
176
+ },
177
+ onActivitiesStarted: ({ activities }) => {
178
+ log.push(`agentic-loop: ${activities.length} new job(s) started, waiting…`);
179
+ },
180
+ onActivitiesCompleted: ({ summary }) => {
181
+ log.push(`agentic-loop: completed activities:\n${summary}`);
182
+ if (session.headlessPlan) log.push(`agentic-loop: plan status:\n${formatPlanStatus(session.headlessPlan)}`);
183
+ },
184
+ onMaxTurns: ({ maxTurns: totalTurns }) => {
185
+ log.push(`agentic-loop: max turns (${totalTurns}) reached without completing`);
186
+ console.error(`[headless] Max agent turns (${totalTurns}) reached.`);
187
+ },
188
+ });
189
+
190
+ return { exitCode: result.ok ? 0 : (result.waitResult?.exitCode ?? 1) };
265
191
  }
266
192
 
267
193
  async function runHeadless(argv, agent) {
@@ -326,7 +252,7 @@ async function runHeadless(argv, agent) {
326
252
  if (useAgenticLoop) {
327
253
  ({ exitCode } = await runHeadlessAgenticLoop(agent, session, input, log, { timeoutMs, maxTurns }));
328
254
  } else {
329
- const response = await runHeadlessAgentTurn(agent, session, input, log);
255
+ const response = await runAgentTurn(agent, session, input);
330
256
  log.push('response:');
331
257
  log.push(response);
332
258
  console.log(response);
@@ -345,7 +271,393 @@ async function runHeadless(argv, agent) {
345
271
  }
346
272
  }
347
273
 
274
+ async function runRuntime(argv, agent) {
275
+ if (argv.includes('--help') || argv.includes('-h')) {
276
+ console.log([
277
+ 'Usage: wiki-manager runtime [--host <host>] [--port <port>] [--state-dir <dir>]',
278
+ '',
279
+ 'Runs the local agentic runtime used by wiki-manager Shell and llm-wiki serve.',
280
+ '',
281
+ 'Defaults:',
282
+ ' --host 0.0.0.0',
283
+ ' --port 7788',
284
+ ' --state-dir .wiki-manager',
285
+ ].join('\n'));
286
+ return;
287
+ }
288
+ const { defaultRuntimeStateDir, openRuntimeStore, RECOVERABLE_QUEUE_STATUSES } = await import('../runtime/store.js');
289
+ const { startRuntimeServer } = await import('../runtime/server.js');
290
+ const { emitRuntimeLog, startActivitySupervisor } = await import('../runtime/supervisor.js');
291
+ const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
292
+ const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
293
+ const { createApprovalManager } = await import('../runtime/approvals.js');
294
+ const { runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
295
+
296
+ const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0';
297
+ const port = Number(valueAfter(argv, '--port') ?? process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788);
298
+ const stateDir = valueAfter(argv, '--state-dir') ?? defaultRuntimeStateDir();
299
+ const auth = resolveRuntimeAuthToken({ host, stateDir });
300
+ if (auth.token) process.env.WIKI_MANAGER_RUNTIME_TOKEN = auth.token;
301
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
302
+ throw new Error(`Invalid runtime port: ${port}`);
303
+ }
304
+
305
+ const store = openRuntimeStore({ stateDir });
306
+ let serverHandle = null;
307
+ const contexts = new Map();
308
+
309
+ async function getWorkspaceContext(workspaceName = null) {
310
+ const requestedWorkspace = workspaceName ? String(workspaceName).trim() : null;
311
+ const key = requestedWorkspace ?? '__default__';
312
+ if (contexts.has(key)) return contexts.get(key);
313
+
314
+ const pending = (async () => {
315
+ const session = createSession();
316
+ session.headless = true;
317
+ session.chatMode = false;
318
+ session.packageJson = packageJson;
319
+
320
+ if (requestedWorkspace) {
321
+ const result = await handleSlashCommand(`/use ${requestedWorkspace}`, { packageJson, session });
322
+ if (!session.workspacePath) throw new Error(result.output || `Workspace not loaded: ${requestedWorkspace}`);
323
+ }
324
+ const workspace = session.workspace ?? requestedWorkspace ?? null;
325
+ store.hydrateSession(session, { workspace });
326
+ session.queueStore = createSqliteQueueStore(store, session, { workspace });
327
+
328
+ const context = {
329
+ workspace,
330
+ session,
331
+ supervisor: null,
332
+ running: false,
333
+ currentAbortController: null,
334
+ approvalManager: null,
335
+ };
336
+ session._onAgentEvent = (event) => {
337
+ store.persistEvent(event);
338
+ serverHandle?.publish(event);
339
+ };
340
+ session._onRuntimeError = (err) => {
341
+ const message = err instanceof Error ? err.message : String(err);
342
+ dispatchAgentEvent(session, createAgentEvent('run_error', {
343
+ origin: 'runtime',
344
+ payload: { message, workspace },
345
+ }));
346
+ };
347
+ context.approvalManager = createApprovalManager(session, {
348
+ defaultTimeoutMs: Number.isFinite(Number(process.env.WIKI_MANAGER_APPROVAL_TIMEOUT_MS))
349
+ ? Math.max(1, Number(process.env.WIKI_MANAGER_APPROVAL_TIMEOUT_MS))
350
+ : undefined,
351
+ });
352
+ session._requestApproval = (request) => context.approvalManager.requestApproval(request);
353
+ context.supervisor = startActivitySupervisor(session);
354
+ contexts.set(key, context);
355
+ if (workspace && workspace !== key) contexts.set(workspace, context);
356
+ return context;
357
+ })();
358
+ contexts.set(key, pending);
359
+ pending.catch(() => {
360
+ if (contexts.get(key) === pending) contexts.delete(key);
361
+ });
362
+ return pending;
363
+ }
364
+
365
+ function recoveryMcpGaps(context) {
366
+ const gaps = [];
367
+ const session = context.session;
368
+ for (const activity of sessionActivities(session)) {
369
+ if (activity.terminal || !activity.poll?.server) continue;
370
+ const endpoint = session.mcp?.[activity.poll.server];
371
+ if (endpoint?.status !== 'connected') gaps.push(activity.poll.server);
372
+ }
373
+ for (const item of session.queueStore?.list?.() ?? []) {
374
+ const status = String(item.status ?? '').toLowerCase();
375
+ if (!RECOVERABLE_QUEUE_STATUSES.includes(status)) continue;
376
+ const endpoint = session.mcp?.[item.server ?? 'production'];
377
+ if (endpoint?.status !== 'connected') gaps.push(item.server ?? 'production');
378
+ }
379
+ return [...new Set(gaps)];
380
+ }
381
+
382
+ function activeNonTerminalActivities(session) {
383
+ return sessionActivities(session).filter((activity) => !activity.terminal);
384
+ }
385
+
386
+ function activePollingActivities(session) {
387
+ return activeNonTerminalActivities(session).filter((activity) => activity.poll);
388
+ }
389
+
390
+ function buildRecoveryPrompt(run, session) {
391
+ const conversation = session.agentProjection?.conversation ?? [];
392
+ const recentConversation = conversation
393
+ .slice(-8)
394
+ .map((message) => `${message.role}: ${message.content}`)
395
+ .join('\n');
396
+ return [
397
+ 'Resume an interrupted runtime run.',
398
+ '',
399
+ 'Original task:',
400
+ run.input ?? '(unknown)',
401
+ '',
402
+ session.headlessPlan ? `Current plan:\n${formatPlanStatus(session.headlessPlan)}` : null,
403
+ recentConversation ? `Recent conversation:\n${recentConversation}` : null,
404
+ '',
405
+ 'Continue from the current plan state. Start only the next pending step.',
406
+ 'If the work is already complete, provide a concise final summary.',
407
+ ].filter(Boolean).join('\n');
408
+ }
409
+
410
+ function startRecoveredAgenticRun(context, run) {
411
+ if (context.running) return false;
412
+ const session = context.session;
413
+ const supervisor = context.supervisor;
414
+ const runId = run.id;
415
+ const input = buildRecoveryPrompt(run, session);
416
+ context.running = true;
417
+ context.currentAbortController = new AbortController();
418
+ session._currentRunIdentity = {
419
+ runId,
420
+ turnId: `${runId}:resume-0`,
421
+ workspace: context.workspace,
422
+ };
423
+ session._abortSignal = context.currentAbortController.signal;
424
+ supervisor?.setRunSignal(context.currentAbortController.signal);
425
+ session._onStep = (message) => emitRuntimeLog(session, message);
426
+ emitRuntimeLog(session, `runtime: resuming interrupted run ${runId}`);
427
+
428
+ runRuntimeAgenticWorkflow(agent, session, run.input ?? input, {
429
+ initialInput: input,
430
+ signal: context.currentAbortController.signal,
431
+ timeoutMs: 3600 * 1000,
432
+ maxTurns: 20,
433
+ runId,
434
+ pollBusy: supervisor?.pollBusy,
435
+ })
436
+ .catch((err) => {
437
+ if (err?.name === 'AbortError') {
438
+ dispatchAgentEvent(session, createAgentEvent('run_cancelled', {
439
+ origin: 'runtime',
440
+ runId,
441
+ payload: { runId, message: 'Recovered runtime run cancelled.' },
442
+ }));
443
+ return;
444
+ }
445
+ dispatchAgentEvent(session, createAgentEvent('run_error', {
446
+ origin: 'runtime',
447
+ runId,
448
+ payload: {
449
+ runId,
450
+ message: err instanceof Error ? err.message : String(err),
451
+ },
452
+ }));
453
+ })
454
+ .finally(() => {
455
+ context.running = false;
456
+ context.currentAbortController = null;
457
+ supervisor?.setRunSignal(null);
458
+ delete session._abortSignal;
459
+ delete session._onStep;
460
+ delete session._currentRunIdentity;
461
+ });
462
+
463
+ return true;
464
+ }
465
+
466
+ async function recoverWorkspace(workspace, { manual = false } = {}) {
467
+ try {
468
+ const context = await getWorkspaceContext(workspace);
469
+ await refreshMcpRuntimeStatus(context.session);
470
+ const gaps = recoveryMcpGaps(context);
471
+ if (gaps.length > 0) {
472
+ const interrupted = store.interruptRuns({ workspace: context.workspace });
473
+ return {
474
+ workspace: context.workspace ?? workspace ?? null,
475
+ resumed: false,
476
+ interrupted,
477
+ reason: `MCP unavailable: ${gaps.join(', ')}`,
478
+ };
479
+ }
480
+ const recoverableRuns = store.listRecoverableRuns({ workspace: context.workspace });
481
+ const runningRun = recoverableRuns.find((run) => run.status === 'running');
482
+ const activeActivities = activeNonTerminalActivities(context.session);
483
+ const pollingActivities = activePollingActivities(context.session);
484
+ if (runningRun && activeActivities.length === 0) {
485
+ if (!runningRun.input) {
486
+ const interrupted = store.interruptRuns({ workspace: context.workspace });
487
+ return {
488
+ workspace: context.workspace ?? workspace ?? null,
489
+ resumed: false,
490
+ interrupted,
491
+ reason: 'Missing original run input.',
492
+ };
493
+ }
494
+ const started = startRecoveredAgenticRun(context, runningRun);
495
+ return {
496
+ workspace: context.workspace ?? workspace ?? null,
497
+ resumed: started,
498
+ interrupted: 0,
499
+ mode: 'agentic_loop',
500
+ };
501
+ }
502
+ if (runningRun && runningRun.input && pollingActivities.length > 0) {
503
+ context.session._onActivitiesTerminal = () => {
504
+ startRecoveredAgenticRun(context, runningRun);
505
+ };
506
+ emitRuntimeLog(context.session, `runtime: recovery watching ${pollingActivities.length} active activity(s)`);
507
+ return {
508
+ workspace: context.workspace ?? workspace ?? null,
509
+ resumed: true,
510
+ interrupted: 0,
511
+ mode: 'activity_poll_then_resume',
512
+ };
513
+ }
514
+ emitRuntimeLog(context.session, manual ? 'runtime: manual resume completed' : 'runtime: recovery completed');
515
+ return {
516
+ workspace: context.workspace ?? workspace ?? null,
517
+ resumed: true,
518
+ interrupted: 0,
519
+ mode: pollingActivities.length > 0 ? 'activity_poll' : 'context',
520
+ };
521
+ } catch (err) {
522
+ const interrupted = store.interruptRuns({ workspace });
523
+ return {
524
+ workspace: workspace ?? null,
525
+ resumed: false,
526
+ interrupted,
527
+ reason: err instanceof Error ? err.message : String(err),
528
+ };
529
+ }
530
+ }
531
+
532
+ async function recoverRuntime({ workspace = null, manual = false } = {}) {
533
+ const workspaces = workspace ? [workspace] : store.listRecoverableWorkspaces();
534
+ const results = await Promise.all(workspaces.map((item) => recoverWorkspace(item, { manual })));
535
+ return {
536
+ resumed: results.filter((result) => result.resumed).length,
537
+ interrupted: results.reduce((sum, result) => sum + Number(result.interrupted ?? 0), 0),
538
+ workspaces: results,
539
+ };
540
+ }
541
+
542
+ async function executeRun(context, body, { signal } = {}) {
543
+ const session = context.session;
544
+ const supervisor = context.supervisor;
545
+ const input = String(body.input ?? body.prompt ?? '').trim();
546
+ const workspace = body.workspace ? String(body.workspace).trim() : null;
547
+ const timeoutMs = (Number.isFinite(Number(body.timeout)) ? Math.max(1, Number(body.timeout)) : 3600) * 1000;
548
+ const maxTurns = Number.isFinite(Number(body.maxTurns)) ? Math.max(1, Number(body.maxTurns)) : 20;
549
+ const maxReplans = Number.isFinite(Number(body.replans)) ? Math.max(0, Math.floor(Number(body.replans))) : undefined;
550
+ const runId = String(body.runId);
551
+ try {
552
+ if (workspace && session.workspace !== workspace) {
553
+ const result = await handleSlashCommand(`/use ${workspace}`, { packageJson, session });
554
+ if (!session.workspacePath) throw new Error(result.output || `Workspace not loaded: ${workspace}`);
555
+ }
556
+ context.workspace = session.workspace ?? workspace ?? context.workspace ?? null;
557
+ session._currentRunIdentity = {
558
+ runId,
559
+ turnId: `${runId}:turn-0`,
560
+ workspace: context.workspace,
561
+ };
562
+ dispatchAgentEvent(session, createAgentEvent('run_started', {
563
+ origin: 'runtime',
564
+ runId,
565
+ payload: { input, workspace: session._currentRunIdentity.workspace },
566
+ }));
567
+ dispatchAgentEvent(session, createAgentEvent('user_message', {
568
+ origin: 'user',
569
+ runId,
570
+ payload: { content: input },
571
+ }));
572
+ session._abortSignal = signal ?? null;
573
+ session._runApprovalRequired = body.requireApproval === true;
574
+ session._runApprovalResolved = false;
575
+ session._approvalTimeoutMs = Number.isFinite(Number(body.approvalTimeoutMs))
576
+ ? Math.max(1, Number(body.approvalTimeoutMs))
577
+ : undefined;
578
+ supervisor?.setRunSignal(signal);
579
+ session._onStep = (message) => emitRuntimeLog(session, message);
580
+ await runRuntimeAgenticWorkflow(agent, session, input, {
581
+ signal,
582
+ timeoutMs,
583
+ maxTurns,
584
+ runId,
585
+ pollBusy: supervisor?.pollBusy,
586
+ evaluate: body.evaluate !== false,
587
+ ...(maxReplans === undefined ? {} : { maxReplans }),
588
+ });
589
+ } catch (err) {
590
+ if (err?.name === 'AbortError') {
591
+ dispatchAgentEvent(session, createAgentEvent('run_cancelled', {
592
+ origin: 'runtime',
593
+ runId,
594
+ payload: {
595
+ runId,
596
+ message: 'Agent run cancelled.',
597
+ },
598
+ }));
599
+ return;
600
+ }
601
+ dispatchAgentEvent(session, createAgentEvent('run_error', {
602
+ origin: 'runtime',
603
+ runId,
604
+ payload: {
605
+ runId,
606
+ message: err instanceof Error ? err.message : String(err),
607
+ },
608
+ }));
609
+ } finally {
610
+ supervisor?.setRunSignal(null);
611
+ delete session._abortSignal;
612
+ delete session._onStep;
613
+ delete session._currentRunIdentity;
614
+ delete session._runApprovalRequired;
615
+ delete session._runApprovalResolved;
616
+ delete session._approvalTimeoutMs;
617
+ }
618
+ }
619
+
620
+ serverHandle = await startRuntimeServer({
621
+ host,
622
+ port,
623
+ store,
624
+ getContext: getWorkspaceContext,
625
+ run: executeRun,
626
+ cancel: (context) => emitRuntimeLog(context.session, 'runtime: cancel requested'),
627
+ resume: ({ workspace }) => recoverRuntime({ workspace, manual: true }),
628
+ approve: async ({ workspace, runId, itemId, approvalId }) => {
629
+ const context = await getWorkspaceContext(workspace);
630
+ return context.approvalManager?.approve({ runId, itemId, approvalId }) ?? { approved: false };
631
+ },
632
+ token: auth.token,
633
+ });
634
+ const recovery = await recoverRuntime();
635
+
636
+ console.log(`wiki-manager runtime listening on http://${host}:${port}`);
637
+ console.log(`runtime state: ${store.dbPath}`);
638
+ if (recovery.resumed > 0 || recovery.interrupted > 0) {
639
+ console.log(`runtime recovery: resumed=${recovery.resumed} interrupted=${recovery.interrupted}`);
640
+ }
641
+ if (auth.tokenPath) console.log(`runtime token: ${auth.tokenPath}`);
642
+
643
+ const shutdown = async () => {
644
+ await Promise.all([...new Set(contexts.values())].map(async (v) => { (await v).supervisor?.stop(); }));
645
+ await serverHandle.close();
646
+ store.close();
647
+ process.exit(0);
648
+ };
649
+ process.once('SIGINT', shutdown);
650
+ process.once('SIGTERM', shutdown);
651
+ await new Promise(() => {});
652
+ }
653
+
348
654
  export async function runCli(argv) {
655
+ if (argv[0] === 'runtime') {
656
+ const agent = createAgentGraph();
657
+ await runRuntime(argv.slice(1), agent);
658
+ return;
659
+ }
660
+
349
661
  if (argv.includes('--setup-wizard')) {
350
662
  if (!process.versions.bun) {
351
663
  throw new Error('Setup wizard requires Bun. Run: bun ./bin/wiki-manager.js --setup-wizard');
@@ -396,9 +708,25 @@ export async function runCli(argv) {
396
708
  const { runOpenTuiShell, runStartupWizard } = await import('../shell/tui.tsx');
397
709
  const gaps = await runChecks();
398
710
  if (gaps.length > 0) await runStartupWizard(gaps);
399
- await runOpenTuiShell({ agent, packageJson });
711
+ let runtime = null;
712
+ try {
713
+ const { ensureRuntime } = await import('../runtime/lifecycle.js');
714
+ runtime = await ensureRuntime();
715
+ } catch (err) {
716
+ console.error(`Runtime unavailable: ${err instanceof Error ? err.message : String(err)}`);
717
+ }
718
+ await runOpenTuiShell({ agent, packageJson, runtime });
400
719
  return;
401
720
  }
402
721
 
403
- await runShell({ agent, packageJson });
722
+ let runtime = null;
723
+ if (process.stdin.isTTY && process.stdout.isTTY) {
724
+ try {
725
+ const { ensureRuntime } = await import('../runtime/lifecycle.js');
726
+ runtime = await ensureRuntime();
727
+ } catch (err) {
728
+ console.error(`Runtime unavailable: ${err instanceof Error ? err.message : String(err)}`);
729
+ }
730
+ }
731
+ await runShell({ agent, packageJson, runtime });
404
732
  }
@@ -490,7 +490,7 @@ function publishDocumentActivity(session, activity) {
490
490
  return publishPayloadActivity(session, { _activity: activity }, { server: 'documents', tool: 'documents_convert_to_markdown' });
491
491
  }
492
492
 
493
- async function refreshMcpRuntimeStatus(session) {
493
+ export async function refreshMcpRuntimeStatus(session) {
494
494
  session.mcp = buildMcpStatus(session);
495
495
  if (!session.workspacePath) return null;
496
496
  try {
@@ -222,6 +222,20 @@ export function formatActivitySummary(source, action, resultText) {
222
222
  return usefulLine ? `${source}.${action}: ${usefulLine.slice(0, 120)}` : null;
223
223
  }
224
224
 
225
+ export function activitySnapshot(session) {
226
+ return new Set(sessionActivities(session).map((a) => a.key));
227
+ }
228
+
229
+ export function newNonTerminalActivities(snapshotBefore, session) {
230
+ return sessionActivities(session).filter((a) => !snapshotBefore.has(a.key) && !a.terminal);
231
+ }
232
+
233
+ export function terminalFailures(activities) {
234
+ return activities.filter(
235
+ (a) => a.terminal && ['failed', 'error', 'cancelled', 'canceled'].includes(String(a.status).toLowerCase()),
236
+ );
237
+ }
238
+
225
239
  export function formatActivityError(source, action, err) {
226
240
  const message = err instanceof Error ? err.message : String(err);
227
241
  const lines = message