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

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.
@@ -100,6 +100,12 @@ export declare class CliProviderInstance implements ProviderInstance {
100
100
  private completedDebounceTimer;
101
101
  private completedDebouncePending;
102
102
  private enforceFreshSessionLaunchIfNeeded;
103
+ private completionHasFinalAssistantMessage;
104
+ private hasAdapterPendingResponse;
105
+ private shouldSuppressStaleParsedBusyStatus;
106
+ private getCompletedFinalizationBlockReason;
107
+ private scheduleCompletedDebounceFlush;
108
+ private flushCompletedDebounceIfFinalized;
103
109
  private maybeAutoApproveStatus;
104
110
  private detectStatusTransition;
105
111
  private pushEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.77-rc.40",
3
+ "version": "0.9.77-rc.42",
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",
@@ -225,7 +225,7 @@ export class ProviderCliAdapter implements CliAdapter {
225
225
  currentTurnScope: TurnParseScope | null;
226
226
  recentOutputBuffer: string;
227
227
  accumulatedBuffer: string;
228
- accumulatedRawBuffer: string;
228
+ accumulatedRawBufferKey: string;
229
229
  screenText: string;
230
230
  currentStatus: CliSessionStatus['status'];
231
231
  activeModal: { message: string; buttons: string[] } | null;
@@ -300,15 +300,23 @@ export class ProviderCliAdapter implements CliAdapter {
300
300
  this.lastScreenSnapshotReadAt = Number.NEGATIVE_INFINITY;
301
301
  }
302
302
 
303
+ private getAccumulatedRawBufferCacheKey(): string {
304
+ return this.accumulatedRawBuffer
305
+ .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
306
+ .replace(/\x1B[P^_X][\s\S]*?(?:\x07|\x1B\\)/g, '')
307
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
308
+ }
309
+
303
310
  private getFreshParsedStatusCache(): any | null {
304
311
  const cached = this.parsedStatusCache;
312
+ const accumulatedRawBufferKey = this.getAccumulatedRawBufferCacheKey();
305
313
  if (
306
314
  cached
307
315
  && cached.responseBuffer === this.responseBuffer
308
316
  && cached.currentTurnScope === this.currentTurnScope
309
317
  && cached.recentOutputBuffer === this.recentOutputBuffer
310
318
  && cached.accumulatedBuffer === this.accumulatedBuffer
311
- && cached.accumulatedRawBuffer === this.accumulatedRawBuffer
319
+ && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
312
320
  && cached.screenText === this.lastScreenText
313
321
  && cached.currentStatus === this.currentStatus
314
322
  && cached.activeModal === this.activeModal
@@ -452,6 +460,7 @@ export class ProviderCliAdapter implements CliAdapter {
452
460
 
453
461
  // Scripts are required — loaded by ProviderLoader via compatibility array
454
462
  this.cliScripts = provider.scripts || {};
463
+ this.scriptState = typeof this.cliScripts.createState === 'function' ? (this.cliScripts.createState() ?? null) : null;
455
464
  const scriptNames = listCliScriptNames(this.cliScripts);
456
465
  if (scriptNames.length > 0) {
457
466
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
@@ -483,7 +492,7 @@ export class ProviderCliAdapter implements CliAdapter {
483
492
  this.parseErrorMessage = null;
484
493
  // Initialize per-session state: createState() is called once here and on script reload.
485
494
  // The returned object lives until the PTY exits (scriptState = null on exit).
486
- this.scriptState = typeof scripts.createState === 'function' ? scripts.createState() : null;
495
+ this.scriptState = typeof scripts.createState === 'function' ? (scripts.createState() ?? null) : null;
487
496
  const scriptNames = listCliScriptNames(scripts);
488
497
  LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${scriptNames.join(', ')}]`);
489
498
  }
@@ -1589,13 +1598,14 @@ export class ProviderCliAdapter implements CliAdapter {
1589
1598
  const screenText = this.readTerminalScreenText();
1590
1599
  const parseScreenText = this.getParseScreenText(screenText);
1591
1600
  const cached = this.parsedStatusCache;
1601
+ const accumulatedRawBufferKey = this.getAccumulatedRawBufferCacheKey();
1592
1602
  if (
1593
1603
  cached
1594
1604
  && cached.responseBuffer === this.responseBuffer
1595
1605
  && cached.currentTurnScope === this.currentTurnScope
1596
1606
  && cached.recentOutputBuffer === this.recentOutputBuffer
1597
1607
  && cached.accumulatedBuffer === this.accumulatedBuffer
1598
- && cached.accumulatedRawBuffer === this.accumulatedRawBuffer
1608
+ && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1599
1609
  && cached.screenText === parseScreenText
1600
1610
  && cached.currentStatus === this.currentStatus
1601
1611
  && cached.activeModal === this.activeModal
@@ -1635,7 +1645,7 @@ export class ProviderCliAdapter implements CliAdapter {
1635
1645
  currentTurnScope: this.currentTurnScope,
1636
1646
  recentOutputBuffer: this.recentOutputBuffer,
1637
1647
  accumulatedBuffer: this.accumulatedBuffer,
1638
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1648
+ accumulatedRawBufferKey,
1639
1649
  screenText: parseScreenText,
1640
1650
  currentStatus: this.currentStatus,
1641
1651
  activeModal: this.activeModal,
@@ -1661,7 +1671,7 @@ export class ProviderCliAdapter implements CliAdapter {
1661
1671
  scope: this.currentTurnScope,
1662
1672
  runtimeSettings: this.runtimeSettings,
1663
1673
  });
1664
- return await Promise.resolve(fn(this.scriptState, {
1674
+ return await Promise.resolve(this.invokeCliScript(fn, {
1665
1675
  ...input,
1666
1676
  args: args && typeof args === 'object' ? { ...args } : {},
1667
1677
  }));
@@ -154,6 +154,35 @@ function loadHermesCoordinatorBaseConfig(targetConfigPath: string): { config: Re
154
154
  return { config: baseConfig, sourceHome, sourceConfigPath };
155
155
  }
156
156
 
157
+ function stripHermesCoordinatorTempModelProviderOverrides(config: Record<string, any>): Record<string, any> {
158
+ const {
159
+ model: _model,
160
+ provider: _provider,
161
+ default_model: _defaultModel,
162
+ defaultProvider: _defaultProvider,
163
+ default_provider: _defaultProviderSnake,
164
+ modelProvider: _modelProvider,
165
+ model_provider: _modelProviderSnake,
166
+ ...sanitized
167
+ } = config;
168
+ const delegation = sanitized.delegation;
169
+ if (delegation && typeof delegation === 'object' && !Array.isArray(delegation)) {
170
+ const {
171
+ model: _delegationModel,
172
+ provider: _delegationProvider,
173
+ modelProvider: _delegationModelProvider,
174
+ model_provider: _delegationModelProviderSnake,
175
+ ...delegationRest
176
+ } = delegation;
177
+ if (Object.keys(delegationRest).length > 0) {
178
+ sanitized.delegation = delegationRest;
179
+ } else {
180
+ delete sanitized.delegation;
181
+ }
182
+ }
183
+ return sanitized;
184
+ }
185
+
157
186
  function copyHermesCoordinatorCredentialFiles(sourceHome: string, targetHome: string) {
158
187
  if (pathResolve(sourceHome) === pathResolve(targetHome)) return;
159
188
  for (const fileName of ['.env', 'auth.json']) {
@@ -388,6 +417,103 @@ export class DaemonCommandRouter {
388
417
  return false;
389
418
  }
390
419
 
420
+ private async cleanupLocalWorktreeNode(args: {
421
+ mesh: any;
422
+ node: any;
423
+ nodeId: string;
424
+ }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string } | { success: false; code: string; error: string; recoveryHint: string }> {
425
+ const workspace = typeof args.node?.workspace === 'string' ? args.node.workspace.trim() : '';
426
+ if (!workspace) {
427
+ return {
428
+ success: false,
429
+ code: 'mesh_worktree_cleanup_missing_workspace',
430
+ error: `Worktree node '${args.nodeId}' is missing workspace metadata`,
431
+ recoveryHint: 'Inspect the mesh node record before removing it, or remove stale metadata manually only after confirming no managed worktree remains.',
432
+ };
433
+ }
434
+
435
+ const worktreeExists = fs.existsSync(workspace);
436
+ const sourceNode = args.node?.clonedFromNodeId
437
+ ? args.mesh?.nodes?.find((n: any) => n.id === args.node.clonedFromNodeId || n.nodeId === args.node.clonedFromNodeId)
438
+ : args.mesh?.nodes?.find((n: any) => !n.isLocalWorktree);
439
+ const repoRoot = typeof sourceNode?.repoRoot === 'string' && sourceNode.repoRoot.trim()
440
+ ? sourceNode.repoRoot.trim()
441
+ : typeof sourceNode?.workspace === 'string' && sourceNode.workspace.trim()
442
+ ? sourceNode.workspace.trim()
443
+ : '';
444
+
445
+ if (!worktreeExists) {
446
+ return { success: true, skipped: true, removedPath: workspace, repoRoot: repoRoot || undefined, reason: 'worktree_path_missing' };
447
+ }
448
+ if (!repoRoot || !fs.existsSync(repoRoot)) {
449
+ return {
450
+ success: false,
451
+ code: 'mesh_worktree_cleanup_missing_source_repo',
452
+ error: `Refusing to remove worktree '${workspace}' because the source repo root is unavailable`,
453
+ recoveryHint: 'Run mesh_remove_node from the machine that owns the source repo, or verify the source node metadata before retrying.',
454
+ };
455
+ }
456
+ if (typeof args.node?.worktreeBranch !== 'string' || !args.node.worktreeBranch.trim()) {
457
+ return {
458
+ success: false,
459
+ code: 'mesh_worktree_cleanup_missing_branch',
460
+ error: `Refusing to remove worktree '${workspace}' because worktreeBranch metadata is missing`,
461
+ recoveryHint: 'Confirm this is an ADHDev-managed worktree before removing it manually; managed worktree nodes include worktreeBranch metadata.',
462
+ };
463
+ }
464
+
465
+ const { resolveWorktreePath, listWorktrees, removeWorktree } = await import('../git/git-worktree.js');
466
+ const normalizePath = (value: string) => {
467
+ const resolved = pathResolve(value);
468
+ try { return fs.realpathSync(resolved); } catch { return resolved; }
469
+ };
470
+ const expectedPath = normalizePath(resolveWorktreePath(repoRoot, String(args.mesh?.name || args.mesh?.id || 'mesh'), args.node.worktreeBranch));
471
+ const actualPath = normalizePath(workspace);
472
+ if (actualPath !== expectedPath) {
473
+ return {
474
+ success: false,
475
+ code: 'mesh_worktree_cleanup_unexpected_path',
476
+ error: `Refusing to remove worktree '${workspace}' because it is not at the expected managed path '${expectedPath}'`,
477
+ recoveryHint: 'Use git worktree list/status to inspect the path. Retry only after confirming the mesh node metadata points to an ADHDev-managed worktree.',
478
+ };
479
+ }
480
+
481
+ const entries = await listWorktrees(repoRoot);
482
+ const managedEntry = entries.find(entry => normalizePath(entry.path) === actualPath);
483
+ if (!managedEntry) {
484
+ return {
485
+ success: false,
486
+ code: 'mesh_worktree_cleanup_not_registered',
487
+ error: `Refusing to remove '${workspace}' because it is not registered in git worktree list for '${repoRoot}'`,
488
+ recoveryHint: 'Inspect git worktree list --porcelain from the source repo. If the path was already removed, prune git worktrees before retrying.',
489
+ };
490
+ }
491
+ if (managedEntry.branch && managedEntry.branch !== args.node.worktreeBranch) {
492
+ return {
493
+ success: false,
494
+ code: 'mesh_worktree_cleanup_branch_mismatch',
495
+ error: `Refusing to remove '${workspace}' because git reports branch '${managedEntry.branch}', expected '${args.node.worktreeBranch}'`,
496
+ recoveryHint: 'Inspect the worktree branch and mesh metadata before retrying cleanup.',
497
+ };
498
+ }
499
+
500
+ try {
501
+ const result = await removeWorktree(repoRoot, workspace, { requireClean: true });
502
+ return { success: true, removedPath: result.removedPath, repoRoot };
503
+ } catch (e: any) {
504
+ const message = String(e?.message || e || 'worktree cleanup failed');
505
+ const dirty = message.includes('dirty worktree') || message.includes('local changes');
506
+ return {
507
+ success: false,
508
+ code: dirty ? 'mesh_worktree_cleanup_dirty' : 'mesh_worktree_cleanup_failed',
509
+ error: message,
510
+ recoveryHint: dirty
511
+ ? 'Commit, stash, or intentionally discard the worktree changes before retrying mesh_remove_node. The mesh registry entry is preserved until cleanup is safe.'
512
+ : 'Inspect git worktree status/list from the source repo and retry after resolving the reported cleanup failure.',
513
+ };
514
+ }
515
+ }
516
+
391
517
  private isCompletedHostedSession(record: any): boolean {
392
518
  return record?.lifecycle === 'stopped' || record?.lifecycle === 'failed' || record?.lifecycle === 'interrupted';
393
519
  }
@@ -1537,21 +1663,21 @@ export class DaemonCommandRouter {
1537
1663
  if (sessionCleanup.success === false) return { success: false, removed: false, sessionCleanup };
1538
1664
  }
1539
1665
 
1540
- // If this is a worktree node, clean up the git worktree first
1541
- if (node?.isLocalWorktree && node.workspace) {
1542
- try {
1543
- const sourceNode = node.clonedFromNodeId
1544
- ? mesh?.nodes.find((n: any) => n.id === node.clonedFromNodeId || n.nodeId === node.clonedFromNodeId)
1545
- : mesh?.nodes.find((n: any) => !n.isLocalWorktree);
1546
- const repoRoot = sourceNode?.repoRoot || sourceNode?.workspace;
1547
- if (repoRoot) {
1548
- const { removeWorktree } = await import('../git/git-worktree.js');
1549
- await removeWorktree(repoRoot, node.workspace);
1550
- }
1551
- } catch (e: any) {
1552
- LOG.warn('MeshNode', `Worktree cleanup failed for ${nodeId}: ${e.message}`);
1553
- // Continue with node removal even if worktree cleanup fails
1666
+ let worktreeCleanup: Record<string, unknown> | undefined;
1667
+ if (node?.isLocalWorktree) {
1668
+ const cleanupResult = await this.cleanupLocalWorktreeNode({ mesh, node, nodeId });
1669
+ if (cleanupResult.success === false) {
1670
+ return {
1671
+ success: false,
1672
+ removed: false,
1673
+ code: cleanupResult.code,
1674
+ error: cleanupResult.error,
1675
+ recoveryHint: cleanupResult.recoveryHint,
1676
+ ...(sessionCleanup ? { sessionCleanup } : {}),
1677
+ worktreeCleanup: cleanupResult,
1678
+ };
1554
1679
  }
1680
+ worktreeCleanup = cleanupResult;
1555
1681
  }
1556
1682
 
1557
1683
  let removed = false;
@@ -1580,7 +1706,7 @@ export class DaemonCommandRouter {
1580
1706
  } catch { /* ledger append is best-effort */ }
1581
1707
  }
1582
1708
 
1583
- return { success: true, removed, ...(sessionCleanup ? { sessionCleanup } : {}) };
1709
+ return { success: true, removed, ...(sessionCleanup ? { sessionCleanup } : {}), ...(worktreeCleanup ? { worktreeCleanup } : {}) };
1584
1710
  } catch (e: any) {
1585
1711
  return { success: false, error: e.message };
1586
1712
  }
@@ -1958,7 +2084,10 @@ export class DaemonCommandRouter {
1958
2084
  if (hadExistingMcpConfig) {
1959
2085
  try {
1960
2086
  const parsedExistingMcpConfig = parseMeshCoordinatorMcpConfig(readFileSync(mcpConfigPath, 'utf-8'), configFormat);
1961
- existingMcpConfig = { ...existingMcpConfig, ...parsedExistingMcpConfig };
2087
+ const existingCoordinatorConfig = hermesManualFallback
2088
+ ? stripHermesCoordinatorTempModelProviderOverrides(parsedExistingMcpConfig)
2089
+ : parsedExistingMcpConfig;
2090
+ existingMcpConfig = { ...existingMcpConfig, ...existingCoordinatorConfig };
1962
2091
  copyFileSync(mcpConfigPath, mcpConfigPath + '.backup');
1963
2092
  } catch (error: any) {
1964
2093
  LOG.error('MeshCoordinator', `Failed to parse existing MCP config ${mcpConfigPath}: ${error?.message || error}`);
@@ -49,6 +49,11 @@ export interface WorktreeEntry {
49
49
  bare: boolean;
50
50
  }
51
51
 
52
+ export interface WorktreeRemoveOptions {
53
+ /** Refuse to remove a worktree with uncommitted or untracked changes. */
54
+ requireClean?: boolean;
55
+ }
56
+
52
57
  export interface WorktreeRemoveResult {
53
58
  success: true;
54
59
  removedPath: string;
@@ -120,17 +125,30 @@ export async function createWorktree(opts: WorktreeCreateOptions): Promise<Workt
120
125
  /**
121
126
  * Remove a git worktree and clean up the directory.
122
127
  *
123
- * Runs: git worktree remove <worktreePath> --force
128
+ * Runs: git worktree remove <worktreePath>
124
129
  */
125
- export async function removeWorktree(repoRoot: string, worktreePath: string): Promise<WorktreeRemoveResult> {
130
+ export async function removeWorktree(repoRoot: string, worktreePath: string, opts: WorktreeRemoveOptions = {}): Promise<WorktreeRemoveResult> {
126
131
  if (!existsSync(worktreePath)) {
127
132
  // Already gone — just prune
128
133
  await pruneWorktrees(repoRoot);
129
134
  return { success: true, removedPath: worktreePath };
130
135
  }
131
136
 
137
+ if (opts.requireClean) {
138
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
139
+ cwd: worktreePath,
140
+ encoding: 'utf8',
141
+ timeout: GIT_TIMEOUT_MS,
142
+ maxBuffer: GIT_MAX_BUFFER,
143
+ windowsHide: true,
144
+ });
145
+ if (stdout.trim()) {
146
+ throw new Error(`Refusing to remove dirty worktree: ${worktreePath}`);
147
+ }
148
+ }
149
+
132
150
  try {
133
- await execFileAsync('git', ['worktree', 'remove', worktreePath, '--force'], {
151
+ await execFileAsync('git', ['worktree', 'remove', worktreePath], {
134
152
  cwd: repoRoot,
135
153
  encoding: 'utf8',
136
154
  timeout: GIT_TIMEOUT_MS,
@@ -138,6 +138,7 @@ const TOOLS_SECTION = `## Available Tools
138
138
  | \`mesh_checkpoint\` | Create a git checkpoint on a node |
139
139
  | \`mesh_approve\` | Approve/reject a pending agent action |
140
140
  | \`mesh_clone_node\` | Create a worktree node for isolated parallel branch work |
141
+ | \`mesh_refine_node\` | Validate and merge a completed worktree node back into its base branch |
141
142
  | \`mesh_remove_node\` | Remove a node (cleans up worktree if applicable) |`;
142
143
 
143
144
  const TOOL_EXPOSURE_PREFLIGHT_SECTION = `## Tool Exposure Preflight
@@ -156,8 +157,9 @@ const WORKFLOW_SECTION = `## Orchestration Workflow
156
157
  4. **Monitor** — Prefer event-driven completion/status notifications. Do **not** poll \`mesh_read_chat\` repeatedly. Use \`mesh_view_queue\` to see the status of all pending, assigned, completed, and failed tasks. Do not call \`mesh_read_chat\` again within a few seconds for the same generating session. Use at most one compact \`mesh_read_chat\` check after a completion/approval signal. Handle approvals via \`mesh_approve\`.
157
158
  5. **Verify** — When a task reports completion or git work is visible, call \`mesh_git_status\` to verify changes were made.
158
159
  6. **Checkpoint** — Call \`mesh_checkpoint\` to save the work.
159
- 7. **Clean up** — Remove worktree nodes via \`mesh_remove_node\` after their work is merged or no longer needed.
160
- 8. **Report** — Summarize what was done, what changed, and any issues.
160
+ 7. **Converge branches** — Before marking any task complete, classify every touched node/branch into exactly one final state: \`merged_to_main\`, \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\`. Use \`mesh_status\` branchConvergenceSummary and \`mesh_refine_node\` for clean worktree branches when safe. A task that remains on a non-main branch is not fully complete unless the final report names the follow-up state and next step.
161
+ 8. **Clean up** — Remove worktree nodes via \`mesh_remove_node\` after their work is merged or no longer needed.
162
+ 9. **Report** — Summarize what was done, what changed, any issues, and the branch convergence state.
161
163
 
162
164
  ## Failure Recovery
163
165
 
@@ -191,5 +193,6 @@ function buildRulesSection(coordinatorCliType?: string): string {
191
193
  - **Respect node capabilities.** Don't send build tasks to read-only nodes. Don't push from nodes that aren't allowed to.
192
194
  - **Never fabricate tool results.** Always call the actual tool; never pretend you did.
193
195
  - **Clean up worktree nodes.** After a worktree task completes and its changes are merged or checkpointed, call \`mesh_remove_node\` to free resources.
196
+ - **Do not strand completed branches.** A checkpointed or clean feature/worktree branch is not done by itself. Merge/refine it to the mesh default branch, or explicitly report one of \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\` with the next action.
194
197
  - **Name worktree branches meaningfully.** Use descriptive names like \`feat/auth-refactor\` or \`fix/build-123\`.${coordinatorNote}`;
195
198
  }
@@ -35,6 +35,17 @@ type PersistableCliHistoryMessage = {
35
35
  receivedAt?: number;
36
36
  };
37
37
 
38
+ type CompletedDebouncePending = {
39
+ chatTitle: string;
40
+ duration: number;
41
+ timestamp: number;
42
+ firstObservedAt: number;
43
+ loggedBlockReason?: string;
44
+ };
45
+
46
+ const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
+ const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
48
+
38
49
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
39
50
  'image/png': '.png',
40
51
  'image/jpeg': '.jpg',
@@ -103,6 +114,15 @@ function cleanupStaleMaterializedImages(dir: string): void {
103
114
  } catch { /* dir may not exist or be inaccessible */ }
104
115
  }
105
116
 
117
+ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
118
+ const buttons = (activeModal as any)?.buttons;
119
+ return Array.isArray(buttons) && buttons.some((button) => String(button || '').trim().length > 0);
120
+ }
121
+
122
+ function isCliGeneratingLikeStatus(status: unknown): boolean {
123
+ return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
124
+ }
125
+
106
126
  export function buildCliStructuredInputPrompt(
107
127
  input: InputEnvelope,
108
128
  options: { materializeDir?: string } = {},
@@ -511,6 +531,10 @@ export class CliProviderInstance implements ProviderInstance {
511
531
  const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
512
532
 
513
533
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
534
+ const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
535
+ ? parsedStatus.status.trim()
536
+ : undefined;
537
+ const suppressStaleParsedBusyStatus = this.shouldSuppressStaleParsedBusyStatus(parsedStatus, adapterStatus);
514
538
 
515
539
  if (parsedMessages.length > 0) {
516
540
  const shouldSkipReplayPersist =
@@ -518,7 +542,7 @@ export class CliProviderInstance implements ProviderInstance {
518
542
  && adapterStatus.status === 'idle'
519
543
  && parsedStatus?.status === 'idle';
520
544
  let messagesToSave = parsedMessages;
521
- if ((parsedStatus?.status === 'generating' || parsedStatus?.status === 'long_generating')) {
545
+ if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
522
546
  const lastIdx = messagesToSave.length - 1;
523
547
  if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
524
548
  messagesToSave = messagesToSave.slice(0, lastIdx);
@@ -553,6 +577,13 @@ export class CliProviderInstance implements ProviderInstance {
553
577
  summaryMetadata: this.summaryMetadata as any,
554
578
  controlValues: this.controlValues,
555
579
  });
580
+ const activeChatStatus = parseErrorMessage
581
+ ? 'error'
582
+ : autoApproveActive && parsedStatus?.status === 'waiting_approval'
583
+ ? 'generating'
584
+ : (adapterStatus.status !== 'idle'
585
+ ? visibleStatus
586
+ : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
556
587
 
557
588
  return {
558
589
  type: this.type,
@@ -563,13 +594,7 @@ export class CliProviderInstance implements ProviderInstance {
563
594
  activeChat: {
564
595
  id: `${this.type}_${this.workingDir}`,
565
596
  title: parsedStatus?.title || dirName,
566
- status: parseErrorMessage
567
- ? 'error'
568
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
569
- ? 'generating'
570
- : (adapterStatus.status !== 'idle'
571
- ? visibleStatus
572
- : (parsedStatus?.status || visibleStatus)),
597
+ status: activeChatStatus,
573
598
  messages: mergedMessages,
574
599
  activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
575
600
  inputContent: '',
@@ -680,7 +705,7 @@ export class CliProviderInstance implements ProviderInstance {
680
705
  }
681
706
 
682
707
  private completedDebounceTimer: NodeJS.Timeout | null = null;
683
- private completedDebouncePending: { chatTitle: string; duration: number; timestamp: number } | null = null;
708
+ private completedDebouncePending: CompletedDebouncePending | null = null;
684
709
 
685
710
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
686
711
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -709,6 +734,119 @@ export class CliProviderInstance implements ProviderInstance {
709
734
  this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
710
735
  }
711
736
 
737
+ private completionHasFinalAssistantMessage(messages: unknown): boolean {
738
+ const visibleMessages = (Array.isArray(messages) ? messages : [])
739
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
740
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
+ const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
+ const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
+ return role === 'assistant' && !!content;
744
+ }
745
+
746
+ private hasAdapterPendingResponse(): boolean {
747
+ const adapterAny = this.adapter as any;
748
+ if (adapterAny?.isWaitingForResponse === true) return true;
749
+ if (adapterAny?.currentTurnScope) return true;
750
+ try {
751
+ if (typeof this.adapter.isProcessing === 'function' && this.adapter.isProcessing()) return true;
752
+ } catch { /* defensive: status rendering must not fail because of adapter diagnostics */ }
753
+ try {
754
+ const partial = typeof this.adapter.getPartialResponse === 'function'
755
+ ? this.adapter.getPartialResponse()
756
+ : '';
757
+ if (typeof partial === 'string' && partial.trim()) return true;
758
+ } catch { /* defensive: missing partial means no pending response evidence */ }
759
+ return false;
760
+ }
761
+
762
+ private shouldSuppressStaleParsedBusyStatus(parsedStatus: any, adapterStatus: any): boolean {
763
+ const parsedRawStatus = typeof parsedStatus?.status === 'string' ? parsedStatus.status.trim() : '';
764
+ const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
765
+ if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
+ if (adapterRawStatus !== 'idle') return false;
767
+ if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
+ return !this.hasAdapterPendingResponse();
769
+ }
770
+
771
+ private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
+ if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
773
+
774
+ const adapterAny = this.adapter as any;
775
+ if (adapterAny?.isWaitingForResponse === true) return 'adapter_waiting_for_response';
776
+ if (adapterAny?.currentTurnScope) return 'adapter_turn_scope_active';
777
+
778
+ const partial = typeof this.adapter.getPartialResponse === 'function'
779
+ ? this.adapter.getPartialResponse()
780
+ : '';
781
+ if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
782
+
783
+ let parsed: any;
784
+ try {
785
+ parsed = this.adapter.getScriptParsedStatus();
786
+ } catch (error: any) {
787
+ return `parse_error:${error?.message || String(error)}`;
788
+ }
789
+
790
+ const parsedStatus = typeof parsed?.status === 'string' ? parsed.status : 'unknown';
791
+ if (parsedStatus !== 'idle') return `parsed_status:${parsedStatus}`;
792
+ if (parsed?.activeModal || parsed?.modal) return 'parsed_modal_active';
793
+ if (!this.completionHasFinalAssistantMessage(parsed?.messages)) return 'missing_final_assistant';
794
+
795
+ return null;
796
+ }
797
+
798
+ private scheduleCompletedDebounceFlush(delayMs: number): void {
799
+ if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
800
+ this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
801
+ }
802
+
803
+ private flushCompletedDebounceIfFinalized(): void {
804
+ const pending = this.completedDebouncePending;
805
+ if (!pending) {
806
+ this.completedDebounceTimer = null;
807
+ return;
808
+ }
809
+
810
+ const latestStatus = this.adapter.getStatus({ allowParse: false });
811
+ const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
+ const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
813
+ if (latestVisibleStatus !== 'idle') {
814
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
815
+ this.completedDebouncePending = null;
816
+ this.completedDebounceTimer = null;
817
+ return;
818
+ }
819
+
820
+ const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
+ if (blockReason) {
822
+ const waitedMs = Date.now() - pending.firstObservedAt;
823
+ if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
824
+ if (pending.loggedBlockReason !== blockReason) {
825
+ LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
826
+ pending.loggedBlockReason = blockReason;
827
+ }
828
+ this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
+ return;
830
+ }
831
+ LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
832
+ this.completedDebouncePending = null;
833
+ this.completedDebounceTimer = null;
834
+ this.generatingStartedAt = 0;
835
+ return;
836
+ }
837
+
838
+ LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
839
+ this.pushEvent({
840
+ event: 'agent:generating_completed',
841
+ chatTitle: pending.chatTitle,
842
+ duration: pending.duration,
843
+ timestamp: pending.timestamp,
844
+ });
845
+ this.completedDebouncePending = null;
846
+ this.completedDebounceTimer = null;
847
+ this.generatingStartedAt = 0;
848
+ }
849
+
712
850
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
713
851
  const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
714
852
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
@@ -811,27 +949,10 @@ export class CliProviderInstance implements ProviderInstance {
811
949
  this.generatingDebouncePending = null;
812
950
  this.generatingStartedAt = 0;
813
951
  } else {
814
- // Debounce completed wait 3s, if still idle then emit
815
- if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
816
- this.completedDebouncePending = { chatTitle, duration, timestamp: now };
817
- this.completedDebounceTimer = setTimeout(() => {
818
- if (this.completedDebouncePending) {
819
- const latestStatus = this.adapter.getStatus({ allowParse: false });
820
- const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
821
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
822
- if (latestVisibleStatus !== 'idle') {
823
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
824
- this.completedDebouncePending = null;
825
- this.completedDebounceTimer = null;
826
- return;
827
- }
828
- LOG.info('CLI', `[${this.type}] completed in ${this.completedDebouncePending.duration}s`);
829
- this.pushEvent({ event: 'agent:generating_completed', ...this.completedDebouncePending });
830
- this.completedDebouncePending = null;
831
- this.generatingStartedAt = 0;
832
- }
833
- this.completedDebounceTimer = null;
834
- }, 3000);
952
+ // Debounce completed, then require the rich transcript path that read_chat
953
+ // uses to show an idle turn whose last user-facing message is assistant.
954
+ this.completedDebouncePending = { chatTitle, duration, timestamp: now, firstObservedAt: now };
955
+ this.scheduleCompletedDebounceFlush(3000);
835
956
  }
836
957
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
837
958
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });