@phnx-labs/agents-cli 1.20.70 → 1.20.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/exec.js +12 -0
  6. package/dist/commands/fleet-capture.d.ts +14 -0
  7. package/dist/commands/fleet-capture.js +107 -0
  8. package/dist/commands/fork.js +13 -7
  9. package/dist/commands/hosts.js +11 -0
  10. package/dist/commands/secrets.d.ts +1 -0
  11. package/dist/commands/secrets.js +24 -4
  12. package/dist/commands/sessions-tail.js +1 -2
  13. package/dist/commands/sessions.js +6 -6
  14. package/dist/commands/ssh.js +24 -6
  15. package/dist/commands/usage.d.ts +13 -0
  16. package/dist/commands/usage.js +50 -28
  17. package/dist/lib/commands.d.ts +10 -0
  18. package/dist/lib/commands.js +31 -7
  19. package/dist/lib/devices/connect.d.ts +13 -0
  20. package/dist/lib/devices/connect.js +20 -0
  21. package/dist/lib/devices/fleet.d.ts +36 -3
  22. package/dist/lib/devices/fleet.js +42 -4
  23. package/dist/lib/devices/sync.d.ts +30 -0
  24. package/dist/lib/devices/sync.js +50 -0
  25. package/dist/lib/doctor-diff.js +38 -16
  26. package/dist/lib/fleet/apply.d.ts +4 -0
  27. package/dist/lib/fleet/apply.js +28 -5
  28. package/dist/lib/fleet/capture.d.ts +35 -0
  29. package/dist/lib/fleet/capture.js +51 -0
  30. package/dist/lib/fleet/manifest.d.ts +6 -0
  31. package/dist/lib/fleet/manifest.js +24 -1
  32. package/dist/lib/fleet/types.d.ts +24 -1
  33. package/dist/lib/git.d.ts +14 -0
  34. package/dist/lib/git.js +39 -1
  35. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  36. package/dist/lib/menubar/install-menubar.js +26 -13
  37. package/dist/lib/secrets/agent.d.ts +5 -0
  38. package/dist/lib/secrets/agent.js +35 -3
  39. package/dist/lib/secrets/bundles.js +28 -0
  40. package/dist/lib/secrets/index.d.ts +14 -0
  41. package/dist/lib/secrets/index.js +36 -5
  42. package/dist/lib/secrets/session-store.d.ts +90 -0
  43. package/dist/lib/secrets/session-store.js +221 -0
  44. package/dist/lib/session/render.d.ts +9 -1
  45. package/dist/lib/session/render.js +35 -4
  46. package/dist/lib/share/capture.d.ts +2 -0
  47. package/dist/lib/share/capture.js +12 -5
  48. package/dist/lib/skills.d.ts +8 -1
  49. package/dist/lib/skills.js +11 -1
  50. package/dist/lib/types.d.ts +5 -1
  51. package/package.json +1 -1
@@ -22,21 +22,19 @@
22
22
  import * as fs from 'fs';
23
23
  import * as path from 'path';
24
24
  import { AGENTS, agentConfigDirName } from './agents.js';
25
- import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, } from './state.js';
25
+ import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, getActiveRulesPreset, } from './state.js';
26
+ import { composeRulesFromState } from './rules/compose.js';
26
27
  import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, compareVersions, } from './versions.js';
27
28
  import { discoverPlugins, marketplaceSpecForName } from './plugins.js';
28
29
  import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace.js';
29
30
  import { markdownToToml } from './convert.js';
30
- import { resolveImports, supportsRulesImports } from './rules/compile.js';
31
- import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
31
+ import { listCommandsInVersionHome, getVersionCommandsDir, listPluginCommandNames } from './commands.js';
32
32
  import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
33
33
  import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
34
34
  import { supports } from './capabilities.js';
35
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
36
36
  import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
37
37
  const RULES_DOC_FILENAME = 'README.md';
38
- const COMPILED_HEADER = '<!-- Auto-compiled by agents-cli from ~/.agents/rules/AGENTS.md + imports.\n' +
39
- ' Edit the source files under ~/.agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
40
38
  const ALL_KINDS = [
41
39
  'commands',
42
40
  'skills',
@@ -173,9 +171,16 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
173
171
  homePath,
174
172
  });
175
173
  }
174
+ // Plugin-bundled commands (installed as `<plugin>-<cmd>`) are source-managed by
175
+ // their plugin, tracked under the `plugins` kind — not extras. Without this,
176
+ // `agents doctor` shows every plugin command (swarm-plan, code-review, …) as an
177
+ // unmanaged extra, mirroring the orphan false-positive the prune path had.
178
+ const pluginCommands = listPluginCommandNames();
176
179
  for (const name of installed) {
177
180
  if (seen.has(name))
178
181
  continue;
182
+ if (pluginCommands.has(name))
183
+ continue;
179
184
  const extraHome = asSkill
180
185
  ? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
181
186
  : agent === 'goose'
@@ -236,6 +241,14 @@ function dirsContentMatch(src, dst) {
236
241
  return true;
237
242
  }
238
243
  function diffSkills(agent, version, cwd, excludeProject = false) {
244
+ // Native ~/.agents/skills consumers (Gemini, …) read central skills directly.
245
+ // The orchestrator DELETES their version-home skills dir (syncResourcesToVersion)
246
+ // and registers no skills writer, so there is nothing in the version home to
247
+ // reconcile. Mirror diffVersionSkills' native gate (skills.ts) — without it
248
+ // every central skill is false-reported `missing` and held as unreconcilable
249
+ // forever (drift never clears for these agents).
250
+ if (AGENTS[agent].nativeAgentsSkillsDir)
251
+ return [];
239
252
  const homeDir = getVersionSkillsDir(agent, version);
240
253
  const installed = new Set(listSkillsInVersionHome(agent, version));
241
254
  const layerBases = buildLayerBases(cwd, 'skills', { excludeProject });
@@ -380,18 +393,27 @@ function listRulesNames(cwd, excludeProject = false) {
380
393
  }
381
394
  return out;
382
395
  }
383
- function expectedRuleContent(agent, name, sourcePath) {
384
- // AGENTS.md on agents without native @-import support is compiled with imports inlined.
385
- if (name === 'AGENTS' && !supportsRulesImports(agent)) {
386
- const root = readSafe(sourcePath);
387
- if (root == null)
396
+ function expectedRuleContent(agent, name, version, sourcePath) {
397
+ // The instruction file (AGENTS the agent's CLAUDE.md/GEMINI.md/AGENTS.md)
398
+ // is COMPOSED from `subrules/` fragments for the version's active preset
399
+ // that is exactly what the rules writer emits (see
400
+ // staleness/writers/rules.ts composeRulesFromState). Compare against the
401
+ // same rendering so a correctly-synced home file reconciles instead of being
402
+ // held forever against the raw `rules/AGENTS.md` (the whole-repo doc, which a
403
+ // preset composition deliberately never equals — system subrules don't
404
+ // auto-append). The `agent` is not part of the rendering (every capable agent
405
+ // gets identical composed bytes); it is kept for symmetry with the writer's
406
+ // per-agent dispatch and future per-agent presets.
407
+ if (name === 'AGENTS') {
408
+ try {
409
+ return composeRulesFromState({ preset: getActiveRulesPreset(agent, version) }).content;
410
+ }
411
+ catch {
412
+ // No rules.yaml / unknown preset — the writer skips too; treat as unknown.
388
413
  return null;
389
- // Compile relative to the source's own dir so project-layer AGENTS.md resolves
390
- // its imports relative to the project rules dir, not the user one.
391
- const baseDir = path.dirname(sourcePath);
392
- const { content } = resolveImports(root, baseDir);
393
- return COMPILED_HEADER + content;
414
+ }
394
415
  }
416
+ // Any sibling top-level rules file syncs as a raw copy.
395
417
  return readSafe(sourcePath);
396
418
  }
397
419
  function diffRules(agent, version, cwd, excludeProject = false) {
@@ -418,7 +440,7 @@ function diffRules(agent, version, cwd, excludeProject = false) {
418
440
  rows.push({ kind: 'rules', name, status: 'missing', source: src.layer, sourcePath: src.path });
419
441
  continue;
420
442
  }
421
- const expected = expectedRuleContent(agent, name, src.path);
443
+ const expected = expectedRuleContent(agent, name, version, src.path);
422
444
  const actual = readSafe(homePath);
423
445
  if (expected == null || actual == null) {
424
446
  rows.push({ kind: 'rules', name, status: 'diff', source: src.layer, sourcePath: src.path, homePath });
@@ -30,6 +30,10 @@ export interface DiffContext {
30
30
  /** agents-cli version the source is on — the fleet target version. */
31
31
  targetCliVersion: string;
32
32
  sourceAuth: SourceAuth;
33
+ /** Secrets-bundle names the profile declares. Values are keychain-local and
34
+ * can't be pushed, so each reachable device surfaces them as a manual recreate
35
+ * (`needs-secret`) — informational, never an executed mutation. */
36
+ secretsBundles?: string[];
33
37
  }
34
38
  /** Pure: desired vs probed -> per-device diff + flat action list. */
35
39
  export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
@@ -44,6 +44,7 @@ export function diffFleet(desired, probes, ctx) {
44
44
  };
45
45
  const rowActions = [];
46
46
  const loginBlocked = [];
47
+ const secretsNeeded = [];
47
48
  if (probe.reachable) {
48
49
  // agents-cli presence.
49
50
  if (!probe.cliVersion) {
@@ -82,8 +83,18 @@ export function diffFleet(desired, probes, ctx) {
82
83
  }
83
84
  }
84
85
  }
86
+ // secrets — surfaced, never pushed (values are keychain-local). Declared
87
+ // once at the manifest level, so every reachable device gets the same
88
+ // manual-recreate reminder. Not an executable action (see idempotence
89
+ // check in commands/apply.ts, which excludes needs-* kinds).
90
+ if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
91
+ for (const bundle of ctx.secretsBundles) {
92
+ secretsNeeded.push(bundle);
93
+ rowActions.push({ device: d.device, kind: 'needs-secret', detail: `recreate secrets bundle '${bundle}' (\`agents secrets create ${bundle}\`)` });
94
+ }
95
+ }
85
96
  }
86
- devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked });
97
+ devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked, secretsNeeded });
87
98
  actions.push(...rowActions);
88
99
  }
89
100
  return { devices, actions };
@@ -161,11 +172,18 @@ export function reconcileDevice(row, device, ctx) {
161
172
  steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
162
173
  ok = ok && r.code === 0;
163
174
  }
164
- // 3. config sync.
175
+ // 3. config sync — one `agents sync <scope>` per declared scope. Each scope is
176
+ // a positional repo target (system/user/project/alias); a bare `agents sync`
177
+ // would ignore the profile's declared scopes entirely.
165
178
  if (row.actions.some((a) => a.kind === 'sync-config')) {
166
- const r = sshAgents(['sync']);
167
- steps.push({ kind: 'sync-config', ok: r.code === 0, detail: `sync config` });
168
- ok = ok && r.code === 0;
179
+ const scopes = row.desired.sync.length > 0 ? row.desired.sync : [''];
180
+ let syncOk = true;
181
+ for (const scope of scopes) {
182
+ const r = sshAgents(scope ? ['sync', scope] : ['sync']);
183
+ syncOk = syncOk && r.code === 0;
184
+ }
185
+ steps.push({ kind: 'sync-config', ok: syncOk, detail: `sync config (${row.desired.sync.join(', ') || 'default'})` });
186
+ ok = ok && syncOk;
169
187
  }
170
188
  // 4. login propagation — one bundle for all pushable agents on this device.
171
189
  const pushAgents = [...new Set(row.actions.filter((a) => a.kind === 'push-login').map((a) => a.agent))];
@@ -182,6 +200,11 @@ export function reconcileDevice(row, device, ctx) {
182
200
  for (const blocked of row.loginBlocked) {
183
201
  steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
184
202
  }
203
+ // Surface declared secrets bundles as (non-fatal) manual-recreate reminders —
204
+ // values are keychain-local, never captured or pushed.
205
+ for (const bundle of row.secretsNeeded) {
206
+ steps.push({ kind: 'needs-secret', ok: false, detail: `secrets bundle '${bundle}' must exist on ${row.device} (\`agents ssh ${row.device} -- secrets create ${bundle}\`)` });
207
+ }
185
208
  return { device: row.device, ok, steps };
186
209
  }
187
210
  /** Run a pool of async tasks with a concurrency cap, preserving input order. */
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Serialize the live environment into a `fleet:` manifest.
3
+ *
4
+ * `captureFleet` is PURE — it takes the previous manifest plus already-gathered
5
+ * inputs (device names, per-device agent specs, browser profiles, secret-bundle
6
+ * names, routine names) and returns the new manifest. All I/O (registry read,
7
+ * `agents secrets`/`routines` enumeration, YAML write) happens in the command
8
+ * (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
9
+ * NAMES ONLY, never IPs/usernames — trivially unit-testable.
10
+ *
11
+ * Additive by design: it merges OVER an existing manifest and never clobbers a
12
+ * hand-authored per-device override (`agents:` you set by hand wins over a
13
+ * captured one). The captured roster reflects live state, so it becomes the
14
+ * source of truth for WHICH devices exist.
15
+ */
16
+ import type { FleetManifest, FleetDefaults } from './types.js';
17
+ export interface CaptureInputs {
18
+ /** Registered, non-control device names to record (the roster — names only). */
19
+ devices: string[];
20
+ /** Optional per-device agent specs (e.g. from `--from-pins`), keyed by name. */
21
+ agentsByDevice?: Record<string, string[]>;
22
+ /** Fleet defaults to seed when the manifest has none (source's own agents). */
23
+ defaults?: FleetDefaults;
24
+ /** Secrets-bundle NAMES to ensure exist (values stay in the keychain). */
25
+ secretsBundles?: string[];
26
+ /** Routine NAMES that should be active on the fleet. */
27
+ routines?: string[];
28
+ }
29
+ /**
30
+ * Build the new `fleet:` manifest from the previous one and captured inputs.
31
+ * Pure — no SSH, no filesystem, no registry. The returned object carries device
32
+ * names + desired state only; a caller that serializes it to YAML can assert no
33
+ * address/username ever appears.
34
+ */
35
+ export declare function captureFleet(prev: FleetManifest | undefined, inputs: CaptureInputs): FleetManifest;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Serialize the live environment into a `fleet:` manifest.
3
+ *
4
+ * `captureFleet` is PURE — it takes the previous manifest plus already-gathered
5
+ * inputs (device names, per-device agent specs, browser profiles, secret-bundle
6
+ * names, routine names) and returns the new manifest. All I/O (registry read,
7
+ * `agents secrets`/`routines` enumeration, YAML write) happens in the command
8
+ * (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
9
+ * NAMES ONLY, never IPs/usernames — trivially unit-testable.
10
+ *
11
+ * Additive by design: it merges OVER an existing manifest and never clobbers a
12
+ * hand-authored per-device override (`agents:` you set by hand wins over a
13
+ * captured one). The captured roster reflects live state, so it becomes the
14
+ * source of truth for WHICH devices exist.
15
+ */
16
+ /**
17
+ * Build the new `fleet:` manifest from the previous one and captured inputs.
18
+ * Pure — no SSH, no filesystem, no registry. The returned object carries device
19
+ * names + desired state only; a caller that serializes it to YAML can assert no
20
+ * address/username ever appears.
21
+ */
22
+ export function captureFleet(prev, inputs) {
23
+ const prevDevices = prev && prev.devices !== 'all' && typeof prev.devices === 'object'
24
+ ? prev.devices
25
+ : {};
26
+ // Roster: explicit map of the captured names. A hand-authored override for a
27
+ // device that still exists is preserved; a captured agent list only fills in
28
+ // when the manifest didn't already pin one for that device.
29
+ const devices = {};
30
+ for (const name of inputs.devices) {
31
+ const prevOverride = prevDevices[name] ?? {};
32
+ const override = { ...prevOverride };
33
+ const captured = inputs.agentsByDevice?.[name];
34
+ if (override.agents === undefined && captured && captured.length > 0) {
35
+ override.agents = captured;
36
+ }
37
+ devices[name] = override;
38
+ }
39
+ const manifest = {
40
+ // Keep hand-authored defaults; otherwise seed from the source snapshot.
41
+ defaults: prev?.defaults ?? inputs.defaults ?? {},
42
+ devices,
43
+ };
44
+ const bundles = inputs.secretsBundles ?? prev?.secrets?.bundles;
45
+ if (bundles && bundles.length > 0)
46
+ manifest.secrets = { bundles: [...bundles].sort() };
47
+ const routines = inputs.routines ?? prev?.routines;
48
+ if (routines && routines.length > 0)
49
+ manifest.routines = [...routines].sort();
50
+ return manifest;
51
+ }
@@ -19,6 +19,12 @@ export interface ResolveContext {
19
19
  registeredDevices: string[];
20
20
  /** The source machine, always excluded from the target set. */
21
21
  source: string;
22
+ /** Names the bootstrap could not resolve from Tailscale (off-tailnet, ignored,
23
+ * or a typo). These are SKIPPED with the caller's warning rather than aborting
24
+ * the whole reconcile — a manifest naming an asleep laptop must not hard-fail
25
+ * every other device. Without a bootstrap, this is empty and an unregistered
26
+ * name still throws (genuine misconfig, caught early). */
27
+ unresolved?: string[];
22
28
  }
23
29
  /**
24
30
  * Expand a manifest into concrete per-device desired states. `devices: all`
@@ -66,7 +66,25 @@ export function parseFleetManifest(raw) {
66
66
  else {
67
67
  throw new Error(`fleet: devices must be the string 'all' or a mapping of device -> overrides (got ${JSON.stringify(o.devices)}).`);
68
68
  }
69
- return { defaults, devices };
69
+ const manifest = { defaults, devices };
70
+ // Additive, backward-compatible extras (captured by `agents fleet capture`).
71
+ if (o.secrets !== undefined) {
72
+ if (typeof o.secrets !== 'object' || o.secrets === null || Array.isArray(o.secrets)) {
73
+ throw new Error('fleet: secrets must be a mapping with a `bundles:` list.');
74
+ }
75
+ const bundles = o.secrets.bundles;
76
+ if (bundles !== undefined && !isStringArray(bundles)) {
77
+ throw new Error('fleet: secrets.bundles must be a list of bundle names (e.g. [attio]).');
78
+ }
79
+ manifest.secrets = { bundles: bundles };
80
+ }
81
+ if (o.routines !== undefined) {
82
+ if (!isStringArray(o.routines)) {
83
+ throw new Error('fleet: routines must be a list of routine names.');
84
+ }
85
+ manifest.routines = o.routines;
86
+ }
87
+ return manifest;
70
88
  }
71
89
  /** Read a YAML file and extract + validate its `fleet:` block. */
72
90
  export function readFleetFile(filePath) {
@@ -115,9 +133,14 @@ export function resolveDesired(manifest, ctx) {
115
133
  }
116
134
  return out;
117
135
  }
136
+ const unresolved = new Set(ctx.unresolved ?? []);
118
137
  for (const [name, override] of Object.entries(manifest.devices)) {
119
138
  if (name === ctx.source)
120
139
  continue;
140
+ // Bootstrap couldn't register this name (off-tailnet / ignored / typo) —
141
+ // skip it (the caller already surfaced it) instead of aborting the run.
142
+ if (unresolved.has(name))
143
+ continue;
121
144
  if (!ctx.registeredDevices.includes(name)) {
122
145
  throw new Error(`fleet: device '${name}' is not a registered device. Run \`agents devices add ${name}\` or fix the manifest.`);
123
146
  }
@@ -42,6 +42,23 @@ export interface FleetDeviceOverride {
42
42
  export interface FleetManifest {
43
43
  defaults?: FleetDefaults;
44
44
  devices: 'all' | Record<string, FleetDeviceOverride>;
45
+ /**
46
+ * Fleet-wide extras captured by `agents fleet capture` so a fresh machine can
47
+ * reconstruct the whole environment, not just installed agents. All additive,
48
+ * portable, and LEAK-FREE — names only, never connection details.
49
+ *
50
+ * (Browser profiles are deliberately NOT captured here: the central `browser:`
51
+ * block already syncs verbatim via `agents repo push/pull`, so duplicating it
52
+ * would be redundant — and its ssh:// endpoints can carry `user@host`, which
53
+ * must never be copied into a second location.)
54
+ */
55
+ /** Secrets-bundle NAMES to ensure exist — values live in the keychain and are
56
+ * never captured or pushed; `apply` surfaces missing bundles to recreate. */
57
+ secrets?: {
58
+ bundles?: string[];
59
+ };
60
+ /** Routine NAMES that should be active on the fleet (files sync via the repo). */
61
+ routines?: string[];
45
62
  }
46
63
  /**
47
64
  * A device's desired state after merging defaults with its override and
@@ -74,7 +91,10 @@ export interface DeviceProbe {
74
91
  note?: string;
75
92
  }
76
93
  /** One planned action against a device, in a single reconcile dimension. */
77
- export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login';
94
+ export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
95
+ /** Secrets bundles the profile declares but that can't be pushed (values are
96
+ * keychain-local) — surfaced as a manual recreate, like `needs-login`. */
97
+ | 'needs-secret';
78
98
  export interface FleetAction {
79
99
  device: string;
80
100
  kind: FleetActionKind;
@@ -101,6 +121,9 @@ export interface DeviceDiff {
101
121
  /** Agents that must be logged in on the device but can't be propagated
102
122
  * (source token is device-bound, e.g. macOS keychain). Surfaced, not faked. */
103
123
  loginBlocked: string[];
124
+ /** Secrets-bundle names the profile declares that must be recreated on the
125
+ * device (values are keychain-local — never captured or pushed). Surfaced. */
126
+ secretsNeeded: string[];
104
127
  }
105
128
  /** A portable auth file captured from a source agent home, ready to propagate. */
106
129
  export interface AuthFilePayload {
package/dist/lib/git.d.ts CHANGED
@@ -219,6 +219,20 @@ export declare function displayHomePath(dir: string): string;
219
219
  * branch reconciles instead of failing with "Need to specify how to reconcile
220
220
  * divergent branches".
221
221
  */
222
+ /**
223
+ * Auto-commit the machine's OWN generated per-device meta file
224
+ * (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
225
+ *
226
+ * That file is committed + synced (so every machine can introspect every other
227
+ * machine's pins), but each box rewrites its own copy whenever a pin changes —
228
+ * leaving the tree perpetually dirty and wedging `agents repo pull` (which
229
+ * refuses a dirty tree). This durably commits just that one path (explicit
230
+ * pathspec) so the pull can proceed; genuine user edits to OTHER files are left
231
+ * untouched and still (correctly) block the pull. No-op when the meta path isn't
232
+ * inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
233
+ * path, or null. `metaAbs` is injectable for tests; defaults to the live path.
234
+ */
235
+ export declare function commitOwnDeviceMeta(dir: string, metaAbs?: string): Promise<string | null>;
222
236
  export declare function pullRepo(dir: string): Promise<{
223
237
  success: boolean;
224
238
  commit: string;
package/dist/lib/git.js CHANGED
@@ -10,7 +10,7 @@ import * as fs from 'fs';
10
10
  import * as os from 'os';
11
11
  import * as path from 'path';
12
12
  import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
13
- import { getPackageLocalPath } from './state.js';
13
+ import { getPackageLocalPath, getDeviceMetaPath } from './state.js';
14
14
  import { DEFAULT_SYSTEM_REPO, systemRepoSlug } from './types.js';
15
15
  /**
16
16
  * Validate that a clone/pull source uses a safe git transport before it is
@@ -733,9 +733,47 @@ export function displayHomePath(dir) {
733
733
  * branch reconciles instead of failing with "Need to specify how to reconcile
734
734
  * divergent branches".
735
735
  */
736
+ /**
737
+ * Auto-commit the machine's OWN generated per-device meta file
738
+ * (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
739
+ *
740
+ * That file is committed + synced (so every machine can introspect every other
741
+ * machine's pins), but each box rewrites its own copy whenever a pin changes —
742
+ * leaving the tree perpetually dirty and wedging `agents repo pull` (which
743
+ * refuses a dirty tree). This durably commits just that one path (explicit
744
+ * pathspec) so the pull can proceed; genuine user edits to OTHER files are left
745
+ * untouched and still (correctly) block the pull. No-op when the meta path isn't
746
+ * inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
747
+ * path, or null. `metaAbs` is injectable for tests; defaults to the live path.
748
+ */
749
+ export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
750
+ const resolvedDir = path.resolve(dir);
751
+ const resolvedMeta = path.resolve(metaAbs);
752
+ if (resolvedMeta !== resolvedDir && !resolvedMeta.startsWith(resolvedDir + path.sep)) {
753
+ return null; // meta lives outside this repo — not ours to commit here
754
+ }
755
+ const rel = path.relative(resolvedDir, resolvedMeta).split(path.sep).join('/');
756
+ try {
757
+ const git = simpleGit(dir);
758
+ const status = await git.status();
759
+ const dirty = status.files.some((f) => f.path === rel);
760
+ if (!dirty)
761
+ return null;
762
+ await git.add([rel]);
763
+ const machine = path.basename(path.dirname(resolvedMeta));
764
+ await git.commit(`chore(devices): snapshot ${machine} agent pins`, [rel]);
765
+ return rel;
766
+ }
767
+ catch {
768
+ return null; // best-effort — never let this block the pull path
769
+ }
770
+ }
736
771
  export async function pullRepo(dir) {
737
772
  try {
738
773
  const git = simpleGit(dir);
774
+ // Commit this machine's own device-meta first so a per-machine pin change
775
+ // never wedges the pull. Genuine edits elsewhere still block below.
776
+ await commitOwnDeviceMeta(dir);
739
777
  const status = await git.status();
740
778
  if (!status.isClean()) {
741
779
  return {
@@ -26,6 +26,18 @@ export declare function menubarServiceInstalled(): boolean;
26
26
  export declare function ensureMenubarAppInstalled(opts?: {
27
27
  forceReinstall?: boolean;
28
28
  }): string | null;
29
+ /**
30
+ * Restart the menu-bar launchd agent from a clean state.
31
+ *
32
+ * Always `bootout` first: on modern macOS `bootstrap` fails when the job is
33
+ * already bootstrapped, and the deprecated `load -w` fallback is unreliable.
34
+ * A prior WindowServer disconnect can leave the job throttled so that a plain
35
+ * `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
36
+ * is the only sequence that reliably re-attaches the status item.
37
+ */
38
+ export declare function restartMenubarLaunchAgent(uid: number, plist: string, exec?: (cmd: string, args: readonly string[], opts: {
39
+ stdio: ['ignore', 'ignore', 'ignore'];
40
+ }) => Buffer): void;
29
41
  /**
30
42
  * Install + start the menu-bar helper as a launchd user service (idempotent).
31
43
  * Clears the sticky opt-out, installs the .app, writes the plist, and
@@ -210,6 +210,31 @@ ${envXml}
210
210
  </dict>
211
211
  </plist>`;
212
212
  }
213
+ /**
214
+ * Restart the menu-bar launchd agent from a clean state.
215
+ *
216
+ * Always `bootout` first: on modern macOS `bootstrap` fails when the job is
217
+ * already bootstrapped, and the deprecated `load -w` fallback is unreliable.
218
+ * A prior WindowServer disconnect can leave the job throttled so that a plain
219
+ * `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
220
+ * is the only sequence that reliably re-attaches the status item.
221
+ */
222
+ export function restartMenubarLaunchAgent(uid, plist, exec = execFileSync) {
223
+ const serviceTarget = `gui/${uid}/${SERVICE_LABEL}`;
224
+ const opts = { stdio: ['ignore', 'ignore', 'ignore'] };
225
+ try {
226
+ exec('launchctl', ['bootout', serviceTarget], opts);
227
+ }
228
+ catch { /* may not be loaded */ }
229
+ try {
230
+ exec('launchctl', ['bootstrap', `gui/${uid}`, plist], opts);
231
+ }
232
+ catch { /* best effort */ }
233
+ try {
234
+ exec('launchctl', ['kickstart', serviceTarget], opts);
235
+ }
236
+ catch { /* best effort */ }
237
+ }
213
238
  /**
214
239
  * Install + start the menu-bar helper as a launchd user service (idempotent).
215
240
  * Clears the sticky opt-out, installs the .app, writes the plist, and
@@ -232,19 +257,7 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
232
257
  fs.mkdirSync(path.dirname(plist), { recursive: true });
233
258
  fs.writeFileSync(plist, generateServicePlist(exec));
234
259
  const uid = process.getuid?.() ?? 0;
235
- try {
236
- execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
237
- }
238
- catch {
239
- try {
240
- execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
241
- }
242
- catch { /* may already be loaded */ }
243
- }
244
- try {
245
- execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
246
- }
247
- catch { /* best effort */ }
260
+ restartMenubarLaunchAgent(uid, plist);
248
261
  // Stamp the version we just installed so the upgrade self-heal can tell when
249
262
  // a later release ships a newer helper that needs reinstalling.
250
263
  try {
@@ -280,6 +280,11 @@ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: Secr
280
280
  * per ~7d), so auto-caching is ON by default; opt out with
281
281
  * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
282
282
  export declare function secretsAgentAutoEnabled(): boolean;
283
+ /** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
284
+ * default (the secure split default: survive restart, re-lock on sleep); set
285
+ * `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
286
+ * Best-effort; an unreadable meta reads as off. */
287
+ export declare function secretsAgentDurable(): boolean;
283
288
  /** Minimum / maximum bounds for the configurable hold window. A too-small value
284
289
  * would defeat the broker (constant re-prompts); a too-large one pins secrets in
285
290
  * memory far longer than intended. */
@@ -34,6 +34,7 @@ import { isAlive } from '../platform/process.js';
34
34
  import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
+ import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
37
38
  /** Bumped when the wire protocol changes; a client that pings a mismatched
38
39
  * server kills and respawns it rather than talking a stale dialect. */
39
40
  const PROTOCOL_VERSION = 1;
@@ -93,6 +94,20 @@ export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
93
94
  function onDarwin() {
94
95
  return process.platform === 'darwin';
95
96
  }
97
+ /**
98
+ * Build the broker's in-memory store, REHYDRATED from the durable session store
99
+ * (session-store.ts) so an unlock survives a daemon restart / agents-cli upgrade.
100
+ * Expired sessions are dropped; `--durable` and default entries alike come back
101
+ * (SLEEP already pruned the non-durable ones from the keychain while the broker
102
+ * was alive). Empty off darwin or when nothing was held.
103
+ */
104
+ function rehydrateStore(now = Date.now()) {
105
+ const store = new Map();
106
+ for (const { name, entry } of rehydrateSessions(now)) {
107
+ store.set(name, { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt });
108
+ }
109
+ return store;
110
+ }
96
111
  /** Broker runtime dir under the regenerable cache, locked to the user (0700).
97
112
  * AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
98
113
  * run a real broker on a temp socket without touching the user's real dir. */
@@ -442,7 +457,7 @@ export async function runSecretsAgent(opts = {}) {
442
457
  throw err;
443
458
  }
444
459
  }
445
- const store = new Map();
460
+ const store = rehydrateStore();
446
461
  // emptySince tracks the last moment the store held something; the sweep exits
447
462
  // the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
448
463
  let emptySince = Date.now();
@@ -589,6 +604,9 @@ export async function runSecretsAgent(opts = {}) {
589
604
  if (shouldWipeOnWatchEvent(chunk)) {
590
605
  store.clear();
591
606
  emptySince = Date.now();
607
+ // Split default: delete non-`--durable` durable sessions too, so a default
608
+ // unlock re-locks on sleep; `--durable` ones survive (session-store.ts).
609
+ pruneSessionsOnSleep();
592
610
  }
593
611
  });
594
612
  watcher.on('error', () => { watcher = null; });
@@ -627,7 +645,7 @@ export async function runSecretsAgent(opts = {}) {
627
645
  export async function startHostedBroker() {
628
646
  if (!onDarwin())
629
647
  return null;
630
- const store = new Map();
648
+ const store = rehydrateStore();
631
649
  const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
632
650
  const handle = (req) => handleAgentRequest(store, req);
633
651
  const onConn = makeConnectionHandler(handle, readAgentToken);
@@ -651,8 +669,10 @@ export async function startHostedBroker() {
651
669
  watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
652
670
  watcher.stdout?.setEncoding('utf-8');
653
671
  watcher.stdout?.on('data', (chunk) => {
654
- if (shouldWipeOnWatchEvent(chunk))
672
+ if (shouldWipeOnWatchEvent(chunk)) {
655
673
  store.clear();
674
+ pruneSessionsOnSleep(); // split default — see runSecretsAgent's handler
675
+ }
656
676
  });
657
677
  watcher.on('error', () => { watcher = null; });
658
678
  }
@@ -912,6 +932,18 @@ export function secretsAgentAutoEnabled() {
912
932
  return true;
913
933
  }
914
934
  }
935
+ /** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
936
+ * default (the secure split default: survive restart, re-lock on sleep); set
937
+ * `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
938
+ * Best-effort; an unreadable meta reads as off. */
939
+ export function secretsAgentDurable() {
940
+ try {
941
+ return readMeta().secrets?.agent?.durable === true;
942
+ }
943
+ catch {
944
+ return false;
945
+ }
946
+ }
915
947
  /** Minimum / maximum bounds for the configurable hold window. A too-small value
916
948
  * would defeat the broker (constant re-prompts); a too-large one pins secrets in
917
949
  * memory far longer than intended. */