@dotdrelle/wiki-manager 0.6.34 → 0.7.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.
Files changed (42) hide show
  1. package/bin/wiki-manager.js +2 -2
  2. package/docker-compose.yml +25 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +2 -19
  5. package/src/cli/wiki-manager.js +236 -129
  6. package/src/commands/slash.js +17 -1
  7. package/src/commands/slash.test.js +78 -0
  8. package/src/core/activity.js +14 -0
  9. package/src/core/agentEvents.js +24 -2
  10. package/src/core/agentLoop.js +164 -0
  11. package/src/core/agentLoop.test.js +85 -0
  12. package/src/core/cacert.js +4 -3
  13. package/src/core/compose.js +4 -3
  14. package/src/core/dockerCompose.test.js +14 -0
  15. package/src/core/documentIntake.test.js +7 -7
  16. package/src/core/env.js +6 -0
  17. package/src/core/jobQueue.js +18 -4
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/queueStore.js +27 -0
  20. package/src/core/queueStore.test.js +31 -0
  21. package/src/core/startupCheck.js +1 -1
  22. package/src/core/startupCheck.test.js +66 -0
  23. package/src/core/wikirc.js +2 -2
  24. package/src/core/workspaces.js +8 -1
  25. package/src/runtime/auth.js +35 -0
  26. package/src/runtime/auth.test.js +21 -0
  27. package/src/runtime/client.js +108 -0
  28. package/src/runtime/lifecycle.js +60 -0
  29. package/src/runtime/queueStore.js +6 -0
  30. package/src/runtime/runner.js +73 -0
  31. package/src/runtime/server.js +160 -0
  32. package/src/runtime/server.test.js +53 -0
  33. package/src/runtime/store.js +292 -0
  34. package/src/runtime/store.test.js +103 -0
  35. package/src/runtime/supervisor.js +98 -0
  36. package/src/runtime/supervisor.test.js +99 -0
  37. package/src/shell/repl.js +132 -17
  38. package/src/shell/repl.test.js +38 -0
  39. package/src/shell/tui.tsx +4 -1
  40. package/src/shell/useAgent.ts +20 -2
  41. package/src/shell/useSession.ts +146 -11
  42. package/wiki-workspace +39 -30
package/src/shell/repl.js CHANGED
@@ -1,19 +1,21 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { Transform } from 'node:stream';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { stdin as input, stdout as output } from 'node:process';
5
6
  import { marked } from 'marked';
6
7
  import { markedTerminal } from 'marked-terminal';
7
8
  import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
8
9
  import { handleSlashCommand } from '../commands/slash.js';
9
10
  import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
10
- import { extractActivity, sessionActivities } from '../core/activity.js';
11
+ import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
11
12
  import { syncActivitiesToPlan } from '../core/plan.js';
12
13
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
13
14
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
14
15
  import { listSkills } from '../core/skills.js';
15
16
  import { listWikircProfiles } from '../core/wikirc.js';
16
17
  import { listWorkspaces } from '../core/workspaces.js';
18
+ import { fetchRuntimeState, postRuntimeCancel, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
17
19
 
18
20
  marked.use(markedTerminal());
19
21
  // marked-terminal's text renderer extracts token.text (raw string) instead of
@@ -202,6 +204,7 @@ function completionValuesFor(parts, inputBuffer, session) {
202
204
  if (tokenIndex === 0) return slashCompletions(session);
203
205
  if (command === '/new' && tokenIndex === 1) return [];
204
206
  if (command === '/use' && tokenIndex === 1) return workspaceNames();
207
+ if (command === '/use' && tokenIndex === 2) return [];
205
208
  if (command === '/config' && tokenIndex === 1) return ['edit', 'list', 'status', 'use'];
206
209
  if (command === '/config' && (previousToken === 'use' || previousToken === 'edit')) return wikircProfileNames(session);
207
210
  if (command === '/mcp' && tokenIndex === 1) return ['call', 'endpoints', 'status', 'tools'];
@@ -668,14 +671,6 @@ function renderBannerWithMcpPanel(columns, session) {
668
671
 
669
672
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
670
673
 
671
- function parseJsonText(text) {
672
- try {
673
- return JSON.parse(text);
674
- } catch {
675
- return null;
676
- }
677
- }
678
-
679
674
  function pathBaseName(value) {
680
675
  return String(value ?? '').split('/').filter(Boolean).pop() ?? '';
681
676
  }
@@ -737,6 +732,45 @@ function rememberProductionActivity(session, payload) {
737
732
  return true;
738
733
  }
739
734
 
735
+ export function applyRuntimeStateToShellSession(session, state) {
736
+ if (!state || typeof state !== 'object') return false;
737
+ session.agentProjection = {
738
+ conversation: Array.isArray(state.conversation) ? state.conversation.map((message) => ({ ...message })) : [],
739
+ chain: Array.isArray(state.chain) ? state.chain.map((step) => ({ ...step })) : [],
740
+ plan: Array.isArray(state.plan) ? state.plan.map((step) => ({ ...step })) : null,
741
+ activities: Array.isArray(state.activities) ? state.activities.map((activity) => ({ ...activity })) : [],
742
+ logs: Array.isArray(state.logs) ? [...state.logs] : [],
743
+ summary: state.summary ?? null,
744
+ status: state.status ?? 'idle',
745
+ };
746
+ session.headlessPlan = session.agentProjection.plan
747
+ ? session.agentProjection.plan.map((step) => ({ ...step }))
748
+ : null;
749
+ session.activities = Object.fromEntries(
750
+ session.agentProjection.activities.map((activity) => [activity.key, { ...activity }]),
751
+ );
752
+ if (Array.isArray(state.queue)) session.jobQueue = state.queue.map((item) => ({ ...item }));
753
+ const production = session.agentProjection.activities.filter((activity) => activity.source === 'production').at(-1);
754
+ if (production) {
755
+ session.productionActivity = {
756
+ jobId: production.id,
757
+ status: production.status,
758
+ label: production.label,
759
+ terminal: production.terminal,
760
+ updatedAt: production.updatedAt,
761
+ };
762
+ }
763
+ const runtimeConversation = session.agentProjection.conversation;
764
+ if (runtimeConversation.length > 0) {
765
+ session.conversations ??= { [GLOBAL_CONVERSATION_KEY]: [] };
766
+ session.conversations[conversationKey(session)] = runtimeConversation.map((message) => ({
767
+ role: message.role === 'assistant' ? 'donna' : message.role,
768
+ content: String(message.content ?? ''),
769
+ }));
770
+ }
771
+ return true;
772
+ }
773
+
740
774
  function activityText(session) {
741
775
  const activity = sessionActivities(session).find((item) => !item.terminal)
742
776
  ?? (session.productionActivity?.label ? session.productionActivity : null);
@@ -747,6 +781,10 @@ function activityText(session) {
747
781
  return `${color}${truncateAnsi(activity.label, 72)}${styles.reset}`;
748
782
  }
749
783
 
784
+ function runtimeRunActive(session) {
785
+ return String(session.agentProjection?.status ?? '').toLowerCase() === 'running';
786
+ }
787
+
750
788
  function dividerWithActivity(session, columns) {
751
789
  const activity = activityText(session);
752
790
  if (!activity) return '─'.repeat(columns);
@@ -1141,7 +1179,7 @@ async function runPipeShell({ agent, packageJson, session }) {
1141
1179
  let lastBodyLineCount = 0;
1142
1180
  let lastMiddleHeight = 5;
1143
1181
 
1144
- async function runTuiShell({ agent, packageJson, session }) {
1182
+ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1145
1183
  const messages = conversationMessages(session);
1146
1184
  messages.push({
1147
1185
  role: 'donna',
@@ -1167,6 +1205,11 @@ async function runTuiShell({ agent, packageJson, session }) {
1167
1205
  let mouseSelectionTimer = null;
1168
1206
  let done = false;
1169
1207
  let processing = Promise.resolve();
1208
+ let runtimePollingActive = false;
1209
+ let runtimeStreamAbort = null;
1210
+ let runtimeReconnectTimer = null;
1211
+ let runtimeSyncTimer = null;
1212
+ let runtimeStreamStopped = false;
1170
1213
  let finish;
1171
1214
  const finished = new Promise((resolve) => {
1172
1215
  finish = resolve;
@@ -1197,8 +1240,55 @@ async function runTuiShell({ agent, packageJson, session }) {
1197
1240
  rerender();
1198
1241
  }
1199
1242
 
1243
+ function syncRuntimeState() {
1244
+ if (!runtime?.url) return;
1245
+ void fetchRuntimeState({ url: runtime.url })
1246
+ .then((state) => {
1247
+ runtimePollingActive = true;
1248
+ applyRuntimeStateToShellSession(session, state);
1249
+ rerender();
1250
+ })
1251
+ .catch(() => {
1252
+ runtimePollingActive = false;
1253
+ rerender();
1254
+ });
1255
+ }
1256
+
1257
+ function scheduleRuntimeStateSync() {
1258
+ if (runtimeSyncTimer) clearTimeout(runtimeSyncTimer);
1259
+ runtimeSyncTimer = setTimeout(syncRuntimeState, 200);
1260
+ }
1261
+
1262
+ async function subscribeRuntimeEvents() {
1263
+ if (!runtime?.url || runtimeStreamStopped) return;
1264
+ runtimeStreamAbort = new AbortController();
1265
+ try {
1266
+ for await (const event of streamRuntimeEvents({ url: runtime.url, signal: runtimeStreamAbort.signal })) {
1267
+ runtimePollingActive = true;
1268
+ if (event.type === 'state') {
1269
+ applyRuntimeStateToShellSession(session, event.data);
1270
+ rerender();
1271
+ } else {
1272
+ scheduleRuntimeStateSync();
1273
+ }
1274
+ }
1275
+ } catch {
1276
+ // Runtime stream may be temporarily unavailable; the local MCP polling fallback resumes below.
1277
+ }
1278
+ if (runtimeStreamStopped) return;
1279
+ runtimePollingActive = false;
1280
+ rerender();
1281
+ runtimeReconnectTimer = setTimeout(() => { void subscribeRuntimeEvents(); }, 1500);
1282
+ }
1283
+
1284
+ if (runtime?.url) {
1285
+ syncRuntimeState();
1286
+ void subscribeRuntimeEvents();
1287
+ }
1288
+
1200
1289
  const pollBusy = new Set();
1201
1290
  const productionPollInterval = setInterval(async () => {
1291
+ if (runtimePollingActive) return;
1202
1292
  const candidates = sessionActivities(session).filter((item) => item.poll && !item.terminal);
1203
1293
  // Legacy fallback: if no generic activities tracked yet, fall back to productionActivity.
1204
1294
  if (candidates.length === 0 && session.productionActivity?.jobId && !session.productionActivity.terminal) {
@@ -1306,14 +1396,12 @@ async function runTuiShell({ agent, packageJson, session }) {
1306
1396
  }
1307
1397
 
1308
1398
  if (key?.ctrl && key.name === 'y') {
1309
- const messages = conversationMessages(session);
1310
1399
  const lastDonna = [...messages].reverse().find((m) => isDonnaRole(m.role));
1311
1400
  if (lastDonna) {
1312
1401
  const text = stripAnsi(colorizeStatus(lastDonna.content)).replace(/\[[0-9;]*m/g, '');
1313
1402
  const clipCmd = process.platform === 'darwin'
1314
1403
  ? { command: 'pbcopy', args: [] }
1315
1404
  : { command: 'xclip', args: ['-selection', 'clipboard'] };
1316
- const { execFileSync } = await import('node:child_process');
1317
1405
  const prevLabel = spinnerLabel;
1318
1406
  const prevBusy = busy;
1319
1407
  try {
@@ -1335,6 +1423,18 @@ async function runTuiShell({ agent, packageJson, session }) {
1335
1423
  rerender();
1336
1424
  return;
1337
1425
  }
1426
+ if (runtime?.url && runtimeRunActive(session)) {
1427
+ void postRuntimeCancel({ url: runtime.url })
1428
+ .then(() => {
1429
+ activityLines = [...activityLines, 'runtime: cancel requested'].slice(-LOWER_DETAIL_ROWS);
1430
+ rerender();
1431
+ })
1432
+ .catch((err) => {
1433
+ activityLines = [...activityLines, `runtime cancel error: ${err instanceof Error ? err.message : String(err)}`].slice(-LOWER_DETAIL_ROWS);
1434
+ rerender();
1435
+ });
1436
+ return;
1437
+ }
1338
1438
  const now = Date.now();
1339
1439
  if (now - lastCtrlCAt <= 1500) {
1340
1440
  done = true;
@@ -1382,9 +1482,20 @@ async function runTuiShell({ agent, packageJson, session }) {
1382
1482
  session._abortSignal = currentAbortController.signal;
1383
1483
  let aborted = false;
1384
1484
  try {
1385
- const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1386
- done = result.exit;
1387
- aborted = result.aborted ?? false;
1485
+ if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1486
+ await postRuntimeRun(line, {
1487
+ url: runtime.url,
1488
+ workspace: session.workspace ?? null,
1489
+ });
1490
+ conversationMessages(session).push({ role: 'user', content: line });
1491
+ conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
1492
+ activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1493
+ syncRuntimeState();
1494
+ } else {
1495
+ const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1496
+ done = result.exit;
1497
+ aborted = result.aborted ?? false;
1498
+ }
1388
1499
  } catch (err) {
1389
1500
  if (err.name !== 'AbortError') throw err;
1390
1501
  aborted = true;
@@ -1479,6 +1590,10 @@ async function runTuiShell({ agent, packageJson, session }) {
1479
1590
  await processing;
1480
1591
  } finally {
1481
1592
  mouseFilter.off('keypress', onKeypress);
1593
+ runtimeStreamStopped = true;
1594
+ runtimeStreamAbort?.abort();
1595
+ clearTimeout(runtimeReconnectTimer);
1596
+ clearTimeout(runtimeSyncTimer);
1482
1597
  clearInterval(productionPollInterval);
1483
1598
  clearTimeout(ctrlCTimer);
1484
1599
  clearTimeout(mouseSelectionTimer);
@@ -1492,11 +1607,11 @@ async function runTuiShell({ agent, packageJson, session }) {
1492
1607
  }
1493
1608
  }
1494
1609
 
1495
- export async function runShell({ agent, packageJson }) {
1610
+ export async function runShell({ agent, packageJson, runtime = null }) {
1496
1611
  const session = createSession();
1497
1612
  if (!input.isTTY || !output.isTTY) {
1498
1613
  await runPipeShell({ agent, packageJson, session });
1499
1614
  return;
1500
1615
  }
1501
- throw new Error('Interactive TUI requires Bun/OpenTUI. Run: bun ./bin/wiki-manager.js');
1616
+ await runTuiShell({ agent, packageJson, session, runtime });
1502
1617
  }
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { applyRuntimeStateToShellSession, createSession, conversationMessages } from './repl.js';
4
+
5
+ test('applyRuntimeStateToShellSession projects runtime state into shell session', () => {
6
+ const session = createSession();
7
+ session.workspace = 'docs';
8
+
9
+ const applied = applyRuntimeStateToShellSession(session, {
10
+ status: 'running',
11
+ conversation: [
12
+ { role: 'user', content: 'Build docs' },
13
+ { role: 'assistant', content: 'Working.' },
14
+ ],
15
+ plan: [{ step: 1, description: 'Build', status: 'running' }],
16
+ activities: [{
17
+ key: 'production:job-1',
18
+ id: 'job-1',
19
+ source: 'production',
20
+ label: 'Production: build',
21
+ status: 'running',
22
+ terminal: false,
23
+ }],
24
+ queue: [{ id: 'q-1', status: 'waiting' }],
25
+ logs: ['agentic-loop: turn 1/20'],
26
+ });
27
+
28
+ assert.equal(applied, true);
29
+ assert.equal(session.agentProjection.status, 'running');
30
+ assert.equal(session.headlessPlan[0].description, 'Build');
31
+ assert.equal(session.activities['production:job-1'].status, 'running');
32
+ assert.equal(session.productionActivity.jobId, 'job-1');
33
+ assert.equal(session.jobQueue[0].id, 'q-1');
34
+ assert.deepEqual(conversationMessages(session), [
35
+ { role: 'user', content: 'Build docs' },
36
+ { role: 'donna', content: 'Working.' },
37
+ ]);
38
+ });
package/src/shell/tui.tsx CHANGED
@@ -41,6 +41,7 @@ function copyToClipboard(text: string, renderer: unknown) {
41
41
  function App(props: {
42
42
  agent: unknown;
43
43
  packageJson: Record<string, unknown>;
44
+ runtime?: any;
44
45
  }) {
45
46
  const renderer = useRenderer();
46
47
  const dimensions = useTerminalDimensions();
@@ -204,11 +205,13 @@ function App(props: {
204
205
  export async function runOpenTuiShell({
205
206
  agent,
206
207
  packageJson,
208
+ runtime = null,
207
209
  }: {
208
210
  agent: unknown;
209
211
  packageJson: Record<string, unknown>;
212
+ runtime?: any;
210
213
  }) {
211
- await render(() => <App agent={agent} packageJson={packageJson} />, {
214
+ await render(() => <App agent={agent} packageJson={packageJson} runtime={runtime} />, {
212
215
  exitOnCtrlC: false,
213
216
  useMouse: true,
214
217
  targetFps: 30,
@@ -1,7 +1,8 @@
1
1
  import { createSignal } from 'solid-js';
2
- import { runLine } from './repl.js';
2
+ import { postRuntimeCancel, postRuntimeRun } from '../runtime/client.js';
3
+ import { conversationMessages, runLine } from './repl.js';
3
4
 
4
- export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; refresh: () => void; addLog: (line: string) => void }) {
5
+ export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
5
6
  const [busy, setBusy] = createSignal(false);
6
7
  const [abortController, setAbortController] = createSignal<AbortController | null>(null);
7
8
 
@@ -17,6 +18,18 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
17
18
  props.addLog(`input: ${trimmed}`);
18
19
 
19
20
  try {
21
+ if (props.runtimeUrl && !props.chatMode() && !trimmed.startsWith('/')) {
22
+ await postRuntimeRun(trimmed, {
23
+ url: props.runtimeUrl,
24
+ workspace: props.session.workspace ?? null,
25
+ });
26
+ conversationMessages(props.session).push({ role: 'user', content: trimmed });
27
+ conversationMessages(props.session).push({ role: 'command', content: `Runtime run queued: ${props.runtimeUrl}` });
28
+ props.onRuntimeAccepted?.();
29
+ props.addLog('runtime: run accepted');
30
+ props.refresh();
31
+ return { exit: false, runtime: true };
32
+ }
20
33
  const result = await runLine(trimmed, {
21
34
  agent: props.agent,
22
35
  packageJson: props.packageJson,
@@ -39,6 +52,11 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
39
52
  }
40
53
 
41
54
  function abort() {
55
+ if (props.runtimeUrl) {
56
+ void postRuntimeCancel({ url: props.runtimeUrl })
57
+ .then(() => props.addLog('runtime: cancel requested'))
58
+ .catch((err) => props.addLog(`runtime cancel error: ${err instanceof Error ? err.message : String(err)}`));
59
+ }
42
60
  abortController()?.abort();
43
61
  props.addLog('interrupt requested');
44
62
  }
@@ -6,6 +6,8 @@ import { extractActivity, parseJsonText, sessionActivities } from '../core/activ
6
6
  import { formatPlanStatus, formatCompletedActivities } from '../core/plan.js';
7
7
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
8
8
  import { queueCounts, startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
9
+ import { queueStoreFor } from '../core/queueStore.js';
10
+ import { fetchRuntimeState, streamRuntimeEvents } from '../runtime/client.js';
9
11
  import type { ActiveFileEditor } from './FileEditorDialog';
10
12
  import {
11
13
  completionContext,
@@ -15,6 +17,16 @@ import {
15
17
  } from './repl.js';
16
18
  import { useAgent } from './useAgent';
17
19
 
20
+ function runtimeStatusText(status: 'disabled' | 'connected' | 'disconnected', runStatus: string): string {
21
+ if (status === 'connected') return `runtime ${runStatus}`;
22
+ if (status === 'disconnected') return 'runtime offline';
23
+ return 'runtime off';
24
+ }
25
+
26
+ function nonEmptyRuntimeArray<T>(value: T[] | undefined | null): T[] | null {
27
+ return Array.isArray(value) && value.length > 0 ? value : null;
28
+ }
29
+
18
30
  function buildContinuationPrompt(session: any, completed: any[]): string {
19
31
  const originalTask = [...conversationMessages(session)]
20
32
  .reverse()
@@ -31,10 +43,12 @@ function buildContinuationPrompt(session: any, completed: any[]): string {
31
43
  ].filter(Boolean).join('\n');
32
44
  }
33
45
 
34
- export function useSession(props: { agent: unknown; packageJson: Record<string, unknown> }) {
46
+ export function useSession(props: { agent: unknown; packageJson: Record<string, unknown>; runtime?: any }) {
35
47
  const session = createSession();
36
48
  const [version, setVersion] = createSignal(0);
37
49
  const [logs, setLogs] = createSignal<string[]>([]);
50
+ const [runtimeState, setRuntimeState] = createSignal<any | null>(null);
51
+ const [runtimeStatus, setRuntimeStatus] = createSignal<'disabled' | 'connected' | 'disconnected'>(props.runtime ? 'disconnected' : 'disabled');
38
52
  const [input, setInput] = createSignal('');
39
53
  const [chatMode, setChatMode] = createSignal(true);
40
54
  (session as any).chatMode = true;
@@ -60,10 +74,45 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
60
74
  const addLog = (line: string) => {
61
75
  setLogs((items) => [...items, `${new Date().toLocaleTimeString()} ${line}`].slice(-200));
62
76
  };
63
- const agent = useAgent({ agent: props.agent, packageJson: props.packageJson, session, chatMode, refresh, addLog });
77
+ if (props.runtime?.url) addLog(`runtime: connected ${props.runtime.url}${props.runtime.started ? ' (started)' : ''}`);
78
+ const agent = useAgent({
79
+ agent: props.agent,
80
+ packageJson: props.packageJson,
81
+ session,
82
+ chatMode,
83
+ runtimeUrl: props.runtime?.url ?? null,
84
+ refresh,
85
+ addLog,
86
+ onRuntimeAccepted: () => {
87
+ setRuntimeState((state) => ({ ...(state ?? {}), status: 'running' }));
88
+ setRuntimeStatus('connected');
89
+ refresh();
90
+ },
91
+ });
92
+ const runtimeRunStatus = createMemo(() => String(runtimeState()?.status ?? 'idle'));
93
+ const agentBusy = createMemo(() =>
94
+ props.runtime?.url
95
+ ? runtimeRunStatus() === 'running' || agent.busy()
96
+ : agent.busy(),
97
+ );
98
+ const localFallbackActive = createMemo(() => !props.runtime?.url || runtimeStatus() === 'disconnected');
64
99
 
65
100
  const messages = createMemo(() => {
66
101
  version();
102
+ const runtimeConversation = runtimeState()?.conversation;
103
+ const localCommands = conversationMessages(session)
104
+ .filter((message: any) => message.role === 'command')
105
+ .map((message: any) => ({
106
+ role: 'command',
107
+ content: String(message.content ?? ''),
108
+ }));
109
+ if (Array.isArray(runtimeConversation) && runtimeConversation.length > 0) {
110
+ const runtimeMessages = runtimeConversation.map((message: any) => ({
111
+ role: message.role === 'assistant' ? 'donna' : message.role,
112
+ content: String(message.content ?? ''),
113
+ }));
114
+ return [...runtimeMessages, ...localCommands].slice(-200);
115
+ }
67
116
  return [...conversationMessages(session)];
68
117
  });
69
118
  const prompt = createMemo(() => {
@@ -84,6 +133,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
84
133
  session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
85
134
  session.language ? session.language : 'no language',
86
135
  session.llm ? 'llm ready' : 'llm limited',
136
+ runtimeStatusText(runtimeStatus(), runtimeRunStatus()),
87
137
  ].join(' ');
88
138
  });
89
139
  const matchContext = createMemo(() => {
@@ -119,37 +169,117 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
119
169
  let lastVisiblePlan: Array<{ step: number; description: string; status: string }> | null = null;
120
170
  const activities = createMemo(() => {
121
171
  version();
172
+ const runtimeActivities = nonEmptyRuntimeArray(runtimeState()?.activities);
173
+ if (runtimeActivities) {
174
+ return runtimeActivities.map((activity: any) => ({ ...activity, _runtime: true }));
175
+ }
122
176
  const current = sessionActivities(session);
123
177
  if (current.length > 0) {
124
178
  lastVisibleActivities = current.map((activity) => ({ ...activity }));
125
179
  return current;
126
180
  }
127
- return agent.busy() && lastVisibleActivities.length > 0
181
+ return agentBusy() && lastVisibleActivities.length > 0
128
182
  ? lastVisibleActivities.map((activity) => ({ ...activity }))
129
183
  : current;
130
184
  });
131
185
  const queueItems = createMemo(() => {
132
186
  version();
187
+ const runtimeQueue = nonEmptyRuntimeArray(runtimeState()?.queue);
188
+ if (runtimeQueue) return runtimeQueue.map((item: any) => ({ ...item, _runtime: true }));
133
189
  return ((session as any).jobQueue ?? []).map((item: any) => ({ ...item }));
134
190
  });
135
191
  const queueInfo = createMemo(() => {
136
192
  version();
193
+ const runtimeQueue = runtimeState()?.queue;
194
+ if (Array.isArray(runtimeQueue)) {
195
+ return {
196
+ active: runtimeQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending'].includes(String(item.status ?? '').toLowerCase())).length,
197
+ current: runtimeQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
198
+ frozen: 0,
199
+ };
200
+ }
137
201
  return queueCounts(session);
138
202
  });
139
203
  const plan = createMemo(() => {
140
204
  version();
205
+ const runtimePlan = nonEmptyRuntimeArray(runtimeState()?.plan);
206
+ if (runtimePlan) {
207
+ return runtimePlan.map((step: any, index: number) => ({
208
+ step: Number(step.step ?? index + 1),
209
+ description: String(step.description ?? step.label ?? step.name ?? `Step ${index + 1}`),
210
+ status: String(step.status ?? 'pending'),
211
+ }));
212
+ }
141
213
  const p = (session as any).headlessPlan as Array<{ step: number; description: string; status: string }> | null;
142
214
  const current = p ? p.map((s) => ({ ...s })) : null;
143
215
  if (current && current.length > 0) {
144
216
  lastVisiblePlan = current.map((step) => ({ ...step }));
145
217
  return current;
146
218
  }
147
- return agent.busy() && lastVisiblePlan && lastVisiblePlan.length > 0
219
+ return agentBusy() && lastVisiblePlan && lastVisiblePlan.length > 0
148
220
  ? lastVisiblePlan.map((step) => ({ ...step }))
149
221
  : current;
150
222
  });
223
+ const visibleLogs = createMemo(() => {
224
+ version();
225
+ const runtimeLogs = runtimeState()?.logs;
226
+ if (!Array.isArray(runtimeLogs) || runtimeLogs.length === 0) return logs();
227
+ const tagged = runtimeLogs.slice(-80).map((line: any) => `runtime ${String(line)}`);
228
+ return [...logs(), ...tagged].slice(-200);
229
+ });
230
+
231
+ function syncRuntimeState() {
232
+ void fetchRuntimeState({ url: props.runtime.url })
233
+ .then((state) => {
234
+ setRuntimeState(state);
235
+ setRuntimeStatus('connected');
236
+ refresh();
237
+ })
238
+ .catch(() => {
239
+ setRuntimeStatus('disconnected');
240
+ refresh();
241
+ });
242
+ }
243
+ let runtimeStreamAbort: AbortController | null = null;
244
+ let runtimeSyncDebounce: ReturnType<typeof setTimeout> | null = null;
245
+ let runtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
246
+ let runtimeStreamStopped = false;
247
+
248
+ function debouncedSyncRuntimeState() {
249
+ if (runtimeSyncDebounce) clearTimeout(runtimeSyncDebounce);
250
+ runtimeSyncDebounce = setTimeout(syncRuntimeState, 200);
251
+ }
252
+
253
+ async function subscribeRuntimeEvents() {
254
+ if (!props.runtime?.url || runtimeStreamStopped) return;
255
+ runtimeStreamAbort = new AbortController();
256
+ try {
257
+ for await (const _event of streamRuntimeEvents({ url: props.runtime.url, signal: runtimeStreamAbort.signal })) {
258
+ setRuntimeStatus('connected');
259
+ debouncedSyncRuntimeState();
260
+ }
261
+ } catch {
262
+ // stream dropped or errored — fall through to reconnect below
263
+ }
264
+ if (runtimeStreamStopped) return;
265
+ setRuntimeStatus('disconnected');
266
+ refresh();
267
+ runtimeReconnectTimer = setTimeout(() => { void subscribeRuntimeEvents(); }, 1500);
268
+ }
269
+
270
+ if (props.runtime?.url) {
271
+ syncRuntimeState();
272
+ void subscribeRuntimeEvents();
273
+ }
274
+ onCleanup(() => {
275
+ runtimeStreamStopped = true;
276
+ runtimeStreamAbort?.abort();
277
+ if (runtimeSyncDebounce) clearTimeout(runtimeSyncDebounce);
278
+ if (runtimeReconnectTimer) clearTimeout(runtimeReconnectTimer);
279
+ });
151
280
 
152
281
  const activityPollTimer = setInterval(() => {
282
+ if (!localFallbackActive()) return;
153
283
  for (const activity of sessionActivities(session)) {
154
284
  if (activity.terminal || !activity.poll) continue;
155
285
  const key = activity.key ?? `${activity.poll.server}:${activity.id ?? activity.label}`;
@@ -195,7 +325,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
195
325
  const plan = (session as any).headlessPlan;
196
326
  const stillRunning = sessionActivities(session).filter((a) => !a.terminal && a.poll);
197
327
  const pendingSteps = (plan ?? []).filter((s: any) => s.status === 'pending');
198
- if (stillRunning.length === 0 && pendingSteps.length > 0 && !agent.busy()) {
328
+ if (stillRunning.length === 0 && pendingSteps.length > 0 && !agentBusy()) {
199
329
  const completedAll = sessionActivities(session).filter((a) => a.terminal);
200
330
  const prompt = buildContinuationPrompt(session, completedAll);
201
331
  void agent.submit(prompt);
@@ -211,18 +341,23 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
211
341
  });
212
342
  }
213
343
  }, 1000);
214
- onCleanup(() => clearInterval(activityPollTimer));
344
+ onCleanup(() => {
345
+ if (activityPollTimer) clearInterval(activityPollTimer);
346
+ });
215
347
 
216
348
  const queueFallbackTimer = setInterval(() => {
217
- if ((session as any).jobQueue?.some((item: any) => item.status === 'waiting')) {
349
+ if (!localFallbackActive()) return;
350
+ if (queueStoreFor(session).list().some((item: any) => item.status === 'waiting')) {
218
351
  void startNextQueuedJob(session, { addLog, refresh });
219
352
  }
220
353
  }, 10000);
221
- onCleanup(() => clearInterval(queueFallbackTimer));
354
+ onCleanup(() => {
355
+ if (queueFallbackTimer) clearInterval(queueFallbackTimer);
356
+ });
222
357
 
223
358
  async function submitInput(submittedValue?: string) {
224
359
  const line = typeof submittedValue === 'string' && submittedValue.trim() ? submittedValue : input();
225
- if (agent.busy()) return { exit: false, busy: true };
360
+ if (agentBusy()) return { exit: false, busy: true };
226
361
  setInput('');
227
362
  setConversationScroll(0);
228
363
  if (line.trim()) {
@@ -344,7 +479,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
344
479
  return {
345
480
  session,
346
481
  messages,
347
- logs,
482
+ logs: visibleLogs,
348
483
  input,
349
484
  setInput: updateInput,
350
485
  title,
@@ -364,7 +499,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
364
499
  plan,
365
500
  conversationScroll,
366
501
  scrollConversation,
367
- busy: agent.busy,
502
+ busy: agentBusy,
368
503
  abort,
369
504
  submitInput,
370
505
  completeSelected,