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

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.
Files changed (72) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +2 -0
  2. package/dist/cli-adapters/provider-cli-adapter.d.ts +2 -0
  3. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  4. package/dist/cli-adapters/provider-cli-shared.d.ts +2 -0
  5. package/dist/commands/router.d.ts +24 -0
  6. package/dist/config/mesh-config.d.ts +66 -1
  7. package/dist/git/git-commands.d.ts +1 -0
  8. package/dist/git/git-status.d.ts +5 -0
  9. package/dist/git/git-types.d.ts +10 -0
  10. package/dist/index.d.ts +13 -6
  11. package/dist/index.js +4619 -1143
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4582 -1128
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/installer.d.ts +1 -4
  16. package/dist/launch.d.ts +1 -1
  17. package/dist/logging/async-batch-writer.d.ts +10 -0
  18. package/dist/mesh/beads-db.d.ts +18 -0
  19. package/dist/mesh/mesh-active-work.d.ts +48 -0
  20. package/dist/mesh/mesh-events.d.ts +28 -5
  21. package/dist/mesh/mesh-fast-forward.d.ts +39 -0
  22. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  23. package/dist/mesh/mesh-ledger.d.ts +38 -1
  24. package/dist/mesh/mesh-work-queue.d.ts +27 -5
  25. package/dist/mesh/refine-config.d.ts +119 -0
  26. package/dist/providers/chat-message-normalization.d.ts +1 -0
  27. package/dist/providers/cli-provider-instance.d.ts +1 -0
  28. package/dist/repo-mesh-types.d.ts +160 -0
  29. package/dist/status/reporter.d.ts +2 -0
  30. package/package.json +3 -1
  31. package/src/boot/daemon-lifecycle.ts +4 -0
  32. package/src/cli-adapters/provider-cli-adapter.ts +91 -3
  33. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  34. package/src/cli-adapters/provider-cli-parse.ts +4 -0
  35. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  36. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  37. package/src/cli-adapters/provider-cli-shared.ts +20 -10
  38. package/src/commands/handler.ts +8 -1
  39. package/src/commands/mesh-coordinator.ts +13 -143
  40. package/src/commands/router.ts +2452 -409
  41. package/src/config/chat-history.ts +9 -7
  42. package/src/config/mesh-config.ts +244 -1
  43. package/src/daemon/dev-cli-debug.ts +10 -1
  44. package/src/detection/ide-detector.ts +26 -16
  45. package/src/git/git-commands.ts +3 -3
  46. package/src/git/git-status.ts +97 -6
  47. package/src/git/git-summary.ts +3 -0
  48. package/src/git/git-types.ts +11 -0
  49. package/src/index.ts +39 -5
  50. package/src/installer.d.ts +1 -1
  51. package/src/installer.ts +8 -6
  52. package/src/launch.d.ts +1 -1
  53. package/src/launch.ts +37 -28
  54. package/src/logging/async-batch-writer.ts +55 -0
  55. package/src/logging/logger.ts +2 -1
  56. package/src/mesh/beads-db.ts +176 -0
  57. package/src/mesh/coordinator-prompt.ts +4 -2
  58. package/src/mesh/mesh-active-work.ts +205 -0
  59. package/src/mesh/mesh-events.ts +291 -38
  60. package/src/mesh/mesh-fast-forward.ts +430 -0
  61. package/src/mesh/mesh-host-ownership.ts +73 -0
  62. package/src/mesh/mesh-ledger.ts +138 -1
  63. package/src/mesh/mesh-work-queue.ts +199 -137
  64. package/src/mesh/refine-config.ts +306 -0
  65. package/src/providers/chat-message-normalization.ts +3 -1
  66. package/src/providers/cli-provider-instance.ts +66 -1
  67. package/src/providers/ide-provider-instance.ts +17 -3
  68. package/src/providers/provider-loader.ts +10 -4
  69. package/src/providers/version-archive.ts +38 -20
  70. package/src/repo-mesh-types.ts +174 -0
  71. package/src/status/reporter.ts +15 -0
  72. package/src/system/host-memory.ts +29 -12
@@ -1387,22 +1387,23 @@ function callProviderNativeHistoryRead(
1387
1387
  agentType: string,
1388
1388
  canonicalHistory: ProviderCanonicalHistoryConfig | undefined,
1389
1389
  scripts: ProviderNativeHistoryScripts | undefined,
1390
- historySessionId: string,
1390
+ historySessionId: string | undefined,
1391
1391
  workspace?: string,
1392
1392
  ): ProviderNativeHistoryReadResult | null {
1393
1393
  const fn = getProviderNativeHistoryScript(scripts, canonicalHistory, 'readSession');
1394
1394
  if (!fn) return null;
1395
+ const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
1395
1396
  const result = fn({
1396
1397
  agentType,
1397
- sessionId: historySessionId,
1398
- historySessionId,
1398
+ sessionId: normalizedSessionId,
1399
+ historySessionId: normalizedSessionId,
1399
1400
  workspace,
1400
1401
  format: canonicalHistory?.format,
1401
1402
  watchPath: canonicalHistory?.watchPath,
1402
- args: { sessionId: historySessionId, historySessionId, workspace },
1403
+ args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, workspace },
1403
1404
  });
1404
1405
  if (!result || typeof result !== 'object') return null;
1405
- const records = normalizeProviderNativeHistoryRecords(agentType, historySessionId, (result as any).messages || (result as any).records);
1406
+ const records = normalizeProviderNativeHistoryRecords(agentType, normalizedSessionId, (result as any).messages || (result as any).records);
1406
1407
  if (records.length === 0) return null;
1407
1408
  return {
1408
1409
  records,
@@ -1419,7 +1420,8 @@ function buildNativeHistoryReadResult(
1419
1420
  workspace?: string,
1420
1421
  ): ProviderNativeHistoryReadResult | null {
1421
1422
  const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
1422
- if (!canonicalHistory || !normalizedSessionId || !isNativeSourceCanonicalHistory(canonicalHistory)) return null;
1423
+ const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';
1424
+ if (!canonicalHistory || (!normalizedSessionId && !normalizedWorkspace) || !isNativeSourceCanonicalHistory(canonicalHistory)) return null;
1423
1425
  return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace);
1424
1426
  }
1425
1427
 
@@ -1478,7 +1480,7 @@ export function readProviderChatHistory(
1478
1480
  scripts?: ProviderNativeHistoryScripts;
1479
1481
  } = {},
1480
1482
  ): { messages: HistoryMessage[]; hasMore: boolean; source: 'provider-native' | 'adhdev-mirror' | 'native-unavailable'; sourcePath?: string; sourceMtimeMs?: number } {
1481
- if (isNativeSourceCanonicalHistory(options.canonicalHistory) && options.historySessionId) {
1483
+ if (isNativeSourceCanonicalHistory(options.canonicalHistory) && (options.historySessionId || options.workspace)) {
1482
1484
  const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace);
1483
1485
  if (!nativeResult) return { messages: [], hasMore: false, source: 'native-unavailable' };
1484
1486
  return {
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { existsSync, readFileSync, writeFileSync } from 'fs';
10
10
  import { join } from 'path';
11
- import { randomUUID } from 'crypto';
11
+ import { createHash, randomBytes, randomUUID } from 'crypto';
12
12
  import { getConfigDir } from './config.js';
13
13
  import type {
14
14
  LocalMeshConfig,
@@ -18,8 +18,11 @@ import type {
18
18
  RepoMeshNodePolicy,
19
19
  RepoMeshNodeCapabilities,
20
20
  RepoMeshCoordinatorConfig,
21
+ RepoMeshHostMetadata,
22
+ RepoMeshDaemonRole,
21
23
  } from '../repo-mesh-types.js';
22
24
  import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
25
+ import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
23
26
 
24
27
  // ─── Persistence ────────────────────────────────
25
28
 
@@ -112,6 +115,7 @@ export interface CreateMeshOptions {
112
115
  defaultBranch?: string;
113
116
  policy?: Partial<RepoMeshPolicy>;
114
117
  coordinator?: RepoMeshCoordinatorConfig;
118
+ meshHost?: RepoMeshHostMetadata;
115
119
  }
116
120
 
117
121
  export function createMesh(opts: CreateMeshOptions): LocalMeshEntry {
@@ -133,6 +137,7 @@ export function createMesh(opts: CreateMeshOptions): LocalMeshEntry {
133
137
  defaultBranch: opts.defaultBranch,
134
138
  policy: mergeMeshPolicy(undefined, opts.policy),
135
139
  coordinator: opts.coordinator || {},
140
+ meshHost: opts.meshHost || createDefaultMeshHostMetadata(),
136
141
  nodes: [],
137
142
  createdAt: now,
138
143
  updatedAt: now,
@@ -148,6 +153,7 @@ export interface UpdateMeshOptions {
148
153
  defaultBranch?: string;
149
154
  policy?: Partial<RepoMeshPolicy>;
150
155
  coordinator?: RepoMeshCoordinatorConfig;
156
+ meshHost?: RepoMeshHostMetadata;
151
157
  }
152
158
 
153
159
  export function updateMesh(meshId: string, opts: UpdateMeshOptions): LocalMeshEntry | undefined {
@@ -159,6 +165,7 @@ export function updateMesh(meshId: string, opts: UpdateMeshOptions): LocalMeshEn
159
165
  if (opts.defaultBranch !== undefined) mesh.defaultBranch = opts.defaultBranch;
160
166
  if (opts.policy) mesh.policy = mergeMeshPolicy(mesh.policy, opts.policy);
161
167
  if (opts.coordinator) mesh.coordinator = opts.coordinator;
168
+ if (opts.meshHost) mesh.meshHost = opts.meshHost;
162
169
  mesh.updatedAt = new Date().toISOString();
163
170
 
164
171
  saveMeshConfig(config);
@@ -174,6 +181,240 @@ export function deleteMesh(meshId: string): boolean {
174
181
  return true;
175
182
  }
176
183
 
184
+ function normalizeManualHostAddress(hostAddress: string): string {
185
+ const normalized = hostAddress.trim().replace(/\/+$/, '');
186
+ if (!normalized) throw new Error('hostAddress required');
187
+ let parsed: URL;
188
+ try {
189
+ parsed = new URL(normalized);
190
+ } catch {
191
+ throw new Error('hostAddress must be a valid http(s) or ws(s) URL');
192
+ }
193
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) {
194
+ throw new Error('hostAddress must use http, https, ws, or wss');
195
+ }
196
+ return normalized;
197
+ }
198
+
199
+ export function tokenIdForManualPairing(token: string): string {
200
+ return `tok_${createHash('sha256').update(token).digest('hex').slice(0, 16)}`;
201
+ }
202
+
203
+ function normalizeTokenExpiry(value: unknown): string | undefined {
204
+ if (typeof value !== 'string' || !value.trim()) return undefined;
205
+ const date = new Date(value);
206
+ if (Number.isNaN(date.getTime())) throw new Error('expiresAt must be a valid ISO date');
207
+ return date.toISOString();
208
+ }
209
+
210
+ function assertPairingTokenValid(pairing: RepoMeshHostMetadata['pairing'], rawToken: string, nowIso: string): { ok: true; tokenId: string } | { ok: false; reason: string; expectedTokenId?: string; presentedTokenId?: string } {
211
+ const token = rawToken.trim();
212
+ if (!token) return { ok: false, reason: 'token required' };
213
+ const presentedTokenId = tokenIdForManualPairing(token);
214
+ const expectedTokenId = pairing?.tokenId;
215
+ if (!expectedTokenId || pairing?.status === 'not_configured' || pairing?.status === 'revoked') {
216
+ return { ok: false, reason: 'host pairing token is not configured', presentedTokenId };
217
+ }
218
+ if (pairing.expiresAt && new Date(pairing.expiresAt).getTime() <= new Date(nowIso).getTime()) {
219
+ return { ok: false, reason: 'host pairing token expired', expectedTokenId, presentedTokenId };
220
+ }
221
+ if (presentedTokenId !== expectedTokenId) {
222
+ return { ok: false, reason: 'invalid pairing token', expectedTokenId, presentedTokenId };
223
+ }
224
+ return { ok: true, tokenId: presentedTokenId };
225
+ }
226
+
227
+ export interface ConfigureMeshHostPairingOptions {
228
+ hostAddress: string;
229
+ token: string;
230
+ now?: string;
231
+ }
232
+
233
+ export function configureMeshHostPairing(
234
+ meshId: string,
235
+ opts: ConfigureMeshHostPairingOptions,
236
+ ): { mesh: LocalMeshEntry; meshHost: RepoMeshHostMetadata; hostAddress: string } | undefined {
237
+ const hostAddress = normalizeManualHostAddress(opts.hostAddress);
238
+ const token = opts.token.trim();
239
+ if (!token) throw new Error('token required');
240
+
241
+ const config = loadMeshConfig();
242
+ const mesh = config.meshes.find(m => m.id === meshId);
243
+ if (!mesh) return undefined;
244
+
245
+ const now = opts.now || new Date().toISOString();
246
+ const previous = mesh.meshHost || createDefaultMeshHostMetadata();
247
+ const meshHost: RepoMeshHostMetadata = {
248
+ ...previous,
249
+ role: 'member',
250
+ hostAddress,
251
+ pairing: {
252
+ status: 'pairing',
253
+ tokenId: tokenIdForManualPairing(token),
254
+ lastPairedAt: now,
255
+ },
256
+ };
257
+
258
+ mesh.meshHost = meshHost;
259
+ mesh.updatedAt = now;
260
+ saveMeshConfig(config);
261
+ return { mesh, meshHost, hostAddress };
262
+ }
263
+
264
+ export interface CreateMeshHostPairingTokenOptions {
265
+ token?: string;
266
+ expiresAt?: string;
267
+ now?: string;
268
+ }
269
+
270
+ export function createMeshHostPairingToken(
271
+ meshId: string,
272
+ opts: CreateMeshHostPairingTokenOptions = {},
273
+ ): { mesh: LocalMeshEntry; meshHost: RepoMeshHostMetadata; token: string; tokenId: string; expiresAt?: string } | undefined {
274
+ const config = loadMeshConfig();
275
+ const mesh = config.meshes.find(m => m.id === meshId);
276
+ if (!mesh) return undefined;
277
+ const now = opts.now || new Date().toISOString();
278
+ const token = (opts.token || `mhj_${randomBytes(24).toString('base64url')}`).trim();
279
+ if (!token) throw new Error('token required');
280
+ const tokenId = tokenIdForManualPairing(token);
281
+ const expiresAt = normalizeTokenExpiry(opts.expiresAt);
282
+ const previous = mesh.meshHost || createDefaultMeshHostMetadata();
283
+ if (previous.role === 'member') {
284
+ throw new Error('Mesh Host daemon required to create host pairing tokens; member daemons cannot mint host join tokens.');
285
+ }
286
+ const meshHost: RepoMeshHostMetadata = {
287
+ ...previous,
288
+ role: 'host',
289
+ pairing: {
290
+ status: 'pairing',
291
+ tokenId,
292
+ lastPairedAt: now,
293
+ ...(expiresAt ? { expiresAt } : {}),
294
+ },
295
+ };
296
+ mesh.meshHost = meshHost;
297
+ mesh.updatedAt = now;
298
+ saveMeshConfig(config);
299
+ return { mesh, meshHost, token, tokenId, ...(expiresAt ? { expiresAt } : {}) };
300
+ }
301
+
302
+ export interface MeshHostJoinMemberNodeInput {
303
+ id?: string;
304
+ workspace: string;
305
+ repoRoot?: string;
306
+ daemonId?: string;
307
+ machineId?: string;
308
+ userOverrides?: Partial<RepoMeshNodeCapabilities>;
309
+ policy?: RepoMeshNodePolicy;
310
+ role?: RepoMeshDaemonRole;
311
+ }
312
+
313
+ export interface ApplyMeshHostJoinOptions {
314
+ token: string;
315
+ memberNode: MeshHostJoinMemberNodeInput;
316
+ memberMeshId?: string;
317
+ now?: string;
318
+ }
319
+
320
+ export function applyMeshHostJoinRequest(
321
+ meshId: string,
322
+ opts: ApplyMeshHostJoinOptions,
323
+ ): { accepted: true; mesh: LocalMeshEntry; meshHost: RepoMeshHostMetadata; node: LocalMeshNodeEntry; tokenId: string } | { accepted: false; mesh?: LocalMeshEntry; meshHost?: RepoMeshHostMetadata; tokenId?: string; reason: string } | undefined {
324
+ const config = loadMeshConfig();
325
+ const mesh = config.meshes.find(m => m.id === meshId);
326
+ if (!mesh) return undefined;
327
+ const now = opts.now || new Date().toISOString();
328
+ const previous = mesh.meshHost || createDefaultMeshHostMetadata();
329
+ if (previous.role === 'member') {
330
+ return { accepted: false, mesh, meshHost: previous, reason: 'Mesh Host daemon required to accept join requests' };
331
+ }
332
+ const meshHost: RepoMeshHostMetadata = { ...previous, role: 'host' };
333
+ const validation = assertPairingTokenValid(meshHost.pairing, opts.token, now);
334
+ if (!validation.ok) {
335
+ mesh.meshHost = {
336
+ ...meshHost,
337
+ pairing: {
338
+ ...(meshHost.pairing || { status: 'not_configured' as const }),
339
+ status: 'rejected',
340
+ lastRejectedAt: now,
341
+ },
342
+ };
343
+ mesh.updatedAt = now;
344
+ saveMeshConfig(config);
345
+ return { accepted: false, mesh, meshHost: mesh.meshHost, tokenId: validation.presentedTokenId, reason: validation.reason };
346
+ }
347
+
348
+ const workspace = opts.memberNode.workspace.trim();
349
+ if (!workspace) throw new Error('memberNode.workspace required');
350
+ const memberId = opts.memberNode.id?.trim();
351
+ let node = mesh.nodes.find(n => (memberId && n.id === memberId) || n.workspace === workspace);
352
+ if (node) {
353
+ node.workspace = workspace;
354
+ node.repoRoot = opts.memberNode.repoRoot;
355
+ node.daemonId = opts.memberNode.daemonId;
356
+ node.machineId = opts.memberNode.machineId;
357
+ node.userOverrides = opts.memberNode.userOverrides || node.userOverrides || {};
358
+ node.policy = { ...(node.policy || {}), ...(opts.memberNode.policy || {}) };
359
+ node.role = 'member';
360
+ } else {
361
+ if (mesh.nodes.length >= 10) throw new Error('Maximum 10 nodes per mesh');
362
+ node = {
363
+ id: memberId || `node_${randomUUID().replace(/-/g, '')}`,
364
+ workspace,
365
+ repoRoot: opts.memberNode.repoRoot,
366
+ daemonId: opts.memberNode.daemonId,
367
+ machineId: opts.memberNode.machineId,
368
+ userOverrides: opts.memberNode.userOverrides || {},
369
+ policy: opts.memberNode.policy || {},
370
+ role: 'member',
371
+ };
372
+ mesh.nodes.push(node);
373
+ }
374
+ mesh.meshHost = {
375
+ ...meshHost,
376
+ pairing: {
377
+ ...(meshHost.pairing || {}),
378
+ status: 'paired',
379
+ tokenId: validation.tokenId,
380
+ joinedAt: now,
381
+ lastPairedAt: meshHost.pairing?.lastPairedAt || now,
382
+ ...(meshHost.pairing?.expiresAt ? { expiresAt: meshHost.pairing.expiresAt } : {}),
383
+ },
384
+ };
385
+ mesh.updatedAt = now;
386
+ saveMeshConfig(config);
387
+ return { accepted: true, mesh, meshHost: mesh.meshHost, node, tokenId: validation.tokenId };
388
+ }
389
+
390
+ export function markMeshHostPairingJoined(
391
+ meshId: string,
392
+ opts: { hostDaemonId?: string; hostNodeId?: string; joinedAt?: string; token?: string; tokenId?: string },
393
+ ): { mesh: LocalMeshEntry; meshHost: RepoMeshHostMetadata } | undefined {
394
+ const config = loadMeshConfig();
395
+ const mesh = config.meshes.find(m => m.id === meshId);
396
+ if (!mesh) return undefined;
397
+ const now = opts.joinedAt || new Date().toISOString();
398
+ const previous = mesh.meshHost || createDefaultMeshHostMetadata();
399
+ const tokenId = opts.tokenId || (opts.token ? tokenIdForManualPairing(opts.token) : previous.pairing?.tokenId);
400
+ mesh.meshHost = {
401
+ ...previous,
402
+ role: 'member',
403
+ ...(opts.hostDaemonId ? { hostDaemonId: opts.hostDaemonId } : {}),
404
+ ...(opts.hostNodeId ? { hostNodeId: opts.hostNodeId } : {}),
405
+ pairing: {
406
+ ...(previous.pairing || {}),
407
+ status: 'paired',
408
+ ...(tokenId ? { tokenId } : {}),
409
+ joinedAt: now,
410
+ lastPairedAt: previous.pairing?.lastPairedAt || now,
411
+ },
412
+ };
413
+ mesh.updatedAt = now;
414
+ saveMeshConfig(config);
415
+ return { mesh, meshHost: mesh.meshHost };
416
+ }
417
+
177
418
  // ─── Node Operations ────────────────────────────
178
419
 
179
420
  export interface AddNodeOptions {
@@ -186,6 +427,7 @@ export interface AddNodeOptions {
186
427
  isLocalWorktree?: boolean;
187
428
  worktreeBranch?: string;
188
429
  clonedFromNodeId?: string;
430
+ role?: RepoMeshDaemonRole;
189
431
  }
190
432
 
191
433
  export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntry | undefined {
@@ -213,6 +455,7 @@ export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntr
213
455
  isLocalWorktree: opts.isLocalWorktree,
214
456
  worktreeBranch: opts.worktreeBranch,
215
457
  clonedFromNodeId: opts.clonedFromNodeId,
458
+ role: opts.role,
216
459
  };
217
460
 
218
461
  mesh.nodes.push(node);
@@ -802,7 +802,16 @@ export async function handleCliSend(ctx: DevServerContext, req: http.IncomingMes
802
802
  }
803
803
 
804
804
  try {
805
- ctx.instanceManager!.sendEvent(target.instanceId, 'send_message', { text });
805
+ if (target.category === 'cli') {
806
+ const bundle = getCliTargetBundle(ctx, type, instanceId);
807
+ if (!bundle) {
808
+ ctx.json(res, 404, { error: `No running CLI adapter found for: ${type || instanceId}` });
809
+ return;
810
+ }
811
+ await bundle.adapter.sendMessage(text);
812
+ } else {
813
+ ctx.instanceManager!.sendEvent(target.instanceId, 'send_message', { text });
814
+ }
806
815
  ctx.json(res, 200, { sent: true, type: target.type, instanceId: target.instanceId });
807
816
  } catch (e: any) {
808
817
  ctx.json(res, 500, { error: `Send failed: ${e.message}` });
@@ -7,8 +7,10 @@
7
7
  * Migrated from @adhdev/core — this is now the single source of truth.
8
8
  */
9
9
 
10
- import { execSync } from 'child_process';
11
- import { existsSync } from 'fs';
10
+ import { exec } from 'child_process';
11
+ import { promisify } from 'util';
12
+ const execAsync = promisify(exec);
13
+ import { existsSync, statSync } from 'fs';
12
14
  import { platform, homedir } from 'os';
13
15
  import * as path from 'path';
14
16
  import type { ProviderLoader } from '../providers/provider-loader.js';
@@ -73,25 +75,33 @@ function findCliCommand(command: string): string | null {
73
75
  const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
74
76
  return existsSync(resolved) ? resolved : null;
75
77
  }
76
- try {
77
- const result = execSync(
78
- platform() === 'win32' ? `where ${trimmed}` : `which ${trimmed}`,
79
- { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
80
- ).trim();
81
- return result.split('\n')[0] || null;
82
- } catch {
83
- return null;
78
+ const isWin = platform() === 'win32';
79
+ const paths = (process.env.PATH || '').split(isWin ? ';' : ':');
80
+ const exes = isWin ? ['.exe', '.cmd', '.bat', ''] : [''];
81
+ for (const p of paths) {
82
+ if (!p) continue;
83
+ for (const ext of exes) {
84
+ const fullPath = path.join(p, trimmed + ext);
85
+ try {
86
+ if (existsSync(fullPath)) {
87
+ const stat = statSync(fullPath);
88
+ if (stat.isFile() && (isWin || (stat.mode & 0o111))) {
89
+ return fullPath;
90
+ }
91
+ }
92
+ } catch { }
93
+ }
84
94
  }
95
+ return null;
85
96
  }
86
97
 
87
- function getIdeVersion(cliCommand: string): string | null {
98
+ async function getIdeVersion(cliCommand: string): Promise<string | null> {
88
99
  try {
89
- const result = execSync(`"${cliCommand}" --version`, {
100
+ const { stdout } = await execAsync(`"${cliCommand}" --version`, {
90
101
  encoding: 'utf-8',
91
102
  timeout: 10000,
92
- stdio: ['pipe', 'pipe', 'pipe'],
93
- }).trim();
94
- return result.split('\n')[0] || null;
103
+ });
104
+ return stdout.trim().split('\n')[0] || null;
95
105
  } catch {
96
106
  return null;
97
107
  }
@@ -152,7 +162,7 @@ export async function detectIDEs(providerLoader?: ProviderLoader): Promise<IDEIn
152
162
  const installed = os === 'darwin'
153
163
  ? !!(resolvedCli || appPath)
154
164
  : !!resolvedCli;
155
- const version = resolvedCli ? getIdeVersion(resolvedCli) : null;
165
+ const version = resolvedCli ? await getIdeVersion(resolvedCli) : null;
156
166
 
157
167
  results.push({
158
168
  id: def.id,
@@ -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;