@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.
- package/.env.example +20 -0
- package/README.md +35 -1
- package/bin/wiki-manager.js +2 -2
- package/docker-compose.yml +2 -0
- package/mcp.endpoints.example.json +13 -0
- package/package.json +2 -2
- package/src/agent/graph.js +101 -15
- package/src/agent/graph.test.js +145 -0
- package/src/cli/wiki-manager.js +459 -131
- package/src/commands/slash.js +1 -1
- package/src/core/activity.js +14 -0
- package/src/core/agentEvents.js +148 -6
- package/src/core/agentEvents.test.js +145 -4
- package/src/core/agentLoop.js +167 -0
- package/src/core/agentLoop.test.js +85 -0
- package/src/core/cacert.js +4 -3
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/documentIntake.test.js +7 -7
- package/src/core/env.js +6 -0
- package/src/core/jobQueue.js +47 -16
- package/src/core/mcp.js +120 -10
- package/src/core/mcp.test.js +121 -1
- package/src/core/plan.js +33 -0
- package/src/core/queueStore.js +27 -0
- package/src/core/queueStore.test.js +32 -0
- package/src/core/wikiWorkspace.test.js +24 -0
- package/src/core/workspaces.js +8 -1
- package/src/runtime/approvals.js +113 -0
- package/src/runtime/auth.js +35 -0
- package/src/runtime/auth.test.js +29 -0
- package/src/runtime/client.js +154 -0
- package/src/runtime/lifecycle.js +84 -0
- package/src/runtime/queueStore.js +6 -0
- package/src/runtime/runner.js +413 -0
- package/src/runtime/runner.test.js +270 -0
- package/src/runtime/server.js +216 -0
- package/src/runtime/server.test.js +308 -0
- package/src/runtime/store.js +431 -0
- package/src/runtime/store.test.js +437 -0
- package/src/runtime/supervisor.js +104 -0
- package/src/runtime/supervisor.test.js +240 -0
- package/src/shell/RightPane.tsx +1 -1
- package/src/shell/repl.js +148 -18
- package/src/shell/repl.test.js +38 -0
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +20 -2
- package/src/shell/useSession.ts +153 -13
- 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
|
+
}
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
-
|
|
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,
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createSignal } from 'solid-js';
|
|
2
|
-
import {
|
|
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
|
}
|