@phnx-labs/agents-cli 1.22.8 → 1.22.10

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 (65) hide show
  1. package/CHANGELOG.md +64 -4
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +2 -2
  5. package/dist/commands/exec.js +41 -9
  6. package/dist/commands/feed.js +38 -1
  7. package/dist/commands/harness.d.ts +40 -1
  8. package/dist/commands/harness.js +316 -76
  9. package/dist/commands/profiles.d.ts +42 -0
  10. package/dist/commands/profiles.js +91 -3
  11. package/dist/commands/secrets.js +38 -39
  12. package/dist/commands/sessions-picker.js +3 -3
  13. package/dist/commands/sessions-render.d.ts +12 -0
  14. package/dist/commands/sessions-render.js +124 -0
  15. package/dist/commands/sessions.js +28 -1
  16. package/dist/commands/ssh.js +30 -8
  17. package/dist/commands/teams.js +12 -1
  18. package/dist/commands/workflows.js +1 -1
  19. package/dist/index.js +9 -2
  20. package/dist/lib/crabbox/cli.d.ts +35 -0
  21. package/dist/lib/crabbox/cli.js +46 -0
  22. package/dist/lib/crabbox/config.d.ts +21 -0
  23. package/dist/lib/crabbox/config.js +43 -0
  24. package/dist/lib/crabbox/lease.d.ts +15 -5
  25. package/dist/lib/crabbox/lease.js +57 -17
  26. package/dist/lib/daemon.js +12 -11
  27. package/dist/lib/devices/resolve-target.d.ts +4 -3
  28. package/dist/lib/devices/resolve-target.js +4 -3
  29. package/dist/lib/hosts/passthrough.js +18 -1
  30. package/dist/lib/hosts/registry.d.ts +4 -0
  31. package/dist/lib/hosts/registry.js +16 -0
  32. package/dist/lib/hosts/remote-cmd.js +1 -0
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  34. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  35. package/dist/lib/observe-aliases.d.ts +30 -0
  36. package/dist/lib/observe-aliases.js +56 -0
  37. package/dist/lib/plugins.d.ts +14 -5
  38. package/dist/lib/plugins.js +29 -5
  39. package/dist/lib/redact.js +4 -0
  40. package/dist/lib/resources/types.d.ts +2 -1
  41. package/dist/lib/resources/workflows.d.ts +1 -1
  42. package/dist/lib/resources/workflows.js +22 -20
  43. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  45. package/dist/lib/secrets/bundles.d.ts +9 -0
  46. package/dist/lib/secrets/bundles.js +9 -23
  47. package/dist/lib/secrets/headless.d.ts +3 -15
  48. package/dist/lib/secrets/headless.js +5 -33
  49. package/dist/lib/secrets/index.d.ts +8 -0
  50. package/dist/lib/secrets/index.js +16 -36
  51. package/dist/lib/session/parse.d.ts +5 -1
  52. package/dist/lib/session/parse.js +23 -13
  53. package/dist/lib/session/prompt.js +5 -0
  54. package/dist/lib/session/render.d.ts +3 -0
  55. package/dist/lib/session/render.js +33 -10
  56. package/dist/lib/share/config.js +5 -7
  57. package/dist/lib/startup/command-registry.js +15 -1
  58. package/dist/lib/types.d.ts +6 -0
  59. package/dist/lib/usage-refresh.d.ts +19 -11
  60. package/dist/lib/usage-refresh.js +75 -43
  61. package/dist/lib/usage.d.ts +12 -0
  62. package/dist/lib/usage.js +37 -6
  63. package/dist/lib/workflows.d.ts +19 -4
  64. package/dist/lib/workflows.js +81 -7
  65. package/package.json +1 -1
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
5
  * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
- * Resolution order: project > user > system.
6
+ * Resolution order (docs/07-entrypoints): project > user > plugin > extra > system.
7
7
  */
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { getProjectAgentsDir, getUserWorkflowsDir, getSystemWorkflowsDir, getEnabledExtraRepos, } from '../state.js';
11
- import { parseWorkflowFrontmatter, countWorkflowSubagents } from '../workflows.js';
11
+ import { parseWorkflowFrontmatter, countWorkflowSubagents, listPluginWorkflowDirs, isBareWorkflowName, } from '../workflows.js';
12
12
  function getLayerDirs(cwd) {
13
13
  const projectDir = getProjectAgentsDir(cwd);
14
14
  const extraRepos = getEnabledExtraRepos();
@@ -32,20 +32,27 @@ function listWorkflowsInDir(dir) {
32
32
  return [];
33
33
  }
34
34
  }
35
+ /** Precedence-ordered (dir, layer) pairs for name lookup / listing. */
36
+ function orderedWorkflowSearchDirs(cwd) {
37
+ const dirs = getLayerDirs(cwd);
38
+ const out = [];
39
+ if (dirs.project)
40
+ out.push({ dir: dirs.project, layer: 'project' });
41
+ out.push({ dir: dirs.user, layer: 'user' });
42
+ for (const pluginDir of listPluginWorkflowDirs(cwd ?? process.cwd())) {
43
+ out.push({ dir: pluginDir, layer: 'plugin' });
44
+ }
45
+ for (const extraDir of dirs.extra)
46
+ out.push({ dir: extraDir, layer: 'system' });
47
+ out.push({ dir: dirs.system, layer: 'system' });
48
+ return out;
49
+ }
35
50
  class WorkflowsHandlerImpl {
36
51
  kind = 'workflow';
37
52
  listAll(_agent, cwd) {
38
- const dirs = getLayerDirs(cwd);
39
53
  const seen = new Set();
40
54
  const results = [];
41
- const layerDirs = [];
42
- if (dirs.project)
43
- layerDirs.push({ dir: dirs.project, layer: 'project' });
44
- layerDirs.push({ dir: dirs.user, layer: 'user' });
45
- layerDirs.push({ dir: dirs.system, layer: 'system' });
46
- for (const extraDir of dirs.extra)
47
- layerDirs.push({ dir: extraDir, layer: 'system' });
48
- for (const { dir, layer } of layerDirs) {
55
+ for (const { dir, layer } of orderedWorkflowSearchDirs(cwd)) {
49
56
  for (const { name, path: workflowPath } of listWorkflowsInDir(dir)) {
50
57
  if (seen.has(name))
51
58
  continue;
@@ -69,15 +76,10 @@ class WorkflowsHandlerImpl {
69
76
  return results.sort((a, b) => a.name.localeCompare(b.name));
70
77
  }
71
78
  resolve(_agent, name, cwd) {
72
- const dirs = getLayerDirs(cwd);
73
- const searchDirs = [];
74
- if (dirs.project)
75
- searchDirs.push({ dir: dirs.project, layer: 'project' });
76
- searchDirs.push({ dir: dirs.user, layer: 'user' });
77
- searchDirs.push({ dir: dirs.system, layer: 'system' });
78
- for (const extraDir of dirs.extra)
79
- searchDirs.push({ dir: extraDir, layer: 'system' });
80
- for (const { dir, layer } of searchDirs) {
79
+ // Same bare-name gate as resolveWorkflowRef — never path-join traversal refs.
80
+ if (!isBareWorkflowName(name))
81
+ return null;
82
+ for (const { dir, layer } of orderedWorkflowSearchDirs(cwd)) {
81
83
  const workflowPath = path.join(dir, name);
82
84
  const fm = parseWorkflowFrontmatter(workflowPath);
83
85
  if (fm) {
@@ -23,6 +23,8 @@
23
23
  import { type BundleValue, type SecretRef } from './index.js';
24
24
  /** Which store carries a bundle's items. */
25
25
  export type SecretsBackend = 'keychain' | 'file' | 'vault';
26
+ /** Disable the broker-only guard for in-memory keychain tests. */
27
+ export declare function setKeychainAgentOnlyBypassForTest(bypass: boolean): void;
26
28
  /**
27
29
  * Discover a bundle's backend by location: a file-backed bundle's metadata
28
30
  * item exists in the encrypted-file store. This is a plain file-existence
@@ -184,6 +186,13 @@ export declare function deleteBundle(name: string): boolean;
184
186
  * extra keychain read is issued. Exported for tests. Returns the count healed.
185
187
  */
186
188
  export declare function healKeychainBundleMetadata(metaJsonByName: Map<string, string>): number;
189
+ /**
190
+ * One-time driver around healKeychainBundleMetadata (RUSH-1759). macOS + real
191
+ * keychain only — libsecret/CredMan have no biometry ACL to shed, and a test
192
+ * backend has no real keychain — and gated by a sentinel so it runs at most
193
+ * once. Best-effort: a heal failure never breaks bundle listing.
194
+ */
195
+ export declare function healKeychainBundleMetadataAclOnce(metaJsonByName: Map<string, string>): void;
187
196
  export declare function listBundles(): SecretsBundle[];
188
197
  export interface BundleEntryInfo {
189
198
  key: string;
@@ -86,6 +86,11 @@ const vaultStore = {
86
86
  delete: vaultDeleteItem,
87
87
  list: vaultListItems,
88
88
  };
89
+ let keychainAgentOnlyBypassForTest = false;
90
+ /** Disable the broker-only guard for in-memory keychain tests. */
91
+ export function setKeychainAgentOnlyBypassForTest(bypass) {
92
+ keychainAgentOnlyBypassForTest = bypass;
93
+ }
89
94
  function itemStore(backend) {
90
95
  if (backend === 'file')
91
96
  return fileItemStore;
@@ -606,7 +611,7 @@ export function healKeychainBundleMetadata(metaJsonByName) {
606
611
  * backend has no real keychain — and gated by a sentinel so it runs at most
607
612
  * once. Best-effort: a heal failure never breaks bundle listing.
608
613
  */
609
- function healKeychainBundleMetadataAclOnce(metaJsonByName) {
614
+ export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
610
615
  if (metaJsonByName.size === 0)
611
616
  return;
612
617
  if (process.platform !== 'darwin')
@@ -684,7 +689,6 @@ export function listBundles() {
684
689
  // still prompt once; it heals on the next interactive scan.)
685
690
  const fetched = getKeychainTokens(keychainServices, { silentNoAcl: true });
686
691
  const keychainBundles = [];
687
- const metaJsonByName = new Map();
688
692
  for (const service of keychainServices) {
689
693
  const json = fetched.get(service);
690
694
  if (json === undefined)
@@ -695,7 +699,6 @@ export function listBundles() {
695
699
  const bundle = parseBundleMeta(nameHint, json, 'keychain');
696
700
  if (bundle) {
697
701
  keychainBundles.push(bundle);
698
- metaJsonByName.set(bundle.name, json);
699
702
  }
700
703
  }
701
704
  for (const bundle of keychainBundles)
@@ -707,13 +710,6 @@ export function listBundles() {
707
710
  if (useAgent && keychainBundles.length > 0) {
708
711
  agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
709
712
  }
710
- // One-time RUSH-1759 heal: bundles written before the metadata-no-ACL
711
- // change carry an ACL'd metadata item, so this fresh read (a broker miss)
712
- // popped Touch ID just to enumerate them. Re-home each metadata item
713
- // no-ACL now — reusing the JSON we just read, so the heal adds no extra
714
- // prompt — and every later enumeration is silent. Runs at most once (a
715
- // sentinel under the regenerable helpers dir).
716
- healKeychainBundleMetadataAclOnce(metaJsonByName);
717
713
  }
718
714
  }
719
715
  }
@@ -1099,24 +1095,14 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1099
1095
  return filtered;
1100
1096
  }
1101
1097
  }
1102
- // Never/no-ACL bundles remain prompt-free regardless. No agent launch — harness,
1103
- // teammate, routine, or the always-on daemon — may raise the sheet itself.
1104
- // Explicit opt-in ONLY — a deliberate NARROWING of the agent-triggered approval
1105
- // added in RUSH-2032 (b99796f8 removed this throw so an agent could raise the
1106
- // sheet itself; 4eeada68 generalized the daemon rule into `!interactiveUnlock`).
1107
- // That default — true whenever an agent name was present — was the spec, not a
1108
- // bug. It is unwanted: each keychain read runs in its own helper process, so the
1109
- // biometric assertion never reuses and one agent launch meant one sheet per
1110
- // bundle. `agentOnly` decides alone now; a human in a plain shell carries no
1111
- // AGENTS_RUNTIME, so isHeadlessSecretsContext() is false, agentOnly is false, the
1112
- // guard never fires, and they still get their prompt. No caller passes this flag;
1113
- // it remains the seam for a future unlock path that wants the sheet on purpose.
1098
+ // Never/no-ACL bundles remain prompt-free regardless. Every ordinary caller
1099
+ // sets agentOnly; only the unlock handler opts into interactive authentication.
1114
1100
  const interactiveUnlock = opts.interactiveUnlock ?? false;
1115
1101
  // A `never`-policy bundle's items carry no biometry ACL, so once the policy
1116
1102
  // check below proves that, the batch read is silent even in a headless
1117
1103
  // context — attest it to the raw-read storm guard via `silentNoAcl`.
1118
1104
  let verifiedNoAclBundle = false;
1119
- if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
1105
+ if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock && !keychainAgentOnlyBypassForTest) {
1120
1106
  try {
1121
1107
  verifiedNoAclBundle = bundlePolicy(readBundle(name)) === 'never';
1122
1108
  }
@@ -5,22 +5,13 @@
5
5
  * importing bundles.ts (which already imports index.ts).
6
6
  */
7
7
  /**
8
- * True when the current process is a background / non-interactive context that
9
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
- * a prompt nobody is watching. Two signals, either sufficient:
8
+ * True when the current process was structurally launched by an agent runtime
9
+ * that must NEVER raise a Keychain biometry prompt on the user's screen:
11
10
  * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
11
  * launch, interactive included, and inherited by everything spawned beneath
13
12
  * one (set on the child env by `agents run --headless`, scheduled routines,
14
13
  * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
14
  * teams/agents.ts).
16
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
- * stdio is redirected to a log — e.g. a release script run in the
18
- * background as `( ... ) >log 2>&1 </dev/null`).
19
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
- * broker-only — the agent, not the human, is the caller there.
24
15
  *
25
16
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
17
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -33,7 +24,4 @@
33
24
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
25
  * the per-caller broker-only pattern used across the headless secrets readers.
35
26
  */
36
- export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
37
- stdin?: boolean;
38
- stdout?: boolean;
39
- }): boolean;
27
+ export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
@@ -5,22 +5,13 @@
5
5
  * importing bundles.ts (which already imports index.ts).
6
6
  */
7
7
  /**
8
- * True when the current process is a background / non-interactive context that
9
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
- * a prompt nobody is watching. Two signals, either sufficient:
8
+ * True when the current process was structurally launched by an agent runtime
9
+ * that must NEVER raise a Keychain biometry prompt on the user's screen:
11
10
  * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
11
  * launch, interactive included, and inherited by everything spawned beneath
13
12
  * one (set on the child env by `agents run --headless`, scheduled routines,
14
13
  * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
14
  * teams/agents.ts).
16
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
- * stdio is redirected to a log — e.g. a release script run in the
18
- * background as `( ... ) >log 2>&1 </dev/null`).
19
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
- * broker-only — the agent, not the human, is the caller there.
24
15
  *
25
16
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
17
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -33,31 +24,12 @@
33
24
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
25
  * the per-caller broker-only pattern used across the headless secrets readers.
35
26
  */
36
- export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
37
- // Injected so the TTY branch below is testable: it is the branch that decides a
38
- // plain human shell still prompts, which is this guard's entire safety argument,
39
- // and reading process.* directly made it unreachable from a test.
40
- tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
27
+ export function isHeadlessSecretsContext(env = process.env, platform = process.platform) {
41
28
  if (platform !== 'darwin')
42
29
  return false; // no biometry prompt to suppress off-darwin
43
- const override = env.AGENTS_SECRETS_NO_PROMPT;
44
- if (override === '1')
45
- return true;
46
- if (override === '0')
47
- return false;
48
- // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
49
- // `terminal` was missing, which made an agent terminal the one launch path
50
- // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
51
- // interactive run (exec.ts:430), that fell through to the TTY check below, and
52
- // a TTY meant "a human is watching, so prompting is fine". It is not fine —
53
- // opening a terminal is not a request to authenticate, and a launch that needs
54
- // a locked bundle should say so and point at `agents secrets unlock`, not grab
55
- // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
56
- // an agent, so `agents secrets export` run beneath one resolves broker-only too —
57
- // correctly: there the agent, not the human, is the caller. A plain shell carries
58
- // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
30
+ // Every agent-launch runtime resolves broker-only, interactive included.
59
31
  const runtime = env.AGENTS_RUNTIME;
60
32
  if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
61
33
  return true;
62
- return !tty.stdin && !tty.stdout;
34
+ return false;
63
35
  }
@@ -143,6 +143,14 @@ export declare function healHmacKeyNoAclOnce(rec: HmacKeyRecord): boolean;
143
143
  * see readAndResolveBundleEnv.
144
144
  */
145
145
  export declare function keychainServiceAlias(item: string): string;
146
+ /**
147
+ * One-shot per process: activate hashing on machines with nothing to move,
148
+ * finish a crash-interrupted delete phase (silent), and run the interactive
149
+ * one-time re-key when cleartext-named items exist and a human is present.
150
+ * Never throws — a failed attempt leaves the process on cleartext names
151
+ * (exact pre-#316 behavior) and the next process retries.
152
+ */
153
+ export declare function maybeAutoRekey(): void;
146
154
  /** One re-keyed (or failed) item, by its old cleartext service name. */
147
155
  export interface RekeyPlanItem {
148
156
  oldService: string;
@@ -247,30 +247,7 @@ export function readHmacKeyRecord() {
247
247
  catch {
248
248
  return null;
249
249
  }
250
- const record = parseHmacKeyRecord(raw);
251
- // Converge a stale-ACL'd hmackey to silent, on the HOT read path. An old helper
252
- // (pre the metadata/hmackey no-ACL migration fix) re-stamped this
253
- // contractually-no-ACL item with a biometry ACL, so the read just above pops the
254
- // generic "Agents CLI needs to authenticate" sheet on EVERY hashed lookup — the
255
- // `agents devices list` stats probe the SessionStart hook runs, and every other
256
- // background hashed read. `maybeAutoRekey`'s one-shot heal only fires on a
257
- // cleartext-bundle resolve and is bypassed for the hmackey/hashed-name path
258
- // (prepareServiceName returns early for HMAC_KEY_ITEM before maybeAutoRekey), so
259
- // it never converged exactly these reads and the machine prompted forever.
260
- // Re-store the record no-ACL once per machine here (the read that produced it has
261
- // already happened — and already prompted if the item was ACL'd); every
262
- // subsequent read, in this process and all future ones, is silent.
263
- if (record && !record.healedNoAcl) {
264
- try {
265
- healHmacKeyNoAclOnce(record);
266
- record.healedNoAcl = true;
267
- }
268
- catch {
269
- // A failed no-ACL re-store leaves the item still ACL'd (a still-prompting
270
- // read) rather than a silent wrong state; the next process retries.
271
- }
272
- }
273
- return record;
250
+ return parseHmacKeyRecord(raw);
274
251
  }
275
252
  function writeHmacKeyRecord(rec) {
276
253
  // JSON.stringify drops undefined fields (used to clear pendingDeletes).
@@ -325,15 +302,13 @@ function resolveHashState() {
325
302
  export function keychainServiceAlias(item) {
326
303
  return prepareServiceName(item);
327
304
  }
328
- function prepareServiceName(item, opts) {
305
+ function prepareServiceName(item) {
329
306
  if (rawScopeDepth > 0)
330
307
  return item;
331
308
  if (!isOurItem(item))
332
309
  return item;
333
310
  if (item === HMAC_KEY_ITEM || item.startsWith(HASHED_SERVICE_PREFIX))
334
311
  return item;
335
- if (opts?.autoRekey)
336
- maybeAutoRekey();
337
312
  const st = resolveHashState();
338
313
  if (!st.active || !st.key)
339
314
  return item;
@@ -354,7 +329,6 @@ function prepareListPrefix(prefix) {
354
329
  return { prefix };
355
330
  if (prefix.startsWith(HASHED_SERVICE_PREFIX))
356
331
  return { prefix };
357
- maybeAutoRekey();
358
332
  const st = resolveHashState();
359
333
  if (!st.active || !st.key)
360
334
  return { prefix };
@@ -429,7 +403,7 @@ function finishPendingDeletes(rec) {
429
403
  * Never throws — a failed attempt leaves the process on cleartext names
430
404
  * (exact pre-#316 behavior) and the next process retries.
431
405
  */
432
- function maybeAutoRekey() {
406
+ export function maybeAutoRekey() {
433
407
  if (autoRekeyAttempted || rekeyRunning || rawScopeDepth > 0)
434
408
  return;
435
409
  autoRekeyAttempted = true;
@@ -446,10 +420,14 @@ function maybeAutoRekey() {
446
420
  return;
447
421
  const st = resolveHashState();
448
422
  if (st.active) {
449
- // The stale-ACL'd-hmackey heal now runs on the hot read path
450
- // (readHmacKeyRecord), which the resolveHashState() above just went through —
451
- // so st.record is already healed here regardless of how this machine reached
452
- // "hashing active". Nothing to do but finish any pending deletes.
423
+ if (st.record && !st.record.healedNoAcl) {
424
+ try {
425
+ healHmacKeyNoAclOnce(st.record);
426
+ }
427
+ catch {
428
+ /* the next explicit unlock retries */
429
+ }
430
+ }
453
431
  if (st.record?.pendingDeletes?.length) {
454
432
  try {
455
433
  finishPendingDeletes(st.record);
@@ -820,7 +798,7 @@ export function getKeychainToken(item, context = {}) {
820
798
  // Errors keep the requested (human-readable) name; the storage name may be
821
799
  // an opaque hash.
822
800
  const requested = item;
823
- item = prepareServiceName(item, { autoRekey: true });
801
+ item = prepareServiceName(item);
824
802
  if (backend)
825
803
  return backend.get(item);
826
804
  assertRawKeychainReadAllowed(requested, context);
@@ -848,6 +826,7 @@ export function getKeychainToken(item, context = {}) {
848
826
  ...process.env,
849
827
  AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
850
828
  AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
829
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
851
830
  },
852
831
  stdio: ['ignore', 'pipe', 'pipe'],
853
832
  });
@@ -890,7 +869,7 @@ export function getKeychainTokens(items, context = {}) {
890
869
  // whether those were cleartext (hashed here) or already-hashed (enumerated).
891
870
  const requestedByStorage = new Map();
892
871
  const storageItems = items.map((item) => {
893
- const storage = prepareServiceName(item, { autoRekey: true });
872
+ const storage = prepareServiceName(item);
894
873
  if (!requestedByStorage.has(storage))
895
874
  requestedByStorage.set(storage, item);
896
875
  return storage;
@@ -939,6 +918,7 @@ export function getKeychainTokens(items, context = {}) {
939
918
  ...process.env,
940
919
  AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
941
920
  AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
921
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
942
922
  // The signed helper's own vocabulary is unchanged (it predates the rename
943
923
  // and ships as a separately-versioned binary), so map to its legacy token.
944
924
  AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
@@ -1037,7 +1017,7 @@ export function setKeychainToken(item, value, opts) {
1037
1017
  if (/[\x00=\r\n]/.test(item))
1038
1018
  throw new Error('Secret item name contains invalid characters.');
1039
1019
  const requested = item;
1040
- item = prepareServiceName(item, { autoRekey: true });
1020
+ item = prepareServiceName(item);
1041
1021
  if (backend) {
1042
1022
  backend.set(item, value, opts);
1043
1023
  return;
@@ -31,7 +31,11 @@ export declare function safeReadSessionFile(filePath: string, maxBytes?: number)
31
31
  /**
32
32
  * Auto-detect agent type from file path and parse the session.
33
33
  */
34
- export declare function parseSession(filePath: string, agent?: SessionAgentId): SessionEvent[];
34
+ export interface ParseSessionOptions {
35
+ /** Keep normalized tool results compact by default; renderers can request full output. */
36
+ maxToolOutputChars?: number;
37
+ }
38
+ export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
35
39
  /** Infer the agent type from a session file path using known directory conventions. */
36
40
  export declare function detectAgent(filePath: string): SessionAgentId | null;
37
41
  /**
@@ -112,10 +112,12 @@ export function safeReadSessionFile(filePath, maxBytes = SESSION_FILE_MAX_BYTES)
112
112
  }
113
113
  return fs.readFileSync(filePath, 'utf-8');
114
114
  }
115
- /**
116
- * Auto-detect agent type from file path and parse the session.
117
- */
118
- export function parseSession(filePath, agent) {
115
+ function truncateNormalizedToolOutput(output, maxChars) {
116
+ if (!Number.isFinite(maxChars) || output.length <= maxChars)
117
+ return output;
118
+ return `${output.slice(0, maxChars)}\n\n[Output truncated: ${output.length - maxChars} characters omitted.]`;
119
+ }
120
+ export function parseSession(filePath, agent, opts = {}) {
119
121
  const detected = agent || detectAgent(filePath);
120
122
  if (!detected) {
121
123
  throw new Error(`Cannot detect agent type from path: ${filePath}`);
@@ -166,7 +168,11 @@ export function parseSession(filePath, agent) {
166
168
  // from `!`-prefix runs, `<system-reminder>`, etc.) as `_synthetic` so turn
167
169
  // slicing and `--include user` count only genuine user intent — one place,
168
170
  // every harness, instead of per-consumer regex.
171
+ const maxToolOutputChars = opts.maxToolOutputChars ?? 500;
169
172
  for (const e of events) {
173
+ if (e.type === 'tool_result' && e.output) {
174
+ e.output = truncateNormalizedToolOutput(e.output, maxToolOutputChars);
175
+ }
170
176
  sanitizeEvent(e);
171
177
  if (e.type === 'message' && e.role === 'user' && isSyntheticUserMessage(e.content)) {
172
178
  e._synthetic = true;
@@ -496,7 +502,11 @@ export function parseClaudeContent(content) {
496
502
  exitCode: structured.exitCode,
497
503
  statusCode: structured.statusCode,
498
504
  errorCode: structured.errorCode,
499
- output: output.length > 500 ? output.slice(0, 497) + '...' : output,
505
+ // Not truncated here: `parseSession` caps every event's `output`
506
+ // centrally via `maxToolOutputChars` (default 500, the same
507
+ // bound this site used to hardcode), so the render path can ask
508
+ // for the full text with `Infinity`.
509
+ output,
500
510
  });
501
511
  }
502
512
  if (toolId)
@@ -686,7 +696,7 @@ export function parseCodexContent(content) {
686
696
  exitCode: result.exitCode,
687
697
  statusCode: result.statusCode,
688
698
  errorCode: result.errorCode,
689
- output: result.text.length > 500 ? result.text.slice(0, 497) + '...' : result.text,
699
+ output: result.text,
690
700
  });
691
701
  if (callId)
692
702
  callMap.delete(callId);
@@ -741,7 +751,7 @@ export function parseCodexContent(content) {
741
751
  exitCode: result.exitCode,
742
752
  statusCode: result.statusCode,
743
753
  errorCode: result.errorCode,
744
- output: result.text.length > 500 ? result.text.slice(0, 497) + '...' : result.text,
754
+ output: result.text,
745
755
  });
746
756
  if (callId)
747
757
  callMap.delete(callId);
@@ -865,7 +875,7 @@ export function parseGemini(filePath) {
865
875
  tool: toolName,
866
876
  callId: typeof tc.id === 'string' ? tc.id : undefined,
867
877
  success: tc.status === 'success',
868
- output: output.length > 500 ? output.slice(0, 497) + '...' : output,
878
+ output,
869
879
  });
870
880
  }
871
881
  }
@@ -1242,7 +1252,7 @@ export function parseGrok(filePath) {
1242
1252
  callId,
1243
1253
  success: structuredError ? false : structuredSuccess ? true : undefined,
1244
1254
  outcome: structuredError ? 'error' : structuredSuccess ? 'ok' : 'unknown',
1245
- output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1255
+ output: output,
1246
1256
  });
1247
1257
  if (callId)
1248
1258
  toolCallMap.delete(callId);
@@ -1360,7 +1370,7 @@ export function parseOpenCode(filePath) {
1360
1370
  tool: toolName,
1361
1371
  callId,
1362
1372
  success: state.status === 'completed',
1363
- output: outputStr.length > 500 ? outputStr.slice(0, 497) + '...' : outputStr,
1373
+ output: outputStr,
1364
1374
  });
1365
1375
  }
1366
1376
  break;
@@ -1470,7 +1480,7 @@ export function parseRush(filePath) {
1470
1480
  tool: info?.tool ?? (typeof raw.name === 'string' ? raw.name : 'unknown'),
1471
1481
  callId,
1472
1482
  success,
1473
- output: outputStr.length > 500 ? outputStr.slice(0, 497) + '...' : outputStr,
1483
+ output: outputStr,
1474
1484
  });
1475
1485
  if (callId)
1476
1486
  toolCallMap.delete(callId);
@@ -1680,7 +1690,7 @@ export function parseKimi(filePath) {
1680
1690
  callId,
1681
1691
  success: structuredError ? false : structuredSuccess ? true : undefined,
1682
1692
  outcome: structuredError ? 'error' : structuredSuccess ? 'ok' : 'unknown',
1683
- output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1693
+ output: output,
1684
1694
  });
1685
1695
  if (callId) {
1686
1696
  toolCallMap.delete(callId);
@@ -1825,7 +1835,7 @@ function parseAnthropicMessageJsonl(filePath, agent) {
1825
1835
  else {
1826
1836
  events.push({
1827
1837
  type: 'tool_result', agent, timestamp, tool: toolInfo?.tool, callId: toolId, success: true,
1828
- output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1838
+ output: output,
1829
1839
  });
1830
1840
  }
1831
1841
  if (toolId)
@@ -46,6 +46,11 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
46
46
  /^\s*<\/?user-prompt-submit-hook>/i,
47
47
  /^\s*<\/?persisted-output>/i,
48
48
  /^\s*<permissions instructions>/i,
49
+ /^\s*<(?:apps|plugins|skills)_instructions>/i,
50
+ /^\s*<(?:multi_agent_mode|environment_context)>/i,
51
+ /^\s*## (?:In-flight in this repo|Host & Fleet)/i,
52
+ /^\s*Your current session id is\b/i,
53
+ /^\s*Linear context skipped:/i,
49
54
  /^\s*<collaboration_mode>/i,
50
55
  /^\s*Caveat: The messages below were generated by the user while running/i,
51
56
  /^\s*\[Request interrupted/i,
@@ -108,6 +108,9 @@ export declare function filterEvents(events: SessionEvent[], opts: FilterOptions
108
108
  */
109
109
  export interface RenderConversationMarkdownOptions {
110
110
  redact?: boolean;
111
+ knownSecrets?: readonly string[];
112
+ reasoning?: 'omit' | 'fold' | 'include';
113
+ maxToolOutputChars?: number;
111
114
  }
112
115
  export declare function renderConversationMarkdown(events: SessionEvent[], opts?: RenderConversationMarkdownOptions): string;
113
116
  /**
@@ -923,10 +923,23 @@ export function filterEvents(events, opts) {
923
923
  const sliced = applyTurnSlice(events, opts);
924
924
  return applyRoleFilter(sliced, opts);
925
925
  }
926
+ function markdownFence(content, language = '') {
927
+ const fence = content.includes('```') ? '````' : '```';
928
+ return `${fence}${language}\n${content}\n${fence}`;
929
+ }
930
+ function truncateToolOutput(content, maxChars) {
931
+ if (content.length <= maxChars)
932
+ return content;
933
+ return `${content.slice(0, maxChars)}\n\n[Output truncated: ${content.length - maxChars} characters omitted.]`;
934
+ }
926
935
  export function renderConversationMarkdown(events, opts = {}) {
927
936
  const parts = [];
928
937
  const shouldRedact = opts.redact !== false;
929
- const sanitize = (text) => shouldRedact ? redactSecrets(text) : text;
938
+ const sanitize = (text) => shouldRedact ? redactSecrets(text, opts.knownSecrets) : text;
939
+ // Preserve the long-standing `sessions <id> --markdown` full-fidelity default.
940
+ // The shareable `sessions render` surface passes `omit` explicitly.
941
+ const reasoning = opts.reasoning ?? 'include';
942
+ const maxToolOutputChars = opts.maxToolOutputChars ?? 4000;
930
943
  for (const event of events) {
931
944
  if (event.type === 'message') {
932
945
  if (event.role === 'user') {
@@ -937,27 +950,37 @@ export function renderConversationMarkdown(events, opts = {}) {
937
950
  }
938
951
  }
939
952
  else if (event.type === 'thinking') {
940
- if (event.content)
941
- parts.push(`### Thinking\n\n${sanitize(event.content)}`);
953
+ if (event.content && reasoning === 'include') {
954
+ parts.push(`### Reasoning\n\n${sanitize(event.content)}`);
955
+ }
956
+ else if (event.content && reasoning === 'fold') {
957
+ parts.push(`<details>\n<summary>Reasoning</summary>\n\n${sanitize(event.content)}\n\n</details>`);
958
+ }
942
959
  }
943
960
  else if (event.type === 'tool_use') {
944
961
  const tool = event.tool || 'unknown';
945
962
  if (event.command) {
946
- parts.push(`### Tool: ${tool}\n\n\`\`\`bash\n${sanitize(event.command)}\n\`\`\``);
963
+ const args = event.args && Object.keys(event.args).length > 0
964
+ ? `\n\nArguments:\n\n${markdownFence(sanitize(JSON.stringify(event.args, null, 2)), 'json')}`
965
+ : '';
966
+ parts.push(`### Tool: ${tool}\n\n${markdownFence(sanitize(event.command), 'bash')}${args}`);
967
+ }
968
+ else if (event.args && Object.keys(event.args).length > 0) {
969
+ parts.push(`### Tool: ${tool}\n\nArguments:\n\n${markdownFence(sanitize(JSON.stringify(event.args, null, 2)), 'json')}`);
947
970
  }
948
971
  else if (event.path) {
949
- parts.push(`### Tool: ${tool}\n\n\`${shortenPathTrace(event.path)}\``);
972
+ parts.push(`### Tool: ${tool}\n\n\`${sanitize(shortenPathTrace(event.path))}\``);
950
973
  }
951
974
  else {
952
975
  const summary = summarizeToolUse(tool, event.args);
953
- parts.push(`### Tool: ${tool}\n\n${summary}`);
976
+ parts.push(`### Tool: ${tool}\n\n${sanitize(summary)}`);
954
977
  }
955
978
  }
956
979
  else if (event.type === 'tool_result') {
957
- if (event.content) {
958
- const truncated = event.content.length > 2000 ? event.content.slice(0, 2000) + '\n…' : event.content;
959
- const body = sanitize(truncated);
960
- parts.push(`### Tool Result\n\n\`\`\`\n${body}\n\`\`\``);
980
+ const output = event.content || event.output;
981
+ if (output) {
982
+ const body = sanitize(truncateToolOutput(output, maxToolOutputChars));
983
+ parts.push(`### Tool Result${event.tool ? `: ${event.tool}` : ''}\n\n${markdownFence(body)}`);
961
984
  }
962
985
  }
963
986
  else if (event.type === 'error') {