@adhdev/daemon-core 0.9.82-rc.80 → 0.9.82-rc.82

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.
@@ -33,6 +33,13 @@ export interface MeshActiveWorkSummary {
33
33
  sourceCounts: Record<MeshActiveWorkSource, number>;
34
34
  statusCounts: Record<MeshActiveWorkStatus, number>;
35
35
  staleDirectCount: number;
36
+ /**
37
+ * When staleDirectCount > 0, this note clarifies that stale direct records are
38
+ * historical/recovery evidence — orphaned ledger entries whose original node or session
39
+ * is no longer present in the live mesh. They are NOT active or unresolved work items.
40
+ * The active queue (queue source) is the authoritative source for pending/assigned work.
41
+ */
42
+ staleDirectNote?: string;
36
43
  }
37
44
  export interface BuildMeshActiveWorkOptions {
38
45
  meshId: string;
@@ -47,5 +54,7 @@ export declare function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRec
47
54
  export declare function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): {
48
55
  activeWork: MeshActiveWorkRecord[];
49
56
  staleDirectWork: MeshActiveWorkRecord[];
57
+ staleDirectWorkNote?: string;
58
+ terminalDirectWork: MeshActiveWorkRecord[];
50
59
  summary: MeshActiveWorkSummary;
51
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.80",
3
+ "version": "0.9.82-rc.82",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -24,7 +24,8 @@ import { filterUserFacingChatMessages, normalizeChatMessages } from '../provider
24
24
  const RECENT_SEND_WINDOW_MS = 1200;
25
25
  export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
26
26
  const HERMES_CLI_STARTING_SEND_SETTLE_MS = 2_000;
27
- const CODEX_NATIVE_HISTORY_FRESH_MS = 5 * 60_000;
27
+ const CLI_NATIVE_HISTORY_FRESH_MS = 5 * 60_000;
28
+ const CLI_NATIVE_TRANSCRIPT_PROVIDERS = new Set(['codex-cli', 'claude-cli']);
28
29
  const recentSendByTarget = new Map<string, number>();
29
30
 
30
31
  interface ApprovalSelectableInstance extends ProviderInstance {
@@ -152,7 +153,17 @@ function getHistorySessionId(h: CommandHelpers, args: any): string | undefined {
152
153
  const instance = h.ctx.instanceManager?.getInstance(targetSessionId);
153
154
  const state = instance?.getState?.();
154
155
  const providerSessionId = typeof state?.providerSessionId === 'string' ? state.providerSessionId.trim() : '';
155
- return providerSessionId || targetSessionId;
156
+ if (providerSessionId) return providerSessionId;
157
+
158
+ const currentSession = h.currentSession as any;
159
+ if (currentSession?.sessionId === targetSessionId) {
160
+ const currentProviderSessionId = typeof currentSession.providerSessionId === 'string'
161
+ ? currentSession.providerSessionId.trim()
162
+ : '';
163
+ if (currentProviderSessionId) return currentProviderSessionId;
164
+ }
165
+
166
+ return targetSessionId;
156
167
  }
157
168
 
158
169
  function getInteractionId(args: any): string | undefined {
@@ -254,7 +265,9 @@ function buildCliMessageSourceProvenance(args: {
254
265
  return {
255
266
  selected: args.selected,
256
267
  provider: args.provider,
268
+ providerType: args.provider,
257
269
  ...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
270
+ ...(args.nativeHandle ? { nativeSessionId: args.nativeHandle } : {}),
258
271
  ...(args.fallbackReason ? { fallbackReason: args.fallbackReason } : {}),
259
272
  ...(args.nativeSource ? { nativeSource: args.nativeSource } : {}),
260
273
  ...(args.sourcePath ? { sourcePath: args.sourcePath } : {}),
@@ -282,7 +295,7 @@ function buildNativeHistoryFallbackReason(args: {
282
295
  safeMapping: boolean;
283
296
  freshEnough: boolean;
284
297
  }): string {
285
- if (args.providerType !== 'codex-cli') return 'provider_not_codex_cli';
298
+ if (!supportsCliNativeTranscript(args.providerType)) return 'provider_native_transcript_not_supported';
286
299
  if (args.nativeSource === 'native-unavailable') return 'native_history_unavailable';
287
300
  if (args.nativeSource && args.nativeSource !== 'provider-native') return `native_history_source_${args.nativeSource}`;
288
301
  if (args.nativeMessageCount <= 0) return 'native_history_empty';
@@ -291,8 +304,8 @@ function buildNativeHistoryFallbackReason(args: {
291
304
  return 'native_history_not_selected';
292
305
  }
293
306
 
294
- function isCodexCliProvider(providerType: string): boolean {
295
- return providerType === 'codex-cli';
307
+ function supportsCliNativeTranscript(providerType: string): boolean {
308
+ return CLI_NATIVE_TRANSCRIPT_PROVIDERS.has(providerType);
296
309
  }
297
310
 
298
311
  function hasSafeNativeHistoryMapping(args: {
@@ -323,7 +336,7 @@ function isNativeHistoryFreshEnough(args: {
323
336
  const ptyNewest = getMessageNewestReceivedAt(args.ptyMessages);
324
337
  if (nativeNewest > 0 && nativeNewest >= ptyNewest) return true;
325
338
  const sourceMtimeMs = Number(args.sourceMtimeMs || 0);
326
- if (sourceMtimeMs > 0 && Date.now() - sourceMtimeMs <= CODEX_NATIVE_HISTORY_FRESH_MS) return true;
339
+ if (sourceMtimeMs > 0 && Date.now() - sourceMtimeMs <= CLI_NATIVE_HISTORY_FRESH_MS) return true;
327
340
  return ptyNewest === 0 && nativeNewest > 0;
328
341
  }
329
342
 
@@ -390,7 +403,7 @@ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown):
390
403
  }
391
404
  switch (raw) {
392
405
  case 'starting':
393
- return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'generating';
406
+ return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'starting';
394
407
  case 'stopped':
395
408
  case 'disconnected':
396
409
  case 'not_monitored':
@@ -413,7 +426,18 @@ function shouldTrustCliAdapterTerminalStatus(parsedStatus: unknown, activeModal:
413
426
  return true;
414
427
  }
415
428
 
416
- function normalizeCliReadChatStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any): string {
429
+ function normalizeCliReadChatStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any, parsedMessages?: unknown[]): string {
430
+ const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
431
+ if (adapterRawStatus === 'starting'
432
+ && isGeneratingLikeStatus(parsedStatus)
433
+ && !hasNonEmptyModalButtons(activeModal)
434
+ && Array.isArray(parsedMessages)
435
+ && parsedMessages.length === 0
436
+ && Array.isArray(adapterStatus?.messages)
437
+ && adapterStatus.messages.length === 0
438
+ && !(typeof adapter.isProcessing === 'function' && adapter.isProcessing())) {
439
+ return 'starting';
440
+ }
417
441
  if (shouldTrustCliAdapterTerminalStatus(parsedStatus, activeModal, adapter, adapterStatus)) return 'idle';
418
442
  return typeof parsedStatus === 'string' && parsedStatus.trim() ? parsedStatus : 'idle';
419
443
  }
@@ -988,7 +1012,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
988
1012
  ? parsedRecord.coverage
989
1013
  : undefined;
990
1014
  const activeModal = parsedRecord.activeModal ?? parsedRecord.modal ?? null;
991
- const returnedStatus = normalizeCliReadChatStatus(parsedRecord.status, activeModal, adapter, adapterStatus);
1015
+ const returnedStatus = normalizeCliReadChatStatus(parsedRecord.status, activeModal, adapter, adapterStatus, parsedRecord.messages);
992
1016
  const runtimeMessageMerger = getTargetInstance(h, args) as RuntimeChatMessageMerger | null;
993
1017
  const parsedMessages = finalizeStreamingMessagesWhenIdle(parsedRecord.messages as ChatMessage[], returnedStatus);
994
1018
  const returnedMessages = runtimeMessageMerger?.category === 'cli'
@@ -1005,13 +1029,13 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1005
1029
  let messageSource = buildCliMessageSourceProvenance({
1006
1030
  selected: 'pty-parser',
1007
1031
  provider: adapter.cliType,
1008
- fallbackReason: isCodexCliProvider(providerType) ? 'native_history_not_checked' : 'provider_not_codex_cli',
1032
+ fallbackReason: supportsCliNativeTranscript(providerType) ? 'native_history_not_checked' : 'provider_native_transcript_not_supported',
1009
1033
  ptyMessages: returnedMessages,
1010
1034
  returnedMessages,
1011
1035
  ptyStatusApprovalOnly: false,
1012
1036
  });
1013
1037
 
1014
- if (isCodexCliProvider(providerType)) {
1038
+ if (supportsCliNativeTranscript(providerType)) {
1015
1039
  const agentStr = provider?.type || args?.agentType || getCurrentProviderType(h, adapter.cliType);
1016
1040
  const workspace = typeof args?.workspace === 'string'
1017
1041
  ? args.workspace
@@ -1128,7 +1152,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1128
1152
  returnedStatus: String(returnedStatus || ''),
1129
1153
  selectedMessageSource: (messageSource as any).selected,
1130
1154
  messageSource,
1131
- shouldPreferAdapterMessages: (messageSource as any).selected !== 'native-history',
1155
+ shouldPreferAdapterMessages: supportsCliNativeTranscript(providerType) && (messageSource as any).selected !== 'native-history',
1132
1156
  parsedMsgCount: parsedRecord.messages.length,
1133
1157
  returnedMsgCount: selectedMessages.length,
1134
1158
  },
@@ -1159,9 +1183,48 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1159
1183
  const historyProviderSessionId = typeof (history as any)?.providerSessionId === 'string'
1160
1184
  ? (history as any).providerSessionId
1161
1185
  : historySessionId;
1186
+ const historyMessages = Array.isArray((history as any)?.messages)
1187
+ ? normalizeChatMessages((history as any).messages as ChatMessage[])
1188
+ : [];
1189
+ const safeMapping = supportsCliNativeTranscript(agentStr)
1190
+ ? hasSafeNativeHistoryMapping({
1191
+ historySessionId,
1192
+ providerSessionId: historyProviderSessionId,
1193
+ workspace,
1194
+ nativeMessages: historyMessages,
1195
+ })
1196
+ : false;
1197
+ const nativeSelected = supportsCliNativeTranscript(agentStr)
1198
+ && (history as any).source === 'provider-native'
1199
+ && historyMessages.length > 0
1200
+ && safeMapping;
1201
+ const messageSource = buildCliMessageSourceProvenance({
1202
+ selected: nativeSelected ? 'native-history' : 'pty-parser',
1203
+ provider: agentStr,
1204
+ nativeHandle: historyProviderSessionId || historySessionId,
1205
+ fallbackReason: nativeSelected
1206
+ ? undefined
1207
+ : buildNativeHistoryFallbackReason({
1208
+ providerType: agentStr,
1209
+ nativeSource: (history as any).source,
1210
+ nativeMessageCount: historyMessages.length,
1211
+ safeMapping,
1212
+ freshEnough: true,
1213
+ }),
1214
+ nativeSource: (history as any).source,
1215
+ sourcePath: (history as any).sourcePath,
1216
+ sourceMtimeMs: (history as any).sourceMtimeMs,
1217
+ nativeMessages: historyMessages,
1218
+ returnedMessages: historyMessages,
1219
+ safeMapping,
1220
+ freshEnough: true,
1221
+ ptyStatusApprovalOnly: false,
1222
+ });
1162
1223
  return buildReadChatCommandResult({
1163
- messages: Array.isArray((history as any)?.messages) ? (history as any).messages : [],
1224
+ messages: historyMessages,
1164
1225
  status: 'idle',
1226
+ messageSource,
1227
+ transcriptProvenance: messageSource,
1165
1228
  ...(typeof (history as any)?.title === 'string' ? { title: (history as any).title } : {}),
1166
1229
  ...(historyProviderSessionId ? { providerSessionId: historyProviderSessionId } : {}),
1167
1230
  ...(((provider?.historyBehavior as any)?.transcriptAuthority === 'provider' || (provider?.historyBehavior as any)?.transcriptAuthority === 'daemon')
@@ -81,6 +81,7 @@ export interface CliManagerDeps {
81
81
  type CommandResult = { success: boolean;[key: string]: unknown };
82
82
 
83
83
  const BUSY_AGENT_STATUSES = new Set(['generating', 'running', 'streaming', 'starting', 'busy', 'waiting', 'waiting_approval', 'long_generating']);
84
+ const ZERO_MESSAGE_STARTING_SEND_WAIT_MS = 2_000;
84
85
 
85
86
  function normalizeAgentStatus(value: unknown): string {
86
87
  return typeof value === 'string' ? value.trim().toLowerCase() : '';
@@ -104,6 +105,24 @@ function hasAdapterPendingResponse(adapter: any): boolean {
104
105
  return false;
105
106
  }
106
107
 
108
+ function countMessages(value: unknown): number {
109
+ return Array.isArray(value) ? value.length : 0;
110
+ }
111
+
112
+ function hasZeroMessageStartingLaunch(adapter: any): boolean {
113
+ const adapterStatus = adapter?.getStatus?.({ allowParse: false }) ?? adapter?.getStatus?.() ?? {};
114
+ const parsedStatus = typeof adapter?.getScriptParsedStatus === 'function'
115
+ ? adapter.getScriptParsedStatus()
116
+ : {};
117
+ const adapterRawStatus = normalizeAgentStatus(adapterStatus?.status);
118
+ const parsedRawStatus = normalizeAgentStatus(parsedStatus?.status);
119
+ if (adapterRawStatus !== 'starting') return false;
120
+ if (parsedRawStatus && parsedRawStatus !== 'starting' && parsedRawStatus !== 'generating') return false;
121
+ if (hasNonEmptyModalButtons(adapterStatus?.activeModal ?? adapterStatus?.modal ?? parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
122
+ if (countMessages(adapterStatus?.messages) > 0 || countMessages(parsedStatus?.messages) > 0) return false;
123
+ return !hasAdapterPendingResponse(adapter);
124
+ }
125
+
107
126
  function shouldSuppressStaleParsedBusyStatus(adapterStatus: string, parsedStatus: any, adapter: any): boolean {
108
127
  const parsedRawStatus = normalizeAgentStatus(parsedStatus?.status);
109
128
  if (!BUSY_AGENT_STATUSES.has(parsedRawStatus)) return false;
@@ -130,6 +149,20 @@ function getEffectiveAgentSendStatus(adapter: any): string {
130
149
  return adapterStatus;
131
150
  }
132
151
 
152
+ async function waitForZeroMessageStartingLaunch(adapter: any): Promise<boolean> {
153
+ try {
154
+ if (!hasZeroMessageStartingLaunch(adapter)) return false;
155
+ } catch {
156
+ return false;
157
+ }
158
+ await new Promise(resolve => setTimeout(resolve, ZERO_MESSAGE_STARTING_SEND_WAIT_MS));
159
+ try {
160
+ return hasZeroMessageStartingLaunch(adapter);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
133
166
  export interface CliTransportFactoryParams {
134
167
  runtimeId: string;
135
168
  providerType: string;
@@ -1123,7 +1156,12 @@ export class DaemonCliManager {
1123
1156
  const { adapter, key } = found;
1124
1157
 
1125
1158
  if (action === 'send_chat') {
1126
- const currentStatus = getEffectiveAgentSendStatus(adapter);
1159
+ let currentStatus = getEffectiveAgentSendStatus(adapter);
1160
+ if (currentStatus === 'starting' && await waitForZeroMessageStartingLaunch(adapter)) {
1161
+ currentStatus = 'idle';
1162
+ } else if (currentStatus === 'starting') {
1163
+ currentStatus = getEffectiveAgentSendStatus(adapter);
1164
+ }
1127
1165
  if (BUSY_AGENT_STATUSES.has(currentStatus)) {
1128
1166
  return {
1129
1167
  success: false,
@@ -36,6 +36,13 @@ export interface MeshActiveWorkSummary {
36
36
  sourceCounts: Record<MeshActiveWorkSource, number>;
37
37
  statusCounts: Record<MeshActiveWorkStatus, number>;
38
38
  staleDirectCount: number;
39
+ /**
40
+ * When staleDirectCount > 0, this note clarifies that stale direct records are
41
+ * historical/recovery evidence — orphaned ledger entries whose original node or session
42
+ * is no longer present in the live mesh. They are NOT active or unresolved work items.
43
+ * The active queue (queue source) is the authoritative source for pending/assigned work.
44
+ */
45
+ staleDirectNote?: string;
39
46
  }
40
47
 
41
48
  export interface BuildMeshActiveWorkOptions {
@@ -134,6 +141,7 @@ export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]):
134
141
  sourceCounts[item.source] += 1;
135
142
  statusCounts[item.status] += 1;
136
143
  }
144
+ const staleDirectCount = activeWork.filter(item => item.source === 'direct' && item.staleReason).length;
137
145
  return {
138
146
  totalActiveCount: activeWork.length,
139
147
  queueActiveCount: sourceCounts.queue,
@@ -144,14 +152,17 @@ export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]):
144
152
  idleCount: statusCounts.idle,
145
153
  sourceCounts,
146
154
  statusCounts,
147
- staleDirectCount: activeWork.filter(item => item.source === 'direct' && item.staleReason).length,
155
+ staleDirectCount,
156
+ ...(staleDirectCount > 0 ? { staleDirectNote: 'Stale direct records are orphaned ledger entries whose node/session no longer exists. They are historical recovery evidence only — not active or unresolved work. The queue (source: queue) is authoritative for pending/assigned tasks.' } : {}),
148
157
  };
149
158
  }
150
159
 
151
- export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeWork: MeshActiveWorkRecord[]; staleDirectWork: MeshActiveWorkRecord[]; summary: MeshActiveWorkSummary } {
160
+
161
+ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeWork: MeshActiveWorkRecord[]; staleDirectWork: MeshActiveWorkRecord[]; staleDirectWorkNote?: string; terminalDirectWork: MeshActiveWorkRecord[]; summary: MeshActiveWorkSummary } {
152
162
  const now = opts.now ?? Date.now();
153
163
  const records: MeshActiveWorkRecord[] = [];
154
164
  const staleDirectWork: MeshActiveWorkRecord[] = [];
165
+ const terminalDirectWork: MeshActiveWorkRecord[] = [];
155
166
 
156
167
  for (const task of opts.queue || []) {
157
168
  if (task.status !== 'pending' && task.status !== 'assigned') continue;
@@ -184,7 +195,6 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
184
195
  const live = sessionStatusFromNodes(opts.nodes, dispatch.nodeId, dispatch.sessionId);
185
196
  const status = terminalStatus || live.status || 'assigned';
186
197
  const terminalRow = Boolean(terminal && terminal.kind !== 'task_approval_needed');
187
- if (terminalRow && opts.includeTerminalDirect !== true) continue;
188
198
  const message = readString(dispatch.payload?.message) || readString(dispatch.payload?.summary) || '';
189
199
  const { title, summary } = summarizeMessage(message);
190
200
  const record: MeshActiveWorkRecord = {
@@ -207,6 +217,10 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
207
217
  terminalAt: terminal?.timestamp,
208
218
  staleReason: live.staleReason,
209
219
  };
220
+ if (terminalRow) {
221
+ terminalDirectWork.push(record);
222
+ if (opts.includeTerminalDirect !== true) continue;
223
+ }
210
224
  if (live.staleReason && !terminalRow) {
211
225
  staleDirectWork.push(record);
212
226
  continue;
@@ -216,7 +230,14 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
216
230
 
217
231
  records.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
218
232
  staleDirectWork.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
233
+ terminalDirectWork.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
219
234
  const summary = buildMeshActiveWorkSummary(records);
220
235
  summary.staleDirectCount = staleDirectWork.length;
221
- return { activeWork: records, staleDirectWork, summary };
236
+ const staleDirectWorkNote = staleDirectWork.length > 0
237
+ ? 'These are orphaned ledger entries whose original node or session no longer exists in the live mesh. They are historical/recovery evidence only — not active or unresolved work. Do not treat staleDirectCount as a status mismatch; use the queue (source: queue) as authoritative for pending/assigned tasks.'
238
+ : undefined;
239
+ if (staleDirectWorkNote) {
240
+ summary.staleDirectNote = staleDirectWorkNote;
241
+ }
242
+ return { activeWork: records, staleDirectWork, staleDirectWorkNote, terminalDirectWork, summary };
222
243
  }
@@ -82,6 +82,31 @@ function hasPendingRefineTerminalEventDuplicate(event: PendingMeshCoordinatorEve
82
82
  );
83
83
  }
84
84
 
85
+ function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string {
86
+ const metadata = readRecord(event.metadataEvent) || {};
87
+ const sessionId = resolveEventSessionId(metadata);
88
+ const providerSessionId = readNonEmptyString(metadata.providerSessionId);
89
+ const taskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
90
+ const jobId = readRefineJobId(event);
91
+ const timestamp = metadata.timestamp !== undefined && metadata.timestamp !== null ? String(metadata.timestamp) : '';
92
+ return [
93
+ event.meshId,
94
+ event.event,
95
+ event.nodeId || '',
96
+ sessionId || '',
97
+ providerSessionId || '',
98
+ taskId || '',
99
+ jobId || '',
100
+ timestamp || '',
101
+ ].join('::');
102
+ }
103
+
104
+ function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean {
105
+ const fingerprint = buildPendingEventFingerprint(event);
106
+ if (!fingerprint.trim()) return false;
107
+ return getPendingMeshCoordinatorEvents(event.meshId).some((pending) => buildPendingEventFingerprint(pending) === fingerprint);
108
+ }
109
+
85
110
  function getPendingEventsPath(meshId: string): string {
86
111
  const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
87
112
  return join(getLedgerDir(), `${safe}.pending-events.jsonl`);
@@ -93,6 +118,10 @@ export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEv
93
118
  LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for refine job ${readRefineJobId(event)}`);
94
119
  return true;
95
120
  }
121
+ if (hasPendingCoordinatorEventDuplicate(event)) {
122
+ LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for mesh ${event.meshId}`);
123
+ return true;
124
+ }
96
125
  appendFileSync(getPendingEventsPath(event.meshId), JSON.stringify(event) + '\n', 'utf-8');
97
126
  return true;
98
127
  } catch (e: any) {
@@ -1071,6 +1100,12 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1071
1100
  return true;
1072
1101
  });
1073
1102
 
1103
+ // Refine terminal events (refine:completed, refine:failed) are coordinator-delivered
1104
+ // synchronously; only buffer them for MCP when no CLI coordinator is present.
1105
+ // Agent runtime events (agent:*) use dual delivery so both CLI and MCP coordinators
1106
+ // receive them regardless of whether a live CLI coordinator session is active.
1107
+ const isRefineTerminalEvent = REFINE_TERMINAL_EVENTS.has(args.event);
1108
+
1074
1109
  if (coordinatorInstances.length === 0) {
1075
1110
  // No CLI coordinator session found — buffer for MCP-based coordinators.
1076
1111
  if (queuePendingMeshCoordinatorEvent({
@@ -1091,6 +1126,28 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1091
1126
  return { success: true, forwarded: 0 };
1092
1127
  }
1093
1128
 
1129
+ // CLI coordinator is present. For non-refine events, also buffer for MCP coordinators
1130
+ // that poll via get_pending_mesh_events (dual delivery). Refine terminal events are
1131
+ // forwarded directly only — they must not accumulate in the pending queue when a live
1132
+ // coordinator already received them.
1133
+ if (!isRefineTerminalEvent) {
1134
+ if (queuePendingMeshCoordinatorEvent({
1135
+ event: args.event,
1136
+ meshId: args.meshId,
1137
+ nodeLabel: args.nodeLabel,
1138
+ nodeId: args.nodeId || undefined,
1139
+ workspace: readNonEmptyString(args.metadataEvent.workspace),
1140
+ metadataEvent: {
1141
+ ...args.metadataEvent,
1142
+ ...(recoveryContext ? { recoveryContext } : {}),
1143
+ },
1144
+ coordinatorMessage: messageText,
1145
+ queuedAt: Date.now(),
1146
+ })) {
1147
+ LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
1148
+ }
1149
+ }
1150
+
1094
1151
  for (const coord of coordinatorInstances) {
1095
1152
  const coordState = coord.getState();
1096
1153
  LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}`);
@@ -2,7 +2,7 @@ import type { MessagePart, ModalInfo, ReadChatResult } from './contracts.js'
2
2
  import { normalizeMessageParts } from './contracts.js'
3
3
  import type { ChatBubbleState, ChatMessage } from '../types.js'
4
4
 
5
- const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'streaming', 'long_generating'] as const
5
+ const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'starting', 'streaming', 'long_generating'] as const
6
6
  const VALID_ROLES = ['user', 'assistant', 'system', 'human'] as const
7
7
  const VALID_BUBBLE_STATES = ['draft', 'streaming', 'final', 'removed'] as const
8
8
  const VALID_TURN_STATUSES = ['open', 'waiting_approval', 'complete', 'error'] as const