@phnx-labs/agents-cli 1.20.85 → 1.20.87
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 +168 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +44 -5
- package/dist/commands/models.js +1 -1
- package/dist/commands/sessions-browser.d.ts +18 -0
- package/dist/commands/sessions-browser.js +126 -24
- package/dist/commands/sessions-picker.d.ts +21 -8
- package/dist/commands/sessions-picker.js +88 -11
- package/dist/commands/sessions.d.ts +19 -0
- package/dist/commands/sessions.js +147 -18
- package/dist/commands/ssh.js +59 -1
- package/dist/commands/teams-picker.d.ts +2 -0
- package/dist/commands/teams-picker.js +2 -1
- package/dist/commands/teams.d.ts +4 -1
- package/dist/commands/teams.js +106 -70
- package/dist/commands/view.js +14 -3
- package/dist/index.js +31 -1
- package/dist/lib/claude-account-token.d.ts +12 -0
- package/dist/lib/claude-account-token.js +63 -0
- package/dist/lib/devices/registry.d.ts +25 -0
- package/dist/lib/devices/registry.js +82 -1
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +13 -0
- package/dist/lib/exec.js +10 -1
- package/dist/lib/format.d.ts +7 -0
- package/dist/lib/format.js +11 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/models.d.ts +21 -0
- package/dist/lib/models.js +133 -4
- 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/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/session/db.d.ts +21 -0
- package/dist/lib/session/db.js +45 -4
- package/dist/lib/session/parse.d.ts +11 -0
- package/dist/lib/session/parse.js +24 -7
- package/dist/lib/session/remote-list.d.ts +7 -0
- package/dist/lib/session/remote-list.js +8 -4
- package/dist/lib/session/state.d.ts +6 -5
- package/dist/lib/session/state.js +84 -11
- package/dist/lib/session/team-filter.d.ts +22 -3
- package/dist/lib/session/team-filter.js +106 -17
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/signin-badge.d.ts +17 -0
- package/dist/lib/signin-badge.js +19 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/usage.js +1 -60
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,173 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.87
|
|
4
|
+
|
|
5
|
+
- **`agents devices enable|disable|prefer|unprefer <name>` control which machines
|
|
6
|
+
Factory auto-launches onto.** A *disabled* device is skipped by
|
|
7
|
+
`New <Agent>` and the balanced launch, but stays available through
|
|
8
|
+
`New <Agent> (Pick Host)`. A *preferred* device wins ties against otherwise
|
|
9
|
+
equivalent machines — worth about two running agents in the ranking, so a
|
|
10
|
+
preference never sends work to a box that is genuinely swamped. Every device
|
|
11
|
+
is enabled and unpreferred by default, and an unregistered name is now
|
|
12
|
+
rejected instead of writing a preference that matches nothing. Preferences
|
|
13
|
+
live in `~/.agents/.history/devices/auto-launch.json`, written by the CLI and
|
|
14
|
+
read by the extension. Source: `apps/cli/src/lib/devices/registry.ts`,
|
|
15
|
+
`apps/cli/src/commands/ssh.ts`, `apps/factory/src/core/deviceAutoLaunch.ts`,
|
|
16
|
+
`apps/factory/src/core/launchHost.ts`.
|
|
17
|
+
|
|
18
|
+
- **Point-of-use friction events for `agents teams` failures.** The CLI's `die()`
|
|
19
|
+
chokepoints in `teams` now emit a structured `friction` event (`surface`,
|
|
20
|
+
`failureId`, `error`) before exiting, so the nightly factory-metrics routine can
|
|
21
|
+
rank recurring failures without re-parsing transcripts. A hidden
|
|
22
|
+
`agents _internal friction` recorder lets shell guard hooks (git-guard, rm-guard,
|
|
23
|
+
large-file-add-guard) self-report blocks into the same stream. Source:
|
|
24
|
+
`apps/cli/src/lib/events.ts`, `apps/cli/src/lib/format.ts`,
|
|
25
|
+
`apps/cli/src/commands/teams.ts`, `apps/cli/src/index.ts`.
|
|
26
|
+
|
|
27
|
+
- **`agents view` no longer reports a working Claude install as "logged out".**
|
|
28
|
+
Claude's `signedIn` is `!!email` read from a version home's `.claude.json`
|
|
29
|
+
(`lib/agents.ts`), so a version that authenticates from an ambient
|
|
30
|
+
`CLAUDE_CODE_OAUTH_TOKEN` — no account ever written to that home — rendered
|
|
31
|
+
"(logged out — log in with: claude, then /login)" while every run against it
|
|
32
|
+
succeeded. On one fleet box five of seven versions read as locked out and all
|
|
33
|
+
of them answered a live prompt. Those now render "(no per-version login —
|
|
34
|
+
using ambient CLAUDE_CODE_OAUTH_TOKEN)", which is both accurate and the more
|
|
35
|
+
useful warning: an ambient token is ONE account, so balanced rotation across
|
|
36
|
+
those versions rotates nothing. Source: `apps/cli/src/lib/signin-badge.ts`,
|
|
37
|
+
`apps/cli/src/commands/view.ts`.
|
|
38
|
+
|
|
39
|
+
- **Claude per-account run tokens.** `agents run` now injects the Claude setup token keyed to the selected version home's own account email, so balanced Claude rotation no longer inherits one ambient shared token across accounts. Source: `src/lib/exec.ts`.
|
|
40
|
+
|
|
41
|
+
- **`agents sessions` now shows which session spawned which team.** The link
|
|
42
|
+
already existed on disk and was discarded twice. `SessionMeta.spawnedTeam` — the
|
|
43
|
+
team name read off the `agents teams create/add` command at scan time — had no
|
|
44
|
+
column in `sessions.db`, so the writer dropped it and no consumer had ever seen
|
|
45
|
+
a non-`undefined` value; a new `spawned_team` column (schema **v21**, which
|
|
46
|
+
forces one full rescan) persists it, and orchestrator rows now carry a green
|
|
47
|
+
`team:<name>` badge. Separately, `classifyTeamSession` was already opening each
|
|
48
|
+
teammate's `meta.json` and throwing away its `task_name` and
|
|
49
|
+
`parent_session_id`, so a teammate row could not name its team or point back at
|
|
50
|
+
its orchestrator; teammate rows now read `[<team>/<handle>]` and the preview
|
|
51
|
+
pane carries a `Team:` line from either end of the lineage. New `--in-team
|
|
52
|
+
<name>` (and a `t` hotkey in the browser) filters to one team's orchestrator
|
|
53
|
+
plus its teammates, `agents teams status --parent-session <id>` lists the
|
|
54
|
+
teammates a given session spawned, and `agents teams list` gains a `by <id>`
|
|
55
|
+
column. Source: `apps/cli/src/lib/session/db.ts`,
|
|
56
|
+
`apps/cli/src/lib/session/team-filter.ts`, `apps/cli/src/commands/sessions.ts`,
|
|
57
|
+
`apps/cli/src/commands/sessions-browser.ts`, `apps/cli/src/commands/teams.ts`.
|
|
58
|
+
|
|
59
|
+
- **`agents sessions --device <box>` no longer opens an empty browser.** The
|
|
60
|
+
interactive one-host listing kept the browser's default this-repo scope, but
|
|
61
|
+
every row it fetches is the peer's and no peer cwd is under the local
|
|
62
|
+
`process.cwd()` — so the filter dropped all of them. A host scope now implies
|
|
63
|
+
all-directories (and the `p` hotkey is a no-op under one). Three more
|
|
64
|
+
scope bugs on the same path: `--device <this machine>` fanned out to the whole
|
|
65
|
+
tailnet, because `gatherRemoteList` reads the resulting empty peer list as "no
|
|
66
|
+
hosts given" and sweeps; `--local --device <box>` rendered a silent empty list
|
|
67
|
+
instead of reporting that the two flags ask for opposite things; and
|
|
68
|
+
`--device <box> --cloud` fell through to the cloud listing, which has no host
|
|
69
|
+
scope and silently ignored the device. An unreachable peer now says so in the
|
|
70
|
+
browser header — the fan-out's stderr note is repainted away by the full-screen
|
|
71
|
+
picker, so "that box is asleep" used to read as "no sessions match". Source:
|
|
72
|
+
`apps/cli/src/commands/sessions.ts`, `apps/cli/src/commands/sessions-browser.ts`,
|
|
73
|
+
`apps/cli/src/lib/session/remote-list.ts`.
|
|
74
|
+
|
|
75
|
+
- **Teammate records are found by the session id they actually produced.** A
|
|
76
|
+
teammate's directory under `teams/agents/` is named for its *agent* id, but the
|
|
77
|
+
harness mints its own session id and the spawn records it separately as
|
|
78
|
+
`remote_session_id`. `classifyTeamSession` looked only under the directory
|
|
79
|
+
name, so most teammates were unreachable — on a live box, 14 of 16 records
|
|
80
|
+
resolved only via `remote_session_id` — and their rows could not name their
|
|
81
|
+
team however complete the record was. Both keys are now registered in one
|
|
82
|
+
index built per process, which also replaces the `existsSync` + `readFileSync`
|
|
83
|
+
the old path paid for every row in the pool. Source:
|
|
84
|
+
`apps/cli/src/lib/session/team-filter.ts`.
|
|
85
|
+
|
|
86
|
+
- **`detectSpawnedTeam` no longer indexes prose or flag values as team names.**
|
|
87
|
+
Rendering the value exposed that it had been wrong for most of the rows that
|
|
88
|
+
had one: on a live index of 4627 sessions, 11 carried a team and 6 of those
|
|
89
|
+
read `2` or `t`. It matched documentation and echoed output rather than only
|
|
90
|
+
executed commands; its flag-skip used `\s` and so ran across a newline to
|
|
91
|
+
capture a word from the next line; and a value-taking flag did not swallow its
|
|
92
|
+
value, so `--device auto` left `auto` looking like the team name — which was
|
|
93
|
+
corrupting real detections, not just adding false ones. After the fix the same
|
|
94
|
+
index resolves ten teams, every one of them a real team name. Source:
|
|
95
|
+
`apps/cli/src/lib/session/state.ts`.
|
|
96
|
+
|
|
97
|
+
- **`--in-team` returns a team's whole lineage, not the slice inside the default
|
|
98
|
+
window.** It filtered in memory after the query, so a team older than the
|
|
99
|
+
default top-50 / 30-day / current-directory scope came back empty with no
|
|
100
|
+
message — and a team's teammates run in their own worktrees, which the
|
|
101
|
+
directory scope hid. The flag now widens its own scope the way `--all` does. It
|
|
102
|
+
is also refused with `--active`, whose live rows carry no lineage to match on,
|
|
103
|
+
rather than being silently ignored. Source: `apps/cli/src/commands/sessions.ts`.
|
|
104
|
+
|
|
105
|
+
- **The session preview pane sanitizes peer-supplied `plan` and directory
|
|
106
|
+
text.** A remote row's metadata is JSON the peer sent and `parseRemoteList`
|
|
107
|
+
hands over verbatim; `sanitizeMeta` covered `topic`/`label`/`cwd`/`todos` but
|
|
108
|
+
not these, so a terminal escape in another machine's plan text reached the TTY.
|
|
109
|
+
The remote preview also renders more of what already rides across the hop: the
|
|
110
|
+
checklist items, the directories the scan recorded, and a one-line plan summary
|
|
111
|
+
(never the full markdown blob). `directoriesTouched` now reads the real
|
|
112
|
+
`recentDirectoriesTouched` field instead of a `dirsTouched` that nothing in the
|
|
113
|
+
repo ever wrote. Source: `apps/cli/src/commands/sessions-picker.ts`.
|
|
114
|
+
|
|
115
|
+
## 1.20.86
|
|
116
|
+
|
|
117
|
+
- **`agents sessions` now shows a Kimi session's todo list and its file-touching
|
|
118
|
+
tool calls.** Kimi writes its checklist with `TodoList` (items shaped
|
|
119
|
+
`{title, status}`, where finished is `done`) rather than Claude's `TodoWrite`
|
|
120
|
+
(`{content, status: "completed"}`), so the checklist registry matched nothing
|
|
121
|
+
and every Kimi session rendered with no todos — in the picker preview, the
|
|
122
|
+
session detail, and the `--active` fan-out that carries progress off remote
|
|
123
|
+
devices. Kimi also names the file argument `path` where Claude names it
|
|
124
|
+
`file_path`, so `Read`/`Write`/`Edit` calls summarized as a bare `Read ` with
|
|
125
|
+
no file. Both spellings are now handled, and the snapshot-checklist tool names
|
|
126
|
+
live in one exported registry (`SNAPSHOT_TODO_TOOLS`) that the picker and the
|
|
127
|
+
state engine share instead of each hardcoding its own pair. Source:
|
|
128
|
+
`apps/cli/src/lib/session/parse.ts`, `apps/cli/src/lib/session/state.ts`,
|
|
129
|
+
`apps/cli/src/commands/sessions-picker.ts`.
|
|
130
|
+
|
|
131
|
+
- **`agents view` now shows Grok's default model (e.g. `grok-4.5`).** Claude,
|
|
132
|
+
Codex, Antigravity, and Kimi already filled the model column via their
|
|
133
|
+
catalogs; Grok was missing from `locateModelSource`, so
|
|
134
|
+
`resolveConfiguredModel` returned null and the column stayed blank. Grok has
|
|
135
|
+
no `settings.json` `model` field (its config is `config.toml` +
|
|
136
|
+
`models_cache.json`); the authoritative default is `grok models` →
|
|
137
|
+
`Default model: <id>`. The catalog extractor now spawns that command against
|
|
138
|
+
the version-home binary (skipping failed-download stubs) and flags the
|
|
139
|
+
default, so `agents view`, `agents view --json` (`configuredModel`), and the
|
|
140
|
+
other identity-cluster surfaces show it. Source: `apps/cli/src/lib/models.ts`,
|
|
141
|
+
`apps/cli/src/commands/models.ts`.
|
|
142
|
+
|
|
143
|
+
- **`agents events --limit 0` now reads the whole stream, and a capped read says
|
|
144
|
+
so.** `--limit` parsed as `Math.max(1, parseInt(raw) || 50)`, so `--limit 0`
|
|
145
|
+
collapsed back to `50` (`0 || 50`) and there was no way to read past the default
|
|
146
|
+
cap at all. The cap is applied after filtering and before the caller sees
|
|
147
|
+
anything, so every aggregation over `--json` silently ranked the newest 50
|
|
148
|
+
records instead of the matching set — measured against a real 7-day corpus of
|
|
149
|
+
2,135 CLI failures in 9 classes, 8 of 9 ranks came out wrong with counts off by
|
|
150
|
+
roughly 100x, and nothing warned. `--limit 0` now means no cap (29,649 records
|
|
151
|
+
on a 30-day stream here, against 50 before), a truncated read prints
|
|
152
|
+
`Showing the newest 50 — more events matched. Pass --limit 0 for all.` (on
|
|
153
|
+
stderr under `--json`, so a `| jq` pipeline still receives clean JSON), and a
|
|
154
|
+
non-numeric, negative, or empty `--limit` exits 2 rather than quietly becoming
|
|
155
|
+
50 — an empty one (`--limit "$LIMIT"` with the variable unset) would otherwise
|
|
156
|
+
have read as "no cap" and returned the whole stream unannounced.
|
|
157
|
+
Source: `apps/cli/src/commands/events.ts`, `apps/cli/tests/events-limit.test.ts`,
|
|
158
|
+
`apps/cli/docs/06-observability.md`.
|
|
159
|
+
|
|
160
|
+
- **Desktop notifications now show the current agents-cli mark, not the old
|
|
161
|
+
logo.** The menu-bar helper's app icon — the icon macOS puts on the left of
|
|
162
|
+
every notification banner it posts (the menu bar helper's own notices and every
|
|
163
|
+
`agents run --notify` finish notice) — was generated from the retired gradient
|
|
164
|
+
"A" logo, so notifications carried stale branding while the menu-bar status
|
|
165
|
+
item already used the new lowercase `a`. The shared master logo
|
|
166
|
+
(`assets/logo.png`) is now the current `a` mark, so the menu-bar helper, the
|
|
167
|
+
`agents computer` helper, and the keychain helper all regenerate their
|
|
168
|
+
`AppIcon.icns` from it on the next build. Source: `assets/logo.png`,
|
|
169
|
+
`apps/cli/menubar/scripts/build.sh`.
|
|
170
|
+
|
|
3
171
|
## 1.20.85
|
|
4
172
|
|
|
5
173
|
- **`agents feed post` can now be mirrored to the systems you actually watch.**
|
package/dist/bin/agents
CHANGED
|
Binary file
|
|
@@ -14,4 +14,20 @@
|
|
|
14
14
|
* today's operational log live.
|
|
15
15
|
*/
|
|
16
16
|
import type { Command } from 'commander';
|
|
17
|
+
/**
|
|
18
|
+
* Resolve `--limit` into a record cap. `0` means "no cap" — without it there is
|
|
19
|
+
* no way to read the whole stream, and any aggregation (group-by failure, count
|
|
20
|
+
* per module) silently ranks the newest 50 records instead of the real set.
|
|
21
|
+
* A non-numeric or negative value is a usage error, not a quiet fallback.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveEventsLimit(raw: string | undefined): number | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Cap `fetched` (read with `limit + 1`) to `limit`, reporting whether records
|
|
26
|
+
* were dropped. The caller announces the cap so a truncated read is never
|
|
27
|
+
* mistaken for the complete set.
|
|
28
|
+
*/
|
|
29
|
+
export declare function capRecords<T>(fetched: T[], limit: number | undefined): {
|
|
30
|
+
records: T[];
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
};
|
|
17
33
|
export declare function registerEventsCommand(program: Command): void;
|
package/dist/commands/events.js
CHANGED
|
@@ -17,6 +17,33 @@ 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
|
+
/**
|
|
21
|
+
* Resolve `--limit` into a record cap. `0` means "no cap" — without it there is
|
|
22
|
+
* no way to read the whole stream, and any aggregation (group-by failure, count
|
|
23
|
+
* per module) silently ranks the newest 50 records instead of the real set.
|
|
24
|
+
* A non-numeric or negative value is a usage error, not a quiet fallback.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveEventsLimit(raw) {
|
|
27
|
+
const token = raw ?? '50';
|
|
28
|
+
// Number('') and Number(' ') are both 0, which would read as "no cap" — an
|
|
29
|
+
// empty --limit (an unset "$LIMIT" in a script) must be rejected, not silently
|
|
30
|
+
// turned into the unbounded read.
|
|
31
|
+
const value = token.trim() === '' ? NaN : Number(token);
|
|
32
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
33
|
+
throw new RangeError(`Invalid --limit ${raw} — pass a whole number, or 0 for no cap.`);
|
|
34
|
+
}
|
|
35
|
+
return value === 0 ? undefined : value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Cap `fetched` (read with `limit + 1`) to `limit`, reporting whether records
|
|
39
|
+
* were dropped. The caller announces the cap so a truncated read is never
|
|
40
|
+
* mistaken for the complete set.
|
|
41
|
+
*/
|
|
42
|
+
export function capRecords(fetched, limit) {
|
|
43
|
+
if (limit === undefined || fetched.length <= limit)
|
|
44
|
+
return { records: fetched, truncated: false };
|
|
45
|
+
return { records: fetched.slice(0, limit), truncated: true };
|
|
46
|
+
}
|
|
20
47
|
/** Parse `--since`: relative offsets (30s/5m/2h/7d/4w) or an ISO/absolute date. */
|
|
21
48
|
function parseSince(s) {
|
|
22
49
|
const m = s.match(/^(\d+)([smhdw])$/);
|
|
@@ -80,7 +107,7 @@ export function registerEventsCommand(program) {
|
|
|
80
107
|
.option('--agent <name>', 'Only events tagged with this agent')
|
|
81
108
|
.option('--since <time>', 'Only events newer than this (e.g. 2h, 7d, or ISO date)')
|
|
82
109
|
.option('--audit', 'Operational events only (skip agent activity)')
|
|
83
|
-
.option('--limit <n>', 'Max records to show (default 50)', '50')
|
|
110
|
+
.option('--limit <n>', 'Max records to show; 0 for no cap (default 50)', '50')
|
|
84
111
|
.option('--json', 'Output raw records as JSON')
|
|
85
112
|
.option('-f, --follow', "Tail today's operational log live")
|
|
86
113
|
.addHelpText('after', `
|
|
@@ -90,31 +117,41 @@ Examples:
|
|
|
90
117
|
agents events --audit Operational events only (secrets / teams / ...)
|
|
91
118
|
agents events --event pr.opened --since 7d
|
|
92
119
|
agents events --module secrets Every secret accessed or revealed
|
|
93
|
-
agents events -f Live tail (operational)
|
|
120
|
+
agents events -f Live tail (operational)
|
|
121
|
+
agents events --event pr.opened --since 30d --limit 0 --json
|
|
122
|
+
Every match — use --limit 0 whenever you
|
|
123
|
+
aggregate, or you rank only the newest 50`)
|
|
94
124
|
.action(async (options) => {
|
|
95
125
|
if (options.follow) {
|
|
96
126
|
await followLog();
|
|
97
127
|
return;
|
|
98
128
|
}
|
|
99
|
-
|
|
129
|
+
let limit;
|
|
100
130
|
let startDate;
|
|
101
131
|
try {
|
|
132
|
+
limit = resolveEventsLimit(options.limit);
|
|
102
133
|
startDate = options.since ? parseSince(options.since) : undefined;
|
|
103
134
|
}
|
|
104
135
|
catch (err) {
|
|
105
136
|
console.error(chalk.red(err.message));
|
|
106
137
|
process.exit(2);
|
|
107
138
|
}
|
|
108
|
-
|
|
139
|
+
// Read one past the cap so we can tell a full result from a clipped one.
|
|
140
|
+
const fetched = readUnifiedEvents({
|
|
109
141
|
startDate,
|
|
110
142
|
eventTypes: options.event && options.event.length ? options.event : undefined,
|
|
111
143
|
agent: options.agent,
|
|
112
144
|
command: options.command,
|
|
113
145
|
module: options.module,
|
|
114
|
-
limit,
|
|
146
|
+
limit: limit === undefined ? undefined : limit + 1,
|
|
115
147
|
includeActivity: !options.audit,
|
|
116
148
|
});
|
|
149
|
+
const { records, truncated } = capRecords(fetched, limit);
|
|
150
|
+
const capNote = `Showing the newest ${limit} — more events matched. Pass --limit 0 for all.`;
|
|
117
151
|
if (options.json) {
|
|
152
|
+
// Notice goes to stderr so `--json | jq` still receives clean JSON.
|
|
153
|
+
if (truncated)
|
|
154
|
+
console.error(chalk.yellow(capNote));
|
|
118
155
|
console.log(JSON.stringify(records, null, 2));
|
|
119
156
|
return;
|
|
120
157
|
}
|
|
@@ -126,6 +163,8 @@ Examples:
|
|
|
126
163
|
for (const r of records.slice().reverse())
|
|
127
164
|
console.log(renderRow(r));
|
|
128
165
|
console.log(chalk.gray(`\n${records.length} event(s). Log: ${getLogsPath()}`));
|
|
166
|
+
if (truncated)
|
|
167
|
+
console.log(chalk.yellow(capNote));
|
|
129
168
|
});
|
|
130
169
|
}
|
|
131
170
|
/** commander repeatable-option collector. */
|
package/dist/commands/models.js
CHANGED
|
@@ -13,7 +13,7 @@ import { listInstalledVersions, getGlobalDefault, resolveVersion, resolveVersion
|
|
|
13
13
|
import { getModelCatalog, locateModelSource } from '../lib/models.js';
|
|
14
14
|
import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
|
|
15
15
|
import { wrapJoined } from './inspect.js';
|
|
16
|
-
const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi'];
|
|
16
|
+
const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi', 'grok'];
|
|
17
17
|
/**
|
|
18
18
|
* Agents that don't necessarily install under ~/.agents/versions (cursor ships
|
|
19
19
|
* via a curl script). For these, fall back to the PATH binary and synthesize
|
|
@@ -24,11 +24,27 @@ export interface BrowserFilter {
|
|
|
24
24
|
agent?: string;
|
|
25
25
|
/** filter to one machine, or all — the `D` key / `--device`. */
|
|
26
26
|
device?: string;
|
|
27
|
+
/** filter to one team's lineage, or all — the `T` key / `--in-team`. */
|
|
28
|
+
team?: string;
|
|
27
29
|
/** this-repo subtree vs every directory — the `P` key / `--all`. */
|
|
28
30
|
projectScope: 'repo' | 'all';
|
|
29
31
|
/** time window (undefined = all time) — the `W` key / `--since`. */
|
|
30
32
|
window?: string;
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Complete a seed into the filter the picker actually runs on.
|
|
36
|
+
*
|
|
37
|
+
* Every optional field of {@link BrowserFilter} has to be named here, because the
|
|
38
|
+
* seed is copied field-by-field and an omission is silent: the field is optional,
|
|
39
|
+
* so the compiler says nothing and the browser just opens without that filter.
|
|
40
|
+
* `team` was dropped exactly this way, which made `--in-team` a no-op on the
|
|
41
|
+
* interactive path while the scope half of the same seed still applied — so the
|
|
42
|
+
* view opened wide and all-time and looked like the flag had worked.
|
|
43
|
+
*
|
|
44
|
+
* Exported so a test can assert the FILTER, not just the seed; testing the seed
|
|
45
|
+
* alone cannot see this class of bug.
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildInitialFilter(initial: Partial<BrowserFilter>): BrowserFilter;
|
|
32
48
|
/** Return the next value in `[undefined, ...options]`, wrapping. */
|
|
33
49
|
export declare function cycle(current: string | undefined, options: string[]): string | undefined;
|
|
34
50
|
export declare function cycleWindow(current: string | undefined): string | undefined;
|
|
@@ -74,6 +90,8 @@ export declare function bareBrowserSeed(opts: {
|
|
|
74
90
|
agent?: string;
|
|
75
91
|
all?: boolean;
|
|
76
92
|
since?: string;
|
|
93
|
+
host?: string[];
|
|
94
|
+
inTeam?: string;
|
|
77
95
|
}): Partial<BrowserFilter>;
|
|
78
96
|
/**
|
|
79
97
|
* A live session's stable row key: its session id when the agent reported one,
|
|
@@ -11,13 +11,51 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import { spawnSync } from 'child_process';
|
|
14
|
+
import chalk from 'chalk';
|
|
14
15
|
import { dynamicPicker } from '../lib/picker.js';
|
|
15
16
|
import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
16
17
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
17
18
|
import { gatherRemoteList } from '../lib/session/remote-list.js';
|
|
19
|
+
import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
|
|
18
20
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
19
21
|
import { buildPreview } from './sessions-picker.js';
|
|
20
|
-
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
|
|
22
|
+
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, } from './sessions.js';
|
|
23
|
+
/**
|
|
24
|
+
* Complete a seed into the filter the picker actually runs on.
|
|
25
|
+
*
|
|
26
|
+
* Every optional field of {@link BrowserFilter} has to be named here, because the
|
|
27
|
+
* seed is copied field-by-field and an omission is silent: the field is optional,
|
|
28
|
+
* so the compiler says nothing and the browser just opens without that filter.
|
|
29
|
+
* `team` was dropped exactly this way, which made `--in-team` a no-op on the
|
|
30
|
+
* interactive path while the scope half of the same seed still applied — so the
|
|
31
|
+
* view opened wide and all-time and looked like the flag had worked.
|
|
32
|
+
*
|
|
33
|
+
* Exported so a test can assert the FILTER, not just the seed; testing the seed
|
|
34
|
+
* alone cannot see this class of bug.
|
|
35
|
+
*/
|
|
36
|
+
export function buildInitialFilter(initial) {
|
|
37
|
+
return {
|
|
38
|
+
running: initial.running ?? false,
|
|
39
|
+
teams: initial.teams ?? false,
|
|
40
|
+
agent: initial.agent,
|
|
41
|
+
device: initial.device,
|
|
42
|
+
team: initial.team,
|
|
43
|
+
projectScope: initial.projectScope ?? 'repo',
|
|
44
|
+
window: 'window' in initial ? initial.window : '30d',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Cache key for the transcript pool: every filter that changes what is FETCHED
|
|
48
|
+
* (window, team-origin inclusion, and the team filter's deeper limit) — not the
|
|
49
|
+
* ones applied in memory afterwards. */
|
|
50
|
+
function poolCacheKey(f) {
|
|
51
|
+
// The team filter contributes only whether it is SET, not which team: any team
|
|
52
|
+
// fetches the same deep pool and is then narrowed in memory. Keying on the name
|
|
53
|
+
// would make `t` the one hotkey that re-fans-out the fleet on every step of the
|
|
54
|
+
// cycle, at up to REMOTE_TIMEOUT_MS per unreachable peer.
|
|
55
|
+
return `${f.window ?? 'all'}|${f.teams}|${f.team ? 'team' : ''}`;
|
|
56
|
+
}
|
|
57
|
+
/** Pool size when a team filter is active; one team's rows can sit anywhere. */
|
|
58
|
+
const WHOLE_TEAM_POOL_LIMIT = 5000;
|
|
21
59
|
/** Ordered window cycle for the `W` key. `undefined` = all time. */
|
|
22
60
|
const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
|
|
23
61
|
/** Return the next value in `[undefined, ...options]`, wrapping. */
|
|
@@ -73,6 +111,8 @@ export function browserFilterToArgv(f, query = '') {
|
|
|
73
111
|
a.push('-a', f.agent);
|
|
74
112
|
if (f.device)
|
|
75
113
|
a.push('--device', f.device);
|
|
114
|
+
if (f.team)
|
|
115
|
+
a.push('--in-team', f.team);
|
|
76
116
|
if (f.projectScope === 'all')
|
|
77
117
|
a.push('--all');
|
|
78
118
|
if (f.window)
|
|
@@ -116,12 +156,28 @@ export function activeBrowserSeed(opts) {
|
|
|
116
156
|
* `-a`/`--device` still narrow their axis.
|
|
117
157
|
*/
|
|
118
158
|
export function bareBrowserSeed(opts) {
|
|
159
|
+
// An explicit --device scopes the pool to a peer, whose cwds live under that
|
|
160
|
+
// machine's home — none of them can be under OUR process.cwd(), so the default
|
|
161
|
+
// 'repo' scope would filter every fetched row away and render an empty list.
|
|
162
|
+
// A host scope therefore implies all-directories, exactly as --all does.
|
|
163
|
+
const scoped = (opts.host?.length ?? 0) > 0;
|
|
164
|
+
// --in-team asks for ONE team's lineage, and a team's teammates run in their own
|
|
165
|
+
// `.agents/worktrees/<slug>/` — a different cwd from ours — while the team itself
|
|
166
|
+
// may be older than the default window. Both defaults would hide exactly the rows
|
|
167
|
+
// the flag exists to surface, so it widens the scope the way --all does. The flag
|
|
168
|
+
// path does this too (sessions.ts `wantsWholeTeam`); the browser is the one a
|
|
169
|
+
// human actually reaches, so it must not be the one that stays narrow.
|
|
170
|
+
const wholeTeam = !!opts.inTeam;
|
|
119
171
|
return {
|
|
120
172
|
teams: !!opts.teams,
|
|
121
173
|
agent: opts.agent,
|
|
174
|
+
// The filter carries one device; seed it only when the scope names exactly
|
|
175
|
+
// one, so a two-device scope isn't narrowed to the first of them.
|
|
176
|
+
device: opts.host?.length === 1 ? normalizeDeviceSeed(opts.host[0]) : undefined,
|
|
177
|
+
team: opts.inTeam,
|
|
122
178
|
// --all maxes every non-status filter: all dirs AND all-time. --since wins.
|
|
123
|
-
projectScope: opts.all ? 'all' : 'repo',
|
|
124
|
-
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
179
|
+
projectScope: opts.all || scoped || wholeTeam ? 'all' : 'repo',
|
|
180
|
+
window: opts.since ?? (opts.all || wholeTeam ? undefined : '30d'),
|
|
125
181
|
};
|
|
126
182
|
}
|
|
127
183
|
/** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
|
|
@@ -154,6 +210,7 @@ function copyToClipboard(text) {
|
|
|
154
210
|
* not add" contract instead of always sweeping the whole fleet. */
|
|
155
211
|
async function fetchRawPool(f, self, local, hosts) {
|
|
156
212
|
const since = f.window;
|
|
213
|
+
let unreachable = [];
|
|
157
214
|
// Local pool: wide (every directory) — device/agent/project are applied in
|
|
158
215
|
// memory so a hotkey toggle is instant and doesn't re-hit the disk. Skipped
|
|
159
216
|
// when an explicit host scope excludes this machine.
|
|
@@ -163,7 +220,10 @@ async function fetchRawPool(f, self, local, hosts) {
|
|
|
163
220
|
cwd: process.cwd(),
|
|
164
221
|
since,
|
|
165
222
|
excludeTeamOrigin: !f.teams,
|
|
166
|
-
|
|
223
|
+
// A team filter reaches back past the usual browse window, so the pool it
|
|
224
|
+
// draws from has to as well — otherwise the newest 500 rows decide which
|
|
225
|
+
// teams exist.
|
|
226
|
+
limit: f.team ? WHOLE_TEAM_POOL_LIMIT : 500,
|
|
167
227
|
sortBy: 'timestamp',
|
|
168
228
|
})
|
|
169
229
|
: [];
|
|
@@ -171,22 +231,40 @@ async function fetchRawPool(f, self, local, hosts) {
|
|
|
171
231
|
// Skipped under --local. An explicit --host/--device scopes exactly which peers
|
|
172
232
|
// are dialed (undefined = sweep every online device). Best-effort — a fan-out
|
|
173
233
|
// failure leaves the local list intact.
|
|
174
|
-
|
|
234
|
+
const remoteHosts = remoteHostsToDial(hosts, self);
|
|
235
|
+
// An explicit scope naming only this machine leaves nothing remote to dial.
|
|
236
|
+
// gatherRemoteList reads `[]` as "no hosts given" and falls through to the
|
|
237
|
+
// whole-fleet sweep, so `--device <self>` would dial every online box — the
|
|
238
|
+
// exact opposite of the flag's scope-not-add contract. Skip the fan-out here,
|
|
239
|
+
// the same way gatherActiveSessions does for --active.
|
|
240
|
+
const dialPeers = !local && (!hosts?.length || (remoteHosts && remoteHosts.length > 0));
|
|
241
|
+
if (dialPeers) {
|
|
175
242
|
try {
|
|
176
|
-
|
|
243
|
+
// The peer's cap has to match the local one, or a team filter widens only
|
|
244
|
+
// this machine's half of the pool and a peer's older rows stay invisible —
|
|
245
|
+
// the same bug one hop out. A numeric --limit is forwarded rather than
|
|
246
|
+
// --in-team itself, which a peer on an older build would reject as an
|
|
247
|
+
// unknown option and fail the whole fan-out.
|
|
248
|
+
const forwarded = ['sessions', '--all', '--json', '--limit', String(f.team ? WHOLE_TEAM_POOL_LIMIT : 500)];
|
|
177
249
|
if (since)
|
|
178
250
|
forwarded.push('--since', since);
|
|
179
251
|
if (f.teams)
|
|
180
252
|
forwarded.push('--teams');
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
253
|
+
const remoteResult = await gatherRemoteList(forwarded, remoteHosts);
|
|
254
|
+
unreachable = remoteResult.unreachable;
|
|
255
|
+
if (remoteResult.sessions.length > 0)
|
|
256
|
+
rows = mergeLocalFirst([...rows, ...remoteResult.sessions], self);
|
|
184
257
|
}
|
|
185
258
|
catch {
|
|
186
259
|
// enrichment, never a hard dependency
|
|
187
260
|
}
|
|
188
261
|
}
|
|
189
|
-
|
|
262
|
+
// Team rows are anonymous without their meta.json (team name, handle, the
|
|
263
|
+
// orchestrator that spawned them). Only pay for that read when team rows are
|
|
264
|
+
// actually in the pool — with `c` off they were excluded at the query.
|
|
265
|
+
if (f.teams)
|
|
266
|
+
rows = enrichTeamOrigins(rows);
|
|
267
|
+
return { key: poolCacheKey(f), rows, unreachable };
|
|
190
268
|
}
|
|
191
269
|
/**
|
|
192
270
|
* A live session's stable row key: its session id when the agent reported one,
|
|
@@ -288,6 +366,8 @@ function applyFilters(rows, live, f, self) {
|
|
|
288
366
|
out = out.filter((r) => r.agent === f.agent);
|
|
289
367
|
if (f.device)
|
|
290
368
|
out = out.filter((r) => (r.machine ?? self) === f.device);
|
|
369
|
+
if (f.team)
|
|
370
|
+
out = out.filter((r) => matchesTeam(r, f.team));
|
|
291
371
|
if (f.projectScope === 'repo') {
|
|
292
372
|
const cwd = process.cwd();
|
|
293
373
|
out = out.filter((r) => !!r.cwd && (r.cwd === cwd || r.cwd.startsWith(cwd + '/')));
|
|
@@ -310,6 +390,7 @@ function headerFor(f) {
|
|
|
310
390
|
const bits = [
|
|
311
391
|
`device:${f.device ?? 'all'}`,
|
|
312
392
|
`agent:${f.agent ?? 'all'}`,
|
|
393
|
+
`team:${f.team ?? 'all'}`,
|
|
313
394
|
f.projectScope === 'repo' ? 'this repo' : 'all dirs',
|
|
314
395
|
`window:${f.window ?? 'all'}`,
|
|
315
396
|
];
|
|
@@ -323,7 +404,7 @@ function helpFor(_f, mode) {
|
|
|
323
404
|
if (mode === 'search') {
|
|
324
405
|
return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
|
|
325
406
|
}
|
|
326
|
-
return 's search · r running · c teams · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
|
|
407
|
+
return 's search · r running · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
|
|
327
408
|
}
|
|
328
409
|
/**
|
|
329
410
|
* Launch the interactive session browser. `initial` seeds the filter (e.g.
|
|
@@ -337,10 +418,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
337
418
|
// Updated after each load so the A/D cycles range over what's actually present.
|
|
338
419
|
let agentsInPool = [];
|
|
339
420
|
let devicesInPool = [];
|
|
421
|
+
let teamsInPool = [];
|
|
340
422
|
let cols = {};
|
|
341
|
-
// Cache the transcript fetch, keyed by (
|
|
342
|
-
// running are applied in memory so their
|
|
423
|
+
// Cache the transcript fetch, keyed by poolCacheKey (everything that changes
|
|
424
|
+
// what is FETCHED); agent/device/project/running are applied in memory so their
|
|
425
|
+
// hotkeys don't re-fan-out the fleet.
|
|
343
426
|
let rawCache = null;
|
|
427
|
+
// Peers that didn't answer the last fan-out. The fan-out's own note goes to
|
|
428
|
+
// stderr, which the full-screen picker repaints over — so it is surfaced in
|
|
429
|
+
// the header instead, where "that box is asleep" stays distinguishable from
|
|
430
|
+
// "that box has nothing matching".
|
|
431
|
+
let unreachable = [];
|
|
344
432
|
// The live index is slow (a full ps/tmux scan) and only the running filter
|
|
345
433
|
// needs it — fetch it once, lazily, the first time running is toggled on.
|
|
346
434
|
let liveCache = null;
|
|
@@ -350,17 +438,13 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
350
438
|
// caches) is a side channel it can't see — so a stale load must never commit
|
|
351
439
|
// it. We compute into locals and only write the shared state as the latest load.
|
|
352
440
|
let loadGen = 0;
|
|
353
|
-
const initialFilter =
|
|
354
|
-
running: initial.running ?? false,
|
|
355
|
-
teams: initial.teams ?? false,
|
|
356
|
-
agent: initial.agent,
|
|
357
|
-
device: initial.device,
|
|
358
|
-
projectScope: initial.projectScope ?? 'repo',
|
|
359
|
-
window: 'window' in initial ? initial.window : '30d',
|
|
360
|
-
};
|
|
441
|
+
const initialFilter = buildInitialFilter(initial);
|
|
361
442
|
const load = async (f) => {
|
|
362
443
|
const myGen = ++loadGen;
|
|
363
|
-
|
|
444
|
+
// f.team decides the fetch limit, so it belongs in the key — otherwise arriving
|
|
445
|
+
// via `t` reuses a 500-row pool while --in-team fetched 5000, and the cycle can
|
|
446
|
+
// only offer the teams that happened to be in whichever pool was built first.
|
|
447
|
+
const key = poolCacheKey(f);
|
|
364
448
|
let pool = rawCache && rawCache.key === key ? rawCache : null;
|
|
365
449
|
if (!pool) {
|
|
366
450
|
const fetched = await fetchRawPool(f, self, local, hosts);
|
|
@@ -386,6 +470,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
386
470
|
// Latest load — commit shared state atomically (no await past this point, so
|
|
387
471
|
// no newer load can interleave between these writes).
|
|
388
472
|
rawCache = pool;
|
|
473
|
+
unreachable = pool.unreachable;
|
|
389
474
|
if (live)
|
|
390
475
|
liveCache = live;
|
|
391
476
|
// Live sessions the transcript pool lacks become rows of their own, so the
|
|
@@ -393,6 +478,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
393
478
|
const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
|
|
394
479
|
agentsInPool = distinct(rows.map((r) => r.agent));
|
|
395
480
|
devicesInPool = distinct(rows.map((r) => r.machine ?? self));
|
|
481
|
+
// Both ends of the lineage seed the cycle: teams a row spawned, and teams a
|
|
482
|
+
// row belongs to. Teammate rows only carry `teamOrigin` when `c` is on, so
|
|
483
|
+
// with teams hidden this ranges over spawned teams alone — which is exactly
|
|
484
|
+
// the set whose rows are visible.
|
|
485
|
+
// Through safeTeamText: the cycle's values become `f.team`, which headerFor
|
|
486
|
+
// interpolates into the header and browserFilterToArgv copies into a command,
|
|
487
|
+
// and on a peer's row these strings are that machine's to choose.
|
|
488
|
+
teamsInPool = distinct([
|
|
489
|
+
...rows.map((r) => safeTeamText(r.spawnedTeam)),
|
|
490
|
+
...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
|
|
491
|
+
]);
|
|
396
492
|
const filtered = applyFilters(rows, live ?? new Map(), f, self);
|
|
397
493
|
cols = pickerColumnsFor(filtered);
|
|
398
494
|
cols.showHost = shouldShowHostColumn(f, live, filtered);
|
|
@@ -406,7 +502,9 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
406
502
|
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
|
|
407
503
|
matches: sessionMatchesQuery,
|
|
408
504
|
buildPreview,
|
|
409
|
-
headerFor
|
|
505
|
+
headerFor: (f) => unreachable.length > 0
|
|
506
|
+
? `${headerFor(f)} · ${chalk.yellow(`${unreachable.join(', ')}: unreachable`)}`
|
|
507
|
+
: headerFor(f),
|
|
410
508
|
helpFor,
|
|
411
509
|
enterHint: 'resume',
|
|
412
510
|
emptyMessage: 'No sessions match this filter.',
|
|
@@ -416,7 +514,11 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
416
514
|
c: (f) => ({ ...f, teams: !f.teams }),
|
|
417
515
|
a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
|
|
418
516
|
d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
|
|
419
|
-
|
|
517
|
+
t: (f) => ({ ...f, team: cycle(f.team, teamsInPool) }),
|
|
518
|
+
// Under an explicit --device scope every row is a peer's, and no peer cwd
|
|
519
|
+
// is under our process.cwd() — so narrowing to "this repo" could only ever
|
|
520
|
+
// empty the list. Returning the same reference makes the key a no-op.
|
|
521
|
+
p: (f) => (hosts ? f : { ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
|
|
420
522
|
w: (f) => ({ ...f, window: cycleWindow(f.window) }),
|
|
421
523
|
},
|
|
422
524
|
onKey: (name, f, _active, query) => {
|