@adhdev/daemon-core 0.9.82-rc.262 → 0.9.82-rc.264

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.
@@ -83,6 +83,24 @@ export declare function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): {
83
83
  terminalDirectWork: MeshActiveWorkRecord[];
84
84
  summary: MeshActiveWorkSummary;
85
85
  };
86
+ /**
87
+ * staleReason strings (produced by sessionStatusFromNodes above) that indicate the original
88
+ * node/session is GONE from the live mesh — i.e. the staleDirect record is an orphaned ledger
89
+ * artifact, not active or recoverable work. These are the only reasons safe to prune from the
90
+ * active staleDirect surface. The "no provider acknowledgement" reason is deliberately excluded:
91
+ * those entries have a still-live node/session (staleDispatchUnacknowledged) and represent
92
+ * recoverable dispatch failures, never orphans.
93
+ */
94
+ export declare const PRUNABLE_ORPHAN_STALE_REASONS: ReadonlySet<string>;
95
+ export type StaleDirectPruneClassification = 'prunable_orphan' | 'prunable_terminal' | 'preserve_unacknowledged' | 'preserve_active';
96
+ /**
97
+ * Classify a direct-work record for the staleDirect prune path. Pure function — the prune tool
98
+ * uses this so the safety rules (never touch active work or recoverable unacknowledged dispatches)
99
+ * live next to the staleReason producers and are independently testable.
100
+ */
101
+ export declare function classifyStaleDirectForPrune(record: Pick<MeshActiveWorkRecord, 'staleReason' | 'staleDispatchUnacknowledged' | 'terminal'>, opts?: {
102
+ includeTerminal?: boolean;
103
+ }): StaleDirectPruneClassification;
86
104
  export declare function buildCompactStaleDirectWorkSummary(staleDirectWork: MeshActiveWorkRecord[], opts?: {
87
105
  sampleLimit?: number;
88
106
  detailHint?: string;
@@ -10,19 +10,50 @@ export interface MeshFastForwardNodeArgs {
10
10
  submoduleIgnorePaths?: string[];
11
11
  timeoutMs?: number;
12
12
  trigger?: 'manual' | 'idle_auto' | string;
13
+ /**
14
+ * Operation mode. 'merge' (default) absorbs upstream commits into the local
15
+ * branch via git merge --ff-only (requires ahead=0, behind>0). 'push' publishes
16
+ * local commits to origin via a strict ff-only push (requires HEAD to be a
17
+ * descendant of origin/<branch>); it never force-pushes, resets, or rebases.
18
+ */
19
+ mode?: 'merge' | 'push';
20
+ /**
21
+ * When mode='push', also fast-forward push submodule (e.g. oss) HEADs to their
22
+ * origin main branch. Gated by allowAutoPublishSubmoduleMainCommits — skipped
23
+ * unless that policy is true. Each submodule must still pass the descendant gate.
24
+ * Defaults false (root push only).
25
+ */
26
+ pushSubmodules?: boolean;
27
+ /**
28
+ * Mesh policy flag mirrored from RepoMeshPolicy.allowAutoPublishSubmoduleMainCommits.
29
+ * Submodule pushes are refused unless this is true.
30
+ */
31
+ allowAutoPublishSubmoduleMainCommits?: boolean;
13
32
  }
14
33
  export interface MeshFastForwardPlannedStep {
15
- operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status';
34
+ operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status' | 'verify_push_descendant' | 'push_ff_only' | 'push_submodules_ff_only';
16
35
  description: string;
17
36
  safe: true;
18
37
  willMutateWorktree: boolean;
19
38
  }
39
+ export interface MeshFastForwardSubmodulePushResult {
40
+ path: string;
41
+ commit?: string;
42
+ remote: string;
43
+ remoteBranch: string;
44
+ pushed: boolean;
45
+ skipped: boolean;
46
+ code: string;
47
+ refspec?: string;
48
+ error?: string;
49
+ }
20
50
  export interface MeshFastForwardResult {
21
51
  success: boolean;
22
52
  code: string;
23
53
  nodeId?: string;
24
54
  meshId?: string;
25
55
  workspace: string;
56
+ mode: 'merge' | 'push';
26
57
  allowed: boolean;
27
58
  dryRun: boolean;
28
59
  willRun: boolean;
@@ -33,9 +64,18 @@ export interface MeshFastForwardResult {
33
64
  current?: GitRepoStatus;
34
65
  preStatus?: GitRepoStatus;
35
66
  postStatus?: GitRepoStatus;
67
+ /** Push target derived from the tracked upstream (mode='push'). */
68
+ pushTarget?: {
69
+ remote: string;
70
+ remoteBranch: string;
71
+ refspec: string;
72
+ };
73
+ /** Submodule ff-only push outcomes (mode='push' with pushSubmodules). */
74
+ submodulePushes?: MeshFastForwardSubmodulePushResult[];
36
75
  finalBranchConvergenceState?: Record<string, unknown>;
37
76
  operationError?: string;
38
77
  ledgerError?: string;
78
+ nextStep?: string;
39
79
  trigger?: string;
40
80
  }
41
81
  export declare function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promise<MeshFastForwardResult>;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable';
17
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned';
18
18
  export interface MeshLedgerEntry {
19
19
  id: string;
20
20
  meshId: string;
@@ -72,6 +72,12 @@ export declare class MeshRuntimeStore {
72
72
  updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
73
73
  cleanupTerminalDirectDispatches(olderThanMs: number): void;
74
74
  deleteDirectDispatches(meshId: string): void;
75
+ /**
76
+ * Delete specific direct dispatch rows by taskId for a mesh. Used by the staleDirect prune
77
+ * path to remove orphaned/terminal dispatch records whose node/session is no longer in the
78
+ * live mesh. Returns the number of rows actually deleted. No-op for an empty taskId list.
79
+ */
80
+ deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number;
75
81
  markStaleDirectDispatches(meshId: string, olderThanMs: number): void;
76
82
  setRemoteIdleSession(nodeId: string, sessionId: string, providerType: string, expiresAt: number, metadata?: any): void;
77
83
  getRemoteIdleSessions(): Array<{
@@ -243,6 +243,12 @@ export declare function getActiveDirectDispatches(meshId: string): DirectDispatc
243
243
  export declare function updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
244
244
  export declare function cleanupTerminalDirectDispatches(olderThanMs?: number): void;
245
245
  export declare function markStaleDirectDispatches(meshId: string, olderThanMs?: number): void;
246
+ /**
247
+ * Delete specific direct dispatch rows by taskId. Returns the number of rows deleted.
248
+ * Used by the staleDirect prune path to evict orphaned/terminal dispatch records from the
249
+ * active staleDirect surface while leaving the append-only mesh ledger (audit history) intact.
250
+ */
251
+ export declare function deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number;
246
252
  export type MeshToolCallRateResult = {
247
253
  rateLimitExceeded: boolean;
248
254
  callsInWindow: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.262",
3
+ "version": "0.9.82-rc.264",
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",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Daemon build stamp — single runtime source for "which commit is this running
3
+ * daemon built from?".
4
+ *
5
+ * The values are injected at BUILD time via tsup `define` (esbuild global
6
+ * replacement). Every build config that bundles daemon-core into a shippable
7
+ * daemon (daemon-core's own dist, daemon-standalone, daemon-cloud) replaces
8
+ * these `__DAEMON_BUILD_*` identifiers with the literal git commit/version that
9
+ * was current when the bundle was produced. See:
10
+ * - oss/packages/daemon-core/tsup.config.ts (standalone consumes this dist)
11
+ * - packages/daemon-cloud/tsup.config.ts (re-bundles daemon-core from source)
12
+ *
13
+ * If a bundle is produced WITHOUT the define (e.g. running src directly through
14
+ * tsx in dev, or a build env without git), the identifiers stay undefined and
15
+ * we fall back to `"unknown"` — never a ReferenceError, never a build failure.
16
+ *
17
+ * IMPORTANT (live-reflection caveat): a fresh local `daemon-core dist` rebuild +
18
+ * daemon restart is NOT enough to make a *cloud* daemon report a new commit —
19
+ * daemon-cloud ships its own re-bundle of daemon-core, so the build stamp only
20
+ * advances after the cloud daemon is rebuilt/redeployed and restarted. This is
21
+ * precisely the gap `mesh_status`'s `staleDaemonBuild` warning exists to surface.
22
+ */
23
+
24
+ // These are replaced at build time by tsup `define`. `typeof` guards keep this
25
+ // safe when the define is absent (the identifiers are simply not declared).
26
+ declare const __DAEMON_BUILD_COMMIT__: string | undefined;
27
+ declare const __DAEMON_BUILD_COMMIT_SHORT__: string | undefined;
28
+ declare const __DAEMON_BUILD_VERSION__: string | undefined;
29
+ declare const __DAEMON_BUILD_AT__: string | undefined;
30
+
31
+ export interface DaemonBuildInfo {
32
+ /** Full 40-char git commit the daemon bundle was built from, or 'unknown'. */
33
+ commit: string;
34
+ /** Short (7-char) form of the same commit, or 'unknown'. */
35
+ commitShort: string;
36
+ /** package.json version baked in at build time, or 'unknown'. */
37
+ version: string;
38
+ /** ISO build timestamp if the build config injected one. */
39
+ builtAt?: string;
40
+ }
41
+
42
+ function readInjected(value: string | undefined): string | undefined {
43
+ if (typeof value !== 'string') return undefined;
44
+ const trimmed = value.trim();
45
+ if (!trimmed || trimmed === 'unknown') return undefined;
46
+ return trimmed;
47
+ }
48
+
49
+ let cached: DaemonBuildInfo | undefined;
50
+
51
+ /**
52
+ * Resolve the build stamp baked into the running daemon bundle. Pure read of
53
+ * build-time constants — no I/O, safe to call from any runtime path. Cached.
54
+ */
55
+ export function getDaemonBuildInfo(): DaemonBuildInfo {
56
+ if (cached) return cached;
57
+
58
+ const commit =
59
+ readInjected(typeof __DAEMON_BUILD_COMMIT__ !== 'undefined' ? __DAEMON_BUILD_COMMIT__ : undefined)
60
+ ?? 'unknown';
61
+ const commitShort =
62
+ readInjected(typeof __DAEMON_BUILD_COMMIT_SHORT__ !== 'undefined' ? __DAEMON_BUILD_COMMIT_SHORT__ : undefined)
63
+ ?? (commit !== 'unknown' ? commit.slice(0, 7) : 'unknown');
64
+ const version =
65
+ readInjected(typeof __DAEMON_BUILD_VERSION__ !== 'undefined' ? __DAEMON_BUILD_VERSION__ : undefined)
66
+ // Fall back to the runtime-injected package version env used elsewhere.
67
+ ?? readInjected(typeof process !== 'undefined' ? process.env?.ADHDEV_PKG_VERSION : undefined)
68
+ ?? 'unknown';
69
+ const builtAt = readInjected(typeof __DAEMON_BUILD_AT__ !== 'undefined' ? __DAEMON_BUILD_AT__ : undefined);
70
+
71
+ cached = builtAt ? { commit, commitShort, version, builtAt } : { commit, commitShort, version };
72
+ return cached;
73
+ }
@@ -64,6 +64,7 @@ import {
64
64
  type WorktreeBootstrapState,
65
65
  } from '../mesh/worktree-bootstrap-config.js';
66
66
  import { buildMachineInfo, buildStatusSnapshot } from '../status/snapshot.js';
67
+ import { getDaemonBuildInfo } from '../build-info.js';
67
68
  import { getSessionCompletionMarker } from '../status/snapshot.js';
68
69
  import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
69
70
  import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
@@ -3521,6 +3522,8 @@ export class DaemonCommandRouter {
3521
3522
  const skippedSessionIds: string[] = [];
3522
3523
  const skippedLiveSessionIds: string[] = [];
3523
3524
  const skippedCoordinatorSessionIds: string[] = [];
3525
+ const skippedLiveSessionReasons: Array<{ sessionId: string; reason: string }> = [];
3526
+ const actedLiveDelegateSessionIds: string[] = [];
3524
3527
  const deleteUnsupportedSessionIds: string[] = [];
3525
3528
  const recordsRemainSessionIds: string[] = [];
3526
3529
  const errors: Array<{ sessionId: string; error: string }> = [];
@@ -3556,16 +3559,49 @@ export class DaemonCommandRouter {
3556
3559
  const surfaceKind = getSessionHostSurfaceKind(record);
3557
3560
  const liveRuntime = surfaceKind === 'live_runtime';
3558
3561
  const coordinatorSession = readStringValue(record?.meta?.meshCoordinatorFor) === args.meshId;
3562
+ // A delegate session was launched by the coordinator specifically FOR this node
3563
+ // (meta.meshNodeId === this node). It is 1:1 bound to the node, even when the node
3564
+ // shares its daemon runtime with the main/other nodes. Removing the node should be
3565
+ // able to stop its own delegate session — the shared-daemon concern only applies to
3566
+ // sessions we matched by workspace alone (which could belong to the coordinator or to
3567
+ // a sibling node that is still active).
3568
+ const recordNodeId = readStringValue(record?.meta?.meshNodeId);
3569
+ const recordMeshNodeFor = readStringValue(record?.meta?.meshNodeFor);
3570
+ const delegateBoundToThisNode = !!recordNodeId
3571
+ && recordNodeId === args.nodeId
3572
+ && (!recordMeshNodeFor || recordMeshNodeFor === args.meshId);
3559
3573
  if (!hasExplicitSessionIds && coordinatorSession) {
3560
3574
  skippedSessionIds.push(sessionId);
3561
3575
  skippedCoordinatorSessionIds.push(sessionId);
3562
3576
  continue;
3563
3577
  }
3564
- if (!hasExplicitSessionIds && liveRuntime) {
3578
+ // Only the conservative shared-daemon guard for live sessions that are NOT a delegate
3579
+ // explicitly bound to this node. Delegate-bound live sessions fall through and are
3580
+ // stopped/deleted by the mode handlers below (which already record an intentional stop).
3581
+ if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode) {
3565
3582
  skippedSessionIds.push(sessionId);
3566
3583
  skippedLiveSessionIds.push(sessionId);
3584
+ const matchedByWorkspaceOnly = !recordNodeId;
3585
+ const reason = recordNodeId && recordNodeId !== args.nodeId
3586
+ ? `live_delegate_bound_to_other_node:${recordNodeId}`
3587
+ : matchedByWorkspaceOnly
3588
+ ? 'live_session_matched_by_workspace_only_no_node_binding'
3589
+ : 'live_session_not_bound_to_this_node';
3590
+ skippedLiveSessionReasons.push({ sessionId, reason });
3567
3591
  continue;
3568
3592
  }
3593
+ if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode && args.mode === 'delete_stopped') {
3594
+ // delete_stopped never stops live runtimes by contract — even bound delegates.
3595
+ // Surface a clear reason instead of an unexplained skip so callers know to use
3596
+ // stop / stop_and_delete to release a still-running bound delegate.
3597
+ skippedSessionIds.push(sessionId);
3598
+ skippedLiveSessionIds.push(sessionId);
3599
+ skippedLiveSessionReasons.push({ sessionId, reason: 'live_delegate_preserved_by_delete_stopped_mode_use_stop_or_stop_and_delete' });
3600
+ continue;
3601
+ }
3602
+ if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode) {
3603
+ actedLiveDelegateSessionIds.push(sessionId);
3604
+ }
3569
3605
  try {
3570
3606
  if (args.mode === 'stop') {
3571
3607
  if (!completed) {
@@ -3631,6 +3667,8 @@ export class DaemonCommandRouter {
3631
3667
  skippedSessionIds,
3632
3668
  skippedLiveSessionIds,
3633
3669
  skippedCoordinatorSessionIds,
3670
+ ...(actedLiveDelegateSessionIds.length ? { actedLiveDelegateSessionIds } : {}),
3671
+ ...(skippedLiveSessionReasons.length ? { skippedLiveSessionReasons } : {}),
3634
3672
  ...(deleteUnsupported ? {
3635
3673
  deleteUnsupported: true,
3636
3674
  effectiveCleanup: args.mode === 'stop_and_delete'
@@ -4489,10 +4527,46 @@ export class DaemonCommandRouter {
4489
4527
  }
4490
4528
 
4491
4529
  const cleanupStarted = Date.now();
4530
+ // Honor the mesh policy for delegated-session cleanup on the auto-removed
4531
+ // worktree node (previously hardcoded to 'preserve', which orphaned the
4532
+ // delegate session as an idle record on the coordinator daemon). Fall back
4533
+ // to 'preserve' when no policy is set.
4534
+ const refineSessionCleanupMode = this.normalizeMeshSessionCleanupMode(
4535
+ mesh?.policy?.sessionCleanupOnNodeRemove,
4536
+ );
4537
+ // The delegate session launched for a clone worktree is frequently matched
4538
+ // by workspace ONLY (no meta.meshNodeId binding), which remove_mesh_node's
4539
+ // shared-daemon guard skips. Since refine knows exactly which workspace it
4540
+ // just merged, collect that workspace's live session ids explicitly and pass
4541
+ // them through — explicit sessionIds bypass the workspace-only-match guard so
4542
+ // the policy-driven stop/delete actually runs.
4543
+ let refineSessionIds: string[] | undefined;
4544
+ if (refineSessionCleanupMode !== 'preserve' && this.deps.sessionHostControl) {
4545
+ try {
4546
+ const liveSessions = await this.deps.sessionHostControl.listSessions();
4547
+ const workspace = typeof node.workspace === 'string' ? node.workspace : '';
4548
+ refineSessionIds = liveSessions
4549
+ .filter((record: any) => {
4550
+ const sid = typeof record?.sessionId === 'string' ? record.sessionId : '';
4551
+ if (!sid) return false;
4552
+ // Never sweep the coordinator's own session for this mesh.
4553
+ if (readStringValue(record?.meta?.meshCoordinatorFor) === meshId) return false;
4554
+ const boundToNode = readStringValue(record?.meta?.meshNodeId) === nodeId;
4555
+ const matchedByWorkspace = !!workspace && record?.workspace === workspace;
4556
+ return boundToNode || matchedByWorkspace;
4557
+ })
4558
+ .map((record: any) => String(record.sessionId));
4559
+ } catch {
4560
+ // listSessions failure is non-fatal — fall back to the policy-mode
4561
+ // cleanup without explicit ids (still better than hardcoded preserve).
4562
+ refineSessionIds = undefined;
4563
+ }
4564
+ }
4492
4565
  const removeResult = await this.execute('remove_mesh_node', {
4493
4566
  meshId,
4494
4567
  nodeId,
4495
- sessionCleanupMode: 'preserve',
4568
+ sessionCleanupMode: refineSessionCleanupMode,
4569
+ ...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
4496
4570
  inlineMesh: args?.inlineMesh,
4497
4571
  });
4498
4572
  recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
@@ -5783,7 +5857,11 @@ export class DaemonCommandRouter {
5783
5857
  version: this.deps.statusVersion || 'unknown',
5784
5858
  profile: 'metadata',
5785
5859
  });
5786
- return { success: true, status: snapshot };
5860
+ // Surface the daemon's build stamp so coordinators (mesh_status)
5861
+ // can detect a running daemon that predates a just-merged fix and
5862
+ // is awaiting deploy/restart. Sibling of `status` to avoid
5863
+ // perturbing the dashboard status snapshot shape.
5864
+ return { success: true, status: snapshot, daemonBuild: getDaemonBuildInfo() };
5787
5865
  }
5788
5866
 
5789
5867
  case 'get_machine_runtime_stats': {
@@ -6886,6 +6964,7 @@ export class DaemonCommandRouter {
6886
6964
  ? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
6887
6965
  : undefined;
6888
6966
  let nodeDaemonId: string | undefined;
6967
+ let allowAutoPublishSubmoduleMainCommits = false;
6889
6968
  if (meshId && nodeId) {
6890
6969
  // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
6891
6970
  const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
@@ -6897,6 +6976,7 @@ export class DaemonCommandRouter {
6897
6976
  if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
6898
6977
  submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
6899
6978
  }
6979
+ allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
6900
6980
  nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
6901
6981
  }
6902
6982
  // If the target node belongs to a remote daemon, forward the command there.
@@ -6921,6 +7001,9 @@ export class DaemonCommandRouter {
6921
7001
  dryRun: args?.dryRun === true,
6922
7002
  updateSubmodules: args?.updateSubmodules === true,
6923
7003
  submoduleIgnorePaths,
7004
+ mode: args?.mode === 'push' ? 'push' : 'merge',
7005
+ pushSubmodules: args?.pushSubmodules === true,
7006
+ allowAutoPublishSubmoduleMainCommits,
6924
7007
  }) as Promise<unknown>);
6925
7008
  return result as CommandRouterResult;
6926
7009
  }
@@ -6929,6 +7012,29 @@ export class DaemonCommandRouter {
6929
7012
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
6930
7013
  const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
6931
7014
  if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
7015
+ // Dry-run (plan-only) is the default and stays synchronous: it does no
7016
+ // validation/merge/push and returns the plan instantly. Only execute=true
7017
+ // (and not dry_run) goes through the async refine job that actually
7018
+ // validates → merges → pushes → cleans up. Mirrors the
7019
+ // batch_refine_mesh_nodes / fast_forward_mesh_node dry_run/execute contract.
7020
+ const isDryRun = args?.dryRun !== false && args?.execute !== true;
7021
+ if (isDryRun) {
7022
+ // preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
7023
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
7024
+ const mesh = meshRecord?.mesh;
7025
+ const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
7026
+ if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
7027
+ return {
7028
+ success: true,
7029
+ dryRun: true,
7030
+ nodeId,
7031
+ workspace: node.workspace,
7032
+ validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
7033
+ mergeWillRun: false,
7034
+ cleanupWillRun: false,
7035
+ hint: 'Dry-run only — no merge/push/cleanup performed. Re-invoke with execute:true to converge this node.',
7036
+ };
7037
+ }
6932
7038
  return this.startMeshRefineJob(meshId, nodeId, args);
6933
7039
  }
6934
7040
 
@@ -6961,9 +7067,22 @@ export class DaemonCommandRouter {
6961
7067
  const sessionCleanupMode = this.normalizeMeshSessionCleanupMode(
6962
7068
  args?.sessionCleanupMode ?? args?.session_cleanup_mode ?? mesh?.policy?.sessionCleanupOnNodeRemove,
6963
7069
  );
7070
+ // Explicit sessionIds (e.g. supplied by refine auto-cleanup) bypass the
7071
+ // workspace-only-match guard so a delegate session that lacks a
7072
+ // meta.meshNodeId binding can still be stopped/deleted.
7073
+ const explicitSessionIds = Array.isArray(args?.sessionIds)
7074
+ ? (args.sessionIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
7075
+ : undefined;
6964
7076
  let sessionCleanup: Record<string, unknown> | undefined;
6965
7077
  if (node && sessionCleanupMode !== 'preserve') {
6966
- sessionCleanup = await this.cleanupMeshSessions({ meshId, nodeId, node, mode: sessionCleanupMode, source: 'mesh_remove_node' });
7078
+ sessionCleanup = await this.cleanupMeshSessions({
7079
+ meshId,
7080
+ nodeId,
7081
+ node,
7082
+ mode: sessionCleanupMode,
7083
+ ...(explicitSessionIds && explicitSessionIds.length > 0 ? { sessionIds: explicitSessionIds } : {}),
7084
+ source: 'mesh_remove_node',
7085
+ });
6967
7086
  if (sessionCleanup.success === false) return { success: false, removed: false, sessionCleanup };
6968
7087
  }
6969
7088
 
@@ -1,5 +1,6 @@
1
- import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
1
+ import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
2
  import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
+ import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
3
4
 
4
5
  type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
5
6
 
@@ -14,6 +15,12 @@ export interface GitStatusOptions {
14
15
  * Callers should opt into this only for convergence-critical surfaces.
15
16
  */
16
17
  refreshUpstream?: boolean;
18
+ /**
19
+ * Test/override seam for the daemon build stamp used by the stale-build
20
+ * detector. Production callers omit this so the real baked-in build commit
21
+ * (getDaemonBuildInfo) is used.
22
+ */
23
+ daemonBuildInfo?: DaemonBuildInfo;
17
24
  }
18
25
 
19
26
  interface GitUpstreamProbe {
@@ -54,6 +61,8 @@ export async function getGitRepoStatus(
54
61
  || stashCount > 0
55
62
  || submoduleDirty;
56
63
 
64
+ const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options);
65
+
57
66
  return {
58
67
  workspace: repo.workspace,
59
68
  repoRoot: repo.repoRoot,
@@ -78,6 +87,7 @@ export async function getGitRepoStatus(
78
87
  stashCount,
79
88
  lastCheckedAt,
80
89
  submodules,
90
+ ...(daemonBuildBehind ? { daemonBuildBehind } : {}),
81
91
  };
82
92
  } catch (error) {
83
93
  if (error instanceof GitCommandError) {
@@ -91,6 +101,158 @@ export async function getGitRepoStatus(
91
101
  }
92
102
  }
93
103
 
104
+ /**
105
+ * Detect whether the running daemon's build commit is a STRICT ancestor of this
106
+ * workspace's HEAD (root) or any of its submodules' HEAD. This surfaces the
107
+ * "merged a fix to main but the live daemon still ships the old bundle" gap:
108
+ * once the fix is committed, the workspace HEAD advances past the daemon's
109
+ * baked-in build commit, but the daemon keeps the old behavior until it is
110
+ * rebuilt/redeployed and restarted.
111
+ *
112
+ * Conservative by construction — returns undefined unless ancestry is provable:
113
+ * - build commit unknown → undefined
114
+ * - build commit not an object in this repo/submodule (different repo) → skip
115
+ * - build commit === HEAD (daemon is current) → undefined
116
+ * - build commit NOT an ancestor of HEAD (daemon ahead / diverged) → undefined
117
+ * Any git error is swallowed (no warning) so a flaky probe never over-warns.
118
+ */
119
+ /**
120
+ * Package names that, when changed, mean the daemon runtime is stale and must be
121
+ * rebuilt/redeployed + restarted. Everything NOT in this set (web-core,
122
+ * web-standalone, web-devconsole, terminal-render-web) is web-only — a daemon
123
+ * restart is not required for those, only a web redeploy. mcp-server runs in the
124
+ * same process surface as the daemon tooling, so it is classified as
125
+ * daemon-affecting (conservative). Unknown package → daemon-affecting.
126
+ */
127
+ const DAEMON_RUNTIME_PACKAGES = new Set([
128
+ 'daemon-core',
129
+ 'daemon-standalone',
130
+ 'session-host-core',
131
+ 'session-host-daemon',
132
+ 'terminal-mux-core',
133
+ 'terminal-mux-control',
134
+ 'terminal-mux-cli',
135
+ 'ghostty-vt-node',
136
+ 'mcp-server',
137
+ ]);
138
+
139
+ const WEB_ONLY_PACKAGES = new Set([
140
+ 'web-core',
141
+ 'web-standalone',
142
+ 'web-devconsole',
143
+ 'terminal-render-web',
144
+ ]);
145
+
146
+ /**
147
+ * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
148
+ * package. Returns isDaemonAffecting:true conservatively when the changed-file set
149
+ * can't be obtained or any changed path is outside the known web-only package set
150
+ * (including root-level / non-package files).
151
+ */
152
+ async function classifyDaemonBuildChange(
153
+ repoPath: string,
154
+ buildCommit: string,
155
+ options: GitStatusOptions,
156
+ ): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
157
+ try {
158
+ const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
159
+ const files = diff.stdout
160
+ .split('\n')
161
+ .map((line) => line.trim())
162
+ .filter(Boolean);
163
+ if (files.length === 0) {
164
+ // No file diff (e.g. only merge metadata) — nothing actionable, but stay
165
+ // conservative and treat as daemon-affecting so we don't suppress a real warning.
166
+ return { isDaemonAffecting: true, affectedPackages: [] };
167
+ }
168
+ const pkgs = new Set<string>();
169
+ let sawNonPackageOrUnknown = false;
170
+ for (const file of files) {
171
+ const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
172
+ if (!match) {
173
+ sawNonPackageOrUnknown = true;
174
+ continue;
175
+ }
176
+ pkgs.add(match[1]);
177
+ }
178
+ const affectedPackages = [...pkgs].sort();
179
+ // Daemon-affecting if: any non-package/root file changed, any unknown package
180
+ // changed, or any explicit daemon-runtime package changed. Only when EVERY
181
+ // changed file maps to a known web-only package is the daemon unaffected.
182
+ const allWebOnly =
183
+ !sawNonPackageOrUnknown &&
184
+ affectedPackages.length > 0 &&
185
+ affectedPackages.every((p) => WEB_ONLY_PACKAGES.has(p) && !DAEMON_RUNTIME_PACKAGES.has(p));
186
+ return { isDaemonAffecting: !allWebOnly, affectedPackages };
187
+ } catch {
188
+ // diff probe failed → can't prove web-only; stay conservative.
189
+ return { isDaemonAffecting: true, affectedPackages: [] };
190
+ }
191
+ }
192
+
193
+ async function detectDaemonBuildBehind(
194
+ repo: ResolvedGitRepo,
195
+ submodules: GitSubmoduleStatus[] | undefined,
196
+ options: GitStatusOptions,
197
+ ): Promise<DaemonBuildBehind | undefined> {
198
+ const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
199
+ if (!build.commit || build.commit === 'unknown') return undefined;
200
+
201
+ // Check the root repo first, then each submodule. The daemon build commit is
202
+ // baked from the daemon-core (oss submodule) HEAD, so on an adhdev
203
+ // superproject worktree the match is expected on the `oss` submodule, not the
204
+ // root — checking both keeps the helper repo-agnostic.
205
+ const scopes: Array<{ scope: string; repoPath: string }> = [
206
+ { scope: 'root', repoPath: repo.repoRoot || repo.workspace },
207
+ ];
208
+ for (const sub of submodules || []) {
209
+ if (sub.repoPath && !sub.error) scopes.push({ scope: sub.path, repoPath: sub.repoPath });
210
+ }
211
+
212
+ for (const { scope, repoPath } of scopes) {
213
+ try {
214
+ // Build commit must be a real object in THIS repo, else it's a different repo.
215
+ await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
216
+ const headResult = await runGit(repoPath, ['rev-parse', 'HEAD'], options);
217
+ const head = headResult.stdout.trim();
218
+ if (!head || head === build.commit) continue;
219
+ // Strict ancestor: build commit is reachable from HEAD but is not HEAD.
220
+ await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
221
+ // No throw → build commit IS an ancestor of HEAD → daemon is behind.
222
+ // Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
223
+ // is only actually required when a daemon-runtime package changed; if only web /
224
+ // render packages changed, the daemon is unaffected and just the web deploy is
225
+ // pending. Conservative: any probe failure → treat as daemon-affecting.
226
+ const { isDaemonAffecting, affectedPackages } = await classifyDaemonBuildChange(
227
+ repoPath,
228
+ build.commit,
229
+ options,
230
+ );
231
+ const scopeLabel = scope === 'root' ? 'workspace' : scope;
232
+ const warning = isDaemonAffecting
233
+ ? `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}. ` +
234
+ `Merged code is NOT live until the daemon is rebuilt/redeployed and restarted — a local dist rebuild alone does not update a cloud daemon.`
235
+ : `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}, ` +
236
+ `but only web packages changed (${(affectedPackages || []).join(', ') || 'web'}). ` +
237
+ `Daemon restart NOT required — redeploy the web app to reflect the change.`;
238
+ return {
239
+ buildCommit: build.commit,
240
+ buildCommitShort: build.commitShort,
241
+ head,
242
+ scope,
243
+ isDaemonAffecting,
244
+ ...(affectedPackages && affectedPackages.length > 0 ? { affectedPackages } : {}),
245
+ warning,
246
+ };
247
+ } catch {
248
+ // cat-file / merge-base non-zero exit (commit absent or not an ancestor)
249
+ // or any git error → not a provable staleness for this scope; try next.
250
+ continue;
251
+ }
252
+ }
253
+ return undefined;
254
+ }
255
+
94
256
  interface ParsedPorcelainStatus {
95
257
  branch: string | null;
96
258
  upstream: string | null;