@adhdev/daemon-core 0.9.82-rc.209 → 0.9.82-rc.210
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/terminal-backends/ghostty-vt-backend.d.ts +2 -0
- package/dist/commands/router.d.ts +6 -0
- package/dist/git/git-commands.d.ts +2 -0
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +5699 -3486
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5679 -3483
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +6 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +5 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
- package/dist/mesh/mesh-events-pending.d.ts +33 -0
- package/dist/mesh/mesh-events-stale.d.ts +40 -0
- package/dist/mesh/mesh-events-utils.d.ts +14 -0
- package/dist/mesh/mesh-events.d.ts +5 -198
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
- package/dist/mesh/mesh-ledger.d.ts +19 -0
- package/dist/mesh/mesh-missions.d.ts +58 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-runtime-store.d.ts +175 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-work-queue.d.ts +82 -0
- package/dist/mesh/refine-config.d.ts +24 -2
- package/dist/mesh/worktree-bootstrap-config.d.ts +22 -0
- package/dist/providers/acp-provider-instance.d.ts +2 -0
- package/dist/providers/spec/driver.d.ts +8 -0
- package/dist/providers/spec/evaluator.d.ts +4 -5
- package/dist/providers/spec/loader.d.ts +1 -0
- package/dist/providers/spec/schema.gen.d.ts +1409 -6
- package/dist/providers/spec/types.d.ts +188 -175
- package/dist/repo-mesh-types.d.ts +1 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +3 -0
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +20 -7
- package/src/commands/router.ts +594 -66
- package/src/git/git-commands.ts +5 -5
- package/src/git/git-diff.ts +53 -0
- package/src/index.ts +11 -5
- package/src/mesh/coordinator-prompt.ts +14 -1
- package/src/mesh/mesh-delivery-policy.ts +17 -0
- package/src/mesh/mesh-events-coordinator.ts +1404 -0
- package/src/mesh/mesh-events-pending.ts +371 -0
- package/src/mesh/mesh-events-stale.ts +283 -0
- package/src/mesh/mesh-events-utils.ts +161 -0
- package/src/mesh/mesh-events.ts +27 -2143
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
- package/src/mesh/mesh-ledger.ts +134 -2
- package/src/mesh/mesh-missions.ts +151 -0
- package/src/mesh/mesh-review-inbox.ts +307 -0
- package/src/mesh/mesh-runtime-store.ts +539 -3
- package/src/mesh/mesh-task-stats.ts +154 -0
- package/src/mesh/mesh-work-queue.ts +233 -17
- package/src/mesh/refine-config.ts +42 -5
- package/src/mesh/worktree-bootstrap-config.ts +79 -0
- package/src/providers/acp-provider-instance.ts +15 -1
- package/src/providers/cli-provider-instance.ts +34 -13
- package/src/providers/spec/driver.ts +57 -29
- package/src/providers/spec/evaluator.ts +302 -112
- package/src/providers/spec/loader.ts +226 -37
- package/src/providers/spec/schema.gen.ts +450 -334
- package/src/providers/spec/schema.json +162 -75
- package/src/providers/spec/types.ts +234 -183
- package/src/repo-mesh-types.ts +1 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join, resolve as pathResolve } from 'path';
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
5
6
|
import * as yaml from 'js-yaml';
|
|
6
7
|
import {
|
|
@@ -31,6 +32,15 @@ export interface WorktreeBootstrapState extends MeshAsyncJobLifecycle {
|
|
|
31
32
|
exitCode?: number | null;
|
|
32
33
|
commandsRun?: Array<Record<string, unknown>>;
|
|
33
34
|
staleInputs?: string[];
|
|
35
|
+
/**
|
|
36
|
+
* M2-1: sha256 per staleInputs path recorded when the bootstrap reached
|
|
37
|
+
* 'ready'. evaluateWorktreeBootstrapState compares current file hashes
|
|
38
|
+
* against this to detect staleness (e.g. base merge changed a lockfile).
|
|
39
|
+
* Missing files hash to the literal 'absent'.
|
|
40
|
+
*/
|
|
41
|
+
staleInputsDigest?: Record<string, string>;
|
|
42
|
+
/** M2-1: why an evaluated state resolved to stale (digest_mismatch | never_ran). */
|
|
43
|
+
staleReason?: string;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
export interface WorktreeBootstrapConfigLoadResult {
|
|
@@ -151,6 +161,70 @@ export function loadMeshWorktreeBootstrapConfig(mesh: any, workspace: string): W
|
|
|
151
161
|
return { source: 'unavailable', sourceType: 'unavailable', error: `No worktree bootstrap config found. Checked: ${MESH_WORKTREE_BOOTSTRAP_CONFIG_LOCATIONS.join(', ')}` };
|
|
152
162
|
}
|
|
153
163
|
|
|
164
|
+
/** M2-1: hash staleInputs files so 'ready' can be invalidated when they change. */
|
|
165
|
+
export function computeStaleInputsDigest(workspace: string, staleInputs: string[] | undefined): Record<string, string> {
|
|
166
|
+
const digest: Record<string, string> = {};
|
|
167
|
+
for (const relative of staleInputs ?? []) {
|
|
168
|
+
const filePath = join(workspace, relative);
|
|
169
|
+
try {
|
|
170
|
+
digest[relative] = createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
171
|
+
} catch {
|
|
172
|
+
digest[relative] = 'absent';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return digest;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* M2-1: the official bootstrap state contract. Resolves the effective state
|
|
180
|
+
* for a node workspace from config presence + the persisted last-run state +
|
|
181
|
+
* a staleInputs digest comparison. Read-only — never runs commands.
|
|
182
|
+
*
|
|
183
|
+
* ready — last run succeeded and staleInputs are unchanged
|
|
184
|
+
* stale — never ran, or a staleInputs file changed since 'ready'
|
|
185
|
+
* running/failed — persisted lifecycle state passes through
|
|
186
|
+
* not_configured / disabled / (invalid → failed) — from config resolution
|
|
187
|
+
*/
|
|
188
|
+
export function evaluateWorktreeBootstrapState(mesh: any, workspace: string, persisted?: WorktreeBootstrapState | null): WorktreeBootstrapState {
|
|
189
|
+
const loaded = loadMeshWorktreeBootstrapConfig(mesh, workspace);
|
|
190
|
+
if (!loaded.config) {
|
|
191
|
+
return { status: 'not_configured', required: false, configSource: loaded.source, configSourceType: loaded.sourceType, error: loaded.error };
|
|
192
|
+
}
|
|
193
|
+
const required = loaded.config.required !== false;
|
|
194
|
+
if (loaded.config.enabled === false || loaded.config.runOnClone === false) {
|
|
195
|
+
return { status: 'disabled', required, configSource: loaded.path || loaded.source, configSourceType: loaded.sourceType };
|
|
196
|
+
}
|
|
197
|
+
if (loaded.sourceType === 'invalid') {
|
|
198
|
+
return { status: 'failed', required, configSource: loaded.path || loaded.source, configSourceType: 'invalid', error: loaded.error };
|
|
199
|
+
}
|
|
200
|
+
if (persisted?.status === 'running') return { ...persisted, required };
|
|
201
|
+
if (persisted?.status === 'failed') return { ...persisted, required };
|
|
202
|
+
if (persisted?.status === 'ready') {
|
|
203
|
+
const staleInputs = loaded.config.staleInputs ?? persisted.staleInputs ?? [];
|
|
204
|
+
if (staleInputs.length > 0 && persisted.staleInputsDigest) {
|
|
205
|
+
const current = computeStaleInputsDigest(workspace, staleInputs);
|
|
206
|
+
const changed = staleInputs.filter(p => current[p] !== persisted.staleInputsDigest![p]);
|
|
207
|
+
if (changed.length > 0) {
|
|
208
|
+
return {
|
|
209
|
+
...persisted,
|
|
210
|
+
status: 'stale',
|
|
211
|
+
required,
|
|
212
|
+
staleReason: `digest_mismatch: ${changed.join(', ')}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { ...persisted, required };
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
status: 'stale',
|
|
220
|
+
required,
|
|
221
|
+
configSource: loaded.path || loaded.source,
|
|
222
|
+
configSourceType: loaded.sourceType,
|
|
223
|
+
staleInputs: loaded.config.staleInputs,
|
|
224
|
+
staleReason: 'never_ran',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
154
228
|
export async function runMeshWorktreeBootstrap(mesh: any, workspace: string): Promise<WorktreeBootstrapState> {
|
|
155
229
|
const loaded = loadMeshWorktreeBootstrapConfig(mesh, workspace);
|
|
156
230
|
if (!loaded.config) {
|
|
@@ -243,5 +317,10 @@ export async function runMeshWorktreeBootstrap(mesh: any, workspace: string): Pr
|
|
|
243
317
|
state.status = 'ready';
|
|
244
318
|
state.exitCode = 0;
|
|
245
319
|
state.completedAt = new Date().toISOString();
|
|
320
|
+
// M2-1: record staleInputs hashes so evaluateWorktreeBootstrapState can
|
|
321
|
+
// invalidate this 'ready' when an input (e.g. lockfile) changes later.
|
|
322
|
+
if (staleInputPaths.length > 0) {
|
|
323
|
+
state.staleInputsDigest = computeStaleInputsDigest(workspace, staleInputPaths);
|
|
324
|
+
}
|
|
246
325
|
return state;
|
|
247
326
|
}
|
|
@@ -287,6 +287,8 @@ export class AcpProviderInstance implements ProviderInstance {
|
|
|
287
287
|
private partialBlocks: ContentBlock[] = [];
|
|
288
288
|
/** Tool calls collected during current turn */
|
|
289
289
|
private turnToolCalls: ToolCallInfo[] = [];
|
|
290
|
+
/** Guard: prevent concurrent sendPrompt calls from racing on shared state */
|
|
291
|
+
private _sendPromptInFlight = false;
|
|
290
292
|
|
|
291
293
|
// Error tracking
|
|
292
294
|
private errorMessage: string | null = null;
|
|
@@ -1002,7 +1004,11 @@ export class AcpProviderInstance implements ProviderInstance {
|
|
|
1002
1004
|
}
|
|
1003
1005
|
} catch (e: any) {
|
|
1004
1006
|
this.log.warn(`[${this.type}] session/new failed: ${e?.message}`);
|
|
1005
|
-
this.
|
|
1007
|
+
if (!this.errorReason) {
|
|
1008
|
+
this.errorReason = 'init_failed';
|
|
1009
|
+
this.errorMessage = `ACP session creation failed: ${e?.message}`;
|
|
1010
|
+
}
|
|
1011
|
+
this.currentStatus = 'error';
|
|
1006
1012
|
}
|
|
1007
1013
|
}
|
|
1008
1014
|
|
|
@@ -1012,6 +1018,12 @@ export class AcpProviderInstance implements ProviderInstance {
|
|
|
1012
1018
|
return;
|
|
1013
1019
|
}
|
|
1014
1020
|
|
|
1021
|
+
if (this._sendPromptInFlight) {
|
|
1022
|
+
this.log.warn(`[${this.type}] sendPrompt already in flight — dropping concurrent request`);
|
|
1023
|
+
throw new Error('ACP sendPrompt already in flight');
|
|
1024
|
+
}
|
|
1025
|
+
this._sendPromptInFlight = true;
|
|
1026
|
+
|
|
1015
1027
|
// Build prompt content
|
|
1016
1028
|
const promptParts: any[] = contentBlocks && contentBlocks.length > 0
|
|
1017
1029
|
? contentBlocks.map((b) => {
|
|
@@ -1098,6 +1110,8 @@ export class AcpProviderInstance implements ProviderInstance {
|
|
|
1098
1110
|
this.finalizeAssistantMessage();
|
|
1099
1111
|
this.currentStatus = 'idle';
|
|
1100
1112
|
this.detectStatusTransition();
|
|
1113
|
+
} finally {
|
|
1114
|
+
this._sendPromptInFlight = false;
|
|
1101
1115
|
}
|
|
1102
1116
|
}
|
|
1103
1117
|
|
|
@@ -1561,6 +1561,15 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1561
1561
|
}
|
|
1562
1562
|
} else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
|
|
1563
1563
|
const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
|
|
1564
|
+
// Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
|
|
1565
|
+
// was entered from 'starting' state (startup PTY noise), not from a real idle→generating
|
|
1566
|
+
// task dispatch. The idle→generating handler is the only code path that sets
|
|
1567
|
+
// generatingStartedAt and generatingDebouncePending, so both being absent means no
|
|
1568
|
+
// task was ever dispatched. Suppress the spurious completion event and fall through
|
|
1569
|
+
// to a simple lastStatus update.
|
|
1570
|
+
if (!this.generatingStartedAt && !this.generatingDebouncePending) {
|
|
1571
|
+
LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
|
|
1572
|
+
} else
|
|
1564
1573
|
// If debounce still pending (generating lasted < 1s), cancel both UI events.
|
|
1565
1574
|
// Still emit agent:generating_completed so mesh orchestration can record
|
|
1566
1575
|
// task_completed for direct dispatches that complete faster than the debounce.
|
|
@@ -1589,19 +1598,31 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1589
1598
|
if (missingEvidence) {
|
|
1590
1599
|
LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
|
|
1591
1600
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1601
|
+
// When evidence is missing and there is no active mesh task context, suppress
|
|
1602
|
+
// the completion event. Providers with requiresFinalAssistantBeforeIdle or
|
|
1603
|
+
// external-native history must confirm a final assistant message before the
|
|
1604
|
+
// coordinator records task_completed. Only emit here if a mesh task is active
|
|
1605
|
+
// so the coordinator can apply its own timeout/retry logic.
|
|
1606
|
+
const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
|
|
1607
|
+
if (missingEvidence && !hasMeshContext) {
|
|
1608
|
+
LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
|
|
1609
|
+
// completedDebouncePending intentionally left null — the session is now idle
|
|
1610
|
+
// with no confirmed turn, matching the startup-blip suppression semantics.
|
|
1611
|
+
} else {
|
|
1612
|
+
this.pushEvent({
|
|
1613
|
+
event: 'agent:generating_completed',
|
|
1614
|
+
chatTitle,
|
|
1615
|
+
duration: 0,
|
|
1616
|
+
timestamp: now,
|
|
1617
|
+
finalSummary: shortFinalSummary,
|
|
1618
|
+
completionDiagnostic: {
|
|
1619
|
+
reason: 'short_generating_suppressed',
|
|
1620
|
+
shortDurationMs,
|
|
1621
|
+
finalAssistantEvidenceSource: shortEvidenceSource,
|
|
1622
|
+
...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
|
|
1623
|
+
},
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1605
1626
|
} else {
|
|
1606
1627
|
// Debounce completed, then require the rich transcript path that read_chat
|
|
1607
1628
|
// uses to show an idle turn whose last user-facing message is assistant.
|
|
@@ -41,7 +41,7 @@ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
|
41
41
|
import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
|
|
42
42
|
import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
|
|
43
43
|
import { loadSpec } from './loader.js';
|
|
44
|
-
import type { CliSpec, Control, DelegateTrigger } from './types.js';
|
|
44
|
+
import type { CliSpec, Control, DelegateTrigger, SectionDef } from './types.js';
|
|
45
45
|
import { LOG } from '../../logging/logger.js';
|
|
46
46
|
|
|
47
47
|
export type DashboardEvent =
|
|
@@ -161,32 +161,27 @@ export function matchesCompletionIdleTargetState(
|
|
|
161
161
|
screen: string,
|
|
162
162
|
cursor?: { row: number; col: number },
|
|
163
163
|
): boolean {
|
|
164
|
-
|
|
164
|
+
// Test whether the idle/default state's when-condition actually matches
|
|
165
|
+
// the current screen, WITHOUT the default fallback. We don't want to
|
|
166
|
+
// return true just because no other state matched (which is what
|
|
167
|
+
// evaluate() does via its default_state fallback).
|
|
168
|
+
const targetId = spec.default_state ?? 'idle';
|
|
169
|
+
const target = spec.states.find(state => state.id === targetId)
|
|
165
170
|
?? spec.states.find(state => state.id === 'idle');
|
|
166
|
-
if (!target
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|| target.when.cursor_col_min !== undefined
|
|
173
|
-
|| target.when.cursor_col_max !== undefined;
|
|
174
|
-
if (hasCursorGuard && cursor !== undefined) {
|
|
175
|
-
const { cursor_row_min, cursor_row_max, cursor_col_min, cursor_col_max } = target.when;
|
|
176
|
-
const cursorOk = (cursor_row_min === undefined || cursor.row >= cursor_row_min)
|
|
177
|
-
&& (cursor_row_max === undefined || cursor.row <= cursor_row_max)
|
|
178
|
-
&& (cursor_col_min === undefined || cursor.col >= cursor_col_min)
|
|
179
|
-
&& (cursor_col_max === undefined || cursor.col <= cursor_col_max);
|
|
180
|
-
if (cursorOk) return true;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (!target.when.regex) return false;
|
|
184
|
-
const haystack = target.when.section
|
|
185
|
-
? ev.sections.find(section => section.id === target.when.section)?.text ?? ''
|
|
186
|
-
: screen;
|
|
187
|
-
if (!haystack) return false;
|
|
171
|
+
if (!target) return false;
|
|
172
|
+
|
|
173
|
+
// Re-use the resolved sections already computed by evaluate().
|
|
174
|
+
const sections = ev.sections;
|
|
175
|
+
const cleanScreen = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l).join('\n');
|
|
176
|
+
|
|
188
177
|
try {
|
|
189
|
-
|
|
178
|
+
// Import the condition evaluator — but we need it as a module-level fn.
|
|
179
|
+
// Instead, call evaluate() and check: if the evaluator chose the target
|
|
180
|
+
// via an *explicit* state match (not fallback), return true.
|
|
181
|
+
// We detect a fallback via trace: the fallback emits "(no state matched".
|
|
182
|
+
const result = evaluate(spec, screen, cursor);
|
|
183
|
+
const wasFallback = result.trace.some(t => t.text.startsWith('(no state matched'));
|
|
184
|
+
return result.state.id === targetId && !wasFallback;
|
|
190
185
|
} catch {
|
|
191
186
|
return false;
|
|
192
187
|
}
|
|
@@ -225,8 +220,16 @@ export class SpecDriver {
|
|
|
225
220
|
private lastModalAt = 0;
|
|
226
221
|
/** The modal state snapshot held across busy blips. */
|
|
227
222
|
private lastModalState: SpecEvaluation['state'] | null = null;
|
|
223
|
+
/** Timestamp of when we last *exited* a modal state (approval/picker → idle
|
|
224
|
+
* or directly via completion_idle_after). Used to suppress completion_idle_after
|
|
225
|
+
* firings that were queued before the modal appeared and expire immediately
|
|
226
|
+
* after the modal is dismissed — without this the agent appears idle even
|
|
227
|
+
* though it is still generating. */
|
|
228
|
+
private lastModalExitAt = 0;
|
|
228
229
|
private completionIdleFirstSeenAt = 0;
|
|
229
230
|
private completionIdleKey = '';
|
|
231
|
+
/** Previous screen lines — passed to evaluate() for `changed` condition detection. */
|
|
232
|
+
private prevScreenLines: string[] = [];
|
|
230
233
|
/** Timer that re-runs evaluate() once the hold window expires. Needed
|
|
231
234
|
* because the PTY stops emitting once the agent finishes; without an
|
|
232
235
|
* explicit wake-up there's nothing to trigger the busy → idle
|
|
@@ -457,7 +460,9 @@ export class SpecDriver {
|
|
|
457
460
|
private reevaluate(forceEmit = false): void {
|
|
458
461
|
const screen = this.adapter.snapshot();
|
|
459
462
|
const cursor = this.adapter.getCursorPosition();
|
|
460
|
-
const ev = evaluate(this.spec, screen, cursor);
|
|
463
|
+
const ev = evaluate(this.spec, screen, cursor, this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined);
|
|
464
|
+
// Update prevScreenLines for next evaluation's `changed` condition detection.
|
|
465
|
+
this.prevScreenLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
461
466
|
|
|
462
467
|
// Busy hold: many TUIs flicker between busy and idle every frame
|
|
463
468
|
// (claude in particular — its token counter appears and disappears
|
|
@@ -499,7 +504,18 @@ export class SpecDriver {
|
|
|
499
504
|
}
|
|
500
505
|
const completionIdleRule = this.spec.debounce?.completion_idle_after;
|
|
501
506
|
let busyWakeMs = busyHoldMs;
|
|
502
|
-
|
|
507
|
+
// Don't fire completion_idle_after while in a modal state (approval,
|
|
508
|
+
// picker, etc.) or within a grace period after leaving one. Two cases:
|
|
509
|
+
// 1. Modal still active: the hold window may have expired so evState
|
|
510
|
+
// resolves to busy, but the user is still looking at the approval screen.
|
|
511
|
+
// 2. Modal just dismissed: the timer may have been queued *before* the
|
|
512
|
+
// approval appeared and its hold expires immediately after dismissal,
|
|
513
|
+
// causing a false-idle even though the agent is still generating.
|
|
514
|
+
const postModalGraceMs = busyHoldMs;
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
const recentlyInModal = isModalState(this.currentStateId) || (this.lastModalAt > 0 && now - this.lastModalAt < postModalGraceMs);
|
|
517
|
+
const recentlyLeftModal = !recentlyInModal && this.lastModalExitAt > 0 && now - this.lastModalExitAt < postModalGraceMs;
|
|
518
|
+
if (evState.id === 'busy' && completionIdleRule && !recentlyInModal && !recentlyLeftModal) {
|
|
503
519
|
const completionKey = matchesCompletionIdleRule(this.spec, ev, screen);
|
|
504
520
|
if (completionKey) {
|
|
505
521
|
const now = Date.now();
|
|
@@ -551,7 +567,15 @@ export class SpecDriver {
|
|
|
551
567
|
// a new tool-output burst arrived that pushed the marker off
|
|
552
568
|
// screen — in that case the old firstSeenAt is stale and should
|
|
553
569
|
// restart when the marker reappears.
|
|
554
|
-
|
|
570
|
+
//
|
|
571
|
+
// Also reset on busy re-entry (transitioning back from a non-busy
|
|
572
|
+
// state). The previous generation may have set completionIdleKey
|
|
573
|
+
// and firstSeenAt; without this reset the hold window appears
|
|
574
|
+
// instantly expired on the new generation's first PTY frame that
|
|
575
|
+
// re-matches the same completion regex key, causing a false-idle
|
|
576
|
+
// before the new generation has a chance to run.
|
|
577
|
+
if (!this.completionIdleKey || this.currentStateId !== 'busy') {
|
|
578
|
+
this.completionIdleKey = '';
|
|
555
579
|
this.completionIdleFirstSeenAt = 0;
|
|
556
580
|
}
|
|
557
581
|
// Schedule a re-evaluation when the hold window expires. PTYs
|
|
@@ -572,7 +596,10 @@ export class SpecDriver {
|
|
|
572
596
|
this.lastModalAt = Date.now();
|
|
573
597
|
this.lastModalState = evState;
|
|
574
598
|
} else {
|
|
575
|
-
// Leaving modal territory (going to idle) — clear the hold
|
|
599
|
+
// Leaving modal territory (going to idle) — clear the hold,
|
|
600
|
+
// but record the exit time so completion_idle_after can be
|
|
601
|
+
// suppressed during the grace period after dismissal.
|
|
602
|
+
if (this.lastModalAt > 0) this.lastModalExitAt = Date.now();
|
|
576
603
|
this.lastModalAt = 0;
|
|
577
604
|
this.lastModalState = null;
|
|
578
605
|
}
|
|
@@ -819,6 +846,7 @@ export class SpecDriver {
|
|
|
819
846
|
const action = picker.spec.action;
|
|
820
847
|
if (action.type !== 'open_picker') return;
|
|
821
848
|
const hay = sectionTextFromSnapshot(this.spec, screen, action.wait_for.section) ?? screen;
|
|
849
|
+
if (!action.wait_for.regex) return;
|
|
822
850
|
const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
|
|
823
851
|
if (!re.test(hay)) return;
|
|
824
852
|
// Cue arrived — the dashboard now sees the picker modal via
|