@phnx-labs/agents-cli 1.20.90 → 1.20.91

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 (65) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/feed.js +77 -4
  4. package/dist/commands/hooks.js +22 -6
  5. package/dist/commands/perf.d.ts +14 -0
  6. package/dist/commands/perf.js +221 -0
  7. package/dist/commands/routines.js +30 -24
  8. package/dist/commands/secrets.d.ts +43 -4
  9. package/dist/commands/secrets.js +217 -32
  10. package/dist/commands/send.d.ts +5 -1
  11. package/dist/commands/send.js +1 -1
  12. package/dist/commands/sessions-picker.js +70 -1
  13. package/dist/index.js +18 -3
  14. package/dist/lib/activity.d.ts +11 -1
  15. package/dist/lib/activity.js +1 -0
  16. package/dist/lib/catchup.d.ts +105 -0
  17. package/dist/lib/catchup.js +160 -0
  18. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  19. package/dist/lib/channels/providers/desktop.js +132 -0
  20. package/dist/lib/channels/providers/index.js +2 -0
  21. package/dist/lib/daemon.js +74 -13
  22. package/dist/lib/events.d.ts +12 -0
  23. package/dist/lib/events.js +122 -9
  24. package/dist/lib/exec.js +10 -0
  25. package/dist/lib/feed-broadcast.d.ts +47 -0
  26. package/dist/lib/feed-broadcast.js +65 -1
  27. package/dist/lib/feed-post.d.ts +10 -0
  28. package/dist/lib/feed-post.js +1 -1
  29. package/dist/lib/feed.d.ts +47 -1
  30. package/dist/lib/feed.js +38 -0
  31. package/dist/lib/hooks/cache.d.ts +2 -0
  32. package/dist/lib/hooks/cache.js +24 -4
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  34. package/dist/lib/overdue.d.ts +14 -0
  35. package/dist/lib/overdue.js +37 -1
  36. package/dist/lib/perf/db.d.ts +25 -0
  37. package/dist/lib/perf/db.js +290 -0
  38. package/dist/lib/perf/spool.d.ts +18 -0
  39. package/dist/lib/perf/spool.js +79 -0
  40. package/dist/lib/perf/types.d.ts +45 -0
  41. package/dist/lib/perf/types.js +2 -0
  42. package/dist/lib/routines-project.js +6 -0
  43. package/dist/lib/routines.d.ts +30 -1
  44. package/dist/lib/routines.js +11 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  47. package/dist/lib/secrets/list-filter.d.ts +94 -0
  48. package/dist/lib/secrets/list-filter.js +245 -0
  49. package/dist/lib/session/digest.d.ts +7 -0
  50. package/dist/lib/session/digest.js +29 -1
  51. package/dist/lib/session/discover.d.ts +1 -2
  52. package/dist/lib/session/discover.js +7 -24
  53. package/dist/lib/session/highlights.d.ts +82 -0
  54. package/dist/lib/session/highlights.js +251 -0
  55. package/dist/lib/session/parse.js +23 -1
  56. package/dist/lib/session/relative-time.d.ts +14 -0
  57. package/dist/lib/session/relative-time.js +36 -0
  58. package/dist/lib/session/render.d.ts +7 -0
  59. package/dist/lib/session/render.js +87 -17
  60. package/dist/lib/session/types.d.ts +4 -1
  61. package/dist/lib/startup/command-registry.d.ts +1 -0
  62. package/dist/lib/startup/command-registry.js +2 -0
  63. package/dist/lib/state.d.ts +9 -0
  64. package/dist/lib/state.js +11 -0
  65. package/package.json +3 -1
@@ -0,0 +1,45 @@
1
+ /** Shared perf sample shape (spool NDJSON + SQLite rows). */
2
+ export type PerfKind = 'hook.fire' | 'perf.timing' | 'command.end' | string;
3
+ export interface PerfSample {
4
+ tsMs?: number;
5
+ kind: PerfKind;
6
+ label: string;
7
+ durationMs: number;
8
+ /** Full session id — same string as sessions.id when known. */
9
+ sessionId?: string;
10
+ /** First 8 chars of sessionId (sessions.short_id shape). */
11
+ sessionShort?: string;
12
+ agent?: string;
13
+ agentVersion?: string;
14
+ /** Fleet registry name (sessions.machine), preferred over raw hostname. */
15
+ machine?: string;
16
+ hostname?: string;
17
+ actor?: string;
18
+ cwd?: string;
19
+ cache?: string;
20
+ exitCode?: number;
21
+ status?: string;
22
+ metaJson?: string;
23
+ }
24
+ export interface PerfAggregateRow {
25
+ kind: string;
26
+ label: string;
27
+ n: number;
28
+ p50Ms: number;
29
+ p99Ms: number;
30
+ meanMs: number;
31
+ maxMs: number;
32
+ minMs: number;
33
+ cacheHitPct?: number;
34
+ cacheStalePct?: number;
35
+ cacheMissPct?: number;
36
+ errorCount?: number;
37
+ }
38
+ export interface AggregateOptions {
39
+ days?: number;
40
+ kinds?: string[];
41
+ label?: string;
42
+ machine?: string;
43
+ agent?: string;
44
+ minN?: number;
45
+ }
@@ -0,0 +1,2 @@
1
+ /** Shared perf sample shape (spool NDJSON + SQLite rows). */
2
+ export {};
@@ -269,6 +269,12 @@ export function syncProjectRoutines(projectRoot) {
269
269
  if (job.devices === undefined && existing.devices && existing.devices.length > 0) {
270
270
  job.devices = existing.devices;
271
271
  }
272
+ // Carry the original creation stamp across. A sync rebuilds the config
273
+ // from the PROJECT yaml, which never carries `createdAt`, so without this
274
+ // every `agents routines sync` would re-stamp it to now — walking the
275
+ // overdue floor forward and hiding real missed fires for project routines.
276
+ if (existing.createdAt)
277
+ job.createdAt = existing.createdAt;
272
278
  }
273
279
  // Placement that leaves the firing machine must pin devices to avoid
274
280
  // every fleet daemon dispatching once.
@@ -122,6 +122,28 @@ export interface JobConfig {
122
122
  * overdue; everywhere else it is inert and `run` refuses with a pointer.
123
123
  */
124
124
  devices?: string[];
125
+ /**
126
+ * Whether a fire this device missed (daemon down, laptop asleep, wedged event
127
+ * loop) is run late. Defaults to true: croner only schedules forward from
128
+ * "now", so without catch-up a missed fire is simply lost and the routine
129
+ * silently does not run.
130
+ *
131
+ * Set `catchup: false` for a routine whose value is tied to its clock — a
132
+ * 9am standup brief is worthless at 3pm. An opted-out routine still records
133
+ * the miss (a `missed` run), it just is not re-run.
134
+ */
135
+ catchup?: boolean;
136
+ /**
137
+ * When this routine came into existence, ISO 8601. Stamped once by
138
+ * {@link writeJob}, like `actor`.
139
+ *
140
+ * Overdue detection needs it: `detectOverdueJobs` walks back a week for the
141
+ * most recent expected fire, so without a floor a brand-new routine is
142
+ * "overdue" for occurrences that happened before it was written. Harmless
143
+ * when catch-up was a manual command; with auto-catchup it would run every
144
+ * newly created routine once, immediately.
145
+ */
146
+ createdAt?: string;
125
147
  /**
126
148
  * Environment variables injected into the spawned run, on top of the sandbox
127
149
  * overlay's own. Merged by `buildSpawnEnv`, so it applies to both the
@@ -217,7 +239,14 @@ export interface RunMeta {
217
239
  pid: number | null;
218
240
  /** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
219
241
  spawnedAt?: number;
220
- status: 'running' | 'completed' | 'failed' | 'timeout';
242
+ /**
243
+ * `missed` is not an execution outcome — it is the record that a scheduled
244
+ * fire never happened (the daemon was down, asleep, or wedged when it came
245
+ * due). Without it a miss leaves no trace at all and the listing keeps
246
+ * showing the previous run's status as if it were current. Written by
247
+ * `claimMissedFire` (catchup.ts), never by the runner.
248
+ */
249
+ status: 'running' | 'completed' | 'failed' | 'timeout' | 'missed';
221
250
  startedAt: string;
222
251
  completedAt: string | null;
223
252
  exitCode: number | null;
@@ -274,6 +274,12 @@ export function writeJob(config) {
274
274
  // only a brand-new routine (no actor yet) gets the current resolver.
275
275
  if (!config.actor)
276
276
  config.actor = resolveActor().id;
277
+ // Stamped once, on first write, and preserved by every later edit (an edit
278
+ // re-writes a config loaded from disk, which already carries it). This is the
279
+ // floor overdue detection uses so a routine is never judged against fires
280
+ // that predate it.
281
+ if (!config.createdAt)
282
+ config.createdAt = new Date().toISOString();
277
283
  const jobsDir = getRoutinesDir();
278
284
  const ymlPath = safeJoin(jobsDir, config.name + '.yml');
279
285
  const yamlPath = safeJoin(jobsDir, config.name + '.yaml');
@@ -294,6 +300,8 @@ export function writeJob(config) {
294
300
  delete output.enabled;
295
301
  if (output.runOnce === false || output.runOnce === undefined)
296
302
  delete output.runOnce;
303
+ if (output.catchup === true || output.catchup === undefined)
304
+ delete output.catchup;
297
305
  const devArr = output.devices;
298
306
  if (!devArr || devArr.length === 0)
299
307
  delete output.devices;
@@ -515,6 +523,9 @@ export function validateJob(config) {
515
523
  }
516
524
  }
517
525
  }
526
+ if (config.catchup !== undefined && typeof config.catchup !== 'boolean') {
527
+ errors.push('catchup must be a boolean (false to skip running a missed fire late)');
528
+ }
518
529
  // Off-box placement without a devices pin fires on every fleet daemon and
519
530
  // each dispatches once (RUSH-1980). Enforce the pin at validation so hand
520
531
  // edits and devices --clear cannot re-open the hole.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Filters for `agents secrets list`.
3
+ *
4
+ * The listing had no filtering at all: `--host`/`--device` pick a machine and
5
+ * `--json` picks a format, but nothing selected over the bundles themselves. On
6
+ * a fleet with fifty-odd bundles the answerable questions — which ones read with
7
+ * no Touch ID at all, which hold a raw literal, which have already expired, what
8
+ * has not been touched in three months — meant piping the table through grep, or
9
+ * were simply unanswerable.
10
+ *
11
+ * Everything here is pure: parsing produces a `SecretsListFilter`, and
12
+ * `bundleMatchesFilter` is a predicate over a bundle plus the ambient facts it
13
+ * cannot derive itself (which bundles the broker currently holds, and the
14
+ * current time). That keeps the whole surface unit-testable without a keychain.
15
+ *
16
+ * Shape follows the `agents sessions` house style: comma-separated lists, an
17
+ * unknown value is a loud error naming the valid set (never a silent ignore),
18
+ * and every axis narrows independently so they AND-compose.
19
+ */
20
+ import { type SecretsBundle, type SecretsPolicy, type SecretsBackend, type SecretType } from './bundles.js';
21
+ /** Ref kinds a var's value can have, from `describeBundle`. */
22
+ export declare const REF_KINDS: readonly ["literal", "keychain", "env", "file", "exec"];
23
+ export type RefKind = typeof REF_KINDS[number];
24
+ /** Default window for `--expiring` with no argument — matches the EXPIRING column. */
25
+ export declare const DEFAULT_EXPIRING_DAYS = 30;
26
+ /** A parsed, validated filter. Every field is optional; absent ⇒ that axis does
27
+ * not narrow. All present axes must match (AND). */
28
+ export interface SecretsListFilter {
29
+ /** Case-insensitive substring over bundle name and description. */
30
+ query?: string;
31
+ policy?: SecretsPolicy[];
32
+ backend?: SecretsBackend[];
33
+ type?: SecretType[];
34
+ kind?: RefKind[];
35
+ /** true ⇒ only bundles the broker holds; false ⇒ only those it does not. */
36
+ held?: boolean;
37
+ /** Only bundles with at least one var whose `expires` is already past. */
38
+ expired?: boolean;
39
+ /** Only bundles with at least one var expiring within this many days. */
40
+ expiringDays?: number;
41
+ /** Only bundles whose `last_used` is older than this epoch-ms (or never used). */
42
+ unusedBefore?: number;
43
+ }
44
+ /** The raw option bag commander hands us. */
45
+ export interface SecretsListFilterOpts {
46
+ policy?: string;
47
+ backend?: string;
48
+ type?: string;
49
+ kind?: string;
50
+ held?: boolean;
51
+ notHeld?: boolean;
52
+ expired?: boolean;
53
+ expiring?: string | boolean;
54
+ unused?: string;
55
+ }
56
+ /**
57
+ * Validate one comma-separated enum list. An unknown value throws and names the
58
+ * whole valid set — a silent ignore would let `--policy hodl` quietly return
59
+ * every bundle, which reads as "nothing matches that" and is worse than an error.
60
+ * Values are lowercased, matching `parsePolicyOpt`'s handling of policy names.
61
+ */
62
+ export declare function parseEnumList<T extends string>(raw: string, flag: string, valid: readonly T[]): T[];
63
+ /** Build a validated filter from commander's option bag. Throws on bad input. */
64
+ export declare function parseListFilters(opts: SecretsListFilterOpts, query?: string): SecretsListFilter;
65
+ /** True when any axis is set — used to decide whether the empty state should
66
+ * explain itself rather than claim there are no bundles at all. */
67
+ export declare function filterIsActive(f: SecretsListFilter): boolean;
68
+ /** Expiry tallies for one bundle: how many vars are already past, and how many
69
+ * fall due within `withinDays`. */
70
+ export declare function bundleExpiry(b: SecretsBundle, now: number, withinDays?: number): {
71
+ expired: number;
72
+ soon: number;
73
+ };
74
+ /** Ambient facts a bundle can't answer about itself. */
75
+ export interface FilterContext {
76
+ /** Bundle name → hold expiry epoch-ms, from the broker. Empty off macOS. */
77
+ held: Map<string, number>;
78
+ now: number;
79
+ }
80
+ /** Does this bundle satisfy every set axis? Pure. */
81
+ export declare function bundleMatchesFilter(b: SecretsBundle, f: SecretsListFilter, ctx: FilterContext): boolean;
82
+ /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
83
+ export declare const SORT_FIELDS: readonly ["name", "used", "created", "updated", "expiry"];
84
+ export type SortField = typeof SORT_FIELDS[number];
85
+ export declare function parseSortField(raw: string | undefined): SortField;
86
+ /** Sort a copy. Time fields are most-recent-first (the useful direction for
87
+ * "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
88
+ * so the order is stable. */
89
+ export declare function sortBundles(bundles: SecretsBundle[], field: SortField): SecretsBundle[];
90
+ /** Human summary of the active filters, for the empty state. `sessions` only
91
+ * echoes --project/--all on a miss, which leaves you guessing which flag emptied
92
+ * the list; naming every active axis is the difference between "nothing matched"
93
+ * and knowing what to relax. */
94
+ export declare function describeFilter(f: SecretsListFilter): string;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Filters for `agents secrets list`.
3
+ *
4
+ * The listing had no filtering at all: `--host`/`--device` pick a machine and
5
+ * `--json` picks a format, but nothing selected over the bundles themselves. On
6
+ * a fleet with fifty-odd bundles the answerable questions — which ones read with
7
+ * no Touch ID at all, which hold a raw literal, which have already expired, what
8
+ * has not been touched in three months — meant piping the table through grep, or
9
+ * were simply unanswerable.
10
+ *
11
+ * Everything here is pure: parsing produces a `SecretsListFilter`, and
12
+ * `bundleMatchesFilter` is a predicate over a bundle plus the ambient facts it
13
+ * cannot derive itself (which bundles the broker currently holds, and the
14
+ * current time). That keeps the whole surface unit-testable without a keychain.
15
+ *
16
+ * Shape follows the `agents sessions` house style: comma-separated lists, an
17
+ * unknown value is a loud error naming the valid set (never a silent ignore),
18
+ * and every axis narrows independently so they AND-compose.
19
+ */
20
+ import { parseCommaSeparatedList } from '../../commands/utils.js';
21
+ // From the leaf module, NOT discover.js — the latter imports `../sqlite.js`, so
22
+ // reaching through it for one parser would pull node:sqlite into every `agents
23
+ // secrets` invocation and print Node's SQLite ExperimentalWarning on stderr.
24
+ import { parseTimeFilter } from '../session/relative-time.js';
25
+ import { describeBundle, bundlePolicy, SECRET_TYPES, } from './bundles.js';
26
+ /** Ref kinds a var's value can have, from `describeBundle`. */
27
+ export const REF_KINDS = ['literal', 'keychain', 'env', 'file', 'exec'];
28
+ const POLICIES = ['always', 'hold', 'never'];
29
+ const BACKENDS = ['keychain', 'file', 'vault'];
30
+ /** Default window for `--expiring` with no argument — matches the EXPIRING column. */
31
+ export const DEFAULT_EXPIRING_DAYS = 30;
32
+ /**
33
+ * Validate one comma-separated enum list. An unknown value throws and names the
34
+ * whole valid set — a silent ignore would let `--policy hodl` quietly return
35
+ * every bundle, which reads as "nothing matches that" and is worse than an error.
36
+ * Values are lowercased, matching `parsePolicyOpt`'s handling of policy names.
37
+ */
38
+ export function parseEnumList(raw, flag, valid) {
39
+ const parts = parseCommaSeparatedList(raw).map((s) => s.toLowerCase());
40
+ if (parts.length === 0) {
41
+ throw new Error(`${flag} requires at least one value. Valid values: ${valid.join(', ')}`);
42
+ }
43
+ for (const p of parts) {
44
+ if (!valid.includes(p)) {
45
+ throw new Error(`Invalid value "${p}" for ${flag}. Valid values: ${valid.join(', ')}`);
46
+ }
47
+ }
48
+ // De-dup so `--policy hold,hold` behaves like `--policy hold`.
49
+ return [...new Set(parts)];
50
+ }
51
+ /** Parse `--expiring` — a bare flag means the default window, a value means N days. */
52
+ function parseExpiringDays(raw) {
53
+ if (raw === undefined || raw === false)
54
+ return undefined;
55
+ if (raw === true || raw === '')
56
+ return DEFAULT_EXPIRING_DAYS;
57
+ const n = Number(raw);
58
+ // 0 is rejected rather than accepted as a no-op: the window is `0 <= d < N`,
59
+ // so `--expiring 0` can never match anything, not even a key expiring today.
60
+ // A flag that silently returns nothing is worse than one that says why.
61
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
62
+ throw new Error(`Invalid --expiring '${raw}'. Use a whole number of days >= 1, e.g. --expiring 7. ` +
63
+ 'For keys that have already lapsed, use --expired.');
64
+ }
65
+ return n;
66
+ }
67
+ /** Build a validated filter from commander's option bag. Throws on bad input. */
68
+ export function parseListFilters(opts, query) {
69
+ if (opts.held && opts.notHeld) {
70
+ throw new Error('--held and --not-held are mutually exclusive');
71
+ }
72
+ const filter = {};
73
+ const trimmedQuery = query?.trim();
74
+ if (trimmedQuery)
75
+ filter.query = trimmedQuery.toLowerCase();
76
+ if (opts.policy)
77
+ filter.policy = parseEnumList(opts.policy, '--policy', POLICIES);
78
+ if (opts.backend)
79
+ filter.backend = parseEnumList(opts.backend, '--backend', BACKENDS);
80
+ if (opts.type)
81
+ filter.type = parseEnumList(opts.type, '--type', SECRET_TYPES);
82
+ if (opts.kind)
83
+ filter.kind = parseEnumList(opts.kind, '--kind', REF_KINDS);
84
+ if (opts.held)
85
+ filter.held = true;
86
+ if (opts.notHeld)
87
+ filter.held = false;
88
+ if (opts.expired)
89
+ filter.expired = true;
90
+ const expiringDays = parseExpiringDays(opts.expiring);
91
+ if (expiringDays !== undefined)
92
+ filter.expiringDays = expiringDays;
93
+ if (opts.unused) {
94
+ // parseTimeFilter turns '90d' into "the epoch-ms 90 days ago"; a bundle is
95
+ // unused when its last_used predates that instant.
96
+ const cutoff = parseTimeFilter(opts.unused);
97
+ if (!cutoff) {
98
+ throw new Error(`Invalid --unused '${opts.unused}'. Use e.g. 30d, 4w, 3mo, or an ISO date.`);
99
+ }
100
+ filter.unusedBefore = cutoff;
101
+ }
102
+ return filter;
103
+ }
104
+ /** True when any axis is set — used to decide whether the empty state should
105
+ * explain itself rather than claim there are no bundles at all. */
106
+ export function filterIsActive(f) {
107
+ return Object.keys(f).length > 0;
108
+ }
109
+ /** Whole days from now until end-of-day UTC of an ISO date. Negative once past.
110
+ * Mirrors the `daysUntil` used by the human render so the filter and the column
111
+ * can never disagree about whether something has expired. */
112
+ function daysUntil(iso, now) {
113
+ const target = new Date(`${iso}T23:59:59Z`).getTime();
114
+ return Math.floor((target - now) / (24 * 60 * 60 * 1000));
115
+ }
116
+ /** Expiry tallies for one bundle: how many vars are already past, and how many
117
+ * fall due within `withinDays`. */
118
+ export function bundleExpiry(b, now, withinDays = DEFAULT_EXPIRING_DAYS) {
119
+ let expired = 0;
120
+ let soon = 0;
121
+ for (const m of Object.values(b.meta ?? {})) {
122
+ if (!m.expires)
123
+ continue;
124
+ const d = daysUntil(m.expires, now);
125
+ if (d < 0)
126
+ expired++;
127
+ else if (d < withinDays)
128
+ soon++;
129
+ }
130
+ return { expired, soon };
131
+ }
132
+ /** Does this bundle satisfy every set axis? Pure. */
133
+ export function bundleMatchesFilter(b, f, ctx) {
134
+ if (f.query) {
135
+ const haystack = `${b.name} ${b.description ?? ''}`.toLowerCase();
136
+ if (!haystack.includes(f.query))
137
+ return false;
138
+ }
139
+ if (f.policy && !f.policy.includes(bundlePolicy(b)))
140
+ return false;
141
+ if (f.backend && !f.backend.includes(b.backend ?? 'keychain'))
142
+ return false;
143
+ if (f.held !== undefined) {
144
+ // A lapsed entry is not held — same liveness rule the POLICY column uses.
145
+ const exp = ctx.held.get(b.name);
146
+ const isHeld = exp !== undefined && exp > ctx.now;
147
+ if (isHeld !== f.held)
148
+ return false;
149
+ }
150
+ if (f.type) {
151
+ const types = Object.values(b.meta ?? {}).map((m) => m.type).filter(Boolean);
152
+ if (!types.some((t) => f.type.includes(t)))
153
+ return false;
154
+ }
155
+ if (f.kind) {
156
+ const kinds = describeBundle(b).map((e) => e.kind);
157
+ if (!kinds.some((k) => f.kind.includes(k)))
158
+ return false;
159
+ }
160
+ if (f.expired || f.expiringDays !== undefined) {
161
+ const { expired, soon } = bundleExpiry(b, ctx.now, f.expiringDays ?? DEFAULT_EXPIRING_DAYS);
162
+ if (f.expired && expired === 0)
163
+ return false;
164
+ if (f.expiringDays !== undefined && soon === 0)
165
+ return false;
166
+ }
167
+ if (f.unusedBefore !== undefined) {
168
+ // Never used counts as unused — it is the strongest form of the answer.
169
+ if (b.last_used && new Date(b.last_used).getTime() >= f.unusedBefore)
170
+ return false;
171
+ }
172
+ return true;
173
+ }
174
+ /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
175
+ export const SORT_FIELDS = ['name', 'used', 'created', 'updated', 'expiry'];
176
+ export function parseSortField(raw) {
177
+ if (!raw)
178
+ return 'name';
179
+ const v = raw.toLowerCase();
180
+ if (!SORT_FIELDS.includes(v)) {
181
+ throw new Error(`Invalid --sort '${raw}'. Valid values: ${SORT_FIELDS.join(', ')}`);
182
+ }
183
+ return v;
184
+ }
185
+ /** Epoch-ms of a bundle's soonest expiry, or Infinity when nothing expires — so
186
+ * `--sort expiry` puts the most urgent first and never-expiring bundles last. */
187
+ function soonestExpiry(b) {
188
+ let soonest = Infinity;
189
+ for (const m of Object.values(b.meta ?? {})) {
190
+ if (!m.expires)
191
+ continue;
192
+ const t = new Date(`${m.expires}T23:59:59Z`).getTime();
193
+ if (t < soonest)
194
+ soonest = t;
195
+ }
196
+ return soonest;
197
+ }
198
+ /** Sort a copy. Time fields are most-recent-first (the useful direction for
199
+ * "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
200
+ * so the order is stable. */
201
+ export function sortBundles(bundles, field) {
202
+ const stamp = (iso) => (iso ? new Date(iso).getTime() : 0);
203
+ const byName = (a, z) => a.name.localeCompare(z.name);
204
+ const out = [...bundles];
205
+ if (field === 'name')
206
+ return out.sort(byName);
207
+ out.sort((a, z) => {
208
+ if (field === 'expiry') {
209
+ const d = soonestExpiry(a) - soonestExpiry(z);
210
+ return d !== 0 ? d : byName(a, z);
211
+ }
212
+ const key = field === 'used' ? 'last_used' : field === 'created' ? 'created_at' : 'updated_at';
213
+ const d = stamp(z[key]) - stamp(a[key]);
214
+ return d !== 0 ? d : byName(a, z);
215
+ });
216
+ return out;
217
+ }
218
+ /** Human summary of the active filters, for the empty state. `sessions` only
219
+ * echoes --project/--all on a miss, which leaves you guessing which flag emptied
220
+ * the list; naming every active axis is the difference between "nothing matched"
221
+ * and knowing what to relax. */
222
+ export function describeFilter(f) {
223
+ const parts = [];
224
+ if (f.query)
225
+ parts.push(`matching "${f.query}"`);
226
+ if (f.policy)
227
+ parts.push(`policy ${f.policy.join('/')}`);
228
+ if (f.backend)
229
+ parts.push(`backend ${f.backend.join('/')}`);
230
+ if (f.type)
231
+ parts.push(`type ${f.type.join('/')}`);
232
+ if (f.kind)
233
+ parts.push(`kind ${f.kind.join('/')}`);
234
+ if (f.held === true)
235
+ parts.push('currently held');
236
+ if (f.held === false)
237
+ parts.push('not currently held');
238
+ if (f.expired)
239
+ parts.push('with an expired key');
240
+ if (f.expiringDays !== undefined)
241
+ parts.push(`expiring within ${f.expiringDays}d`);
242
+ if (f.unusedBefore !== undefined)
243
+ parts.push('unused since the given cutoff');
244
+ return parts.join(', ');
245
+ }
@@ -13,6 +13,13 @@ export interface FileChange {
13
13
  path: string;
14
14
  op: FileOp;
15
15
  }
16
+ /**
17
+ * Path-shaped noise that must never surface as a session "change": shell
18
+ * redirect tokens (`2>&1`), unexpanded env-var prefixes (`$WT/...`), dependency
19
+ * dirs (`node_modules`), and agents-cli's internal `.system` marker. Filtered at
20
+ * the source so every consumer (summary, picker, digest counts) agrees.
21
+ */
22
+ export declare function isNoisePath(p: string): boolean;
16
23
  /** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
17
24
  export declare function extractDeletedPaths(command: string): string[];
18
25
  /**
@@ -12,6 +12,29 @@
12
12
  const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
13
13
  const WRITE_TOOLS = new Set(['Write', 'write_file', 'create_file']);
14
14
  const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'replace', 'patch', 'MultiEdit', 'apply_patch']);
15
+ /**
16
+ * Path-shaped noise that must never surface as a session "change": shell
17
+ * redirect tokens (`2>&1`), unexpanded env-var prefixes (`$WT/...`), dependency
18
+ * dirs (`node_modules`), and agents-cli's internal `.system` marker. Filtered at
19
+ * the source so every consumer (summary, picker, digest counts) agrees.
20
+ */
21
+ export function isNoisePath(p) {
22
+ if (!p)
23
+ return true;
24
+ if (/^\d+>[&/]?/.test(p))
25
+ return true; // 2>&1, 1>&2, 2>/dev/null
26
+ if (p.includes('>&'))
27
+ return true;
28
+ if (p.startsWith('$') || p.startsWith('"$') || p.startsWith("'$"))
29
+ return true; // unexpanded $VAR
30
+ if (/(^|\/)node_modules(\/|$)/.test(p))
31
+ return true;
32
+ if (/(^|\/)\.system$/.test(p))
33
+ return true;
34
+ if (p.includes('/.agents/.history/'))
35
+ return true; // agents-cli internal archives
36
+ return false;
37
+ }
15
38
  /** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
16
39
  export function extractDeletedPaths(command) {
17
40
  const out = [];
@@ -25,7 +48,10 @@ export function extractDeletedPaths(command) {
25
48
  continue; // flags (-r, -f, --force)
26
49
  if (/[*?{}]/.test(tok))
27
50
  continue; // globs — too imprecise to attribute
28
- out.push(tok.replace(/^['"]|['"]$/g, '')); // unquote
51
+ const p = tok.replace(/^['"]|['"]$/g, ''); // unquote
52
+ if (isNoisePath(p))
53
+ continue;
54
+ out.push(p);
29
55
  }
30
56
  }
31
57
  return out;
@@ -57,6 +83,8 @@ export function classifyFileChanges(events) {
57
83
  continue;
58
84
  if (p.includes('.claude/plans/') && p.endsWith('.md'))
59
85
  continue;
86
+ if (isNoisePath(p))
87
+ continue;
60
88
  if (READ_TOOLS.has(tool)) {
61
89
  readBefore.add(p);
62
90
  }
@@ -674,5 +674,4 @@ export declare function readGrokMeta(filePath: string, currentVersion?: string):
674
674
  meta: SessionMeta;
675
675
  content: string;
676
676
  } | null;
677
- /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
678
- export declare function parseTimeFilter(input: string): number;
677
+ export { parseTimeFilter } from './relative-time.js';
@@ -16,6 +16,7 @@ import { promisify } from 'util';
16
16
  import Database from '../sqlite.js';
17
17
  import { getAgentsDir, getUserAgentsDir, getHistoryDir, getRunsDir } from '../state.js';
18
18
  import { shortCodexHome } from '../codex-home.js';
19
+ import { parseTimeFilter } from './relative-time.js';
19
20
  const execFileAsync = promisify(execFile);
20
21
  import { AGENTS, agentConfigDirName, getCliVersion } from '../agents.js';
21
22
  import { walkForFilesWithStat } from '../fs-walk.js';
@@ -4117,27 +4118,9 @@ export function readGrokMeta(filePath, currentVersion) {
4117
4118
  };
4118
4119
  return { meta, content: topic || '' };
4119
4120
  }
4120
- /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
4121
- export function parseTimeFilter(input) {
4122
- // Units: m=minute, h=hour, d=day, w=week, mo=month(30d), y=year(365d). `mo`
4123
- // must precede the single-letter alternatives so "1mo" isn't read as "1m"+"o".
4124
- const relativeMatch = input.match(/^(\d+)(mo|[mhdwy])$/i);
4125
- if (relativeMatch) {
4126
- const value = parseInt(relativeMatch[1], 10);
4127
- const unit = relativeMatch[2].toLowerCase();
4128
- if (unit === 'm')
4129
- return Date.now() - value * 60_000;
4130
- if (unit === 'h')
4131
- return Date.now() - value * 3_600_000;
4132
- if (unit === 'd')
4133
- return Date.now() - value * 86_400_000;
4134
- if (unit === 'w')
4135
- return Date.now() - value * 7 * 86_400_000;
4136
- if (unit === 'mo')
4137
- return Date.now() - value * 30 * 86_400_000;
4138
- if (unit === 'y')
4139
- return Date.now() - value * 365 * 86_400_000;
4140
- }
4141
- const ts = new Date(input).getTime();
4142
- return Number.isNaN(ts) ? 0 : ts;
4143
- }
4121
+ // parseTimeFilter moved to ./relative-time.js a leaf module so a caller that
4122
+ // only needs the duration parser does not pull this file's `../sqlite.js` import
4123
+ // (and Node's SQLite ExperimentalWarning on stderr) into its module graph.
4124
+ // Re-exported so every existing importer keeps working unchanged. Imported too,
4125
+ // because a bare re-export does not bind the name for this file's own callers.
4126
+ export { parseTimeFilter } from './relative-time.js';