@adhdev/daemon-core 0.9.82-rc.376 → 0.9.82-rc.378

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 (48) hide show
  1. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  2. package/dist/commands/chat-commands-read.d.ts +7 -0
  3. package/dist/commands/chat-commands-scope.d.ts +39 -0
  4. package/dist/commands/chat-commands-shared.d.ts +33 -0
  5. package/dist/commands/chat-commands-write.d.ts +14 -0
  6. package/dist/commands/chat-commands.d.ts +9 -49
  7. package/dist/commands/router.d.ts +3 -470
  8. package/dist/index.js +3166 -3115
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +3561 -3510
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  13. package/dist/mesh/mesh-event-classify.d.ts +5 -0
  14. package/dist/mesh/mesh-event-forwarding.d.ts +18 -0
  15. package/dist/mesh/mesh-events-coordinator.d.ts +4 -92
  16. package/dist/mesh/mesh-events-utils.d.ts +3 -0
  17. package/dist/mesh/mesh-ledger-reconciliation.d.ts +0 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +289 -0
  19. package/dist/mesh/mesh-queue-assignment.d.ts +86 -0
  20. package/dist/mesh/mesh-refine-gates.d.ts +428 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +0 -3
  22. package/dist/providers/native-history/constants.d.ts +12 -0
  23. package/dist/runtime-defaults.d.ts +2 -0
  24. package/package.json +2 -2
  25. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  26. package/src/commands/chat-commands-read.ts +2327 -0
  27. package/src/commands/chat-commands-scope.ts +54 -0
  28. package/src/commands/chat-commands-shared.ts +114 -0
  29. package/src/commands/chat-commands-write.ts +880 -0
  30. package/src/commands/chat-commands.ts +20 -3697
  31. package/src/commands/router.ts +59 -3631
  32. package/src/mesh/mesh-coordinator-config.ts +97 -0
  33. package/src/mesh/mesh-event-classify.ts +51 -0
  34. package/src/mesh/mesh-event-forwarding.ts +1502 -0
  35. package/src/mesh/mesh-events-coordinator.ts +30 -2993
  36. package/src/mesh/mesh-events-pending.ts +1 -10
  37. package/src/mesh/mesh-events-stale.ts +3 -14
  38. package/src/mesh/mesh-events-utils.ts +52 -14
  39. package/src/mesh/mesh-ledger-reconciliation.ts +0 -2
  40. package/src/mesh/mesh-node-identity.ts +1887 -0
  41. package/src/mesh/mesh-queue-assignment.ts +1457 -0
  42. package/src/mesh/mesh-refine-gates.ts +1652 -0
  43. package/src/mesh/mesh-runtime-store.ts +0 -37
  44. package/src/providers/cli-provider-instance.ts +40 -1
  45. package/src/providers/native-history/constants.ts +19 -0
  46. package/src/providers/native-history/dispatcher.ts +2 -3
  47. package/src/providers/spec/native-history-executor.ts +1 -9
  48. package/src/runtime-defaults.ts +39 -0
@@ -1,3701 +1,24 @@
1
1
  /**
2
2
  * Chat Commands — readChat, sendChat, listChats, newChat, switchChat,
3
3
  * setMode, changeModel, setThoughtLevel, resolveAction, chatHistory
4
- */
5
-
6
- import * as fs from 'node:fs';
7
- import * as os from 'node:os';
8
- import * as path from 'node:path';
9
- import { randomUUID } from 'node:crypto';
10
- import type { CommandResult, CommandHelpers } from './handler.js';
11
- import type { CliAdapter } from '../cli-adapter-types.js';
12
- import { flattenContent, normalizeInputEnvelope, type InputEnvelope, type ProviderModule, type ProviderScripts } from '../providers/contracts.js';
13
- import { assertProviderSupportsDeclaredInput, assertTextOnlyInput } from '../providers/provider-input-support.js';
14
- import { validateReadChatResultPayload } from '../providers/read-chat-contract.js';
15
- import { pickApprovalButton } from '../providers/approval-utils.js';
16
- import type { ProviderInstance } from '../providers/provider-instance.js';
17
- import { isNativeSourceCanonicalHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
18
- import { getCoordinatorForSession } from '../mesh/coordinator-registry.js';
19
- import { LOG, getRecentLogs } from '../logging/logger.js';
20
- import { getRecentDebugTrace, recordDebugTrace } from '../logging/debug-trace.js';
21
- import { buildChatMessageSignature, hashSignatureParts } from '../chat/chat-signatures.js';
22
- import {
23
- CHAT_SOURCE_REGISTRY,
24
- buildV1NativePresentObservation,
25
- chatSourceSessionKey,
26
- type ChatSourceDecision,
27
- type ChatSourceObservation,
28
- type ChatSourceTransitionCause,
29
- } from '../chat/source-resolver.js';
30
- import type { ChatMessage } from '../types.js';
31
- import type { SessionTransport } from '../shared-types.js';
32
- import { filterUserFacingChatMessages, isActivityChatMessage, isUserFacingChatMessage, normalizeChatMessages } from '../providers/chat-message-normalization.js';
33
- import { normalizeMeshWorkspaceForCompare } from '@adhdev/mesh-shared';
34
-
35
- const RECENT_SEND_WINDOW_MS = 1200;
36
- export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
37
- // Minimum tail floor for hot-path history/mirror reads. The dashboard requests a
38
- // bounded tail (~60); we keep a small floor so a tiny requested tailLimit still
39
- // has enough surrounding context for seed/mirror dedup correctness, but it must
40
- // NOT dominate the hot subscribe/poll path the way the previous 200 floor did.
41
- // readChatHistory now serves this as an O(tail) bounded read, so the cost scales
42
- // with this floor, not with total accumulated history.
43
- const HOT_TAIL_MIN_LIMIT = 60;
44
- const HERMES_CLI_STARTING_SEND_SETTLE_MS = 2_000;
45
- // (A2.2) CLI_NATIVE_HISTORY_FRESH_MS removed with isNativeHistoryFreshEnough.
46
- // Hardcoded native-transcript provider allow-list. Deprecated. Kept only as a
47
- // last-resort fallback when ProviderModule is not yet loaded; on every hit we
48
- // warn so the dependency on this set is visible. A2 deletes the set entirely
49
- // and routes solely through canonicalHistory.contractVersion +
50
- // isNativeSourceCanonicalHistory().
51
- const CLI_NATIVE_TRANSCRIPT_PROVIDERS = new Set(['codex-cli', 'claude-cli', 'hermes-cli', 'antigravity-cli']);
52
- const warnedLegacyNativeAllowlistHits = new Set<string>();
53
- function warnLegacyNativeAllowlistHit(providerType: string): void {
54
- if (warnedLegacyNativeAllowlistHits.has(providerType)) return;
55
- warnedLegacyNativeAllowlistHits.add(providerType);
56
- // eslint-disable-next-line no-console
57
- console.warn(
58
- `[chat-commands] supportsCliNativeTranscript fell back to the hardcoded `
59
- + `CLI_NATIVE_TRANSCRIPT_PROVIDERS set for "${providerType}". `
60
- + `The provider module was unavailable or did not declare canonicalHistory. `
61
- + `Set canonicalHistory.contractVersion in the provider.json to remove this dependency.`,
62
- );
63
- }
64
- const recentSendByTarget = new Map<string, number>();
65
-
66
- interface ApprovalSelectableInstance extends ProviderInstance {
67
- recordApprovalSelection?(buttonText: string): void;
68
- }
69
-
70
- interface RuntimeChatMessageMerger extends ProviderInstance {
71
- mergeRuntimeChatMessages?(messages: ChatMessage[]): ChatMessage[];
72
- recordAcknowledgedUserInput?(input: InputEnvelope | string): void;
73
- }
74
-
75
- type LegacyStringScript = (params?: Record<string, unknown> | string) => string;
76
-
77
- function getCurrentProviderType(h: CommandHelpers, fallback = ''): string {
78
- return h.currentSession?.providerType || h.currentProviderType || fallback;
79
- }
80
-
81
- function getCurrentManagerKey(h: CommandHelpers): string {
82
- return h.currentSession?.cdpManagerKey || h.currentManagerKey || '';
83
- }
84
-
85
- function getTargetedCliAdapter(h: CommandHelpers, args: any, providerType?: string): CliAdapter | null {
86
- return h.getCliAdapter(args?.targetSessionId || providerType || h.currentSession?.providerType || h.currentManagerKey);
87
- }
88
-
89
- function getExplicitHistorySessionId(args: any): string | undefined {
90
- const explicit = typeof args?.historySessionId === 'string' ? args.historySessionId.trim() : '';
91
- if (explicit) return explicit;
92
-
93
- const explicitProviderSessionId = typeof args?.providerSessionId === 'string' ? args.providerSessionId.trim() : '';
94
- if (explicitProviderSessionId) return explicitProviderSessionId;
95
-
96
- return undefined;
97
- }
98
-
99
- function getTargetInstance(h: CommandHelpers, args: any): ApprovalSelectableInstance | null {
100
- const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
101
- const sessionId = targetSessionId || h.currentSession?.sessionId || '';
102
- if (!sessionId) return null;
103
- const session = h.ctx.sessionRegistry?.get(sessionId);
104
- const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
105
- return (h.ctx.instanceManager?.getInstance(instanceKey) as ApprovalSelectableInstance | undefined) || null;
106
- }
107
-
108
- function getTargetTransport(h: CommandHelpers, provider?: ProviderModule): SessionTransport | null {
109
- if (h.currentSession?.transport) return h.currentSession.transport;
110
- switch (provider?.category) {
111
- case 'cli':
112
- return 'pty';
113
- case 'acp':
114
- return 'acp';
115
- case 'extension':
116
- return 'cdp-webview';
117
- case 'ide':
118
- return 'cdp-page';
119
- default:
120
- return null;
121
- }
122
- }
123
-
124
- function isCliLikeTransport(transport: SessionTransport | null): boolean {
125
- return transport === 'pty' || transport === 'acp';
126
- }
127
-
128
- function isExtensionTransport(transport: SessionTransport | null): boolean {
129
- return transport === 'cdp-webview';
130
- }
131
-
132
- function buildRecentSendKey(h: CommandHelpers, args: any, provider: ProviderModule | undefined, signature: string): string {
133
- const transport = getTargetTransport(h, provider) || 'unknown';
134
- const target =
135
- args?.targetSessionId
136
- || args?.agentType
137
- || h.currentSession?.providerType
138
- || h.currentProviderType
139
- || h.currentManagerKey
140
- || 'unknown';
141
- return `${transport}:${target}:${signature.trim()}`;
142
- }
143
-
144
- function summarizeSendInputPart(part: any): string {
145
- if (!part || typeof part !== 'object') return String(part ?? '');
146
- if (part.type === 'text') return `text:${String(part.text || '').trim()}`;
147
- const fields = [
148
- `type=${String(part.type || '')}`,
149
- `mime=${String(part.mimeType || '')}`,
150
- `uri=${String(part.uri || '')}`,
151
- `name=${String(part.name || '')}`,
152
- ];
153
- const data = typeof part.data === 'string'
154
- ? part.data
155
- : typeof part.resource?.blob === 'string'
156
- ? part.resource.blob
157
- : '';
158
- if (data) fields.push(`dataLen=${data.length}`, `dataHash=${hashSignatureParts([data]).slice(0, 12)}`);
159
- const textish = [part.alt, part.transcript, part.description, part.title, part.resource?.uri]
160
- .filter((value) => typeof value === 'string' && value.trim())
161
- .join('\u001f');
162
- if (textish) fields.push(`meta=${hashSignatureParts([textish]).slice(0, 12)}`);
163
- return fields.join(';');
164
- }
165
-
166
- export function buildSendInputSignature(input: InputEnvelope): string {
167
- const text = typeof input.textFallback === 'string' ? input.textFallback.trim() : '';
168
- const partSummaries = (input.parts || []).map(summarizeSendInputPart);
169
- return hashSignatureParts([text, ...partSummaries]);
170
- }
171
-
172
- function getSendChatInputEnvelope(args: any): InputEnvelope {
173
- return normalizeInputEnvelope(args?.input ? { input: args.input } : args);
174
- }
175
-
176
- function sleep(ms: number): Promise<void> {
177
- return new Promise((resolve) => setTimeout(resolve, ms));
178
- }
179
-
180
- async function waitOnceForFreshHermesCliStart(adapter: CliAdapter, log: (msg: string) => void): Promise<void> {
181
- if (adapter.cliType !== 'hermes-cli') return;
182
- const status = typeof adapter.getStatus === 'function' ? adapter.getStatus()?.status : undefined;
183
- if (status !== 'starting') return;
184
-
185
- log(`Hermes CLI is still starting; waiting ${HERMES_CLI_STARTING_SEND_SETTLE_MS}ms before first send`);
186
- await sleep(HERMES_CLI_STARTING_SEND_SETTLE_MS);
187
- }
188
-
189
- function getHistorySessionId(h: CommandHelpers, args: any): string | undefined {
190
- const explicit = getExplicitHistorySessionId(args);
191
- if (explicit) return explicit;
192
-
193
- const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
194
- if (!targetSessionId) return undefined;
195
-
196
- const session = h.ctx.sessionRegistry?.get(targetSessionId) as any;
197
- const registeredProviderSessionId = typeof session?.providerSessionId === 'string' ? session.providerSessionId.trim() : '';
198
- if (registeredProviderSessionId) return registeredProviderSessionId;
199
-
200
- const instance = getTargetInstance(h, args);
201
- const state = instance?.getState?.();
202
- const providerSessionId = typeof state?.providerSessionId === 'string' ? state.providerSessionId.trim() : '';
203
- if (providerSessionId) return providerSessionId;
204
-
205
- const currentSession = h.currentSession as any;
206
- if (currentSession?.sessionId === targetSessionId) {
207
- const currentProviderSessionId = typeof currentSession.providerSessionId === 'string'
208
- ? currentSession.providerSessionId.trim()
209
- : '';
210
- if (currentProviderSessionId) return currentProviderSessionId;
211
- }
212
-
213
- return targetSessionId;
214
- }
215
-
216
- function resolveCliNativeHistorySessionId(args: any, currentHistorySessionId: string | undefined, parsedProviderSessionId: string | undefined): string | undefined {
217
- const explicit = getExplicitHistorySessionId(args);
218
- if (explicit) return explicit;
219
-
220
- const parsed = typeof parsedProviderSessionId === 'string' ? parsedProviderSessionId.trim() : '';
221
- const current = typeof currentHistorySessionId === 'string' ? currentHistorySessionId.trim() : '';
222
- const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
223
-
224
- // getHistorySessionId falls back to the runtime session id when no native
225
- // handle has been registered yet. For live CLI adapters the parser may
226
- // already know the provider-native handle; prefer it over the runtime id so
227
- // exact native reads do not miss the worker transcript and fall back to PTY
228
- // or same-workspace history.
229
- if (parsed && (!current || current === targetSessionId)) return parsed;
230
- return current || parsed || undefined;
231
- }
232
-
233
- function shouldSkipLiveCliNativeHistoryWithoutProviderSession(args: {
234
- adapter?: CliAdapter | null;
235
- providerType?: string;
236
- readChatArgs: any;
237
- nativeHistorySessionId?: string;
238
- parsedProviderSessionId?: string;
239
- }): boolean {
240
- const explicit = getExplicitHistorySessionId(args.readChatArgs);
241
- if (explicit) return false;
242
-
243
- const targetSessionId = typeof args.readChatArgs?.targetSessionId === 'string'
244
- ? args.readChatArgs.targetSessionId.trim()
245
- : '';
246
- if (!targetSessionId) return false;
247
-
248
- const resolved = typeof args.nativeHistorySessionId === 'string'
249
- ? args.nativeHistorySessionId.trim()
250
- : '';
251
- if (!resolved || resolved !== targetSessionId) return false;
252
-
253
- const parsed = typeof args.parsedProviderSessionId === 'string'
254
- ? args.parsedProviderSessionId.trim()
255
- : '';
256
- if (parsed) return false;
257
-
258
- const cliType = args.adapter?.cliType || args.providerType || '';
259
- if (cliType !== 'codex-cli') return false;
260
-
261
- // A live Codex session starts with only the daemon runtime UUID. That UUID
262
- // is not the provider-native rollout id, so using it for native history
263
- // lets the file picker fall back to the newest same-workspace transcript
264
- // and makes concurrent fresh sessions all show the same old conversation.
265
- return !!args.adapter;
266
- }
267
-
268
- function getInteractionId(args: any): string | undefined {
269
- return typeof args?._interactionId === 'string' && args._interactionId.trim()
270
- ? args._interactionId.trim()
271
- : undefined;
272
- }
273
-
274
- function traceProviderEvent(
275
- args: any,
276
- category: 'provider' | 'parser',
277
- stage: string,
278
- options: {
279
- h: CommandHelpers;
280
- provider?: ProviderModule;
281
- payload?: Record<string, unknown>;
282
- level?: 'debug' | 'info' | 'warn' | 'error';
283
- },
284
- ): void {
285
- recordDebugTrace({
286
- interactionId: getInteractionId(args),
287
- category,
288
- stage,
289
- level: options.level || 'info',
290
- sessionId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : options.h.currentSession?.sessionId,
291
- providerType: options.provider?.type || options.h.currentProviderType || options.h.currentSession?.providerType,
292
- payload: options.payload,
293
- });
294
- }
295
-
296
- function callLegacyTextScript(script: ProviderScripts[keyof ProviderScripts] | undefined, text: string): string | null {
297
- if (typeof script !== 'function') return null;
298
- return (script as LegacyStringScript)(text);
299
- }
300
-
301
- function isRecentDuplicateSend(key: string): boolean {
302
- const now = Date.now();
303
- for (const [candidate, ts] of recentSendByTarget.entries()) {
304
- if (now - ts > RECENT_SEND_WINDOW_MS) recentSendByTarget.delete(candidate);
305
- }
306
- const previous = recentSendByTarget.get(key);
307
- if (previous && (now - previous) <= RECENT_SEND_WINDOW_MS) return true;
308
- recentSendByTarget.set(key, now);
309
- return false;
310
- }
311
-
312
- function parseMaybeJson(value: any): any {
313
- if (typeof value !== 'string') return value;
314
- try {
315
- return JSON.parse(value);
316
- } catch {
317
- return value;
318
- }
319
- }
320
-
321
- function getChatMessageSignature(message: ChatMessage | null | undefined): string {
322
- return buildChatMessageSignature(message);
323
- }
324
-
325
- function normalizeReadChatTailLimit(args: any): number {
326
- const value = Number(args?.tailLimit || 0);
327
- return Number.isFinite(value) ? Math.max(0, value) : 0;
328
- }
329
-
330
- function normalizeReadChatMessages(payload: Record<string, any>): ChatMessage[] {
331
- const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
332
- return normalizeChatMessages(messages);
333
- }
334
-
335
- function getMessageNewestReceivedAt(messages: Array<{ receivedAt?: unknown; timestamp?: unknown }>): number {
336
- let newest = 0;
337
- for (const message of messages) {
338
- const receivedAt = Number(message?.receivedAt ?? message?.timestamp ?? 0);
339
- if (Number.isFinite(receivedAt) && receivedAt > newest) newest = receivedAt;
340
- }
341
- return newest;
342
- }
343
-
344
- function readHistorySessionIdFromMessages(messages: ChatMessage[]): string | undefined {
345
- for (const message of messages as Array<ChatMessage & { historySessionId?: unknown }>) {
346
- const historySessionId = typeof message?.historySessionId === 'string' ? message.historySessionId.trim() : '';
347
- if (historySessionId) return historySessionId;
348
- }
349
- return undefined;
350
- }
351
-
352
- function shouldPreserveNativeIdentity(providerType: string, sessionId: string, message: ChatMessage): boolean {
353
- const providerUnitKey = typeof message.providerUnitKey === 'string' ? message.providerUnitKey.trim() : '';
354
- const turnKey = typeof message._turnKey === 'string' ? message._turnKey.trim() : '';
355
- if (!providerUnitKey) return false;
356
- // (A2.3) v2 stamped identity is producer-owned and globally stable; trust it
357
- // unconditionally. Producers may omit _turnKey (the daemon recomputes it
358
- // from the current ordering), so do not require turnKey for v2 messages.
359
- if (providerUnitKey.startsWith('v2:') || providerUnitKey.startsWith('v2-pty:')) {
360
- return true;
361
- }
362
- // v1 identity always required both keys to be present.
363
- if (!turnKey) return false;
364
- if (providerType === 'hermes-cli' && sessionId) {
365
- return providerUnitKey.startsWith(`${providerType}:native:${sessionId}:`)
366
- && turnKey.startsWith(`${providerType}:native-turn:${sessionId}:`);
367
- }
368
- return true;
369
- }
370
-
371
- /**
372
- * Drop the synthetic "user" message some CLIs surface in their native
373
- * transcript when the daemon injects a coordinator system prompt
374
- * (codex puts the AGENTS.md / developer_instructions block in as
375
- * role=user; agy/claude/hermes have similar artifacts). The user can
376
- * opt back into seeing it via the provider setting
377
- * `showCoordinatorSystemPrompt`. Default is off — the prompt is still
378
- * fully visible from the chat-header ⓘ "Session info" dialog.
379
- *
380
- * Matching rules:
381
- * 1. Setting must be off (default).
382
- * 2. There must be a registered coordinator entry for the session.
383
- * 3. The candidate message is filtered when its role is user OR
384
- * system and its content either contains the prompt body verbatim,
385
- * OR contains the well-known coordinator marker
386
- * `adhdev-mesh-coordinator-prompt`. The marker covers context-file
387
- * cases (agy AGENTS.md / gemini GEMINI.md) where the CLI may wrap
388
- * its own preamble around our block. Verbatim-content covers
389
- * codex's developer_instructions echo.
390
- *
391
- * Returns the messages array unchanged when none of the rules match,
392
- * so this is safe to apply unconditionally to every read_chat result.
393
- */
394
- function maybeHideCoordinatorPromptMessage(
395
- h: CommandHelpers,
396
- providerType: string,
397
- sessionId: string | undefined,
398
- messages: ChatMessage[],
399
- ): ChatMessage[] {
400
- if (!Array.isArray(messages) || messages.length === 0) return messages;
401
- if (!sessionId) return messages;
402
- const loader = h.ctx?.providerLoader;
403
- if (!loader) return messages;
404
- let showSetting: unknown = undefined;
405
- try {
406
- showSetting = (loader as any).getSettingValue?.(providerType, 'showCoordinatorSystemPrompt');
407
- } catch { /* unknown setting key for this provider — fall through */ }
408
- if (showSetting === true) return messages;
409
- const coord = getCoordinatorForSession(sessionId);
410
- if (!coord) return messages;
411
- const promptBody = typeof coord.systemPrompt === 'string' ? coord.systemPrompt : '';
412
- const MARKER = 'adhdev-mesh-coordinator-prompt';
413
- const filtered = messages.filter(m => {
414
- const role = String((m as any)?.role || '').toLowerCase();
415
- if (role !== 'user' && role !== 'system') return true;
416
- const content = flattenContent((m as any)?.content);
417
- if (!content) return true;
418
- if (content.includes(MARKER)) return false;
419
- if (promptBody && content.includes(promptBody.slice(0, Math.min(400, promptBody.length)))) return false;
420
- return true;
421
- });
422
- if (filtered.length !== messages.length) {
423
- LOG.debug('ChatFilter', `[${providerType}] hid ${messages.length - filtered.length} coordinator-prompt message(s) from ${sessionId}`);
424
- }
425
- return filtered;
426
- }
427
-
428
- /**
429
- * Convenience wrapper used at every native-history call site: normalize +
430
- * conditionally drop the coordinator system-prompt message. Avoids
431
- * duplicating the filter at four read_chat code paths.
432
- */
433
- function normalizeAndFilterNativeHistory(
434
- h: CommandHelpers,
435
- providerType: string,
436
- args: any,
437
- messages: ChatMessage[],
438
- nativeSessionId?: string,
439
- ): ChatMessage[] {
440
- const normalized = normalizeNativeHistoryMessages(providerType, messages, nativeSessionId);
441
- const sessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId
442
- : typeof args?.sessionId === 'string' ? args.sessionId
443
- : undefined;
444
- return maybeHideCoordinatorPromptMessage(h, providerType, sessionId, normalized);
445
- }
446
-
447
- function normalizeNativeHistoryMessages(providerType: string, messages: ChatMessage[], nativeSessionId?: string): ChatMessage[] {
448
- let turnIndex = 0;
449
- return normalizeChatMessages(messages).map((message, index) => {
450
- const role = typeof message.role === 'string' ? message.role.trim().toLowerCase() : '';
451
- const kind = typeof message.kind === 'string' && message.kind.trim() ? message.kind.trim() : (role === 'system' ? 'system' : 'standard');
452
- if ((role === 'user' || role === 'human') && index > 0) turnIndex += 1;
453
- const historySessionId = typeof (message as any).historySessionId === 'string'
454
- ? (message as any).historySessionId.trim()
455
- : '';
456
- const contentHash = hashSignatureParts([
457
- providerType,
458
- historySessionId,
459
- String(message.receivedAt || message.timestamp || index),
460
- role,
461
- kind,
462
- flattenContent(message.content),
463
- ]).slice(0, 12);
464
- const nativeIdentitySessionId = historySessionId || (typeof nativeSessionId === 'string' ? nativeSessionId.trim() : '');
465
- const preserveNativeIdentity = shouldPreserveNativeIdentity(providerType, nativeIdentitySessionId, message);
466
- const existingProviderUnitKey = typeof message.providerUnitKey === 'string' ? message.providerUnitKey.trim() : '';
467
- const existingTurnKey = typeof message._turnKey === 'string' ? message._turnKey.trim() : '';
468
- const providerUnitKey = preserveNativeIdentity
469
- ? existingProviderUnitKey
470
- : `${providerType}:native:${nativeIdentitySessionId || 'workspace'}:${index}:${role || 'message'}:${kind}:${contentHash}`;
471
- const meta = message.meta && typeof message.meta === 'object' ? message.meta as Record<string, unknown> : undefined;
472
- const isSystemSessionStart = role === 'system' || kind === 'system' || kind === 'session_start';
473
- const isActivity = role === 'assistant' && (kind === 'tool' || kind === 'terminal' || kind === 'thought');
474
- // (A2.3) sequence emit. Producer-supplied wins (v2-stamped messages
475
- // bring their own monotonic sequence); otherwise derive from
476
- // receivedAt/timestamp; otherwise positional. Always present on the
477
- // output so consumers (ChatSourceMachine) have a stable ordering key.
478
- const existingSequence = typeof (message as any).sequence === 'number'
479
- && Number.isFinite((message as any).sequence)
480
- ? (message as any).sequence
481
- : null;
482
- const tsCandidate = Number(message.receivedAt || message.timestamp || 0);
483
- const sequence = existingSequence !== null
484
- ? existingSequence
485
- : (tsCandidate > 0 ? tsCandidate : index);
486
- return {
487
- ...message,
488
- role: role === 'human' ? 'user' : (role || 'assistant'),
489
- kind: isSystemSessionStart ? 'system' : kind,
490
- ...(nativeIdentitySessionId ? { historySessionId: nativeIdentitySessionId } : {}),
491
- providerUnitKey,
492
- bubbleId: typeof message.bubbleId === 'string' && message.bubbleId.trim()
493
- && preserveNativeIdentity
494
- ? message.bubbleId.trim()
495
- : `bubble:${providerUnitKey}`,
496
- sequence,
497
- _turnKey: preserveNativeIdentity
498
- ? existingTurnKey
499
- : `${providerType}:native-turn:${nativeIdentitySessionId || 'workspace'}:${turnIndex}`,
500
- bubbleState: message.bubbleState || 'final',
501
- ...(isSystemSessionStart ? {
502
- visibility: message.visibility || 'hidden',
503
- transcriptVisibility: message.transcriptVisibility || 'hidden',
504
- audience: message.audience || 'internal',
505
- source: message.source || 'runtime_status',
506
- } : isActivity ? {
507
- source: message.source || (kind === 'terminal' ? 'terminal_command' : 'tool_call'),
508
- meta: { ...meta, label: message.senderName || meta?.label || (kind === 'terminal' ? 'Terminal' : 'Tool') },
509
- } : {
510
- source: message.source || (role === 'assistant' ? 'assistant_text' : undefined),
511
- }),
512
- } as ChatMessage;
513
- });
514
- }
515
-
516
- function buildCliMessageSourceProvenance(args: {
517
- selected: 'native-history' | 'pty-parser';
518
- provider: string;
519
- nativeHandle?: string;
520
- sessionWorkspace?: string;
521
- intendedWorkspace?: string;
522
- transcriptWorkspace?: string;
523
- fallbackReason?: string;
524
- nativeSource?: string;
525
- sourcePath?: string;
526
- sourceMtimeMs?: number;
527
- nativeHistoryCoverage?: string;
528
- partialReason?: string;
529
- unavailableReason?: string;
530
- nativeMessages?: ChatMessage[];
531
- ptyMessages?: ChatMessage[];
532
- returnedMessages?: ChatMessage[];
533
- safeMapping?: boolean;
534
- freshEnough?: boolean;
535
- ptyStatusApprovalOnly?: boolean;
536
- }): Record<string, unknown> {
537
- const sourceMtimeMs = Number(args.sourceMtimeMs || 0);
538
- const sourceMtimeAgeMs = sourceMtimeMs > 0 ? Math.max(0, Date.now() - sourceMtimeMs) : undefined;
539
- const nativeMessages = args.nativeMessages || [];
540
- const ptyMessages = args.ptyMessages || [];
541
- const returnedMessages = args.returnedMessages || [];
542
- const identityStatus = args.selected === 'native-history'
543
- ? 'safe'
544
- : args.fallbackReason === 'native_history_not_safely_mapped'
545
- ? 'ambiguous_session_identity'
546
- : args.fallbackReason?.startsWith('native_history_unavailable')
547
- ? 'transcript_unmapped'
548
- : undefined;
549
- return {
550
- selected: args.selected,
551
- provider: args.provider,
552
- providerType: args.provider,
553
- ...(identityStatus ? { identityStatus } : {}),
554
- ...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
555
- ...(args.nativeHandle ? { nativeSessionId: args.nativeHandle } : {}),
556
- ...(args.sessionWorkspace ? { sessionWorkspace: args.sessionWorkspace } : {}),
557
- ...(args.intendedWorkspace ? { intendedWorkspace: args.intendedWorkspace } : {}),
558
- ...(args.transcriptWorkspace ? { transcriptWorkspace: args.transcriptWorkspace } : {}),
559
- ...(args.fallbackReason ? { fallbackReason: args.fallbackReason } : {}),
560
- ...(args.nativeSource ? { nativeSource: args.nativeSource } : {}),
561
- ...(args.sourcePath ? { sourcePath: args.sourcePath } : {}),
562
- ...(args.nativeHistoryCoverage ? { nativeHistoryCoverage: args.nativeHistoryCoverage } : {}),
563
- ...(args.partialReason ? { partialReason: args.partialReason } : {}),
564
- ...(args.unavailableReason ? { unavailableReason: args.unavailableReason } : {}),
565
- ptyStatusApprovalOnly: args.ptyStatusApprovalOnly === true,
566
- staleness: {
567
- sourceMtimeMs: sourceMtimeMs || undefined,
568
- sourceMtimeAgeMs,
569
- nativeNewestMessageAt: getMessageNewestReceivedAt(nativeMessages),
570
- ptyNewestMessageAt: getMessageNewestReceivedAt(ptyMessages),
571
- freshEnough: args.freshEnough === true,
572
- },
573
- coverage: {
574
- nativeMessageCount: nativeMessages.length,
575
- ptyMessageCount: ptyMessages.length,
576
- returnedMessageCount: returnedMessages.length,
577
- safeMapping: args.safeMapping === true,
578
- // true when PTY message bodies are suppressed and must not be treated as
579
- // chat content. PTY may still contribute status/approval/screen evidence.
580
- ptyMessagesSuppressed: args.selected === 'native-history' || args.ptyStatusApprovalOnly === true,
581
- },
582
- };
583
- }
584
-
585
- /**
586
- * Map a ChatSourceMachine transition cause back to the v1 messageSource
587
- * `fallbackReason` vocabulary so legacy consumers (web-cloud, tests, mesh
588
- * debug bundles) keep parsing strings they already know. A3 replaces the
589
- * caller surface with stateTransition/lockState, after which this map can be
590
- * deleted.
591
- *
592
- * Returns undefined when the cause does not correspond to a fallback (i.e.
593
- * the source is native-history and there is nothing to explain).
594
- */
595
- function causeToLegacyFallbackReason(
596
- cause: ChatSourceTransitionCause,
597
- selected: 'native-history' | 'pty-parser',
598
- extraDetail?: { unavailableReason?: string; nativeSource?: string },
599
- ): string | undefined {
600
- if (selected === 'native-history') return undefined;
601
- switch (cause) {
602
- case 'initial':
603
- return 'native_history_not_checked';
604
- case 'native_progressed':
605
- // Selected pty-parser despite a progressed observation — that
606
- // means we held PtyOnly stickily (peak unmet or non-superset).
607
- return 'native_history_not_selected';
608
- case 'native_regressed_shrunk':
609
- return 'native_history_empty';
610
- case 'native_regressed_unsafe_mapping':
611
- return 'native_history_not_safely_mapped';
612
- case 'native_regressed_coverage_partial':
613
- return 'native_history_partial';
614
- case 'native_regressed_coverage_unavailable':
615
- return 'native_history_unavailable';
616
- case 'native_unavailable_read_error':
617
- return extraDetail?.unavailableReason
618
- ? `native_history_unavailable:${extraDetail.unavailableReason}`
619
- : 'native_history_unavailable';
620
- case 'native_unavailable_provider_unsupported':
621
- return 'provider_native_transcript_not_supported';
622
- case 'native_unavailable_empty':
623
- return 'native_history_empty';
624
- case 'native_unavailable_not_native_source':
625
- return extraDetail?.nativeSource
626
- ? `native_history_source_${extraDetail.nativeSource}`
627
- : 'native_history_unavailable';
628
- }
629
- }
630
-
631
- /**
632
- * Translate a native-history fetch result + provider/adapter context into a
633
- * ChatSourceObservation and drive ChatSourceRegistry. Returns the decision
634
- * together with the legacy messageSource payload so call sites can produce
635
- * a v1-compatible response without duplicating the registry plumbing.
636
- *
637
- * This is the replacement for the 300-line if-ladder that previously lived
638
- * inline in handleReadChat. It is intentionally split out for two reasons:
639
- * (1) we will call it from two places (CLI adapter branch + history-only
640
- * branch) instead of duplicating the ladder, (2) tests can drive it with
641
- * synthetic native-history results to verify the cause→fallbackReason
642
- * mapping without booting the whole readChat pipeline.
643
- */
644
- function decideCliReadChatSource(args: {
645
- providerType: string;
646
- provider?: ProviderModule;
647
- sessionId: string;
648
- nativeHistoryResult: any | null;
649
- nativeHistoryError?: unknown;
650
- safeMapping: boolean;
651
- trustedExactNativeIdentity?: boolean;
652
- sessionWorkspace?: string;
653
- intendedWorkspace?: string;
654
- ptyMessages: ChatMessage[];
655
- ptyStatusApprovalOnly: boolean;
656
- }): {
657
- decision: ChatSourceDecision;
658
- messageSource: Record<string, unknown>;
659
- nativeMessages: ChatMessage[];
660
- nativeSelected: boolean;
661
- } {
662
- const supportsNative = supportsCliNativeTranscript(args.providerType, args.provider);
663
- const observation = buildObservationForCli(args, supportsNative);
664
- const sessionKey = chatSourceSessionKey(args.providerType, args.sessionId);
665
- let decision = CHAT_SOURCE_REGISTRY.observe(sessionKey, observation);
666
-
667
- // A restored runtime can briefly expose different native slices while the
668
- // provider transcript settles (for example, startup/system rows may be
669
- // filtered after the first read). The source machine correctly treats a
670
- // shrinking slice as regression, but an exact provider-session lookup with
671
- // no PTY transcript has no safer fallback. Re-bootstrap only this proven
672
- // identity so the chat does not disappear after daemon restart.
673
- if (
674
- decision.selected === 'pty-parser'
675
- && args.trustedExactNativeIdentity === true
676
- && args.safeMapping
677
- && args.ptyMessages.length === 0
678
- && observation.kind === 'native_present'
679
- && observation.coverage !== 'partial'
680
- && observation.messages.length > 0
681
- ) {
682
- CHAT_SOURCE_REGISTRY.clear(sessionKey);
683
- decision = CHAT_SOURCE_REGISTRY.observe(sessionKey, observation);
684
- }
685
-
686
- const nativeMessages: ChatMessage[] = observation.kind === 'native_present'
687
- ? extractNativeMessagesFromResult(args.providerType, args.nativeHistoryResult)
688
- : [];
689
-
690
- const nativeSource = typeof args.nativeHistoryResult?.source === 'string'
691
- ? args.nativeHistoryResult.source
692
- : undefined;
693
- const sourcePath = typeof args.nativeHistoryResult?.sourcePath === 'string'
694
- ? args.nativeHistoryResult.sourcePath
695
- : undefined;
696
- const sourceMtimeMs = typeof args.nativeHistoryResult?.sourceMtimeMs === 'number'
697
- ? args.nativeHistoryResult.sourceMtimeMs
698
- : undefined;
699
- const coverageHint = typeof args.nativeHistoryResult?.nativeHistoryCoverage === 'string'
700
- ? args.nativeHistoryResult.nativeHistoryCoverage
701
- : undefined;
702
- const partialReason = typeof args.nativeHistoryResult?.partialReason === 'string'
703
- ? args.nativeHistoryResult.partialReason
704
- : undefined;
705
- const unavailableReason = typeof args.nativeHistoryResult?.unavailableReason === 'string'
706
- ? args.nativeHistoryResult.unavailableReason
707
- : args.nativeHistoryError
708
- ? `error:${(args.nativeHistoryError as any)?.message || String(args.nativeHistoryError)}`
709
- : undefined;
710
- const nativeHandle = typeof args.nativeHistoryResult?.providerSessionId === 'string'
711
- ? args.nativeHistoryResult.providerSessionId
712
- : undefined;
713
- const transcriptWorkspace = typeof args.nativeHistoryResult?.workspace === 'string'
714
- ? args.nativeHistoryResult.workspace
715
- : nativeMessages.map((m: any) => typeof m?.workspace === 'string' ? m.workspace.trim() : '').find(Boolean);
716
-
717
- const fallbackReason = causeToLegacyFallbackReason(decision.transition.cause, decision.selected, {
718
- unavailableReason,
719
- nativeSource: nativeSource && nativeSource !== 'provider-native' ? nativeSource : undefined,
720
- });
721
-
722
- // ptyStatusApprovalOnly: when the machine selected native-history we
723
- // suppress PTY content so the dashboard does not double-show messages
724
- // already in the native transcript. When the machine selected
725
- // pty-parser, PTY is the authoritative source — do NOT suppress it.
726
- // Callers used to hard-code this to `nativeSelected first = true` which
727
- // suppressed PTY content even when native was empty/unavailable, leaving
728
- // the dashboard with zero visible messages (the codex generating/waiting
729
- // approval stuck state). Trust the machine here, not the caller hint.
730
- const ptyStatusApprovalOnly = decision.selected === 'native-history'
731
- ? true
732
- : args.ptyStatusApprovalOnly;
733
-
734
- const messageSource = buildCliMessageSourceProvenance({
735
- selected: decision.selected,
736
- provider: args.providerType,
737
- nativeHandle,
738
- sessionWorkspace: args.sessionWorkspace,
739
- intendedWorkspace: args.intendedWorkspace,
740
- transcriptWorkspace,
741
- fallbackReason,
742
- nativeSource,
743
- sourcePath,
744
- sourceMtimeMs,
745
- nativeHistoryCoverage: coverageHint,
746
- partialReason,
747
- unavailableReason,
748
- nativeMessages,
749
- ptyMessages: args.ptyMessages,
750
- returnedMessages: decision.selected === 'native-history' ? nativeMessages : args.ptyMessages,
751
- safeMapping: args.safeMapping,
752
- // freshEnough is a v1 concept the machine does not model directly.
753
- // We surface lockState.locked here so v1 consumers reading
754
- // staleness.freshEnough still get a meaningful boolean.
755
- freshEnough: decision.lockState.locked,
756
- ptyStatusApprovalOnly,
757
- });
758
-
759
- return {
760
- decision,
761
- messageSource,
762
- nativeMessages,
763
- nativeSelected: decision.selected === 'native-history',
764
- };
765
- }
766
-
767
- function buildObservationForCli(
768
- args: {
769
- providerType: string;
770
- sessionId: string;
771
- nativeHistoryResult: any | null;
772
- nativeHistoryError?: unknown;
773
- safeMapping: boolean;
774
- },
775
- supportsNative: boolean,
776
- ): ChatSourceObservation {
777
- if (!supportsNative) {
778
- return { kind: 'native_unavailable', reason: 'provider_not_supported' };
779
- }
780
- if (args.nativeHistoryError) {
781
- return { kind: 'native_unavailable', reason: 'read_error' };
782
- }
783
- const result = args.nativeHistoryResult;
784
- if (!result || typeof result !== 'object') {
785
- return { kind: 'native_unavailable', reason: 'read_error' };
786
- }
787
- const source = typeof result.source === 'string' ? result.source : '';
788
- if (source && source !== 'provider-native') {
789
- // 'native-unavailable' or other producer-side declined source.
790
- return { kind: 'native_unavailable', reason: source === 'native-unavailable' ? 'empty' : 'not_native_source' };
791
- }
792
- const messages = Array.isArray(result.messages) ? result.messages : [];
793
- if (messages.length === 0) {
794
- return { kind: 'native_unavailable', reason: 'empty' };
795
- }
796
- const coverage = typeof result.nativeHistoryCoverage === 'string'
797
- ? result.nativeHistoryCoverage
798
- : 'tail';
799
- if (coverage === 'unavailable') {
800
- return { kind: 'native_unavailable', reason: 'coverage_unavailable' };
801
- }
802
- return buildV1NativePresentObservation({
803
- providerType: args.providerType,
804
- sessionId: args.sessionId,
805
- messages,
806
- coverage: coverage === 'full' || coverage === 'tail' || coverage === 'current-turn' || coverage === 'partial'
807
- ? coverage
808
- : 'tail',
809
- safeMapping: args.safeMapping,
810
- });
811
- }
812
-
813
- function extractNativeMessagesFromResult(providerType: string, result: any): ChatMessage[] {
814
- if (!result || !Array.isArray(result.messages)) return [];
815
- return normalizeNativeHistoryMessages(
816
- providerType,
817
- result.messages as ChatMessage[],
818
- typeof result.providerSessionId === 'string' ? result.providerSessionId : undefined,
819
- );
820
- }
821
-
822
- /**
823
- * ptyStatusApprovalOnly is true when the daemon should treat PTY content as
824
- * status/approval signal only (not as chat messages). v1 set this to `true`
825
- * whenever native-history was selected as the source, and `false` otherwise.
826
- * The machine equivalent: when native is the source we want PTY suppressed.
827
- */
828
- function primaryPtyApprovalOnlyFor(_cliType: string, nativeSelected: boolean): boolean {
829
- return nativeSelected;
830
- }
831
-
832
- /**
833
- * Codex-only unsafe-native fallback: when the primary native fetch produced
834
- * unsafe-mapping data, v1 attempted to recover by reading exact runtime
835
- * mirror messages, runtime input ACK messages, or by trusting the current-
836
- * runtime PTY when safely attributed. None of this is the machine's
837
- * responsibility — the machine already decided pty-parser. This helper
838
- * preserves the daemon-side message selection and annotates messageSource.
839
- */
840
- function applyUnsafeNativeDaemonFallback(args: {
841
- providerType: string;
842
- adapter: CliAdapter;
843
- helpers: CommandHelpers;
844
- readChatArgs: any;
845
- sessionWorkspace?: string;
846
- intendedWorkspace?: string;
847
- ptyMessages: ChatMessage[];
848
- nativeHistoryLimit: number;
849
- provider?: ProviderModule;
850
- messageSourceRef: { set(value: Record<string, unknown>): void; get(): Record<string, unknown> };
851
- apply(selection: {
852
- messages: ChatMessage[];
853
- transcriptAuthority?: 'provider' | 'daemon';
854
- coverage?: 'full' | 'tail' | 'current-turn';
855
- status?: string;
856
- }): void;
857
- activeModal: unknown;
858
- returnedStatus: string;
859
- coverage?: 'full' | 'tail' | 'current-turn';
860
- }): void {
861
- if (args.adapter.cliType !== 'codex-cli') {
862
- // Only codex-cli had v1 daemon mirror recovery. Other providers skip.
863
- return;
864
- }
865
- const ms = args.messageSourceRef.get();
866
- const fallbackReason = typeof ms.fallbackReason === 'string' ? ms.fallbackReason : '';
867
- if (!isUnsafeNativeTranscriptFallback(fallbackReason)) {
868
- return;
869
- }
870
- const safeCurrentRuntimePtyMessages = isCurrentRuntimePtySafelyAttributed({
871
- adapter: args.adapter,
872
- helpers: args.helpers,
873
- readChatArgs: args.readChatArgs,
874
- sessionWorkspace: args.sessionWorkspace,
875
- intendedWorkspace: args.intendedWorkspace,
876
- ptyMessages: args.ptyMessages,
877
- });
878
- if (safeCurrentRuntimePtyMessages) {
879
- args.apply({
880
- messages: args.ptyMessages,
881
- transcriptAuthority: 'daemon',
882
- coverage: args.coverage || 'current-turn',
883
- status: args.returnedStatus,
884
- });
885
- const next = { ...ms, selectedDaemonSource: 'current-runtime-pty', transcriptAuthority: 'daemon', runtimeMappingSafe: true };
886
- args.messageSourceRef.set(next);
887
- return;
888
- }
889
- const safeRuntimeAckMessages = selectRuntimeInputAckMessages(args.ptyMessages);
890
- if (safeRuntimeAckMessages.length > 0) {
891
- args.apply({
892
- messages: safeRuntimeAckMessages,
893
- transcriptAuthority: 'daemon',
894
- coverage: 'tail',
895
- status: coerceUnsafeNativeFallbackStatus(args.returnedStatus, args.activeModal),
896
- });
897
- const next = { ...ms, ptyStatusApprovalOnly: true };
898
- args.messageSourceRef.set(next);
899
- return;
900
- }
901
- const exactRuntimeMirrorMessages = readExactRuntimeMirrorMessages({
902
- providerType: args.providerType,
903
- targetSessionId: typeof args.readChatArgs?.targetSessionId === 'string' ? args.readChatArgs.targetSessionId : undefined,
904
- currentSessionId: typeof (args.helpers.currentSession as any)?.sessionId === 'string' ? (args.helpers.currentSession as any).sessionId : undefined,
905
- tailLimit: args.nativeHistoryLimit,
906
- historyBehavior: args.provider?.historyBehavior,
907
- });
908
- if (exactRuntimeMirrorMessages.length > 0) {
909
- args.apply({
910
- messages: exactRuntimeMirrorMessages,
911
- transcriptAuthority: 'daemon',
912
- coverage: 'tail',
913
- status: coerceUnsafeNativeFallbackStatus(args.returnedStatus, args.activeModal),
914
- });
915
- const next = { ...ms, selectedDaemonSource: 'exact-runtime-mirror', transcriptAuthority: 'daemon', ptyStatusApprovalOnly: true };
916
- args.messageSourceRef.set(next);
917
- return;
918
- }
919
- // No daemon mirror available — keep PTY messages as-is (still pty-parser
920
- // selection); just coerce status for waiting_approval consistency.
921
- args.apply({
922
- messages: args.ptyMessages,
923
- coverage: args.coverage,
924
- status: coerceUnsafeNativeFallbackStatus(args.returnedStatus, args.activeModal),
925
- });
926
- const next = { ...ms, ptyStatusApprovalOnly: true };
927
- args.messageSourceRef.set(next);
928
- }
929
-
930
- // (A2.2) buildNativeHistoryFallbackReason removed. ChatSourceMachine emits a
931
- // ChatSourceTransitionCause; causeToLegacyFallbackReason maps it back to the
932
- // v1 vocabulary for response compatibility. A3 deletes the v1 vocabulary
933
- // entirely and surfaces stateTransition/lockState directly.
934
-
935
- function isUnsafeNativeTranscriptFallback(reason?: string): boolean {
936
- const value = String(reason || '').trim();
937
- return value.startsWith('native_history_unavailable')
938
- || value === 'native_history_not_safely_mapped'
939
- || value === 'native_history_stale'
940
- || value === 'native_history_partial';
941
- }
942
-
943
- function coerceUnsafeNativeFallbackStatus(status: string, activeModal: unknown): string {
944
- if (status === 'waiting_approval' && activeModal) return status;
945
- return 'idle';
946
- }
947
-
948
- function isRuntimeInputAckMessage(message: ChatMessage | undefined): boolean {
949
- if (!message || typeof message !== 'object') return false;
950
- const role = String((message as any).role || '').trim().toLowerCase();
951
- if (role !== 'user' && role !== 'human') return false;
952
- const meta = (message as any).meta;
953
- return !!meta && typeof meta === 'object' && !Array.isArray(meta) && meta.runtimeInputAck === true;
954
- }
955
-
956
- function selectRuntimeInputAckMessages(messages: ChatMessage[]): ChatMessage[] {
957
- return messages.filter((message) => isRuntimeInputAckMessage(message));
958
- }
959
-
960
- function readExactRuntimeMirrorMessages(args: {
961
- providerType: string;
962
- targetSessionId?: string;
963
- currentSessionId?: string;
964
- tailLimit: number;
965
- historyBehavior?: ProviderModule['historyBehavior'];
966
- }): ChatMessage[] {
967
- const targetSessionId = String(args.targetSessionId || '').trim();
968
- const currentSessionId = String(args.currentSessionId || '').trim();
969
- if (!targetSessionId || targetSessionId !== currentSessionId) return [];
970
-
971
- const history = readChatHistory(
972
- args.providerType,
973
- 0,
974
- Math.max(args.tailLimit || 0, HOT_TAIL_MIN_LIMIT),
975
- targetSessionId,
976
- 0,
977
- args.historyBehavior,
978
- );
979
- return normalizeChatMessages((history.messages || []) as ChatMessage[])
980
- .filter((message) => {
981
- const historySessionId = String((message as any).historySessionId || '').trim();
982
- const instanceId = String((message as any).instanceId || '').trim();
983
- return historySessionId === targetSessionId || instanceId === targetSessionId;
984
- });
985
- }
986
-
987
- function normalizeComparableWorkspace(value: unknown): string {
988
- const text = typeof value === 'string' ? value.trim() : '';
989
- if (!text) return '';
990
- return path.resolve(text);
991
- }
992
-
993
- /**
994
- * read_chat node scope verdict. One physical daemon hosts a base node plus several
995
- * worktree nodes; mesh_read_chat always dispatches read_chat with the requested
996
- * node's workspace (`args.workspace`). When the resolved target session actually
997
- * lives in a DIFFERENT worktree, returning its transcript — or worse, letting the
998
- * native-history-by-workspace fallback splice sibling worktree turns into the
999
- * reply — makes the coordinator believe one session received every worktree's
1000
- * work. This guard refuses a CONFIRMED cross-workspace read instead of mixing.
1001
- *
1002
- * Conservative by design (mirrors the WTCLAIM fix-B "unknown → allow" rule): only
1003
- * a session id that resolves to a known workspace which is unequal to a known
1004
- * intended workspace blocks. When either side is unknown — no targetSessionId, no
1005
- * args.workspace, an unregistered session, the coordinator self-session, or a
1006
- * plain dashboard read that never passes a node workspace — the read proceeds
1007
- * untouched, so base-node and same-daemon coordinator reads never regress.
1008
- */
1009
- export function evaluateReadChatNodeWorkspaceScope(args: {
1010
- targetSessionId?: string;
1011
- intendedWorkspace?: string;
1012
- sessionWorkspace?: string;
1013
- }): { scoped: false } | { scoped: true; intended: string; actual: string } {
1014
- const targetSessionId = typeof args.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
1015
- if (!targetSessionId) return { scoped: false };
1016
- const intended = normalizeMeshWorkspaceForCompare(args.intendedWorkspace);
1017
- const actual = normalizeMeshWorkspaceForCompare(args.sessionWorkspace);
1018
- if (!intended || !actual) return { scoped: false };
1019
- if (intended === actual) return { scoped: false };
1020
- return { scoped: true, intended, actual };
1021
- }
1022
-
1023
- /**
1024
- * Resolve the target session's ACTUAL workspace from the most authoritative source
1025
- * available on this daemon: the session registry record (stamped at register
1026
- * time), then the live CLI adapter's working directory, then the bound instance
1027
- * state. Returns '' when nothing knows the session's workspace — the caller treats
1028
- * that as "unknown" and does not block.
1029
- */
1030
- function resolveTargetSessionActualWorkspace(h: CommandHelpers, targetSessionId: string): string {
1031
- const registryWorkspace = (h.ctx?.sessionRegistry?.get?.(targetSessionId) as any)?.workspace;
1032
- if (typeof registryWorkspace === 'string' && registryWorkspace.trim()) return registryWorkspace;
1033
- const adapter = h.getCliAdapter?.(targetSessionId);
1034
- if (adapter && typeof adapter.workingDir === 'string' && adapter.workingDir.trim()) return adapter.workingDir;
1035
- const instanceWorkspace = (getTargetInstance(h, { targetSessionId })?.getState?.() as any)?.workspace;
1036
- if (typeof instanceWorkspace === 'string' && instanceWorkspace.trim()) return instanceWorkspace;
1037
- return '';
1038
- }
1039
-
1040
- function isCurrentRuntimePtySafelyAttributed(args: {
1041
- adapter: CliAdapter;
1042
- helpers: CommandHelpers;
1043
- readChatArgs: any;
1044
- sessionWorkspace?: string;
1045
- intendedWorkspace?: string;
1046
- ptyMessages: ChatMessage[];
1047
- }): boolean {
1048
- if (args.adapter.cliType !== 'codex-cli') return false;
1049
- if (!Array.isArray(args.ptyMessages) || args.ptyMessages.length === 0) return false;
1050
- const targetSessionId = typeof args.readChatArgs?.targetSessionId === 'string'
1051
- ? args.readChatArgs.targetSessionId.trim()
1052
- : '';
1053
- const currentSession = args.helpers.currentSession as any;
1054
- const currentSessionId = typeof currentSession?.sessionId === 'string'
1055
- ? currentSession.sessionId.trim()
1056
- : '';
1057
- if (!targetSessionId || !currentSessionId || targetSessionId !== currentSessionId) return false;
1058
-
1059
- const runtimeMeta = typeof (args.adapter as any).getRuntimeMetadata === 'function'
1060
- ? (args.adapter as any).getRuntimeMetadata()
1061
- : null;
1062
- const runtimeId = typeof runtimeMeta?.runtimeId === 'string' ? runtimeMeta.runtimeId.trim() : '';
1063
- if (!runtimeId || runtimeId !== targetSessionId) return false;
1064
- const surfaceKind = typeof runtimeMeta?.surfaceKind === 'string' ? runtimeMeta.surfaceKind : '';
1065
- if (surfaceKind === 'inactive_record' || surfaceKind === 'recovery_snapshot') return false;
1066
-
1067
- const sessionWorkspace = normalizeComparableWorkspace(args.sessionWorkspace);
1068
- const adapterWorkspace = normalizeComparableWorkspace(args.adapter.workingDir);
1069
- if (!sessionWorkspace || !adapterWorkspace || sessionWorkspace !== adapterWorkspace) return false;
1070
- const intendedWorkspace = normalizeComparableWorkspace(args.intendedWorkspace);
1071
- if (intendedWorkspace && intendedWorkspace !== sessionWorkspace) return false;
1072
-
1073
- const registryEntry = args.helpers.ctx?.sessionRegistry?.get?.(targetSessionId) as any;
1074
- const registryInstanceKey = typeof registryEntry?.adapterKey === 'string' && registryEntry.adapterKey.trim()
1075
- ? registryEntry.adapterKey.trim()
1076
- : typeof registryEntry?.instanceKey === 'string' && registryEntry.instanceKey.trim()
1077
- ? registryEntry.instanceKey.trim()
1078
- : '';
1079
- if (registryInstanceKey) {
1080
- const targetInstance = args.helpers.ctx?.instanceManager?.getInstance?.(registryInstanceKey);
1081
- if (targetInstance) {
1082
- const instanceType = typeof (targetInstance as any).type === 'string' ? (targetInstance as any).type : '';
1083
- if (instanceType && instanceType !== args.adapter.cliType) return false;
1084
- }
1085
- }
1086
-
1087
- return true;
1088
- }
1089
-
1090
- function supportsCliNativeTranscript(providerType: string, provider?: ProviderModule): boolean {
1091
- // Preferred path: the provider module declares canonicalHistory in its
1092
- // provider.json. We trust that declaration regardless of the legacy
1093
- // allow-list. A2 will additionally require canonicalHistory.contractVersion
1094
- // to be a supported value (transcript-v2.ts).
1095
- if (provider?.category === 'cli' && isNativeSourceCanonicalHistory(provider?.nativeHistory)) {
1096
- return true;
1097
- }
1098
- // Last-resort fallback for early call sites where the provider module is
1099
- // not yet loaded. Warn once per provider type so this dependency is visible
1100
- // and can be removed in A2.
1101
- if (CLI_NATIVE_TRANSCRIPT_PROVIDERS.has(providerType)) {
1102
- warnLegacyNativeAllowlistHit(providerType);
1103
- return true;
1104
- }
1105
- return false;
1106
- }
1107
-
1108
- function getComparableVisibleText(message: ChatMessage | undefined): string {
1109
- if (!message) return '';
1110
- const role = String((message as any).role || '').trim().toLowerCase();
1111
- if (role !== 'user' && role !== 'assistant') return '';
1112
- const kind = String((message as any).kind || 'standard').trim().toLowerCase();
1113
- if (kind && kind !== 'standard') return '';
1114
- const content = flattenContent((message as any).content).replace(/\s+/g, ' ').trim();
1115
- return content;
1116
- }
1117
-
1118
- function hasOverlappingVisibleConversationText(nativeMessages: ChatMessage[], ptyMessages: ChatMessage[]): boolean {
1119
- const nativeTexts = nativeMessages.map(getComparableVisibleText).filter(Boolean);
1120
- const ptyTexts = ptyMessages.map(getComparableVisibleText).filter(Boolean);
1121
- if (nativeTexts.length === 0 || ptyTexts.length === 0) return false;
1122
- for (const nativeText of nativeTexts) {
1123
- for (const ptyText of ptyTexts) {
1124
- if (nativeText === ptyText) return true;
1125
- const shorter = nativeText.length <= ptyText.length ? nativeText : ptyText;
1126
- const longer = nativeText.length <= ptyText.length ? ptyText : nativeText;
1127
- if (shorter.length >= 32 && longer.includes(shorter)) return true;
1128
- }
1129
- }
1130
- return false;
1131
- }
1132
-
1133
- function hasSafeNativeHistoryMapping(args: {
1134
- historySessionId?: string;
1135
- providerSessionId?: string;
1136
- workspace?: string;
1137
- nativeMessages: ChatMessage[];
1138
- ptyMessages?: ChatMessage[];
1139
- requireWorkspaceContentOverlap?: boolean;
1140
- }): boolean {
1141
- const isCoordinatorTranscript = args.nativeMessages.some((m: any) => {
1142
- const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '');
1143
- return text.includes('mesh_send_task') || text.includes('mesh_status') || text.includes('mesh_read_chat') || text.includes('mesh_launch_session');
1144
- });
1145
-
1146
- const explicitSessionId = String(args.historySessionId || args.providerSessionId || '').trim();
1147
- if (explicitSessionId) {
1148
- const expectedWorkspace = normalizeComparableWorkspace(args.workspace);
1149
- const declaredWorkspaces = args.nativeMessages
1150
- .map((message: any) => normalizeComparableWorkspace(message?.workspace))
1151
- .filter(Boolean);
1152
- if (
1153
- expectedWorkspace
1154
- && declaredWorkspaces.length > 0
1155
- && !declaredWorkspaces.some((workspace) => workspace === expectedWorkspace)
1156
- ) {
1157
- return false;
1158
- }
1159
- const messageSessionIds = args.nativeMessages
1160
- .map((message: any) => typeof message?.historySessionId === 'string' ? message.historySessionId.trim() : '')
1161
- .filter(Boolean);
1162
- if (messageSessionIds.length > 0) {
1163
- return messageSessionIds.some((id) => id === explicitSessionId);
1164
- }
1165
-
1166
- // Messages carry no historySessionId — cannot confirm they belong to the requested session.
1167
- // Only allow a coordinator transcript that is also confirmed by the PTY side; otherwise
1168
- // fail closed so a same-workspace session's history is never silently accepted.
1169
- if (isCoordinatorTranscript && args.ptyMessages && args.ptyMessages.length > 0) {
1170
- const ptyHasCoordinator = args.ptyMessages.some((m: any) => {
1171
- const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '');
1172
- return text.includes('mesh_send_task') || text.includes('mesh_status') || text.includes('mesh_read_chat');
1173
- });
1174
- return ptyHasCoordinator;
1175
- }
1176
-
1177
- // No historySessionId in messages and no coordinator cross-check: fail closed.
1178
- // Workspace-only matching must not override an explicit session identity.
1179
- return false;
1180
- }
1181
- const workspace = String(args.workspace || '').trim();
1182
- if (!workspace) return false;
1183
- const workspaceMatches = args.nativeMessages.some((message: any) => String(message?.workspace || '').trim() === workspace);
1184
- if (!workspaceMatches) return false;
1185
-
1186
- if (isCoordinatorTranscript && args.ptyMessages && args.ptyMessages.length > 0) {
1187
- const ptyHasCoordinator = args.ptyMessages.some((m: any) => {
1188
- const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '');
1189
- return text.includes('mesh_send_task') || text.includes('mesh_status') || text.includes('mesh_read_chat');
1190
- });
1191
- if (!ptyHasCoordinator) {
1192
- return false;
1193
- }
1194
- }
1195
-
1196
- if (!args.requireWorkspaceContentOverlap) return true;
1197
- return hasOverlappingVisibleConversationText(args.nativeMessages, args.ptyMessages || []);
1198
- }
1199
-
1200
- // Provenance boundary: workspace-only native history lookup is never safe
1201
- // because multiple concurrent sessions sharing the same cwd would alias each
1202
- // other. historySessionId (the provider-native session key) is required to
1203
- // establish ownership. hasSafeNativeHistoryMapping() enforces the same
1204
- // invariant after the read; both guards must hold for native history to be used.
1205
- /**
1206
- * Pull the session's spawnedAtMs out of the registry. Native-history
1207
- * file pickers use it as a "files older than this can't be from this
1208
- * session" floor; without it a fresh dashboard view would inherit the
1209
- * previous session's transcript whenever its file happened to be the
1210
- * newest match. Returns undefined when the session isn't registered
1211
- * (e.g. read_chat before the live session was wired up) — the executor
1212
- * treats undefined as "no floor".
1213
- */
1214
- function sessionStartedAtMsFromRegistry(h: CommandHelpers, targetSessionId: string | undefined): number | undefined {
1215
- const sid = typeof targetSessionId === 'string' ? targetSessionId.trim() : '';
1216
- if (!sid) return undefined;
1217
- const target = h.ctx?.sessionRegistry?.get?.(sid);
1218
- return typeof target?.spawnedAtMs === 'number' ? target.spawnedAtMs : undefined;
1219
- }
1220
-
1221
- /**
1222
- * Pull the env vars the daemon set when it spawned this session's CLI.
1223
- * Mesh coordinator points hermes at a per-coordinator HERMES_HOME so
1224
- * the native-history reader needs that override to find the right
1225
- * state.db; without it the reader sees ~/.hermes/state.db and misses
1226
- * every coordinator-session transcript.
1227
- *
1228
- * Returns undefined when no SpecCliAdapter is in play (legacy
1229
- * providers / CDP) or when the adapter exposes no spawn env.
1230
- */
1231
- function sessionSpawnEnvFromAdapter(h: CommandHelpers, targetSessionId: string | undefined): Record<string, string> | undefined {
1232
- const adapter = getTargetedCliAdapter(h, { targetSessionId }, undefined);
1233
- if (!adapter || typeof adapter.getRuntimeMetadata !== 'function') return undefined;
1234
- const meta = adapter.getRuntimeMetadata() as Record<string, unknown> | undefined;
1235
- const env = meta && typeof meta === 'object' ? (meta as Record<string, unknown>).spawnedEnv : undefined;
1236
- return env && typeof env === 'object' ? env as Record<string, string> : undefined;
1237
- }
1238
-
1239
-
1240
- function readCliProviderNativeHistory(agentStr: string, args: {
1241
- canonicalHistory?: ProviderModule['canonicalHistory'];
1242
- historySessionId?: string;
1243
- workspace?: string;
1244
- offset: number;
1245
- limit: number;
1246
- excludeRecentCount: number;
1247
- historyBehavior?: ProviderModule['historyBehavior'];
1248
- scripts?: ProviderScripts;
1249
- excludeInProgressTurn?: boolean;
1250
- sessionStartedAtMs?: number;
1251
- envOverrides?: Record<string, string>;
1252
- }): ReturnType<typeof readProviderChatHistory> & { lookup: 'session' | 'workspace' } {
1253
- const canBindFromLiveSession = !args.historySessionId
1254
- && typeof args.sessionStartedAtMs === 'number'
1255
- && args.sessionStartedAtMs > 0
1256
- && typeof args.workspace === 'string'
1257
- && args.workspace.trim().length > 0;
1258
- if (!args.historySessionId && !canBindFromLiveSession) {
1259
- return {
1260
- messages: [],
1261
- hasMore: false,
1262
- source: 'native-unavailable',
1263
- unavailableReason: 'native_history_workspace_only_lookup_unsafe',
1264
- lookup: 'session',
1265
- } as ReturnType<typeof readProviderChatHistory> & { lookup: 'session' | 'workspace' };
1266
- }
1267
- const sessionHistory = readProviderChatHistory(agentStr, {
1268
- canonicalHistory: args.canonicalHistory,
1269
- historySessionId: args.historySessionId,
1270
- workspace: args.workspace,
1271
- offset: args.offset,
1272
- limit: args.limit,
1273
- excludeRecentCount: args.excludeRecentCount,
1274
- historyBehavior: args.historyBehavior,
1275
- scripts: args.scripts as any,
1276
- excludeInProgressTurn: args.excludeInProgressTurn,
1277
- sessionStartedAtMs: args.sessionStartedAtMs,
1278
- envOverrides: args.envOverrides,
1279
- });
1280
- const boundProviderSessionId = typeof (sessionHistory as any)?.providerSessionId === 'string'
1281
- ? (sessionHistory as any).providerSessionId.trim()
1282
- : '';
1283
- // A fresh live session can be bound without a provider id when the native
1284
- // reader matched both cwd and session_meta.timestamp to spawnedAtMs.
1285
- return {
1286
- ...(sessionHistory as any),
1287
- lookup: args.historySessionId || (canBindFromLiveSession && boundProviderSessionId)
1288
- ? 'session'
1289
- : 'workspace',
1290
- };
1291
- }
1292
-
1293
- function readLiveCodexWorkspaceNativeHistory(agentStr: string, args: {
1294
- canonicalHistory?: ProviderModule['canonicalHistory'];
1295
- workspace?: string;
1296
- offset: number;
1297
- limit: number;
1298
- excludeRecentCount: number;
1299
- historyBehavior?: ProviderModule['historyBehavior'];
1300
- scripts?: ProviderScripts;
1301
- }): (ReturnType<typeof readProviderChatHistory> & { lookup: 'workspace' }) | null {
1302
- if (agentStr !== 'codex-cli') return null;
1303
- const workspace = typeof args.workspace === 'string' ? args.workspace.trim() : '';
1304
- if (!workspace) return null;
1305
- const history = readProviderChatHistory(agentStr, {
1306
- canonicalHistory: args.canonicalHistory,
1307
- workspace,
1308
- offset: args.offset,
1309
- limit: args.limit,
1310
- excludeRecentCount: args.excludeRecentCount,
1311
- historyBehavior: args.historyBehavior,
1312
- scripts: args.scripts as any,
1313
- });
1314
- return { ...(history as any), lookup: 'workspace' };
1315
- }
1316
-
1317
- // (A2.2) isNativeHistoryFreshEnough removed. The v1 freshness comparison
1318
- // (native_newest vs pty_newest with a 5-minute mtime grace window) was the
1319
- // direct cause of the plipping behaviour: PTY arrived every turn so native
1320
- // looked stale by default. ChatSourceMachine never compares native vs PTY
1321
- // freshness — the lock holds across arbitrary PTY arrival. See
1322
- // chat/source-machine.ts for the new semantics.
1323
-
1324
- function shouldPreserveReadChatPayloadField(key: string): boolean {
1325
- return key === 'messageSource' || key === 'transcriptProvenance';
1326
- }
1327
-
1328
- function updateMessageSourceReturnedCount(value: unknown, returnedMessageCount: number): unknown {
1329
- if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
1330
- const record = value as Record<string, unknown>;
1331
- const coverage = record.coverage && typeof record.coverage === 'object' && !Array.isArray(record.coverage)
1332
- ? record.coverage as Record<string, unknown>
1333
- : undefined;
1334
- if (!coverage) return value;
1335
- return {
1336
- ...record,
1337
- coverage: {
1338
- ...coverage,
1339
- returnedMessageCount,
1340
- },
1341
- };
1342
- }
1343
-
1344
- function deriveHistoryDedupKey(message: ChatMessage & { _unitKey?: string; _turnKey?: string }): string | undefined {
1345
- const unitKey = typeof message._unitKey === 'string' ? message._unitKey.trim() : '';
1346
- if (unitKey) return `read_chat:${unitKey}`;
1347
-
1348
- const turnKey = typeof message._turnKey === 'string' ? message._turnKey.trim() : '';
1349
- if (!turnKey) return undefined;
1350
-
1351
- let content = '';
1352
- try {
1353
- content = JSON.stringify(message.content ?? '');
1354
- } catch {
1355
- content = String(message.content ?? '');
1356
- }
1357
- return `read_chat:${turnKey}:${String(message.role || '').toLowerCase()}:${content}`;
1358
- }
1359
-
1360
- function toHistoryPersistedMessages(messages: ChatMessage[]): Array<{
1361
- role: string;
1362
- content: string;
1363
- receivedAt?: number;
1364
- kind?: string;
1365
- senderName?: string;
1366
- historyDedupKey?: string;
1367
- }> {
1368
- return messages.map((message) => ({
1369
- role: message.role,
1370
- content: flattenContent(message.content),
1371
- receivedAt: typeof message.receivedAt === 'number' ? message.receivedAt : undefined,
1372
- kind: typeof message.kind === 'string' ? message.kind : undefined,
1373
- senderName: typeof message.senderName === 'string' ? message.senderName : undefined,
1374
- historyDedupKey: deriveHistoryDedupKey(message as ChatMessage & { _unitKey?: string; _turnKey?: string }),
1375
- }));
1376
- }
1377
-
1378
- function buildFullTail(messages: ChatMessage[], tailLimit: number): {
1379
- messages: ChatMessage[];
1380
- totalMessages: number;
1381
- } {
1382
- const totalMessages = messages.length;
1383
- const tailMessages = tailLimit > 0 ? messages.slice(-tailLimit) : messages;
1384
- return {
1385
- messages: tailMessages,
1386
- totalMessages,
1387
- };
1388
- }
1389
-
1390
- function hasNonEmptyModalButtons(activeModal: unknown): boolean {
1391
- if (!activeModal || typeof activeModal !== 'object') return false;
1392
- const buttons = (activeModal as { buttons?: unknown }).buttons;
1393
- return Array.isArray(buttons) && buttons.some((button) => typeof button === 'string' && button.trim().length > 0);
1394
- }
1395
-
1396
- function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown): string {
1397
- const raw = typeof status === 'string' ? status.trim() : '';
1398
- if (!raw) {
1399
- return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'idle';
1400
- }
1401
- switch (raw) {
1402
- case 'starting':
1403
- return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'starting';
1404
- case 'stopped':
1405
- case 'disconnected':
1406
- case 'not_monitored':
1407
- return 'error';
1408
- case 'waiting_approval':
1409
- // The contract validator requires activeModal+buttons whenever
1410
- // status is waiting_approval. If a producer/coercer set this
1411
- // status without staging the modal yet (a race we hit with
1412
- // codex-cli during tool approval setup), downgrade to a
1413
- // generating-like status so readChat still returns successfully.
1414
- // The next poll will pick up the modal once the provider has
1415
- // emitted it.
1416
- return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'generating';
1417
- default:
1418
- return raw;
1419
- }
1420
- }
1421
-
1422
- function isGeneratingLikeStatus(status: unknown): boolean {
1423
- return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
1424
- }
1425
-
1426
- function hasVisibleAssistantMessage(messages: unknown[] | undefined): boolean {
1427
- if (!Array.isArray(messages)) return false;
1428
- return messages.some((message: any) => {
1429
- if (!message || message.role !== 'assistant') return false;
1430
- const kind = typeof message.kind === 'string' ? message.kind : 'standard';
1431
- if (kind !== 'standard') return false;
1432
- return String(message.content || '').trim().length > 0;
1433
- });
1434
- }
1435
-
1436
- function hasFinalVisibleAssistantMessage(messages: unknown[] | undefined): boolean {
1437
- if (!Array.isArray(messages)) return false;
1438
- const visible = filterUserFacingChatMessages(messages as ChatMessage[]);
1439
- const last = visible[visible.length - 1] as ChatMessage | undefined;
1440
- const role = typeof last?.role === 'string' ? last.role.trim().toLowerCase() : '';
1441
- const content = last ? flattenContent(last.content).trim() : '';
1442
- return (role === 'assistant' || role === 'model') && content.length > 0;
1443
- }
1444
-
1445
- function shouldTrustCliAdapterTerminalStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any): boolean {
1446
- if (!isGeneratingLikeStatus(parsedStatus)) return false;
1447
- if (hasNonEmptyModalButtons(activeModal)) return false;
1448
- const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
1449
- if (adapterRawStatus !== 'idle') return false;
1450
- if (typeof adapter.isProcessing === 'function' && adapter.isProcessing()) return false;
1451
- return true;
1452
- }
1453
-
1454
- function normalizeCliReadChatStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any, parsedMessages?: unknown[]): string {
1455
- const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
1456
- if (adapterRawStatus === 'starting'
1457
- && isGeneratingLikeStatus(parsedStatus)
1458
- && !hasNonEmptyModalButtons(activeModal)
1459
- && Array.isArray(parsedMessages)
1460
- && parsedMessages.length === 0
1461
- && Array.isArray(adapterStatus?.messages)
1462
- && adapterStatus.messages.length === 0
1463
- && !(typeof adapter.isProcessing === 'function' && adapter.isProcessing())) {
1464
- return 'starting';
1465
- }
1466
- if (
1467
- isGeneratingLikeStatus(adapterRawStatus)
1468
- && parsedStatus === 'idle'
1469
- && !hasNonEmptyModalButtons(activeModal)
1470
- && !hasVisibleAssistantMessage(parsedMessages)
1471
- ) {
1472
- return adapterRawStatus;
1473
- }
1474
- if (shouldTrustCliAdapterTerminalStatus(parsedStatus, activeModal, adapter, adapterStatus)) return 'idle';
1475
- return typeof parsedStatus === 'string' && parsedStatus.trim() ? parsedStatus : 'idle';
1476
- }
1477
-
1478
- function finalizeStreamingMessagesWhenIdle(messages: ChatMessage[], status: string): ChatMessage[] {
1479
- if (status !== 'idle') return messages;
1480
- return messages.map((message) => {
1481
- const meta = message.meta && typeof message.meta === 'object'
1482
- ? message.meta as Record<string, unknown>
1483
- : undefined;
1484
- const hasStreamingMeta = meta?.streaming === true;
1485
- if (message.bubbleState !== 'streaming' && !hasStreamingMeta) return message;
1486
- return {
1487
- ...message,
1488
- ...(message.bubbleState === 'streaming' ? { bubbleState: 'final' as const } : {}),
1489
- ...(hasStreamingMeta ? { meta: { ...meta, streaming: false } } : {}),
1490
- };
1491
- });
1492
- }
1493
-
1494
- /**
1495
- * Collapse adjacent PTY messages whose canonical (whitespace-stripped)
1496
- * content is identical, OR whose turn key + role/kind match.
1497
4
  *
1498
- * The PTY parser of some providers (hermes-cli observed in the wild)
1499
- * emits the same logical assistant turn twice when the terminal re-wraps
1500
- * the text at a different column. The two emissions differ in newline
1501
- * position and sometimes in a single inserted space next to punctuation
1502
- * (e.g. `(수정 2개), upstream` vs `(수정 2개 ), upstream`), so a simple
1503
- * `\s+ -> ' '` normalize cannot collapse them.
1504
- *
1505
- * Strategy:
1506
- * 1. If both messages carry the same _turnKey + role + kind, they are
1507
- * the same logical turn by construction. Collapse.
1508
- * 2. Otherwise compare with all whitespace stripped — wrap variants
1509
- * collapse to identical strings.
1510
- *
1511
- * Native-history paths run through pageHistoryRecords and already
1512
- * collapse on a normalized signature; this helper is the PTY equivalent
1513
- * the readChat sync path was missing.
1514
- */
1515
- function collapseAdjacentDuplicateChatMessages(messages: ChatMessage[]): ChatMessage[] {
1516
- if (!Array.isArray(messages) || messages.length <= 1) return messages;
1517
- const result: ChatMessage[] = [];
1518
- let prevRoleKind = '';
1519
- let prevStripped = '';
1520
- for (const message of messages) {
1521
- const role = typeof message.role === 'string' ? message.role : '';
1522
- const kind = typeof message.kind === 'string' ? message.kind : 'standard';
1523
- const content = typeof message.content === 'string'
1524
- ? message.content
1525
- : (Array.isArray(message.content) ? message.content.map((p: any) => typeof p?.text === 'string' ? p.text : '').join('') : '');
1526
- const strippedContent = content.replace(/\s+/g, '');
1527
- // Empty content or system messages are passed through untouched.
1528
- if (!strippedContent || role === 'system') {
1529
- result.push(message);
1530
- prevRoleKind = '';
1531
- prevStripped = '';
1532
- continue;
1533
- }
1534
- const roleKind = `${role}:${kind}`;
1535
- const sameStripped = strippedContent === prevStripped && roleKind === prevRoleKind;
1536
- if (result.length > 0 && sameStripped) {
1537
- // Adjacent duplicate after stripping all whitespace. Keep the
1538
- // *later* copy because PTY's last emission usually has the most
1539
- // complete formatting.
1540
- result[result.length - 1] = message;
1541
- prevRoleKind = roleKind;
1542
- prevStripped = strippedContent;
1543
- continue;
1544
- }
1545
- result.push(message);
1546
- prevRoleKind = roleKind;
1547
- prevStripped = strippedContent;
1548
- }
1549
- return result;
1550
- }
1551
-
1552
- function buildReadChatCommandResult(payload: Record<string, any>, args: any, h?: CommandHelpers): CommandResult {
1553
- let validatedPayload: Record<string, any>;
1554
- const debugReadChat = payload?.debugReadChat && typeof payload.debugReadChat === 'object'
1555
- ? payload.debugReadChat
1556
- : undefined;
1557
- try {
1558
- validatedPayload = validateReadChatResultPayload({
1559
- ...payload,
1560
- status: normalizeReadChatCommandStatus(payload?.status, payload?.activeModal),
1561
- }, 'read_chat command result') as Record<string, any>;
1562
- } catch (error: any) {
1563
- return { success: false, error: error?.message || String(error) };
1564
- }
1565
- const messages = normalizeReadChatMessages(validatedPayload);
1566
- // Last-mile coordinator-prompt filter. Different read_chat code paths
1567
- // produce the final messages array (native-history main path, codex
1568
- // exact-runtime-mirror fallback, daemon-side pty-parser, etc), so
1569
- // applying it here means we don't have to thread the filter through
1570
- // every one. Driven by the provider setting `showCoordinatorSystemPrompt`
1571
- // + the coordinator-registry entry for the target session.
1572
- const sessionIdHint = typeof args?.targetSessionId === 'string' ? args.targetSessionId
1573
- : typeof args?.sessionId === 'string' ? args.sessionId
1574
- : '';
1575
- const providerHint = typeof args?.cliType === 'string' ? args.cliType
1576
- : typeof args?.providerType === 'string' ? args.providerType
1577
- : typeof args?.agentType === 'string' ? args.agentType
1578
- : '';
1579
- const filteredMessages = h
1580
- ? maybeHideCoordinatorPromptMessage(h, providerHint, sessionIdHint, messages)
1581
- : messages;
1582
- // By default read_chat returns only user-facing prose turns. When the
1583
- // caller opts in with `includeActivity`, tool/terminal/thought activity
1584
- // bubbles (e.g. the native transcript's tool calls and results) are kept
1585
- // inline too, in chronological order, so a restored conversation can show
1586
- // what the agent actually did — not just the prose around it.
1587
- const includeActivity = args?.includeActivity === true || args?.includeActivity === 'true';
1588
- const visibleMessages = includeActivity
1589
- ? filteredMessages.filter((m) => isUserFacingChatMessage(m) || isActivityChatMessage(m))
1590
- : filterUserFacingChatMessages(filteredMessages);
1591
- const sync = buildFullTail(visibleMessages, normalizeReadChatTailLimit(args));
1592
- const hiddenMsgCount = Math.max(0, messages.length - visibleMessages.length);
1593
- const preservedPayloadFields = Object.fromEntries(Object.entries(payload).filter(([key]) => shouldPreserveReadChatPayloadField(key)));
1594
- if (preservedPayloadFields.messageSource) {
1595
- preservedPayloadFields.messageSource = updateMessageSourceReturnedCount(preservedPayloadFields.messageSource, sync.messages.length);
1596
- }
1597
- if (preservedPayloadFields.transcriptProvenance) {
1598
- preservedPayloadFields.transcriptProvenance = updateMessageSourceReturnedCount(preservedPayloadFields.transcriptProvenance, sync.messages.length);
1599
- }
1600
- const returnedDebugReadChat = debugReadChat
1601
- ? {
1602
- ...debugReadChat,
1603
- fullMsgCount: typeof debugReadChat.fullMsgCount === 'number'
1604
- ? debugReadChat.fullMsgCount
1605
- : messages.length,
1606
- visibleMsgCount: visibleMessages.length,
1607
- hiddenMsgCount,
1608
- returnedMsgCount: sync.messages.length,
1609
- }
1610
- : undefined;
1611
- return {
1612
- success: true,
1613
- ...validatedPayload,
1614
- ...preservedPayloadFields,
1615
- messages: sync.messages,
1616
- totalMessages: sync.totalMessages,
1617
- ...(returnedDebugReadChat ? { debugReadChat: returnedDebugReadChat } : {}),
1618
- };
1619
- }
1620
-
1621
-
1622
- interface DebugSanitizeOptions {
1623
- maxDepth?: number;
1624
- maxArrayLength?: number;
1625
- maxObjectKeys?: number;
1626
- maxStringLength?: number;
1627
- }
1628
-
1629
- const DEFAULT_DEBUG_SANITIZE_OPTIONS: Required<DebugSanitizeOptions> = {
1630
- maxDepth: 8,
1631
- maxArrayLength: 80,
1632
- maxObjectKeys: 120,
1633
- maxStringLength: 16_000,
1634
- };
1635
-
1636
- const SECRET_KEY_PATTERN = /(?:token|secret|password|passwd|authorization|cookie|api[_-]?key|access[_-]?key|refresh[_-]?token|client[_-]?secret|private[_-]?key)/i;
1637
-
1638
- function truncateDebugString(value: string, maxLength: number): string {
1639
- if (value.length <= maxLength) return value;
1640
- return `${value.slice(0, maxLength)}…[truncated ${value.length - maxLength} chars]`;
1641
- }
1642
-
1643
- function redactDebugSecrets(value: string): string {
1644
- return value
1645
- .replace(/(Authorization\s*:\s*Bearer\s+)[^\s'"`]+/gi, '$1[REDACTED:bearer]')
1646
- .replace(/(Bearer\s+)[A-Za-z0-9._~+\/-]{16,}=*/gi, '$1[REDACTED:bearer]')
1647
- .replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/g, '[REDACTED:github-token]')
1648
- .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[REDACTED:api-key]')
1649
- .replace(/\bxox[baprs]-[A-Za-z0-9-]{12,}\b/g, '[REDACTED:slack-token]')
1650
- .replace(/\b(?:adk|adm)_[A-Za-z0-9_-]{16,}\b/g, '[REDACTED:adhdev-token]')
1651
- .replace(/((?:api[_-]?key|token|secret|password|passwd|client[_-]?secret)\s*[:=]\s*)[^\s,'"`}&]+/gi, '$1[REDACTED:secret]')
1652
- .replace(/([?&](?:api[_-]?key|token|secret|password|client_secret)=)[^&#\s]+/gi, '$1[REDACTED:secret]');
1653
- }
1654
-
1655
- export function sanitizeDebugBundleValue(
1656
- value: unknown,
1657
- options: DebugSanitizeOptions = {},
1658
- depth = 0,
1659
- keyHint = '',
1660
- ): unknown {
1661
- const normalizedOptions = { ...DEFAULT_DEBUG_SANITIZE_OPTIONS, ...options };
1662
- if (value === null || value === undefined) return value;
1663
- if (typeof value === 'number' || typeof value === 'boolean') return value;
1664
- if (typeof value === 'bigint') return String(value);
1665
- if (typeof value === 'string') {
1666
- if (SECRET_KEY_PATTERN.test(keyHint) && value.trim()) return '[REDACTED:secret-field]';
1667
- return truncateDebugString(redactDebugSecrets(value), normalizedOptions.maxStringLength);
1668
- }
1669
- if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`;
1670
- if (typeof value !== 'object') return String(value);
1671
- if (depth >= normalizedOptions.maxDepth) return '[MaxDepth]';
1672
-
1673
- if (Array.isArray(value)) {
1674
- const items = value
1675
- .slice(0, normalizedOptions.maxArrayLength)
1676
- .map((item) => sanitizeDebugBundleValue(item, normalizedOptions, depth + 1, keyHint));
1677
- if (value.length > normalizedOptions.maxArrayLength) {
1678
- items.push(`[truncated ${value.length - normalizedOptions.maxArrayLength} items]`);
1679
- }
1680
- return items;
1681
- }
1682
-
1683
- const record = value as Record<string, unknown>;
1684
- const result: Record<string, unknown> = {};
1685
- const entries = Object.entries(record).slice(0, normalizedOptions.maxObjectKeys);
1686
- for (const [key, item] of entries) {
1687
- result[key] = sanitizeDebugBundleValue(item, normalizedOptions, depth + 1, key);
1688
- }
1689
- const remaining = Object.keys(record).length - entries.length;
1690
- if (remaining > 0) result.__truncatedKeys = remaining;
1691
- return result;
1692
- }
1693
-
1694
- function summarizeProviderForDebug(provider: ProviderModule | undefined): Record<string, unknown> | null {
1695
- if (!provider) return null;
1696
- const scripts = provider.scripts && typeof provider.scripts === 'object'
1697
- ? Object.keys(provider.scripts)
1698
- : [];
1699
- const controls = Array.isArray((provider as any).controls)
1700
- ? (provider as any).controls.map((control: any) => ({
1701
- id: control?.id,
1702
- label: control?.label,
1703
- type: control?.type,
1704
- settingKey: control?.settingKey,
1705
- invokeScript: control?.invokeScript,
1706
- listScript: control?.listScript,
1707
- location: control?.location,
1708
- }))
1709
- : [];
1710
- return {
1711
- type: provider.type,
1712
- name: provider.name,
1713
- category: provider.category,
1714
- version: (provider as any).version,
1715
- canonicalHistory: provider.nativeHistory,
1716
- historyBehavior: provider.historyBehavior,
1717
- webviewMatchText: provider.webviewMatchText,
1718
- scriptNames: scripts,
1719
- controls,
1720
- resume: provider.resume,
1721
- };
1722
- }
1723
-
1724
- function summarizeSessionForDebug(session: any): Record<string, unknown> | null {
1725
- if (!session || typeof session !== 'object') return null;
1726
- return {
1727
- sessionId: session.sessionId,
1728
- instanceKey: session.instanceKey,
1729
- adapterKey: session.adapterKey,
1730
- providerType: session.providerType,
1731
- providerName: session.providerName,
1732
- transport: session.transport,
1733
- kind: session.kind,
1734
- cdpManagerKey: session.cdpManagerKey,
1735
- parentSessionId: session.parentSessionId,
1736
- providerSessionId: session.providerSessionId,
1737
- workspace: session.workspace,
1738
- title: session.title,
1739
- status: session.status,
1740
- mode: session.mode,
1741
- capabilities: session.capabilities,
1742
- };
1743
- }
1744
-
1745
- function summarizeStateForDebug(state: any): Record<string, unknown> | null {
1746
- if (!state || typeof state !== 'object') return null;
1747
- const activeChat = state.activeChat && typeof state.activeChat === 'object' ? state.activeChat : null;
1748
- return {
1749
- type: state.type,
1750
- name: state.name,
1751
- category: state.category,
1752
- status: state.status,
1753
- instanceId: state.instanceId,
1754
- providerSessionId: state.providerSessionId,
1755
- title: state.title,
1756
- transport: state.transport,
1757
- mode: state.mode,
1758
- workspace: state.workspace,
1759
- runtime: state.runtime,
1760
- errorMessage: state.errorMessage,
1761
- errorReason: state.errorReason,
1762
- activeChat: activeChat ? {
1763
- status: activeChat.status,
1764
- title: activeChat.title,
1765
- messageCount: Array.isArray(activeChat.messages) ? activeChat.messages.length : undefined,
1766
- activeModal: activeChat.activeModal,
1767
- activeInteractivePrompt: activeChat.activeInteractivePrompt ?? null,
1768
- messagesTail: Array.isArray(activeChat.messages) ? activeChat.messages.slice(-10) : undefined,
1769
- } : null,
1770
- activeInteractivePrompt: (state as { activeInteractivePrompt?: unknown }).activeInteractivePrompt ?? null,
1771
- controlValues: state.controlValues,
1772
- summaryMetadata: state.summaryMetadata,
1773
- };
1774
- }
1775
-
1776
- function buildDebugBundleText(bundle: Record<string, unknown>): string {
1777
- return [
1778
- '# ADHDev Chat Debug Bundle',
1779
- '',
1780
- '```json',
1781
- JSON.stringify(bundle, null, 2),
1782
- '```',
1783
- ].join('\n');
1784
- }
1785
-
1786
- function getChatDebugBundleDir(): string {
1787
- const override = typeof process.env.ADHDEV_DEBUG_BUNDLE_DIR === 'string'
1788
- ? process.env.ADHDEV_DEBUG_BUNDLE_DIR.trim()
1789
- : '';
1790
- return override || path.join(os.homedir(), '.adhdev', 'debug-bundles', 'chat');
1791
- }
1792
-
1793
- function safeBundleIdSegment(value: unknown, fallback: string): string {
1794
- const normalized = String(value || fallback)
1795
- .trim()
1796
- .replace(/[^A-Za-z0-9_.-]+/g, '-')
1797
- .replace(/^-+|-+$/g, '')
1798
- .slice(0, 80);
1799
- return normalized || fallback;
1800
- }
1801
-
1802
- function createChatDebugBundleId(targetSessionId: string): string {
1803
- const timestamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', 'T').replace('Z', 'Z');
1804
- const sessionSegment = safeBundleIdSegment(targetSessionId, 'unknown-session');
1805
- return `chat-debug-${timestamp}-${sessionSegment}-${randomUUID().slice(0, 8)}`;
1806
- }
1807
-
1808
- function buildChatDebugBundleSummary(bundle: Record<string, unknown>): Record<string, unknown> {
1809
- const target = bundle.target && typeof bundle.target === 'object' ? bundle.target as Record<string, unknown> : {};
1810
- const readChat = bundle.readChat && typeof bundle.readChat === 'object' ? bundle.readChat as Record<string, unknown> : {};
1811
- const cli = bundle.cli && typeof bundle.cli === 'object' ? bundle.cli as Record<string, unknown> : null;
1812
- const frontend = bundle.frontend && typeof bundle.frontend === 'object' ? bundle.frontend as Record<string, unknown> : null;
1813
- const debugReadChat = readChat.debugReadChat && typeof readChat.debugReadChat === 'object'
1814
- ? readChat.debugReadChat as Record<string, unknown>
1815
- : {};
1816
- const parsedStatus = cli?.parsedStatus && typeof cli.parsedStatus === 'object'
1817
- ? cli.parsedStatus as Record<string, unknown>
1818
- : null;
1819
- const cliParsedMessageCount = Array.isArray(parsedStatus?.messages) ? parsedStatus.messages.length : undefined;
1820
- const readChatReturnedMessages = Array.isArray(readChat.messagesTail) ? readChat.messagesTail.length : undefined;
1821
- const cliPartialResponse = typeof cli?.partialResponse === 'string' ? cli.partialResponse : '';
1822
- const readChatStatus = typeof readChat.status === 'string' ? readChat.status : '';
1823
- const cliStatus = typeof cli?.status === 'string' ? cli.status : '';
1824
- const cliParsedStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status : '';
1825
- return {
1826
- createdAt: bundle.createdAt,
1827
- targetSessionId: target.targetSessionId,
1828
- providerType: target.providerType,
1829
- transport: target.transport,
1830
- readChatSuccess: readChat.success,
1831
- readChatStatus: readChat.status,
1832
- readChatTotalMessages: readChat.totalMessages,
1833
- readChatReturnedMessages,
1834
- cliStatus: cli?.status,
1835
- cliParsedStatus: cliParsedStatus || undefined,
1836
- cliMessageCount: cli?.messageCount,
1837
- cliParsedMessageCount,
1838
- cliPartialResponseChars: cliPartialResponse.length,
1839
- parserAdapterStatusMismatch: Boolean(cliStatus && cliParsedStatus && cliStatus !== cliParsedStatus),
1840
- parserReadChatStatusMismatch: Boolean(readChatStatus && cliParsedStatus && readChatStatus !== cliParsedStatus),
1841
- readChatDebug: Object.keys(debugReadChat).length ? {
1842
- adapterStatus: debugReadChat.adapterStatus,
1843
- parsedStatus: debugReadChat.parsedStatus,
1844
- returnedStatus: debugReadChat.returnedStatus,
1845
- selectedMessageSource: debugReadChat.selectedMessageSource,
1846
- messageSource: debugReadChat.messageSource,
1847
- parsedMsgCount: debugReadChat.parsedMsgCount,
1848
- returnedMsgCount: debugReadChat.returnedMsgCount,
1849
- shouldPreferAdapterMessages: debugReadChat.shouldPreferAdapterMessages,
1850
- } : undefined,
1851
- hasFrontendSnapshot: !!frontend,
1852
- };
1853
- }
1854
-
1855
- function storeChatDebugBundleOnDaemon(bundle: Record<string, unknown>, targetSessionId: string): { bundleId: string; savedPath: string; sizeBytes: number } {
1856
- const bundleId = createChatDebugBundleId(targetSessionId);
1857
- const dir = getChatDebugBundleDir();
1858
- fs.mkdirSync(dir, { recursive: true });
1859
- const savedPath = path.join(dir, `${bundleId}.json`);
1860
- const json = `${JSON.stringify(bundle, null, 2)}\n`;
1861
- fs.writeFileSync(savedPath, json, { encoding: 'utf8', mode: 0o600 });
1862
- return { bundleId, savedPath, sizeBytes: Buffer.byteLength(json, 'utf8') };
1863
- }
1864
-
1865
- function isDaemonFileDebugDelivery(args: any): boolean {
1866
- return args?.delivery === 'daemon_file' || args?.delivery === 'file';
1867
- }
1868
-
1869
- export async function handleGetChatDebugBundle(h: CommandHelpers, args: any): Promise<CommandResult> {
1870
- const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
1871
- if (!targetSessionId && !h.currentSession) {
1872
- return { success: false, error: 'No targetSessionId specified — cannot route command' };
1873
- }
1874
-
1875
- const provider = h.getProvider(args?.agentType);
1876
- const transport = getTargetTransport(h, provider);
1877
- const providerType = provider?.type || getCurrentProviderType(h, args?.agentType || '');
1878
- const adapter = isCliLikeTransport(transport) ? getTargetedCliAdapter(h, args, provider?.type) : null;
1879
- const targetInstance = getTargetInstance(h, args);
1880
-
1881
- let adapterStatus: unknown = null;
1882
- let parsedStatus: unknown = null;
1883
- let adapterDebugSnapshot: unknown = null;
1884
- let partialResponse = '';
1885
- if (adapter) {
1886
- try { adapterStatus = adapter.getStatus?.(); } catch (error: any) { adapterStatus = { error: error?.message || String(error) }; }
1887
- try { parsedStatus = typeof adapter.getScriptParsedStatus === 'function' ? parseMaybeJson(adapter.getScriptParsedStatus()) : null; } catch (error: any) { parsedStatus = { error: error?.message || String(error) }; }
1888
- try { adapterDebugSnapshot = typeof adapter.getDebugSnapshot === 'function' ? adapter.getDebugSnapshot() : null; } catch (error: any) { adapterDebugSnapshot = { error: error?.message || String(error) }; }
1889
- try { partialResponse = adapter.getPartialResponse?.() || ''; } catch { partialResponse = ''; }
1890
- }
1891
-
1892
- let instanceState: unknown = null;
1893
- if (targetInstance?.getState) {
1894
- try { instanceState = summarizeStateForDebug(targetInstance.getState()); } catch (error: any) { instanceState = { error: error?.message || String(error) }; }
1895
- }
1896
-
1897
- let readChat: unknown = null;
1898
- try {
1899
- const readResult = await handleReadChat(h, { ...args, tailLimit: Math.max(1, Math.min(40, Number(args?.tailLimit || 40))) });
1900
- readChat = readResult.success
1901
- ? {
1902
- success: true,
1903
- status: readResult.status,
1904
- title: readResult.title,
1905
- totalMessages: readResult.totalMessages,
1906
- providerSessionId: readResult.providerSessionId,
1907
- transcriptAuthority: readResult.transcriptAuthority,
1908
- coverage: readResult.coverage,
1909
- messageSource: readResult.messageSource,
1910
- transcriptProvenance: readResult.transcriptProvenance,
1911
- activeModal: readResult.activeModal,
1912
- messagesTail: Array.isArray(readResult.messages) ? readResult.messages.slice(-20) : [],
1913
- debugReadChat: readResult.debugReadChat,
1914
- }
1915
- : {
1916
- success: false,
1917
- error: readResult.error,
1918
- code: readResult.code,
1919
- messageSource: readResult.messageSource,
1920
- transcriptProvenance: readResult.transcriptProvenance,
1921
- debugReadChat: readResult.debugReadChat,
1922
- };
1923
- } catch (error: any) {
1924
- readChat = { success: false, error: error?.message || String(error) };
1925
- }
1926
-
1927
- const cdp = h.getCdp();
1928
- const rawBundle: Record<string, unknown> = {
1929
- version: 1,
1930
- createdAt: new Date().toISOString(),
1931
- target: {
1932
- targetSessionId,
1933
- providerType,
1934
- transport,
1935
- routeManagerKey: h.currentManagerKey,
1936
- currentIdeType: h.currentIdeType,
1937
- },
1938
- session: summarizeSessionForDebug(h.currentSession),
1939
- provider: summarizeProviderForDebug(provider),
1940
- daemon: {
1941
- pid: process.pid,
1942
- platform: process.platform,
1943
- nodeVersion: process.version,
1944
- cwd: process.cwd(),
1945
- },
1946
- cdp: {
1947
- requested: !!cdp,
1948
- connected: !!cdp?.isConnected,
1949
- managerKey: getCurrentManagerKey(h),
1950
- },
1951
- instanceState,
1952
- cli: adapter ? {
1953
- cliType: adapter.cliType,
1954
- cliName: adapter.cliName,
1955
- workingDir: adapter.workingDir,
1956
- status: (adapterStatus as any)?.status,
1957
- activeModal: (adapterStatus as any)?.activeModal,
1958
- messageCount: Array.isArray((adapterStatus as any)?.messages) ? (adapterStatus as any).messages.length : undefined,
1959
- messagesTail: Array.isArray((adapterStatus as any)?.messages) ? (adapterStatus as any).messages.slice(-20) : undefined,
1960
- parsedStatus,
1961
- partialResponse,
1962
- ready: typeof adapter.isReady === 'function' ? adapter.isReady() : undefined,
1963
- processing: typeof adapter.isProcessing === 'function' ? adapter.isProcessing() : undefined,
1964
- debugSnapshot: adapterDebugSnapshot,
1965
- scriptInvocationTrace: typeof (adapter as any).getScriptInvocationTrace === 'function'
1966
- ? (adapter as any).getScriptInvocationTrace()
1967
- : undefined,
1968
- } : null,
1969
- readChat,
1970
- frontend: args?.frontendSnapshot && typeof args.frontendSnapshot === 'object' ? args.frontendSnapshot : null,
1971
- recentLogs: getRecentLogs(80, 'debug'),
1972
- recentDebugTrace: getRecentDebugTrace({ limit: 120 }),
1973
- };
1974
-
1975
- const bundle = sanitizeDebugBundleValue(rawBundle) as Record<string, unknown>;
1976
- if (isDaemonFileDebugDelivery(args)) {
1977
- const summary = buildChatDebugBundleSummary(bundle);
1978
- const stored = storeChatDebugBundleOnDaemon(bundle, targetSessionId || String(summary.targetSessionId || 'unknown-session'));
1979
- LOG.info('Command', `[get_chat_debug_bundle] saved daemon_file bundle id=${stored.bundleId} path=${stored.savedPath} sizeBytes=${stored.sizeBytes} targetSessionId=${summary.targetSessionId || ''} providerType=${summary.providerType || ''} transport=${summary.transport || ''}`);
1980
- return {
1981
- success: true,
1982
- delivery: 'daemon_file',
1983
- bundleId: stored.bundleId,
1984
- savedPath: stored.savedPath,
1985
- sizeBytes: stored.sizeBytes,
1986
- createdAt: bundle.createdAt,
1987
- summary,
1988
- };
1989
- }
1990
- return {
1991
- success: true,
1992
- bundle,
1993
- text: buildDebugBundleText(bundle),
1994
- };
1995
- }
1996
-
1997
- function didProviderConfirmSend(result: any): boolean {
1998
- const parsed = parseMaybeJson(result);
1999
- if (parsed === true) return true;
2000
- if (typeof parsed === 'string') {
2001
- const normalized = parsed.trim().toLowerCase();
2002
- return normalized === 'ok' || normalized === 'sent' || normalized === 'success' || normalized === 'true';
2003
- }
2004
- if (!parsed || typeof parsed !== 'object') return false;
2005
-
2006
- return parsed.sent === true
2007
- || parsed.success === true
2008
- || parsed.ok === true
2009
- || parsed.submitted === true
2010
- || parsed.dispatched === true;
2011
- }
2012
-
2013
- async function readExtensionChatState(h: CommandHelpers): Promise<any | null> {
2014
- try {
2015
- const evalResult = await h.evaluateProviderScript('readChat', undefined, READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS);
2016
- if (!evalResult?.result) return null;
2017
- const parsed = parseMaybeJson(evalResult.result);
2018
- return parsed && typeof parsed === 'object' ? parsed : null;
2019
- } catch {
2020
- return null;
2021
- }
2022
- }
2023
-
2024
- function getStateMessageCount(state: any): number {
2025
- return Array.isArray(state?.messages) ? state.messages.length : 0;
2026
- }
2027
-
2028
- function getStateLastSignature(state: any): string {
2029
- const messages = Array.isArray(state?.messages) ? state.messages : [];
2030
- const last = messages[messages.length - 1];
2031
- if (!last) return '';
2032
- return `${last.role || ''}:${String(last.content || '').replace(/\s+/g, ' ').trim()}`;
2033
- }
2034
-
2035
- function toNonNegativeNumber(value: any): number {
2036
- const numeric = Number(value ?? 0);
2037
- return Number.isFinite(numeric) ? Math.max(0, numeric) : 0;
2038
- }
2039
-
2040
- function getCliVisibleTranscriptCount(adapter: any): number {
2041
- if (typeof adapter?.getScriptParsedStatus !== 'function') return 0;
2042
- try {
2043
- const parsed = parseMaybeJson(adapter.getScriptParsedStatus());
2044
- return Array.isArray(parsed?.messages) ? parsed.messages.length : 0;
2045
- } catch {
2046
- return 0;
2047
- }
2048
- }
2049
-
2050
- async function getStableExtensionBaseline(h: CommandHelpers): Promise<any | null> {
2051
- const first = await readExtensionChatState(h);
2052
- if (getStateMessageCount(first) > 0 || getStateLastSignature(first)) return first;
2053
- await new Promise((resolve) => setTimeout(resolve, 150));
2054
- const second = await readExtensionChatState(h);
2055
- return getStateMessageCount(second) >= getStateMessageCount(first) ? second : first;
2056
- }
2057
-
2058
- async function verifyExtensionSendObserved(h: CommandHelpers, before: any): Promise<boolean> {
2059
- const beforeCount = getStateMessageCount(before);
2060
- const beforeSignature = getStateLastSignature(before);
2061
- for (let attempt = 0; attempt < 12; attempt += 1) {
2062
- await new Promise((resolve) => setTimeout(resolve, 250));
2063
- const state = await readExtensionChatState(h);
2064
- if (state?.status === 'waiting_approval') return true;
2065
- const afterCount = getStateMessageCount(state);
2066
- const afterSignature = getStateLastSignature(state);
2067
- if (afterCount > beforeCount) return true;
2068
- if (afterSignature && afterSignature !== beforeSignature) return true;
2069
- }
2070
- return false;
2071
- }
2072
-
2073
- export async function handleChatHistory(h: CommandHelpers, args: any): Promise<CommandResult> {
2074
- const { agentType, offset, limit } = args;
2075
- const historySessionId = getHistorySessionId(h, args);
2076
- try {
2077
- const provider = h.getProvider(agentType);
2078
- const agentStr = provider?.type || agentType || getCurrentProviderType(h);
2079
- const transport = getTargetTransport(h, provider);
2080
- const hasExplicitExcludeRecentCount = args?.excludeRecentCount !== undefined && args?.excludeRecentCount !== null;
2081
- let excludeRecentCount = toNonNegativeNumber(args?.excludeRecentCount);
2082
- if (!hasExplicitExcludeRecentCount && isCliLikeTransport(transport)) {
2083
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
2084
- const visibleCount = getCliVisibleTranscriptCount(adapter);
2085
- if (visibleCount > excludeRecentCount) excludeRecentCount = visibleCount;
2086
- }
2087
- const workspace = typeof args?.workspace === 'string'
2088
- ? args.workspace
2089
- : typeof (h.currentSession as any)?.workspace === 'string'
2090
- ? (h.currentSession as any).workspace
2091
- : undefined;
2092
- const exactNativeHistoryScope = Boolean(
2093
- (typeof args?.targetSessionId === 'string' && args.targetSessionId.trim())
2094
- || (typeof args?.historySessionId === 'string' && args.historySessionId.trim())
2095
- || (typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
2096
- );
2097
- const result = supportsCliNativeTranscript(agentStr, provider) && isNativeSourceCanonicalHistory(provider?.nativeHistory)
2098
- ? readCliProviderNativeHistory(agentStr, {
2099
- canonicalHistory: provider?.nativeHistory,
2100
- historySessionId,
2101
- workspace,
2102
- offset: offset || 0,
2103
- limit: limit || 30,
2104
- excludeRecentCount,
2105
- historyBehavior: provider?.historyBehavior,
2106
- scripts: provider?.scripts as any,
2107
- sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
2108
- envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
2109
- })
2110
- : readProviderChatHistory(agentStr, {
2111
- canonicalHistory: provider?.nativeHistory,
2112
- historySessionId,
2113
- workspace,
2114
- offset: offset || 0,
2115
- limit: limit || 30,
2116
- excludeRecentCount,
2117
- historyBehavior: provider?.historyBehavior,
2118
- scripts: provider?.scripts as any,
2119
- });
2120
- if (supportsCliNativeTranscript(agentStr, provider) && isNativeSourceCanonicalHistory(provider?.nativeHistory)) {
2121
- const lookup = (result as any).lookup === 'workspace' ? 'workspace' : 'session';
2122
- const messages = Array.isArray((result as any).messages)
2123
- ? normalizeAndFilterNativeHistory(h, agentStr, args, (result as any).messages as ChatMessage[], (result as any)?.providerSessionId)
2124
- : [];
2125
- const historyProviderSessionId = typeof (result as any)?.providerSessionId === 'string'
2126
- ? (result as any).providerSessionId
2127
- : readHistorySessionIdFromMessages(messages) || historySessionId;
2128
- const safeMapping = hasSafeNativeHistoryMapping({
2129
- historySessionId: lookup === 'workspace' ? undefined : historySessionId,
2130
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
2131
- workspace,
2132
- nativeMessages: messages,
2133
- });
2134
- if ((result as any).source === 'provider-native' && messages.length > 0 && !safeMapping) {
2135
- return {
2136
- success: true,
2137
- messages: [],
2138
- hasMore: false,
2139
- source: 'native-unavailable',
2140
- agent: agentStr,
2141
- };
2142
- }
2143
- }
2144
- return { success: true, ...result, agent: agentStr };
2145
- } catch (e: any) {
2146
- return { success: false, error: e.message };
2147
- }
2148
- }
2149
-
2150
- export async function handleReadChat(h: CommandHelpers, args: any): Promise<CommandResult> {
2151
- // Node scope guard: a daemon hosting a base node + several worktree nodes must
2152
- // not serve worktree A's transcript (or splice sibling worktree turns via the
2153
- // native-history-by-workspace fallback) when a coordinator scoped the read to
2154
- // worktree B. mesh_read_chat always passes the requested node's workspace as
2155
- // args.workspace; refuse a CONFIRMED cross-workspace read rather than mix.
2156
- {
2157
- const guardSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
2158
- if (guardSessionId && typeof args?.workspace === 'string' && args.workspace.trim()) {
2159
- const verdict = evaluateReadChatNodeWorkspaceScope({
2160
- targetSessionId: guardSessionId,
2161
- intendedWorkspace: args.workspace,
2162
- sessionWorkspace: resolveTargetSessionActualWorkspace(h, guardSessionId),
2163
- });
2164
- if (verdict.scoped) {
2165
- LOG.info('Command', `[read_chat] node scope mismatch: session ${guardSessionId} workspace "${verdict.actual}" ≠ requested node workspace "${verdict.intended}" — refusing cross-worktree transcript`);
2166
- return {
2167
- success: false,
2168
- code: 'read_chat_session_node_scope_mismatch',
2169
- error: `Session ${guardSessionId} belongs to a different worktree (workspace "${verdict.actual}") than the requested node (workspace "${verdict.intended}"). Refusing to return a cross-worktree transcript — target the node that owns this session.`,
2170
- };
2171
- }
2172
- }
2173
- }
2174
- // Resolve provider in order: explicit agentType/providerType > registered session.
2175
- // Without this fallback, callers that only have a sessionId (e.g. a chat tail
2176
- // controller that just got handed a session ID over WS) get an empty result
2177
- // because getProvider(undefined) returns undefined and the rest of the pipeline
2178
- // bails. This makes the UI look like the session "disappeared".
2179
- let providerHint: string | undefined = args?.agentType || args?.providerType;
2180
- if (!providerHint) {
2181
- const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
2182
- if (targetSessionId) {
2183
- const session = (h.ctx as any)?.sessionRegistry?.get?.(targetSessionId);
2184
- if (session && typeof session.providerType === 'string') {
2185
- providerHint = session.providerType;
2186
- }
2187
- }
2188
- if (!providerHint && h.currentSession?.providerType) {
2189
- providerHint = h.currentSession.providerType;
2190
- }
2191
- }
2192
- const provider = h.getProvider(providerHint);
2193
- const transport = getTargetTransport(h, provider);
2194
- const historySessionId = getHistorySessionId(h, args);
2195
-
2196
- const _log = (msg: string) => LOG.debug('Command', `[read_chat] ${msg}`);
2197
-
2198
- // PTY / ACP transport: read from adapter
2199
- if (isCliLikeTransport(transport)) {
2200
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
2201
- if (adapter) {
2202
- _log(`${transport} adapter: ${adapter.cliType}`);
2203
- if (typeof adapter.getScriptParsedStatus !== 'function') {
2204
- return { success: false, error: `${transport} adapter parseSession unavailable` };
2205
- }
2206
- let parsedStatus: any = null;
2207
- try {
2208
- parsedStatus = parseMaybeJson(adapter.getScriptParsedStatus());
2209
- } catch (error: any) {
2210
- return { success: false, error: error?.message || String(error) };
2211
- }
2212
- const parsedRecord = parsedStatus && typeof parsedStatus === 'object'
2213
- ? parsedStatus as Record<string, any>
2214
- : null;
2215
- if (!parsedRecord || !Array.isArray(parsedRecord.messages)) {
2216
- return { success: false, error: `${transport} parser did not return messages` };
2217
- }
2218
- const adapterStatus = typeof adapter.getStatus === 'function'
2219
- ? adapter.getStatus()
2220
- : {};
2221
- const title = typeof parsedRecord.title === 'string' ? parsedRecord.title : undefined;
2222
- const providerSessionId = typeof parsedRecord.providerSessionId === 'string'
2223
- ? parsedRecord.providerSessionId
2224
- : undefined;
2225
- const transcriptAuthority = parsedRecord.transcriptAuthority === 'provider' || parsedRecord.transcriptAuthority === 'daemon'
2226
- ? parsedRecord.transcriptAuthority
2227
- : undefined;
2228
- const coverage = parsedRecord.coverage === 'full' || parsedRecord.coverage === 'tail' || parsedRecord.coverage === 'current-turn'
2229
- ? parsedRecord.coverage
2230
- : undefined;
2231
- const activeModal = parsedRecord.activeModal ?? parsedRecord.modal ?? null;
2232
- const returnedStatus = normalizeCliReadChatStatus(parsedRecord.status, activeModal, adapter, adapterStatus, parsedRecord.messages);
2233
- const runtimeMessageMerger = getTargetInstance(h, args) as RuntimeChatMessageMerger | null;
2234
- const parsedMessages = collapseAdjacentDuplicateChatMessages(
2235
- finalizeStreamingMessagesWhenIdle(parsedRecord.messages as ChatMessage[], returnedStatus),
2236
- );
2237
- const returnedMessages = runtimeMessageMerger?.category === 'cli'
2238
- && runtimeMessageMerger.type === adapter.cliType
2239
- && typeof runtimeMessageMerger.mergeRuntimeChatMessages === 'function'
2240
- ? runtimeMessageMerger.mergeRuntimeChatMessages(parsedMessages)
2241
- : parsedMessages;
2242
- const providerType = provider?.type || adapter.cliType;
2243
- let selectedMessages = returnedMessages;
2244
- let selectedTitle = title;
2245
- let selectedProviderSessionId = providerSessionId;
2246
- let selectedTranscriptAuthority = transcriptAuthority;
2247
- let selectedCoverage = coverage;
2248
- let selectedStatus = returnedStatus;
2249
- const _targetSidForWs = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
2250
- const _registryWs = _targetSidForWs
2251
- ? (h.ctx?.sessionRegistry?.get?.(_targetSidForWs) as any)?.workspace
2252
- : undefined;
2253
- const _currentSessionWs = typeof (h.currentSession as any)?.workspace === 'string'
2254
- ? (h.currentSession as any).workspace
2255
- : typeof adapter.workingDir === 'string'
2256
- ? adapter.workingDir
2257
- : undefined;
2258
- const sessionWorkspace = _targetSidForWs
2259
- ? (typeof _registryWs === 'string' ? _registryWs : (typeof args?.workspace === 'string' ? args.workspace : undefined) ?? _currentSessionWs)
2260
- : _currentSessionWs;
2261
- const intendedWorkspace = typeof args?.workspace === 'string' ? args.workspace : undefined;
2262
- // ───────────────────────────────────────────────────────────
2263
- // Chat source decision via ChatSourceMachine (A2 big-bang).
2264
- // Replaces the ~300-line if-ladder that mixed source decision
2265
- // with native fetch, anchor mutation, and runtime mirror
2266
- // selection. The machine decides only between native-history
2267
- // and pty-parser; downstream selection of which message array
2268
- // to surface stays here.
2269
- //
2270
- // Behavioural changes vs v1:
2271
- // - No more nativeHistoryAnchoredAt mutation on the adapter.
2272
- // Lock state lives in CHAT_SOURCE_REGISTRY keyed by
2273
- // (providerType, sessionId).
2274
- // - No PTY-vs-native freshness comparison. The lock holds
2275
- // across arbitrary PTY arrival; only native regression /
2276
- // unavailability unlocks. This is the plipping fix.
2277
- // - 6 trigger strings (native_history_partial / _stale /
2278
- // _not_safely_mapped / _empty / _error / _unavailable)
2279
- // collapse to 3 events with diagnostic causes preserved
2280
- // and mapped back to legacy fallbackReason strings for
2281
- // response compatibility.
2282
- // - Codex live-workspace native probe and unsafe-native
2283
- // daemon mirror fallbacks are preserved as additional
2284
- // input rounds to the machine; they were never the source
2285
- // decision itself, they were retries.
2286
- // ───────────────────────────────────────────────────────────
2287
-
2288
- const supportsNative = supportsCliNativeTranscript(providerType, provider)
2289
- && isNativeSourceCanonicalHistory(provider?.nativeHistory);
2290
- const agentStr = provider?.type || args?.agentType || getCurrentProviderType(h, adapter.cliType);
2291
- const workspace = sessionWorkspace;
2292
- const nativeHistoryLimit = Math.max(
2293
- normalizeReadChatTailLimit(args) || 0,
2294
- returnedMessages.length,
2295
- HOT_TAIL_MIN_LIMIT,
2296
- );
2297
- const nativeHistorySessionId = supportsNative
2298
- ? resolveCliNativeHistorySessionId(args, historySessionId, providerSessionId)
2299
- : undefined;
2300
- const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
2301
- const skipLiveNativeHistoryWithoutProviderSession = shouldSkipLiveCliNativeHistoryWithoutProviderSession({
2302
- adapter,
2303
- providerType,
2304
- readChatArgs: args,
2305
- nativeHistorySessionId,
2306
- parsedProviderSessionId: providerSessionId,
2307
- });
2308
- const nativeHistoryReadSessionId = skipLiveNativeHistoryWithoutProviderSession
2309
- ? undefined
2310
- : nativeHistorySessionId;
2311
- const exactNativeHistoryScope = Boolean(
2312
- (typeof args?.historySessionId === 'string' && args.historySessionId.trim())
2313
- || (typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
2314
- || providerSessionId
2315
- || (nativeHistoryReadSessionId && nativeHistoryReadSessionId !== targetSessionId)
2316
- || ((h.currentSession as any)?.sessionId === args?.targetSessionId && typeof (h.currentSession as any)?.providerSessionId === 'string' && (h.currentSession as any).providerSessionId.trim())
2317
- );
2318
-
2319
- // 1. Fetch native history (or skip if provider does not support it).
2320
- let nativeHistory: any | null = null;
2321
- let nativeHistoryError: unknown | undefined;
2322
- if (supportsNative) {
2323
- try {
2324
- nativeHistory = readCliProviderNativeHistory(agentStr, {
2325
- canonicalHistory: provider?.nativeHistory,
2326
- historySessionId: nativeHistoryReadSessionId,
2327
- workspace,
2328
- offset: 0,
2329
- limit: nativeHistoryLimit,
2330
- excludeRecentCount: 0,
2331
- historyBehavior: provider?.historyBehavior,
2332
- scripts: provider?.scripts as any,
2333
- excludeInProgressTurn: returnedStatus === 'waiting_approval',
2334
- sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
2335
- envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
2336
- });
2337
- } catch (error: any) {
2338
- nativeHistoryError = error;
2339
- nativeHistory = null;
2340
- }
2341
- }
2342
-
2343
- // 2. Compute safeMapping with the same rules the v1 code used so the
2344
- // machine sees the same observation it always would have.
2345
- let nativeMessages: ChatMessage[] = nativeHistory && Array.isArray(nativeHistory.messages)
2346
- ? normalizeAndFilterNativeHistory(h, agentStr, args, nativeHistory.messages as ChatMessage[], nativeHistory.providerSessionId)
2347
- : [];
2348
- const sessionStartedAtMs = sessionStartedAtMsFromRegistry(h, args?.targetSessionId);
2349
- let historyProviderSessionId = typeof nativeHistory?.providerSessionId === 'string'
2350
- ? nativeHistory.providerSessionId
2351
- : readHistorySessionIdFromMessages(nativeMessages) || nativeHistoryReadSessionId || historySessionId;
2352
- let lookup = nativeHistory?.lookup === 'workspace' ? 'workspace' : 'session';
2353
- let nativeHistorySessionForMapping = adapter.cliType === 'antigravity-cli'
2354
- && historyProviderSessionId
2355
- && nativeHistoryReadSessionId
2356
- && historyProviderSessionId !== nativeHistoryReadSessionId
2357
- ? undefined
2358
- : nativeHistoryReadSessionId;
2359
- let safeMapping = supportsNative && nativeHistory
2360
- ? hasSafeNativeHistoryMapping({
2361
- historySessionId: lookup === 'workspace' ? undefined : nativeHistorySessionForMapping,
2362
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId || providerSessionId,
2363
- workspace,
2364
- nativeMessages,
2365
- ptyMessages: returnedMessages,
2366
- requireWorkspaceContentOverlap: lookup === 'workspace' && !exactNativeHistoryScope,
2367
- })
2368
- : false;
2369
- if (skipLiveNativeHistoryWithoutProviderSession && (!safeMapping || returnedMessages.length === 0)) {
2370
- nativeHistory = null;
2371
- nativeMessages = [];
2372
- historyProviderSessionId = undefined;
2373
- lookup = 'session';
2374
- safeMapping = false;
2375
- }
2376
- const mayRetryUnsafeAutoDetectedCodexSession = adapter.cliType === 'codex-cli'
2377
- && !getExplicitHistorySessionId(args)
2378
- && Boolean(sessionStartedAtMs && sessionStartedAtMs > 0)
2379
- && !skipLiveNativeHistoryWithoutProviderSession
2380
- && !safeMapping;
2381
- if (mayRetryUnsafeAutoDetectedCodexSession) {
2382
- try {
2383
- nativeHistory = readCliProviderNativeHistory(agentStr, {
2384
- canonicalHistory: provider?.nativeHistory,
2385
- historySessionId: undefined,
2386
- workspace,
2387
- offset: 0,
2388
- limit: nativeHistoryLimit,
2389
- excludeRecentCount: 0,
2390
- historyBehavior: provider?.historyBehavior,
2391
- scripts: provider?.scripts as any,
2392
- excludeInProgressTurn: returnedStatus === 'waiting_approval',
2393
- sessionStartedAtMs,
2394
- envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
2395
- });
2396
- nativeHistoryError = undefined;
2397
- nativeMessages = nativeHistory && Array.isArray(nativeHistory.messages)
2398
- ? normalizeAndFilterNativeHistory(h, agentStr, args, nativeHistory.messages as ChatMessage[], nativeHistory.providerSessionId)
2399
- : [];
2400
- historyProviderSessionId = typeof nativeHistory?.providerSessionId === 'string'
2401
- ? nativeHistory.providerSessionId
2402
- : readHistorySessionIdFromMessages(nativeMessages);
2403
- lookup = nativeHistory?.lookup === 'workspace' ? 'workspace' : 'session';
2404
- nativeHistorySessionForMapping = undefined;
2405
- safeMapping = supportsNative && nativeHistory
2406
- ? hasSafeNativeHistoryMapping({
2407
- historySessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
2408
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
2409
- workspace,
2410
- nativeMessages,
2411
- ptyMessages: returnedMessages,
2412
- requireWorkspaceContentOverlap: lookup === 'workspace' && !exactNativeHistoryScope,
2413
- })
2414
- : false;
2415
- } catch (error: any) {
2416
- nativeHistoryError = error;
2417
- nativeHistory = null;
2418
- nativeMessages = [];
2419
- historyProviderSessionId = undefined;
2420
- safeMapping = false;
2421
- }
2422
- }
2423
- const trustedExactNativeIdentity = lookup !== 'workspace'
2424
- && Boolean(nativeHistoryReadSessionId)
2425
- && Boolean(historyProviderSessionId)
2426
- && nativeHistoryReadSessionId === historyProviderSessionId;
2427
-
2428
- // 3. Drive ChatSourceMachine — one observation per readChat call,
2429
- // keyed by (providerType, sessionKey-for-this-call). targetSessionId
2430
- // is the most specific session anchor we have; fall back to
2431
- // historySessionId so we never leak state across distinct sessions.
2432
- const machineSessionKey = String(
2433
- args?.targetSessionId
2434
- || providerSessionId
2435
- || historySessionId
2436
- || (h.currentSession as any)?.sessionId
2437
- || ''
2438
- );
2439
- const primary = decideCliReadChatSource({
2440
- providerType,
2441
- provider,
2442
- sessionId: machineSessionKey,
2443
- nativeHistoryResult: nativeHistory,
2444
- nativeHistoryError,
2445
- safeMapping,
2446
- trustedExactNativeIdentity,
2447
- sessionWorkspace,
2448
- intendedWorkspace,
2449
- ptyMessages: returnedMessages,
2450
- // Start with PTY visible; decideCliReadChatSource flips this
2451
- // to true when the machine actually selects native-history.
2452
- ptyStatusApprovalOnly: false,
2453
- });
2454
- let messageSource: Record<string, unknown> = primary.messageSource;
2455
-
2456
- if (primary.nativeSelected) {
2457
- selectedMessages = finalizeStreamingMessagesWhenIdle(primary.nativeMessages, returnedStatus);
2458
- selectedProviderSessionId = historyProviderSessionId || providerSessionId;
2459
- selectedTranscriptAuthority = 'provider';
2460
- selectedCoverage = nativeHistory?.hasMore ? 'tail' : 'full';
2461
- if (selectedProviderSessionId && selectedProviderSessionId !== providerSessionId) {
2462
- adapter.updateRuntimeMeta?.({ providerSessionId: selectedProviderSessionId });
2463
- }
2464
- } else if (supportsNative) {
2465
- // Native not selected. Two preserved v1 fallbacks before settling
2466
- // on PTY: (a) Codex-only live workspace native probe; (b) unsafe-
2467
- // native daemon mirror selection. The machine sees each retry as
2468
- // an additional observation.
2469
- const liveCurrentRuntimePtySafe = isCurrentRuntimePtySafelyAttributed({
2470
- adapter,
2471
- helpers: h,
2472
- readChatArgs: args,
2473
- sessionWorkspace,
2474
- intendedWorkspace,
2475
- ptyMessages: returnedMessages,
2476
- });
2477
- const mayProbeLiveCodexWorkspaceNative = adapter.cliType === 'codex-cli'
2478
- && liveCurrentRuntimePtySafe
2479
- && !(typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
2480
- && !(providerSessionId && providerSessionId.trim())
2481
- && !nativeHistoryReadSessionId
2482
- && (!historyProviderSessionId || historyProviderSessionId === nativeHistoryReadSessionId || historyProviderSessionId === historySessionId)
2483
- && !skipLiveNativeHistoryWithoutProviderSession;
2484
- const liveWorkspaceNativeHistory = mayProbeLiveCodexWorkspaceNative
2485
- ? readLiveCodexWorkspaceNativeHistory(agentStr, {
2486
- canonicalHistory: provider?.nativeHistory,
2487
- workspace,
2488
- offset: 0,
2489
- limit: nativeHistoryLimit,
2490
- excludeRecentCount: 0,
2491
- historyBehavior: provider?.historyBehavior,
2492
- scripts: provider?.scripts as any,
2493
- })
2494
- : null;
2495
- const liveWorkspaceNativeMessages = Array.isArray((liveWorkspaceNativeHistory as any)?.messages)
2496
- ? normalizeAndFilterNativeHistory(h, agentStr, args, (liveWorkspaceNativeHistory as any).messages as ChatMessage[], (liveWorkspaceNativeHistory as any)?.providerSessionId)
2497
- : [];
2498
- const liveWorkspaceNativeProviderSessionId = typeof (liveWorkspaceNativeHistory as any)?.providerSessionId === 'string'
2499
- ? (liveWorkspaceNativeHistory as any).providerSessionId
2500
- : readHistorySessionIdFromMessages(liveWorkspaceNativeMessages);
2501
- const liveWorkspaceNativeSafeMapping = liveWorkspaceNativeMessages.length > 0
2502
- && hasSafeNativeHistoryMapping({
2503
- workspace,
2504
- nativeMessages: liveWorkspaceNativeMessages,
2505
- ptyMessages: returnedMessages,
2506
- requireWorkspaceContentOverlap: true,
2507
- });
2508
- if (liveWorkspaceNativeHistory) {
2509
- const liveDecision = decideCliReadChatSource({
2510
- providerType,
2511
- provider,
2512
- // Distinct session key so a transient codex live-probe does not
2513
- // clobber the primary session's lock. The machine treats this
2514
- // as its own session; the primary session's state is untouched.
2515
- sessionId: `${machineSessionKey}::live-workspace`,
2516
- nativeHistoryResult: liveWorkspaceNativeHistory,
2517
- safeMapping: liveWorkspaceNativeSafeMapping,
2518
- sessionWorkspace,
2519
- intendedWorkspace,
2520
- ptyMessages: returnedMessages,
2521
- ptyStatusApprovalOnly: true,
2522
- });
2523
- if (liveDecision.nativeSelected) {
2524
- selectedMessages = finalizeStreamingMessagesWhenIdle(liveDecision.nativeMessages, returnedStatus);
2525
- selectedProviderSessionId = liveWorkspaceNativeProviderSessionId || providerSessionId;
2526
- selectedTranscriptAuthority = 'provider';
2527
- selectedCoverage = (liveWorkspaceNativeHistory as any).hasMore ? 'tail' : 'full';
2528
- if (selectedProviderSessionId && selectedProviderSessionId !== providerSessionId) {
2529
- adapter.updateRuntimeMeta?.({ providerSessionId: selectedProviderSessionId });
2530
- }
2531
- messageSource = liveDecision.messageSource;
2532
- (messageSource as any).selectedDaemonSource = 'live-workspace-native-history';
2533
- (messageSource as any).runtimeMappingSafe = true;
2534
- } else {
2535
- // Live probe also rejected: apply unsafe-native daemon mirror
2536
- // selection (codex-only) using the primary decision's
2537
- // fallbackReason.
2538
- applyUnsafeNativeDaemonFallback({
2539
- providerType,
2540
- adapter,
2541
- helpers: h,
2542
- readChatArgs: args,
2543
- sessionWorkspace,
2544
- intendedWorkspace,
2545
- ptyMessages: returnedMessages,
2546
- nativeHistoryLimit,
2547
- provider,
2548
- messageSourceRef: { set(value) { messageSource = value; }, get() { return messageSource; } },
2549
- apply(selection) {
2550
- selectedMessages = selection.messages;
2551
- selectedTranscriptAuthority = selection.transcriptAuthority;
2552
- selectedCoverage = selection.coverage ?? coverage;
2553
- selectedStatus = selection.status ?? returnedStatus;
2554
- },
2555
- activeModal,
2556
- returnedStatus,
2557
- coverage,
2558
- });
2559
- }
2560
- } else {
2561
- applyUnsafeNativeDaemonFallback({
2562
- providerType,
2563
- adapter,
2564
- helpers: h,
2565
- readChatArgs: args,
2566
- sessionWorkspace,
2567
- intendedWorkspace,
2568
- ptyMessages: returnedMessages,
2569
- nativeHistoryLimit,
2570
- provider,
2571
- messageSourceRef: { set(value) { messageSource = value; }, get() { return messageSource; } },
2572
- apply(selection) {
2573
- selectedMessages = selection.messages;
2574
- selectedTranscriptAuthority = selection.transcriptAuthority;
2575
- selectedCoverage = selection.coverage ?? coverage;
2576
- selectedStatus = selection.status ?? returnedStatus;
2577
- },
2578
- activeModal,
2579
- returnedStatus,
2580
- coverage,
2581
- });
2582
- }
2583
- }
2584
- if (
2585
- isGeneratingLikeStatus(selectedStatus)
2586
- && selectedTranscriptAuthority === 'provider'
2587
- && !hasNonEmptyModalButtons(activeModal)
2588
- && hasFinalVisibleAssistantMessage(selectedMessages)
2589
- ) {
2590
- selectedStatus = 'idle';
2591
- selectedMessages = finalizeStreamingMessagesWhenIdle(selectedMessages, selectedStatus);
2592
- messageSource = {
2593
- ...messageSource,
2594
- statusReconciled: {
2595
- from: returnedStatus,
2596
- to: 'idle',
2597
- reason: 'provider_native_final_assistant',
2598
- },
2599
- };
2600
- }
2601
- LOG.debug('Command', `[read_chat] cli-like parsed provider=${adapter.cliType} target=${String(args?.targetSessionId || '')} adapterStatus=${String(adapterStatus.status || '')} parsedStatus=${String(parsedRecord.status || '')} parsedMsgCount=${parsedRecord.messages.length} returnedMsgCount=${returnedMessages.length}`);
2602
- return buildReadChatCommandResult({
2603
- messages: selectedMessages,
2604
- status: selectedStatus,
2605
- activeModal,
2606
- messageSource,
2607
- transcriptProvenance: messageSource,
2608
- debugReadChat: {
2609
- provider: adapter.cliType,
2610
- targetSessionId: String(args?.targetSessionId || ''),
2611
- adapterStatus: String(adapterStatus.status || ''),
2612
- parsedStatus: String(parsedRecord.status || ''),
2613
- returnedStatus: String(selectedStatus || ''),
2614
- selectedMessageSource: (messageSource as any).selected,
2615
- messageSource,
2616
- shouldPreferAdapterMessages: supportsCliNativeTranscript(providerType, provider)
2617
- && isNativeSourceCanonicalHistory(provider?.nativeHistory)
2618
- && (messageSource as any).selected !== 'native-history'
2619
- && typeof (messageSource as any).fallbackReason === 'string'
2620
- && (messageSource as any).fallbackReason.startsWith('native_history_')
2621
- && (messageSource as any).fallbackReason !== 'native_history_not_checked'
2622
- && !isUnsafeNativeTranscriptFallback((messageSource as any).fallbackReason)
2623
- && !(selectedTranscriptAuthority === 'provider' && selectedCoverage === 'full'),
2624
- parsedMsgCount: parsedRecord.messages.length,
2625
- returnedMsgCount: selectedMessages.length,
2626
- },
2627
- ...(selectedTitle ? { title: selectedTitle } : {}),
2628
- ...(selectedProviderSessionId ? { providerSessionId: selectedProviderSessionId } : {}),
2629
- ...(selectedTranscriptAuthority ? { transcriptAuthority: selectedTranscriptAuthority } : {}),
2630
- ...(selectedCoverage ? { coverage: selectedCoverage } : {}),
2631
- }, args, h);
2632
- }
2633
- // History-only path (no adapter). Same source-decision contract as
2634
- // the adapter path above, but with no PTY messages — the machine
2635
- // simply decides whether native is usable; if not we return the
2636
- // history we have plus a `native_history_not_safely_available`
2637
- // error response when the provider requires native source.
2638
- const historyLimit = normalizeReadChatTailLimit(args);
2639
- try {
2640
- const agentStr = provider?.type || args?.agentType || getCurrentProviderType(h);
2641
- const targetSid = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
2642
- const registrySessionWorkspace = targetSid
2643
- ? (h.ctx?.sessionRegistry?.get?.(targetSid) as any)?.workspace
2644
- : undefined;
2645
- const currentSessionWorkspace = typeof (h.currentSession as any)?.workspace === 'string'
2646
- ? (h.currentSession as any).workspace
2647
- : undefined;
2648
- const argsWorkspace = typeof args?.workspace === 'string' ? args.workspace : undefined;
2649
- // When reading a different session (targetSid), prefer that session's registered
2650
- // workspace (or the caller-supplied args.workspace) over the current (coordinator)
2651
- // session's workspace — otherwise the coordinator's cwd shadows the worker's cwd
2652
- // and history lookups find the wrong files.
2653
- const workspace = targetSid
2654
- ? (typeof registrySessionWorkspace === 'string' ? registrySessionWorkspace : argsWorkspace ?? currentSessionWorkspace)
2655
- : (typeof currentSessionWorkspace === 'string' ? currentSessionWorkspace : undefined);
2656
- const intendedWorkspace = argsWorkspace;
2657
- const supportsNative = supportsCliNativeTranscript(agentStr, provider)
2658
- && isNativeSourceCanonicalHistory(provider?.nativeHistory);
2659
- const history = supportsNative
2660
- ? readCliProviderNativeHistory(agentStr, {
2661
- canonicalHistory: provider?.nativeHistory,
2662
- historySessionId,
2663
- workspace,
2664
- offset: 0,
2665
- limit: historyLimit,
2666
- excludeRecentCount: 0,
2667
- historyBehavior: provider?.historyBehavior,
2668
- scripts: provider?.scripts as any,
2669
- sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
2670
- envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
2671
- })
2672
- : readProviderChatHistory(agentStr, {
2673
- canonicalHistory: provider?.nativeHistory,
2674
- historySessionId,
2675
- workspace,
2676
- offset: 0,
2677
- limit: historyLimit,
2678
- excludeRecentCount: 0,
2679
- historyBehavior: provider?.historyBehavior,
2680
- scripts: provider?.scripts as any,
2681
- });
2682
- const lookup = (history as any)?.lookup === 'workspace' ? 'workspace' : 'session';
2683
- const historyMessages = Array.isArray((history as any)?.messages)
2684
- ? normalizeAndFilterNativeHistory(h, agentStr, args, (history as any).messages as ChatMessage[], (history as any)?.providerSessionId)
2685
- : [];
2686
- const historyProviderSessionId = typeof (history as any)?.providerSessionId === 'string'
2687
- ? (history as any).providerSessionId
2688
- : readHistorySessionIdFromMessages(historyMessages) || historySessionId;
2689
- const safeMapping = supportsNative
2690
- ? hasSafeNativeHistoryMapping({
2691
- historySessionId: lookup === 'workspace' ? undefined : historySessionId,
2692
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
2693
- workspace,
2694
- nativeMessages: historyMessages,
2695
- })
2696
- : false;
2697
- const trustedExactNativeIdentity = lookup !== 'workspace'
2698
- && Boolean(historySessionId)
2699
- && Boolean(historyProviderSessionId)
2700
- && historySessionId === historyProviderSessionId;
2701
-
2702
- const machineSessionKey = String(
2703
- args?.targetSessionId
2704
- || historyProviderSessionId
2705
- || historySessionId
2706
- || (h.currentSession as any)?.sessionId
2707
- || ''
2708
- );
2709
- const decision = decideCliReadChatSource({
2710
- providerType: agentStr,
2711
- provider,
2712
- sessionId: machineSessionKey,
2713
- nativeHistoryResult: history,
2714
- safeMapping,
2715
- trustedExactNativeIdentity,
2716
- sessionWorkspace: workspace,
2717
- intendedWorkspace,
2718
- ptyMessages: [],
2719
- ptyStatusApprovalOnly: false,
2720
- });
2721
-
2722
- if (supportsNative && !decision.nativeSelected) {
2723
- // Dead-end: we are in the history-only path (no live PTY/ACP
2724
- // adapter was found for this target session) AND provider-native
2725
- // history is not safely mappable to the requested session
2726
- // (no historySessionId stamp / workspace mismatch). Previously
2727
- // this returned `success:false`, which the command logger emits
2728
- // at warn level on EVERY poll (handler.ts logCommandEnd) —
2729
- // mesh coordinators poll read_chat continuously, so a worker whose
2730
- // transcript can never be safely mapped produced a 100% warn-log
2731
- // storm with no recovery. Switch to a SOFT response: success with
2732
- // empty messages + pending:true so the coordinator treats it as
2733
- // "no live messages readable yet" rather than a hard failure, and
2734
- // carry the machine-readable reason for debuggability. The normal
2735
- // live-adapter path (above) and the safe-native return (below) are
2736
- // unaffected — this is strictly the both-absent dead end.
2737
- LOG.debug('Command', `[read_chat] soft pending: no live adapter and native history not safely mappable target=${String(args?.targetSessionId || '')} provider=${agentStr} reason=native_history_not_safely_available`);
2738
- return {
2739
- success: true,
2740
- pending: true,
2741
- // Both signals are true here: we reached the history-only path
2742
- // because no live adapter was found (`live_adapter_not_found`),
2743
- // and native history is not safely mappable
2744
- // (`native_history_not_safely_available`).
2745
- reason: 'native_history_not_safely_available',
2746
- reasons: ['live_adapter_not_found', 'native_history_not_safely_available'],
2747
- code: 'native_history_not_safely_available',
2748
- messages: [],
2749
- status: 'idle',
2750
- providerSessionId: historyProviderSessionId,
2751
- messageSource: decision.messageSource,
2752
- transcriptProvenance: decision.messageSource,
2753
- };
2754
- }
2755
- return buildReadChatCommandResult({
2756
- messages: historyMessages,
2757
- status: 'idle',
2758
- messageSource: decision.messageSource,
2759
- transcriptProvenance: decision.messageSource,
2760
- ...(typeof (history as any)?.title === 'string' ? { title: (history as any).title } : {}),
2761
- ...(historyProviderSessionId ? { providerSessionId: historyProviderSessionId } : {}),
2762
- ...(((provider?.historyBehavior as any)?.transcriptAuthority === 'provider' || (provider?.historyBehavior as any)?.transcriptAuthority === 'daemon')
2763
- ? { transcriptAuthority: (provider?.historyBehavior as any).transcriptAuthority }
2764
- : {}),
2765
- coverage: 'tail',
2766
- }, args, h);
2767
- } catch (error: any) {
2768
- return { success: false, error: error?.message || `${transport} adapter not found` };
2769
- }
2770
- }
2771
-
2772
- // Extension transport: evaluateInSession
2773
- if (isExtensionTransport(transport)) {
2774
- let extensionReadChatError = '';
2775
- try {
2776
- const evalResult = await h.evaluateProviderScript('readChat', undefined, READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS);
2777
- if (evalResult?.result) {
2778
- let parsed = evalResult.result;
2779
- if (typeof parsed === 'string') {
2780
- try {
2781
- parsed = JSON.parse(parsed);
2782
- } catch (e: any) {
2783
- extensionReadChatError = `extension read_chat parse failed: ${e?.message || String(e)}`;
2784
- }
2785
- }
2786
- if (parsed && typeof parsed === 'object') {
2787
- const validated = validateReadChatResultPayload(parsed, 'extension read_chat');
2788
- _log(`Extension OK: ${validated.messages?.length || 0} msgs`);
2789
- traceProviderEvent(args, 'provider', 'extension.read_chat.success', {
2790
- h,
2791
- provider,
2792
- payload: {
2793
- method: 'evaluateProviderScript',
2794
- result: evalResult.result,
2795
- parsed: validated,
2796
- messageCount: Array.isArray(validated.messages) ? validated.messages.length : 0,
2797
- },
2798
- });
2799
- h.historyWriter.appendNewMessages(
2800
- provider?.type || 'unknown_extension',
2801
- toHistoryPersistedMessages(normalizeReadChatMessages(validated)),
2802
- validated.title,
2803
- args?.targetSessionId,
2804
- historySessionId,
2805
- );
2806
- return buildReadChatCommandResult(validated as Record<string, any>, args, h);
2807
- }
2808
- if (!extensionReadChatError) {
2809
- extensionReadChatError = 'extension read_chat returned a non-object payload';
2810
- }
2811
- } else {
2812
- extensionReadChatError = 'extension read_chat returned no payload';
2813
- }
2814
- } catch (e: any) {
2815
- extensionReadChatError = `extension read_chat failed: ${e?.message || String(e)}`;
2816
- _log(`Extension error: ${e.message}`);
2817
- traceProviderEvent(args, 'provider', 'extension.read_chat.error', {
2818
- h,
2819
- provider,
2820
- level: 'warn',
2821
- payload: { method: 'evaluateProviderScript', error: e.message },
2822
- });
2823
- }
2824
- // Alternative: AgentStreamManager (script fail when)
2825
- if (h.agentStream) {
2826
- const cdp = h.getCdp();
2827
- const parentSessionId = h.currentSession?.parentSessionId;
2828
- if (cdp && parentSessionId) {
2829
- const stream = await h.agentStream.collectActiveSession(cdp, parentSessionId);
2830
- if (stream && stream.agentType !== provider?.type) {
2831
- return { success: false, error: `extension read_chat stream agent mismatch for ${provider?.type || 'unknown_extension'}` };
2832
- }
2833
- if (stream) {
2834
- h.historyWriter.appendNewMessages(
2835
- stream.agentType,
2836
- toHistoryPersistedMessages(stream.messages || []),
2837
- undefined,
2838
- args?.targetSessionId,
2839
- historySessionId,
2840
- );
2841
- return buildReadChatCommandResult({
2842
- messages: stream.messages || [],
2843
- status: stream.status,
2844
- agentType: stream.agentType,
2845
- }, args, h);
2846
- }
2847
- }
2848
- }
2849
- return { success: false, error: extensionReadChatError || 'extension read_chat unavailable' };
2850
- }
2851
-
2852
- // IDE category (default): cdp.evaluate
2853
- const cdp = h.getCdp();
2854
- if (!cdp?.isConnected) return { success: false, error: 'CDP not connected' };
2855
-
2856
- // webview IDE (Kiro, PearAI) → evaluateInWebviewFrame directly use
2857
- const webviewScript = h.getProviderScript('webviewReadChat') || h.getProviderScript('webview_read_chat');
2858
- if (webviewScript) {
2859
- let webviewReadChatError = '';
2860
- try {
2861
- const matchText = provider?.webviewMatchText;
2862
- const matchFn = matchText
2863
- ? (body: string) => body.includes(matchText)
2864
- : undefined;
2865
- const raw = await cdp.evaluateInWebviewFrame(webviewScript, matchFn);
2866
- if (raw) {
2867
- let parsed: any = raw;
2868
- if (typeof parsed === 'string') {
2869
- try {
2870
- parsed = JSON.parse(parsed);
2871
- } catch (e: any) {
2872
- webviewReadChatError = `webview read_chat parse failed: ${e?.message || String(e)}`;
2873
- }
2874
- }
2875
- if (parsed && typeof parsed === 'object') {
2876
- const validated = validateReadChatResultPayload(parsed, 'webview read_chat');
2877
- _log(`Webview OK: ${validated.messages?.length || 0} msgs`);
2878
- h.historyWriter.appendNewMessages(
2879
- provider?.type || getCurrentProviderType(h, 'unknown_webview'),
2880
- toHistoryPersistedMessages(normalizeReadChatMessages(validated)),
2881
- validated.title,
2882
- args?.targetSessionId,
2883
- historySessionId,
2884
- );
2885
- return buildReadChatCommandResult(validated as Record<string, any>, args, h);
2886
- }
2887
- if (!webviewReadChatError) {
2888
- webviewReadChatError = 'webview read_chat returned a non-object payload';
2889
- }
2890
- } else {
2891
- webviewReadChatError = 'webview read_chat returned no payload';
2892
- }
2893
- } catch (e: any) {
2894
- webviewReadChatError = `webview read_chat failed: ${e?.message || String(e)}`;
2895
- _log(`Webview readChat error: ${e.message}`);
2896
- }
2897
- return { success: false, error: webviewReadChatError || 'webview read_chat unavailable' };
2898
- }
2899
-
2900
- // Regular IDE (Cursor, Windsurf, Trae etc) → main DOM evaluate
2901
- const script = h.getProviderScript('readChat') || h.getProviderScript('read_chat');
2902
- if (script) {
2903
- let ideReadChatError = '';
2904
- try {
2905
- const evalResult = await h.evaluateProviderScript('readChat', undefined, READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS);
2906
- if (evalResult?.result) {
2907
- let parsed: any = evalResult.result;
2908
- if (typeof parsed === 'string') {
2909
- try {
2910
- parsed = JSON.parse(parsed);
2911
- } catch (e: any) {
2912
- ideReadChatError = `ide read_chat parse failed: ${e?.message || String(e)}`;
2913
- }
2914
- }
2915
- if (parsed && typeof parsed === 'object') {
2916
- const validated = validateReadChatResultPayload(parsed, 'ide read_chat');
2917
- _log(`OK: ${validated.messages?.length || 0} msgs`);
2918
- traceProviderEvent(args, 'provider', 'ide.read_chat.success', {
2919
- h,
2920
- provider,
2921
- payload: {
2922
- method: 'evaluate',
2923
- result: evalResult.result,
2924
- parsed: validated,
2925
- messageCount: Array.isArray(validated.messages) ? validated.messages.length : 0,
2926
- },
2927
- });
2928
- h.historyWriter.appendNewMessages(
2929
- provider?.type || getCurrentProviderType(h, 'unknown_ide'),
2930
- toHistoryPersistedMessages(normalizeReadChatMessages(validated)),
2931
- validated.title,
2932
- args?.targetSessionId,
2933
- historySessionId,
2934
- );
2935
- return buildReadChatCommandResult(validated as Record<string, any>, args, h);
2936
- }
2937
- if (!ideReadChatError) {
2938
- ideReadChatError = 'ide read_chat returned a non-object payload';
2939
- }
2940
- } else {
2941
- ideReadChatError = 'ide read_chat returned no payload';
2942
- }
2943
- } catch (e: any) {
2944
- ideReadChatError = `ide read_chat failed: ${e?.message || String(e)}`;
2945
- LOG.info('Command', `[read_chat] Script error: ${e.message}`);
2946
- traceProviderEvent(args, 'provider', 'ide.read_chat.error', {
2947
- h,
2948
- provider,
2949
- level: 'warn',
2950
- payload: { method: 'evaluate', error: e.message },
2951
- });
2952
- }
2953
- return { success: false, error: ideReadChatError || 'ide read_chat unavailable' };
2954
- }
2955
-
2956
- return { success: false, error: 'read_chat unavailable' };
2957
- }
2958
-
2959
- export async function handleSendChat(h: CommandHelpers, args: any): Promise<CommandResult> {
2960
- const input = getSendChatInputEnvelope(args);
2961
- const text = input.textFallback;
2962
- const hasInput = input.parts.length > 0 || (typeof text === 'string' && text.trim().length > 0);
2963
- if (!hasInput) return { success: false, error: 'input required' };
2964
- const _log = (msg: string) => LOG.debug('Command', `[send_chat] ${msg}`);
2965
- const provider = h.getProvider(args?.agentType);
2966
- const transport = getTargetTransport(h, provider);
2967
- const dedupeKey = buildRecentSendKey(h, args, provider, buildSendInputSignature(input));
2968
-
2969
- const _logSendSuccess = (method: string, targetAgent?: string) => {
2970
- // Sending and transcript persistence are intentionally decoupled.
2971
- // User turns should reach history through read_chat/runtime transcript sync,
2972
- // not by eagerly appending the outgoing input here.
2973
- return { success: true, sent: true, method, targetAgent };
2974
- };
2975
-
2976
- if (isRecentDuplicateSend(dedupeKey)) {
2977
- _log(`Suppressed duplicate send for ${dedupeKey}`);
2978
- return { success: true, sent: false, deduplicated: true };
2979
- }
2980
-
2981
- if (transport === 'acp') {
2982
- const target = getTargetInstance(h, args);
2983
- if (!target || target.category !== 'acp') {
2984
- return { success: false, error: `ACP instance not found for ${provider?.type || args?.agentType || 'unknown'}` };
2985
- }
2986
- try {
2987
- assertProviderSupportsDeclaredInput(provider, input);
2988
- target.onEvent('send_message', { input });
2989
- return _logSendSuccess('acp-instance', target.type);
2990
- } catch (e: any) {
2991
- return { success: false, error: `acp send failed: ${e.message}` };
2992
- }
2993
- }
2994
-
2995
- // PTY transport: route structured input through the provider instance so
2996
- // provider-specific CLI attachment strategies (for example Hermes file-path
2997
- // image prompts) are applied instead of collapsing everything to text.
2998
- if (transport === 'pty') {
2999
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
3000
- if (adapter) {
3001
- _log(`${transport} adapter: ${adapter.cliType}`);
3002
- try {
3003
- const hasStructuredParts = input.parts.some((part) => part.type !== 'text');
3004
- if (hasStructuredParts) {
3005
- const target = getTargetInstance(h, args);
3006
- if (!target || target.category !== 'cli') {
3007
- return { success: false, error: `CLI instance not found for ${provider?.type || args?.agentType || 'unknown'}` };
3008
- }
3009
- assertProviderSupportsDeclaredInput(provider, input);
3010
- await waitOnceForFreshHermesCliStart(adapter, _log);
3011
- target.onEvent('send_message', { input });
3012
- return _logSendSuccess(`${transport}-instance`, target.type);
3013
- }
3014
- assertTextOnlyInput(provider, input);
3015
- if (!text) return { success: false, error: 'text required for PTY send' };
3016
- await waitOnceForFreshHermesCliStart(adapter, _log);
3017
- const forceSend = args?.force === true || args?.forceSend === true;
3018
- if (forceSend && typeof adapter.forceSendMessage === 'function') {
3019
- await adapter.forceSendMessage(text);
3020
- } else if (forceSend) {
3021
- await adapter.sendMessage(text, { force: true });
3022
- } else {
3023
- await adapter.sendMessage(text);
3024
- }
3025
- const target = getTargetInstance(h, args) as RuntimeChatMessageMerger | null;
3026
- if (target?.category === 'cli'
3027
- && target.type === adapter.cliType
3028
- && typeof target.recordAcknowledgedUserInput === 'function') {
3029
- target.recordAcknowledgedUserInput(input);
3030
- }
3031
- return {
3032
- ..._logSendSuccess(`${transport}-adapter`, adapter.cliType),
3033
- ...(forceSend ? { forceSent: true } : {}),
3034
- };
3035
- } catch (e: any) {
3036
- return { success: false, error: `${transport} send failed: ${e.message}` };
3037
- }
3038
- }
3039
- }
3040
-
3041
- assertTextOnlyInput(provider, input);
3042
- if (!text) return { success: false, error: 'text required' };
3043
-
3044
- // Extension transport: via AgentStreamManager
3045
- if (isExtensionTransport(transport)) {
3046
- _log(`Extension: ${provider?.type || 'unknown_extension'}`);
3047
- // Method 1: provider sendMessage script via evaluateInSession
3048
- try {
3049
- const beforeState = await getStableExtensionBaseline(h);
3050
- const evalResult = await h.evaluateProviderScript('sendMessage', { message: text }, 30000);
3051
- if (evalResult?.result) {
3052
- const parsed = parseMaybeJson(evalResult.result);
3053
- if (didProviderConfirmSend(parsed)) {
3054
- const observed = await verifyExtensionSendObserved(h, beforeState);
3055
- if (observed) {
3056
- _log(`Extension script sent OK`);
3057
- return _logSendSuccess('extension-script');
3058
- }
3059
- _log(`Extension script reported send but no chat-state change was observed`);
3060
- }
3061
- if (parsed?.needsTypeAndSend) {
3062
- _log(`Extension needsTypeAndSend → AgentStreamManager`);
3063
- }
3064
- }
3065
- } catch (e: any) {
3066
- _log(`Extension script error: ${e.message}`);
3067
- }
3068
- // Method 2: AgentStreamManager
3069
- const extensionSessionId = h.currentSession?.sessionId;
3070
- if (h.agentStream && h.getCdp() && extensionSessionId) {
3071
- const ok = await h.agentStream.sendToSession(h.getCdp()!, extensionSessionId, text);
3072
- if (ok) {
3073
- _log(`AgentStreamManager sent OK`);
3074
- return _logSendSuccess('agent-stream');
3075
- }
3076
- }
3077
- return { success: false, error: `Extension '${provider?.type || 'unknown_extension'}' send failed` };
3078
- }
3079
-
3080
- // IDE category (default): provider sendMessage script is authoritative when present.
3081
- const targetCdp = h.getCdp();
3082
- if (!targetCdp?.isConnected) {
3083
- const managerKey = getCurrentManagerKey(h);
3084
- _log(`No CDP for ${managerKey}`);
3085
- return { success: false, error: `CDP for ${managerKey || 'unknown'} not connected` };
3086
- }
3087
-
3088
- _log(`Targeting IDE: ${getCurrentManagerKey(h)}`);
3089
- const sendScript = h.getProviderScript('sendMessage', { message: text });
3090
- if (sendScript) {
3091
- try {
3092
- const result = await targetCdp.evaluate(sendScript, 30000);
3093
- const parsed: any = parseMaybeJson(result);
3094
- if (didProviderConfirmSend(parsed)) {
3095
- _log(`sendMessage script OK`);
3096
- return _logSendSuccess('script');
3097
- }
3098
- if (parsed?.needsTypeAndSend && parsed?.selector) {
3099
- try {
3100
- const sent = await targetCdp.typeAndSend(parsed.selector, text);
3101
- if (sent) {
3102
- _log(`typeAndSend(script.selector=${parsed.selector}) success`);
3103
- return _logSendSuccess('typeAndSend-script');
3104
- }
3105
- } catch (e: any) {
3106
- _log(`typeAndSend(script.selector) failed: ${e.message}`);
3107
- }
3108
- }
3109
- if (parsed?.needsTypeAndSend && parsed?.clickCoords) {
3110
- try {
3111
- const { x, y } = parsed.clickCoords;
3112
- const sent = await targetCdp.typeAndSendAt(x, y, text);
3113
- if (sent) {
3114
- _log(`typeAndSendAt(${x},${y}) success`);
3115
- return _logSendSuccess('typeAndSendAt-script');
3116
- }
3117
- } catch (e: any) {
3118
- _log(`typeAndSendAt failed: ${e.message}`);
3119
- }
3120
- }
3121
- if (parsed?.needsTypeAndSend && provider?.inputMethod === 'cdp-type-and-send' && provider.inputSelector) {
3122
- try {
3123
- const sent = await targetCdp.typeAndSend(provider.inputSelector, text);
3124
- if (sent) {
3125
- _log(`typeAndSend(provider.inputSelector=${provider.inputSelector}) success`);
3126
- return _logSendSuccess('typeAndSend-provider');
3127
- }
3128
- } catch (e: any) {
3129
- _log(`typeAndSend(provider) failed: ${e.message}`);
3130
- }
3131
- }
3132
- if (parsed?.needsTypeAndSend && provider?.webviewMatchText && provider?.scripts?.webviewSendMessage) {
3133
- try {
3134
- const webviewScript = callLegacyTextScript(provider.scripts.webviewSendMessage, text);
3135
- if (webviewScript && targetCdp.evaluateInWebviewFrame) {
3136
- const matchText = provider.webviewMatchText;
3137
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3138
- const wvResult = await targetCdp.evaluateInWebviewFrame(webviewScript, matchFn);
3139
- const wvParsed: any = parseMaybeJson(wvResult);
3140
- if (didProviderConfirmSend(wvParsed)) {
3141
- _log(`webviewSendMessage OK`);
3142
- return _logSendSuccess('webview-script');
3143
- }
3144
- }
3145
- } catch (e: any) {
3146
- _log(`webviewSendMessage failed: ${e.message}`);
3147
- }
3148
- }
3149
- return { success: false, error: parsed?.error || 'Provider sendMessage did not confirm send' };
3150
- } catch (e: any) {
3151
- _log(`sendMessage script failed: ${e.message}`);
3152
- return { success: false, error: `Provider sendMessage failed: ${e.message}` };
3153
- }
3154
- }
3155
-
3156
- if (provider?.webviewMatchText && provider?.scripts?.webviewSendMessage) {
3157
- try {
3158
- const webviewScript = callLegacyTextScript(provider.scripts.webviewSendMessage, text);
3159
- if (webviewScript && targetCdp.evaluateInWebviewFrame) {
3160
- const matchText = provider.webviewMatchText;
3161
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3162
- const wvResult = await targetCdp.evaluateInWebviewFrame(webviewScript, matchFn);
3163
- const wvParsed: any = parseMaybeJson(wvResult);
3164
- if (didProviderConfirmSend(wvParsed)) {
3165
- _log(`webviewSendMessage OK`);
3166
- return _logSendSuccess('webview-script');
3167
- }
3168
- }
3169
- } catch (e: any) {
3170
- _log(`webviewSendMessage failed: ${e.message}`);
3171
- }
3172
- }
3173
-
3174
- if (provider?.inputMethod === 'cdp-type-and-send' && provider.inputSelector) {
3175
- try {
3176
- const sent = await targetCdp.typeAndSend(provider.inputSelector, text);
3177
- if (sent) {
3178
- _log(`typeAndSend(provider.inputSelector=${provider.inputSelector}) success`);
3179
- return _logSendSuccess('typeAndSend-provider');
3180
- }
3181
- } catch (e: any) {
3182
- _log(`typeAndSend(provider) failed: ${e.message}`);
3183
- }
3184
- }
3185
-
3186
- _log('All methods failed');
3187
- return { success: false, error: 'No provider method could send the message' };
3188
- }
3189
-
3190
- export async function handleListChats(h: CommandHelpers, args: any): Promise<CommandResult> {
3191
- const provider = h.getProvider(args?.agentType);
3192
- const transport = getTargetTransport(h, provider);
3193
-
3194
- // Extension transport: via AgentStreamManager
3195
- if (isExtensionTransport(transport) && h.agentStream && h.getCdp() && h.currentSession?.sessionId) {
3196
- try {
3197
- const chats = await h.agentStream.listSessionChats(h.getCdp()!, h.currentSession.sessionId);
3198
- LOG.info('Command', `[list_chats] Extension: ${chats.length} chats`);
3199
- return { success: true, chats };
3200
- } catch (e: any) {
3201
- LOG.info('Command', `[list_chats] Extension error: ${e.message}`);
3202
- }
3203
- }
3204
-
3205
- // webview IDE
3206
- try {
3207
- const webviewScript = h.getProviderScript('webviewListSessions') || h.getProviderScript('webview_list_sessions');
3208
- if (webviewScript) {
3209
- const matchText = provider?.webviewMatchText;
3210
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3211
- const raw = await h.getCdp()?.evaluateInWebviewFrame?.(webviewScript, matchFn);
3212
- let parsed: any = raw;
3213
- if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { } }
3214
- if (parsed?.sessions) {
3215
- LOG.info('Command', `[list_chats] Webview OK: ${parsed.sessions.length} chats`);
3216
- return { success: true, chats: parsed.sessions };
3217
- }
3218
- }
3219
- } catch (e: any) {
3220
- LOG.info('Command', `[list_chats] Webview error: ${e.message}`);
3221
- }
3222
-
3223
- // IDE/default: evaluateProviderScript
3224
- try {
3225
- const evalResult = await h.evaluateProviderScript('listSessions');
3226
- if (evalResult) {
3227
- let parsed = evalResult.result;
3228
- if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { } }
3229
- if (parsed?.sessions && Array.isArray(parsed.sessions)) {
3230
- LOG.info('Command', `[list_chats] OK: ${parsed.sessions.length} chats`);
3231
- return { success: true, chats: parsed.sessions };
3232
- }
3233
- if (parsed?.chats && Array.isArray(parsed.chats)) {
3234
- LOG.info('Command', `[list_chats] OK: ${parsed.chats.length} chats`);
3235
- return { success: true, chats: parsed.chats };
3236
- }
3237
- if (Array.isArray(parsed)) {
3238
- LOG.info('Command', `[list_chats] OK: ${parsed.length} chats`);
3239
- return { success: true, chats: parsed };
3240
- }
3241
- }
3242
- } catch (e: any) {
3243
- LOG.info('Command', `[list_chats] error: ${e.message}`);
3244
- }
3245
-
3246
- return { success: false, error: 'listSessions script not available for this provider' };
3247
- }
3248
-
3249
- export async function handleNewChat(h: CommandHelpers, args: any): Promise<CommandResult> {
3250
- const provider = h.getProvider(args?.agentType);
3251
- const transport = getTargetTransport(h, provider);
3252
-
3253
- if (transport === 'pty') {
3254
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
3255
- if (!adapter) return { success: false, error: 'CLI adapter not running' };
3256
- if (typeof adapter.clearHistory === 'function') {
3257
- adapter.clearHistory();
3258
- return { success: true, cleared: true };
3259
- }
3260
- return { success: false, error: 'new_chat not supported by this CLI provider' };
3261
- }
3262
-
3263
- if (isExtensionTransport(transport) && h.agentStream && h.getCdp() && h.currentSession?.sessionId) {
3264
- const ok = await h.agentStream.newSession(h.getCdp()!, h.currentSession.sessionId);
3265
- return { success: ok };
3266
- }
3267
-
3268
- // webview IDE
3269
- try {
3270
- const webviewScript = h.getProviderScript('webviewNewSession') || h.getProviderScript('webview_new_session');
3271
- if (webviewScript) {
3272
- const matchText = provider?.webviewMatchText;
3273
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3274
- const raw = await h.getCdp()?.evaluateInWebviewFrame?.(webviewScript, matchFn);
3275
- if (raw) return { success: true, result: raw };
3276
- }
3277
- } catch (e: any) {
3278
- return { success: false, error: `webviewNewSession failed: ${e.message}` };
3279
- }
3280
-
3281
- try {
3282
- const evalResult = await h.evaluateProviderScript('newSession');
3283
- if (evalResult) return { success: true };
3284
- } catch (e: any) {
3285
- return { success: false, error: `newSession failed: ${e.message}` };
3286
- }
3287
-
3288
- return { success: false, error: 'newSession script not available for this provider' };
3289
- }
3290
-
3291
- export async function handleSwitchChat(h: CommandHelpers, args: any): Promise<CommandResult> {
3292
- const provider = h.getProvider(args?.agentType);
3293
- const transport = getTargetTransport(h, provider);
3294
- const managerKey = getCurrentManagerKey(h);
3295
- const sessionId = args?.sessionId || args?.id || args?.chatId;
3296
- if (!sessionId) return { success: false, error: 'sessionId required' };
3297
- LOG.info('Command', `[switch_chat] sessionId=${sessionId}, manager=${managerKey}`);
3298
-
3299
- if (isExtensionTransport(transport) && h.agentStream && h.getCdp() && h.currentSession?.sessionId) {
3300
- const ok = await h.agentStream.switchConversation(h.getCdp()!, h.currentSession.sessionId, sessionId);
3301
- return { success: ok, result: ok ? 'switched' : 'failed' };
3302
- }
3303
-
3304
- const cdp = h.getCdp(managerKey);
3305
- if (!cdp?.isConnected) return { success: false, error: 'CDP not connected' };
3306
-
3307
- // webview IDE
3308
- try {
3309
- const webviewScript = h.getProviderScript('webviewSwitchSession', { SESSION_ID: JSON.stringify(sessionId) });
3310
- if (webviewScript) {
3311
- const matchText = provider?.webviewMatchText;
3312
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3313
- const raw = await cdp.evaluateInWebviewFrame?.(webviewScript, matchFn);
3314
- if (raw) return { success: true, result: raw };
3315
- }
3316
- } catch (e: any) {
3317
- return { success: false, error: `webviewSwitchSession failed: ${e.message}` };
3318
- }
3319
-
3320
- const switchParams = {
3321
- sessionId,
3322
- title: sessionId,
3323
- id: sessionId,
3324
- SESSION_ID: JSON.stringify(sessionId),
3325
- };
3326
- const script = h.getProviderScript('switchSession', switchParams)
3327
- || h.getProviderScript('switch_session', switchParams);
3328
- if (!script) return { success: false, error: 'switch_session script not available' };
3329
-
3330
- try {
3331
- const raw = await cdp.evaluate(script, 15000);
3332
- LOG.info('Command', `[switch_chat] result: ${raw}`);
3333
-
3334
- let parsed: any = null;
3335
- try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { }
3336
-
3337
- if (parsed?.action === 'click' && parsed.clickX && parsed.clickY) {
3338
- const x = Math.round(parsed.clickX);
3339
- const y = Math.round(parsed.clickY);
3340
- LOG.info('Command', `[switch_chat] CDP click at (${x}, ${y}) for "${parsed.title}"`);
3341
- await cdp.send('Input.dispatchMouseEvent', {
3342
- type: 'mousePressed', x, y, button: 'left', clickCount: 1
3343
- });
3344
- await cdp.send('Input.dispatchMouseEvent', {
3345
- type: 'mouseReleased', x, y, button: 'left', clickCount: 1
3346
- });
3347
- await new Promise(r => setTimeout(r, 2000));
3348
-
3349
- // Auto-handle workspace selection dialog
3350
- const wsResult = await cdp.evaluate(`
3351
- (() => {
3352
- const inp = Array.from(document.querySelectorAll('input[type="text"]'))
3353
- .find(i => i.offsetWidth > 0 && (i.placeholder || '').includes('Select where'));
3354
- if (!inp) return null;
3355
- const rows = inp.closest('[class*="quickInput"]')?.querySelectorAll('[class*="cursor-pointer"]');
3356
- if (rows && rows.length > 0) {
3357
- const r = rows[0].getBoundingClientRect();
3358
- return JSON.stringify({ x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2) });
3359
- }
3360
- return null;
3361
- })()
3362
- `, 5000);
3363
- if (wsResult) {
3364
- try {
3365
- const ws = JSON.parse(wsResult as string);
3366
- await cdp.send('Input.dispatchMouseEvent', {
3367
- type: 'mousePressed', x: ws.x, y: ws.y, button: 'left', clickCount: 1
3368
- });
3369
- await cdp.send('Input.dispatchMouseEvent', {
3370
- type: 'mouseReleased', x: ws.x, y: ws.y, button: 'left', clickCount: 1
3371
- });
3372
- } catch { }
3373
- }
3374
- return { success: true, result: 'switched' };
3375
- }
3376
-
3377
- if (parsed?.error) return { success: false, error: parsed.error };
3378
- return { success: true, result: raw };
3379
- } catch (e: any) {
3380
- LOG.error('Command', `[switch_chat] error: ${e.message}`);
3381
- return { success: false, error: e.message };
3382
- }
3383
- }
3384
-
3385
- export async function handleSetMode(h: CommandHelpers, args: any): Promise<CommandResult> {
3386
- const provider = h.getProvider(args?.agentType);
3387
- const transport = getTargetTransport(h, provider);
3388
- const mode = args?.mode || 'agent';
3389
-
3390
- // ACP transport
3391
- if (transport === 'acp') {
3392
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
3393
- const acpInstance = adapter?._acpInstance;
3394
- if (acpInstance && typeof acpInstance.setMode === 'function') {
3395
- await acpInstance.setMode(mode);
3396
- return { success: true, mode };
3397
- }
3398
- return { success: false, error: 'ACP adapter not found' };
3399
- }
3400
-
3401
- // 1. webview setMode
3402
- const webviewScript = h.getProviderScript('webviewSetMode', { MODE: JSON.stringify(mode) });
3403
- if (webviewScript) {
3404
- const cdp = h.getCdp();
3405
- if (cdp?.isConnected) {
3406
- try {
3407
- const matchText = provider?.webviewMatchText;
3408
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3409
- const raw = await cdp.evaluateInWebviewFrame?.(webviewScript, matchFn);
3410
- let result: any = raw;
3411
- if (typeof raw === 'string') { try { result = JSON.parse(raw); } catch { } }
3412
- if (result?.success) return { success: true, mode, method: 'webview-script' };
3413
- } catch (e: any) {
3414
- LOG.info('Command', `[set_mode] webview script error: ${e.message}`);
3415
- }
3416
- }
3417
- }
3418
-
3419
- // 2. main frame setMode
3420
- const mainScript = h.getProviderScript('setMode', { MODE: JSON.stringify(mode) });
3421
- if (mainScript) {
3422
- try {
3423
- const evalResult = await h.evaluateProviderScript('setMode', { MODE: JSON.stringify(mode) }, 15000);
3424
- if (evalResult?.result) {
3425
- let parsed = evalResult.result;
3426
- if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { } }
3427
- if (parsed?.success) return { success: true, mode, method: 'script' };
3428
- }
3429
- } catch (e: any) {
3430
- LOG.info('Command', `[set_mode] script error: ${e.message}`);
3431
- }
3432
- }
3433
-
3434
- return { success: false, error: `setMode '${mode}' not supported by this provider` };
3435
- }
3436
-
3437
- export async function handleChangeModel(h: CommandHelpers, args: any): Promise<CommandResult> {
3438
- const provider = h.getProvider(args?.agentType);
3439
- const transport = getTargetTransport(h, provider);
3440
- const model = args?.model;
3441
-
3442
- LOG.info('Command', `[change_model] model=${model} provider=${provider?.type} transport=${transport} manager=${getCurrentManagerKey(h)} providerType=${getCurrentProviderType(h)}`);
3443
-
3444
- // ACP transport
3445
- if (transport === 'acp') {
3446
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
3447
- LOG.info('Command', `[change_model] ACP adapter found: ${!!adapter}, type=${adapter?.cliType}, hasAcpInstance=${!!adapter?._acpInstance}`);
3448
- const acpInstance = adapter?._acpInstance;
3449
- if (acpInstance && typeof acpInstance.setConfigOption === 'function') {
3450
- await acpInstance.setConfigOption('model', model);
3451
- LOG.info('Command', `[change_model] Updated ACP model to ${model}`);
3452
- return { success: true, model };
3453
- }
3454
- return { success: false, error: 'ACP adapter not found' };
3455
- }
3456
-
3457
- // 1. webview setModel
3458
- const webviewScript = h.getProviderScript('webviewSetModel', { MODEL: JSON.stringify(model) });
3459
- if (webviewScript) {
3460
- const cdp = h.getCdp();
3461
- if (cdp?.isConnected) {
3462
- try {
3463
- const matchText = provider?.webviewMatchText;
3464
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3465
- const raw = await cdp.evaluateInWebviewFrame?.(webviewScript, matchFn);
3466
- let result: any = raw;
3467
- if (typeof raw === 'string') { try { result = JSON.parse(raw); } catch { } }
3468
- if (result?.success) return { success: true, model, method: 'webview-script' };
3469
- } catch (e: any) {
3470
- LOG.info('Command', `[change_model] webview script error: ${e.message}`);
3471
- }
3472
- }
3473
- }
3474
-
3475
- // 2. main frame setModel
3476
- const mainScript = h.getProviderScript('setModel', { MODEL: JSON.stringify(model) });
3477
- if (mainScript) {
3478
- try {
3479
- const evalResult = await h.evaluateProviderScript('setModel', { MODEL: JSON.stringify(model) }, 15000);
3480
- if (evalResult?.result) {
3481
- let parsed = evalResult.result;
3482
- if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { } }
3483
- if (parsed?.success) return { success: true, model, method: 'script' };
3484
- }
3485
- } catch (e: any) {
3486
- LOG.info('Command', `[change_model] script error: ${e.message}`);
3487
- }
3488
- }
3489
-
3490
- return { success: false, error: 'changeModel not supported by this IDE provider' };
3491
- }
3492
-
3493
- export async function handleSetThoughtLevel(h: CommandHelpers, args: any): Promise<CommandResult> {
3494
- const configId = args?.configId;
3495
- const value = args?.value;
3496
- if (!configId || !value) return { success: false, error: 'configId and value required' };
3497
-
3498
- const provider = h.getProvider(args?.agentType);
3499
- const transport = getTargetTransport(h, provider);
3500
- if (transport !== 'acp') {
3501
- return { success: false, error: 'set_thought_level only for ACP providers' };
3502
- }
3503
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
3504
- const acpInstance = adapter?._acpInstance;
3505
- if (!acpInstance) return { success: false, error: 'ACP instance not found' };
3506
- if (typeof acpInstance.setConfigOption !== 'function') {
3507
- return { success: false, error: 'ACP setConfigOption not available' };
3508
- }
3509
-
3510
- try {
3511
- await acpInstance.setConfigOption(configId, value);
3512
- LOG.info('Command', `[set_thought_level] ${configId}=${value} for ${provider?.type || 'unknown_acp'}`);
3513
- return { success: true, configId, value };
3514
- } catch (e: any) {
3515
- return { success: false, error: e?.message };
3516
- }
3517
- }
3518
-
3519
- export async function handleResolveAction(h: CommandHelpers, args: any): Promise<CommandResult> {
3520
- const provider = h.getProvider(args?.agentType);
3521
- const transport = getTargetTransport(h, provider);
3522
- const action = args?.action || 'approve';
3523
- const button = args?.button || args?.buttonText
3524
- || (action === 'approve' ? 'Accept' : action === 'reject' ? 'Reject' : 'Accept');
3525
-
3526
- LOG.info('Command', `[resolveAction] action=${action} button="${button}" provider=${provider?.type}`);
3527
-
3528
- // 0. PTY transport: navigate approval dialog via PTY arrow keys + Enter
3529
- if (transport === 'pty') {
3530
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
3531
- if (!adapter) return { success: false, error: 'CLI adapter not running' };
3532
-
3533
- // Handle data-driven resolve actions (like from the dashboard 'Fix' button)
3534
- if (args?.data && typeof adapter.resolveAction === 'function') {
3535
- try {
3536
- await adapter.resolveAction(args.data);
3537
- LOG.info('Command', `[resolveAction] CLI PTY → resolveAction triggered with data payload`);
3538
- return { success: true, method: 'cli-resolve-action' };
3539
- } catch (e: any) {
3540
- return { success: false, error: `CLI resolveAction failed: ${e.message}` };
3541
- }
3542
- }
3543
-
3544
- const status = adapter.getStatus();
3545
- const targetInstance = getTargetInstance(h, args);
3546
- const targetState = targetInstance?.getState?.() as { activeChat?: { status?: string; activeModal?: { message?: string; buttons?: string[] } | null } } | undefined;
3547
- const surfacedModal = targetState?.activeChat?.activeModal && Array.isArray(targetState.activeChat.activeModal.buttons)
3548
- && targetState.activeChat.activeModal.buttons.some((candidate) => typeof candidate === 'string' && candidate.trim())
3549
- ? targetState.activeChat.activeModal
3550
- : null;
3551
- const statusModal = status?.activeModal && Array.isArray(status.activeModal.buttons)
3552
- && status.activeModal.buttons.some((candidate) => typeof candidate === 'string' && candidate.trim())
3553
- ? status.activeModal
3554
- : null;
3555
- const parsedStatus = !statusModal && !surfacedModal && typeof adapter.getScriptParsedStatus === 'function'
3556
- ? (() => {
3557
- try {
3558
- return parseMaybeJson(adapter.getScriptParsedStatus());
3559
- } catch {
3560
- return null;
3561
- }
3562
- })()
3563
- : null;
3564
- const parsedModal = parsedStatus?.status === 'waiting_approval'
3565
- && parsedStatus?.activeModal
3566
- && Array.isArray(parsedStatus.activeModal.buttons)
3567
- && parsedStatus.activeModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim())
3568
- ? parsedStatus.activeModal
3569
- : null;
3570
- const effectiveModal = statusModal || surfacedModal || parsedModal;
3571
- const effectiveStatus = status?.status === 'waiting_approval' || targetState?.activeChat?.status === 'waiting_approval' || parsedStatus?.status === 'waiting_approval'
3572
- ? 'waiting_approval'
3573
- : status?.status;
3574
- LOG.info('Command', `[resolveAction] CLI PTY gate target=${String(args?.targetSessionId || '')} rawStatus=${String(status?.status || '')} effectiveStatus=${String(effectiveStatus || '')} statusModal=${statusModal ? 'yes' : 'no'} surfacedModal=${surfacedModal ? 'yes' : 'no'} parsedModal=${parsedModal ? 'yes' : 'no'} instance=${targetInstance ? 'yes' : 'no'}`);
3575
- if (!effectiveModal) {
3576
- return { success: false, error: 'Not in approval state' };
3577
- }
3578
- const buttons: string[] = Array.isArray(effectiveModal.buttons) ? effectiveModal.buttons : [];
3579
- // Resolve button index: explicit buttonIndex arg → exact text match → explicit action mapping
3580
- let buttonIndex = typeof args?.buttonIndex === 'number' ? args.buttonIndex : -1;
3581
- if (buttonIndex < 0 && button) {
3582
- const btnLower = button.toLowerCase();
3583
- buttonIndex = buttons.findIndex(b => b.toLowerCase().includes(btnLower));
3584
- }
3585
- if (buttonIndex < 0 && (action === 'reject' || action === 'deny')) {
3586
- buttonIndex = buttons.findIndex(b => /deny|reject|no/i.test(b));
3587
- }
3588
- if (buttonIndex < 0 && (action === 'always' || /always/i.test(button))) {
3589
- buttonIndex = buttons.findIndex(b => /always/i.test(b));
3590
- }
3591
- if (buttonIndex < 0 && (action === 'approve' || action === 'accept')) {
3592
- buttonIndex = pickApprovalButton(buttons, provider).index;
3593
- }
3594
- if (buttonIndex < 0) {
3595
- return { success: false, error: 'Approval action did not match any visible button' };
3596
- }
3597
- // Idempotency: if the adapter already resolved this approval within cooldown, report
3598
- // stale_prompt rather than writing a second key to the PTY.
3599
- if (typeof adapter.isApprovalRecentlyResolved === 'function' && adapter.isApprovalRecentlyResolved()) {
3600
- LOG.info('Command', `[resolveAction] CLI PTY → stale_prompt (already resolved within cooldown)`);
3601
- return { success: true, stalePrompt: true, buttonIndex, button: buttons[buttonIndex] ?? button };
3602
- }
3603
- if (typeof adapter.resolveModal === 'function') {
3604
- adapter.resolveModal(buttonIndex);
3605
- } else {
3606
- const keys = '\x1B[B'.repeat(Math.max(0, buttonIndex)) + '\r';
3607
- adapter.writeRaw?.(keys);
3608
- }
3609
- LOG.info('Command', `[resolveAction] CLI PTY → buttonIndex=${buttonIndex} "${buttons[buttonIndex] ?? '?'}"`);
3610
- getTargetInstance(h, args)?.recordApprovalSelection?.(buttons[buttonIndex] ?? button);
3611
- return { success: true, buttonIndex, button: buttons[buttonIndex] ?? button };
3612
- }
3613
-
3614
- // 1. Extension transport: via AgentStreamManager
3615
- if (isExtensionTransport(transport) && h.agentStream && h.getCdp() && h.currentSession?.sessionId) {
3616
- const ok = await h.agentStream.resolveSessionAction(h.getCdp()!, h.currentSession.sessionId, action, button);
3617
- return { success: ok };
3618
- }
3619
-
3620
- // 1.5 ACP transport: resolve protocol permission request directly
3621
- if (transport === 'acp') {
3622
- const adapter = getTargetedCliAdapter(h, args, provider?.type);
3623
- const acpInstance = adapter?._acpInstance;
3624
- if (!acpInstance) return { success: false, error: 'ACP instance not found' };
3625
- if (typeof acpInstance.resolvePermission !== 'function') {
3626
- return { success: false, error: 'ACP resolvePermission not available' };
3627
- }
3628
-
3629
- try {
3630
- await acpInstance.resolvePermission(action === 'approve' || action === 'accept' || action === 'always');
3631
- LOG.info('Command', `[resolveAction] ACP → ${action}`);
3632
- return { success: true, action };
3633
- } catch (e: any) {
3634
- return { success: false, error: e?.message || 'ACP resolve action failed' };
3635
- }
3636
- }
3637
-
3638
- // 2. Webview Provider script
3639
- if (provider?.scripts?.webviewResolveAction || provider?.scripts?.webview_resolve_action) {
3640
- const script = h.getProviderScript('webviewResolveAction', { action, button, buttonText: button })
3641
- || h.getProviderScript('webview_resolve_action', { action, button, buttonText: button });
3642
- if (script) {
3643
- const cdp = h.getCdp();
3644
- if (cdp?.isConnected) {
3645
- try {
3646
- const matchText = provider?.webviewMatchText;
3647
- const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
3648
- const raw = await cdp.evaluateInWebviewFrame?.(script, matchFn);
3649
- let result: any = raw;
3650
- if (typeof raw === 'string') { try { result = JSON.parse(raw); } catch { } }
3651
- LOG.info('Command', `[resolveAction] webview script result: ${JSON.stringify(result)}`);
3652
-
3653
- if (result?.resolved) return { success: true, clicked: result.clicked };
3654
- if (result?.found && result.x != null && result.y != null) {
3655
- LOG.info('Command', `[resolveAction] Webview coordinate click not fully supported via CDP. Click directly in script.`);
3656
- }
3657
- if (result?.found || result?.resolved) return { success: true };
3658
- } catch (e: any) {
3659
- return { success: false, error: `webviewResolveAction failed: ${e.message}` };
3660
- }
3661
- }
3662
- }
3663
- }
3664
-
3665
- // 3. Provider script (Main DOM) → returns coords → CDP mouse click
3666
- if (provider?.scripts?.resolveAction) {
3667
- const script = provider.scripts.resolveAction({ action, button, buttonText: button });
3668
- if (script) {
3669
- const cdp = h.getCdp();
3670
- if (!cdp?.isConnected) return { success: false, error: 'CDP not connected' };
3671
- try {
3672
- const raw = await cdp.evaluate(script, 30000);
3673
- let result: any = raw;
3674
- if (typeof raw === 'string') { try { result = JSON.parse(raw); } catch {} }
3675
- LOG.info('Command', `[resolveAction] script result: ${JSON.stringify(result)}`);
3676
-
3677
- if (result?.resolved) {
3678
- LOG.info('Command', `[resolveAction] script-click resolved — "${result.clicked}"`);
3679
- return { success: true, clicked: result.clicked };
3680
- }
3681
- if (result?.found && result.x != null && result.y != null) {
3682
- const x = result.x;
3683
- const y = result.y;
3684
- await cdp.send('Input.dispatchMouseEvent', {
3685
- type: 'mousePressed', x, y, button: 'left', clickCount: 1
3686
- });
3687
- await cdp.send('Input.dispatchMouseEvent', {
3688
- type: 'mouseReleased', x, y, button: 'left', clickCount: 1
3689
- });
3690
- LOG.info('Command', `[resolveAction] CDP click at (${x}, ${y}) — "${result.text}"`);
3691
- return { success: true, clicked: result.text };
3692
- }
3693
- return { success: false, error: result?.found === false ? `Button not found: ${button}` : 'No coordinates' };
3694
- } catch (e: any) {
3695
- return { success: false, error: `resolveAction failed: ${e.message}` };
3696
- }
3697
- }
3698
- }
3699
-
3700
- return { success: false, error: 'resolveAction script not available for this provider' };
3701
- }
5
+ * This module is a re-export barrel. The implementation was split into focused
6
+ * sub-modules (chat-commands-shared / -scope / -debug-bundle / -read / -write)
7
+ * as a pure move; the public export surface here is unchanged. handler.ts does
8
+ * `import * as Chat from './chat-commands.js'` and keeps working unchanged.
9
+ */
10
+
11
+ export { READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS, buildSendInputSignature } from './chat-commands-shared.js';
12
+ export { evaluateReadChatNodeWorkspaceScope } from './chat-commands-scope.js';
13
+ export { sanitizeDebugBundleValue, handleGetChatDebugBundle } from './chat-commands-debug-bundle.js';
14
+ export { handleChatHistory, handleReadChat } from './chat-commands-read.js';
15
+ export {
16
+ handleSendChat,
17
+ handleListChats,
18
+ handleNewChat,
19
+ handleSwitchChat,
20
+ handleSetMode,
21
+ handleChangeModel,
22
+ handleSetThoughtLevel,
23
+ handleResolveAction,
24
+ } from './chat-commands-write.js';