@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.
- package/CHANGELOG.md +121 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.js +70 -1
- package/dist/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/digest.d.ts +7 -0
- package/dist/lib/session/digest.js +29 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/render.d.ts +7 -0
- package/dist/lib/session/render.js +87 -17
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +11 -0
- 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
|
+
}
|
|
@@ -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.
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/lib/routines.js
CHANGED
|
@@ -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.
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
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';
|