runwork 0.8.4 → 0.9.1

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.
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { parse, stringify } from 'smol-toml';
@@ -10,7 +10,9 @@ function isRunworkManagedCodexKey(key) {
10
10
  }
11
11
  import { writeHintToFile, writeTeamInstructionsToFile, removeHintFromFile, removeTeamInstructionsFromFile } from './utils/instruction-hint.js';
12
12
  import { querySqlite } from '../utils/sqlite.js';
13
- import { whichBinary } from '../utils/which.js';
13
+ import { whichBinary, isAppRunning } from '../utils/which.js';
14
+ import { runAgentDetection } from './detection.js';
15
+ import { getRegistryAgent } from './registry.js';
14
16
  export class CodexAdapter {
15
17
  name = 'Codex';
16
18
  slug = 'codex';
@@ -107,6 +109,18 @@ export class CodexAdapter {
107
109
  };
108
110
  parsed.approval_policy = modeMap[config.permissionRules.defaultMode] ?? config.permissionRules.defaultMode;
109
111
  }
112
+ // Apply minimum permission floors: only upgrade, never downgrade
113
+ if (config.minimumPermissions) {
114
+ for (const { field, order, minimum } of config.minimumPermissions) {
115
+ const current = typeof parsed[field] === 'string' ? parsed[field] : '';
116
+ const currentIdx = order.indexOf(current);
117
+ const minimumIdx = order.indexOf(minimum);
118
+ // Upgrade if current is unknown (not in order) or more restrictive than minimum
119
+ if (currentIdx < minimumIdx) {
120
+ parsed[field] = minimum;
121
+ }
122
+ }
123
+ }
110
124
  mkdirSync(join(configPath, '..'), { recursive: true });
111
125
  writeFileSync(configPath, stringify(parsed));
112
126
  }
@@ -225,4 +239,201 @@ export class CodexAdapter {
225
239
  catch { /* best-effort */ }
226
240
  return null;
227
241
  }
242
+ async readSkillUsage(lastSyncAt) {
243
+ // Codex rollout JSONL files contain a session_meta entry whose
244
+ // `instructions` field lists all skills available in the session.
245
+ // Skills appear as "- name: description (file: path/SKILL.md)".
246
+ // This gives us reach data (which skills were loaded), not invocation.
247
+ //
248
+ // Rollout files live at ~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl
249
+ // and are linked from the threads table via rollout_path.
250
+ try {
251
+ const sessionsDir = join(homedir(), '.codex', 'sessions');
252
+ if (!existsSync(sessionsDir))
253
+ return null;
254
+ const sinceMs = lastSyncAt ? new Date(lastSyncAt).getTime() : 0;
255
+ const skillCounts = new Map();
256
+ // Walk session directories by year/month/day
257
+ const walkDir = (dir) => {
258
+ let entries;
259
+ try {
260
+ entries = readdirSync(dir);
261
+ }
262
+ catch {
263
+ return;
264
+ }
265
+ for (const entry of entries) {
266
+ const fullPath = join(dir, entry);
267
+ if (entry.endsWith('.jsonl')) {
268
+ let fileStat;
269
+ try {
270
+ fileStat = statSync(fullPath);
271
+ }
272
+ catch {
273
+ continue;
274
+ }
275
+ if (fileStat.mtimeMs <= sinceMs)
276
+ continue;
277
+ this.parseRolloutForSkills(fullPath, sinceMs, skillCounts);
278
+ }
279
+ else {
280
+ // Recurse into year/month/day subdirectories
281
+ try {
282
+ if (statSync(fullPath).isDirectory())
283
+ walkDir(fullPath);
284
+ }
285
+ catch {
286
+ continue;
287
+ }
288
+ }
289
+ }
290
+ };
291
+ walkDir(sessionsDir);
292
+ if (skillCounts.size === 0)
293
+ return null;
294
+ const results = [];
295
+ for (const [skillName, { count, lastTs }] of skillCounts) {
296
+ results.push({
297
+ skillName,
298
+ count,
299
+ lastUsedAt: new Date(lastTs).toISOString(),
300
+ });
301
+ }
302
+ return results;
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ /** Parse a rollout JSONL file for skill data */
309
+ parseRolloutForSkills(filePath, sinceMs, skillCounts) {
310
+ let content;
311
+ try {
312
+ content = readFileSync(filePath, 'utf-8');
313
+ }
314
+ catch {
315
+ return;
316
+ }
317
+ for (const line of content.split('\n')) {
318
+ if (!line)
319
+ continue;
320
+ let entry;
321
+ try {
322
+ entry = JSON.parse(line);
323
+ }
324
+ catch {
325
+ continue;
326
+ }
327
+ if (entry.type !== 'session_meta')
328
+ continue;
329
+ const payload = entry.payload;
330
+ if (!payload)
331
+ continue;
332
+ const tsRaw = entry.timestamp;
333
+ const ts = typeof tsRaw === 'string' ? Date.parse(tsRaw) : 0;
334
+ if (ts <= sinceMs)
335
+ continue;
336
+ const instructions = payload.instructions;
337
+ if (typeof instructions !== 'string')
338
+ continue;
339
+ // Parse "- name: description (file: path/SKILL.md)" entries
340
+ // from the "### Available skills" section
341
+ const skillRegex = /^- ([^:]+):\s+.+\(file:\s+.+\/SKILL\.md\)/gm;
342
+ let match;
343
+ while ((match = skillRegex.exec(instructions)) !== null) {
344
+ const skillName = match[1].trim();
345
+ const existing = skillCounts.get(skillName);
346
+ if (existing) {
347
+ existing.count++;
348
+ if (ts > existing.lastTs)
349
+ existing.lastTs = ts;
350
+ }
351
+ else {
352
+ skillCounts.set(skillName, { count: 1, lastTs: ts });
353
+ }
354
+ }
355
+ // Only process the first session_meta per file
356
+ break;
357
+ }
358
+ }
359
+ // ── Codex Desktop app workspace registration ──────────────────────
360
+ /**
361
+ * Register a workspace directory in the Codex desktop app's project list.
362
+ * Adds the path to electron-saved-workspace-roots, project-order, and
363
+ * electron-workspace-root-labels. Skips active-workspace-roots to avoid
364
+ * force-switching the user's active project.
365
+ *
366
+ * Returns 'written' if changes were made, 'already_registered' if the
367
+ * path was already present, or 'app_running' if Codex is open and would
368
+ * overwrite our changes.
369
+ */
370
+ registerDesktopWorkspace(workspacePath, label) {
371
+ const statePath = join(homedir(), '.codex', '.codex-global-state.json');
372
+ // Read current state (or start fresh if file doesn't exist)
373
+ let state = {};
374
+ if (existsSync(statePath)) {
375
+ try {
376
+ state = JSON.parse(readFileSync(statePath, 'utf-8'));
377
+ }
378
+ catch {
379
+ return 'app_running'; // corrupt file, don't touch
380
+ }
381
+ }
382
+ // Check if already registered in all required keys
383
+ const savedRoots = (Array.isArray(state['electron-saved-workspace-roots'])
384
+ ? state['electron-saved-workspace-roots'] : []);
385
+ const projectOrder = (Array.isArray(state['project-order'])
386
+ ? state['project-order'] : []);
387
+ const labels = (state['electron-workspace-root-labels'] && typeof state['electron-workspace-root-labels'] === 'object'
388
+ ? state['electron-workspace-root-labels'] : {});
389
+ const inSaved = savedRoots.includes(workspacePath);
390
+ const inOrder = projectOrder.includes(workspacePath);
391
+ const inLabels = labels[workspacePath] === label;
392
+ if (inSaved && inOrder && inLabels) {
393
+ return 'already_registered';
394
+ }
395
+ // Codex desktop app holds this file in memory and overwrites on any state
396
+ // change. Writing while it's open is futile. Check if it's running.
397
+ if (isAppRunning('Codex')) {
398
+ return 'app_running';
399
+ }
400
+ // Merge our workspace into the state
401
+ if (!inSaved) {
402
+ savedRoots.push(workspacePath);
403
+ state['electron-saved-workspace-roots'] = savedRoots;
404
+ }
405
+ if (!inOrder) {
406
+ projectOrder.push(workspacePath);
407
+ state['project-order'] = projectOrder;
408
+ }
409
+ labels[workspacePath] = label;
410
+ state['electron-workspace-root-labels'] = labels;
411
+ mkdirSync(join(statePath, '..'), { recursive: true });
412
+ writeFileSync(statePath, JSON.stringify(state));
413
+ return 'written';
414
+ }
415
+ }
416
+ /**
417
+ * Codex Desktop (OpenAI's GUI app) reads its config from the same `~/.codex`
418
+ * directory as the Codex CLI, so every sync/cleanup behavior inherits from
419
+ * CodexAdapter unchanged. Only the identity (slug/name) and the installation
420
+ * detection differ -- the desktop app is detected by application bundle
421
+ * presence, not by a `codex` binary on PATH.
422
+ */
423
+ export class CodexDesktopAdapter extends CodexAdapter {
424
+ name = 'Codex';
425
+ slug = 'codex-app';
426
+ async detect() {
427
+ const def = getRegistryAgent('codex-app');
428
+ if (!def)
429
+ return false;
430
+ return runAgentDetection(def.detection);
431
+ }
432
+ // Usage stats come from ~/.codex/state_5.sqlite, which is shared between
433
+ // Codex CLI and Codex Desktop. Leaving the read here would double-count
434
+ // sessions/tokens whenever both adapters are detected on the same machine.
435
+ // Defer to CodexAdapter (slug 'codex') as the sole source of Codex stats.
436
+ async readUsageStats() {
437
+ return null;
438
+ }
228
439
  }
@@ -2,7 +2,7 @@ import { ClaudeCodeAdapter } from './claude-code.js';
2
2
  import { ClaudeDesktopAdapter } from './claude-desktop.js';
3
3
  import { CursorAdapter } from './cursor.js';
4
4
  import { WindsurfAdapter } from './windsurf.js';
5
- import { CodexAdapter } from './codex.js';
5
+ import { CodexAdapter, CodexDesktopAdapter } from './codex.js';
6
6
  import { ClineAdapter } from './cline.js';
7
7
  import { GeminiAdapter } from './gemini.js';
8
8
  import { GenericAgentAdapter } from './generic-adapter.js';
@@ -17,6 +17,7 @@ const CUSTOM_ADAPTERS = [
17
17
  new CursorAdapter(),
18
18
  new WindsurfAdapter(),
19
19
  new CodexAdapter(),
20
+ new CodexDesktopAdapter(),
20
21
  new ClineAdapter(),
21
22
  new GeminiAdapter(),
22
23
  ];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared agent-installation detection used by the generic adapter and by
3
+ * custom adapters that opt in to the registry's declared detection rules
4
+ * (e.g. Codex Desktop, which detects via `/Applications/Codex.app` on macOS
5
+ * and the AppX package on Windows rather than a `codex` binary on PATH).
6
+ *
7
+ * Mirrors the desktop's agent-detection.ts logic but uses Node APIs instead
8
+ * of Tauri plugins.
9
+ */
10
+ import type { AgentDetection } from './registry-data.js';
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.
16
+ */
17
+ export declare function runAgentDetection(detection: AgentDetection): boolean;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared agent-installation detection used by the generic adapter and by
3
+ * custom adapters that opt in to the registry's declared detection rules
4
+ * (e.g. Codex Desktop, which detects via `/Applications/Codex.app` on macOS
5
+ * and the AppX package on Windows rather than a `codex` binary on PATH).
6
+ *
7
+ * Mirrors the desktop's agent-detection.ts logic but uses Node APIs instead
8
+ * of Tauri plugins.
9
+ */
10
+ import { execFileSync } from 'child_process';
11
+ import { existsSync } from 'fs';
12
+ import { homedir, platform } from 'os';
13
+ import { isAbsolute, join } from 'path';
14
+ import { whichBinary } from '../utils/which.js';
15
+ import { resolvePlatformString } from './registry.js';
16
+ function isWindows() {
17
+ return platform() === 'win32';
18
+ }
19
+ function powershellQuote(value) {
20
+ return `'${value.replace(/'/g, "''")}'`;
21
+ }
22
+ function toList(value) {
23
+ return Array.isArray(value) ? value : [value];
24
+ }
25
+ function runPowerShell(script) {
26
+ try {
27
+ execFileSync('powershell', ['-NoProfile', '-Command', script], { stdio: 'pipe' });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function checkPath(target) {
35
+ const resolved = resolvePlatformString(target);
36
+ if (!resolved)
37
+ return false;
38
+ if (isAbsolute(resolved))
39
+ return existsSync(resolved);
40
+ return existsSync(join(homedir(), resolved));
41
+ }
42
+ function checkWindowsAppxPackage(target) {
43
+ if (!isWindows())
44
+ return false;
45
+ for (const pkg of toList(target)) {
46
+ 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;
51
+ }
52
+ function checkWindowsStartApp(target) {
53
+ if (!isWindows())
54
+ return false;
55
+ for (const pattern of toList(target)) {
56
+ 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;
61
+ }
62
+ /**
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.
67
+ */
68
+ export function runAgentDetection(detection) {
69
+ switch (detection.method) {
70
+ case 'binary': {
71
+ const target = resolvePlatformString(detection.target);
72
+ return !!target && !!whichBinary(target);
73
+ }
74
+ case 'path':
75
+ return checkPath(detection.target);
76
+ case 'windows-appx':
77
+ return checkWindowsAppxPackage(detection.target);
78
+ case 'windows-start-app':
79
+ 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;
86
+ default:
87
+ return false;
88
+ }
89
+ }
@@ -1,11 +1,11 @@
1
1
  import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
2
- import { isAbsolute, join } from 'path';
2
+ import { join } from 'path';
3
3
  import { homedir } from 'os';
4
- import { whichBinary } from '../utils/which.js';
5
4
  import { buildSkillMd } from './types.js';
6
5
  import { mergeJsonMcpServers, removeRunworkMcpServers } from './utils/json-config.js';
7
6
  import { writeHintToFile, writeTeamInstructionsToFile, removeHintFromFile, removeTeamInstructionsFromFile } from './utils/instruction-hint.js';
8
7
  import { resolvePlatformString, resolveToAbsolute } from './registry.js';
8
+ import { runAgentDetection } from './detection.js';
9
9
  /**
10
10
  * Generic adapter that handles any agent from the registry using its
11
11
  * skillsPaths, mcpConfigPath, and instructionFile metadata.
@@ -25,17 +25,7 @@ export class GenericAgentAdapter {
25
25
  this.slug = def.slug;
26
26
  }
27
27
  async detect() {
28
- const target = resolvePlatformString(this.def.detection.target);
29
- if (!target)
30
- return false;
31
- if (this.def.detection.method === 'binary') {
32
- return !!whichBinary(target);
33
- }
34
- // path detection
35
- if (isAbsolute(target)) {
36
- return existsSync(target);
37
- }
38
- return existsSync(join(homedir(), target));
28
+ return runAgentDetection(this.def.detection);
39
29
  }
40
30
  supportsMcpScope(scope) {
41
31
  if (!this.def.mcpConfigPath)
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Unified agent registry (types + data + browser-safe lookups).
3
+ *
4
+ * This file is the SINGLE SOURCE OF TRUTH for every agent Runwork knows about:
5
+ * CLI adapters read from here (via `./registry.js`), and the desktop app
6
+ * re-exports from here (via the `@cli-agent-registry` path alias).
7
+ *
8
+ * Keep this file browser-safe: no `os`, `path`, `fs`, or other Node-only
9
+ * imports. Node-only path resolvers live next to this file in `./registry.ts`.
10
+ */
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
+ };
34
+ export interface AgentLaunch {
35
+ app?: {
36
+ macos?: string;
37
+ windows?: string;
38
+ linux?: string;
39
+ };
40
+ cli?: string;
41
+ cliAcceptsPrompt?: boolean;
42
+ }
43
+ export interface AgentQuickStart {
44
+ launchHint?: string;
45
+ panelHint?: string;
46
+ skillCommand?: string;
47
+ examplePrompts?: string[];
48
+ }
49
+ export interface ManualSetupStep {
50
+ id: string;
51
+ label: string;
52
+ }
53
+ /**
54
+ * Optional downloadable artifact generated inline during a manual setup step
55
+ * (e.g. a plugin zip the user uploads into the agent).
56
+ */
57
+ export interface AgentManualSetupArtifact {
58
+ label: string;
59
+ filename: string;
60
+ /** CLI subcommand that generates the artifact, e.g. "build-plugin" */
61
+ command: string;
62
+ }
63
+ /** Where a manual-setup step is inserted in the onboarding journey. */
64
+ export type ManualSetupSlot = 'installation' | 'connecting' | 'try-it';
65
+ export interface AgentManualSetup {
66
+ title: string;
67
+ steps: ManualSetupStep[];
68
+ opensAgent?: boolean;
69
+ /** Where in the journey to show this step. Defaults to 'try-it'. */
70
+ showAfter?: ManualSetupSlot;
71
+ downloadArtifact?: AgentManualSetupArtifact;
72
+ }
73
+ export interface AgentDefinition {
74
+ slug: string;
75
+ name: string;
76
+ aliases?: string[];
77
+ description: string;
78
+ category: AgentCategory;
79
+ detection: AgentDetection;
80
+ /** Desktop launch hints (GUI app name, CLI command) */
81
+ 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
+ /** Whether the CLI knows how to auto-install this agent */
87
+ autoInstallable?: boolean;
88
+ /** Skill file directories relative to $HOME (global) or project root (project) */
89
+ skillsPaths?: {
90
+ global?: PlatformString;
91
+ project?: PlatformString;
92
+ };
93
+ /** MCP config file path relative to $HOME */
94
+ mcpConfigPath?: PlatformString;
95
+ /** MCP config JSON key where servers are stored (default: 'mcpServers') */
96
+ mcpConfigKey?: string;
97
+ /** First-class agent = full Runwork support (custom adapter or rich desktop onboarding) */
98
+ firstClass?: boolean;
99
+ /** Instruction file path relative to $HOME (global) or project root (project) */
100
+ instructionFile?: {
101
+ global?: PlatformString;
102
+ project?: PlatformString;
103
+ };
104
+ /** Skill file format */
105
+ skillFormat?: 'skill-md' | 'mdc' | 'windsurf-md' | 'gemini-flat';
106
+ /** Desktop onboarding hint copy */
107
+ quickStart?: AgentQuickStart;
108
+ /** Manual setup steps required after installation (desktop onboarding) */
109
+ manualSetup?: AgentManualSetup;
110
+ }
111
+ export declare function getAgent(slug: string): AgentDefinition | undefined;
112
+ export declare function getAgentByName(name: string): AgentDefinition | undefined;
113
+ export declare function getAgents(): AgentDefinition[];
114
+ export declare function getDetectableAgents(): AgentDefinition[];
115
+ export declare function getLaunchableAgents(): AgentDefinition[];
116
+ export declare function getFirstClassAgents(): AgentDefinition[];
117
+ export declare function getAgentsByCategory(category: AgentCategory): AgentDefinition[];
118
+ export declare function isCLIAgent(slug: string): boolean;
119
+ export declare function isIDEAgent(slug: string): boolean;
120
+ export declare function isDesktopAgent(slug: string): boolean;
121
+ /** Alias of {@link getAgent} kept for backward compatibility with CLI callers. */
122
+ export declare const getRegistryAgent: typeof getAgent;
123
+ /** Alias of {@link getAgents} kept for backward compatibility with CLI callers. */
124
+ export declare const getRegistryAgents: typeof getAgents;
125
+ export declare function getFirstClassSlugs(): string[];
126
+ export declare function getCustomAdapterSlugs(): Set<string>;