@adhdev/daemon-core 0.9.77-rc.4 → 0.9.77-rc.40

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.
@@ -3,7 +3,20 @@ import { getMesh, getMeshByRepo } from '../config/mesh-config.js';
3
3
  import { LOG } from '../logging/logger.js';
4
4
  import { appendLedgerEntry, getSessionRecoveryContext } from './mesh-ledger.js';
5
5
  import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
6
- import { claimNextTask, updateSessionTaskStatus, enqueueTask } from './mesh-work-queue.js';
6
+ import { claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus } from './mesh-work-queue.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Remote Node Idle Session Tracking
10
+ // ---------------------------------------------------------------------------
11
+ // Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
12
+ // can assign tasks to them.
13
+ // ---------------------------------------------------------------------------
14
+ interface RemoteIdleSession {
15
+ nodeId: string;
16
+ sessionId: string;
17
+ providerType: string;
18
+ }
19
+ const remoteIdleSessions = new Map<string, RemoteIdleSession>(); // key: `${nodeId}:${sessionId}`
7
20
 
8
21
  // ---------------------------------------------------------------------------
9
22
  // MCP coordinator pending-event queue
@@ -33,10 +46,19 @@ function readNonEmptyString(value: unknown): string {
33
46
  return typeof value === 'string' && value.trim() ? value.trim() : '';
34
47
  }
35
48
 
49
+ function resolveEventSessionId(event: Record<string, unknown>, fallback?: unknown): string {
50
+ return readNonEmptyString(event.targetSessionId)
51
+ || readNonEmptyString(event.sessionId)
52
+ || readNonEmptyString(event.instanceId)
53
+ || readNonEmptyString(fallback);
54
+ }
55
+
36
56
  const MESH_COORDINATOR_EVENTS = new Set([
57
+ 'agent:generating_started',
37
58
  'agent:generating_completed',
38
59
  'agent:waiting_approval',
39
60
  'agent:stopped',
61
+ 'agent:ready',
40
62
  'monitor:long_generating',
41
63
  ]);
42
64
 
@@ -60,25 +82,57 @@ function formatCompletionMetadata(event: Record<string, unknown>): string {
60
82
  return parts.length > 0 ? ` (${parts.join('; ')})` : '';
61
83
  }
62
84
 
85
+ function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
86
+ const localMesh = getMesh(meshId);
87
+ if (localMesh) return localMesh;
88
+ return components.router?.getCachedInlineMesh(meshId);
89
+ }
90
+
91
+
63
92
  export function tryAssignQueueTask(
64
- components: { cliManager: any },
93
+ components: DaemonComponents,
65
94
  meshId: string,
66
95
  nodeId: string,
67
96
  sessionId: string,
68
97
  providerType: string
69
98
  ): boolean {
70
99
  const task = claimNextTask(meshId, nodeId, sessionId);
71
- if (!task) return false;
100
+ if (!task) {
101
+ return false;
102
+ }
72
103
 
73
104
  LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
105
+
106
+ // Check if the node is remote
107
+ const mesh = getMeshWithCache(components, meshId);
108
+ const node = mesh?.nodes.find((n: any) => n.id === nodeId);
74
109
 
110
+ // If the node is explicitly remote and we have a dispatch mechanism, route via P2P
111
+ if (node?.daemonId && components.dispatchMeshCommand) {
112
+ const isLocalNode = components.cliManager.adapters.has(sessionId);
113
+ if (!isLocalNode) {
114
+ components.dispatchMeshCommand(node.daemonId, 'agent_command', {
115
+ targetSessionId: sessionId,
116
+ cliType: providerType,
117
+ action: 'send_chat',
118
+ message: task.message,
119
+ }).catch((e: any) => {
120
+ LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
121
+ updateTaskStatus(meshId, task.id, 'failed');
122
+ });
123
+ return true;
124
+ }
125
+ }
126
+
127
+ // Local routing
75
128
  components.cliManager.handleCliCommand('agent_command', {
76
129
  targetSessionId: sessionId,
77
130
  cliType: providerType,
78
131
  action: 'send_chat',
79
- input: task.message,
132
+ message: task.message,
80
133
  }).catch((e: any) => {
81
- LOG.error('MeshQueue', `Failed to dispatch task to node ${nodeId}: ${e?.message}`);
134
+ LOG.error('MeshQueue', `Failed to dispatch task locally to node ${nodeId}: ${e?.message}`);
135
+ updateTaskStatus(meshId, task.id, 'failed');
82
136
  });
83
137
 
84
138
  return true;
@@ -88,8 +142,8 @@ export function tryAssignQueueTask(
88
142
  * Triggers a queue check for all nodes in the mesh.
89
143
  * Called when a new task is enqueued, in case nodes are already idle.
90
144
  */
91
- export function triggerMeshQueue(components: { instanceManager: any; cliManager: any }, meshId: string) {
92
- const mesh = getMesh(meshId);
145
+ export function triggerMeshQueue(components: DaemonComponents, meshId: string) {
146
+ const mesh = getMeshWithCache(components, meshId);
93
147
  if (!mesh) return;
94
148
 
95
149
  // Find all CLI instances that belong to this mesh and are idle
@@ -99,13 +153,17 @@ export function triggerMeshQueue(components: { instanceManager: any; cliManager:
99
153
  const settings = state.settings as Record<string, unknown> || {};
100
154
 
101
155
  const instMeshId = readNonEmptyString(settings.meshNodeFor);
102
- if (instMeshId !== meshId && !settings.launchedByCoordinator) continue;
156
+ if (instMeshId !== meshId) continue;
103
157
 
104
158
  const nodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
105
159
  if (!nodeId) continue;
106
160
 
107
- // Is it idle? (online and waiting for input)
108
- if (state.status !== 'idle' && state.status !== 'stopped' && state.activeChat?.status !== 'waiting_input') continue;
161
+ // Only genuinely idle live sessions can pull work. Restored/stopped
162
+ // records are kept for transcript/recovery visibility, but assigning
163
+ // queue items to them strands tasks in assigned/pending without chat.
164
+ const status = readNonEmptyString(state.status).toLowerCase();
165
+ if (['stopped', 'failed', 'terminated', 'exited', 'closed'].includes(status)) continue;
166
+ if (status !== 'idle' && state.activeChat?.status !== 'waiting_input') continue;
109
167
 
110
168
  const sessionId = state.instanceId;
111
169
  const providerType = state.type || readNonEmptyString(settings.providerType);
@@ -115,6 +173,18 @@ export function triggerMeshQueue(components: { instanceManager: any; cliManager:
115
173
  tryAssignQueueTask(components, meshId, nodeId, sessionId, providerType);
116
174
  }
117
175
  }
176
+
177
+ // Also check known idle remote sessions
178
+ for (const [key, idle] of remoteIdleSessions.entries()) {
179
+ // Find if this node is in the same mesh
180
+ const node = mesh.nodes.find((n: any) => n.id === idle.nodeId);
181
+ if (node) {
182
+ const assigned = tryAssignQueueTask(components, meshId, idle.nodeId, idle.sessionId, idle.providerType);
183
+ if (assigned) {
184
+ remoteIdleSessions.delete(key);
185
+ }
186
+ }
187
+ }
118
188
  }
119
189
 
120
190
  function buildMeshSystemMessage(args: {
@@ -164,18 +234,21 @@ function buildMeshSystemMessage(args: {
164
234
  function injectMeshSystemMessage(components: DaemonComponents, args: {
165
235
  meshId: string;
166
236
  sourceInstanceId?: string;
237
+ nodeId?: string;
167
238
  nodeLabel: string;
168
239
  event: string;
169
240
  metadataEvent: Record<string, unknown>;
170
241
  }) {
171
242
  // ── Task Queue & Ledger ──
243
+ let completedTaskForLedger: { id?: string } | null = null;
172
244
  if (args.event === 'agent:generating_completed') {
173
- const sessionId = readNonEmptyString(args.metadataEvent.targetSessionId);
174
- const nodeId = readNonEmptyString(args.metadataEvent.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
245
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
246
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
175
247
  const providerType = readNonEmptyString(args.metadataEvent.providerType);
176
248
 
177
249
  if (sessionId) {
178
- updateSessionTaskStatus(args.meshId, sessionId, 'completed');
250
+ const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed');
251
+ completedTaskForLedger = completedTask ? { id: completedTask.id } : null;
179
252
  if (nodeId && providerType) {
180
253
  // Short delay to allow completion event to propagate before pulling next
181
254
  setTimeout(() => {
@@ -183,8 +256,56 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
183
256
  }, 500);
184
257
  }
185
258
  }
259
+ } else if (args.event === 'agent:ready') {
260
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
261
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
262
+ const providerType = readNonEmptyString(args.metadataEvent.providerType);
263
+ const completedTask = sessionId
264
+ ? updateSessionTaskStatus(args.meshId, sessionId, 'completed')
265
+ : null;
266
+ if (completedTask) {
267
+ completedTaskForLedger = { id: completedTask.id };
268
+ try {
269
+ appendLedgerEntry(args.meshId, {
270
+ kind: 'task_completed',
271
+ nodeId: nodeId || undefined,
272
+ sessionId,
273
+ providerType: providerType || undefined,
274
+ payload: {
275
+ event: args.event,
276
+ nodeLabel: args.nodeLabel,
277
+ taskId: completedTask.id,
278
+ completedViaReady: true,
279
+ providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
280
+ finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
281
+ },
282
+ });
283
+ } catch (e: any) {
284
+ LOG.warn('MeshLedger', `Failed to record task_completed from ready: ${e?.message || e}`);
285
+ }
286
+ }
287
+
288
+ if (sessionId && nodeId && providerType) {
289
+ remoteIdleSessions.set(`${nodeId}:${sessionId}`, { nodeId, sessionId, providerType });
290
+ setTimeout(() => {
291
+ const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
292
+ if (assigned) {
293
+ remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
294
+ }
295
+ }, 500);
296
+ }
297
+ } else if (args.event === 'agent:generating_started') {
298
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
299
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
300
+ if (sessionId && nodeId) {
301
+ remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
302
+ }
186
303
  } else if (args.event === 'agent:stopped') {
187
- const sessionId = readNonEmptyString(args.metadataEvent.targetSessionId);
304
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
305
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
306
+ if (sessionId && nodeId) {
307
+ remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
308
+ }
188
309
  if (sessionId) {
189
310
  updateSessionTaskStatus(args.meshId, sessionId, 'failed');
190
311
  }
@@ -195,13 +316,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
195
316
  try {
196
317
  appendLedgerEntry(args.meshId, {
197
318
  kind: ledgerKind,
198
- nodeId: readNonEmptyString(args.metadataEvent.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId) || undefined,
199
- sessionId: readNonEmptyString(args.metadataEvent.targetSessionId) || undefined,
319
+ nodeId: readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId) || undefined,
320
+ sessionId: resolveEventSessionId(args.metadataEvent, args.sourceInstanceId) || undefined,
200
321
  providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
201
322
  payload: {
202
323
  event: args.event,
203
324
  nodeLabel: args.nodeLabel,
325
+ taskId: completedTaskForLedger?.id || undefined,
204
326
  providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
327
+ finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
205
328
  },
206
329
  });
207
330
  } catch (e: any) {
@@ -218,8 +341,8 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
218
341
  const maxRetries = mesh?.policy?.maxTaskRetries ?? 1;
219
342
 
220
343
  recoveryContext = getSessionRecoveryContext(args.meshId, {
221
- sessionId: readNonEmptyString(args.metadataEvent.targetSessionId) || undefined,
222
- nodeId: readNonEmptyString(args.metadataEvent.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId) || undefined,
344
+ sessionId: resolveEventSessionId(args.metadataEvent, args.sourceInstanceId) || undefined,
345
+ nodeId: readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId) || undefined,
223
346
  maxRetries,
224
347
  });
225
348
  recoveryContext.failedProviderType = readNonEmptyString(args.metadataEvent.providerType) || null;
@@ -327,12 +450,14 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
327
450
  const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
328
451
  return injectMeshSystemMessage(components, {
329
452
  meshId,
453
+ nodeId,
330
454
  nodeLabel,
331
455
  event: eventName,
332
456
  metadataEvent: {
333
- targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId),
457
+ targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
334
458
  providerType: readNonEmptyString(payload.providerType),
335
459
  providerSessionId: readNonEmptyString(payload.providerSessionId),
460
+ finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
336
461
  },
337
462
  });
338
463
  }
@@ -367,13 +492,14 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
367
492
  const isMeshDelegate = Boolean(meshIdFromRuntime || settings.launchedByCoordinator);
368
493
  if (!isMeshDelegate) return;
369
494
 
370
- const mesh = meshIdFromRuntime ? getMesh(meshIdFromRuntime) : getMeshByRepo(workspace);
495
+ const mesh = meshIdFromRuntime ? getMeshWithCache(components, meshIdFromRuntime) : getMeshByRepo(workspace);
371
496
  const meshId = meshIdFromRuntime || readNonEmptyString(mesh?.id);
372
497
  if (!meshId) return;
373
498
 
374
499
  // Determine node label. Inline/cloud meshes may be unavailable here, so preserve runtime node id.
375
500
  const targetNode = mesh?.nodes?.find((n: any) => n.workspace === workspace);
376
501
  const runtimeNodeId = readNonEmptyString(settings.meshNodeId);
502
+ const resolvedNodeId = targetNode?.id || runtimeNodeId;
377
503
  const nodeLabel = targetNode
378
504
  ? `Node '${targetNode.id}'`
379
505
  : runtimeNodeId
@@ -383,6 +509,7 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
383
509
  injectMeshSystemMessage(components, {
384
510
  meshId,
385
511
  sourceInstanceId: instanceId,
512
+ nodeId: resolvedNodeId,
386
513
  nodeLabel,
387
514
  event: event.event,
388
515
  metadataEvent: event,
@@ -3,7 +3,7 @@ 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
7
 
8
8
  export interface MeshWorkQueueEntry {
9
9
  id: string;
@@ -12,10 +12,19 @@ export interface MeshWorkQueueEntry {
12
12
  status: MeshTaskStatus;
13
13
  /** If specified, only this node can claim the task (used by legacy mesh_send_task) */
14
14
  targetNodeId?: string;
15
+ /** If specified, only this runtime session can claim the task */
16
+ targetSessionId?: string;
15
17
  /** The node that actually claimed and is executing the task */
16
18
  assignedNodeId?: string;
17
19
  /** The session currently executing the task */
18
20
  assignedSessionId?: string;
21
+ /** Human/operator reason for terminal cancellation. */
22
+ cancelReason?: string;
23
+ cancelledAt?: string;
24
+ /** Human/operator reason for manually requeueing a task. */
25
+ requeueReason?: string;
26
+ requeuedAt?: string;
27
+ requeueCount?: number;
19
28
  createdAt: string;
20
29
  updatedAt: string;
21
30
  }
@@ -47,7 +56,7 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
47
56
  export function enqueueTask(
48
57
  meshId: string,
49
58
  message: string,
50
- opts?: { targetNodeId?: string }
59
+ opts?: { targetNodeId?: string; targetSessionId?: string }
51
60
  ): MeshWorkQueueEntry {
52
61
  const queue = readQueue(meshId);
53
62
  const entry: MeshWorkQueueEntry = {
@@ -56,6 +65,7 @@ export function enqueueTask(
56
65
  message,
57
66
  status: 'pending',
58
67
  targetNodeId: opts?.targetNodeId,
68
+ targetSessionId: opts?.targetSessionId,
59
69
  createdAt: new Date().toISOString(),
60
70
  updatedAt: new Date().toISOString(),
61
71
  };
@@ -81,13 +91,25 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
81
91
  */
82
92
  export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
83
93
  const queue = readQueue(meshId);
94
+
95
+ // A worker must finish or fail its current queued assignment before it can
96
+ // claim another one. maxParallelTasks limits total mesh concurrency; it is
97
+ // not permission for one node/session to accumulate multiple assigned items.
98
+ const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
99
+ q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
100
+ ));
101
+ if (hasActiveAssignment) return null;
84
102
 
85
103
  // 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);
104
+ // 1. Pending tasks explicitly targeted at this runtime session
105
+ // 2. Pending tasks explicitly targeted at this node (but not another session)
106
+ // 3. Pending tasks with no target node/session
107
+ let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
108
+ if (targetIdx === -1) {
109
+ targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
110
+ }
89
111
  if (targetIdx === -1) {
90
- targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId);
112
+ targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
91
113
  }
92
114
 
93
115
  if (targetIdx === -1) return null;
@@ -121,6 +143,65 @@ export function updateTaskStatus(
121
143
  return queue[idx];
122
144
  }
123
145
 
146
+ /**
147
+ * Mark a queue task as manually cancelled without deleting audit history.
148
+ */
149
+ export function cancelTask(
150
+ meshId: string,
151
+ taskId: string,
152
+ opts?: { reason?: string },
153
+ ): MeshWorkQueueEntry | null {
154
+ const queue = readQueue(meshId);
155
+ const idx = queue.findIndex(q => q.id === taskId);
156
+ if (idx === -1) return null;
157
+
158
+ const now = new Date().toISOString();
159
+ queue[idx].status = 'cancelled';
160
+ queue[idx].updatedAt = now;
161
+ queue[idx].cancelledAt = now;
162
+ if (opts?.reason) queue[idx].cancelReason = opts.reason;
163
+ writeQueue(meshId, queue);
164
+ return queue[idx];
165
+ }
166
+
167
+ /**
168
+ * Return a queue task to pending for retry. By default, dead session targeting
169
+ * and assigned ownership are cleared so stale assignments do not strand again.
170
+ */
171
+ export function requeueTask(
172
+ meshId: string,
173
+ taskId: string,
174
+ opts?: {
175
+ reason?: string;
176
+ targetNodeId?: string;
177
+ targetSessionId?: string;
178
+ clearTargetNode?: boolean;
179
+ clearTargetSession?: boolean;
180
+ },
181
+ ): MeshWorkQueueEntry | null {
182
+ const queue = readQueue(meshId);
183
+ const idx = queue.findIndex(q => q.id === taskId);
184
+ if (idx === -1) return null;
185
+
186
+ const entry = queue[idx];
187
+ const now = new Date().toISOString();
188
+ entry.status = 'pending';
189
+ delete entry.assignedNodeId;
190
+ delete entry.assignedSessionId;
191
+ delete entry.cancelledAt;
192
+ delete entry.cancelReason;
193
+ if (opts?.clearTargetNode) delete entry.targetNodeId;
194
+ if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
195
+ if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
196
+ if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
197
+ entry.updatedAt = now;
198
+ entry.requeuedAt = now;
199
+ entry.requeueCount = (entry.requeueCount || 0) + 1;
200
+ if (opts?.reason) entry.requeueReason = opts.reason;
201
+ writeQueue(meshId, queue);
202
+ return entry;
203
+ }
204
+
124
205
  /**
125
206
  * Update the status of the task currently assigned to a specific session.
126
207
  */
@@ -148,6 +229,13 @@ export interface MeshWorkQueueStats {
148
229
  assigned: number;
149
230
  completed: number;
150
231
  failed: number;
232
+ cancelled: number;
233
+ activeAssignments: Array<{
234
+ id: string;
235
+ nodeId?: string;
236
+ sessionId?: string;
237
+ message: string;
238
+ }>;
151
239
  }
152
240
 
153
241
  /**
@@ -160,5 +248,14 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
160
248
  assigned: queue.filter(q => q.status === 'assigned').length,
161
249
  completed: queue.filter(q => q.status === 'completed').length,
162
250
  failed: queue.filter(q => q.status === 'failed').length,
251
+ cancelled: queue.filter(q => q.status === 'cancelled').length,
252
+ activeAssignments: queue
253
+ .filter(q => q.status === 'assigned')
254
+ .map(q => ({
255
+ id: q.id,
256
+ nodeId: q.assignedNodeId,
257
+ sessionId: q.assignedSessionId,
258
+ message: q.message,
259
+ })),
163
260
  };
164
261
  }
@@ -833,6 +833,8 @@ export class CliProviderInstance implements ProviderInstance {
833
833
  this.completedDebounceTimer = null;
834
834
  }, 3000);
835
835
  }
836
+ } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
837
+ this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
836
838
  } else if (newStatus === 'stopped') {
837
839
  // Cancel any pending debounce
838
840
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -394,6 +394,13 @@ export interface SessionEntry {
394
394
  assigned: number;
395
395
  completed: number;
396
396
  failed: number;
397
+ cancelled?: number;
398
+ activeAssignments?: Array<{
399
+ id: string;
400
+ nodeId?: string;
401
+ sessionId?: string;
402
+ message: string;
403
+ }>;
397
404
  };
398
405
  }
399
406
 
@@ -439,6 +446,13 @@ export interface CompactSessionEntry {
439
446
  assigned: number;
440
447
  completed: number;
441
448
  failed: number;
449
+ cancelled?: number;
450
+ activeAssignments?: Array<{
451
+ id: string;
452
+ nodeId?: string;
453
+ sessionId?: string;
454
+ message: string;
455
+ }>;
442
456
  };
443
457
  }
444
458