@dotdrelle/wiki-manager 0.7.3 → 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.
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { readFileSync } from 'node:fs';
3
2
  import { mkdir, writeFile } from 'node:fs/promises';
4
3
  import { dirname, join, resolve } from 'node:path';
@@ -6,7 +5,7 @@ import { fileURLToPath } from 'node:url';
6
5
  import { loadManagerEnv } from '../core/env.js';
7
6
  loadManagerEnv();
8
7
  import { createAgentGraph } from '../agent/graph.js';
9
- import { handleSlashCommand, printHelp, printVersion } from '../commands/slash.js';
8
+ import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
10
9
  import { runShell } from '../shell/repl.js';
11
10
  import { runChecks } from '../core/startupCheck.js';
12
11
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
@@ -20,7 +19,7 @@ import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
20
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
20
  const packageJsonPath = resolve(__dirname, '../../package.json');
22
21
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
23
- 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'];
24
23
 
25
24
  function valueAfter(argv, flag) {
26
25
  const index = argv.indexOf(flag);
@@ -286,12 +285,13 @@ async function runRuntime(argv, agent) {
286
285
  ].join('\n'));
287
286
  return;
288
287
  }
289
- const { defaultRuntimeStateDir, openRuntimeStore } = await import('../runtime/store.js');
288
+ const { defaultRuntimeStateDir, openRuntimeStore, RECOVERABLE_QUEUE_STATUSES } = await import('../runtime/store.js');
290
289
  const { startRuntimeServer } = await import('../runtime/server.js');
291
290
  const { emitRuntimeLog, startActivitySupervisor } = await import('../runtime/supervisor.js');
292
291
  const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
293
292
  const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
294
- const { runRuntimeAgenticLoop } = await import('../runtime/runner.js');
293
+ const { createApprovalManager } = await import('../runtime/approvals.js');
294
+ const { runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
295
295
 
296
296
  const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0';
297
297
  const port = Number(valueAfter(argv, '--port') ?? process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788);
@@ -302,45 +302,267 @@ async function runRuntime(argv, agent) {
302
302
  throw new Error(`Invalid runtime port: ${port}`);
303
303
  }
304
304
 
305
- const session = createSession();
306
- session.headless = true;
307
- session.chatMode = false;
308
- session.packageJson = packageJson;
309
-
310
305
  const store = openRuntimeStore({ stateDir });
311
- store.hydrateSession(session);
312
- session.queueStore = createSqliteQueueStore(store, session);
313
-
314
306
  let serverHandle = null;
315
- session._onAgentEvent = (event) => {
316
- store.persistEvent(event);
317
- serverHandle?.publish(event);
318
- };
319
- session._onRuntimeError = (err) => {
320
- const message = err instanceof Error ? err.message : String(err);
321
- dispatchAgentEvent(session, createAgentEvent('run_error', {
322
- origin: 'runtime',
323
- payload: { message },
324
- }));
325
- };
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
+ }
326
531
 
327
- let supervisor = null;
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
+ }
328
541
 
329
- async function executeRun(body, { signal } = {}) {
542
+ async function executeRun(context, body, { signal } = {}) {
543
+ const session = context.session;
544
+ const supervisor = context.supervisor;
330
545
  const input = String(body.input ?? body.prompt ?? '').trim();
331
546
  const workspace = body.workspace ? String(body.workspace).trim() : null;
332
547
  const timeoutMs = (Number.isFinite(Number(body.timeout)) ? Math.max(1, Number(body.timeout)) : 3600) * 1000;
333
548
  const maxTurns = Number.isFinite(Number(body.maxTurns)) ? Math.max(1, Number(body.maxTurns)) : 20;
334
- const runId = randomUUID();
549
+ const maxReplans = Number.isFinite(Number(body.replans)) ? Math.max(0, Math.floor(Number(body.replans))) : undefined;
550
+ const runId = String(body.runId);
335
551
  try {
336
552
  if (workspace && session.workspace !== workspace) {
337
553
  const result = await handleSlashCommand(`/use ${workspace}`, { packageJson, session });
338
554
  if (!session.workspacePath) throw new Error(result.output || `Workspace not loaded: ${workspace}`);
339
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
+ };
340
562
  dispatchAgentEvent(session, createAgentEvent('run_started', {
341
563
  origin: 'runtime',
342
564
  runId,
343
- payload: { input, workspace },
565
+ payload: { input, workspace: session._currentRunIdentity.workspace },
344
566
  }));
345
567
  dispatchAgentEvent(session, createAgentEvent('user_message', {
346
568
  origin: 'user',
@@ -348,35 +570,22 @@ async function runRuntime(argv, agent) {
348
570
  payload: { content: input },
349
571
  }));
350
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;
351
578
  supervisor?.setRunSignal(signal);
352
579
  session._onStep = (message) => emitRuntimeLog(session, message);
353
- const result = await runRuntimeAgenticLoop(agent, session, input, {
580
+ await runRuntimeAgenticWorkflow(agent, session, input, {
354
581
  signal,
355
582
  timeoutMs,
356
583
  maxTurns,
357
584
  runId,
358
585
  pollBusy: supervisor?.pollBusy,
586
+ evaluate: body.evaluate !== false,
587
+ ...(maxReplans === undefined ? {} : { maxReplans }),
359
588
  });
360
- if (!result.ok) {
361
- dispatchAgentEvent(session, createAgentEvent('run_error', {
362
- origin: 'runtime',
363
- runId,
364
- payload: {
365
- runId,
366
- message: result.timedOut
367
- ? 'Runtime agentic loop timed out.'
368
- : result.maxTurns
369
- ? `Runtime agentic loop reached max turns (${maxTurns}).`
370
- : 'Runtime agentic loop failed.',
371
- },
372
- }));
373
- return;
374
- }
375
- dispatchAgentEvent(session, createAgentEvent('run_done', {
376
- origin: 'runtime',
377
- runId,
378
- payload: { runId },
379
- }));
380
589
  } catch (err) {
381
590
  if (err?.name === 'AbortError') {
382
591
  dispatchAgentEvent(session, createAgentEvent('run_cancelled', {
@@ -401,6 +610,10 @@ async function runRuntime(argv, agent) {
401
610
  supervisor?.setRunSignal(null);
402
611
  delete session._abortSignal;
403
612
  delete session._onStep;
613
+ delete session._currentRunIdentity;
614
+ delete session._runApprovalRequired;
615
+ delete session._runApprovalResolved;
616
+ delete session._approvalTimeoutMs;
404
617
  }
405
618
  }
406
619
 
@@ -408,19 +621,27 @@ async function runRuntime(argv, agent) {
408
621
  host,
409
622
  port,
410
623
  store,
411
- session,
624
+ getContext: getWorkspaceContext,
412
625
  run: executeRun,
413
- cancel: () => emitRuntimeLog(session, 'runtime: cancel requested'),
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
+ },
414
632
  token: auth.token,
415
633
  });
416
- supervisor = startActivitySupervisor(session);
634
+ const recovery = await recoverRuntime();
417
635
 
418
636
  console.log(`wiki-manager runtime listening on http://${host}:${port}`);
419
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
+ }
420
641
  if (auth.tokenPath) console.log(`runtime token: ${auth.tokenPath}`);
421
642
 
422
643
  const shutdown = async () => {
423
- supervisor?.stop();
644
+ await Promise.all([...new Set(contexts.values())].map(async (v) => { (await v).supervisor?.stop(); }));
424
645
  await serverHandle.close();
425
646
  store.close();
426
647
  process.exit(0);
@@ -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 {
@@ -1,11 +1,18 @@
1
1
  import { normalizeActivity } from './activity.js';
2
- import { syncActivitiesToPlan } from './plan.js';
2
+ import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
3
 
4
4
  const SESSION_PROJECTION_EVENTS = new Set([
5
5
  'run_started',
6
6
  'plan_set',
7
7
  'plan_step_updated',
8
8
  'activity_upserted',
9
+ 'run_evaluated',
10
+ 'run_replanned',
11
+ 'run_pending_approval',
12
+ 'run_approved',
13
+ 'tool_pending_approval',
14
+ 'tool_approved',
15
+ 'run_done',
9
16
  'run_error',
10
17
  'run_cancelled',
11
18
  'runtime_log',
@@ -19,9 +26,16 @@ const PLAN_MUTATING_EVENTS = new Set([
19
26
  'plan_set',
20
27
  'plan_step_updated',
21
28
  'activity_upserted',
29
+ 'run_done',
22
30
  ]);
23
31
 
24
- export function createAgentEvent(type, { origin = 'system', payload = {}, runId = null, turnId = null } = {}) {
32
+ export function createAgentEvent(type, {
33
+ origin = 'system',
34
+ payload = {},
35
+ runId = null,
36
+ turnId = null,
37
+ workspace = null,
38
+ } = {}) {
25
39
  return {
26
40
  id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
27
41
  ts: new Date().toISOString(),
@@ -29,12 +43,16 @@ export function createAgentEvent(type, { origin = 'system', payload = {}, runId
29
43
  origin,
30
44
  runId,
31
45
  turnId,
46
+ workspace,
32
47
  payload,
33
48
  };
34
49
  }
35
50
 
36
51
  export function dispatchAgentEvent(session, event) {
37
- const normalized = event.id && event.ts ? event : createAgentEvent(event.type, event);
52
+ const normalized = withSessionRunIdentity(
53
+ event.id && event.ts ? event : createAgentEvent(event.type, event),
54
+ session,
55
+ );
38
56
  const tracksPlan = PLAN_MUTATING_EVENTS.has(normalized.type);
39
57
  const previousPlan = tracksPlan ? JSON.stringify(session.headlessPlan ?? null) : null;
40
58
  session.agentEvents ??= [];
@@ -52,6 +70,17 @@ export function dispatchAgentEvent(session, event) {
52
70
  return normalized;
53
71
  }
54
72
 
73
+ function withSessionRunIdentity(event, session) {
74
+ const identity = session?._currentRunIdentity;
75
+ if (!identity) return event;
76
+ return {
77
+ ...event,
78
+ runId: event.runId ?? identity.runId ?? null,
79
+ turnId: event.turnId ?? identity.turnId ?? null,
80
+ workspace: event.workspace ?? identity.workspace ?? null,
81
+ };
82
+ }
83
+
55
84
  export function reduceAgentEvents(events = []) {
56
85
  const state = createProjectionState();
57
86
 
@@ -69,6 +98,9 @@ function createProjectionState() {
69
98
  plan: null,
70
99
  activities: {},
71
100
  logs: [],
101
+ evaluation: null,
102
+ replans: [],
103
+ approvals: [],
72
104
  summary: null,
73
105
  status: 'idle',
74
106
  };
@@ -81,6 +113,9 @@ function publicProjection(state) {
81
113
  plan: state.plan ? state.plan.map((step) => ({ ...step })) : null,
82
114
  activities: sortedActivities(state.activities).map((activity) => ({ ...activity })),
83
115
  logs: [...state.logs],
116
+ evaluation: state.evaluation ? { ...state.evaluation } : null,
117
+ replans: state.replans.map((replan) => ({ ...replan, plan: [...(replan.plan ?? [])] })),
118
+ approvals: state.approvals.map((approval) => ({ ...approval })),
84
119
  summary: state.summary,
85
120
  status: state.status,
86
121
  };
@@ -103,10 +138,13 @@ function applyEvent(state, event) {
103
138
  switch (event.type) {
104
139
  case 'run_started':
105
140
  state.status = 'running';
106
- state.plan = null;
141
+ state.plan = defaultRunPlan();
107
142
  state.chain = [];
108
143
  state.activities = {};
109
144
  state.logs = [];
145
+ state.evaluation = null;
146
+ state.replans = [];
147
+ state.approvals = [];
110
148
  state.summary = null;
111
149
  return;
112
150
  case 'user_message':
@@ -144,8 +182,50 @@ function applyEvent(state, event) {
144
182
  state.summary = String(event.payload?.content ?? '');
145
183
  if (state.summary) state.conversation.push({ role: 'assistant', content: state.summary });
146
184
  return;
185
+ case 'run_evaluated':
186
+ state.evaluation = {
187
+ ok: event.payload?.ok === true,
188
+ reason: String(event.payload?.reason ?? ''),
189
+ suggestedAction: event.payload?.suggestedAction ?? null,
190
+ runId: event.runId ?? event.payload?.runId ?? null,
191
+ };
192
+ return;
193
+ case 'run_replanned':
194
+ state.replans.push({
195
+ reason: String(event.payload?.reason ?? ''),
196
+ plan: Array.isArray(event.payload?.plan) ? event.payload.plan.map(String) : [],
197
+ replansLeft: Number(event.payload?.replansLeft ?? 0),
198
+ runId: event.runId ?? event.payload?.runId ?? null,
199
+ });
200
+ return;
201
+ case 'run_pending_approval':
202
+ case 'tool_pending_approval':
203
+ upsertApproval(state, {
204
+ id: event.payload?.approvalId ?? event.payload?.runId ?? event.payload?.itemId ?? event.id,
205
+ scope: event.type === 'run_pending_approval' ? 'run' : 'tool',
206
+ status: 'pending_approval',
207
+ runId: event.runId ?? event.payload?.runId ?? null,
208
+ itemId: event.payload?.itemId ?? null,
209
+ reason: event.payload?.reason ?? null,
210
+ tool: event.payload?.tool ?? null,
211
+ plan: event.payload?.plan ?? null,
212
+ createdAt: event.ts,
213
+ });
214
+ return;
215
+ case 'run_approved':
216
+ case 'tool_approved':
217
+ upsertApproval(state, {
218
+ id: event.payload?.approvalId ?? event.payload?.runId ?? event.payload?.itemId ?? event.id,
219
+ scope: event.type === 'run_approved' ? 'run' : 'tool',
220
+ status: 'approved',
221
+ runId: event.runId ?? event.payload?.runId ?? null,
222
+ itemId: event.payload?.itemId ?? null,
223
+ approvedAt: event.ts,
224
+ });
225
+ return;
147
226
  case 'run_done':
148
227
  state.status = 'done';
228
+ finishPendingPlanSteps(state.plan);
149
229
  return;
150
230
  case 'run_cancelled':
151
231
  state.status = 'cancelled';
@@ -198,6 +278,26 @@ function finishToolCall(state, payload = {}) {
198
278
  if (!existing) state.chain.push(step);
199
279
  }
200
280
 
281
+ 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
+ };
291
+ }
292
+
293
+ function finishPendingPlanSteps(plan) {
294
+ for (const step of plan ?? []) {
295
+ if (step.status === 'running' || step.status === 'pending') {
296
+ step.status = 'done';
297
+ }
298
+ }
299
+ }
300
+
201
301
  function upsertActivity(state, rawActivity) {
202
302
  const activity = normalizeActivity(rawActivity);
203
303
  if (!activity) return;
@@ -211,6 +311,10 @@ function upsertActivity(state, rawActivity) {
211
311
 
212
312
  function ensurePlanFromActivityProjection(state, activity) {
213
313
  const actKey = activity.key ?? null;
314
+ if (state.plan?.some((step) => step.owner === 'orchestrator')) {
315
+ attachActivityToExistingPlan(state.plan, activity);
316
+ return;
317
+ }
214
318
  if (state.plan && actKey !== null && state.plan[0]?._activityKey === actKey) return;
215
319
  const steps = activity.plan?.steps;
216
320
  if (Array.isArray(steps) && steps.length > 0) {
@@ -219,6 +323,8 @@ function ensurePlanFromActivityProjection(state, activity) {
219
323
  id: step.id ?? null,
220
324
  description: step.label,
221
325
  status: 'pending',
326
+ owner: 'activity',
327
+ ownerActivityKey: activity.key,
222
328
  _activityKey: activity.key,
223
329
  }));
224
330
  return;
@@ -228,12 +334,16 @@ function ensurePlanFromActivityProjection(state, activity) {
228
334
  id: null,
229
335
  description: activity.label,
230
336
  status: 'pending',
337
+ owner: 'activity',
338
+ ownerActivityKey: activity.key,
231
339
  _activityKey: activity.key,
232
340
  }];
233
341
  }
234
342
 
235
343
  function normalizePlan(steps, payload = {}) {
236
344
  if (!Array.isArray(steps)) return null;
345
+ const owner = payload.owner ?? 'orchestrator';
346
+ const ownerActivityKey = payload.ownerActivityKey ?? payload.activityKey ?? null;
237
347
  return steps.map((raw, i) => {
238
348
  const item = typeof raw === 'string' ? { description: raw } : (raw ?? {});
239
349
  return {
@@ -241,11 +351,21 @@ function normalizePlan(steps, payload = {}) {
241
351
  id: item.id ?? null,
242
352
  description: String(item.description ?? item.label ?? item.name ?? `Step ${i + 1}`),
243
353
  status: item.status ?? 'pending',
354
+ owner: item.owner ?? owner,
355
+ ownerActivityKey: item.ownerActivityKey ?? ownerActivityKey,
244
356
  _activityKey: item._activityKey ?? payload.activityKey ?? null,
245
357
  };
246
358
  });
247
359
  }
248
360
 
361
+ function defaultRunPlan() {
362
+ return [
363
+ { step: 1, id: 'analyze', description: 'Analyze the request', status: 'running', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
364
+ { step: 2, id: 'execute', description: 'Execute the required actions', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
365
+ { step: 3, id: 'verify', description: 'Verify the result', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
366
+ ];
367
+ }
368
+
249
369
  function updatePlanStep(plan, payload) {
250
370
  if (!plan) return;
251
371
  const step = plan.find((item) => item.step === Number(payload.step));