@adhdev/daemon-core 0.9.77-rc.41 → 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.41",
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",
@@ -460,6 +460,7 @@ export class ProviderCliAdapter implements CliAdapter {
460
460
 
461
461
  // Scripts are required — loaded by ProviderLoader via compatibility array
462
462
  this.cliScripts = provider.scripts || {};
463
+ this.scriptState = typeof this.cliScripts.createState === 'function' ? (this.cliScripts.createState() ?? null) : null;
463
464
  const scriptNames = listCliScriptNames(this.cliScripts);
464
465
  if (scriptNames.length > 0) {
465
466
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
@@ -491,7 +492,7 @@ export class ProviderCliAdapter implements CliAdapter {
491
492
  this.parseErrorMessage = null;
492
493
  // Initialize per-session state: createState() is called once here and on script reload.
493
494
  // The returned object lives until the PTY exits (scriptState = null on exit).
494
- this.scriptState = typeof scripts.createState === 'function' ? scripts.createState() : null;
495
+ this.scriptState = typeof scripts.createState === 'function' ? (scripts.createState() ?? null) : null;
495
496
  const scriptNames = listCliScriptNames(scripts);
496
497
  LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${scriptNames.join(', ')}]`);
497
498
  }
@@ -1670,7 +1671,7 @@ export class ProviderCliAdapter implements CliAdapter {
1670
1671
  scope: this.currentTurnScope,
1671
1672
  runtimeSettings: this.runtimeSettings,
1672
1673
  });
1673
- return await Promise.resolve(fn(this.scriptState, {
1674
+ return await Promise.resolve(this.invokeCliScript(fn, {
1674
1675
  ...input,
1675
1676
  args: args && typeof args === 'object' ? { ...args } : {},
1676
1677
  }));
@@ -417,6 +417,103 @@ export class DaemonCommandRouter {
417
417
  return false;
418
418
  }
419
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
+
420
517
  private isCompletedHostedSession(record: any): boolean {
421
518
  return record?.lifecycle === 'stopped' || record?.lifecycle === 'failed' || record?.lifecycle === 'interrupted';
422
519
  }
@@ -1566,21 +1663,21 @@ export class DaemonCommandRouter {
1566
1663
  if (sessionCleanup.success === false) return { success: false, removed: false, sessionCleanup };
1567
1664
  }
1568
1665
 
1569
- // If this is a worktree node, clean up the git worktree first
1570
- if (node?.isLocalWorktree && node.workspace) {
1571
- try {
1572
- const sourceNode = node.clonedFromNodeId
1573
- ? mesh?.nodes.find((n: any) => n.id === node.clonedFromNodeId || n.nodeId === node.clonedFromNodeId)
1574
- : mesh?.nodes.find((n: any) => !n.isLocalWorktree);
1575
- const repoRoot = sourceNode?.repoRoot || sourceNode?.workspace;
1576
- if (repoRoot) {
1577
- const { removeWorktree } = await import('../git/git-worktree.js');
1578
- await removeWorktree(repoRoot, node.workspace);
1579
- }
1580
- } catch (e: any) {
1581
- LOG.warn('MeshNode', `Worktree cleanup failed for ${nodeId}: ${e.message}`);
1582
- // 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
+ };
1583
1679
  }
1680
+ worktreeCleanup = cleanupResult;
1584
1681
  }
1585
1682
 
1586
1683
  let removed = false;
@@ -1609,7 +1706,7 @@ export class DaemonCommandRouter {
1609
1706
  } catch { /* ledger append is best-effort */ }
1610
1707
  }
1611
1708
 
1612
- return { success: true, removed, ...(sessionCleanup ? { sessionCleanup } : {}) };
1709
+ return { success: true, removed, ...(sessionCleanup ? { sessionCleanup } : {}), ...(worktreeCleanup ? { worktreeCleanup } : {}) };
1613
1710
  } catch (e: any) {
1614
1711
  return { success: false, error: e.message };
1615
1712
  }
@@ -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 });