@phnx-labs/agents-cli 1.20.15 → 1.20.17

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 (49) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/commands/secrets.js +53 -1
  3. package/dist/commands/sessions-sync.d.ts +13 -0
  4. package/dist/commands/sessions-sync.js +73 -0
  5. package/dist/commands/sessions.js +2 -0
  6. package/dist/commands/sync.d.ts +10 -3
  7. package/dist/commands/sync.js +72 -9
  8. package/dist/commands/view.js +11 -3
  9. package/dist/index.js +1 -1
  10. package/dist/lib/agents.d.ts +11 -0
  11. package/dist/lib/agents.js +11 -9
  12. package/dist/lib/daemon.d.ts +19 -0
  13. package/dist/lib/daemon.js +97 -2
  14. package/dist/lib/hooks.js +12 -0
  15. package/dist/lib/migrate.d.ts +22 -0
  16. package/dist/lib/migrate.js +99 -1
  17. package/dist/lib/plugin-marketplace.d.ts +15 -0
  18. package/dist/lib/plugin-marketplace.js +54 -0
  19. package/dist/lib/secrets/drivers/rush.d.ts +14 -0
  20. package/dist/lib/secrets/drivers/rush.js +84 -0
  21. package/dist/lib/secrets/index.js +20 -0
  22. package/dist/lib/secrets/linux.js +88 -10
  23. package/dist/lib/secrets/sync-backend.d.ts +48 -0
  24. package/dist/lib/secrets/sync-backend.js +13 -0
  25. package/dist/lib/secrets/sync.d.ts +15 -23
  26. package/dist/lib/secrets/sync.js +31 -66
  27. package/dist/lib/session/parse.d.ts +2 -0
  28. package/dist/lib/session/parse.js +168 -2
  29. package/dist/lib/session/sync/agents.d.ts +46 -0
  30. package/dist/lib/session/sync/agents.js +94 -0
  31. package/dist/lib/session/sync/config.d.ts +30 -0
  32. package/dist/lib/session/sync/config.js +58 -0
  33. package/dist/lib/session/sync/crdt.d.ts +44 -0
  34. package/dist/lib/session/sync/crdt.js +119 -0
  35. package/dist/lib/session/sync/manifest.d.ts +51 -0
  36. package/dist/lib/session/sync/manifest.js +96 -0
  37. package/dist/lib/session/sync/r2.d.ts +32 -0
  38. package/dist/lib/session/sync/r2.js +121 -0
  39. package/dist/lib/session/sync/sync.d.ts +82 -0
  40. package/dist/lib/session/sync/sync.js +251 -0
  41. package/dist/lib/shims.d.ts +1 -1
  42. package/dist/lib/shims.js +17 -1
  43. package/dist/lib/sync-umbrella.d.ts +76 -0
  44. package/dist/lib/sync-umbrella.js +125 -0
  45. package/dist/lib/teams/parsers.js +159 -1
  46. package/dist/lib/usage.d.ts +18 -0
  47. package/dist/lib/usage.js +25 -0
  48. package/dist/lib/versions.js +30 -13
  49. package/package.json +2 -1
@@ -0,0 +1,82 @@
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 { type SyncAgentSpec } from './agents.js';
18
+ import { type ManifestEntry, type PullState } from './manifest.js';
19
+ export interface SyncResult {
20
+ machine: string;
21
+ pushed: number;
22
+ pushSkipped: number;
23
+ pulled: number;
24
+ merged: number;
25
+ pullSkipped: number;
26
+ errors: string[];
27
+ }
28
+ export interface SyncOptions {
29
+ verbose?: boolean;
30
+ log?: (msg: string) => void;
31
+ /** Upload this machine's transcripts (default true). */
32
+ push?: boolean;
33
+ /** Download + merge other machines' transcripts (default true). A machine
34
+ * with read-only R2 credentials can still pull. */
35
+ pull?: boolean;
36
+ }
37
+ export interface RemoteCopy {
38
+ machine: string;
39
+ entry: ManifestEntry;
40
+ }
41
+ export interface PendingSession {
42
+ agentId: string;
43
+ sessionId: string;
44
+ copies: RemoteCopy[];
45
+ /** Signature of source hashes — stored in pull state once applied. */
46
+ sig: string;
47
+ }
48
+ /**
49
+ * Decide which remote sessions need fetching this tick. Pure: no I/O.
50
+ * Skips sessions we hold locally (home wins) and sessions whose source set is
51
+ * unchanged since we last materialized them (pull-state hit → quiescence).
52
+ */
53
+ export declare function selectSessionsToFetch(copies: Map<string, Map<string, RemoteCopy[]>>, localIdsByAgent: Map<string, Set<string>>, pullState: PullState): PendingSession[];
54
+ /**
55
+ * Resolve the mirror destination + merged content for one session. Pure.
56
+ * The canonical path comes from the lexicographically-smallest machine so every
57
+ * puller derives an identical location; the content is the CRDT union of copies.
58
+ */
59
+ export declare function resolveMirrorWrite(spec: SyncAgentSpec, copies: RemoteCopy[], contents: string[]): {
60
+ dest: string;
61
+ content: string;
62
+ merged: boolean;
63
+ };
64
+ /**
65
+ * Decide how to reconcile one session's fetched copies. Pure: no I/O.
66
+ *
67
+ * `fetched` is positionally aligned to `copies` — a `null` slot means that
68
+ * copy's object wasn't retrievable this tick (R2 404 / LIST→GET consistency
69
+ * lag / a transient get error). If ANY listed copy is missing, returns `null`:
70
+ * the caller must then skip the mirror write AND skip stamping pull-state, so
71
+ * the session is retried next tick. Writing a partial set instead would
72
+ * materialize a non-converged union and — because pull-state would record the
73
+ * full source signature — abandon the missing branch forever (the bug this
74
+ * guards: a signature match in selectSessionsToFetch never re-fetches it).
75
+ */
76
+ export declare function reconcileCopies(spec: SyncAgentSpec, copies: RemoteCopy[], fetched: Array<string | null>): {
77
+ dest: string;
78
+ content: string;
79
+ merged: boolean;
80
+ } | null;
81
+ /** Run one full sync cycle: push this machine's changes, pull everyone else's. */
82
+ export declare function syncSessions(opts?: SyncOptions): Promise<SyncResult>;
@@ -0,0 +1,251 @@
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, SESSIONS_PREFIX, } from './agents.js';
22
+ import { mergeTranscripts, transcriptStats } from './crdt.js';
23
+ import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, } from './manifest.js';
24
+ const nowIso = () => new Date().toISOString();
25
+ function specById(id) {
26
+ return SYNC_AGENTS.find(s => s.id === id);
27
+ }
28
+ /** Upload this machine's changed transcripts and publish its manifest. */
29
+ async function pushOwn(r2, me, opts, result) {
30
+ const ledger = loadLedger();
31
+ const prev = loadLocalManifest();
32
+ const manifest = emptyManifest(me, nowIso());
33
+ for (const spec of SYNC_AGENTS) {
34
+ const agentManifest = {};
35
+ for (const t of listLocalTranscripts(spec)) {
36
+ let stat;
37
+ try {
38
+ stat = fs.statSync(t.absPath);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ const prevEntry = prev?.agents?.[spec.id]?.[t.sessionId];
44
+ if (prevEntry && ledgerUnchanged(ledger, t.absPath, stat.size, stat.mtimeMs)) {
45
+ agentManifest[t.sessionId] = prevEntry; // unchanged: reuse, no read, no upload
46
+ result.pushSkipped++;
47
+ continue;
48
+ }
49
+ let content;
50
+ try {
51
+ content = fs.readFileSync(t.absPath, 'utf-8');
52
+ }
53
+ catch {
54
+ continue;
55
+ }
56
+ const hash = hashContent(content);
57
+ const { lastTs } = transcriptStats(content);
58
+ const entry = { relKey: t.relKey, size: stat.size, hash, lastTs };
59
+ try {
60
+ await r2.put(objectKey(me, spec.id, t.sessionId), content, 'application/x-ndjson');
61
+ ledgerRecord(ledger, t.absPath, stat.size, stat.mtimeMs, hash);
62
+ agentManifest[t.sessionId] = entry;
63
+ result.pushed++;
64
+ if (opts.verbose)
65
+ opts.log?.(` push ${spec.id}/${t.sessionId.slice(0, 8)} (${stat.size}B)`);
66
+ }
67
+ catch (err) {
68
+ result.errors.push(`push ${spec.id}/${t.sessionId}: ${err.message}`);
69
+ }
70
+ }
71
+ if (Object.keys(agentManifest).length > 0)
72
+ manifest.agents[spec.id] = agentManifest;
73
+ }
74
+ try {
75
+ await r2.put(manifestKey(me), JSON.stringify(manifest), 'application/json');
76
+ saveLocalManifest(manifest);
77
+ saveLedger(ledger);
78
+ }
79
+ catch (err) {
80
+ result.errors.push(`manifest publish: ${err.message}`);
81
+ }
82
+ }
83
+ /**
84
+ * Decide which remote sessions need fetching this tick. Pure: no I/O.
85
+ * Skips sessions we hold locally (home wins) and sessions whose source set is
86
+ * unchanged since we last materialized them (pull-state hit → quiescence).
87
+ */
88
+ export function selectSessionsToFetch(copies, localIdsByAgent, pullState) {
89
+ const pending = [];
90
+ for (const [agentId, byAgent] of copies) {
91
+ const localIds = localIdsByAgent.get(agentId) ?? new Set();
92
+ for (const [sessionId, list] of byAgent) {
93
+ if (localIds.has(sessionId))
94
+ continue;
95
+ const sig = sourceSignature(list.map(c => c.entry.hash));
96
+ if (pullState[`${agentId}/${sessionId}`] === sig)
97
+ continue;
98
+ pending.push({ agentId, sessionId, copies: list, sig });
99
+ }
100
+ }
101
+ return pending;
102
+ }
103
+ /**
104
+ * Resolve the mirror destination + merged content for one session. Pure.
105
+ * The canonical path comes from the lexicographically-smallest machine so every
106
+ * puller derives an identical location; the content is the CRDT union of copies.
107
+ */
108
+ export function resolveMirrorWrite(spec, copies, contents) {
109
+ const canonical = [...copies].sort((a, b) => (a.machine < b.machine ? -1 : a.machine > b.machine ? 1 : 0))[0];
110
+ const content = contents.length === 1 ? contents[0] : mergeTranscripts(contents);
111
+ return {
112
+ dest: mirrorPath(spec, canonical.machine, canonical.entry.relKey),
113
+ content,
114
+ merged: contents.length > 1,
115
+ };
116
+ }
117
+ /**
118
+ * Decide how to reconcile one session's fetched copies. Pure: no I/O.
119
+ *
120
+ * `fetched` is positionally aligned to `copies` — a `null` slot means that
121
+ * copy's object wasn't retrievable this tick (R2 404 / LIST→GET consistency
122
+ * lag / a transient get error). If ANY listed copy is missing, returns `null`:
123
+ * the caller must then skip the mirror write AND skip stamping pull-state, so
124
+ * the session is retried next tick. Writing a partial set instead would
125
+ * materialize a non-converged union and — because pull-state would record the
126
+ * full source signature — abandon the missing branch forever (the bug this
127
+ * guards: a signature match in selectSessionsToFetch never re-fetches it).
128
+ */
129
+ export function reconcileCopies(spec, copies, fetched) {
130
+ const contents = [];
131
+ for (const text of fetched) {
132
+ if (text === null)
133
+ return null; // incomplete fetch — retry next tick
134
+ contents.push(text);
135
+ }
136
+ if (contents.length === 0)
137
+ return null;
138
+ return resolveMirrorWrite(spec, copies, contents);
139
+ }
140
+ /** Fetch other machines' manifests, union changed sessions into the mirror. */
141
+ async function pullAndReconcile(r2, me, opts, result) {
142
+ const prefixes = await r2.listPrefixes(SESSIONS_PREFIX); // sessions/<machine>/
143
+ const machines = prefixes
144
+ .map(p => p.slice(SESSIONS_PREFIX.length).replace(/\/$/, ''))
145
+ .filter(m => m && m !== me);
146
+ // agentId -> sessionId -> copies across machines
147
+ const copies = new Map();
148
+ for (const m of machines) {
149
+ let manifest = null;
150
+ try {
151
+ const text = await r2.get(manifestKey(m));
152
+ manifest = text ? parseManifest(text) : null;
153
+ }
154
+ catch (err) {
155
+ result.errors.push(`fetch manifest ${m}: ${err.message}`);
156
+ }
157
+ if (!manifest)
158
+ continue;
159
+ for (const [agentId, sessions] of Object.entries(manifest.agents)) {
160
+ if (!specById(agentId))
161
+ continue;
162
+ let byAgent = copies.get(agentId);
163
+ if (!byAgent)
164
+ copies.set(agentId, (byAgent = new Map()));
165
+ for (const [sessionId, entry] of Object.entries(sessions)) {
166
+ const list = byAgent.get(sessionId) ?? [];
167
+ list.push({ machine: m, entry });
168
+ byAgent.set(sessionId, list);
169
+ }
170
+ }
171
+ }
172
+ const pullState = loadPullState();
173
+ const localIdsByAgent = new Map();
174
+ for (const agentId of copies.keys()) {
175
+ const spec = specById(agentId);
176
+ if (spec)
177
+ localIdsByAgent.set(agentId, localSessionIds(spec));
178
+ }
179
+ const pending = selectSessionsToFetch(copies, localIdsByAgent, pullState);
180
+ let candidates = 0;
181
+ for (const byAgent of copies.values())
182
+ candidates += byAgent.size;
183
+ result.pullSkipped += candidates - pending.length; // local-owned or unchanged
184
+ for (const { agentId, sessionId, copies: list, sig } of pending) {
185
+ const spec = specById(agentId);
186
+ // Download each copy (could be a fork across >1 machine), keeping the
187
+ // result positionally aligned to `list` — null marks a copy we couldn't
188
+ // fetch this tick (404 / consistency lag / error).
189
+ const fetched = [];
190
+ for (const c of list) {
191
+ try {
192
+ fetched.push(await r2.get(objectKey(c.machine, agentId, sessionId)));
193
+ }
194
+ catch (err) {
195
+ result.errors.push(`get ${c.machine}/${sessionId}: ${err.message}`);
196
+ fetched.push(null);
197
+ }
198
+ }
199
+ // null ⇒ an incomplete fetch: skip the write AND the pull-state stamp so we
200
+ // retry next tick instead of persisting a partial union / abandoning a branch.
201
+ const resolved = reconcileCopies(spec, list, fetched);
202
+ if (!resolved)
203
+ continue;
204
+ const { dest, content, merged } = resolved;
205
+ try {
206
+ let existing = null;
207
+ try {
208
+ existing = fs.readFileSync(dest, 'utf-8');
209
+ }
210
+ catch { /* not present yet */ }
211
+ if (existing !== content) {
212
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
213
+ fs.writeFileSync(dest, content, 'utf-8');
214
+ if (merged)
215
+ result.merged++;
216
+ result.pulled++;
217
+ if (opts.verbose) {
218
+ opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)} <- ${list.map(c => c.machine).join('+')}`);
219
+ }
220
+ }
221
+ else {
222
+ result.pullSkipped++;
223
+ }
224
+ pullState[`${agentId}/${sessionId}`] = sig;
225
+ }
226
+ catch (err) {
227
+ result.errors.push(`write mirror ${sessionId}: ${err.message}`);
228
+ }
229
+ }
230
+ savePullState(pullState);
231
+ }
232
+ /** Run one full sync cycle: push this machine's changes, pull everyone else's. */
233
+ export async function syncSessions(opts = {}) {
234
+ const cfg = loadR2Config();
235
+ const me = machineId();
236
+ const r2 = new R2Client(cfg);
237
+ const result = {
238
+ machine: me,
239
+ pushed: 0,
240
+ pushSkipped: 0,
241
+ pulled: 0,
242
+ merged: 0,
243
+ pullSkipped: 0,
244
+ errors: [],
245
+ };
246
+ if (opts.push !== false)
247
+ await pushOwn(r2, me, opts, result);
248
+ if (opts.pull !== false)
249
+ await pullAndReconcile(r2, me, opts, result);
250
+ return result;
251
+ }
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 18;
80
+ export declare const SHIM_SCHEMA_VERSION = 19;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
package/dist/lib/shims.js CHANGED
@@ -202,7 +202,7 @@ async function promptConflictStrategy(conflictInfos) {
202
202
  * top-level entry add/remove — deep edits to plugin contents won't
203
203
  * trigger auto-resync, run `agents sync` for that.
204
204
  */
205
- export const SHIM_SCHEMA_VERSION = 18;
205
+ export const SHIM_SCHEMA_VERSION = 19;
206
206
  /** Internal marker string used to embed the schema version in shim scripts. */
207
207
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
208
208
  function shellQuote(value) {
@@ -414,6 +414,22 @@ elif [ "$AGENT" = "kimi" ]; then
414
414
  # Last resort: whatever is on PATH
415
415
  BINARY=$(command -v kimi 2>/dev/null || echo "")
416
416
  fi
417
+ # Droid (Factory AI) special case: the official installer drops a standalone
418
+ # native binary at ~/.local/bin/droid — there is no npm package and nothing
419
+ # lands in node_modules/.bin. Resolve the fixed install path directly. The
420
+ # PATH fallback explicitly refuses anything under our own shims dir: that path
421
+ # IS this dispatcher, so exec'ing it would re-enter and spin in an infinite
422
+ # re-exec loop (the bug this branch fixes).
423
+ elif [ "$AGENT" = "droid" ]; then
424
+ DROID_BINARY="$HOME/.local/bin/droid"
425
+ if [ -x "$DROID_BINARY" ]; then
426
+ BINARY="$DROID_BINARY"
427
+ else
428
+ BINARY=$(command -v droid 2>/dev/null || echo "")
429
+ case "$BINARY" in
430
+ "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
431
+ esac
432
+ fi
417
433
  else
418
434
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
419
435
  fi
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Umbrella `agents sync` orchestration — "make this machine current".
3
+ *
4
+ * Bare `agents sync` fetches remote state (config repos + secrets + sessions)
5
+ * then reconciles it into every installed agent's version home. Each stage is
6
+ * an existing exported library function; this module only sequences them and
7
+ * decides — from the flags — which stages run (`planUmbrellaStages`). The
8
+ * planner is pure so the flag matrix is unit-tested without any I/O.
9
+ *
10
+ * Stage backends:
11
+ * repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
12
+ * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
13
+ * cleanly when none is available — tokenized non-interactive auth
14
+ * arrives with `agents login`, #366/#367)
15
+ * sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
16
+ * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
17
+ */
18
+ /** The five umbrella flags off `agents sync`. */
19
+ export interface UmbrellaFlags {
20
+ repos?: boolean;
21
+ secrets?: boolean;
22
+ sessions?: boolean;
23
+ cloud?: boolean;
24
+ local?: boolean;
25
+ }
26
+ /** Which stages a given flag combination runs. */
27
+ export interface UmbrellaPlan {
28
+ fetchRepos: boolean;
29
+ fetchSecrets: boolean;
30
+ fetchSessions: boolean;
31
+ reconcile: boolean;
32
+ }
33
+ /**
34
+ * Decide which stages run. Pure — no I/O. Semantics:
35
+ * bare (no flags) fetch all three, then reconcile
36
+ * --local reconcile only, no fetch
37
+ * --cloud fetch (all, or the selected subset), skip reconcile
38
+ * --repos/--secrets/... fetch only the selected types, then reconcile
39
+ * `--local` wins over everything; `--cloud` suppresses reconcile.
40
+ */
41
+ export declare function planUmbrellaStages(f: UmbrellaFlags): UmbrellaPlan;
42
+ export interface UmbrellaResult {
43
+ plan: UmbrellaPlan;
44
+ repos?: {
45
+ pulled: number;
46
+ errors: string[];
47
+ };
48
+ secrets?: {
49
+ pulled: number;
50
+ skipped: boolean;
51
+ reason?: string;
52
+ errors: string[];
53
+ };
54
+ sessions?: {
55
+ ran: boolean;
56
+ pushed: number;
57
+ pulled: number;
58
+ merged: number;
59
+ };
60
+ reconciled: boolean;
61
+ }
62
+ export interface RunUmbrellaArgs {
63
+ flags: UmbrellaFlags;
64
+ /** Progress sink (already quiet-aware in the caller). */
65
+ log: (msg: string) => void;
66
+ /** Pass `skipPrompts` through to reconcile / non-interactive behavior. */
67
+ yes: boolean;
68
+ /** Secrets passphrase, if available (env var or prompt). Undefined => skip secrets. */
69
+ passphrase?: string;
70
+ }
71
+ /**
72
+ * Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
73
+ * A failure in one fetch stage is recorded and does not abort the others or the
74
+ * reconcile — `agents sync` should make as much current as it can in one pass.
75
+ */
76
+ export declare function runUmbrellaSync(args: RunUmbrellaArgs): Promise<UmbrellaResult>;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Umbrella `agents sync` orchestration — "make this machine current".
3
+ *
4
+ * Bare `agents sync` fetches remote state (config repos + secrets + sessions)
5
+ * then reconciles it into every installed agent's version home. Each stage is
6
+ * an existing exported library function; this module only sequences them and
7
+ * decides — from the flags — which stages run (`planUmbrellaStages`). The
8
+ * planner is pure so the flag matrix is unit-tested without any I/O.
9
+ *
10
+ * Stage backends:
11
+ * repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
12
+ * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
13
+ * cleanly when none is available — tokenized non-interactive auth
14
+ * arrives with `agents login`, #366/#367)
15
+ * sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
16
+ * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
17
+ */
18
+ import { pullRepo } from './git.js';
19
+ import { getUserAgentsDir, getEnabledExtraRepos } from './state.js';
20
+ import { listRemoteBundles, pullBundle } from './secrets/sync.js';
21
+ /**
22
+ * Decide which stages run. Pure — no I/O. Semantics:
23
+ * bare (no flags) fetch all three, then reconcile
24
+ * --local reconcile only, no fetch
25
+ * --cloud fetch (all, or the selected subset), skip reconcile
26
+ * --repos/--secrets/... fetch only the selected types, then reconcile
27
+ * `--local` wins over everything; `--cloud` suppresses reconcile.
28
+ */
29
+ export function planUmbrellaStages(f) {
30
+ if (f.local) {
31
+ return { fetchRepos: false, fetchSecrets: false, fetchSessions: false, reconcile: true };
32
+ }
33
+ const anySelector = !!(f.repos || f.secrets || f.sessions);
34
+ if (anySelector) {
35
+ return {
36
+ fetchRepos: !!f.repos,
37
+ fetchSecrets: !!f.secrets,
38
+ fetchSessions: !!f.sessions,
39
+ reconcile: !f.cloud,
40
+ };
41
+ }
42
+ // No per-type selector: bare = all + reconcile; --cloud = all, no reconcile.
43
+ return { fetchRepos: true, fetchSecrets: true, fetchSessions: true, reconcile: !f.cloud };
44
+ }
45
+ /**
46
+ * Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
47
+ * A failure in one fetch stage is recorded and does not abort the others or the
48
+ * reconcile — `agents sync` should make as much current as it can in one pass.
49
+ */
50
+ export async function runUmbrellaSync(args) {
51
+ const { flags, log, yes, passphrase } = args;
52
+ const plan = planUmbrellaStages(flags);
53
+ const result = { plan, reconciled: false };
54
+ if (plan.fetchRepos) {
55
+ const dirs = [
56
+ { alias: 'user', dir: getUserAgentsDir() },
57
+ ...getEnabledExtraRepos().map((e) => ({ alias: e.alias, dir: e.dir })),
58
+ ];
59
+ let pulled = 0;
60
+ const errors = [];
61
+ for (const { alias, dir } of dirs) {
62
+ const r = await pullRepo(dir);
63
+ if (r.success) {
64
+ pulled++;
65
+ log(`repos: ${alias} → ${r.commit}`);
66
+ }
67
+ else {
68
+ errors.push(`${alias}: ${r.error ?? 'unknown error'}`);
69
+ }
70
+ }
71
+ result.repos = { pulled, errors };
72
+ }
73
+ if (plan.fetchSecrets) {
74
+ if (!passphrase) {
75
+ result.secrets = {
76
+ pulled: 0,
77
+ skipped: true,
78
+ reason: 'no passphrase — set AGENTS_SECRETS_PASSPHRASE or run `agents login` (#366)',
79
+ errors: [],
80
+ };
81
+ log('secrets: skipped (no passphrase available)');
82
+ }
83
+ else {
84
+ let pulled = 0;
85
+ const errors = [];
86
+ try {
87
+ const remote = await listRemoteBundles();
88
+ for (const b of remote) {
89
+ try {
90
+ await pullBundle(b.name, { passphrase, force: true });
91
+ pulled++;
92
+ log(`secrets: ${b.name}`);
93
+ }
94
+ catch (err) {
95
+ errors.push(`${b.name}: ${err.message}`);
96
+ }
97
+ }
98
+ }
99
+ catch (err) {
100
+ errors.push(err.message);
101
+ }
102
+ result.secrets = { pulled, skipped: false, errors };
103
+ }
104
+ }
105
+ if (plan.fetchSessions) {
106
+ // Gate exactly like the daemon: a missing r2.backups bundle is a clean no-op,
107
+ // not an error that fails the whole sync.
108
+ const { isSyncConfigured } = await import('./session/sync/config.js');
109
+ if (isSyncConfigured()) {
110
+ const { syncSessions } = await import('./session/sync/sync.js');
111
+ const r = await syncSessions();
112
+ result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
113
+ log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
114
+ }
115
+ else {
116
+ result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0 };
117
+ }
118
+ }
119
+ if (plan.reconcile) {
120
+ const { refresh } = await import('./refresh.js');
121
+ await refresh({ skipPrompts: yes });
122
+ result.reconciled = true;
123
+ }
124
+ return result;
125
+ }