@phnx-labs/agents-cli 1.20.72 → 1.20.73

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 (219) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +12 -0
  5. package/dist/commands/activity.js +68 -0
  6. package/dist/commands/apply.d.ts +13 -0
  7. package/dist/commands/apply.js +28 -3
  8. package/dist/commands/browser.js +1 -1
  9. package/dist/commands/cloud.js +21 -11
  10. package/dist/commands/doctor.js +8 -4
  11. package/dist/commands/events.d.ts +11 -10
  12. package/dist/commands/events.js +30 -21
  13. package/dist/commands/exec.d.ts +34 -0
  14. package/dist/commands/exec.js +337 -39
  15. package/dist/commands/feed.d.ts +13 -0
  16. package/dist/commands/feed.js +130 -29
  17. package/dist/commands/hq.d.ts +2 -0
  18. package/dist/commands/hq.js +58 -0
  19. package/dist/commands/lease.d.ts +19 -0
  20. package/dist/commands/lease.js +138 -11
  21. package/dist/commands/login.d.ts +2 -0
  22. package/dist/commands/login.js +123 -0
  23. package/dist/commands/logs.js +1 -1
  24. package/dist/commands/mine.d.ts +27 -0
  25. package/dist/commands/mine.js +207 -0
  26. package/dist/commands/monitors.js +79 -49
  27. package/dist/commands/plugins.js +1 -0
  28. package/dist/commands/profiles.js +158 -0
  29. package/dist/commands/repo.js +2 -60
  30. package/dist/commands/resources.d.ts +5 -0
  31. package/dist/commands/resources.js +95 -0
  32. package/dist/commands/routines.d.ts +2 -0
  33. package/dist/commands/routines.js +139 -37
  34. package/dist/commands/secrets.js +234 -47
  35. package/dist/commands/send.d.ts +15 -0
  36. package/dist/commands/send.js +62 -0
  37. package/dist/commands/sessions-tail.d.ts +1 -0
  38. package/dist/commands/sessions-tail.js +20 -9
  39. package/dist/commands/sessions.js +18 -3
  40. package/dist/commands/setup-mine.d.ts +18 -0
  41. package/dist/commands/setup-mine.js +106 -0
  42. package/dist/commands/setup-share.js +2 -2
  43. package/dist/commands/setup.js +3 -1
  44. package/dist/commands/share.d.ts +8 -1
  45. package/dist/commands/share.js +122 -37
  46. package/dist/commands/ssh.d.ts +9 -0
  47. package/dist/commands/ssh.js +196 -26
  48. package/dist/commands/sync.js +1 -1
  49. package/dist/commands/teams.js +11 -2
  50. package/dist/index.js +79 -10
  51. package/dist/lib/activity.d.ts +124 -0
  52. package/dist/lib/activity.js +542 -0
  53. package/dist/lib/agents.js +16 -5
  54. package/dist/lib/artifact-actions.d.ts +1 -1
  55. package/dist/lib/artifact-actions.js +1 -1
  56. package/dist/lib/ask-classifier.d.ts +2 -1
  57. package/dist/lib/ask-classifier.js +3 -3
  58. package/dist/lib/auth-health.d.ts +32 -4
  59. package/dist/lib/auth-health.js +40 -1
  60. package/dist/lib/auto-pull.js +8 -1
  61. package/dist/lib/brand.d.ts +40 -0
  62. package/dist/lib/brand.js +122 -0
  63. package/dist/lib/browser/drivers/ssh.js +4 -7
  64. package/dist/lib/browser/service.js +6 -8
  65. package/dist/lib/channels/providers/index.d.ts +2 -0
  66. package/dist/lib/channels/providers/index.js +20 -0
  67. package/dist/lib/channels/providers/mailbox.d.ts +2 -0
  68. package/dist/lib/channels/providers/mailbox.js +26 -0
  69. package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
  70. package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
  71. package/dist/lib/channels/providers/rush.d.ts +6 -0
  72. package/dist/lib/channels/providers/rush.js +55 -0
  73. package/dist/lib/channels/registry.d.ts +42 -0
  74. package/dist/lib/channels/registry.js +20 -0
  75. package/dist/lib/channels/resolve.d.ts +11 -0
  76. package/dist/lib/channels/resolve.js +12 -0
  77. package/dist/lib/cli-resources.d.ts +18 -0
  78. package/dist/lib/cli-resources.js +53 -4
  79. package/dist/lib/cloud/codex.js +6 -3
  80. package/dist/lib/cloud/factory.d.ts +1 -0
  81. package/dist/lib/cloud/factory.js +10 -4
  82. package/dist/lib/cloud/rush.d.ts +3 -1
  83. package/dist/lib/cloud/rush.js +6 -1
  84. package/dist/lib/cloud/types.d.ts +2 -0
  85. package/dist/lib/codex-home.d.ts +35 -0
  86. package/dist/lib/codex-home.js +136 -0
  87. package/dist/lib/crabbox/cli.d.ts +44 -0
  88. package/dist/lib/crabbox/cli.js +120 -20
  89. package/dist/lib/crabbox/lease.d.ts +52 -1
  90. package/dist/lib/crabbox/lease.js +117 -16
  91. package/dist/lib/crabbox/progress.d.ts +31 -0
  92. package/dist/lib/crabbox/progress.js +76 -0
  93. package/dist/lib/crabbox/runtimes.d.ts +4 -0
  94. package/dist/lib/crabbox/runtimes.js +33 -8
  95. package/dist/lib/crabbox/setup-copy.d.ts +87 -0
  96. package/dist/lib/crabbox/setup-copy.js +127 -0
  97. package/dist/lib/daemon.d.ts +5 -5
  98. package/dist/lib/daemon.js +9 -9
  99. package/dist/lib/drive-sync.js +6 -3
  100. package/dist/lib/event-stream.d.ts +36 -0
  101. package/dist/lib/event-stream.js +68 -0
  102. package/dist/lib/events.d.ts +1 -1
  103. package/dist/lib/exec.d.ts +11 -0
  104. package/dist/lib/exec.js +23 -2
  105. package/dist/lib/feed-policy.d.ts +1 -1
  106. package/dist/lib/feed-policy.js +14 -9
  107. package/dist/lib/feed-ranking.d.ts +32 -0
  108. package/dist/lib/feed-ranking.js +224 -0
  109. package/dist/lib/feed.d.ts +35 -2
  110. package/dist/lib/feed.js +66 -3
  111. package/dist/lib/fleet/auth-sync.d.ts +53 -0
  112. package/dist/lib/fleet/auth-sync.js +92 -0
  113. package/dist/lib/fleet/remote-login.d.ts +187 -0
  114. package/dist/lib/fleet/remote-login.js +556 -0
  115. package/dist/lib/format.d.ts +30 -3
  116. package/dist/lib/format.js +34 -5
  117. package/dist/lib/fs-atomic.d.ts +7 -2
  118. package/dist/lib/fs-atomic.js +8 -12
  119. package/dist/lib/git.d.ts +16 -0
  120. package/dist/lib/git.js +79 -2
  121. package/dist/lib/heal.js +11 -3
  122. package/dist/lib/hosts/progress.d.ts +28 -10
  123. package/dist/lib/hosts/progress.js +79 -22
  124. package/dist/lib/hosts/remote-cmd.js +4 -0
  125. package/dist/lib/hq/floor.d.ts +87 -0
  126. package/dist/lib/hq/floor.js +226 -0
  127. package/dist/lib/menubar/install-menubar.js +14 -20
  128. package/dist/lib/notify.d.ts +1 -0
  129. package/dist/lib/notify.js +3 -3
  130. package/dist/lib/open-url.d.ts +2 -0
  131. package/dist/lib/open-url.js +19 -0
  132. package/dist/lib/openclaw-keychain.d.ts +56 -0
  133. package/dist/lib/openclaw-keychain.js +236 -0
  134. package/dist/lib/overdue.js +10 -0
  135. package/dist/lib/permissions.d.ts +44 -1
  136. package/dist/lib/permissions.js +284 -7
  137. package/dist/lib/project-launch.d.ts +6 -12
  138. package/dist/lib/project-launch.js +13 -228
  139. package/dist/lib/project-resources.d.ts +7 -0
  140. package/dist/lib/project-resources.js +291 -0
  141. package/dist/lib/pty-client.d.ts +27 -0
  142. package/dist/lib/pty-client.js +136 -10
  143. package/dist/lib/refresh.js +14 -10
  144. package/dist/lib/resource-profiles.d.ts +26 -0
  145. package/dist/lib/resource-profiles.js +157 -0
  146. package/dist/lib/resources/permissions.js +7 -1
  147. package/dist/lib/resources/types.d.ts +1 -1
  148. package/dist/lib/resources.d.ts +1 -1
  149. package/dist/lib/resources.js +32 -3
  150. package/dist/lib/routines.d.ts +11 -0
  151. package/dist/lib/routines.js +56 -15
  152. package/dist/lib/runner.d.ts +1 -0
  153. package/dist/lib/runner.js +66 -5
  154. package/dist/lib/secrets/bundles.d.ts +16 -3
  155. package/dist/lib/secrets/bundles.js +206 -37
  156. package/dist/lib/secrets/icloud-import.d.ts +2 -2
  157. package/dist/lib/secrets/icloud-import.js +9 -6
  158. package/dist/lib/secrets/mcp.js +1 -1
  159. package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
  160. package/dist/lib/secrets/vault-age-helper.js +34 -0
  161. package/dist/lib/secrets/vault.d.ts +49 -0
  162. package/dist/lib/secrets/vault.js +397 -0
  163. package/dist/lib/self-heal/checks/path.js +3 -1
  164. package/dist/lib/self-heal/checks/shadowing.js +10 -2
  165. package/dist/lib/self-heal/checks/shims.js +19 -11
  166. package/dist/lib/session/cloud.d.ts +2 -2
  167. package/dist/lib/session/cloud.js +2 -2
  168. package/dist/lib/session/db.d.ts +4 -0
  169. package/dist/lib/session/db.js +44 -7
  170. package/dist/lib/session/discover.d.ts +2 -0
  171. package/dist/lib/session/discover.js +114 -3
  172. package/dist/lib/session/stream-render.d.ts +3 -0
  173. package/dist/lib/session/stream-render.js +130 -0
  174. package/dist/lib/session/types.d.ts +6 -0
  175. package/dist/lib/share/analytics.d.ts +13 -0
  176. package/dist/lib/share/analytics.js +45 -0
  177. package/dist/lib/share/config.d.ts +19 -5
  178. package/dist/lib/share/config.js +44 -8
  179. package/dist/lib/share/provision.d.ts +58 -6
  180. package/dist/lib/share/provision.js +97 -22
  181. package/dist/lib/share/publish.d.ts +36 -13
  182. package/dist/lib/share/publish.js +55 -8
  183. package/dist/lib/share/worker-template.js +83 -17
  184. package/dist/lib/shims.d.ts +9 -0
  185. package/dist/lib/shims.js +91 -13
  186. package/dist/lib/ssh-exec.d.ts +14 -0
  187. package/dist/lib/ssh-exec.js +57 -0
  188. package/dist/lib/staleness/detectors/hooks.js +25 -1
  189. package/dist/lib/staleness/detectors/permissions.js +66 -0
  190. package/dist/lib/staleness/detectors/workflows.js +20 -0
  191. package/dist/lib/staleness/writers/hooks.js +58 -4
  192. package/dist/lib/staleness/writers/permissions.js +2 -2
  193. package/dist/lib/staleness/writers/skills.js +1 -1
  194. package/dist/lib/staleness/writers/sources.d.ts +10 -1
  195. package/dist/lib/staleness/writers/sources.js +68 -1
  196. package/dist/lib/star-nudge.d.ts +45 -0
  197. package/dist/lib/star-nudge.js +91 -0
  198. package/dist/lib/startup/command-registry.d.ts +6 -1
  199. package/dist/lib/startup/command-registry.js +17 -3
  200. package/dist/lib/state.d.ts +2 -0
  201. package/dist/lib/state.js +3 -0
  202. package/dist/lib/subagents-registry.d.ts +2 -0
  203. package/dist/lib/subagents-registry.js +3 -0
  204. package/dist/lib/teams/parsers.js +214 -6
  205. package/dist/lib/teams/supervisor.d.ts +7 -0
  206. package/dist/lib/teams/supervisor.js +2 -1
  207. package/dist/lib/template.d.ts +1 -1
  208. package/dist/lib/template.js +1 -1
  209. package/dist/lib/triggers/webhook.js +36 -3
  210. package/dist/lib/types.d.ts +73 -0
  211. package/dist/lib/version.d.ts +40 -0
  212. package/dist/lib/version.js +94 -16
  213. package/dist/lib/versions.d.ts +11 -0
  214. package/dist/lib/versions.js +208 -76
  215. package/dist/lib/workflows.d.ts +2 -0
  216. package/dist/lib/workflows.js +88 -0
  217. package/package.json +2 -1
  218. package/dist/commands/daemon.d.ts +0 -10
  219. package/dist/commands/daemon.js +0 -121
@@ -103,10 +103,41 @@ export function inferLeaseRuntime(agentName, detected) {
103
103
  // Custom/workflow agent: fall back to the signed-in runtime (preferring claude).
104
104
  return signedIn.find((d) => d.id === 'claude')?.id ?? signedIn[0]?.id ?? null;
105
105
  }
106
+ const PROFILE_AUTH_ENV_KEYS_BY_RUNTIME = {
107
+ claude: [
108
+ 'ANTHROPIC_AUTH_TOKEN',
109
+ 'ANTHROPIC_API_KEY',
110
+ 'AWS_ACCESS_KEY_ID',
111
+ 'AWS_PROFILE',
112
+ 'AWS_BEARER_TOKEN_BEDROCK',
113
+ 'GOOGLE_APPLICATION_CREDENTIALS',
114
+ 'GOOGLE_CLOUD_PROJECT',
115
+ 'ANTHROPIC_FOUNDRY_API_KEY',
116
+ ],
117
+ codex: ['OPENAI_API_KEY'],
118
+ gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
119
+ grok: ['XAI_API_KEY', 'GROK_API_KEY'],
120
+ };
121
+ /** True when a profile already carries auth for its host runtime via env. */
122
+ export function profileNeedsBaseRuntimeCredentials(agent, env, authEnvVar) {
123
+ if (!LEASE_RUNTIMES.some((c) => c.id === agent))
124
+ return false;
125
+ if (authEnvVar && typeof env[authEnvVar] === 'string' && env[authEnvVar].trim() !== '')
126
+ return false;
127
+ const keys = PROFILE_AUTH_ENV_KEYS_BY_RUNTIME[agent] ?? [];
128
+ return !keys.some((key) => typeof env[key] === 'string' && env[key].trim() !== '');
129
+ }
106
130
  // A long random sentinel makes an accidental (or malicious) collision with a
107
131
  // token's contents effectively impossible, so the quoted heredoc can never be
108
132
  // closed early by the credential body.
109
133
  const CRED_EOF = 'AGENTS_LEASE_CRED_EOF_9f3c1a7b5e2d4068';
134
+ /** Build a quoted heredoc write to a path under the remote user's home. */
135
+ export function buildHomeFileWriteScript(remote, contents) {
136
+ const dir = path.posix.dirname(remote);
137
+ const mkdir = dir && dir !== '.' ? `mkdir -p "$HOME/${dir}"\n` : '';
138
+ return (`${mkdir}cat > "$HOME/${remote}" <<'${CRED_EOF}'\n${contents}${contents.endsWith('\n') ? '' : '\n'}${CRED_EOF}\n` +
139
+ `chmod 600 "$HOME/${remote}"`);
140
+ }
110
141
  /**
111
142
  * Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
112
143
  * LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token
@@ -206,12 +237,6 @@ export async function resolveClaudeCredentialsBlob(opts) {
206
237
  export function buildCredentialScript(picked, detected, extras) {
207
238
  const byId = new Map(detected.map((d) => [d.id, d]));
208
239
  const parts = [];
209
- const writeFile = (remote, contents) => {
210
- const dir = path.posix.dirname(remote);
211
- const mkdir = dir && dir !== '.' ? `mkdir -p "$HOME/${dir}"\n` : '';
212
- return (`${mkdir}cat > "$HOME/${remote}" <<'${CRED_EOF}'\n${contents}${contents.endsWith('\n') ? '' : '\n'}${CRED_EOF}\n` +
213
- `chmod 600 "$HOME/${remote}"`);
214
- };
215
240
  for (const id of picked) {
216
241
  const d = byId.get(id);
217
242
  const cred = LEASE_RUNTIMES.find((c) => c.id === id);
@@ -224,11 +249,11 @@ export function buildCredentialScript(picked, detected, extras) {
224
249
  catch {
225
250
  continue;
226
251
  }
227
- parts.push(writeFile(cred.remote, contents));
252
+ parts.push(buildHomeFileWriteScript(cred.remote, contents));
228
253
  // For claude the file above is config/state only; the OAuth token is a
229
254
  // second artifact — without it the box comes up "Not logged in".
230
255
  if (id === 'claude' && extras?.claudeCredentialsJson) {
231
- parts.push(writeFile(CLAUDE_TOKEN_REMOTE, extras.claudeCredentialsJson));
256
+ parts.push(buildHomeFileWriteScript(CLAUDE_TOKEN_REMOTE, extras.claudeCredentialsJson));
232
257
  }
233
258
  }
234
259
  return parts.join('\n');
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Setup-copy for `agents run --lease` (RUSH-1920), transport = PUSH-FROM-LOCAL.
3
+ *
4
+ * A fresh crabbox box has agents-cli installed but none of the caller's own
5
+ * `~/.agents` config (skills, hooks, commands, MCP, profiles). This module
6
+ * replicates the *git-tracked* subset of the LOCAL `~/.agents` onto the box:
7
+ *
8
+ * 1. `git -C <userAgentsDir> ls-files` — tracked files only. Tracked-only is
9
+ * the safety boundary: it excludes `.history/`, `.cache/`, `.system/` and
10
+ * any keychain-backed secrets by construction (those are gitignored), so no
11
+ * credential material is ever pushed.
12
+ * 2. `rsync` that exact file set to `~/.agents` on the box over crabbox's OWN
13
+ * ssh invocation (`crabboxSshArgv`) — crabbox provisions a per-lease identity
14
+ * key, so a raw `ssh crabbox@ip` fails publickey; only crabbox's key works.
15
+ * 3. `agents repo refresh` on the box so the copied config takes effect.
16
+ *
17
+ * NEVER copies `~/.claude` / `~/.claude.json` — they live in `$HOME`, not
18
+ * `~/.agents`, and are rebuilt on the box by `agents add` + refresh; an explicit
19
+ * filter belts-and-braces the tracked-only guarantee.
20
+ *
21
+ * This is a self-contained local function the command layer calls; it does NOT
22
+ * ride the box-side bootstrap script (that only echoes the `copy-setup` progress
23
+ * sentinel — see `buildBootstrapScript`).
24
+ */
25
+ /** Remote path (relative to the box user's home) the tracked config lands in. */
26
+ export declare const REMOTE_AGENTS_DIR = ".agents/";
27
+ export interface CopySetupOptions {
28
+ /** crabbox box slug to push to — its per-lease ssh key does the auth. */
29
+ slug: string;
30
+ /** Secrets bundle whose env the ssh/rsync children inherit (crabbox parity). */
31
+ secretsBundle?: string;
32
+ /** Override the local `~/.agents` dir (defaults to `getUserAgentsDir()`). */
33
+ userAgentsDir?: string;
34
+ /** Receives combined stdout/stderr of the rsync + refresh, if set. */
35
+ onData?: (chunk: string) => void;
36
+ /**
37
+ * Run `agents repo refresh` on the box after the push (default `true`). Set
38
+ * `false` when the caller runs the refresh itself in the box bootstrap — the
39
+ * lease path does this so the refresh runs AFTER the box installs agents-cli
40
+ * (this host-side push happens before the box boots agents-cli, so a host-side
41
+ * refresh here could not find the CLI).
42
+ */
43
+ refresh?: boolean;
44
+ }
45
+ export interface CopySetupResult {
46
+ /** The tracked files enumerated for the push (post-exclusion). */
47
+ files: string[];
48
+ /** rsync exit code, or null when the process failed to spawn. */
49
+ pushExitCode: number | null;
50
+ /** `agents repo refresh` exit code, or null when it was skipped/failed to spawn. */
51
+ refreshExitCode: number | null;
52
+ }
53
+ /**
54
+ * git-tracked files under `dir` (paths relative to `dir`), minus the never-copy
55
+ * set. Returns `[]` when `dir` is not a git repo — a caller with no tracked
56
+ * `~/.agents` simply copies nothing.
57
+ */
58
+ export declare function enumerateTrackedFiles(dir: string): string[];
59
+ /**
60
+ * Split crabbox's ssh argv (`['ssh', …opts, 'crabbox@host']`) into the `-e`
61
+ * transport string (`ssh …opts`) and the `crabbox@host` endpoint. This is what
62
+ * carries the per-lease identity key + known_hosts a raw ssh lacks.
63
+ */
64
+ export declare function sshTransportFromArgv(sshArgv: string[]): {
65
+ rsh: string;
66
+ host: string;
67
+ };
68
+ /**
69
+ * rsync argv to push the tracked file set to `~/.agents` on the box. Reads the
70
+ * NUL-separated list at `filesFrom` (`--from0`, matching `ls-files -z`) so paths
71
+ * with spaces survive, and tunnels over crabbox's own ssh (`rsh`).
72
+ */
73
+ export declare function buildSetupRsyncArgs(opts: {
74
+ rsh: string;
75
+ host: string;
76
+ filesFrom: string;
77
+ source: string;
78
+ remoteDir?: string;
79
+ }): string[];
80
+ /**
81
+ * Replicate the git-tracked subset of the local `~/.agents` onto the crabbox box
82
+ * and refresh it. Enumerates tracked files, rsyncs them over ssh, then runs
83
+ * `agents repo refresh` on the box. Refresh is skipped when the push fails or the
84
+ * file set is empty (nothing to refresh). Never throws — surfaces failure through
85
+ * the returned exit codes.
86
+ */
87
+ export declare function copySetupToBox(opts: CopySetupOptions): Promise<CopySetupResult>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Setup-copy for `agents run --lease` (RUSH-1920), transport = PUSH-FROM-LOCAL.
3
+ *
4
+ * A fresh crabbox box has agents-cli installed but none of the caller's own
5
+ * `~/.agents` config (skills, hooks, commands, MCP, profiles). This module
6
+ * replicates the *git-tracked* subset of the LOCAL `~/.agents` onto the box:
7
+ *
8
+ * 1. `git -C <userAgentsDir> ls-files` — tracked files only. Tracked-only is
9
+ * the safety boundary: it excludes `.history/`, `.cache/`, `.system/` and
10
+ * any keychain-backed secrets by construction (those are gitignored), so no
11
+ * credential material is ever pushed.
12
+ * 2. `rsync` that exact file set to `~/.agents` on the box over crabbox's OWN
13
+ * ssh invocation (`crabboxSshArgv`) — crabbox provisions a per-lease identity
14
+ * key, so a raw `ssh crabbox@ip` fails publickey; only crabbox's key works.
15
+ * 3. `agents repo refresh` on the box so the copied config takes effect.
16
+ *
17
+ * NEVER copies `~/.claude` / `~/.claude.json` — they live in `$HOME`, not
18
+ * `~/.agents`, and are rebuilt on the box by `agents add` + refresh; an explicit
19
+ * filter belts-and-braces the tracked-only guarantee.
20
+ *
21
+ * This is a self-contained local function the command layer calls; it does NOT
22
+ * ride the box-side bootstrap script (that only echoes the `copy-setup` progress
23
+ * sentinel — see `buildBootstrapScript`).
24
+ */
25
+ import { spawn, spawnSync } from 'child_process';
26
+ import * as fs from 'fs';
27
+ import * as os from 'os';
28
+ import * as path from 'path';
29
+ import { getUserAgentsDir } from '../state.js';
30
+ import { crabboxEnv, crabboxSshArgv } from './cli.js';
31
+ /** Remote path (relative to the box user's home) the tracked config lands in. */
32
+ export const REMOTE_AGENTS_DIR = '.agents/';
33
+ /** Top-level paths that must never be pushed, even if somehow tracked. */
34
+ const NEVER_COPY = new Set(['.claude', '.claude.json']);
35
+ /**
36
+ * git-tracked files under `dir` (paths relative to `dir`), minus the never-copy
37
+ * set. Returns `[]` when `dir` is not a git repo — a caller with no tracked
38
+ * `~/.agents` simply copies nothing.
39
+ */
40
+ export function enumerateTrackedFiles(dir) {
41
+ const r = spawnSync('git', ['-C', dir, 'ls-files', '-z'], { encoding: 'utf-8' });
42
+ if (r.status !== 0 || !r.stdout)
43
+ return [];
44
+ return r.stdout
45
+ .split('\0')
46
+ .filter(Boolean)
47
+ .filter((f) => {
48
+ const top = f.split('/')[0];
49
+ return !NEVER_COPY.has(f) && !NEVER_COPY.has(top);
50
+ });
51
+ }
52
+ /**
53
+ * Split crabbox's ssh argv (`['ssh', …opts, 'crabbox@host']`) into the `-e`
54
+ * transport string (`ssh …opts`) and the `crabbox@host` endpoint. This is what
55
+ * carries the per-lease identity key + known_hosts a raw ssh lacks.
56
+ */
57
+ export function sshTransportFromArgv(sshArgv) {
58
+ const host = sshArgv[sshArgv.length - 1];
59
+ const rsh = sshArgv.slice(0, -1).join(' '); // 'ssh -i <key> -o … -p 2222'
60
+ return { rsh, host };
61
+ }
62
+ /**
63
+ * rsync argv to push the tracked file set to `~/.agents` on the box. Reads the
64
+ * NUL-separated list at `filesFrom` (`--from0`, matching `ls-files -z`) so paths
65
+ * with spaces survive, and tunnels over crabbox's own ssh (`rsh`).
66
+ */
67
+ export function buildSetupRsyncArgs(opts) {
68
+ const remote = `${opts.host}:${opts.remoteDir ?? REMOTE_AGENTS_DIR}`;
69
+ const source = opts.source.endsWith('/') ? opts.source : `${opts.source}/`;
70
+ return ['-az', '--files-from', opts.filesFrom, '--from0', '-e', opts.rsh, source, remote];
71
+ }
72
+ function runStreaming(cmd, args, env, onData) {
73
+ return new Promise((resolve) => {
74
+ const proc = spawn(cmd, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
75
+ const pump = (chunk) => {
76
+ const s = chunk.toString('utf-8');
77
+ if (onData)
78
+ onData(s);
79
+ else
80
+ process.stdout.write(s);
81
+ };
82
+ proc.stdout.on('data', pump);
83
+ proc.stderr.on('data', pump);
84
+ proc.on('error', () => resolve(null));
85
+ proc.on('close', (code) => resolve(code));
86
+ });
87
+ }
88
+ /**
89
+ * Replicate the git-tracked subset of the local `~/.agents` onto the crabbox box
90
+ * and refresh it. Enumerates tracked files, rsyncs them over ssh, then runs
91
+ * `agents repo refresh` on the box. Refresh is skipped when the push fails or the
92
+ * file set is empty (nothing to refresh). Never throws — surfaces failure through
93
+ * the returned exit codes.
94
+ */
95
+ export async function copySetupToBox(opts) {
96
+ const dir = opts.userAgentsDir ?? getUserAgentsDir();
97
+ const files = enumerateTrackedFiles(dir);
98
+ if (files.length === 0) {
99
+ return { files, pushExitCode: null, refreshExitCode: null };
100
+ }
101
+ // crabbox provisions a per-lease ssh key; a raw `ssh crabbox@ip` fails publickey.
102
+ // Ask crabbox for its exact ssh invocation and tunnel rsync through it.
103
+ const sshArgv = crabboxSshArgv(opts.slug, { secretsBundle: opts.secretsBundle });
104
+ if (!sshArgv) {
105
+ return { files, pushExitCode: null, refreshExitCode: null };
106
+ }
107
+ const { rsh, host } = sshTransportFromArgv(sshArgv);
108
+ const env = crabboxEnv({ secretsBundle: opts.secretsBundle });
109
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-setup-copy-'));
110
+ const listPath = path.join(tmp, 'files.lst');
111
+ try {
112
+ // NUL-separated list, matching `buildSetupRsyncArgs`'s `--from0`.
113
+ fs.writeFileSync(listPath, files.join('\0'), 'utf-8');
114
+ const rsyncArgs = buildSetupRsyncArgs({ rsh, host, filesFrom: listPath, source: dir });
115
+ const pushExitCode = await runStreaming('rsync', rsyncArgs, env, opts.onData);
116
+ let refreshExitCode = null;
117
+ if (pushExitCode === 0 && opts.refresh !== false) {
118
+ // ssh <opts> crabbox@host bash -lc 'agents repo refresh'
119
+ const refreshArgs = [...sshArgv.slice(1), 'bash', '-lc', 'agents repo refresh'];
120
+ refreshExitCode = await runStreaming('ssh', refreshArgs, env, opts.onData);
121
+ }
122
+ return { files, pushExitCode, refreshExitCode };
123
+ }
124
+ finally {
125
+ fs.rmSync(tmp, { recursive: true, force: true });
126
+ }
127
+ }
@@ -33,9 +33,9 @@ export declare function isDaemonWedged(): boolean;
33
33
  /** Check if the daemon process is alive by sending signal 0 to the stored PID. */
34
34
  export declare function isDaemonRunning(): boolean;
35
35
  /**
36
- * Single-instance claim for the daemon `_run` entrypoint.
36
+ * Single-instance claim for the daemon foreground entrypoint.
37
37
  *
38
- * `agents daemon _run` is reachable directly — a manual invocation, or a
38
+ * `agents __daemon-run` is reachable directly — a manual invocation, or a
39
39
  * service-manager restart that races a still-alive predecessor — bypassing the
40
40
  * start lock in startDaemon(). Without this guard runDaemon() would call
41
41
  * writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
@@ -49,7 +49,7 @@ export declare function isDaemonRunning(): boolean;
49
49
  */
50
50
  export declare function claimDaemonInstance(): boolean;
51
51
  /**
52
- * Reap stray duplicate daemon processes — a `daemon _run` of THIS install that
52
+ * Reap stray duplicate daemon processes — a `__daemon-run` of THIS install that
53
53
  * isn't this process and isn't the pid-file owner. Mirrors the browser orphan
54
54
  * reaper (below): a predecessor that was SIGKILLed/OOM-ed without cleaning up,
55
55
  * or a duplicate that lost the pid-file write race, would otherwise keep a
@@ -141,8 +141,8 @@ export declare function ensureDaemonStarted(): {
141
141
  */
142
142
  export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, oauthToken?: string | null): NodeJS.ProcessEnv;
143
143
  /**
144
- * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
145
- * exact form that works under a direct `daemon _run`.
144
+ * Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
145
+ * exact form that works under a direct `__daemon-run`.
146
146
  *
147
147
  * We spawn the Node runtime (`process.execPath`) with the CLI entry as an
148
148
  * argument rather than executing the entry path directly. Executing the `.js`
@@ -187,9 +187,9 @@ export function isDaemonRunning() {
187
187
  return false;
188
188
  }
189
189
  /**
190
- * Single-instance claim for the daemon `_run` entrypoint.
190
+ * Single-instance claim for the daemon foreground entrypoint.
191
191
  *
192
- * `agents daemon _run` is reachable directly — a manual invocation, or a
192
+ * `agents __daemon-run` is reachable directly — a manual invocation, or a
193
193
  * service-manager restart that races a still-alive predecessor — bypassing the
194
194
  * start lock in startDaemon(). Without this guard runDaemon() would call
195
195
  * writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
@@ -216,7 +216,7 @@ export function claimDaemonInstance() {
216
216
  }
217
217
  }
218
218
  /**
219
- * Reap stray duplicate daemon processes — a `daemon _run` of THIS install that
219
+ * Reap stray duplicate daemon processes — a `__daemon-run` of THIS install that
220
220
  * isn't this process and isn't the pid-file owner. Mirrors the browser orphan
221
221
  * reaper (below): a predecessor that was SIGKILLed/OOM-ed without cleaning up,
222
222
  * or a duplicate that lost the pid-file write race, would otherwise keep a
@@ -252,10 +252,10 @@ export function reapStrayDaemons(keepPid = process.pid) {
252
252
  const args = m[2];
253
253
  if (isNaN(pid) || pid === keepPid || pid === process.pid || pid === ownerPid)
254
254
  continue;
255
- // Same install (same launch entry) AND a `daemon _run` command line.
255
+ // Same install (same launch entry) AND a `__daemon-run` command line.
256
256
  if (!args.includes(selfEntry))
257
257
  continue;
258
- if (!/\bdaemon\b.*\b_run\b/.test(args))
258
+ if (!/\b__daemon-run\b/.test(args))
259
259
  continue;
260
260
  try {
261
261
  process.kill(pid, 'SIGTERM');
@@ -295,7 +295,7 @@ export function log(level, message) {
295
295
  }
296
296
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
297
297
  export async function runDaemon() {
298
- // Single-instance guard: a direct `agents daemon _run` (manual, or a
298
+ // Single-instance guard: a direct `agents __daemon-run` (manual, or a
299
299
  // service-manager restart racing a live predecessor) must not clobber a
300
300
  // running daemon's pid file and start a second scheduler.
301
301
  if (!claimDaemonInstance()) {
@@ -1005,8 +1005,8 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
1005
1005
  return env;
1006
1006
  }
1007
1007
  /**
1008
- * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
1009
- * exact form that works under a direct `daemon _run`.
1008
+ * Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
1009
+ * exact form that works under a direct `__daemon-run`.
1010
1010
  *
1011
1011
  * We spawn the Node runtime (`process.execPath`) with the CLI entry as an
1012
1012
  * argument rather than executing the entry path directly. Executing the `.js`
@@ -1026,7 +1026,7 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
1026
1026
  const { warnings } = validateDaemonBinary(agentsBin);
1027
1027
  for (const w of warnings)
1028
1028
  process.stderr.write(`[agents] ${w}\n`);
1029
- return getCliLaunch(['daemon', '_run'], agentsBin);
1029
+ return getCliLaunch(['__daemon-run'], agentsBin);
1030
1030
  }
1031
1031
  /**
1032
1032
  * The directory of the Node runtime that generated this service manifest, kept
@@ -13,6 +13,7 @@ import { execFile } from 'child_process';
13
13
  import { promisify } from 'util';
14
14
  import { getDriveDir } from './state.js';
15
15
  import { AGENTS } from './agents.js';
16
+ import { sshExec } from './ssh-exec.js';
16
17
  const execFileAsync = promisify(execFile);
17
18
  // `remote` flows from disk into rsync/ssh argv. Use a strict regex so a
18
19
  // tampered ~/.agents/drive/config.json can't sneak shell metacharacters or
@@ -92,9 +93,11 @@ export async function push() {
92
93
  assertValidRemote(config.remote);
93
94
  const localDir = getDriveDir() + '/';
94
95
  const remoteSpec = `${config.remote}:~/.agents/drive/`;
95
- // Ensure remote directory exists. ssh's command argument is one positional
96
- // string; we hand it as a single argv element so no local shell sees it.
97
- await execFileAsync('ssh', [config.remote, 'mkdir -p ~/.agents/drive']);
96
+ // Ensure remote directory exists via the shared hardened SSH primitive.
97
+ const mkdir = sshExec(config.remote, 'mkdir -p ~/.agents/drive', { timeoutMs: 30_000 });
98
+ if (mkdir.code !== 0) {
99
+ throw new Error(`Failed to prepare remote drive directory on ${config.remote}${mkdir.stderr.trim() ? `: ${mkdir.stderr.trim()}` : ''}`);
100
+ }
98
101
  await execFileAsync('rsync', ['-az', '--exclude=config.json', localDir, remoteSpec]);
99
102
  config.lastPush = new Date().toISOString();
100
103
  writeDriveConfig(config);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The unified event reader -- one stream over BOTH operational events
3
+ * (`~/.agents/events.jsonl` via events.ts: secrets, commands, teams, ...) and
4
+ * agent-semantic events (the per-session activity logs via activity.ts: plans,
5
+ * PRs, worktrees, sub-agents, artifacts). They share one {@link EventType}
6
+ * vocabulary and one {@link EventRecord} shape, so `agents events` and any
7
+ * higher-level feature (a session/project summarizer, RSS) read them together.
8
+ *
9
+ * The two write paths stay separate for efficiency -- operational events append
10
+ * to the locked global log (low frequency), agent events append to lock-free
11
+ * per-session shards (high frequency, one writer each). This module is the
12
+ * single READ surface that merges them; nothing here writes.
13
+ */
14
+ import { type EventRecord, type EventType, type EventLevel } from './events.js';
15
+ export interface UnifiedQuery {
16
+ startDate?: Date;
17
+ endDate?: Date;
18
+ eventTypes?: EventType[];
19
+ level?: EventLevel;
20
+ agent?: string;
21
+ caller?: string;
22
+ command?: string;
23
+ module?: string;
24
+ limit?: number;
25
+ /** Include agent-semantic activity events. Default true. */
26
+ includeActivity?: boolean;
27
+ /** Override the activity dir (tests). */
28
+ activityRoot?: string;
29
+ }
30
+ /**
31
+ * Read a unified, newest-first event stream. Operational events come from
32
+ * events.ts `query()`; agent-semantic events from the activity logs, normalized
33
+ * to the same record shape and filtered identically. `limit` caps the merged
34
+ * result (each source is fetched up to `limit`, so the top-N is exact).
35
+ */
36
+ export declare function readUnifiedEvents(q?: UnifiedQuery): EventRecord[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The unified event reader -- one stream over BOTH operational events
3
+ * (`~/.agents/events.jsonl` via events.ts: secrets, commands, teams, ...) and
4
+ * agent-semantic events (the per-session activity logs via activity.ts: plans,
5
+ * PRs, worktrees, sub-agents, artifacts). They share one {@link EventType}
6
+ * vocabulary and one {@link EventRecord} shape, so `agents events` and any
7
+ * higher-level feature (a session/project summarizer, RSS) read them together.
8
+ *
9
+ * The two write paths stay separate for efficiency -- operational events append
10
+ * to the locked global log (low frequency), agent events append to lock-free
11
+ * per-session shards (high frequency, one writer each). This module is the
12
+ * single READ surface that merges them; nothing here writes.
13
+ */
14
+ import { query, levelFor } from './events.js';
15
+ import { readActivityAsEventRecords } from './activity.js';
16
+ /** Apply the same filters query() applies, to an activity-derived record. */
17
+ function matches(r, q) {
18
+ const ms = Date.parse(r.ts);
19
+ // Mirror query()'s default upper bound (endDate = now) so both sources drop
20
+ // future-dated records identically -- keeps the two in exact filter parity.
21
+ const endMs = (q.endDate ?? new Date()).getTime();
22
+ if (q.startDate && !Number.isNaN(ms) && ms < q.startDate.getTime())
23
+ return false;
24
+ if (!Number.isNaN(ms) && ms > endMs)
25
+ return false;
26
+ if (q.eventTypes && !q.eventTypes.includes(r.event))
27
+ return false;
28
+ if (q.level && (r.level ?? levelFor(r.event)) !== q.level)
29
+ return false;
30
+ if (q.agent && r.agent !== q.agent)
31
+ return false;
32
+ if (q.caller && r.caller !== q.caller)
33
+ return false;
34
+ if (q.command && r.command !== q.command &&
35
+ !(typeof r.command === 'string' && r.command.startsWith(q.command + ' ')))
36
+ return false;
37
+ if (q.module && r.module !== q.module)
38
+ return false;
39
+ return true;
40
+ }
41
+ /**
42
+ * Read a unified, newest-first event stream. Operational events come from
43
+ * events.ts `query()`; agent-semantic events from the activity logs, normalized
44
+ * to the same record shape and filtered identically. `limit` caps the merged
45
+ * result (each source is fetched up to `limit`, so the top-N is exact).
46
+ */
47
+ export function readUnifiedEvents(q = {}) {
48
+ const ops = query({
49
+ startDate: q.startDate,
50
+ endDate: q.endDate,
51
+ eventTypes: q.eventTypes,
52
+ level: q.level,
53
+ agent: q.agent,
54
+ caller: q.caller,
55
+ command: q.command,
56
+ module: q.module,
57
+ limit: q.limit,
58
+ });
59
+ if (q.includeActivity === false)
60
+ return ops;
61
+ const acts = readActivityAsEventRecords({
62
+ sinceMs: q.startDate?.getTime(),
63
+ limit: q.limit,
64
+ root: q.activityRoot,
65
+ }).filter((r) => matches(r, q));
66
+ const merged = [...ops, ...acts].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
67
+ return typeof q.limit === 'number' ? merged.slice(0, q.limit) : merged;
68
+ }
@@ -12,7 +12,7 @@
12
12
  * - Performance tracking: withTiming() wrapper for any async function
13
13
  */
14
14
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
15
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'error' | 'warn' | 'info' | 'debug';
15
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
16
  export declare function levelFor(event: EventType): EventLevel;
17
17
  export interface EventMeta {
18
18
  ts: string;
@@ -158,6 +158,17 @@ export interface ExecOptions {
158
158
  * `--interactive` takes precedence over `--headless`; the CLI layer rejects passing both.
159
159
  */
160
160
  export declare function resolveInteractive(options: Pick<ExecOptions, 'interactive' | 'headless' | 'prompt'>): boolean;
161
+ /**
162
+ * True when a run resolved to *inferred* interactive intent — no prompt and no
163
+ * explicit `--interactive` — but there is no terminal to host the REPL. Launching
164
+ * would attach a TUI to a dead stdin and hang forever, so the caller should fail
165
+ * fast with the headless alternatives instead (RUSH-1829).
166
+ *
167
+ * An explicit `--interactive` is the caller's deliberate choice and is never
168
+ * blocked (they may be driving a PTY we can't detect). Pure — the TTY state is a
169
+ * parameter so this is unit-testable without touching `process.std*`.
170
+ */
171
+ export declare function inferredInteractiveWithoutTty(options: Pick<ExecOptions, 'interactive' | 'headless' | 'prompt'>, isTty: boolean): boolean;
161
172
  /**
162
173
  * Decide whether spawnAgent must capture (PIPE + tee) the child's stdout so the
163
174
  * live budget watcher can parse it (issue #346, FIX 3).
package/dist/lib/exec.js CHANGED
@@ -15,7 +15,8 @@ import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion }
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
- import { getShimsDir } from './state.js';
18
+ import { getShimsDir, getHistoryDir } from './state.js';
19
+ import { resolveCodexHome } from './codex-home.js';
19
20
  import { readCodexConfiguredModel } from './shims.js';
20
21
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
21
22
  import { recordRunName } from './session/run-names.js';
@@ -153,6 +154,21 @@ export function resolveInteractive(options) {
153
154
  return false;
154
155
  return options.prompt === undefined;
155
156
  }
157
+ /**
158
+ * True when a run resolved to *inferred* interactive intent — no prompt and no
159
+ * explicit `--interactive` — but there is no terminal to host the REPL. Launching
160
+ * would attach a TUI to a dead stdin and hang forever, so the caller should fail
161
+ * fast with the headless alternatives instead (RUSH-1829).
162
+ *
163
+ * An explicit `--interactive` is the caller's deliberate choice and is never
164
+ * blocked (they may be driving a PTY we can't detect). Pure — the TTY state is a
165
+ * parameter so this is unit-testable without touching `process.std*`.
166
+ */
167
+ export function inferredInteractiveWithoutTty(options, isTty) {
168
+ if (options.interactive === true)
169
+ return false;
170
+ return resolveInteractive(options) && !isTty;
171
+ }
156
172
  /**
157
173
  * Decide whether spawnAgent must capture (PIPE + tee) the child's stdout so the
158
174
  * live budget watcher can parse it (issue #346, FIX 3).
@@ -241,7 +257,12 @@ export function buildExecEnv(options) {
241
257
  ? resolvedVersion
242
258
  : (resolvedVersion && isVersionInstalled('codex', resolvedVersion) ? resolvedVersion : null);
243
259
  if (version) {
244
- result.CODEX_HOME = path.join(getVersionHomePath('codex', version), '.codex');
260
+ // On macOS the deep versioned home overflows the Unix-socket SUN_LEN
261
+ // limit for codex's app-server control socket; resolve to a short,
262
+ // SUN_LEN-safe home (migrating once if needed). See codex-home.ts.
263
+ const versionedHome = path.join(getVersionHomePath('codex', version), '.codex');
264
+ const agentsUserDir = path.dirname(getHistoryDir());
265
+ result.CODEX_HOME = resolveCodexHome(versionedHome, agentsUserDir, version);
245
266
  }
246
267
  delete result.CLAUDE_CONFIG_DIR;
247
268
  delete result.COPILOT_HOME;
@@ -27,4 +27,4 @@ export interface PolicyResult {
27
27
  * Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
28
28
  * Caller is responsible for persistence/logging side effects not owned by feed.ts.
29
29
  */
30
- export declare function applyPolicyToBlock(block: OpenBlock, policy: FeedPolicy, now: Date, root?: string): PolicyResult;
30
+ export declare function applyPolicyToBlock(block: OpenBlock, policy: FeedPolicy, now: Date, root?: string, mailboxRoot?: string): PolicyResult;
@@ -28,12 +28,12 @@ export const DEFAULT_POLICY = {
28
28
  export function getPolicyPath(root) {
29
29
  return path.join(root ?? getUserAgentsDir(), POLICY_FILE);
30
30
  }
31
- function normalizeClassPolicy(raw) {
31
+ function normalizeClassPolicy(raw, fallback) {
32
32
  const p = (raw ?? {});
33
- const timeout = typeof p.timeoutMinutes === 'number' ? p.timeoutMinutes : DEFAULT_POLICY.approval.timeoutMinutes;
33
+ const timeout = typeof p.timeoutMinutes === 'number' ? p.timeoutMinutes : fallback.timeoutMinutes;
34
34
  return {
35
35
  timeoutMinutes: Math.max(1, Math.round(timeout)),
36
- safeDefault: typeof p.safeDefault === 'string' ? p.safeDefault : undefined,
36
+ safeDefault: typeof p.safeDefault === 'string' ? p.safeDefault : fallback.safeDefault,
37
37
  };
38
38
  }
39
39
  export function loadPolicy(root) {
@@ -45,8 +45,8 @@ export function loadPolicy(root) {
45
45
  const p = parsed;
46
46
  const threshold = p.phoneNotifyThreshold;
47
47
  return {
48
- approval: normalizeClassPolicy(p.approval ?? { timeoutMinutes: DEFAULT_POLICY.approval.timeoutMinutes, safeDefault: DEFAULT_POLICY.approval.safeDefault }),
49
- decision: normalizeClassPolicy(p.decision ?? { timeoutMinutes: DEFAULT_POLICY.decision.timeoutMinutes }),
48
+ approval: normalizeClassPolicy(p.approval, DEFAULT_POLICY.approval),
49
+ decision: normalizeClassPolicy(p.decision, DEFAULT_POLICY.decision),
50
50
  phoneNotifyThreshold: threshold === 'low' || threshold === 'medium' || threshold === 'high' ? threshold : DEFAULT_POLICY.phoneNotifyThreshold,
51
51
  };
52
52
  }
@@ -71,16 +71,21 @@ export function minutesElapsed(block, now) {
71
71
  return 0;
72
72
  return (now.getTime() - ts) / 60_000;
73
73
  }
74
+ function timeoutMinutesForBlock(block, policy) {
75
+ if (typeof block.timeoutMinutes === 'number' && Number.isFinite(block.timeoutMinutes) && block.timeoutMinutes > 0) {
76
+ return Math.max(1, Math.round(block.timeoutMinutes));
77
+ }
78
+ return policy[blockClass(block)].timeoutMinutes;
79
+ }
74
80
  export function isTimedOut(block, policy, now) {
75
- const cls = blockClass(block);
76
81
  const minutes = minutesElapsed(block, now);
77
- return minutes >= policy[cls].timeoutMinutes;
82
+ return minutes >= timeoutMinutesForBlock(block, policy);
78
83
  }
79
84
  /**
80
85
  * Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
81
86
  * Caller is responsible for persistence/logging side effects not owned by feed.ts.
82
87
  */
83
- export function applyPolicyToBlock(block, policy, now, root) {
88
+ export function applyPolicyToBlock(block, policy, now, root, mailboxRoot) {
84
89
  if (block.answer || block.parkedAt || block.defaultedAt) {
85
90
  return { blockId: block.blockId, action: 'none' };
86
91
  }
@@ -97,7 +102,7 @@ export function applyPolicyToBlock(block, policy, now, root) {
97
102
  if (!claim.ok) {
98
103
  return { blockId: block.blockId, action: 'none' };
99
104
  }
100
- const msgId = enqueue(mailboxDir(block.mailboxId, root ?? undefined), {
105
+ const msgId = enqueue(mailboxDir(block.mailboxId, mailboxRoot), {
101
106
  to: block.mailboxId,
102
107
  text: safeDefault,
103
108
  from: 'policy',