@adhdev/daemon-core 0.8.24 → 0.8.27

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 (34) hide show
  1. package/dist/agent-stream/provider-adapter.d.ts +1 -0
  2. package/dist/boot/daemon-lifecycle.d.ts +2 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +3 -0
  4. package/dist/commands/router.d.ts +24 -0
  5. package/dist/index.js +309 -19
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +309 -19
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/providers/cli-provider-instance.d.ts +1 -0
  10. package/dist/providers/extension-provider-instance.d.ts +7 -0
  11. package/dist/sessions/reconcile.d.ts +22 -0
  12. package/dist/status/normalize.js +14 -2
  13. package/dist/status/normalize.js.map +1 -1
  14. package/dist/status/normalize.mjs +14 -2
  15. package/dist/status/normalize.mjs.map +1 -1
  16. package/dist/status/snapshot.d.ts +0 -2
  17. package/node_modules/@adhdev/session-host-core/dist/index.d.mts +72 -1
  18. package/node_modules/@adhdev/session-host-core/dist/index.d.ts +72 -1
  19. package/node_modules/@adhdev/session-host-core/dist/index.js.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/poller.ts +4 -0
  23. package/src/agent-stream/provider-adapter.ts +30 -1
  24. package/src/boot/daemon-lifecycle.ts +3 -0
  25. package/src/cli-adapters/pty-transport.ts +3 -0
  26. package/src/cli-adapters/session-host-transport.ts +8 -0
  27. package/src/commands/chat-commands.ts +2 -2
  28. package/src/commands/handler.ts +79 -13
  29. package/src/commands/router.ts +132 -0
  30. package/src/providers/cli-provider-instance.ts +29 -1
  31. package/src/providers/extension-provider-instance.ts +24 -1
  32. package/src/sessions/reconcile.ts +85 -0
  33. package/src/status/normalize.ts +19 -2
  34. package/src/status/snapshot.ts +6 -13
@@ -16,6 +16,7 @@ import type { ProviderLoader } from '../providers/provider-loader.js';
16
16
  import type { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
17
17
  import { registerExtensionProviders } from '../cdp/setup.js';
18
18
  import type { SessionRegistry } from '../sessions/registry.js';
19
+ import { reconcileIdeRuntimeSessions } from '../sessions/reconcile.js';
19
20
  import { LOG } from '../logging/logger.js';
20
21
  import type { AgentStreamState } from './types.js';
21
22
 
@@ -78,6 +79,9 @@ export class AgentStreamPoller {
78
79
 
79
80
  if (!agentStreamManager || cdpManagers.size === 0) return;
80
81
 
82
+ // Defensive repair: keep SessionRegistry aligned with live IDE/extension instances.
83
+ reconcileIdeRuntimeSessions(instanceManager as any, sessionRegistry);
84
+
81
85
  // ─── Phase 1: Refresh extension providers + IDE instance extensions ───
82
86
  for (const [ideType, cdp] of cdpManagers) {
83
87
  // 1a. Refresh CDP manager's extension providers from config
@@ -41,6 +41,15 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
41
41
  return typeof (this.provider.scripts as any)?.[name] === 'function';
42
42
  }
43
43
 
44
+ private parseMaybeJson(raw: unknown): any {
45
+ if (typeof raw !== 'string') return raw;
46
+ try {
47
+ return JSON.parse(raw);
48
+ } catch {
49
+ return raw;
50
+ }
51
+ }
52
+
44
53
  private summarizeRaw(raw: unknown): string {
45
54
  try {
46
55
  if (typeof raw === 'string') return raw.replace(/\s+/g, ' ').trim().slice(0, 240);
@@ -111,12 +120,32 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
111
120
  }
112
121
 
113
122
  async sendMessage(evaluate: AgentEvaluateFn, text: string): Promise<void> {
114
- const script = this.callScript('sendMessage', text);
123
+ const params = { message: text };
124
+ const script = this.callScript('sendMessage', params) || this.callScript('sendMessage', text);
115
125
  if (!script) throw new Error(`[${this.agentName}] sendMessage script not available`);
116
126
  const result = await evaluate(script) as string;
117
127
  if (result && typeof result === 'string' && result.startsWith('error:')) {
118
128
  throw new Error(`[${this.agentName}] sendMessage failed: ${result}`);
119
129
  }
130
+
131
+ const parsed = this.parseMaybeJson(result);
132
+ if (parsed === true) return;
133
+ if (typeof parsed === 'string') {
134
+ const normalized = parsed.trim().toLowerCase();
135
+ if (normalized === 'ok' || normalized === 'sent' || normalized === 'success' || normalized === 'true') {
136
+ return;
137
+ }
138
+ }
139
+ if (parsed && typeof parsed === 'object') {
140
+ if (parsed.sent === true || parsed.success === true || parsed.ok === true || parsed.submitted === true || parsed.dispatched === true) {
141
+ return;
142
+ }
143
+ if (typeof parsed.error === 'string' && parsed.error.trim()) {
144
+ throw new Error(`[${this.agentName}] sendMessage failed: ${parsed.error}`);
145
+ }
146
+ }
147
+
148
+ throw new Error(`[${this.agentName}] sendMessage was not confirmed`);
120
149
  }
121
150
 
122
151
  async resolveAction(evaluate: AgentEvaluateFn, action: string, button?: string): Promise<boolean> {
@@ -12,6 +12,7 @@ import { DaemonCdpInitializer, type CdpInitializerConfig } from '../cdp/initiali
12
12
  import { setupIdeInstance, type CdpSetupContext } from '../cdp/setup.js';
13
13
  import { DaemonCommandHandler } from '../commands/handler.js';
14
14
  import { DaemonCommandRouter, type CommandRouterDeps } from '../commands/router.js';
15
+ import type { SessionHostControlPlane } from '../commands/router.js';
15
16
  import {
16
17
  DaemonCliManager,
17
18
  type CliTransportFactoryParams,
@@ -51,6 +52,7 @@ export interface DaemonInitConfig {
51
52
  /** Router transport-specific callbacks */
52
53
  onStatusChange?: () => void;
53
54
  onPostChatCommand?: () => void;
55
+ sessionHostControl?: SessionHostControlPlane | null;
54
56
  getCdpLogFn?: (ideType: string) => (msg: string) => void;
55
57
 
56
58
  /** Additional callback after CDP manager created (transport-specific extras) */
@@ -245,6 +247,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
245
247
  onIdeConnected: () => poller?.start(),
246
248
  onStatusChange: config.onStatusChange,
247
249
  onPostChatCommand: config.onPostChatCommand,
250
+ sessionHostControl: config.sessionHostControl,
248
251
  getCdpLogFn: config.getCdpLogFn || ((ideType: string) => LOG.forComponent(`CDP:${ideType}`).asLogFn()),
249
252
  });
250
253
 
@@ -42,6 +42,9 @@ export interface PtyRuntimeMetadata {
42
42
  workspaceLabel?: string;
43
43
  writeOwner?: PtyRuntimeWriteOwner | null;
44
44
  attachedClients?: PtyRuntimeClientInfo[];
45
+ restoredFromStorage?: boolean;
46
+ recoveryState?: string | null;
47
+ recoveryError?: string | null;
45
48
  }
46
49
 
47
50
  export interface PtyRuntimeTransport {
@@ -305,6 +305,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
305
305
  }
306
306
 
307
307
  private handleEvent(event: SessionHostEvent): void {
308
+ if (!('sessionId' in event)) return;
308
309
  if (event.sessionId !== this.options.runtimeId) return;
309
310
  if ((event.type === 'session_started' || event.type === 'session_resumed') && typeof event.pid === 'number') {
310
311
  this.currentPid = event.pid;
@@ -383,6 +384,13 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
383
384
  type: client.type,
384
385
  readOnly: client.readOnly,
385
386
  })),
387
+ restoredFromStorage: record.meta?.restoredFromStorage === true,
388
+ recoveryState: typeof record.meta?.runtimeRecoveryState === 'string'
389
+ ? String(record.meta.runtimeRecoveryState)
390
+ : null,
391
+ recoveryError: typeof record.meta?.runtimeRecoveryError === 'string'
392
+ ? String(record.meta.runtimeRecoveryError)
393
+ : null,
386
394
  };
387
395
  }
388
396
 
@@ -303,7 +303,7 @@ export async function handleSendChat(h: CommandHelpers, args: any): Promise<Comm
303
303
  _log(`Extension: ${provider?.type || 'unknown_extension'}`);
304
304
  // Method 1: provider sendMessage script via evaluateInSession
305
305
  try {
306
- const evalResult = await h.evaluateProviderScript('sendMessage', { MESSAGE: text }, 30000);
306
+ const evalResult = await h.evaluateProviderScript('sendMessage', { message: text }, 30000);
307
307
  if (evalResult?.result) {
308
308
  const parsed = parseMaybeJson(evalResult.result);
309
309
  if (didProviderConfirmSend(parsed)) {
@@ -338,7 +338,7 @@ export async function handleSendChat(h: CommandHelpers, args: any): Promise<Comm
338
338
  }
339
339
 
340
340
  _log(`Targeting IDE: ${getCurrentManagerKey(h)}`);
341
- const sendScript = h.getProviderScript('sendMessage', { MESSAGE: text });
341
+ const sendScript = h.getProviderScript('sendMessage', { message: text });
342
342
  if (sendScript) {
343
343
  try {
344
344
  const result = await targetCdp.evaluate(sendScript, 30000);
@@ -21,6 +21,7 @@ import type { DaemonAgentStreamManager } from '../agent-stream/index.js';
21
21
  import { loadConfig } from '../config/config.js';
22
22
  import { ChatHistoryWriter } from '../config/chat-history.js';
23
23
  import type { SessionRegistry, SessionRuntimeTarget } from '../sessions/registry.js';
24
+ import { reconcileIdeRuntimeSessions } from '../sessions/reconcile.js';
24
25
  import { LOG } from '../logging/logger.js';
25
26
 
26
27
  // Sub-module imports
@@ -165,6 +166,7 @@ export class DaemonCommandHandler implements CommandHelpers {
165
166
  session?: SessionRuntimeTarget;
166
167
  managerKey?: string;
167
168
  providerType?: string;
169
+ sessionLookupFailed?: boolean;
168
170
  } = {};
169
171
 
170
172
  constructor(ctx: CommandContext) {
@@ -213,9 +215,31 @@ export class DaemonCommandHandler implements CommandHelpers {
213
215
  if (provider?.scripts) {
214
216
  const fn = (provider.scripts as any)[scriptName];
215
217
  if (typeof fn === 'function') {
216
- const firstVal = params ? Object.values(params)[0] : undefined;
217
- const script = firstVal ? fn(firstVal) : fn();
218
- if (script) return script;
218
+ if (params && Object.keys(params).length > 0) {
219
+ const firstVal = Object.values(params)[0];
220
+ if (scriptName === 'sendMessage' && typeof firstVal === 'string') {
221
+ const legacyScript = fn(firstVal);
222
+ if (legacyScript) return legacyScript;
223
+ }
224
+ const script = fn(params);
225
+ if (script) {
226
+ const likelyLegacyObjectLeak =
227
+ typeof script === 'string'
228
+ && script.includes('[object Object]')
229
+ && typeof firstVal === 'string';
230
+ if (!likelyLegacyObjectLeak) return script;
231
+ }
232
+
233
+ if (firstVal !== undefined) {
234
+ const legacyScript = fn(firstVal);
235
+ if (legacyScript) return legacyScript;
236
+ }
237
+
238
+ if (script) return script;
239
+ } else {
240
+ const script = fn();
241
+ if (script) return script;
242
+ }
219
243
  }
220
244
  }
221
245
  return null;
@@ -284,23 +308,36 @@ export class DaemonCommandHandler implements CommandHelpers {
284
308
  return key.split('_')[0];
285
309
  }
286
310
 
287
- private resolveRoute(args: any): { session?: SessionRuntimeTarget; managerKey?: string; providerType?: string } {
288
- const session = this._ctx.sessionRegistry?.get(args?.targetSessionId);
289
- const managerKey = this.extractIdeType(args);
290
- const providerType =
291
- args?.agentType
292
- || args?.providerType
293
- || session?.providerType
294
- || this.inferProviderType(managerKey);
295
- return { session, managerKey, providerType };
311
+ private resolveRoute(args: any): { session?: SessionRuntimeTarget; managerKey?: string; providerType?: string; sessionLookupFailed?: boolean } {
312
+ const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
313
+ let session = targetSessionId ? this._ctx.sessionRegistry?.get(targetSessionId) : undefined;
314
+ if (targetSessionId && !session) {
315
+ reconcileIdeRuntimeSessions(this._ctx.instanceManager as any, this._ctx.sessionRegistry);
316
+ session = this._ctx.sessionRegistry?.get(targetSessionId);
317
+ }
318
+ const sessionLookupFailed = !!targetSessionId && !session;
319
+
320
+ const managerKey = this.extractIdeType(args, sessionLookupFailed);
321
+ let providerType: string | undefined;
322
+
323
+ if (!sessionLookupFailed) {
324
+ providerType =
325
+ session?.providerType
326
+ || args?.agentType
327
+ || args?.providerType
328
+ || this.inferProviderType(managerKey);
329
+ }
330
+
331
+ return { session, managerKey, providerType, sessionLookupFailed };
296
332
  }
297
333
 
298
334
  /** Extract CDP scope key from target session or explicit ideType */
299
- private extractIdeType(args: any): string | undefined {
335
+ private extractIdeType(args: any, sessionLookupFailed = false): string | undefined {
300
336
  if (args?.targetSessionId) {
301
337
  const target = this._ctx.sessionRegistry?.get(args.targetSessionId);
302
338
  if (target?.cdpManagerKey) return target.cdpManagerKey;
303
339
  if (this._ctx.cdpManagers.has(args.targetSessionId)) return args.targetSessionId;
340
+ if (sessionLookupFailed) return undefined;
304
341
  }
305
342
 
306
343
  // Also accept explicit ideType from args (P2P input, agentType for extensions)
@@ -360,6 +397,35 @@ export class DaemonCommandHandler implements CommandHelpers {
360
397
  const startedAt = Date.now();
361
398
  this.logCommandStart(cmd, args);
362
399
 
400
+ const sessionScopedCommands = new Set([
401
+ 'read_chat',
402
+ 'send_chat',
403
+ 'list_chats',
404
+ 'new_chat',
405
+ 'switch_chat',
406
+ 'set_mode',
407
+ 'change_model',
408
+ 'set_thought_level',
409
+ 'resolve_action',
410
+ 'focus_session',
411
+ 'pty_input',
412
+ 'pty_resize',
413
+ 'invoke_provider_script',
414
+ 'list_extension_models',
415
+ 'set_extension_model',
416
+ 'list_extension_modes',
417
+ 'set_extension_mode',
418
+ ]);
419
+
420
+ if (this._currentRoute.sessionLookupFailed && sessionScopedCommands.has(cmd)) {
421
+ const result = {
422
+ success: false,
423
+ error: `Live session not found for targetSessionId: ${String(args?.targetSessionId || '').trim() || 'unknown'}`,
424
+ };
425
+ this.logCommandEnd(cmd, result, startedAt);
426
+ return result;
427
+ }
428
+
363
429
  // Commands without ideType CDP silently fail (prevent P2P retry spam)
364
430
  let result: CommandResult;
365
431
  if (!this._currentRoute.session && !this._currentRoute.managerKey && !this._currentRoute.providerType) {
@@ -14,6 +14,7 @@ import { registerExtensionProviders } from '../cdp/setup.js';
14
14
  import { DaemonCommandHandler } from './handler.js';
15
15
  import { DaemonCliManager } from './cli-manager.js';
16
16
  import { supportsExplicitSessionResume } from './cli-manager.js';
17
+ import type { HostedCliRuntimeDescriptor } from './cli-manager.js';
17
18
  import type { ProviderLoader } from '../providers/provider-loader.js';
18
19
  import type { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
19
20
  import { launchWithCdp, killIdeProcess, isIdeRunning } from '../launch.js';
@@ -35,6 +36,18 @@ import * as fs from 'fs';
35
36
 
36
37
  // ─── Types ───
37
38
 
39
+ export interface SessionHostControlPlane {
40
+ getDiagnostics(payload?: { includeSessions?: boolean; limit?: number }): Promise<any>;
41
+ listSessions(): Promise<any[]>;
42
+ stopSession(sessionId: string): Promise<any>;
43
+ resumeSession(sessionId: string): Promise<any>;
44
+ restartSession(sessionId: string): Promise<any>;
45
+ sendSignal(sessionId: string, signal: string): Promise<any>;
46
+ forceDetachClient(sessionId: string, clientId: string): Promise<any>;
47
+ acquireWrite(payload: { sessionId: string; clientId: string; ownerType: 'agent' | 'user'; force?: boolean }): Promise<any>;
48
+ releaseWrite(payload: { sessionId: string; clientId: string }): Promise<any>;
49
+ }
50
+
38
51
  export interface CommandRouterDeps {
39
52
  commandHandler: DaemonCommandHandler;
40
53
  cliManager: DaemonCliManager;
@@ -56,6 +69,8 @@ export interface CommandRouterDeps {
56
69
  getCdpLogFn?: (ideType: string) => (msg: string) => void;
57
70
  /** Package name for upgrade detection ('adhdev' or '@adhdev/daemon-standalone') */
58
71
  packageName?: string;
72
+ /** Session host control plane */
73
+ sessionHostControl?: SessionHostControlPlane | null;
59
74
  }
60
75
 
61
76
  export interface CommandRouterResult {
@@ -70,6 +85,30 @@ const CHAT_COMMANDS = [
70
85
  ];
71
86
  const READ_DEBUG_ENABLED = process.argv.includes('--dev') || process.env.ADHDEV_READ_DEBUG === '1';
72
87
 
88
+ function toHostedCliRuntimeDescriptor(record: any): HostedCliRuntimeDescriptor | null {
89
+ if (!record || typeof record !== 'object') return null;
90
+ const runtimeId = typeof record.sessionId === 'string' ? record.sessionId : '';
91
+ const cliType = typeof record.providerType === 'string' ? record.providerType : '';
92
+ const workspace = typeof record.workspace === 'string' ? record.workspace : '';
93
+ if (!runtimeId || !cliType || !workspace) return null;
94
+ return {
95
+ runtimeId,
96
+ runtimeKey: typeof record.runtimeKey === 'string' ? record.runtimeKey : undefined,
97
+ displayName: typeof record.displayName === 'string' ? record.displayName : undefined,
98
+ workspaceLabel: typeof record.workspaceLabel === 'string' ? record.workspaceLabel : undefined,
99
+ lifecycle: typeof record.lifecycle === 'string' ? record.lifecycle as HostedCliRuntimeDescriptor['lifecycle'] : undefined,
100
+ recoveryState: typeof record.meta?.runtimeRecoveryState === 'string'
101
+ ? String(record.meta.runtimeRecoveryState)
102
+ : null,
103
+ cliType,
104
+ workspace,
105
+ cliArgs: Array.isArray(record.meta?.cliArgs) ? record.meta.cliArgs as string[] : [],
106
+ providerSessionId: typeof record.meta?.providerSessionId === 'string'
107
+ ? String(record.meta.providerSessionId)
108
+ : undefined,
109
+ };
110
+ }
111
+
73
112
  export class DaemonCommandRouter {
74
113
  private deps: CommandRouterDeps;
75
114
 
@@ -159,6 +198,99 @@ export class DaemonCommandRouter {
159
198
  }
160
199
  }
161
200
 
201
+ case 'session_host_get_diagnostics': {
202
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
203
+ const diagnostics = await this.deps.sessionHostControl.getDiagnostics({
204
+ includeSessions: args?.includeSessions !== false,
205
+ limit: Number(args?.limit) || undefined,
206
+ });
207
+ return { success: true, diagnostics };
208
+ }
209
+
210
+ case 'session_host_list_sessions': {
211
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
212
+ const sessions = await this.deps.sessionHostControl.listSessions();
213
+ return { success: true, sessions };
214
+ }
215
+
216
+ case 'session_host_stop_session': {
217
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
218
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
219
+ if (!sessionId) return { success: false, error: 'sessionId required' };
220
+ const record = await this.deps.sessionHostControl.stopSession(sessionId);
221
+ return { success: true, record };
222
+ }
223
+
224
+ case 'session_host_resume_session': {
225
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
226
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
227
+ if (!sessionId) return { success: false, error: 'sessionId required' };
228
+ const record = await this.deps.sessionHostControl.resumeSession(sessionId);
229
+ const hosted = toHostedCliRuntimeDescriptor(record);
230
+ if (hosted) {
231
+ await this.deps.cliManager.restoreHostedSessions([hosted]);
232
+ }
233
+ return { success: true, record };
234
+ }
235
+
236
+ case 'session_host_restart_session': {
237
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
238
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
239
+ if (!sessionId) return { success: false, error: 'sessionId required' };
240
+ const record = await this.deps.sessionHostControl.restartSession(sessionId);
241
+ const hosted = toHostedCliRuntimeDescriptor(record);
242
+ if (hosted) {
243
+ await this.deps.cliManager.restoreHostedSessions([hosted]);
244
+ }
245
+ return { success: true, record };
246
+ }
247
+
248
+ case 'session_host_send_signal': {
249
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
250
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
251
+ const signal = typeof args?.signal === 'string' ? args.signal : '';
252
+ if (!sessionId) return { success: false, error: 'sessionId required' };
253
+ if (!signal) return { success: false, error: 'signal required' };
254
+ const record = await this.deps.sessionHostControl.sendSignal(sessionId, signal);
255
+ return { success: true, record };
256
+ }
257
+
258
+ case 'session_host_force_detach_client': {
259
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
260
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
261
+ const clientId = typeof args?.clientId === 'string' ? args.clientId : '';
262
+ if (!sessionId) return { success: false, error: 'sessionId required' };
263
+ if (!clientId) return { success: false, error: 'clientId required' };
264
+ const record = await this.deps.sessionHostControl.forceDetachClient(sessionId, clientId);
265
+ return { success: true, record };
266
+ }
267
+
268
+ case 'session_host_acquire_write': {
269
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
270
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
271
+ const clientId = typeof args?.clientId === 'string' ? args.clientId : '';
272
+ const ownerType = args?.ownerType === 'agent' ? 'agent' : 'user';
273
+ if (!sessionId) return { success: false, error: 'sessionId required' };
274
+ if (!clientId) return { success: false, error: 'clientId required' };
275
+ const record = await this.deps.sessionHostControl.acquireWrite({
276
+ sessionId,
277
+ clientId,
278
+ ownerType,
279
+ force: args?.force !== false,
280
+ });
281
+ return { success: true, record };
282
+ }
283
+
284
+ case 'session_host_release_write': {
285
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
286
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
287
+ const clientId = typeof args?.clientId === 'string' ? args.clientId : '';
288
+ if (!sessionId) return { success: false, error: 'sessionId required' };
289
+ if (!clientId) return { success: false, error: 'clientId required' };
290
+ const record = await this.deps.sessionHostControl.releaseWrite({ sessionId, clientId });
291
+ return { success: true, record };
292
+ }
293
+
162
294
  case 'list_saved_sessions': {
163
295
  const providerType = typeof args?.providerType === 'string'
164
296
  ? args.providerType.trim()
@@ -14,7 +14,7 @@ import type { ProviderModule } from './contracts.js';
14
14
  import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext } from './provider-instance.js';
15
15
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
16
16
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
17
- import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
17
+ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
18
18
  import { StatusMonitor } from './status-monitor.js';
19
19
  import { ChatHistoryWriter, readChatHistory } from '../config/chat-history.js';
20
20
  import { LOG } from '../logging/logger.js';
@@ -133,6 +133,7 @@ export class CliProviderInstance implements ProviderInstance {
133
133
 
134
134
  // PTY spawn
135
135
  await this.adapter.spawn();
136
+ this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
136
137
  if (this.providerSessionId) {
137
138
  const restoredHistory = readChatHistory(this.type, 0, 200, this.providerSessionId);
138
139
  if (restoredHistory.messages.length > 0) {
@@ -246,6 +247,7 @@ export class CliProviderInstance implements ProviderInstance {
246
247
  this.promoteProviderSessionId(parsedProviderSessionId);
247
248
  }
248
249
  const runtime = this.adapter.getRuntimeMetadata();
250
+ this.maybeAppendRuntimeRecoveryMessage(runtime);
249
251
  const parsedMessages = Array.isArray(parsedStatus?.messages) ? parsedStatus.messages : [];
250
252
  const controlValues = extractProviderControlValues(this.provider.controls, parsedStatus);
251
253
  if (controlValues) {
@@ -603,6 +605,32 @@ export class CliProviderInstance implements ProviderInstance {
603
605
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
604
606
  }
605
607
 
608
+ private maybeAppendRuntimeRecoveryMessage(runtime: PtyRuntimeMetadata | null): void {
609
+ if (!runtime?.restoredFromStorage || !runtime.runtimeId) return;
610
+
611
+ const recoveryState = String(runtime.recoveryState || '').trim();
612
+ if (!recoveryState) return;
613
+
614
+ let content = '';
615
+ if (recoveryState === 'auto_resumed') {
616
+ content = 'Session host restored this CLI after restart and reattached it from a saved snapshot.';
617
+ } else if (recoveryState === 'resume_failed') {
618
+ const errorSuffix = runtime.recoveryError ? ` Resume failed: ${runtime.recoveryError}` : '';
619
+ content = `Session host found this CLI after restart, but automatic resume failed.${errorSuffix}`;
620
+ } else if (recoveryState === 'host_restart_interrupted') {
621
+ content = 'Session host found this CLI in interrupted state after restart and is attempting to resume it.';
622
+ } else if (recoveryState === 'orphan_snapshot') {
623
+ content = 'Session host restored the last snapshot for this CLI, but the original runtime was not resumed automatically.';
624
+ } else {
625
+ content = `Session host restored this CLI after restart (${recoveryState}).`;
626
+ }
627
+
628
+ this.appendRuntimeSystemMessage(
629
+ content,
630
+ `runtime_recovery:${runtime.runtimeId}:${recoveryState}`,
631
+ );
632
+ }
633
+
606
634
  private appendRuntimeSystemMessage(content: string, dedupKey: string, receivedAt = Date.now()): void {
607
635
  const normalizedContent = String(content || '').trim();
608
636
  if (!normalizedContent) return;
@@ -25,6 +25,7 @@ export class ExtensionProviderInstance implements ProviderInstance {
25
25
  private currentStatus: string = 'idle';
26
26
  private agentStreams: any[] = [];
27
27
  private messages: any[] = [];
28
+ private prevMessageHashes = new Map<string, number>();
28
29
  private activeModal: any = null;
29
30
  private currentModel: string = '';
30
31
  private currentMode: string = '';
@@ -104,7 +105,7 @@ export class ExtensionProviderInstance implements ProviderInstance {
104
105
  if (event === 'stream_update') {
105
106
  // Reflect data collected from agent-stream-manager
106
107
  if (data?.streams) this.agentStreams = data.streams;
107
- if (data?.messages) this.messages = data.messages;
108
+ if (data?.messages) this.messages = this.assignReceivedAt(data.messages);
108
109
  if (data?.activeModal !== undefined) this.activeModal = data.activeModal;
109
110
  if (data?.model) this.currentModel = data.model;
110
111
  if (data?.mode) this.currentMode = data.mode;
@@ -132,6 +133,7 @@ export class ExtensionProviderInstance implements ProviderInstance {
132
133
  dispose(): void {
133
134
  this.agentStreams = [];
134
135
  this.messages = [];
136
+ this.prevMessageHashes.clear();
135
137
  this.monitor.reset();
136
138
  this.appliedEffectKeys.clear();
137
139
  this.runtimeMessages = [];
@@ -315,6 +317,26 @@ export class ExtensionProviderInstance implements ProviderInstance {
315
317
  );
316
318
  }
317
319
 
320
+ /**
321
+ * Assign stable receivedAt to extension messages.
322
+ * Same pattern as IdeProviderInstance.readChat() prevByHash —
323
+ * preserves first-seen timestamp across polling cycles.
324
+ */
325
+ private assignReceivedAt(messages: any[]): any[] {
326
+ const now = Date.now();
327
+ const nextHashes = new Map<string, number>();
328
+
329
+ for (const msg of messages) {
330
+ const hash = `${msg.role}:${(msg.content || '').slice(0, 100)}`;
331
+ const prevTime = this.prevMessageHashes.get(hash);
332
+ msg.receivedAt = prevTime || now;
333
+ nextHashes.set(hash, msg.receivedAt);
334
+ }
335
+
336
+ this.prevMessageHashes = nextHashes;
337
+ return messages;
338
+ }
339
+
318
340
  private mergeConversationMessages(messages: any[]): ChatMessage[] {
319
341
  if (this.runtimeMessages.length === 0) return messages;
320
342
  return [...messages, ...this.runtimeMessages.map((entry) => entry.message)]
@@ -382,6 +404,7 @@ export class ExtensionProviderInstance implements ProviderInstance {
382
404
  }
383
405
  this.agentStreams = [];
384
406
  this.messages = [];
407
+ this.prevMessageHashes.clear();
385
408
  this.activeModal = null;
386
409
  this.currentModel = '';
387
410
  this.currentMode = '';
@@ -0,0 +1,85 @@
1
+ import type { SessionRegistry, SessionRuntimeTarget } from './registry.js';
2
+
3
+ interface IdeLikeInstance {
4
+ category?: string;
5
+ type?: string;
6
+ getInstanceId?: () => string;
7
+ getExtensionInstances?: () => Array<{
8
+ type?: string;
9
+ getInstanceId?: () => string;
10
+ }>;
11
+ }
12
+
13
+ interface InstanceManagerLike {
14
+ listInstanceIds(): string[];
15
+ getInstance(id: string): IdeLikeInstance | undefined;
16
+ }
17
+
18
+ function upsertSessionTarget(sessionRegistry: SessionRegistry, target: SessionRuntimeTarget): void {
19
+ const existing = sessionRegistry.get(target.sessionId);
20
+ if (
21
+ existing
22
+ && existing.parentSessionId === target.parentSessionId
23
+ && existing.providerType === target.providerType
24
+ && existing.transport === target.transport
25
+ && existing.cdpManagerKey === target.cdpManagerKey
26
+ && existing.instanceKey === target.instanceKey
27
+ ) {
28
+ return;
29
+ }
30
+ sessionRegistry.register(target);
31
+ }
32
+
33
+ /**
34
+ * Rebuild missing IDE/runtime session registry entries from live ProviderInstance objects.
35
+ *
36
+ * This is a defensive repair path for cases where an IDE extension instance still exists
37
+ * in InstanceManager/status, but its runtime session entry has been dropped from SessionRegistry.
38
+ */
39
+ export function reconcileIdeRuntimeSessions(
40
+ instanceManager: InstanceManagerLike | undefined,
41
+ sessionRegistry: SessionRegistry | undefined,
42
+ ): void {
43
+ if (!instanceManager || !sessionRegistry) return;
44
+
45
+ for (const instanceKey of instanceManager.listInstanceIds()) {
46
+ if (!instanceKey.startsWith('ide:')) continue;
47
+
48
+ const ideInstance = instanceManager.getInstance(instanceKey);
49
+ if (!ideInstance || ideInstance.category !== 'ide' || typeof ideInstance.getInstanceId !== 'function') {
50
+ continue;
51
+ }
52
+
53
+ const managerKey = instanceKey.slice(4);
54
+ const ideType = typeof ideInstance.type === 'string' && ideInstance.type.trim()
55
+ ? ideInstance.type.trim()
56
+ : managerKey.split('_')[0];
57
+ const parentSessionId = ideInstance.getInstanceId();
58
+ if (!parentSessionId) continue;
59
+
60
+ upsertSessionTarget(sessionRegistry, {
61
+ sessionId: parentSessionId,
62
+ parentSessionId: null,
63
+ providerType: ideType,
64
+ transport: 'cdp-page',
65
+ cdpManagerKey: managerKey,
66
+ instanceKey,
67
+ });
68
+
69
+ const extensions = ideInstance.getExtensionInstances?.() || [];
70
+ for (const ext of extensions) {
71
+ const extType = typeof ext?.type === 'string' ? ext.type.trim() : '';
72
+ const extSessionId = ext?.getInstanceId?.();
73
+ if (!extType || !extSessionId) continue;
74
+
75
+ upsertSessionTarget(sessionRegistry, {
76
+ sessionId: extSessionId,
77
+ parentSessionId,
78
+ providerType: extType,
79
+ transport: 'cdp-webview',
80
+ cdpManagerKey: managerKey,
81
+ instanceKey,
82
+ });
83
+ }
84
+ }
85
+ }