@phnx-labs/agents-cli 1.22.24 → 1.22.26

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 (143) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +31 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/cloud.js +9 -5
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +108 -9
  10. package/dist/commands/exec.js +19 -16
  11. package/dist/commands/feed.d.ts +5 -0
  12. package/dist/commands/feed.js +21 -1
  13. package/dist/commands/focus.js +2 -2
  14. package/dist/commands/insights.d.ts +25 -19
  15. package/dist/commands/insights.js +107 -33
  16. package/dist/commands/menubar.js +8 -0
  17. package/dist/commands/reconnect.d.ts +46 -0
  18. package/dist/commands/reconnect.js +109 -0
  19. package/dist/commands/routines.d.ts +3 -0
  20. package/dist/commands/routines.js +72 -49
  21. package/dist/commands/run-cloud.js +1 -1
  22. package/dist/commands/secrets.d.ts +2 -8
  23. package/dist/commands/secrets.js +29 -105
  24. package/dist/commands/sessions-browser.d.ts +1 -1
  25. package/dist/commands/sessions-browser.js +27 -7
  26. package/dist/commands/sessions-resume.js +3 -2
  27. package/dist/commands/sessions.d.ts +13 -1
  28. package/dist/commands/sessions.js +28 -2
  29. package/dist/commands/setup-secrets.d.ts +1 -0
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/setup-watchdog.js +5 -10
  32. package/dist/commands/setup.d.ts +26 -3
  33. package/dist/commands/setup.js +106 -47
  34. package/dist/commands/teams.d.ts +6 -0
  35. package/dist/commands/teams.js +46 -2
  36. package/dist/commands/trends.d.ts +8 -0
  37. package/dist/commands/trends.js +10 -156
  38. package/dist/commands/watchdog.d.ts +3 -4
  39. package/dist/commands/watchdog.js +26 -66
  40. package/dist/index.js +37 -2
  41. package/dist/lib/agents.d.ts +11 -0
  42. package/dist/lib/agents.js +155 -23
  43. package/dist/lib/analytics/dashboard.d.ts +10 -6
  44. package/dist/lib/analytics/dashboard.js +6 -4
  45. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  46. package/dist/lib/analytics/mix-commands.js +229 -0
  47. package/dist/lib/analytics/recipes.d.ts +19 -14
  48. package/dist/lib/analytics/recipes.js +4 -2
  49. package/dist/lib/browser/ipc.d.ts +26 -0
  50. package/dist/lib/browser/ipc.js +139 -24
  51. package/dist/lib/browser/profiles.d.ts +11 -0
  52. package/dist/lib/browser/profiles.js +1 -1
  53. package/dist/lib/browser/stream.d.ts +14 -0
  54. package/dist/lib/browser/stream.js +71 -0
  55. package/dist/lib/channels/owner-sink.d.ts +27 -0
  56. package/dist/lib/channels/owner-sink.js +93 -0
  57. package/dist/lib/cloud/cursor.d.ts +79 -0
  58. package/dist/lib/cloud/cursor.js +228 -0
  59. package/dist/lib/cloud/registry.js +2 -0
  60. package/dist/lib/cloud/types.d.ts +7 -2
  61. package/dist/lib/cloud/types.js +14 -0
  62. package/dist/lib/crabbox/cli.d.ts +2 -2
  63. package/dist/lib/crabbox/config.d.ts +7 -8
  64. package/dist/lib/crabbox/config.js +14 -14
  65. package/dist/lib/crabbox/lease.d.ts +11 -4
  66. package/dist/lib/crabbox/lease.js +40 -8
  67. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  68. package/dist/lib/crabbox/setup-copy.js +17 -1
  69. package/dist/lib/daemon.js +27 -1
  70. package/dist/lib/device-config.js +7 -0
  71. package/dist/lib/devices/doctor-findings.d.ts +13 -1
  72. package/dist/lib/devices/doctor-findings.js +73 -2
  73. package/dist/lib/events.d.ts +9 -0
  74. package/dist/lib/events.js +58 -0
  75. package/dist/lib/exec.d.ts +3 -3
  76. package/dist/lib/exec.js +24 -10
  77. package/dist/lib/feed-outcome.d.ts +3 -0
  78. package/dist/lib/feed-outcome.js +18 -10
  79. package/dist/lib/feed.d.ts +4 -0
  80. package/dist/lib/fleet/apply.d.ts +59 -3
  81. package/dist/lib/fleet/apply.js +183 -6
  82. package/dist/lib/fleet/types.d.ts +21 -2
  83. package/dist/lib/hooks/cache.js +15 -0
  84. package/dist/lib/hosts/passthrough.d.ts +44 -0
  85. package/dist/lib/hosts/passthrough.js +84 -12
  86. package/dist/lib/hosts/ready.d.ts +2 -0
  87. package/dist/lib/hosts/ready.js +10 -1
  88. package/dist/lib/hosts/reconnect.d.ts +14 -12
  89. package/dist/lib/hosts/reconnect.js +41 -40
  90. package/dist/lib/mcp.js +5 -1
  91. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  92. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  93. package/dist/lib/menubar/snapshot.d.ts +15 -0
  94. package/dist/lib/menubar/snapshot.js +40 -0
  95. package/dist/lib/plugins.js +13 -1
  96. package/dist/lib/resources/mcp.js +3 -0
  97. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  98. package/dist/lib/routine-process-cleanup.js +73 -0
  99. package/dist/lib/routines.js +14 -2
  100. package/dist/lib/runner.d.ts +0 -3
  101. package/dist/lib/runner.js +6 -19
  102. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  103. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  104. package/dist/lib/secrets/bundles.js +1 -20
  105. package/dist/lib/secrets/filestore.d.ts +2 -0
  106. package/dist/lib/secrets/filestore.js +13 -0
  107. package/dist/lib/secrets/push.d.ts +94 -0
  108. package/dist/lib/secrets/push.js +145 -0
  109. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  110. package/dist/lib/secrets/rc-hygiene.js +14 -1
  111. package/dist/lib/secrets/reaper.d.ts +15 -1
  112. package/dist/lib/secrets/reaper.js +30 -3
  113. package/dist/lib/session/active.d.ts +4 -0
  114. package/dist/lib/session/db.d.ts +21 -3
  115. package/dist/lib/session/db.js +229 -15
  116. package/dist/lib/session/discover.d.ts +1 -0
  117. package/dist/lib/session/discover.js +115 -19
  118. package/dist/lib/session/insights.d.ts +18 -0
  119. package/dist/lib/session/insights.js +143 -1
  120. package/dist/lib/session/remote-list.d.ts +2 -0
  121. package/dist/lib/session/remote-list.js +1 -0
  122. package/dist/lib/session/session-cache.d.ts +4 -4
  123. package/dist/lib/session/session-cache.js +4 -4
  124. package/dist/lib/session/tool-index.js +133 -22
  125. package/dist/lib/session/tool-store.d.ts +26 -2
  126. package/dist/lib/session/tool-store.js +36 -17
  127. package/dist/lib/shims.js +21 -1
  128. package/dist/lib/signin-badge.js +2 -0
  129. package/dist/lib/ssh-exec.js +8 -2
  130. package/dist/lib/startup/command-registry.d.ts +16 -0
  131. package/dist/lib/startup/command-registry.js +46 -0
  132. package/dist/lib/teams/agents.d.ts +13 -0
  133. package/dist/lib/teams/agents.js +76 -8
  134. package/dist/lib/teams/parsers.d.ts +1 -1
  135. package/dist/lib/teams/placement-probe.d.ts +21 -0
  136. package/dist/lib/teams/placement-probe.js +135 -0
  137. package/dist/lib/teams/scheduler.d.ts +74 -1
  138. package/dist/lib/teams/scheduler.js +187 -10
  139. package/dist/lib/types.d.ts +1 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Serialize a resolved env map to `.env` lines that round-trip losslessly through
3
+ * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
4
+ * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
5
+ * value survives unchanged with no escaping. Newlines would break its line-based
6
+ * parse, so multi-line values are rejected rather than silently corrupted.
7
+ */
8
+ export declare function bundleEnvToDotenv(env: Record<string, string>): string;
9
+ /** Where the bundle should live ON THE REMOTE. */
10
+ export type RemoteBackend = 'keychain' | 'file';
11
+ /** A bundle read once, ready to push to any number of hosts. */
12
+ export interface ResolvedBundleForPush {
13
+ /** key -> value. Never logged; only its KEY NAMES are ever surfaced. */
14
+ env: Record<string, string>;
15
+ /** The same values as a dotenv blob, shipped over ssh stdin (never argv). */
16
+ dotenv: string;
17
+ keyCount: number;
18
+ }
19
+ export interface PushBundleOptions {
20
+ remoteBackend: RemoteBackend;
21
+ /** Overwrite a key that already exists on the remote. */
22
+ force?: boolean;
23
+ /**
24
+ * Forwarded to the remote as the FIRST stdin line for the FILE backend only,
25
+ * and only when non-empty.
26
+ *
27
+ * Empty is the DEFAULT and the good path: the remote's file store then
28
+ * auto-provisions its own machine-local key (0600, `~/.agents/.secrets-key/`)
29
+ * and reads headlessly. Setting this keys the remote bundle under a shared
30
+ * off-disk secret instead — an opt-in, never a requirement. Requiring one is
31
+ * what pushed operators toward exporting the master key fleet-wide (RUSH-1968).
32
+ */
33
+ passphrase?: string;
34
+ /** Label for the audit trail — `export --host` vs `fleet apply`. */
35
+ operation: string;
36
+ }
37
+ export interface PushBundleResult {
38
+ ok: boolean;
39
+ host: string;
40
+ bundle: string;
41
+ keyCount: number;
42
+ /** One line for the caller to render. Never contains a secret value. */
43
+ message: string;
44
+ }
45
+ /**
46
+ * Read and resolve a bundle once, for pushing to one or more hosts.
47
+ *
48
+ * `agentOnly` + `keyMode: 'storage'` match what `export --host` has always
49
+ * passed: storage-shaped values, and the headless guard that fails fast rather
50
+ * than popping Touch ID inside an automated run.
51
+ */
52
+ export declare function resolveBundleForPush(bundle: string, caller: string): ResolvedBundleForPush;
53
+ /**
54
+ * WHICH transport a (backend, target-OS) pair selects, and the exact bytes it
55
+ * will send. One of four outcomes, and picking the wrong one is silent: a
56
+ * Windows target handed a POSIX `bash -lc` produces garbage, and a Windows
57
+ * target handed `--from -` hangs forever on a stdin the `agents.ps1` shim never
58
+ * forwards. Neither shows up as a failed ssh, so the selection is the thing
59
+ * worth pinning.
60
+ *
61
+ * Separated from execution so it is decidable without a network: the branch is
62
+ * chosen from the device registry alone, and `pushResolvedBundleToHost` below
63
+ * does nothing but run what this returns.
64
+ */
65
+ export type PushTransport =
66
+ /** No supported command exists for this pair — fail loud, never a wrong path. */
67
+ {
68
+ kind: 'refuse';
69
+ message: string;
70
+ }
71
+ /** A command sent over the raw ssh engine, with the .env on stdin. */
72
+ | {
73
+ kind: 'ssh';
74
+ remoteCmd: string;
75
+ input: string;
76
+ }
77
+ /** The OS-aware `agents secrets` wrapper, the READ inverse's own path. */
78
+ | {
79
+ kind: 'remote-secrets';
80
+ args: string[];
81
+ input: string;
82
+ };
83
+ /** Choose the transport for one push. Pure: registry read in, plan out. */
84
+ export declare function planPushTransport(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): PushTransport;
85
+ /**
86
+ * Push an already-resolved bundle to ONE host.
87
+ *
88
+ * Drives the remote's own `agents secrets import --from -`, so the values land
89
+ * in the remote's chosen backend and the .env is read off ssh stdin rather than
90
+ * parsed by a remote shell. `import` auto-creates the bundle.
91
+ */
92
+ export declare function pushResolvedBundleToHost(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
93
+ /** Resolve and push in one call — for a single host. */
94
+ export declare function pushBundleToHost(bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Push one bundle's values to a remote host over SSH — the provisioning
3
+ * primitive behind `agents secrets export --host` and, from RUSH-1968, behind
4
+ * `agents fleet apply --provision-secrets`.
5
+ *
6
+ * This logic used to live inline in the `export --host` command action. It moved
7
+ * here because `lib/fleet/apply.ts` needs it and a lib MUST NOT import a command
8
+ * module — and because the absence of a callable primitive is part of why
9
+ * `fleet apply` never provisioned secrets at all, leaving an operator to
10
+ * hand-export the file store's master key across the fleet.
11
+ *
12
+ * Two rules shape the shape of this module:
13
+ *
14
+ * - **It never prints.** The lib layer stays `console.*`-free (SEC-14/SEC-16), so
15
+ * every outcome is returned as data and the CALLER renders it. That is also
16
+ * what lets `fleet apply` fold a push into its own per-device report instead of
17
+ * interleaving stray lines into it.
18
+ * - **Resolve once, push N times.** `resolveBundleForPush` is separate because
19
+ * reading a bundle can prompt (Touch ID); doing it per host would prompt per
20
+ * host. `export --host a,b,c` resolves once and pushes three times.
21
+ */
22
+ import { sshExec } from '../ssh-exec.js';
23
+ import { remoteShellFor, buildWindowsStdinImportCommand } from '../hosts/remote-cmd.js';
24
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
25
+ import { remoteSecretsRaw, verifyRemoteKeychainPush, keychainWriteFailureMessage, buildRemoteFileImportCommand, } from './remote.js';
26
+ import { readAndResolveBundleEnv } from './bundles.js';
27
+ /**
28
+ * Serialize a resolved env map to `.env` lines that round-trip losslessly through
29
+ * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
30
+ * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
31
+ * value survives unchanged with no escaping. Newlines would break its line-based
32
+ * parse, so multi-line values are rejected rather than silently corrupted.
33
+ */
34
+ export function bundleEnvToDotenv(env) {
35
+ const lines = [];
36
+ for (const [k, v] of Object.entries(env)) {
37
+ if (/[\r\n]/.test(v)) {
38
+ throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
39
+ `Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
40
+ }
41
+ lines.push(`${k}="${v}"`);
42
+ }
43
+ return lines.join('\n') + '\n';
44
+ }
45
+ /**
46
+ * Read and resolve a bundle once, for pushing to one or more hosts.
47
+ *
48
+ * `agentOnly` + `keyMode: 'storage'` match what `export --host` has always
49
+ * passed: storage-shaped values, and the headless guard that fails fast rather
50
+ * than popping Touch ID inside an automated run.
51
+ */
52
+ export function resolveBundleForPush(bundle, caller) {
53
+ const { env } = readAndResolveBundleEnv(bundle, { caller, keyMode: 'storage', agentOnly: true });
54
+ return { env, dotenv: bundleEnvToDotenv(env), keyCount: Object.keys(env).length };
55
+ }
56
+ function isPowershellTarget(host) {
57
+ return remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell';
58
+ }
59
+ /** Choose the transport for one push. Pure: registry read in, plan out. */
60
+ export function planPushTransport(resolved, bundle, host, opts) {
61
+ const powershell = isPowershellTarget(host);
62
+ if (opts.remoteBackend === 'file') {
63
+ // Both file-backend paths build a POSIX `bash -lc` command. Refuse a Windows
64
+ // target cleanly rather than emit broken PowerShell (fail loud at the
65
+ // boundary, never a silent wrong path).
66
+ if (powershell) {
67
+ return { kind: 'refuse', message: 'file backend export to a Windows target is not yet supported' };
68
+ }
69
+ const { remoteCmd, input } = buildRemoteFileImportCommand(bundle, resolved.dotenv, {
70
+ passphrase: opts.passphrase ?? '',
71
+ force: opts.force,
72
+ });
73
+ return { kind: 'ssh', remoteCmd, input };
74
+ }
75
+ if (powershell) {
76
+ // Keychain on a Windows target: the `agents.ps1` shim doesn't forward
77
+ // ssh-piped stdin to node, so `--from -` would hang. Bridge the piped .env
78
+ // through PowerShell into a temp file and import `--from <file>` (deleted
79
+ // afterwards). Same hardened ssh engine; the .env still only ever crosses
80
+ // the wire over ssh stdin.
81
+ return {
82
+ kind: 'ssh',
83
+ remoteCmd: buildWindowsStdinImportCommand(bundle, { force: opts.force }),
84
+ input: resolved.dotenv,
85
+ };
86
+ }
87
+ // Keychain on a POSIX target: OS-aware wrapping + the hardened ssh engine
88
+ // (BatchMode, ConnectTimeout, keepalive, control-socket reuse) via the same
89
+ // path the READ inverse (`remoteResolveEnv`) uses.
90
+ return {
91
+ kind: 'remote-secrets',
92
+ args: ['import', bundle, '--from', '-', ...(opts.force ? ['--force'] : [])],
93
+ input: resolved.dotenv,
94
+ };
95
+ }
96
+ /**
97
+ * Push an already-resolved bundle to ONE host.
98
+ *
99
+ * Drives the remote's own `agents secrets import --from -`, so the values land
100
+ * in the remote's chosen backend and the .env is read off ssh stdin rather than
101
+ * parsed by a remote shell. `import` auto-creates the bundle.
102
+ */
103
+ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
104
+ const fail = (message) => ({ ok: false, host, bundle, keyCount: resolved.keyCount, message });
105
+ const plan = planPushTransport(resolved, bundle, host, opts);
106
+ if (plan.kind === 'refuse')
107
+ return fail(plan.message);
108
+ const res = plan.kind === 'ssh'
109
+ ? sshExec(host, plan.remoteCmd, { input: plan.input })
110
+ : remoteSecretsRaw(host, plan.args, { input: plan.input, osLookupName: host });
111
+ if (res.code === null) {
112
+ return fail(res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed'));
113
+ }
114
+ if (res.code !== 0) {
115
+ const msg = (res.stderr || res.stdout || '').trim();
116
+ return fail(`remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`);
117
+ }
118
+ // A keychain-backed push to a macOS remote over headless SSH can land the
119
+ // bundle metadata but no READABLE value items: the remote login keychain is
120
+ // locked in the non-interactive SSH context, so Security accepts the write but
121
+ // the biometry-ACL'd item is unreadable — and the remote `import` still exits
122
+ // 0. Read it back the way a release will and FAIL LOUDLY, rather than leave a
123
+ // metadata-only bundle that breaks later with "stored item not found". The
124
+ // file backend is headless-readable by construction, so it is skipped.
125
+ if (opts.remoteBackend === 'keychain') {
126
+ const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host });
127
+ if (!verdict.ok) {
128
+ return fail(verdict.kind === 'locked-keychain'
129
+ ? keychainWriteFailureMessage(host, bundle, verdict.reason)
130
+ : `pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
131
+ }
132
+ }
133
+ const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
134
+ return {
135
+ ok: true,
136
+ host,
137
+ bundle,
138
+ keyCount: resolved.keyCount,
139
+ message: remoteMsg || `${resolved.keyCount} key(s) exported`,
140
+ };
141
+ }
142
+ /** Resolve and push in one call — for a single host. */
143
+ export function pushBundleToHost(bundle, host, opts) {
144
+ return pushResolvedBundleToHost(resolveBundleForPush(bundle, opts.operation), bundle, host, opts);
145
+ }
@@ -32,6 +32,20 @@ export interface RcSecretFinding {
32
32
  /** The file-store master passphrase gets called out separately — it is the highest-severity case. */
33
33
  isMasterPassphrase: boolean;
34
34
  }
35
+ /** The file-store master key. Its own resolution prefers an off-env 0600 file, so
36
+ * a shell-rc export is always wrong once the store exists (RUSH-1968). */
37
+ export declare const MASTER_PASSPHRASE = "AGENTS_SECRETS_PASSPHRASE";
38
+ /**
39
+ * True when the file-store master key is live in THIS process's environment.
40
+ *
41
+ * The scanner above reads FILES, and that leaves a hole: a value inherited by a
42
+ * long-lived process outlives the rc line that set it, so deleting the export
43
+ * makes `scanRcExports` report clean while every shell, editor and agent started
44
+ * beforehand still carries the key and passes it to everything it spawns.
45
+ * Returns a boolean — never the value — so a finding built from it can be
46
+ * printed, logged, or shipped without leaking the secret.
47
+ */
48
+ export declare function masterPassphraseInEnv(): boolean;
35
49
  /** True if a variable name looks like it holds a credential value. */
36
50
  export declare function isCredentialName(name: string): boolean;
37
51
  /**
@@ -33,7 +33,20 @@ export const RC_FILENAMES = [
33
33
  ];
34
34
  /** The file-store master key. Its own resolution prefers an off-env 0600 file, so
35
35
  * a shell-rc export is always wrong once the store exists (RUSH-1968). */
36
- const MASTER_PASSPHRASE = 'AGENTS_SECRETS_PASSPHRASE';
36
+ export const MASTER_PASSPHRASE = 'AGENTS_SECRETS_PASSPHRASE';
37
+ /**
38
+ * True when the file-store master key is live in THIS process's environment.
39
+ *
40
+ * The scanner above reads FILES, and that leaves a hole: a value inherited by a
41
+ * long-lived process outlives the rc line that set it, so deleting the export
42
+ * makes `scanRcExports` report clean while every shell, editor and agent started
43
+ * beforehand still carries the key and passes it to everything it spawns.
44
+ * Returns a boolean — never the value — so a finding built from it can be
45
+ * printed, logged, or shipped without leaking the secret.
46
+ */
47
+ export function masterPassphraseInEnv() {
48
+ return (process.env[MASTER_PASSPHRASE] ?? '').length > 0;
49
+ }
37
50
  /**
38
51
  * Last `_`-delimited segment values that mark a variable as credential-shaped.
39
52
  * Matched against the FINAL segment (segment equality, not substring) so
@@ -27,7 +27,12 @@ export interface KeychainProcessSnapshot {
27
27
  * `null` means "could not capture" — the planner must fail closed.
28
28
  */
29
29
  startTime: string | null;
30
- /** True when this process's executable path matches the installed helper. */
30
+ /**
31
+ * True when this process is a REAP-ELIGIBLE helper invocation — the installed
32
+ * helper binary running a short-lived keychain verb. False for a non-helper
33
+ * process AND for the long-lived `watch-lock` watcher (see
34
+ * {@link isReapableHelperCommand}), which must never be reaped.
35
+ */
31
36
  isHelper: boolean;
32
37
  }
33
38
  /**
@@ -78,6 +83,15 @@ export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], n
78
83
  * Returns null for an unparseable value so the caller drops the row.
79
84
  */
80
85
  export declare function parseEtimeToSeconds(raw: string): number | null;
86
+ /**
87
+ * Whether a `ps` command line is a REAP-ELIGIBLE helper invocation: the installed
88
+ * helper binary running a short-lived keychain verb (get/has/list/set/delete/
89
+ * migrate-*) that a wedged `coreauthd` can hang. Returns false for a non-helper
90
+ * command AND for the deliberately long-lived `watch-lock` watcher — matching by
91
+ * the full argv (`ps … command=`), so a live-parent `watch-lock` child is never
92
+ * mistaken for a stuck read and killed. Pure; unit-tested.
93
+ */
94
+ export declare function isReapableHelperCommand(command: string, helperPath: string): boolean;
81
95
  /** Test seam: reset the persisted candidate state. */
82
96
  export declare function resetKeychainReaperCandidatesForTest(): void;
83
97
  /**
@@ -134,6 +134,31 @@ function parsePsLine(line) {
134
134
  return null;
135
135
  return { pid, ppid, elapsedSec, command };
136
136
  }
137
+ /**
138
+ * The one helper verb that is DELIBERATELY long-lived: the broker's auto-lock
139
+ * sleep/lock watcher (`spawn(getKeychainHelperPath(), ['watch-lock'], …)` in
140
+ * `agent.ts`). It lives for the broker's whole hold — potentially days — as a
141
+ * healthy child of the live broker/daemon, emitting LOCK/SLEEP lines that wipe
142
+ * the in-memory secret store on sleep. It is NOT a stuck keychain read, so the
143
+ * reaper must never target it: killing it silently disables auto-lock-on-sleep.
144
+ */
145
+ const HELPER_WATCH_LOCK_VERB = 'watch-lock';
146
+ /**
147
+ * Whether a `ps` command line is a REAP-ELIGIBLE helper invocation: the installed
148
+ * helper binary running a short-lived keychain verb (get/has/list/set/delete/
149
+ * migrate-*) that a wedged `coreauthd` can hang. Returns false for a non-helper
150
+ * command AND for the deliberately long-lived `watch-lock` watcher — matching by
151
+ * the full argv (`ps … command=`), so a live-parent `watch-lock` child is never
152
+ * mistaken for a stuck read and killed. Pure; unit-tested.
153
+ */
154
+ export function isReapableHelperCommand(command, helperPath) {
155
+ if (command === helperPath)
156
+ return true; // bare exec, no verb — never watch-lock
157
+ if (!command.startsWith(`${helperPath} `))
158
+ return false; // not our helper
159
+ const firstArg = command.slice(helperPath.length + 1).trimStart().split(/\s+/)[0];
160
+ return firstArg !== HELPER_WATCH_LOCK_VERB;
161
+ }
137
162
  /** Module-state for the two-sweep stuck-parent debounce. */
138
163
  let stuckParentCandidates = new Map();
139
164
  /** Test seam: reset the persisted candidate state. */
@@ -178,9 +203,11 @@ export function reapOrphanedKeychainProcesses() {
178
203
  if (!parsed)
179
204
  continue;
180
205
  const { pid, ppid, elapsedSec, command } = parsed;
181
- // Exact path-match: the helper invocation's command line begins with the
182
- // absolute helper path, followed by a space and its arguments (or nothing).
183
- const isHelper = command === helperPath || command.startsWith(`${helperPath} `);
206
+ // Reap-eligible = the helper binary running a short-lived keychain verb. The
207
+ // full-argv match excludes the deliberately long-lived `watch-lock` watcher,
208
+ // whose live-parent child would otherwise be killed as if it were stuck
209
+ // (RUSH-2232 — that silently disabled auto-lock-on-sleep).
210
+ const isHelper = isReapableHelperCommand(command, helperPath);
184
211
  rows.push({ pid, ppid, elapsedSec, isHelper, startTime: null });
185
212
  }
186
213
  const rowByPid = new Map(rows.map((r) => [r.pid, r]));
@@ -126,6 +126,10 @@ export interface ActiveSession {
126
126
  */
127
127
  lastActivityMs?: number;
128
128
  status: ActiveStatus;
129
+ /** Indexed launch origin, backfilled by the sessions command for JSON consumers. */
130
+ origin?: 'cli' | 'routine';
131
+ /** Routine definition name when origin is `routine`. */
132
+ routineName?: string;
129
133
  /**
130
134
  * Foreground/background presence for the detach/attach model:
131
135
  * `attached` — live interactive TUI you're watching;
@@ -7,12 +7,12 @@
7
7
  * BM25 ranking, and label-first search for /rename'd sessions.
8
8
  */
9
9
  import Database from '../sqlite.js';
10
- import type { SessionAgentId, SessionMeta } from './types.js';
10
+ import type { SessionAgentId, SessionEvent, SessionMeta } from './types.js';
11
11
  import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 34;
15
+ export declare const SCHEMA_VERSION = 36;
16
16
  /**
17
17
  * Bump to force `agents sessions backfill resources` to re-derive every
18
18
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -26,7 +26,7 @@ export declare const RESOURCE_INDEX_VERSION = 1;
26
26
  * re-derives on the next `agents insights` instead of silently reporting stale
27
27
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
28
28
  */
29
- export declare const INSIGHTS_EXTRACTOR_VERSION = 3;
29
+ export declare const INSIGHTS_EXTRACTOR_VERSION = 4;
30
30
  /** Raw row shape returned from the sessions table. */
31
31
  export interface SessionRow {
32
32
  id: string;
@@ -149,6 +149,23 @@ export interface FtsOptimizeResult {
149
149
  * the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
150
150
  */
151
151
  export declare function optimizeSessionSearchIndex(): FtsOptimizeResult[];
152
+ /**
153
+ * Keep the FTS indexes from degrading on the normal scan path.
154
+ *
155
+ * `optimizeSessionSearchIndex` is the full, unbounded compaction behind
156
+ * `agents sessions optimize`. Leaving it as the ONLY compaction meant the index
157
+ * degraded until a human happened to run that command, which is how
158
+ * `tool_call_text_data` reached gigabytes for tens of MB of content. This is the
159
+ * automatic counterpart: bounded, threshold-gated, and safe to call after every
160
+ * batch of writes. Non-destructive — merging never changes what is searchable.
161
+ *
162
+ * Returns one result per table it actually merged (empty when every table is
163
+ * under the threshold, which is the common case on a warm index).
164
+ */
165
+ export declare function maintainSessionSearchIndex(db?: Database.Database, options?: {
166
+ segmentThreshold?: number;
167
+ mergePages?: number;
168
+ }): FtsOptimizeResult[];
152
169
  /**
153
170
  * Try to claim the right to run the incremental scan. Returns true if this
154
171
  * process should proceed with scanning, false if another live process is
@@ -245,6 +262,7 @@ export declare function upsertSessionsBatch(entries: Array<{
245
262
  scan?: ScanStamp;
246
263
  parserState?: string;
247
264
  contentText?: string;
265
+ events?: SessionEvent[];
248
266
  toolCalls?: IndexedToolCall[];
249
267
  toolScan?: ScanStamp;
250
268
  toolIndexMode?: 'replace' | 'append';