runwork 0.9.1 → 0.9.3

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.
@@ -13,6 +13,12 @@ vi.mock('os', () => ({
13
13
  }));
14
14
  vi.mock('child_process', () => ({
15
15
  execFileSync: vi.fn(),
16
+ // detection.ts (transitively imported via codex.ts) wraps execFile through
17
+ // util.promisify; provide a callback-shaped stub so module load succeeds.
18
+ execFile: vi.fn((..._args) => {
19
+ const cb = _args[_args.length - 1];
20
+ cb(new Error('ENOENT'));
21
+ }),
16
22
  }));
17
23
  vi.mock('../../utils/sqlite.js', () => ({
18
24
  querySqlite: vi.fn(() => ''),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ // Force a Windows environment for Windows-only detection methods. The
3
+ // `runAgentDetection` function bails out off Windows for `windows-appx` and
4
+ // `windows-start-app`, so we have to pretend.
5
+ vi.mock('os', async () => {
6
+ const actual = await vi.importActual('os');
7
+ return { ...actual, platform: vi.fn(() => 'win32') };
8
+ });
9
+ const execBehaviors = [];
10
+ let defaultBehavior = { kind: 'success' };
11
+ vi.mock('child_process', () => ({
12
+ // promisify(execFile) drives this: the last argument is always the
13
+ // (err, stdout, stderr) callback regardless of whether options are passed.
14
+ execFile: (...args) => {
15
+ const cb = args[args.length - 1];
16
+ const behavior = execBehaviors.shift() ?? defaultBehavior;
17
+ const finish = (b) => {
18
+ if (b.kind === 'success')
19
+ cb(null, '', '');
20
+ else if (b.kind === 'fail')
21
+ cb(new Error('exit 1'), '', '');
22
+ };
23
+ if (behavior.kind === 'delay') {
24
+ setTimeout(() => finish({ kind: behavior.result }), behavior.ms);
25
+ }
26
+ else {
27
+ finish(behavior);
28
+ }
29
+ },
30
+ }));
31
+ vi.mock('../../utils/which.js', () => ({
32
+ whichBinary: vi.fn((name) => (name === 'present-binary' ? `/usr/bin/${name}` : null)),
33
+ }));
34
+ import { runAgentDetection } from '../detection.js';
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ execBehaviors.length = 0;
38
+ defaultBehavior = { kind: 'success' };
39
+ });
40
+ describe('runAgentDetection', () => {
41
+ it('reports binary detection via whichBinary', async () => {
42
+ expect(await runAgentDetection({ method: 'binary', target: 'present-binary' })).toBe(true);
43
+ expect(await runAgentDetection({ method: 'binary', target: 'missing-binary' })).toBe(false);
44
+ });
45
+ it('reports false when a windows-appx probe exits non-zero', async () => {
46
+ defaultBehavior = { kind: 'fail' };
47
+ expect(await runAgentDetection({ method: 'windows-appx', target: 'Some.Package' })).toBe(false);
48
+ });
49
+ it('reports true when any windows-appx probe succeeds', async () => {
50
+ // First two probes fail, third succeeds — the function should still
51
+ // return true because we union with Promise.all + .some(Boolean).
52
+ execBehaviors.push({ kind: 'fail' }, { kind: 'fail' }, { kind: 'success' });
53
+ expect(await runAgentDetection({ method: 'windows-appx', target: ['A', 'B', 'C'] })).toBe(true);
54
+ });
55
+ it('runs windows-appx probes in parallel (wall time ≈ slowest probe, not sum)', async () => {
56
+ // Each probe takes 80ms. Six probes serialized = ~480ms; parallel = ~80ms.
57
+ // Allow slack for CI noise but enough to catch a regression to sync execution.
58
+ defaultBehavior = { kind: 'delay', ms: 80, result: 'fail' };
59
+ const start = Date.now();
60
+ const result = await runAgentDetection({
61
+ method: 'windows-appx',
62
+ target: ['A', 'B', 'C', 'D', 'E', 'F'],
63
+ });
64
+ const elapsed = Date.now() - start;
65
+ expect(result).toBe(false);
66
+ expect(elapsed).toBeLessThan(300);
67
+ });
68
+ it('any combinator returns true on first hit', async () => {
69
+ expect(await runAgentDetection({
70
+ method: 'any',
71
+ target: [
72
+ { method: 'binary', target: 'missing-binary' },
73
+ { method: 'binary', target: 'present-binary' },
74
+ ],
75
+ })).toBe(true);
76
+ });
77
+ it('any combinator returns false when every probe misses', async () => {
78
+ expect(await runAgentDetection({
79
+ method: 'any',
80
+ target: [
81
+ { method: 'binary', target: 'missing-binary' },
82
+ { method: 'binary', target: 'also-missing' },
83
+ ],
84
+ })).toBe(false);
85
+ });
86
+ });
@@ -13,6 +13,12 @@ vi.mock('os', () => ({
13
13
  }));
14
14
  vi.mock('child_process', () => ({
15
15
  execFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
16
+ // detection.ts wraps execFile via util.promisify; provide the callback
17
+ // shape so the import doesn't blow up even though no test here exercises it.
18
+ execFile: vi.fn((..._args) => {
19
+ const cb = _args[_args.length - 1];
20
+ cb(new Error('ENOENT'));
21
+ }),
16
22
  }));
17
23
  vi.mock('../../utils/sqlite.js', () => ({
18
24
  querySqlite: vi.fn(() => ''),
@@ -9,9 +9,10 @@
9
9
  */
10
10
  import type { AgentDetection } from './registry-data.js';
11
11
  /**
12
- * Synchronous detection — returns true when the agent is installed according
13
- * to its registry `detection` rules. Supports every method the desktop app
14
- * understands: `binary`, `path`, `windows-appx`, `windows-start-app`, and
15
- * nested `any` combinators. Windows-only methods return false off Windows.
12
+ * Detection — returns true when the agent is installed according to its
13
+ * registry `detection` rules. Supports `binary`, `path`, `windows-appx`,
14
+ * `windows-start-app`, and nested `any` combinators. PowerShell-backed
15
+ * Windows probes run in parallel so a multi-target `any { windows-appx }`
16
+ * pays one PowerShell startup, not N.
16
17
  */
17
- export declare function runAgentDetection(detection: AgentDetection): boolean;
18
+ export declare function runAgentDetection(detection: AgentDetection): Promise<boolean>;
@@ -7,12 +7,14 @@
7
7
  * Mirrors the desktop's agent-detection.ts logic but uses Node APIs instead
8
8
  * of Tauri plugins.
9
9
  */
10
- import { execFileSync } from 'child_process';
10
+ import { execFile } from 'child_process';
11
11
  import { existsSync } from 'fs';
12
12
  import { homedir, platform } from 'os';
13
13
  import { isAbsolute, join } from 'path';
14
+ import { promisify } from 'util';
14
15
  import { whichBinary } from '../utils/which.js';
15
16
  import { resolvePlatformString } from './registry.js';
17
+ const execFileAsync = promisify(execFile);
16
18
  function isWindows() {
17
19
  return platform() === 'win32';
18
20
  }
@@ -22,9 +24,9 @@ function powershellQuote(value) {
22
24
  function toList(value) {
23
25
  return Array.isArray(value) ? value : [value];
24
26
  }
25
- function runPowerShell(script) {
27
+ async function runPowerShell(script) {
26
28
  try {
27
- execFileSync('powershell', ['-NoProfile', '-Command', script], { stdio: 'pipe' });
29
+ await execFileAsync('powershell', ['-NoProfile', '-Command', script]);
28
30
  return true;
29
31
  }
30
32
  catch {
@@ -39,33 +41,35 @@ function checkPath(target) {
39
41
  return existsSync(resolved);
40
42
  return existsSync(join(homedir(), resolved));
41
43
  }
42
- function checkWindowsAppxPackage(target) {
44
+ async function checkWindowsAppxPackage(target) {
43
45
  if (!isWindows())
44
46
  return false;
45
- for (const pkg of toList(target)) {
47
+ // Probe all candidate package names in parallel; resolve true on first hit.
48
+ const probes = toList(target).map((pkg) => {
46
49
  const script = `$p = Get-AppxPackage -Name ${powershellQuote(pkg)} -ErrorAction SilentlyContinue; if ($null -ne $p) { exit 0 } exit 1`;
47
- if (runPowerShell(script))
48
- return true;
49
- }
50
- return false;
50
+ return runPowerShell(script);
51
+ });
52
+ const results = await Promise.all(probes);
53
+ return results.some(Boolean);
51
54
  }
52
- function checkWindowsStartApp(target) {
55
+ async function checkWindowsStartApp(target) {
53
56
  if (!isWindows())
54
57
  return false;
55
- for (const pattern of toList(target)) {
58
+ const probes = toList(target).map((pattern) => {
56
59
  const script = `$a = Get-StartApps -Name ${powershellQuote(pattern)} -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $a) { exit 0 } exit 1`;
57
- if (runPowerShell(script))
58
- return true;
59
- }
60
- return false;
60
+ return runPowerShell(script);
61
+ });
62
+ const results = await Promise.all(probes);
63
+ return results.some(Boolean);
61
64
  }
62
65
  /**
63
- * Synchronous detection — returns true when the agent is installed according
64
- * to its registry `detection` rules. Supports every method the desktop app
65
- * understands: `binary`, `path`, `windows-appx`, `windows-start-app`, and
66
- * nested `any` combinators. Windows-only methods return false off Windows.
66
+ * Detection — returns true when the agent is installed according to its
67
+ * registry `detection` rules. Supports `binary`, `path`, `windows-appx`,
68
+ * `windows-start-app`, and nested `any` combinators. PowerShell-backed
69
+ * Windows probes run in parallel so a multi-target `any { windows-appx }`
70
+ * pays one PowerShell startup, not N.
67
71
  */
68
- export function runAgentDetection(detection) {
72
+ export async function runAgentDetection(detection) {
69
73
  switch (detection.method) {
70
74
  case 'binary': {
71
75
  const target = resolvePlatformString(detection.target);
@@ -77,12 +81,10 @@ export function runAgentDetection(detection) {
77
81
  return checkWindowsAppxPackage(detection.target);
78
82
  case 'windows-start-app':
79
83
  return checkWindowsStartApp(detection.target);
80
- case 'any':
81
- for (const probe of detection.target) {
82
- if (runAgentDetection(probe))
83
- return true;
84
- }
85
- return false;
84
+ case 'any': {
85
+ const probes = await Promise.all(detection.target.map((p) => runAgentDetection(p)));
86
+ return probes.some(Boolean);
87
+ }
86
88
  default:
87
89
  return false;
88
90
  }
@@ -9,28 +9,10 @@
9
9
  * imports. Node-only path resolvers live next to this file in `./registry.ts`.
10
10
  */
11
11
  export type AgentCategory = 'cli' | 'ide' | 'desktop' | 'extension';
12
- /** Platform-specific string. Plain string = same on all platforms. */
13
- export type PlatformString = string | {
14
- default?: string;
15
- macos?: string;
16
- windows?: string;
17
- linux?: string;
18
- };
19
- /**
20
- * Detection strategy for an agent. Supports simple binary/path checks plus
21
- * richer Windows-specific methods and a nested `any` combinator used by the
22
- * desktop app to detect agents that ship as both GUI app and CLI binary.
23
- */
24
- export type AgentDetection = {
25
- method: 'binary' | 'path';
26
- target: PlatformString;
27
- } | {
28
- method: 'windows-appx' | 'windows-start-app';
29
- target: string | string[];
30
- } | {
31
- method: 'any';
32
- target: AgentDetection[];
33
- };
12
+ export type { PlatformString, Detection, InstallableTool } from '../tools/types.js';
13
+ import type { InstallableTool, Detection, PlatformString } from '../tools/types.js';
14
+ /** @deprecated use `Detection` from ../tools/types — kept as alias for back-compat. */
15
+ export type AgentDetection = Detection;
34
16
  export interface AgentLaunch {
35
17
  app?: {
36
18
  macos?: string;
@@ -70,19 +52,11 @@ export interface AgentManualSetup {
70
52
  showAfter?: ManualSetupSlot;
71
53
  downloadArtifact?: AgentManualSetupArtifact;
72
54
  }
73
- export interface AgentDefinition {
74
- slug: string;
75
- name: string;
55
+ export interface AgentDefinition extends InstallableTool {
76
56
  aliases?: string[];
77
- description: string;
78
57
  category: AgentCategory;
79
- detection: AgentDetection;
80
58
  /** Desktop launch hints (GUI app name, CLI command) */
81
59
  launch?: AgentLaunch;
82
- /** Logo identifier used by the desktop UI */
83
- logo?: string;
84
- /** Public download/install URL shown in the desktop onboarding */
85
- downloadUrl?: string;
86
60
  /** Whether the CLI knows how to auto-install this agent */
87
61
  autoInstallable?: boolean;
88
62
  /** Skill file directories relative to $HOME (global) or project root (project) */
@@ -316,7 +316,7 @@ const AGENT_REGISTRY = [
316
316
  mcpConfigKey: 'mcpServers',
317
317
  },
318
318
  // === Community agents (from skillshare targets.yaml) ===
319
- { slug: 'antigravity', name: 'Antigravity', aliases: ['Antigravity (Google)'], description: "Google's Antigravity AI agent", category: 'cli', detection: { method: 'binary', target: 'antigravity' }, logo: 'antigravity', skillsPaths: { global: '.gemini/antigravity/skills', project: '.agent/skills' } },
319
+ { slug: 'antigravity', name: 'Antigravity', aliases: ['Antigravity (Google)'], description: "Google's Antigravity AI agent", category: 'cli', detection: { method: 'binary', target: 'antigravity' }, logo: 'antigravity', launch: { cli: 'antigravity' }, skillsPaths: { global: '.gemini/antigravity/skills', project: '.agent/skills' } },
320
320
  { slug: 'amp', name: 'Amp', description: 'AI coding agent by Sourcegraph', category: 'cli', detection: { method: 'binary', target: 'amp' }, skillsPaths: { global: '.config/agents/skills', project: '.agents/skills' } },
321
321
  { slug: 'adal', name: 'AdaL', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'adal' }, skillsPaths: { global: '.adal/skills', project: '.adal/skills' } },
322
322
  { slug: 'astrbot', name: 'AstrBot', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'astrbot' }, skillsPaths: { global: '.astrbot/data/skills', project: 'data/skills' } },
@@ -346,7 +346,7 @@ const AGENT_REGISTRY = [
346
346
  { slug: 'neovate', name: 'Neovate', description: 'AI coding agent for Neovim', category: 'extension', detection: { method: 'binary', target: 'neovate' }, skillsPaths: { global: '.neovate/skills', project: '.neovate/skills' } },
347
347
  { slug: 'omp', name: 'Oh My Pi', aliases: ['oh-my-pi'], description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'omp' }, skillsPaths: { global: '.omp/agent/skills', project: '.omp/skills' } },
348
348
  { slug: 'openclaw', name: 'OpenClaw', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openclaw' }, skillsPaths: { global: '.openclaw/skills', project: 'skills' } },
349
- { slug: 'opencode', name: 'OpenCode', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'opencode' }, skillsPaths: { global: '.config/opencode/skills', project: '.opencode/skills' } },
349
+ { slug: 'opencode', name: 'OpenCode', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'opencode' }, launch: { cli: 'opencode' }, skillsPaths: { global: '.config/opencode/skills', project: '.opencode/skills' } },
350
350
  { slug: 'openhands', name: 'OpenHands', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openhands' }, skillsPaths: { global: '.openhands/skills', project: '.openhands/skills' } },
351
351
  { slug: 'pi', name: 'Pi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pi' }, skillsPaths: { global: '.pi/agent/skills', project: '.pi/skills' } },
352
352
  { slug: 'pochi', name: 'Pochi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pochi' }, skillsPaths: { global: '.pochi/skills', project: '.pochi/skills' } },
@@ -358,7 +358,7 @@ const AGENT_REGISTRY = [
358
358
  { slug: 'trae-cn', name: 'Trae CN', description: 'ByteDance AI coding IDE (China)', category: 'ide', detection: { method: 'binary', target: 'trae-cn' }, skillsPaths: { global: '.trae-cn/skills', project: '.trae/skills' } },
359
359
  { slug: 'mistral-vibe', name: 'Mistral Vibe', aliases: ['vibe'], description: "Mistral's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'vibe' }, skillsPaths: { global: '.vibe/skills', project: '.vibe/skills' } },
360
360
  { slug: 'verdent', name: 'Verdent', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'verdent' }, skillsPaths: { global: '.verdent/skills', project: '.verdent/skills' } },
361
- { slug: 'warp', name: 'Warp AI', description: 'AI-powered terminal', category: 'cli', detection: { method: 'path', target: { macos: '/Applications/Warp.app', linux: '/usr/bin/warp-terminal' } }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
361
+ { slug: 'warp', name: 'Warp Terminal', aliases: ['Warp AI'], description: 'AI-powered terminal', category: 'cli', detection: { method: 'path', target: { macos: '/Applications/Warp.app', linux: '/usr/bin/warp-terminal' } }, launch: { app: { macos: 'Warp', windows: 'Warp' } }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
362
362
  { slug: 'witsy', name: 'Witsy', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'witsy' }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
363
363
  { slug: 'xcode-claude', name: 'Xcode Claude', description: 'Claude integration for Xcode', category: 'extension', detection: { method: 'path', target: { macos: 'Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig' } }, logo: 'claude', skillsPaths: { global: 'Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/skills', project: '.claude/skills' } },
364
364
  { slug: 'xcode-codex', name: 'Xcode Codex', description: 'Codex integration for Xcode', category: 'extension', detection: { method: 'path', target: { macos: 'Library/Developer/Xcode/CodingAssistant/codex' } }, logo: 'openai', skillsPaths: { global: 'Library/Developer/Xcode/CodingAssistant/codex/skills', project: '.codex/skills' } },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('../../agents/detect.js', () => ({
3
+ detectAgents: vi.fn(),
4
+ // getAdapterBySlug is imported by sync.ts at module load; stub it so the
5
+ // import graph resolves without dragging in real adapter side-effects.
6
+ getAdapterBySlug: vi.fn(() => undefined),
7
+ }));
8
+ import { detectAgents } from '../../agents/detect.js';
9
+ import { shouldRedetect, refreshConfiguredAgents, REDETECT_TTL_MS, } from '../sync.js';
10
+ const baseOpts = {
11
+ dryRun: false,
12
+ pullOnly: false,
13
+ yes: true,
14
+ };
15
+ function makeState(overrides = {}) {
16
+ return {
17
+ workspaceId: 'ws-1',
18
+ workspaceName: 'Test',
19
+ configuredAgents: ['claude-code'],
20
+ scope: 'user',
21
+ lastSyncAt: '',
22
+ mcpServers: [],
23
+ skills: [],
24
+ skillHashes: {},
25
+ lastDetectedAt: new Date().toISOString(),
26
+ ...overrides,
27
+ };
28
+ }
29
+ function fakeAdapter(slug) {
30
+ return {
31
+ name: slug,
32
+ slug,
33
+ detect: async () => true,
34
+ writeMcpServers: async () => { },
35
+ writeSkills: async () => { },
36
+ writeInstructionHint: async () => { },
37
+ supportsSkills: () => false,
38
+ supportsMcpScope: () => false,
39
+ };
40
+ }
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ });
44
+ describe('shouldRedetect', () => {
45
+ it('returns true when --redetect is passed even on fresh state', () => {
46
+ expect(shouldRedetect(makeState(), { ...baseOpts, redetect: true })).toBe(true);
47
+ });
48
+ it('returns true when configuredAgents is empty (recovery)', () => {
49
+ expect(shouldRedetect(makeState({ configuredAgents: [] }), baseOpts)).toBe(true);
50
+ });
51
+ it('returns true when lastDetectedAt is missing (first sync)', () => {
52
+ expect(shouldRedetect(makeState({ lastDetectedAt: undefined }), baseOpts)).toBe(true);
53
+ });
54
+ it('returns true when lastDetectedAt is unparseable (corrupt state)', () => {
55
+ expect(shouldRedetect(makeState({ lastDetectedAt: 'not-a-date' }), baseOpts)).toBe(true);
56
+ });
57
+ it('returns true when lastDetectedAt is older than the TTL', () => {
58
+ const stale = new Date(Date.now() - REDETECT_TTL_MS - 1000).toISOString();
59
+ expect(shouldRedetect(makeState({ lastDetectedAt: stale }), baseOpts)).toBe(true);
60
+ });
61
+ it('returns false when lastDetectedAt is fresh (within TTL)', () => {
62
+ const fresh = new Date(Date.now() - 60_000).toISOString();
63
+ expect(shouldRedetect(makeState({ lastDetectedAt: fresh }), baseOpts)).toBe(false);
64
+ });
65
+ });
66
+ describe('refreshConfiguredAgents', () => {
67
+ it('appends newly-detected agents and returns the diff', async () => {
68
+ vi.mocked(detectAgents).mockResolvedValue([
69
+ fakeAdapter('claude-code'),
70
+ fakeAdapter('codex'),
71
+ fakeAdapter('cursor'),
72
+ ]);
73
+ const state = makeState({ configuredAgents: ['claude-code'], lastDetectedAt: undefined });
74
+ const added = await refreshConfiguredAgents(state);
75
+ expect(added.sort()).toEqual(['codex', 'cursor']);
76
+ expect(state.configuredAgents.sort()).toEqual(['claude-code', 'codex', 'cursor']);
77
+ expect(state.lastDetectedAt).toBeTruthy();
78
+ });
79
+ it('returns empty diff and stamps lastDetectedAt when nothing new is found', async () => {
80
+ vi.mocked(detectAgents).mockResolvedValue([fakeAdapter('claude-code')]);
81
+ const state = makeState({ configuredAgents: ['claude-code'], lastDetectedAt: undefined });
82
+ const added = await refreshConfiguredAgents(state);
83
+ expect(added).toEqual([]);
84
+ expect(state.configuredAgents).toEqual(['claude-code']);
85
+ expect(state.lastDetectedAt).toBeTruthy();
86
+ });
87
+ it('never removes agents that are no longer detected', async () => {
88
+ // A transient PATH glitch or temporarily-unmounted disk shouldn't wipe
89
+ // an agent the user explicitly configured. Removal is opt-in via setup.
90
+ vi.mocked(detectAgents).mockResolvedValue([fakeAdapter('claude-code')]);
91
+ const state = makeState({ configuredAgents: ['claude-code', 'codex', 'cursor'] });
92
+ const added = await refreshConfiguredAgents(state);
93
+ expect(added).toEqual([]);
94
+ expect(state.configuredAgents.sort()).toEqual(['claude-code', 'codex', 'cursor']);
95
+ });
96
+ });
@@ -35,6 +35,65 @@ function readConfig() {
35
35
  }
36
36
  return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
37
37
  }
38
+ // Files the CLI cannot function without and which the user did not author.
39
+ // `.runwork.json` is the local app identity; `blueprint.json` is the app's
40
+ // canonical feature definition; `.gitignore` keeps caches out of git. If a
41
+ // sync from the remote silently removes any of these (which has happened
42
+ // when a server-side agent commits with a stale index — see
43
+ // worker/agents/git/git.ts), restoring from a pre-sync snapshot keeps the
44
+ // project usable and unblocks `runwork deploy`.
45
+ const CRITICAL_FILES = ['.runwork.json', 'blueprint.json', '.gitignore'];
46
+ function snapshotCriticalFiles(cwd) {
47
+ const snapshots = [];
48
+ for (const rel of CRITICAL_FILES) {
49
+ const abs = join(cwd, rel);
50
+ if (!existsSync(abs))
51
+ continue;
52
+ try {
53
+ snapshots.push({ path: rel, contents: readFileSync(abs, 'utf-8') });
54
+ }
55
+ catch {
56
+ // Best-effort: skip unreadable files.
57
+ }
58
+ }
59
+ return snapshots;
60
+ }
61
+ function restoreMissingCriticalFiles(cwd, snapshots) {
62
+ const restored = [];
63
+ for (const snap of snapshots) {
64
+ const abs = join(cwd, snap.path);
65
+ if (existsSync(abs))
66
+ continue;
67
+ try {
68
+ writeFileSync(abs, snap.contents, 'utf-8');
69
+ restored.push(snap.path);
70
+ }
71
+ catch {
72
+ // Best-effort: skip files we cannot write back.
73
+ }
74
+ }
75
+ return restored;
76
+ }
77
+ function commitAndPushRestoredFiles(cwd, files) {
78
+ if (files.length === 0)
79
+ return false;
80
+ try {
81
+ execFileSync('git', ['add', '--', ...files], { cwd, stdio: 'pipe' });
82
+ execFileSync('git', ['commit', '-m', 'chore: restore critical files removed by sync'], { cwd, stdio: 'pipe' });
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ try {
88
+ execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
89
+ return true;
90
+ }
91
+ catch {
92
+ // Push failures are non-fatal: the local copy is restored, and the
93
+ // restoration commit will be pushed with the next auto-sync cycle.
94
+ return false;
95
+ }
96
+ }
38
97
  export async function execDev(options) {
39
98
  const useJson = options?.json ?? false;
40
99
  const config = readConfig();
@@ -108,7 +167,26 @@ export async function execDev(options) {
108
167
  // Sync
109
168
  if (!useJson)
110
169
  console.log(dim('Syncing...'));
170
+ const criticalSnapshot = snapshotCriticalFiles(cwd);
111
171
  const syncResult = syncWithRemote(cwd);
172
+ const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
173
+ if (restoredCriticalFiles.length > 0) {
174
+ const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
175
+ if (useJson) {
176
+ jsonLine({
177
+ event: 'sync_restored_critical_files',
178
+ files: restoredCriticalFiles,
179
+ pushed,
180
+ timestamp: ts(),
181
+ });
182
+ }
183
+ else {
184
+ console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
185
+ if (!pushed) {
186
+ console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
187
+ }
188
+ }
189
+ }
112
190
  if (useJson) {
113
191
  jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
114
192
  if (syncResult.status === 'sync-failed') {
@@ -110,7 +110,9 @@ export const setupCommand = new Command('setup')
110
110
  const chosen = await promptSelect('Configure for:', scopeChoices);
111
111
  scope = chosen.value;
112
112
  }
113
- // 5. Save setup state (minimal, sync populates the rest)
113
+ // 5. Save setup state (minimal, sync populates the rest).
114
+ // `lastDetectedAt` is stamped now so the immediate post-setup sync
115
+ // doesn't waste time re-running detection that just ran.
114
116
  const state = {
115
117
  workspaceId,
116
118
  workspaceName: workspaceName || '',
@@ -121,6 +123,7 @@ export const setupCommand = new Command('setup')
121
123
  mcpServers: [],
122
124
  skills: [],
123
125
  skillHashes: {},
126
+ lastDetectedAt: new Date().toISOString(),
124
127
  };
125
128
  const scopes = scope === 'both' ? ['project', 'user'] : [scope];
126
129
  for (const s of scopes) {
@@ -6,6 +6,32 @@ export interface SyncOptions {
6
6
  prefer?: 'local' | 'remote';
7
7
  yes: boolean;
8
8
  verbose?: boolean;
9
+ /**
10
+ * Force a fresh agent-installation detection pass and union newly-found
11
+ * agents into `state.configuredAgents`. Without this, sync only re-detects
12
+ * when state is empty or `lastDetectedAt` is older than REDETECT_TTL_MS.
13
+ */
14
+ redetect?: boolean;
9
15
  }
16
+ /**
17
+ * How long a detection result stays trusted before sync re-runs detection
18
+ * on its own. Detection is cheap on macOS/Linux but pays a PowerShell
19
+ * waterfall on Windows, so we cache for a day. Users who just installed
20
+ * a new agent can shortcut the wait via `--redetect` (or the desktop's
21
+ * "Rescan" button, which forwards the flag).
22
+ */
23
+ export declare const REDETECT_TTL_MS: number;
24
+ /**
25
+ * Decide whether to re-run installation detection for this sync. We always
26
+ * re-detect on explicit user request, on first sync (no timestamp yet), when
27
+ * setup ended up with zero agents (recovery), or after the TTL has lapsed.
28
+ */
29
+ export declare function shouldRedetect(state: SetupState, opts: SyncOptions): boolean;
30
+ /**
31
+ * Detect installed agents and merge any new slugs into `state.configuredAgents`.
32
+ * Never removes agents — a transient PATH glitch shouldn't wipe the setup.
33
+ * Returns the slugs that were newly added (for logging).
34
+ */
35
+ export declare function refreshConfiguredAgents(state: SetupState): Promise<string[]>;
10
36
  export declare function syncFromState(state: SetupState, statePath: string, credentials: Credentials, opts: SyncOptions): Promise<void>;
11
37
  export declare const syncCommand: Command;