@adhdev/daemon-core 0.9.82-rc.132 → 0.9.82-rc.134

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.
@@ -21,6 +21,12 @@ export interface MeshActiveWorkRecord {
21
21
  terminalKind?: string;
22
22
  terminalAt?: string;
23
23
  staleReason?: string;
24
+ /**
25
+ * When true, the session targeted by this direct dispatch still exists in the live mesh
26
+ * but did not transition to generating — the dispatch was not acknowledged by the provider.
27
+ * Distinct from historical/orphaned stale entries where the node/session is gone.
28
+ */
29
+ staleDispatchUnacknowledged?: boolean;
24
30
  }
25
31
  export interface MeshActiveWorkSummary {
26
32
  totalActiveCount: number;
@@ -34,10 +40,15 @@ export interface MeshActiveWorkSummary {
34
40
  statusCounts: Record<MeshActiveWorkStatus, number>;
35
41
  staleDirectCount: number;
36
42
  /**
37
- * When staleDirectCount > 0, this note clarifies that stale direct records are
38
- * historical/recovery evidenceorphaned 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.
43
+ * Count of stale direct entries where the target session still exists in the live mesh
44
+ * but never transitioned to generating these are fresh dispatch failures, not historical orphans.
45
+ * Requires immediate recovery: launch a fresh session and retry the task.
46
+ */
47
+ staleDirectUnacknowledgedCount?: number;
48
+ /**
49
+ * When staleDirectCount > 0, this note clarifies what kind of stale records exist.
50
+ * Fresh unacknowledged dispatches carry an actionable recovery note; orphaned historical
51
+ * entries carry the "historical evidence only" note. Both may coexist.
41
52
  */
42
53
  staleDirectNote?: string;
43
54
  }
@@ -32,6 +32,7 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
32
32
  duplicateRefineTerminalEvent?: undefined;
33
33
  duplicateCompletion?: undefined;
34
34
  error?: undefined;
35
+ bufferedForGeneratingCoordinator?: undefined;
35
36
  } | {
36
37
  success: boolean;
37
38
  forwarded: number;
@@ -42,6 +43,7 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
42
43
  duplicateRefineTerminalEvent?: undefined;
43
44
  duplicateCompletion?: undefined;
44
45
  error?: undefined;
46
+ bufferedForGeneratingCoordinator?: undefined;
45
47
  } | {
46
48
  success: boolean;
47
49
  forwarded: number;
@@ -52,6 +54,7 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
52
54
  terminalLedgerKind?: undefined;
53
55
  duplicateCompletion?: undefined;
54
56
  error?: undefined;
57
+ bufferedForGeneratingCoordinator?: undefined;
55
58
  } | {
56
59
  success: boolean;
57
60
  forwarded: number;
@@ -62,6 +65,7 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
62
65
  terminalLedgerKind?: undefined;
63
66
  duplicateRefineTerminalEvent?: undefined;
64
67
  error?: undefined;
68
+ bufferedForGeneratingCoordinator?: undefined;
65
69
  } | {
66
70
  success: boolean;
67
71
  forwarded: number;
@@ -72,6 +76,7 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
72
76
  terminalLedgerKind?: undefined;
73
77
  duplicateRefineTerminalEvent?: undefined;
74
78
  error?: undefined;
79
+ bufferedForGeneratingCoordinator?: undefined;
75
80
  } | {
76
81
  success: boolean;
77
82
  forwarded: number;
@@ -82,6 +87,18 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
82
87
  duplicateRefineTerminalEvent?: undefined;
83
88
  duplicateCompletion?: undefined;
84
89
  error?: undefined;
90
+ bufferedForGeneratingCoordinator?: undefined;
91
+ } | {
92
+ success: boolean;
93
+ forwarded: number;
94
+ bufferedForGeneratingCoordinator: boolean;
95
+ suppressed?: undefined;
96
+ intentionalCleanupStop?: undefined;
97
+ terminalLedgerEvidence?: undefined;
98
+ terminalLedgerKind?: undefined;
99
+ duplicateRefineTerminalEvent?: undefined;
100
+ duplicateCompletion?: undefined;
101
+ error?: undefined;
85
102
  } | {
86
103
  success: boolean;
87
104
  error: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.132",
3
+ "version": "0.9.82-rc.134",
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",
@@ -211,7 +211,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
211
211
 
212
212
  - **Minimize coordinator context.** The coordinator's job is routing, not implementing. Do not read source files, run commands, or analyze code directly — delegate all of that to node agents. Your context should stay lean.
213
213
  - **Delegate analysis too.** If you need to understand a bug or explore the codebase, send that investigation as a task to the queue or a node. Do not do it yourself.
214
- - **Respect explicit provider requests.** If the user names an agent/provider, pass the matching provider type to \`mesh_launch_session\`: Hermes → \`hermes-cli\`, Claude Code/Claude → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`. Never substitute \`claude-cli\` just because the coordinator itself is Claude Code.
214
+ - **Respect explicit provider requests.** If the user names an agent/provider, pass the matching provider type to \`mesh_launch_session\`: Hermes → \`hermes-cli\`, Claude Code/Claude → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`, Antigravity → \`antigravity-cli\`. Never substitute \`claude-cli\` just because the coordinator itself is Claude Code.
215
215
  - **Front-load new task messages.** When calling \`mesh_enqueue_task\` or \`mesh_send_task\` for a new task, include everything the agent needs: what files to touch, what the problem is, what the fix should look like. The agent won't ask follow-up questions.
216
216
  - **Avoid context-wasting restarts.** For follow-up, retry, commit/push, preview, or cleanup work on the same issue, prefer the existing idle session and send only the delta from its last verified state. Start a fresh chat/session only for genuinely independent work, explicit provider/user request, unsafe transcript contamination, or required branch/worktree isolation.
217
217
  - **Don't inspect code.** Treat delegated agent summaries as self-reports, not verification. Verify side effects via \`mesh_git_status\` (including related repo freshness when configured), not by reading source files.
@@ -23,6 +23,12 @@ export interface MeshActiveWorkRecord {
23
23
  terminalKind?: string;
24
24
  terminalAt?: string;
25
25
  staleReason?: string;
26
+ /**
27
+ * When true, the session targeted by this direct dispatch still exists in the live mesh
28
+ * but did not transition to generating — the dispatch was not acknowledged by the provider.
29
+ * Distinct from historical/orphaned stale entries where the node/session is gone.
30
+ */
31
+ staleDispatchUnacknowledged?: boolean;
26
32
  }
27
33
 
28
34
  export interface MeshActiveWorkSummary {
@@ -37,10 +43,15 @@ export interface MeshActiveWorkSummary {
37
43
  statusCounts: Record<MeshActiveWorkStatus, number>;
38
44
  staleDirectCount: number;
39
45
  /**
40
- * When staleDirectCount > 0, this note clarifies that stale direct records are
41
- * historical/recovery evidenceorphaned 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.
46
+ * Count of stale direct entries where the target session still exists in the live mesh
47
+ * but never transitioned to generating these are fresh dispatch failures, not historical orphans.
48
+ * Requires immediate recovery: launch a fresh session and retry the task.
49
+ */
50
+ staleDirectUnacknowledgedCount?: number;
51
+ /**
52
+ * When staleDirectCount > 0, this note clarifies what kind of stale records exist.
53
+ * Fresh unacknowledged dispatches carry an actionable recovery note; orphaned historical
54
+ * entries carry the "historical evidence only" note. Both may coexist.
44
55
  */
45
56
  staleDirectNote?: string;
46
57
  }
@@ -163,6 +174,7 @@ export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]):
163
174
  statusCounts[item.status] += 1;
164
175
  }
165
176
  const staleDirectCount = activeWork.filter(item => item.source === 'direct' && item.staleReason).length;
177
+ const staleDirectUnacknowledgedCount = activeWork.filter(item => item.source === 'direct' && item.staleDispatchUnacknowledged).length;
166
178
  return {
167
179
  totalActiveCount: activeWork.length,
168
180
  queueActiveCount: sourceCounts.queue,
@@ -174,6 +186,7 @@ export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]):
174
186
  sourceCounts,
175
187
  statusCounts,
176
188
  staleDirectCount,
189
+ ...(staleDirectUnacknowledgedCount > 0 ? { staleDirectUnacknowledgedCount } : {}),
177
190
  ...(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.' } : {}),
178
191
  };
179
192
  }
@@ -216,11 +229,25 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
216
229
  const live = sessionStatusFromNodes(opts.nodes, dispatch.nodeId, dispatch.sessionId);
217
230
  const status = terminalStatus || live.status || 'assigned';
218
231
  const terminalRow = Boolean(terminal && terminal.kind !== 'task_approval_needed');
219
- const ledgerOnlyStaleReason = !terminalRow && (status === 'idle' || (!terminalStatus && !live.status))
232
+ // dispatchedToIdleSession is recorded by mesh_send_task when the target session was idle
233
+ // at dispatch time. Combined with status === 'idle', it confirms no runtime transition.
234
+ const dispatchedToIdleSession = dispatch.payload?.dispatchedToIdleSession === true;
235
+ // Flag stale when no terminal event AND either:
236
+ // - session is now idle (dispatch not acknowledged), OR
237
+ // - no live status reported (node/session absent from live mesh), OR
238
+ // - payload flagged idle-at-dispatch AND session is still not generating (redundant guard for clarity).
239
+ const isNoTransition = !terminalStatus && !live.status;
240
+ const isIdleUnacknowledged = status === 'idle';
241
+ const ledgerOnlyStaleReason = !terminalRow && (isIdleUnacknowledged || isNoTransition || (dispatchedToIdleSession && isIdleUnacknowledged))
220
242
  ? 'direct task dispatch has no provider acknowledgement, transcript append, or active runtime transition'
221
243
  : undefined;
222
244
  const message = readString(dispatch.payload?.message) || readString(dispatch.payload?.summary) || '';
223
245
  const { title, summary } = summarizeMessage(message);
246
+ // A dispatch is a fresh unacknowledged failure when the node and session both still exist
247
+ // in the live mesh but the session never transitioned to generating. This is distinct from
248
+ // historical/orphaned stale entries where the node or session is gone from the live mesh.
249
+ // fresh-unacknowledged: ledgerOnlyStaleReason is set (session didn't ack) AND no live.staleReason (node/session is present)
250
+ const isFreshUnacknowledged = Boolean(ledgerOnlyStaleReason && !live.staleReason);
224
251
  const record: MeshActiveWorkRecord = {
225
252
  taskId,
226
253
  source: 'direct',
@@ -240,6 +267,7 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
240
267
  terminalKind: terminal?.kind,
241
268
  terminalAt: terminal?.timestamp,
242
269
  staleReason: live.staleReason || ledgerOnlyStaleReason,
270
+ ...(isFreshUnacknowledged ? { staleDispatchUnacknowledged: true } : {}),
243
271
  };
244
272
  if (terminalRow) {
245
273
  terminalDirectWork.push(record);
@@ -257,8 +285,19 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
257
285
  terminalDirectWork.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
258
286
  const summary = buildMeshActiveWorkSummary(records);
259
287
  summary.staleDirectCount = staleDirectWork.length;
288
+ const unacknowledgedCount = staleDirectWork.filter(r => r.staleDispatchUnacknowledged).length;
289
+ if (unacknowledgedCount > 0) {
290
+ summary.staleDirectUnacknowledgedCount = unacknowledgedCount;
291
+ }
292
+ // Build a note that accurately describes what's in the stale set.
293
+ // When there are fresh unacknowledged entries (node/session still live, session never started),
294
+ // use the actionable recovery note instead of the misleading "historical evidence only" note.
260
295
  const staleDirectWorkNote = staleDirectWork.length > 0
261
- ? '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.'
296
+ ? unacknowledgedCount > 0 && unacknowledgedCount === staleDirectWork.length
297
+ ? `${unacknowledgedCount} direct dispatch(es) were not acknowledged by the target session — the session received the agent_command but never transitioned to generating. This is a fresh dispatch failure, not historical noise. Recovery: launch a fresh session on the same node and retry the task, or use mesh_enqueue_task for queue-based assignment.`
298
+ : unacknowledgedCount > 0
299
+ ? `${unacknowledgedCount} of ${staleDirectWork.length} stale direct record(s) are fresh unacknowledged dispatch failures (session still live but never transitioned to generating); the rest are orphaned historical entries whose node/session no longer exists. Fresh unacknowledged dispatches need recovery: launch a fresh session and retry. Orphaned entries are historical evidence only — not active or unresolved work.`
300
+ : '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.'
262
301
  : undefined;
263
302
  if (staleDirectWorkNote) {
264
303
  summary.staleDirectNote = staleDirectWorkNote;
@@ -415,22 +415,38 @@ function findRecentTerminalLedgerEvidence(args: {
415
415
  meshId: string;
416
416
  sessionId?: string;
417
417
  nodeId?: string;
418
- }): { kind: MeshLedgerKind; payload: Record<string, unknown>; timestamp: string } | null {
418
+ }): { id: string; kind: MeshLedgerKind; payload: Record<string, unknown>; timestamp: string } | null {
419
419
  if (!args.sessionId && !args.nodeId) return null;
420
420
  const entries = readLedgerEntries(args.meshId);
421
421
  for (let i = entries.length - 1; i >= 0; i--) {
422
422
  const entry = entries[i];
423
423
  if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed' && entry.kind !== 'task_stalled') continue;
424
424
  if (args.sessionId && entry.sessionId === args.sessionId) {
425
- return { kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
425
+ return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
426
426
  }
427
427
  if (!args.sessionId && args.nodeId && entry.nodeId === args.nodeId) {
428
- return { kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
428
+ return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
429
429
  }
430
430
  }
431
431
  return null;
432
432
  }
433
433
 
434
+ // Returns true when a task_dispatched entry for the given session appears AFTER the terminal
435
+ // entry (identified by terminalId) in ledger order. Positional (append) order is used rather
436
+ // than timestamp comparison because both entries may share the same millisecond.
437
+ function hasDispatchAfterTerminal(meshId: string, sessionId: string, terminalId: string): boolean {
438
+ const entries = readLedgerEntries(meshId);
439
+ let pastTerminal = false;
440
+ for (const entry of entries) {
441
+ if (!pastTerminal) {
442
+ if (entry.id === terminalId) pastTerminal = true;
443
+ continue;
444
+ }
445
+ if (entry.kind === 'task_dispatched' && entry.sessionId === sessionId) return true;
446
+ }
447
+ return false;
448
+ }
449
+
434
450
  function buildLongGeneratingCompletionReconciliation(args: {
435
451
  meshId: string;
436
452
  nodeId?: string;
@@ -1060,17 +1076,22 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1060
1076
  nodeId: eventNodeId || undefined,
1061
1077
  });
1062
1078
  if (terminal?.kind === 'task_completed' && !sessionHasActiveAssignment(args.meshId, eventSessionId)) {
1063
- const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
1064
- const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
1065
- const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
1066
- const eventFinalSummary = readNonEmptyString(args.metadataEvent.finalSummary);
1067
- if (
1068
- (terminalProviderSessionId && terminalProviderSessionId === eventProviderSessionId)
1069
- || (terminalFinalSummary && terminalFinalSummary === eventFinalSummary)
1070
- || args.metadataEvent.source === 'long_generating_reconciliation'
1071
- ) {
1072
- LOG.info('MeshEvents', `Suppressed duplicate completion with existing terminal ledger evidence for mesh ${args.meshId} session ${eventSessionId}`);
1073
- return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true, terminalLedgerEvidence: true };
1079
+ // If a new task_dispatched was recorded for this session after the prior terminal,
1080
+ // this completion belongs to the new task — never suppress it as a duplicate.
1081
+ const newDispatchAfterTerminal = hasDispatchAfterTerminal(args.meshId, eventSessionId, terminal.id);
1082
+ if (!newDispatchAfterTerminal) {
1083
+ const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
1084
+ const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
1085
+ const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
1086
+ const eventFinalSummary = readNonEmptyString(args.metadataEvent.finalSummary);
1087
+ if (
1088
+ (terminalProviderSessionId && terminalProviderSessionId === eventProviderSessionId)
1089
+ || (terminalFinalSummary && terminalFinalSummary === eventFinalSummary)
1090
+ || args.metadataEvent.source === 'long_generating_reconciliation'
1091
+ ) {
1092
+ LOG.info('MeshEvents', `Suppressed duplicate completion with existing terminal ledger evidence for mesh ${args.meshId} session ${eventSessionId}`);
1093
+ return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true, terminalLedgerEvidence: true };
1094
+ }
1074
1095
  }
1075
1096
  }
1076
1097
  const duplicateCompletion = isDuplicateMeshCompletionEvent({
@@ -1332,7 +1353,24 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1332
1353
  // that poll via get_pending_mesh_events (dual delivery). Refine terminal events are
1333
1354
  // forwarded directly only — they must not accumulate in the pending queue when a live
1334
1355
  // coordinator already received them.
1335
- if (!isRefineTerminalEvent) {
1356
+ //
1357
+ // Exception: if ALL live CLI coordinator instances are in a generating/active state
1358
+ // when a refine terminal event fires (e.g. a Codex CLI coordinator that triggered an
1359
+ // async refine job and is still in the generating turn that sent it), the coordinator
1360
+ // cannot immediately receive send_message input. Buffer the event to the pending queue
1361
+ // so it is available via get_pending_mesh_events when the coordinator returns to idle.
1362
+ // Critically: do NOT attempt send_message injection into a generating PTY coordinator for
1363
+ // terminal refine events — injecting text into an active PTY can corrupt the input stream
1364
+ // and leave the coordinator stuck in generating state, unable to process the refine result.
1365
+ const allCoordinatorsGenerating = isRefineTerminalEvent && coordinatorInstances.every((inst) => {
1366
+ const s = inst.getState();
1367
+ const status = readNonEmptyString(s.status).toLowerCase();
1368
+ const activeChatStatus = readNonEmptyString(s.activeChat?.status).toLowerCase();
1369
+ return status === 'generating' || status === 'streaming' || status === 'long_generating'
1370
+ || activeChatStatus === 'generating' || activeChatStatus === 'streaming';
1371
+ });
1372
+
1373
+ if (!isRefineTerminalEvent || allCoordinatorsGenerating) {
1336
1374
  if (queuePendingMeshCoordinatorEvent({
1337
1375
  event: args.event,
1338
1376
  meshId: args.meshId,
@@ -1346,10 +1384,23 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1346
1384
  coordinatorMessage: messageText,
1347
1385
  queuedAt: Date.now(),
1348
1386
  })) {
1349
- LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
1387
+ if (allCoordinatorsGenerating) {
1388
+ LOG.info('MeshEvents', `Queued ${args.event} for generating CLI coordinator (mesh ${args.meshId}) — will be delivered via get_pending_mesh_events when coordinator returns to idle`);
1389
+ } else {
1390
+ LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
1391
+ }
1350
1392
  }
1351
1393
  }
1352
1394
 
1395
+ // When all CLI coordinators are actively generating and a terminal refine event fires,
1396
+ // skip send_message injection entirely. The event is already buffered to the pending queue.
1397
+ // Injecting into a generating PTY coordinator can corrupt its input stream and cause it to
1398
+ // remain stuck in generating state, never processing the refine result.
1399
+ // The coordinator will drain pending events via get_pending_mesh_events on its next idle cycle.
1400
+ if (allCoordinatorsGenerating) {
1401
+ return { success: true, forwarded: 0, bufferedForGeneratingCoordinator: true };
1402
+ }
1403
+
1353
1404
  for (const coord of coordinatorInstances) {
1354
1405
  const coordState = coord.getState();
1355
1406
  LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}`);
@@ -899,7 +899,16 @@ export class CliProviderInstance implements ProviderInstance {
899
899
  if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
900
900
  if (adapterRawStatus !== 'idle') return false;
901
901
  if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
902
- return !this.hasAdapterPendingResponse();
902
+ if (this.hasAdapterPendingResponse()) return false;
903
+ // Do not suppress when the adapter's raw response buffer is still non-empty.
904
+ // This catches the case where isWaitingForResponse has already flipped to false
905
+ // (so getPartialResponse() returns '') but the provider's native parser still
906
+ // reports generating because it's parsing buffered content. Suppressing the
907
+ // finalization block here would emit a false completion event while the provider
908
+ // session is still actively processing its response stream.
909
+ const adapterAny = this.adapter as any;
910
+ if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
911
+ return true;
903
912
  }
904
913
 
905
914
  private getCompletedFinalizationBlock(latestVisibleStatus: string): CompletedFinalizationBlock | null {
@@ -1122,12 +1131,30 @@ export class CliProviderInstance implements ProviderInstance {
1122
1131
  }
1123
1132
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
1124
1133
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
1125
- // If debounce still pending (generating lasted < 1s), cancel both events
1134
+ // If debounce still pending (generating lasted < 1s), cancel both UI events.
1135
+ // Still emit agent:generating_completed so mesh orchestration can record
1136
+ // task_completed for direct dispatches that complete faster than the debounce.
1126
1137
  if (this.generatingDebouncePending) {
1127
- LOG.info('CLI', `[${this.type}] suppressed short generating (${now - this.generatingStartedAt}ms)`);
1138
+ const shortDurationMs = this.generatingStartedAt ? now - this.generatingStartedAt : 0;
1139
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms)`);
1128
1140
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1129
1141
  this.generatingDebouncePending = null;
1130
1142
  this.generatingStartedAt = 0;
1143
+ // Emit completion for mesh task association even though the UI generating
1144
+ // started/completed pair is suppressed (too short for visible UI update).
1145
+ let shortFinalSummary: string | undefined;
1146
+ try { shortFinalSummary = extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages); } catch { /* best-effort */ }
1147
+ this.pushEvent({
1148
+ event: 'agent:generating_completed',
1149
+ chatTitle,
1150
+ duration: 0,
1151
+ timestamp: now,
1152
+ finalSummary: shortFinalSummary,
1153
+ completionDiagnostic: {
1154
+ reason: 'short_generating_suppressed',
1155
+ shortDurationMs,
1156
+ },
1157
+ });
1131
1158
  } else {
1132
1159
  // Debounce completed, then require the rich transcript path that read_chat
1133
1160
  // uses to show an idle turn whose last user-facing message is assistant.