@phnx-labs/agents-cli 1.20.35 → 1.20.37

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 (236) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions-sync.d.ts +3 -0
  34. package/dist/commands/sessions-sync.js +44 -4
  35. package/dist/commands/sessions.d.ts +78 -1
  36. package/dist/commands/sessions.js +506 -66
  37. package/dist/commands/setup.js +4 -2
  38. package/dist/commands/sync.d.ts +3 -1
  39. package/dist/commands/sync.js +156 -4
  40. package/dist/commands/teams.js +217 -0
  41. package/dist/commands/versions.js +2 -4
  42. package/dist/commands/watchdog.d.ts +18 -0
  43. package/dist/commands/watchdog.js +238 -0
  44. package/dist/index.js +25 -2
  45. package/dist/lib/audit/log.d.ts +92 -0
  46. package/dist/lib/audit/log.js +177 -0
  47. package/dist/lib/auto-pull.js +2 -1
  48. package/dist/lib/browser/chrome.d.ts +10 -0
  49. package/dist/lib/browser/chrome.js +18 -7
  50. package/dist/lib/browser/drivers/ssh.js +2 -1
  51. package/dist/lib/browser/har.d.ts +84 -0
  52. package/dist/lib/browser/har.js +77 -0
  53. package/dist/lib/browser/ipc.js +24 -3
  54. package/dist/lib/browser/profiles.d.ts +1 -1
  55. package/dist/lib/browser/profiles.js +8 -10
  56. package/dist/lib/browser/refs.d.ts +65 -0
  57. package/dist/lib/browser/refs.js +73 -1
  58. package/dist/lib/browser/runtime-state.js +1 -0
  59. package/dist/lib/browser/service.d.ts +38 -2
  60. package/dist/lib/browser/service.js +112 -8
  61. package/dist/lib/browser/types.d.ts +14 -1
  62. package/dist/lib/budget/live-cloud.d.ts +42 -0
  63. package/dist/lib/budget/live-cloud.js +79 -0
  64. package/dist/lib/budget/live-team.d.ts +31 -0
  65. package/dist/lib/budget/live-team.js +115 -0
  66. package/dist/lib/cloud/codex.js +4 -0
  67. package/dist/lib/cloud/rush.d.ts +12 -1
  68. package/dist/lib/cloud/rush.js +13 -3
  69. package/dist/lib/cloud/types.d.ts +9 -0
  70. package/dist/lib/computer/dispatch.d.ts +8 -0
  71. package/dist/lib/computer/dispatch.js +125 -0
  72. package/dist/lib/computer/loop.d.ts +62 -0
  73. package/dist/lib/computer/loop.js +98 -0
  74. package/dist/lib/computer/model.d.ts +44 -0
  75. package/dist/lib/computer/model.js +157 -0
  76. package/dist/lib/concurrency.d.ts +19 -0
  77. package/dist/lib/concurrency.js +33 -0
  78. package/dist/lib/daemon.d.ts +57 -0
  79. package/dist/lib/daemon.js +196 -18
  80. package/dist/lib/devices/registry.d.ts +7 -0
  81. package/dist/lib/devices/registry.js +24 -0
  82. package/dist/lib/devices/resolve-target.d.ts +24 -0
  83. package/dist/lib/devices/resolve-target.js +80 -0
  84. package/dist/lib/devices/tailscale.js +1 -1
  85. package/dist/lib/drift.d.ts +52 -0
  86. package/dist/lib/drift.js +112 -0
  87. package/dist/lib/events.d.ts +1 -1
  88. package/dist/lib/events.js +31 -13
  89. package/dist/lib/exec.d.ts +17 -0
  90. package/dist/lib/exec.js +79 -13
  91. package/dist/lib/git.d.ts +27 -0
  92. package/dist/lib/git.js +56 -1
  93. package/dist/lib/hooks/cache.d.ts +6 -0
  94. package/dist/lib/hooks/cache.js +54 -12
  95. package/dist/lib/hooks.d.ts +27 -0
  96. package/dist/lib/hooks.js +127 -8
  97. package/dist/lib/hosts/dispatch.d.ts +15 -0
  98. package/dist/lib/hosts/dispatch.js +39 -6
  99. package/dist/lib/hosts/logs.js +30 -1
  100. package/dist/lib/hosts/option.js +1 -1
  101. package/dist/lib/hosts/passthrough.js +3 -1
  102. package/dist/lib/hosts/ready.d.ts +29 -6
  103. package/dist/lib/hosts/ready.js +66 -15
  104. package/dist/lib/hosts/registry.d.ts +19 -2
  105. package/dist/lib/hosts/registry.js +58 -2
  106. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  107. package/dist/lib/hosts/remote-cmd.js +70 -1
  108. package/dist/lib/hosts/remote-os.d.ts +17 -0
  109. package/dist/lib/hosts/remote-os.js +30 -0
  110. package/dist/lib/hosts/session-index.d.ts +34 -0
  111. package/dist/lib/hosts/session-index.js +56 -0
  112. package/dist/lib/hosts/tasks.d.ts +14 -0
  113. package/dist/lib/hosts/tasks.js +15 -0
  114. package/dist/lib/lock.d.ts +93 -0
  115. package/dist/lib/lock.js +207 -0
  116. package/dist/lib/loop.js +16 -1
  117. package/dist/lib/machine-id.d.ts +21 -0
  118. package/dist/lib/machine-id.js +26 -0
  119. package/dist/lib/mailbox-target.d.ts +36 -0
  120. package/dist/lib/mailbox-target.js +45 -0
  121. package/dist/lib/mailbox.d.ts +47 -0
  122. package/dist/lib/mailbox.js +194 -0
  123. package/dist/lib/mcp.d.ts +5 -0
  124. package/dist/lib/mcp.js +24 -8
  125. package/dist/lib/migrate.d.ts +19 -0
  126. package/dist/lib/migrate.js +134 -26
  127. package/dist/lib/overdue.js +3 -0
  128. package/dist/lib/picker.d.ts +2 -0
  129. package/dist/lib/picker.js +4 -1
  130. package/dist/lib/platform/exec.d.ts +46 -0
  131. package/dist/lib/platform/exec.js +74 -0
  132. package/dist/lib/platform/process.d.ts +31 -0
  133. package/dist/lib/platform/process.js +34 -1
  134. package/dist/lib/platform/winpath.js +2 -0
  135. package/dist/lib/plugins.js +16 -6
  136. package/dist/lib/profiles.d.ts +25 -0
  137. package/dist/lib/profiles.js +22 -6
  138. package/dist/lib/pty-client.js +2 -1
  139. package/dist/lib/rotate.d.ts +61 -0
  140. package/dist/lib/rotate.js +52 -0
  141. package/dist/lib/routines.d.ts +40 -2
  142. package/dist/lib/routines.js +66 -8
  143. package/dist/lib/runner.d.ts +11 -2
  144. package/dist/lib/runner.js +49 -7
  145. package/dist/lib/scheduler.js +6 -1
  146. package/dist/lib/secrets/bundles.d.ts +60 -4
  147. package/dist/lib/secrets/bundles.js +131 -12
  148. package/dist/lib/secrets/filestore.d.ts +3 -0
  149. package/dist/lib/secrets/filestore.js +42 -16
  150. package/dist/lib/secrets/index.d.ts +43 -2
  151. package/dist/lib/secrets/index.js +102 -3
  152. package/dist/lib/secrets/mcp.d.ts +93 -0
  153. package/dist/lib/secrets/mcp.js +205 -0
  154. package/dist/lib/secrets/remote.js +12 -5
  155. package/dist/lib/secrets/sync.js +83 -4
  156. package/dist/lib/secrets/windows.js +14 -3
  157. package/dist/lib/serve/data.d.ts +81 -0
  158. package/dist/lib/serve/data.js +91 -0
  159. package/dist/lib/serve/page.d.ts +7 -0
  160. package/dist/lib/serve/page.js +140 -0
  161. package/dist/lib/serve/server.d.ts +46 -0
  162. package/dist/lib/serve/server.js +115 -0
  163. package/dist/lib/session/active.d.ts +73 -0
  164. package/dist/lib/session/active.js +199 -22
  165. package/dist/lib/session/db.d.ts +2 -1
  166. package/dist/lib/session/db.js +41 -5
  167. package/dist/lib/session/discover.d.ts +39 -0
  168. package/dist/lib/session/discover.js +127 -29
  169. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  170. package/dist/lib/session/ghostty-tabs.js +126 -0
  171. package/dist/lib/session/inject.d.ts +18 -0
  172. package/dist/lib/session/inject.js +21 -0
  173. package/dist/lib/session/parse.js +23 -20
  174. package/dist/lib/session/pid-registry.d.ts +1 -0
  175. package/dist/lib/session/pid-registry.js +24 -0
  176. package/dist/lib/session/provenance.d.ts +14 -2
  177. package/dist/lib/session/provenance.js +39 -8
  178. package/dist/lib/session/relative-time.js +6 -2
  179. package/dist/lib/session/remote-active.js +19 -17
  180. package/dist/lib/session/remote-list.d.ts +51 -0
  181. package/dist/lib/session/remote-list.js +205 -0
  182. package/dist/lib/session/remote.d.ts +7 -1
  183. package/dist/lib/session/remote.js +20 -4
  184. package/dist/lib/session/sync/config.d.ts +14 -15
  185. package/dist/lib/session/sync/config.js +60 -20
  186. package/dist/lib/session/types.d.ts +23 -0
  187. package/dist/lib/shims.d.ts +36 -6
  188. package/dist/lib/shims.js +91 -29
  189. package/dist/lib/ssh-exec.js +2 -0
  190. package/dist/lib/ssh-tunnel.js +2 -1
  191. package/dist/lib/startup/command-registry.d.ts +6 -0
  192. package/dist/lib/startup/command-registry.js +13 -1
  193. package/dist/lib/state.d.ts +13 -0
  194. package/dist/lib/state.js +103 -9
  195. package/dist/lib/sync-umbrella.d.ts +14 -7
  196. package/dist/lib/sync-umbrella.js +21 -13
  197. package/dist/lib/teams/forEach.d.ts +110 -0
  198. package/dist/lib/teams/forEach.js +186 -0
  199. package/dist/lib/teams/index.d.ts +1 -0
  200. package/dist/lib/teams/index.js +1 -0
  201. package/dist/lib/teams/pr-watch.d.ts +226 -0
  202. package/dist/lib/teams/pr-watch.js +371 -0
  203. package/dist/lib/teams/supervisor.d.ts +14 -1
  204. package/dist/lib/teams/supervisor.js +19 -0
  205. package/dist/lib/teams/worktree.d.ts +9 -0
  206. package/dist/lib/teams/worktree.js +32 -0
  207. package/dist/lib/terminal/backends/index.d.ts +2 -1
  208. package/dist/lib/terminal/backends/index.js +3 -1
  209. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  210. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  211. package/dist/lib/terminal/index.d.ts +4 -1
  212. package/dist/lib/terminal/index.js +4 -1
  213. package/dist/lib/terminal/inject.d.ts +204 -0
  214. package/dist/lib/terminal/inject.js +247 -0
  215. package/dist/lib/terminal/resolve.d.ts +64 -0
  216. package/dist/lib/terminal/resolve.js +90 -0
  217. package/dist/lib/terminal/types.d.ts +1 -1
  218. package/dist/lib/tmux/session.d.ts +10 -0
  219. package/dist/lib/tmux/session.js +31 -0
  220. package/dist/lib/triggers/webhook.d.ts +85 -0
  221. package/dist/lib/triggers/webhook.js +141 -0
  222. package/dist/lib/versions.d.ts +23 -0
  223. package/dist/lib/versions.js +119 -13
  224. package/dist/lib/watchdog/index.d.ts +3 -0
  225. package/dist/lib/watchdog/index.js +5 -0
  226. package/dist/lib/watchdog/read.d.ts +35 -0
  227. package/dist/lib/watchdog/read.js +149 -0
  228. package/dist/lib/watchdog/runner.d.ts +127 -0
  229. package/dist/lib/watchdog/runner.js +322 -0
  230. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  231. package/dist/lib/watchdog/watchdog.js +166 -0
  232. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  233. package/dist/lib/watchdog/watchdogTail.js +154 -0
  234. package/dist/lib/workflows.d.ts +166 -0
  235. package/dist/lib/workflows.js +193 -0
  236. package/package.json +5 -4
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `agents.lock` — a deterministic SHA-256 manifest of the resolved resource set
3
+ * at a project root, plus the `--frozen` verification that fails closed on any
4
+ * drift. This is the reproducible-CI slice of the governance work (#337); org
5
+ * version pins and bundle signing are deferred follow-ups.
6
+ *
7
+ * Design:
8
+ * - Content hashing REUSES `fingerprintDir` / `fingerprintFile`
9
+ * (src/lib/staleness/fingerprint.ts) — the same two-tier fingerprinter every
10
+ * staleness checker uses. No new recursive file walker is introduced here.
11
+ * - The resource SET is the layered, resolved source union (project > user >
12
+ * system > extras), the same precedence `resolveResource` / `syncResourcesToVersion`
13
+ * read from. It is enumerated agent-independently on purpose: the lock must be
14
+ * reproducible on a fresh CI checkout that has no installed agent VERSIONS, so
15
+ * it snapshots the SOURCES sync copies FROM, not any per-version home.
16
+ *
17
+ * Lock shape (JSON): `{ "version": 1, "resources": { "<relpath>": "<sha256>" } }`,
18
+ * keys sorted so the file is byte-stable across machines and runs.
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import { fingerprintDir, fingerprintFile } from './staleness/fingerprint.js';
23
+ import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, } from './state.js';
24
+ export const LOCK_FILENAME = 'agents.lock';
25
+ export const LOCK_VERSION = 1;
26
+ /**
27
+ * Resource kinds captured by the lock — the file/dir-backed layered resources
28
+ * shared across every agent. Presence-only kinds handled specially by the sync
29
+ * differ (plugins/promptcuts) are intentionally out of this first slice.
30
+ */
31
+ export const LOCK_KINDS = [
32
+ 'commands',
33
+ 'skills',
34
+ 'hooks',
35
+ 'rules',
36
+ 'mcp',
37
+ 'permissions',
38
+ 'subagents',
39
+ ];
40
+ /** Normalise a filesystem-relative path to POSIX separators for stable keys. */
41
+ function toPosix(rel) {
42
+ return rel.split(path.sep).join('/');
43
+ }
44
+ /**
45
+ * Hash every resolved source into a flat `relpath -> sha256` map. Directories are
46
+ * expanded with `fingerprintDir` (every contained file, sorted); single files use
47
+ * `fingerprintFile`. Unreadable / missing sources are skipped — enumeration only
48
+ * ever hands us paths that existed at scan time.
49
+ */
50
+ export function hashLockSources(sources) {
51
+ const out = {};
52
+ for (const src of sources) {
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(src.path);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ if (stat.isDirectory()) {
61
+ for (const fp of fingerprintDir(src.path)) {
62
+ const rel = toPosix(path.relative(src.path, fp.path));
63
+ out[`${src.key}/${rel}`] = fp.sha256;
64
+ }
65
+ }
66
+ else if (stat.isFile()) {
67
+ const fp = fingerprintFile(src.path);
68
+ if (fp)
69
+ out[src.key] = fp.sha256;
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+ /** Build a fully-sorted lock object from a resolved source set. */
75
+ export function buildLock(sources) {
76
+ return { version: LOCK_VERSION, resources: sortRecord(hashLockSources(sources)) };
77
+ }
78
+ /** Return a new record with keys inserted in sorted order (deterministic JSON). */
79
+ function sortRecord(record) {
80
+ const out = {};
81
+ for (const key of Object.keys(record).sort())
82
+ out[key] = record[key];
83
+ return out;
84
+ }
85
+ /** Serialise a lock to the canonical, sorted, newline-terminated JSON form. */
86
+ export function serializeLock(lock) {
87
+ return JSON.stringify({ version: lock.version, resources: sortRecord(lock.resources) }, null, 2) + '\n';
88
+ }
89
+ /** Absolute path to the lockfile for a project root. */
90
+ export function lockPath(projectRoot) {
91
+ return path.join(projectRoot, LOCK_FILENAME);
92
+ }
93
+ /**
94
+ * Read + validate an existing lock. Returns null ONLY when the file is absent —
95
+ * a present-but-malformed lock throws, so `--frozen` fails closed instead of
96
+ * silently treating corruption as "no lock".
97
+ */
98
+ export function readLock(projectRoot) {
99
+ const p = lockPath(projectRoot);
100
+ if (!fs.existsSync(p))
101
+ return null;
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
105
+ }
106
+ catch (e) {
107
+ throw new Error(`${LOCK_FILENAME} is not valid JSON: ${e.message}`);
108
+ }
109
+ if (!parsed || typeof parsed !== 'object') {
110
+ throw new Error(`${LOCK_FILENAME} is malformed (expected an object).`);
111
+ }
112
+ const obj = parsed;
113
+ if (obj.version !== LOCK_VERSION) {
114
+ throw new Error(`${LOCK_FILENAME} version ${String(obj.version)} is unsupported (expected ${LOCK_VERSION}).`);
115
+ }
116
+ if (!obj.resources || typeof obj.resources !== 'object') {
117
+ throw new Error(`${LOCK_FILENAME} is missing a resources map.`);
118
+ }
119
+ return { version: LOCK_VERSION, resources: obj.resources };
120
+ }
121
+ /** Write the lock to `<projectRoot>/agents.lock`, returning the path written. */
122
+ export function writeLock(projectRoot, lock) {
123
+ const p = lockPath(projectRoot);
124
+ fs.writeFileSync(p, serializeLock(lock));
125
+ return p;
126
+ }
127
+ /** Diff a recorded lock against a freshly-computed one. */
128
+ export function diffLock(expected, actual) {
129
+ const e = expected.resources;
130
+ const a = actual.resources;
131
+ const added = [];
132
+ const removed = [];
133
+ const changed = [];
134
+ for (const k of Object.keys(a))
135
+ if (!(k in e))
136
+ added.push(k);
137
+ for (const k of Object.keys(e)) {
138
+ if (!(k in a))
139
+ removed.push(k);
140
+ else if (e[k] !== a[k])
141
+ changed.push(k);
142
+ }
143
+ added.sort();
144
+ removed.sort();
145
+ changed.sort();
146
+ return { added, removed, changed };
147
+ }
148
+ /** True when a diff has no added/removed/changed entries. */
149
+ export function lockDiffIsClean(diff) {
150
+ return diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
151
+ }
152
+ /** Verify a recorded lock against the live resolved sources. */
153
+ export function verifyLock(expected, sources) {
154
+ return diffLock(expected, buildLock(sources));
155
+ }
156
+ /**
157
+ * The project root the lockfile lives at: the parent of the discovered project
158
+ * `.agents/` dir, or the cwd itself when none is found.
159
+ */
160
+ export function resolveProjectRoot(cwd = process.cwd()) {
161
+ const projectDir = getProjectAgentsDir(cwd);
162
+ return projectDir ? path.dirname(projectDir) : path.resolve(cwd);
163
+ }
164
+ /**
165
+ * Enumerate the resolved resource sources for a cwd: for each lockable kind, the
166
+ * winning entry per name across layers (project > user > system > extras), keyed
167
+ * `<kind>/<entryName>`. This is a single-level `readdir` per kind dir — the
168
+ * recursive fingerprinting is delegated to `fingerprintDir`.
169
+ *
170
+ * Entry names keep their extension in the key, so a hook script `foo.sh` and its
171
+ * sidecar `foo.yaml` stay distinct (a name-stripped enumerator would collide
172
+ * them). Dotfiles at the kind-dir top level are skipped as noise.
173
+ */
174
+ export function enumerateLockSources(cwd = process.cwd()) {
175
+ const roots = [];
176
+ const projectDir = getProjectAgentsDir(cwd);
177
+ if (projectDir)
178
+ roots.push(projectDir);
179
+ roots.push(getUserAgentsDir());
180
+ roots.push(getSystemAgentsDir());
181
+ for (const extra of getEnabledExtraRepos())
182
+ roots.push(extra.dir);
183
+ const sources = [];
184
+ const seen = new Set();
185
+ for (const kind of LOCK_KINDS) {
186
+ for (const root of roots) {
187
+ const kindDir = path.join(root, kind);
188
+ let entries;
189
+ try {
190
+ entries = fs.readdirSync(kindDir, { withFileTypes: true });
191
+ }
192
+ catch {
193
+ continue;
194
+ }
195
+ for (const entry of entries) {
196
+ if (entry.name.startsWith('.'))
197
+ continue;
198
+ const key = `${kind}/${entry.name}`;
199
+ if (seen.has(key))
200
+ continue; // project layer wins on same name
201
+ seen.add(key);
202
+ sources.push({ path: path.join(kindDir, entry.name), key });
203
+ }
204
+ }
205
+ }
206
+ return sources.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
207
+ }
package/dist/lib/loop.js CHANGED
@@ -26,6 +26,8 @@ import { buildExecCommand, buildExecEnv } from './exec.js';
26
26
  import { extractUsageEvents } from './budget/enforce.js';
27
27
  import { parseTimeout } from './routines.js';
28
28
  import { writeCheckpoint } from './checkpoint.js';
29
+ import { mailboxDir } from './mailbox.js';
30
+ import { composeWin32CommandLine } from './platform/index.js';
29
31
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
32
  /** Path to a run's loop-signal.json. */
31
33
  export function loopSignalPath(runDir) {
@@ -129,8 +131,14 @@ export function defaultRunIteration(options) {
129
131
  const cwd = execOptions.cwd || process.cwd();
130
132
  const model = execOptions.model ?? `${execOptions.agent}-default`;
131
133
  return new Promise((resolve, reject) => {
134
+ // DEP0190-safe shell spawn: on the win32 shell path compose ONE fully-quoted
135
+ // command line and pass an EMPTY args array (see composeWin32CommandLine) so
136
+ // Node never concatenates the args array — which carries the re-injected
137
+ // prompt — into the cmd.exe line unescaped.
132
138
  const useShell = process.platform === 'win32' && (!path.isAbsolute(executable) || executable.endsWith('.cmd'));
133
- const child = spawn(executable, args, {
139
+ const spawnCommand = useShell ? composeWin32CommandLine(executable, args) : executable;
140
+ const spawnArgs = useShell ? [] : args;
141
+ const child = spawn(spawnCommand, spawnArgs, {
134
142
  cwd,
135
143
  stdio: ['inherit', 'pipe', 'pipe'],
136
144
  env,
@@ -207,6 +215,9 @@ export async function runLoop(execOptions, loop, ctx, deps) {
207
215
  process.stderr.write(`[loop] WARNING: cross-iteration conversation continuity applies to claude only. ` +
208
216
  `Each ${ctx.agent} iteration runs as an independent fresh conversation (no /continue handoff).\n`);
209
217
  }
218
+ // Surface the run-level mailbox id (otherwise undiscoverable — runId is not in
219
+ // the session registry) so an operator can message the loop mid-flight.
220
+ process.stderr.write(`[loop] mailbox: agents message ${ctx.runId} "<text>"\n`);
210
221
  let tokens = ctx.startTokens ?? 0;
211
222
  let lastSignal;
212
223
  let stopSignal = false;
@@ -271,6 +282,10 @@ export async function runLoop(execOptions, loop, ctx, deps) {
271
282
  AGENTS_RUN_DIR: ctx.runDir,
272
283
  AGENTS_LOOP_SIGNAL: loopSignalPath(ctx.runDir),
273
284
  AGENTS_LOOP_ITERATION: String(iteration),
285
+ // Every iteration is a fresh session, so key the mailbox by the stable
286
+ // run id — one box for the whole loop. Overrides the per-iteration
287
+ // AGENTS_MAILBOX_DIR that buildExecEnv would derive from sessionId.
288
+ AGENTS_MAILBOX_DIR: mailboxDir(ctx.runId),
274
289
  },
275
290
  };
276
291
  let result;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This machine's stable, human-readable id. Lives in a dependency-free leaf so
3
+ * low-level modules (state.ts) can key per-device paths off it without importing
4
+ * the secrets/session-sync layer (which would create a cycle).
5
+ */
6
+ /**
7
+ * Normalize a raw hostname into a stable device id: first label only,
8
+ * lowercased, non-alphanumerics collapsed to hyphens. `zion.tail…ts.net` and
9
+ * `ZION` both become `zion`. The single source for this transform — machineId()
10
+ * and cross-machine session grouping must agree or the local machine won't
11
+ * match its own registry key.
12
+ */
13
+ export declare function normalizeHost(raw: string): string;
14
+ /**
15
+ * This machine's stable, human-readable id, used as its R2 sync prefix, session
16
+ * mirror directory, `agents devices` self-key, and per-device config folder.
17
+ * Tailnet hostnames (zion, yosemite-s0, mac-mini) are already unique and
18
+ * readable; we lowercase and strip any domain suffix. Overridable via
19
+ * AGENTS_SYNC_MACHINE_ID for tests and unusual setups.
20
+ */
21
+ export declare function machineId(): string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * This machine's stable, human-readable id. Lives in a dependency-free leaf so
3
+ * low-level modules (state.ts) can key per-device paths off it without importing
4
+ * the secrets/session-sync layer (which would create a cycle).
5
+ */
6
+ import * as os from 'os';
7
+ /**
8
+ * Normalize a raw hostname into a stable device id: first label only,
9
+ * lowercased, non-alphanumerics collapsed to hyphens. `zion.tail…ts.net` and
10
+ * `ZION` both become `zion`. The single source for this transform — machineId()
11
+ * and cross-machine session grouping must agree or the local machine won't
12
+ * match its own registry key.
13
+ */
14
+ export function normalizeHost(raw) {
15
+ return raw.split('.')[0].trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'unknown';
16
+ }
17
+ /**
18
+ * This machine's stable, human-readable id, used as its R2 sync prefix, session
19
+ * mirror directory, `agents devices` self-key, and per-device config folder.
20
+ * Tailnet hostnames (zion, yosemite-s0, mac-mini) are already unique and
21
+ * readable; we lowercase and strip any domain suffix. Overridable via
22
+ * AGENTS_SYNC_MACHINE_ID for tests and unusual setups.
23
+ */
24
+ export function machineId() {
25
+ return normalizeHost(process.env.AGENTS_SYNC_MACHINE_ID || os.hostname());
26
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Resolve an `agents message <target>` argument to exactly one destination —
3
+ * a cloud task, one live local/teams/loop agent, or an error. The anti-misroute
4
+ * rule lives here: a target that matches zero or more-than-one live agent is
5
+ * NEVER guessed; the caller reports it. Pure (no I/O) so it is unit-testable.
6
+ */
7
+ import type { ActiveSession } from './session/active.js';
8
+ export type MessageResolution = {
9
+ kind: 'cloud';
10
+ id: string;
11
+ } | {
12
+ kind: 'local';
13
+ id: string;
14
+ } | {
15
+ kind: 'none';
16
+ } | {
17
+ kind: 'ambiguous';
18
+ candidates: Array<{
19
+ id: string;
20
+ label: string;
21
+ }>;
22
+ };
23
+ /**
24
+ * The mailbox id a live session's box is keyed by. Teams stamp a durable
25
+ * `agentId` (== the Claude session id for Claude teammates); a bare run has
26
+ * only its `sessionId`. The spawn-time `AGENTS_MAILBOX_DIR` wiring must key the
27
+ * box by this same id — this is the single source of truth for both sides.
28
+ */
29
+ export declare function mailboxIdForActiveSession(s: ActiveSession): string | undefined;
30
+ /**
31
+ * Resolve `target` against the live sessions. Exact id matches win over prefix
32
+ * matches; results are de-duped by canonical mailbox id (collapsed subagents
33
+ * share one). `isCloudTask` is consulted first so a cloud task id routes to the
34
+ * cloud provider.
35
+ */
36
+ export declare function resolveMessageTarget(target: string, sessions: ActiveSession[], isCloudTask: (id: string) => boolean): MessageResolution;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The mailbox id a live session's box is keyed by. Teams stamp a durable
3
+ * `agentId` (== the Claude session id for Claude teammates); a bare run has
4
+ * only its `sessionId`. The spawn-time `AGENTS_MAILBOX_DIR` wiring must key the
5
+ * box by this same id — this is the single source of truth for both sides.
6
+ */
7
+ export function mailboxIdForActiveSession(s) {
8
+ return s.agentId ?? s.sessionId;
9
+ }
10
+ function labelFor(s) {
11
+ return s.label ?? s.topic ?? s.teamName ?? s.host ?? s.context;
12
+ }
13
+ /**
14
+ * Resolve `target` against the live sessions. Exact id matches win over prefix
15
+ * matches; results are de-duped by canonical mailbox id (collapsed subagents
16
+ * share one). `isCloudTask` is consulted first so a cloud task id routes to the
17
+ * cloud provider.
18
+ */
19
+ export function resolveMessageTarget(target, sessions, isCloudTask) {
20
+ if (isCloudTask(target))
21
+ return { kind: 'cloud', id: target };
22
+ // An empty target would make every `startsWith` prefix match — never guess.
23
+ if (target.length === 0)
24
+ return { kind: 'none' };
25
+ const exact = sessions.filter((s) => s.sessionId === target || s.agentId === target);
26
+ const chosen = exact.length > 0
27
+ ? exact
28
+ : sessions.filter((s) => Boolean(s.sessionId?.startsWith(target)) || Boolean(s.agentId?.startsWith(target)));
29
+ // De-dupe by canonical mailbox id (one box per logical agent).
30
+ const byId = new Map();
31
+ for (const s of chosen) {
32
+ const id = mailboxIdForActiveSession(s);
33
+ if (id && !byId.has(id))
34
+ byId.set(id, s);
35
+ }
36
+ const ids = [...byId.keys()];
37
+ if (ids.length === 0)
38
+ return { kind: 'none' };
39
+ if (ids.length === 1)
40
+ return { kind: 'local', id: ids[0] };
41
+ return {
42
+ kind: 'ambiguous',
43
+ candidates: [...byId.entries()].map(([id, s]) => ({ id, label: labelFor(s) })),
44
+ };
45
+ }
@@ -0,0 +1,47 @@
1
+ /** A single mailbox message. `text` may embed `host:/path` clip tokens. */
2
+ export interface MailboxMessage {
3
+ /** Unique, time-sortable id. Also the on-disk filename stem. */
4
+ msgId: string;
5
+ /** The mailboxId this message is addressed to (anti-misroute stamp). */
6
+ to: string;
7
+ /** Who sent it (operator label / agent id / host). Optional. */
8
+ from?: string;
9
+ /** ISO-8601 creation time. */
10
+ ts: string;
11
+ /** The message body. */
12
+ text: string;
13
+ }
14
+ /**
15
+ * A mailboxId must be a single, separator-free path segment. Real ids (session
16
+ * UUID / teams agentId / loop runId) already satisfy this; rejecting anything
17
+ * else fails loud instead of silently misrouting — a message whose id contained
18
+ * a `/` would nest under a different dir than the `to` stamp it is matched
19
+ * against, and would be dropped with no error. Also blocks `.`/`..` traversal
20
+ * once `agents message` starts accepting external target ids.
21
+ */
22
+ export declare function isValidMailboxId(mailboxId: string): boolean;
23
+ export declare function assertValidMailboxId(mailboxId: string): void;
24
+ /** Absolute path to a box. `root` override is for tests. */
25
+ export declare function mailboxDir(mailboxId: string, root?: string): string;
26
+ /**
27
+ * Enqueue a message into `boxDir` atomically. Returns the msgId. The `to` field
28
+ * is stamped so a drain can refuse a message that lands in the wrong box.
29
+ */
30
+ export declare function enqueue(boxDir: string, msg: {
31
+ to: string;
32
+ text: string;
33
+ from?: string;
34
+ }): string;
35
+ /**
36
+ * Drain the box: return every pending message addressed to it, in FIFO order,
37
+ * removing them from the queue. Claim-first (inbox → processing → consumed) so
38
+ * an interrupted drain is recovered on the next call (at-least-once). Corrupt
39
+ * files and messages addressed to a different box are dropped, not returned.
40
+ *
41
+ * `boxId` defaults to the box's directory name — the id it was created under.
42
+ */
43
+ export declare function drain(boxDir: string, boxId?: string): MailboxMessage[];
44
+ /** Read pending messages (inbox + in-flight) without consuming them. FIFO. */
45
+ export declare function peek(boxDir: string, boxId?: string): MailboxMessage[];
46
+ /** Delete pending (not-yet-claimed) inbox messages. Returns the count removed. */
47
+ export declare function clear(boxDir: string): number;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Agent mailbox — a file-spool that lets a message reach an already-running
3
+ * agent mid-flight. One box per logical agent, keyed by a unique-per-launch id
4
+ * (session UUID / teams agentId / loop runId), so boxes are disjoint and a
5
+ * message can never reach the wrong agent (see docs / plan velvety-conjuring-popcorn).
6
+ *
7
+ * Layout: <root>/<mailboxId>/{inbox,processing,consumed}/<msgId>.json
8
+ * inbox/ pending, written by `agents message`
9
+ * processing/ claimed by a drain (claim-first = crash-safe)
10
+ * consumed/ archived after delivery (and dropped mismatches)
11
+ *
12
+ * A box has a SINGLE consumer — the owning agent, whose tool calls (and thus
13
+ * hook-driven drains) are sequential. Writers may be concurrent; each enqueue
14
+ * is atomic (temp-write + rename), so a drain never observes a partial file.
15
+ * Delivery is at-least-once: an interrupted drain leaves the message in
16
+ * processing/, and the next drain recovers it. Consumers dedup by `msgId`.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { randomUUID } from 'crypto';
21
+ import { getMailboxRootDir } from './state.js';
22
+ /**
23
+ * A mailboxId must be a single, separator-free path segment. Real ids (session
24
+ * UUID / teams agentId / loop runId) already satisfy this; rejecting anything
25
+ * else fails loud instead of silently misrouting — a message whose id contained
26
+ * a `/` would nest under a different dir than the `to` stamp it is matched
27
+ * against, and would be dropped with no error. Also blocks `.`/`..` traversal
28
+ * once `agents message` starts accepting external target ids.
29
+ */
30
+ export function isValidMailboxId(mailboxId) {
31
+ return /^[A-Za-z0-9._-]+$/.test(mailboxId) && mailboxId !== '.' && mailboxId !== '..';
32
+ }
33
+ export function assertValidMailboxId(mailboxId) {
34
+ if (!isValidMailboxId(mailboxId)) {
35
+ throw new Error(`Invalid mailboxId ${JSON.stringify(mailboxId)}: must be a single path segment ` +
36
+ `matching [A-Za-z0-9._-] (no separators, not '.'/'..').`);
37
+ }
38
+ }
39
+ /** Absolute path to a box. `root` override is for tests. */
40
+ export function mailboxDir(mailboxId, root = getMailboxRootDir()) {
41
+ assertValidMailboxId(mailboxId);
42
+ return path.join(root, mailboxId);
43
+ }
44
+ function inboxDir(boxDir) { return path.join(boxDir, 'inbox'); }
45
+ function processingDir(boxDir) { return path.join(boxDir, 'processing'); }
46
+ function consumedDir(boxDir) { return path.join(boxDir, 'consumed'); }
47
+ /** Create the three sub-buckets. Idempotent. */
48
+ function ensureDirs(boxDir) {
49
+ fs.mkdirSync(inboxDir(boxDir), { recursive: true });
50
+ fs.mkdirSync(processingDir(boxDir), { recursive: true });
51
+ fs.mkdirSync(consumedDir(boxDir), { recursive: true });
52
+ }
53
+ let seq = 0;
54
+ /**
55
+ * `<epochMs>-<seq>-<rand>` — sorts by filename in FIFO order. The per-process
56
+ * monotonic `seq` breaks ties within the same millisecond (so a single writer's
57
+ * order is preserved); `rand` keeps it unique across processes/hosts.
58
+ */
59
+ function newMsgId() {
60
+ const s = String(seq++).padStart(6, '0');
61
+ return `${Date.now()}-${s}-${randomUUID().slice(0, 8)}`;
62
+ }
63
+ /**
64
+ * Enqueue a message into `boxDir` atomically. Returns the msgId. The `to` field
65
+ * is stamped so a drain can refuse a message that lands in the wrong box.
66
+ */
67
+ export function enqueue(boxDir, msg) {
68
+ assertValidMailboxId(msg.to);
69
+ ensureDirs(boxDir);
70
+ const msgId = newMsgId();
71
+ const record = {
72
+ msgId,
73
+ to: msg.to,
74
+ from: msg.from,
75
+ ts: new Date().toISOString(),
76
+ text: msg.text,
77
+ };
78
+ const target = path.join(inboxDir(boxDir), `${msgId}.json`);
79
+ const tmp = `${target}.${process.pid}.tmp`;
80
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2), 'utf-8');
81
+ fs.renameSync(tmp, target); // atomic on a single filesystem
82
+ return msgId;
83
+ }
84
+ /** Parse a message file. Returns null on missing/corrupt/invalid-shape. */
85
+ function readMessage(file) {
86
+ let raw;
87
+ try {
88
+ raw = fs.readFileSync(file, 'utf-8');
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const m = parsed;
101
+ if (typeof m?.msgId !== 'string' || typeof m?.to !== 'string' || typeof m?.text !== 'string') {
102
+ return null;
103
+ }
104
+ return { msgId: m.msgId, to: m.to, from: m.from, ts: m.ts ?? '', text: m.text };
105
+ }
106
+ function jsonFiles(dir) {
107
+ let names;
108
+ try {
109
+ names = fs.readdirSync(dir);
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ return names.filter((n) => n.endsWith('.json')).sort();
115
+ }
116
+ /**
117
+ * Consume a file already sitting in `processing/`: read, verify it is addressed
118
+ * to this box, then archive to `consumed/`. Returns the message iff valid AND
119
+ * addressed here; a mismatched/corrupt file is archived (dropped) so it never
120
+ * loops. Returns null when the file vanished (a racing drain took it).
121
+ */
122
+ function consumeClaimed(boxDir, name, expectedTo) {
123
+ const src = path.join(processingDir(boxDir), name);
124
+ const dest = path.join(consumedDir(boxDir), name);
125
+ const msg = readMessage(src);
126
+ try {
127
+ fs.renameSync(src, dest);
128
+ }
129
+ catch {
130
+ return null; // already archived/claimed elsewhere
131
+ }
132
+ if (!msg || msg.to !== expectedTo)
133
+ return null; // dropped (corrupt or wrong box)
134
+ return msg;
135
+ }
136
+ /**
137
+ * Drain the box: return every pending message addressed to it, in FIFO order,
138
+ * removing them from the queue. Claim-first (inbox → processing → consumed) so
139
+ * an interrupted drain is recovered on the next call (at-least-once). Corrupt
140
+ * files and messages addressed to a different box are dropped, not returned.
141
+ *
142
+ * `boxId` defaults to the box's directory name — the id it was created under.
143
+ */
144
+ export function drain(boxDir, boxId = path.basename(boxDir)) {
145
+ ensureDirs(boxDir);
146
+ const out = [];
147
+ // 1. Recover orphans left in processing/ by a prior interrupted drain.
148
+ for (const name of jsonFiles(processingDir(boxDir))) {
149
+ const msg = consumeClaimed(boxDir, name, boxId);
150
+ if (msg)
151
+ out.push(msg);
152
+ }
153
+ // 2. Claim and consume pending inbox messages.
154
+ for (const name of jsonFiles(inboxDir(boxDir))) {
155
+ const from = path.join(inboxDir(boxDir), name);
156
+ const to = path.join(processingDir(boxDir), name);
157
+ try {
158
+ fs.renameSync(from, to); // atomic claim
159
+ }
160
+ catch {
161
+ continue; // vanished — a racing drain took it
162
+ }
163
+ const msg = consumeClaimed(boxDir, name, boxId);
164
+ if (msg)
165
+ out.push(msg);
166
+ }
167
+ return out;
168
+ }
169
+ /** Read pending messages (inbox + in-flight) without consuming them. FIFO. */
170
+ export function peek(boxDir, boxId = path.basename(boxDir)) {
171
+ const out = [];
172
+ for (const dir of [processingDir(boxDir), inboxDir(boxDir)]) {
173
+ for (const name of jsonFiles(dir)) {
174
+ const msg = readMessage(path.join(dir, name));
175
+ if (msg && msg.to === boxId)
176
+ out.push(msg);
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+ /** Delete pending (not-yet-claimed) inbox messages. Returns the count removed. */
182
+ export function clear(boxDir) {
183
+ let n = 0;
184
+ for (const name of jsonFiles(inboxDir(boxDir))) {
185
+ try {
186
+ fs.unlinkSync(path.join(inboxDir(boxDir), name));
187
+ n++;
188
+ }
189
+ catch {
190
+ // already gone — ignore
191
+ }
192
+ }
193
+ return n;
194
+ }
package/dist/lib/mcp.d.ts CHANGED
@@ -39,6 +39,11 @@ export interface McpTargetOperationResult {
39
39
  * Parse an MCP server config from a YAML file.
40
40
  */
41
41
  export declare function parseMcpServerConfig(filePath: string): McpYamlConfig | null;
42
+ /**
43
+ * Validate an MCP server name. Rejects names that could be misinterpreted as
44
+ * command-line options or that contain characters unsafe for argv/identifier use.
45
+ */
46
+ export declare function validateMcpServerName(name: string): void;
42
47
  /**
43
48
  * List all MCP server configs from ~/.agents/mcp/.
44
49
  */