@adhdev/daemon-core 0.9.82-rc.11 → 0.9.82-rc.111

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 (68) hide show
  1. package/dist/cli-adapters/provider-cli-adapter.d.ts +21 -0
  2. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  3. package/dist/cli-adapters/provider-cli-shared.d.ts +14 -0
  4. package/dist/commands/router.d.ts +22 -0
  5. package/dist/config/chat-history.d.ts +4 -0
  6. package/dist/config/mesh-config.d.ts +66 -1
  7. package/dist/index.d.ts +12 -5
  8. package/dist/index.js +6001 -1225
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +5967 -1212
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/installer.d.ts +1 -4
  13. package/dist/launch.d.ts +1 -1
  14. package/dist/logging/async-batch-writer.d.ts +10 -0
  15. package/dist/mesh/beads-db.d.ts +18 -0
  16. package/dist/mesh/mesh-active-work.d.ts +60 -0
  17. package/dist/mesh/mesh-events.d.ts +26 -5
  18. package/dist/mesh/mesh-fast-forward.d.ts +39 -0
  19. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  20. package/dist/mesh/mesh-ledger.d.ts +38 -1
  21. package/dist/mesh/mesh-work-queue.d.ts +27 -5
  22. package/dist/mesh/refine-config.d.ts +176 -0
  23. package/dist/providers/chat-message-normalization.d.ts +1 -0
  24. package/dist/providers/cli-provider-instance.d.ts +2 -1
  25. package/dist/repo-mesh-types.d.ts +45 -0
  26. package/dist/status/reporter.d.ts +2 -0
  27. package/package.json +3 -1
  28. package/src/boot/daemon-lifecycle.ts +1 -0
  29. package/src/cli-adapters/provider-cli-adapter.ts +453 -17
  30. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  31. package/src/cli-adapters/provider-cli-parse.ts +4 -0
  32. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  33. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  34. package/src/cli-adapters/provider-cli-shared.ts +32 -10
  35. package/src/commands/chat-commands.ts +626 -20
  36. package/src/commands/cli-manager.ts +129 -1
  37. package/src/commands/handler.ts +8 -1
  38. package/src/commands/mesh-coordinator.ts +13 -143
  39. package/src/commands/router.ts +2820 -437
  40. package/src/config/chat-history.ts +37 -9
  41. package/src/config/mesh-config.ts +245 -1
  42. package/src/daemon/dev-cli-debug.ts +10 -1
  43. package/src/detection/ide-detector.ts +26 -16
  44. package/src/index.ts +30 -4
  45. package/src/installer.d.ts +1 -1
  46. package/src/installer.ts +8 -6
  47. package/src/launch.d.ts +1 -1
  48. package/src/launch.ts +37 -28
  49. package/src/logging/async-batch-writer.ts +55 -0
  50. package/src/logging/logger.ts +2 -1
  51. package/src/mesh/beads-db.ts +176 -0
  52. package/src/mesh/coordinator-prompt.ts +31 -8
  53. package/src/mesh/mesh-active-work.ts +255 -0
  54. package/src/mesh/mesh-events.ts +389 -47
  55. package/src/mesh/mesh-fast-forward.ts +430 -0
  56. package/src/mesh/mesh-host-ownership.ts +73 -0
  57. package/src/mesh/mesh-ledger.ts +138 -1
  58. package/src/mesh/mesh-work-queue.ts +199 -137
  59. package/src/mesh/refine-config.ts +356 -0
  60. package/src/providers/chat-message-normalization.ts +7 -12
  61. package/src/providers/cli-provider-instance.ts +143 -18
  62. package/src/providers/ide-provider-instance.ts +17 -3
  63. package/src/providers/provider-loader.ts +10 -4
  64. package/src/providers/read-chat-contract.ts +1 -1
  65. package/src/providers/version-archive.ts +38 -20
  66. package/src/repo-mesh-types.ts +50 -0
  67. package/src/status/reporter.ts +15 -0
  68. package/src/system/host-memory.ts +29 -12
@@ -0,0 +1,356 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import * as yaml from 'js-yaml';
4
+
5
+ export const MESH_REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
6
+ export type MeshRefineValidationCategory = typeof MESH_REFINE_VALIDATION_CATEGORIES[number];
7
+
8
+ export interface RepoMeshRefineValidationCommandConfig {
9
+ /** Executable name or a whitespace-tokenized command string. Never executed through a shell. */
10
+ command: string;
11
+ /** Optional explicit argv. Prefer this over shell-like command strings. */
12
+ args?: string[];
13
+ category?: MeshRefineValidationCategory;
14
+ cwd?: string;
15
+ timeoutMs?: number;
16
+ env?: Record<string, string>;
17
+ }
18
+
19
+ export interface RepoMeshRefineConfig {
20
+ version: 1;
21
+ /**
22
+ * Narrow Refinery opt-in for monorepos with submodule gitlinks.
23
+ * When true, Refinery may non-force publish unreachable submodule gitlink
24
+ * commits to the submodule remote main branch after validation and
25
+ * patch-equivalence pass, then verify remote-main reachability.
26
+ */
27
+ allowAutoPublishSubmoduleMainCommits?: boolean;
28
+ validation?: {
29
+ required?: boolean;
30
+ /**
31
+ * Optional dependency/bootstrap commands that Refinery runs before
32
+ * validation commands. Refinery never infers installs on its own.
33
+ */
34
+ bootstrapCommands?: RepoMeshRefineValidationCommandConfig[];
35
+ commands?: RepoMeshRefineValidationCommandConfig[];
36
+ };
37
+ }
38
+
39
+ export interface MeshRefineValidationCommandPlan {
40
+ command: string;
41
+ args: string[];
42
+ displayCommand: string;
43
+ category: MeshRefineValidationCategory | 'custom';
44
+ source: string;
45
+ cwd?: string;
46
+ timeoutMs?: number;
47
+ env?: Record<string, string>;
48
+ }
49
+
50
+ export interface MeshRefineConfigLoadResult {
51
+ config?: RepoMeshRefineConfig;
52
+ source: string;
53
+ sourceType: 'mesh_policy' | 'repo_file' | 'unavailable' | 'invalid';
54
+ path?: string;
55
+ error?: string;
56
+ }
57
+
58
+ export interface MeshRefineValidationPlan {
59
+ source: string;
60
+ sourceType: MeshRefineConfigLoadResult['sourceType'];
61
+ bootstrapCommands: MeshRefineValidationCommandPlan[];
62
+ commands: MeshRefineValidationCommandPlan[];
63
+ rejectedCommands: Array<Record<string, unknown>>;
64
+ suggestions: RepoMeshRefineValidationCommandConfig[];
65
+ suggestedConfig?: RepoMeshRefineConfig;
66
+ unavailableReason?: string;
67
+ }
68
+
69
+ export const MESH_REFINE_CONFIG_LOCATIONS = [
70
+ '.adhdev/refine.json',
71
+ '.adhdev/refine.yaml',
72
+ '.adhdev/refine.yml',
73
+ '.adhdev/repo-mesh-refine.json',
74
+ '.adhdev/repo-mesh-refine.yaml',
75
+ '.adhdev/repo-mesh-refine.yml',
76
+ 'repo-mesh.refine.json',
77
+ 'repo-mesh.refine.yaml',
78
+ 'repo-mesh.refine.yml',
79
+ ];
80
+
81
+ export const MESH_REFINE_CONFIG_SCHEMA = {
82
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
83
+ title: 'ADHDev Repo Mesh Refinery Config',
84
+ type: 'object',
85
+ additionalProperties: false,
86
+ required: ['version'],
87
+ properties: {
88
+ version: { const: 1 },
89
+ allowAutoPublishSubmoduleMainCommits: {
90
+ type: 'boolean',
91
+ default: false,
92
+ description: 'When true, Refinery may non-force publish submodule gitlink commits referenced by the refined root tree to each submodule origin/main after validation and patch-equivalence pass, then verify reachability.',
93
+ },
94
+ validation: {
95
+ type: 'object',
96
+ additionalProperties: false,
97
+ properties: {
98
+ required: { type: 'boolean', default: true },
99
+ commands: {
100
+ type: 'array',
101
+ minItems: 1,
102
+ maxItems: 8,
103
+ items: {
104
+ type: 'object',
105
+ additionalProperties: false,
106
+ required: ['command'],
107
+ properties: {
108
+ command: { type: 'string', minLength: 1 },
109
+ args: { type: 'array', items: { type: 'string' } },
110
+ category: { enum: [...MESH_REFINE_VALIDATION_CATEGORIES, 'custom'] },
111
+ cwd: { type: 'string' },
112
+ timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
113
+ env: { type: 'object', additionalProperties: { type: 'string' } },
114
+ },
115
+ },
116
+ },
117
+ bootstrapCommands: {
118
+ type: 'array',
119
+ maxItems: 4,
120
+ items: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ required: ['command'],
124
+ properties: {
125
+ command: { type: 'string', minLength: 1 },
126
+ args: { type: 'array', items: { type: 'string' } },
127
+ category: { enum: [...MESH_REFINE_VALIDATION_CATEGORIES, 'custom'] },
128
+ cwd: { type: 'string' },
129
+ timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
130
+ env: { type: 'object', additionalProperties: { type: 'string' } },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ },
136
+ },
137
+ } as const;
138
+
139
+ function isRecord(value: unknown): value is Record<string, unknown> {
140
+ return !!value && typeof value === 'object' && !Array.isArray(value);
141
+ }
142
+
143
+ function tokenizeCommandString(command: string): string[] | null {
144
+ const trimmed = command.trim();
145
+ if (!trimmed) return null;
146
+ // Explicit config may name any executable, but the Refinery never invokes a shell.
147
+ // Reject shell syntax, quotes and substitutions so config cannot smuggle a compound command.
148
+ if (/[;&|<>`$\\\n\r'\"]/.test(trimmed)) return null;
149
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
150
+ if (!tokens.length) return null;
151
+ if (tokens.some(token => !/^[A-Za-z0-9_@./:=+-]+$/.test(token))) return null;
152
+ return tokens;
153
+ }
154
+
155
+ function validateCategory(value: unknown): MeshRefineValidationCategory | 'custom' {
156
+ return typeof value === 'string' && ([...MESH_REFINE_VALIDATION_CATEGORIES, 'custom'] as string[]).includes(value)
157
+ ? value as MeshRefineValidationCategory | 'custom'
158
+ : 'custom';
159
+ }
160
+
161
+ function normalizeCommandConfig(entry: unknown, source: string): { command?: MeshRefineValidationCommandPlan; rejected?: Record<string, unknown> } {
162
+ if (!isRecord(entry) || typeof entry.command !== 'string') {
163
+ return { rejected: { source, reason: 'validation command must be an object with a command string' } };
164
+ }
165
+
166
+ const commandText = entry.command.trim();
167
+ const explicitArgs = Array.isArray(entry.args) ? entry.args : undefined;
168
+ if (explicitArgs && !explicitArgs.every(arg => typeof arg === 'string')) {
169
+ return { rejected: { source, command: commandText, reason: 'args must be an array of strings' } };
170
+ }
171
+
172
+ let command = commandText;
173
+ let args = explicitArgs ? [...explicitArgs] : [];
174
+ if (!explicitArgs) {
175
+ const tokens = tokenizeCommandString(commandText);
176
+ if (!tokens) return { rejected: { source, command: commandText, reason: 'unsafe command string is not allowlisted' } };
177
+ command = tokens[0];
178
+ args = tokens.slice(1);
179
+ } else if (!tokenizeCommandString(command)) {
180
+ return { rejected: { source, command: commandText, reason: 'unsafe executable name is not allowlisted' } };
181
+ }
182
+
183
+ if (args.some(arg => /[\n\r\0]/.test(arg))) {
184
+ return { rejected: { source, command: commandText, reason: 'args cannot contain control characters' } };
185
+ }
186
+ if (entry.cwd !== undefined && typeof entry.cwd !== 'string') {
187
+ return { rejected: { source, command: commandText, reason: 'cwd must be a string when provided' } };
188
+ }
189
+ if (entry.timeoutMs !== undefined && (typeof entry.timeoutMs !== 'number' || !Number.isFinite(entry.timeoutMs) || entry.timeoutMs < 1000 || entry.timeoutMs > 600000)) {
190
+ return { rejected: { source, command: commandText, reason: 'timeoutMs must be between 1000 and 600000' } };
191
+ }
192
+ if (entry.env !== undefined && (!isRecord(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
193
+ return { rejected: { source, command: commandText, reason: 'env must be an object of string values' } };
194
+ }
195
+
196
+ return {
197
+ command: {
198
+ command,
199
+ args,
200
+ displayCommand: [command, ...args].join(' '),
201
+ category: validateCategory(entry.category),
202
+ source,
203
+ ...(typeof entry.cwd === 'string' && entry.cwd.trim() ? { cwd: entry.cwd.trim() } : {}),
204
+ ...(typeof entry.timeoutMs === 'number' ? { timeoutMs: entry.timeoutMs } : {}),
205
+ ...(isRecord(entry.env) ? { env: entry.env as Record<string, string> } : {}),
206
+ },
207
+ };
208
+ }
209
+
210
+ export function validateMeshRefineConfig(config: unknown, source = 'inline'): { valid: boolean; errors: string[]; bootstrapCommands: MeshRefineValidationCommandPlan[]; commands: MeshRefineValidationCommandPlan[]; rejectedCommands: Array<Record<string, unknown>> } {
211
+ const errors: string[] = [];
212
+ const bootstrapCommands: MeshRefineValidationCommandPlan[] = [];
213
+ const commands: MeshRefineValidationCommandPlan[] = [];
214
+ const rejectedCommands: Array<Record<string, unknown>> = [];
215
+
216
+ if (!isRecord(config)) return { valid: false, errors: ['config must be an object'], bootstrapCommands, commands, rejectedCommands };
217
+ if (config.version !== 1) errors.push('version must be 1');
218
+ if (config.allowAutoPublishSubmoduleMainCommits !== undefined && typeof config.allowAutoPublishSubmoduleMainCommits !== 'boolean') {
219
+ errors.push('allowAutoPublishSubmoduleMainCommits must be a boolean when provided');
220
+ }
221
+ const validation = config.validation;
222
+ if (validation !== undefined && !isRecord(validation)) errors.push('validation must be an object');
223
+ const rawCommands = isRecord(validation) ? validation.commands : undefined;
224
+ const rawBootstrapCommands = isRecord(validation) ? validation.bootstrapCommands : undefined;
225
+ if (rawCommands !== undefined && !Array.isArray(rawCommands)) errors.push('validation.commands must be an array');
226
+ if (rawBootstrapCommands !== undefined && !Array.isArray(rawBootstrapCommands)) errors.push('validation.bootstrapCommands must be an array');
227
+ if (Array.isArray(rawBootstrapCommands)) {
228
+ rawBootstrapCommands.forEach((entry, index) => {
229
+ const normalized = normalizeCommandConfig(entry, `${source}:validation.bootstrapCommands[${index}]`);
230
+ if (normalized.command) bootstrapCommands.push(normalized.command);
231
+ if (normalized.rejected) rejectedCommands.push(normalized.rejected);
232
+ });
233
+ }
234
+ if (Array.isArray(rawCommands)) {
235
+ rawCommands.forEach((entry, index) => {
236
+ const normalized = normalizeCommandConfig(entry, `${source}:validation.commands[${index}]`);
237
+ if (normalized.command) commands.push(normalized.command);
238
+ if (normalized.rejected) rejectedCommands.push(normalized.rejected);
239
+ });
240
+ }
241
+ if (rejectedCommands.length) errors.push('one or more validation commands are invalid');
242
+ return { valid: errors.length === 0, errors, bootstrapCommands, commands, rejectedCommands };
243
+ }
244
+
245
+ function parseConfigText(path: string, text: string): unknown {
246
+ if (/\.json$/i.test(path)) return JSON.parse(text);
247
+ return yaml.load(text);
248
+ }
249
+
250
+ export function loadMeshRefineConfig(mesh: any, workspace: string): MeshRefineConfigLoadResult {
251
+ const policy = mesh?.policy && typeof mesh.policy === 'object' && !Array.isArray(mesh.policy) ? mesh.policy : {};
252
+ const inline = mesh?.refineConfig || (policy as any).refineConfig || (policy as any).refine;
253
+ if (inline !== undefined) {
254
+ const validation = validateMeshRefineConfig(inline, 'mesh.policy.refineConfig');
255
+ if (!validation.valid) return { source: 'mesh.policy.refineConfig', sourceType: 'invalid', error: String(validation.rejectedCommands[0]?.reason || validation.errors.join('; ')) };
256
+ return { config: inline as RepoMeshRefineConfig, source: 'mesh.policy.refineConfig', sourceType: 'mesh_policy' };
257
+ }
258
+
259
+ for (const relative of MESH_REFINE_CONFIG_LOCATIONS) {
260
+ const configPath = join(workspace, relative);
261
+ if (!existsSync(configPath)) continue;
262
+ try {
263
+ const parsed = parseConfigText(configPath, readFileSync(configPath, 'utf-8'));
264
+ const validation = validateMeshRefineConfig(parsed, relative);
265
+ if (!validation.valid) return { source: relative, sourceType: 'invalid', path: configPath, error: String(validation.rejectedCommands[0]?.reason || validation.errors.join('; ')) };
266
+ return { config: parsed as RepoMeshRefineConfig, source: relative, sourceType: 'repo_file', path: configPath };
267
+ } catch (error: any) {
268
+ return { source: relative, sourceType: 'invalid', path: configPath, error: error?.message || String(error) };
269
+ }
270
+ }
271
+
272
+ return {
273
+ source: 'unavailable',
274
+ sourceType: 'unavailable',
275
+ error: `No repo mesh/refine config found. Checked: ${MESH_REFINE_CONFIG_LOCATIONS.join(', ')}`,
276
+ };
277
+ }
278
+
279
+ function readPackageScripts(workspace: string): Record<string, string> {
280
+ try {
281
+ const parsed = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf-8'));
282
+ return isRecord(parsed?.scripts) ? parsed.scripts as Record<string, string> : {};
283
+ } catch {
284
+ return {};
285
+ }
286
+ }
287
+
288
+ function collectProjectContextSuggestions(mesh: any): RepoMeshRefineValidationCommandConfig[] {
289
+ const commands = mesh?.projectContext?.commands;
290
+ if (!isRecord(commands)) return [];
291
+ const suggestions: RepoMeshRefineValidationCommandConfig[] = [];
292
+ for (const category of MESH_REFINE_VALIDATION_CATEGORIES) {
293
+ const entries = Array.isArray(commands[category]) ? commands[category] : [];
294
+ for (const entry of entries) {
295
+ if (isRecord(entry) && typeof entry.command === 'string') suggestions.push({ command: entry.command, category });
296
+ }
297
+ }
298
+ return suggestions;
299
+ }
300
+
301
+ function collectPackageScriptSuggestions(workspace: string): RepoMeshRefineValidationCommandConfig[] {
302
+ const scripts = readPackageScripts(workspace);
303
+ const suggestions: RepoMeshRefineValidationCommandConfig[] = [];
304
+ for (const category of MESH_REFINE_VALIDATION_CATEGORIES) {
305
+ for (const scriptName of Object.keys(scripts)) {
306
+ if (scriptName === category || scriptName.startsWith(`${category}:`)) {
307
+ suggestions.push({ command: 'npm', args: ['run', scriptName], category });
308
+ }
309
+ }
310
+ }
311
+ return suggestions;
312
+ }
313
+
314
+ export function suggestMeshRefineConfig(mesh: any, workspace: string): { suggestions: RepoMeshRefineValidationCommandConfig[]; suggestedConfig?: RepoMeshRefineConfig } {
315
+ const seen = new Set<string>();
316
+ const suggestions: RepoMeshRefineValidationCommandConfig[] = [];
317
+ for (const entry of [...collectProjectContextSuggestions(mesh), ...collectPackageScriptSuggestions(workspace)]) {
318
+ const key = `${entry.command} ${(entry.args || []).join(' ')}`.trim();
319
+ if (seen.has(key)) continue;
320
+ seen.add(key);
321
+ suggestions.push(entry);
322
+ }
323
+ return {
324
+ suggestions,
325
+ suggestedConfig: suggestions.length ? { version: 1, validation: { required: true, commands: suggestions.slice(0, 4) } } : undefined,
326
+ };
327
+ }
328
+
329
+ export function resolveMeshRefineValidationPlan(mesh: any, workspace: string): MeshRefineValidationPlan {
330
+ const loaded = loadMeshRefineConfig(mesh, workspace);
331
+ const suggestion = suggestMeshRefineConfig(mesh, workspace);
332
+ if (!loaded.config) {
333
+ return {
334
+ source: loaded.source,
335
+ sourceType: loaded.sourceType,
336
+ bootstrapCommands: [],
337
+ commands: [],
338
+ rejectedCommands: loaded.error ? [{ source: loaded.source, reason: loaded.error }] : [],
339
+ suggestions: suggestion.suggestions,
340
+ suggestedConfig: suggestion.suggestedConfig,
341
+ unavailableReason: loaded.error || 'validation_unavailable: repo mesh/refine config missing',
342
+ };
343
+ }
344
+
345
+ const validation = validateMeshRefineConfig(loaded.config, loaded.source);
346
+ return {
347
+ source: loaded.path || loaded.source,
348
+ sourceType: loaded.sourceType,
349
+ bootstrapCommands: validation.bootstrapCommands,
350
+ commands: validation.commands,
351
+ rejectedCommands: validation.rejectedCommands,
352
+ suggestions: suggestion.suggestions,
353
+ suggestedConfig: suggestion.suggestedConfig,
354
+ unavailableReason: validation.commands.length ? undefined : 'validation_unavailable: repo mesh/refine config has no validation.commands',
355
+ };
356
+ }
@@ -1,9 +1,11 @@
1
1
  import type { ChatMessage } from '../types.js';
2
2
  import { flattenContent } from './contracts.js';
3
3
 
4
+ export const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 4_000;
5
+
4
6
  export function extractFinalSummaryFromMessages(
5
7
  messages: ChatMessage[] | null | undefined,
6
- maxChars: number = 500,
8
+ maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
7
9
  ): string {
8
10
  if (!Array.isArray(messages) || messages.length === 0) return '';
9
11
 
@@ -18,17 +20,10 @@ export function extractFinalSummaryFromMessages(
18
20
  }
19
21
  }
20
22
 
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
-
23
+ // Completion summaries must describe the assistant/model result. If no
24
+ // user-facing assistant/model message exists yet (for example, only the
25
+ // dispatched user prompt is visible), return empty instead of echoing the
26
+ // prompt as a misleading finalSummary.
32
27
  return '';
33
28
  }
34
29
 
@@ -43,6 +43,11 @@ type CompletedDebouncePending = {
43
43
  loggedBlockReason?: string;
44
44
  };
45
45
 
46
+ type CompletedFinalizationBlock = {
47
+ reason: string;
48
+ terminal?: boolean;
49
+ };
50
+
46
51
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
52
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
48
53
 
@@ -492,8 +497,14 @@ export class CliProviderInstance implements ProviderInstance {
492
497
  if (typeof this.adapter.getScriptParsedStatus === 'function') {
493
498
  try {
494
499
  parsedStatus = this.adapter.getScriptParsedStatus() || null;
495
- this.errorMessage = undefined;
496
- this.errorReason = undefined;
500
+ const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
501
+ ? parsedStatus.errorMessage.trim()
502
+ : undefined;
503
+ const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
504
+ ? parsedStatus.errorReason.trim() as ProviderErrorReason
505
+ : undefined;
506
+ this.errorMessage = parsedErrorMessage;
507
+ this.errorReason = parsedErrorReason;
497
508
  } catch (error: any) {
498
509
  parseErrorMessage = error?.message || String(error);
499
510
  this.errorMessage = parseErrorMessage;
@@ -503,8 +514,15 @@ export class CliProviderInstance implements ProviderInstance {
503
514
  this.errorMessage = undefined;
504
515
  this.errorReason = undefined;
505
516
  }
517
+ const adapterProviderSessionId = normalizeProviderSessionId(
518
+ this.provider,
519
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
520
+ );
521
+ if (adapterProviderSessionId) {
522
+ this.promoteProviderSessionId(adapterProviderSessionId);
523
+ }
506
524
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
507
- const visibleStatus = parseErrorMessage
525
+ const visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
508
526
  ? 'error'
509
527
  : (autoApproveActive ? 'generating' : adapterStatus.status);
510
528
  const parsedProviderSessionId = normalizeProviderSessionId(
@@ -516,6 +534,7 @@ export class CliProviderInstance implements ProviderInstance {
516
534
  }
517
535
  const runtime = this.adapter.getRuntimeMetadata();
518
536
  this.maybeAppendRuntimeRecoveryMessage(runtime);
537
+ const activeChatId = this.providerSessionId || runtime?.runtimeId || this.instanceId;
519
538
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
539
  ? parsedStatus.messages
521
540
  : [];
@@ -529,6 +548,15 @@ export class CliProviderInstance implements ProviderInstance {
529
548
  }
530
549
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
531
550
  const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
551
+ const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
552
+ ? this.lastPersistedHistoryMessages.map((message) => ({
553
+ role: message.role,
554
+ content: message.content,
555
+ kind: message.kind,
556
+ senderName: message.senderName,
557
+ receivedAt: message.receivedAt,
558
+ }))
559
+ : mergedMessages;
532
560
 
533
561
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
534
562
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
@@ -592,10 +620,10 @@ export class CliProviderInstance implements ProviderInstance {
592
620
  status: visibleStatus,
593
621
  mode: this.presentationMode,
594
622
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
623
+ id: activeChatId,
596
624
  title: parsedStatus?.title || dirName,
597
625
  status: activeChatStatus,
598
- messages: mergedMessages,
626
+ messages: statusMessages,
599
627
  activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
600
628
  inputContent: '',
601
629
  },
@@ -743,6 +771,55 @@ export class CliProviderInstance implements ProviderInstance {
743
771
  return role === 'assistant' && !!content;
744
772
  }
745
773
 
774
+ private buildCompletedFinalizationDiagnostic(args: {
775
+ blockReason: string;
776
+ latestStatus?: any;
777
+ latestVisibleStatus: string;
778
+ waitedMs: number;
779
+ pending: CompletedDebouncePending;
780
+ emittedAfterFinalizationTimeout: boolean;
781
+ }): Record<string, unknown> {
782
+ let parsed: any = null;
783
+ let parseError: string | undefined;
784
+ try {
785
+ parsed = this.adapter.getScriptParsedStatus();
786
+ } catch (error: any) {
787
+ parseError = error?.message || String(error);
788
+ }
789
+
790
+ const visibleMessages = (Array.isArray(parsed?.messages) ? parsed.messages : [])
791
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
792
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
793
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
794
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
795
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
796
+
797
+ return {
798
+ providerType: this.type,
799
+ sessionId: this.instanceId,
800
+ providerSessionId: this.providerSessionId || null,
801
+ workspace: this.workingDir,
802
+ blockReason: args.blockReason,
803
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
804
+ waitedMs: args.waitedMs,
805
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
806
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
807
+ latestVisibleStatus: args.latestVisibleStatus,
808
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
809
+ parseError: parseError || undefined,
810
+ finalAssistantPresent: this.completionHasFinalAssistantMessage(parsed?.messages),
811
+ visibleMessageCount: visibleMessages.length,
812
+ lastVisibleRole,
813
+ lastVisibleKind,
814
+ lastVisibleContentLength,
815
+ pendingStartedAt: this.generatingStartedAt || null,
816
+ pendingFirstObservedAt: args.pending.firstObservedAt,
817
+ pendingTimestamp: args.pending.timestamp,
818
+ pendingDurationSec: args.pending.duration,
819
+ previousBlockReason: args.pending.loggedBlockReason || null,
820
+ };
821
+ }
822
+
746
823
  private hasAdapterPendingResponse(): boolean {
747
824
  const adapterAny = this.adapter as any;
748
825
  if (adapterAny?.isWaitingForResponse === true) return true;
@@ -768,29 +845,34 @@ export class CliProviderInstance implements ProviderInstance {
768
845
  return !this.hasAdapterPendingResponse();
769
846
  }
770
847
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
848
+ private getCompletedFinalizationBlock(latestVisibleStatus: string): CompletedFinalizationBlock | null {
849
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
850
 
774
851
  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';
852
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
853
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
854
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
777
855
 
778
856
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
857
  ? this.adapter.getPartialResponse()
780
858
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
859
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
860
 
783
861
  let parsed: any;
784
862
  try {
785
863
  parsed = this.adapter.getScriptParsedStatus();
786
864
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
865
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
866
  }
789
867
 
790
868
  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';
869
+ if (parsedStatus !== 'idle') {
870
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
871
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
872
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
873
+ }
874
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
875
+ if (!this.completionHasFinalAssistantMessage(parsed?.messages)) return { reason: 'missing_final_assistant' };
794
876
 
795
877
  return null;
796
878
  }
@@ -817,10 +899,11 @@ export class CliProviderInstance implements ProviderInstance {
817
899
  return;
818
900
  }
819
901
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
902
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus);
903
+ if (block) {
904
+ const blockReason = block.reason;
822
905
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
906
+ if (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
824
907
  if (pending.loggedBlockReason !== blockReason) {
825
908
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
826
909
  pending.loggedBlockReason = blockReason;
@@ -828,7 +911,25 @@ export class CliProviderInstance implements ProviderInstance {
828
911
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
912
  return;
830
913
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
914
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
915
+ blockReason,
916
+ latestStatus,
917
+ latestVisibleStatus,
918
+ waitedMs,
919
+ pending,
920
+ emittedAfterFinalizationTimeout: true,
921
+ });
922
+ LOG.warn('CLI', `[${this.type}] emitting completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
923
+ this.pushEvent({
924
+ event: 'agent:generating_completed',
925
+ chatTitle: pending.chatTitle,
926
+ duration: pending.duration,
927
+ timestamp: pending.timestamp,
928
+ finalSummary: blockReason.startsWith('parsed_status:')
929
+ ? ''
930
+ : extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
931
+ completionDiagnostic,
932
+ });
832
933
  this.completedDebouncePending = null;
833
934
  this.completedDebounceTimer = null;
834
935
  this.generatingStartedAt = 0;
@@ -876,6 +977,13 @@ export class CliProviderInstance implements ProviderInstance {
876
977
  // during long-running CLI sessions. Keep this path on adapter-owned light
877
978
  // state only; rich provider parsing is reserved for getState/read_chat.
878
979
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
980
+ const adapterProviderSessionId = normalizeProviderSessionId(
981
+ this.provider,
982
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
983
+ );
984
+ if (adapterProviderSessionId) {
985
+ this.promoteProviderSessionId(adapterProviderSessionId);
986
+ }
879
987
  const parsedStatus = null;
880
988
  const rawStatus = adapterStatus.status;
881
989
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
@@ -957,6 +1065,23 @@ export class CliProviderInstance implements ProviderInstance {
957
1065
  }
958
1066
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
959
1067
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
1068
+ } else if (newStatus === 'error') {
1069
+ if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1070
+ this.generatingDebouncePending = null;
1071
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1072
+ this.completedDebouncePending = null;
1073
+ this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
1074
+ this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
1075
+ this.pushEvent({
1076
+ event: 'agent:stopped',
1077
+ chatTitle,
1078
+ timestamp: now,
1079
+ finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
1080
+ completionDiagnostic: {
1081
+ reason: adapterStatus.errorReason || 'provider_error',
1082
+ errorMessage: adapterStatus.errorMessage || undefined,
1083
+ },
1084
+ });
960
1085
  } else if (newStatus === 'stopped') {
961
1086
  // Cancel any pending debounce
962
1087
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }