@adhdev/daemon-core 0.9.82-rc.353 → 0.9.82-rc.355
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/commands/handler.d.ts +15 -0
- package/dist/index.js +703 -220
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +703 -220
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-trace.d.ts +21 -0
- package/dist/mesh/mesh-runtime-store.d.ts +1 -1
- package/dist/mesh/mesh-work-queue.d.ts +1 -1
- package/dist/providers/acp-provider-instance.d.ts +3 -0
- package/dist/providers/cli-provider-instance.d.ts +14 -0
- package/dist/providers/manual-attendance.d.ts +63 -0
- package/dist/providers/provider-instance.d.ts +8 -0
- package/dist/providers/spec/adapter.d.ts +22 -0
- package/dist/providers/spec/fsm-driver.d.ts +49 -7
- package/dist/providers/spec/fsm-evaluator.d.ts +4 -0
- package/dist/providers/spec/types.d.ts +9 -5
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +20 -2
- package/src/commands/handler.ts +32 -0
- package/src/commands/router.ts +19 -6
- package/src/git/git-diff.ts +31 -14
- package/src/mesh/mesh-event-trace.ts +67 -0
- package/src/mesh/mesh-events-coordinator.ts +117 -12
- package/src/mesh/mesh-events-pending.ts +33 -0
- package/src/mesh/mesh-events-stale.ts +3 -1
- package/src/mesh/mesh-reconcile-loop.ts +47 -0
- package/src/mesh/mesh-runtime-store.ts +18 -2
- package/src/mesh/mesh-work-queue.ts +8 -1
- package/src/providers/acp-provider-instance.ts +18 -1
- package/src/providers/cli-provider-instance.ts +123 -7
- package/src/providers/manual-attendance.ts +85 -0
- package/src/providers/provider-instance.ts +9 -0
- package/src/providers/spec/adapter.ts +67 -0
- package/src/providers/spec/cli-adapter.ts +6 -0
- package/src/providers/spec/evaluator.ts +24 -9
- package/src/providers/spec/fsm-driver.ts +135 -13
- package/src/providers/spec/fsm-evaluator.ts +19 -2
- package/src/providers/spec/types.ts +9 -5
|
@@ -21,6 +21,7 @@ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pt
|
|
|
21
21
|
import { StatusMonitor } from './status-monitor.js';
|
|
22
22
|
import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
|
|
23
23
|
import { LOG } from '../logging/logger.js';
|
|
24
|
+
import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
|
|
24
25
|
import type { ChatMessage } from '../types.js';
|
|
25
26
|
import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
|
|
26
27
|
import { formatAutoApprovalMessage, pickApprovalButton, pickAutoApprovalButton, looksLikeActiveApprovalPromptText } from './approval-utils.js';
|
|
@@ -29,6 +30,7 @@ import { mergeProviderPatchState, resolveProviderStateSurface } from './provider
|
|
|
29
30
|
import { normalizeProviderSessionId } from './provider-session-id.js';
|
|
30
31
|
import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
|
|
31
32
|
import { workingDirBasename } from './working-dir.js';
|
|
33
|
+
import { ManualAttendanceTracker } from './manual-attendance.js';
|
|
32
34
|
|
|
33
35
|
type PersistableCliHistoryMessage = {
|
|
34
36
|
role: string;
|
|
@@ -416,6 +418,11 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
416
418
|
// a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
|
|
417
419
|
// brief generating flip does not immediately wipe the settle clock.
|
|
418
420
|
private autoApproveInactiveSince = 0;
|
|
421
|
+
// Provider-common manual-attendance signal: while a human is actively driving
|
|
422
|
+
// this session from the dashboard, auto-approve holds so they can take manual
|
|
423
|
+
// control. Background mesh workers are never attended → delegated auto-approve
|
|
424
|
+
// is unaffected.
|
|
425
|
+
private readonly manualAttendance = new ManualAttendanceTracker();
|
|
419
426
|
private controlValues: Record<string, string | number | boolean> = {};
|
|
420
427
|
private summaryMetadata: unknown = undefined;
|
|
421
428
|
private appliedEffectKeys = new Set<string>();
|
|
@@ -828,7 +835,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
828
835
|
|
|
829
836
|
getHotChatSessionState(): HotChatSessionState {
|
|
830
837
|
const adapterStatus = this.adapter.getStatus({ allowParse: false });
|
|
831
|
-
const autoApproveActive = adapterStatus.status
|
|
838
|
+
const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
|
|
832
839
|
const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
|
|
833
840
|
const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
|
|
834
841
|
const runtime = this.adapter.getRuntimeMetadata();
|
|
@@ -844,7 +851,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
844
851
|
|
|
845
852
|
getSessionModalState(sessionId?: string): SessionModalState {
|
|
846
853
|
const adapterStatus = this.adapter.getStatus({ allowParse: true });
|
|
847
|
-
const autoApproveActive = adapterStatus.status
|
|
854
|
+
const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
|
|
848
855
|
const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
|
|
849
856
|
const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
|
|
850
857
|
const dirName = workingDirBasename(this.workingDir);
|
|
@@ -947,7 +954,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
947
954
|
} catch {
|
|
948
955
|
return null;
|
|
949
956
|
}
|
|
950
|
-
|
|
957
|
+
// A session whose auto-approve is held by manual attendance IS parked on a
|
|
958
|
+
// modal awaiting the human — autoApproveEffectivelyActive folds that in, so
|
|
959
|
+
// the mesh force-inject guard correctly treats it as modal-parked.
|
|
960
|
+
if (adapterStatus.status === 'waiting_approval' && !this.autoApproveEffectivelyActive(adapterStatus.status)) {
|
|
951
961
|
return 'waiting_approval';
|
|
952
962
|
}
|
|
953
963
|
return null;
|
|
@@ -1402,6 +1412,26 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1402
1412
|
this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
|
|
1403
1413
|
}
|
|
1404
1414
|
|
|
1415
|
+
// EVTTRACE (observation-only): is this a mesh worker session whose completion
|
|
1416
|
+
// events must route to a coordinator? Used purely to gate trace logging so a
|
|
1417
|
+
// non-mesh CLI session's completions don't add EvtTrace noise. No decision logic.
|
|
1418
|
+
private isMeshWorkerSession(): boolean {
|
|
1419
|
+
return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId
|
|
1420
|
+
|| this.settings.meshNodeId || this.settings.launchedByCoordinator);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// EVTTRACE correlation context for this session's completion lifecycle. taskId is
|
|
1424
|
+
// the primary grep anchor; instanceId is the session fallback.
|
|
1425
|
+
private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
|
|
1426
|
+
return {
|
|
1427
|
+
taskId: this.settings.meshActiveTaskId,
|
|
1428
|
+
sessionId: this.instanceId,
|
|
1429
|
+
nodeId: this.settings.meshNodeId,
|
|
1430
|
+
meshId: this.settings.meshNodeFor,
|
|
1431
|
+
event,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1405
1435
|
private flushCompletedDebounceIfFinalized(): void {
|
|
1406
1436
|
const pending = this.completedDebouncePending;
|
|
1407
1437
|
if (!pending) {
|
|
@@ -1424,24 +1454,55 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1424
1454
|
if (block) {
|
|
1425
1455
|
const blockReason = block.reason;
|
|
1426
1456
|
const waitedMs = Date.now() - pending.firstObservedAt;
|
|
1427
|
-
|
|
1428
|
-
|
|
1457
|
+
// CANON-C (completion-gate decouple): a block carrying `allowTimeout` is the
|
|
1458
|
+
// transcript-evidence gate — the worker FSM has ALREADY reached idle and the only
|
|
1459
|
+
// thing missing is the append-only transcript's final assistant turn (a native-source
|
|
1460
|
+
// race: claude-cli owns its history externally and the file write trails the idle
|
|
1461
|
+
// transition). `allowTimeout` is set ONLY on the missing_final_assistant block, and
|
|
1462
|
+
// ONLY for mesh worker sessions (meshNodeFor / meshActiveTaskId / launchedByCoordinator).
|
|
1463
|
+
// The coordinator's sole path to learn this session is idle is agent:generating_completed,
|
|
1464
|
+
// so holding it up to COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) leaves the coordinator
|
|
1465
|
+
// false-generating while the worker is done. Decouple the idle NOTIFICATION from the
|
|
1466
|
+
// transcript evidence: emit the completion immediately, marked weak
|
|
1467
|
+
// (completionDiagnostic.blockReason=missing_final_assistant, finalAssistantPresent=false).
|
|
1468
|
+
// The finalSummary is enriched on a SEPARATE path — the mesh reconcile loop reads the
|
|
1469
|
+
// transcript once written and re-emits a GENUINE completion (CANON-B weak→genuine
|
|
1470
|
+
// upgrade; buildPendingEventFingerprint keeps weak and genuine distinct so the enriched
|
|
1471
|
+
// one still surfaces, and isFalseIdleCompletion keeps the direct dispatch active until
|
|
1472
|
+
// then). All OTHER blocks (genuinely-busy adapter/partial/parsed states, transient
|
|
1473
|
+
// parse_error) keep the existing terminal-hold / 30s-retry behavior unchanged.
|
|
1474
|
+
const isTranscriptEvidenceGate = block.allowTimeout === true;
|
|
1475
|
+
LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
|
|
1476
|
+
if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
|
|
1429
1477
|
if (pending.loggedBlockReason !== blockReason) {
|
|
1430
1478
|
LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
|
|
1479
|
+
// EVTTRACE: completion held by the finalization gate (CANON-C). Observation
|
|
1480
|
+
// only — does not change the hold decision above.
|
|
1481
|
+
if (this.isMeshWorkerSession()) {
|
|
1482
|
+
traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
|
|
1483
|
+
}
|
|
1431
1484
|
pending.loggedBlockReason = blockReason;
|
|
1432
1485
|
}
|
|
1433
1486
|
this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
|
|
1434
1487
|
return;
|
|
1435
1488
|
}
|
|
1489
|
+
const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
|
|
1436
1490
|
const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
|
|
1437
1491
|
blockReason,
|
|
1438
1492
|
latestStatus,
|
|
1439
1493
|
latestVisibleStatus,
|
|
1440
1494
|
waitedMs,
|
|
1441
1495
|
pending,
|
|
1442
|
-
emittedAfterFinalizationTimeout
|
|
1496
|
+
emittedAfterFinalizationTimeout,
|
|
1443
1497
|
});
|
|
1444
|
-
|
|
1498
|
+
// Surface the CANON-C immediate-emit path distinctly so a delegated worker's idle
|
|
1499
|
+
// notification (transcript still pending) is not mistaken for a 30s-timeout fallback.
|
|
1500
|
+
(completionDiagnostic as Record<string, unknown>).decoupledImmediateEmit = isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout;
|
|
1501
|
+
LOG.warn('CLI', `[${this.type}] emitting completed event (${isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'CANON-C decoupled-immediate, transcript pending' : `after ${waitedMs}ms`}) without finalized assistant turn (${blockReason})`);
|
|
1502
|
+
// EVTTRACE: completion fired (forced past the finalization timeout / CANON-C decoupled-immediate).
|
|
1503
|
+
if (this.isMeshWorkerSession()) {
|
|
1504
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
|
|
1505
|
+
}
|
|
1445
1506
|
this.pushEvent({
|
|
1446
1507
|
event: 'agent:generating_completed',
|
|
1447
1508
|
chatTitle: pending.chatTitle,
|
|
@@ -1467,6 +1528,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1467
1528
|
}
|
|
1468
1529
|
|
|
1469
1530
|
LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
|
|
1531
|
+
// EVTTRACE: completion fired (transcript finalized cleanly).
|
|
1532
|
+
if (this.isMeshWorkerSession()) {
|
|
1533
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
|
|
1534
|
+
}
|
|
1470
1535
|
this.pushEvent({
|
|
1471
1536
|
event: 'agent:generating_completed',
|
|
1472
1537
|
chatTitle: pending.chatTitle,
|
|
@@ -1481,6 +1546,29 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1481
1546
|
}
|
|
1482
1547
|
|
|
1483
1548
|
private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
|
|
1549
|
+
// Manual-attendance suppression (provider-common): when a human is
|
|
1550
|
+
// actively driving this session from the dashboard, hold auto-approve so
|
|
1551
|
+
// the modal stays visible and they can pick a button / use the controlbar
|
|
1552
|
+
// themselves. Return false (NOT auto-approving) so getState keeps the
|
|
1553
|
+
// modal surfaced. Clear any in-progress settle gate — a genuine fire
|
|
1554
|
+
// after the window lapses must re-settle from scratch — and arm a
|
|
1555
|
+
// re-check for the lapse moment, because the PTY may have gone silent and
|
|
1556
|
+
// would otherwise never re-drive this decision. Background mesh workers
|
|
1557
|
+
// are never attended, so their delegated auto-approve is untouched.
|
|
1558
|
+
if (adapterStatus?.status === 'waiting_approval'
|
|
1559
|
+
&& this.shouldAutoApprove()
|
|
1560
|
+
&& this.manualAttendance.isAttended(now)) {
|
|
1561
|
+
this.lastAutoApprovalSignature = '';
|
|
1562
|
+
this.pendingAutoApprovalSignature = '';
|
|
1563
|
+
this.pendingAutoApprovalSince = 0;
|
|
1564
|
+
this.autoApproveInactiveSince = 0;
|
|
1565
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
1566
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
1567
|
+
this.autoApproveSettleTimer = null;
|
|
1568
|
+
this.recheckAutoApproveSettled();
|
|
1569
|
+
}, this.manualAttendance.remainingMs(now) + 20);
|
|
1570
|
+
return false;
|
|
1571
|
+
}
|
|
1484
1572
|
const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
|
|
1485
1573
|
// Guard re-entry: onStatusChange/getState can observe the same modal multiple
|
|
1486
1574
|
// times while the PTY absorbs the approval key. Without this flag, repeated
|
|
@@ -1805,7 +1893,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1805
1893
|
LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
|
|
1806
1894
|
// completedDebouncePending intentionally left null — the session is now idle
|
|
1807
1895
|
// with no confirmed turn, matching the startup-blip suppression semantics.
|
|
1896
|
+
// (No EvtTrace: not a mesh session, so nothing routes to a coordinator.)
|
|
1808
1897
|
} else {
|
|
1898
|
+
// EVTTRACE: completion fired (short-generating idle path).
|
|
1899
|
+
if (this.isMeshWorkerSession()) {
|
|
1900
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), `short-generating idle (source=${shortEvidenceSource})`);
|
|
1901
|
+
}
|
|
1809
1902
|
this.pushEvent({
|
|
1810
1903
|
event: 'agent:generating_completed',
|
|
1811
1904
|
chatTitle,
|
|
@@ -1886,6 +1979,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1886
1979
|
&& !this.hasAdapterPendingResponse()
|
|
1887
1980
|
&& !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
|
|
1888
1981
|
) {
|
|
1982
|
+
// EVTTRACE: completion fired (no-progress monitor reconciled to completion).
|
|
1983
|
+
if (this.isMeshWorkerSession()) {
|
|
1984
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), 'no_progress_monitor_final_summary');
|
|
1985
|
+
}
|
|
1889
1986
|
this.pushEvent({
|
|
1890
1987
|
event: 'agent:generating_completed',
|
|
1891
1988
|
chatTitle,
|
|
@@ -2096,6 +2193,25 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2096
2193
|
return false;
|
|
2097
2194
|
}
|
|
2098
2195
|
|
|
2196
|
+
/** @see ProviderInstance.noteManualInteraction */
|
|
2197
|
+
noteManualInteraction(now = Date.now()): void {
|
|
2198
|
+
this.manualAttendance.note(now);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Whether auto-approve should be treated as active *right now* for display
|
|
2203
|
+
* and firing decisions: the configured intent AND the user is not currently
|
|
2204
|
+
* attending this session by hand. When a human is attending, auto-approve is
|
|
2205
|
+
* held so the modal stays visible and they can drive it via the controlbar.
|
|
2206
|
+
* Provider-agnostic — the attendance signal is the command set, never any
|
|
2207
|
+
* CLI-specific modal text.
|
|
2208
|
+
*/
|
|
2209
|
+
private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
|
|
2210
|
+
return status === 'waiting_approval'
|
|
2211
|
+
&& this.shouldAutoApprove()
|
|
2212
|
+
&& !this.manualAttendance.isAttended(now);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2099
2215
|
private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
|
|
2100
2216
|
this.appendRuntimeSystemMessage(
|
|
2101
2217
|
formatAutoApprovalMessage(modalMessage, buttonLabel),
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ManualAttendanceTracker — provider-agnostic "is a human driving this session
|
|
3
|
+
* right now" signal, used to suppress auto-approve while the user is taking
|
|
4
|
+
* manual control of a session from the dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ---------------
|
|
8
|
+
* When `autoApprove` is on, an approval modal is auto-dismissed within a few
|
|
9
|
+
* hundred ms of appearing. For a background mesh worker that is exactly the
|
|
10
|
+
* desired delegated behavior. But for a session the user is actively watching
|
|
11
|
+
* and operating (a base-node / foreground session), the auto-fire closes the
|
|
12
|
+
* modal before the human can pick a button — and likewise fights their use of
|
|
13
|
+
* the controlbar. The fix is to give the human a short quiet window: while they
|
|
14
|
+
* are attending the session by hand, auto-approve holds; once they go idle it
|
|
15
|
+
* resumes.
|
|
16
|
+
*
|
|
17
|
+
* The tracker holds only a timestamp. The *signal* — which commands count as
|
|
18
|
+
* "a human attending" — is decided by the caller (the command handler), and is
|
|
19
|
+
* the same set for every provider: foreground tab selection (select_session /
|
|
20
|
+
* open_panel), controlbar use (invoke_provider_script / set_mode / change_model
|
|
21
|
+
* / set_thought_level), manual approval (resolve_action) and manual terminal
|
|
22
|
+
* input (pty_input). Notably NOT send_chat, which is also how a coordinator
|
|
23
|
+
* delegates a task to a worker — counting it would wrongly suppress the
|
|
24
|
+
* worker's delegated auto-approve.
|
|
25
|
+
*
|
|
26
|
+
* Because a background worker never receives any of those attending commands,
|
|
27
|
+
* it is never "attended", so its delegated auto-approve is unaffected. The
|
|
28
|
+
* mechanism is therefore provider-common AND preserves worker auto-approve
|
|
29
|
+
* without any per-provider branching.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How long after the last manual interaction auto-approve stays suppressed.
|
|
34
|
+
*
|
|
35
|
+
* Trade-off: long enough that after foregrounding a session's tab the user has
|
|
36
|
+
* a realistic chance to act on an incoming approval (the auto-approve settle
|
|
37
|
+
* window is only ~600ms, so a human cannot out-race it), yet short enough that
|
|
38
|
+
* a session left unattended — e.g. a worker tab the user briefly peeked at —
|
|
39
|
+
* resumes auto-approving within about a minute. Re-armed on every attending
|
|
40
|
+
* command, so a user who keeps interacting keeps the window fresh.
|
|
41
|
+
*/
|
|
42
|
+
export const AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS = 60_000;
|
|
43
|
+
|
|
44
|
+
export class ManualAttendanceTracker {
|
|
45
|
+
private lastInteractionAt = 0;
|
|
46
|
+
|
|
47
|
+
constructor(private readonly suppressMs: number = AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS) {}
|
|
48
|
+
|
|
49
|
+
/** Record that a human just drove this session by hand. */
|
|
50
|
+
note(now = Date.now()): void {
|
|
51
|
+
this.lastInteractionAt = now;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True while a manual interaction is recent enough to suppress auto-approve. */
|
|
55
|
+
isAttended(now = Date.now()): boolean {
|
|
56
|
+
return this.lastInteractionAt > 0 && (now - this.lastInteractionAt) < this.suppressMs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Milliseconds remaining in the current suppression window, or 0 when not
|
|
61
|
+
* attended. Used to re-arm a re-check timer so auto-approve fires the moment
|
|
62
|
+
* the window lapses even if the PTY/agent has since gone silent.
|
|
63
|
+
*/
|
|
64
|
+
remainingMs(now = Date.now()): number {
|
|
65
|
+
if (this.lastInteractionAt <= 0) return 0;
|
|
66
|
+
return Math.max(0, this.suppressMs - (now - this.lastInteractionAt));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The session-scoped commands that count as "a human is attending this session
|
|
72
|
+
* by hand". Shared so the command handler and any forward path agree on one
|
|
73
|
+
* definition. Deliberately excludes send_chat (coordinator task delegation) and
|
|
74
|
+
* pure read commands (read_chat / list_chats — passive polling, not driving).
|
|
75
|
+
*/
|
|
76
|
+
export const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string> = new Set([
|
|
77
|
+
'select_session',
|
|
78
|
+
'open_panel',
|
|
79
|
+
'invoke_provider_script',
|
|
80
|
+
'set_mode',
|
|
81
|
+
'change_model',
|
|
82
|
+
'set_thought_level',
|
|
83
|
+
'resolve_action',
|
|
84
|
+
'pty_input',
|
|
85
|
+
]);
|
|
@@ -226,6 +226,15 @@ export interface ProviderInstance {
|
|
|
226
226
|
/** Refresh static provider definition/scripts without restarting the live runtime. */
|
|
227
227
|
refreshProviderDefinition?(provider: ProviderModule): void;
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Record that a human is actively attending this session by hand right now
|
|
231
|
+
* (foreground tab selection, controlbar use, manual approval, terminal
|
|
232
|
+
* input). Provider-common signal that suppresses auto-approve for a short
|
|
233
|
+
* window so the user can drive the session manually; background mesh worker
|
|
234
|
+
* sessions never receive it, so their delegated auto-approve is unaffected.
|
|
235
|
+
*/
|
|
236
|
+
noteManualInteraction?(now?: number): void;
|
|
237
|
+
|
|
229
238
|
/** cleanup */
|
|
230
239
|
dispose(): void;
|
|
231
240
|
}
|
|
@@ -51,6 +51,43 @@ export interface TerminalAdapterHandlers {
|
|
|
51
51
|
tick?(): void;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* One entry in the PTY input/output event timeline (debug-only). Captured at
|
|
56
|
+
* the single common point every spec@4 provider funnels through — this adapter
|
|
57
|
+
* — so the Spec Debug Snapshot can answer "what input / output preceded a status
|
|
58
|
+
* transition?". Observation only; nothing here feeds the FSM decision.
|
|
59
|
+
*/
|
|
60
|
+
export interface SpecPtyEvent {
|
|
61
|
+
/** Wall-clock ms. */
|
|
62
|
+
ts: number;
|
|
63
|
+
kind: 'spawn' | 'input' | 'output' | 'resize' | 'cursor' | 'exit';
|
|
64
|
+
/** Human-readable, control-char-escaped, length-capped preview. */
|
|
65
|
+
content: string;
|
|
66
|
+
/** Raw byte length before truncation (output/input only). */
|
|
67
|
+
bytes?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MAX_PTY_EVENTS = 300;
|
|
71
|
+
const EVENT_CONTENT_CAP = 240;
|
|
72
|
+
|
|
73
|
+
/** Escape control characters into a visible form so the timeline is readable
|
|
74
|
+
* (CR/LF/ESC/tab become \r \n \x1b \t; other C0 controls become \xNN). */
|
|
75
|
+
function escapeControl(text: string): string {
|
|
76
|
+
// eslint-disable-next-line no-control-regex
|
|
77
|
+
return String(text).replace(/[\x00-\x1f\x7f]/g, (ch) => {
|
|
78
|
+
const code = ch.charCodeAt(0);
|
|
79
|
+
if (ch === '\r') return '\\r';
|
|
80
|
+
if (ch === '\n') return '\\n';
|
|
81
|
+
if (ch === '\t') return '\\t';
|
|
82
|
+
if (code === 0x1b) return '\\x1b';
|
|
83
|
+
return '\\x' + code.toString(16).padStart(2, '0');
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function capPreview(text: string): string {
|
|
88
|
+
return text.length > EVENT_CONTENT_CAP ? text.slice(0, EVENT_CONTENT_CAP) + `…(+${text.length - EVENT_CONTENT_CAP})` : text;
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
export class TerminalAdapter {
|
|
55
92
|
private rows: number;
|
|
56
93
|
private cols: number;
|
|
@@ -62,6 +99,9 @@ export class TerminalAdapter {
|
|
|
62
99
|
private screenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
63
100
|
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
101
|
private lastScreen = '';
|
|
102
|
+
/** Debug-only ring buffer of PTY input/output/resize/cursor events. */
|
|
103
|
+
private events: SpecPtyEvent[] = [];
|
|
104
|
+
private lastCursorKey = '';
|
|
65
105
|
|
|
66
106
|
constructor(
|
|
67
107
|
private readonly opts: TerminalAdapterOpts,
|
|
@@ -95,10 +135,12 @@ export class TerminalAdapter {
|
|
|
95
135
|
cols: this.cols,
|
|
96
136
|
rows: this.rows,
|
|
97
137
|
});
|
|
138
|
+
this.recordEvent('spawn', `${this.opts.binary} (${this.cols}x${this.rows})`);
|
|
98
139
|
this.handlers.init?.({ pid: this.pty.pid });
|
|
99
140
|
this.pty.onData((chunk) => this.onChunk(chunk));
|
|
100
141
|
this.pty.onExit((info) => {
|
|
101
142
|
this.stopTimers();
|
|
143
|
+
this.recordEvent('exit', `exitCode=${typeof info.exitCode === 'number' ? info.exitCode : 0}`);
|
|
102
144
|
this.handlers.on_exit?.({ exitCode: typeof info.exitCode === 'number' ? info.exitCode : 0 });
|
|
103
145
|
this.pty = null;
|
|
104
146
|
});
|
|
@@ -109,6 +151,7 @@ export class TerminalAdapter {
|
|
|
109
151
|
|
|
110
152
|
resize(cols: number, rows: number): void {
|
|
111
153
|
this.cols = cols; this.rows = rows;
|
|
154
|
+
this.recordEvent('resize', `${cols}x${rows}`);
|
|
112
155
|
this.pty?.resize(cols, rows);
|
|
113
156
|
this.screen.resize(rows, cols);
|
|
114
157
|
}
|
|
@@ -133,9 +176,24 @@ export class TerminalAdapter {
|
|
|
133
176
|
}
|
|
134
177
|
|
|
135
178
|
send_keys(text: string): void {
|
|
179
|
+
this.recordEvent('input', capPreview(escapeControl(text)), text.length);
|
|
136
180
|
this.pty?.write(text);
|
|
137
181
|
}
|
|
138
182
|
|
|
183
|
+
/** Debug-only: most-recent PTY input/output/resize/cursor events, oldest
|
|
184
|
+
* first. Pure observation — never consulted by the FSM. */
|
|
185
|
+
getEventTimeline(limit = MAX_PTY_EVENTS): SpecPtyEvent[] {
|
|
186
|
+
const n = Math.max(0, Math.min(limit, this.events.length));
|
|
187
|
+
return this.events.slice(this.events.length - n);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private recordEvent(kind: SpecPtyEvent['kind'], content: string, bytes?: number): void {
|
|
191
|
+
const ev: SpecPtyEvent = { ts: Date.now(), kind, content };
|
|
192
|
+
if (typeof bytes === 'number') ev.bytes = bytes;
|
|
193
|
+
this.events.push(ev);
|
|
194
|
+
if (this.events.length > MAX_PTY_EVENTS) this.events.splice(0, this.events.length - MAX_PTY_EVENTS);
|
|
195
|
+
}
|
|
196
|
+
|
|
139
197
|
kill(): void {
|
|
140
198
|
this.stopTimers();
|
|
141
199
|
try { this.pty?.kill(); } catch { /* ignore */ }
|
|
@@ -144,6 +202,7 @@ export class TerminalAdapter {
|
|
|
144
202
|
}
|
|
145
203
|
|
|
146
204
|
private onChunk(chunk: string): void {
|
|
205
|
+
this.recordEvent('output', capPreview(escapeControl(chunk)), chunk.length);
|
|
147
206
|
try { this.handlers.on_pty_data?.(chunk); } catch { /* user side */ }
|
|
148
207
|
this.screen.write(chunk);
|
|
149
208
|
// Coalesce snapshot emission — rapid bursts shouldn't fire 200x.
|
|
@@ -151,6 +210,14 @@ export class TerminalAdapter {
|
|
|
151
210
|
this.screenTimer = setTimeout(() => {
|
|
152
211
|
this.screenTimer = null;
|
|
153
212
|
const snap = this.computeScreen();
|
|
213
|
+
// Record cursor movement at the (debounced) screen-change boundary
|
|
214
|
+
// rather than per output chunk, so the timeline isn't flooded.
|
|
215
|
+
const cur = this.screen.getCursorPosition();
|
|
216
|
+
const curKey = `${cur.row},${cur.col}`;
|
|
217
|
+
if (curKey !== this.lastCursorKey) {
|
|
218
|
+
this.lastCursorKey = curKey;
|
|
219
|
+
this.recordEvent('cursor', `(${cur.row},${cur.col})`);
|
|
220
|
+
}
|
|
154
221
|
if (snap === this.lastScreen) return;
|
|
155
222
|
this.lastScreen = snap;
|
|
156
223
|
try { this.handlers.on_screen_changed?.(snap); } catch { /* user side */ }
|
|
@@ -552,6 +552,10 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
552
552
|
// answers "why did this rule fire" after the fact, unlike the live
|
|
553
553
|
// `fsm` field which only reflects the current instant.
|
|
554
554
|
fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
|
|
555
|
+
// PTY input/output/resize/cursor event timeline (debug-only) so the
|
|
556
|
+
// snapshot shows what we typed / what the PTY printed around each
|
|
557
|
+
// status transition. Null for drivers without the timeline.
|
|
558
|
+
eventTimeline: this.driver.getEventTimeline?.() ?? null,
|
|
555
559
|
// Extended fields
|
|
556
560
|
name: this.cliName,
|
|
557
561
|
status: this.getStatus().status,
|
|
@@ -962,6 +966,8 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
962
966
|
// v4 FSM transition snapshot history — the captured pre-transition
|
|
963
967
|
// evaluation table at each transition (null for v3 specs).
|
|
964
968
|
fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
|
|
969
|
+
// PTY input/output/resize/cursor event timeline (debug-only).
|
|
970
|
+
eventTimeline: this.driver.getEventTimeline?.() ?? null,
|
|
965
971
|
messages,
|
|
966
972
|
committedMessages: messages,
|
|
967
973
|
};
|
|
@@ -55,12 +55,24 @@ export function resolveSections(
|
|
|
55
55
|
// Normalize anchor + context into parallel candidate lists. A
|
|
56
56
|
// scalar anchor becomes a single-entry array; a single context
|
|
57
57
|
// object applies to every entry; an array context is positional.
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
58
|
+
// Each candidate resolves its own anchor line independently
|
|
59
|
+
// (anchor_last → that candidate's LAST matching line, else its
|
|
60
|
+
// FIRST). Across candidates we then pick the TOPMOST resolved
|
|
61
|
+
// line, because a section's anchor marks the TOP of the block:
|
|
62
|
+
// among several recognized landmark shapes, the highest one
|
|
63
|
+
// bounds the whole block. This keeps a scalar anchor identical
|
|
64
|
+
// (one candidate), preserves a genuine box-top divider (it sits
|
|
65
|
+
// ABOVE the question line, so it still wins), and — crucially —
|
|
66
|
+
// stops a stray lower landmark from clipping the block: e.g. a
|
|
67
|
+
// claude approval whose numbered choices sit ABOVE the input-box
|
|
68
|
+
// `────` rule. anchor_last on the bare-divider pattern alone
|
|
69
|
+
// would latch that LOWER chrome rule and strand the buttons
|
|
70
|
+
// above it (deriveModal sees < min_count → auto-approve never
|
|
71
|
+
// fires); preferring the topmost landmark (here the question
|
|
72
|
+
// line just above the choices, matched by the fallback context)
|
|
73
|
+
// captures the whole modal. The fallback still only contributes
|
|
74
|
+
// when its own pattern matches, so non-modal screens are
|
|
75
|
+
// unaffected.
|
|
64
76
|
const anchorPatterns = Array.isArray(sec.anchor) ? sec.anchor : [sec.anchor];
|
|
65
77
|
const sharedCtx: AnchorContext | null = Array.isArray(sec.anchor_context)
|
|
66
78
|
? null
|
|
@@ -84,12 +96,15 @@ export function resolveSections(
|
|
|
84
96
|
&& (c.nextRe === null || (i < total - 1 && c.nextRe.test(lines[i + 1])));
|
|
85
97
|
let idx = -1;
|
|
86
98
|
for (const c of candidates) {
|
|
99
|
+
let candIdx = -1;
|
|
87
100
|
if (sec.anchor_last) {
|
|
88
|
-
for (let i = total - 1; i >= 0; i--) { if (matchesCandidate(c, i)) {
|
|
101
|
+
for (let i = total - 1; i >= 0; i--) { if (matchesCandidate(c, i)) { candIdx = i; break; } }
|
|
89
102
|
} else {
|
|
90
|
-
for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) {
|
|
103
|
+
for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) { candIdx = i; break; } }
|
|
91
104
|
}
|
|
92
|
-
|
|
105
|
+
// Topmost resolved anchor across candidates wins (see note
|
|
106
|
+
// above): keep the smallest matching line index.
|
|
107
|
+
if (candIdx !== -1 && (idx === -1 || candIdx < idx)) idx = candIdx;
|
|
93
108
|
}
|
|
94
109
|
if (idx !== -1) {
|
|
95
110
|
from = idx;
|