@adhdev/daemon-core 0.9.82-rc.9 → 0.9.82-rc.91

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 (67) hide show
  1. package/dist/cli-adapters/provider-cli-adapter.d.ts +2 -0
  2. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  3. package/dist/cli-adapters/provider-cli-shared.d.ts +2 -0
  4. package/dist/commands/router.d.ts +22 -0
  5. package/dist/config/mesh-config.d.ts +66 -1
  6. package/dist/index.d.ts +13 -6
  7. package/dist/index.js +5417 -1207
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5381 -1193
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/installer.d.ts +1 -4
  12. package/dist/launch.d.ts +1 -1
  13. package/dist/logging/async-batch-writer.d.ts +10 -0
  14. package/dist/mesh/beads-db.d.ts +18 -0
  15. package/dist/mesh/mesh-active-work.d.ts +60 -0
  16. package/dist/mesh/mesh-events.d.ts +29 -5
  17. package/dist/mesh/mesh-fast-forward.d.ts +39 -0
  18. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  19. package/dist/mesh/mesh-ledger.d.ts +38 -1
  20. package/dist/mesh/mesh-work-queue.d.ts +27 -5
  21. package/dist/mesh/refine-config.d.ts +176 -0
  22. package/dist/providers/chat-message-normalization.d.ts +1 -0
  23. package/dist/providers/cli-provider-instance.d.ts +2 -1
  24. package/dist/repo-mesh-types.d.ts +46 -0
  25. package/dist/status/reporter.d.ts +2 -0
  26. package/package.json +3 -1
  27. package/src/boot/daemon-lifecycle.ts +1 -0
  28. package/src/cli-adapters/provider-cli-adapter.ts +91 -3
  29. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  30. package/src/cli-adapters/provider-cli-parse.ts +4 -0
  31. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  32. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  33. package/src/cli-adapters/provider-cli-shared.ts +20 -10
  34. package/src/commands/chat-commands.ts +472 -15
  35. package/src/commands/cli-manager.ts +126 -0
  36. package/src/commands/handler.ts +8 -1
  37. package/src/commands/mesh-coordinator.ts +13 -143
  38. package/src/commands/router.ts +2687 -435
  39. package/src/config/chat-history.ts +9 -7
  40. package/src/config/mesh-config.ts +245 -1
  41. package/src/daemon/dev-cli-debug.ts +10 -1
  42. package/src/detection/ide-detector.ts +26 -16
  43. package/src/index.ts +31 -5
  44. package/src/installer.d.ts +1 -1
  45. package/src/installer.ts +8 -6
  46. package/src/launch.d.ts +1 -1
  47. package/src/launch.ts +37 -28
  48. package/src/logging/async-batch-writer.ts +55 -0
  49. package/src/logging/logger.ts +2 -1
  50. package/src/mesh/beads-db.ts +176 -0
  51. package/src/mesh/coordinator-prompt.ts +30 -7
  52. package/src/mesh/mesh-active-work.ts +255 -0
  53. package/src/mesh/mesh-events.ts +400 -47
  54. package/src/mesh/mesh-fast-forward.ts +430 -0
  55. package/src/mesh/mesh-host-ownership.ts +73 -0
  56. package/src/mesh/mesh-ledger.ts +138 -1
  57. package/src/mesh/mesh-work-queue.ts +199 -137
  58. package/src/mesh/refine-config.ts +356 -0
  59. package/src/providers/chat-message-normalization.ts +7 -12
  60. package/src/providers/cli-provider-instance.ts +93 -14
  61. package/src/providers/ide-provider-instance.ts +17 -3
  62. package/src/providers/provider-loader.ts +10 -4
  63. package/src/providers/read-chat-contract.ts +1 -1
  64. package/src/providers/version-archive.ts +38 -20
  65. package/src/repo-mesh-types.ts +51 -0
  66. package/src/status/reporter.ts +15 -0
  67. 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
 
@@ -516,6 +521,7 @@ export class CliProviderInstance implements ProviderInstance {
516
521
  }
517
522
  const runtime = this.adapter.getRuntimeMetadata();
518
523
  this.maybeAppendRuntimeRecoveryMessage(runtime);
524
+ const activeChatId = this.providerSessionId || runtime?.runtimeId || this.instanceId;
519
525
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
526
  ? parsedStatus.messages
521
527
  : [];
@@ -592,7 +598,7 @@ export class CliProviderInstance implements ProviderInstance {
592
598
  status: visibleStatus,
593
599
  mode: this.presentationMode,
594
600
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
601
+ id: activeChatId,
596
602
  title: parsedStatus?.title || dirName,
597
603
  status: activeChatStatus,
598
604
  messages: mergedMessages,
@@ -743,6 +749,55 @@ export class CliProviderInstance implements ProviderInstance {
743
749
  return role === 'assistant' && !!content;
744
750
  }
745
751
 
752
+ private buildCompletedFinalizationDiagnostic(args: {
753
+ blockReason: string;
754
+ latestStatus?: any;
755
+ latestVisibleStatus: string;
756
+ waitedMs: number;
757
+ pending: CompletedDebouncePending;
758
+ emittedAfterFinalizationTimeout: boolean;
759
+ }): Record<string, unknown> {
760
+ let parsed: any = null;
761
+ let parseError: string | undefined;
762
+ try {
763
+ parsed = this.adapter.getScriptParsedStatus();
764
+ } catch (error: any) {
765
+ parseError = error?.message || String(error);
766
+ }
767
+
768
+ const visibleMessages = (Array.isArray(parsed?.messages) ? parsed.messages : [])
769
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
770
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
771
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
772
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
773
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
774
+
775
+ return {
776
+ providerType: this.type,
777
+ sessionId: this.instanceId,
778
+ providerSessionId: this.providerSessionId || null,
779
+ workspace: this.workingDir,
780
+ blockReason: args.blockReason,
781
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
782
+ waitedMs: args.waitedMs,
783
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
784
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
785
+ latestVisibleStatus: args.latestVisibleStatus,
786
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
787
+ parseError: parseError || undefined,
788
+ finalAssistantPresent: this.completionHasFinalAssistantMessage(parsed?.messages),
789
+ visibleMessageCount: visibleMessages.length,
790
+ lastVisibleRole,
791
+ lastVisibleKind,
792
+ lastVisibleContentLength,
793
+ pendingStartedAt: this.generatingStartedAt || null,
794
+ pendingFirstObservedAt: args.pending.firstObservedAt,
795
+ pendingTimestamp: args.pending.timestamp,
796
+ pendingDurationSec: args.pending.duration,
797
+ previousBlockReason: args.pending.loggedBlockReason || null,
798
+ };
799
+ }
800
+
746
801
  private hasAdapterPendingResponse(): boolean {
747
802
  const adapterAny = this.adapter as any;
748
803
  if (adapterAny?.isWaitingForResponse === true) return true;
@@ -768,29 +823,34 @@ export class CliProviderInstance implements ProviderInstance {
768
823
  return !this.hasAdapterPendingResponse();
769
824
  }
770
825
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
826
+ private getCompletedFinalizationBlock(latestVisibleStatus: string): CompletedFinalizationBlock | null {
827
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
828
 
774
829
  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';
830
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
831
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
832
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
777
833
 
778
834
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
835
  ? this.adapter.getPartialResponse()
780
836
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
837
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
838
 
783
839
  let parsed: any;
784
840
  try {
785
841
  parsed = this.adapter.getScriptParsedStatus();
786
842
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
843
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
844
  }
789
845
 
790
846
  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';
847
+ if (parsedStatus !== 'idle') {
848
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
849
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
850
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
851
+ }
852
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
853
+ if (!this.completionHasFinalAssistantMessage(parsed?.messages)) return { reason: 'missing_final_assistant' };
794
854
 
795
855
  return null;
796
856
  }
@@ -817,10 +877,11 @@ export class CliProviderInstance implements ProviderInstance {
817
877
  return;
818
878
  }
819
879
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
880
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus);
881
+ if (block) {
882
+ const blockReason = block.reason;
822
883
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
884
+ if (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
824
885
  if (pending.loggedBlockReason !== blockReason) {
825
886
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
826
887
  pending.loggedBlockReason = blockReason;
@@ -828,7 +889,25 @@ export class CliProviderInstance implements ProviderInstance {
828
889
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
890
  return;
830
891
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
892
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
893
+ blockReason,
894
+ latestStatus,
895
+ latestVisibleStatus,
896
+ waitedMs,
897
+ pending,
898
+ emittedAfterFinalizationTimeout: true,
899
+ });
900
+ LOG.warn('CLI', `[${this.type}] emitting completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
901
+ this.pushEvent({
902
+ event: 'agent:generating_completed',
903
+ chatTitle: pending.chatTitle,
904
+ duration: pending.duration,
905
+ timestamp: pending.timestamp,
906
+ finalSummary: blockReason.startsWith('parsed_status:')
907
+ ? ''
908
+ : extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
909
+ completionDiagnostic,
910
+ });
832
911
  this.completedDebouncePending = null;
833
912
  this.completedDebounceTimer = null;
834
913
  this.generatingStartedAt = 0;
@@ -43,6 +43,20 @@ type ReadChatPayload = {
43
43
  [key: string]: unknown;
44
44
  };
45
45
 
46
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
47
+ let timer: ReturnType<typeof setTimeout> | null = null;
48
+ try {
49
+ return await Promise.race([
50
+ promise,
51
+ new Promise<never>((_, reject) => {
52
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
53
+ }),
54
+ ]);
55
+ } finally {
56
+ if (timer) clearTimeout(timer);
57
+ }
58
+ }
59
+
46
60
  export class IdeProviderInstance implements ProviderInstance {
47
61
  readonly type: string;
48
62
  readonly category = 'ide' as const;
@@ -320,7 +334,7 @@ export class IdeProviderInstance implements ProviderInstance {
320
334
  if (webviewScript) {
321
335
  const matchText = this.provider.webviewMatchText;
322
336
  const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
323
- const webviewRaw = await cdp.evaluateInWebviewFrame(webviewScript, matchFn);
337
+ const webviewRaw = await withTimeout(cdp.evaluateInWebviewFrame(webviewScript, matchFn), 30000, 'evaluateInWebviewFrame');
324
338
  if (webviewRaw) {
325
339
  raw = typeof webviewRaw === 'string' ? (() => { try { return JSON.parse(webviewRaw); } catch { return null; } })() : webviewRaw;
326
340
  }
@@ -331,7 +345,7 @@ export class IdeProviderInstance implements ProviderInstance {
331
345
  if (!raw) {
332
346
  const readChatScript = this.getReadChatScript();
333
347
  if (!readChatScript) return;
334
- raw = await cdp.evaluate(readChatScript, 30000);
348
+ raw = await withTimeout(cdp.evaluate(readChatScript, 30000), 30000, 'evaluate.readChatScript');
335
349
  if (typeof raw === 'string') {
336
350
  try { raw = JSON.parse(raw); } catch { return; }
337
351
  }
@@ -706,7 +720,7 @@ export class IdeProviderInstance implements ProviderInstance {
706
720
  );
707
721
 
708
722
  LOG.info('IdeInstance', `[IdeInstance:${this.type}] autoApprove: executing resolveAction for "${targetButton}"`);
709
- let rawResult = await cdp.evaluate(script, 10000);
723
+ let rawResult = await withTimeout(cdp.evaluate(script, 10000), 10000, 'evaluate.autoApprove');
710
724
  if (typeof rawResult === 'string') {
711
725
  try { rawResult = JSON.parse(rawResult); } catch { }
712
726
  }
@@ -1067,13 +1067,17 @@ export class ProviderLoader {
1067
1067
  awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
1068
1068
  });
1069
1069
 
1070
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null;
1070
1071
  const handleChange = (filePath: string) => {
1071
1072
  if (/[\/\\]fixtures[\/\\]/.test(filePath)) {
1072
1073
  return;
1073
1074
  }
1074
1075
  if (filePath.endsWith('.js') || filePath.endsWith('.json')) {
1075
- this.log(`File changed: ${path.basename(filePath)}, reloading...`);
1076
- this.reload();
1076
+ if (reloadTimer) clearTimeout(reloadTimer);
1077
+ reloadTimer = setTimeout(() => {
1078
+ this.log(`File changed: ${path.basename(filePath)}, reloading...`);
1079
+ this.reload();
1080
+ }, 300);
1077
1081
  }
1078
1082
  };
1079
1083
 
@@ -1130,7 +1134,9 @@ export class ProviderLoader {
1130
1134
  return { updated: false };
1131
1135
  }
1132
1136
  const https = require('https') as typeof import('https');
1133
- const { execSync } = require('child_process') as typeof import('child_process');
1137
+ const { exec } = require('child_process') as typeof import('child_process');
1138
+ const { promisify } = require('util');
1139
+ const execAsync = promisify(exec);
1134
1140
 
1135
1141
  const metaPath = path.join(this.upstreamDir, ProviderLoader.META_FILE);
1136
1142
  let prevEtag = '';
@@ -1207,7 +1213,7 @@ export class ProviderLoader {
1207
1213
 
1208
1214
  // Extract
1209
1215
  fs.mkdirSync(tmpExtract, { recursive: true });
1210
- execSync(`tar -xzf "${tmpTar}" -C "${tmpExtract}"`, { timeout: 30000 });
1216
+ await execAsync(`tar -xzf "${tmpTar}" -C "${tmpExtract}"`, { timeout: 30000 });
1211
1217
 
1212
1218
  // Tarball internal structure: adhdev-providers-main/ide/... → strip 1 level
1213
1219
  const extracted = fs.readdirSync(tmpExtract);
@@ -2,7 +2,7 @@ import type { MessagePart, ModalInfo, ReadChatResult } from './contracts.js'
2
2
  import { normalizeMessageParts } from './contracts.js'
3
3
  import type { ChatBubbleState, ChatMessage } from '../types.js'
4
4
 
5
- const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'streaming', 'long_generating'] as const
5
+ const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'starting', 'streaming', 'long_generating'] as const
6
6
  const VALID_ROLES = ['user', 'assistant', 'system', 'human'] as const
7
7
  const VALID_BUBBLE_STATES = ['draft', 'streaming', 'final', 'removed'] as const
8
8
  const VALID_TURN_STATUSES = ['open', 'waiting_approval', 'complete', 'error'] as const