@adhdev/daemon-core 0.9.33 → 0.9.35

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.
@@ -5,7 +5,7 @@
5
5
  * collectCliData() + status transition logic from daemon-status.ts moved here.
6
6
  */
7
7
  import { type ProviderModule } from './contracts.js';
8
- import type { ProviderInstance, ProviderState, InstanceContext } from './provider-instance.js';
8
+ import type { ProviderInstance, ProviderState, InstanceContext, HotChatSessionState } from './provider-instance.js';
9
9
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
10
10
  import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
11
11
  type PersistableCliHistoryMessage = {
@@ -79,6 +79,7 @@ export declare class CliProviderInstance implements ProviderInstance {
79
79
  previousProviderSessionId?: string;
80
80
  }) => void;
81
81
  });
82
+ refreshProviderDefinition(provider: ProviderModule): void;
82
83
  init(context: InstanceContext): Promise<void>;
83
84
  onTick(): Promise<void>;
84
85
  /**
@@ -89,6 +90,7 @@ export declare class CliProviderInstance implements ProviderInstance {
89
90
  getState(): ProviderState;
90
91
  setPresentationMode(mode: 'terminal' | 'chat'): void;
91
92
  getPresentationMode(): 'terminal' | 'chat';
93
+ getHotChatSessionState(): HotChatSessionState;
92
94
  updateSettings(newSettings: Record<string, any>): void;
93
95
  onEvent(event: string, data?: any): void;
94
96
  dispose(): void;
@@ -7,7 +7,7 @@
7
7
  * 3. Collect overall state
8
8
  * 4. Event collection and propagation
9
9
  */
10
- import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext } from './provider-instance.js';
10
+ import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, HotChatSessionState } from './provider-instance.js';
11
11
  export declare class ProviderInstanceManager {
12
12
  private instances;
13
13
  private tickTimer;
@@ -45,6 +45,7 @@ export declare class ProviderInstanceManager {
45
45
  * + Propagate pending events to event listeners
46
46
  */
47
47
  collectAllStates(): ProviderState[];
48
+ collectHotChatSessionStates(): HotChatSessionState[];
48
49
  /**
49
50
  * Per-category status collect
50
51
  */
@@ -77,6 +78,7 @@ export declare class ProviderInstanceManager {
77
78
  * Called when user changes settings from dashboard.
78
79
  */
79
80
  updateInstanceSettings(providerType: string, settings: Record<string, any>): number;
81
+ refreshProviderDefinitions(resolveProvider: (providerType: string) => unknown): number;
80
82
  /**
81
83
  * All terminate
82
84
  */
@@ -7,7 +7,7 @@
7
7
  * Daemon only collects via ProviderInstance.getState(),
8
8
  * Each Instance manages its own status.
9
9
  */
10
- import type { ProviderResumeCapability } from './contracts.js';
10
+ import type { ProviderModule, ProviderResumeCapability } from './contracts.js';
11
11
  import type { AcpConfigOption, AcpMode, ProviderControlSchema, ProviderSummaryMetadata, SessionCapability } from '../shared-types.js';
12
12
  import type { ChatMessage } from '../types.js';
13
13
  export type ProviderStatus = 'idle' | 'generating' | 'waiting_approval' | 'error' | 'stopped' | 'starting';
@@ -112,6 +112,17 @@ export interface ProviderEvent {
112
112
  timestamp: number;
113
113
  [key: string]: any;
114
114
  }
115
+ export interface HotChatSessionState {
116
+ id: string;
117
+ status?: unknown;
118
+ unread?: unknown;
119
+ inboxBucket?: unknown;
120
+ lastMessageAt?: unknown;
121
+ runtimeLifecycle?: unknown;
122
+ runtimeSurfaceKind?: unknown;
123
+ runtimeRestoredFromStorage?: unknown;
124
+ runtimeRecoveryState?: unknown;
125
+ }
115
126
  export interface InstanceContext {
116
127
  /** CDP connection (IDE/Extension) */
117
128
  cdp?: {
@@ -142,10 +153,18 @@ export interface ProviderInstance {
142
153
  onTick(): Promise<void>;
143
154
  /** Return current status */
144
155
  getState(): ProviderState;
156
+ /**
157
+ * Return the cheap session metadata needed to decide whether chat-tail
158
+ * subscriptions should be flushed. Implementations must avoid rich transcript
159
+ * parsing here; callers use this on P2P hot flush paths.
160
+ */
161
+ getHotChatSessionState?(): HotChatSessionState | HotChatSessionState[] | null;
145
162
  /** Receive event (external → Instance) */
146
163
  onEvent(event: string, data?: any): void;
147
164
  /** Update settings at runtime (called when user changes settings from dashboard) */
148
165
  updateSettings?(newSettings: Record<string, any>): void;
166
+ /** Refresh static provider definition/scripts without restarting the live runtime. */
167
+ refreshProviderDefinition?(provider: ProviderModule): void;
149
168
  /** cleanup */
150
169
  dispose(): void;
151
170
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.9.33",
3
+ "version": "0.9.35",
4
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.33",
3
+ "version": "0.9.35",
4
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",
@@ -51,6 +51,7 @@ export interface CliAdapter {
51
51
  isReady(): boolean;
52
52
  setOnStatusChange(callback: () => void): void;
53
53
  updateRuntimeSettings?(settings: Record<string, unknown>): void;
54
+ setCliScripts?(scripts: Record<string, unknown>): void;
54
55
  setServerConn?(serverConn: unknown): void;
55
56
  clearHistory?(): void;
56
57
  resolveAction?(data: unknown): Promise<void>;
@@ -32,6 +32,7 @@ import {
32
32
  extractPromptRetrySnippet,
33
33
  getLastUserPromptText,
34
34
  listCliScriptNames,
35
+ normalizeComparableMessageContent,
35
36
  normalizePromptText,
36
37
  normalizeScreenSnapshot,
37
38
  promptLikelyVisible,
@@ -183,6 +184,8 @@ export class ProviderCliAdapter implements CliAdapter {
183
184
  private messages: CliChatMessage[] = [];
184
185
  private committedMessages: CliChatMessage[] = [];
185
186
  private structuredMessages: CliChatMessage[] = [];
187
+ private committedMessagesActivitySignature = '';
188
+ private committedMessagesChangedAt = 0;
186
189
  private currentStatus: CliSessionStatus['status'] = 'starting';
187
190
  private onStatusChange: (() => void) | null = null;
188
191
 
@@ -288,11 +291,38 @@ export class ProviderCliAdapter implements CliAdapter {
288
291
  private static readonly FINISH_RETRY_DELAY_MS = 300;
289
292
  private static readonly MAX_FINISH_RETRIES = 2;
290
293
 
294
+ private buildCommittedMessagesActivitySignature(): string {
295
+ const last = this.committedMessages[this.committedMessages.length - 1];
296
+ return [
297
+ String(this.committedMessages.length),
298
+ String(last?.role || ''),
299
+ String(last?.kind || ''),
300
+ String(last?.senderName || ''),
301
+ String(last?.timestamp || ''),
302
+ String(last?.receivedAt || ''),
303
+ normalizeComparableMessageContent(last?.content || '').slice(-240),
304
+ ].join('|');
305
+ }
306
+
291
307
  private syncMessageViews(): void {
308
+ const signature = this.buildCommittedMessagesActivitySignature();
309
+ if (signature !== this.committedMessagesActivitySignature) {
310
+ this.committedMessagesActivitySignature = signature;
311
+ this.committedMessagesChangedAt = Date.now();
312
+ }
292
313
  this.messages = [...this.committedMessages];
293
314
  this.structuredMessages = [...this.committedMessages];
294
315
  }
295
316
 
317
+ getLastCommittedMessageActivityAt(): number {
318
+ const last = this.committedMessages[this.committedMessages.length - 1];
319
+ const messageTime = Math.max(
320
+ typeof last?.receivedAt === 'number' && Number.isFinite(last.receivedAt) ? last.receivedAt : 0,
321
+ typeof last?.timestamp === 'number' && Number.isFinite(last.timestamp) ? last.timestamp : 0,
322
+ );
323
+ return Math.max(messageTime, this.committedMessagesChangedAt || 0);
324
+ }
325
+
296
326
  private readTerminalScreenText(now = Date.now()): string {
297
327
  const screenText = this.terminalScreen.getText() || '';
298
328
  this.lastScreenText = screenText;
@@ -519,10 +549,18 @@ export class ProviderCliAdapter implements CliAdapter {
519
549
  /** Inject CLI scripts after construction (e.g. when resolved by ProviderLoader) */
520
550
  setCliScripts(scripts: CliScripts): void {
521
551
  this.cliScripts = scripts;
552
+ this.parsedStatusCache = null;
553
+ this.parseErrorMessage = null;
522
554
  const scriptNames = listCliScriptNames(scripts);
523
555
  LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${scriptNames.join(', ')}]`);
524
556
  }
525
557
 
558
+ /** Refresh provider scripts/config used by this adapter without restarting the PTY runtime. */
559
+ refreshProviderDefinition(provider: CliProviderModule): void {
560
+ this.provider = provider;
561
+ this.setCliScripts(provider.scripts || {});
562
+ }
563
+
526
564
  updateRuntimeSettings(settings: Record<string, any>): void {
527
565
  this.runtimeSettings = { ...settings };
528
566
  }
@@ -1067,7 +1105,7 @@ export class ProviderCliAdapter implements CliAdapter {
1067
1105
  }
1068
1106
 
1069
1107
  if (this.currentTurnScope && !lastParsedAssistant) {
1070
- LOG.info(
1108
+ LOG.debug(
1071
1109
  'CLI',
1072
1110
  `[${this.cliType}] Settled without assistant: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 220)).slice(0, 260)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'}`
1073
1111
  );
@@ -2013,9 +2051,52 @@ export class ProviderCliAdapter implements CliAdapter {
2013
2051
 
2014
2052
  private armResponseTimeout(): void {
2015
2053
  if (this.responseTimeout) clearTimeout(this.responseTimeout);
2054
+ const timeoutMs = this.timeouts.maxResponse;
2055
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
2056
+ this.responseTimeout = null;
2057
+ return;
2058
+ }
2016
2059
  this.responseTimeout = setTimeout(() => {
2017
- if (this.isWaitingForResponse) this.finishResponse();
2018
- }, this.timeouts.maxResponse);
2060
+ this.responseTimeout = null;
2061
+ if (!this.isWaitingForResponse) return;
2062
+
2063
+ const detectedStatusBeforeEval = this.runDetectStatus(this.recentOutputBuffer);
2064
+ this.recordTrace('response_timeout_check', {
2065
+ timeoutMs,
2066
+ detectedStatus: detectedStatusBeforeEval,
2067
+ currentStatus: this.currentStatus,
2068
+ isWaitingForResponse: this.isWaitingForResponse,
2069
+ hasActionableApproval: this.hasActionableApproval(),
2070
+ ...buildCliTraceParseSnapshot({
2071
+ accumulatedBuffer: this.accumulatedBuffer,
2072
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
2073
+ responseBuffer: this.responseBuffer,
2074
+ partialResponse: this.responseBuffer,
2075
+ scope: this.currentTurnScope,
2076
+ }),
2077
+ });
2078
+
2079
+ // maxResponse is a watchdog/checkpoint, not a completion signal. The old
2080
+ // behavior called finishResponse() unconditionally at the default 300s,
2081
+ // which fabricated idle transitions and downstream generating_completed
2082
+ // notifications while long-running CLIs were still generating. Re-run the
2083
+ // normal settled parser instead and keep the turn open unless the provider
2084
+ // actually reports an idle, commit-ready state.
2085
+ this.settledBuffer = this.recentOutputBuffer;
2086
+ this.evaluateSettled();
2087
+
2088
+ if (this.isWaitingForResponse && !this.hasActionableApproval()) {
2089
+ const detectedStatusAfterEval = this.runDetectStatus(this.recentOutputBuffer);
2090
+ this.recordTrace('response_timeout_kept_open', {
2091
+ timeoutMs,
2092
+ detectedStatusBeforeEval,
2093
+ detectedStatusAfterEval,
2094
+ currentStatus: this.currentStatus,
2095
+ isWaitingForResponse: this.isWaitingForResponse,
2096
+ });
2097
+ this.armResponseTimeout();
2098
+ }
2099
+ }, timeoutMs);
2019
2100
  }
2020
2101
 
2021
2102
  private writeSubmitKeyForRetry(mode: string): void {
@@ -31,6 +31,7 @@ export function hydrateCliParsedMessages(
31
31
  ): any[] {
32
32
  const { committedMessages, scope, lastOutputAt } = options;
33
33
  const referenceMessages = [...committedMessages];
34
+ const referenceComparables = referenceMessages.map((message) => normalizeComparableMessageContent(message?.content || ''));
34
35
  const usedReferenceIndexes = new Set<number>();
35
36
  const now = options.now ?? Date.now();
36
37
 
@@ -43,7 +44,7 @@ export function hydrateCliParsedMessages(
43
44
  sameIndex
44
45
  && !usedReferenceIndexes.has(parsedIndex)
45
46
  && sameIndex.role === role
46
- && normalizeComparableMessageContent(sameIndex.content) === normalizedContent
47
+ && referenceComparables[parsedIndex] === normalizedContent
47
48
  && typeof sameIndex.timestamp === 'number'
48
49
  && Number.isFinite(sameIndex.timestamp)
49
50
  ) {
@@ -55,7 +56,7 @@ export function hydrateCliParsedMessages(
55
56
  if (usedReferenceIndexes.has(i)) continue;
56
57
  const candidate = referenceMessages[i];
57
58
  if (!candidate || candidate.role !== role) continue;
58
- const candidateContent = normalizeComparableMessageContent(candidate.content);
59
+ const candidateContent = referenceComparables[i];
59
60
  if (!candidateContent) continue;
60
61
  const exactMatch = candidateContent === normalizedContent;
61
62
  const fuzzyMatch = candidateContent.includes(normalizedContent) || normalizedContent.includes(candidateContent);
@@ -507,7 +507,16 @@ export class DaemonCommandHandler implements CommandHelpers {
507
507
  if (this._ctx.providerLoader) {
508
508
  await this._ctx.providerLoader.fetchLatest().catch(() => {});
509
509
  this._ctx.providerLoader.reload();
510
- return { success: true };
510
+ this._ctx.providerLoader.registerToDetector();
511
+ const refreshedInstances = this._ctx.instanceManager
512
+ ? this._ctx.instanceManager.refreshProviderDefinitions((providerType) => this._ctx.providerLoader!.resolve(providerType))
513
+ : 0;
514
+ const providers = this._ctx.providerLoader.getAll().map((provider) => ({
515
+ type: provider.type,
516
+ name: provider.name,
517
+ category: provider.category,
518
+ }));
519
+ return { success: true, refreshedInstances, providers };
511
520
  }
512
521
  return { success: false, error: 'ProviderLoader not initialized' };
513
522
  }
@@ -161,6 +161,52 @@ function killPid(pid: number): boolean {
161
161
  }
162
162
  }
163
163
 
164
+ function getWindowsProcessCommandLine(pid: number): string | null {
165
+ const pidFilter = `ProcessId=${pid}`;
166
+ try {
167
+ const psOut = execFileSync('powershell.exe', [
168
+ '-NoProfile',
169
+ '-NonInteractive',
170
+ '-ExecutionPolicy', 'Bypass',
171
+ '-Command',
172
+ `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
173
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
174
+ if (psOut) return psOut;
175
+ } catch {
176
+ // fall through to wmic fallback
177
+ }
178
+
179
+ try {
180
+ const wmicOut = execFileSync('wmic', [
181
+ 'process', 'where', pidFilter, 'get', 'CommandLine',
182
+ ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
183
+ if (wmicOut) return wmicOut;
184
+ } catch {
185
+ // noop
186
+ }
187
+ return null;
188
+ }
189
+
190
+ function getProcessCommandLine(pid: number): string | null {
191
+ if (!Number.isFinite(pid) || pid <= 0) return null;
192
+ if (process.platform === 'win32') return getWindowsProcessCommandLine(pid);
193
+ try {
194
+ const text = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
195
+ encoding: 'utf8',
196
+ timeout: 3000,
197
+ stdio: ['ignore', 'pipe', 'ignore'],
198
+ }).trim();
199
+ return text || null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function isManagedSessionHostPid(pid: number): boolean {
206
+ const commandLine = getProcessCommandLine(pid);
207
+ return !!commandLine && /session-host-daemon/i.test(commandLine);
208
+ }
209
+
164
210
  async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
165
211
  const start = Date.now();
166
212
  while (Date.now() - start < timeoutMs) {
@@ -173,12 +219,12 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
173
219
  }
174
220
  }
175
221
 
176
- function stopSessionHostProcesses(appName: string): void {
222
+ export function stopSessionHostProcesses(appName: string): void {
177
223
  const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
178
224
  try {
179
225
  if (fs.existsSync(pidFile)) {
180
226
  const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
181
- if (Number.isFinite(pid)) {
227
+ if (Number.isFinite(pid) && pid !== process.pid && isManagedSessionHostPid(pid)) {
182
228
  killPid(pid);
183
229
  }
184
230
  }
@@ -191,20 +237,6 @@ function stopSessionHostProcesses(appName: string): void {
191
237
  // noop
192
238
  }
193
239
  }
194
-
195
- if (process.platform !== 'win32') {
196
- try {
197
- const raw = execFileSync('pgrep', ['-f', 'session-host-daemon'], { encoding: 'utf8' }).trim();
198
- for (const line of raw.split('\n')) {
199
- const pid = Number.parseInt(line.trim(), 10);
200
- if (Number.isFinite(pid)) {
201
- killPid(pid);
202
- }
203
- }
204
- } catch {
205
- // noop
206
- }
207
- }
208
240
  }
209
241
 
210
242
  function removeDaemonPidFile(): void {
@@ -507,20 +507,9 @@ 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
+ const refreshedInstances = this.instanceManager
511
+ ? this.instanceManager.refreshProviderDefinitions((providerType) => this.providerLoader.resolve(providerType))
512
+ : 0;
524
513
  const providers = this.providerLoader.getAll().map(p => ({
525
514
  type: p.type, name: p.name, category: p.category,
526
515
  }));
@@ -12,7 +12,7 @@ import * as fs from 'fs';
12
12
  import { createRequire } from 'node:module';
13
13
  import { normalizeInputEnvelope, type ProviderModule, flattenContent } from './contracts.js';
14
14
  import { assertTextOnlyInput } from './provider-input-support.js';
15
- import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason } from './provider-instance.js';
15
+ import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState } from './provider-instance.js';
16
16
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
17
17
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
18
18
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
@@ -219,6 +219,12 @@ export class CliProviderInstance implements ProviderInstance {
219
219
  this.historyWriter = new ChatHistoryWriter();
220
220
  }
221
221
 
222
+ refreshProviderDefinition(provider: ProviderModule): void {
223
+ if (provider.type !== this.type || provider.category !== 'cli') return;
224
+ this.provider = provider;
225
+ this.adapter.refreshProviderDefinition(provider as CliProviderModule);
226
+ }
227
+
222
228
  // ─── Lifecycle ─────────────────────────────────
223
229
 
224
230
  async init(context: InstanceContext): Promise<void> {
@@ -487,6 +493,25 @@ export class CliProviderInstance implements ProviderInstance {
487
493
  return this.presentationMode;
488
494
  }
489
495
 
496
+ getHotChatSessionState(): HotChatSessionState {
497
+ const adapterStatus = this.adapter.getStatus();
498
+ const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
499
+ const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
500
+ const runtime = this.adapter.getRuntimeMetadata();
501
+ const lastCommittedMessageActivityAt = typeof this.adapter.getLastCommittedMessageActivityAt === 'function'
502
+ ? this.adapter.getLastCommittedMessageActivityAt()
503
+ : 0;
504
+ return {
505
+ id: this.instanceId,
506
+ status: visibleStatus,
507
+ lastMessageAt: lastCommittedMessageActivityAt || undefined,
508
+ runtimeLifecycle: runtime?.lifecycle ?? null,
509
+ runtimeSurfaceKind: runtime?.surfaceKind,
510
+ runtimeRestoredFromStorage: runtime?.restoredFromStorage === true,
511
+ runtimeRecoveryState: runtime?.recoveryState ?? null,
512
+ };
513
+ }
514
+
490
515
  updateSettings(newSettings: Record<string, any>): void {
491
516
  this.settings = { ...newSettings };
492
517
  this.adapter.updateRuntimeSettings?.(this.settings);
@@ -650,6 +675,15 @@ export class CliProviderInstance implements ProviderInstance {
650
675
  this.completedDebouncePending = { chatTitle, duration, timestamp: now };
651
676
  this.completedDebounceTimer = setTimeout(() => {
652
677
  if (this.completedDebouncePending) {
678
+ const latestStatus = this.adapter.getStatus();
679
+ const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
680
+ const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
681
+ if (latestVisibleStatus !== 'idle') {
682
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
683
+ this.completedDebouncePending = null;
684
+ this.completedDebounceTimer = null;
685
+ return;
686
+ }
653
687
  LOG.info('CLI', `[${this.type}] completed in ${this.completedDebouncePending.duration}s`);
654
688
  this.pushEvent({ event: 'agent:generating_completed', ...this.completedDebouncePending });
655
689
  this.completedDebouncePending = null;
@@ -8,9 +8,28 @@
8
8
  * 4. Event collection and propagation
9
9
  */
10
10
 
11
- import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext } from './provider-instance.js';
11
+ import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, HotChatSessionState } from './provider-instance.js';
12
12
  import { LOG } from '../logging/logger.js';
13
13
 
14
+ function projectHotChatSessionStatesFromProviderState(state: ProviderState): HotChatSessionState[] {
15
+ const project = (item: ProviderState): HotChatSessionState => ({
16
+ id: item.instanceId,
17
+ status: item.activeChat?.status || item.status,
18
+ unread: (item as any).unread,
19
+ inboxBucket: (item as any).inboxBucket,
20
+ lastMessageAt: (item as any).lastMessageAt ?? (item.activeChat as any)?.lastMessageAt,
21
+ runtimeLifecycle: item.runtime?.lifecycle ?? null,
22
+ runtimeSurfaceKind: item.runtime?.surfaceKind,
23
+ runtimeRestoredFromStorage: item.runtime?.restoredFromStorage === true,
24
+ runtimeRecoveryState: item.runtime?.recoveryState ?? null,
25
+ });
26
+
27
+ if (state.category === 'ide') {
28
+ return [project(state), ...state.extensions.map(project)];
29
+ }
30
+ return [project(state)];
31
+ }
32
+
14
33
  export class ProviderInstanceManager {
15
34
  private instances = new Map<string, ProviderInstance>();
16
35
  private tickTimer: NodeJS.Timeout | null = null;
@@ -120,6 +139,32 @@ export class ProviderInstanceManager {
120
139
  return states;
121
140
  }
122
141
 
142
+ collectHotChatSessionStates(): HotChatSessionState[] {
143
+ const sessions: HotChatSessionState[] = [];
144
+ for (const [id, instance] of this.instances) {
145
+ try {
146
+ const projected = instance.getHotChatSessionState?.();
147
+ if (Array.isArray(projected)) {
148
+ sessions.push(...projected.filter((session): session is HotChatSessionState => !!session?.id));
149
+ continue;
150
+ }
151
+ if (projected?.id) {
152
+ sessions.push(projected);
153
+ continue;
154
+ }
155
+
156
+ // Fallback for provider types that have not implemented the cheap
157
+ // projection yet. CLI implements getHotChatSessionState() because
158
+ // its full getState() may run rich transcript parsing.
159
+ const state = instance.getState();
160
+ sessions.push(...projectHotChatSessionStatesFromProviderState(state));
161
+ } catch (e) {
162
+ LOG.warn('InstanceMgr', `[InstanceManager] Failed to collect hot chat metadata from ${id}: ${(e as Error).message}`);
163
+ }
164
+ }
165
+ return sessions;
166
+ }
167
+
123
168
  /**
124
169
  * Per-category status collect
125
170
  */
@@ -216,6 +261,18 @@ export class ProviderInstanceManager {
216
261
  return updated;
217
262
  }
218
263
 
264
+ refreshProviderDefinitions(resolveProvider: (providerType: string) => unknown): number {
265
+ let refreshed = 0;
266
+ for (const instance of this.instances.values()) {
267
+ if (typeof instance.refreshProviderDefinition !== 'function') continue;
268
+ const provider = resolveProvider(instance.type);
269
+ if (!provider || typeof provider !== 'object') continue;
270
+ instance.refreshProviderDefinition(provider as any);
271
+ refreshed += 1;
272
+ }
273
+ return refreshed;
274
+ }
275
+
219
276
  // ─── cleanup ──────────────────────────────────────
220
277
 
221
278
  /**
@@ -135,6 +135,18 @@ export interface ProviderEvent {
135
135
  [key: string]: any;
136
136
  }
137
137
 
138
+ export interface HotChatSessionState {
139
+ id: string;
140
+ status?: unknown;
141
+ unread?: unknown;
142
+ inboxBucket?: unknown;
143
+ lastMessageAt?: unknown;
144
+ runtimeLifecycle?: unknown;
145
+ runtimeSurfaceKind?: unknown;
146
+ runtimeRestoredFromStorage?: unknown;
147
+ runtimeRecoveryState?: unknown;
148
+ }
149
+
138
150
  // ─── ProviderInstance interface ─────────────────
139
151
 
140
152
  export interface InstanceContext {
@@ -172,12 +184,22 @@ export interface ProviderInstance {
172
184
  /** Return current status */
173
185
  getState(): ProviderState;
174
186
 
187
+ /**
188
+ * Return the cheap session metadata needed to decide whether chat-tail
189
+ * subscriptions should be flushed. Implementations must avoid rich transcript
190
+ * parsing here; callers use this on P2P hot flush paths.
191
+ */
192
+ getHotChatSessionState?(): HotChatSessionState | HotChatSessionState[] | null;
193
+
175
194
  /** Receive event (external → Instance) */
176
195
  onEvent(event: string, data?: any): void;
177
196
 
178
197
  /** Update settings at runtime (called when user changes settings from dashboard) */
179
198
  updateSettings?(newSettings: Record<string, any>): void;
180
199
 
200
+ /** Refresh static provider definition/scripts without restarting the live runtime. */
201
+ refreshProviderDefinition?(provider: ProviderModule): void;
202
+
181
203
  /** cleanup */
182
204
  dispose(): void;
183
205
  }