@adhdev/daemon-core 0.9.77-rc.5 → 0.9.77-rc.50

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 (45) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +3 -0
  2. package/dist/cli-adapters/provider-cli-adapter.d.ts +4 -0
  3. package/dist/cli-adapters/provider-cli-shared.d.ts +14 -4
  4. package/dist/commands/mesh-coordinator.d.ts +10 -0
  5. package/dist/commands/router.d.ts +4 -1
  6. package/dist/config/mesh-config.d.ts +1 -0
  7. package/dist/git/git-worktree.d.ts +15 -2
  8. package/dist/index.d.ts +8 -4
  9. package/dist/index.js +1959 -291
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1947 -291
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/mesh/mesh-events.d.ts +10 -7
  14. package/dist/mesh/mesh-ledger-reconciliation.d.ts +55 -0
  15. package/dist/mesh/mesh-ledger.d.ts +84 -4
  16. package/dist/mesh/mesh-sync.d.ts +4 -12
  17. package/dist/mesh/mesh-work-queue.d.ts +56 -1
  18. package/dist/mesh/p2p-relay-failure.d.ts +35 -0
  19. package/dist/providers/cli-provider-instance.d.ts +6 -0
  20. package/dist/repo-mesh-types.d.ts +2 -0
  21. package/dist/shared-types.d.ts +38 -0
  22. package/package.json +1 -1
  23. package/src/boot/daemon-lifecycle.ts +5 -0
  24. package/src/cli-adapters/provider-cli-adapter.ts +35 -4
  25. package/src/cli-adapters/provider-cli-shared.ts +14 -4
  26. package/src/commands/cli-manager.ts +0 -4
  27. package/src/commands/mesh-coordinator.ts +55 -7
  28. package/src/commands/router.ts +847 -26
  29. package/src/commands/stream-commands.ts +8 -1
  30. package/src/config/config.ts +2 -1
  31. package/src/config/mesh-config.ts +2 -0
  32. package/src/config/workspaces.ts +1 -1
  33. package/src/git/git-worktree.ts +56 -4
  34. package/src/index.d.ts +3 -0
  35. package/src/index.ts +20 -4
  36. package/src/mesh/coordinator-prompt.ts +21 -10
  37. package/src/mesh/mesh-events.ts +522 -22
  38. package/src/mesh/mesh-ledger-reconciliation.ts +115 -0
  39. package/src/mesh/mesh-ledger.ts +209 -8
  40. package/src/mesh/mesh-sync.ts +4 -34
  41. package/src/mesh/mesh-work-queue.ts +163 -10
  42. package/src/mesh/p2p-relay-failure.ts +152 -0
  43. package/src/providers/cli-provider-instance.ts +153 -30
  44. package/src/repo-mesh-types.ts +2 -0
  45. package/src/shared-types.ts +38 -0
@@ -3,7 +3,12 @@ import { join } from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
5
5
 
6
- export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed';
6
+ export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
7
+ export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
8
+ export type MeshHistoricalTaskStatus = Extract<MeshTaskStatus, 'completed' | 'failed' | 'cancelled'>;
9
+
10
+ export const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[] = ['pending', 'assigned'];
11
+ export const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[] = ['completed', 'failed', 'cancelled'];
7
12
 
8
13
  export interface MeshWorkQueueEntry {
9
14
  id: string;
@@ -12,10 +17,28 @@ export interface MeshWorkQueueEntry {
12
17
  status: MeshTaskStatus;
13
18
  /** If specified, only this node can claim the task (used by legacy mesh_send_task) */
14
19
  targetNodeId?: string;
20
+ /** If specified, only this runtime session can claim the task */
21
+ targetSessionId?: string;
15
22
  /** The node that actually claimed and is executing the task */
16
23
  assignedNodeId?: string;
17
24
  /** The session currently executing the task */
18
25
  assignedSessionId?: string;
26
+ /** Human/operator reason for terminal cancellation. */
27
+ cancelReason?: string;
28
+ cancelledAt?: string;
29
+ /** Human/operator reason for manually requeueing a task. */
30
+ requeueReason?: string;
31
+ requeuedAt?: string;
32
+ requeueCount?: number;
33
+ /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
34
+ autoLaunch?: {
35
+ status: 'skipped' | 'started' | 'failed' | 'completed';
36
+ reason?: string;
37
+ nodeId?: string;
38
+ providerType?: string;
39
+ sessionId?: string;
40
+ updatedAt: string;
41
+ };
19
42
  createdAt: string;
20
43
  updatedAt: string;
21
44
  }
@@ -47,7 +70,7 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
47
70
  export function enqueueTask(
48
71
  meshId: string,
49
72
  message: string,
50
- opts?: { targetNodeId?: string }
73
+ opts?: { targetNodeId?: string; targetSessionId?: string }
51
74
  ): MeshWorkQueueEntry {
52
75
  const queue = readQueue(meshId);
53
76
  const entry: MeshWorkQueueEntry = {
@@ -56,6 +79,7 @@ export function enqueueTask(
56
79
  message,
57
80
  status: 'pending',
58
81
  targetNodeId: opts?.targetNodeId,
82
+ targetSessionId: opts?.targetSessionId,
59
83
  createdAt: new Date().toISOString(),
60
84
  updatedAt: new Date().toISOString(),
61
85
  };
@@ -81,13 +105,25 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
81
105
  */
82
106
  export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
83
107
  const queue = readQueue(meshId);
108
+
109
+ // A worker must finish or fail its current queued assignment before it can
110
+ // claim another one. maxParallelTasks limits total mesh concurrency; it is
111
+ // not permission for one node/session to accumulate multiple assigned items.
112
+ const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
113
+ q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
114
+ ));
115
+ if (hasActiveAssignment) return null;
84
116
 
85
117
  // Find highest priority task:
86
- // 1. Pending tasks explicitly targeted at this node
87
- // 2. Pending tasks with no target node
88
- let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId);
118
+ // 1. Pending tasks explicitly targeted at this runtime session
119
+ // 2. Pending tasks explicitly targeted at this node (but not another session)
120
+ // 3. Pending tasks with no target node/session
121
+ let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
89
122
  if (targetIdx === -1) {
90
- targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId);
123
+ targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
124
+ }
125
+ if (targetIdx === -1) {
126
+ targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
91
127
  }
92
128
 
93
129
  if (targetIdx === -1) return null;
@@ -121,6 +157,83 @@ export function updateTaskStatus(
121
157
  return queue[idx];
122
158
  }
123
159
 
160
+ export function recordTaskAutoLaunch(
161
+ meshId: string,
162
+ taskId: string,
163
+ autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
164
+ ): MeshWorkQueueEntry | null {
165
+ const queue = readQueue(meshId);
166
+ const idx = queue.findIndex(q => q.id === taskId);
167
+ if (idx === -1) return null;
168
+ const now = new Date().toISOString();
169
+ queue[idx].autoLaunch = {
170
+ ...autoLaunch,
171
+ updatedAt: now,
172
+ };
173
+ queue[idx].updatedAt = now;
174
+ writeQueue(meshId, queue);
175
+ return queue[idx];
176
+ }
177
+
178
+ /**
179
+ * Mark a queue task as manually cancelled without deleting audit history.
180
+ */
181
+ export function cancelTask(
182
+ meshId: string,
183
+ taskId: string,
184
+ opts?: { reason?: string },
185
+ ): MeshWorkQueueEntry | null {
186
+ const queue = readQueue(meshId);
187
+ const idx = queue.findIndex(q => q.id === taskId);
188
+ if (idx === -1) return null;
189
+
190
+ const now = new Date().toISOString();
191
+ queue[idx].status = 'cancelled';
192
+ queue[idx].updatedAt = now;
193
+ queue[idx].cancelledAt = now;
194
+ if (opts?.reason) queue[idx].cancelReason = opts.reason;
195
+ writeQueue(meshId, queue);
196
+ return queue[idx];
197
+ }
198
+
199
+ /**
200
+ * Return a queue task to pending for retry. By default, dead session targeting
201
+ * and assigned ownership are cleared so stale assignments do not strand again.
202
+ */
203
+ export function requeueTask(
204
+ meshId: string,
205
+ taskId: string,
206
+ opts?: {
207
+ reason?: string;
208
+ targetNodeId?: string;
209
+ targetSessionId?: string;
210
+ clearTargetNode?: boolean;
211
+ clearTargetSession?: boolean;
212
+ },
213
+ ): MeshWorkQueueEntry | null {
214
+ const queue = readQueue(meshId);
215
+ const idx = queue.findIndex(q => q.id === taskId);
216
+ if (idx === -1) return null;
217
+
218
+ const entry = queue[idx];
219
+ const now = new Date().toISOString();
220
+ entry.status = 'pending';
221
+ delete entry.assignedNodeId;
222
+ delete entry.assignedSessionId;
223
+ delete entry.cancelledAt;
224
+ delete entry.cancelReason;
225
+ if (opts?.clearTargetNode) delete entry.targetNodeId;
226
+ if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
227
+ if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
228
+ if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
229
+ entry.updatedAt = now;
230
+ entry.requeuedAt = now;
231
+ entry.requeueCount = (entry.requeueCount || 0) + 1;
232
+ if (opts?.reason) entry.requeueReason = opts.reason;
233
+ writeQueue(meshId, queue);
234
+ return entry;
235
+ }
236
+
124
237
  /**
125
238
  * Update the status of the task currently assigned to a specific session.
126
239
  */
@@ -144,10 +257,24 @@ export function updateSessionTaskStatus(
144
257
  }
145
258
 
146
259
  export interface MeshWorkQueueStats {
260
+ total: number;
261
+ active: number;
262
+ historical: number;
147
263
  pending: number;
148
264
  assigned: number;
149
265
  completed: number;
150
266
  failed: number;
267
+ cancelled: number;
268
+ /** Source-of-truth active queue counters; only pending/assigned are live work. */
269
+ activeCounts: Record<MeshActiveTaskStatus, number>;
270
+ /** Terminal ledger records kept for audit/history; never count as active work. */
271
+ historicalCounts: Record<MeshHistoricalTaskStatus, number>;
272
+ activeAssignments: Array<{
273
+ id: string;
274
+ nodeId?: string;
275
+ sessionId?: string;
276
+ message: string;
277
+ }>;
151
278
  }
152
279
 
153
280
  /**
@@ -155,10 +282,36 @@ export interface MeshWorkQueueStats {
155
282
  */
156
283
  export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
157
284
  const queue = readQueue(meshId);
285
+ const pending = queue.filter(q => q.status === 'pending').length;
286
+ const assigned = queue.filter(q => q.status === 'assigned').length;
287
+ const completed = queue.filter(q => q.status === 'completed').length;
288
+ const failed = queue.filter(q => q.status === 'failed').length;
289
+ const cancelled = queue.filter(q => q.status === 'cancelled').length;
158
290
  return {
159
- pending: queue.filter(q => q.status === 'pending').length,
160
- assigned: queue.filter(q => q.status === 'assigned').length,
161
- completed: queue.filter(q => q.status === 'completed').length,
162
- failed: queue.filter(q => q.status === 'failed').length,
291
+ total: queue.length,
292
+ active: pending + assigned,
293
+ historical: completed + failed + cancelled,
294
+ pending,
295
+ assigned,
296
+ completed,
297
+ failed,
298
+ cancelled,
299
+ activeCounts: {
300
+ pending,
301
+ assigned,
302
+ },
303
+ historicalCounts: {
304
+ completed,
305
+ failed,
306
+ cancelled,
307
+ },
308
+ activeAssignments: queue
309
+ .filter(q => q.status === 'assigned')
310
+ .map(q => ({
311
+ id: q.id,
312
+ nodeId: q.assignedNodeId,
313
+ sessionId: q.assignedSessionId,
314
+ message: q.message,
315
+ })),
163
316
  };
164
317
  }
@@ -0,0 +1,152 @@
1
+ export type P2pRelayFailureCode =
2
+ | 'p2p_unavailable'
3
+ | 'p2p_timeout'
4
+ | 'p2p_not_connected'
5
+ | 'p2p_datachannel_closed'
6
+ | 'p2p_no_route'
7
+ | 'p2p_daemon_offline'
8
+ | 'mesh_logic_or_provider_failure';
9
+
10
+ export interface P2pRelayFailureContext {
11
+ command?: string;
12
+ targetDaemonId?: string;
13
+ }
14
+
15
+ export interface P2pRelayFailureClassification {
16
+ code: P2pRelayFailureCode;
17
+ reason: string;
18
+ transport: 'p2p' | 'unknown';
19
+ recoverable: boolean;
20
+ retryRecommended: boolean;
21
+ nextAction: string;
22
+ noFallbackReason: string;
23
+ }
24
+
25
+ export interface P2pRelayFailurePayload extends P2pRelayFailureClassification {
26
+ success: false;
27
+ error: string;
28
+ command?: string;
29
+ targetDaemonId?: string;
30
+ }
31
+
32
+ const NO_FALLBACK_REASON = 'Repo Mesh command/data-plane is P2P-only; WS/REST command fallback is intentionally disabled to preserve the transport boundary.';
33
+ const P2P_NEXT_ACTION = 'Check daemon/P2P health, wait briefly for connection establishment, then do one bounded retry or requeue the mesh task after clearing stale target session metadata.';
34
+ const NON_P2P_NEXT_ACTION = 'Inspect the provider/command error and fix the underlying logic or configuration before retrying.';
35
+
36
+ function messageFromError(error: unknown): string {
37
+ if (error instanceof Error) return error.message;
38
+ if (typeof error === 'string') return error;
39
+ if (error && typeof error === 'object') {
40
+ const candidate = (error as any).error ?? (error as any).message ?? (error as any).reason;
41
+ if (typeof candidate === 'string') return candidate;
42
+ }
43
+ return String(error || 'mesh relay command failed');
44
+ }
45
+
46
+ export function classifyP2pRelayFailure(error: unknown, _context: P2pRelayFailureContext = {}): P2pRelayFailureClassification {
47
+ const message = messageFromError(error);
48
+ const lower = message.toLowerCase();
49
+
50
+ const hasP2pSignal = /p2p|datachannel|node-datachannel|webrtc|ice|mesh_relay_command|daemon_mesh_p2p_transport/i.test(message);
51
+ const hasFailureSignal = /unavailable|missing|failed|failure|timeout|timed out|not connected|closed|disconnected|offline|no route|route unavailable|cannot send|cannot establish/i.test(message);
52
+
53
+ // Validation errors that merely mention mesh_relay_command are not transport failures.
54
+ if (/requires targetdaemonid and command|providerpriority|no inference provider|permission denied|read-only|not a member/i.test(message)) {
55
+ return {
56
+ code: 'mesh_logic_or_provider_failure',
57
+ reason: 'mesh_logic_or_provider_failure',
58
+ transport: 'unknown',
59
+ recoverable: false,
60
+ retryRecommended: false,
61
+ nextAction: NON_P2P_NEXT_ACTION,
62
+ noFallbackReason: NO_FALLBACK_REASON,
63
+ };
64
+ }
65
+
66
+ let code: P2pRelayFailureCode | null = null;
67
+ let reason = '';
68
+
69
+ if (/timeout|timed out/i.test(message) && (hasP2pSignal || /mesh transport/i.test(message))) {
70
+ code = 'p2p_timeout';
71
+ reason = 'daemon_mesh_p2p_timeout';
72
+ } else if (/no route|route unavailable/i.test(message)) {
73
+ code = 'p2p_no_route';
74
+ reason = 'daemon_mesh_p2p_no_route';
75
+ } else if (/offline|not owned|not found|not connected to server/i.test(message) && /daemon|peer|target/i.test(message)) {
76
+ code = 'p2p_daemon_offline';
77
+ reason = 'daemon_mesh_target_offline';
78
+ } else if (/closed|disconnected/i.test(message) && (hasP2pSignal || /state changed/i.test(message))) {
79
+ code = 'p2p_datachannel_closed';
80
+ reason = 'daemon_mesh_p2p_datachannel_closed';
81
+ } else if (/not connected|cannot send|cannot establish/i.test(message) && hasP2pSignal) {
82
+ code = 'p2p_not_connected';
83
+ reason = 'daemon_mesh_p2p_not_connected';
84
+ } else if (hasP2pSignal && hasFailureSignal) {
85
+ code = 'p2p_unavailable';
86
+ reason = 'daemon_mesh_p2p_transport_unavailable';
87
+ }
88
+
89
+ if (!code) {
90
+ return {
91
+ code: 'mesh_logic_or_provider_failure',
92
+ reason: 'mesh_logic_or_provider_failure',
93
+ transport: 'unknown',
94
+ recoverable: false,
95
+ retryRecommended: false,
96
+ nextAction: NON_P2P_NEXT_ACTION,
97
+ noFallbackReason: NO_FALLBACK_REASON,
98
+ };
99
+ }
100
+
101
+ return {
102
+ code,
103
+ reason,
104
+ transport: 'p2p',
105
+ recoverable: true,
106
+ retryRecommended: true,
107
+ nextAction: P2P_NEXT_ACTION,
108
+ noFallbackReason: NO_FALLBACK_REASON,
109
+ };
110
+ }
111
+
112
+ export function isP2pRelayTransportFailure(error: unknown): boolean {
113
+ return classifyP2pRelayFailure(error).recoverable === true;
114
+ }
115
+
116
+ export function buildP2pRelayFailurePayload(error: unknown, context: P2pRelayFailureContext = {}): P2pRelayFailurePayload {
117
+ const classification = classifyP2pRelayFailure(error, context);
118
+ return {
119
+ success: false,
120
+ ...classification,
121
+ error: messageFromError(error),
122
+ ...(context.command ? { command: context.command } : {}),
123
+ ...(context.targetDaemonId ? { targetDaemonId: context.targetDaemonId } : {}),
124
+ };
125
+ }
126
+
127
+ export class P2pRelayFailureError extends Error {
128
+ code: P2pRelayFailureCode;
129
+ reason: string;
130
+ transport: 'p2p' | 'unknown';
131
+ recoverable: boolean;
132
+ retryRecommended: boolean;
133
+ nextAction: string;
134
+ noFallbackReason: string;
135
+ command?: string;
136
+ targetDaemonId?: string;
137
+
138
+ constructor(message: string, context: P2pRelayFailureContext = {}) {
139
+ super(message);
140
+ this.name = 'P2pRelayFailureError';
141
+ const payload = buildP2pRelayFailurePayload(message, context);
142
+ this.code = payload.code;
143
+ this.reason = payload.reason;
144
+ this.transport = payload.transport;
145
+ this.recoverable = payload.recoverable;
146
+ this.retryRecommended = payload.retryRecommended;
147
+ this.nextAction = payload.nextAction;
148
+ this.noFallbackReason = payload.noFallbackReason;
149
+ this.command = context.command;
150
+ this.targetDaemonId = context.targetDaemonId;
151
+ }
152
+ }
@@ -35,6 +35,17 @@ type PersistableCliHistoryMessage = {
35
35
  receivedAt?: number;
36
36
  };
37
37
 
38
+ type CompletedDebouncePending = {
39
+ chatTitle: string;
40
+ duration: number;
41
+ timestamp: number;
42
+ firstObservedAt: number;
43
+ loggedBlockReason?: string;
44
+ };
45
+
46
+ const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
+ const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
48
+
38
49
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
39
50
  'image/png': '.png',
40
51
  'image/jpeg': '.jpg',
@@ -103,6 +114,15 @@ function cleanupStaleMaterializedImages(dir: string): void {
103
114
  } catch { /* dir may not exist or be inaccessible */ }
104
115
  }
105
116
 
117
+ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
118
+ const buttons = (activeModal as any)?.buttons;
119
+ return Array.isArray(buttons) && buttons.some((button) => String(button || '').trim().length > 0);
120
+ }
121
+
122
+ function isCliGeneratingLikeStatus(status: unknown): boolean {
123
+ return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
124
+ }
125
+
106
126
  export function buildCliStructuredInputPrompt(
107
127
  input: InputEnvelope,
108
128
  options: { materializeDir?: string } = {},
@@ -511,6 +531,10 @@ export class CliProviderInstance implements ProviderInstance {
511
531
  const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
512
532
 
513
533
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
534
+ const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
535
+ ? parsedStatus.status.trim()
536
+ : undefined;
537
+ const suppressStaleParsedBusyStatus = this.shouldSuppressStaleParsedBusyStatus(parsedStatus, adapterStatus);
514
538
 
515
539
  if (parsedMessages.length > 0) {
516
540
  const shouldSkipReplayPersist =
@@ -518,7 +542,7 @@ export class CliProviderInstance implements ProviderInstance {
518
542
  && adapterStatus.status === 'idle'
519
543
  && parsedStatus?.status === 'idle';
520
544
  let messagesToSave = parsedMessages;
521
- if ((parsedStatus?.status === 'generating' || parsedStatus?.status === 'long_generating')) {
545
+ if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
522
546
  const lastIdx = messagesToSave.length - 1;
523
547
  if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
524
548
  messagesToSave = messagesToSave.slice(0, lastIdx);
@@ -553,6 +577,13 @@ export class CliProviderInstance implements ProviderInstance {
553
577
  summaryMetadata: this.summaryMetadata as any,
554
578
  controlValues: this.controlValues,
555
579
  });
580
+ const activeChatStatus = parseErrorMessage
581
+ ? 'error'
582
+ : autoApproveActive && parsedStatus?.status === 'waiting_approval'
583
+ ? 'generating'
584
+ : (adapterStatus.status !== 'idle'
585
+ ? visibleStatus
586
+ : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
556
587
 
557
588
  return {
558
589
  type: this.type,
@@ -563,13 +594,7 @@ export class CliProviderInstance implements ProviderInstance {
563
594
  activeChat: {
564
595
  id: `${this.type}_${this.workingDir}`,
565
596
  title: parsedStatus?.title || dirName,
566
- status: parseErrorMessage
567
- ? 'error'
568
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
569
- ? 'generating'
570
- : (adapterStatus.status !== 'idle'
571
- ? visibleStatus
572
- : (parsedStatus?.status || visibleStatus)),
597
+ status: activeChatStatus,
573
598
  messages: mergedMessages,
574
599
  activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
575
600
  inputContent: '',
@@ -680,7 +705,7 @@ export class CliProviderInstance implements ProviderInstance {
680
705
  }
681
706
 
682
707
  private completedDebounceTimer: NodeJS.Timeout | null = null;
683
- private completedDebouncePending: { chatTitle: string; duration: number; timestamp: number } | null = null;
708
+ private completedDebouncePending: CompletedDebouncePending | null = null;
684
709
 
685
710
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
686
711
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -709,6 +734,119 @@ export class CliProviderInstance implements ProviderInstance {
709
734
  this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
710
735
  }
711
736
 
737
+ private completionHasFinalAssistantMessage(messages: unknown): boolean {
738
+ const visibleMessages = (Array.isArray(messages) ? messages : [])
739
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
740
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
+ const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
+ const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
+ return role === 'assistant' && !!content;
744
+ }
745
+
746
+ private hasAdapterPendingResponse(): boolean {
747
+ const adapterAny = this.adapter as any;
748
+ if (adapterAny?.isWaitingForResponse === true) return true;
749
+ if (adapterAny?.currentTurnScope) return true;
750
+ try {
751
+ if (typeof this.adapter.isProcessing === 'function' && this.adapter.isProcessing()) return true;
752
+ } catch { /* defensive: status rendering must not fail because of adapter diagnostics */ }
753
+ try {
754
+ const partial = typeof this.adapter.getPartialResponse === 'function'
755
+ ? this.adapter.getPartialResponse()
756
+ : '';
757
+ if (typeof partial === 'string' && partial.trim()) return true;
758
+ } catch { /* defensive: missing partial means no pending response evidence */ }
759
+ return false;
760
+ }
761
+
762
+ private shouldSuppressStaleParsedBusyStatus(parsedStatus: any, adapterStatus: any): boolean {
763
+ const parsedRawStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status.trim() : '';
764
+ const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
765
+ if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
+ if (adapterRawStatus !== 'idle') return false;
767
+ if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
+ return !this.hasAdapterPendingResponse();
769
+ }
770
+
771
+ private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
+ if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
773
+
774
+ const adapterAny = this.adapter as any;
775
+ if (adapterAny?.isWaitingForResponse === true) return 'adapter_waiting_for_response';
776
+ if (adapterAny?.currentTurnScope) return 'adapter_turn_scope_active';
777
+
778
+ const partial = typeof this.adapter.getPartialResponse === 'function'
779
+ ? this.adapter.getPartialResponse()
780
+ : '';
781
+ if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
782
+
783
+ let parsed: any;
784
+ try {
785
+ parsed = this.adapter.getScriptParsedStatus();
786
+ } catch (error: any) {
787
+ return `parse_error:${error?.message || String(error)}`;
788
+ }
789
+
790
+ const parsedStatus = typeof parsed?.status === 'string' ? parsed.status : 'unknown';
791
+ if (parsedStatus !== 'idle') return `parsed_status:${parsedStatus}`;
792
+ if (parsed?.activeModal || parsed?.modal) return 'parsed_modal_active';
793
+ if (!this.completionHasFinalAssistantMessage(parsed?.messages)) return 'missing_final_assistant';
794
+
795
+ return null;
796
+ }
797
+
798
+ private scheduleCompletedDebounceFlush(delayMs: number): void {
799
+ if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
800
+ this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
801
+ }
802
+
803
+ private flushCompletedDebounceIfFinalized(): void {
804
+ const pending = this.completedDebouncePending;
805
+ if (!pending) {
806
+ this.completedDebounceTimer = null;
807
+ return;
808
+ }
809
+
810
+ const latestStatus = this.adapter.getStatus({ allowParse: false });
811
+ const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
+ const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
813
+ if (latestVisibleStatus !== 'idle') {
814
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
815
+ this.completedDebouncePending = null;
816
+ this.completedDebounceTimer = null;
817
+ return;
818
+ }
819
+
820
+ const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
+ if (blockReason) {
822
+ const waitedMs = Date.now() - pending.firstObservedAt;
823
+ if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
824
+ if (pending.loggedBlockReason !== blockReason) {
825
+ LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
826
+ pending.loggedBlockReason = blockReason;
827
+ }
828
+ this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
+ return;
830
+ }
831
+ LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
832
+ this.completedDebouncePending = null;
833
+ this.completedDebounceTimer = null;
834
+ this.generatingStartedAt = 0;
835
+ return;
836
+ }
837
+
838
+ LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
839
+ this.pushEvent({
840
+ event: 'agent:generating_completed',
841
+ chatTitle: pending.chatTitle,
842
+ duration: pending.duration,
843
+ timestamp: pending.timestamp,
844
+ });
845
+ this.completedDebouncePending = null;
846
+ this.completedDebounceTimer = null;
847
+ this.generatingStartedAt = 0;
848
+ }
849
+
712
850
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
713
851
  const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
714
852
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
@@ -811,28 +949,13 @@ export class CliProviderInstance implements ProviderInstance {
811
949
  this.generatingDebouncePending = null;
812
950
  this.generatingStartedAt = 0;
813
951
  } else {
814
- // Debounce completed wait 3s, if still idle then emit
815
- if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
816
- this.completedDebouncePending = { chatTitle, duration, timestamp: now };
817
- this.completedDebounceTimer = setTimeout(() => {
818
- if (this.completedDebouncePending) {
819
- const latestStatus = this.adapter.getStatus({ allowParse: false });
820
- const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
821
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
822
- if (latestVisibleStatus !== 'idle') {
823
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
824
- this.completedDebouncePending = null;
825
- this.completedDebounceTimer = null;
826
- return;
827
- }
828
- LOG.info('CLI', `[${this.type}] completed in ${this.completedDebouncePending.duration}s`);
829
- this.pushEvent({ event: 'agent:generating_completed', ...this.completedDebouncePending });
830
- this.completedDebouncePending = null;
831
- this.generatingStartedAt = 0;
832
- }
833
- this.completedDebounceTimer = null;
834
- }, 3000);
952
+ // Debounce completed, then require the rich transcript path that read_chat
953
+ // uses to show an idle turn whose last user-facing message is assistant.
954
+ this.completedDebouncePending = { chatTitle, duration, timestamp: now, firstObservedAt: now };
955
+ this.scheduleCompletedDebounceFlush(3000);
835
956
  }
957
+ } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
958
+ this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
836
959
  } else if (newStatus === 'stopped') {
837
960
  // Cancel any pending debounce
838
961
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -224,6 +224,8 @@ export interface LocalMeshNodeEntry {
224
224
  workspace: string;
225
225
  repoRoot?: string;
226
226
  daemonId?: string;
227
+ /** Machine registry ID that owns this workspace, when known. */
228
+ machineId?: string;
227
229
  userOverrides: Partial<RepoMeshNodeCapabilities>;
228
230
  policy: RepoMeshNodePolicy;
229
231
  /** For single-machine mesh: same daemon, different worktree */