@phnx-labs/agents-cli 1.20.93 → 1.21.0

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 (112) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/exec.js +13 -0
  7. package/dist/commands/mailboxes.js +39 -1
  8. package/dist/commands/message.js +12 -1
  9. package/dist/commands/packages.js +14 -5
  10. package/dist/commands/projects.d.ts +28 -0
  11. package/dist/commands/projects.js +173 -47
  12. package/dist/commands/routines.js +27 -1
  13. package/dist/commands/rules.js +6 -0
  14. package/dist/commands/sessions.d.ts +17 -0
  15. package/dist/commands/sessions.js +16 -8
  16. package/dist/commands/setup-browser.js +5 -1
  17. package/dist/commands/setup-fleet.js +5 -0
  18. package/dist/commands/setup-preferences.d.ts +53 -0
  19. package/dist/commands/setup-preferences.js +142 -0
  20. package/dist/commands/setup.js +8 -9
  21. package/dist/commands/ssh.js +220 -10
  22. package/dist/commands/sync.d.ts +2 -2
  23. package/dist/commands/sync.js +4 -9
  24. package/dist/commands/watchdog.js +26 -3
  25. package/dist/index.js +22 -6
  26. package/dist/lib/activity.d.ts +7 -0
  27. package/dist/lib/activity.js +19 -2
  28. package/dist/lib/auto-dispatch.d.ts +6 -1
  29. package/dist/lib/auto-dispatch.js +7 -2
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/daemon.d.ts +13 -0
  33. package/dist/lib/daemon.js +80 -60
  34. package/dist/lib/device-config.d.ts +82 -0
  35. package/dist/lib/device-config.js +296 -0
  36. package/dist/lib/event-provenance.d.ts +19 -0
  37. package/dist/lib/event-provenance.js +48 -0
  38. package/dist/lib/events.d.ts +2 -2
  39. package/dist/lib/events.js +4 -53
  40. package/dist/lib/feed.d.ts +1 -1
  41. package/dist/lib/feed.js +2 -0
  42. package/dist/lib/git.d.ts +13 -2
  43. package/dist/lib/git.js +38 -6
  44. package/dist/lib/linear-project-counts.d.ts +72 -5
  45. package/dist/lib/linear-project-counts.js +90 -6
  46. package/dist/lib/linear-projects.d.ts +12 -0
  47. package/dist/lib/linear-projects.js +30 -0
  48. package/dist/lib/mailbox-gc.js +30 -7
  49. package/dist/lib/mailbox.d.ts +14 -1
  50. package/dist/lib/mailbox.js +35 -3
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  54. package/dist/lib/menubar/install-menubar.js +35 -44
  55. package/dist/lib/migrate.js +1 -1
  56. package/dist/lib/models.js +21 -11
  57. package/dist/lib/overdue.d.ts +1 -1
  58. package/dist/lib/overdue.js +54 -19
  59. package/dist/lib/project-import.d.ts +96 -0
  60. package/dist/lib/project-import.js +171 -0
  61. package/dist/lib/remote-agents-json.d.ts +14 -1
  62. package/dist/lib/remote-agents-json.js +21 -3
  63. package/dist/lib/routines.d.ts +53 -0
  64. package/dist/lib/routines.js +105 -4
  65. package/dist/lib/rules/run-sync.d.ts +18 -0
  66. package/dist/lib/rules/run-sync.js +92 -0
  67. package/dist/lib/scheduler.js +12 -2
  68. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  70. package/dist/lib/secrets/index.d.ts +4 -5
  71. package/dist/lib/secrets/index.js +4 -5
  72. package/dist/lib/session/active.d.ts +16 -2
  73. package/dist/lib/session/active.js +8 -4
  74. package/dist/lib/session/bundle.js +5 -1
  75. package/dist/lib/session/remote-list.d.ts +1 -15
  76. package/dist/lib/session/remote-list.js +22 -111
  77. package/dist/lib/session/sync/config.d.ts +13 -5
  78. package/dist/lib/session/sync/config.js +21 -13
  79. package/dist/lib/startup/command-registry.d.ts +0 -2
  80. package/dist/lib/startup/command-registry.js +1 -5
  81. package/dist/lib/state.d.ts +7 -2
  82. package/dist/lib/state.js +85 -10
  83. package/dist/lib/sync-umbrella.d.ts +11 -23
  84. package/dist/lib/sync-umbrella.js +14 -45
  85. package/dist/lib/teams/agents.d.ts +35 -3
  86. package/dist/lib/teams/agents.js +52 -8
  87. package/dist/lib/teams/scheduler.d.ts +29 -2
  88. package/dist/lib/teams/scheduler.js +60 -16
  89. package/dist/lib/types.d.ts +17 -1
  90. package/package.json +1 -2
  91. package/dist/commands/drive.d.ts +0 -10
  92. package/dist/commands/drive.js +0 -183
  93. package/dist/commands/hq.d.ts +0 -2
  94. package/dist/commands/hq.js +0 -58
  95. package/dist/commands/sessions-sync.d.ts +0 -17
  96. package/dist/commands/sessions-sync.js +0 -135
  97. package/dist/commands/sync-provision.d.ts +0 -23
  98. package/dist/commands/sync-provision.js +0 -107
  99. package/dist/lib/drive-sync.d.ts +0 -45
  100. package/dist/lib/drive-sync.js +0 -238
  101. package/dist/lib/hq/floor.d.ts +0 -87
  102. package/dist/lib/hq/floor.js +0 -243
  103. package/dist/lib/session/sync/crdt.d.ts +0 -44
  104. package/dist/lib/session/sync/crdt.js +0 -119
  105. package/dist/lib/session/sync/manifest.d.ts +0 -62
  106. package/dist/lib/session/sync/manifest.js +0 -100
  107. package/dist/lib/session/sync/provision.d.ts +0 -49
  108. package/dist/lib/session/sync/provision.js +0 -91
  109. package/dist/lib/session/sync/r2.d.ts +0 -32
  110. package/dist/lib/session/sync/r2.js +0 -121
  111. package/dist/lib/session/sync/sync.d.ts +0 -106
  112. package/dist/lib/session/sync/sync.js +0 -374
@@ -1,374 +0,0 @@
1
- /**
2
- * Cross-machine session sync orchestration.
3
- *
4
- * PUSH this machine's transcripts (changed since last tick) to its own R2
5
- * prefix, then publish a manifest. Single-writer prefixes mean no two
6
- * machines ever write the same object — zero remote contention.
7
- *
8
- * PULL every other machine's manifest, fetch changed transcripts, CRDT-union
9
- * copies of the same session, and write the result into the local mirror
10
- * (a scan root). The existing scanner indexes it; sessions present in the
11
- * live home always win, so the mirror only fills in remote-origin sessions.
12
- *
13
- * Invariants:
14
- * - Live home transcripts are only ever READ + uploaded, never rewritten.
15
- * - Union is idempotent, so once sets match nothing is transferred (quiescence).
16
- */
17
- import * as fs from 'fs';
18
- import * as path from 'path';
19
- import { R2Client } from './r2.js';
20
- import { loadR2Config, machineId } from './config.js';
21
- import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, isMergeableFile, SESSIONS_PREFIX, } from './agents.js';
22
- import { mergeTranscripts, transcriptStats } from './crdt.js';
23
- import { resolveSyncEncKey, encryptTranscript, decryptTranscriptBody } from './transcript-crypto.js';
24
- import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, manifestEntries, } from './manifest.js';
25
- const nowIso = () => new Date().toISOString();
26
- function specById(id) {
27
- return SYNC_AGENTS.find(s => s.id === id);
28
- }
29
- /** Upload this machine's changed transcripts and publish its manifest. */
30
- async function pushOwn(r2, me, encKey, opts, result) {
31
- const ledger = loadLedger();
32
- const prev = loadLocalManifest();
33
- const manifest = emptyManifest(me, nowIso());
34
- // The transcript body is sealed client-side before it leaves the machine
35
- // (AES-256-GCM under the shared bundle key). Without a key we still upload —
36
- // the feature predates encryption — but flag it LOUDLY once per cycle so an
37
- // unencrypted upload never happens silently.
38
- if (!encKey) {
39
- result.warnings.push(`R2_SYNC_ENC_KEY not set in the r2.backups bundle — transcripts are uploaded UNENCRYPTED ` +
40
- `(readable by anyone with bucket access). Add the shared key to enable client-side encryption.`);
41
- }
42
- for (const spec of SYNC_AGENTS) {
43
- const agentManifest = {};
44
- for (const t of listLocalTranscripts(spec)) {
45
- // A session is one file (file-shaped) or many (dir-shaped). Push each file
46
- // that changed, reusing the prior manifest entry (keyed by relKey) for the
47
- // ones the ledger shows unchanged. Object keys nest under the session id for
48
- // dir-shaped agents and stay flat for file-shaped ones (unchanged keys).
49
- const prevVal = prev?.agents?.[spec.id]?.[t.sessionId];
50
- const prevByRel = new Map((prevVal ? manifestEntries(prevVal) : []).map(e => [e.relKey, e]));
51
- const entries = [];
52
- for (const f of t.files) {
53
- let stat;
54
- try {
55
- stat = fs.statSync(f.absPath);
56
- }
57
- catch {
58
- continue;
59
- }
60
- const prevEntry = prevByRel.get(f.relKey);
61
- if (prevEntry && ledgerUnchanged(ledger, f.absPath, stat.size, stat.mtimeMs)) {
62
- entries.push(prevEntry); // unchanged: reuse, no read, no upload
63
- result.pushSkipped++;
64
- continue;
65
- }
66
- let content;
67
- try {
68
- content = fs.readFileSync(f.absPath, 'utf-8');
69
- }
70
- catch {
71
- continue;
72
- }
73
- // Identity for CRDT merge is the PLAINTEXT hash (ciphertext is
74
- // non-deterministic), so hash + manifest are computed on cleartext; only
75
- // the stored object body is sealed.
76
- const hash = hashContent(content);
77
- const lastTs = deriveLastTs(spec, f.relKey, content, stat.mtimeMs);
78
- const entry = { relKey: f.relKey, size: stat.size, hash, lastTs };
79
- const body = encKey ? encryptTranscript(content, encKey) : content;
80
- const contentType = encKey ? 'application/json' : 'application/x-ndjson';
81
- try {
82
- await r2.put(objectKey(me, spec.id, t.sessionId, spec.dirShaped ? f.relKey : undefined), body, contentType);
83
- ledgerRecord(ledger, f.absPath, stat.size, stat.mtimeMs, hash);
84
- entries.push(entry);
85
- result.pushed++;
86
- const label = spec.dirShaped ? `${t.sessionId.slice(0, 8)}/${f.relKey}` : t.sessionId.slice(0, 8);
87
- if (opts.verbose)
88
- opts.log?.(` push ${spec.id}/${label} (${stat.size}B)`);
89
- }
90
- catch (err) {
91
- result.errors.push(`push ${spec.id}/${t.sessionId}/${f.relKey}: ${err.message}`);
92
- }
93
- }
94
- // File-shaped: store the single entry (byte-identical to the old format so
95
- // older CLIs read it unchanged). Dir-shaped: store the per-file array.
96
- if (entries.length > 0)
97
- agentManifest[t.sessionId] = spec.dirShaped ? entries : entries[0];
98
- }
99
- if (Object.keys(agentManifest).length > 0)
100
- manifest.agents[spec.id] = agentManifest;
101
- }
102
- try {
103
- await r2.put(manifestKey(me), JSON.stringify(manifest), 'application/json');
104
- saveLocalManifest(manifest);
105
- saveLedger(ledger);
106
- }
107
- catch (err) {
108
- result.errors.push(`manifest publish: ${err.message}`);
109
- }
110
- }
111
- /**
112
- * Decide which remote sessions need fetching this tick. Pure: no I/O.
113
- * Skips sessions we hold locally (home wins) and sessions whose source set is
114
- * unchanged since we last materialized them (pull-state hit → quiescence).
115
- */
116
- export function selectSessionsToFetch(copies, localIdsByAgent, pullState) {
117
- const pending = [];
118
- for (const [agentId, byAgent] of copies) {
119
- const localIds = localIdsByAgent.get(agentId) ?? new Set();
120
- for (const [sessionId, list] of byAgent) {
121
- if (localIds.has(sessionId))
122
- continue;
123
- const sig = sourceSignature(list.map(c => c.entry.hash));
124
- if (pullState[`${agentId}/${sessionId}`] === sig)
125
- continue;
126
- pending.push({ agentId, sessionId, copies: list, sig });
127
- }
128
- }
129
- return pending;
130
- }
131
- /**
132
- * Resolve the mirror destination + reconciled content for ONE file across its
133
- * copies (every copy here is the same file — same relKey — held by a different
134
- * machine). Pure.
135
- *
136
- * The canonical path comes from the lexicographically-smallest machine so every
137
- * puller derives an identical location. The content depends on the file's kind
138
- * (see `isMergeableFile`):
139
- * - append-only logs (a transcript `.jsonl`) take the CRDT G-Set union — every
140
- * machine converges to byte-identical output regardless of order.
141
- * - mutable blobs (Kimi `state.json`) can't be line-unioned without corruption,
142
- * so they resolve **last-writer-wins**: the copy with the latest event
143
- * timestamp, tie-broken by content hash so the pick is deterministic fleet-wide.
144
- */
145
- /**
146
- * The `lastTs` a manifest entry carries for one file. Append-only logs (a
147
- * conversation `.jsonl`) embed per-line event timestamps, so their recency is
148
- * the latest line timestamp (`transcriptStats`). Mutable blobs (Kimi
149
- * `state.json`, the per-tool `tasks/*.json` sidecars) carry no event timestamp —
150
- * their own `updatedAt`/`createdAt` fields are agent-specific and unreliable — so
151
- * their "last written" signal is the file mtime. Without this, `transcriptStats`
152
- * returns `''` for every blob and the last-writer-wins branch in
153
- * `resolveMirrorWrite` silently degrades to "highest-hash-wins", which can pick a
154
- * stale copy over the genuinely newer one.
155
- */
156
- export function deriveLastTs(spec, relKey, content, mtimeMs) {
157
- if (isMergeableFile(spec, relKey))
158
- return transcriptStats(content).lastTs;
159
- return new Date(mtimeMs).toISOString();
160
- }
161
- export function resolveMirrorWrite(spec, copies, contents) {
162
- const canonical = [...copies].sort((a, b) => (a.machine < b.machine ? -1 : a.machine > b.machine ? 1 : 0))[0];
163
- let content;
164
- if (contents.length === 1) {
165
- content = contents[0];
166
- }
167
- else if (isMergeableFile(spec, canonical.entry.relKey)) {
168
- content = mergeTranscripts(contents);
169
- }
170
- else {
171
- // Last-writer-wins: highest (lastTs, hash) among the copies.
172
- let win = 0;
173
- for (let i = 1; i < copies.length; i++) {
174
- const a = copies[i].entry, b = copies[win].entry;
175
- if (a.lastTs > b.lastTs || (a.lastTs === b.lastTs && a.hash > b.hash))
176
- win = i;
177
- }
178
- content = contents[win];
179
- }
180
- return {
181
- dest: mirrorPath(spec, canonical.machine, canonical.entry.relKey),
182
- content,
183
- // "merged" means an actual CRDT line-union happened — not a last-writer-wins
184
- // pick of one mutable blob over another (that discards a copy, it doesn't merge).
185
- merged: contents.length > 1 && isMergeableFile(spec, canonical.entry.relKey),
186
- };
187
- }
188
- /**
189
- * Decide how to reconcile one session's fetched copies. Pure: no I/O.
190
- *
191
- * `fetched` is positionally aligned to `copies` — a `null` slot means that
192
- * copy's object wasn't retrievable this tick (R2 404 / LIST→GET consistency
193
- * lag / a transient get error). If ANY listed copy is missing, returns `null`:
194
- * the caller must then skip the mirror write AND skip stamping pull-state, so
195
- * the session is retried next tick. Writing a partial set instead would
196
- * materialize a non-converged union and — because pull-state would record the
197
- * full source signature — abandon the missing branch forever (the bug this
198
- * guards: a signature match in selectSessionsToFetch never re-fetches it).
199
- */
200
- export function reconcileCopies(spec, copies, fetched) {
201
- const contents = [];
202
- for (const text of fetched) {
203
- if (text === null)
204
- return null; // incomplete fetch — retry next tick
205
- contents.push(text);
206
- }
207
- if (contents.length === 0)
208
- return null;
209
- return resolveMirrorWrite(spec, copies, contents);
210
- }
211
- /** Fetch other machines' manifests, union changed sessions into the mirror. */
212
- async function pullAndReconcile(r2, me, encKey, opts, result) {
213
- const prefixes = await r2.listPrefixes(SESSIONS_PREFIX); // sessions/<machine>/
214
- const machines = prefixes
215
- .map(p => p.slice(SESSIONS_PREFIX.length).replace(/\/$/, ''))
216
- .filter(m => m && m !== me);
217
- // agentId -> sessionId -> copies across machines
218
- const copies = new Map();
219
- for (const m of machines) {
220
- let manifest = null;
221
- try {
222
- const text = await r2.get(manifestKey(m));
223
- manifest = text ? parseManifest(text) : null;
224
- }
225
- catch (err) {
226
- result.errors.push(`fetch manifest ${m}: ${err.message}`);
227
- }
228
- if (!manifest)
229
- continue;
230
- for (const [agentId, sessions] of Object.entries(manifest.agents)) {
231
- if (!specById(agentId))
232
- continue;
233
- let byAgent = copies.get(agentId);
234
- if (!byAgent)
235
- copies.set(agentId, (byAgent = new Map()));
236
- for (const [sessionId, value] of Object.entries(sessions)) {
237
- const list = byAgent.get(sessionId) ?? [];
238
- // One RemoteCopy per (machine, file): file-shaped sessions contribute one,
239
- // dir-shaped ones contribute an entry per constituent file. `manifestEntries`
240
- // also normalizes a single-object entry written by an older CLI.
241
- for (const entry of manifestEntries(value))
242
- list.push({ machine: m, entry });
243
- byAgent.set(sessionId, list);
244
- }
245
- }
246
- }
247
- const pullState = loadPullState();
248
- const localIdsByAgent = new Map();
249
- for (const agentId of copies.keys()) {
250
- const spec = specById(agentId);
251
- if (spec)
252
- localIdsByAgent.set(agentId, localSessionIds(spec));
253
- }
254
- const pending = selectSessionsToFetch(copies, localIdsByAgent, pullState);
255
- let candidates = 0;
256
- for (const byAgent of copies.values())
257
- candidates += byAgent.size;
258
- result.pullSkipped += candidates - pending.length; // local-owned or unchanged
259
- for (const { agentId, sessionId, copies: list, sig } of pending) {
260
- const spec = specById(agentId);
261
- // A dir-shaped session spans several files; reconcile each file independently
262
- // (its copies across machines share one relKey). File-shaped sessions have a
263
- // single group, so this collapses to the original one-file path.
264
- const byRel = new Map();
265
- for (const c of list) {
266
- const g = byRel.get(c.entry.relKey);
267
- if (g)
268
- g.push(c);
269
- else
270
- byRel.set(c.entry.relKey, [c]);
271
- }
272
- // Resolve every file's write before touching disk. If ANY file's fetch is
273
- // incomplete (a copy 404s / consistency lag), abandon the WHOLE session this
274
- // tick — no writes, no pull-state stamp — so it retries intact next time
275
- // rather than materializing half a session and stamping it done.
276
- const writes = [];
277
- let incomplete = false;
278
- try {
279
- for (const group of byRel.values()) {
280
- const fetched = [];
281
- for (const c of group) {
282
- try {
283
- const body = await r2.get(objectKey(c.machine, agentId, sessionId, spec.dirShaped ? c.entry.relKey : undefined));
284
- // Decrypt before the body reaches the CRDT union — merge + mirror always
285
- // operate on plaintext. A legacy plaintext object passes through
286
- // untouched; an envelope without a key throws (surfaced below).
287
- fetched.push(body === null ? null : decryptTranscriptBody(body, encKey));
288
- }
289
- catch (err) {
290
- result.errors.push(`get ${c.machine}/${sessionId}/${c.entry.relKey}: ${err.message}`);
291
- fetched.push(null);
292
- }
293
- }
294
- const resolved = reconcileCopies(spec, group, fetched);
295
- if (!resolved) {
296
- incomplete = true;
297
- break;
298
- }
299
- writes.push(resolved);
300
- }
301
- }
302
- catch (err) {
303
- // `reconcileCopies` -> `mirrorPath` now rejects unsafe peer-controlled
304
- // machine/relKey (C1 containment). That rejection must stay scoped to this
305
- // one session: without this catch a single malicious/malformed manifest
306
- // entry would throw out of the whole `pending` loop, skip the
307
- // `savePullState` below, and re-throw every tick — a peer-triggered DoS on
308
- // everyone else's session-sync. Record it and skip; never stamp pull-state
309
- // for a rejected entry (so nothing marks the bad session "done").
310
- result.errors.push(`resolve mirror ${agentId}/${sessionId}: ${err.message}`);
311
- continue;
312
- }
313
- if (incomplete)
314
- continue; // retry next tick, session intact
315
- try {
316
- let changed = false;
317
- let mergedAny = false;
318
- for (const { dest, content, merged } of writes) {
319
- let existing = null;
320
- try {
321
- existing = fs.readFileSync(dest, 'utf-8');
322
- }
323
- catch { /* not present yet */ }
324
- if (existing !== content) {
325
- fs.mkdirSync(path.dirname(dest), { recursive: true });
326
- fs.writeFileSync(dest, content, 'utf-8');
327
- changed = true;
328
- if (merged)
329
- mergedAny = true;
330
- }
331
- }
332
- if (changed) {
333
- if (mergedAny)
334
- result.merged++;
335
- result.pulled++;
336
- if (opts.verbose) {
337
- const machines = [...new Set(list.map(c => c.machine))].join('+');
338
- const files = byRel.size > 1 ? ` (${byRel.size} files)` : '';
339
- opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)}${files} <- ${machines}`);
340
- }
341
- }
342
- else {
343
- result.pullSkipped++;
344
- }
345
- pullState[`${agentId}/${sessionId}`] = sig;
346
- }
347
- catch (err) {
348
- result.errors.push(`write mirror ${sessionId}: ${err.message}`);
349
- }
350
- }
351
- savePullState(pullState);
352
- }
353
- /** Run one full sync cycle: push this machine's changes, pull everyone else's. */
354
- export async function syncSessions(opts = {}) {
355
- const cfg = loadR2Config();
356
- const me = machineId();
357
- const r2 = new R2Client(cfg);
358
- const encKey = resolveSyncEncKey(cfg); // shared client-side transcript key, or null
359
- const result = {
360
- machine: me,
361
- pushed: 0,
362
- pushSkipped: 0,
363
- pulled: 0,
364
- merged: 0,
365
- pullSkipped: 0,
366
- errors: [],
367
- warnings: [],
368
- };
369
- if (opts.push !== false)
370
- await pushOwn(r2, me, encKey, opts, result);
371
- if (opts.pull !== false)
372
- await pullAndReconcile(r2, me, encKey, opts, result);
373
- return result;
374
- }