@phnx-labs/agents-cli 1.20.92 → 1.20.93
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/events.js +91 -1
- package/dist/commands/projects.d.ts +10 -0
- package/dist/commands/projects.js +189 -8
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sync.js +9 -3
- package/dist/commands/view.js +4 -0
- package/dist/index.js +16 -0
- package/dist/lib/activity.d.ts +8 -0
- package/dist/lib/activity.js +7 -0
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +15 -3
- package/dist/lib/events.js +55 -3
- package/dist/lib/linear-project-counts.d.ts +62 -0
- package/dist/lib/linear-project-counts.js +122 -0
- package/dist/lib/linear-projects.d.ts +50 -0
- package/dist/lib/linear-projects.js +114 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-status.d.ts +32 -1
- package/dist/lib/project-status.js +82 -1
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/run-notify.js +3 -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/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -0
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/state.d.ts +12 -0
- package/dist/lib/state.js +14 -0
- package/dist/lib/types.d.ts +5 -4
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +1 -1
|
@@ -127,9 +127,37 @@ function record(kind, name, relPaths, result, manifestPaths) {
|
|
|
127
127
|
manifestPaths.add(toPosixRel(rel));
|
|
128
128
|
}
|
|
129
129
|
function skip(dest, projectRoot, result) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
result.skipped.push(path.relative(projectRoot, dest));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One human line for the files a project sync left alone because you already
|
|
134
|
+
* wrote them. This is the normal steady state — every sync of a project whose
|
|
135
|
+
* `.claude/commands/` you hand-authored hits it — so it is a single grouped
|
|
136
|
+
* line, not one wrapped warning per file, and it says "yours" rather than the
|
|
137
|
+
* internal "user-owned". Returns null when nothing was skipped.
|
|
138
|
+
*/
|
|
139
|
+
export function formatKeptProjectResources(skipped) {
|
|
140
|
+
if (skipped.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
const rels = [...skipped].sort((a, b) => a.localeCompare(b)).map(toPosixRel);
|
|
143
|
+
if (rels.length === 1)
|
|
144
|
+
return `Kept your existing ${rels[0]}`;
|
|
145
|
+
const byDir = new Map();
|
|
146
|
+
for (const rel of rels) {
|
|
147
|
+
const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '.';
|
|
148
|
+
const names = byDir.get(dir) ?? [];
|
|
149
|
+
names.push(rel.slice(rel.lastIndexOf('/') + 1));
|
|
150
|
+
byDir.set(dir, names);
|
|
151
|
+
}
|
|
152
|
+
if (byDir.size === 1) {
|
|
153
|
+
const [dir, names] = [...byDir.entries()][0];
|
|
154
|
+
const PREVIEW = 3;
|
|
155
|
+
const preview = names.slice(0, PREVIEW).join(', ');
|
|
156
|
+
const more = names.length > PREVIEW ? `, +${names.length - PREVIEW} more` : '';
|
|
157
|
+
return `Kept ${rels.length} of your own files in ${dir}: ${preview}${more}`;
|
|
158
|
+
}
|
|
159
|
+
const dirs = [...byDir.entries()].map(([dir, names]) => `${dir} (${names.length})`).join(', ');
|
|
160
|
+
return `Kept ${rels.length} of your own files in ${dirs}`;
|
|
133
161
|
}
|
|
134
162
|
function syncProjectCommands(agent, version, projectAgentsDir, agentRoot, result, manifestPaths) {
|
|
135
163
|
const cfg = AGENTS[agent];
|
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { ActiveSession, ActiveStatus } from './session/active.js';
|
|
15
15
|
import { type ProjectDef } from './projects.js';
|
|
16
|
+
/** One live agent on a project — the WHO behind the byStatus count. */
|
|
17
|
+
export interface ProjectMember {
|
|
18
|
+
/** Harness name (claude / codex / …), from the session's `kind`. */
|
|
19
|
+
agent: string;
|
|
20
|
+
/** Lifecycle status (running / idle / …). */
|
|
21
|
+
status: string;
|
|
22
|
+
/** Tracker ticket the session is tied to, when any. */
|
|
23
|
+
ticket?: string;
|
|
24
|
+
/** Machine the session runs on (provenance host / fleet peer), when known. */
|
|
25
|
+
host?: string;
|
|
26
|
+
}
|
|
16
27
|
/** One project's live session rollup. */
|
|
17
28
|
export interface ProjectSessionRollup {
|
|
18
29
|
name: string;
|
|
@@ -20,6 +31,8 @@ export interface ProjectSessionRollup {
|
|
|
20
31
|
agents: number;
|
|
21
32
|
/** Count per lifecycle status. */
|
|
22
33
|
byStatus: Partial<Record<ActiveStatus, number>>;
|
|
34
|
+
/** Which agents are on the project (one per matched session). */
|
|
35
|
+
members: ProjectMember[];
|
|
23
36
|
/** Summed checklist progress across this project's sessions. */
|
|
24
37
|
plan: {
|
|
25
38
|
done: number;
|
|
@@ -46,7 +59,20 @@ export declare function planPct(plan: {
|
|
|
46
59
|
done: number;
|
|
47
60
|
total: number;
|
|
48
61
|
}): number | undefined;
|
|
49
|
-
/**
|
|
62
|
+
/** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
|
|
63
|
+
export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
|
|
64
|
+
/** Cap for the members line before it collapses to `+N more`. */
|
|
65
|
+
export declare const MEMBERS_LINE_LIMIT = 6;
|
|
66
|
+
/**
|
|
67
|
+
* The `agents` line under `live`: one cell per DISTINCT member state —
|
|
68
|
+
* `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
|
|
69
|
+
* `×N` count (35 same-harness sessions in one state are one fact, not six
|
|
70
|
+
* truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
|
|
71
|
+
* `+N more` tail counting members, not cells. Pure (chalk styling only); the
|
|
72
|
+
* caller adds the label.
|
|
73
|
+
*/
|
|
74
|
+
export declare function formatProjectMembers(members: ProjectMember[], limit?: number): string;
|
|
75
|
+
/** Harvested signals not on the session list: repo-global merged PRs + releases, local artifacts, in a time window. */
|
|
50
76
|
export interface ProjectRemoteSignals {
|
|
51
77
|
windowDays: number;
|
|
52
78
|
/** PRs merged into the primary repo within the window (via `gh`). */
|
|
@@ -55,6 +81,11 @@ export interface ProjectRemoteSignals {
|
|
|
55
81
|
artifacts: number;
|
|
56
82
|
/** Basename of the most recent artifact, when any. */
|
|
57
83
|
lastArtifact?: string;
|
|
84
|
+
/** Latest release of the PRIMARY repo (via `gh release list`), when any. */
|
|
85
|
+
latestRelease?: {
|
|
86
|
+
tag: string;
|
|
87
|
+
publishedAt: string;
|
|
88
|
+
};
|
|
58
89
|
}
|
|
59
90
|
/**
|
|
60
91
|
* Harvest the signals that don't live on the active-session list: recently
|
|
@@ -13,11 +13,21 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { execFile } from 'child_process';
|
|
15
15
|
import { promisify } from 'util';
|
|
16
|
+
import chalk from 'chalk';
|
|
16
17
|
import { projectNameForCwd } from './projects.js';
|
|
17
18
|
import { readRecentActivity } from './activity.js';
|
|
18
19
|
const execFileAsync = promisify(execFile);
|
|
19
20
|
function blank(name) {
|
|
20
|
-
return {
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
agents: 0,
|
|
24
|
+
byStatus: {},
|
|
25
|
+
members: [],
|
|
26
|
+
plan: { done: 0, total: 0 },
|
|
27
|
+
openPrs: [],
|
|
28
|
+
tickets: [],
|
|
29
|
+
worktrees: 0,
|
|
30
|
+
};
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
33
|
* Roll active sessions up by project. Returns a map keyed by project name,
|
|
@@ -41,6 +51,12 @@ export function rollupSessionsByProject(defs, sessions) {
|
|
|
41
51
|
}
|
|
42
52
|
r.agents++;
|
|
43
53
|
r.byStatus[s.status] = (r.byStatus[s.status] ?? 0) + 1;
|
|
54
|
+
const member = { agent: s.kind, status: s.status };
|
|
55
|
+
if (s.ticket?.id)
|
|
56
|
+
member.ticket = s.ticket.id;
|
|
57
|
+
if (s.machine)
|
|
58
|
+
member.host = s.machine;
|
|
59
|
+
r.members.push(member);
|
|
44
60
|
if (s.todos) {
|
|
45
61
|
r.plan.done += s.todos.done;
|
|
46
62
|
r.plan.total += s.todos.total;
|
|
@@ -67,6 +83,59 @@ export function planPct(plan) {
|
|
|
67
83
|
return undefined;
|
|
68
84
|
return Math.round((plan.done / plan.total) * 100);
|
|
69
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Display order for the members line: the states a human scans for first
|
|
88
|
+
* (running, then idle, then need-input, then queued), everything else after,
|
|
89
|
+
* status name then agent name ascending within a state.
|
|
90
|
+
*/
|
|
91
|
+
const MEMBER_STATUS_RANK = { running: 0, idle: 1, input_required: 2, queued: 3 };
|
|
92
|
+
/** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
|
|
93
|
+
export function sortProjectMembers(members) {
|
|
94
|
+
return [...members].sort((a, b) => {
|
|
95
|
+
const ra = MEMBER_STATUS_RANK[a.status] ?? 4;
|
|
96
|
+
const rb = MEMBER_STATUS_RANK[b.status] ?? 4;
|
|
97
|
+
if (ra !== rb)
|
|
98
|
+
return ra - rb;
|
|
99
|
+
if (ra === 4 && a.status !== b.status)
|
|
100
|
+
return a.status.localeCompare(b.status);
|
|
101
|
+
return a.agent.localeCompare(b.agent);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Cap for the members line before it collapses to `+N more`. */
|
|
105
|
+
export const MEMBERS_LINE_LIMIT = 6;
|
|
106
|
+
/**
|
|
107
|
+
* The `agents` line under `live`: one cell per DISTINCT member state —
|
|
108
|
+
* `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
|
|
109
|
+
* `×N` count (35 same-harness sessions in one state are one fact, not six
|
|
110
|
+
* truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
|
|
111
|
+
* `+N more` tail counting members, not cells. Pure (chalk styling only); the
|
|
112
|
+
* caller adds the label.
|
|
113
|
+
*/
|
|
114
|
+
export function formatProjectMembers(members, limit = MEMBERS_LINE_LIMIT) {
|
|
115
|
+
if (members.length === 0)
|
|
116
|
+
return '';
|
|
117
|
+
// Collapse identical cells — 35 same-harness sessions in the same state are
|
|
118
|
+
// one fact (`claude · running ×16`), not six truncated duplicates.
|
|
119
|
+
const counts = new Map();
|
|
120
|
+
for (const m of sortProjectMembers(members)) {
|
|
121
|
+
const parts = [m.agent, m.status];
|
|
122
|
+
if (m.ticket)
|
|
123
|
+
parts.push(m.ticket);
|
|
124
|
+
const cell = parts.join(' · ') + (m.host ? ` @${m.host}` : '');
|
|
125
|
+
const key = cell.toLowerCase();
|
|
126
|
+
const entry = counts.get(key);
|
|
127
|
+
if (entry)
|
|
128
|
+
entry.n++;
|
|
129
|
+
else
|
|
130
|
+
counts.set(key, { cell, n: 1 });
|
|
131
|
+
}
|
|
132
|
+
const entries = [...counts.values()];
|
|
133
|
+
const shown = entries.slice(0, Math.max(1, limit));
|
|
134
|
+
const shownMembers = shown.reduce((acc, e) => acc + e.n, 0);
|
|
135
|
+
const more = members.length - shownMembers;
|
|
136
|
+
const cells = shown.map(({ cell, n }) => (n > 1 ? `${cell} ×${n}` : cell));
|
|
137
|
+
return cells.join(chalk.dim(' · ')) + (more > 0 ? chalk.dim(` · +${more} more`) : '');
|
|
138
|
+
}
|
|
70
139
|
/**
|
|
71
140
|
* Harvest the signals that don't live on the active-session list: recently
|
|
72
141
|
* merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
|
|
@@ -96,6 +165,18 @@ export async function enrichProjectSignals(def, windowDays, nowMs, opts = {}) {
|
|
|
96
165
|
catch {
|
|
97
166
|
/* gh missing / unauthenticated / repo not found — skip this signal */
|
|
98
167
|
}
|
|
168
|
+
// Latest release of the PRIMARY repo only (repos[] is deliberately not
|
|
169
|
+
// scanned — one release line per card). Same best-effort degradation.
|
|
170
|
+
try {
|
|
171
|
+
const { stdout } = await execFileAsync('gh', ['release', 'list', '-R', def.repo, '-L', '1', '--json', 'tagName,publishedAt'], { timeout: 8000, encoding: 'utf8' });
|
|
172
|
+
const rows = JSON.parse(stdout);
|
|
173
|
+
const first = rows[0];
|
|
174
|
+
if (first?.tagName)
|
|
175
|
+
out.latestRelease = { tag: first.tagName, publishedAt: first.publishedAt ?? '' };
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* gh missing / unauthenticated / repo has no releases — skip this signal */
|
|
179
|
+
}
|
|
99
180
|
}
|
|
100
181
|
return out;
|
|
101
182
|
}
|
package/dist/lib/projects.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface ProjectRepo {
|
|
|
24
24
|
slug: string;
|
|
25
25
|
/** Optional path within the repo an agent working this project cares about. */
|
|
26
26
|
subpath?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Optional home-relative local checkout of this repo. The def's `root` only
|
|
29
|
+
* knows the primary repo on disk; `path` opts an additional repo into
|
|
30
|
+
* workspace probing (`projects status --fleet`).
|
|
31
|
+
*/
|
|
32
|
+
path?: string;
|
|
27
33
|
}
|
|
28
34
|
/**
|
|
29
35
|
* A described context anchor: a subdirectory plus what it is. Agents starting on
|
package/dist/lib/projects.js
CHANGED
|
@@ -86,9 +86,15 @@ export function validateProjectDef(raw, sourceName) {
|
|
|
86
86
|
def.repos = o.repos.flatMap((r) => {
|
|
87
87
|
if (r && typeof r === 'object' && typeof r.slug === 'string') {
|
|
88
88
|
const rr = r;
|
|
89
|
+
// A malformed `path` sinks the whole entry, like any other malformed
|
|
90
|
+
// list row — a half-valid repo must not probe a surprising location.
|
|
91
|
+
if (rr.path !== undefined && typeof rr.path !== 'string')
|
|
92
|
+
return [];
|
|
89
93
|
const repo = { slug: rr.slug };
|
|
90
94
|
if (typeof rr.subpath === 'string')
|
|
91
95
|
repo.subpath = rr.subpath;
|
|
96
|
+
if (typeof rr.path === 'string')
|
|
97
|
+
repo.path = rr.path;
|
|
92
98
|
return [repo];
|
|
93
99
|
}
|
|
94
100
|
return [];
|
|
@@ -192,6 +198,12 @@ export function writeProjectDef(def) {
|
|
|
192
198
|
defaultPath: validated.defaultPath
|
|
193
199
|
? toHomeRelative(expandLocalHome(validated.defaultPath))
|
|
194
200
|
: undefined,
|
|
201
|
+
repos: validated.repos?.map((r) => {
|
|
202
|
+
const repo = { ...r };
|
|
203
|
+
if (r.path)
|
|
204
|
+
repo.path = toHomeRelative(expandLocalHome(r.path));
|
|
205
|
+
return repo;
|
|
206
|
+
}),
|
|
195
207
|
};
|
|
196
208
|
// Drop undefined keys so the YAML stays clean.
|
|
197
209
|
const clean = Object.fromEntries(Object.entries(normalized).filter(([, v]) => v !== undefined));
|
|
@@ -26,6 +26,17 @@ import { type DesktopNotification } from './menubar/notify-desktop.js';
|
|
|
26
26
|
type RoutineKind = 'agent' | 'workflow' | 'command';
|
|
27
27
|
/** Which flavor of routine a config/meta describes — drives the notify threshold. */
|
|
28
28
|
export declare function routineKind(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
|
|
29
|
+
/**
|
|
30
|
+
* The harness a routine runs on, for the notification's right-hand avatar, or
|
|
31
|
+
* undefined when none owns it. A command routine is deterministic housekeeping
|
|
32
|
+
* with no agent, so it gets no avatar. An agent routine names its own harness.
|
|
33
|
+
* A workflow routine has no `agent` field (the schema omits it — routines.ts
|
|
34
|
+
* `JobConfig.agent` and the validation that rejects setting both), and it runs
|
|
35
|
+
* via `agents run <workflow>`, which delegates to claude under the hood — so its
|
|
36
|
+
* avatar is the Claude mark, matching `effectiveAgent` on the finish path
|
|
37
|
+
* (runner.ts). Start and finish banners therefore show the same avatar.
|
|
38
|
+
*/
|
|
39
|
+
export declare function routineAgent(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): string | undefined;
|
|
29
40
|
/** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
|
|
30
41
|
export declare function formatDuration(ms: number | undefined): string | null;
|
|
31
42
|
/**
|
|
@@ -33,6 +33,24 @@ export function routineKind(r) {
|
|
|
33
33
|
return 'workflow';
|
|
34
34
|
return 'agent';
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The harness a routine runs on, for the notification's right-hand avatar, or
|
|
38
|
+
* undefined when none owns it. A command routine is deterministic housekeeping
|
|
39
|
+
* with no agent, so it gets no avatar. An agent routine names its own harness.
|
|
40
|
+
* A workflow routine has no `agent` field (the schema omits it — routines.ts
|
|
41
|
+
* `JobConfig.agent` and the validation that rejects setting both), and it runs
|
|
42
|
+
* via `agents run <workflow>`, which delegates to claude under the hood — so its
|
|
43
|
+
* avatar is the Claude mark, matching `effectiveAgent` on the finish path
|
|
44
|
+
* (runner.ts). Start and finish banners therefore show the same avatar.
|
|
45
|
+
*/
|
|
46
|
+
export function routineAgent(r) {
|
|
47
|
+
const kind = routineKind(r);
|
|
48
|
+
if (kind === 'command')
|
|
49
|
+
return undefined;
|
|
50
|
+
if (kind === 'workflow')
|
|
51
|
+
return 'claude';
|
|
52
|
+
return r.agent?.trim() || undefined;
|
|
53
|
+
}
|
|
36
54
|
/** Human label for the routine body ("agent claude", "workflow deploy", "command"). */
|
|
37
55
|
function routineLabel(r) {
|
|
38
56
|
if (r.command)
|
|
@@ -90,6 +108,7 @@ export function routineStartNotification(config) {
|
|
|
90
108
|
subtitle: config.name,
|
|
91
109
|
body: `Running ${routineLabel(config)}`,
|
|
92
110
|
action: 'routines:list',
|
|
111
|
+
agent: routineAgent(config),
|
|
93
112
|
};
|
|
94
113
|
}
|
|
95
114
|
/**
|
|
@@ -108,6 +127,7 @@ export function routineStartFailedNotification(config, error) {
|
|
|
108
127
|
subtitle: config.name,
|
|
109
128
|
body: `Failed to start: ${error}`,
|
|
110
129
|
action: 'routines:list',
|
|
130
|
+
agent: routineAgent(config),
|
|
111
131
|
};
|
|
112
132
|
}
|
|
113
133
|
/**
|
|
@@ -131,6 +151,7 @@ export function routineFinishNotification(meta, opts = {}) {
|
|
|
131
151
|
subtitle: meta.jobName,
|
|
132
152
|
body: snippet ?? (dur ? `Completed in ${dur}` : 'Completed'),
|
|
133
153
|
action,
|
|
154
|
+
agent: routineAgent(meta),
|
|
134
155
|
};
|
|
135
156
|
}
|
|
136
157
|
// failed | timeout
|
|
@@ -144,6 +165,7 @@ export function routineFinishNotification(meta, opts = {}) {
|
|
|
144
165
|
subtitle: meta.jobName,
|
|
145
166
|
body: reason,
|
|
146
167
|
action,
|
|
168
|
+
agent: routineAgent(meta),
|
|
147
169
|
};
|
|
148
170
|
}
|
|
149
171
|
/** Read a finished run's report text + the best artifact to open on click. */
|
package/dist/lib/run-notify.js
CHANGED
|
@@ -34,6 +34,9 @@ export function buildRunFinishNotification(ctx, exitCode) {
|
|
|
34
34
|
const n = {
|
|
35
35
|
title: exitCode === 0 ? `${label} finished` : `${label} failed`,
|
|
36
36
|
body: shorten(ctx.prompt?.trim() || `${ctx.agent} run`),
|
|
37
|
+
// The harness that ran becomes the banner's right-hand avatar, so a finished
|
|
38
|
+
// run is identifiable at a glance even when `--name` renamed the title.
|
|
39
|
+
agent: ctx.agent,
|
|
37
40
|
};
|
|
38
41
|
if (where)
|
|
39
42
|
n.subtitle = where;
|
|
Binary file
|
|
Binary file
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked';
|
|
1
|
+
export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view';
|
|
2
2
|
export interface SecretAuditParams {
|
|
3
3
|
/** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
|
|
4
4
|
event: SecretAuditEvent;
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical audit emitter for `agents secrets`
|
|
2
|
+
* Canonical audit emitter for every `agents secrets` lifecycle/access event.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* This is the ONE write path for secret events. Every path that creates,
|
|
5
|
+
* imports, exports, views, reads a VALUE from, or unlocks a bundle funnels its
|
|
6
|
+
* audit through here, so the operational event stream — `agents events`, backed
|
|
7
|
+
* by the append-only `~/.agents/events.jsonl` audit log — carries a uniform,
|
|
8
|
+
* value-free provenance record: bundle, key NAMES, the resolving agent/harness
|
|
9
|
+
* identity, operation, source, status. The ts / host / session / caller fields
|
|
10
|
+
* are filled in by `emit()` itself. The secret VALUE is never part of the
|
|
11
|
+
* payload; this helper only ever receives metadata, and `emit()`'s
|
|
12
|
+
* `sanitizePayload` is a second redaction layer.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
+
* The same call also mirrors the event into the per-bundle usage read-model DB
|
|
15
|
+
* (`~/.agents/secrets/secrets.db`, lib/secrets/usage-db.ts) so `secrets view` /
|
|
16
|
+
* `list` / `activity` can answer "how often / how recently / by whom was this
|
|
17
|
+
* bundle used?" without scanning the whole event stream — a DERIVED index fed
|
|
18
|
+
* off this chokepoint, not a second write path a caller has to remember.
|
|
19
|
+
*
|
|
20
|
+
* The event vocabulary, all audit-level and non-milestone (so they surface in
|
|
14
21
|
* `agents events` and the persisted audit trail, but are NOT required in the
|
|
15
22
|
* curated `agents activity` / `agents feed` surfaces):
|
|
16
23
|
* - `secrets.get` — a value was READ (exec inject, export, `view --reveal`,
|
|
@@ -19,8 +26,27 @@
|
|
|
19
26
|
* - `secrets.unlocked` — a bundle was GRANTED into the secrets broker / durable
|
|
20
27
|
* session by `agents secrets unlock`, then readable
|
|
21
28
|
* prompt-free for the grant TTL.
|
|
29
|
+
* - `secrets.create` — a new bundle was created.
|
|
30
|
+
* - `secrets.import` — keys were imported into a bundle (file / ssh / 1password).
|
|
31
|
+
* - `secrets.export` — a bundle's values were exported (file / shell / ssh /
|
|
32
|
+
* 1password). Exporting also READS the values, so the
|
|
33
|
+
* underlying resolve emits its own `secrets.get`.
|
|
34
|
+
* - `secrets.view` — a bundle's (masked) metadata was inspected via `view`.
|
|
22
35
|
*/
|
|
23
36
|
import { emit } from '../events.js';
|
|
37
|
+
import { recordSecretUsage } from './usage-db.js';
|
|
38
|
+
/**
|
|
39
|
+
* Map each audit event onto the usage-DB kind it is counted as. `secrets.get` is
|
|
40
|
+
* the injection/read access; the rest map 1:1 to their lifecycle kind.
|
|
41
|
+
*/
|
|
42
|
+
const USAGE_KIND = {
|
|
43
|
+
'secrets.get': 'access',
|
|
44
|
+
'secrets.unlocked': 'unlock',
|
|
45
|
+
'secrets.create': 'create',
|
|
46
|
+
'secrets.import': 'import',
|
|
47
|
+
'secrets.export': 'export',
|
|
48
|
+
'secrets.view': 'view',
|
|
49
|
+
};
|
|
24
50
|
/**
|
|
25
51
|
* The agent/harness identity to attribute a secret access to. Explicit callers
|
|
26
52
|
* (the bundle reader knows the scope it resolved under) win; otherwise fall back
|
|
@@ -53,4 +79,21 @@ export function emitSecretAudit(p) {
|
|
|
53
79
|
...(p.ttlMs !== undefined ? { ttlMs: p.ttlMs } : {}),
|
|
54
80
|
...(p.error !== undefined ? { error: p.error } : {}),
|
|
55
81
|
});
|
|
82
|
+
// Mirror the event into the per-bundle usage read-model so `secrets view` /
|
|
83
|
+
// `list` / `activity` can report frequency and recency without scanning the
|
|
84
|
+
// whole event stream. Fed off THIS chokepoint alongside the events.jsonl
|
|
85
|
+
// write, never a second write path. Per-bundle only — a raw `secrets get
|
|
86
|
+
// <item>` has no bundle, so it stays in the events.jsonl audit but is not
|
|
87
|
+
// counted as bundle usage. Best-effort inside usage-db (swallows errors).
|
|
88
|
+
if (p.bundle) {
|
|
89
|
+
recordSecretUsage({
|
|
90
|
+
bundle: p.bundle,
|
|
91
|
+
event: USAGE_KIND[p.event],
|
|
92
|
+
agent,
|
|
93
|
+
host: p.host,
|
|
94
|
+
source: p.source,
|
|
95
|
+
status: p.status,
|
|
96
|
+
keyCount: p.keyCount,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
56
99
|
}
|
|
@@ -79,14 +79,29 @@ export interface FilterContext {
|
|
|
79
79
|
}
|
|
80
80
|
/** Does this bundle satisfy every set axis? Pure. */
|
|
81
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
|
-
|
|
82
|
+
/** Sort fields for `--sort`. `name` is the default and matches `listBundles()`.
|
|
83
|
+
* `used` is most-recently-used first and `uses` is most-frequently-accessed
|
|
84
|
+
* first; both read the value-free usage read-model (lib/secrets/usage-db.ts). */
|
|
85
|
+
export declare const SORT_FIELDS: readonly ["name", "used", "uses", "created", "updated", "expiry"];
|
|
84
86
|
export type SortField = typeof SORT_FIELDS[number];
|
|
87
|
+
/**
|
|
88
|
+
* The two value-free usage facts `--sort used|uses` needs, keyed by bundle name.
|
|
89
|
+
* Kept as a minimal shape (not the full BundleUsageSummary) so this module stays
|
|
90
|
+
* pure and unit-testable without opening the SQLite read-model.
|
|
91
|
+
*/
|
|
92
|
+
export interface BundleUsageHint {
|
|
93
|
+
/** Most recent recorded event across all kinds, ISO 8601, or null. */
|
|
94
|
+
lastUsedAt: string | null;
|
|
95
|
+
/** Recorded `access` (read/inject) count — what `--sort uses` ranks on. */
|
|
96
|
+
uses: number;
|
|
97
|
+
}
|
|
85
98
|
export declare function parseSortField(raw: string | undefined): SortField;
|
|
86
99
|
/** Sort a copy. Time fields are most-recent-first (the useful direction for
|
|
87
|
-
* "what did I touch lately"); `
|
|
88
|
-
* so the order is stable.
|
|
89
|
-
|
|
100
|
+
* "what did I touch lately"); `uses` is most-frequently-accessed first; `expiry`
|
|
101
|
+
* is soonest-first; ties fall back to name so the order is stable. `usage`
|
|
102
|
+
* carries the value-free read-model facts `used`/`uses` rank on — when omitted,
|
|
103
|
+
* `used` falls back to the throttled `last_used` stamp and `uses` sees zero. */
|
|
104
|
+
export declare function sortBundles(bundles: SecretsBundle[], field: SortField, usage?: Map<string, BundleUsageHint>): SecretsBundle[];
|
|
90
105
|
/** Human summary of the active filters, for the empty state. `sessions` only
|
|
91
106
|
* echoes --project/--all on a miss, which leaves you guessing which flag emptied
|
|
92
107
|
* the list; naming every active axis is the difference between "nothing matched"
|
|
@@ -171,8 +171,10 @@ export function bundleMatchesFilter(b, f, ctx) {
|
|
|
171
171
|
}
|
|
172
172
|
return true;
|
|
173
173
|
}
|
|
174
|
-
/** Sort fields for `--sort`. `name` is the default and matches `listBundles()`.
|
|
175
|
-
|
|
174
|
+
/** Sort fields for `--sort`. `name` is the default and matches `listBundles()`.
|
|
175
|
+
* `used` is most-recently-used first and `uses` is most-frequently-accessed
|
|
176
|
+
* first; both read the value-free usage read-model (lib/secrets/usage-db.ts). */
|
|
177
|
+
export const SORT_FIELDS = ['name', 'used', 'uses', 'created', 'updated', 'expiry'];
|
|
176
178
|
export function parseSortField(raw) {
|
|
177
179
|
if (!raw)
|
|
178
180
|
return 'name';
|
|
@@ -196,11 +198,17 @@ function soonestExpiry(b) {
|
|
|
196
198
|
return soonest;
|
|
197
199
|
}
|
|
198
200
|
/** Sort a copy. Time fields are most-recent-first (the useful direction for
|
|
199
|
-
* "what did I touch lately"); `
|
|
200
|
-
* so the order is stable.
|
|
201
|
-
|
|
201
|
+
* "what did I touch lately"); `uses` is most-frequently-accessed first; `expiry`
|
|
202
|
+
* is soonest-first; ties fall back to name so the order is stable. `usage`
|
|
203
|
+
* carries the value-free read-model facts `used`/`uses` rank on — when omitted,
|
|
204
|
+
* `used` falls back to the throttled `last_used` stamp and `uses` sees zero. */
|
|
205
|
+
export function sortBundles(bundles, field, usage) {
|
|
202
206
|
const stamp = (iso) => (iso ? new Date(iso).getTime() : 0);
|
|
203
207
|
const byName = (a, z) => a.name.localeCompare(z.name);
|
|
208
|
+
// `used` combines the throttled keychain stamp with the exact usage-DB recency
|
|
209
|
+
// so a just-recorded access ranks a bundle even before its stamp catches up.
|
|
210
|
+
const usedMs = (b) => Math.max(stamp(b.last_used), stamp(usage?.get(b.name)?.lastUsedAt));
|
|
211
|
+
const usesOf = (b) => usage?.get(b.name)?.uses ?? 0;
|
|
204
212
|
const out = [...bundles];
|
|
205
213
|
if (field === 'name')
|
|
206
214
|
return out.sort(byName);
|
|
@@ -209,7 +217,15 @@ export function sortBundles(bundles, field) {
|
|
|
209
217
|
const d = soonestExpiry(a) - soonestExpiry(z);
|
|
210
218
|
return d !== 0 ? d : byName(a, z);
|
|
211
219
|
}
|
|
212
|
-
|
|
220
|
+
if (field === 'used') {
|
|
221
|
+
const d = usedMs(z) - usedMs(a);
|
|
222
|
+
return d !== 0 ? d : byName(a, z);
|
|
223
|
+
}
|
|
224
|
+
if (field === 'uses') {
|
|
225
|
+
const d = usesOf(z) - usesOf(a);
|
|
226
|
+
return d !== 0 ? d : byName(a, z);
|
|
227
|
+
}
|
|
228
|
+
const key = field === 'created' ? 'created_at' : 'updated_at';
|
|
213
229
|
const d = stamp(z[key]) - stamp(a[key]);
|
|
214
230
|
return d !== 0 ? d : byName(a, z);
|
|
215
231
|
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed usage read-model for `agents secrets`.
|
|
3
|
+
*
|
|
4
|
+
* A small local database at ~/.agents/secrets/secrets.db that records one
|
|
5
|
+
* value-free row for every secret lifecycle/access event a bundle accrues over
|
|
6
|
+
* its life — created, imported, exported, viewed, accessed (read for injection),
|
|
7
|
+
* unlocked. It is the queryable, per-bundle counterpart to the append-only
|
|
8
|
+
* ~/.agents/events.jsonl audit log: the SAME chokepoint (`emitSecretAudit`,
|
|
9
|
+
* lib/secrets/audit.ts) feeds both, and this store answers "how often / how
|
|
10
|
+
* recently / by whom was THIS bundle used?" without scanning the whole event
|
|
11
|
+
* stream. It is a DERIVED index fed off the real access flow — the way
|
|
12
|
+
* sessions.db indexes session metadata — never a second write path an operation
|
|
13
|
+
* has to remember to call.
|
|
14
|
+
*
|
|
15
|
+
* Contract, mirroring the audit log: NEVER a secret value. Only metadata — the
|
|
16
|
+
* bundle name, the event kind, key counts, the resolving agent/host, a status.
|
|
17
|
+
*
|
|
18
|
+
* Every write is best-effort: a failure here (missing runtime SQLite, a locked
|
|
19
|
+
* db, a read-only fs) is swallowed so usage telemetry can never break secret
|
|
20
|
+
* resolution. Set AGENTS_NO_USAGE_TRACK=1 to disable recording entirely (used by
|
|
21
|
+
* tests and by callers that must stay perfectly silent).
|
|
22
|
+
*/
|
|
23
|
+
/** The lifecycle/access events a bundle accrues over its life. */
|
|
24
|
+
export type SecretUsageEvent = 'access' | 'unlock' | 'import' | 'export' | 'create' | 'view';
|
|
25
|
+
/** All event kinds, in the order `view` prints them. */
|
|
26
|
+
export declare const SECRET_USAGE_EVENTS: readonly SecretUsageEvent[];
|
|
27
|
+
export interface RecordUsageParams {
|
|
28
|
+
/** Bundle the event applies to (required — usage is always per-bundle). */
|
|
29
|
+
bundle: string;
|
|
30
|
+
/** What happened. */
|
|
31
|
+
event: SecretUsageEvent;
|
|
32
|
+
/** Resolving agent/harness identity, when known (`*` = a global grant). */
|
|
33
|
+
agent?: string;
|
|
34
|
+
/** Remote host the value was pulled from / pushed to, when applicable. */
|
|
35
|
+
host?: string;
|
|
36
|
+
/** Free-form origin label, e.g. 'agent', 'reveal', 'ssh', '1password'. */
|
|
37
|
+
source?: string;
|
|
38
|
+
/** Outcome; defaults to 'success'. */
|
|
39
|
+
status?: 'success' | 'error';
|
|
40
|
+
/** How many keys the event touched (names only are ever known here). */
|
|
41
|
+
keyCount?: number;
|
|
42
|
+
}
|
|
43
|
+
/** One event kind's rollup for a bundle. */
|
|
44
|
+
export interface UsageStat {
|
|
45
|
+
count: number;
|
|
46
|
+
/** ISO 8601 timestamp of the most recent occurrence, or null if never. */
|
|
47
|
+
last: string | null;
|
|
48
|
+
}
|
|
49
|
+
/** Per-bundle usage summary for the `view` / `list` surfaces. */
|
|
50
|
+
export interface BundleUsageSummary {
|
|
51
|
+
bundle: string;
|
|
52
|
+
/** Every recorded event, all kinds. */
|
|
53
|
+
total: number;
|
|
54
|
+
/** Rollup per event kind (every kind present, zeroed when unused). */
|
|
55
|
+
events: Record<SecretUsageEvent, UsageStat>;
|
|
56
|
+
/** Most recent event across all kinds, or null. */
|
|
57
|
+
lastUsedAt: string | null;
|
|
58
|
+
/** Earliest event across all kinds, or null. */
|
|
59
|
+
firstUsedAt: string | null;
|
|
60
|
+
/** Event count grouped by resolving agent, most-first. `*` = a global grant. */
|
|
61
|
+
byAgent: Array<{
|
|
62
|
+
agent: string;
|
|
63
|
+
count: number;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
/** One recorded event, for the `secrets activity` timeline. */
|
|
67
|
+
export interface SecretUsageHistoryEntry {
|
|
68
|
+
ts: string;
|
|
69
|
+
bundle: string;
|
|
70
|
+
event: SecretUsageEvent;
|
|
71
|
+
agent: string | null;
|
|
72
|
+
host: string | null;
|
|
73
|
+
source: string | null;
|
|
74
|
+
status: string | null;
|
|
75
|
+
keyCount: number | null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Record one usage event. Best-effort and value-free — swallows every error and
|
|
79
|
+
* honors AGENTS_NO_USAGE_TRACK so telemetry never blocks or slows a read. Rows
|
|
80
|
+
* with an empty bundle name are ignored (usage is per-bundle by definition).
|
|
81
|
+
*
|
|
82
|
+
* This is called from ONE place only — `emitSecretAudit` (lib/secrets/audit.ts)
|
|
83
|
+
* — so every recorded event has already been written to the events.jsonl audit
|
|
84
|
+
* log through the same chokepoint. Do not call it from a command handler; emit
|
|
85
|
+
* the audit event instead.
|
|
86
|
+
*/
|
|
87
|
+
export declare function recordSecretUsage(p: RecordUsageParams): void;
|
|
88
|
+
/**
|
|
89
|
+
* Usage summary for one bundle, or undefined when nothing has ever been
|
|
90
|
+
* recorded (or the DB is unavailable). Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export declare function getBundleUsage(bundle: string): BundleUsageSummary | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Usage summaries for every bundle that has any recorded event, keyed by bundle
|
|
95
|
+
* name. Powers `secrets list --sort uses|used`. Empty map when the DB is
|
|
96
|
+
* unavailable or has no rows. Never throws.
|
|
97
|
+
*/
|
|
98
|
+
export declare function getAllBundleUsage(): Map<string, BundleUsageSummary>;
|
|
99
|
+
/**
|
|
100
|
+
* Recent events for the `secrets activity` timeline — one bundle when named,
|
|
101
|
+
* else across all bundles — newest first. Empty when the DB is unavailable.
|
|
102
|
+
* Never throws.
|
|
103
|
+
*/
|
|
104
|
+
export declare function getUsageHistory(bundle: string | undefined, limit?: number): SecretUsageHistoryEntry[];
|
|
105
|
+
/** Close the cached handle. Used by tests between temp-db swaps. */
|
|
106
|
+
export declare function closeSecretsUsageDb(): void;
|