@adhdev/daemon-core 0.8.72 → 0.8.74

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.
@@ -356,6 +356,8 @@ export interface ProviderModule {
356
356
  resume?: ProviderResumeCapability;
357
357
  /** Session ID probe config — auto-discovers provider session ID from local SQLite DB */
358
358
  sessionProbe?: ProviderSessionProbe;
359
+ /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
360
+ allowInputDuringGeneration?: boolean;
359
361
  /** Approval button priority hints used when auto-approve must pick a positive action */
360
362
  approvalPositiveHints?: string[];
361
363
  scripts?: ProviderScripts;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.8.72",
4
- "description": "ADHDev local session host core session registry, protocol, buffers",
3
+ "version": "0.8.74",
4
+ "description": "ADHDev local session host core \u2014 session registry, protocol, buffers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.8.72",
4
- "description": "ADHDev daemon core CDP, IDE detection, providers, command execution",
3
+ "version": "0.8.74",
4
+ "description": "ADHDev daemon core \u2014 CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
@@ -10,7 +10,11 @@ import {
10
10
  buildSessionModalDeliverySignature,
11
11
  } from './chat-signatures.js'
12
12
 
13
- export interface ChatTailSubscriptionCursor extends Pick<ReadChatCursor, 'knownMessageCount' | 'lastMessageSignature' | 'tailLimit'> {}
13
+ export interface ChatTailSubscriptionCursor {
14
+ knownMessageCount: number
15
+ lastMessageSignature: string
16
+ tailLimit: number
17
+ }
14
18
 
15
19
  export type SessionChatTailCommandResult = Partial<Omit<ReadChatSyncResult, 'activeModal'>> & {
16
20
  success?: boolean
@@ -1439,6 +1439,10 @@ export class ProviderCliAdapter implements CliAdapter {
1439
1439
 
1440
1440
  async sendMessage(text: string): Promise<void> {
1441
1441
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1442
+ const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
1443
+ const allowInterventionPrompt = allowInputDuringGeneration
1444
+ && this.isWaitingForResponse
1445
+ && this.currentStatus !== 'waiting_approval';
1442
1446
  if (this.startupParseGate) {
1443
1447
  const deadline = Date.now() + 10000;
1444
1448
  while (this.startupParseGate && Date.now() < deadline) {
@@ -1446,7 +1450,9 @@ export class ProviderCliAdapter implements CliAdapter {
1446
1450
  await new Promise(resolve => setTimeout(resolve, 50));
1447
1451
  }
1448
1452
  }
1449
- await this.waitForInteractivePrompt();
1453
+ if (!allowInterventionPrompt) {
1454
+ await this.waitForInteractivePrompt();
1455
+ }
1450
1456
  if (!this.ready) {
1451
1457
  this.resolveStartupState('send_precheck');
1452
1458
  const screenText = this.terminalScreen.getText() || '';
@@ -1458,7 +1464,7 @@ export class ProviderCliAdapter implements CliAdapter {
1458
1464
  }
1459
1465
  }
1460
1466
  if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.currentStatus})`);
1461
- if (this.isWaitingForResponse) {
1467
+ if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1462
1468
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1463
1469
  }
1464
1470
  const blockingModal = this.activeModal || this.getStartupConfirmationModal(this.terminalScreen.getText() || '');
@@ -105,6 +105,8 @@ export interface CliProviderModule {
105
105
  sendDelayMs?: number;
106
106
  sendKey?: string;
107
107
  submitStrategy?: 'wait_for_echo' | 'immediate';
108
+ /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
109
+ allowInputDuringGeneration?: boolean;
108
110
  scripts?: CliScripts;
109
111
  spawn: {
110
112
  command: string;
@@ -110,6 +110,8 @@ export interface CliProviderModule {
110
110
  sendDelayMs?: number;
111
111
  sendKey?: string;
112
112
  submitStrategy?: 'wait_for_echo' | 'immediate';
113
+ /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
114
+ allowInputDuringGeneration?: boolean;
113
115
  scripts?: CliScripts;
114
116
  spawn: {
115
117
  command: string;
@@ -175,6 +175,48 @@ function normalizeReadChatMessages(payload: Record<string, any>): ChatMessage[]
175
175
  return normalizeChatMessages(messages);
176
176
  }
177
177
 
178
+ function buildReadChatReplayCollapseSignature(message: ChatMessage | null | undefined): string {
179
+ if (!message) return '';
180
+ const role = typeof message.role === 'string' ? message.role.trim().toLowerCase() : '';
181
+ const kind = typeof message.kind === 'string' ? message.kind.trim().toLowerCase() : 'standard';
182
+ const senderName = typeof message.senderName === 'string' ? message.senderName.trim().toLowerCase() : '';
183
+ const content = flattenContent(message.content || '').replace(/\s+/g, ' ').trim();
184
+ return `${role}:${kind}:${senderName}:${content}`;
185
+ }
186
+
187
+ function shouldCollapseReadChatReplayDuplicate(message: ChatMessage | null | undefined): boolean {
188
+ if (!message) return false;
189
+ const role = typeof message.role === 'string' ? message.role.trim().toLowerCase() : '';
190
+ if (role !== 'assistant' && role !== 'system') return false;
191
+ const kind = typeof message.kind === 'string' ? message.kind.trim().toLowerCase() : 'standard';
192
+ return kind === 'tool' || kind === 'terminal' || kind === 'thought' || kind === 'system';
193
+ }
194
+
195
+ function collapseReplayDuplicatesFromReadChat(messages: ChatMessage[]): ChatMessage[] {
196
+ const collapsed: ChatMessage[] = [];
197
+ let lastReplayTurnSignature = '';
198
+
199
+ for (const message of messages) {
200
+ const signature = buildReadChatReplayCollapseSignature(message);
201
+ const previous = collapsed[collapsed.length - 1];
202
+ const previousSignature = buildReadChatReplayCollapseSignature(previous);
203
+
204
+ if (shouldCollapseReadChatReplayDuplicate(message) && signature) {
205
+ if (previousSignature === signature) continue;
206
+ if (lastReplayTurnSignature === signature) continue;
207
+ }
208
+
209
+ collapsed.push(message);
210
+ if (shouldCollapseReadChatReplayDuplicate(message) && signature) {
211
+ lastReplayTurnSignature = signature;
212
+ } else if ((message.role || '').toLowerCase() === 'user') {
213
+ lastReplayTurnSignature = '';
214
+ }
215
+ }
216
+
217
+ return collapsed;
218
+ }
219
+
178
220
  function deriveHistoryDedupKey(message: ChatMessage & { _unitKey?: string; _turnKey?: string }): string | undefined {
179
221
  const unitKey = typeof message._unitKey === 'string' ? message._unitKey.trim() : '';
180
222
  if (unitKey) return `read_chat:${unitKey}`;
@@ -273,14 +315,40 @@ function computeReadChatSync(messages: ChatMessage[], cursor: Required<ReadChatC
273
315
  };
274
316
  }
275
317
 
318
+ function hasNonEmptyModalButtons(activeModal: unknown): boolean {
319
+ if (!activeModal || typeof activeModal !== 'object') return false;
320
+ const buttons = (activeModal as { buttons?: unknown }).buttons;
321
+ return Array.isArray(buttons) && buttons.some((button) => typeof button === 'string' && button.trim().length > 0);
322
+ }
323
+
324
+ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown): string {
325
+ const raw = typeof status === 'string' ? status.trim() : '';
326
+ if (!raw) {
327
+ return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'idle';
328
+ }
329
+ switch (raw) {
330
+ case 'starting':
331
+ return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'generating';
332
+ case 'stopped':
333
+ case 'disconnected':
334
+ case 'not_monitored':
335
+ return 'error';
336
+ default:
337
+ return raw;
338
+ }
339
+ }
340
+
276
341
  function buildReadChatCommandResult(payload: Record<string, any>, args: any): CommandResult {
277
342
  let validatedPayload: Record<string, any>;
278
343
  try {
279
- validatedPayload = validateReadChatResultPayload(payload, 'read_chat command result') as Record<string, any>;
344
+ validatedPayload = validateReadChatResultPayload({
345
+ ...payload,
346
+ status: normalizeReadChatCommandStatus(payload?.status, payload?.activeModal),
347
+ }, 'read_chat command result') as Record<string, any>;
280
348
  } catch (error: any) {
281
349
  return { success: false, error: error?.message || String(error) };
282
350
  }
283
- const messages = normalizeReadChatMessages(validatedPayload);
351
+ const messages = collapseReplayDuplicatesFromReadChat(normalizeReadChatMessages(validatedPayload));
284
352
  const cursor = normalizeReadChatCursor(args);
285
353
  if (!cursor.knownMessageCount && !cursor.lastMessageSignature && cursor.tailLimit > 0 && messages.length > cursor.tailLimit) {
286
354
  const tailMessages = messages.slice(-cursor.tailLimit);
@@ -374,7 +442,15 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
374
442
  try {
375
443
  const provider = h.getProvider(agentType);
376
444
  const agentStr = provider?.type || agentType || getCurrentProviderType(h);
377
- const result = readChatHistory(agentStr, offset || 0, limit || 30, historySessionId);
445
+ const transport = getTargetTransport(h, provider);
446
+ let excludeRecentCount = Math.max(0, Number(args?.excludeRecentCount || 0));
447
+ if (isCliLikeTransport(transport)) {
448
+ const adapter = getTargetedCliAdapter(h, args, provider?.type);
449
+ const status = adapter?.getStatus?.();
450
+ const visibleCount = Array.isArray(status?.messages) ? status.messages.length : 0;
451
+ if (visibleCount > excludeRecentCount) excludeRecentCount = visibleCount;
452
+ }
453
+ const result = readChatHistory(agentStr, offset || 0, limit || 30, historySessionId, excludeRecentCount);
378
454
  return { success: true, ...result, agent: agentStr };
379
455
  } catch (e: any) {
380
456
  return { success: false, error: e.message };
@@ -113,9 +113,6 @@ export async function handleOpenPanel(h: CommandHelpers, args: any): Promise<Com
113
113
  export function handlePtyInput(h: CommandHelpers, args: any): CommandResult {
114
114
  const { cliType, data, targetSessionId } = args || {};
115
115
  if (!data) return { success: false, error: 'data required' };
116
- if (getCliPresentationMode(h, targetSessionId) === 'chat') {
117
- return { success: false, error: 'CLI session is in chat mode', code: 'CLI_VIEW_MODE_NOT_TERMINAL' };
118
- }
119
116
  const adapter = h.getCliAdapter(targetSessionId || cliType);
120
117
  if (!adapter || typeof adapter.writeRaw !== 'function') {
121
118
  return { success: false, error: `CLI adapter not found: ${targetSessionId || cliType || 'unknown'}` };
@@ -124,24 +121,10 @@ export function handlePtyInput(h: CommandHelpers, args: any): CommandResult {
124
121
  return { success: true };
125
122
  }
126
123
 
127
- export function handlePtyResize(h: CommandHelpers, args: any): CommandResult {
128
- const { cliType, cols, rows, force, targetSessionId } = args || {};
124
+ export function handlePtyResize(_h: CommandHelpers, args: any): CommandResult {
125
+ const { cols, rows } = args || {};
129
126
  if (!cols || !rows) return { success: false, error: 'cols and rows required' };
130
- if (getCliPresentationMode(h, targetSessionId) === 'chat') {
131
- return { success: false, error: 'CLI session is in chat mode', code: 'CLI_VIEW_MODE_NOT_TERMINAL' };
132
- }
133
- const adapter = h.getCliAdapter(targetSessionId || cliType);
134
- if (!adapter || typeof adapter.resize !== 'function') {
135
- return { success: false, error: `CLI adapter not found: ${targetSessionId || cliType || 'unknown'}` };
136
- }
137
- const resize = adapter.resize;
138
- if (force) {
139
- resize(cols - 1, rows);
140
- setTimeout(() => resize(cols, rows), 50);
141
- } else {
142
- resize(cols, rows);
143
- }
144
- return { success: true };
127
+ return { success: false, error: 'PTY resize temporarily disabled', code: 'PTY_RESIZE_DISABLED' };
145
128
  }
146
129
 
147
130
  // ─── Provider Settings ────────────────────────
@@ -251,18 +234,56 @@ export function normalizeProviderScriptArgs(args: any, scriptName?: string): Rec
251
234
 
252
235
  function buildControlScriptResult(scriptName: string, payload: any): Record<string, unknown> {
253
236
  if (!payload || typeof payload !== 'object') return {};
254
- if (Array.isArray(payload.options)) {
255
- return { controlResult: normalizeControlListResult(payload) };
237
+
238
+ const legacyListPayload = (() => {
239
+ if (Array.isArray(payload.options)) return payload;
240
+ if (/^listmodels$/i.test(scriptName) && Array.isArray(payload.models)) {
241
+ return {
242
+ options: payload.models,
243
+ currentValue: payload.currentValue ?? payload.current ?? payload.currentModel,
244
+ ...(typeof payload.error === 'string' ? { error: payload.error } : {}),
245
+ };
246
+ }
247
+ if (/^listmodes$/i.test(scriptName) && Array.isArray(payload.modes)) {
248
+ return {
249
+ options: payload.modes,
250
+ currentValue: payload.currentValue ?? payload.current ?? payload.currentMode ?? payload.mode,
251
+ ...(typeof payload.error === 'string' ? { error: payload.error } : {}),
252
+ };
253
+ }
254
+ return null;
255
+ })();
256
+ if (legacyListPayload) {
257
+ return { controlResult: normalizeControlListResult(legacyListPayload) };
256
258
  }
257
259
 
260
+ const legacyMutationPayload = (() => {
261
+ if (typeof payload.ok === 'boolean') return payload;
262
+ if (typeof payload.success === 'boolean') {
263
+ return {
264
+ ok: payload.success,
265
+ currentValue: payload.currentValue
266
+ ?? payload.value
267
+ ?? payload.model
268
+ ?? payload.mode
269
+ ?? payload.selectedModel
270
+ ?? payload.selectedMode,
271
+ ...(Array.isArray(payload.effects) ? { effects: payload.effects } : {}),
272
+ ...(typeof payload.error === 'string' ? { error: payload.error } : {}),
273
+ };
274
+ }
275
+ return null;
276
+ })();
277
+
258
278
  const looksLikeValueMutation = /^set|^change/i.test(scriptName)
259
279
  || payload.currentValue !== undefined
260
- || payload.value !== undefined;
280
+ || payload.value !== undefined
281
+ || payload.success !== undefined;
261
282
  if (looksLikeValueMutation) {
262
- return { controlResult: normalizeControlSetResult(payload) };
283
+ return { controlResult: normalizeControlSetResult(legacyMutationPayload || payload) };
263
284
  }
264
285
  if (payload.ok !== undefined || Array.isArray(payload.effects) || typeof payload.error === 'string') {
265
- return { controlResult: normalizeControlInvokeResult(payload) };
286
+ return { controlResult: normalizeControlInvokeResult(legacyMutationPayload || payload) };
266
287
  }
267
288
  return {};
268
289
  }
@@ -359,7 +380,7 @@ async function executeProviderScript(h: CommandHelpers, args: any, scriptName: s
359
380
 
360
381
  // IDE-level scripts (model/mode) — try session frame first, fallback to main page
361
382
  const IDE_LEVEL_SCRIPTS = provider.type === 'claude-code-vscode'
362
- ? ['listModes', 'setMode']
383
+ ? ['listModes', 'setMode', 'listModels', 'setModel', 'setModelGui']
363
384
  : ['listModes', 'setMode', 'listModels', 'setModel'];
364
385
  if (IDE_LEVEL_SCRIPTS.includes(scriptName)) {
365
386
  // Try session frame first (some extensions embed mode selector in their webview)
@@ -81,11 +81,12 @@ export declare class ChatHistoryWriter {
81
81
  /**
82
82
  * Read history (static — called from P2P commands)
83
83
  *
84
- * Read JSONL files in reverse order, returning most recent messages first.
85
- * When instanceId is specified, reads only that instance file.
86
- * Offset/limit-based paging.
84
+ * Read JSONL files for a session and return a chronological page while paging
85
+ * backwards from the newest saved messages. When excludeRecentCount is set,
86
+ * the newest N messages are skipped so older-history pagination can avoid
87
+ * duplicating the live transcript tail already shown in the UI.
87
88
  */
88
- export declare function readChatHistory(agentType: string, offset?: number, limit?: number, historySessionId?: string): {
89
+ export declare function readChatHistory(agentType: string, offset?: number, limit?: number, historySessionId?: string, excludeRecentCount?: number): {
89
90
  messages: HistoryMessage[];
90
91
  hasMore: boolean;
91
92
  };
@@ -17,6 +17,13 @@ import { buildRuntimeSystemChatMessage } from '../providers/chat-message-normali
17
17
  const HISTORY_DIR = path.join(os.homedir(), '.adhdev', 'history');
18
18
  const RETAIN_DAYS = 30;
19
19
 
20
+ interface SavedHistorySessionCacheEntry {
21
+ signature: string;
22
+ summaries: SavedHistorySessionSummary[];
23
+ }
24
+
25
+ const savedHistorySessionCache = new Map<string, SavedHistorySessionCacheEntry>();
26
+
20
27
  interface HistoryMessage {
21
28
  ts: string; // ISO timestamp
22
29
  receivedAt: number; // epoch ms
@@ -128,6 +135,96 @@ export interface SavedHistorySessionSummary {
128
135
  workspace?: string;
129
136
  }
130
137
 
138
+ function sanitizeHistoryFileSegment(value?: string): string {
139
+ return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_');
140
+ }
141
+
142
+ function listHistoryFiles(dir: string, historySessionId?: string): string[] {
143
+ const sanitizedSessionId = historySessionId ? sanitizeHistoryFileSegment(historySessionId) : '';
144
+ return fs.readdirSync(dir)
145
+ .filter((file) => {
146
+ if (!file.endsWith('.jsonl')) return false;
147
+ if (sanitizedSessionId) {
148
+ return file.startsWith(`${sanitizedSessionId}_`);
149
+ }
150
+ return true;
151
+ })
152
+ .sort()
153
+ .reverse();
154
+ }
155
+
156
+ function buildSavedHistoryCacheSignature(dir: string, files: string[]): string {
157
+ return files.map((file) => {
158
+ try {
159
+ const stat = fs.statSync(path.join(dir, file));
160
+ return `${file}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
161
+ } catch {
162
+ return `${file}:missing`;
163
+ }
164
+ }).join('|');
165
+ }
166
+
167
+ function computeSavedHistorySessionSummaries(agentType: string, dir: string, files: string[]): SavedHistorySessionSummary[] {
168
+ const groupedFiles = new Map<string, string[]>();
169
+ const filePattern = /^([A-Za-z0-9_-]+)_\d{4}-\d{2}-\d{2}\.jsonl$/;
170
+ for (const file of files) {
171
+ const match = file.match(filePattern);
172
+ if (!match?.[1]) continue;
173
+ const historySessionId = match[1];
174
+ const grouped = groupedFiles.get(historySessionId) || [];
175
+ grouped.push(file);
176
+ groupedFiles.set(historySessionId, grouped);
177
+ }
178
+
179
+ const summaries: SavedHistorySessionSummary[] = [];
180
+ for (const [historySessionId, grouped] of groupedFiles.entries()) {
181
+ let messageCount = 0;
182
+ let firstMessageAt = 0;
183
+ let lastMessageAt = 0;
184
+ let sessionTitle = '';
185
+ let preview = '';
186
+ let workspace = '';
187
+
188
+ for (const file of grouped.sort()) {
189
+ const filePath = path.join(dir, file);
190
+ const content = fs.readFileSync(filePath, 'utf-8');
191
+ const lines = content.split('\n').filter(Boolean);
192
+ for (const line of lines) {
193
+ let parsed: HistoryMessage | null = null;
194
+ try {
195
+ parsed = JSON.parse(line) as HistoryMessage;
196
+ } catch {
197
+ parsed = null;
198
+ }
199
+ if (!parsed || parsed.historySessionId !== historySessionId) continue;
200
+ if (parsed.kind === 'session_start') {
201
+ if (!workspace && parsed.workspace) workspace = parsed.workspace;
202
+ continue;
203
+ }
204
+ messageCount += 1;
205
+ if (!firstMessageAt || parsed.receivedAt < firstMessageAt) firstMessageAt = parsed.receivedAt;
206
+ if (!lastMessageAt || parsed.receivedAt > lastMessageAt) lastMessageAt = parsed.receivedAt;
207
+ if (parsed.sessionTitle) sessionTitle = parsed.sessionTitle;
208
+ if (parsed.role !== 'system' && parsed.content.trim()) preview = parsed.content.trim();
209
+ }
210
+ }
211
+
212
+ if (messageCount === 0 || !lastMessageAt) continue;
213
+ summaries.push({
214
+ historySessionId,
215
+ sessionTitle: sessionTitle || undefined,
216
+ messageCount,
217
+ firstMessageAt,
218
+ lastMessageAt,
219
+ preview: preview || undefined,
220
+ workspace: workspace || undefined,
221
+ });
222
+ }
223
+
224
+ summaries.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
225
+ return summaries;
226
+ }
227
+
131
228
  export class ChatHistoryWriter {
132
229
  /** Last seen message count per agent (deduplication) */
133
230
  private lastSeenCounts = new Map<string, number>();
@@ -539,15 +636,17 @@ export class ChatHistoryWriter {
539
636
  /**
540
637
  * Read history (static — called from P2P commands)
541
638
  *
542
- * Read JSONL files in reverse order, returning most recent messages first.
543
- * When instanceId is specified, reads only that instance file.
544
- * Offset/limit-based paging.
639
+ * Read JSONL files for a session and return a chronological page while paging
640
+ * backwards from the newest saved messages. When excludeRecentCount is set,
641
+ * the newest N messages are skipped so older-history pagination can avoid
642
+ * duplicating the live transcript tail already shown in the UI.
545
643
  */
546
644
  export function readChatHistory(
547
645
  agentType: string,
548
646
  offset: number = 0,
549
647
  limit: number = 30,
550
648
  historySessionId?: string,
649
+ excludeRecentCount: number = 0,
551
650
  ): { messages: HistoryMessage[]; hasMore: boolean } {
552
651
  try {
553
652
  const sanitized = agentType.replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -555,19 +654,7 @@ export function readChatHistory(
555
654
  if (!fs.existsSync(dir)) return { messages: [], hasMore: false };
556
655
 
557
656
  // JSONL file list — filter by persistent history key when specified
558
- const sanitizedInstance = historySessionId?.replace(/[^a-zA-Z0-9_-]/g, '_');
559
- const files = fs.readdirSync(dir)
560
- .filter(f => {
561
- if (!f.endsWith('.jsonl')) return false;
562
- if (sanitizedInstance) {
563
- // With instanceId: only that instance's files
564
- return f.startsWith(`${sanitizedInstance}_`);
565
- }
566
- // Without instanceId: include ALL files (legacy + instanced)
567
- return true;
568
- })
569
- .sort()
570
- .reverse();
657
+ const files = listHistoryFiles(dir, historySessionId);
571
658
 
572
659
  const allMessages: HistoryMessage[] = [];
573
660
  const seen = new Set<string>();
@@ -602,9 +689,15 @@ export function readChatHistory(
602
689
  }
603
690
  const collapsed = collapseReplayAssistantTurns(agentType, chronological);
604
691
 
605
- // offset/limit apply
606
- const sliced = collapsed.slice(offset, offset + limit);
607
- const hasMore = collapsed.length > offset + limit;
692
+ // Page backwards from the newest saved messages while keeping the returned
693
+ // slice in chronological order for prepend-based UI rendering.
694
+ const boundedLimit = Math.max(1, limit);
695
+ const boundedOffset = Math.max(0, offset);
696
+ const boundedExclude = Math.max(0, Math.min(excludeRecentCount, collapsed.length));
697
+ const endExclusive = Math.max(0, collapsed.length - boundedExclude - boundedOffset);
698
+ const startInclusive = Math.max(0, endExclusive - boundedLimit);
699
+ const sliced = collapsed.slice(startInclusive, endExclusive);
700
+ const hasMore = startInclusive > 0;
608
701
 
609
702
  return { messages: sliced, hasMore };
610
703
  } catch {
@@ -619,66 +712,24 @@ export function listSavedHistorySessions(
619
712
  try {
620
713
  const sanitized = agentType.replace(/[^a-zA-Z0-9_-]/g, '_');
621
714
  const dir = path.join(HISTORY_DIR, sanitized);
622
- if (!fs.existsSync(dir)) return { sessions: [], hasMore: false };
623
-
624
- const groupedFiles = new Map<string, string[]>();
625
- const filePattern = /^([A-Za-z0-9_-]+)_\d{4}-\d{2}-\d{2}\.jsonl$/;
626
- for (const file of fs.readdirSync(dir)) {
627
- if (!file.endsWith('.jsonl')) continue;
628
- const match = file.match(filePattern);
629
- if (!match?.[1]) continue;
630
- const historySessionId = match[1];
631
- const files = groupedFiles.get(historySessionId) || [];
632
- files.push(file);
633
- groupedFiles.set(historySessionId, files);
715
+ if (!fs.existsSync(dir)) {
716
+ savedHistorySessionCache.delete(sanitized);
717
+ return { sessions: [], hasMore: false };
634
718
  }
635
719
 
636
- const summaries: SavedHistorySessionSummary[] = [];
637
- for (const [historySessionId, files] of groupedFiles.entries()) {
638
- let messageCount = 0;
639
- let firstMessageAt = 0;
640
- let lastMessageAt = 0;
641
- let sessionTitle = '';
642
- let preview = '';
643
- let workspace = '';
644
-
645
- for (const file of files.sort()) {
646
- const filePath = path.join(dir, file);
647
- const content = fs.readFileSync(filePath, 'utf-8');
648
- const lines = content.split('\n').filter(Boolean);
649
- for (const line of lines) {
650
- let parsed: HistoryMessage | null = null;
651
- try {
652
- parsed = JSON.parse(line) as HistoryMessage;
653
- } catch {
654
- parsed = null;
655
- }
656
- if (!parsed || parsed.historySessionId !== historySessionId) continue;
657
- if (parsed.kind === 'session_start') {
658
- if (!workspace && parsed.workspace) workspace = parsed.workspace;
659
- continue;
660
- }
661
- messageCount += 1;
662
- if (!firstMessageAt || parsed.receivedAt < firstMessageAt) firstMessageAt = parsed.receivedAt;
663
- if (!lastMessageAt || parsed.receivedAt > lastMessageAt) lastMessageAt = parsed.receivedAt;
664
- if (parsed.sessionTitle) sessionTitle = parsed.sessionTitle;
665
- if (parsed.role !== 'system' && parsed.content.trim()) preview = parsed.content.trim();
666
- }
667
- }
668
-
669
- if (messageCount === 0 || !lastMessageAt) continue;
670
- summaries.push({
671
- historySessionId,
672
- sessionTitle: sessionTitle || undefined,
673
- messageCount,
674
- firstMessageAt,
675
- lastMessageAt,
676
- preview: preview || undefined,
677
- workspace: workspace || undefined,
720
+ const files = listHistoryFiles(dir);
721
+ const signature = buildSavedHistoryCacheSignature(dir, files);
722
+ const cached = savedHistorySessionCache.get(sanitized);
723
+ const summaries = cached?.signature === signature
724
+ ? cached.summaries
725
+ : computeSavedHistorySessionSummaries(agentType, dir, files);
726
+ if (!cached || cached.signature !== signature) {
727
+ savedHistorySessionCache.set(sanitized, {
728
+ signature,
729
+ summaries,
678
730
  });
679
731
  }
680
732
 
681
- summaries.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
682
733
  const offset = Math.max(0, options.offset || 0);
683
734
  const limit = Math.max(1, options.limit || 30);
684
735
  const sliced = summaries.slice(offset, offset + limit);
@@ -507,6 +507,20 @@ export class DevServer implements DevServerContext {
507
507
  private async handleReload(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
508
508
  try {
509
509
  this.providerLoader.reload();
510
+ let refreshedInstances = 0;
511
+ if (this.instanceManager) {
512
+ for (const id of this.instanceManager.listInstanceIds()) {
513
+ const instance = this.instanceManager.getInstance(id) as any;
514
+ const providerType = typeof instance?.type === 'string' ? instance.type : '';
515
+ if (!providerType) continue;
516
+ const resolved = this.providerLoader.resolve(providerType);
517
+ if (!resolved) continue;
518
+ if (instance && typeof instance === 'object' && 'provider' in instance) {
519
+ instance.provider = resolved;
520
+ refreshedInstances += 1;
521
+ }
522
+ }
523
+ }
510
524
  const providers = this.providerLoader.getAll().map(p => ({
511
525
  type: p.type, name: p.name, category: p.category,
512
526
  }));
@@ -515,7 +529,7 @@ export class DevServer implements DevServerContext {
515
529
  cdp.clearTargetId();
516
530
  }
517
531
  }
518
- this.json(res, 200, { reloaded: true, providers });
532
+ this.json(res, 200, { reloaded: true, refreshedInstances, providers });
519
533
  } catch (e: any) {
520
534
  this.json(res, 500, { error: e.message });
521
535
  }
@@ -1,24 +1,25 @@
1
1
  import type { ProviderModule } from './contracts.js';
2
2
 
3
3
  const DEFAULT_APPROVAL_POSITIVE_HINTS = [
4
- 'run',
4
+ 'yes',
5
+ 'allow once',
5
6
  'approve',
6
7
  'accept',
7
- 'allow once',
8
- 'always allow',
9
- 'allow',
10
- 'yes',
11
- 'proceed',
12
8
  'continue',
9
+ 'run',
10
+ 'proceed',
13
11
  'confirm',
14
12
  'save',
15
13
  'ok',
16
14
  'trust',
15
+ 'allow',
16
+ 'always allow',
17
17
  ];
18
18
 
19
19
  function normalizeApprovalLabel(value: string): string {
20
20
  return String(value || '')
21
21
  .toLowerCase()
22
+ .replace(/^[\s\[(<{]*\d+(?:\s*[.)\]}>:-]|\s)+/, '')
22
23
  .replace(/[^\p{L}\p{N}]+/gu, ' ')
23
24
  .trim();
24
25
  }
@@ -333,6 +333,8 @@ export interface ProviderModule {
333
333
  resume?: ProviderResumeCapability;
334
334
  /** Session ID probe config — auto-discovers provider session ID from local SQLite DB */
335
335
  sessionProbe?: ProviderSessionProbe;
336
+ /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
337
+ allowInputDuringGeneration?: boolean;
336
338
  /** Approval button priority hints used when auto-approve must pick a positive action */
337
339
  approvalPositiveHints?: string[];
338
340
  scripts?: ProviderScripts;