@adhdev/daemon-core 0.9.82-rc.7 → 0.9.82-rc.9

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.
@@ -213,14 +213,135 @@ export interface RepoMeshStatus {
213
213
  repoIdentity: string;
214
214
  refreshedAt: string;
215
215
  nodes: RepoMeshNodeStatus[];
216
+ queue?: RepoMeshQueueStatus;
217
+ ledger?: RepoMeshLedgerStatus;
218
+ }
219
+ export interface RepoMeshSessionStatus {
220
+ sessionId: string;
221
+ providerType?: string;
222
+ state?: string;
223
+ lifecycle?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'interrupted';
224
+ surfaceKind?: 'live_runtime' | 'recovery_snapshot' | 'inactive_record';
225
+ recoveryState?: string | null;
226
+ workspace?: string | null;
227
+ title?: string | null;
228
+ lastActivityAt?: string | null;
229
+ isCached?: boolean;
230
+ }
231
+ export type RepoMeshPeerConnectionState = 'self' | 'connected' | 'connecting' | 'disconnected' | 'failed' | 'closed' | 'unknown';
232
+ export type RepoMeshPeerConnectionTransport = 'local' | 'direct' | 'relay' | 'unknown';
233
+ export interface RepoMeshPeerConnectionStatus {
234
+ perspective: 'selected_coordinator';
235
+ source: 'mesh_peer_status' | 'not_reported';
236
+ state: RepoMeshPeerConnectionState;
237
+ transport: RepoMeshPeerConnectionTransport;
238
+ reported: boolean;
239
+ reason?: string;
240
+ lastStateChangeAt?: string;
241
+ lastConnectedAt?: string;
242
+ lastCommandAt?: string;
216
243
  }
217
244
  export interface RepoMeshNodeStatus {
218
245
  nodeId: string;
219
246
  machineLabel: string;
220
247
  workspace: string;
248
+ repoRoot?: string;
249
+ daemonId?: string;
250
+ machineId?: string;
251
+ machineStatus?: string;
252
+ isLocalWorktree?: boolean;
253
+ worktreeBranch?: string;
221
254
  health: RepoMeshNodeHealth;
222
255
  git?: GitRepoStatus;
223
256
  providers: string[];
224
257
  activeSessions: string[];
258
+ activeSessionDetails?: RepoMeshSessionStatus[];
259
+ providerPriority?: string[];
260
+ launchReady?: boolean;
261
+ lastSeenAt?: string;
262
+ updatedAt?: string;
263
+ connection?: RepoMeshPeerConnectionStatus;
225
264
  error?: string;
226
265
  }
266
+ export type RepoMeshQueueTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
267
+ export interface RepoMeshQueueTask {
268
+ id: string;
269
+ meshId: string;
270
+ message: string;
271
+ status: RepoMeshQueueTaskStatus;
272
+ targetNodeId?: string;
273
+ targetSessionId?: string;
274
+ assignedNodeId?: string;
275
+ assignedSessionId?: string;
276
+ cancelReason?: string;
277
+ cancelledAt?: string;
278
+ requeueReason?: string;
279
+ requeuedAt?: string;
280
+ requeueCount?: number;
281
+ autoLaunch?: {
282
+ status: 'skipped' | 'started' | 'failed' | 'completed';
283
+ reason?: string;
284
+ nodeId?: string;
285
+ providerType?: string;
286
+ sessionId?: string;
287
+ updatedAt: string;
288
+ };
289
+ dispatchTimestamp?: string;
290
+ createdAt: string;
291
+ updatedAt: string;
292
+ }
293
+ export interface RepoMeshQueueSummary {
294
+ total: number;
295
+ active: number;
296
+ historical: number;
297
+ pending: number;
298
+ assigned: number;
299
+ completed: number;
300
+ failed: number;
301
+ cancelled: number;
302
+ activeCounts: {
303
+ pending: number;
304
+ assigned: number;
305
+ };
306
+ historicalCounts: {
307
+ completed: number;
308
+ failed: number;
309
+ cancelled: number;
310
+ };
311
+ activeAssignments: Array<{
312
+ id: string;
313
+ nodeId?: string;
314
+ sessionId?: string;
315
+ message: string;
316
+ }>;
317
+ }
318
+ export interface RepoMeshQueueStatus {
319
+ tasks: RepoMeshQueueTask[];
320
+ summary: RepoMeshQueueSummary;
321
+ }
322
+ export interface RepoMeshLedgerEntryStatus {
323
+ id: string;
324
+ meshId: string;
325
+ timestamp: string;
326
+ kind: string;
327
+ nodeId?: string;
328
+ sessionId?: string;
329
+ providerType?: string;
330
+ payload: Record<string, unknown>;
331
+ }
332
+ export interface RepoMeshLedgerSummaryStatus {
333
+ meshId: string;
334
+ totalEntries: number;
335
+ taskDispatched: number;
336
+ taskCompleted: number;
337
+ taskFailed: number;
338
+ taskStalled: number;
339
+ sessionLaunched: number;
340
+ checkpointCreated: number;
341
+ lastActivityAt: string | null;
342
+ recentFailures: number;
343
+ }
344
+ export interface RepoMeshLedgerStatus {
345
+ entries: RepoMeshLedgerEntryStatus[];
346
+ summary: RepoMeshLedgerSummaryStatus;
347
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.7",
3
+ "version": "0.9.82-rc.9",
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",
@@ -86,6 +86,8 @@ export interface DaemonInitConfig {
86
86
 
87
87
  /** Relays a command to a remote mesh node daemon */
88
88
  dispatchMeshCommand?: (daemonId: string, command: string, args: Record<string, unknown>) => Promise<any>;
89
+ /** Returns selected-coordinator mesh peer telemetry for a target daemon when available. */
90
+ getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
89
91
  }
90
92
 
91
93
  // ─── Result ───
@@ -306,6 +308,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
306
308
  sessionHostControl: config.sessionHostControl,
307
309
  statusInstanceId: config.statusInstanceId,
308
310
  statusVersion: config.statusVersion,
311
+ getMeshPeerConnectionStatus: config.getMeshPeerConnectionStatus,
309
312
  getCdpLogFn: config.getCdpLogFn || ((ideType: string) => LOG.forComponent(`CDP:${ideType}`).asLogFn()),
310
313
  });
311
314
 
@@ -259,17 +259,93 @@ function readCachedInlineMeshActiveSessions(node: any): string[] {
259
259
  return sessionId ? [sessionId] : [];
260
260
  }
261
261
 
262
+ function readCachedInlineMeshActiveSessionDetails(node: any): Array<Record<string, unknown>> {
263
+ const cachedStatus = readObjectRecord(node?.cachedStatus);
264
+ const activeSession = readObjectRecord(cachedStatus.activeSession);
265
+ const fallbackSession = Object.keys(activeSession).length
266
+ ? activeSession
267
+ : readObjectRecord(node?.activeSession ?? node?.active_session);
268
+ const sessionId = readStringValue(
269
+ fallbackSession.id,
270
+ fallbackSession.sessionId,
271
+ fallbackSession.session_id,
272
+ node?.activeSessionId,
273
+ node?.active_session_id,
274
+ node?.sessionId,
275
+ node?.session_id,
276
+ );
277
+ if (!sessionId) return [];
278
+ return [{
279
+ sessionId,
280
+ providerType: readStringValue(
281
+ fallbackSession.providerType,
282
+ fallbackSession.provider_type,
283
+ fallbackSession.cliType,
284
+ fallbackSession.cli_type,
285
+ fallbackSession.provider,
286
+ node?.providerType,
287
+ node?.provider_type,
288
+ ),
289
+ state: readStringValue(fallbackSession.status, fallbackSession.state, fallbackSession.lifecycle),
290
+ lifecycle: readStringValue(fallbackSession.lifecycle),
291
+ title: readStringValue(fallbackSession.title, fallbackSession.displayName, fallbackSession.display_name) ?? null,
292
+ workspace: readStringValue(fallbackSession.workspace, node?.workspace) ?? null,
293
+ lastActivityAt: readStringValue(fallbackSession.lastActivityAt, fallbackSession.last_activity_at) ?? null,
294
+ recoveryState: readStringValue(fallbackSession.recoveryState, fallbackSession.recovery_state) ?? null,
295
+ isCached: true,
296
+ }];
297
+ }
298
+
299
+ function readLiveMeshSessionState(record: any): string | undefined {
300
+ return readStringValue(
301
+ record?.meta?.sessionStatus,
302
+ record?.meta?.status,
303
+ record?.meta?.providerStatus,
304
+ record?.status,
305
+ record?.state,
306
+ record?.lifecycle,
307
+ );
308
+ }
309
+
310
+ function toIsoTimestamp(value: unknown): string | null {
311
+ if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
312
+ const stringValue = readStringValue(value);
313
+ return stringValue || null;
314
+ }
315
+
316
+ function summarizeMeshSessionRecord(record: any): Record<string, unknown> {
317
+ return {
318
+ sessionId: readStringValue(record?.sessionId) || 'unknown',
319
+ providerType: readStringValue(record?.providerType),
320
+ state: readLiveMeshSessionState(record),
321
+ lifecycle: readStringValue(record?.lifecycle),
322
+ surfaceKind: getSessionHostSurfaceKind(record as any),
323
+ recoveryState: readStringValue(record?.meta?.runtimeRecoveryState) ?? null,
324
+ workspace: readStringValue(record?.workspace) ?? null,
325
+ title: readStringValue(record?.displayName, record?.workspaceLabel) ?? null,
326
+ lastActivityAt: toIsoTimestamp(record?.updatedAt ?? record?.lastActivityAt ?? record?.last_activity_at),
327
+ isCached: false,
328
+ };
329
+ }
330
+
262
331
  function applyCachedInlineMeshNodeStatus(status: Record<string, unknown>, node: any): boolean {
263
332
  const cachedStatus = readObjectRecord(node?.cachedStatus);
264
333
  const git = buildCachedInlineMeshGitStatus(node);
265
334
  const error = readStringValue(cachedStatus.error, node?.error);
266
335
  const health = readStringValue(cachedStatus.health, node?.health);
267
336
  const machineStatus = readStringValue(cachedStatus.machineStatus, node?.machineStatus);
337
+ const lastSeenAt = toIsoTimestamp(cachedStatus.lastSeenAt ?? cachedStatus.last_seen_at ?? node?.lastSeenAt ?? node?.last_seen_at);
338
+ const updatedAt = toIsoTimestamp(cachedStatus.updatedAt ?? cachedStatus.updated_at ?? node?.updatedAt ?? node?.updated_at);
268
339
  const activeSessions = readCachedInlineMeshActiveSessions(node);
269
- if (!git && !error && !health && !machineStatus && activeSessions.length === 0) return false;
340
+ const activeSessionDetails = readCachedInlineMeshActiveSessionDetails(node);
341
+ if (!git && !error && !health && !machineStatus && !lastSeenAt && !updatedAt && activeSessions.length === 0) return false;
270
342
  if (git) status.git = git;
271
343
  if (error) status.error = error;
344
+ if (machineStatus) status.machineStatus = machineStatus;
345
+ if (lastSeenAt) status.lastSeenAt = lastSeenAt;
346
+ if (updatedAt) status.updatedAt = updatedAt;
272
347
  if (activeSessions.length > 0) status.activeSessions = activeSessions;
348
+ if (activeSessionDetails.length > 0) status.activeSessionDetails = activeSessionDetails;
273
349
  if (health) {
274
350
  status.health = health;
275
351
  return true;
@@ -278,7 +354,7 @@ function applyCachedInlineMeshNodeStatus(status: Record<string, unknown>, node:
278
354
  status.health = deriveMeshNodeHealthFromGit(git);
279
355
  return true;
280
356
  }
281
- return activeSessions.length > 0 || !!machineStatus;
357
+ return activeSessions.length > 0 || !!machineStatus || !!lastSeenAt || !!updatedAt;
282
358
  }
283
359
 
284
360
  async function resolveProviderTypeFromPriority(args: {
@@ -698,6 +774,8 @@ export interface CommandRouterDeps {
698
774
  statusVersion?: string;
699
775
  /** Session host control plane */
700
776
  sessionHostControl?: SessionHostControlPlane | null;
777
+ /** Selected-coordinator mesh peer telemetry surface for target daemons, when supported by the runtime. */
778
+ getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
701
779
  }
702
780
 
703
781
  export interface CommandRouterResult {
@@ -2955,36 +3033,91 @@ export class DaemonCommandRouter {
2955
3033
  : [];
2956
3034
  const liveMeshSessions = partitionSessionHostRecords(Array.isArray(sessionHostRecords) ? sessionHostRecords : []).liveRuntimes;
2957
3035
 
3036
+ const localMachineId = loadConfig().machineId || '';
3037
+ const inlineCoordinatorNodeId = meshRecord?.inline && Array.isArray(mesh.nodes)
3038
+ ? readStringValue((mesh.nodes[0] as any)?.id, (mesh.nodes[0] as any)?.nodeId)
3039
+ : undefined;
3040
+ const refreshedAt = new Date().toISOString();
2958
3041
  const nodeStatuses = [];
2959
- for (const node of mesh.nodes || []) {
3042
+ for (const [nodeIndex, node] of (mesh.nodes || []).entries()) {
3043
+ const nodeId = String(node.id || node.nodeId || '');
3044
+ const daemonId = readStringValue(node.daemonId);
3045
+ const providerPriority = readProviderPriorityFromPolicy(node.policy);
3046
+ const isSelfNode = Boolean(
3047
+ nodeId && inlineCoordinatorNodeId && nodeId === inlineCoordinatorNodeId,
3048
+ ) || Boolean(
3049
+ daemonId && (daemonId === localMachineId || daemonId === this.deps.statusInstanceId),
3050
+ ) || Boolean(meshRecord?.inline && nodeIndex === 0);
2960
3051
  const status: Record<string, unknown> = {
2961
- nodeId: node.id || node.nodeId,
3052
+ nodeId,
2962
3053
  machineLabel: node.machineLabel || node.id || node.nodeId,
2963
3054
  workspace: node.workspace,
2964
3055
  repoRoot: node.repoRoot,
2965
3056
  isLocalWorktree: node.isLocalWorktree,
2966
3057
  worktreeBranch: node.worktreeBranch,
2967
- daemonId: node.daemonId,
3058
+ daemonId,
2968
3059
  machineId: node.machineId,
3060
+ machineStatus: node.machineStatus,
2969
3061
  health: 'unknown',
2970
3062
  providers: node.providers || [],
3063
+ providerPriority,
2971
3064
  activeSessions: [],
3065
+ activeSessionDetails: [],
3066
+ launchReady: false,
2972
3067
  };
2973
- const nodeId = String(node.id || node.nodeId || '');
2974
- const matchedLiveSessions = liveMeshSessions
2975
- .filter((record) => this.sessionMatchesMeshNode(record, node, nodeId))
2976
- .map((record: any) => typeof record?.sessionId === 'string' ? record.sessionId : '')
2977
- .filter(Boolean);
2978
- if (matchedLiveSessions.length > 0) {
2979
- status.activeSessions = matchedLiveSessions;
3068
+ if (isSelfNode) {
3069
+ status.connection = {
3070
+ perspective: 'selected_coordinator',
3071
+ source: 'mesh_peer_status',
3072
+ state: 'self',
3073
+ transport: 'local',
3074
+ reported: true,
3075
+ reason: 'Selected coordinator daemon',
3076
+ lastStateChangeAt: refreshedAt,
3077
+ };
3078
+ } else if (daemonId) {
3079
+ const connection = this.deps.getMeshPeerConnectionStatus?.(daemonId);
3080
+ status.connection = connection ?? {
3081
+ perspective: 'selected_coordinator',
3082
+ source: 'not_reported',
3083
+ state: 'unknown',
3084
+ transport: 'unknown',
3085
+ reported: false,
3086
+ reason: 'No live mesh peer telemetry reported by the selected coordinator yet.',
3087
+ };
3088
+ } else {
3089
+ status.connection = {
3090
+ perspective: 'selected_coordinator',
3091
+ source: 'not_reported',
3092
+ state: 'unknown',
3093
+ transport: 'unknown',
3094
+ reported: false,
3095
+ reason: 'Node has no daemon id, so mesh transport cannot be reported from the selected coordinator.',
3096
+ };
3097
+ }
3098
+ const matchedLiveSessionRecords = liveMeshSessions
3099
+ .filter((record) => this.sessionMatchesMeshNode(record, node, nodeId));
3100
+ if (matchedLiveSessionRecords.length > 0) {
3101
+ const sessionIds = matchedLiveSessionRecords
3102
+ .map((record: any) => typeof record?.sessionId === 'string' ? record.sessionId : '')
3103
+ .filter(Boolean);
3104
+ const providerTypes = matchedLiveSessionRecords
3105
+ .map((record: any) => readStringValue(record?.providerType))
3106
+ .filter(Boolean) as string[];
3107
+ status.activeSessions = sessionIds;
3108
+ status.activeSessionDetails = matchedLiveSessionRecords.map(summarizeMeshSessionRecord);
3109
+ if (providerTypes.length > 0) {
3110
+ status.providers = Array.from(new Set([...(Array.isArray(status.providers) ? status.providers as string[] : []), ...providerTypes]));
3111
+ }
2980
3112
  }
2981
3113
  if (node.workspace && typeof node.workspace === 'string') {
2982
3114
  if (!fs.existsSync(node.workspace as string) && applyCachedInlineMeshNodeStatus(status, node)) {
3115
+ status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
2983
3116
  nodeStatuses.push(status);
2984
3117
  continue;
2985
3118
  }
2986
3119
  try {
2987
- const gitStatus = await getGitRepoStatus(node.workspace as string, { timeoutMs: 10_000 });
3120
+ const gitStatus = await getGitRepoStatus(node.workspace as string, { timeoutMs: 10_000, refreshUpstream: true });
2988
3121
  status.git = gitStatus;
2989
3122
  if (gitStatus.isGitRepo) {
2990
3123
  status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
@@ -3000,6 +3133,7 @@ export class DaemonCommandRouter {
3000
3133
  } else {
3001
3134
  applyCachedInlineMeshNodeStatus(status, node);
3002
3135
  }
3136
+ status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
3003
3137
  nodeStatuses.push(status);
3004
3138
  }
3005
3139
 
@@ -3009,6 +3143,7 @@ export class DaemonCommandRouter {
3009
3143
  meshName: mesh.name,
3010
3144
  repoIdentity: mesh.repoIdentity,
3011
3145
  defaultBranch: mesh.defaultBranch,
3146
+ refreshedAt: new Date().toISOString(),
3012
3147
  nodes: nodeStatuses,
3013
3148
  queue: { tasks: queue, summary: queueSummary },
3014
3149
  ledger: { entries: ledgerEntries, summary: ledgerSummary },
@@ -62,7 +62,7 @@ export interface GitPushResult extends GitRepoIdentity {
62
62
  }
63
63
 
64
64
  export interface GitCommandServices {
65
- getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
65
+ getStatus?: (params: { workspace: string; refreshUpstream?: boolean }) => Promise<GitRepoStatus> | GitRepoStatus;
66
66
  getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
67
67
  getDiffFile?: (params: { workspace: string; path: string; staged?: boolean }) => Promise<GitFileDiff> | GitFileDiff;
68
68
  createSnapshot?: (params: {
@@ -171,7 +171,7 @@ const defaultSnapshotStore = createGitSnapshotStore({
171
171
 
172
172
  export function createDefaultGitCommandServices(): GitCommandServices {
173
173
  return {
174
- getStatus: ({ workspace }) => getGitRepoStatus(workspace),
174
+ getStatus: ({ workspace, refreshUpstream }) => getGitRepoStatus(workspace, { refreshUpstream }),
175
175
  getDiffSummary: ({ workspace }) => getGitDiffSummary(workspace),
176
176
  getDiffFile: ({ workspace, path: filePath }) => getGitFileDiff(workspace, filePath),
177
177
  createSnapshot: ({ workspace, reason, sessionId, turnId }) => defaultSnapshotStore.create({
@@ -290,7 +290,7 @@ export async function handleGitCommand(
290
290
  switch (command) {
291
291
  case 'git_status': {
292
292
  if (!services.getStatus) return serviceNotImplemented(command);
293
- const status = await runService(() => services.getStatus!({ workspace }));
293
+ const status = await runService(() => services.getStatus!({ workspace, refreshUpstream: optionalBoolean(args?.refreshUpstream) }));
294
294
  return 'success' in status ? status : { success: true, status };
295
295
  }
296
296
 
@@ -1,12 +1,25 @@
1
- import type { GitRepoStatus, GitSubmoduleStatus } from './git-types.js';
1
+ import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
2
  import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
3
 
4
+ type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
5
+
4
6
  export interface GitStatusOptions {
5
7
  timeoutMs?: number;
6
8
  /** When true, include submodule status in the result. Defaults to true. */
7
9
  includeSubmodules?: boolean;
8
10
  /** Optional filter to exclude specific submodule paths from status */
9
11
  submoduleIgnorePaths?: string[];
12
+ /**
13
+ * When true, refresh the tracked remote before trusting ahead/behind.
14
+ * Callers should opt into this only for convergence-critical surfaces.
15
+ */
16
+ refreshUpstream?: boolean;
17
+ }
18
+
19
+ interface GitUpstreamProbe {
20
+ upstreamStatus: GitUpstreamFreshness;
21
+ upstreamFetchedAt?: number;
22
+ upstreamFetchError?: string;
10
23
  }
11
24
 
12
25
  export async function getGitRepoStatus(
@@ -18,8 +31,16 @@ export async function getGitRepoStatus(
18
31
 
19
32
  try {
20
33
  const repo = await resolveGitRepository(workspace, options);
21
- const statusOutput = await runGit(repo, ['status', '--porcelain=v2', '--branch'], options);
22
- const parsed = parsePorcelainV2Status(statusOutput.stdout);
34
+ let parsed = await readPorcelainStatus(repo, options);
35
+ let upstreamProbe: GitUpstreamProbe = getInitialUpstreamProbe(parsed);
36
+
37
+ if (options.refreshUpstream) {
38
+ upstreamProbe = await refreshTrackedUpstream(repo, parsed, options);
39
+ if (upstreamProbe.upstreamStatus === 'fresh') {
40
+ parsed = await readPorcelainStatus(repo, options);
41
+ }
42
+ }
43
+
23
44
  const head = await readHead(repo, options);
24
45
  const stashCount = await readStashCount(repo, options);
25
46
 
@@ -36,6 +57,9 @@ export async function getGitRepoStatus(
36
57
  headCommit: head.commit,
37
58
  headMessage: head.message,
38
59
  upstream: parsed.upstream,
60
+ upstreamStatus: parsed.upstream ? upstreamProbe.upstreamStatus : 'no_upstream',
61
+ upstreamFetchedAt: upstreamProbe.upstreamFetchedAt,
62
+ upstreamFetchError: upstreamProbe.upstreamFetchError,
39
63
  ahead: parsed.ahead,
40
64
  behind: parsed.behind,
41
65
  staged: parsed.staged,
@@ -74,6 +98,72 @@ interface ParsedPorcelainStatus {
74
98
  conflictFiles: string[];
75
99
  }
76
100
 
101
+ async function readPorcelainStatus(repo: ResolvedGitRepo, options: GitStatusOptions): Promise<ParsedPorcelainStatus> {
102
+ const statusOutput = await runGit(repo, ['status', '--porcelain=v2', '--branch'], options);
103
+ return parsePorcelainV2Status(statusOutput.stdout);
104
+ }
105
+
106
+ function getInitialUpstreamProbe(parsed: ParsedPorcelainStatus): GitUpstreamProbe {
107
+ return {
108
+ upstreamStatus: parsed.upstream ? 'unchecked' : 'no_upstream',
109
+ };
110
+ }
111
+
112
+ async function refreshTrackedUpstream(
113
+ repo: ResolvedGitRepo,
114
+ parsed: ParsedPorcelainStatus,
115
+ options: GitStatusOptions,
116
+ ): Promise<GitUpstreamProbe> {
117
+ if (!parsed.upstream || !parsed.branch) {
118
+ return { upstreamStatus: 'no_upstream' };
119
+ }
120
+
121
+ const remoteName = (await readBranchRemote(repo, parsed.branch, options)) ?? inferRemoteName(parsed.upstream);
122
+ if (!remoteName) {
123
+ return {
124
+ upstreamStatus: 'stale',
125
+ upstreamFetchError: `Unable to resolve remote for upstream '${parsed.upstream}'`,
126
+ };
127
+ }
128
+
129
+ try {
130
+ await runGit(repo, ['fetch', '--quiet', '--prune', '--no-tags', remoteName], options);
131
+ return {
132
+ upstreamStatus: 'fresh',
133
+ upstreamFetchedAt: Date.now(),
134
+ };
135
+ } catch (error) {
136
+ return {
137
+ upstreamStatus: 'stale',
138
+ upstreamFetchError: formatGitError(error),
139
+ };
140
+ }
141
+ }
142
+
143
+ async function readBranchRemote(repo: ResolvedGitRepo, branch: string, options: GitStatusOptions): Promise<string | null> {
144
+ try {
145
+ const result = await runGit(repo, ['config', '--get', `branch.${branch}.remote`], options);
146
+ return result.stdout.trim() || null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function inferRemoteName(upstream: string): string | null {
153
+ const [remoteName] = upstream.split('/');
154
+ return remoteName?.trim() || null;
155
+ }
156
+
157
+ function formatGitError(error: unknown): string {
158
+ if (error instanceof GitCommandError) {
159
+ return error.stderr || error.message;
160
+ }
161
+ if (error instanceof Error) {
162
+ return error.message;
163
+ }
164
+ return String(error);
165
+ }
166
+
77
167
  export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
78
168
  const parsed: ParsedPorcelainStatus = {
79
169
  branch: null,
@@ -145,7 +235,7 @@ export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
145
235
  }
146
236
 
147
237
  async function readHead(
148
- repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
238
+ repo: ResolvedGitRepo,
149
239
  options: GitStatusOptions,
150
240
  ): Promise<{ commit: string | null; message: string | null }> {
151
241
  try {
@@ -163,7 +253,7 @@ async function readHead(
163
253
  }
164
254
 
165
255
  async function readStashCount(
166
- repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
256
+ repo: ResolvedGitRepo,
167
257
  options: GitStatusOptions,
168
258
  ): Promise<number> {
169
259
  try {
@@ -187,6 +277,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
187
277
  headCommit: null,
188
278
  headMessage: null,
189
279
  upstream: null,
280
+ upstreamStatus: 'unavailable',
190
281
  ahead: 0,
191
282
  behind: 0,
192
283
  staged: 0,
@@ -206,7 +297,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
206
297
  // ─── Submodule Status ───────────────────────────
207
298
 
208
299
  async function getSubmoduleStatuses(
209
- repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
300
+ repo: ResolvedGitRepo,
210
301
  options: GitStatusOptions,
211
302
  ): Promise<GitSubmoduleStatus[]> {
212
303
  if (!repo.repoRoot) return [];
@@ -22,6 +22,9 @@ export function createGitCompactSummary(status: GitRepoStatus, diffSummary?: Git
22
22
  isGitRepo: status.isGitRepo,
23
23
  repoRoot: status.repoRoot,
24
24
  branch: status.branch,
25
+ upstreamStatus: status.upstreamStatus,
26
+ upstreamFetchedAt: status.upstreamFetchedAt,
27
+ upstreamFetchError: status.upstreamFetchError,
25
28
  dirty:
26
29
  status.staged > 0 ||
27
30
  status.modified > 0 ||
@@ -40,11 +40,19 @@ export interface GitSubmoduleStatus {
40
40
  error?: string;
41
41
  }
42
42
 
43
+ export type GitUpstreamFreshness = 'fresh' | 'unchecked' | 'stale' | 'no_upstream' | 'unavailable';
44
+
43
45
  export interface GitRepoStatus extends GitRepoIdentity {
44
46
  branch: string | null;
45
47
  headCommit: string | null;
46
48
  headMessage: string | null;
47
49
  upstream: string | null;
50
+ /** Whether ahead/behind was verified against a freshly fetched upstream ref. */
51
+ upstreamStatus: GitUpstreamFreshness;
52
+ /** Timestamp for the fetch that refreshed upstream refs when upstreamStatus === 'fresh'. */
53
+ upstreamFetchedAt?: number;
54
+ /** Error from the last refresh attempt when upstreamStatus === 'stale'. */
55
+ upstreamFetchError?: string;
48
56
  ahead: number;
49
57
  behind: number;
50
58
  staged: number;
@@ -134,6 +142,9 @@ export interface GitCompactSummary {
134
142
  isGitRepo: boolean;
135
143
  repoRoot: string | null;
136
144
  branch: string | null;
145
+ upstreamStatus: GitUpstreamFreshness;
146
+ upstreamFetchedAt?: number;
147
+ upstreamFetchError?: string;
137
148
  dirty: boolean;
138
149
  changedFiles: number;
139
150
  ahead: number;
package/src/index.ts CHANGED
@@ -103,6 +103,14 @@ export type {
103
103
  LocalMeshNodeEntry,
104
104
  RepoMeshStatus,
105
105
  RepoMeshNodeStatus,
106
+ RepoMeshSessionStatus,
107
+ RepoMeshQueueTask,
108
+ RepoMeshQueueTaskStatus,
109
+ RepoMeshQueueSummary,
110
+ RepoMeshQueueStatus,
111
+ RepoMeshLedgerEntryStatus,
112
+ RepoMeshLedgerSummaryStatus,
113
+ RepoMeshLedgerStatus,
106
114
  } from './repo-mesh-types.js';
107
115
  export { DEFAULT_MESH_POLICY } from './repo-mesh-types.js';
108
116