@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,126 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.93
|
|
4
|
+
|
|
5
|
+
- **`agents send` is a real delivery envelope; `notify` is just `--to owner` (RUSH-2123).** Flag-first form: `--to`, `--text`, `--channel`, `--attach`, `--url`. `--to owner` expands from `notify.owner` in agents.yaml. Positional text still works. Help names the three planes (deliver / record / control) so send is not confused with `feed post`, `activity`, or `message`/`sessions inject`. Source: `apps/cli/src/commands/send.ts`, `apps/cli/src/lib/channels/send.ts`.
|
|
6
|
+
|
|
7
|
+
- **`agents events emit` — record events produced outside the CLI.** In-process code
|
|
8
|
+
calls `emit()` directly, but the producers that most need to record events are not
|
|
9
|
+
agents-cli processes: the Factory VS Code extension host, shell guards, external
|
|
10
|
+
tools. They now pipe JSONL on stdin —
|
|
11
|
+
`… | agents events emit --source factory`. `--source` is stamped as `module`, so
|
|
12
|
+
`agents events --module factory` filters to one producer. Routing is forced by the
|
|
13
|
+
stores rather than chosen: a milestone kind requires a `sessionId` and lands in that
|
|
14
|
+
session's activity log, everything else lands in the operational log. A milestone
|
|
15
|
+
with no `sessionId` is rejected, not quietly written elsewhere. Rejection is per
|
|
16
|
+
line, so one bad line never discards a batch, and the exit code is 1 if any line was
|
|
17
|
+
rejected. `--dry-run` validates without writing.
|
|
18
|
+
Source: `apps/cli/src/lib/events-ingest.ts`, `apps/cli/src/commands/events.ts`,
|
|
19
|
+
`apps/cli/docs/06-observability.md`.
|
|
20
|
+
|
|
21
|
+
- **Four `factory.*` event kinds.** `factory.command`, `factory.action`, `factory.uri`
|
|
22
|
+
and `factory.launch` describe what a user did in the Factory VS Code extension.
|
|
23
|
+
`factory.launch` is a milestone — it carries the `sessionId` and `terminalId` that
|
|
24
|
+
later events join through — and `factory.uri` is audit-level, since an external
|
|
25
|
+
process driving the user's editor is a "who reached in from outside" fact.
|
|
26
|
+
Source: `apps/cli/src/lib/events.ts`, `apps/cli/src/lib/activity.ts`.
|
|
27
|
+
|
|
28
|
+
- **`emit()` accepts a caller-supplied timestamp.** A batched producer records when
|
|
29
|
+
each event *happened* and flushes later; without this, every event in a flush was
|
|
30
|
+
stamped at flush time, collapsing their order and corrupting `--since` boundaries.
|
|
31
|
+
`ts` stays reserved against payload injection — only the explicit override can set
|
|
32
|
+
it. Source: `apps/cli/src/lib/events.ts`.
|
|
33
|
+
|
|
34
|
+
- **Fixed: `agents _internal friction` recorded its own invocation.** The command
|
|
35
|
+
exists precisely because shell guards run before any `agents` process exists and so
|
|
36
|
+
cannot emit in-process, but it still fired the `command.start` / `command.end` audit
|
|
37
|
+
hooks, writing two records on top of every friction record. Recorder commands are now
|
|
38
|
+
exempt. Source: `apps/cli/src/index.ts`.
|
|
39
|
+
|
|
40
|
+
- **Desktop notifications now show the agent on the right, not a second copy of
|
|
41
|
+
the app icon.** macOS draws two images on a banner: the sending app's icon on
|
|
42
|
+
the left and `contentImage` on the right (a YouTube notification uses the slots
|
|
43
|
+
for "YouTube" plus the channel avatar). agents-cli was putting its own lime mark
|
|
44
|
+
in the right slot, so both slots said the same thing. The right slot now carries
|
|
45
|
+
the harness the notification is *about* — a brand-colored tile with a two-letter
|
|
46
|
+
mark (`CL` claude, `CX` codex, `GK` grok, …), two letters because four harnesses
|
|
47
|
+
start with `c` and two with `g`. `agents run --notify` and agent/workflow
|
|
48
|
+
routines pass their harness through; a daemon heal, an overdue sweep, a command
|
|
49
|
+
routine, or a fan-out across several agents has no single agent and leaves the
|
|
50
|
+
right slot empty. Source:
|
|
51
|
+
`apps/cli/menubar/Sources/MenubarHelper/AgentAvatar.swift`,
|
|
52
|
+
`apps/cli/src/lib/menubar/notify-desktop.ts`, `apps/cli/src/lib/run-notify.ts`,
|
|
53
|
+
`apps/cli/src/lib/routine-notify.ts`, `apps/cli/docs/menubar.md`.
|
|
54
|
+
|
|
55
|
+
- **`MenubarHelper --notify` gains `--agent <id>`.** The one-shot notifier accepts
|
|
56
|
+
the harness id that drives the right-hand avatar; omitting it is how a caller
|
|
57
|
+
says "no single agent owns this event".
|
|
58
|
+
|
|
59
|
+
- **`agents projects status --fleet` — per-device workspace drift (beta).**
|
|
60
|
+
Projects are natively multi-device; `--fleet` adds a `fleet` line to the status
|
|
61
|
+
card showing, for each project, whether its workspace repos are present on every
|
|
62
|
+
fleet device, on which branch, ahead/behind their upstream (`↑`/`↓`), and how
|
|
63
|
+
many uncommitted changes they carry — plus a hidden `agents projects probe
|
|
64
|
+
--json <path...>` subcommand that is the peer half of the fan-out. One parallel
|
|
65
|
+
SSH call per device (12s timeout), **no `git fetch`** — drift is measured
|
|
66
|
+
against each peer's last-fetched upstream, and a repo with no upstream reports
|
|
67
|
+
no drift rather than zero. Peers that are unreachable or run an older CLI are
|
|
68
|
+
named once in a trailing note; `probe` itself is not beta-gated so peers answer
|
|
69
|
+
whenever their binary carries it. The schema gains `repos[].path` (home-relative
|
|
70
|
+
local checkout) to opt additional repos into probing beyond the primary `root`,
|
|
71
|
+
and `--json` gains per-project `workspaces[]` with the host-tagged probe rows.
|
|
72
|
+
The card's `live` line also counts agents on every box under `--fleet` via the
|
|
73
|
+
existing sessions fan-out. Source: `apps/cli/src/lib/project-probe.ts`,
|
|
74
|
+
`apps/cli/src/commands/projects.ts`, `apps/cli/src/lib/projects.ts`.
|
|
75
|
+
|
|
76
|
+
- **`agents projects` outcomes on the card — agent×project members, releases,
|
|
77
|
+
Linear counts, and `projects link --linear` (beta).** The status card gains an
|
|
78
|
+
`agents` line under `live` naming WHICH harness is on each project
|
|
79
|
+
(`claude · running · RUSH-2107 @zion`, sorted running-first, capped at 6 with a
|
|
80
|
+
`+N more` tail; under `--fleet` remote agents carry their peer's hostname), a
|
|
81
|
+
latest-release tag on the `ships` line (primary repo only, best-effort
|
|
82
|
+
`gh release list`), and a `linear` line counting the bound Linear project's
|
|
83
|
+
issues by state type (`12/30 done · 5 in progress`) — best-effort with an 8s
|
|
84
|
+
budget, skipped by `--no-remote`, and omitted when the def has no
|
|
85
|
+
`linear.projectId`. The new `agents projects link <name> --linear [query]`
|
|
86
|
+
writes that binding: no query auto-suggests from the def name + repo slug via
|
|
87
|
+
the normalized-key matcher (ported from Factory's `linearProjects.ts`),
|
|
88
|
+
ambiguous/none prints the candidate list and exits 1. `--json` gains
|
|
89
|
+
`members[]`, `latestRelease`, and `linear`. Source:
|
|
90
|
+
`apps/cli/src/lib/project-status.ts`, `apps/cli/src/lib/linear-projects.ts`,
|
|
91
|
+
`apps/cli/src/lib/linear-project-counts.ts`, `apps/cli/src/commands/projects.ts`.
|
|
92
|
+
|
|
93
|
+
- **Project sync no longer spams a warning per file it left alone.** Syncing a
|
|
94
|
+
project whose `.claude/commands/` you wrote yourself printed one wrapped
|
|
95
|
+
`Skipping project resource target …: already exists and is user-owned` line
|
|
96
|
+
per file — six hand-authored commands meant twelve lines of terminal noise in
|
|
97
|
+
the middle of `agents view claude`. Those files are the normal steady state,
|
|
98
|
+
not a warning, so the sync now reports them once, grouped, in plain words:
|
|
99
|
+
`Kept 6 of your own files in .claude/commands: debug.md, doc-gaps.md,
|
|
100
|
+
image-nbp.md, +3 more`. The list also rides out on `SyncResult.projectSkipped`
|
|
101
|
+
for callers that want it. Source: `apps/cli/src/lib/project-resources.ts`.
|
|
102
|
+
|
|
103
|
+
- **`agents secrets` now tracks per-bundle usage and surfaces it.** Every secret
|
|
104
|
+
lifecycle/access event — create, import, export, view, access (a value read for
|
|
105
|
+
injection), unlock — funnels through the one `emitSecretAudit` chokepoint, which
|
|
106
|
+
writes to BOTH the append-only `~/.agents/events.jsonl` audit log AND a derived,
|
|
107
|
+
value-free read-model at `~/.agents/secrets/secrets.db` (never a secret value —
|
|
108
|
+
bundle name, event kind, key count, resolving agent/host, status only).
|
|
109
|
+
`agents secrets view <bundle>` now shows whether the bundle is currently
|
|
110
|
+
**unlocked** (held by the secrets-agent, so reads are prompt-free), a **usage**
|
|
111
|
+
summary ("accessed 42× (last 2h ago) · exported 3× (last 1d ago)"), and
|
|
112
|
+
**per-agent** attribution, and nudges when a bundle has no description (also at
|
|
113
|
+
`create` time). `agents secrets list` gains **`--sort uses`** (most frequently
|
|
114
|
+
accessed) alongside the existing `--sort used`, and the `--json` payloads carry
|
|
115
|
+
`uses`, `usage`, and `heldExpiresAt`. A new **`agents secrets activity [bundle]`**
|
|
116
|
+
prints the recent value-free event timeline (bounded to 90 days). Naming guidance
|
|
117
|
+
is taught in the help and skill: name a website bundle after its domain
|
|
118
|
+
(`stripe.com`, `openai.ai`), a desktop-app bundle after its binary suffix
|
|
119
|
+
(`slack.app`, `photoshop.exe`). Recording is best-effort and
|
|
120
|
+
`AGENTS_NO_USAGE_TRACK=1` disables it. Source:
|
|
121
|
+
`apps/cli/src/lib/secrets/usage-db.ts`, `apps/cli/src/lib/secrets/audit.ts`,
|
|
122
|
+
`apps/cli/src/commands/secrets.ts`, `apps/cli/src/lib/secrets/list-filter.ts`.
|
|
123
|
+
|
|
3
124
|
## 1.20.92
|
|
4
125
|
|
|
5
126
|
- **`agents sessions` rows show creation time as well as last activity (RUSH-2107).**
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/events.js
CHANGED
|
@@ -17,6 +17,8 @@ import chalk from 'chalk';
|
|
|
17
17
|
import * as fs from 'fs';
|
|
18
18
|
import { getLogsPath } from '../lib/events.js';
|
|
19
19
|
import { readUnifiedEvents } from '../lib/event-stream.js';
|
|
20
|
+
import { ingestBatch } from '../lib/events-ingest.js';
|
|
21
|
+
import { setHelpSections } from '../lib/help.js';
|
|
20
22
|
/**
|
|
21
23
|
* Resolve `--limit` into a record cap. `0` means "no cap" — without it there is
|
|
22
24
|
* no way to read the whole stream, and any aggregation (group-by failure, count
|
|
@@ -97,8 +99,93 @@ function renderRow(r) {
|
|
|
97
99
|
const agent = r.agent ? chalk.gray(` ${r.agent}`) : '';
|
|
98
100
|
return `${time} ${originLabel(r).padEnd(24)} ${user.padEnd(22)} ${ev.padEnd(26)}${agent} ${detailFor(r)}`;
|
|
99
101
|
}
|
|
102
|
+
/** Read all of stdin. Returns '' when nothing is piped. */
|
|
103
|
+
async function readStdin() {
|
|
104
|
+
if (process.stdin.isTTY)
|
|
105
|
+
return '';
|
|
106
|
+
const chunks = [];
|
|
107
|
+
for await (const chunk of process.stdin)
|
|
108
|
+
chunks.push(Buffer.from(chunk));
|
|
109
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
110
|
+
}
|
|
111
|
+
function registerEmitSubcommand(events) {
|
|
112
|
+
const emitCmd = events
|
|
113
|
+
.command('emit')
|
|
114
|
+
.description('Record events produced outside this process (reads JSONL on stdin)')
|
|
115
|
+
.requiredOption('--source <name>', 'Producer name; stamped as `module` so `events --module <name>` filters to it')
|
|
116
|
+
.option('--dry-run', 'Validate and report without writing')
|
|
117
|
+
.option('--json', 'Report {written, rejected} as JSON')
|
|
118
|
+
.action(async (opts, cmd) => {
|
|
119
|
+
// The parent `events` command also declares `--json` (for its read view).
|
|
120
|
+
// Commander binds a flag declared on BOTH to the parent, so a bare
|
|
121
|
+
// `events emit … --json` lands on parent.opts().json and the child sees
|
|
122
|
+
// undefined — the human string would print where a caller expects JSON.
|
|
123
|
+
// Read both, same as `feed post`.
|
|
124
|
+
const wantJson = Boolean(opts?.json ?? cmd?.parent?.opts?.().json);
|
|
125
|
+
const input = await readStdin();
|
|
126
|
+
if (input.trim() === '') {
|
|
127
|
+
console.error('events emit: nothing on stdin — pipe one JSON object per line.');
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let result;
|
|
132
|
+
try {
|
|
133
|
+
result = ingestBatch(input, { source: opts.source, dryRun: opts.dryRun });
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.error(`events emit: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (wantJson) {
|
|
141
|
+
console.log(JSON.stringify({ written: result.written, rejected: result.rejected, routed: result.routed }, null, 2));
|
|
142
|
+
}
|
|
143
|
+
else if (result.rejected.length === 0) {
|
|
144
|
+
console.log(`${result.written} event(s) recorded${opts.dryRun ? ' (dry run)' : ''}.`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
console.log(`${result.written} event(s) recorded${opts.dryRun ? ' (dry run)' : ''}.`);
|
|
148
|
+
console.error(`rejected ${result.rejected.length} line(s):`);
|
|
149
|
+
for (const r of result.rejected)
|
|
150
|
+
console.error(` line ${r.line}: ${r.reason}`);
|
|
151
|
+
}
|
|
152
|
+
// Valid siblings are already written; a non-zero exit reports that the
|
|
153
|
+
// batch was not fully accepted without pretending nothing landed.
|
|
154
|
+
if (result.rejected.length > 0)
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
});
|
|
157
|
+
setHelpSections(emitCmd, {
|
|
158
|
+
examples: `
|
|
159
|
+
# 1. Produce one JSON object per line (this is the whole input format).
|
|
160
|
+
printf '%s\\n' '{"event":"factory.command","commandId":"agents.newClaude"}' \\
|
|
161
|
+
| agents events emit --source factory
|
|
162
|
+
|
|
163
|
+
# 2. Flush a batch and check what landed.
|
|
164
|
+
agents events emit --source factory --json < batch.jsonl
|
|
165
|
+
|
|
166
|
+
# 3. Read it back. --limit 0 matters whenever you aggregate.
|
|
167
|
+
agents events --module factory --limit 0 --json
|
|
168
|
+
`,
|
|
169
|
+
notes: `
|
|
170
|
+
\`event\` is required and must be a known kind. \`ts\` (ISO-8601) is optional
|
|
171
|
+
and defaults to now — pass it so a batched producer records when each event
|
|
172
|
+
HAPPENED rather than when it flushed.
|
|
173
|
+
|
|
174
|
+
Envelope keys: ts, sessionId, mailboxId, terminalId, launchId, tmuxPane,
|
|
175
|
+
host, runtime, agent, tool, detail, url, project, cwd. Any other key is
|
|
176
|
+
payload and passes through the usual redaction.
|
|
177
|
+
|
|
178
|
+
A milestone kind (e.g. factory.launch) REQUIRES a sessionId — the activity
|
|
179
|
+
log is one file per session. Milestones route there; everything else goes
|
|
180
|
+
to the operational log. \`agents events\` reads both.
|
|
181
|
+
|
|
182
|
+
Rejection is per line: one bad line never discards the batch. Exit is 1 if
|
|
183
|
+
any line was rejected.
|
|
184
|
+
`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
100
187
|
export function registerEventsCommand(program) {
|
|
101
|
-
program
|
|
188
|
+
const events = program
|
|
102
189
|
.command('events')
|
|
103
190
|
.description('Read the unified event stream (operational + agent activity)')
|
|
104
191
|
.option('--module <name>', 'Only events from this group (e.g. teams, secrets, activity)')
|
|
@@ -166,6 +253,9 @@ Examples:
|
|
|
166
253
|
if (truncated)
|
|
167
254
|
console.log(chalk.yellow(capNote));
|
|
168
255
|
});
|
|
256
|
+
// `events` both reads (its own action, above) and writes (this subcommand) —
|
|
257
|
+
// the same shape as `feed` / `feed post`.
|
|
258
|
+
registerEmitSubcommand(events);
|
|
169
259
|
}
|
|
170
260
|
/** commander repeatable-option collector. */
|
|
171
261
|
function collect(value, previous) {
|
|
@@ -9,4 +9,14 @@
|
|
|
9
9
|
* lifecycle state, plan completion, open PRs, and tickets in flight.
|
|
10
10
|
*/
|
|
11
11
|
import type { Command } from 'commander';
|
|
12
|
+
/** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
|
|
13
|
+
export declare const PROJECTS_NO_FANOUT_ENV = "AGENTS_PROJECTS_LOCAL";
|
|
14
|
+
/**
|
|
15
|
+
* One compact trailing note for peers that didn't answer the `--fleet`
|
|
16
|
+
* fan-out — unreachable, running an agents-cli too old to carry `projects
|
|
17
|
+
* probe`, or too slow to finish inside the 12s SSH budget. Mirrors
|
|
18
|
+
* `formatUnreachableNote` (activity) with the probe-specific reasons; empty
|
|
19
|
+
* string when everything answered.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatFleetSkippedNote(skipped: string[]): string;
|
|
12
22
|
export declare function registerProjectsCommands(program: Command): void;
|
|
@@ -16,10 +16,36 @@ import { setHelpSections } from '../lib/help.js';
|
|
|
16
16
|
import { getMainRepoRoot } from '../lib/git.js';
|
|
17
17
|
import { parseOwnerRepoFromRemote } from '../lib/registry.js';
|
|
18
18
|
import { toHomeRelative } from '../lib/project-root.js';
|
|
19
|
+
import { machineId } from '../lib/machine-id.js';
|
|
19
20
|
import { getActiveSessions } from '../lib/session/active.js';
|
|
21
|
+
import { gatherRemoteActive } from '../lib/session/remote-active.js';
|
|
22
|
+
import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
|
|
20
23
|
import { factoryProjectsPath } from '../lib/auto-dispatch.js';
|
|
24
|
+
import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
|
|
21
25
|
import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
|
|
22
|
-
import { rollupSessionsByProject, planPct, enrichProjectSignals, } from '../lib/project-status.js';
|
|
26
|
+
import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
|
|
27
|
+
import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
|
|
28
|
+
import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
|
|
29
|
+
/** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
|
|
30
|
+
export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
|
|
31
|
+
/** Max peers named in the skipped note before the rest collapse to `+N`. */
|
|
32
|
+
const SKIPPED_NAME_LIMIT = 4;
|
|
33
|
+
/**
|
|
34
|
+
* One compact trailing note for peers that didn't answer the `--fleet`
|
|
35
|
+
* fan-out — unreachable, running an agents-cli too old to carry `projects
|
|
36
|
+
* probe`, or too slow to finish inside the 12s SSH budget. Mirrors
|
|
37
|
+
* `formatUnreachableNote` (activity) with the probe-specific reasons; empty
|
|
38
|
+
* string when everything answered.
|
|
39
|
+
*/
|
|
40
|
+
export function formatFleetSkippedNote(skipped) {
|
|
41
|
+
if (skipped.length === 0)
|
|
42
|
+
return '';
|
|
43
|
+
const named = skipped.slice(0, SKIPPED_NAME_LIMIT);
|
|
44
|
+
const rest = skipped.length - named.length;
|
|
45
|
+
const list = rest > 0 ? `${named.join(', ')} +${rest}` : named.join(', ');
|
|
46
|
+
const noun = skipped.length === 1 ? 'device' : 'devices';
|
|
47
|
+
return chalk.gray(` · ${skipped.length} ${noun} didn't answer (unreachable, older agents-cli, or timed out): ${list}\n`);
|
|
48
|
+
}
|
|
23
49
|
/** `path:purpose` → a context anchor. Purpose may contain colons. */
|
|
24
50
|
function parseContextFlag(raw) {
|
|
25
51
|
const i = raw.indexOf(':');
|
|
@@ -54,7 +80,7 @@ function statusBar(r) {
|
|
|
54
80
|
parts.push(chalk.gray(`+${r.agents - shown} other`));
|
|
55
81
|
return parts.join(' · ') || chalk.gray('no live agents');
|
|
56
82
|
}
|
|
57
|
-
function renderCard(def, r, remote) {
|
|
83
|
+
function renderCard(def, r, remote, fleet, linear) {
|
|
58
84
|
const agents = r?.agents ?? 0;
|
|
59
85
|
const pct = r ? planPct(r.plan) : undefined;
|
|
60
86
|
const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
|
|
@@ -62,6 +88,8 @@ function renderCard(def, r, remote) {
|
|
|
62
88
|
if (def.description)
|
|
63
89
|
console.log(` ${chalk.dim(def.description)}`);
|
|
64
90
|
console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
|
|
91
|
+
if (r && r.members.length)
|
|
92
|
+
console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
|
|
65
93
|
const ships = [];
|
|
66
94
|
if (remote?.mergedPrs)
|
|
67
95
|
ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
|
|
@@ -69,11 +97,25 @@ function renderCard(def, r, remote) {
|
|
|
69
97
|
ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
|
|
70
98
|
if (r?.worktrees)
|
|
71
99
|
ships.push(`${r.worktrees} worktree${r.worktrees === 1 ? '' : 's'}`);
|
|
100
|
+
if (remote?.latestRelease)
|
|
101
|
+
ships.push(remote.latestRelease.tag);
|
|
72
102
|
if (ships.length)
|
|
73
103
|
console.log(` ${chalk.dim('ships')} ${ships.join(' · ')}`);
|
|
104
|
+
if (linear) {
|
|
105
|
+
console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
|
|
106
|
+
}
|
|
74
107
|
if (r && r.tickets.length) {
|
|
75
108
|
console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
|
|
76
109
|
}
|
|
110
|
+
if (fleet) {
|
|
111
|
+
const lines = formatFleetWorkspaces(fleet);
|
|
112
|
+
if (lines.length === 0) {
|
|
113
|
+
console.log(` ${chalk.dim('fleet')} ${chalk.gray('no workspace paths (set root or repos[].path)')}`);
|
|
114
|
+
}
|
|
115
|
+
lines.forEach((line, i) => {
|
|
116
|
+
console.log(` ${chalk.dim((i === 0 ? 'fleet' : '').padEnd(5))} ${line}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
77
119
|
if (remote?.artifacts) {
|
|
78
120
|
const last = remote.lastArtifact ? ` ${chalk.dim(`· last: ${remote.lastArtifact}`)}` : '';
|
|
79
121
|
console.log(` ${chalk.dim('proof')} ${remote.artifacts} artifact${remote.artifacts === 1 ? '' : 's'} (${remote.windowDays}d)${last}`);
|
|
@@ -100,6 +142,8 @@ export function registerProjectsCommands(program) {
|
|
|
100
142
|
agents projects list
|
|
101
143
|
agents projects status # progress card for every project
|
|
102
144
|
agents projects status rush --json # one project, machine-readable
|
|
145
|
+
agents projects status --fleet # + per-device workspace drift over SSH
|
|
146
|
+
agents projects link rush --linear # bind the Linear project (auto-suggest)
|
|
103
147
|
agents run --project rush # land an agent in the project
|
|
104
148
|
`,
|
|
105
149
|
notes: `
|
|
@@ -107,9 +151,14 @@ export function registerProjectsCommands(program) {
|
|
|
107
151
|
machines with 'agents push/pull'. Enable with: agents beta enable projects.
|
|
108
152
|
`,
|
|
109
153
|
});
|
|
110
|
-
projects.hook('preAction', () => {
|
|
154
|
+
projects.hook('preAction', (_thisCommand, actionCommand) => {
|
|
111
155
|
if (enabled)
|
|
112
156
|
return;
|
|
157
|
+
// `probe` is the peer half of `status --fleet`: it must answer whenever the
|
|
158
|
+
// binary carries it, even where the beta flag is off — a gated peer would
|
|
159
|
+
// look unreachable to every fleet member on a newer CLI.
|
|
160
|
+
if (actionCommand.name() === 'probe')
|
|
161
|
+
return;
|
|
113
162
|
console.error(chalk.red('agents projects is in beta.'));
|
|
114
163
|
console.error(chalk.gray(betaEnableHint('projects')));
|
|
115
164
|
process.exit(1);
|
|
@@ -239,6 +288,7 @@ export function registerProjectsCommands(program) {
|
|
|
239
288
|
.option('--json', 'Machine-readable output')
|
|
240
289
|
.option('--window <days>', 'Window for merged PRs and artifacts', '7')
|
|
241
290
|
.option('--no-remote', 'Skip the GitHub lookup (merged-PR count); faster, offline')
|
|
291
|
+
.option('--fleet', 'Also dial every fleet device for workspace presence, branch, and drift (one SSH per peer)')
|
|
242
292
|
.action(async (name, opts) => {
|
|
243
293
|
const all = listProjectDefs();
|
|
244
294
|
// Named lookup goes through the strict single-def loader so a broken
|
|
@@ -257,14 +307,57 @@ export function registerProjectsCommands(program) {
|
|
|
257
307
|
}
|
|
258
308
|
const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
|
|
259
309
|
const nowMs = Date.now();
|
|
260
|
-
|
|
310
|
+
// --fleet: probe each shown def's workspace paths (root + repos[].path)
|
|
311
|
+
// locally and on every peer in one parallel SSH round, and widen the
|
|
312
|
+
// live-session rollup to the whole fleet via the existing sessions
|
|
313
|
+
// fan-out. Both are opt-in — they dial the fleet.
|
|
314
|
+
const fleetTargets = opts.fleet ? [...new Set(defs.flatMap(workspaceTargetsForDef))] : [];
|
|
315
|
+
let fleetWs = [];
|
|
316
|
+
let fleetSkipped = [];
|
|
317
|
+
let fleetSessions = [];
|
|
318
|
+
if (opts.fleet) {
|
|
319
|
+
const self = machineId();
|
|
320
|
+
fleetWs.push(...probeProjectWorkspaces(fleetTargets).map((s) => ({ ...s, host: self })));
|
|
321
|
+
const [probeRes, activeRes] = await Promise.all([
|
|
322
|
+
fleetTargets.length > 0
|
|
323
|
+
? gatherRemoteAgentsJson({
|
|
324
|
+
args: ['projects', 'probe', '--json', ...fleetTargets],
|
|
325
|
+
noFanoutEnv: PROJECTS_NO_FANOUT_ENV,
|
|
326
|
+
parse: parseRemoteProbe,
|
|
327
|
+
quiet: true,
|
|
328
|
+
})
|
|
329
|
+
: Promise.resolve({ items: [], deviceCount: 0, skipped: [] }),
|
|
330
|
+
gatherRemoteActive(undefined, { quiet: true }),
|
|
331
|
+
]);
|
|
332
|
+
fleetWs.push(...probeRes.items);
|
|
333
|
+
fleetSkipped = probeRes.skipped;
|
|
334
|
+
fleetSessions = activeRes.sessions;
|
|
335
|
+
}
|
|
336
|
+
const roll = rollupSessionsByProject(all, [...await getActiveSessions(), ...fleetSessions]);
|
|
261
337
|
// Enrich only the shown projects. --no-remote still reads the local artifact
|
|
262
|
-
// log but skips the gh
|
|
338
|
+
// log but skips the gh calls + Linear counts (both are network).
|
|
263
339
|
const remote = new Map();
|
|
340
|
+
const linear = new Map();
|
|
264
341
|
await Promise.all(defs.map(async (d) => {
|
|
265
|
-
|
|
342
|
+
const skipRemote = opts.remote === false;
|
|
343
|
+
const [sig, counts] = await Promise.all([
|
|
344
|
+
enrichProjectSignals(d, windowDays, nowMs, { skipRemote }),
|
|
345
|
+
!skipRemote && d.linear?.projectId
|
|
346
|
+
? fetchLinearProjectCounts(d.linear.projectId)
|
|
347
|
+
: Promise.resolve(undefined),
|
|
348
|
+
]);
|
|
349
|
+
remote.set(d.name, sig);
|
|
350
|
+
if (counts)
|
|
351
|
+
linear.set(d.name, counts);
|
|
266
352
|
}));
|
|
353
|
+
/** This def's slice of the fleet probe, in its own target order. */
|
|
354
|
+
const fleetFor = (d) => {
|
|
355
|
+
const targets = new Set(workspaceTargetsForDef(d));
|
|
356
|
+
return fleetWs.filter((s) => targets.has(s.path));
|
|
357
|
+
};
|
|
267
358
|
if (opts.json) {
|
|
359
|
+
if (fleetSkipped.length > 0)
|
|
360
|
+
process.stderr.write(formatFleetSkippedNote(fleetSkipped));
|
|
268
361
|
console.log(JSON.stringify(defs.map((d) => {
|
|
269
362
|
const r = roll.get(d.name);
|
|
270
363
|
const rem = remote.get(d.name);
|
|
@@ -272,22 +365,39 @@ export function registerProjectsCommands(program) {
|
|
|
272
365
|
name: d.name,
|
|
273
366
|
agents: r?.agents ?? 0,
|
|
274
367
|
byStatus: r?.byStatus ?? {},
|
|
368
|
+
members: r?.members ?? [],
|
|
275
369
|
plan: r?.plan ?? { done: 0, total: 0 },
|
|
276
370
|
planPct: r ? planPct(r.plan) ?? null : null,
|
|
277
371
|
openPrs: r?.openPrs ?? [],
|
|
278
372
|
mergedPrs: rem?.mergedPrs ?? 0,
|
|
373
|
+
latestRelease: rem?.latestRelease ?? null,
|
|
374
|
+
linear: linear.get(d.name) ?? null,
|
|
279
375
|
tickets: r?.tickets ?? [],
|
|
280
376
|
worktrees: r?.worktrees ?? 0,
|
|
281
377
|
artifacts: rem?.artifacts ?? 0,
|
|
282
378
|
lastArtifact: rem?.lastArtifact ?? null,
|
|
283
379
|
windowDays,
|
|
284
380
|
repos: [d.repo, ...(d.repos ?? []).map((r2) => r2.slug)].filter(Boolean),
|
|
381
|
+
...(opts.fleet ? { workspaces: fleetFor(d) } : {}),
|
|
285
382
|
};
|
|
286
383
|
}), null, 2));
|
|
287
384
|
return;
|
|
288
385
|
}
|
|
289
|
-
for (const d of defs)
|
|
290
|
-
renderCard(d, roll.get(d.name), remote.get(d.name));
|
|
386
|
+
for (const d of defs) {
|
|
387
|
+
renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name));
|
|
388
|
+
}
|
|
389
|
+
if (fleetSkipped.length > 0)
|
|
390
|
+
process.stdout.write(formatFleetSkippedNote(fleetSkipped));
|
|
391
|
+
});
|
|
392
|
+
// ---- probe (hidden; the peer half of `status --fleet`) ----
|
|
393
|
+
projects
|
|
394
|
+
.command('probe [paths...]', { hidden: true })
|
|
395
|
+
.description('Probe workspace repos (presence, branch, drift, dirtiness) and print JSON. Answers for this machine only.')
|
|
396
|
+
.option('--json', 'Machine-readable output (the only output format)')
|
|
397
|
+
.action((paths) => {
|
|
398
|
+
// Never fans out — the recursion guard env is set by the parent fan-out,
|
|
399
|
+
// and this command has no remote code path either way.
|
|
400
|
+
console.log(JSON.stringify(probeProjectWorkspaces(paths ?? []), null, 2));
|
|
291
401
|
});
|
|
292
402
|
// ---- import ----
|
|
293
403
|
projects
|
|
@@ -341,6 +451,77 @@ export function registerProjectsCommands(program) {
|
|
|
341
451
|
}
|
|
342
452
|
console.log(chalk.green(`Imported ${created} project${created === 1 ? '' : 's'}${skipped ? chalk.gray(` (${skipped} skipped)`) : ''}`));
|
|
343
453
|
});
|
|
454
|
+
// ---- link ----
|
|
455
|
+
projects
|
|
456
|
+
.command('link <name>')
|
|
457
|
+
.description('Attach an external tracker to a project definition (writes linear.projectId into the YAML).')
|
|
458
|
+
.option('--linear [query]', 'Bind a Linear project by exact name or id; no value auto-suggests from the def name + repo')
|
|
459
|
+
.action((name, opts) => {
|
|
460
|
+
const def = loadProjectDef(name);
|
|
461
|
+
if (!def) {
|
|
462
|
+
console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
if (opts.linear === undefined) {
|
|
466
|
+
console.error(chalk.red('Nothing to link. Pass a tracker flag, e.g. --linear [query].'));
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
// Explicit user command — a missing/unauthenticated `linear` CLI is loud,
|
|
470
|
+
// unlike the best-effort card enrichment.
|
|
471
|
+
let list;
|
|
472
|
+
try {
|
|
473
|
+
list = listLinearProjects();
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
console.error(chalk.red(e instanceof Error ? e.message : String(e)));
|
|
477
|
+
process.exit(1);
|
|
478
|
+
}
|
|
479
|
+
const query = typeof opts.linear === 'string' ? opts.linear.trim() : '';
|
|
480
|
+
let pick;
|
|
481
|
+
if (query) {
|
|
482
|
+
pick = pickLinearProject(query, list);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
// Auto-suggest from the def's own identity — the primary repo slug is
|
|
486
|
+
// the sharpest key, then the def name. Only an exact normalized match
|
|
487
|
+
// writes itself; anything weaker asks the user to disambiguate.
|
|
488
|
+
pick = { kind: 'none' };
|
|
489
|
+
for (const hint of [def.repo, def.name].filter((h) => typeof h === 'string' && h.length > 0)) {
|
|
490
|
+
const d = pickLinearProject(hint, list);
|
|
491
|
+
if (d.kind === 'match') {
|
|
492
|
+
pick = d;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
if (pick.kind === 'none')
|
|
496
|
+
pick = d;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (pick.kind !== 'match') {
|
|
500
|
+
if (pick.kind === 'candidates') {
|
|
501
|
+
console.error(chalk.yellow(`No confident Linear match${query ? ` for "${query}"` : ` for "${def.name}"`}. Candidates:`));
|
|
502
|
+
for (const c of pick.projects)
|
|
503
|
+
console.error(` ${chalk.cyan(c.id)} ${c.name}`);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.error(chalk.yellow(`No Linear project matches${query ? ` "${query}"` : ` "${def.name}"`}. Available:`));
|
|
507
|
+
for (const c of list)
|
|
508
|
+
console.error(` ${chalk.cyan(c.id)} ${c.name}`);
|
|
509
|
+
}
|
|
510
|
+
console.error(chalk.gray(`Re-run with an explicit name or id: agents projects link ${name} --linear "<name-or-id>"`));
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
const p = pick.project;
|
|
514
|
+
// Preserve every other field — load, set linear, write back. Keep a
|
|
515
|
+
// previously linked url when the new CLI row carries none.
|
|
516
|
+
if (def.linear?.projectId && def.linear.projectId !== p.id) {
|
|
517
|
+
console.log(chalk.gray(` replacing previous Linear link (${def.linear.projectId})`));
|
|
518
|
+
}
|
|
519
|
+
def.linear = { ...def.linear, projectId: p.id };
|
|
520
|
+
if (p.url)
|
|
521
|
+
def.linear.url = p.url;
|
|
522
|
+
writeProjectDef(def);
|
|
523
|
+
console.log(chalk.green(`${def.name} → Linear project "${p.name}" (${p.id})${p.url ? ` ${p.url}` : ''}`));
|
|
524
|
+
});
|
|
344
525
|
// ---- rm ----
|
|
345
526
|
projects
|
|
346
527
|
.command('rm <name>')
|
|
@@ -10,6 +10,7 @@ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
|
|
|
10
10
|
import { quoteWin32ExecArg } from '../lib/platform/index.js';
|
|
11
11
|
import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
|
|
12
12
|
import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
|
|
13
|
+
import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
|
|
13
14
|
/** Read all available data from stdin synchronously, trimmed. */
|
|
14
15
|
/**
|
|
15
16
|
* Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
|
|
@@ -125,6 +126,22 @@ export declare function formatHoldWindow(ms: number): string;
|
|
|
125
126
|
* shared by the POLICY column, the `--held` filter, and the JSON payload, so the
|
|
126
127
|
* three can never disagree about the same bundle. */
|
|
127
128
|
export declare function liveHold(expiresAt: number | undefined, now?: number): number | null;
|
|
129
|
+
/**
|
|
130
|
+
* Compact one-line usage summary for `secrets view` — each recorded event with
|
|
131
|
+
* its count and how long ago it last happened ("accessed 42× (last 2h ago) ·
|
|
132
|
+
* exported 3× (last 1d ago)"). Empty string when nothing has been recorded yet.
|
|
133
|
+
* Pure — unit-tested.
|
|
134
|
+
*/
|
|
135
|
+
export declare function formatUsageLine(summary: BundleUsageSummary | undefined): string;
|
|
136
|
+
/**
|
|
137
|
+
* The held-state line for `secrets view`: whether the secrets-agent currently
|
|
138
|
+
* holds this bundle (so reads are prompt-free) and for how long, or that Touch
|
|
139
|
+
* ID is required on the next read. Only meaningful for keychain bundles on macOS
|
|
140
|
+
* — file/vault backends have no broker to hold, and a `never`-policy bundle is
|
|
141
|
+
* always readable — so both return an empty line and the caller skips it.
|
|
142
|
+
* `heldExpiresAt` is the max expiry across every held scope.
|
|
143
|
+
*/
|
|
144
|
+
export declare function renderViewStatusLine(b: SecretsBundle, heldExpiresAt: number | null): string;
|
|
128
145
|
/**
|
|
129
146
|
* The EXPIRING cell. Counts keys needing attention — already expired plus due
|
|
130
147
|
* within 30 days — and colours by the worst of the two: red once anything has
|