@phnx-labs/agents-cli 1.20.51 → 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 (62) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/commands/browser.js +215 -7
  3. package/dist/commands/cloud.js +6 -0
  4. package/dist/commands/events.d.ts +1 -1
  5. package/dist/commands/events.js +2 -3
  6. package/dist/commands/exec.js +17 -2
  7. package/dist/commands/factory.js +8 -0
  8. package/dist/commands/feed.d.ts +9 -0
  9. package/dist/commands/feed.js +69 -0
  10. package/dist/commands/logs.d.ts +5 -1
  11. package/dist/commands/logs.js +248 -3
  12. package/dist/commands/mcp.js +7 -0
  13. package/dist/commands/secrets.d.ts +22 -0
  14. package/dist/commands/secrets.js +173 -42
  15. package/dist/commands/teams.js +4 -0
  16. package/dist/index.js +6 -2
  17. package/dist/lib/browser/login-detection.d.ts +94 -0
  18. package/dist/lib/browser/login-detection.js +274 -0
  19. package/dist/lib/browser/profiles.d.ts +17 -8
  20. package/dist/lib/browser/profiles.js +27 -8
  21. package/dist/lib/browser/secret-ref.d.ts +10 -0
  22. package/dist/lib/browser/secret-ref.js +14 -0
  23. package/dist/lib/browser/service.js +14 -12
  24. package/dist/lib/cloud/rush.d.ts +15 -0
  25. package/dist/lib/cloud/rush.js +7 -1
  26. package/dist/lib/crabbox/lease.d.ts +6 -0
  27. package/dist/lib/crabbox/lease.js +11 -9
  28. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  29. package/dist/lib/crabbox/runtimes.js +98 -5
  30. package/dist/lib/daemon.d.ts +12 -9
  31. package/dist/lib/daemon.js +32 -17
  32. package/dist/lib/events.d.ts +31 -5
  33. package/dist/lib/events.js +288 -101
  34. package/dist/lib/exec.js +1 -0
  35. package/dist/lib/feed.d.ts +56 -0
  36. package/dist/lib/feed.js +251 -0
  37. package/dist/lib/hooks.js +7 -2
  38. package/dist/lib/hosts/passthrough.js +1 -0
  39. package/dist/lib/rotate.js +2 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/agent.d.ts +21 -0
  43. package/dist/lib/secrets/agent.js +63 -1
  44. package/dist/lib/secrets/bundles.d.ts +33 -1
  45. package/dist/lib/secrets/bundles.js +38 -8
  46. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  47. package/dist/lib/secrets/icloud-import.js +173 -0
  48. package/dist/lib/secrets/index.d.ts +36 -0
  49. package/dist/lib/secrets/index.js +99 -9
  50. package/dist/lib/secrets/remote.js +1 -1
  51. package/dist/lib/secrets/sync.js +1 -1
  52. package/dist/lib/session/discover.js +1 -2
  53. package/dist/lib/session/state.js +13 -1
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +2 -0
  56. package/dist/lib/state.d.ts +2 -0
  57. package/dist/lib/state.js +25 -8
  58. package/dist/lib/teams/agents.js +6 -3
  59. package/dist/lib/types.d.ts +10 -0
  60. package/dist/lib/whats-new.d.ts +5 -3
  61. package/dist/lib/whats-new.js +25 -5
  62. package/package.json +1 -1
@@ -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,
@@ -1826,8 +1826,7 @@ export async function scanClaudeSession(filePath) {
1826
1826
  pendingTicketTools.add(b.id);
1827
1827
  }
1828
1828
  // ExitPlanMode plan markdown — last one wins so a re-planned session
1829
- // reports its most recent plan (the semantic parsePlanFromClaudeJsonl
1830
- // implemented in the extension).
1829
+ // reports its most recent plan.
1831
1830
  if (b?.name === 'ExitPlanMode' && typeof b?.input?.plan === 'string') {
1832
1831
  const p = b.input.plan.trim();
1833
1832
  if (p)
@@ -17,6 +17,14 @@
17
17
  import { summarizeToolUse } from './parse.js';
18
18
  /** A healthy live session writes several times a minute; 2 min ⇒ "recently active". */
19
19
  const ACTIVE_WINDOW_MS = 2 * 60_000;
20
+ /**
21
+ * A prose trailing question ("…?") is a HEURISTIC, so it decays: past this long
22
+ * with no session writes it stops classifying as waiting_input — otherwise a
23
+ * finished session that signed off with "anything else?" reads as needing input
24
+ * forever (RUSH-1522). The structural ExitPlanMode / AskUserQuestion signals are
25
+ * exempt: they are precise, still-unanswered decisions.
26
+ */
27
+ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
20
28
  /** Claude tool names that structurally mean "the agent handed control back to you". */
21
29
  const PLAN_TOOL = 'ExitPlanMode';
22
30
  const ASK_TOOL = 'AskUserQuestion';
@@ -299,7 +307,11 @@ export function inferActivity(events, ctx = {}) {
299
307
  }
300
308
  // Assistant spoke last and stopped. A trailing question → waiting; else idle.
301
309
  // A prose question takes a free-text reply (no select-list), so no options/keys.
302
- if (looksLikeQuestion(last.content ?? '')) {
310
+ // Unlike the structural plan/ask signals above, the prose heuristic DECAYS: a
311
+ // question nobody answered within PROSE_QUESTION_FRESH_MS is a session that
312
+ // ended, not one that needs you (RUSH-1522). Unknown mtime keeps the question.
313
+ const questionFresh = ctx.mtimeMs == null || Date.now() - ctx.mtimeMs < PROSE_QUESTION_FRESH_MS;
314
+ if (questionFresh && looksLikeQuestion(last.content ?? '')) {
303
315
  const text = oneLine(last.content ?? '');
304
316
  return { ...base, activity: 'waiting_input', awaitingReason: 'question', question: { text, reason: 'question' } };
305
317
  }
@@ -83,6 +83,7 @@ export declare const loadSessions: ModuleLoader;
83
83
  export declare const loadTeams: ModuleLoader;
84
84
  export declare const loadCloud: ModuleLoader;
85
85
  export declare const loadMessage: ModuleLoader;
86
+ export declare const loadFeed: ModuleLoader;
86
87
  export declare const loadServe: ModuleLoader;
87
88
  export declare const loadAudit: ModuleLoader;
88
89
  /**
@@ -61,6 +61,7 @@ export const loadSessions = async () => (await import('../../commands/sessions.j
61
61
  export const loadTeams = async () => (await import('../../commands/teams.js')).registerTeamsCommands;
62
62
  export const loadCloud = async () => (await import('../../commands/cloud.js')).registerCloudCommands;
63
63
  export const loadMessage = async () => (await import('../../commands/message.js')).registerMessageCommand;
64
+ export const loadFeed = async () => (await import('../../commands/feed.js')).registerFeedCommand;
64
65
  export const loadServe = async () => (await import('../../commands/serve.js')).registerServeCommand;
65
66
  export const loadAudit = async () => (await import('../../commands/audit.js')).registerAuditCommands;
66
67
  /**
@@ -154,6 +155,7 @@ export const COMMAND_LOADERS = {
154
155
  teams: [loadTeams],
155
156
  cloud: [loadCloud],
156
157
  message: [loadMessage],
158
+ feed: [loadFeed],
157
159
  serve: [loadServe],
158
160
  audit: [loadAudit],
159
161
  };
@@ -122,6 +122,8 @@ export declare function getProjectRoutinesDir(cwd?: string): string | null;
122
122
  export declare function getRunsDir(): string;
123
123
  /** Root for per-agent mailboxes (~/.agents/.history/mailbox/). */
124
124
  export declare function getMailboxRootDir(): string;
125
+ /** Root for open-block feed records (~/.agents/.history/feed/). */
126
+ export declare function getFeedDir(): string;
125
127
  /** Path to installed agent CLI binaries (~/.agents/.history/versions/). */
126
128
  export declare function getVersionsDir(): string;
127
129
  /** Path to version-switching shim scripts (~/.agents/.cache/shims/). */
package/dist/lib/state.js CHANGED
@@ -89,6 +89,7 @@ const TEAMS_AGENTS_DIR = path.join(HISTORY_DIR, 'teams', 'agents');
89
89
  const BACKUPS_DIR = path.join(HISTORY_DIR, 'backups');
90
90
  const TRASH_DIR = path.join(HISTORY_DIR, 'trash');
91
91
  const MAILBOX_DIR = path.join(HISTORY_DIR, 'mailbox');
92
+ const FEED_DIR = path.join(HISTORY_DIR, 'feed');
92
93
  // Cache bucket (regenerable).
93
94
  const SHIMS_DIR = path.join(CACHE_DIR, 'shims');
94
95
  const HOOK_SHIMS_DIR = path.join(SHIMS_DIR, 'hooks');
@@ -313,6 +314,8 @@ export function getProjectRoutinesDir(cwd = process.cwd()) {
313
314
  export function getRunsDir() { return RUNS_DIR; }
314
315
  /** Root for per-agent mailboxes (~/.agents/.history/mailbox/). */
315
316
  export function getMailboxRootDir() { return MAILBOX_DIR; }
317
+ /** Root for open-block feed records (~/.agents/.history/feed/). */
318
+ export function getFeedDir() { return FEED_DIR; }
316
319
  /** Path to installed agent CLI binaries (~/.agents/.history/versions/). */
317
320
  export function getVersionsDir() { return VERSIONS_DIR; }
318
321
  /** Path to version-switching shim scripts (~/.agents/.cache/shims/). */
@@ -569,25 +572,35 @@ function writeIfChanged(filePath, content) {
569
572
  /**
570
573
  * Partition the in-memory Meta across three files by sync-domain:
571
574
  * - central `~/.agents/agents.yaml` — portable, everything else
572
- * - device `~/.agents/devices/<machine>/agents.yaml` — `agents:` pins (per-device)
575
+ * - device `~/.agents/devices/<machine>/agents.yaml` — `agents:` pins +
576
+ * `defaultBrowserProfile:` (both per-device)
573
577
  * - history `~/.agents/.history/version-resources.json` — `versions:` (machine-local)
574
578
  * All callers funnel through writeMeta → here, so nothing else changes. Empty
575
579
  * `agents:` / `versions:` are not written (no empty committed files).
576
580
  */
577
581
  function writeMetaUnlocked(meta) {
578
- const { agents, versions, ...central } = meta;
582
+ const { agents, versions, defaultBrowserProfile, ...central } = meta;
579
583
  // Write the machine-local files FIRST, then strip central — so a crash mid-write
580
584
  // never removes pins/versions from central before they're persisted elsewhere.
581
585
  const devicePath = getDeviceMetaPath();
582
- if (agents && Object.keys(agents).length > 0) {
586
+ const hasAgents = !!agents && Object.keys(agents).length > 0;
587
+ const hasDefaultBrowser = !!defaultBrowserProfile;
588
+ if (hasAgents || hasDefaultBrowser) {
589
+ // Device-local doc carries `agents:` pins and `defaultBrowserProfile:` — both
590
+ // are per-machine and must never land in central agents.yaml (which syncs).
591
+ const deviceDoc = {};
592
+ if (hasAgents)
593
+ deviceDoc.agents = agents;
594
+ if (hasDefaultBrowser)
595
+ deviceDoc.defaultBrowserProfile = defaultBrowserProfile;
583
596
  fs.mkdirSync(path.dirname(devicePath), { recursive: true });
584
- writeIfChanged(devicePath, META_HEADER + yaml.stringify({ agents }));
597
+ writeIfChanged(devicePath, META_HEADER + yaml.stringify(deviceDoc));
585
598
  }
586
599
  else if (fs.existsSync(devicePath)) {
587
- // Every pin was cleared. Persist the emptied map instead of skipping the
588
- // write — otherwise the stale device file survives and overlayMachineLocal
589
- // re-applies the removed pin on the next read, leaving a dangling default
590
- // (e.g. a launcher pointing at a version that was just uninstalled).
600
+ // Every device-local value was cleared. Persist the emptied doc instead of
601
+ // skipping the write — otherwise the stale device file survives and
602
+ // overlayMachineLocal re-applies the removed pin/default on the next read,
603
+ // leaving a dangling value (e.g. a default pointing at a deleted profile).
591
604
  writeIfChanged(devicePath, META_HEADER + yaml.stringify({ agents: {} }));
592
605
  }
593
606
  if (versions && Object.keys(versions).length > 0) {
@@ -602,6 +615,8 @@ function writeMetaUnlocked(meta) {
602
615
  * Overlay this machine's local state onto a central-portable Meta:
603
616
  * - `agents:` from the device file (device wins; the union both preserves the
604
617
  * one-level merge and self-heals a pre-migration central that still has pins)
618
+ * - `defaultBrowserProfile:` from the device file (device is the sole source;
619
+ * the field is stripped from central on write, so nothing to merge against)
605
620
  * - `versions:` from the history JSON (wholesale replace; falls back to
606
621
  * whatever central carried when the history file doesn't exist yet)
607
622
  */
@@ -612,6 +627,8 @@ function overlayMachineLocal(meta) {
612
627
  const dm = yaml.parse(fs.readFileSync(devicePath, 'utf-8'));
613
628
  if (dm?.agents)
614
629
  meta.agents = { ...meta.agents, ...dm.agents };
630
+ if (dm?.defaultBrowserProfile)
631
+ meta.defaultBrowserProfile = dm.defaultBrowserProfile;
615
632
  }
616
633
  catch { /* ignore malformed device file */ }
617
634
  }
@@ -1693,6 +1693,7 @@ export class AgentManager {
1693
1693
  ];
1694
1694
  if (model)
1695
1695
  args.push('--model', model);
1696
+ args.push('--env', 'AGENTS_RUNTIME=teams');
1696
1697
  return args;
1697
1698
  }
1698
1699
  buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
@@ -1704,9 +1705,11 @@ export class AgentManager {
1704
1705
  ];
1705
1706
  if (cwd)
1706
1707
  cmd.push('--cwd', cwd);
1707
- // Pin Claude's session UUID to our agent_id so its session file lands at
1708
- // ~/.claude/projects/.../<agent_id>.jsonl unified identity for status polling.
1709
- if (agentType === 'claude' && sessionId) {
1708
+ // Pin the session UUID to our agent_id so buildExecEnv keys
1709
+ // AGENTS_MAILBOX_DIR by the same id mailboxIdForActiveSession returns.
1710
+ // Claude also forwards --session-id to its CLI (unified identity);
1711
+ // other agents ignore the flag but still get the correct mailbox dir.
1712
+ if (sessionId) {
1710
1713
  cmd.push('--session-id', sessionId);
1711
1714
  }
1712
1715
  // Claude: grant access to the teammate's working directory.
@@ -652,6 +652,16 @@ export interface Meta {
652
652
  * lives separately in ~/.agents/.cache/browser/<profile>/.
653
653
  */
654
654
  browser?: Record<string, BrowserProfileConfig>;
655
+ /**
656
+ * Device-local pointer: the browser profile `agents browser start` resolves to
657
+ * when no `--profile` is passed (and what an explicit `--profile default`
658
+ * re-points to). Stored per-machine in `~/.agents/devices/<machine>/agents.yaml`,
659
+ * NOT central — the target profile may carry machine-local logins, so the choice
660
+ * must not ride `agents repo push/pull` to other machines. Unset = auto-detect an
661
+ * installed Chromium-family browser (legacy behavior). Set via
662
+ * `agents browser profiles set-default <name>`.
663
+ */
664
+ defaultBrowserProfile?: string;
655
665
  /**
656
666
  * Agent-host registry keyed by host name (`agents hosts`). Portable user
657
667
  * config synced with `agents repo push/pull`. For `ssh-config` hosts this is
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Render a compact "What's new" summary from a CHANGELOG.md body: one bullet
3
- * per feature/fix heading (the `**...**` lines) for each version in the range
4
- * the user actually moved through, `(fromVersion, toVersion]`. The verbose
5
- * sub-bullets are intentionally dropped — the full notes live in the changelog.
3
+ * per feature/fix heading for each version in the range the user actually
4
+ * moved through, `(fromVersion, toVersion]`. Headings are recognized in both
5
+ * changelog formats — the current `- **Title.** prose…` single-line bullets
6
+ * and the older standalone `**Heading**` lines. The verbose prose/sub-bullets
7
+ * are intentionally dropped — the full notes live in the changelog.
6
8
  *
7
9
  * Returns colored lines ready to print, empty when nothing is in range.
8
10
  */
@@ -2,15 +2,23 @@ import chalk from 'chalk';
2
2
  import { compareVersions } from './agent-spec/primitives.js';
3
3
  /**
4
4
  * Render a compact "What's new" summary from a CHANGELOG.md body: one bullet
5
- * per feature/fix heading (the `**...**` lines) for each version in the range
6
- * the user actually moved through, `(fromVersion, toVersion]`. The verbose
7
- * sub-bullets are intentionally dropped — the full notes live in the changelog.
5
+ * per feature/fix heading for each version in the range the user actually
6
+ * moved through, `(fromVersion, toVersion]`. Headings are recognized in both
7
+ * changelog formats — the current `- **Title.** prose…` single-line bullets
8
+ * and the older standalone `**Heading**` lines. The verbose prose/sub-bullets
9
+ * are intentionally dropped — the full notes live in the changelog.
8
10
  *
9
11
  * Returns colored lines ready to print, empty when nothing is in range.
10
12
  */
11
13
  export function renderWhatsNew(changelog, fromVersion, toVersion) {
12
14
  const out = [];
13
15
  let inRelevantSection = false;
16
+ // Whether the CURRENT version section uses the old standalone-heading format.
17
+ // Old sections nest `-` sub-bullets under each `**Heading**` line, and some
18
+ // sub-bullets are themselves bold-led (`- **Claim.** detail…`) — once a
19
+ // standalone heading is seen, `- **` lines in that section are sub-bullets,
20
+ // not entries, and must not render.
21
+ let sectionUsesStandaloneHeadings = false;
14
22
  for (const line of changelog.split('\n')) {
15
23
  const versionMatch = line.match(/^## (\d+\.\d+\.\d+)/);
16
24
  if (versionMatch) {
@@ -20,15 +28,27 @@ export function renderWhatsNew(changelog, fromVersion, toVersion) {
20
28
  inRelevantSection =
21
29
  compareVersions(currentVersion, fromVersion) > 0 &&
22
30
  compareVersions(currentVersion, toVersion) <= 0;
31
+ sectionUsesStandaloneHeadings = false;
23
32
  if (inRelevantSection) {
24
33
  out.push('');
25
34
  out.push(chalk.bold(`v${currentVersion}`));
26
35
  }
27
36
  continue;
28
37
  }
29
- // Only the bold headings — one bullet per feature/fix.
30
- if (inRelevantSection && line.startsWith('**') && line.endsWith('**')) {
38
+ // Only the entry headings — one bullet per feature/fix. Two formats exist
39
+ // across the changelog's history: the current single-line bullets
40
+ // (`- **Title.** verbose prose…`, heading kept, prose dropped) and the
41
+ // older standalone `**Heading**` lines with `-` sub-bullets beneath.
42
+ if (!inRelevantSection)
43
+ continue;
44
+ if (line.startsWith('**') && line.endsWith('**')) {
45
+ sectionUsesStandaloneHeadings = true;
31
46
  out.push(` ${chalk.cyan('•')} ${line.replace(/\*\*/g, '')}`);
47
+ continue;
48
+ }
49
+ const entryBullet = sectionUsesStandaloneHeadings ? null : line.match(/^- \*\*(.+?)\*\*/);
50
+ if (entryBullet) {
51
+ out.push(` ${chalk.cyan('•')} ${entryBullet[1].replace(/\*\*/g, '')}`);
32
52
  }
33
53
  }
34
54
  return out;