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

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.
@@ -8,6 +8,10 @@ export interface PendingMeshCoordinatorEvent {
8
8
  }
9
9
  /** Drain and return all pending coordinator events, clearing the queue. */
10
10
  export declare function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[];
11
+ /** Peek at pending coordinator events without draining (non-destructive). */
12
+ export declare function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[];
13
+ /** Explicitly clear all pending coordinator events. */
14
+ export declare function clearPendingMeshCoordinatorEvents(): void;
11
15
  export declare function tryAssignQueueTask(components: DaemonComponents, meshId: string, nodeId: string, sessionId: string, providerType: string): boolean;
12
16
  /**
13
17
  * Triggers a queue check for all nodes in the mesh.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Mesh Visualization — Transform RepoMeshStatus into a graph structure
3
+ * for SVG/Canvas rendering without external dependencies.
4
+ */
5
+ import type { RepoMeshStatus, RepoMeshNodeStatus, RepoMeshNodeHealth } from '../repo-mesh-types.js';
6
+ export type MeshGraphNodeType = 'defaultBranchNode' | 'worktreeNode' | 'orphanNode';
7
+ export type MeshGraphEdgeType = 'parentBranch' | 'worktreeLink' | 'sessionLink';
8
+ export interface MeshGraphNode {
9
+ id: string;
10
+ type: MeshGraphNodeType;
11
+ label: string;
12
+ workspace: string;
13
+ branch: string | null;
14
+ health: RepoMeshNodeHealth;
15
+ ahead: number;
16
+ behind: number;
17
+ dirty: boolean;
18
+ dirtyFiles: number;
19
+ hasConflicts: boolean;
20
+ activeSessionCount: number;
21
+ activeSessions: string[];
22
+ providers: string[];
23
+ isOrphan: boolean;
24
+ orphanReasons: string[];
25
+ /** Next-step hint from convergence analysis */
26
+ nextStepHint?: string;
27
+ /** Original node status for drill-down */
28
+ source: RepoMeshNodeStatus;
29
+ }
30
+ export interface MeshGraphEdge {
31
+ id: string;
32
+ source: string;
33
+ target: string;
34
+ type: MeshGraphEdgeType;
35
+ label?: string;
36
+ }
37
+ export interface MeshGraph {
38
+ meshId: string;
39
+ meshName: string;
40
+ repoIdentity: string;
41
+ refreshedAt: string;
42
+ nodes: MeshGraphNode[];
43
+ edges: MeshGraphEdge[];
44
+ /** Summary statistics */
45
+ stats: {
46
+ totalNodes: number;
47
+ onlineNodes: number;
48
+ dirtyNodes: number;
49
+ orphanNodes: number;
50
+ errorNodes: number;
51
+ offlineNodes: number;
52
+ totalActiveSessions: number;
53
+ };
54
+ /** Global orphan / stale warnings */
55
+ warnings: string[];
56
+ }
57
+ /**
58
+ * Build a visualization graph from RepoMeshStatus.
59
+ *
60
+ * Nodes:
61
+ * - defaultBranchNode: the mesh's default branch (aggregated from nodes on that branch)
62
+ * - worktreeNode: a node on a feature / worktree branch
63
+ * - orphanNode: a node with orphanReasons (upstream missing, detached HEAD, etc.)
64
+ *
65
+ * Edges:
66
+ * - parentBranch: default branch → worktree branch (when branch name differs)
67
+ * - worktreeLink: links nodes that share the same branch (clustering hint)
68
+ * - sessionLink: node → session (lightweight, optional; not rendered as primary edge)
69
+ */
70
+ export declare function buildMeshGraph(status: RepoMeshStatus): MeshGraph;
@@ -32,6 +32,8 @@ export interface MeshWorkQueueEntry {
32
32
  sessionId?: string;
33
33
  updatedAt: string;
34
34
  };
35
+ /** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
36
+ dispatchTimestamp?: string;
35
37
  createdAt: string;
36
38
  updatedAt: string;
37
39
  }
@@ -1,4 +1,5 @@
1
1
  import type { ChatMessage } from '../types.js';
2
+ export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
2
3
  export declare const BUILTIN_CHAT_MESSAGE_KINDS: readonly ["standard", "thought", "tool", "terminal", "system"];
3
4
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
4
5
  export type ChatMessageKind = BuiltinChatMessageKind | (string & {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.77-rc.50",
3
+ "version": "0.9.77-rc.52",
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",
package/src/index.ts CHANGED
@@ -159,8 +159,13 @@ export type { MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshL
159
159
  export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats } from './mesh/mesh-work-queue.js';
160
160
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshWorkQueueStats } from './mesh/mesh-work-queue.js';
161
161
 
162
+ // ── Mesh Visualization ──
163
+ export { buildMeshGraph } from './mesh/mesh-visualization.js';
164
+ export type { MeshGraph, MeshGraphNode, MeshGraphEdge, MeshGraphNodeType, MeshGraphEdgeType } from './mesh/mesh-visualization.js';
165
+
162
166
  // ── Mesh Events ──
163
- export { triggerMeshQueue } from './mesh/mesh-events.js';
167
+ export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents } from './mesh/mesh-events.js';
168
+ export type { PendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
164
169
 
165
170
  // ── Mesh P2P Relay Failure Classification ──
166
171
  export {
@@ -44,6 +44,16 @@ export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent
44
44
  return pendingMeshCoordinatorEvents.splice(0);
45
45
  }
46
46
 
47
+ /** Peek at pending coordinator events without draining (non-destructive). */
48
+ export function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[] {
49
+ return pendingMeshCoordinatorEvents.slice();
50
+ }
51
+
52
+ /** Explicitly clear all pending coordinator events. */
53
+ export function clearPendingMeshCoordinatorEvents(): void {
54
+ pendingMeshCoordinatorEvents.splice(0);
55
+ }
56
+
47
57
  function readNonEmptyString(value: unknown): string {
48
58
  return typeof value === 'string' && value.trim() ? value.trim() : '';
49
59
  }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Mesh Visualization — Transform RepoMeshStatus into a graph structure
3
+ * for SVG/Canvas rendering without external dependencies.
4
+ */
5
+
6
+ import type { RepoMeshStatus, RepoMeshNodeStatus, RepoMeshNodeHealth } from '../repo-mesh-types.js';
7
+ import type { GitRepoStatus } from '../git/git-types.js';
8
+
9
+ // ─── Graph Types ─────────────────────────────────
10
+
11
+ export type MeshGraphNodeType = 'defaultBranchNode' | 'worktreeNode' | 'orphanNode';
12
+ export type MeshGraphEdgeType = 'parentBranch' | 'worktreeLink' | 'sessionLink';
13
+
14
+ export interface MeshGraphNode {
15
+ id: string;
16
+ type: MeshGraphNodeType;
17
+ label: string;
18
+ workspace: string;
19
+ branch: string | null;
20
+ health: RepoMeshNodeHealth;
21
+ ahead: number;
22
+ behind: number;
23
+ dirty: boolean;
24
+ dirtyFiles: number;
25
+ hasConflicts: boolean;
26
+ activeSessionCount: number;
27
+ activeSessions: string[];
28
+ providers: string[];
29
+ isOrphan: boolean;
30
+ orphanReasons: string[];
31
+ /** Next-step hint from convergence analysis */
32
+ nextStepHint?: string;
33
+ /** Original node status for drill-down */
34
+ source: RepoMeshNodeStatus;
35
+ }
36
+
37
+ export interface MeshGraphEdge {
38
+ id: string;
39
+ source: string;
40
+ target: string;
41
+ type: MeshGraphEdgeType;
42
+ label?: string;
43
+ }
44
+
45
+ export interface MeshGraph {
46
+ meshId: string;
47
+ meshName: string;
48
+ repoIdentity: string;
49
+ refreshedAt: string;
50
+ nodes: MeshGraphNode[];
51
+ edges: MeshGraphEdge[];
52
+ /** Summary statistics */
53
+ stats: {
54
+ totalNodes: number;
55
+ onlineNodes: number;
56
+ dirtyNodes: number;
57
+ orphanNodes: number;
58
+ errorNodes: number;
59
+ offlineNodes: number;
60
+ totalActiveSessions: number;
61
+ };
62
+ /** Global orphan / stale warnings */
63
+ warnings: string[];
64
+ }
65
+
66
+ // ─── Helpers ─────────────────────────────────────
67
+
68
+ function isDirty(git?: GitRepoStatus): boolean {
69
+ if (!git) return false;
70
+ return (git.staged + git.modified + git.untracked + git.deleted + git.renamed) > 0;
71
+ }
72
+
73
+ function dirtyFileCount(git?: GitRepoStatus): number {
74
+ if (!git) return 0;
75
+ return git.staged + git.modified + git.untracked + git.deleted + git.renamed;
76
+ }
77
+
78
+ function detectOrphanReasons(node: RepoMeshNodeStatus, defaultBranch?: string | null): string[] {
79
+ const reasons: string[] = [];
80
+ const git = node.git;
81
+
82
+ if (!git) {
83
+ reasons.push('No git status available');
84
+ return reasons;
85
+ }
86
+
87
+ if (!git.isGitRepo) {
88
+ reasons.push('Not a git repository');
89
+ return reasons;
90
+ }
91
+
92
+ // Detached HEAD
93
+ if (git.branch === null && git.headCommit) {
94
+ reasons.push('Detached HEAD');
95
+ }
96
+
97
+ // No upstream tracking
98
+ if (git.branch && !git.upstream) {
99
+ // Local-only branch — orphan if not default
100
+ if (defaultBranch && git.branch !== defaultBranch) {
101
+ reasons.push(`No upstream: ${git.branch}`);
102
+ }
103
+ }
104
+
105
+ // Worktree branch with no upstream
106
+ if (git.branch && git.upstream === null && defaultBranch && git.branch !== defaultBranch) {
107
+ if (!reasons.includes(`No upstream: ${git.branch}`)) {
108
+ reasons.push(`No upstream: ${git.branch}`);
109
+ }
110
+ }
111
+
112
+ // Stale assigned tasks would be detected at queue level, not per node git status
113
+ // We surface node-level errors as orphan indicators
114
+ if (node.error) {
115
+ reasons.push(`Error: ${node.error}`);
116
+ }
117
+
118
+ return reasons;
119
+ }
120
+
121
+ function nodeHealthPriority(health: RepoMeshNodeHealth): number {
122
+ switch (health) {
123
+ case 'online': return 0;
124
+ case 'dirty': return 1;
125
+ case 'degraded': return 2;
126
+ case 'wrong_branch': return 3;
127
+ case 'offline': return 4;
128
+ case 'unknown': return 5;
129
+ default: return 5;
130
+ }
131
+ }
132
+
133
+ function pickDominantHealth(healths: RepoMeshNodeHealth[]): RepoMeshNodeHealth {
134
+ if (healths.length === 0) return 'unknown';
135
+ return healths.reduce((best, h) =>
136
+ nodeHealthPriority(h) > nodeHealthPriority(best) ? h : best
137
+ );
138
+ }
139
+
140
+ // ─── Graph Builder ─────────────────────────────────
141
+
142
+ /**
143
+ * Build a visualization graph from RepoMeshStatus.
144
+ *
145
+ * Nodes:
146
+ * - defaultBranchNode: the mesh's default branch (aggregated from nodes on that branch)
147
+ * - worktreeNode: a node on a feature / worktree branch
148
+ * - orphanNode: a node with orphanReasons (upstream missing, detached HEAD, etc.)
149
+ *
150
+ * Edges:
151
+ * - parentBranch: default branch → worktree branch (when branch name differs)
152
+ * - worktreeLink: links nodes that share the same branch (clustering hint)
153
+ * - sessionLink: node → session (lightweight, optional; not rendered as primary edge)
154
+ */
155
+ export function buildMeshGraph(status: RepoMeshStatus): MeshGraph {
156
+ const nodes: MeshGraphNode[] = [];
157
+ const edges: MeshGraphEdge[] = [];
158
+ const warnings: string[] = [];
159
+
160
+ // Track branch → nodes for edge creation
161
+ const branchToNodeIds = new Map<string, string[]>();
162
+
163
+ for (const nodeStatus of status.nodes) {
164
+ const git = nodeStatus.git;
165
+ const branch = git?.branch || null;
166
+ const orphanReasons = detectOrphanReasons(nodeStatus, null);
167
+ const dirty = isDirty(git);
168
+ const dfc = dirtyFileCount(git);
169
+
170
+ // Determine node type
171
+ let type: MeshGraphNodeType = 'worktreeNode';
172
+ if (orphanReasons.length > 0) {
173
+ type = 'orphanNode';
174
+ }
175
+
176
+ const graphNode: MeshGraphNode = {
177
+ id: nodeStatus.nodeId,
178
+ type,
179
+ label: nodeStatus.machineLabel || nodeStatus.nodeId.slice(0, 8),
180
+ workspace: nodeStatus.workspace,
181
+ branch,
182
+ health: nodeStatus.health,
183
+ ahead: git?.ahead ?? 0,
184
+ behind: git?.behind ?? 0,
185
+ dirty,
186
+ dirtyFiles: dfc,
187
+ hasConflicts: git?.hasConflicts ?? false,
188
+ activeSessionCount: nodeStatus.activeSessions?.length ?? 0,
189
+ activeSessions: nodeStatus.activeSessions ?? [],
190
+ providers: nodeStatus.providers ?? [],
191
+ isOrphan: orphanReasons.length > 0,
192
+ orphanReasons,
193
+ source: nodeStatus,
194
+ };
195
+
196
+ nodes.push(graphNode);
197
+
198
+ if (branch) {
199
+ const list = branchToNodeIds.get(branch) ?? [];
200
+ list.push(graphNode.id);
201
+ branchToNodeIds.set(branch, list);
202
+ }
203
+ }
204
+
205
+ // Create synthetic default branch node if we can infer one
206
+ // Heuristic: most common branch among nodes with upstream
207
+ const branchUpstreamCounts = new Map<string, number>();
208
+ for (const n of status.nodes) {
209
+ const b = n.git?.branch;
210
+ const upstream = n.git?.upstream;
211
+ if (b && upstream) {
212
+ branchUpstreamCounts.set(b, (branchUpstreamCounts.get(b) ?? 0) + 1);
213
+ }
214
+ }
215
+ let inferredDefaultBranch: string | null = null;
216
+ let bestCount = 0;
217
+ for (const [b, count] of branchUpstreamCounts) {
218
+ if (count > bestCount) {
219
+ bestCount = count;
220
+ inferredDefaultBranch = b;
221
+ }
222
+ }
223
+
224
+ // If we have a clear default branch, create a synthetic node for it
225
+ const defaultBranchNodeId = inferredDefaultBranch
226
+ ? `__branch_${inferredDefaultBranch}`
227
+ : null;
228
+
229
+ if (defaultBranchNodeId && inferredDefaultBranch) {
230
+ const branchNodes = branchToNodeIds.get(inferredDefaultBranch) ?? [];
231
+ const branchHealths = branchNodes.map(id =>
232
+ nodes.find(n => n.id === id)!.health
233
+ );
234
+
235
+ const defaultNode: MeshGraphNode = {
236
+ id: defaultBranchNodeId,
237
+ type: 'defaultBranchNode',
238
+ label: inferredDefaultBranch,
239
+ workspace: '',
240
+ branch: inferredDefaultBranch,
241
+ health: pickDominantHealth(branchHealths),
242
+ ahead: 0,
243
+ behind: 0,
244
+ dirty: false,
245
+ dirtyFiles: 0,
246
+ hasConflicts: false,
247
+ activeSessionCount: 0,
248
+ activeSessions: [],
249
+ providers: [],
250
+ isOrphan: false,
251
+ orphanReasons: [],
252
+ nextStepHint: branchNodes.length > 0 ? `${branchNodes.length} node(s) on default branch` : undefined,
253
+ source: {
254
+ nodeId: defaultBranchNodeId,
255
+ machineLabel: inferredDefaultBranch,
256
+ workspace: '',
257
+ health: 'online',
258
+ providers: [],
259
+ activeSessions: [],
260
+ },
261
+ };
262
+ nodes.push(defaultNode);
263
+
264
+ // Edge: default branch → each worktree branch that is NOT the default
265
+ const seenBranches = new Set<string>();
266
+ for (const n of nodes) {
267
+ if (n.type === 'defaultBranchNode') continue;
268
+ if (!n.branch) continue;
269
+ if (n.branch === inferredDefaultBranch) {
270
+ // Link worktree nodes on default branch directly to default branch node
271
+ edges.push({
272
+ id: `${defaultBranchNodeId}--${n.id}`,
273
+ source: defaultBranchNodeId,
274
+ target: n.id,
275
+ type: 'parentBranch',
276
+ label: 'default',
277
+ });
278
+ continue;
279
+ }
280
+ if (seenBranches.has(n.branch)) continue;
281
+ seenBranches.add(n.branch);
282
+ edges.push({
283
+ id: `${defaultBranchNodeId}--branch_${n.branch}`,
284
+ source: defaultBranchNodeId,
285
+ target: n.branch,
286
+ type: 'parentBranch',
287
+ label: n.branch,
288
+ });
289
+ }
290
+ }
291
+
292
+ // worktreeLink edges: connect nodes sharing the same branch
293
+ for (const [branch, ids] of branchToNodeIds) {
294
+ if (ids.length < 2) continue;
295
+ for (let i = 1; i < ids.length; i++) {
296
+ edges.push({
297
+ id: `wt_${ids[0]}--${ids[i]}`,
298
+ source: ids[0],
299
+ target: ids[i],
300
+ type: 'worktreeLink',
301
+ label: branch,
302
+ });
303
+ }
304
+ }
305
+
306
+ // Collect warnings
307
+ const orphanCount = nodes.filter(n => n.isOrphan).length;
308
+ if (orphanCount > 0) {
309
+ warnings.push(`${orphanCount} orphan node(s) detected`);
310
+ }
311
+ const conflictCount = nodes.filter(n => n.hasConflicts).length;
312
+ if (conflictCount > 0) {
313
+ warnings.push(`${conflictCount} node(s) with merge conflicts`);
314
+ }
315
+ const offlineCount = nodes.filter(n => n.health === 'offline').length;
316
+ if (offlineCount > 0) {
317
+ warnings.push(`${offlineCount} node(s) offline`);
318
+ }
319
+
320
+ // Stats
321
+ const stats = {
322
+ totalNodes: status.nodes.length,
323
+ onlineNodes: status.nodes.filter(n => n.health === 'online').length,
324
+ dirtyNodes: nodes.filter(n => n.dirty).length,
325
+ orphanNodes: orphanCount,
326
+ errorNodes: status.nodes.filter(n => !!n.error).length,
327
+ offlineNodes: offlineCount,
328
+ totalActiveSessions: status.nodes.reduce((sum, n) => sum + (n.activeSessions?.length ?? 0), 0),
329
+ };
330
+
331
+ return {
332
+ meshId: status.meshId,
333
+ meshName: status.meshName,
334
+ repoIdentity: status.repoIdentity,
335
+ refreshedAt: status.refreshedAt,
336
+ nodes,
337
+ edges,
338
+ stats,
339
+ warnings,
340
+ };
341
+ }
@@ -39,6 +39,8 @@ export interface MeshWorkQueueEntry {
39
39
  sessionId?: string;
40
40
  updatedAt: string;
41
41
  };
42
+ /** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
43
+ dispatchTimestamp?: string;
42
44
  createdAt: string;
43
45
  updatedAt: string;
44
46
  }
@@ -132,6 +134,7 @@ export function claimNextTask(meshId: string, nodeId: string, sessionId: string)
132
134
  entry.status = 'assigned';
133
135
  entry.assignedNodeId = nodeId;
134
136
  entry.assignedSessionId = sessionId;
137
+ entry.dispatchTimestamp = new Date().toISOString();
135
138
  entry.updatedAt = new Date().toISOString();
136
139
 
137
140
  writeQueue(meshId, queue);
@@ -243,17 +246,27 @@ export function updateSessionTaskStatus(
243
246
  status: MeshTaskStatus,
244
247
  ): MeshWorkQueueEntry | null {
245
248
  const queue = readQueue(meshId);
246
- // Find the most recently assigned task for this session that isn't already terminal
247
- // (In case multiple tasks were assigned to the same session over time, though rare)
249
+ // Collect all assigned tasks for this session, then pick the one with the
250
+ // most recent dispatchTimestamp (or updatedAt fallback for legacy entries).
251
+ // This prevents completing the wrong task when multiple tasks were assigned
252
+ // to the same session in rapid succession.
253
+ let bestIdx = -1;
254
+ let bestTime = 0;
248
255
  for (let i = queue.length - 1; i >= 0; i--) {
249
256
  if (queue[i].assignedSessionId === sessionId && queue[i].status === 'assigned') {
250
- queue[i].status = status;
251
- queue[i].updatedAt = new Date().toISOString();
252
- writeQueue(meshId, queue);
253
- return queue[i];
257
+ const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
258
+ if (time > bestTime) {
259
+ bestTime = time;
260
+ bestIdx = i;
261
+ }
254
262
  }
255
263
  }
256
- return null;
264
+ if (bestIdx === -1) return null;
265
+
266
+ queue[bestIdx].status = status;
267
+ queue[bestIdx].updatedAt = new Date().toISOString();
268
+ writeQueue(meshId, queue);
269
+ return queue[bestIdx];
257
270
  }
258
271
 
259
272
  export interface MeshWorkQueueStats {
@@ -61,6 +61,7 @@ import {
61
61
  buildToolChatMessage,
62
62
  buildUserChatMessage,
63
63
  normalizeChatMessages,
64
+ extractFinalSummaryFromMessages,
64
65
  } from './chat-message-normalization.js';
65
66
  import { LOG } from '../logging/logger.js';
66
67
  import type { ChatMessage } from '../types.js';
@@ -1507,7 +1508,7 @@ export class AcpProviderInstance implements ProviderInstance {
1507
1508
  });
1508
1509
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
1509
1510
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
1510
- this.pushEvent({ event: 'agent:generating_completed', chatTitle, duration, timestamp: now });
1511
+ this.pushEvent({ event: 'agent:generating_completed', chatTitle, duration, timestamp: now, finalSummary: extractFinalSummaryFromMessages(this.messages) });
1511
1512
  this.generatingStartedAt = 0;
1512
1513
  } else if (newStatus === 'stopped') {
1513
1514
  this.pushEvent({ event: 'agent:stopped', chatTitle, timestamp: now });
@@ -1,4 +1,36 @@
1
1
  import type { ChatMessage } from '../types.js';
2
+ import { flattenContent } from './contracts.js';
3
+
4
+ export function extractFinalSummaryFromMessages(
5
+ messages: ChatMessage[] | null | undefined,
6
+ maxChars: number = 500,
7
+ ): string {
8
+ if (!Array.isArray(messages) || messages.length === 0) return '';
9
+
10
+ // Find last user-facing assistant message
11
+ for (let i = messages.length - 1; i >= 0; i--) {
12
+ const msg = messages[i];
13
+ if (!msg) continue;
14
+ const classification = classifyChatMessageVisibility(msg);
15
+ if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
16
+ const text = flattenContent(msg.content).trim();
17
+ if (text) return text.slice(0, maxChars);
18
+ }
19
+ }
20
+
21
+ // Fallback: last user-facing message of any role
22
+ for (let i = messages.length - 1; i >= 0; i--) {
23
+ const msg = messages[i];
24
+ if (!msg) continue;
25
+ const classification = classifyChatMessageVisibility(msg);
26
+ if (classification.isUserFacing) {
27
+ const text = flattenContent(msg.content).trim();
28
+ if (text) return text.slice(0, maxChars);
29
+ }
30
+ }
31
+
32
+ return '';
33
+ }
2
34
 
3
35
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
4
36
 
@@ -25,7 +25,7 @@ import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.
25
25
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
26
26
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
27
27
  import { normalizeProviderSessionId } from './provider-session-id.js';
28
- import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind } from './chat-message-normalization.js';
28
+ import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
29
29
 
30
30
  type PersistableCliHistoryMessage = {
31
31
  role: string;
@@ -841,6 +841,7 @@ export class CliProviderInstance implements ProviderInstance {
841
841
  chatTitle: pending.chatTitle,
842
842
  duration: pending.duration,
843
843
  timestamp: pending.timestamp,
844
+ finalSummary: extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
844
845
  });
845
846
  this.completedDebouncePending = null;
846
847
  this.completedDebounceTimer = null;
@@ -12,7 +12,7 @@ import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from '.
12
12
  import { ChatHistoryWriter } from '../config/chat-history.js';
13
13
  import type { ChatMessage } from '../types.js';
14
14
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
15
- import { buildChatMessage, buildRuntimeSystemChatMessage, normalizeChatMessages } from './chat-message-normalization.js';
15
+ import { buildChatMessage, buildRuntimeSystemChatMessage, normalizeChatMessages, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
16
16
  import { getProviderSessionCapabilities, EXTENSION_PROVIDER_SESSION_CAPABILITIES_BASE } from './open-panel-support.js';
17
17
 
18
18
  export class ExtensionProviderInstance implements ProviderInstance {
@@ -234,6 +234,7 @@ export class ExtensionProviderInstance implements ProviderInstance {
234
234
  agentType: this.type,
235
235
  agentName: this.agentName || this.provider.name,
236
236
  extensionId: this.extensionId || this.type,
237
+ finalSummary: extractFinalSummaryFromMessages(data?.messages),
237
238
  });
238
239
  this.generatingStartedAt = 0;
239
240
  }
@@ -22,7 +22,7 @@ import { validateReadChatResultPayload } from './read-chat-contract.js';
22
22
  import type { ChatMessage } from '../types.js';
23
23
  import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
24
24
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
25
- import { buildChatMessage, buildRuntimeSystemChatMessage, normalizeChatMessages } from './chat-message-normalization.js';
25
+ import { buildChatMessage, buildRuntimeSystemChatMessage, normalizeChatMessages, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
26
26
  import { getProviderSessionCapabilities, IDE_PROVIDER_SESSION_CAPABILITIES_BASE } from './open-panel-support.js';
27
27
 
28
28
  type ReadChatModal = {
@@ -470,7 +470,7 @@ export class IdeProviderInstance implements ProviderInstance {
470
470
  } else if (agentStatus === 'idle' && (lastStatus === 'generating' || lastStatus === 'waiting_approval')) {
471
471
  const startedAt = this.generatingStartedAt.get(agentKey);
472
472
  const duration = startedAt ? Math.round((now - startedAt) / 1000) : 0;
473
- this.pushEvent({ event: 'agent:generating_completed', chatTitle, duration, timestamp: now, ideType: this.type });
473
+ this.pushEvent({ event: 'agent:generating_completed', chatTitle, duration, timestamp: now, ideType: this.type, finalSummary: extractFinalSummaryFromMessages(chatData?.messages) });
474
474
  this.generatingStartedAt.delete(agentKey);
475
475
  }
476
476