@adhdev/daemon-core 0.8.28 → 0.8.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/agent-stream/manager.d.ts +1 -1
  2. package/dist/agent-stream/provider-adapter.d.ts +5 -0
  3. package/dist/commands/router.d.ts +5 -0
  4. package/dist/config/chat-history.d.ts +12 -0
  5. package/dist/index.js +552 -52
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +552 -52
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/providers/acp-provider-instance.d.ts +1 -0
  10. package/dist/providers/approval-utils.d.ts +7 -0
  11. package/dist/providers/cli-provider-instance.d.ts +3 -0
  12. package/dist/providers/contracts.d.ts +2 -0
  13. package/dist/providers/ide-provider-instance.d.ts +1 -0
  14. package/node_modules/@adhdev/session-host-core/dist/index.d.mts +24 -1
  15. package/node_modules/@adhdev/session-host-core/dist/index.d.ts +24 -1
  16. package/node_modules/@adhdev/session-host-core/dist/index.js +6 -1
  17. package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
  18. package/node_modules/@adhdev/session-host-core/dist/index.mjs +6 -1
  19. package/node_modules/@adhdev/session-host-core/dist/index.mjs.map +1 -1
  20. package/node_modules/@adhdev/session-host-core/package.json +1 -1
  21. package/package.json +1 -1
  22. package/src/agent-stream/manager.ts +2 -2
  23. package/src/agent-stream/poller.ts +38 -1
  24. package/src/agent-stream/provider-adapter.ts +97 -3
  25. package/src/cli-adapters/provider-cli-adapter.ts +3 -0
  26. package/src/commands/chat-commands.ts +53 -3
  27. package/src/commands/cli-manager.ts +14 -0
  28. package/src/commands/router.ts +11 -0
  29. package/src/config/chat-history.ts +269 -18
  30. package/src/providers/acp-provider-instance.ts +17 -2
  31. package/src/providers/approval-utils.ts +66 -0
  32. package/src/providers/cli-provider-instance.ts +47 -6
  33. package/src/providers/contracts.d.ts +1 -0
  34. package/src/providers/contracts.ts +3 -1
  35. package/src/providers/ide-provider-instance.ts +28 -23
  36. package/src/providers/provider-loader.ts +26 -2
@@ -29,6 +29,93 @@ interface HistoryMessage {
29
29
  sessionTitle?: string;
30
30
  }
31
31
 
32
+ const CODEX_STARTER_PROMPT_RE = /^(?:[›❯]\s*)?(?:Find and fix a bug in @filename|Improve documentation in @filename|Write tests for @filename|Explain this codebase|Summarize recent commits|Implement \{feature\}|Use \/skills(?: to list available skills)?|Run \/review on my current changes)$/i;
33
+
34
+ function normalizeHistoryComparable(text: string): string {
35
+ return String(text || '').replace(/\s+/g, ' ').trim();
36
+ }
37
+
38
+ function cleanupHistoryContent(agentType: string, role: HistoryMessage['role'], content: string): string {
39
+ let value = String(content || '').replace(/\r\n/g, '\n').trim();
40
+ if (!value) return '';
41
+
42
+ if (agentType === 'codex-cli' && role === 'assistant') {
43
+ const filtered = value
44
+ .split('\n')
45
+ .filter((line) => !CODEX_STARTER_PROMPT_RE.test(line.trim()))
46
+ .join('\n')
47
+ .replace(/\n{3,}/g, '\n\n')
48
+ .trim();
49
+ value = filtered;
50
+ }
51
+
52
+ return value;
53
+ }
54
+
55
+ function buildHistoryMessageHash(
56
+ agentType: string,
57
+ message: Pick<HistoryMessage, 'role' | 'content' | 'receivedAt' | 'kind'> & { historyDedupKey?: string },
58
+ ): string {
59
+ if (message.historyDedupKey) return message.historyDedupKey;
60
+ const cleaned = cleanupHistoryContent(agentType, message.role, message.content);
61
+ return `${message.kind || 'standard'}:${message.role}:${message.receivedAt || 0}:${normalizeHistoryComparable(cleaned)}`;
62
+ }
63
+
64
+ function buildHistoryMessageSignature(
65
+ agentType: string,
66
+ message: Pick<HistoryMessage, 'role' | 'content' | 'kind'>,
67
+ ): string {
68
+ const cleaned = cleanupHistoryContent(agentType, message.role, message.content);
69
+ return `${message.kind || 'standard'}:${message.role}:${normalizeHistoryComparable(cleaned)}`;
70
+ }
71
+
72
+ function isAdjacentHistoryDuplicate(
73
+ agentType: string,
74
+ previous: Pick<HistoryMessage, 'role' | 'content' | 'kind'> | null | undefined,
75
+ next: Pick<HistoryMessage, 'role' | 'content' | 'kind'> | null | undefined,
76
+ ): boolean {
77
+ if (!previous || !next) return false;
78
+ return buildHistoryMessageSignature(agentType, previous) === buildHistoryMessageSignature(agentType, next);
79
+ }
80
+
81
+ function collapseReplayAssistantTurns(agentType: string, messages: HistoryMessage[]): HistoryMessage[] {
82
+ if (agentType !== 'codex-cli') return messages;
83
+
84
+ const collapsed: HistoryMessage[] = [];
85
+ let sawAssistantSinceLastUser = false;
86
+
87
+ for (const message of messages) {
88
+ if (message.role === 'user') {
89
+ sawAssistantSinceLastUser = false;
90
+ collapsed.push(message);
91
+ continue;
92
+ }
93
+
94
+ if (message.role === 'assistant') {
95
+ if (sawAssistantSinceLastUser) continue;
96
+ sawAssistantSinceLastUser = true;
97
+ collapsed.push(message);
98
+ continue;
99
+ }
100
+
101
+ collapsed.push(message);
102
+ }
103
+
104
+ return collapsed;
105
+ }
106
+
107
+ function sanitizeHistoryMessage(agentType: string, message: HistoryMessage): HistoryMessage | null {
108
+ if (!message || (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system')) {
109
+ return null;
110
+ }
111
+ const content = cleanupHistoryContent(agentType, message.role, message.content);
112
+ if (!content) return null;
113
+ return {
114
+ ...message,
115
+ content,
116
+ };
117
+ }
118
+
32
119
  export interface SavedHistorySessionSummary {
33
120
  historySessionId: string;
34
121
  sessionTitle?: string;
@@ -43,6 +130,10 @@ export class ChatHistoryWriter {
43
130
  private lastSeenCounts = new Map<string, number>();
44
131
  /** Last seen message hash per agent (deduplication) */
45
132
  private lastSeenHashes = new Map<string, Set<string>>();
133
+ /** Last appended normalized message signature per agent/session */
134
+ private lastSeenSignatures = new Map<string, string>();
135
+ /** Last appended normalized non-system turn signature per agent/session */
136
+ private lastSeenTurnSignatures = new Map<string, string>();
46
137
  private rotated = false;
47
138
 
48
139
  /**
@@ -75,14 +166,36 @@ export class ChatHistoryWriter {
75
166
  // Filter new messages
76
167
  const newMessages: HistoryMessage[] = [];
77
168
  for (const msg of messages) {
78
- const hash = msg.historyDedupKey || `${msg.kind || 'standard'}:${msg.role}:${(msg.content || '').slice(0, 50)}`;
169
+ const role = msg.role as 'user' | 'assistant' | 'system';
170
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
171
+ const content = cleanupHistoryContent(agentType, role, msg.content || '');
172
+ if (!content) continue;
173
+ const receivedAt = msg.receivedAt || Date.now();
174
+ const hash = buildHistoryMessageHash(agentType, {
175
+ role,
176
+ content,
177
+ receivedAt,
178
+ kind: typeof msg.kind === 'string' ? msg.kind : undefined,
179
+ historyDedupKey: msg.historyDedupKey,
180
+ });
181
+ const signature = buildHistoryMessageSignature(agentType, {
182
+ role,
183
+ content,
184
+ kind: typeof msg.kind === 'string' ? msg.kind : undefined,
185
+ });
79
186
  if (seenHashes.has(hash)) continue;
187
+ if (this.lastSeenSignatures.get(dedupKey) === signature) continue;
188
+ if (role !== 'system' && this.lastSeenTurnSignatures.get(dedupKey) === signature) continue;
80
189
  seenHashes.add(hash);
190
+ this.lastSeenSignatures.set(dedupKey, signature);
191
+ if (role !== 'system') {
192
+ this.lastSeenTurnSignatures.set(dedupKey, signature);
193
+ }
81
194
  newMessages.push({
82
- ts: new Date(msg.receivedAt || Date.now()).toISOString(),
83
- receivedAt: msg.receivedAt || Date.now(),
84
- role: msg.role as 'user' | 'assistant' | 'system',
85
- content: msg.content || '',
195
+ ts: new Date(receivedAt).toISOString(),
196
+ receivedAt,
197
+ role,
198
+ content,
86
199
  kind: typeof msg.kind === 'string' ? msg.kind : undefined,
87
200
  senderName: typeof msg.senderName === 'string' ? msg.senderName : undefined,
88
201
  agent: agentType,
@@ -108,6 +221,8 @@ export class ChatHistoryWriter {
108
221
  const prevCount = this.lastSeenCounts.get(dedupKey) || 0;
109
222
  if (messages.length < prevCount * 0.5 && prevCount > 3) {
110
223
  seenHashes.clear();
224
+ this.lastSeenSignatures.delete(dedupKey);
225
+ this.lastSeenTurnSignatures.delete(dedupKey);
111
226
  for (const msg of messages) {
112
227
  seenHashes.add(msg.historyDedupKey || `${msg.kind || 'standard'}:${msg.role}:${(msg.content || '').slice(0, 50)}`);
113
228
  }
@@ -124,6 +239,62 @@ export class ChatHistoryWriter {
124
239
  }
125
240
  }
126
241
 
242
+ seedSessionHistory(
243
+ agentType: string,
244
+ messages: Array<{ role: string; content: string; receivedAt?: number; kind?: string; historyDedupKey?: string }> = [],
245
+ historySessionId?: string,
246
+ instanceId?: string,
247
+ ): void {
248
+ const effectiveHistoryKey = historySessionId || instanceId;
249
+ const dedupKey = effectiveHistoryKey ? `${agentType}:${effectiveHistoryKey}` : agentType;
250
+ const seenHashes = new Set<string>();
251
+
252
+ for (const raw of messages) {
253
+ const role = raw?.role as 'user' | 'assistant' | 'system';
254
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
255
+ const content = cleanupHistoryContent(agentType, role, raw?.content || '');
256
+ if (!content) continue;
257
+ seenHashes.add(buildHistoryMessageHash(agentType, {
258
+ role,
259
+ content,
260
+ receivedAt: raw?.receivedAt || 0,
261
+ kind: typeof raw?.kind === 'string' ? raw.kind : undefined,
262
+ historyDedupKey: raw?.historyDedupKey,
263
+ }));
264
+ }
265
+
266
+ this.lastSeenHashes.set(dedupKey, seenHashes);
267
+ this.lastSeenCounts.set(dedupKey, messages.length);
268
+ const lastMessage = [...messages].reverse().find((raw) => {
269
+ const role = raw?.role as 'user' | 'assistant' | 'system';
270
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') return false;
271
+ return !!cleanupHistoryContent(agentType, role, raw?.content || '');
272
+ });
273
+ const lastTurnMessage = [...messages].reverse().find((raw) => {
274
+ const role = raw?.role as 'user' | 'assistant';
275
+ if (role !== 'user' && role !== 'assistant') return false;
276
+ return !!cleanupHistoryContent(agentType, role, raw?.content || '');
277
+ });
278
+ if (lastMessage) {
279
+ this.lastSeenSignatures.set(dedupKey, buildHistoryMessageSignature(agentType, {
280
+ role: lastMessage.role as HistoryMessage['role'],
281
+ content: lastMessage.content,
282
+ kind: typeof lastMessage.kind === 'string' ? lastMessage.kind : undefined,
283
+ }));
284
+ } else {
285
+ this.lastSeenSignatures.delete(dedupKey);
286
+ }
287
+ if (lastTurnMessage) {
288
+ this.lastSeenTurnSignatures.set(dedupKey, buildHistoryMessageSignature(agentType, {
289
+ role: lastTurnMessage.role as 'user' | 'assistant',
290
+ content: lastTurnMessage.content,
291
+ kind: typeof lastTurnMessage.kind === 'string' ? lastTurnMessage.kind : undefined,
292
+ }));
293
+ } else {
294
+ this.lastSeenTurnSignatures.delete(dedupKey);
295
+ }
296
+ }
297
+
127
298
  appendSystemMarker(
128
299
  agentType: string,
129
300
  content: string,
@@ -171,6 +342,16 @@ export class ChatHistoryWriter {
171
342
  this.lastSeenHashes.set(toDedupKey, nextHashes);
172
343
  this.lastSeenHashes.delete(fromDedupKey);
173
344
  }
345
+ const fromSignature = this.lastSeenSignatures.get(fromDedupKey);
346
+ if (fromSignature) {
347
+ this.lastSeenSignatures.set(toDedupKey, fromSignature);
348
+ this.lastSeenSignatures.delete(fromDedupKey);
349
+ }
350
+ const fromTurnSignature = this.lastSeenTurnSignatures.get(fromDedupKey);
351
+ if (fromTurnSignature) {
352
+ this.lastSeenTurnSignatures.set(toDedupKey, fromTurnSignature);
353
+ this.lastSeenTurnSignatures.delete(fromDedupKey);
354
+ }
174
355
  const fromCount = this.lastSeenCounts.get(fromDedupKey);
175
356
  if (typeof fromCount === 'number') {
176
357
  this.lastSeenCounts.set(toDedupKey, Math.max(fromCount, this.lastSeenCounts.get(toDedupKey) || 0));
@@ -221,10 +402,69 @@ export class ChatHistoryWriter {
221
402
  }
222
403
  }
223
404
 
405
+ compactHistorySession(agentType: string, historySessionId: string): void {
406
+ const sessionId = String(historySessionId || '').trim();
407
+ if (!sessionId) return;
408
+
409
+ try {
410
+ const dir = path.join(HISTORY_DIR, this.sanitize(agentType));
411
+ if (!fs.existsSync(dir)) return;
412
+
413
+ const prefix = `${this.sanitize(sessionId)}_`;
414
+ const files = fs.readdirSync(dir)
415
+ .filter((file) => file.startsWith(prefix) && file.endsWith('.jsonl'))
416
+ .sort();
417
+
418
+ const seen = new Set<string>();
419
+ for (const file of files) {
420
+ const filePath = path.join(dir, file);
421
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
422
+ const next: HistoryMessage[] = [];
423
+
424
+ for (const line of lines) {
425
+ let parsed: HistoryMessage | null = null;
426
+ try {
427
+ parsed = JSON.parse(line) as HistoryMessage;
428
+ } catch {
429
+ parsed = null;
430
+ }
431
+ if (!parsed || parsed.historySessionId !== sessionId) continue;
432
+ const sanitized = sanitizeHistoryMessage(agentType, parsed);
433
+ if (!sanitized) continue;
434
+ const hash = buildHistoryMessageHash(agentType, sanitized);
435
+ if (seen.has(hash)) continue;
436
+ seen.add(hash);
437
+ next.push(sanitized);
438
+ }
439
+
440
+ next.sort((a, b) => a.receivedAt - b.receivedAt);
441
+ const dedupedAdjacent: HistoryMessage[] = [];
442
+ let lastTurn: HistoryMessage | null = null;
443
+ for (const entry of next) {
444
+ const previous = dedupedAdjacent[dedupedAdjacent.length - 1];
445
+ if (isAdjacentHistoryDuplicate(agentType, previous, entry)) continue;
446
+ if (entry.role !== 'system' && isAdjacentHistoryDuplicate(agentType, lastTurn, entry)) continue;
447
+ dedupedAdjacent.push(entry);
448
+ if (entry.role !== 'system') lastTurn = entry;
449
+ }
450
+ const collapsed = collapseReplayAssistantTurns(agentType, dedupedAdjacent);
451
+ if (collapsed.length === 0) {
452
+ fs.unlinkSync(filePath);
453
+ continue;
454
+ }
455
+ fs.writeFileSync(filePath, `${collapsed.map((entry) => JSON.stringify(entry)).join('\n')}\n`, 'utf-8');
456
+ }
457
+ } catch {
458
+ // Ignore compaction failure.
459
+ }
460
+ }
461
+
224
462
  /** Called when agent session is explicitly changed */
225
463
  onSessionChange(agentType: string): void {
226
464
  this.lastSeenHashes.delete(agentType);
227
465
  this.lastSeenCounts.delete(agentType);
466
+ this.lastSeenSignatures.delete(agentType);
467
+ this.lastSeenTurnSignatures.delete(agentType);
228
468
  }
229
469
 
230
470
  /** Delete history files older than 30 days */
@@ -293,31 +533,42 @@ export function readChatHistory(
293
533
  .sort()
294
534
  .reverse();
295
535
 
296
- // Read lines from all files (reverse order)
297
536
  const allMessages: HistoryMessage[] = [];
298
- const needed = offset + limit + 1; // hasMore check +1
537
+ const seen = new Set<string>();
299
538
 
300
539
  for (const file of files) {
301
- if (allMessages.length >= needed) break;
302
540
  const filePath = path.join(dir, file);
303
541
  const content = fs.readFileSync(filePath, 'utf-8');
304
542
  const lines = content.trim().split('\n').filter(Boolean);
305
-
306
- // Parse in reverse order
307
- for (let i = lines.length - 1; i >= 0; i--) {
308
- if (allMessages.length >= needed) break;
543
+
544
+ for (let i = 0; i < lines.length; i++) {
309
545
  try {
310
- allMessages.push(JSON.parse(lines[i]));
546
+ const parsed = JSON.parse(lines[i]) as HistoryMessage;
547
+ const sanitizedMessage = sanitizeHistoryMessage(agentType, parsed);
548
+ if (!sanitizedMessage) continue;
549
+ const hash = buildHistoryMessageHash(agentType, sanitizedMessage);
550
+ if (seen.has(hash)) continue;
551
+ seen.add(hash);
552
+ allMessages.push(sanitizedMessage);
311
553
  } catch { /* skip invalid lines */ }
312
554
  }
313
555
  }
314
556
 
315
- // offset/limit apply
316
- const sliced = allMessages.slice(offset, offset + limit);
317
- const hasMore = allMessages.length > offset + limit;
557
+ allMessages.sort((a, b) => a.receivedAt - b.receivedAt);
558
+ const chronological: HistoryMessage[] = [];
559
+ let lastTurn: HistoryMessage | null = null;
560
+ for (const message of allMessages) {
561
+ const previous = chronological[chronological.length - 1];
562
+ if (isAdjacentHistoryDuplicate(agentType, previous, message)) continue;
563
+ if (message.role !== 'system' && isAdjacentHistoryDuplicate(agentType, lastTurn, message)) continue;
564
+ chronological.push(message);
565
+ if (message.role !== 'system') lastTurn = message;
566
+ }
567
+ const collapsed = collapseReplayAssistantTurns(agentType, chronological);
318
568
 
319
- // Sort in chronological order (top→bottom = oldest→newest)
320
- sliced.reverse();
569
+ // offset/limit apply
570
+ const sliced = collapsed.slice(offset, offset + limit);
571
+ const hasMore = collapsed.length > offset + limit;
321
572
 
322
573
  return { messages: sliced, hasMore };
323
574
  } catch {
@@ -600,8 +600,10 @@ export class AcpProviderInstance implements ProviderInstance {
600
600
  }
601
601
 
602
602
  // ─── Auto-approve: skip user confirmation ───
603
- if (this.settings.autoApprove) {
604
- this.log.info(`[${this.type}] Auto-approving: ${tc.title || tc.toolCallId}`);
603
+ if (this.settings.autoApprove !== false) {
604
+ const toolTitle = tc.title || tc.toolCallId || 'tool call';
605
+ this.log.info(`[${this.type}] Auto-approving: ${toolTitle}`);
606
+ this.appendSystemMessage(`Auto-approved: ${toolTitle}`);
605
607
  const allowOption = params.options.find(o => o.kind === 'allow_once') || params.options.find(o => o.kind === 'allow_always');
606
608
  if (allowOption) {
607
609
  return { outcome: { outcome: 'selected', optionId: allowOption.optionId } };
@@ -1143,6 +1145,19 @@ export class AcpProviderInstance implements ProviderInstance {
1143
1145
  if (this.events.length > 50) this.events = this.events.slice(-50);
1144
1146
  }
1145
1147
 
1148
+ private appendSystemMessage(content: string, timestamp = Date.now()): void {
1149
+ const normalizedContent = String(content || '').trim();
1150
+ if (!normalizedContent) return;
1151
+ this.messages.push({
1152
+ role: 'system',
1153
+ content: normalizedContent,
1154
+ timestamp,
1155
+ });
1156
+ if (this.messages.length > 200) {
1157
+ this.messages = this.messages.slice(-100);
1158
+ }
1159
+ }
1160
+
1146
1161
  private flushEvents(): ProviderEvent[] {
1147
1162
  const events = [...this.events];
1148
1163
  this.events = [];
@@ -0,0 +1,66 @@
1
+ import type { ProviderModule } from './contracts.js';
2
+
3
+ const DEFAULT_APPROVAL_POSITIVE_HINTS = [
4
+ 'run',
5
+ 'approve',
6
+ 'accept',
7
+ 'allow once',
8
+ 'always allow',
9
+ 'allow',
10
+ 'yes',
11
+ 'proceed',
12
+ 'continue',
13
+ 'confirm',
14
+ 'save',
15
+ 'ok',
16
+ 'trust',
17
+ ];
18
+
19
+ function normalizeApprovalLabel(value: string): string {
20
+ return String(value || '')
21
+ .toLowerCase()
22
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
23
+ .trim();
24
+ }
25
+
26
+ export function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[] {
27
+ const customHints = Array.isArray(provider?.approvalPositiveHints)
28
+ ? provider.approvalPositiveHints
29
+ .map((hint) => normalizeApprovalLabel(String(hint || '')))
30
+ .filter(Boolean)
31
+ : [];
32
+ return customHints.length > 0 ? customHints : DEFAULT_APPROVAL_POSITIVE_HINTS;
33
+ }
34
+
35
+ export function pickApprovalButton(
36
+ buttons: string[] | null | undefined,
37
+ provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null,
38
+ ): { index: number; label: string } {
39
+ const labels = (buttons || []).map((button) => String(button || '').trim()).filter(Boolean);
40
+ if (labels.length === 0) {
41
+ return { index: 0, label: 'Approve' };
42
+ }
43
+
44
+ const normalizedButtons = labels.map((label) => normalizeApprovalLabel(label));
45
+ const hints = getApprovalPositiveHints(provider);
46
+
47
+ for (const hint of hints) {
48
+ const exactIndex = normalizedButtons.findIndex((label) => label === hint);
49
+ if (exactIndex >= 0) return { index: exactIndex, label: labels[exactIndex] };
50
+
51
+ const prefixIndex = normalizedButtons.findIndex((label) => label.startsWith(hint));
52
+ if (prefixIndex >= 0) return { index: prefixIndex, label: labels[prefixIndex] };
53
+
54
+ const includeIndex = normalizedButtons.findIndex((label) => label.includes(hint));
55
+ if (includeIndex >= 0) return { index: includeIndex, label: labels[includeIndex] };
56
+ }
57
+
58
+ return { index: 0, label: labels[0] };
59
+ }
60
+
61
+ export function formatAutoApprovalMessage(modalMessage?: string, buttonLabel?: string): string {
62
+ const lines = [`Auto-approved${buttonLabel ? `: ${buttonLabel}` : ''}`];
63
+ const cleanMessage = String(modalMessage || '').trim();
64
+ if (cleanMessage) lines.push(cleanMessage);
65
+ return lines.join('\n');
66
+ }
@@ -20,6 +20,7 @@ import { ChatHistoryWriter, readChatHistory } from '../config/chat-history.js';
20
20
  import { LOG } from '../logging/logger.js';
21
21
  import type { ChatMessage } from '../types.js';
22
22
  import { extractProviderControlValues, normalizeProviderEffects } from './control-effects.js';
23
+ import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
23
24
 
24
25
  let CachedDatabaseSync: (new (path: string, options?: { readOnly?: boolean }) => {
25
26
  prepare(sql: string): { get(...params: Array<string | number>): unknown };
@@ -60,6 +61,7 @@ export class CliProviderInstance implements ProviderInstance {
60
61
  private historyWriter: ChatHistoryWriter;
61
62
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
62
63
  readonly instanceId: string;
64
+ private suppressIdleHistoryReplay = false;
63
65
 
64
66
  private presentationMode: 'terminal' | 'chat';
65
67
  private providerSessionId?: string;
@@ -135,7 +137,15 @@ export class CliProviderInstance implements ProviderInstance {
135
137
  await this.adapter.spawn();
136
138
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
137
139
  if (this.providerSessionId) {
140
+ this.historyWriter.compactHistorySession(this.type, this.providerSessionId);
138
141
  const restoredHistory = readChatHistory(this.type, 0, 200, this.providerSessionId);
142
+ this.historyWriter.seedSessionHistory(
143
+ this.type,
144
+ restoredHistory.messages,
145
+ this.providerSessionId,
146
+ this.instanceId,
147
+ );
148
+ this.suppressIdleHistoryReplay = restoredHistory.messages.length > 0;
139
149
  if (restoredHistory.messages.length > 0) {
140
150
  this.adapter.seedCommittedMessages(
141
151
  restoredHistory.messages.map((message) => ({
@@ -184,7 +194,7 @@ export class CliProviderInstance implements ProviderInstance {
184
194
  } else if (this.type === 'codex-cli') {
185
195
  probedSessionId = this.probeSessionIdFromConfig({
186
196
  dbPath: '~/.codex/state_5.sqlite',
187
- query: 'select id from threads where cwd in ({dirs}) and created_at >= ? and archived = 0 order by created_at desc limit 1',
197
+ query: 'select id from threads where cwd in ({dirs}) and updated_at >= ? and archived = 0 order by updated_at desc limit 1',
188
198
  timestampFormat: 'unix_s',
189
199
  });
190
200
  } else if (this.type === 'goose-cli') {
@@ -240,6 +250,8 @@ export class CliProviderInstance implements ProviderInstance {
240
250
  getState(): ProviderState {
241
251
  const adapterStatus = this.adapter.getStatus();
242
252
  const parsedStatus = this.adapter.getScriptParsedStatus?.() || null;
253
+ const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
254
+ const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
243
255
  const parsedProviderSessionId = typeof parsedStatus?.providerSessionId === 'string'
244
256
  ? parsedStatus.providerSessionId.trim()
245
257
  : '';
@@ -260,6 +272,10 @@ export class CliProviderInstance implements ProviderInstance {
260
272
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
261
273
 
262
274
  if (parsedMessages.length > 0) {
275
+ const shouldSkipReplayPersist =
276
+ this.suppressIdleHistoryReplay
277
+ && adapterStatus.status === 'idle'
278
+ && parsedStatus?.status === 'idle';
263
279
  let messagesToSave = parsedMessages;
264
280
  if ((parsedStatus?.status === 'generating' || parsedStatus?.status === 'long_generating')) {
265
281
  const lastIdx = messagesToSave.length - 1;
@@ -267,7 +283,7 @@ export class CliProviderInstance implements ProviderInstance {
267
283
  messagesToSave = messagesToSave.slice(0, lastIdx);
268
284
  }
269
285
  }
270
- if (messagesToSave.length > 0) {
286
+ if (!shouldSkipReplayPersist && messagesToSave.length > 0) {
271
287
  this.historyWriter.appendNewMessages(
272
288
  this.type,
273
289
  messagesToSave,
@@ -284,14 +300,16 @@ export class CliProviderInstance implements ProviderInstance {
284
300
  type: this.type,
285
301
  name: this.provider.name,
286
302
  category: 'cli',
287
- status: adapterStatus.status,
303
+ status: visibleStatus,
288
304
  mode: this.presentationMode,
289
305
  activeChat: {
290
306
  id: `${this.type}_${this.workingDir}`,
291
307
  title: parsedStatus?.title || dirName,
292
- status: parsedStatus?.status || adapterStatus.status,
308
+ status: autoApproveActive && parsedStatus?.status === 'waiting_approval'
309
+ ? 'generating'
310
+ : (parsedStatus?.status || visibleStatus),
293
311
  messages: mergedMessages,
294
- activeModal: parsedStatus?.activeModal ?? adapterStatus.activeModal,
312
+ activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
295
313
  inputContent: '',
296
314
  },
297
315
  workspace: this.workingDir,
@@ -362,7 +380,16 @@ export class CliProviderInstance implements ProviderInstance {
362
380
  const now = Date.now();
363
381
  const adapterStatus = this.adapter.getStatus();
364
382
  const parsedStatus = this.adapter.getScriptParsedStatus?.() || null;
365
- const newStatus = adapterStatus.status;
383
+ const rawStatus = adapterStatus.status;
384
+ const autoApproveActive = rawStatus === 'waiting_approval' && this.shouldAutoApprove();
385
+ if (autoApproveActive) {
386
+ const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(adapterStatus.activeModal?.buttons, this.provider);
387
+ this.recordAutoApproval(adapterStatus.activeModal?.message, buttonLabel, now);
388
+ setTimeout(() => {
389
+ this.adapter.resolveModal(buttonIndex);
390
+ }, 0);
391
+ }
392
+ const newStatus = autoApproveActive ? 'generating' : rawStatus;
366
393
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
367
394
  const chatTitle = `${this.provider.name} · ${dirName}`;
368
395
  const partial = this.adapter.getPartialResponse();
@@ -374,6 +401,7 @@ export class CliProviderInstance implements ProviderInstance {
374
401
  if (newStatus !== this.lastStatus) {
375
402
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
376
403
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
404
+ this.suppressIdleHistoryReplay = false;
377
405
  // Cancel any pending completed event (multi-step: idle→generating resume)
378
406
  if (this.completedDebouncePending) {
379
407
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
@@ -394,6 +422,7 @@ export class CliProviderInstance implements ProviderInstance {
394
422
  this.generatingDebounceTimer = null;
395
423
  }, 1000);
396
424
  } else if (newStatus === 'waiting_approval') {
425
+ this.suppressIdleHistoryReplay = false;
397
426
  // Flush pending generating_started if debounce still pending
398
427
  if (this.generatingDebouncePending) {
399
428
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -588,6 +617,18 @@ export class CliProviderInstance implements ProviderInstance {
588
617
  get cliType(): string { return this.type; }
589
618
  get cliName(): string { return this.provider.name; }
590
619
 
620
+ private shouldAutoApprove(): boolean {
621
+ return this.settings.autoApprove !== false;
622
+ }
623
+
624
+ private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
625
+ this.appendRuntimeSystemMessage(
626
+ formatAutoApprovalMessage(modalMessage, buttonLabel),
627
+ `auto_approval:${now}:${buttonLabel || 'approve'}`,
628
+ now,
629
+ );
630
+ }
631
+
591
632
  recordApprovalSelection(buttonText: string): void {
592
633
  const cleanButton = String(buttonText || '').trim();
593
634
  if (!cleanButton) return;
@@ -253,6 +253,7 @@ export interface ProviderModule {
253
253
  };
254
254
  cleanOutput?: (raw: string, lastUserInput?: string) => string;
255
255
  resume?: ProviderResumeCapability;
256
+ approvalPositiveHints?: string[];
256
257
  scripts?: ProviderScripts;
257
258
  vscodeCommands?: {
258
259
  focusPanel?: string;
@@ -388,8 +388,10 @@ export interface ProviderModule {
388
388
  };
389
389
  cleanOutput?: (raw: string, lastUserInput?: string) => string;
390
390
  resume?: ProviderResumeCapability;
391
- /** Session ID probe config — auto-discovers provider session ID from local SQLite DB */
391
+ /** Session ID probe config — auto-discovers provider session ID from local SQLite DB */
392
392
  sessionProbe?: ProviderSessionProbe;
393
+ /** Approval button priority hints used when auto-approve must pick a positive action */
394
+ approvalPositiveHints?: string[];
393
395
 
394
396
  // ─── CDP scripts (ide/extension category) ───
395
397
  scripts?: ProviderScripts;