@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.
- package/dist/cli-adapters/provider-cli-adapter.d.ts +1 -0
- package/dist/commands/router.d.ts +1 -0
- package/dist/git/git-worktree.d.ts +6 -2
- package/dist/index.js +270 -43
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +270 -43
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +6 -0
- package/package.json +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +16 -6
- package/src/commands/router.ts +145 -16
- package/src/git/git-worktree.ts +21 -3
- package/src/mesh/coordinator-prompt.ts +5 -2
- package/src/providers/cli-provider-instance.ts +151 -30
|
@@ -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
|
@@ -225,7 +225,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
225
225
|
currentTurnScope: TurnParseScope | null;
|
|
226
226
|
recentOutputBuffer: string;
|
|
227
227
|
accumulatedBuffer: string;
|
|
228
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
1674
|
+
return await Promise.resolve(this.invokeCliScript(fn, {
|
|
1665
1675
|
...input,
|
|
1666
1676
|
args: args && typeof args === 'object' ? { ...args } : {},
|
|
1667
1677
|
}));
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
-
|
|
1541
|
-
if (node?.isLocalWorktree
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
:
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/git/git-worktree.ts
CHANGED
|
@@ -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>
|
|
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
|
|
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. **
|
|
160
|
-
8. **
|
|
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 ((
|
|
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:
|
|
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:
|
|
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
|
|
815
|
-
|
|
816
|
-
this.completedDebouncePending = { chatTitle, duration, timestamp: now };
|
|
817
|
-
this.
|
|
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 });
|