@adhdev/daemon-core 0.8.83 → 0.8.85

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.
@@ -8,6 +8,14 @@ import { type ProviderModule } from './contracts.js';
8
8
  import type { ProviderInstance, ProviderState, InstanceContext } 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
+ type PersistableCliHistoryMessage = {
12
+ role: string;
13
+ content: string;
14
+ kind?: string;
15
+ senderName?: string;
16
+ receivedAt?: number;
17
+ };
18
+ export declare function buildIncrementalHistoryAppendMessages(previousMessages: PersistableCliHistoryMessage[], currentMessages: PersistableCliHistoryMessage[]): PersistableCliHistoryMessage[];
11
19
  export declare function getForcedNewSessionScriptName(provider: ProviderModule | undefined, launchMode: 'new' | 'resume' | 'manual'): string | null;
12
20
  export declare function waitForCliAdapterReady(adapter: {
13
21
  isReady?: () => boolean;
@@ -39,6 +47,7 @@ export declare class CliProviderInstance implements ProviderInstance {
39
47
  private appliedEffectKeys;
40
48
  private historyWriter;
41
49
  private runtimeMessages;
50
+ private lastPersistedHistoryMessages;
42
51
  readonly instanceId: string;
43
52
  private suppressIdleHistoryReplay;
44
53
  private errorMessage;
@@ -99,3 +108,4 @@ export declare class CliProviderInstance implements ProviderInstance {
99
108
  private buildSqlPlaceholderList;
100
109
  private querySqliteText;
101
110
  }
111
+ export {};
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.8.83",
3
+ "version": "0.8.85",
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.8.83",
3
+ "version": "0.8.85",
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",
@@ -853,6 +853,58 @@ export class ProviderCliAdapter implements CliAdapter {
853
853
  );
854
854
  }
855
855
 
856
+ private clearStaleIdleResponseGuard(reason: string): boolean {
857
+ const screenText = this.terminalScreen.getText() || '';
858
+ const visibleIdlePrompt = this.looksLikeVisibleIdlePrompt(screenText);
859
+ const blockingModal = this.activeModal || this.getStartupConfirmationModal(screenText);
860
+ if (!this.isWaitingForResponse || this.currentStatus !== 'idle' || !visibleIdlePrompt || !!blockingModal) {
861
+ return false;
862
+ }
863
+ if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
864
+ if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
865
+ if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
866
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
867
+ this.clearIdleFinishCandidate(reason);
868
+ this.responseBuffer = '';
869
+ this.isWaitingForResponse = false;
870
+ this.responseSettleIgnoreUntil = 0;
871
+ this.submitRetryUsed = false;
872
+ this.submitRetryPromptSnippet = '';
873
+ this.finishRetryCount = 0;
874
+ this.currentTurnScope = null;
875
+ this.activeModal = null;
876
+ this.recordTrace('stale_idle_response_cleared', {
877
+ reason,
878
+ screenText: summarizeCliTraceText(screenText, 240),
879
+ });
880
+ return true;
881
+ }
882
+
883
+ private hasMeaningfulResponseBuffer(promptSnippet: string): boolean {
884
+ const raw = String(this.responseBuffer || '').trim();
885
+ if (!raw) return false;
886
+ const normalizedPrompt = compactPromptText(promptSnippet);
887
+ if (!normalizedPrompt) return true;
888
+ const normalizedBuffer = compactPromptText(raw);
889
+ if (!normalizedBuffer) return false;
890
+ if (normalizedBuffer === normalizedPrompt) return false;
891
+ if (normalizedBuffer.startsWith(normalizedPrompt)) {
892
+ const remainder = normalizedBuffer
893
+ .slice(normalizedPrompt.length)
894
+ .replace(/[─═\-]+/g, '')
895
+ .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
896
+ .replace(/accepteditson\([^)]*\)/gi, '')
897
+ .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
898
+ .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
899
+ .replace(/esctointerrupt/gi, '')
900
+ .replace(/❯/g, '')
901
+ .replace(/^[\s\-–—:;,.!/?]+/, '')
902
+ .trim();
903
+ return remainder.length > 0;
904
+ }
905
+ return true;
906
+ }
907
+
856
908
  private evaluateSettled(): void {
857
909
  const now = Date.now();
858
910
  if (this.submitPendingUntil > now || this.responseSettleIgnoreUntil > now) {
@@ -888,7 +940,11 @@ export class ProviderCliAdapter implements CliAdapter {
888
940
  lastOutputAt: this.lastOutputAt,
889
941
  })
890
942
  : [];
943
+ if (this.maybeCommitVisibleIdleTranscript(parsedTranscript)) {
944
+ return;
945
+ }
891
946
  const lastParsedAssistant = [...parsedMessages].reverse().find((message) => message.role === 'assistant');
947
+ const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet || this.currentTurnScope?.prompt || '');
892
948
  this.recordTrace('settled', {
893
949
  tail: summarizeCliTraceText(tail, 500),
894
950
  screenText: summarizeCliTraceText(screenText, 1200),
@@ -906,6 +962,32 @@ export class ProviderCliAdapter implements CliAdapter {
906
962
  scope: this.currentTurnScope,
907
963
  }),
908
964
  });
965
+ if (
966
+ this.currentTurnScope
967
+ && !lastParsedAssistant
968
+ && !this.submitRetryUsed
969
+ && this.ptyProcess
970
+ && this.currentStatus !== 'waiting_approval'
971
+ && promptLikelyVisible(screenText, normalizedPromptSnippet)
972
+ && !this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)
973
+ ) {
974
+ this.submitRetryUsed = true;
975
+ this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
976
+ LOG.info('CLI', `[${this.cliType}] Retrying submit key from settled parser (no assistant yet)`);
977
+ this.recordTrace('submit_write', {
978
+ mode: 'settled_retry',
979
+ sendKey: this.sendKey,
980
+ screenText: summarizeCliTraceText(screenText, 500),
981
+ });
982
+ this.ptyProcess.write(this.sendKey);
983
+ if (this.settleTimer) clearTimeout(this.settleTimer);
984
+ this.settleTimer = setTimeout(() => {
985
+ this.settleTimer = null;
986
+ this.settledBuffer = this.recentOutputBuffer;
987
+ this.evaluateSettled();
988
+ }, this.timeouts.outputSettle + 150);
989
+ return;
990
+ }
909
991
  if (this.currentTurnScope && !lastParsedAssistant) {
910
992
  LOG.info(
911
993
  'CLI',
@@ -956,11 +1038,20 @@ export class ProviderCliAdapter implements CliAdapter {
956
1038
 
957
1039
  const recentInteractiveActivity = this.hasRecentInteractiveActivity(now);
958
1040
  const statusActivityHoldMs = this.getStatusActivityHoldMs();
1041
+ const visibleIdlePrompt = this.looksLikeVisibleIdlePrompt(screenText);
1042
+ const visibleAssistantCandidate = this.looksLikeVisibleAssistantCandidate(screenText);
1043
+ if (this.currentTurnScope && this.cliType === 'claude-cli') {
1044
+ LOG.info(
1045
+ 'CLI',
1046
+ `[${this.cliType}] settled diagnostics prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} scriptStatus=${String(scriptStatus || '')} parsedStatus=${String(parsedTranscript?.status || '')} parsedMsgCount=${parsedMessages.length} lastParsedAssistant=${JSON.stringify(summarizeCliTraceText(lastParsedAssistant?.content || '', 120)).slice(0, 160)} visibleIdlePrompt=${String(visibleIdlePrompt)} visibleAssistantCandidate=${String(visibleAssistantCandidate)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 160)).slice(0, 220)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 160)).slice(0, 220)}`
1047
+ );
1048
+ }
959
1049
  const shouldHoldGenerating =
960
1050
  scriptStatus === 'idle'
961
1051
  && this.isWaitingForResponse
962
1052
  && !modal
963
- && recentInteractiveActivity;
1053
+ && recentInteractiveActivity
1054
+ && !(visibleIdlePrompt && visibleAssistantCandidate);
964
1055
 
965
1056
  if (shouldHoldGenerating) {
966
1057
  this.clearIdleFinishCandidate('hold_generating_recent_activity');
@@ -1202,6 +1293,70 @@ export class ProviderCliAdapter implements CliAdapter {
1202
1293
  this.onStatusChange?.();
1203
1294
  }
1204
1295
 
1296
+ private maybeCommitVisibleIdleTranscript(
1297
+ parsed: any,
1298
+ options?: { requireVisibleAssistantCandidate?: boolean; screenText?: string },
1299
+ ): boolean {
1300
+ const allowImmediateScriptIdleCommit = this.provider.allowInputDuringGeneration === true;
1301
+ if (!allowImmediateScriptIdleCommit) return false;
1302
+ if (
1303
+ !parsed
1304
+ || !Array.isArray(parsed.messages)
1305
+ || parsed.status !== 'idle'
1306
+ || !this.isWaitingForResponse
1307
+ || !this.currentTurnScope
1308
+ || this.activeModal
1309
+ || parsed.activeModal
1310
+ ) {
1311
+ return false;
1312
+ }
1313
+
1314
+ if (options?.requireVisibleAssistantCandidate) {
1315
+ const candidateText = options.screenText || this.terminalScreen.getText() || '';
1316
+ if (!this.looksLikeVisibleAssistantCandidate(candidateText)) {
1317
+ return false;
1318
+ }
1319
+ }
1320
+
1321
+ const hydratedForIdleCommit = normalizeCliParsedMessages(parsed.messages, {
1322
+ committedMessages: this.committedMessages,
1323
+ scope: this.currentTurnScope,
1324
+ lastOutputAt: this.lastOutputAt,
1325
+ });
1326
+ const visibleAssistant = [...hydratedForIdleCommit].reverse().find((message) => message.role === 'assistant' && message.content.trim());
1327
+ if (!visibleAssistant) return false;
1328
+
1329
+ this.committedMessages = hydratedForIdleCommit;
1330
+ const promptForTrim = this.currentTurnScope?.prompt || getLastUserPromptText(this.committedMessages);
1331
+ if (promptForTrim) {
1332
+ const lastAssistantForTrim = [...this.committedMessages].reverse().find((message) => message.role === 'assistant');
1333
+ if (lastAssistantForTrim) {
1334
+ lastAssistantForTrim.content = trimPromptEchoPrefix(lastAssistantForTrim.content, promptForTrim);
1335
+ }
1336
+ }
1337
+ if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1338
+ if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
1339
+ if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1340
+ if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1341
+ if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1342
+ this.syncMessageViews();
1343
+ this.responseBuffer = '';
1344
+ this.isWaitingForResponse = false;
1345
+ this.responseSettleIgnoreUntil = 0;
1346
+ this.submitRetryUsed = false;
1347
+ this.submitRetryPromptSnippet = '';
1348
+ this.finishRetryCount = 0;
1349
+ this.currentTurnScope = null;
1350
+ this.activeModal = null;
1351
+ this.setStatus('idle', 'script_idle_commit');
1352
+ this.onStatusChange?.();
1353
+ this.recordTrace('script_idle_commit', {
1354
+ messageCount: this.committedMessages.length,
1355
+ lastAssistant: summarizeCliTraceText(visibleAssistant.content, 320),
1356
+ });
1357
+ return true;
1358
+ }
1359
+
1205
1360
  private commitCurrentTranscript(): { hasAssistant: boolean; assistantContent: string } {
1206
1361
  const parsed = this.parseCurrentTranscript(
1207
1362
  this.committedMessages,
@@ -1223,6 +1378,12 @@ export class ProviderCliAdapter implements CliAdapter {
1223
1378
  }
1224
1379
  this.syncMessageViews();
1225
1380
  const lastAssistant = [...this.committedMessages].reverse().find((message) => message.role === 'assistant');
1381
+ if (this.currentTurnScope) {
1382
+ LOG.info(
1383
+ 'CLI',
1384
+ `[${this.cliType}] commitCurrentTranscript committedMessages=${this.committedMessages.length} finalLastAssistant=${JSON.stringify(summarizeCliTraceText(lastAssistant?.content || '', 220)).slice(0, 260)}`
1385
+ );
1386
+ }
1226
1387
  this.recordTrace('commit_transcript', {
1227
1388
  parsedStatus: parsed.status || null,
1228
1389
  messageCount: this.committedMessages.length,
@@ -1242,17 +1403,25 @@ export class ProviderCliAdapter implements CliAdapter {
1242
1403
  `[${this.cliType}] Commit without assistant turn: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'} scriptsPath=${this.providerResolutionMeta.scriptsPath || '-'}`
1243
1404
  );
1244
1405
  }
1406
+ const hasAssistant = !!lastAssistant;
1245
1407
  return {
1246
- hasAssistant: !!lastAssistant,
1408
+ hasAssistant,
1247
1409
  assistantContent: lastAssistant?.content || '',
1248
1410
  };
1249
1411
  }
1412
+ if (this.currentTurnScope) {
1413
+ LOG.info(
1414
+ 'CLI',
1415
+ `[${this.cliType}] commitCurrentTranscript parsed.messages=none responseBufferLen=${this.responseBuffer.length} accumulatedBufferLen=${this.accumulatedBuffer.length} parsedStatus=${parsed?.status || '-'} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'}`
1416
+ );
1417
+ }
1250
1418
  return {
1251
1419
  hasAssistant: false,
1252
1420
  assistantContent: '',
1253
1421
  };
1254
1422
  }
1255
1423
 
1424
+
1256
1425
  // ─── Script Execution ──────────────────────────
1257
1426
 
1258
1427
  private runDetectStatus(text: string): string | null {
@@ -1359,26 +1528,35 @@ export class ProviderCliAdapter implements CliAdapter {
1359
1528
  this.currentTurnScope,
1360
1529
  screenText,
1361
1530
  );
1531
+ if (this.maybeCommitVisibleIdleTranscript(parsed)) {
1532
+ return this.getScriptParsedStatus();
1533
+ }
1362
1534
  const shouldPreferCommittedMessages =
1363
1535
  !this.currentTurnScope
1364
- && this.currentStatus === 'idle'
1365
- && !this.activeModal;
1536
+ && !this.activeModal
1537
+ && this.currentStatus === 'idle';
1366
1538
  let result: any;
1367
1539
  if (parsed && Array.isArray(parsed.messages)) {
1368
- const hydratedMessages = shouldPreferCommittedMessages
1369
- ? this.committedMessages.map((message, index) => buildChatMessage({
1370
- ...message,
1371
- id: message.id || `msg_${index}`,
1372
- index: typeof message.index === 'number' ? message.index : index,
1373
- receivedAt: typeof message.receivedAt === 'number'
1374
- ? message.receivedAt
1375
- : message.timestamp,
1376
- }))
1377
- : hydrateCliParsedMessages(parsed.messages, {
1378
- committedMessages: this.committedMessages,
1379
- scope: this.currentTurnScope,
1380
- lastOutputAt: this.lastOutputAt,
1381
- });
1540
+ const parsedHydratedMessages = hydrateCliParsedMessages(parsed.messages, {
1541
+ committedMessages: this.committedMessages,
1542
+ scope: this.currentTurnScope,
1543
+ lastOutputAt: this.lastOutputAt,
1544
+ });
1545
+ const committedHydratedMessages = this.committedMessages.map((message, index) => buildChatMessage({
1546
+ ...message,
1547
+ id: message.id || `msg_${index}`,
1548
+ index: typeof message.index === 'number' ? message.index : index,
1549
+ receivedAt: typeof message.receivedAt === 'number'
1550
+ ? message.receivedAt
1551
+ : message.timestamp,
1552
+ }));
1553
+ const shouldPreferCommittedHistoryReplay =
1554
+ !this.currentTurnScope
1555
+ && !this.activeModal
1556
+ && committedHydratedMessages.length > parsedHydratedMessages.length;
1557
+ const hydratedMessages = (shouldPreferCommittedMessages || shouldPreferCommittedHistoryReplay)
1558
+ ? committedHydratedMessages
1559
+ : parsedHydratedMessages;
1382
1560
  result = {
1383
1561
  id: parsed.id || 'cli_session',
1384
1562
  status: parsed.status || this.currentStatus,
@@ -1405,6 +1583,32 @@ export class ProviderCliAdapter implements CliAdapter {
1405
1583
  };
1406
1584
  }
1407
1585
 
1586
+ const hasVisibleAssistantMessage = Array.isArray(result?.messages)
1587
+ && result.messages.some((message: any) => message?.role === 'assistant' && typeof message?.content === 'string' && message.content.trim());
1588
+ const shouldClampStaleGeneratingToIdle =
1589
+ result?.status === 'generating'
1590
+ && this.currentStatus === 'idle'
1591
+ && !this.currentTurnScope
1592
+ && !result?.activeModal
1593
+ && hasVisibleAssistantMessage;
1594
+ if (shouldClampStaleGeneratingToIdle) {
1595
+ result = {
1596
+ ...result,
1597
+ status: 'idle',
1598
+ messages: Array.isArray(result.messages)
1599
+ ? result.messages.map((message: any) => {
1600
+ if (message?.role !== 'assistant' || !message?.meta?.streaming) return message;
1601
+ const nextMeta = { ...(message.meta || {}) };
1602
+ delete nextMeta.streaming;
1603
+ return {
1604
+ ...message,
1605
+ ...(Object.keys(nextMeta).length > 0 ? { meta: nextMeta } : { meta: undefined }),
1606
+ };
1607
+ })
1608
+ : result.messages,
1609
+ };
1610
+ }
1611
+
1408
1612
  this.parsedStatusCache = {
1409
1613
  committedMessagesRef: this.committedMessages,
1410
1614
  responseBuffer: this.responseBuffer,
@@ -1541,9 +1745,36 @@ export class ProviderCliAdapter implements CliAdapter {
1541
1745
  }
1542
1746
  }
1543
1747
  if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.currentStatus})`);
1544
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1748
+ const parsedStatusBeforeSend = !allowInputDuringGeneration
1749
+ ? (() => {
1750
+ try {
1751
+ return this.getScriptParsedStatus?.() || null;
1752
+ } catch {
1753
+ return null;
1754
+ }
1755
+ })()
1756
+ : null;
1757
+ const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1758
+ ? String(parsedStatusBeforeSend.status)
1759
+ : '';
1760
+ const parsedMessagesBeforeSend = Array.isArray(parsedStatusBeforeSend?.messages)
1761
+ ? parsedStatusBeforeSend.messages.filter((message: any) => message && (message.role === 'user' || message.role === 'assistant'))
1762
+ : [];
1763
+ const shouldCommitParsedIdleBeforeSend = !allowInputDuringGeneration
1764
+ && parsedSessionStatus === 'idle'
1765
+ && parsedMessagesBeforeSend.length > this.committedMessages.length
1766
+ && parsedMessagesBeforeSend.some((message: any) => message?.role === 'assistant' && typeof message?.content === 'string' && message.content.trim());
1767
+ if (shouldCommitParsedIdleBeforeSend) {
1768
+ this.commitCurrentTranscript();
1769
+ }
1770
+ if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating')) {
1545
1771
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1546
1772
  }
1773
+ if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1774
+ if (!this.clearStaleIdleResponseGuard('send_message_guard')) {
1775
+ throw new Error(`${this.cliName} is still processing the previous prompt`);
1776
+ }
1777
+ }
1547
1778
  const blockingModal = this.activeModal || this.getStartupConfirmationModal(this.terminalScreen.getText() || '');
1548
1779
  if (blockingModal || this.currentStatus === 'waiting_approval') {
1549
1780
  throw new Error(`${this.cliName} is awaiting confirmation before it can accept a prompt`);
@@ -1622,7 +1853,7 @@ export class ProviderCliAdapter implements CliAdapter {
1622
1853
  this.submitRetryTimer = null;
1623
1854
  if (!this.ptyProcess || !this.isWaitingForResponse || this.submitRetryUsed) return;
1624
1855
  if (this.currentStatus === 'waiting_approval') return;
1625
- if ((this.responseBuffer || '').trim()) return;
1856
+ if (this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)) return;
1626
1857
  const screenText = this.terminalScreen.getText();
1627
1858
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return;
1628
1859
  if (/Esc to interrupt|Do you want to proceed|This command requires approval|Allow Codex to|Approve and run now|Always approve this session|Running…|Running\.\.\./i.test(screenText)) return;
@@ -1660,7 +1891,7 @@ export class ProviderCliAdapter implements CliAdapter {
1660
1891
  this.submitRetryTimer = null;
1661
1892
  if (!this.ptyProcess || !this.isWaitingForResponse || this.submitRetryUsed) return;
1662
1893
  if (this.currentStatus === 'waiting_approval') return;
1663
- if ((this.responseBuffer || '').trim()) return;
1894
+ if (this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)) return;
1664
1895
  const screenText = this.terminalScreen.getText();
1665
1896
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return;
1666
1897
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
@@ -27,6 +27,14 @@ interface SessionHostRuntimeOptions extends SessionHostPtyTransportFactoryOption
27
27
  spawnOptions: PtySpawnOptions;
28
28
  }
29
29
 
30
+ export function shouldResumeAttachedSession(record: SessionHostRecord | null | undefined): boolean {
31
+ if (!record) return false;
32
+ if (record.lifecycle === 'interrupted') return true;
33
+ if (record.lifecycle !== 'stopped') return false;
34
+ if (record.meta?.restoredFromStorage === true) return true;
35
+ return typeof record.meta?.runtimeRecoveryState === 'string' && String(record.meta.runtimeRecoveryState).trim().length > 0;
36
+ }
37
+
30
38
  class SessionHostRuntimeTransport implements PtyRuntimeTransport {
31
39
  readonly ready: Promise<void>;
32
40
  readonly terminalQueriesHandled = true;
@@ -215,7 +223,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
215
223
  const existingRecord = existingRecords.success && existingRecords.result
216
224
  ? existingRecords.result.find((item) => item.sessionId === this.options.runtimeId) || null
217
225
  : null;
218
- if (existingRecord?.lifecycle === 'interrupted') {
226
+ if (shouldResumeAttachedSession(existingRecord)) {
219
227
  const resumeResponse = await this.client.request<SessionHostRecord>({
220
228
  type: 'resume_session',
221
229
  payload: {
@@ -340,6 +340,9 @@ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown):
340
340
 
341
341
  function buildReadChatCommandResult(payload: Record<string, any>, args: any): CommandResult {
342
342
  let validatedPayload: Record<string, any>;
343
+ const debugReadChat = payload?.debugReadChat && typeof payload.debugReadChat === 'object'
344
+ ? payload.debugReadChat
345
+ : undefined;
343
346
  try {
344
347
  validatedPayload = validateReadChatResultPayload({
345
348
  ...payload,
@@ -361,6 +364,7 @@ function buildReadChatCommandResult(payload: Record<string, any>, args: any): Co
361
364
  replaceFrom: 0,
362
365
  totalMessages: messages.length,
363
366
  lastMessageSignature,
367
+ ...(debugReadChat ? { debugReadChat } : {}),
364
368
  };
365
369
  }
366
370
  const sync = computeReadChatSync(messages, cursor);
@@ -372,6 +376,7 @@ function buildReadChatCommandResult(payload: Record<string, any>, args: any): Co
372
376
  replaceFrom: sync.replaceFrom,
373
377
  totalMessages: sync.totalMessages,
374
378
  lastMessageSignature: sync.lastMessageSignature,
379
+ ...(debugReadChat ? { debugReadChat } : {}),
375
380
  };
376
381
  }
377
382
 
@@ -480,16 +485,44 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
480
485
  const parsedRecord = parsedStatus && typeof parsedStatus === 'object'
481
486
  ? parsedStatus as Record<string, any>
482
487
  : null;
483
- const status = parsedRecord || adapter.getStatus();
488
+ const adapterStatus = adapter.getStatus();
489
+ const shouldPreferAdapterMessages =
490
+ Array.isArray(adapterStatus.messages)
491
+ && adapterStatus.messages.length > 0
492
+ && Array.isArray(parsedRecord?.messages)
493
+ && adapterStatus.messages.length > parsedRecord.messages.length;
494
+ const status = parsedRecord
495
+ ? {
496
+ ...parsedRecord,
497
+ messages: shouldPreferAdapterMessages ? adapterStatus.messages : parsedRecord.messages,
498
+ status: adapterStatus.status !== 'idle'
499
+ ? adapterStatus.status
500
+ : (parsedRecord.status || adapterStatus.status),
501
+ activeModal: parsedRecord.activeModal || adapterStatus.activeModal,
502
+ }
503
+ : adapterStatus;
504
+
484
505
  const title = typeof parsedRecord?.title === 'string' ? parsedRecord.title : undefined;
485
506
  const providerSessionId = typeof parsedRecord?.providerSessionId === 'string'
486
507
  ? parsedRecord.providerSessionId
487
508
  : undefined;
488
509
  if (status) {
510
+ LOG.info('Command', `[read_chat] cli-like resolved provider=${adapter.cliType} target=${String(args?.targetSessionId || '')} adapterStatus=${String(adapterStatus.status || '')} parsedStatus=${String(parsedRecord?.status || '')} shouldPreferAdapterMessages=${String(shouldPreferAdapterMessages)} adapterMsgCount=${Array.isArray(adapterStatus.messages) ? adapterStatus.messages.length : 0} parsedMsgCount=${Array.isArray(parsedRecord?.messages) ? parsedRecord.messages.length : 0} returnedMsgCount=${Array.isArray((status as any).messages) ? (status as any).messages.length : 0}`);
489
511
  return buildReadChatCommandResult({
490
- messages: status.messages || [],
512
+ messages: (status as any).messages || [],
491
513
  status: status.status,
492
514
  activeModal: status.activeModal,
515
+ debugReadChat: {
516
+ provider: adapter.cliType,
517
+ targetSessionId: String(args?.targetSessionId || ''),
518
+ adapterStatus: String(adapterStatus.status || ''),
519
+ parsedStatus: String(parsedRecord?.status || ''),
520
+ returnedStatus: String(status.status || ''),
521
+ shouldPreferAdapterMessages,
522
+ adapterMsgCount: Array.isArray(adapterStatus.messages) ? adapterStatus.messages.length : 0,
523
+ parsedMsgCount: Array.isArray(parsedRecord?.messages) ? parsedRecord.messages.length : 0,
524
+ returnedMsgCount: Array.isArray((status as any).messages) ? (status as any).messages.length : 0,
525
+ },
493
526
  ...(title ? { title } : {}),
494
527
  ...(providerSessionId ? { providerSessionId } : {}),
495
528
  }, args);
@@ -1274,3 +1274,140 @@ export function listSavedHistorySessions(
1274
1274
  return { sessions: [], hasMore: false };
1275
1275
  }
1276
1276
  }
1277
+
1278
+ function normalizeCanonicalHermesMessageContent(content: unknown): string {
1279
+ if (typeof content === 'string') return content.trim();
1280
+ if (content == null) return '';
1281
+ try {
1282
+ return JSON.stringify(content).trim();
1283
+ } catch {
1284
+ return String(content).trim();
1285
+ }
1286
+ }
1287
+
1288
+ function extractCanonicalHermesMessageTimestamp(message: Record<string, unknown>, fallbackTs: number): number {
1289
+ const numericTimestamp = Number(message.receivedAt || message.timestamp || message.ts || 0);
1290
+ if (Number.isFinite(numericTimestamp) && numericTimestamp > 0) return numericTimestamp;
1291
+ const stringTimestamp = typeof message.ts === 'string'
1292
+ ? Date.parse(message.ts)
1293
+ : (typeof message.timestamp === 'string' ? Date.parse(message.timestamp) : NaN);
1294
+ if (Number.isFinite(stringTimestamp) && stringTimestamp > 0) return stringTimestamp;
1295
+ return fallbackTs;
1296
+ }
1297
+
1298
+ function readExistingHermesSessionStartRecord(historySessionId: string): HistoryMessage | null {
1299
+ try {
1300
+ const dir = path.join(HISTORY_DIR, 'hermes-cli');
1301
+ if (!fs.existsSync(dir)) return null;
1302
+ const files = listHistoryFiles(dir, historySessionId).sort();
1303
+ for (const file of files) {
1304
+ const lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean);
1305
+ for (const line of lines) {
1306
+ try {
1307
+ const parsed = JSON.parse(line) as HistoryMessage;
1308
+ if (parsed.historySessionId !== historySessionId) continue;
1309
+ if (parsed.kind === 'session_start' && parsed.role === 'system') {
1310
+ return parsed;
1311
+ }
1312
+ } catch {
1313
+ // Ignore malformed lines while probing for the original session_start marker.
1314
+ }
1315
+ }
1316
+ }
1317
+ return null;
1318
+ } catch {
1319
+ return null;
1320
+ }
1321
+ }
1322
+
1323
+ export function rebuildHermesSavedHistoryFromCanonicalSession(historySessionId: string): boolean {
1324
+ const normalizedSessionId = normalizeSavedHistorySessionId('hermes-cli', historySessionId);
1325
+ if (!normalizedSessionId) return false;
1326
+
1327
+ try {
1328
+ const sessionFilePath = path.join(os.homedir(), '.hermes', 'sessions', `session_${normalizedSessionId}.json`);
1329
+ if (!fs.existsSync(sessionFilePath)) return false;
1330
+ const raw = JSON.parse(fs.readFileSync(sessionFilePath, 'utf-8')) as {
1331
+ session_start?: string;
1332
+ last_updated?: string;
1333
+ messages?: Array<Record<string, unknown>>;
1334
+ };
1335
+ const canonicalMessages = Array.isArray(raw.messages) ? raw.messages : [];
1336
+ const dir = path.join(HISTORY_DIR, 'hermes-cli');
1337
+ fs.mkdirSync(dir, { recursive: true });
1338
+ const existingSessionStart = readExistingHermesSessionStartRecord(normalizedSessionId);
1339
+ const records: HistoryMessage[] = [];
1340
+ if (existingSessionStart) {
1341
+ records.push({
1342
+ ...existingSessionStart,
1343
+ historySessionId: normalizedSessionId,
1344
+ });
1345
+ }
1346
+
1347
+ let fallbackTs = Date.parse(raw.session_start || raw.last_updated || '') || Date.now();
1348
+ for (const message of canonicalMessages) {
1349
+ const role = String(message.role || '').trim();
1350
+ const content = normalizeCanonicalHermesMessageContent(message.content);
1351
+ if (!content) continue;
1352
+ const receivedAt = extractCanonicalHermesMessageTimestamp(message, fallbackTs);
1353
+ fallbackTs = receivedAt + 1;
1354
+
1355
+ if (role === 'user') {
1356
+ records.push({
1357
+ ts: new Date(receivedAt).toISOString(),
1358
+ receivedAt,
1359
+ role: 'user',
1360
+ content,
1361
+ kind: 'standard',
1362
+ agent: 'hermes-cli',
1363
+ historySessionId: normalizedSessionId,
1364
+ });
1365
+ continue;
1366
+ }
1367
+
1368
+ if (role === 'assistant') {
1369
+ records.push({
1370
+ ts: new Date(receivedAt).toISOString(),
1371
+ receivedAt,
1372
+ role: 'assistant',
1373
+ content,
1374
+ kind: 'standard',
1375
+ agent: 'hermes-cli',
1376
+ historySessionId: normalizedSessionId,
1377
+ });
1378
+ continue;
1379
+ }
1380
+
1381
+ if (role === 'tool') {
1382
+ records.push({
1383
+ ts: new Date(receivedAt).toISOString(),
1384
+ receivedAt,
1385
+ role: 'assistant',
1386
+ content,
1387
+ kind: 'tool',
1388
+ senderName: 'Tool',
1389
+ agent: 'hermes-cli',
1390
+ historySessionId: normalizedSessionId,
1391
+ });
1392
+ }
1393
+ }
1394
+
1395
+ if (records.length === 0) return false;
1396
+
1397
+ const prefix = `${normalizedSessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
1398
+ for (const file of fs.readdirSync(dir)) {
1399
+ if (file.startsWith(prefix) && file.endsWith('.jsonl')) {
1400
+ fs.unlinkSync(path.join(dir, file));
1401
+ }
1402
+ }
1403
+
1404
+ const targetDate = new Date(records[records.length - 1].receivedAt || Date.now()).toISOString().slice(0, 10);
1405
+ const filePath = path.join(dir, `${prefix}${targetDate}.jsonl`);
1406
+ fs.writeFileSync(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, 'utf-8');
1407
+ invalidatePersistedSavedHistoryIndex('hermes-cli', dir);
1408
+ savedHistorySessionCache.delete('hermes-cli');
1409
+ return true;
1410
+ } catch {
1411
+ return false;
1412
+ }
1413
+ }