@adhdev/daemon-core 0.9.82-rc.211 → 0.9.82-rc.213

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.
@@ -146,6 +146,10 @@ export declare class SpecDriver {
146
146
  private completionIdleKey;
147
147
  /** Previous screen lines — passed to evaluate() for `changed` condition detection. */
148
148
  private prevScreenLines;
149
+ /** Timestamp of the last PTY frame that changed the screen content.
150
+ * Used by screen_active_hold_ms to suppress idle downshifts while
151
+ * the terminal is still actively updating. */
152
+ private lastScreenChangedAt;
149
153
  /** Timer that re-runs evaluate() once the hold window expires. Needed
150
154
  * because the PTY stops emitting once the agent finishes; without an
151
155
  * explicit wake-up there's nothing to trigger the busy → idle
@@ -755,6 +755,10 @@ export declare const SCHEMA_V3: {
755
755
  readonly type: "integer";
756
756
  readonly minimum: 0;
757
757
  };
758
+ readonly screen_active_hold_ms: {
759
+ readonly type: "integer";
760
+ readonly minimum: 0;
761
+ };
758
762
  readonly completion_marker: {
759
763
  readonly type: "object";
760
764
  readonly additionalProperties: false;
@@ -1451,6 +1455,10 @@ export declare const SCHEMA: {
1451
1455
  readonly type: "integer";
1452
1456
  readonly minimum: 0;
1453
1457
  };
1458
+ readonly screen_active_hold_ms: {
1459
+ readonly type: "integer";
1460
+ readonly minimum: 0;
1461
+ };
1454
1462
  readonly completion_marker: {
1455
1463
  readonly type: "object";
1456
1464
  readonly additionalProperties: false;
@@ -225,6 +225,11 @@ export interface CliSpec {
225
225
  busy_hold_ms?: number;
226
226
  idle_hold_ms?: number;
227
227
  startup_grace_ms?: number;
228
+ /** Suppress idle downshift while the screen is actively changing.
229
+ * When set, any PTY frame that changes the screen resets a timer;
230
+ * idle transitions (busy_hold expiry and completion_idle_after) are
231
+ * blocked until the screen has been stable for this many ms. */
232
+ screen_active_hold_ms?: number;
228
233
  completion_marker?: {
229
234
  section?: string;
230
235
  matches: string;
@@ -243,6 +248,7 @@ export interface CliSpec {
243
248
  busy_hold_ms?: number;
244
249
  idle_hold_ms?: number;
245
250
  startup_grace_ms?: number;
251
+ screen_active_hold_ms?: number;
246
252
  /**
247
253
  * Legacy field name for completion_marker. Populated by the loader
248
254
  * from `timing.completion_marker` for backward compat.
@@ -70,6 +70,14 @@ export interface RepoMeshNode {
70
70
  export type RepoMeshNodeHealth = 'online' | 'offline' | 'degraded' | 'dirty' | 'wrong_branch' | 'unknown';
71
71
  export type RepoMeshSessionCleanupMode = 'preserve' | 'stop' | 'delete_stopped' | 'stop_and_delete';
72
72
  export type RepoMeshSpawnedSessionVisibility = 'visible' | 'hidden';
73
+ export interface RepoMeshAutoFastForwardPolicy {
74
+ /** Defaults to true. Set false to disable daemon-initiated idle fast-forwards. */
75
+ enabled: boolean;
76
+ /** Maximum behind count eligible for automatic fast-forward. Missing means no limit. */
77
+ maxBehind?: number;
78
+ /** Defaults to true. Require submodule status to be clean before automatic fast-forward. */
79
+ requireCleanSubmodules?: boolean;
80
+ }
73
81
  export interface RepoMeshPolicy {
74
82
  requirePreTaskCheckpoint: boolean;
75
83
  requirePostTaskCheckpoint: boolean;
@@ -97,6 +105,11 @@ export interface RepoMeshPolicy {
97
105
  * runtimes are never stopped/deleted unless the mesh owner opts in.
98
106
  */
99
107
  sessionCleanupOnNodeRemove?: RepoMeshSessionCleanupMode;
108
+ /**
109
+ * Daemon-initiated fast-forward for idle clean nodes that are only behind
110
+ * their tracked upstream. Defaults to enabled.
111
+ */
112
+ autoFastForward?: RepoMeshAutoFastForwardPolicy;
100
113
  /**
101
114
  * Maximum number of automatic retry recommendations for a failed task on the
102
115
  * same node before the daemon advises the coordinator to escalate or reassign.
@@ -358,6 +371,10 @@ export interface RepoMeshNodeStatus {
358
371
  activeSessionDetails?: RepoMeshSessionStatus[];
359
372
  providerPriority?: string[];
360
373
  launchReady?: boolean;
374
+ /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
375
+ autoFastForwardEligible?: boolean;
376
+ /** Coordinator-facing suggestion for obvious clean catch-up work. */
377
+ suggestedAction?: 'auto_fast_forward';
361
378
  worktreeBootstrap?: LocalMeshNodeEntry['worktreeBootstrap'];
362
379
  launchBlockedReason?: string;
363
380
  launchBlockedMessage?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.211",
3
+ "version": "0.9.82-rc.213",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -669,6 +669,25 @@ function getGitSubmoduleDriftState(git: Record<string, unknown> | null | undefin
669
669
  return { dirty, outOfSync };
670
670
  }
671
671
 
672
+ function isInlineMeshAutoFastForwardEligible(git: Record<string, unknown> | null | undefined): boolean {
673
+ if (!git) return false;
674
+ if (readBooleanValue(git.isGitRepo) !== true) return false;
675
+ if (!readStringValue(git.branch)) return false;
676
+ if (!readStringValue(git.upstream)) return false;
677
+ const upstreamStatus = readStringValue(git.upstreamStatus, git.upstream_status);
678
+ if (upstreamStatus !== 'fresh') return false;
679
+ if ((readNumberValue(git.ahead) ?? 0) !== 0) return false;
680
+ if ((readNumberValue(git.behind) ?? 0) <= 0) return false;
681
+ const hasConflicts = readBooleanValue(git.hasConflicts)
682
+ ?? (Array.isArray(git.conflictFiles) && git.conflictFiles.length > 0);
683
+ if (hasConflicts) return false;
684
+ if ((readNumberValue(git.stashCount, git.stash_count) ?? 0) > 0) return false;
685
+ const submoduleDrift = getGitSubmoduleDriftState(git);
686
+ if (submoduleDrift.dirty || submoduleDrift.outOfSync) return false;
687
+ const dirty = readBooleanValue(git.dirty) ?? (countGitWorktreeChanges(git) > 0);
688
+ return dirty !== true && countGitWorktreeChanges(git) === 0;
689
+ }
690
+
672
691
  function deriveMeshNodeHealthFromGit(git: Record<string, unknown> | null | undefined): 'online' | 'dirty' | 'degraded' {
673
692
  if (!git || readBooleanValue(git.isGitRepo) === false) return 'degraded';
674
693
  const branch = readStringValue(git.branch);
@@ -815,6 +834,12 @@ function applyInlineMeshBranchConvergence(mesh: any, node: any, status: Record<s
815
834
  status.isDirty = uncommittedChanges > 0;
816
835
  status.uncommittedChanges = uncommittedChanges;
817
836
  status.branchConvergence = buildInlineMeshBranchConvergence({ mesh, node, status });
837
+ status.autoFastForwardEligible = isInlineMeshAutoFastForwardEligible(git);
838
+ if (status.autoFastForwardEligible) {
839
+ status.suggestedAction = 'auto_fast_forward';
840
+ } else {
841
+ delete status.suggestedAction;
842
+ }
818
843
  }
819
844
 
820
845
  function summarizeInlineMeshBranchConvergence(nodes: Array<Record<string, unknown>>): Record<string, unknown> {
@@ -95,7 +95,17 @@ const SESSION_CLEANUP_MODES = new Set(['preserve', 'stop', 'delete_stopped', 'st
95
95
  const SPAWNED_SESSION_VISIBILITY_MODES = new Set(['visible', 'hidden']);
96
96
 
97
97
  function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy {
98
- const policy: RepoMeshPolicy = { ...DEFAULT_MESH_POLICY, ...(base || {}), ...(patch || {}) };
98
+ const autoFastForward = normalizeAutoFastForwardPolicy({
99
+ ...DEFAULT_MESH_POLICY.autoFastForward,
100
+ ...((base?.autoFastForward && typeof base.autoFastForward === 'object') ? base.autoFastForward : {}),
101
+ ...((patch?.autoFastForward && typeof patch.autoFastForward === 'object') ? patch.autoFastForward : {}),
102
+ });
103
+ const policy: RepoMeshPolicy = {
104
+ ...DEFAULT_MESH_POLICY,
105
+ ...(base || {}),
106
+ ...(patch || {}),
107
+ autoFastForward,
108
+ };
99
109
  if (!['block', 'warn', 'checkpoint_then_continue'].includes(policy.dirtyWorkspaceBehavior)) {
100
110
  policy.dirtyWorkspaceBehavior = 'warn';
101
111
  }
@@ -111,6 +121,18 @@ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMe
111
121
  return policy;
112
122
  }
113
123
 
124
+ function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
125
+ const record = value && typeof value === 'object' && !Array.isArray(value)
126
+ ? value as Record<string, unknown>
127
+ : {};
128
+ const maxBehind = Number(record.maxBehind);
129
+ return {
130
+ enabled: record.enabled !== false,
131
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
132
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
133
+ };
134
+ }
135
+
114
136
  export function listMeshes(): LocalMeshEntry[] {
115
137
  return loadMeshConfig().meshes;
116
138
  }
@@ -349,6 +349,39 @@ function isDirtyNode(node: any): boolean {
349
349
  return node?.health === 'dirty' || node?.git?.dirty === true;
350
350
  }
351
351
 
352
+ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?: number; requireCleanSubmodules: boolean } {
353
+ const record = mesh?.policy?.autoFastForward && typeof mesh.policy.autoFastForward === 'object' && !Array.isArray(mesh.policy.autoFastForward)
354
+ ? mesh.policy.autoFastForward as Record<string, unknown>
355
+ : {};
356
+ const maxBehind = Number(record.maxBehind);
357
+ return {
358
+ enabled: record.enabled !== false,
359
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
360
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
361
+ };
362
+ }
363
+
364
+ function sessionStateLooksActive(state: any): boolean {
365
+ const status = readNonEmptyString(state?.status).toLowerCase();
366
+ const chatStatus = readNonEmptyString(state?.activeChat?.status).toLowerCase();
367
+ const active = new Set(['generating', 'streaming', 'long_generating', 'working', 'starting', 'waiting_approval']);
368
+ return active.has(status) || active.has(chatStatus);
369
+ }
370
+
371
+ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nodeId: string, currentSessionId?: string): boolean {
372
+ if (nodeHasActiveAssignment(meshId, nodeId)) return true;
373
+ return components.instanceManager.getByCategory('cli').some((inst: any) => {
374
+ const state = inst.getState();
375
+ const settings = state.settings as Record<string, unknown> || {};
376
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
377
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
378
+ if (instNodeId !== nodeId) return false;
379
+ const sessionId = readNonEmptyString(state.instanceId);
380
+ if (currentSessionId && sessionId === currentSessionId && isIdleSessionState(state)) return false;
381
+ return sessionStateLooksActive(state);
382
+ });
383
+ }
384
+
352
385
  function isLaunchableNode(node: any): boolean {
353
386
  if (!node || node.status === 'disabled' || node.status === 'removed') return false;
354
387
  const health = readNonEmptyString(node.health).toLowerCase();
@@ -759,6 +792,10 @@ async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args:
759
792
  if (!workspace) return;
760
793
  if (!existsSync(workspace)) return;
761
794
 
795
+ const policy = resolveAutoFastForwardPolicy(mesh);
796
+ if (!policy.enabled) return;
797
+ if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId, args.sessionId)) return;
798
+
762
799
  const throttleKey = `${args.meshId}:${args.nodeId}`;
763
800
  const now = Date.now();
764
801
  const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
@@ -780,6 +817,12 @@ async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args:
780
817
  trigger: 'idle_auto',
781
818
  });
782
819
  if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
820
+ const behind = Number(dryRun.current?.behind);
821
+ if (policy.maxBehind !== undefined && Number.isFinite(behind) && behind > policy.maxBehind) return;
822
+ if (policy.requireCleanSubmodules) {
823
+ const submodules = Array.isArray(dryRun.current?.submodules) ? dryRun.current.submodules : [];
824
+ if (submodules.some((submodule: any) => submodule?.dirty || submodule?.outOfSync || submodule?.error)) return;
825
+ }
783
826
  await fastForwardMeshNode({
784
827
  meshId: args.meshId,
785
828
  nodeId: args.nodeId,
@@ -38,12 +38,14 @@ function formatCompletionMetadata(event: Record<string, unknown>): string {
38
38
  const finalAssistantPresent = typeof completionDiagnostic?.finalAssistantPresent === 'boolean'
39
39
  ? String(completionDiagnostic.finalAssistantPresent)
40
40
  : '';
41
+ const evidenceLevel = readNonEmptyString(event.evidenceLevel);
41
42
  const parts = [
42
43
  readNonEmptyString(event.targetSessionId) ? `session_id=${readNonEmptyString(event.targetSessionId)}` : '',
43
44
  readNonEmptyString(event.providerType) ? `provider=${readNonEmptyString(event.providerType)}` : '',
44
45
  readNonEmptyString(event.providerSessionId) ? `provider_session_id=${readNonEmptyString(event.providerSessionId)}` : '',
45
46
  diagnosticReason ? `completion_diagnostic=${diagnosticReason}` : '',
46
47
  finalAssistantPresent ? `final_assistant=${finalAssistantPresent}` : '',
48
+ evidenceLevel && evidenceLevel !== 'sufficient' ? `evidence_level=${evidenceLevel}` : '',
47
49
  ].filter(Boolean);
48
50
  return parts.length > 0 ? ` (${parts.join('; ')})` : '';
49
51
  }
@@ -59,7 +61,10 @@ export function buildMeshSystemMessage(args: {
59
61
  if (args.metadataEvent.source === 'long_generating_reconciliation') {
60
62
  return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The long-generating monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
61
63
  }
62
- return `[System] ${args.nodeLabel} has completed its task and is now idle${metadata}. This completion came from the agent status event path; use mesh_read_chat once to review its final progress, but do not poll repeatedly.`;
64
+ const reviewNote = args.metadataEvent.reviewRecommended === true
65
+ ? ' Completion evidence is insufficient — verify via git status or provider_session_id before assuming the task is done. Use mesh_read_chat once if needed, but do not poll repeatedly.'
66
+ : ' Use mesh_read_chat once to review its final progress, but do not poll repeatedly.';
67
+ return `[System] ${args.nodeLabel} has completed its task and is now idle${metadata}. This completion came from the agent status event path;${reviewNote}`;
63
68
  }
64
69
  if (args.event === 'agent:waiting_approval') {
65
70
  return `[System] ${args.nodeLabel} is waiting for approval to proceed${metadata}. You may use mesh_read_chat and mesh_approve to handle it.`;
@@ -1544,9 +1544,10 @@ export class CliProviderInstance implements ProviderInstance {
1544
1544
  message: typeof modal?.message === 'string' ? modal.message.trim() : '',
1545
1545
  buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
1546
1546
  });
1547
- // PTY redraws can briefly leave waiting_approval and then re-enter it.
1548
- // Keep one coordinator event per logical modal until the turn completes.
1549
- if (this.lastStatus !== 'waiting_approval' && approvalFingerprint !== this.lastApprovalEventFingerprint) {
1547
+ // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
1548
+ // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
1549
+ // (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
1550
+ if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
1550
1551
  this.lastApprovalEventFingerprint = approvalFingerprint;
1551
1552
  this.appendRuntimeSystemMessage(
1552
1553
  this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
@@ -230,6 +230,10 @@ export class SpecDriver {
230
230
  private completionIdleKey = '';
231
231
  /** Previous screen lines — passed to evaluate() for `changed` condition detection. */
232
232
  private prevScreenLines: string[] = [];
233
+ /** Timestamp of the last PTY frame that changed the screen content.
234
+ * Used by screen_active_hold_ms to suppress idle downshifts while
235
+ * the terminal is still actively updating. */
236
+ private lastScreenChangedAt = 0;
233
237
  /** Timer that re-runs evaluate() once the hold window expires. Needed
234
238
  * because the PTY stops emitting once the agent finishes; without an
235
239
  * explicit wake-up there's nothing to trigger the busy → idle
@@ -470,9 +474,14 @@ export class SpecDriver {
470
474
  private reevaluate(forceEmit = false): void {
471
475
  const screen = this.adapter.snapshot();
472
476
  const cursor = this.adapter.getCursorPosition();
477
+ const currentLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
473
478
  const ev = evaluate(this.spec, screen, cursor, this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined);
479
+ // Track when the screen last changed for screen_active_hold_ms.
480
+ if (this.prevScreenLines.length > 0 && currentLines.join('\n') !== this.prevScreenLines.join('\n')) {
481
+ this.lastScreenChangedAt = Date.now();
482
+ }
474
483
  // Update prevScreenLines for next evaluation's `changed` condition detection.
475
- this.prevScreenLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
484
+ this.prevScreenLines = currentLines;
476
485
 
477
486
  // Busy hold: many TUIs flicker between busy and idle every frame
478
487
  // (claude in particular — its token counter appears and disappears
@@ -513,6 +522,7 @@ export class SpecDriver {
513
522
  }
514
523
  }
515
524
  const completionIdleRule = this.spec.debounce?.completion_idle_after;
525
+ const screenActiveHoldMs = this.spec.debounce?.screen_active_hold_ms;
516
526
  let busyWakeMs = busyHoldMs;
517
527
  // Don't fire completion_idle_after while in a modal state (approval,
518
528
  // picker, etc.) or within a grace period after leaving one. Two cases:
@@ -525,7 +535,18 @@ export class SpecDriver {
525
535
  const now = Date.now();
526
536
  const recentlyInModal = isModalState(this.currentStateId) || (this.lastModalAt > 0 && now - this.lastModalAt < postModalGraceMs);
527
537
  const recentlyLeftModal = !recentlyInModal && this.lastModalExitAt > 0 && now - this.lastModalExitAt < postModalGraceMs;
528
- if (evState.id === 'busy' && completionIdleRule && !recentlyInModal && !recentlyLeftModal) {
538
+ // screen_active_hold_ms: suppress idle downshift while the screen is
539
+ // actively changing. Any PTY frame that mutates screen content resets
540
+ // lastScreenChangedAt; we hold off until it has been stable for the
541
+ // configured duration. Applies to both completion_idle_after and the
542
+ // busy_hold expiry path below.
543
+ const screenActiveMs = screenActiveHoldMs ?? 0;
544
+ const screenStableMs = this.lastScreenChangedAt > 0 ? now - this.lastScreenChangedAt : Infinity;
545
+ const screenIsActive = screenActiveMs > 0 && screenStableMs < screenActiveMs;
546
+ if (screenIsActive) {
547
+ busyWakeMs = Math.min(busyWakeMs, screenActiveMs - screenStableMs + 50);
548
+ }
549
+ if (evState.id === 'busy' && completionIdleRule && !recentlyInModal && !recentlyLeftModal && !screenIsActive) {
529
550
  const completionKey = matchesCompletionIdleRule(this.spec, ev, screen);
530
551
  if (completionKey) {
531
552
  const now = Date.now();
@@ -560,8 +581,18 @@ export class SpecDriver {
560
581
  this.completionIdleFirstSeenAt = 0;
561
582
  }
562
583
  } else if (evState.id !== 'busy') {
563
- this.completionIdleKey = '';
564
- this.completionIdleFirstSeenAt = 0;
584
+ if (!screenIsActive) {
585
+ this.completionIdleKey = '';
586
+ this.completionIdleFirstSeenAt = 0;
587
+ }
588
+ }
589
+
590
+ // screen_active_hold_ms: if the screen is still changing and the
591
+ // evaluator wants to downshift from busy to idle, pin to busy instead.
592
+ // This catches paths not covered by the completion_idle_after gate above
593
+ // (e.g. direct idle via busy_hold expiry or default-state fallback).
594
+ if (screenIsActive && this.currentStateId === 'busy' && evState.id === (this.spec.default_state ?? 'idle')) {
595
+ evState = this.lastBusyState ?? evState;
565
596
  }
566
597
 
567
598
  if (evState.id === 'busy') {
@@ -79,6 +79,7 @@ function attachDebounceAlias(spec: CliSpec): void {
79
79
  ...(t.busy_hold_ms !== undefined ? { busy_hold_ms: t.busy_hold_ms } : {}),
80
80
  ...(t.idle_hold_ms !== undefined ? { idle_hold_ms: t.idle_hold_ms } : {}),
81
81
  ...(t.startup_grace_ms !== undefined ? { startup_grace_ms: t.startup_grace_ms } : {}),
82
+ ...(t.screen_active_hold_ms !== undefined ? { screen_active_hold_ms: t.screen_active_hold_ms } : {}),
82
83
  ...(cm ? {
83
84
  completion_idle_after: {
84
85
  ...(cm.section ? { section: cm.section } : {}),
@@ -384,6 +384,7 @@ export const SCHEMA_V3 = {
384
384
  "busy_hold_ms": { "type": "integer", "minimum": 0 },
385
385
  "idle_hold_ms": { "type": "integer", "minimum": 0 },
386
386
  "startup_grace_ms": { "type": "integer", "minimum": 0 },
387
+ "screen_active_hold_ms": { "type": "integer", "minimum": 0 },
387
388
  "completion_marker": {
388
389
  "type": "object",
389
390
  "additionalProperties": false,
@@ -46,6 +46,7 @@
46
46
  "busy_hold_ms": { "type": "integer", "minimum": 0 },
47
47
  "idle_hold_ms": { "type": "integer", "minimum": 0 },
48
48
  "startup_grace_ms": { "type": "integer", "minimum": 0 },
49
+ "screen_active_hold_ms": { "type": "integer", "minimum": 0 },
49
50
  "completion_marker": {
50
51
  "type": "object",
51
52
  "additionalProperties": false,
@@ -273,6 +273,11 @@ export interface CliSpec {
273
273
  busy_hold_ms?: number;
274
274
  idle_hold_ms?: number;
275
275
  startup_grace_ms?: number;
276
+ /** Suppress idle downshift while the screen is actively changing.
277
+ * When set, any PTY frame that changes the screen resets a timer;
278
+ * idle transitions (busy_hold expiry and completion_idle_after) are
279
+ * blocked until the screen has been stable for this many ms. */
280
+ screen_active_hold_ms?: number;
276
281
  completion_marker?: {
277
282
  section?: string;
278
283
  matches: string;
@@ -291,6 +296,7 @@ export interface CliSpec {
291
296
  busy_hold_ms?: number;
292
297
  idle_hold_ms?: number;
293
298
  startup_grace_ms?: number;
299
+ screen_active_hold_ms?: number;
294
300
  /**
295
301
  * Legacy field name for completion_marker. Populated by the loader
296
302
  * from `timing.completion_marker` for backward compat.
@@ -90,6 +90,15 @@ export type RepoMeshNodeHealth =
90
90
  export type RepoMeshSessionCleanupMode = 'preserve' | 'stop' | 'delete_stopped' | 'stop_and_delete';
91
91
  export type RepoMeshSpawnedSessionVisibility = 'visible' | 'hidden';
92
92
 
93
+ export interface RepoMeshAutoFastForwardPolicy {
94
+ /** Defaults to true. Set false to disable daemon-initiated idle fast-forwards. */
95
+ enabled: boolean;
96
+ /** Maximum behind count eligible for automatic fast-forward. Missing means no limit. */
97
+ maxBehind?: number;
98
+ /** Defaults to true. Require submodule status to be clean before automatic fast-forward. */
99
+ requireCleanSubmodules?: boolean;
100
+ }
101
+
93
102
  export interface RepoMeshPolicy {
94
103
  requirePreTaskCheckpoint: boolean;
95
104
  requirePostTaskCheckpoint: boolean;
@@ -117,6 +126,11 @@ export interface RepoMeshPolicy {
117
126
  * runtimes are never stopped/deleted unless the mesh owner opts in.
118
127
  */
119
128
  sessionCleanupOnNodeRemove?: RepoMeshSessionCleanupMode;
129
+ /**
130
+ * Daemon-initiated fast-forward for idle clean nodes that are only behind
131
+ * their tracked upstream. Defaults to enabled.
132
+ */
133
+ autoFastForward?: RepoMeshAutoFastForwardPolicy;
120
134
  /**
121
135
  * Maximum number of automatic retry recommendations for a failed task on the
122
136
  * same node before the daemon advises the coordinator to escalate or reassign.
@@ -171,6 +185,7 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
171
185
  maxParallelTasks: 2,
172
186
  spawnedSessionVisibility: 'visible',
173
187
  sessionCleanupOnNodeRemove: 'preserve',
188
+ autoFastForward: { enabled: true },
174
189
  maxTaskRetries: 1,
175
190
  };
176
191
 
@@ -415,6 +430,10 @@ export interface RepoMeshNodeStatus {
415
430
  activeSessionDetails?: RepoMeshSessionStatus[];
416
431
  providerPriority?: string[];
417
432
  launchReady?: boolean;
433
+ /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
434
+ autoFastForwardEligible?: boolean;
435
+ /** Coordinator-facing suggestion for obvious clean catch-up work. */
436
+ suggestedAction?: 'auto_fast_forward';
418
437
  worktreeBootstrap?: LocalMeshNodeEntry['worktreeBootstrap'];
419
438
  launchBlockedReason?: string;
420
439
  launchBlockedMessage?: string;