@phnx-labs/agents-cli 1.20.33 → 1.20.34

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 (56) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/computer.d.ts +23 -0
  4. package/dist/commands/computer.js +45 -3
  5. package/dist/commands/doctor.d.ts +10 -0
  6. package/dist/commands/doctor.js +49 -0
  7. package/dist/commands/import.js +1 -1
  8. package/dist/commands/rules.js +1 -1
  9. package/dist/commands/secrets-migrate.js +23 -11
  10. package/dist/commands/secrets.d.ts +20 -0
  11. package/dist/commands/secrets.js +53 -1
  12. package/dist/commands/status.d.ts +12 -0
  13. package/dist/commands/status.js +81 -0
  14. package/dist/commands/teams.js +70 -6
  15. package/dist/commands/versions.js +2 -1
  16. package/dist/commands/view.d.ts +39 -0
  17. package/dist/commands/view.js +194 -75
  18. package/dist/index.js +4 -2
  19. package/dist/lib/acp/harnesses.d.ts +1 -1
  20. package/dist/lib/acp/harnesses.js +2 -2
  21. package/dist/lib/agents.d.ts +12 -0
  22. package/dist/lib/agents.js +115 -32
  23. package/dist/lib/browser/chrome.js +20 -0
  24. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  25. package/dist/lib/browser/drivers/ssh.js +18 -3
  26. package/dist/lib/doctor-diff.js +29 -2
  27. package/dist/lib/drift-sync.d.ts +43 -0
  28. package/dist/lib/drift-sync.js +179 -0
  29. package/dist/lib/exec.d.ts +15 -0
  30. package/dist/lib/exec.js +21 -11
  31. package/dist/lib/platform/winpath.d.ts +31 -2
  32. package/dist/lib/platform/winpath.js +133 -24
  33. package/dist/lib/pwsh.d.ts +11 -0
  34. package/dist/lib/pwsh.js +13 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/secrets/agent.d.ts +42 -1
  38. package/dist/lib/secrets/agent.js +89 -11
  39. package/dist/lib/secrets/bundles.js +40 -9
  40. package/dist/lib/secrets/filestore.js +31 -1
  41. package/dist/lib/secrets/index.d.ts +33 -1
  42. package/dist/lib/secrets/index.js +90 -9
  43. package/dist/lib/secrets/windows.d.ts +74 -0
  44. package/dist/lib/secrets/windows.js +440 -0
  45. package/dist/lib/shims.d.ts +20 -0
  46. package/dist/lib/shims.js +53 -20
  47. package/dist/lib/startup/command-registry.d.ts +1 -0
  48. package/dist/lib/startup/command-registry.js +2 -0
  49. package/dist/lib/sync-status.d.ts +102 -0
  50. package/dist/lib/sync-status.js +135 -0
  51. package/dist/lib/teams/agents.d.ts +24 -0
  52. package/dist/lib/teams/agents.js +30 -1
  53. package/dist/lib/types.d.ts +20 -1
  54. package/dist/lib/usage.d.ts +30 -0
  55. package/dist/lib/usage.js +159 -2
  56. package/package.json +1 -1
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Interactive drift-sync flow — the single "we detected drift, want to fix it?"
3
+ * action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
4
+ * SYNC" row.
5
+ *
6
+ * It composes existing pieces, re-implementing nothing:
7
+ * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
+ * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
+ * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
+ * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
11
+ *
12
+ * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
+ * a single "Sync all detected" both pulls `.system` and reconciles the chosen
14
+ * version homes. The security posture is preserved — the `.system` pull only ever
15
+ * happens on an explicit user choice here, never silently (see auto-pull-worker.ts
16
+ * for why system auto-pull is off by default).
17
+ */
18
+ import chalk from 'chalk';
19
+ import { select, confirm } from '@inquirer/prompts';
20
+ import { AGENTS } from './agents.js';
21
+ import { pullRepo } from './git.js';
22
+ import { heal } from './heal.js';
23
+ import { promptAgentVersionSelection } from './versions.js';
24
+ import { isInteractiveTerminal, isPromptCancelled } from '../commands/utils.js';
25
+ import { computeSyncStatus, } from './sync-status.js';
26
+ const agentName = (id) => AGENTS[id]?.name ?? id;
27
+ /** "claude@2.1.170 2 drifted · 1 missing" for one version. */
28
+ function versionLine(v) {
29
+ const bits = [];
30
+ if (v.counts.drifted)
31
+ bits.push(`${v.counts.drifted} drifted`);
32
+ if (v.counts.missing)
33
+ bits.push(`${v.counts.missing} missing`);
34
+ const label = `${agentName(v.agent)}@${v.version}`;
35
+ return ` ${label.padEnd(28)} ${chalk.yellow(bits.join(' · '))}`;
36
+ }
37
+ /** Render the drift summary (system freshness + each version owed a sync). */
38
+ function renderSummary(status, needing) {
39
+ console.log(chalk.bold('\nSync status'));
40
+ if (status.system.behind > 0) {
41
+ console.log(` ${'.system repo'.padEnd(28)} ${chalk.yellow(`${status.system.behind} commit${status.system.behind === 1 ? '' : 's'} behind`)} ${chalk.gray('— pull recommended')}`);
42
+ }
43
+ for (const v of needing)
44
+ console.log(versionLine(v));
45
+ if (status.totals.orphan > 0) {
46
+ console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
47
+ }
48
+ }
49
+ /** Fast-forward the `.system` repo. Returns whether it actually moved. */
50
+ async function pullSystem(status) {
51
+ if (status.system.behind <= 0)
52
+ return false;
53
+ const res = await pullRepo(status.system.dir);
54
+ if (res.success) {
55
+ console.log(chalk.green(`Pulled .system (+${status.system.behind}).`));
56
+ return true;
57
+ }
58
+ console.log(chalk.red(`Could not pull .system: ${res.error ?? 'unknown error'}`));
59
+ return false;
60
+ }
61
+ /** Reconcile a set of versions grouped by agent via the shared heal engine. */
62
+ async function healVersions(versionsByAgent, cwd) {
63
+ const out = [];
64
+ for (const [agent, versions] of versionsByAgent) {
65
+ if (versions.length === 0)
66
+ continue;
67
+ const res = await heal({ mode: 'full', cwd, agent, versions });
68
+ out.push(...res.versions);
69
+ }
70
+ return out;
71
+ }
72
+ /** Report which agents received what after a heal. */
73
+ function reportHealed(healed) {
74
+ const touched = healed.filter((v) => v.healed.length > 0);
75
+ if (touched.length === 0) {
76
+ console.log(chalk.gray('Nothing to reconcile — homes already matched sources.'));
77
+ return;
78
+ }
79
+ const total = touched.reduce((n, v) => n + v.healed.length, 0);
80
+ const agents = [...new Set(touched.map((v) => agentName(v.agent)))].join(', ');
81
+ console.log(chalk.green(`Synced ${total} resource${total === 1 ? '' : 's'} to ${agents}.`));
82
+ }
83
+ function groupNeeding(needing) {
84
+ const m = new Map();
85
+ for (const v of needing) {
86
+ const list = m.get(v.agent) ?? [];
87
+ list.push(v.version);
88
+ m.set(v.agent, list);
89
+ }
90
+ return m;
91
+ }
92
+ /**
93
+ * The unified "drift detected — sync now?" flow. Returns a structured result so
94
+ * callers (menu-bar, doctor) can report without re-scanning.
95
+ */
96
+ export async function promptDriftSync(opts = {}) {
97
+ const cwd = opts.cwd ?? process.cwd();
98
+ const status = opts.status ?? (await computeSyncStatus({ cwd }));
99
+ const needing = status.agents.filter((a) => a.needsSync);
100
+ const systemBehind = status.system.behind;
101
+ const base = {
102
+ systemBehindBefore: systemBehind,
103
+ systemPulled: false,
104
+ healed: [],
105
+ cancelled: false,
106
+ nothingToDo: false,
107
+ };
108
+ if (systemBehind <= 0 && needing.length === 0) {
109
+ console.log(chalk.green('Everything is in sync.'));
110
+ return { ...base, nothingToDo: true };
111
+ }
112
+ if (!opts.quiet)
113
+ renderSummary(status, needing);
114
+ // Non-interactive OR explicit --yes: reconcile everything detected.
115
+ if (opts.yes || !isInteractiveTerminal()) {
116
+ if (!opts.yes) {
117
+ // Non-TTY without --yes: report, don't act, don't throw.
118
+ console.log(chalk.gray('\nRun `agents status --yes` to sync, or `agents status` in a terminal to choose.'));
119
+ return base;
120
+ }
121
+ const systemPulled = await pullSystem(status);
122
+ const healed = await healVersions(groupNeeding(needing), cwd);
123
+ reportHealed(healed);
124
+ return { ...base, systemPulled, healed };
125
+ }
126
+ // Interactive gate.
127
+ let choice;
128
+ try {
129
+ choice = await select({
130
+ message: 'Sync now?',
131
+ choices: [
132
+ { name: 'Sync all detected', value: 'all' },
133
+ { name: 'Choose agents & resources', value: 'choose' },
134
+ { name: 'No', value: 'no' },
135
+ ],
136
+ default: 'all',
137
+ });
138
+ }
139
+ catch (err) {
140
+ if (isPromptCancelled(err))
141
+ return { ...base, cancelled: true };
142
+ throw err;
143
+ }
144
+ if (choice === 'no')
145
+ return { ...base, cancelled: true };
146
+ if (choice === 'all') {
147
+ const systemPulled = await pullSystem(status);
148
+ const healed = await healVersions(groupNeeding(needing), cwd);
149
+ reportHealed(healed);
150
+ return { ...base, systemPulled, healed };
151
+ }
152
+ // choice === 'choose': optional .system pull, then per-agent/version selection.
153
+ let systemPulled = false;
154
+ if (systemBehind > 0) {
155
+ try {
156
+ const pull = await confirm({ message: `Pull .system (${systemBehind} behind) first?`, default: true });
157
+ if (pull)
158
+ systemPulled = await pullSystem(status);
159
+ }
160
+ catch (err) {
161
+ if (!isPromptCancelled(err))
162
+ throw err;
163
+ return { ...base, systemPulled, cancelled: true };
164
+ }
165
+ }
166
+ const needingAgents = [...new Set(needing.map((a) => a.agent))];
167
+ let selection;
168
+ try {
169
+ selection = await promptAgentVersionSelection(needingAgents);
170
+ }
171
+ catch (err) {
172
+ if (isPromptCancelled(err))
173
+ return { ...base, systemPulled, cancelled: true };
174
+ throw err;
175
+ }
176
+ const healed = await healVersions(selection.versionSelections, cwd);
177
+ reportHealed(healed);
178
+ return { ...base, systemPulled, healed };
179
+ }
@@ -189,6 +189,21 @@ export declare function nativeResume(agent: AgentId): boolean;
189
189
  export declare function buildExecCommand(options: ExecOptions): string[];
190
190
  /** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
191
191
  export declare function execAgent(options: ExecOptions): Promise<number>;
192
+ /**
193
+ * Resolve how to spawn a shim target for a platform. Pure — testable on any host.
194
+ *
195
+ * POSIX always execs the binary directly (no shell). On Windows a bare
196
+ * (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
197
+ * resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
198
+ * or extensionless path is exec'd through the shell / directly. npm always ships
199
+ * a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
200
+ * hands us is the `.cmd` (never a bare `.ps1`).
201
+ */
202
+ export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: string, extraArgs: string[]): {
203
+ command: string;
204
+ args: string[];
205
+ shell: boolean;
206
+ };
192
207
  /**
193
208
  * Transparent passthrough exec for generated shims — the node-side delegate that
194
209
  * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
package/dist/lib/exec.js CHANGED
@@ -374,15 +374,6 @@ export const AGENT_COMMANDS = {
374
374
  edit: [],
375
375
  },
376
376
  },
377
- roo: {
378
- base: ['roo'],
379
- promptFlag: 'positional',
380
- modeFlags: {
381
- plan: ['--mode', 'architect'],
382
- edit: ['--mode', 'code'],
383
- },
384
- modelFlag: '--model',
385
- },
386
377
  // TODO: --output-format json is documented but currently broken upstream
387
378
  // ("flags provided but not defined: -output-format"). Track resolution at
388
379
  // https://github.com/google-antigravity/antigravity-cli/issues/7 before
@@ -665,6 +656,25 @@ export async function execAgent(options) {
665
656
  const { exitCode } = await spawnAgent(options);
666
657
  return exitCode;
667
658
  }
659
+ /**
660
+ * Resolve how to spawn a shim target for a platform. Pure — testable on any host.
661
+ *
662
+ * POSIX always execs the binary directly (no shell). On Windows a bare
663
+ * (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
664
+ * resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
665
+ * or extensionless path is exec'd through the shell / directly. npm always ships
666
+ * a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
667
+ * hands us is the `.cmd` (never a bare `.ps1`).
668
+ */
669
+ export function resolveShimSpawn(platform, binary, extraArgs) {
670
+ if (platform === 'win32') {
671
+ // Use win32 path semantics regardless of the host running this (the platform
672
+ // is the parameter, not process.platform) so `C:\...` reads as absolute.
673
+ const useShell = !path.win32.isAbsolute(binary) || binary.endsWith('.cmd');
674
+ return { command: binary, args: extraArgs, shell: useShell };
675
+ }
676
+ return { command: binary, args: extraArgs, shell: false };
677
+ }
668
678
  /**
669
679
  * Transparent passthrough exec for generated shims — the node-side delegate that
670
680
  * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
@@ -691,9 +701,9 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
691
701
  // mode/effort are required by ExecOptions but unused by buildExecEnv (which only
692
702
  // derives the per-version config-dir env); pass the agent's default to satisfy the type.
693
703
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto' });
694
- const useShell = process.platform === 'win32' && (!path.isAbsolute(binary) || binary.endsWith('.cmd'));
704
+ const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
695
705
  return new Promise((resolve) => {
696
- const child = spawn(binary, [...launchArgs, ...rawArgs], { cwd, stdio: 'inherit', env, shell: useShell });
706
+ const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
697
707
  child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
698
708
  child.on('error', (err) => {
699
709
  process.stderr.write(`agents: failed to launch ${agent}: ${err.message}\n`);
@@ -4,12 +4,41 @@ export interface WinPathResult {
4
4
  alreadyPresent?: boolean;
5
5
  error?: string;
6
6
  }
7
+ /**
8
+ * Compute the new User PATH from the RAW (unexpanded) current value. Pure and
9
+ * OS-independent — the single source of truth for the prepend/dedup logic.
10
+ *
11
+ * Idempotent: returns `{ changed: false }` (value unchanged, verbatim) when
12
+ * `dir` is already the first `;`-split entry. Otherwise removes every existing
13
+ * occurrence of `dir` and prepends it, dropping empty segments — matching POSIX
14
+ * `export PATH="${dir}:$PATH"`. `%VAR%` segments are preserved verbatim (never
15
+ * expanded), which is the #308 regression this fix targets.
16
+ */
17
+ export declare function computeNewUserPath(currentRaw: string, dir: string): {
18
+ changed: boolean;
19
+ value: string;
20
+ };
21
+ /**
22
+ * Decide whether to write the User PATH back as REG_EXPAND_SZ (ExpandString) vs
23
+ * REG_SZ (String). Pure — testable on any host.
24
+ *
25
+ * True (expandable) when the original value was already ExpandString, when the
26
+ * raw value contains a `%VAR%` reference, or when `Path` was absent (default to
27
+ * ExpandString — Windows' native Path type). Only a plain String value with no
28
+ * `%` stays REG_SZ. `originalKind` is the .NET RegistryValueKind name
29
+ * (`ExpandString`/`String`/…) or `null`/`Absent` when `Path` had no value.
30
+ */
31
+ export declare function shouldWriteExpandable(originalKind: string | null, rawValue: string): boolean;
7
32
  /**
8
33
  * Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
9
34
  * already first; moves it to the front when it exists but is positioned later
10
35
  * (e.g. appended by an older install) so it overrides conflicting entries.
11
- * `dir` is passed via an env var so it is never interpolated into the script
12
- * text.
36
+ *
37
+ * Reads the RAW registry value (preserving `%VAR%` and the REG_EXPAND_SZ type),
38
+ * computes the new value in TS via `computeNewUserPath`, and only writes when
39
+ * the value actually changes — preserving the original value type and
40
+ * broadcasting WM_SETTINGCHANGE. `dir` and the computed value are passed via env
41
+ * vars so they are never interpolated into the script text.
13
42
  */
14
43
  export declare function prependToWindowsUserPath(dir: string): WinPathResult;
15
44
  /**
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Windows User PATH + execution-policy primitives.
3
3
  *
4
- * The single place that mutates the Windows User PATH via the .NET environment
5
- * API (which writes the registry AND broadcasts WM_SETTINGCHANGE the correct
6
- * analog of editing a shell rc file: no `setx` truncation, no manual step).
4
+ * The single place that mutates the Windows User PATH. It reads and writes the
5
+ * RAW registry value via `Microsoft.Win32.Registry` (NOT the .NET
6
+ * `[Environment]::*Environment*Variable` API, which expands `%VAR%` references
7
+ * on read and downgrades REG_EXPAND_SZ to REG_SZ on write — issue #308,
8
+ * dotnet/runtime#89695 / #1442). The prepend/dedup itself is computed in TS
9
+ * (`computeNewUserPath`, the single source of truth) so it has unit coverage on
10
+ * every OS; PowerShell is used only for the registry primitives. Because a raw
11
+ * `SetValue` does NOT broadcast the change (the old `[Environment]` API did), the
12
+ * write script broadcasts WM_SETTINGCHANGE itself so a new terminal picks up the
13
+ * PATH without re-login.
7
14
  * Consumers: `shims.ts` (shims dir) and `scripts/postinstall.js` (npm global-bin
8
15
  * dir, so the `agents` command itself resolves).
9
16
  *
@@ -12,34 +19,136 @@
12
19
  */
13
20
  import { execFileSync } from 'child_process';
14
21
  import * as path from 'path';
22
+ /**
23
+ * Compute the new User PATH from the RAW (unexpanded) current value. Pure and
24
+ * OS-independent — the single source of truth for the prepend/dedup logic.
25
+ *
26
+ * Idempotent: returns `{ changed: false }` (value unchanged, verbatim) when
27
+ * `dir` is already the first `;`-split entry. Otherwise removes every existing
28
+ * occurrence of `dir` and prepends it, dropping empty segments — matching POSIX
29
+ * `export PATH="${dir}:$PATH"`. `%VAR%` segments are preserved verbatim (never
30
+ * expanded), which is the #308 regression this fix targets.
31
+ */
32
+ export function computeNewUserPath(currentRaw, dir) {
33
+ const parts = currentRaw.split(';').filter((p) => p !== '');
34
+ if (parts.length > 0 && parts[0] === dir) {
35
+ return { changed: false, value: currentRaw };
36
+ }
37
+ const others = parts.filter((p) => p !== dir);
38
+ return { changed: true, value: [dir, ...others].join(';') };
39
+ }
40
+ /**
41
+ * Decide whether to write the User PATH back as REG_EXPAND_SZ (ExpandString) vs
42
+ * REG_SZ (String). Pure — testable on any host.
43
+ *
44
+ * True (expandable) when the original value was already ExpandString, when the
45
+ * raw value contains a `%VAR%` reference, or when `Path` was absent (default to
46
+ * ExpandString — Windows' native Path type). Only a plain String value with no
47
+ * `%` stays REG_SZ. `originalKind` is the .NET RegistryValueKind name
48
+ * (`ExpandString`/`String`/…) or `null`/`Absent` when `Path` had no value.
49
+ */
50
+ export function shouldWriteExpandable(originalKind, rawValue) {
51
+ if (originalKind === null || originalKind === 'Absent')
52
+ return true;
53
+ if (originalKind === 'ExpandString')
54
+ return true;
55
+ return rawValue.includes('%');
56
+ }
57
+ // Sentinel separating the value kind from the (possibly '%'-laden) raw value in
58
+ // the read script's stdout — PATH entries never contain a newline, so an
59
+ // exclusive line marker parses unambiguously.
60
+ const READ_MARKER = '===AGENTS-PATH-VALUE===';
61
+ // Reads the RAW User PATH preserving REG_EXPAND_SZ: DoNotExpandEnvironmentNames
62
+ // keeps `%VAR%` literal, and GetValueKind reports the original type (throws when
63
+ // 'Path' is absent -> caught, reported as Absent).
64
+ const READ_SCRIPT = [
65
+ "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)",
66
+ 'if ($null -eq $key) {',
67
+ " Write-Output 'KIND:Absent'",
68
+ ` Write-Output '${READ_MARKER}'`,
69
+ " Write-Output ''",
70
+ '} else {',
71
+ " try { $kind = $key.GetValueKind('Path').ToString() } catch { $kind = 'Absent' }",
72
+ " $val = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)",
73
+ " Write-Output ('KIND:' + $kind)",
74
+ ` Write-Output '${READ_MARKER}'`,
75
+ ' Write-Output $val',
76
+ '}',
77
+ ].join('\n');
78
+ // Writes the computed value back with the preserved kind and broadcasts
79
+ // WM_SETTINGCHANGE (raw SetValue does not, unlike the old [Environment] API).
80
+ // The value comes in via AGENTS_WINPATH_VALUE so it is never interpolated into
81
+ // the script text (preserves the no-injection property for '%'-laden paths).
82
+ const WRITE_SCRIPT = [
83
+ "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)",
84
+ "if ($null -eq $key) { $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') }",
85
+ '$val = $env:AGENTS_WINPATH_VALUE',
86
+ "if ($env:AGENTS_WINPATH_EXPAND -eq '1') {",
87
+ ' $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString',
88
+ '} else {',
89
+ ' $kind = [Microsoft.Win32.RegistryValueKind]::String',
90
+ '}',
91
+ "$key.SetValue('Path', $val, $kind)",
92
+ 'Add-Type @"',
93
+ 'using System;',
94
+ 'using System.Runtime.InteropServices;',
95
+ 'public static class AgentsWinPath {',
96
+ ' [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]',
97
+ ' public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);',
98
+ '}',
99
+ '"@',
100
+ '$res = [UIntPtr]::Zero',
101
+ // HWND_BROADCAST=0xffff, WM_SETTINGCHANGE=0x1a, SMTO_ABORTIFHUNG=2, 5s timeout
102
+ "[AgentsWinPath]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$res) | Out-Null",
103
+ "Write-Output 'written'",
104
+ ].join('\n');
105
+ function runPowerShell(script, extraEnv) {
106
+ return execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
107
+ encoding: 'utf-8',
108
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ });
111
+ }
112
+ /** Parse the read script's stdout into the original value kind and RAW value. */
113
+ function parseReadOutput(out) {
114
+ const idx = out.indexOf(READ_MARKER);
115
+ if (idx === -1)
116
+ return { kind: null, raw: '' };
117
+ const head = out.slice(0, idx);
118
+ const kindMatch = head.match(/KIND:(\S+)/);
119
+ const kind = kindMatch ? kindMatch[1] : null;
120
+ // Everything after the marker line, minus the leading/trailing newline PS adds.
121
+ const raw = out
122
+ .slice(idx + READ_MARKER.length)
123
+ .replace(/^\r?\n/, '')
124
+ .replace(/\r?\n$/, '');
125
+ return { kind, raw };
126
+ }
15
127
  /**
16
128
  * Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
17
129
  * already first; moves it to the front when it exists but is positioned later
18
130
  * (e.g. appended by an older install) so it overrides conflicting entries.
19
- * `dir` is passed via an env var so it is never interpolated into the script
20
- * text.
131
+ *
132
+ * Reads the RAW registry value (preserving `%VAR%` and the REG_EXPAND_SZ type),
133
+ * computes the new value in TS via `computeNewUserPath`, and only writes when
134
+ * the value actually changes — preserving the original value type and
135
+ * broadcasting WM_SETTINGCHANGE. `dir` and the computed value are passed via env
136
+ * vars so they are never interpolated into the script text.
21
137
  */
22
138
  export function prependToWindowsUserPath(dir) {
23
- const script = [
24
- '$d = $env:AGENTS_WINPATH_DIR',
25
- "$u = [Environment]::GetEnvironmentVariable('Path','User')",
26
- "if ($null -eq $u) { $u = '' }",
27
- "$parts = @($u -split ';' | Where-Object { $_ -ne '' })",
28
- // Already first — nothing to do
29
- "if ($parts.Count -gt 0 -and $parts[0] -eq $d) { 'present' } else {",
30
- // Remove any existing occurrence then prepend, matching POSIX `export PATH="${dir}:$PATH"`
31
- " $newParts = @($d) + @($parts | Where-Object { $_ -ne $d })",
32
- " [Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), 'User')",
33
- " 'added'",
34
- '}',
35
- ].join('\n');
36
139
  try {
37
- const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
38
- encoding: 'utf-8',
39
- env: { ...process.env, AGENTS_WINPATH_DIR: dir },
40
- stdio: ['ignore', 'pipe', 'pipe'],
41
- }).trim();
42
- return { success: true, alreadyPresent: out.includes('present') };
140
+ const readOut = runPowerShell(READ_SCRIPT);
141
+ const { kind, raw } = parseReadOutput(readOut);
142
+ const { changed, value } = computeNewUserPath(raw, dir);
143
+ if (!changed) {
144
+ return { success: true, alreadyPresent: true };
145
+ }
146
+ const expandable = shouldWriteExpandable(kind, value);
147
+ runPowerShell(WRITE_SCRIPT, {
148
+ AGENTS_WINPATH_VALUE: value,
149
+ AGENTS_WINPATH_EXPAND: expandable ? '1' : '0',
150
+ });
151
+ return { success: true, alreadyPresent: false };
43
152
  }
44
153
  catch (err) {
45
154
  return { success: false, error: `Could not update the Windows user PATH: ${err.message}` };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * PowerShell `-EncodedCommand` helper.
3
+ *
4
+ * Base64 of a script's UTF-16LE bytes is a single quote-free token, so it rides
5
+ * through Node spawn → Windows sshd → cmd.exe with zero escaping hazards
6
+ * (hand-quoted `powershell -Command "…"` is fragile the moment a path, URL, or
7
+ * newline is involved). Shared by the browser SSH driver (which builds a
8
+ * `powershell -EncodedCommand …` string) and the Windows secrets backend (which
9
+ * spawns powershell.exe with an argv array).
10
+ */
11
+ export declare function encodePwshBase64(script: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * PowerShell `-EncodedCommand` helper.
3
+ *
4
+ * Base64 of a script's UTF-16LE bytes is a single quote-free token, so it rides
5
+ * through Node spawn → Windows sshd → cmd.exe with zero escaping hazards
6
+ * (hand-quoted `powershell -Command "…"` is fragile the moment a path, URL, or
7
+ * newline is involved). Shared by the browser SSH driver (which builds a
8
+ * `powershell -EncodedCommand …` string) and the Windows secrets backend (which
9
+ * spawns powershell.exe with an argv array).
10
+ */
11
+ export function encodePwshBase64(script) {
12
+ return Buffer.from(script, 'utf16le').toString('base64');
13
+ }
@@ -25,6 +25,19 @@
25
25
  import type { SecretsBundle } from './bundles.js';
26
26
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
27
27
  export declare const DEFAULT_TTL_MS: number;
28
+ /**
29
+ * Reserved store-key prefix for the `secrets list` metadata snapshot cache.
30
+ * The broker holds the resolved bundle-metadata array (names/policy/timestamps,
31
+ * NO resolved secret values beyond the literals already in metadata) keyed by a
32
+ * hash of the current keychain bundle name-set, so the second and later
33
+ * `secrets list` within the daily window read metadata without a Touch ID
34
+ * prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
35
+ * changes the key and misses the cache automatically — no active invalidation.
36
+ * The '!' sentinel can never collide with a real bundle name
37
+ * (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
38
+ * spawnSync argv (unlike a NUL byte); `status` hides these entries.
39
+ */
40
+ export declare const META_CACHE_PREFIX = "!meta:";
28
41
  /**
29
42
  * Decide whether a persistent broker should self-heal onto freshly-installed
30
43
  * code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
@@ -119,6 +132,14 @@ export type Response = {
119
132
  * unit-testable with a controlled `now`, without a socket or a spawned process.
120
133
  * Mutates `store` in place; returns the wire response.
121
134
  */
135
+ /**
136
+ * Count of real unlocked bundles in the store, excluding the internal
137
+ * `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
138
+ * and idle-exit: a metadata-only store must read as empty so a disposable list
139
+ * cache never blocks an upgrade restart (#435) or an idle one-off broker from
140
+ * exiting. Pure + exported for unit testing.
141
+ */
142
+ export declare function realBundleCount(store: Map<string, StoredBundle>): number;
122
143
  export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
123
144
  /**
124
145
  * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
@@ -140,6 +161,22 @@ export declare function agentGetSync(name: string): {
140
161
  bundle: SecretsBundle;
141
162
  env: Record<string, string>;
142
163
  } | null;
164
+ /**
165
+ * Read the cached `secrets list` metadata snapshot for the given keychain
166
+ * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
167
+ * fast-path socket read (agentGetSync) — no prompt, no wire change. The hash is
168
+ * the cache key: a changed name-set (bundle added/removed/renamed) yields a
169
+ * different key and therefore a clean miss, so the stale set is never served.
170
+ */
171
+ export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] | null;
172
+ /**
173
+ * Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
174
+ * the next `secrets list` within the daily window renders without a prompt.
175
+ * Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
176
+ * reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
177
+ * detached worker (never argv/disk), same as value caching. macOS only.
178
+ */
179
+ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: SecretsBundle[], ttlMs: number): void;
143
180
  /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
144
181
  * broker is the mechanism that delivers the `daily` default policy (one Touch ID
145
182
  * per ~24h), so auto-caching is ON by default; opt out with
@@ -171,7 +208,11 @@ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Reco
171
208
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
172
209
  * wiped, or 0 when no broker is running. */
173
210
  export declare function agentLock(name?: string): Promise<number>;
174
- /** List currently-unlocked bundles, or [] when no broker is running. */
211
+ /** List currently-unlocked bundles, or [] when no broker is running. The
212
+ * internal `secrets list` metadata-cache entry is filtered out here as well as
213
+ * server-side: during a rollout a NEW client can talk to an OLD broker that
214
+ * predates the server-side exclusion, so this keeps the internal entry from
215
+ * surfacing in `agents secrets status` in that skew window. */
175
216
  export declare function agentStatus(): Promise<AgentStatusEntry[]>;
176
217
  /**
177
218
  * Ensure a broker is running and reachable. Returns true once the socket answers