@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
@@ -0,0 +1,240 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
+ import { pollActivitiesOnce, startActivitySupervisor } from './supervisor.js';
5
+
6
+ test('pollActivitiesOnce updates activity through the event reducer', async () => {
7
+ const session = {
8
+ mcp: { production: { status: 'connected' } },
9
+ activities: {},
10
+ headlessPlan: null,
11
+ jobQueue: [],
12
+ };
13
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
14
+ payload: {
15
+ activity: {
16
+ id: 'job-1',
17
+ source: 'production',
18
+ status: 'running',
19
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-1' }, intervalMs: 1000 },
20
+ },
21
+ },
22
+ }));
23
+ const key = Object.keys(session.activities)[0];
24
+ session.activities[key].lastPolledAt = '1970-01-01T00:00:00.000Z';
25
+
26
+ await pollActivitiesOnce(session, {
27
+ callTool: async () => ({
28
+ content: [{ type: 'text', text: JSON.stringify({
29
+ _activity: {
30
+ id: 'job-1',
31
+ source: 'production',
32
+ status: 'done',
33
+ terminal: true,
34
+ },
35
+ }) }],
36
+ }),
37
+ });
38
+
39
+ assert.equal(session.activities[key].status, 'done');
40
+ assert.equal(session.activities[key].terminal, true);
41
+ assert.ok(session.agentProjection.logs.some((line) => line.includes('activity:')));
42
+ });
43
+
44
+ test('pollActivitiesOnce retries transient MCP poll failures', async () => {
45
+ const originalFetch = globalThis.fetch;
46
+ let attempts = 0;
47
+ globalThis.fetch = async () => {
48
+ attempts += 1;
49
+ if (attempts === 1) {
50
+ return {
51
+ ok: false,
52
+ status: 503,
53
+ headers: { get: () => null },
54
+ text: async () => 'temporarily unavailable',
55
+ };
56
+ }
57
+ return {
58
+ ok: true,
59
+ status: 200,
60
+ headers: { get: () => null },
61
+ text: async () => JSON.stringify({
62
+ result: {
63
+ content: [{ type: 'text', text: JSON.stringify({
64
+ _activity: {
65
+ id: 'job-retry',
66
+ source: 'production',
67
+ status: 'done',
68
+ terminal: true,
69
+ },
70
+ }) }],
71
+ },
72
+ }),
73
+ };
74
+ };
75
+
76
+ const session = {
77
+ mcp: {
78
+ production: {
79
+ status: 'connected',
80
+ url: 'http://127.0.0.1:3000/mcp/',
81
+ retry: { maxAttempts: 2, backoffMs: 0 },
82
+ },
83
+ },
84
+ activities: {},
85
+ headlessPlan: null,
86
+ jobQueue: [],
87
+ };
88
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
89
+ payload: {
90
+ activity: {
91
+ id: 'job-retry',
92
+ source: 'production',
93
+ status: 'running',
94
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-retry' }, intervalMs: 0 },
95
+ },
96
+ },
97
+ }));
98
+
99
+ try {
100
+ await pollActivitiesOnce(session);
101
+ const key = Object.keys(session.activities)[0];
102
+ assert.equal(attempts, 2);
103
+ assert.equal(session.activities[key].status, 'done');
104
+ } finally {
105
+ globalThis.fetch = originalFetch;
106
+ }
107
+ });
108
+
109
+ test('startActivitySupervisor passes the active run signal to background polls', async () => {
110
+ const session = {
111
+ mcp: { production: { status: 'connected' } },
112
+ activities: {},
113
+ headlessPlan: null,
114
+ jobQueue: [],
115
+ };
116
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
117
+ payload: {
118
+ activity: {
119
+ id: 'job-2',
120
+ source: 'production',
121
+ status: 'running',
122
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-2' }, intervalMs: 0 },
123
+ },
124
+ },
125
+ }));
126
+
127
+ const controller = new AbortController();
128
+ const signals = [];
129
+ const supervisor = startActivitySupervisor(session, {
130
+ intervalMs: 10,
131
+ queueIntervalMs: 1000,
132
+ callTool: async (_mcp, _server, _tool, _args, signal) => {
133
+ signals.push(signal ?? null);
134
+ return {
135
+ content: [{ type: 'text', text: JSON.stringify({
136
+ _activity: {
137
+ id: 'job-2',
138
+ source: 'production',
139
+ status: 'running',
140
+ terminal: false,
141
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-2' }, intervalMs: 0 },
142
+ },
143
+ }) }],
144
+ };
145
+ },
146
+ });
147
+
148
+ try {
149
+ supervisor.setRunSignal(controller.signal);
150
+ await waitFor(() => signals.includes(controller.signal));
151
+ assert.ok(signals.includes(controller.signal));
152
+ } finally {
153
+ supervisor.stop();
154
+ }
155
+ });
156
+
157
+ test('pollActivitiesOnce fires _onActivitiesTerminal when last activity becomes terminal', async () => {
158
+ let fired = false;
159
+ const session = {
160
+ mcp: { production: { status: 'connected' } },
161
+ activities: {},
162
+ headlessPlan: null,
163
+ jobQueue: [],
164
+ _onActivitiesTerminal: () => { fired = true; },
165
+ };
166
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
167
+ payload: {
168
+ activity: {
169
+ id: 'job-hook',
170
+ source: 'production',
171
+ status: 'running',
172
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-hook' }, intervalMs: 0 },
173
+ },
174
+ },
175
+ }));
176
+ const key = Object.keys(session.activities)[0];
177
+ session.activities[key].lastPolledAt = '1970-01-01T00:00:00.000Z';
178
+
179
+ await pollActivitiesOnce(session, {
180
+ callTool: async () => ({
181
+ content: [{ type: 'text', text: JSON.stringify({
182
+ _activity: { id: 'job-hook', source: 'production', status: 'done', terminal: true },
183
+ }) }],
184
+ }),
185
+ });
186
+
187
+ assert.equal(fired, true);
188
+ assert.equal(session._onActivitiesTerminal, undefined);
189
+ });
190
+
191
+ test('pollActivitiesOnce does not fire _onActivitiesTerminal while other activities remain', async () => {
192
+ let fired = false;
193
+ const session = {
194
+ mcp: { production: { status: 'connected' } },
195
+ activities: {},
196
+ headlessPlan: null,
197
+ jobQueue: [],
198
+ _onActivitiesTerminal: () => { fired = true; },
199
+ };
200
+ for (const id of ['job-a', 'job-b']) {
201
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
202
+ payload: {
203
+ activity: {
204
+ id,
205
+ source: 'production',
206
+ status: 'running',
207
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: id }, intervalMs: 0 },
208
+ },
209
+ },
210
+ }));
211
+ }
212
+ for (const key of Object.keys(session.activities)) {
213
+ session.activities[key].lastPolledAt = '1970-01-01T00:00:00.000Z';
214
+ }
215
+
216
+ let callCount = 0;
217
+ await pollActivitiesOnce(session, {
218
+ callTool: async (_mcp, _server, _tool, args) => {
219
+ callCount += 1;
220
+ const terminal = args.jobId === 'job-a';
221
+ return {
222
+ content: [{ type: 'text', text: JSON.stringify({
223
+ _activity: { id: args.jobId, source: 'production', status: terminal ? 'done' : 'running', terminal },
224
+ }) }],
225
+ };
226
+ },
227
+ });
228
+
229
+ assert.equal(fired, false);
230
+ assert.equal(callCount, 2);
231
+ });
232
+
233
+ async function waitFor(predicate, timeoutMs = 250) {
234
+ const deadline = Date.now() + timeoutMs;
235
+ while (Date.now() < deadline) {
236
+ if (predicate()) return;
237
+ await new Promise((resolve) => setTimeout(resolve, 10));
238
+ }
239
+ assert.fail('Timed out waiting for condition.');
240
+ }
@@ -68,7 +68,7 @@ function activityColor(status: string) {
68
68
 
69
69
  function queueColor(status: string) {
70
70
  const value = String(status ?? '').toLowerCase();
71
- if (value === 'waiting') return '#FBBF24';
71
+ if (value === 'waiting' || value === 'pending_approval') return '#FBBF24';
72
72
  return activityColor(status);
73
73
  }
74
74
 
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, postRuntimeApprove, 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
@@ -72,6 +74,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
72
74
  '/chat': 'Switch free text to direct LLM chat without tools.',
73
75
  '/agent': 'Switch free text to the LangGraph agent with tools.',
74
76
  '/openui': 'Open the workspace web UI in the browser.',
77
+ '/approve': 'Approve a pending runtime run or tool.',
75
78
  };
76
79
 
77
80
  const SUBCOMMAND_COMPLETION_DESCRIPTIONS = {
@@ -108,7 +111,7 @@ export function createSession() {
108
111
  wikircConfig: null,
109
112
  language: null,
110
113
  mcp: null,
111
- commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'queue'],
114
+ commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'queue', 'approve'],
112
115
  chatMode: true,
113
116
  llm: null,
114
117
  activities: {},
@@ -669,14 +672,6 @@ function renderBannerWithMcpPanel(columns, session) {
669
672
 
670
673
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
671
674
 
672
- function parseJsonText(text) {
673
- try {
674
- return JSON.parse(text);
675
- } catch {
676
- return null;
677
- }
678
- }
679
-
680
675
  function pathBaseName(value) {
681
676
  return String(value ?? '').split('/').filter(Boolean).pop() ?? '';
682
677
  }
@@ -738,6 +733,45 @@ function rememberProductionActivity(session, payload) {
738
733
  return true;
739
734
  }
740
735
 
736
+ export function applyRuntimeStateToShellSession(session, state) {
737
+ if (!state || typeof state !== 'object') return false;
738
+ session.agentProjection = {
739
+ conversation: Array.isArray(state.conversation) ? state.conversation.map((message) => ({ ...message })) : [],
740
+ chain: Array.isArray(state.chain) ? state.chain.map((step) => ({ ...step })) : [],
741
+ plan: Array.isArray(state.plan) ? state.plan.map((step) => ({ ...step })) : null,
742
+ activities: Array.isArray(state.activities) ? state.activities.map((activity) => ({ ...activity })) : [],
743
+ logs: Array.isArray(state.logs) ? [...state.logs] : [],
744
+ summary: state.summary ?? null,
745
+ status: state.status ?? 'idle',
746
+ };
747
+ session.headlessPlan = session.agentProjection.plan
748
+ ? session.agentProjection.plan.map((step) => ({ ...step }))
749
+ : null;
750
+ session.activities = Object.fromEntries(
751
+ session.agentProjection.activities.map((activity) => [activity.key, { ...activity }]),
752
+ );
753
+ if (Array.isArray(state.queue)) session.jobQueue = state.queue.map((item) => ({ ...item }));
754
+ const production = session.agentProjection.activities.filter((activity) => activity.source === 'production').at(-1);
755
+ if (production) {
756
+ session.productionActivity = {
757
+ jobId: production.id,
758
+ status: production.status,
759
+ label: production.label,
760
+ terminal: production.terminal,
761
+ updatedAt: production.updatedAt,
762
+ };
763
+ }
764
+ const runtimeConversation = session.agentProjection.conversation;
765
+ if (runtimeConversation.length > 0) {
766
+ session.conversations ??= { [GLOBAL_CONVERSATION_KEY]: [] };
767
+ session.conversations[conversationKey(session)] = runtimeConversation.map((message) => ({
768
+ role: message.role === 'assistant' ? 'donna' : message.role,
769
+ content: String(message.content ?? ''),
770
+ }));
771
+ }
772
+ return true;
773
+ }
774
+
741
775
  function activityText(session) {
742
776
  const activity = sessionActivities(session).find((item) => !item.terminal)
743
777
  ?? (session.productionActivity?.label ? session.productionActivity : null);
@@ -748,6 +782,10 @@ function activityText(session) {
748
782
  return `${color}${truncateAnsi(activity.label, 72)}${styles.reset}`;
749
783
  }
750
784
 
785
+ function runtimeRunActive(session) {
786
+ return String(session.agentProjection?.status ?? '').toLowerCase() === 'running';
787
+ }
788
+
751
789
  function dividerWithActivity(session, columns) {
752
790
  const activity = activityText(session);
753
791
  if (!activity) return '─'.repeat(columns);
@@ -1142,7 +1180,7 @@ async function runPipeShell({ agent, packageJson, session }) {
1142
1180
  let lastBodyLineCount = 0;
1143
1181
  let lastMiddleHeight = 5;
1144
1182
 
1145
- async function runTuiShell({ agent, packageJson, session }) {
1183
+ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1146
1184
  const messages = conversationMessages(session);
1147
1185
  messages.push({
1148
1186
  role: 'donna',
@@ -1168,6 +1206,11 @@ async function runTuiShell({ agent, packageJson, session }) {
1168
1206
  let mouseSelectionTimer = null;
1169
1207
  let done = false;
1170
1208
  let processing = Promise.resolve();
1209
+ let runtimePollingActive = false;
1210
+ let runtimeStreamAbort = null;
1211
+ let runtimeReconnectTimer = null;
1212
+ let runtimeSyncTimer = null;
1213
+ let runtimeStreamStopped = false;
1171
1214
  let finish;
1172
1215
  const finished = new Promise((resolve) => {
1173
1216
  finish = resolve;
@@ -1198,8 +1241,55 @@ async function runTuiShell({ agent, packageJson, session }) {
1198
1241
  rerender();
1199
1242
  }
1200
1243
 
1244
+ function syncRuntimeState() {
1245
+ if (!runtime?.url) return;
1246
+ void fetchRuntimeState({ url: runtime.url, workspace: session.workspace ?? null })
1247
+ .then((state) => {
1248
+ runtimePollingActive = true;
1249
+ applyRuntimeStateToShellSession(session, state);
1250
+ rerender();
1251
+ })
1252
+ .catch(() => {
1253
+ runtimePollingActive = false;
1254
+ rerender();
1255
+ });
1256
+ }
1257
+
1258
+ function scheduleRuntimeStateSync() {
1259
+ if (runtimeSyncTimer) clearTimeout(runtimeSyncTimer);
1260
+ runtimeSyncTimer = setTimeout(syncRuntimeState, 200);
1261
+ }
1262
+
1263
+ async function subscribeRuntimeEvents() {
1264
+ if (!runtime?.url || runtimeStreamStopped) return;
1265
+ runtimeStreamAbort = new AbortController();
1266
+ try {
1267
+ for await (const event of streamRuntimeEvents({ url: runtime.url, signal: runtimeStreamAbort.signal, workspace: session.workspace ?? null })) {
1268
+ runtimePollingActive = true;
1269
+ if (event.type === 'state') {
1270
+ applyRuntimeStateToShellSession(session, event.data);
1271
+ rerender();
1272
+ } else {
1273
+ scheduleRuntimeStateSync();
1274
+ }
1275
+ }
1276
+ } catch {
1277
+ // Runtime stream may be temporarily unavailable; the local MCP polling fallback resumes below.
1278
+ }
1279
+ if (runtimeStreamStopped) return;
1280
+ runtimePollingActive = false;
1281
+ rerender();
1282
+ runtimeReconnectTimer = setTimeout(() => { void subscribeRuntimeEvents(); }, 1500);
1283
+ }
1284
+
1285
+ if (runtime?.url) {
1286
+ syncRuntimeState();
1287
+ void subscribeRuntimeEvents();
1288
+ }
1289
+
1201
1290
  const pollBusy = new Set();
1202
1291
  const productionPollInterval = setInterval(async () => {
1292
+ if (runtimePollingActive) return;
1203
1293
  const candidates = sessionActivities(session).filter((item) => item.poll && !item.terminal);
1204
1294
  // Legacy fallback: if no generic activities tracked yet, fall back to productionActivity.
1205
1295
  if (candidates.length === 0 && session.productionActivity?.jobId && !session.productionActivity.terminal) {
@@ -1307,14 +1397,12 @@ async function runTuiShell({ agent, packageJson, session }) {
1307
1397
  }
1308
1398
 
1309
1399
  if (key?.ctrl && key.name === 'y') {
1310
- const messages = conversationMessages(session);
1311
1400
  const lastDonna = [...messages].reverse().find((m) => isDonnaRole(m.role));
1312
1401
  if (lastDonna) {
1313
1402
  const text = stripAnsi(colorizeStatus(lastDonna.content)).replace(/\[[0-9;]*m/g, '');
1314
1403
  const clipCmd = process.platform === 'darwin'
1315
1404
  ? { command: 'pbcopy', args: [] }
1316
1405
  : { command: 'xclip', args: ['-selection', 'clipboard'] };
1317
- const { execFileSync } = await import('node:child_process');
1318
1406
  const prevLabel = spinnerLabel;
1319
1407
  const prevBusy = busy;
1320
1408
  try {
@@ -1336,6 +1424,18 @@ async function runTuiShell({ agent, packageJson, session }) {
1336
1424
  rerender();
1337
1425
  return;
1338
1426
  }
1427
+ if (runtime?.url && runtimeRunActive(session)) {
1428
+ void postRuntimeCancel({ url: runtime.url, workspace: session.workspace ?? null })
1429
+ .then(() => {
1430
+ activityLines = [...activityLines, 'runtime: cancel requested'].slice(-LOWER_DETAIL_ROWS);
1431
+ rerender();
1432
+ })
1433
+ .catch((err) => {
1434
+ activityLines = [...activityLines, `runtime cancel error: ${err instanceof Error ? err.message : String(err)}`].slice(-LOWER_DETAIL_ROWS);
1435
+ rerender();
1436
+ });
1437
+ return;
1438
+ }
1339
1439
  const now = Date.now();
1340
1440
  if (now - lastCtrlCAt <= 1500) {
1341
1441
  done = true;
@@ -1383,9 +1483,35 @@ async function runTuiShell({ agent, packageJson, session }) {
1383
1483
  session._abortSignal = currentAbortController.signal;
1384
1484
  let aborted = false;
1385
1485
  try {
1386
- const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1387
- done = result.exit;
1388
- aborted = result.aborted ?? false;
1486
+ if (runtime?.url && line.trim().startsWith('/approve')) {
1487
+ const parts = line.trim().split(/\s+/).slice(1);
1488
+ const kind = ['run', 'item', 'approval'].includes(parts[0]) ? parts.shift() : 'run';
1489
+ const id = parts[0];
1490
+ if (!id) {
1491
+ conversationMessages(session).push({ role: 'command', content: 'Usage: /approve [run|item|approval] <id>' });
1492
+ } else {
1493
+ const result = await postRuntimeApprove({
1494
+ url: runtime.url,
1495
+ workspace: session.workspace ?? null,
1496
+ ...(kind === 'item' ? { itemId: id } : kind === 'approval' ? { approvalId: id } : { runId: id }),
1497
+ });
1498
+ conversationMessages(session).push({ role: 'command', content: `Approval ${result.approved ? 'accepted' : 'not found'}: ${id}` });
1499
+ syncRuntimeState();
1500
+ }
1501
+ } else if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1502
+ await postRuntimeRun(line, {
1503
+ url: runtime.url,
1504
+ workspace: session.workspace ?? null,
1505
+ });
1506
+ conversationMessages(session).push({ role: 'user', content: line });
1507
+ conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
1508
+ activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1509
+ syncRuntimeState();
1510
+ } else {
1511
+ const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1512
+ done = result.exit;
1513
+ aborted = result.aborted ?? false;
1514
+ }
1389
1515
  } catch (err) {
1390
1516
  if (err.name !== 'AbortError') throw err;
1391
1517
  aborted = true;
@@ -1480,6 +1606,10 @@ async function runTuiShell({ agent, packageJson, session }) {
1480
1606
  await processing;
1481
1607
  } finally {
1482
1608
  mouseFilter.off('keypress', onKeypress);
1609
+ runtimeStreamStopped = true;
1610
+ runtimeStreamAbort?.abort();
1611
+ clearTimeout(runtimeReconnectTimer);
1612
+ clearTimeout(runtimeSyncTimer);
1483
1613
  clearInterval(productionPollInterval);
1484
1614
  clearTimeout(ctrlCTimer);
1485
1615
  clearTimeout(mouseSelectionTimer);
@@ -1493,11 +1623,11 @@ async function runTuiShell({ agent, packageJson, session }) {
1493
1623
  }
1494
1624
  }
1495
1625
 
1496
- export async function runShell({ agent, packageJson }) {
1626
+ export async function runShell({ agent, packageJson, runtime = null }) {
1497
1627
  const session = createSession();
1498
1628
  if (!input.isTTY || !output.isTTY) {
1499
1629
  await runPipeShell({ agent, packageJson, session });
1500
1630
  return;
1501
1631
  }
1502
- throw new Error('Interactive TUI requires Bun/OpenTUI. Run: bun ./bin/wiki-manager.js');
1632
+ await runTuiShell({ agent, packageJson, session, runtime });
1503
1633
  }
@@ -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, workspace: props.session.workspace ?? null })
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
  }