@inixiative/archive 0.12.1 → 0.14.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.
package/README.md CHANGED
@@ -33,7 +33,7 @@ archive list
33
33
  archive search --query 'migration'
34
34
  ```
35
35
 
36
- Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Repeat `--project-root` to map several checkouts to one project; `--worktrees` also accepts every current git worktree of each root, re-read on each scan. Nested checkouts are separate mappings. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The collector rescans files every 30 seconds; it deduplicates normalized content rather than maintaining byte-offset ingestion cursors. A watching collector waits out a server that is still starting.
36
+ Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Repeat `--project-root` to map several checkouts to one project; `--worktrees` also accepts every current git worktree of each root, re-read on each scan. Nested checkouts are separate mappings unless `--subdirectories` lets a root also take the directories below it; the longest matching root wins. `--catch-all` (no roots) takes every session no other collector of that source claims, as projectId `unrouted`; no destination may take `unrouted`, so those sessions never leave the machine. Subdirectory and catch-all matches skip the exclusions: temp directories (test fixtures, agent scratchpads) and `.foundry/decision-receipts` by default, plus each `--exclude` (absolute paths match below them, relative ones match those path segments anywhere); an exact root is explicit and still matches. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The collector rescans every 30 seconds. A session whose digest the server already holds is not sent again, and a watching collector does not re-read a file whose size and mtime have not moved since its last outcome (it re-checks a stored session against the server every 10 minutes, so one deleted there is captured again). A session is captured into one project; a collector refuses one another project holds, except one the catch-all holds as `unrouted`, which moves to the project that now claims it. Collectors compete only with collectors over the same history directory. A watching collector waits out a server that is still starting. `serve --sync` backs off failed deliveries: 30 seconds after the first failure, doubling to at most 30 minutes, cleared on delivery. An archive a destination refuses waits alone, and is tried again at once when it has a new revision; a destination that does not answer (or answers 5xx) is skipped for all its archives until it is due.
37
37
 
38
38
  To run the server without Docker, point `DATABASE_URL` at a Postgres with the `vector` and `pg_trgm` extensions available and run `archive serve [--port 4700] [--sync]`. Set `ARCHIVE_DEBUG=1` to see the underlying error when a command fails.
39
39
 
@@ -62,7 +62,7 @@ archive agents install
62
62
  archive agents status
63
63
  ```
64
64
 
65
- `add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees` and `--atlas` behave as for `collect`. `remove-collector --name N` and `server --url URL` edit the same file. `install` writes one unit per collector, `com.inixiative.archive.collect.<name>`. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared (including the retired `local` and `sync` units); other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. Re-run `install` after upgrading the package if its install path changes.
65
+ `add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees`, `--subdirectories`, `--catch-all` and `--atlas` behave as for `collect`. `remove-collector --name N`, `exclude [--exclude PATH ...]` (replaces the extra exclusions) and `server --url URL` edit the same file. `install` writes one unit per collector, `com.inixiative.archive.collect.<name>`, running `collect --agent <name> --watch`: the unit re-reads `agents.json` on every scan and routes each session against every collector of its source, so edits to roots and exclusions apply without a reinstall. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared. Units are written and loaded one at a time; a unit that fails to load gets its previous file back, so the next `install` loads it again, and `install` reports every failure after trying the rest (including the retired `local` and `sync` units); other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. Re-run `install` after upgrading the package if its install path changes.
66
66
 
67
67
  `@inixiative/archive/agents` exports the pure pieces for integrations: `agentsConfigSchema`, `agentUnits`, `renderPlist`, `renderSystemdUnit` and `planAgents`, plus `installAgents`, `uninstallAgents` and `agentStatus` with an injectable command runner and supervisor directory.
68
68
 
@@ -128,7 +128,7 @@ The archive owns its tag system. It keeps two different things apart:
128
128
  bun run archive tag --id ARCHIVE_ID --tag reviewed --untag debugging
129
129
  ```
130
130
 
131
- Tag edits apply to that archive without a new revision, survive re-capture, and do not sync. Offered tags are defined archive-wide or for one actor. Each session can name its **actor**: a `user`, a `service` such as an API-key run, or a `job` (its id is the job name), with the `integrationId` it ran under when there is one. An organization's archive mostly holds its members' work, filterable per person. Entries the model produced record its `model` and `effort` (from Claude Code assistant records and Codex turn context, per turn); listings summarize them per session as `models` and filter by `model` and `effort`.
131
+ Tag edits apply to that archive without a new revision, survive re-capture, and do not sync. Offered tags are defined archive-wide or for one actor. Each session can name its **actor**: a `user`, a `service` such as an API-key run, or a `job` (its id is the job name), with the `integrationId` it ran under when there is one. An organization's archive mostly holds its members' work, filterable per person. Entries the model produced record its `model` and `effort` (from Claude Code assistant records and Codex turn context, per turn); listings summarize them per session as `models` and filter by `model` and `effort`. Each session also records its `runtime`: `provider` (`anthropic` for Claude Code, Codex's `model_provider`) and `accessPath` (`subscription`, `api` or `unknown`), with `evidence` `transcript` when the transcript proves it (a subscription's rate-limit window: Claude Code `quotaLimits`, Codex `rate_limits.plan_type`) or `harness` when the collector read the harness login instead (Claude Code's `oauthAccount` unless its settings supply an API key; Codex `auth.json`). A subagent run is its own session with `lineage`: `kind` (`subagent` or `fork`), `parentSessionId`, and what the harness recorded (`agentType`, `agentName`, `description`, `toolUseId`, `depth`; Claude Code's `agent-<id>.meta.json`, Codex's subagent `session_meta`). Example: `models: [{model: 'claude-opus-5-5', effort: 'medium'}]`, `runtime: {provider: 'anthropic', accessPath: 'subscription', evidence: 'harness'}`.
132
132
 
133
133
  The **retention** setting (`retentionDays`, off by default) deletes archives whose latest capture is older than that, at server start and hourly. It deletes only from the archive it is set on; deletions never sync.
134
134
 
@@ -148,7 +148,7 @@ All data endpoints are `POST /api/v1/archive/<action>` with a JSON body and `Aut
148
148
  | `tags/list`, `tags/define`, `tags/remove` | `tag`, `actorId?`, `description?` | offered tags with usage counts |
149
149
  | `destinations/list`, `destinations/libraries`, `destinations/connect`, `destinations/remove`, `destinations/routes`, `destinations/sync` | see [Routing](#routing) | routes |
150
150
 
151
- Filters are `projectId`, `source`, `tag`, `actorId`, `model`, `effort` and `reference` (`{integration, ref}`). Jev integration is not enabled. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
151
+ Filters are `projectId`, `source`, `tag`, `actorId`, `model`, `effort`, `provider`, `accessPath` and `reference` (`{integration, ref}`); `archive list` and `archive search` take them as flags. Jev integration is not enabled. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
152
152
 
153
153
  ## Limits
154
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/archive",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@biomejs/biome": "2.5.2",
46
- "@inixiative/config": "^0.5.66",
46
+ "@inixiative/config": "^0.5.70",
47
47
  "@types/bun": "1.4.2",
48
48
  "lefthook": "2.1.9",
49
49
  "typescript": "6.0.3"
@@ -0,0 +1,10 @@
1
+ -- AlterTable
2
+ ALTER TABLE "archives" ADD COLUMN "accessPath" TEXT,
3
+ ADD COLUMN "provider" TEXT;
4
+
5
+ -- CreateIndex
6
+ CREATE INDEX "archives_provider_idx" ON "archives"("provider");
7
+
8
+ -- CreateIndex
9
+ CREATE INDEX "archives_accessPath_idx" ON "archives"("accessPath");
10
+
@@ -28,6 +28,9 @@ model Archive {
28
28
  source String
29
29
  projectId String?
30
30
  actorId String?
31
+ /// The model provider (anthropic, openai) and the access path (subscription, api, unknown).
32
+ provider String?
33
+ accessPath String?
31
34
  capturedAt Float
32
35
  summary Json
33
36
 
@@ -38,6 +41,8 @@ model Archive {
38
41
 
39
42
  @@index([projectId])
40
43
  @@index([actorId])
44
+ @@index([provider])
45
+ @@index([accessPath])
41
46
  @@map("archives")
42
47
  }
43
48
 
package/src/agents.ts CHANGED
@@ -17,6 +17,7 @@ import { homedir } from 'node:os';
17
17
  import { dirname, isAbsolute, join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { z } from 'zod';
20
+ import { type CollectionSource, defaultExclusions, UNROUTED } from './collector';
20
21
 
21
22
  export const defaultHome = () => join(homedir(), '.local/share/archive');
22
23
  export const AGENT_LABEL_PREFIX = 'com.inixiative.archive.';
@@ -25,22 +26,40 @@ export const defaultHistoryDirectory = (source: 'claude-code' | 'codex') =>
25
26
  join(homedir(), source === 'codex' ? '.codex/sessions' : '.claude/projects');
26
27
 
27
28
  const absolute = z.string().max(4096).refine(isAbsolute, 'Path must be absolute');
28
- export const collectorAgentSchema = z.strictObject({
29
- name: z
30
- .string()
31
- .max(64)
32
- .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/),
33
- source: z.enum(['claude-code', 'codex']),
34
- directory: absolute,
35
- projectId: z.string().min(1).max(256),
36
- projectRoots: z.array(absolute).min(1).max(100),
37
- worktrees: z.boolean().optional(),
38
- atlas: z.boolean().optional(),
39
- });
29
+ export const collectorAgentSchema = z
30
+ .strictObject({
31
+ name: z
32
+ .string()
33
+ .max(64)
34
+ .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/),
35
+ source: z.enum(['claude-code', 'codex']),
36
+ directory: absolute,
37
+ projectId: z.string().min(1).max(256),
38
+ projectRoots: z.array(absolute).max(100),
39
+ worktrees: z.boolean().optional(),
40
+ subdirectories: z.boolean().optional(),
41
+ /** Every session no other collector of this source claims; projectId `unrouted`, never synced. */
42
+ catchAll: z.boolean().optional(),
43
+ atlas: z.boolean().optional(),
44
+ })
45
+ .superRefine((c, ctx) => {
46
+ if (c.catchAll && (c.projectId !== UNROUTED || c.projectRoots.length))
47
+ ctx.addIssue({
48
+ code: 'custom',
49
+ message: `A catch-all collector has projectId ${UNROUTED} and no roots`,
50
+ });
51
+ if (!c.catchAll && (c.projectId === UNROUTED || !c.projectRoots.length))
52
+ ctx.addIssue({
53
+ code: 'custom',
54
+ message: `A collector needs a root and a projectId other than ${UNROUTED}`,
55
+ });
56
+ });
40
57
  export const agentsConfigSchema = z
41
58
  .strictObject({
42
59
  /** The Archive server collectors write to; the token is the home's `server.token`. */
43
60
  server: z.strictObject({ url: z.url() }).optional(),
61
+ /** Added to the default exclusions (temp directories, Foundry decision receipts). */
62
+ exclude: z.array(z.string().min(1).max(4096)).max(100).optional(),
44
63
  collectors: z.array(collectorAgentSchema).max(100),
45
64
  })
46
65
  .refine(
@@ -82,21 +101,36 @@ export interface AgentUnitOptions {
82
101
  }
83
102
  export const packageCliPath = () => fileURLToPath(new URL('./cli.ts', import.meta.url));
84
103
 
85
- export function collectorArgs(c: CollectorAgent, server?: { url: string }) {
86
- return [
87
- 'collect',
88
- ...(server ? ['--url', server.url] : []),
89
- '--directory',
90
- c.directory,
91
- '--source',
92
- c.source,
93
- ...c.projectRoots.flatMap((root) => ['--project-root', root]),
94
- ...(c.worktrees ? ['--worktrees'] : []),
95
- ...(c.atlas ? ['--atlas'] : []),
96
- '--project-id',
97
- c.projectId,
98
- '--watch',
99
- ];
104
+ /** A unit runs one named collector; it re-reads agents.json each scan, so edits apply live. */
105
+ export function collectorArgs(c: CollectorAgent) {
106
+ return ['collect', '--agent', c.name, '--watch'];
107
+ }
108
+
109
+ /** A named collector, the peers it is routed against, its exclusions and server, from agents.json. */
110
+ export function collectorPlan(config: AgentsConfig, name: string) {
111
+ const collector = config.collectors.find((c) => c.name === name);
112
+ if (!collector) throw new Error(`No collector named ${name} in agents.json`);
113
+ const source = (c: CollectorAgent): CollectionSource => ({
114
+ directory: c.directory,
115
+ source: c.source,
116
+ projectRoots: c.projectRoots,
117
+ projectId: c.projectId,
118
+ ...(c.worktrees ? { worktrees: true } : {}),
119
+ ...(c.subdirectories ? { subdirectories: true } : {}),
120
+ ...(c.catchAll ? { catchAll: true } : {}),
121
+ ...(c.atlas ? { atlas: true } : {}),
122
+ });
123
+ // Collectors over the same history compete for its sessions; another directory is another history.
124
+ const named = config.collectors.filter(
125
+ (c) => c.source === collector.source && resolve(c.directory) === resolve(collector.directory),
126
+ );
127
+ const peers = named.map(source);
128
+ return {
129
+ collector: peers[named.indexOf(collector)]!,
130
+ peers,
131
+ exclude: [...defaultExclusions(), ...(config.exclude ?? [])],
132
+ server: config.server,
133
+ };
100
134
  }
101
135
 
102
136
  /** One supervised unit per declared agent. */
@@ -114,7 +148,7 @@ export function agentUnits(config: AgentsConfig, options: AgentUnitOptions): Age
114
148
  stderr: join(home, 'logs', `${label}.err.log`),
115
149
  };
116
150
  };
117
- return config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c, config.server)));
151
+ return config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c)));
118
152
  }
119
153
 
120
154
  const xml = (value: string) =>
@@ -310,23 +344,49 @@ export function installAgents(config: AgentsConfig, options: AgentHostOptions =
310
344
  }));
311
345
  const plan = planAgents(units, installed, h.platform, h.dir);
312
346
  h.remove(plan.remove);
313
- for (const { file, content } of plan.write) {
347
+ const previous = new Map(installed.map((i) => [i.label, i.content]));
348
+ const write = (file: string, content: string) => {
314
349
  const temp = `${file}.${randomUUID()}.tmp`;
315
350
  writeFileSync(temp, content, { mode: 0o644, flag: 'wx' });
316
351
  renameSync(temp, file);
317
- }
318
- if (h.platform === 'linux' && plan.write.length) h.systemctl('daemon-reload');
352
+ };
353
+ const loaded: string[] = [];
354
+ const failed: { label: string; error: string }[] = [];
355
+ // Write and load one unit at a time. A unit that fails to load gets its old file back (or none),
356
+ // so the next install sees it as changed and loads it, instead of trusting a stale running copy.
357
+ const install = (label: string, content?: string) => {
358
+ try {
359
+ if (content !== undefined) {
360
+ write(h.file(label), content);
361
+ if (h.platform === 'linux') h.systemctl('daemon-reload');
362
+ }
363
+ h.load(label);
364
+ loaded.push(label);
365
+ } catch (error) {
366
+ failed.push({ label, error: error instanceof Error ? error.message : String(error) });
367
+ if (content === undefined) return;
368
+ const old = previous.get(label);
369
+ try {
370
+ if (old === undefined) rmSync(h.file(label), { force: true });
371
+ else write(h.file(label), old);
372
+ if (h.platform === 'linux') h.systemctl('daemon-reload');
373
+ // Loading stopped the unit that ran before; bring it back as it was.
374
+ if (old !== undefined) h.load(label);
375
+ } catch {}
376
+ }
377
+ };
378
+ for (const { label, content } of plan.write) install(label, content);
319
379
  // Unchanged units that are not running are loaded again.
320
- const reload = [
321
- ...plan.write.map((w) => w.label),
322
- ...plan.unchanged.filter((label) => !h.loaded(label).loaded),
323
- ];
324
- for (const label of reload) h.load(label);
380
+ for (const label of plan.unchanged) if (!h.loaded(label).loaded) install(label);
381
+ if (failed.length)
382
+ throw new Error(
383
+ `Archive agents failed to load: ${failed.map((f) => `${f.label} (${f.error})`).join('; ')}`,
384
+ );
325
385
  return {
326
386
  platform: h.platform,
327
387
  dir: h.dir,
328
- loaded: reload,
329
- unchanged: plan.unchanged.filter((l) => !reload.includes(l)),
388
+ loaded,
389
+ unchanged: plan.unchanged.filter((l) => !loaded.includes(l)),
330
390
  removed: plan.remove,
331
391
  };
332
392
  }
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { parseArgs } from 'node:util';
8
8
  import {
9
9
  agentStatus,
10
+ collectorPlan,
10
11
  defaultHistoryDirectory,
11
12
  defaultHome,
12
13
  installAgents,
@@ -16,8 +17,8 @@ import {
16
17
  writeAgents,
17
18
  } from './agents';
18
19
  import { importChatGPTThread } from './chatgpt';
19
- import { archiveRequest, searchRemotes, syncArchives } from './client';
20
- import { collectSessions } from './collector';
20
+ import { archiveRequest, type SyncBackoff, searchRemotes, syncArchives } from './client';
21
+ import { type CollectorCache, collectSessions, defaultExclusions, UNROUTED } from './collector';
21
22
  import {
22
23
  archiveDestinationSchema,
23
24
  connectDestination,
@@ -66,6 +67,14 @@ export async function runCli(args = Bun.argv.slice(2)) {
66
67
  query: { type: 'string', default: '' },
67
68
  'project-root': { type: 'string', multiple: true },
68
69
  worktrees: { type: 'boolean', default: false },
70
+ subdirectories: { type: 'boolean', default: false },
71
+ 'catch-all': { type: 'boolean', default: false },
72
+ exclude: { type: 'string', multiple: true },
73
+ agent: { type: 'string' },
74
+ provider: { type: 'string' },
75
+ model: { type: 'string' },
76
+ effort: { type: 'string' },
77
+ 'access-path': { type: 'string' },
69
78
  atlas: { type: 'boolean', default: false },
70
79
  tag: { type: 'string', multiple: true },
71
80
  untag: { type: 'string', multiple: true },
@@ -91,14 +100,16 @@ export async function runCli(args = Bun.argv.slice(2)) {
91
100
  'up | down (the local Archive: bundled compose.yaml with Postgres, data in --home)\n' +
92
101
  'serve [--port 4700 --hostname 127.0.0.1] [--sync] (DATABASE_URL; token from ARCHIVE_SERVER_TOKEN or --token-file)\n' +
93
102
  'import --file PATH --source codex|claude-code|chatgpt --project-id ID [--tag TAG]\n' +
94
- 'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
95
- 'list | export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...] | search --query TEXT [--remote]\n' +
103
+ 'collect --directory HISTORY --source codex|claude-code (--project-root CWD [--project-root ...] --project-id ID [--worktrees] [--subdirectories] | --catch-all) [--exclude PATH ...] [--atlas] [--watch]\n' +
104
+ 'collect --agent NAME [--watch] (one collector from agents.json, routed against the others, re-read each scan)\n' +
105
+ 'list | search --query TEXT [--remote] [--project-id ID] [--source S] [--tag T] [--provider P] [--model M] [--effort E] [--access-path subscription|api|unknown]\n' +
106
+ 'export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...]\n' +
96
107
  'pair --kingdom KINGDOM_API_URL [--name NAME] [--yes] (register this Archive with Kingdom; claim the review code there, then confirm the owner)\n' +
97
108
  'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) (a remote Archive, for serve --sync)\n' +
98
109
  'connect --kind kingdom --url KINGDOM_API_URL --credential-file PATH --integration-id UUID --resource-id UUID --project-id ID (a hosted Archive through Kingdom)\n' +
99
110
  'sync | routes (the server publishes to, or previews, its destinations once)\n' +
100
- 'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
101
- 'agents remove-collector --name N | server --url URL | install | uninstall | status\n' +
111
+ 'agents add-collector --name N --source codex|claude-code (--project-id ID --project-root DIR [--project-root ...] [--worktrees] [--subdirectories] | --catch-all) [--directory HISTORY] [--atlas]\n' +
112
+ 'agents remove-collector --name N | exclude [--exclude PATH ...] | server --url URL | install | uninstall | status\n' +
102
113
  `Data commands talk to the Archive server at --url (default ${DEFAULT_URL}) with the token in --token-file (default <home>/server.token) or ARCHIVE_TOKEN.`,
103
114
  );
104
115
  return;
@@ -170,10 +181,12 @@ export async function runCli(args = Bun.argv.slice(2)) {
170
181
  output({ listening: instance.server.url.href, sync: v.sync });
171
182
  let stopped = false;
172
183
  // One process owns the database connection: the server and its sync share it.
184
+ // Failed deliveries back off across cycles instead of being re-sent every 30 seconds.
185
+ const backoff: SyncBackoff = new Map();
173
186
  const sync = (async () => {
174
187
  while (v.sync && !stopped) {
175
188
  try {
176
- const results = await syncArchives(instance.store, readDestinations(config));
189
+ const results = await syncArchives(instance.store, readDestinations(config), { backoff });
177
190
  const failed = results.filter((r) => r.status.startsWith('failed'));
178
191
  if (failed.length) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
179
192
  } catch {
@@ -258,8 +271,11 @@ export async function runCli(args = Bun.argv.slice(2)) {
258
271
  const action = positionals[1];
259
272
  const base = { collectors: [], ...current };
260
273
  if (action === 'add-collector') {
261
- if (!v.name || !v['project-id'] || !v['project-root'] || !v.source)
262
- throw new Error('add-collector requires --name, --source, --project-id and --project-root');
274
+ const catchAll = v['catch-all'];
275
+ if (!v.name || !v.source || (!catchAll && (!v['project-id'] || !v['project-root'])))
276
+ throw new Error(
277
+ 'add-collector requires --name, --source, and --project-id with --project-root (or --catch-all)',
278
+ );
263
279
  const source = v.source as 'codex' | 'claude-code';
264
280
  output(
265
281
  writeAgents(
@@ -268,13 +284,18 @@ export async function runCli(args = Bun.argv.slice(2)) {
268
284
  name: v.name,
269
285
  source,
270
286
  directory: resolve(v.directory ?? defaultHistoryDirectory(source)),
271
- projectId: v['project-id'],
272
- projectRoots: v['project-root'].map((root) => resolve(root)),
287
+ projectId: catchAll ? UNROUTED : v['project-id']!,
288
+ projectRoots: catchAll ? [] : v['project-root']!.map((root) => resolve(root)),
273
289
  ...(v.worktrees ? { worktrees: true } : {}),
290
+ ...(v.subdirectories ? { subdirectories: true } : {}),
291
+ ...(catchAll ? { catchAll: true } : {}),
274
292
  ...(v.atlas ? { atlas: true } : {}),
275
293
  }),
276
294
  ),
277
295
  );
296
+ } else if (action === 'exclude') {
297
+ const { exclude: _, ...rest } = base;
298
+ output(writeAgents(file, { ...rest, ...(v.exclude?.length ? { exclude: v.exclude } : {}) }));
278
299
  } else if (action === 'remove-collector') {
279
300
  if (!v.name) throw new Error('remove-collector requires --name');
280
301
  output(
@@ -313,7 +334,21 @@ export async function runCli(args = Bun.argv.slice(2)) {
313
334
  }
314
335
  const token = process.env.ARCHIVE_TOKEN ?? readToken();
315
336
  if (!token) throw new Error('No Archive token: run `archive init` or set ARCHIVE_TOKEN');
337
+ const agentsFile = join(home, 'agents.json');
316
338
  const archive = new ArchiveClient({ url: v.url ?? DEFAULT_URL, token });
339
+ if ((command === 'list' || command === 'search') && (v.tag?.length ?? 0) > 1)
340
+ throw new Error('Filter by one --tag at a time');
341
+ const filter = {
342
+ ...(v['project-id'] ? { projectId: v['project-id'] } : {}),
343
+ ...(v.source ? { source: v.source as 'claude-code' | 'codex' | 'foundry' | 'chatgpt' } : {}),
344
+ ...(v.tag?.length ? { tag: v.tag[0] } : {}),
345
+ ...(v.provider ? { provider: v.provider } : {}),
346
+ ...(v.model ? { model: v.model } : {}),
347
+ ...(v.effort ? { effort: v.effort } : {}),
348
+ ...(v['access-path']
349
+ ? { accessPath: v['access-path'] as 'subscription' | 'api' | 'unknown' }
350
+ : {}),
351
+ };
317
352
  if (command === 'routes') output(await archive.routes());
318
353
  else if (command === 'sync') {
319
354
  const results = await archive.sync();
@@ -349,15 +384,48 @@ export async function runCli(args = Bun.argv.slice(2)) {
349
384
  entries: snapshot.entries.length,
350
385
  coverage: snapshot.coverage,
351
386
  });
352
- } else if (command === 'list') output(await archive.list());
387
+ } else if (command === 'list') output(await archive.list(filter));
353
388
  else if (command === 'collect') {
354
- if (
355
- !v.directory ||
356
- !v['project-root'] ||
357
- !v['project-id'] ||
358
- !['codex', 'claude-code'].includes(v.source ?? '')
359
- )
360
- throw new Error('Collect requires --directory, --source, --project-root and --project-id');
389
+ const flagged = () => {
390
+ const catchAll = v['catch-all'];
391
+ if (
392
+ !v.directory ||
393
+ !['codex', 'claude-code'].includes(v.source ?? '') ||
394
+ (!catchAll && (!v['project-root'] || !v['project-id']))
395
+ )
396
+ throw new Error(
397
+ 'Collect requires --agent, or --directory, --source and --project-root with --project-id (or --catch-all)',
398
+ );
399
+ const collector = {
400
+ directory: v.directory,
401
+ source: v.source as 'codex' | 'claude-code',
402
+ projectRoots: catchAll ? [] : v['project-root']!,
403
+ projectId: catchAll ? UNROUTED : v['project-id']!,
404
+ worktrees: v.worktrees,
405
+ subdirectories: v.subdirectories,
406
+ catchAll,
407
+ atlas: v.atlas,
408
+ tags: v.tag,
409
+ };
410
+ return {
411
+ collector,
412
+ peers: [collector],
413
+ exclude: [...defaultExclusions(), ...(v.exclude ?? [])],
414
+ server: undefined as { url: string } | undefined,
415
+ };
416
+ };
417
+ // A named collector re-reads agents.json each scan, so its routing follows edits without a restart.
418
+ const plan = () => {
419
+ if (!v.agent) return flagged();
420
+ const config = readAgents(agentsFile);
421
+ if (!config) throw new Error('No agents.json; declare agents first');
422
+ return collectorPlan(config, v.agent);
423
+ };
424
+ plan();
425
+ const cache: CollectorCache = new Map();
426
+ // A named collector writes to the server agents.json declares (unless --url), re-read each scan.
427
+ let client = archive;
428
+ let clientUrl = v.url ?? DEFAULT_URL;
361
429
  let stopped = false;
362
430
  const stop = () => {
363
431
  stopped = true;
@@ -365,17 +433,27 @@ export async function runCli(args = Bun.argv.slice(2)) {
365
433
  process.once('SIGINT', stop);
366
434
  process.once('SIGTERM', stop);
367
435
  do {
436
+ let current: ReturnType<typeof plan>;
437
+ try {
438
+ current = plan();
439
+ } catch (error) {
440
+ if (!v.watch) throw error;
441
+ console.error(
442
+ JSON.stringify({ collect: 'agents.json unusable', error: (error as Error).message }),
443
+ );
444
+ for (let i = 0; !stopped && i < 30; i++) await Bun.sleep(1000);
445
+ continue;
446
+ }
368
447
  try {
369
- const result = await collectSessions(archive, {
370
- directory: v.directory,
371
- source: v.source as 'codex' | 'claude-code',
372
- projectRoots: v['project-root'],
373
- worktrees: v.worktrees,
374
- atlas: v.atlas,
375
- projectId: v['project-id'],
376
- tags: v.tag,
377
- });
378
- output(result);
448
+ const { collector, peers, exclude, server } = current;
449
+ const url = v.url ?? server?.url ?? DEFAULT_URL;
450
+ if (url !== clientUrl) {
451
+ client = new ArchiveClient({ url, token });
452
+ clientUrl = url;
453
+ }
454
+ const started = Date.now();
455
+ const result = await collectSessions(client, collector, { peers, exclude, cache });
456
+ output({ ...result, ms: Date.now() - started });
379
457
  if (!v.watch && result.failed) process.exitCode = 1;
380
458
  } catch (error) {
381
459
  // The server may be starting; a watching collector retries.
@@ -390,6 +468,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
390
468
  } else if (command === 'search')
391
469
  output(
392
470
  await archive.search({
471
+ ...filter,
393
472
  query: v.query,
394
473
  limit: Number(v.limit) > 100 ? 100 : Number(v.limit),
395
474
  }),
package/src/client.ts CHANGED
@@ -126,28 +126,81 @@ export async function routingPreview(
126
126
  }));
127
127
  }
128
128
 
129
+ /**
130
+ * Deliveries that failed, and when each may be tried again: per archive and destination, and per
131
+ * destination while it is unreachable. A retry waits 30 seconds, doubling per failure up to 30
132
+ * minutes; a new revision of the archive is tried at once.
133
+ */
134
+ export type SyncBackoff = Map<string, { failures: number; until: number; digest?: string }>;
135
+ const BACKOFF_BASE_MS = 30_000;
136
+ const BACKOFF_MAX_MS = 30 * 60_000;
137
+ /** No HTTP answer, or a 5xx: the destination is down, not refusing this archive. */
138
+ const outage = (message: string) => {
139
+ const status = Number(/\((\d{3})\)/.exec(message)?.[1]);
140
+ return !status || status >= 500;
141
+ };
142
+
129
143
  export async function syncArchives(
130
144
  store: ArchiveStore,
131
145
  destinations: ArchiveDestination[],
146
+ options: { backoff?: SyncBackoff; now?: () => number } = {},
132
147
  ): Promise<SyncResult[]> {
148
+ const now = options.now ?? Date.now;
149
+ const backoff = options.backoff;
133
150
  const results: SyncResult[] = [];
134
- for (const archive of await store.list())
151
+ const seen = new Set<string>();
152
+ const delay = (failures: number) =>
153
+ Math.min(BACKOFF_BASE_MS * 2 ** (failures - 1), BACKOFF_MAX_MS);
154
+ const archives = await store.list();
155
+ for (const archive of archives)
135
156
  for (const destination of destinations.filter((d) => d.projectId === archive.projectId)) {
157
+ const identity = destinationIdentity(destination);
158
+ const key = JSON.stringify([archive.id, identity]);
159
+ const down = JSON.stringify(['destination', identity]);
160
+ seen.add(key).add(down);
161
+ const deferred = (until: number) =>
162
+ results.push({
163
+ id: archive.id,
164
+ destination: destination.url,
165
+ status: `deferred until ${new Date(until).toISOString()}`,
166
+ });
167
+ const circuit = backoff?.get(down);
168
+ if (circuit && now() < circuit.until) {
169
+ deferred(circuit.until);
170
+ continue;
171
+ }
172
+ const waiting = backoff?.get(key);
173
+ if (waiting && now() < waiting.until && waiting.digest === archive.digest) {
174
+ deferred(waiting.until);
175
+ continue;
176
+ }
136
177
  try {
137
178
  const result = await publishArchive(store, archive.id, destination);
179
+ backoff?.delete(key);
180
+ backoff?.delete(down);
138
181
  results.push({
139
182
  id: archive.id,
140
183
  destination: destination.url,
141
184
  status: result.unchanged ? 'unchanged' : 'published',
142
185
  });
143
186
  } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ if (outage(message)) {
189
+ const failures = (circuit?.failures ?? 0) + 1;
190
+ backoff?.set(down, { failures, until: now() + delay(failures) });
191
+ } else {
192
+ const failures = (waiting?.failures ?? 0) + 1;
193
+ backoff?.set(key, { failures, until: now() + delay(failures), digest: archive.digest });
194
+ }
144
195
  results.push({
145
196
  id: archive.id,
146
197
  destination: destination.url,
147
- status: `failed; local revision retained: ${error instanceof Error ? error.message : String(error)}`,
198
+ status: `failed; local revision retained: ${message}`,
148
199
  });
149
200
  }
150
201
  }
202
+ // Archives that were deleted, or routes that were removed, leave no entries behind.
203
+ for (const key of backoff?.keys() ?? []) if (!seen.has(key)) backoff?.delete(key);
151
204
  return results;
152
205
  }
153
206