@phnx-labs/agents-cli 1.20.59 → 1.20.61

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.
@@ -306,14 +306,21 @@ export function buildPermissionsFromGroups(groupNames) {
306
306
  // Matches lines like: - "Bash(git *)" or - "WebFetch(domain:example.com)"
307
307
  // Handles nested quotes that break YAML parsers
308
308
  const lines = content.split('\n');
309
+ let section = null;
309
310
  for (const line of lines) {
311
+ const sectionMatch = line.match(/^\s*(allow|deny)\s*:\s*(?:#.*)?$/);
312
+ if (sectionMatch) {
313
+ section = sectionMatch[1];
314
+ continue;
315
+ }
310
316
  // Match: optional whitespace, dash, whitespace, quote, content, quote
311
317
  // Use greedy match to capture everything between first and last quote
312
318
  const match = line.match(/^\s*-\s*"(.+)"$/);
313
319
  if (match) {
314
320
  const rule = match[1];
315
- // 99-deny group rules go to deny, others to allow
316
- if (groupName === '99-deny' || groupName.includes('-deny')) {
321
+ // 99-deny group rules go to deny, others follow their YAML section.
322
+ // Legacy group files used bare lists with no section; keep those as allow.
323
+ if (section === 'deny' || groupName === '99-deny' || groupName.includes('-deny')) {
317
324
  allDeny.push(rule);
318
325
  }
319
326
  else {
@@ -486,32 +493,40 @@ function parseCanonicalPattern(permission) {
486
493
  const BLANKET_BASH_FORMS = new Set(['Bash', 'Bash(*)', 'Bash(**)']);
487
494
  /**
488
495
  * Convert canonical permission set to Gemini format.
489
- * Gemini reads tool allow-lists from settings.json under `tools.allowed`.
490
- * Bash permissions map to run_shell_command(prefix) — the prefix is extracted
491
- * from the canonical "Bash(cmd:*)" pattern by stripping the trailing ":*".
492
- * Blanket Bash grants map to bare "run_shell_command" (no prefix filter).
496
+ * Gemini reads tool allow/deny lists from settings.json under
497
+ * `tools.core` / `tools.exclude`. Bash permissions map to ShellTool(pattern).
498
+ * Blanket Bash grants map to bare "ShellTool" (no command filter).
493
499
  * Non-Bash tool patterns are skipped; Gemini uses different tool names.
494
500
  */
495
501
  export function convertToGeminiFormat(set) {
496
- const allowed = new Set();
497
- for (const perm of set.allow) {
498
- if (BLANKET_BASH_FORMS.has(perm)) {
499
- allowed.add('run_shell_command');
500
- continue;
501
- }
502
- const parsed = parseCanonicalPattern(perm);
503
- if (!parsed || parsed.tool !== 'bash')
504
- continue;
505
- const colonIdx = parsed.pattern.lastIndexOf(':');
506
- const prefix = colonIdx > 0 ? parsed.pattern.slice(0, colonIdx) : parsed.pattern;
507
- if (prefix === '*' || prefix === '**') {
508
- allowed.add('run_shell_command');
509
- }
510
- else {
511
- allowed.add(`run_shell_command(${prefix})`);
502
+ const serialize = (permissions) => {
503
+ const tools = new Set();
504
+ for (const perm of permissions) {
505
+ if (BLANKET_BASH_FORMS.has(perm)) {
506
+ tools.add('ShellTool');
507
+ continue;
508
+ }
509
+ const parsed = parseCanonicalPattern(perm);
510
+ if (!parsed || parsed.tool !== 'bash')
511
+ continue;
512
+ const command = normalizeBashPattern(parsed.pattern);
513
+ if (command === '*') {
514
+ tools.add('ShellTool');
515
+ }
516
+ else {
517
+ tools.add(`ShellTool(${command})`);
518
+ }
512
519
  }
513
- }
514
- return { tools: { allowed: Array.from(allowed) } };
520
+ return Array.from(tools);
521
+ };
522
+ const core = serialize(set.allow);
523
+ const exclude = serialize(set.deny ?? []);
524
+ return {
525
+ tools: {
526
+ core,
527
+ ...(exclude.length ? { exclude } : {}),
528
+ },
529
+ };
515
530
  }
516
531
  /**
517
532
  * Strip Claude's `:*` subcommand-wildcard suffix and return a space-glob form.
@@ -596,6 +611,49 @@ const ANTIGRAVITY_ACTION_BY_TOOL = {
596
611
  write: 'write_file',
597
612
  webfetch: 'read_url',
598
613
  };
614
+ const GOOSE_TOOL_BY_CANONICAL = {
615
+ bash: 'developer__shell',
616
+ read: 'developer__text_editor',
617
+ write: 'developer__text_editor',
618
+ edit: 'developer__text_editor',
619
+ grep: 'developer__analyze',
620
+ glob: 'developer__analyze',
621
+ webfetch: 'developer__fetch',
622
+ mcp: undefined,
623
+ };
624
+ function canonicalToGooseTool(permission) {
625
+ if (BLANKET_BASH_FORMS.has(permission))
626
+ return 'developer__shell';
627
+ const parsed = parseCanonicalPattern(permission);
628
+ if (!parsed)
629
+ return null;
630
+ return GOOSE_TOOL_BY_CANONICAL[parsed.tool] ?? null;
631
+ }
632
+ /** Convert canonical permissions to Goose's per-tool permission.yaml shape. */
633
+ export function convertToGooseFormat(set) {
634
+ const alwaysAllow = new Set();
635
+ const neverAllow = new Set();
636
+ for (const permission of set.allow) {
637
+ const tool = canonicalToGooseTool(permission);
638
+ if (tool)
639
+ alwaysAllow.add(tool);
640
+ }
641
+ for (const permission of set.deny ?? []) {
642
+ const tool = canonicalToGooseTool(permission);
643
+ if (tool)
644
+ neverAllow.add(tool);
645
+ }
646
+ for (const tool of neverAllow) {
647
+ alwaysAllow.delete(tool);
648
+ }
649
+ return {
650
+ user: {
651
+ always_allow: Array.from(alwaysAllow).sort(),
652
+ ask_before: [],
653
+ never_allow: Array.from(neverAllow).sort(),
654
+ },
655
+ };
656
+ }
599
657
  /**
600
658
  * Convert canonical permission set to Grok format.
601
659
  * Grok reads ~/.grok/config.toml with
@@ -1274,17 +1332,29 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1274
1332
  const geminiPerms = convertToGeminiFormat(set);
1275
1333
  const settingsPath = path.join(versionHome, '.gemini', 'settings.json');
1276
1334
  updateGeminiSettings(settingsPath, (settings) => {
1277
- // Remove stale permissions key written by earlier versions of this serializer.
1335
+ // Remove stale keys written by earlier serializer versions:
1336
+ // top-level `permissions`, and Gemini's pre-core/exclude `tools.allowed`.
1278
1337
  delete settings.permissions;
1279
1338
  const tools = (typeof settings.tools === 'object' && settings.tools !== null && !Array.isArray(settings.tools))
1280
1339
  ? settings.tools
1281
1340
  : {};
1341
+ delete tools.allowed;
1282
1342
  if (merge) {
1283
- const existing = Array.isArray(tools.allowed) ? tools.allowed : [];
1284
- tools.allowed = Array.from(new Set([...existing, ...geminiPerms.tools.allowed]));
1343
+ const existingCore = Array.isArray(tools.core) ? tools.core : [];
1344
+ const existingExclude = Array.isArray(tools.exclude) ? tools.exclude : [];
1345
+ tools.core = Array.from(new Set([...existingCore, ...geminiPerms.tools.core]));
1346
+ const mergedExclude = Array.from(new Set([...existingExclude, ...(geminiPerms.tools.exclude ?? [])]));
1347
+ if (mergedExclude.length)
1348
+ tools.exclude = mergedExclude;
1349
+ else
1350
+ delete tools.exclude;
1285
1351
  }
1286
1352
  else {
1287
- tools.allowed = geminiPerms.tools.allowed;
1353
+ tools.core = geminiPerms.tools.core;
1354
+ if (geminiPerms.tools.exclude?.length)
1355
+ tools.exclude = geminiPerms.tools.exclude;
1356
+ else
1357
+ delete tools.exclude;
1288
1358
  }
1289
1359
  settings.tools = tools;
1290
1360
  });
@@ -1349,6 +1419,47 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1349
1419
  fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
1350
1420
  return { success: true };
1351
1421
  }
1422
+ if (agentId === 'goose') {
1423
+ const permissionsPath = path.join(versionHome, '.config', 'goose', 'permission.yaml');
1424
+ let config = {};
1425
+ if (fs.existsSync(permissionsPath)) {
1426
+ const parsed = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
1427
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1428
+ config = parsed;
1429
+ }
1430
+ }
1431
+ const converted = convertToGooseFormat(set).user;
1432
+ const user = (typeof config.user === 'object' && config.user !== null && !Array.isArray(config.user))
1433
+ ? config.user
1434
+ : {};
1435
+ const currentAlways = Array.isArray(user.always_allow) ? user.always_allow.filter((v) => typeof v === 'string') : [];
1436
+ const currentAsk = Array.isArray(user.ask_before) ? user.ask_before.filter((v) => typeof v === 'string') : [];
1437
+ const currentNever = Array.isArray(user.never_allow) ? user.never_allow.filter((v) => typeof v === 'string') : [];
1438
+ if (merge) {
1439
+ const incomingAlways = new Set(converted.always_allow);
1440
+ const incomingNever = new Set(converted.never_allow);
1441
+ const touched = new Set([...incomingAlways, ...incomingNever]);
1442
+ const always = new Set(currentAlways.filter(tool => !touched.has(tool)));
1443
+ const ask = new Set(currentAsk.filter(tool => !touched.has(tool)));
1444
+ const never = new Set(currentNever.filter(tool => !touched.has(tool)));
1445
+ for (const tool of incomingAlways)
1446
+ always.add(tool);
1447
+ for (const tool of incomingNever)
1448
+ never.add(tool);
1449
+ user.always_allow = Array.from(always).sort();
1450
+ user.ask_before = Array.from(ask).sort();
1451
+ user.never_allow = Array.from(never).sort();
1452
+ }
1453
+ else {
1454
+ user.always_allow = converted.always_allow;
1455
+ user.ask_before = converted.ask_before;
1456
+ user.never_allow = converted.never_allow;
1457
+ }
1458
+ config.user = user;
1459
+ fs.mkdirSync(path.dirname(permissionsPath), { recursive: true });
1460
+ fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
1461
+ return { success: true };
1462
+ }
1352
1463
  if (agentId === 'kimi') {
1353
1464
  const configPath = path.join(versionHome, '.kimi-code', 'config.toml');
1354
1465
  let config = {};
@@ -191,6 +191,14 @@ export declare function resolveOpenCodePluginSources(pluginRoot: string): string
191
191
  export declare function installOpenCodePlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
192
192
  export declare function isOpenCodePluginInstalled(pluginName: string, versionHome: string): boolean;
193
193
  export declare function removeOpenCodePlugin(pluginName: string, versionHome: string): boolean;
194
+ /**
195
+ * Gemini CLI extensions live under `$HOME/.gemini/extensions/<name>/` and
196
+ * require a `gemini-extension.json` manifest at the extension root.
197
+ */
198
+ export declare function geminiExtensionsDir(versionHome: string): string;
199
+ export declare function installGeminiPlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
200
+ export declare function isGeminiPluginInstalled(pluginName: string, versionHome: string): boolean;
201
+ export declare function removeGeminiPlugin(pluginName: string, versionHome: string): boolean;
194
202
  /**
195
203
  * Goose auto-discovers Open Plugins at `$HOME/.agents/plugins/<name>/`.
196
204
  * Under agents-cli version isolation HOME is the version home, so we install to:
@@ -21,6 +21,7 @@ import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './com
21
21
  import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, registerDroidInstalledPlugin, unregisterDroidInstalledPlugin, isDroidPluginInstalled, registerCopilotInstalledPlugin, unregisterCopilotInstalledPlugin, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
22
22
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
23
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
+ const GEMINI_EXTENSION_MANIFEST_FILE = 'gemini-extension.json';
24
25
  const USER_CONFIG_FILE = '.user-config.json';
25
26
  const SOURCE_FILE = '.source';
26
27
  export const PLUGIN_EXEC_SURFACE_LABELS = {
@@ -502,6 +503,27 @@ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
502
503
  result.skills.push(plugin.name);
503
504
  return result;
504
505
  }
506
+ // Gemini CLI loads extensions from $HOME/.gemini/extensions/<name>/.
507
+ // Copy the plugin bundle as an extension and synthesize gemini-extension.json.
508
+ if (agent === 'gemini') {
509
+ const enablePlugin = options.allowExecSurfaces === true || !hasPluginExecSurfaces(inspectPluginCapabilities(plugin.root));
510
+ if (!enablePlugin) {
511
+ return result;
512
+ }
513
+ const ok = installGeminiPlugin(plugin, versionHome);
514
+ result.success = ok;
515
+ if (ok) {
516
+ result.skills = plugin.skills.map(s => `${plugin.name}:${s}`);
517
+ result.commands = plugin.commands.map(c => `${plugin.name}:${c}`);
518
+ result.agentDefs = plugin.agentDefs.map(a => `${plugin.name}:${a}`);
519
+ result.bin = plugin.bin;
520
+ result.hooks = plugin.hooks;
521
+ result.mcp = plugin.hasMcp;
522
+ result.settings = plugin.hasSettings;
523
+ result.permissions = pluginHasPermissions(plugin);
524
+ }
525
+ return result;
526
+ }
505
527
  // Goose loads Open Plugins from $HOME/.agents/plugins/<name>/ (same layout as
506
528
  // agents-cli's source tree). Under the shim HOME is the version home.
507
529
  if (agent === 'goose') {
@@ -901,6 +923,82 @@ export function removeOpenCodePlugin(pluginName, versionHome) {
901
923
  }
902
924
  return removed;
903
925
  }
926
+ // ─── Gemini extensions ───────────────────────────────────────────────────────
927
+ /**
928
+ * Gemini CLI extensions live under `$HOME/.gemini/extensions/<name>/` and
929
+ * require a `gemini-extension.json` manifest at the extension root.
930
+ */
931
+ export function geminiExtensionsDir(versionHome) {
932
+ return path.join(versionHome, '.gemini', 'extensions');
933
+ }
934
+ function readPluginMcpConfigForGemini(pluginRoot) {
935
+ const mcpPath = path.join(pluginRoot, '.mcp.json');
936
+ if (!fs.existsSync(mcpPath))
937
+ return undefined;
938
+ try {
939
+ const parsed = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
940
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object')
941
+ return undefined;
942
+ return rewriteGeminiExtensionVars(parsed.mcpServers);
943
+ }
944
+ catch {
945
+ return undefined;
946
+ }
947
+ }
948
+ function rewriteGeminiExtensionVars(value) {
949
+ if (typeof value === 'string') {
950
+ return value
951
+ .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, '${extensionPath}')
952
+ .replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, '${extensionPath}/.data');
953
+ }
954
+ if (Array.isArray(value))
955
+ return value.map(rewriteGeminiExtensionVars);
956
+ if (value && typeof value === 'object') {
957
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteGeminiExtensionVars(item)]));
958
+ }
959
+ return value;
960
+ }
961
+ function writeGeminiExtensionManifest(plugin, destRoot) {
962
+ const manifest = {
963
+ name: plugin.manifest.name,
964
+ version: plugin.manifest.version,
965
+ description: plugin.manifest.description,
966
+ };
967
+ const mcpServers = readPluginMcpConfigForGemini(destRoot);
968
+ if (mcpServers && Object.keys(mcpServers).length > 0) {
969
+ manifest.mcpServers = mcpServers;
970
+ }
971
+ fs.writeFileSync(path.join(destRoot, GEMINI_EXTENSION_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
972
+ }
973
+ export function installGeminiPlugin(plugin, versionHome) {
974
+ const destRoot = path.join(geminiExtensionsDir(versionHome), plugin.name);
975
+ try {
976
+ if (fs.existsSync(destRoot)) {
977
+ fs.rmSync(destRoot, { recursive: true, force: true });
978
+ }
979
+ fs.cpSync(plugin.root, destRoot, { recursive: true });
980
+ const userConfig = loadUserConfig(plugin.name);
981
+ if (Object.keys(userConfig).length > 0) {
982
+ expandUserConfigInDir(destRoot, userConfig);
983
+ }
984
+ writeGeminiExtensionManifest(plugin, destRoot);
985
+ fs.writeFileSync(path.join(destRoot, '.agents-cli-managed'), `plugin=${plugin.name}\n`, 'utf-8');
986
+ return true;
987
+ }
988
+ catch {
989
+ return false;
990
+ }
991
+ }
992
+ export function isGeminiPluginInstalled(pluginName, versionHome) {
993
+ return fs.existsSync(path.join(geminiExtensionsDir(versionHome), pluginName, GEMINI_EXTENSION_MANIFEST_FILE));
994
+ }
995
+ export function removeGeminiPlugin(pluginName, versionHome) {
996
+ const destRoot = path.join(geminiExtensionsDir(versionHome), pluginName);
997
+ if (!fs.existsSync(destRoot))
998
+ return false;
999
+ fs.rmSync(destRoot, { recursive: true, force: true });
1000
+ return true;
1001
+ }
904
1002
  // ─── Goose plugins (Open Plugins under .agents/plugins/) ─────────────────────
905
1003
  /**
906
1004
  * Goose auto-discovers Open Plugins at `$HOME/.agents/plugins/<name>/`.
@@ -949,6 +1047,9 @@ export function isPluginSynced(plugin, agent, versionHome) {
949
1047
  if (agent === 'opencode') {
950
1048
  return isOpenCodePluginInstalled(plugin.name, versionHome);
951
1049
  }
1050
+ if (agent === 'gemini') {
1051
+ return isGeminiPluginInstalled(plugin.name, versionHome);
1052
+ }
952
1053
  if (agent === 'goose') {
953
1054
  return isGoosePluginInstalled(plugin.name, versionHome);
954
1055
  }
@@ -986,6 +1087,13 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
986
1087
  }
987
1088
  return result;
988
1089
  }
1090
+ // Gemini: remove extension directory from ~/.gemini/extensions/.
1091
+ if (agent === 'gemini') {
1092
+ if (removeGeminiPlugin(pluginName, versionHome)) {
1093
+ result.skills.push(pluginName);
1094
+ }
1095
+ return result;
1096
+ }
989
1097
  // Goose: remove Open Plugin directory from versionHome/.agents/plugins/.
990
1098
  if (agent === 'goose') {
991
1099
  if (removeGoosePlugin(pluginName, versionHome)) {
@@ -4,7 +4,7 @@
4
4
  * Permissions are stored as YAML files in permissions/ directories at each layer.
5
5
  * Resolution: project > user > system (higher layer wins on name conflict).
6
6
  * Unlike other resources, permissions merge into agent-specific config files
7
- * (Claude: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
7
+ * (Claude/Gemini: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
8
8
  */
9
9
  import type { ResourceHandler } from './types.js';
10
10
  import type { PermissionSet } from '../types.js';
@@ -4,7 +4,7 @@
4
4
  * Permissions are stored as YAML files in permissions/ directories at each layer.
5
5
  * Resolution: project > user > system (higher layer wins on name conflict).
6
6
  * Unlike other resources, permissions merge into agent-specific config files
7
- * (Claude: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
7
+ * (Claude/Gemini: settings.json, Codex: config.toml, OpenCode: opencode.jsonc).
8
8
  */
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
@@ -65,6 +65,8 @@ function getAgentConfigPath(agent, versionHome) {
65
65
  return path.join(versionHome, '.claude', 'settings.json');
66
66
  case 'codex':
67
67
  return path.join(versionHome, '.codex', 'config.toml');
68
+ case 'gemini':
69
+ return path.join(versionHome, '.gemini', 'settings.json');
68
70
  case 'opencode':
69
71
  return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
70
72
  case 'kimi':
@@ -73,6 +75,8 @@ function getAgentConfigPath(agent, versionHome) {
73
75
  return path.join(versionHome, '.factory', 'settings.json');
74
76
  case 'kiro':
75
77
  return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
78
+ case 'goose':
79
+ return path.join(versionHome, '.config', 'goose', 'permission.yaml');
76
80
  default:
77
81
  return null;
78
82
  }
@@ -5,7 +5,7 @@
5
5
  * - Union: All resources from all layers are combined
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
- export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'forge';
8
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'forge';
9
9
  export type Layer = 'system' | 'user' | 'project';
10
10
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
11
  /** A resolved resource with its origin layer. */
@@ -75,6 +75,13 @@ export interface JobConfig {
75
75
  version?: string;
76
76
  runOnce?: boolean;
77
77
  endAt?: string;
78
+ /**
79
+ * When set, the job resumes this existing agent session id at fire time
80
+ * (`agents run <agent> --resume <id>`) instead of starting a fresh conversation,
81
+ * so the actual session reopens with full context and `prompt` becomes its next
82
+ * turn. Powers self-scheduled wake-ups (e.g. /hibernate). claude/codex only.
83
+ */
84
+ resume?: string;
78
85
  /** When set, executeJob runs this job through the loop driver instead of once. */
79
86
  loop?: LoopConfig;
80
87
  }
@@ -138,6 +145,22 @@ export declare function readJob(name: string, cwd?: string): JobConfig | null;
138
145
  * name, the write fails explicitly so we never choose or drop a sibling.
139
146
  */
140
147
  export declare function writeJob(config: JobConfig): void;
148
+ /**
149
+ * Serialize a job config, preserving the on-disk formatting of an existing file.
150
+ *
151
+ * A full `yaml.stringify(config)` re-emits the whole document — restyling every
152
+ * scalar (unquoting `schedule`, re-wrapping the folded `prompt` block, reordering
153
+ * keys). When a routine is only being toggled (pause/resume) or re-pinned
154
+ * (`devices --set`), that rewrites the entire file, leaving the git-backed
155
+ * `~/.agents` tree perpetually dirty so `agents repo pull` refuses to sync across
156
+ * the fleet. To keep the diff to the field that actually changed, we edit the
157
+ * existing document in place and only re-render touched nodes; untouched nodes
158
+ * (notably the large `prompt` block) keep their byte-for-byte formatting.
159
+ *
160
+ * `existingText` is the current file contents, or null for a new file. New,
161
+ * unparseable, and non-mapping documents fall back to canonical `yaml.stringify`.
162
+ */
163
+ export declare function serializeJob(output: Record<string, unknown>, existingText: string | null): string;
141
164
  /** Delete a job config file by name. Returns true if the file existed. */
142
165
  export declare function deleteJob(name: string): boolean;
143
166
  /** Enable or disable a job by name. */
@@ -187,7 +187,52 @@ export function writeJob(config) {
187
187
  const devArr = output.devices;
188
188
  if (!devArr || devArr.length === 0)
189
189
  delete output.devices;
190
- atomicWriteFileSync(filePath, yaml.stringify(output));
190
+ let existingText = null;
191
+ if (ymlExists || yamlExists) {
192
+ try {
193
+ existingText = fs.readFileSync(filePath, 'utf-8');
194
+ }
195
+ catch {
196
+ existingText = null;
197
+ }
198
+ }
199
+ atomicWriteFileSync(filePath, serializeJob(output, existingText));
200
+ }
201
+ /**
202
+ * Serialize a job config, preserving the on-disk formatting of an existing file.
203
+ *
204
+ * A full `yaml.stringify(config)` re-emits the whole document — restyling every
205
+ * scalar (unquoting `schedule`, re-wrapping the folded `prompt` block, reordering
206
+ * keys). When a routine is only being toggled (pause/resume) or re-pinned
207
+ * (`devices --set`), that rewrites the entire file, leaving the git-backed
208
+ * `~/.agents` tree perpetually dirty so `agents repo pull` refuses to sync across
209
+ * the fleet. To keep the diff to the field that actually changed, we edit the
210
+ * existing document in place and only re-render touched nodes; untouched nodes
211
+ * (notably the large `prompt` block) keep their byte-for-byte formatting.
212
+ *
213
+ * `existingText` is the current file contents, or null for a new file. New,
214
+ * unparseable, and non-mapping documents fall back to canonical `yaml.stringify`.
215
+ */
216
+ export function serializeJob(output, existingText) {
217
+ if (existingText == null)
218
+ return yaml.stringify(output);
219
+ const doc = yaml.parseDocument(existingText);
220
+ if (doc.errors.length > 0 || !yaml.isMap(doc.contents))
221
+ return yaml.stringify(output);
222
+ const existing = (doc.toJS() ?? {});
223
+ // Update or add only the keys that actually changed; leave the rest untouched
224
+ // so their original formatting is preserved.
225
+ for (const [key, value] of Object.entries(output)) {
226
+ if (JSON.stringify(existing[key]) !== JSON.stringify(value))
227
+ doc.set(key, value);
228
+ }
229
+ // Drop keys that no longer belong (e.g. an omitted default, or `devices`
230
+ // cleared back to fleet-wide).
231
+ for (const key of Object.keys(existing)) {
232
+ if (!(key in output))
233
+ doc.delete(key);
234
+ }
235
+ return doc.toString();
191
236
  }
192
237
  /** Delete a job config file by name. Returns true if the file existed. */
193
238
  export function deleteJob(name) {
@@ -253,6 +298,24 @@ export function validateJob(config) {
253
298
  errors.push('workflow must be a lowercase alphanumeric name (hyphens and underscores allowed, e.g. autodev)');
254
299
  }
255
300
  }
301
+ if (config.resume !== undefined) {
302
+ // Only claude/codex support native `--resume`; other agents would emit a resume
303
+ // flag they don't understand. Resume reopens an agent session, so it is
304
+ // incompatible with workflow and loop jobs (which have no single session to reopen).
305
+ const RESUMABLE_AGENTS = ['claude', 'codex'];
306
+ if (typeof config.resume !== 'string' || config.resume.trim() === '') {
307
+ errors.push('resume must be a non-empty session id string');
308
+ }
309
+ if (hasWorkflow) {
310
+ errors.push('resume cannot be combined with workflow (resume reopens an existing agent session)');
311
+ }
312
+ if (config.loop) {
313
+ errors.push('resume cannot be combined with loop');
314
+ }
315
+ if (hasAgent && config.agent && !RESUMABLE_AGENTS.includes(config.agent)) {
316
+ errors.push(`resume is only supported for agents with native --resume (${RESUMABLE_AGENTS.join(', ')}); got '${config.agent}'`);
317
+ }
318
+ }
256
319
  if (config.mode && !['plan', 'edit', 'auto', 'skip', 'full'].includes(config.mode)) {
257
320
  errors.push("mode must be plan, edit, auto, or skip ('full' accepted as alias for skip)");
258
321
  }
@@ -50,6 +50,13 @@ export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string): P
50
50
  * surface as "agents: no version of X configured".
51
51
  */
52
52
  export declare function pinJobBinary(cmd: string[], agent: AgentId, version: string | undefined): string[];
53
+ /**
54
+ * Whether a job's command is dispatched through `agents run` (so `cmd[0] === 'agents'`)
55
+ * rather than the agent binary directly. True for workflow jobs and for resume jobs.
56
+ * Such commands must NOT be binary-pinned (pinning rewrites cmd[0] to the agent binary,
57
+ * producing a broken `<binary> run …`) and must not receive a version-pinned spawn env.
58
+ */
59
+ export declare function dispatchesViaAgentsRun(config: Pick<JobConfig, 'workflow' | 'resume'>): boolean;
53
60
  /**
54
61
  * Merge sandbox/base env with the canonical per-version exec env
55
62
  * (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
@@ -44,6 +44,14 @@ export function buildJobCommand(config, resolvedPrompt) {
44
44
  const cmd = ['agents', 'run', config.workflow, resolvedPrompt, '--mode', config.mode];
45
45
  return cmd;
46
46
  }
47
+ // Resume branch: reopen an EXISTING session via `agents run <agent> --resume <id>`
48
+ // instead of starting fresh. The real session resumes with its full prior context
49
+ // (index-based lookup, cwd-independent) and `resolvedPrompt` becomes its next turn —
50
+ // so a self-scheduled wake (e.g. /hibernate) is handled by the session that scheduled
51
+ // it, not a fresh, context-less agent that would refuse an "opaque" instruction.
52
+ if (config.resume) {
53
+ return ['agents', 'run', config.agent, '--resume', config.resume, resolvedPrompt, '--mode', config.mode];
54
+ }
47
55
  const template = AGENT_COMMANDS[config.agent];
48
56
  if (!template) {
49
57
  throw new Error(`Unsupported agent for daemon jobs: ${config.agent}`);
@@ -263,6 +271,15 @@ export function pinJobBinary(cmd, agent, version) {
263
271
  next[0] = binary;
264
272
  return next;
265
273
  }
274
+ /**
275
+ * Whether a job's command is dispatched through `agents run` (so `cmd[0] === 'agents'`)
276
+ * rather than the agent binary directly. True for workflow jobs and for resume jobs.
277
+ * Such commands must NOT be binary-pinned (pinning rewrites cmd[0] to the agent binary,
278
+ * producing a broken `<binary> run …`) and must not receive a version-pinned spawn env.
279
+ */
280
+ export function dispatchesViaAgentsRun(config) {
281
+ return Boolean(config.workflow || config.resume);
282
+ }
266
283
  /**
267
284
  * Merge sandbox/base env with the canonical per-version exec env
268
285
  * (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
@@ -394,7 +411,11 @@ export async function executeJob(config, deps) {
394
411
  schedule: config.schedule,
395
412
  });
396
413
  const resolvedPrompt = resolveJobPrompt(config);
397
- const useSandbox = config.sandbox !== false;
414
+ // Resume must run against the REAL home: `--resume <id>` resolves the session from
415
+ // the agent's config dir, and the sandbox overlay home has only a freshly-generated
416
+ // config with no session store. So a resume job is never sandboxed, regardless of
417
+ // `config.sandbox` (see the resume branch in buildJobCommand).
418
+ const useSandbox = config.sandbox !== false && !config.resume;
398
419
  const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
399
420
  const runId = generateRunId();
400
421
  const runDir = getRunDir(config.name, runId);
@@ -470,10 +491,11 @@ export async function executeJob(config, deps) {
470
491
  if (i === 0) {
471
492
  process.stderr.write(`[agents] routine ${config.name}: running ${label}\n`);
472
493
  }
473
- const cmd = config.workflow
494
+ const viaAgentsRun = dispatchesViaAgentsRun(config);
495
+ const cmd = viaAgentsRun
474
496
  ? baseCmd
475
497
  : pinJobBinary(baseCmd, attemptAgent, attemptVersion);
476
- const spawnEnv = config.workflow
498
+ const spawnEnv = viaAgentsRun
477
499
  ? (() => {
478
500
  const e = { ...baseEnv };
479
501
  if (config.timezone)
@@ -553,10 +575,16 @@ export async function executeJobDetached(config) {
553
575
  const version = launch.chain[0]?.version ?? config.version;
554
576
  const resolvedPrompt = resolveJobPrompt(config);
555
577
  let cmd = buildJobCommand(config, resolvedPrompt);
556
- if (!config.workflow && version) {
578
+ // workflow AND resume dispatch through `agents run` — never binary-pin them (pinning
579
+ // rewrites cmd[0] to the agent binary → broken `<binary> run …`).
580
+ if (!dispatchesViaAgentsRun(config) && version) {
557
581
  cmd = pinJobBinary(cmd, config.agent, version);
558
582
  }
559
- const useSandbox = config.sandbox !== false;
583
+ // Resume must run against the REAL home: `--resume <id>` resolves the session from
584
+ // the agent's config dir, and the sandbox overlay home has only a freshly-generated
585
+ // config with no session store. So a resume job is never sandboxed, regardless of
586
+ // `config.sandbox` (see the resume branch in buildJobCommand).
587
+ const useSandbox = config.sandbox !== false && !config.resume;
560
588
  const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
561
589
  const runId = generateRunId();
562
590
  const runDir = getRunDir(config.name, runId);
@@ -566,7 +594,7 @@ export async function executeJobDetached(config) {
566
594
  const baseEnv = useSandbox
567
595
  ? buildSpawnEnv(overlayHome)
568
596
  : { ...process.env };
569
- const spawnEnv = config.workflow
597
+ const spawnEnv = dispatchesViaAgentsRun(config)
570
598
  ? (() => {
571
599
  const e = { ...baseEnv };
572
600
  if (config.timezone)