codemini-cli 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,554 +1,555 @@
1
- import { ApprovalManager } from './approval-manager.js';
2
- import { summarizeToolResult } from '../../src/core/tool-result-store.js';
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
- import { getSessionsDir } from '../../src/core/paths.js';
6
-
7
- function webTranscriptPath(sessionId) {
8
- return path.join(getSessionsDir(), 'web-ui-transcripts', `${String(sessionId || 'unknown')}.json`);
9
- }
10
-
11
- function parseToolContent(content) {
12
- if (typeof content !== 'string') return content;
13
- const text = content.trim();
14
- if (!text) return '';
15
- try {
16
- return JSON.parse(text);
17
- } catch {
18
- return text;
19
- }
20
- }
21
-
22
- function summarizeHistoricalToolMessage(message) {
23
- const explicit = String(message?.tool_summary || '').trim();
24
- if (explicit) return explicit;
25
- return summarizeToolResult(parseToolContent(message?.content || ''));
26
- }
27
-
28
- function stripPlanProgressText(text) {
29
- return String(text || '').replace(/(?:^|\n)\[plan\]\s+Step\s+\d+\/\d+\s+->[^\n]*\n?/g, '');
30
- }
31
-
32
- function addToolToSegments(segments, toolCard) {
33
- if (!Array.isArray(segments) || segments.length === 0) return [{ type: 'tools', cards: [toolCard] }];
34
- const last = segments[segments.length - 1];
35
- if (last.type === 'tools') return [...segments.slice(0, -1), { ...last, cards: [...last.cards, toolCard] }];
36
- return [...segments, { type: 'tools', cards: [toolCard] }];
37
- }
38
-
39
- function updateToolInSegments(segments, toolId, updater) {
40
- return (Array.isArray(segments) ? segments : []).map((seg) => {
41
- if (seg.type !== 'tools') return seg;
42
- const idx = seg.cards.findIndex((card) => card.id === toolId);
43
- if (idx === -1) return seg;
44
- const cards = [...seg.cards];
45
- cards[idx] = updater(cards[idx]);
46
- return { ...seg, cards };
47
- });
48
- }
49
-
50
- function appendTextSegment(segments, delta, isStreaming = true) {
51
- const value = String(delta || '');
52
- if (!value) return segments || [];
53
- const current = Array.isArray(segments) ? segments : [];
54
- const last = current[current.length - 1];
55
- if (last?.type === 'text') {
56
- return [
57
- ...current.slice(0, -1),
58
- { ...last, text: `${last.text || ''}${value}`, isStreaming }
59
- ];
60
- }
61
- return [...current, { type: 'text', text: value, isStreaming }];
62
- }
63
-
64
- function createPlanStepUiMessage(event) {
65
- return {
66
- id: `plan-step-${event.step}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
67
- role: event.role || 'general',
68
- text: '',
69
- segments: [],
70
- skillBadges: [],
71
- fileChanges: [],
72
- timestamp: new Date().toISOString(),
73
- planStep: {
74
- step: event.step,
75
- total: event.total,
76
- role: event.role || 'general',
77
- title: event.title || '',
78
- status: 'running',
79
- summary: ''
80
- }
81
- };
82
- }
83
-
84
- export class RuntimeBridge {
85
- #runtime = null;
86
- #clients = new Set();
87
- #approval = new ApprovalManager();
88
- #busy = false;
89
- #startupConsumed = false;
90
- #uiMessages = [];
91
- #uiActiveMsgId = null;
92
- #uiPlanStepIds = new Map();
93
- #uiTranscriptSessionId = '';
94
- #uiPersisting = false;
95
- #uiPersistQueued = false;
96
-
97
- constructor(runtime) {
98
- this.#runtime = runtime;
99
- this.#installApprovalHandler();
100
- this.#uiTranscriptSessionId = this.getSessionId();
101
- runtime.setOnTitleUpdate?.((sessionId, title) => {
102
- this.#broadcast({ type: 'session:title', sessionId, title });
103
- });
104
- }
105
-
106
- #installApprovalHandler() {
107
- this.#runtime.setRequestToolApproval((request) => {
108
- const { id, name, displayName, arguments: args, approvalDetails } = request;
109
- this.#broadcast({ type: 'approval:request', id, toolName: name, displayName, arguments: args, details: approvalDetails });
110
- return this.#approval.create(id);
111
- });
112
- }
113
-
114
- #broadcast(event) {
115
- const data = `data: ${JSON.stringify(event)}\n\n`;
116
- for (const res of this.#clients) {
117
- try { res.write(data); } catch {}
118
- }
119
- }
120
-
121
- #broadcastRuntimeState() {
122
- this.#broadcast({ type: 'runtime:state', state: this.getState() });
123
- }
124
-
125
- broadcastRuntimeState() {
126
- this.#broadcastRuntimeState();
127
- }
128
-
129
- async #writeUiTranscriptSnapshot() {
130
- const sessionId = this.getSessionId();
131
- if (!sessionId) return;
132
- try {
133
- const filePath = webTranscriptPath(sessionId);
134
- await fs.mkdir(path.dirname(filePath), { recursive: true });
135
- await fs.writeFile(filePath, JSON.stringify({
136
- sessionId,
137
- updatedAt: new Date().toISOString(),
138
- messages: this.#uiMessages
139
- }), 'utf8');
140
- } catch {}
141
- }
142
-
143
- #persistUiTranscriptSoon() {
144
- this.#uiPersistQueued = true;
145
- if (this.#uiPersisting) return;
146
- this.#uiPersisting = true;
147
- (async () => {
148
- while (this.#uiPersistQueued) {
149
- this.#uiPersistQueued = false;
150
- await this.#writeUiTranscriptSnapshot();
151
- }
152
- this.#uiPersisting = false;
153
- })();
154
- }
155
-
156
- #resetUiTranscriptIfSessionChanged() {
157
- const sessionId = this.getSessionId();
158
- if (sessionId === this.#uiTranscriptSessionId) return;
159
- this.#uiTranscriptSessionId = sessionId;
160
- this.#uiMessages = [];
161
- this.#uiActiveMsgId = null;
162
- this.#uiPlanStepIds = new Map();
163
- }
164
-
165
- #addUiMessage(message) {
166
- const next = {
167
- ...message,
168
- id: message.id || `ui-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
169
- segments: Array.isArray(message.segments)
170
- ? message.segments
171
- : (message.text ? [{ type: 'text', text: message.text, isStreaming: false }] : []),
172
- skillBadges: Array.isArray(message.skillBadges) ? message.skillBadges : [],
173
- fileChanges: Array.isArray(message.fileChanges) ? message.fileChanges : []
174
- };
175
- this.#uiMessages = [...this.#uiMessages, next];
176
- this.#persistUiTranscriptSoon();
177
- return next.id;
178
- }
179
-
180
- #updateUiMessage(id, mapper) {
181
- if (!id) return;
182
- this.#uiMessages = this.#uiMessages.map((message) => message.id === id ? mapper(message) : message);
183
- this.#persistUiTranscriptSoon();
184
- }
185
-
186
- #removeUiTransientWaiting() {
187
- this.#uiMessages = this.#uiMessages.filter((message) => message.transientKey !== 'waiting-response');
188
- }
189
-
190
- #recordUiEvent(event) {
191
- if (!event?.type) return;
192
- this.#resetUiTranscriptIfSessionChanged();
193
- const activeId = this.#uiActiveMsgId;
194
-
195
- switch (event.type) {
196
- case 'assistant:start': {
197
- this.#removeUiTransientWaiting();
198
- if (!activeId) {
199
- this.#uiActiveMsgId = this.#addUiMessage({
200
- role: 'general',
201
- text: '',
202
- timestamp: new Date().toISOString()
203
- });
204
- }
205
- break;
206
- }
207
- case 'assistant:delta': {
208
- const delta = stripPlanProgressText(event.text);
209
- if (this.#uiActiveMsgId && delta) {
210
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
211
- ...message,
212
- segments: appendTextSegment(message.segments, delta, true)
213
- }));
214
- }
215
- break;
216
- }
217
- case 'assistant:response': {
218
- if (this.#uiActiveMsgId && event.text) {
219
- const text = stripPlanProgressText(event.text);
220
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
221
- ...message,
222
- segments: text ? [{ type: 'text', text, isStreaming: false }] : message.segments
223
- }));
224
- }
225
- break;
226
- }
227
- case 'tool:start': {
228
- if (this.#uiActiveMsgId) {
229
- const toolCard = { id: event.id, name: event.name, arguments: event.arguments, status: 'running', durationMs: null, summary: '', result: '' };
230
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
231
- ...message,
232
- segments: addToolToSegments(message.segments, toolCard)
233
- }));
234
- }
235
- break;
236
- }
237
- case 'tool:end': {
238
- if (this.#uiActiveMsgId) {
239
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
240
- ...message,
241
- segments: updateToolInSegments(message.segments, event.id, (card) => ({
242
- ...card,
243
- status: 'done',
244
- durationMs: event.durationMs,
245
- summary: event.summary || card.summary
246
- }))
247
- }));
248
- }
249
- break;
250
- }
251
- case 'tool:result': {
252
- if (this.#uiActiveMsgId) {
253
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
254
- ...message,
255
- segments: updateToolInSegments(message.segments, event.id, (card) => ({ ...card, result: event.content || '' }))
256
- }));
257
- }
258
- break;
259
- }
260
- case 'tool:error': {
261
- if (this.#uiActiveMsgId) {
262
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
263
- ...message,
264
- segments: updateToolInSegments(message.segments, event.id, (card) => ({
265
- ...card,
266
- status: 'error',
267
- durationMs: event.durationMs,
268
- summary: event.summary || card.summary
269
- }))
270
- }));
271
- }
272
- break;
273
- }
274
- case 'tool:blocked': {
275
- if (this.#uiActiveMsgId) {
276
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
277
- ...message,
278
- segments: updateToolInSegments(message.segments, event.id, (card) => ({
279
- ...card,
280
- status: 'blocked',
281
- summary: card.summary || 'Tool blocked'
282
- }))
283
- }));
284
- }
285
- break;
286
- }
287
- case 'plan:steps': {
288
- if (this.#uiActiveMsgId) {
289
- this.#uiMessages = this.#uiMessages.filter((message) => {
290
- if (message.id !== this.#uiActiveMsgId) return true;
291
- return message.planStep || (Array.isArray(message.segments) && message.segments.length > 0);
292
- });
293
- this.#uiActiveMsgId = null;
294
- this.#persistUiTranscriptSoon();
295
- }
296
- this.#uiPlanStepIds = new Map();
297
- break;
298
- }
299
- case 'plan:step_start': {
300
- const key = String(event.step);
301
- let msgId = this.#uiPlanStepIds.get(key);
302
- if (!msgId) {
303
- const message = createPlanStepUiMessage(event);
304
- msgId = message.id;
305
- this.#uiPlanStepIds.set(key, msgId);
306
- this.#uiMessages = [...this.#uiMessages.filter((message) => message.transientKey !== 'waiting-response'), message];
307
- this.#persistUiTranscriptSoon();
308
- } else {
309
- this.#updateUiMessage(msgId, (message) => ({
310
- ...message,
311
- planStep: { ...(message.planStep || {}), status: 'running' }
312
- }));
313
- }
314
- this.#uiActiveMsgId = msgId;
315
- break;
316
- }
317
- case 'plan:step_done': {
318
- const msgId = this.#uiPlanStepIds.get(String(event.step));
319
- if (msgId) {
320
- this.#updateUiMessage(msgId, (message) => ({
321
- ...message,
322
- segments: message.segments.map((seg) => seg.type === 'text' ? { ...seg, isStreaming: false } : seg),
323
- planStep: {
324
- ...(message.planStep || {}),
325
- status: event.status || 'done',
326
- summary: event.summary || ''
327
- }
328
- }));
329
- }
330
- break;
331
- }
332
- case 'compact:auto': {
333
- this.#addUiMessage({
334
- role: 'divider',
335
- dividerType: 'compact',
336
- text: `以上内容已压缩 (${event.mode || ''}, ${event.threshold || ''}%)`,
337
- timestamp: new Date().toISOString()
338
- });
339
- break;
340
- }
341
- default:
342
- break;
343
- }
344
- }
345
-
346
- addClient(res) {
347
- res.writeHead(200, {
348
- 'Content-Type': 'text/event-stream',
349
- 'Cache-Control': 'no-cache',
350
- Connection: 'keep-alive',
351
- 'X-Accel-Buffering': 'no'
352
- });
353
- res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
354
- this.#clients.add(res);
355
- res.on('close', () => this.#clients.delete(res));
356
- }
357
-
358
- async handleStartupEvents() {
359
- if (this.#startupConsumed) return [];
360
- this.#startupConsumed = true;
361
- return this.#runtime.consumeStartupEvents();
362
- }
363
-
364
- handleSubmit(line, options = {}) {
365
- if (this.#busy) return { error: true, message: 'A request is already in progress' };
366
- this.#resetUiTranscriptIfSessionChanged();
367
- const trimmed = String(line || '').trim();
368
- const lower = trimmed.toLowerCase();
369
- const planControl = ['/yes', '/plan approve', '/no', '/reject'].includes(lower) || lower.startsWith('/edit ');
370
- if (!options?.readOnlyCodeWiki && trimmed && !planControl) {
371
- this.#addUiMessage({
372
- role: 'you',
373
- text: line,
374
- timestamp: new Date().toISOString()
375
- });
376
- }
377
- this.#busy = true;
378
- this.#runtime.submit(line, (event) => {
379
- this.#recordUiEvent(event);
380
- this.#broadcast(event);
381
- }, options).then((result) => {
382
- if (this.#uiActiveMsgId) {
383
- this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
384
- ...message,
385
- segments: message.segments.map((seg) => seg.type === 'text' ? { ...seg, isStreaming: false } : seg)
386
- }));
387
- }
388
- this.#uiActiveMsgId = null;
389
- this.#uiPlanStepIds = new Map();
390
- this.#broadcast({ type: 'submit:done', result: { type: result.type, aborted: result.aborted, text: result.text } });
391
- }).catch((err) => {
392
- this.#addUiMessage({
393
- role: 'error',
394
- text: `Failed: ${err.message}`,
395
- timestamp: new Date().toISOString()
396
- });
397
- this.#broadcast({ type: 'submit:done', result: { type: 'error', text: err.message } });
398
- }).finally(() => {
399
- this.#busy = false;
400
- this.#broadcastRuntimeState();
401
- });
402
- return { accepted: true };
403
- }
404
-
405
- async handleCodeWikiAsk(line, onEvent = null) {
406
- if (this.#busy) return { error: true, message: 'A request is already in progress' };
407
- this.#busy = true;
408
- const emit = (event) => {
409
- if (typeof onEvent === 'function' && event?.type) onEvent(event);
410
- };
411
- try {
412
- const result = await this.#runtime.submit(line, emit, { readOnlyCodeWiki: true });
413
- const payload = {
414
- ok: true,
415
- type: result?.type || 'assistant',
416
- text: result?.text || '',
417
- aborted: !!result?.aborted
418
- };
419
- emit({ type: 'codewiki:done', result: payload });
420
- return payload;
421
- } catch (err) {
422
- const payload = { error: true, message: err?.message || 'Request failed' };
423
- emit({ type: 'codewiki:error', message: payload.message });
424
- return payload;
425
- } finally {
426
- this.#busy = false;
427
- }
428
- }
429
-
430
- isBusy() {
431
- return this.#busy;
432
- }
433
-
434
- handleAbort() {
435
- return this.#runtime.abort();
436
- }
437
-
438
- async setExecutionMode(mode) {
439
- if (this.#busy) return false;
440
- const ok = await this.#runtime.setExecutionMode(mode);
441
- if (ok) this.#broadcast({ type: 'mode:changed', mode, ...this.getState() });
442
- return ok;
443
- }
444
-
1
+ import { ApprovalManager } from './approval-manager.js';
2
+ import { summarizeToolResult } from '../../src/core/tool-result-store.js';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { getSessionsDir } from '../../src/core/paths.js';
6
+
7
+ function webTranscriptPath(sessionId) {
8
+ return path.join(getSessionsDir(), 'web-ui-transcripts', `${String(sessionId || 'unknown')}.json`);
9
+ }
10
+
11
+ function parseToolContent(content) {
12
+ if (typeof content !== 'string') return content;
13
+ const text = content.trim();
14
+ if (!text) return '';
15
+ try {
16
+ return JSON.parse(text);
17
+ } catch {
18
+ return text;
19
+ }
20
+ }
21
+
22
+ function summarizeHistoricalToolMessage(message) {
23
+ const explicit = String(message?.tool_summary || '').trim();
24
+ if (explicit) return explicit;
25
+ return summarizeToolResult(parseToolContent(message?.content || ''));
26
+ }
27
+
28
+ function stripPlanProgressText(text) {
29
+ return String(text || '').replace(/(?:^|\n)\[plan\]\s+Step\s+\d+\/\d+\s+->[^\n]*\n?/g, '');
30
+ }
31
+
32
+ function addToolToSegments(segments, toolCard) {
33
+ if (!Array.isArray(segments) || segments.length === 0) return [{ type: 'tools', cards: [toolCard] }];
34
+ const last = segments[segments.length - 1];
35
+ if (last.type === 'tools') return [...segments.slice(0, -1), { ...last, cards: [...last.cards, toolCard] }];
36
+ return [...segments, { type: 'tools', cards: [toolCard] }];
37
+ }
38
+
39
+ function updateToolInSegments(segments, toolId, updater) {
40
+ return (Array.isArray(segments) ? segments : []).map((seg) => {
41
+ if (seg.type !== 'tools') return seg;
42
+ const idx = seg.cards.findIndex((card) => card.id === toolId);
43
+ if (idx === -1) return seg;
44
+ const cards = [...seg.cards];
45
+ cards[idx] = updater(cards[idx]);
46
+ return { ...seg, cards };
47
+ });
48
+ }
49
+
50
+ function appendTextSegment(segments, delta, isStreaming = true) {
51
+ const value = String(delta || '');
52
+ if (!value) return segments || [];
53
+ const current = Array.isArray(segments) ? segments : [];
54
+ const last = current[current.length - 1];
55
+ if (last?.type === 'text') {
56
+ return [
57
+ ...current.slice(0, -1),
58
+ { ...last, text: `${last.text || ''}${value}`, isStreaming }
59
+ ];
60
+ }
61
+ return [...current, { type: 'text', text: value, isStreaming }];
62
+ }
63
+
64
+ function createPlanStepUiMessage(event) {
65
+ return {
66
+ id: `plan-step-${event.step}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
67
+ role: event.role || 'general',
68
+ text: '',
69
+ segments: [],
70
+ skillBadges: [],
71
+ fileChanges: [],
72
+ timestamp: new Date().toISOString(),
73
+ planStep: {
74
+ step: event.step,
75
+ total: event.total,
76
+ role: event.role || 'general',
77
+ title: event.title || '',
78
+ status: 'running',
79
+ summary: ''
80
+ }
81
+ };
82
+ }
83
+
84
+ export class RuntimeBridge {
85
+ #runtime = null;
86
+ #clients = new Set();
87
+ #approval = new ApprovalManager();
88
+ #busy = false;
89
+ #startupConsumed = false;
90
+ #uiMessages = [];
91
+ #uiActiveMsgId = null;
92
+ #uiPlanStepIds = new Map();
93
+ #uiTranscriptSessionId = '';
94
+ #uiPersisting = false;
95
+ #uiPersistQueued = false;
96
+
97
+ constructor(runtime) {
98
+ this.#runtime = runtime;
99
+ this.#installApprovalHandler();
100
+ this.#uiTranscriptSessionId = this.getSessionId();
101
+ runtime.setOnTitleUpdate?.((sessionId, title) => {
102
+ this.#broadcast({ type: 'session:title', sessionId, title });
103
+ });
104
+ }
105
+
106
+ #installApprovalHandler() {
107
+ this.#runtime.setRequestToolApproval((request) => {
108
+ const { id, name, displayName, arguments: args, approvalDetails } = request;
109
+ this.#broadcast({ type: 'approval:request', id, toolName: name, displayName, arguments: args, details: approvalDetails });
110
+ return this.#approval.create(id);
111
+ });
112
+ }
113
+
114
+ #broadcast(event) {
115
+ const data = `data: ${JSON.stringify(event)}\n\n`;
116
+ for (const res of this.#clients) {
117
+ try { res.write(data); } catch {}
118
+ }
119
+ }
120
+
121
+ #broadcastRuntimeState() {
122
+ this.#broadcast({ type: 'runtime:state', state: this.getState() });
123
+ }
124
+
125
+ broadcastRuntimeState() {
126
+ this.#broadcastRuntimeState();
127
+ }
128
+
129
+ async #writeUiTranscriptSnapshot() {
130
+ const sessionId = this.getSessionId();
131
+ if (!sessionId) return;
132
+ try {
133
+ const filePath = webTranscriptPath(sessionId);
134
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
135
+ await fs.writeFile(filePath, JSON.stringify({
136
+ sessionId,
137
+ updatedAt: new Date().toISOString(),
138
+ messages: this.#uiMessages
139
+ }), 'utf8');
140
+ } catch {}
141
+ }
142
+
143
+ #persistUiTranscriptSoon() {
144
+ this.#uiPersistQueued = true;
145
+ if (this.#uiPersisting) return;
146
+ this.#uiPersisting = true;
147
+ (async () => {
148
+ while (this.#uiPersistQueued) {
149
+ this.#uiPersistQueued = false;
150
+ await this.#writeUiTranscriptSnapshot();
151
+ }
152
+ this.#uiPersisting = false;
153
+ })();
154
+ }
155
+
156
+ #resetUiTranscriptIfSessionChanged() {
157
+ const sessionId = this.getSessionId();
158
+ if (sessionId === this.#uiTranscriptSessionId) return;
159
+ this.#uiTranscriptSessionId = sessionId;
160
+ this.#uiMessages = [];
161
+ this.#uiActiveMsgId = null;
162
+ this.#uiPlanStepIds = new Map();
163
+ }
164
+
165
+ #addUiMessage(message) {
166
+ const next = {
167
+ ...message,
168
+ id: message.id || `ui-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
169
+ segments: Array.isArray(message.segments)
170
+ ? message.segments
171
+ : (message.text ? [{ type: 'text', text: message.text, isStreaming: false }] : []),
172
+ skillBadges: Array.isArray(message.skillBadges) ? message.skillBadges : [],
173
+ fileChanges: Array.isArray(message.fileChanges) ? message.fileChanges : []
174
+ };
175
+ this.#uiMessages = [...this.#uiMessages, next];
176
+ this.#persistUiTranscriptSoon();
177
+ return next.id;
178
+ }
179
+
180
+ #updateUiMessage(id, mapper) {
181
+ if (!id) return;
182
+ this.#uiMessages = this.#uiMessages.map((message) => message.id === id ? mapper(message) : message);
183
+ this.#persistUiTranscriptSoon();
184
+ }
185
+
186
+ #removeUiTransientWaiting() {
187
+ this.#uiMessages = this.#uiMessages.filter((message) => message.transientKey !== 'waiting-response');
188
+ }
189
+
190
+ #recordUiEvent(event) {
191
+ if (!event?.type) return;
192
+ this.#resetUiTranscriptIfSessionChanged();
193
+ const activeId = this.#uiActiveMsgId;
194
+
195
+ switch (event.type) {
196
+ case 'assistant:start': {
197
+ this.#removeUiTransientWaiting();
198
+ if (!activeId) {
199
+ this.#uiActiveMsgId = this.#addUiMessage({
200
+ role: 'general',
201
+ text: '',
202
+ timestamp: new Date().toISOString()
203
+ });
204
+ }
205
+ break;
206
+ }
207
+ case 'assistant:delta': {
208
+ const delta = stripPlanProgressText(event.text);
209
+ if (this.#uiActiveMsgId && delta) {
210
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
211
+ ...message,
212
+ segments: appendTextSegment(message.segments, delta, true)
213
+ }));
214
+ }
215
+ break;
216
+ }
217
+ case 'assistant:response': {
218
+ if (this.#uiActiveMsgId && event.text) {
219
+ const text = stripPlanProgressText(event.text);
220
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
221
+ ...message,
222
+ segments: text ? [{ type: 'text', text, isStreaming: false }] : message.segments
223
+ }));
224
+ }
225
+ break;
226
+ }
227
+ case 'tool:start': {
228
+ if (this.#uiActiveMsgId) {
229
+ const toolCard = { id: event.id, name: event.name, arguments: event.arguments, status: 'running', durationMs: null, summary: '', result: '' };
230
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
231
+ ...message,
232
+ segments: addToolToSegments(message.segments, toolCard)
233
+ }));
234
+ }
235
+ break;
236
+ }
237
+ case 'tool:end': {
238
+ if (this.#uiActiveMsgId) {
239
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
240
+ ...message,
241
+ segments: updateToolInSegments(message.segments, event.id, (card) => ({
242
+ ...card,
243
+ status: 'done',
244
+ durationMs: event.durationMs,
245
+ summary: event.summary || card.summary
246
+ }))
247
+ }));
248
+ }
249
+ break;
250
+ }
251
+ case 'tool:result': {
252
+ if (this.#uiActiveMsgId) {
253
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
254
+ ...message,
255
+ segments: updateToolInSegments(message.segments, event.id, (card) => ({ ...card, result: event.content || '' }))
256
+ }));
257
+ }
258
+ break;
259
+ }
260
+ case 'tool:error': {
261
+ if (this.#uiActiveMsgId) {
262
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
263
+ ...message,
264
+ segments: updateToolInSegments(message.segments, event.id, (card) => ({
265
+ ...card,
266
+ status: 'error',
267
+ durationMs: event.durationMs,
268
+ summary: event.summary || card.summary
269
+ }))
270
+ }));
271
+ }
272
+ break;
273
+ }
274
+ case 'tool:blocked': {
275
+ if (this.#uiActiveMsgId) {
276
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
277
+ ...message,
278
+ segments: updateToolInSegments(message.segments, event.id, (card) => ({
279
+ ...card,
280
+ status: 'blocked',
281
+ summary: card.summary || 'Tool blocked'
282
+ }))
283
+ }));
284
+ }
285
+ break;
286
+ }
287
+ case 'plan:steps': {
288
+ if (this.#uiActiveMsgId) {
289
+ this.#uiMessages = this.#uiMessages.filter((message) => {
290
+ if (message.id !== this.#uiActiveMsgId) return true;
291
+ return message.planStep || (Array.isArray(message.segments) && message.segments.length > 0);
292
+ });
293
+ this.#uiActiveMsgId = null;
294
+ this.#persistUiTranscriptSoon();
295
+ }
296
+ this.#uiPlanStepIds = new Map();
297
+ break;
298
+ }
299
+ case 'plan:step_start': {
300
+ const key = String(event.step);
301
+ let msgId = this.#uiPlanStepIds.get(key);
302
+ if (!msgId) {
303
+ const message = createPlanStepUiMessage(event);
304
+ msgId = message.id;
305
+ this.#uiPlanStepIds.set(key, msgId);
306
+ this.#uiMessages = [...this.#uiMessages.filter((message) => message.transientKey !== 'waiting-response'), message];
307
+ this.#persistUiTranscriptSoon();
308
+ } else {
309
+ this.#updateUiMessage(msgId, (message) => ({
310
+ ...message,
311
+ planStep: { ...(message.planStep || {}), status: 'running' }
312
+ }));
313
+ }
314
+ this.#uiActiveMsgId = msgId;
315
+ break;
316
+ }
317
+ case 'plan:step_done': {
318
+ const msgId = this.#uiPlanStepIds.get(String(event.step));
319
+ if (msgId) {
320
+ this.#updateUiMessage(msgId, (message) => ({
321
+ ...message,
322
+ segments: message.segments.map((seg) => seg.type === 'text' ? { ...seg, isStreaming: false } : seg),
323
+ planStep: {
324
+ ...(message.planStep || {}),
325
+ status: event.status || 'done',
326
+ summary: event.summary || ''
327
+ }
328
+ }));
329
+ }
330
+ break;
331
+ }
332
+ case 'compact:auto': {
333
+ this.#addUiMessage({
334
+ role: 'divider',
335
+ dividerType: 'compact',
336
+ text: `以上内容已压缩 (${event.mode || ''}, ${event.threshold || ''}%)`,
337
+ timestamp: new Date().toISOString()
338
+ });
339
+ break;
340
+ }
341
+ default:
342
+ break;
343
+ }
344
+ }
345
+
346
+ addClient(res) {
347
+ res.writeHead(200, {
348
+ 'Content-Type': 'text/event-stream',
349
+ 'Cache-Control': 'no-cache',
350
+ Connection: 'keep-alive',
351
+ 'X-Accel-Buffering': 'no'
352
+ });
353
+ res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
354
+ this.#clients.add(res);
355
+ res.on('close', () => this.#clients.delete(res));
356
+ }
357
+
358
+ async handleStartupEvents() {
359
+ if (this.#startupConsumed) return [];
360
+ this.#startupConsumed = true;
361
+ return this.#runtime.consumeStartupEvents();
362
+ }
363
+
364
+ handleSubmit(line, options = {}) {
365
+ if (this.#busy) return { error: true, message: 'A request is already in progress' };
366
+ this.#resetUiTranscriptIfSessionChanged();
367
+ const trimmed = String(line || '').trim();
368
+ const lower = trimmed.toLowerCase();
369
+ const planControl = ['/yes', '/plan approve', '/no', '/reject'].includes(lower) || lower.startsWith('/edit ');
370
+ if (!options?.readOnlyCodeWiki && trimmed && !planControl) {
371
+ this.#addUiMessage({
372
+ role: 'you',
373
+ text: line,
374
+ timestamp: new Date().toISOString()
375
+ });
376
+ }
377
+ this.#busy = true;
378
+ this.#runtime.submit(line, (event) => {
379
+ this.#recordUiEvent(event);
380
+ this.#broadcast(event);
381
+ }, options).then((result) => {
382
+ if (this.#uiActiveMsgId) {
383
+ this.#updateUiMessage(this.#uiActiveMsgId, (message) => ({
384
+ ...message,
385
+ segments: message.segments.map((seg) => seg.type === 'text' ? { ...seg, isStreaming: false } : seg)
386
+ }));
387
+ }
388
+ this.#uiActiveMsgId = null;
389
+ this.#uiPlanStepIds = new Map();
390
+ this.#broadcast({ type: 'submit:done', result: { type: result.type, aborted: result.aborted, text: result.text } });
391
+ }).catch((err) => {
392
+ this.#addUiMessage({
393
+ role: 'error',
394
+ text: `Failed: ${err.message}`,
395
+ timestamp: new Date().toISOString()
396
+ });
397
+ this.#broadcast({ type: 'submit:done', result: { type: 'error', text: err.message } });
398
+ }).finally(() => {
399
+ this.#busy = false;
400
+ this.#broadcastRuntimeState();
401
+ });
402
+ return { accepted: true };
403
+ }
404
+
405
+ async handleCodeWikiAsk(line, onEvent = null) {
406
+ if (this.#busy) return { error: true, message: 'A request is already in progress' };
407
+ this.#busy = true;
408
+ const emit = (event) => {
409
+ if (typeof onEvent === 'function' && event?.type) onEvent(event);
410
+ };
411
+ try {
412
+ const result = await this.#runtime.submit(line, emit, { readOnlyCodeWiki: true });
413
+ const payload = {
414
+ ok: true,
415
+ type: result?.type || 'assistant',
416
+ text: result?.text || '',
417
+ aborted: !!result?.aborted
418
+ };
419
+ emit({ type: 'codewiki:done', result: payload });
420
+ return payload;
421
+ } catch (err) {
422
+ const payload = { error: true, message: err?.message || 'Request failed' };
423
+ emit({ type: 'codewiki:error', message: payload.message });
424
+ return payload;
425
+ } finally {
426
+ this.#busy = false;
427
+ }
428
+ }
429
+
430
+ isBusy() {
431
+ return this.#busy;
432
+ }
433
+
434
+ handleAbort() {
435
+ return this.#runtime.abort();
436
+ }
437
+
438
+ async setExecutionMode(mode) {
439
+ if (this.#busy) return false;
440
+ const ok = await this.#runtime.setExecutionMode(mode);
441
+ if (ok) this.#broadcast({ type: 'mode:changed', mode, ...this.getState() });
442
+ return ok;
443
+ }
444
+
445
445
  async reloadConfig(options = {}) {
446
446
  return this.#runtime.reloadConfig?.(options);
447
447
  }
448
-
449
- handleApproval(id, approved) {
450
- return this.#approval.resolve(id, approved);
451
- }
452
-
453
- getState() {
454
- const state = this.#runtime.getRuntimeState();
455
- const serializableState = typeof state?.toJSON === 'function' ? state.toJSON() : state;
456
- return {
448
+
449
+ handleApproval(id, approved) {
450
+ return this.#approval.resolve(id, approved);
451
+ }
452
+
453
+ getState() {
454
+ const state = this.#runtime.getRuntimeState();
455
+ const serializableState = typeof state?.toJSON === 'function' ? state.toJSON() : state;
456
+ return {
457
457
  ...serializableState,
458
458
  busy: this.#busy,
459
459
  requestInFlight: this.#busy,
460
- pendingPlanApproval: this.#busy ? null : state.pendingPlanApproval
460
+ pendingPlanApproval: this.#busy ? null : serializableState.pendingPlanApproval,
461
+ pendingReflectSkill: this.#busy ? null : serializableState.pendingReflectSkill
461
462
  };
462
463
  }
463
-
464
- getSessionMessages() {
465
- const messages = this.#runtime.getSessionMessages();
466
- if (!Array.isArray(messages)) return [];
467
- return messages
468
- .filter(m => m.role !== 'system')
469
- .map(m => ({
470
- role: m.role,
471
- content: typeof m.content === 'string' ? m.content : (Array.isArray(m.content) ? m.content.map(c => c.text || '').join('') : ''),
472
- toolCalls: m.tool_calls || [],
473
- toolCallId: m.tool_call_id || null,
474
- toolSummary: m.role === 'tool' ? summarizeHistoricalToolMessage(m) : null,
475
- toolDurationMs: Number.isFinite(Number(m.tool_duration_ms)) ? Number(m.tool_duration_ms) : null,
476
- toolStatus: m.tool_status || null,
477
- planTranscript: Array.isArray(m.plan_transcript) ? m.plan_transcript : null,
478
- at: m.at || null
479
- }));
480
- }
481
-
482
- getSessionCompactMeta() {
483
- const compact = this.#runtime.getSessionCompact();
484
- if (!compact) return null;
485
- return { boundaryIndex: compact.boundaryIndex, mode: compact.mode, timestamp: compact.timestamp };
486
- }
487
-
488
- async getUiMessages() {
489
- this.#resetUiTranscriptIfSessionChanged();
490
- if (this.#uiMessages.length > 0) return this.#uiMessages;
491
- const sessionId = this.getSessionId();
492
- if (!sessionId) return [];
493
- try {
494
- const raw = await fs.readFile(webTranscriptPath(sessionId), 'utf8');
495
- const parsed = JSON.parse(raw);
496
- return Array.isArray(parsed?.messages) ? parsed.messages : [];
497
- } catch {
498
- return [];
499
- }
500
- }
501
-
502
- getCompletions(input) {
503
- return this.#runtime.getCompletionOptions(input);
504
- }
505
-
506
- getHistory() {
507
- return this.#runtime.getInputHistory();
508
- }
509
-
510
- getCommands() {
511
- return this.#runtime.listCommandNames();
512
- }
513
-
514
- getSessionId() {
515
- return this.#runtime.getCurrentSessionId();
516
- }
517
-
518
- get busy() { return this.#busy; }
519
-
520
- get runtime() { return this.#runtime; }
521
-
522
- async switchRuntime(newRuntime) {
523
- // Abort anything in-flight
524
- if (this.#busy) {
525
- try { this.#runtime.abort(); } catch {}
526
- this.#busy = false;
527
- }
528
- // Dispose old runtime
529
- try { await this.#runtime.dispose?.(); } catch {}
530
- // Swap
531
- this.#runtime = newRuntime;
532
- this.#startupConsumed = false;
533
- this.#approval = new ApprovalManager();
534
- this.#uiMessages = [];
535
- this.#uiActiveMsgId = null;
536
- this.#uiPlanStepIds = new Map();
537
- this.#uiTranscriptSessionId = newRuntime.getCurrentSessionId?.() || '';
538
- this.#installApprovalHandler();
539
- // Push title updates via SSE
540
- newRuntime.setOnTitleUpdate?.((sessionId, title) => {
541
- this.#broadcast({ type: 'session:title', sessionId, title });
542
- });
543
- // Notify clients
544
- this.#broadcast({ type: 'runtime:switched', sessionId: newRuntime.getCurrentSessionId?.() });
545
- }
546
-
547
- async dispose() {
548
- for (const res of this.#clients) {
549
- try { res.end(); } catch {}
550
- }
551
- this.#clients.clear();
552
- await this.#runtime.dispose?.();
553
- }
554
- }
464
+
465
+ getSessionMessages() {
466
+ const messages = this.#runtime.getSessionMessages();
467
+ if (!Array.isArray(messages)) return [];
468
+ return messages
469
+ .filter(m => m.role !== 'system')
470
+ .map(m => ({
471
+ role: m.role,
472
+ content: typeof m.content === 'string' ? m.content : (Array.isArray(m.content) ? m.content.map(c => c.text || '').join('') : ''),
473
+ toolCalls: m.tool_calls || [],
474
+ toolCallId: m.tool_call_id || null,
475
+ toolSummary: m.role === 'tool' ? summarizeHistoricalToolMessage(m) : null,
476
+ toolDurationMs: Number.isFinite(Number(m.tool_duration_ms)) ? Number(m.tool_duration_ms) : null,
477
+ toolStatus: m.tool_status || null,
478
+ planTranscript: Array.isArray(m.plan_transcript) ? m.plan_transcript : null,
479
+ at: m.at || null
480
+ }));
481
+ }
482
+
483
+ getSessionCompactMeta() {
484
+ const compact = this.#runtime.getSessionCompact();
485
+ if (!compact) return null;
486
+ return { boundaryIndex: compact.boundaryIndex, mode: compact.mode, timestamp: compact.timestamp };
487
+ }
488
+
489
+ async getUiMessages() {
490
+ this.#resetUiTranscriptIfSessionChanged();
491
+ if (this.#uiMessages.length > 0) return this.#uiMessages;
492
+ const sessionId = this.getSessionId();
493
+ if (!sessionId) return [];
494
+ try {
495
+ const raw = await fs.readFile(webTranscriptPath(sessionId), 'utf8');
496
+ const parsed = JSON.parse(raw);
497
+ return Array.isArray(parsed?.messages) ? parsed.messages : [];
498
+ } catch {
499
+ return [];
500
+ }
501
+ }
502
+
503
+ getCompletions(input) {
504
+ return this.#runtime.getCompletionOptions(input);
505
+ }
506
+
507
+ getHistory() {
508
+ return this.#runtime.getInputHistory();
509
+ }
510
+
511
+ getCommands() {
512
+ return this.#runtime.listCommandNames();
513
+ }
514
+
515
+ getSessionId() {
516
+ return this.#runtime.getCurrentSessionId();
517
+ }
518
+
519
+ get busy() { return this.#busy; }
520
+
521
+ get runtime() { return this.#runtime; }
522
+
523
+ async switchRuntime(newRuntime) {
524
+ // Abort anything in-flight
525
+ if (this.#busy) {
526
+ try { this.#runtime.abort(); } catch {}
527
+ this.#busy = false;
528
+ }
529
+ // Dispose old runtime
530
+ try { await this.#runtime.dispose?.(); } catch {}
531
+ // Swap
532
+ this.#runtime = newRuntime;
533
+ this.#startupConsumed = false;
534
+ this.#approval = new ApprovalManager();
535
+ this.#uiMessages = [];
536
+ this.#uiActiveMsgId = null;
537
+ this.#uiPlanStepIds = new Map();
538
+ this.#uiTranscriptSessionId = newRuntime.getCurrentSessionId?.() || '';
539
+ this.#installApprovalHandler();
540
+ // Push title updates via SSE
541
+ newRuntime.setOnTitleUpdate?.((sessionId, title) => {
542
+ this.#broadcast({ type: 'session:title', sessionId, title });
543
+ });
544
+ // Notify clients
545
+ this.#broadcast({ type: 'runtime:switched', sessionId: newRuntime.getCurrentSessionId?.() });
546
+ }
547
+
548
+ async dispose() {
549
+ for (const res of this.#clients) {
550
+ try { res.end(); } catch {}
551
+ }
552
+ this.#clients.clear();
553
+ await this.#runtime.dispose?.();
554
+ }
555
+ }