@phnx-labs/agents-cli 1.20.60 → 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.
package/CHANGELOG.md CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## 1.20.60
5
+ ## 1.20.61
6
+
7
+ - **Detect OpenCode sign-in state so `agents view` stops mislabeling a logged-in install as "not signed in."** `getAccountInfo` had no `opencode` case, so it fell through to `signedIn: false` and every row printed "(not signed in — run opencode to log in)" even with a live login. It now reads OpenCode's `auth.json` (`$XDG_DATA_HOME/opencode/auth.json`, defaulting to `~/.local/share/opencode/auth.json` on every platform — `xdg-basedir` does not special-case macOS), validates each provider entry against its `oauth`/`api`/`wellknown` credential shape, and reports the account as signed in with the non-secret provider ids surfaced as the account label (e.g. `id:muse-spark`). Credential secrets (`access`/`refresh`/`key`/`token`) are only inspected for presence — never read into any display or JSON output. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/agents.test.ts`.
8
+ - **Wire Antigravity workflows support (RUSH-1580).** `agents` now syncs workflows to Antigravity as markdown files with the required `description` frontmatter plus an `agents_workflow` ownership marker, invocable as `/<name>` slash commands. Antigravity workflows are the one non-version-isolated target: `agy` scans a single shared, HOME-global `~/.gemini/config/global_workflows/` at startup (a real home directory, never symlinked per version — verified via strace of `agy`), so the writer and detector both resolve that shared dir for every installed version instead of a per-version home. Gated at `>= 1.0.6`. The ownership marker prevents overwriting or removing user-authored workflows of the same name. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/workflows.ts`, `apps/cli/src/lib/staleness/detectors/workflows.ts`.
6
9
 
10
+ ## 1.20.60
7
11
  - **Fix Goose skill sync status for its native central-storage path.** `agents skills list goose@<version>` now reports skills under `~/.agents/skills/` as installed instead of falsely requiring a per-version `.config/goose/skills/` copy that Goose never reads. Source: `apps/cli/src/lib/skills.ts`.
8
12
  - **Correct the documented `auto` and ACP `skip` semantics.** The README and bundled `run` skill now distinguish Kimi's interactive `--auto` from its already-auto-approved headless `-p` path, document Droid's native `--auto high`, and explain that ACP `skip` prefers `allow_always` but falls back to the first permission option offered by the server. Documentation only; runtime behavior is unchanged. Source: `README.md`, `skills/run/SKILL.md`.
9
- - **Wire Antigravity subagents and Kimi workflow sync (RUSH-1548, RUSH-1581).** Antigravity now receives subagents as custom-agent Markdown under `~/.gemini/config/agents/<name>/agent.md` with the `>= 1.0.16` version gate enforced during sync. Kimi receives workflows as managed `type: flow` skills under `.kimi-code/skills/<name>/SKILL.md`, using the canonical slug as the flow name and an `agents_workflow` marker so native user-owned flows are not overwritten or removed. Antigravity workflows remain disabled for version-home sync until a global filesystem target is documented. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/workflows.ts`.
13
+ - **Wire Antigravity subagents and Kimi workflow sync (RUSH-1548, RUSH-1581).** Antigravity now receives subagents as custom-agent Markdown under `~/.gemini/config/agents/<name>/agent.md` with the `>= 1.0.16` version gate enforced during sync. Kimi receives workflows as managed `type: flow` skills under `.kimi-code/skills/<name>/SKILL.md`, using the canonical slug as the flow name and an `agents_workflow` marker so native user-owned flows are not overwritten or removed. Antigravity workflows are wired separately in RUSH-1580 (they target a shared HOME-global dir, not a version home). Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/workflows.ts`.
10
14
  - **Release retries rebuild the exact merged, CI-tested release tree even after `main` advances.** A registry/auth failure after the release PR merged previously stranded that version because the catch-up guard required the release merge to remain current `main`. The local release script now validates the original PR head and full green matrix, verifies the SHA-pinned keychain helper, rebuilds the unpinned menu-bar helper from historical source in a detached temporary worktree, rejects mismatched remote tags, and tags/publishes that exact merge without including later commits. Source: `apps/cli/scripts/release.sh`.
11
15
  - **Wire Gemini plugins/subagents and Goose workflows/allowlists.** Gemini now syncs plugin bundles as Gemini extensions (`.gemini/extensions/<name>/gemini-extension.json`) from CLI 0.8.0+ and subagents as `.gemini/agents/*.md` from CLI 0.36.0+. Goose now syncs workflows as recipe/subrecipe YAML under `.config/goose/recipes/` and permission groups into `.config/goose/permission.yaml`. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/plugins.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/workflows.ts`, `apps/cli/src/lib/permissions.ts`. (RUSH-1568, RUSH-1569, RUSH-1574, RUSH-1582)
12
16
  - **Wire Gemini permissions/allowlist support (RUSH-1567).** Gemini permission groups now sync Bash allow/deny rules into `.gemini/settings.json` as `tools.core` / `tools.exclude` entries with per-command `ShellTool(...)` patterns; non-Bash canonical permissions remain unsupported by Gemini's native tool grammar and are skipped. Flip `allowlist: true`, register the permission writer/detector through the capability table, and replace the dormant legacy `tools.allowed` serializer. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/permissions.ts`, `apps/cli/src/lib/resources/permissions.ts`, `apps/cli/src/lib/staleness/detectors/permissions.ts`.
package/README.md CHANGED
@@ -855,7 +855,7 @@ Which DotAgents resources each agent CLI can load. Source of truth: [src/lib/age
855
855
  | Grok Build | yes | yes | yes | yes | yes | skills ($name) | yes | no | `AGENTS.md` | no |
856
856
  | OpenClaw | yes | yes | yes | no | yes | gateway | yes | yes | `workspace/AGENTS.md` | no |
857
857
  | Cursor | yes | no | yes | no | yes | yes | no | no | `.cursorrules` | no |
858
- | OpenCode | yes | no | yes | no | yes | yes | no | no | `AGENTS.md` | no |
858
+ | OpenCode | yes | no | yes | >= 1.1.1 | yes | yes | no | no | `AGENTS.md` | no |
859
859
  | Copilot | yes | no | yes | no | yes | yes | no | no | `AGENTS.md` | no |
860
860
  | Amp | yes | no | yes | no | yes | yes | no | no | `AGENTS.md` | no |
861
861
  | Kiro | yes | no | yes | >= 2.8.0 | yes | yes | no | >= 1.23.0 | `AGENTS.md` | no |
package/dist/bin/agents CHANGED
Binary file
@@ -347,6 +347,7 @@ export function registerRoutinesCommands(program) {
347
347
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
348
348
  .option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
349
349
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
350
+ .option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
350
351
  .action(async (nameOrPath, options) => {
351
352
  // Check if inline mode (has flags) or file mode
352
353
  const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at;
@@ -406,6 +407,7 @@ export function registerRoutinesCommands(program) {
406
407
  ...(devices ? { devices } : {}),
407
408
  ...(runOnce ? { runOnce: true } : {}),
408
409
  ...(options.endAt ? { endAt: options.endAt } : {}),
410
+ ...(options.resume ? { resume: options.resume } : {}),
409
411
  };
410
412
  const errors = validateJob(config);
411
413
  if (errors.length > 0) {
@@ -450,7 +450,7 @@ export const AGENTS = {
450
450
  variableSyntax: '{{args}}',
451
451
  supportsHooks: true,
452
452
  cloudProvider: 'antigravity',
453
- capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '1.0.16' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit', 'skip'], rulesImports: false },
453
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '1.0.16' }, rules: { file: 'AGENTS.md' }, workflows: { since: '1.0.6' }, memory: false, modes: ['edit', 'skip'], rulesImports: false }, // workflows: markdown files in the shared, HOME-global ~/.gemini/config/global_workflows/ (agy scans it at startup; not version-isolated — see workflows.ts), invoked as /<name> slash commands
454
454
  },
455
455
  // xAI Grok Build CLI (`grok`) — early beta, SuperGrok Heavy. Auth via OAuth on
456
456
  // first launch, or XAI_API_KEY env var for headless. MCP servers configured inline
@@ -1024,6 +1024,64 @@ async function antigravityKeychainSignedIn() {
1024
1024
  }
1025
1025
  return cachedAgyKeychainSignedIn;
1026
1026
  }
1027
+ /**
1028
+ * OpenCode (sst/opencode) stores provider credentials in a single JSON file at
1029
+ * `$XDG_DATA_HOME/opencode/auth.json`, defaulting to
1030
+ * `~/.local/share/opencode/auth.json` on EVERY platform — its `xdg-basedir`
1031
+ * dependency does not special-case macOS, so there is no
1032
+ * `~/Library/Application Support` variant. The path is account-global (not
1033
+ * per-version), matching how `session/discover.ts` already resolves
1034
+ * `~/.local/share/opencode/opencode.db`.
1035
+ *
1036
+ * Resolution order, first existing wins:
1037
+ * 1. `<base>/.local/share/opencode/auth.json` — the passed per-version home.
1038
+ * This is primarily a test hook (suites write a hermetic auth file under a
1039
+ * temp home) but also covers any relocated install.
1040
+ * 2. `$XDG_DATA_HOME/opencode/auth.json` — an explicit XDG override, exactly
1041
+ * what OpenCode itself honours.
1042
+ * 3. `<realHome>/.local/share/opencode/auth.json` — the active default, under
1043
+ * `AGENTS_REAL_HOME` or `os.homedir()`, so every installed version reflects
1044
+ * the one account-global login (same fallback shape as
1045
+ * resolveAccountCredentialPath).
1046
+ * Returns the first existing path, or null. Never throws.
1047
+ */
1048
+ function resolveOpenCodeAuthPath(base) {
1049
+ const candidates = [path.join(base, '.local', 'share', 'opencode', 'auth.json')];
1050
+ const xdgData = process.env.XDG_DATA_HOME;
1051
+ if (xdgData)
1052
+ candidates.push(path.join(xdgData, 'opencode', 'auth.json'));
1053
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
1054
+ candidates.push(path.join(realHome, '.local', 'share', 'opencode', 'auth.json'));
1055
+ for (const candidate of candidates) {
1056
+ try {
1057
+ if (fs.existsSync(candidate))
1058
+ return candidate;
1059
+ }
1060
+ catch { /* unreadable */ }
1061
+ }
1062
+ return null;
1063
+ }
1064
+ /**
1065
+ * Validate one OpenCode auth.json entry against its discriminated union
1066
+ * (`type: 'oauth' | 'api' | 'wellknown'`) and confirm the credential actually
1067
+ * carries its required secret field(s) non-empty. This guards against a
1068
+ * corrupt/half-written entry reading as signed-in — the same "must have a real
1069
+ * credential" floor grok/antigravity apply. We only INSPECT the shape here; the
1070
+ * secret values (`access`/`refresh`/`key`/`token`) are never read out or
1071
+ * surfaced anywhere.
1072
+ */
1073
+ function isValidOpenCodeCredential(value) {
1074
+ if (!value || typeof value !== 'object')
1075
+ return false;
1076
+ const cred = value;
1077
+ const nonEmpty = (v) => typeof v === 'string' && v.length > 0;
1078
+ switch (cred.type) {
1079
+ case 'oauth': return nonEmpty(cred.access) || nonEmpty(cred.refresh);
1080
+ case 'api': return nonEmpty(cred.key);
1081
+ case 'wellknown': return nonEmpty(cred.key) && nonEmpty(cred.token);
1082
+ default: return false;
1083
+ }
1084
+ }
1027
1085
  export async function getAccountInfo(agentId, home) {
1028
1086
  const base = home || os.homedir();
1029
1087
  const empty = {
@@ -1253,6 +1311,34 @@ export async function getAccountInfo(agentId, home) {
1253
1311
  return { ...empty, lastActive };
1254
1312
  return { ...empty, signedIn: true, lastActive };
1255
1313
  }
1314
+ case 'opencode': {
1315
+ // OpenCode's auth.json is a record keyed by provider id ->
1316
+ // { type: 'oauth'|'api'|'wellknown', ...secret fields }. There is no
1317
+ // email/identity claim to surface, so — like antigravity/kimi — we
1318
+ // report signed-in state plus the NON-SECRET provider metadata (which
1319
+ // provider ids hold a valid credential) and never read the tokens/keys
1320
+ // themselves. The user's complaint was the row read "not signed in"
1321
+ // despite a live login; a valid provider entry now shows e.g.
1322
+ // "id:muse-spark" so they can see exactly which provider is configured.
1323
+ const authPath = resolveOpenCodeAuthPath(base);
1324
+ if (!authPath)
1325
+ return { ...empty, lastActive };
1326
+ const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
1327
+ if (!data || typeof data !== 'object')
1328
+ return { ...empty, lastActive };
1329
+ const providers = Object.entries(data)
1330
+ .filter(([, cred]) => isValidOpenCodeCredential(cred))
1331
+ .map(([id]) => id)
1332
+ .sort();
1333
+ if (providers.length === 0)
1334
+ return { ...empty, lastActive };
1335
+ // Provider ids are config keys (e.g. "anthropic", "muse-spark"), not
1336
+ // secrets. Join them into a stable, human-readable account label +
1337
+ // identity key for usage dedup.
1338
+ const accountId = providers.join('+');
1339
+ const accountKey = buildIdentityKey(agentId, [['providers', accountId]]);
1340
+ return { ...empty, signedIn: true, accountId, accountKey, lastActive };
1341
+ }
1256
1342
  default:
1257
1343
  return { ...empty, lastActive };
1258
1344
  }
@@ -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
  }
@@ -298,6 +298,24 @@ export function validateJob(config) {
298
298
  errors.push('workflow must be a lowercase alphanumeric name (hyphens and underscores allowed, e.g. autodev)');
299
299
  }
300
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
+ }
301
319
  if (config.mode && !['plan', 'edit', 'auto', 'skip', 'full'].includes(config.mode)) {
302
320
  errors.push("mode must be plan, edit, auto, or skip ('full' accepted as alias for skip)");
303
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)
@@ -3,6 +3,7 @@
3
3
  * containing WORKFLOW.md. Mirrors versions.ts:551-558.
4
4
  */
5
5
  import * as fs from 'fs';
6
+ import * as os from 'os';
6
7
  import * as path from 'path';
7
8
  import * as yaml from 'yaml';
8
9
  import { capableAgents } from '../../capabilities.js';
@@ -36,6 +37,34 @@ function buildWorkflowsDetector(agent) {
36
37
  })
37
38
  .map(d => d.name);
38
39
  }
40
+ if (agent === 'antigravity') {
41
+ // Antigravity user workflows are HOME-global and shared across versions,
42
+ // not version-isolated — see workflows.ts:antigravityWorkflowsDir(). agy
43
+ // scans the real ~/.gemini/config/global_workflows/, so the detector reads
44
+ // the same shared dir the writer targets (versionHome is intentionally unused).
45
+ const dir = path.join(process.env.HOME ?? os.homedir(), '.gemini', 'config', 'global_workflows');
46
+ if (!fs.existsSync(dir))
47
+ return [];
48
+ return fs.readdirSync(dir, { withFileTypes: true })
49
+ .filter(d => d.isFile() && d.name.endsWith('.md') && !d.name.startsWith('.'))
50
+ .filter(d => {
51
+ try {
52
+ const content = fs.readFileSync(path.join(dir, d.name), 'utf-8');
53
+ const lines = content.split('\n');
54
+ if (lines[0] !== '---')
55
+ return false;
56
+ const endIndex = lines.slice(1).findIndex(l => l === '---');
57
+ if (endIndex < 0)
58
+ return false;
59
+ const parsed = yaml.parse(lines.slice(1, endIndex + 1).join('\n'));
60
+ return parsed?.agents_workflow === d.name.slice(0, -'.md'.length);
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ })
66
+ .map(d => d.name.slice(0, -'.md'.length));
67
+ }
39
68
  if (agent === 'goose') {
40
69
  const recipesDir = path.join(versionHome, '.config', 'goose', 'recipes');
41
70
  if (!fs.existsSync(recipesDir))
@@ -146,19 +146,25 @@ export declare function setSessionHook(name: string, hook: string, command: stri
146
146
  * in the tmux server instead of launching a second tmux client against
147
147
  * the same socket from inside the hook. That self-client could race the
148
148
  * server under load and leave the dead split behind.
149
+ * v5 — `run-shell -b -C "kill-pane -t #{hook_pane}"` runs the targeted kill-pane
150
+ * in the background inside the server command queue. The synchronous
151
+ * variant could stall the hook on a loaded CI runner, letting the dead
152
+ * split survive until the test's wait timeout expired (flake in CI shard
153
+ * 3: pane-guarded pane-died hook / user split exit).
149
154
  */
150
- export declare const AGENT_HOOK_SCHEMA = 4;
155
+ export declare const AGENT_HOOK_SCHEMA = 5;
151
156
  /**
152
157
  * The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
153
158
  * (so the blocking attach in runInTmux returns and the exit status can be read);
154
159
  * a user split's death runs the else-branch, closing just that split. The
155
- * else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
160
+ * else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
156
161
  * target: tmux format-expands the command at fire time and executes it inside
157
- * the server command queue, so the event pane is always the one killed without
158
- * launching a second tmux client against the same socket. A bare `kill-pane`
159
- * relied on the hook context supplying a "current pane", while an external
160
- * self-client could race the server under load. Single source of truth: both
161
- * the spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
162
+ * the server command queue in the background, so the event pane is always the
163
+ * one killed without launching a second tmux client against the same socket and
164
+ * without stalling the hook on a loaded runner. A bare `kill-pane` relied on
165
+ * the hook context supplying a "current pane", while an external self-client
166
+ * could race the server under load. Single source of truth: both the
167
+ * spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
162
168
  * two can never drift.
163
169
  */
164
170
  export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
@@ -93,6 +93,15 @@ export async function createSession(opts) {
93
93
  const res = await runTmux({ socket, args, env: opts.env });
94
94
  // Only the new-session command in the `;`-chained invocation emits output.
95
95
  const pane = /^%\d+$/.test(res.stdout.trim()) ? res.stdout.trim() : undefined;
96
+ // Keep the agent pane around after its process exits (so runInTmux can read
97
+ // the exit status and capture the final error), but do NOT keep that behavior
98
+ // for user-created splits. Apply remain-on-exit to the agent pane only, then
99
+ // revert the global default so future splits close automatically when their
100
+ // command finishes. The global-on above protects a fast-exiting agent during
101
+ // the brief window before the pane option is stamped.
102
+ if (pane) {
103
+ await runTmux({ socket, args: ['set-option', '-pt', pane, 'remain-on-exit', 'on', ';', 'set-option', '-g', 'remain-on-exit', 'off'], throwOnError: false }).catch(() => { });
104
+ }
96
105
  const meta = {
97
106
  name: opts.name,
98
107
  socket,
@@ -294,25 +303,31 @@ export async function setSessionHook(name, hook, command, socket) {
294
303
  * in the tmux server instead of launching a second tmux client against
295
304
  * the same socket from inside the hook. That self-client could race the
296
305
  * server under load and leave the dead split behind.
306
+ * v5 — `run-shell -b -C "kill-pane -t #{hook_pane}"` runs the targeted kill-pane
307
+ * in the background inside the server command queue. The synchronous
308
+ * variant could stall the hook on a loaded CI runner, letting the dead
309
+ * split survive until the test's wait timeout expired (flake in CI shard
310
+ * 3: pane-guarded pane-died hook / user split exit).
297
311
  */
298
- export const AGENT_HOOK_SCHEMA = 4;
312
+ export const AGENT_HOOK_SCHEMA = 5;
299
313
  /** Per-session tmux user-option that records which AGENT_HOOK_SCHEMA a session's hook is at. */
300
314
  const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
301
315
  /**
302
316
  * The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
303
317
  * (so the blocking attach in runInTmux returns and the exit status can be read);
304
318
  * a user split's death runs the else-branch, closing just that split. The
305
- * else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
319
+ * else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
306
320
  * target: tmux format-expands the command at fire time and executes it inside
307
- * the server command queue, so the event pane is always the one killed without
308
- * launching a second tmux client against the same socket. A bare `kill-pane`
309
- * relied on the hook context supplying a "current pane", while an external
310
- * self-client could race the server under load. Single source of truth: both
311
- * the spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
321
+ * the server command queue in the background, so the event pane is always the
322
+ * one killed without launching a second tmux client against the same socket and
323
+ * without stalling the hook on a loaded runner. A bare `kill-pane` relied on
324
+ * the hook context supplying a "current pane", while an external self-client
325
+ * could race the server under load. Single source of truth: both the
326
+ * spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
312
327
  * two can never drift.
313
328
  */
314
329
  export function agentPaneDiedHook(sessionName, agentPane) {
315
- return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -C "kill-pane -t #{hook_pane}"'`;
330
+ return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -b -C "kill-pane -t #{hook_pane}"'`;
316
331
  }
317
332
  /** Stamp a session's hook-schema marker to the current version. */
318
333
  export async function markSessionHookSchema(name, socket) {
@@ -1148,6 +1148,72 @@ export function setGlobalDefault(agent, version) {
1148
1148
  }
1149
1149
  writeMeta(meta);
1150
1150
  }
1151
+ /**
1152
+ * Grok's official installer writes into ~/.grok/downloads, which (because we
1153
+ * symlink ~/.grok to the active version home) resolves to the PREVIOUS default
1154
+ * home during `agents add grok@<new>`. Move the freshly-downloaded binary and
1155
+ * its generic platform copy into the target version's isolated home so
1156
+ * `listInstalledVersions` and the shim resolve the right binary.
1157
+ */
1158
+ function relocateGrokBinaryToVersionHome(installedVersion) {
1159
+ const hostGrokLink = path.join(getHomeDir(), agentConfigDirName('grok'));
1160
+ let sourceDownloads;
1161
+ try {
1162
+ sourceDownloads = path.join(fs.readlinkSync(hostGrokLink), 'downloads');
1163
+ }
1164
+ catch {
1165
+ sourceDownloads = path.join(hostGrokLink, 'downloads');
1166
+ }
1167
+ const targetDownloads = path.join(getVersionHomePath('grok', installedVersion), agentConfigDirName('grok'), 'downloads');
1168
+ if (!fs.existsSync(sourceDownloads))
1169
+ return;
1170
+ if (path.resolve(sourceDownloads) === path.resolve(targetDownloads))
1171
+ return;
1172
+ const entries = fs.readdirSync(sourceDownloads).filter((e) => e.startsWith('grok-'));
1173
+ if (entries.length === 0)
1174
+ return;
1175
+ fs.mkdirSync(targetDownloads, { recursive: true });
1176
+ const escapedVersion = installedVersion.replace(/\./g, '\\.');
1177
+ const versionedPattern = new RegExp(`^grok-${escapedVersion}-`);
1178
+ const movedPaths = [];
1179
+ // Move the versioned binary first.
1180
+ for (const entry of entries) {
1181
+ if (!versionedPattern.test(entry))
1182
+ continue;
1183
+ const src = path.join(sourceDownloads, entry);
1184
+ const dst = path.join(targetDownloads, entry);
1185
+ if (fs.existsSync(dst))
1186
+ continue;
1187
+ try {
1188
+ fs.renameSync(src, dst);
1189
+ movedPaths.push(dst);
1190
+ }
1191
+ catch {
1192
+ /* ignore per-file failures */
1193
+ }
1194
+ }
1195
+ if (movedPaths.length === 0)
1196
+ return;
1197
+ // The installer also creates a generic platform binary (e.g. grok-macos-aarch64)
1198
+ // that is a copy of the versioned binary. Move it too if its size matches.
1199
+ const movedSize = fs.statSync(movedPaths[0]).size;
1200
+ for (const entry of entries) {
1201
+ if (versionedPattern.test(entry))
1202
+ continue; // already handled
1203
+ const src = path.join(sourceDownloads, entry);
1204
+ const dst = path.join(targetDownloads, entry);
1205
+ if (fs.existsSync(dst))
1206
+ continue;
1207
+ try {
1208
+ if (fs.statSync(src).size === movedSize) {
1209
+ fs.renameSync(src, dst);
1210
+ }
1211
+ }
1212
+ catch {
1213
+ /* ignore per-file failures */
1214
+ }
1215
+ }
1216
+ }
1151
1217
  /**
1152
1218
  * Install a specific version of an agent.
1153
1219
  */
@@ -1204,6 +1270,12 @@ export async function installVersion(agent, version, onProgress, opts) {
1204
1270
  const versionDir = getVersionDir(agent, installedVersion);
1205
1271
  fs.mkdirSync(versionDir, { recursive: true });
1206
1272
  fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
1273
+ // Grok's installer drops the binary into ~/.grok/downloads, which currently
1274
+ // resolves to the PREVIOUS default home. Move it into the target version home
1275
+ // so version isolation is correct.
1276
+ if (agent === 'grok') {
1277
+ relocateGrokBinaryToVersionHome(installedVersion);
1278
+ }
1207
1279
  // Symlink the installed binary into the version's node_modules/.bin so
1208
1280
  // listInstalledVersions (which checks getBinaryPath) sees this version as
1209
1281
  // installed. Without this, `agents add antigravity@latest` succeeds
@@ -265,6 +265,15 @@ export declare function pruneStaleWorkflowSubagents(sharedAgentsDir: string, wor
265
265
  export declare function countWorkflowSubagents(workflowDir: string): number;
266
266
  /** Convert a canonical agents-cli workflow bundle into a Kimi flow skill. */
267
267
  export declare function transformWorkflowForKimi(workflowPath: string, name: string): string;
268
+ /**
269
+ * Convert a canonical agents-cli workflow bundle into an Antigravity workflow
270
+ * markdown file. Antigravity discovers workflows as flat `<name>.md` files under
271
+ * `~/.gemini/config/global_workflows/` (scanned by `agy` at startup) and exposes
272
+ * each as a `/<name>` slash command. Frontmatter carries the required `description`
273
+ * plus the shared `agents_workflow` ownership marker so agents-cli never clobbers a
274
+ * user-authored workflow of the same name.
275
+ */
276
+ export declare function transformWorkflowForAntigravity(workflowPath: string, name: string): string;
268
277
  /**
269
278
  * Resolve an `agents run <workflow>` reference.
270
279
  *
@@ -6,6 +6,7 @@
6
6
  * are composed at runtime by `agents run <workflow>`.
7
7
  */
8
8
  import * as fs from 'fs';
9
+ import * as os from 'os';
9
10
  import * as path from 'path';
10
11
  import * as yaml from 'yaml';
11
12
  import { capableAgents } from './capabilities.js';
@@ -369,6 +370,26 @@ export function transformWorkflowForKimi(workflowPath, name) {
369
370
  const instructions = (body || fm.description).trim();
370
371
  return `---\n${frontmatter}\n---\n\n\`\`\`d2\nBEGIN -> step -> END\nstep: |md\n${indentD2BlockString(instructions)}\n|\n\`\`\`\n`;
371
372
  }
373
+ /**
374
+ * Convert a canonical agents-cli workflow bundle into an Antigravity workflow
375
+ * markdown file. Antigravity discovers workflows as flat `<name>.md` files under
376
+ * `~/.gemini/config/global_workflows/` (scanned by `agy` at startup) and exposes
377
+ * each as a `/<name>` slash command. Frontmatter carries the required `description`
378
+ * plus the shared `agents_workflow` ownership marker so agents-cli never clobbers a
379
+ * user-authored workflow of the same name.
380
+ */
381
+ export function transformWorkflowForAntigravity(workflowPath, name) {
382
+ const fm = parseWorkflowFrontmatter(workflowPath);
383
+ if (!fm)
384
+ throw new Error(`Invalid WORKFLOW.md in ${workflowPath}`);
385
+ const body = getWorkflowBody(workflowPath) || fm.description;
386
+ const frontmatter = yaml.stringify({
387
+ description: fm.description,
388
+ name: fm.name || name,
389
+ [KIMI_WORKFLOW_MARKER]: name,
390
+ }).trim();
391
+ return `---\n${frontmatter}\n---\n\n${body.trim()}\n`;
392
+ }
372
393
  function expandWorkflowPath(ref) {
373
394
  if (ref === '~')
374
395
  return process.env.HOME ?? ref;
@@ -527,9 +548,24 @@ export function removeWorkflow(name) {
527
548
  return { success: false, error: err.message };
528
549
  }
529
550
  }
551
+ /**
552
+ * Antigravity user workflows are NOT version-isolated. `agy` scans a single,
553
+ * shared, HOME-global directory at startup — `~/.gemini/config/global_workflows/`
554
+ * — and that dir is a real directory in the user's home, never symlinked into a
555
+ * per-version home (only `~/.gemini/antigravity-cli` is version-scoped). Writing
556
+ * into a version home therefore lands somewhere agy never reads. So every
557
+ * antigravity version resolves to the same real shared dir; `versionHome` is
558
+ * intentionally ignored. (Verified via strace of `agy`: it opens
559
+ * `$HOME/.gemini/config/global_workflows/<name>.md` and never the version home.)
560
+ */
561
+ function antigravityWorkflowsDir() {
562
+ return path.join(process.env.HOME ?? os.homedir(), '.gemini', 'config', 'global_workflows');
563
+ }
530
564
  function workflowTargetRoot(agent, versionHome) {
531
565
  if (agent === 'kimi')
532
566
  return path.join(versionHome, '.kimi-code', 'skills');
567
+ if (agent === 'antigravity')
568
+ return antigravityWorkflowsDir();
533
569
  return path.join(versionHome, 'workflows');
534
570
  }
535
571
  /** List workflow names synced into a specific agent version home. */
@@ -543,6 +579,20 @@ export function listWorkflowsForAgent(agent, versionHome) {
543
579
  .filter(d => kimiWorkflowMarker(path.join(skillsDir, d.name, 'SKILL.md')) === d.name)
544
580
  .map(d => d.name);
545
581
  }
582
+ if (agent === 'antigravity') {
583
+ const dir = workflowTargetRoot(agent, versionHome);
584
+ if (!fs.existsSync(dir))
585
+ return [];
586
+ try {
587
+ return fs.readdirSync(dir, { withFileTypes: true })
588
+ .filter(d => d.isFile() && d.name.endsWith('.md') && !d.name.startsWith('.'))
589
+ .map(d => d.name.slice(0, -'.md'.length))
590
+ .filter(base => antigravityWorkflowMarker(path.join(dir, `${base}.md`)) === base);
591
+ }
592
+ catch {
593
+ return [];
594
+ }
595
+ }
546
596
  if (agent === 'goose') {
547
597
  const recipesDir = path.join(versionHome, '.config', 'goose', 'recipes');
548
598
  if (!fs.existsSync(recipesDir))
@@ -678,7 +728,17 @@ export function syncWorkflowToVersion(workflowPath, name, agent, versionHome) {
678
728
  return { success: true };
679
729
  }
680
730
  if (agent === 'antigravity') {
681
- return { success: false, error: 'Antigravity workflow sync is not supported for version homes' };
731
+ const targetDir = workflowTargetRoot(agent, versionHome);
732
+ const targetFile = path.join(targetDir, `${name}.md`);
733
+ if (fs.existsSync(targetFile)) {
734
+ const marker = antigravityWorkflowMarker(targetFile);
735
+ if (marker !== name) {
736
+ return { success: false, error: `Antigravity workflow '${name}' already exists and is not managed by agents-cli` };
737
+ }
738
+ }
739
+ fs.mkdirSync(targetDir, { recursive: true });
740
+ fs.writeFileSync(targetFile, transformWorkflowForAntigravity(workflowPath, name), 'utf-8');
741
+ return { success: true };
682
742
  }
683
743
  const targetDir = path.join(workflowTargetRoot(agent, versionHome), name);
684
744
  fs.mkdirSync(workflowTargetRoot(agent, versionHome), { recursive: true });
@@ -696,7 +756,20 @@ export function syncWorkflowToVersion(workflowPath, name, agent, versionHome) {
696
756
  export function removeWorkflowFromVersion(agent, version, name) {
697
757
  const versionHome = getVersionHomePath(agent, version);
698
758
  if (agent === 'antigravity') {
699
- return { success: false, error: 'Antigravity workflow sync is not supported for version homes' };
759
+ const targetFile = path.join(workflowTargetRoot(agent, versionHome), `${name}.md`);
760
+ if (!fs.existsSync(targetFile)) {
761
+ return { success: false, error: `Workflow '${name}' not synced to ${agent}@${version}` };
762
+ }
763
+ if (antigravityWorkflowMarker(targetFile) !== name) {
764
+ return { success: false, error: `Antigravity workflow '${name}' is not managed by agents-cli` };
765
+ }
766
+ try {
767
+ fs.rmSync(targetFile, { force: true });
768
+ return { success: true };
769
+ }
770
+ catch (err) {
771
+ return { success: false, error: err.message };
772
+ }
700
773
  }
701
774
  if (agent === 'goose') {
702
775
  const recipePath = path.join(versionHome, '.config', 'goose', 'recipes', `${name}.yaml`);
@@ -754,6 +827,15 @@ function kimiWorkflowMarker(filePath) {
754
827
  return null;
755
828
  }
756
829
  }
830
+ function antigravityWorkflowMarker(filePath) {
831
+ try {
832
+ const fm = parseSkillFrontmatter(filePath);
833
+ return typeof fm?.agents_workflow === 'string' ? fm.agents_workflow : null;
834
+ }
835
+ catch {
836
+ return null;
837
+ }
838
+ }
757
839
  /** Iterate all installed (agent, version) pairs that support workflows. */
758
840
  export function iterWorkflowsCapableVersions(filter) {
759
841
  const result = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.60",
3
+ "version": "1.20.61",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",