@phnx-labs/agents-cli 1.20.50 → 1.20.52

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 (124) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/browser.js +215 -7
  4. package/dist/commands/cloud.js +7 -25
  5. package/dist/commands/computer.d.ts +1 -0
  6. package/dist/commands/computer.js +129 -8
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +66 -8
  10. package/dist/commands/factory.js +9 -4
  11. package/dist/commands/feed.d.ts +9 -0
  12. package/dist/commands/feed.js +69 -0
  13. package/dist/commands/inspect.js +1 -11
  14. package/dist/commands/logs.d.ts +5 -1
  15. package/dist/commands/logs.js +248 -3
  16. package/dist/commands/mcp.js +9 -6
  17. package/dist/commands/message.js +1 -4
  18. package/dist/commands/profiles.js +1 -18
  19. package/dist/commands/repo.js +33 -14
  20. package/dist/commands/resource-view.d.ts +1 -0
  21. package/dist/commands/resource-view.js +5 -17
  22. package/dist/commands/secrets.d.ts +23 -0
  23. package/dist/commands/secrets.js +174 -70
  24. package/dist/commands/sessions-picker.js +1 -18
  25. package/dist/commands/sessions.js +6 -8
  26. package/dist/commands/teams-picker.js +1 -32
  27. package/dist/commands/teams.js +5 -27
  28. package/dist/commands/tmux.js +1 -3
  29. package/dist/commands/view.js +1 -9
  30. package/dist/commands/worktree.js +1 -4
  31. package/dist/index.js +6 -2
  32. package/dist/lib/agents.d.ts +0 -4
  33. package/dist/lib/agents.js +20 -33
  34. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  35. package/dist/lib/auto-dispatch-linear.js +107 -0
  36. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  37. package/dist/lib/auto-dispatch-provider.js +25 -0
  38. package/dist/lib/auto-dispatch.d.ts +87 -0
  39. package/dist/lib/auto-dispatch.js +142 -0
  40. package/dist/lib/browser/cdp.js +11 -2
  41. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  42. package/dist/lib/browser/drivers/ssh.js +57 -18
  43. package/dist/lib/browser/login-detection.d.ts +94 -0
  44. package/dist/lib/browser/login-detection.js +274 -0
  45. package/dist/lib/browser/profiles.d.ts +17 -8
  46. package/dist/lib/browser/profiles.js +27 -8
  47. package/dist/lib/browser/refs.js +1 -5
  48. package/dist/lib/browser/secret-ref.d.ts +10 -0
  49. package/dist/lib/browser/secret-ref.js +14 -0
  50. package/dist/lib/browser/service.js +14 -12
  51. package/dist/lib/cli-resources.d.ts +0 -2
  52. package/dist/lib/cli-resources.js +30 -13
  53. package/dist/lib/cloud/rush.d.ts +15 -24
  54. package/dist/lib/cloud/rush.js +7 -32
  55. package/dist/lib/crabbox/cli.js +4 -1
  56. package/dist/lib/crabbox/lease.d.ts +6 -0
  57. package/dist/lib/crabbox/lease.js +40 -10
  58. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  59. package/dist/lib/crabbox/runtimes.js +98 -5
  60. package/dist/lib/daemon.d.ts +12 -9
  61. package/dist/lib/daemon.js +73 -17
  62. package/dist/lib/events.d.ts +31 -5
  63. package/dist/lib/events.js +288 -101
  64. package/dist/lib/exec.js +32 -14
  65. package/dist/lib/feed.d.ts +56 -0
  66. package/dist/lib/feed.js +251 -0
  67. package/dist/lib/format.d.ts +38 -0
  68. package/dist/lib/format.js +108 -0
  69. package/dist/lib/git.d.ts +21 -0
  70. package/dist/lib/git.js +92 -0
  71. package/dist/lib/hooks/cache.d.ts +9 -2
  72. package/dist/lib/hooks/cache.js +220 -8
  73. package/dist/lib/hooks.js +24 -10
  74. package/dist/lib/hosts/passthrough.js +1 -0
  75. package/dist/lib/platform/exec.d.ts +4 -1
  76. package/dist/lib/platform/exec.js +8 -2
  77. package/dist/lib/resources.d.ts +0 -8
  78. package/dist/lib/resources.js +0 -10
  79. package/dist/lib/rotate.js +2 -0
  80. package/dist/lib/runner.js +10 -2
  81. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  83. package/dist/lib/secrets/agent.d.ts +21 -0
  84. package/dist/lib/secrets/agent.js +63 -1
  85. package/dist/lib/secrets/bundles.d.ts +33 -1
  86. package/dist/lib/secrets/bundles.js +38 -8
  87. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  88. package/dist/lib/secrets/icloud-import.js +173 -0
  89. package/dist/lib/secrets/index.d.ts +36 -0
  90. package/dist/lib/secrets/index.js +99 -9
  91. package/dist/lib/secrets/remote.js +1 -1
  92. package/dist/lib/secrets/sync.js +1 -1
  93. package/dist/lib/session/active.d.ts +11 -1
  94. package/dist/lib/session/active.js +3 -0
  95. package/dist/lib/session/db.d.ts +1 -4
  96. package/dist/lib/session/db.js +20 -25
  97. package/dist/lib/session/discover.d.ts +2 -2
  98. package/dist/lib/session/discover.js +60 -48
  99. package/dist/lib/session/parse.js +35 -34
  100. package/dist/lib/session/render.d.ts +7 -3
  101. package/dist/lib/session/render.js +15 -9
  102. package/dist/lib/session/state.d.ts +55 -0
  103. package/dist/lib/session/state.js +100 -11
  104. package/dist/lib/session/types.d.ts +9 -0
  105. package/dist/lib/shims.d.ts +9 -3
  106. package/dist/lib/shims.js +44 -8
  107. package/dist/lib/ssh-tunnel.d.ts +33 -2
  108. package/dist/lib/ssh-tunnel.js +94 -7
  109. package/dist/lib/staleness/types.d.ts +0 -1
  110. package/dist/lib/startup/command-registry.d.ts +1 -0
  111. package/dist/lib/startup/command-registry.js +2 -0
  112. package/dist/lib/state.d.ts +2 -0
  113. package/dist/lib/state.js +25 -8
  114. package/dist/lib/teams/agents.js +6 -3
  115. package/dist/lib/types.d.ts +24 -1
  116. package/dist/lib/versions.d.ts +0 -26
  117. package/dist/lib/versions.js +2 -145
  118. package/dist/lib/warn-unpushed.d.ts +40 -0
  119. package/dist/lib/warn-unpushed.js +128 -0
  120. package/dist/lib/whats-new.d.ts +5 -3
  121. package/dist/lib/whats-new.js +25 -5
  122. package/package.json +3 -1
  123. package/dist/lib/resources/index.d.ts +0 -53
  124. package/dist/lib/resources/index.js +0 -76
@@ -25,7 +25,7 @@ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainTo
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
28
- import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
+ import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
29
29
  import { createHash } from 'node:crypto';
30
30
  const keychainStore = {
31
31
  has: hasKeychainToken,
@@ -105,9 +105,9 @@ export const SECRET_TYPES = [
105
105
  ];
106
106
  /** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
107
107
  const LAST_USED_THROTTLE_MS = 60_000;
108
- const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
109
- const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
110
- const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
108
+ export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
109
+ export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
110
+ export const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
111
111
  const SECRETS_ITEM_PREFIX = 'agents-cli.secrets.';
112
112
  export const RESERVED_ENV_NAMES = new Set([
113
113
  'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
@@ -277,7 +277,24 @@ export function secretsDefaultPolicy() {
277
277
  export function bundlePolicy(bundle) {
278
278
  return bundle.policy ?? secretsDefaultPolicy();
279
279
  }
280
- export function writeBundle(bundle) {
280
+ /**
281
+ * Whether a bundle write should evict the broker-held copy. Pure + exported
282
+ * for regression coverage. Skips when the writer opted out (stampLastUsed),
283
+ * when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
284
+ * kill-switch the read fast-path honors), or when a test keychain backend is
285
+ * installed (an in-memory backend has no real keychain behind it, and a test
286
+ * writing bundle 'prod' must never evict the user's real 'prod' unlock).
287
+ */
288
+ export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
289
+ if (skipRequested)
290
+ return false;
291
+ if (noAgentEnv === '1')
292
+ return false;
293
+ if (backendOverridden)
294
+ return false;
295
+ return true;
296
+ }
297
+ export function writeBundle(bundle, opts = {}) {
281
298
  validateBundleName(bundle.name);
282
299
  const backend = bundle.backend ?? 'keychain';
283
300
  if (backend === 'file')
@@ -337,12 +354,20 @@ export function writeBundle(bundle) {
337
354
  // no-ACL command is missing) rather than silently landing an ACL'd item.
338
355
  itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
339
356
  emit('secrets.set', { module: 'secrets', bundle: bundle.name });
357
+ // A broker-held snapshot predates this write; evict it so the next read
358
+ // re-resolves from the keychain instead of serving stale values.
359
+ if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
360
+ agentEvictSync(bundle.name);
361
+ }
340
362
  }
341
363
  export function deleteBundle(name) {
342
364
  validateBundleName(name);
343
365
  const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
344
366
  if (deleted) {
345
367
  emit('secrets.delete', { module: 'secrets', bundle: name });
368
+ if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
369
+ agentEvictSync(name);
370
+ }
346
371
  }
347
372
  return deleted;
348
373
  }
@@ -513,7 +538,9 @@ function stampLastUsed(bundle) {
513
538
  }
514
539
  try {
515
540
  bundle.last_used = new Date(nowMs).toISOString();
516
- writeBundle(bundle);
541
+ // skipBrokerEviction: this stamp fires on every broker HIT; letting it
542
+ // evict would make the cache destroy itself on first use.
543
+ writeBundle(bundle, { skipBrokerEviction: true });
517
544
  }
518
545
  catch {
519
546
  // Swallow — telemetry must never block secret resolution.
@@ -694,7 +721,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
694
721
  emit('secrets.get', {
695
722
  module: 'secrets',
696
723
  bundle: name,
697
- caller: opts.caller,
724
+ operation: opts.caller,
698
725
  status: 'success',
699
726
  source: 'agent',
700
727
  keyCount: Object.keys(filtered.env).length,
@@ -702,6 +729,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
702
729
  return filtered;
703
730
  }
704
731
  }
732
+ if (opts.agentOnly) {
733
+ throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent.`);
734
+ }
705
735
  if (backend === 'file')
706
736
  assertFileBackendUsable(name);
707
737
  const store = itemStore(backend);
@@ -784,7 +814,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
784
814
  emit('secrets.get', {
785
815
  module: 'secrets',
786
816
  bundle: bundle.name,
787
- caller: opts.caller,
817
+ operation: opts.caller,
788
818
  status,
789
819
  keyCount: keys.length,
790
820
  keys,
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
3
+ *
4
+ * The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
5
+ * device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
6
+ * query) orphaned those items: they still sync back via iCloud Keychain, but
7
+ * neither `secrets list` nor `migrate-acl` can see them. This module powers
8
+ * `agents secrets import --from icloud` — discover the orphaned bundles,
9
+ * re-import them as normal device-local bundles, and optionally purge the
10
+ * iCloud copies.
11
+ *
12
+ * The item-name scheme is the same one the modern store uses (see bundles.ts):
13
+ * metadata under `agents-cli.bundles.<name>`, one value per key under
14
+ * `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
15
+ * (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
16
+ * the bundle/key boundary even for dotted bundle names like `hetzner.com`.
17
+ */
18
+ import { type SecretsBackend } from './bundles.js';
19
+ /** One orphaned iCloud bundle, as discovered from the synced item names. */
20
+ export interface SyncedBundleCandidate {
21
+ /** Bundle name derived from the iCloud service names. */
22
+ name: string;
23
+ /** Env keys that have a per-key secret item in the iCloud keychain. */
24
+ keys: string[];
25
+ /** True when an `agents-cli.bundles.<name>` metadata item exists in iCloud. */
26
+ hasMeta: boolean;
27
+ /** Every iCloud service name belonging to this candidate (the purge set). */
28
+ services: string[];
29
+ }
30
+ /**
31
+ * Group raw synced service names into per-bundle candidates. Pure — separated
32
+ * from discovery so the parsing rules are unit-testable without a keychain.
33
+ *
34
+ * A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
35
+ * secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
36
+ * synced), or both; all three shapes appear in real iCloud strays, so every
37
+ * one becomes a candidate.
38
+ */
39
+ export declare function groupSyncedServices(services: string[]): SyncedBundleCandidate[];
40
+ /** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
41
+ export declare function discoverSyncedBundles(): SyncedBundleCandidate[];
42
+ export interface ImportSyncedOptions {
43
+ /** Overwrite keys that already exist in the local bundle. */
44
+ force?: boolean;
45
+ /** Store imported values as literals in the bundle metadata (no keychain items). */
46
+ allPlaintext?: boolean;
47
+ /** Backend for a newly created bundle (existing bundles keep theirs). */
48
+ backend?: SecretsBackend;
49
+ /** Delete the iCloud copies of successfully-read items after import. */
50
+ purge?: boolean;
51
+ }
52
+ export interface ImportSyncedResult {
53
+ name: string;
54
+ added: number;
55
+ skipped: number;
56
+ /** Keys whose iCloud value could not be read (left in place, never purged). */
57
+ missing: string[];
58
+ purged: number;
59
+ }
60
+ /**
61
+ * Import one discovered iCloud bundle into the local (device-local) store.
62
+ *
63
+ * Values come from the synced secret items; the synced metadata item, when
64
+ * present, contributes the description, literal vars, and non-keychain refs
65
+ * (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
66
+ * them exactly). Existing local keys are skipped unless `force`. With `purge`,
67
+ * only services whose value was successfully read are deleted from iCloud —
68
+ * an unreadable item is never destroyed.
69
+ */
70
+ export declare function importSyncedBundle(candidate: SyncedBundleCandidate, opts?: ImportSyncedOptions): ImportSyncedResult;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
3
+ *
4
+ * The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
5
+ * device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
6
+ * query) orphaned those items: they still sync back via iCloud Keychain, but
7
+ * neither `secrets list` nor `migrate-acl` can see them. This module powers
8
+ * `agents secrets import --from icloud` — discover the orphaned bundles,
9
+ * re-import them as normal device-local bundles, and optionally purge the
10
+ * iCloud copies.
11
+ *
12
+ * The item-name scheme is the same one the modern store uses (see bundles.ts):
13
+ * metadata under `agents-cli.bundles.<name>`, one value per key under
14
+ * `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
15
+ * (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
16
+ * the bundle/key boundary even for dotted bundle names like `hetzner.com`.
17
+ */
18
+ import { deleteSyncedKeychainItem, getSyncedKeychainTokens, listSyncedKeychainItems, parseBundleValue, secretsKeychainItem, serializeRef, SECRETS_ITEM_PREFIX, } from './index.js';
19
+ import { BUNDLE_META_PREFIX, BUNDLE_NAME_PATTERN, ENV_KEY_PATTERN, bundleExists, bundleItemStore, bundlePolicy, keychainRef, readBundle, writeBundle, } from './bundles.js';
20
+ /**
21
+ * Group raw synced service names into per-bundle candidates. Pure — separated
22
+ * from discovery so the parsing rules are unit-testable without a keychain.
23
+ *
24
+ * A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
25
+ * secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
26
+ * synced), or both; all three shapes appear in real iCloud strays, so every
27
+ * one becomes a candidate.
28
+ */
29
+ export function groupSyncedServices(services) {
30
+ const byName = new Map();
31
+ const claim = (name) => {
32
+ let c = byName.get(name);
33
+ if (!c) {
34
+ c = { name, keys: [], hasMeta: false, services: [] };
35
+ byName.set(name, c);
36
+ }
37
+ return c;
38
+ };
39
+ for (const svc of services) {
40
+ if (svc.startsWith(BUNDLE_META_PREFIX)) {
41
+ const name = svc.slice(BUNDLE_META_PREFIX.length);
42
+ if (!BUNDLE_NAME_PATTERN.test(name))
43
+ continue;
44
+ const c = claim(name);
45
+ c.hasMeta = true;
46
+ c.services.push(svc);
47
+ }
48
+ else if (svc.startsWith(SECRETS_ITEM_PREFIX)) {
49
+ const rest = svc.slice(SECRETS_ITEM_PREFIX.length);
50
+ const cut = rest.lastIndexOf('.');
51
+ if (cut <= 0)
52
+ continue;
53
+ const name = rest.slice(0, cut);
54
+ const key = rest.slice(cut + 1);
55
+ if (!BUNDLE_NAME_PATTERN.test(name) || !ENV_KEY_PATTERN.test(key))
56
+ continue;
57
+ const c = claim(name);
58
+ if (!c.keys.includes(key))
59
+ c.keys.push(key);
60
+ c.services.push(svc);
61
+ }
62
+ }
63
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
64
+ }
65
+ /** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
66
+ export function discoverSyncedBundles() {
67
+ return groupSyncedServices(listSyncedKeychainItems('agents-cli.'));
68
+ }
69
+ /**
70
+ * Import one discovered iCloud bundle into the local (device-local) store.
71
+ *
72
+ * Values come from the synced secret items; the synced metadata item, when
73
+ * present, contributes the description, literal vars, and non-keychain refs
74
+ * (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
75
+ * them exactly). Existing local keys are skipped unless `force`. With `purge`,
76
+ * only services whose value was successfully read are deleted from iCloud —
77
+ * an unreadable item is never destroyed.
78
+ */
79
+ export function importSyncedBundle(candidate, opts = {}) {
80
+ const values = getSyncedKeychainTokens(candidate.services);
81
+ let bundle;
82
+ if (bundleExists(candidate.name)) {
83
+ bundle = readBundle(candidate.name);
84
+ }
85
+ else {
86
+ bundle = {
87
+ name: candidate.name,
88
+ backend: opts.backend === 'file' ? 'file' : undefined,
89
+ vars: {},
90
+ };
91
+ }
92
+ const metaService = BUNDLE_META_PREFIX + candidate.name;
93
+ let metaVars = {};
94
+ const metaJson = values.get(metaService);
95
+ if (metaJson !== undefined) {
96
+ try {
97
+ const parsed = JSON.parse(metaJson);
98
+ if (parsed && typeof parsed === 'object') {
99
+ if (typeof parsed.description === 'string' && !bundle.description) {
100
+ bundle.description = parsed.description;
101
+ }
102
+ if (parsed.vars && typeof parsed.vars === 'object') {
103
+ metaVars = parsed.vars;
104
+ }
105
+ }
106
+ }
107
+ catch {
108
+ // Corrupt legacy metadata — the per-key secret items still import.
109
+ }
110
+ }
111
+ const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
112
+ let added = 0;
113
+ let skipped = 0;
114
+ const missing = [];
115
+ // Keys with a synced secret item: re-store the value device-locally.
116
+ for (const key of candidate.keys) {
117
+ const value = values.get(secretsKeychainItem(candidate.name, key));
118
+ if (value === undefined) {
119
+ missing.push(key);
120
+ continue;
121
+ }
122
+ if (!opts.force && key in bundle.vars) {
123
+ skipped++;
124
+ continue;
125
+ }
126
+ if (opts.allPlaintext) {
127
+ bundle.vars[key] = { value };
128
+ }
129
+ else {
130
+ store.set(secretsKeychainItem(candidate.name, key), value);
131
+ bundle.vars[key] = keychainRef(key);
132
+ }
133
+ added++;
134
+ }
135
+ // Vars declared only in the synced metadata: literals and non-keychain refs
136
+ // carry everything they need; a keychain ref without its synced item is
137
+ // unrecoverable.
138
+ for (const [key, raw] of Object.entries(metaVars)) {
139
+ if (!ENV_KEY_PATTERN.test(key))
140
+ continue;
141
+ if (candidate.keys.includes(key))
142
+ continue; // the secret item already covered it
143
+ let parsed;
144
+ try {
145
+ parsed = parseBundleValue(raw);
146
+ }
147
+ catch {
148
+ continue; // malformed legacy entry
149
+ }
150
+ if ('ref' in parsed && parsed.ref.provider === 'keychain') {
151
+ if (!missing.includes(key))
152
+ missing.push(key);
153
+ continue;
154
+ }
155
+ if (!opts.force && key in bundle.vars) {
156
+ skipped++;
157
+ continue;
158
+ }
159
+ bundle.vars[key] = 'literal' in parsed ? { value: parsed.literal } : serializeRef(parsed.ref);
160
+ added++;
161
+ }
162
+ writeBundle(bundle);
163
+ let purged = 0;
164
+ if (opts.purge) {
165
+ for (const svc of candidate.services) {
166
+ if (!values.has(svc))
167
+ continue; // never destroy an item we couldn't read
168
+ if (deleteSyncedKeychainItem(svc))
169
+ purged++;
170
+ }
171
+ }
172
+ return { name: candidate.name, added, skipped, missing, purged };
173
+ }
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import type { NativeImportReport } from './fallback.js';
26
26
  export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
27
+ export declare const SECRETS_ITEM_PREFIX = "agents-cli.secrets.";
27
28
  /** Supported secret resolution backends. */
28
29
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
29
30
  /** A typed reference to a secret, consisting of a provider and a provider-specific value. */
@@ -141,6 +142,41 @@ export declare function listKeychainItems(prefix: string): string[];
141
142
  * stragglers instead of every item (which would be a Touch ID storm).
142
143
  */
143
144
  export declare function listLegacyKeychainItems(prefix: string): string[];
145
+ /**
146
+ * Test seam for the LEGACY SYNCHRONIZABLE (iCloud Keychain) recovery path.
147
+ * The main `KeychainBackend` seam models the live device-local store; this one
148
+ * models the orphaned iCloud items that `secrets import --from icloud` reads.
149
+ * Kept separate so a test can populate both sides independently.
150
+ */
151
+ export interface SyncedKeychainBackend {
152
+ list(prefix: string): string[];
153
+ getBatch(items: string[]): Map<string, string>;
154
+ delete(item: string): boolean;
155
+ }
156
+ export declare function setSyncedKeychainBackendForTest(b: SyncedKeychainBackend | null): SyncedKeychainBackend | null;
157
+ /**
158
+ * Enumerate LEGACY SYNCHRONIZABLE (iCloud Keychain) item names with the given
159
+ * prefix — bundles written by the pre-biometry helper era, which defaulted
160
+ * secrets to iCloud Keychain sync. The device-local cutover orphaned them:
161
+ * every modern query pins synchronizable=false, so only the helper's
162
+ * `list-synced` verb can see them. Silent (attributes only, never decrypts).
163
+ * macOS only — Linux/Windows never had iCloud Keychain sync, so this returns [].
164
+ */
165
+ export declare function listSyncedKeychainItems(prefix: string): string[];
166
+ /**
167
+ * Batch-read LEGACY SYNCHRONIZABLE (iCloud Keychain) items. Returns a map of
168
+ * item name → value; missing items are simply absent. Pre-biometry items carry
169
+ * no biometry ACL, so this does not normally prompt. macOS only — returns an
170
+ * empty map on Linux/Windows.
171
+ */
172
+ export declare function getSyncedKeychainTokens(items: string[]): Map<string, string>;
173
+ /**
174
+ * Delete a LEGACY SYNCHRONIZABLE (iCloud Keychain) item after a successful
175
+ * import (`--purge`). Matches synchronizable items only — the device-local
176
+ * copy the import wrote is untouched. iCloud propagates the deletion to the
177
+ * user's other devices. Returns true if a copy was removed.
178
+ */
179
+ export declare function deleteSyncedKeychainItem(item: string): boolean;
144
180
  /**
145
181
  * One-time upgrade for a keychain item that was written by a previous helper
146
182
  * generation with a trusted-app ACL. The helper reads the legacy item
@@ -30,7 +30,7 @@ import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSe
30
30
  import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
31
31
  import { getKeychainHelperPath } from './install-helper.js';
32
32
  const SERVICE_PREFIX = 'agents-cli';
33
- const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
33
+ export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
34
34
  const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
35
35
  const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
36
36
  /** Parse a bundle value into either a literal string or a typed secret ref. */
@@ -234,12 +234,20 @@ export function getKeychainTokens(items) {
234
234
  throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
235
235
  }
236
236
  const out = child.stdout?.toString() ?? '';
237
- // Output is a sequence of records, one per service in input order:
238
- // "V <service>\n<value>\n" (present)
239
- // "M <service>\n" (missing)
240
- // Service names are validated newline/'='-free by setKeychainToken below
241
- // and values are rejected if they contain newlines so splitting on '\n'
242
- // and walking line-by-line is unambiguous.
237
+ parseBatchRecords(out, result);
238
+ return result;
239
+ }
240
+ /**
241
+ * Parse the helper's batch-read output into `into`. The format is shared by
242
+ * `get-batch` and `get-batch-synced` a sequence of records, one per service
243
+ * in input order:
244
+ * "V <service>\n<value>\n" (present)
245
+ * "M <service>\n" (missing)
246
+ * Service names are validated newline/'='-free by setKeychainToken below
247
+ * and values are rejected if they contain newlines — so splitting on '\n'
248
+ * and walking line-by-line is unambiguous.
249
+ */
250
+ function parseBatchRecords(out, into) {
243
251
  const lines = out.split('\n');
244
252
  let i = 0;
245
253
  while (i < lines.length) {
@@ -249,7 +257,7 @@ export function getKeychainTokens(items) {
249
257
  if (line.startsWith('V ')) {
250
258
  const service = line.slice(2);
251
259
  const value = lines[i + 1] ?? '';
252
- result.set(service, value);
260
+ into.set(service, value);
253
261
  i += 2;
254
262
  }
255
263
  else if (line.startsWith('M ')) {
@@ -262,7 +270,6 @@ export function getKeychainTokens(items) {
262
270
  throw new Error(`Malformed get-batch output line: ${JSON.stringify(line)}`);
263
271
  }
264
272
  }
265
- return result;
266
273
  }
267
274
  /** Store or update a secret value in the keychain/keyring. Device-local;
268
275
  * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
@@ -408,6 +415,89 @@ export function listLegacyKeychainItems(prefix) {
408
415
  const out = result.stdout?.toString() || '';
409
416
  return out.split('\n').map((s) => s.trim()).filter(Boolean);
410
417
  }
418
+ let syncedBackend = null;
419
+ export function setSyncedKeychainBackendForTest(b) {
420
+ const prev = syncedBackend;
421
+ syncedBackend = b;
422
+ return prev;
423
+ }
424
+ /**
425
+ * Enumerate LEGACY SYNCHRONIZABLE (iCloud Keychain) item names with the given
426
+ * prefix — bundles written by the pre-biometry helper era, which defaulted
427
+ * secrets to iCloud Keychain sync. The device-local cutover orphaned them:
428
+ * every modern query pins synchronizable=false, so only the helper's
429
+ * `list-synced` verb can see them. Silent (attributes only, never decrypts).
430
+ * macOS only — Linux/Windows never had iCloud Keychain sync, so this returns [].
431
+ */
432
+ export function listSyncedKeychainItems(prefix) {
433
+ if (syncedBackend)
434
+ return syncedBackend.list(prefix);
435
+ if (backend)
436
+ return [];
437
+ assertSupportedPlatform();
438
+ if (isLinux() || isWindows())
439
+ return [];
440
+ const bin = getKeychainHelperPath();
441
+ const result = spawnSync(bin, ['list-synced', prefix], {
442
+ stdio: ['ignore', 'pipe', 'pipe'],
443
+ });
444
+ if (result.status !== 0) {
445
+ const msg = result.stderr?.toString().trim();
446
+ throw new Error(msg || `Failed to enumerate iCloud keychain items with prefix '${prefix}'.`);
447
+ }
448
+ const out = result.stdout?.toString() || '';
449
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
450
+ }
451
+ /**
452
+ * Batch-read LEGACY SYNCHRONIZABLE (iCloud Keychain) items. Returns a map of
453
+ * item name → value; missing items are simply absent. Pre-biometry items carry
454
+ * no biometry ACL, so this does not normally prompt. macOS only — returns an
455
+ * empty map on Linux/Windows.
456
+ */
457
+ export function getSyncedKeychainTokens(items) {
458
+ const result = new Map();
459
+ if (items.length === 0)
460
+ return result;
461
+ if (syncedBackend)
462
+ return syncedBackend.getBatch(items);
463
+ if (backend)
464
+ return result;
465
+ assertSupportedPlatform();
466
+ if (isLinux() || isWindows())
467
+ return result;
468
+ const bin = getKeychainHelperPath();
469
+ const child = spawnSync(bin, ['get-batch-synced', os.userInfo().username, ...items], {
470
+ stdio: ['ignore', 'pipe', 'pipe'],
471
+ });
472
+ if (child.status === 4) {
473
+ throw new Error(`Auth cancelled while reading ${items.length} iCloud keychain item(s).`);
474
+ }
475
+ if (child.status !== 0) {
476
+ const msg = child.stderr?.toString().trim();
477
+ throw new Error(msg || `Failed to batch-read ${items.length} iCloud keychain items.`);
478
+ }
479
+ parseBatchRecords(child.stdout?.toString() ?? '', result);
480
+ return result;
481
+ }
482
+ /**
483
+ * Delete a LEGACY SYNCHRONIZABLE (iCloud Keychain) item after a successful
484
+ * import (`--purge`). Matches synchronizable items only — the device-local
485
+ * copy the import wrote is untouched. iCloud propagates the deletion to the
486
+ * user's other devices. Returns true if a copy was removed.
487
+ */
488
+ export function deleteSyncedKeychainItem(item) {
489
+ if (syncedBackend)
490
+ return syncedBackend.delete(item);
491
+ if (backend)
492
+ return false;
493
+ assertSupportedPlatform();
494
+ if (isLinux() || isWindows())
495
+ return false;
496
+ const bin = getKeychainHelperPath();
497
+ return spawnSync(bin, ['delete-synced', item, os.userInfo().username], {
498
+ stdio: ['ignore', 'pipe', 'pipe'],
499
+ }).status === 0;
500
+ }
411
501
  /**
412
502
  * One-time upgrade for a keychain item that was written by a previous helper
413
503
  * generation with a trusted-app ACL. The helper reads the legacy item
@@ -161,7 +161,7 @@ export async function remoteResolveEnv(target, bundle) {
161
161
  emit('secrets.get', {
162
162
  module: 'secrets',
163
163
  bundle,
164
- caller: 'remote resolve',
164
+ operation: 'remote resolve',
165
165
  source: 'remote',
166
166
  host: target,
167
167
  status: 'success',
@@ -193,7 +193,7 @@ export async function pushBundle(name, opts) {
193
193
  emit('secrets.get', {
194
194
  module: 'secrets',
195
195
  bundle: name,
196
- caller: 'sync push',
196
+ operation: 'sync push',
197
197
  source: 'sync-push',
198
198
  status: 'success',
199
199
  keyCount: Object.keys(snap.secrets).length,
@@ -1,5 +1,5 @@
1
1
  import { type PidSessionEntry } from './pid-registry.js';
2
- import { type SessionActivity, type AwaitingReason, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
2
+ import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
3
3
  import { type SessionProvenance } from './provenance.js';
4
4
  /**
5
5
  * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
@@ -30,6 +30,16 @@ export interface ActiveSession {
30
30
  activity?: SessionActivity;
31
31
  /** Why the agent is waiting, when activity is waiting_input. */
32
32
  awaitingReason?: AwaitingReason;
33
+ /** The structured decision (question/plan/permission + options) the agent is waiting on. */
34
+ question?: StructuredQuestion;
35
+ /**
36
+ * Plan markdown from the last `ExitPlanMode` tool call. Present when the
37
+ * transcript ever entered plan-review; `awaitingReason === 'plan_review'`
38
+ * says whether it is still pending.
39
+ */
40
+ plan?: string;
41
+ /** Last few assistant turns (most-recent last), for at-a-glance context in the UI. */
42
+ tail?: string[];
33
43
  /** PR opened during the session. */
34
44
  pr?: DetectedPr;
35
45
  /** Worktree the session runs in. */
@@ -236,6 +236,9 @@ function applyState(base, state, fallbackFile) {
236
236
  status: statusFromActivity(state.activity),
237
237
  activity: state.activity,
238
238
  awaitingReason: state.awaitingReason,
239
+ question: state.question,
240
+ plan: state.plan,
241
+ tail: state.tail,
239
242
  // Prefer the live preview (latest turn); keep the first-prompt topic as a fallback.
240
243
  preview: state.preview ?? base.preview,
241
244
  pr: state.pr,
@@ -35,6 +35,7 @@ export interface SessionRow {
35
35
  pr_number: number | null;
36
36
  worktree_slug: string | null;
37
37
  ticket_id: string | null;
38
+ plan: string | null;
38
39
  }
39
40
  /** File stat snapshot used to detect changes between scan runs. */
40
41
  export interface ScanStamp {
@@ -199,10 +200,6 @@ export interface TopCostSession {
199
200
  * vanished, mirroring querySessions' liveness filter.
200
201
  */
201
202
  export declare function topSessionsByCost(n: number, options?: QueryOptions): TopCostSession[];
202
- /** Return the set of all file paths currently tracked in the sessions table. */
203
- export declare function getAllFilePaths(): Set<string>;
204
- /** Look up sessions by their source file paths. */
205
- export declare function getSessionsByFilePaths(paths: string[]): Map<string, SessionMeta>;
206
203
  /** Look up a single session by its unique ID. */
207
204
  export declare function getSessionById(id: string): SessionMeta | null;
208
205
  /**