@dotdrelle/wiki-manager 0.6.47 → 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.
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
@@ -669,14 +671,6 @@ function renderBannerWithMcpPanel(columns, session) {
669
671
 
670
672
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
671
673
 
672
- function parseJsonText(text) {
673
- try {
674
- return JSON.parse(text);
675
- } catch {
676
- return null;
677
- }
678
- }
679
-
680
674
  function pathBaseName(value) {
681
675
  return String(value ?? '').split('/').filter(Boolean).pop() ?? '';
682
676
  }
@@ -738,6 +732,45 @@ function rememberProductionActivity(session, payload) {
738
732
  return true;
739
733
  }
740
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
+
741
774
  function activityText(session) {
742
775
  const activity = sessionActivities(session).find((item) => !item.terminal)
743
776
  ?? (session.productionActivity?.label ? session.productionActivity : null);
@@ -748,6 +781,10 @@ function activityText(session) {
748
781
  return `${color}${truncateAnsi(activity.label, 72)}${styles.reset}`;
749
782
  }
750
783
 
784
+ function runtimeRunActive(session) {
785
+ return String(session.agentProjection?.status ?? '').toLowerCase() === 'running';
786
+ }
787
+
751
788
  function dividerWithActivity(session, columns) {
752
789
  const activity = activityText(session);
753
790
  if (!activity) return '─'.repeat(columns);
@@ -1142,7 +1179,7 @@ async function runPipeShell({ agent, packageJson, session }) {
1142
1179
  let lastBodyLineCount = 0;
1143
1180
  let lastMiddleHeight = 5;
1144
1181
 
1145
- async function runTuiShell({ agent, packageJson, session }) {
1182
+ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1146
1183
  const messages = conversationMessages(session);
1147
1184
  messages.push({
1148
1185
  role: 'donna',
@@ -1168,6 +1205,11 @@ async function runTuiShell({ agent, packageJson, session }) {
1168
1205
  let mouseSelectionTimer = null;
1169
1206
  let done = false;
1170
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;
1171
1213
  let finish;
1172
1214
  const finished = new Promise((resolve) => {
1173
1215
  finish = resolve;
@@ -1198,8 +1240,55 @@ async function runTuiShell({ agent, packageJson, session }) {
1198
1240
  rerender();
1199
1241
  }
1200
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
+
1201
1289
  const pollBusy = new Set();
1202
1290
  const productionPollInterval = setInterval(async () => {
1291
+ if (runtimePollingActive) return;
1203
1292
  const candidates = sessionActivities(session).filter((item) => item.poll && !item.terminal);
1204
1293
  // Legacy fallback: if no generic activities tracked yet, fall back to productionActivity.
1205
1294
  if (candidates.length === 0 && session.productionActivity?.jobId && !session.productionActivity.terminal) {
@@ -1307,14 +1396,12 @@ async function runTuiShell({ agent, packageJson, session }) {
1307
1396
  }
1308
1397
 
1309
1398
  if (key?.ctrl && key.name === 'y') {
1310
- const messages = conversationMessages(session);
1311
1399
  const lastDonna = [...messages].reverse().find((m) => isDonnaRole(m.role));
1312
1400
  if (lastDonna) {
1313
1401
  const text = stripAnsi(colorizeStatus(lastDonna.content)).replace(/\[[0-9;]*m/g, '');
1314
1402
  const clipCmd = process.platform === 'darwin'
1315
1403
  ? { command: 'pbcopy', args: [] }
1316
1404
  : { command: 'xclip', args: ['-selection', 'clipboard'] };
1317
- const { execFileSync } = await import('node:child_process');
1318
1405
  const prevLabel = spinnerLabel;
1319
1406
  const prevBusy = busy;
1320
1407
  try {
@@ -1336,6 +1423,18 @@ async function runTuiShell({ agent, packageJson, session }) {
1336
1423
  rerender();
1337
1424
  return;
1338
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
+ }
1339
1438
  const now = Date.now();
1340
1439
  if (now - lastCtrlCAt <= 1500) {
1341
1440
  done = true;
@@ -1383,9 +1482,20 @@ async function runTuiShell({ agent, packageJson, session }) {
1383
1482
  session._abortSignal = currentAbortController.signal;
1384
1483
  let aborted = false;
1385
1484
  try {
1386
- const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1387
- done = result.exit;
1388
- 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
+ }
1389
1499
  } catch (err) {
1390
1500
  if (err.name !== 'AbortError') throw err;
1391
1501
  aborted = true;
@@ -1480,6 +1590,10 @@ async function runTuiShell({ agent, packageJson, session }) {
1480
1590
  await processing;
1481
1591
  } finally {
1482
1592
  mouseFilter.off('keypress', onKeypress);
1593
+ runtimeStreamStopped = true;
1594
+ runtimeStreamAbort?.abort();
1595
+ clearTimeout(runtimeReconnectTimer);
1596
+ clearTimeout(runtimeSyncTimer);
1483
1597
  clearInterval(productionPollInterval);
1484
1598
  clearTimeout(ctrlCTimer);
1485
1599
  clearTimeout(mouseSelectionTimer);
@@ -1493,11 +1607,11 @@ async function runTuiShell({ agent, packageJson, session }) {
1493
1607
  }
1494
1608
  }
1495
1609
 
1496
- export async function runShell({ agent, packageJson }) {
1610
+ export async function runShell({ agent, packageJson, runtime = null }) {
1497
1611
  const session = createSession();
1498
1612
  if (!input.isTTY || !output.isTTY) {
1499
1613
  await runPipeShell({ agent, packageJson, session });
1500
1614
  return;
1501
1615
  }
1502
- throw new Error('Interactive TUI requires Bun/OpenTUI. Run: bun ./bin/wiki-manager.js');
1616
+ await runTuiShell({ agent, packageJson, session, runtime });
1503
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,