@phnx-labs/agents-cli 1.22.44 → 1.22.46
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 +32 -0
- package/README.md +24 -0
- package/dist/bootstrap.js +1 -6
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +1 -3
- package/dist/commands/accounts.d.ts +1 -9
- package/dist/commands/accounts.js +12 -90
- package/dist/commands/auth.d.ts +0 -7
- package/dist/commands/auth.js +198 -83
- package/dist/commands/insights.js +82 -154
- package/dist/commands/view.js +1 -1
- package/dist/lib/accounting/usage.d.ts +22 -3
- package/dist/lib/accounting/usage.js +94 -12
- package/dist/lib/agent-spec/agents.js +6 -1
- package/dist/lib/cli-resources.js +17 -15
- package/dist/lib/devices/harness-inventory.js +20 -3
- package/dist/lib/exec.d.ts +20 -0
- package/dist/lib/exec.js +43 -6
- package/dist/lib/identity/client.d.ts +53 -0
- package/dist/lib/identity/client.js +106 -0
- package/dist/lib/identity/index.d.ts +115 -0
- package/dist/lib/identity/index.js +82 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
- 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 +15 -2
- package/dist/lib/probe.d.ts +8 -0
- package/dist/lib/probe.js +105 -0
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +5 -2
- package/dist/lib/view-types.d.ts +2 -2
- package/package.json +1 -1
- package/dist/commands/org.d.ts +0 -11
- package/dist/commands/org.js +0 -228
- package/dist/lib/entitlement.d.ts +0 -31
- package/dist/lib/entitlement.js +0 -137
- package/dist/lib/prix-account.d.ts +0 -159
- package/dist/lib/prix-account.js +0 -215
package/dist/commands/auth.js
CHANGED
|
@@ -1,112 +1,227 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { PhoenixApiError, clearSession, createSpace, createSpaceInvite, fetchWhoAmI, listSpaceMembers, listSpaces, pollDeviceToken, readSession, removeSpaceMember, resolveMemberFromList, resolveSpaceFromList, slugify, startDeviceAuthorization, updateSpaceMemberRole, writeSession, } from '../lib/identity/index.js';
|
|
2
3
|
import { setHelpSections } from '../lib/help.js';
|
|
3
4
|
import { runOrDie } from '../lib/format.js';
|
|
4
|
-
import { clearPrixSession, fetchWhoAmI, pollDeviceToken, PrixApiError, readPrixSession, resolvePrixToken, startDeviceAuthorization, writePrixSession, } from '../lib/prix-account.js';
|
|
5
|
-
import { registerAuthSpaceCommand } from './org.js';
|
|
6
|
-
function sleep(ms) {
|
|
7
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
-
}
|
|
9
5
|
/**
|
|
10
|
-
* `agents auth
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `rush login` fallback rather than silently stubbing a fake session.
|
|
6
|
+
* `agents auth` — sign in to Phoenix ID, the account layer behind teams and
|
|
7
|
+
* plan tiers. Everything here goes through `lib/identity`; this file builds no
|
|
8
|
+
* URLs and reads no credential files of its own.
|
|
14
9
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw new Error(`Device login is unavailable right now (${message}). Run 'rush login' instead, then 'agents auth whoami' to confirm.`);
|
|
29
|
-
}
|
|
30
|
-
console.log(`To sign in, open:\n\n ${chalk.cyan(authorization.verification_uri_complete)}\n`);
|
|
31
|
-
console.log(chalk.gray(`(code: ${authorization.user_code})`));
|
|
32
|
-
const deadline = Date.now() + authorization.expires_in * 1000;
|
|
33
|
-
let interval = Math.max(authorization.interval, 1) * 1000;
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
async function login() {
|
|
14
|
+
const grant = await startDeviceAuthorization();
|
|
15
|
+
console.log('');
|
|
16
|
+
console.log(` Your code: ${chalk.bold.cyan(grant.user_code)}`);
|
|
17
|
+
console.log(` Open: ${chalk.underline(grant.verification_uri_complete)}`);
|
|
18
|
+
console.log('');
|
|
19
|
+
console.log(chalk.gray(' Waiting for you to approve it in the browser…'));
|
|
20
|
+
// The server sets the pace; `slow_down` widens it (RFC 8628 §3.5).
|
|
21
|
+
let interval = Math.max(1, grant.interval) * 1000;
|
|
22
|
+
const deadline = Date.now() + grant.expires_in * 1000;
|
|
34
23
|
while (Date.now() < deadline) {
|
|
35
24
|
await sleep(interval);
|
|
36
|
-
const poll = await pollDeviceToken(
|
|
37
|
-
if (poll.status === '
|
|
38
|
-
|
|
25
|
+
const poll = await pollDeviceToken(grant.device_code);
|
|
26
|
+
if (poll.status === 'authorized') {
|
|
27
|
+
writeSession({ access_token: poll.access_token, email: poll.user.email, userId: poll.user.id });
|
|
28
|
+
console.log(chalk.green(`\n Signed in as ${poll.user.email}.`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
39
31
|
if (poll.status === 'slow_down') {
|
|
40
|
-
interval +=
|
|
32
|
+
interval += 5000;
|
|
41
33
|
continue;
|
|
42
34
|
}
|
|
43
|
-
if (poll.status === 'expired')
|
|
44
|
-
throw new Error('Login code expired. Run `agents auth login` again.');
|
|
45
35
|
if (poll.status === 'denied')
|
|
46
|
-
throw new Error('
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
refresh_token: poll.refresh_token,
|
|
50
|
-
expires_at: poll.expires_in ? Date.now() + poll.expires_in * 1000 : undefined,
|
|
51
|
-
email: poll.user.email,
|
|
52
|
-
userId: poll.user.id,
|
|
53
|
-
});
|
|
54
|
-
console.log(chalk.green(`Signed in as ${poll.user.email}.`));
|
|
55
|
-
return;
|
|
36
|
+
throw new Error('Sign-in was denied in the browser.');
|
|
37
|
+
if (poll.status === 'expired')
|
|
38
|
+
throw new Error("That code expired. Run 'agents auth login' again.");
|
|
56
39
|
}
|
|
57
|
-
throw new Error(
|
|
40
|
+
throw new Error("Timed out waiting for approval. Run 'agents auth login' again.");
|
|
58
41
|
}
|
|
59
|
-
async function
|
|
60
|
-
const
|
|
61
|
-
if (!
|
|
62
|
-
|
|
63
|
-
|
|
42
|
+
async function whoami(json) {
|
|
43
|
+
const session = readSession();
|
|
44
|
+
if (!session) {
|
|
45
|
+
if (json) {
|
|
46
|
+
console.log(JSON.stringify({ signedIn: false }, null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
console.log(chalk.gray("Not signed in. Run 'agents auth login'."));
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
64
53
|
try {
|
|
65
|
-
|
|
54
|
+
const me = await fetchWhoAmI();
|
|
55
|
+
if (json) {
|
|
56
|
+
console.log(JSON.stringify({ signedIn: true, ...me }, null, 2));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
console.log(`${chalk.bold(me.email)} ${chalk.gray(me.userId)}`);
|
|
66
60
|
}
|
|
67
61
|
catch (err) {
|
|
68
|
-
if (err instanceof
|
|
69
|
-
throw new Error(
|
|
62
|
+
if (err instanceof PhoenixApiError && err.status === 401) {
|
|
63
|
+
throw new Error("Your session is no longer valid. Run 'agents auth login' again.");
|
|
70
64
|
}
|
|
71
65
|
throw err;
|
|
72
66
|
}
|
|
73
|
-
if (json) {
|
|
74
|
-
console.log(JSON.stringify({ ...who, source: resolved.source }, null, 2));
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
console.log(`${chalk.bold(who.email)} ${chalk.gray(who.userId)}`);
|
|
78
|
-
console.log(chalk.gray(`signed in via ${resolved.source === 'agents' ? "'agents auth login'" : "'rush login' (shared session)"}`));
|
|
79
67
|
}
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
console.log(chalk.gray('Not signed in via `agents auth login` — nothing to clear.'));
|
|
68
|
+
function printSpaces(spaces) {
|
|
69
|
+
if (!spaces.length) {
|
|
70
|
+
console.log(chalk.gray(" No spaces yet. Create one with 'agents auth space create <name>'."));
|
|
71
|
+
return;
|
|
85
72
|
}
|
|
86
|
-
|
|
87
|
-
console.log(chalk.
|
|
73
|
+
for (const space of spaces) {
|
|
74
|
+
console.log(` ${chalk.cyan(space.slug)} ${space.name} ${chalk.gray(space.user_role)}`);
|
|
88
75
|
}
|
|
89
|
-
|
|
90
|
-
|
|
76
|
+
}
|
|
77
|
+
/** Resolve a space reference (or the caller's only space) to a concrete space. */
|
|
78
|
+
async function requireSpace(ref) {
|
|
79
|
+
const spaces = await listSpaces();
|
|
80
|
+
const space = resolveSpaceFromList(spaces, ref);
|
|
81
|
+
if (space)
|
|
82
|
+
return space;
|
|
83
|
+
if (!ref) {
|
|
84
|
+
throw new Error(spaces.length
|
|
85
|
+
? `You are in ${spaces.length} spaces — name one: ${spaces.map((s) => s.slug).join(', ')}.`
|
|
86
|
+
: "You are not in a space yet. Create one with 'agents auth space create <name>'.");
|
|
91
87
|
}
|
|
88
|
+
throw new Error(`No space named '${ref}'.`);
|
|
92
89
|
}
|
|
93
90
|
export function registerAuthCommand(program) {
|
|
94
|
-
const auth = program
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
const auth = program
|
|
92
|
+
.command('auth')
|
|
93
|
+
.description('Sign in to Phoenix ID — the account layer behind spaces and plan tiers');
|
|
94
|
+
setHelpSections(auth, {
|
|
95
|
+
examples: `agents auth login # device-code sign-in via your browser
|
|
96
|
+
agents auth whoami # who this machine is signed in as
|
|
97
|
+
agents auth space create "Design Team" # start a space
|
|
98
|
+
agents auth space invite ada@example.com # add a teammate
|
|
99
|
+
agents auth logout # clear this machine only`,
|
|
100
|
+
notes: `Sign-in is Google-only and opens a Phoenix-branded page; the CLI never sees a password.
|
|
101
|
+
The session lives in this machine's agents state dir, so logging out here signs out nothing else.
|
|
102
|
+
Point at a different backend with PHOENIX_ID_BASE (defaults to the production service).`,
|
|
103
|
+
});
|
|
104
|
+
auth
|
|
105
|
+
.command('login')
|
|
106
|
+
.description('Sign in with the device-code flow')
|
|
107
|
+
.action(() => runOrDie(() => login()));
|
|
108
|
+
auth
|
|
109
|
+
.command('whoami')
|
|
110
|
+
.description('Show the signed-in account')
|
|
111
|
+
.option('--json', 'Machine-readable output')
|
|
97
112
|
.action((o, command) => {
|
|
98
|
-
const json = !!
|
|
99
|
-
return runOrDie(() =>
|
|
113
|
+
const json = !!o.json || !!command.optsWithGlobals().json;
|
|
114
|
+
return runOrDie(() => whoami(json), { json });
|
|
100
115
|
});
|
|
101
|
-
auth
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
auth
|
|
117
|
+
.command('logout')
|
|
118
|
+
.description("Clear this machine's session (no other device is affected)")
|
|
119
|
+
.action(() => runOrDie(() => {
|
|
120
|
+
const session = readSession();
|
|
121
|
+
clearSession();
|
|
122
|
+
console.log(session ? chalk.green(`Signed out ${session.email ?? 'this machine'}.`) : chalk.gray('Already signed out.'));
|
|
123
|
+
}));
|
|
124
|
+
const space = auth.command('space').description('Spaces — share work with teammates');
|
|
125
|
+
space
|
|
126
|
+
.command('list', { isDefault: true })
|
|
127
|
+
.description('Spaces you belong to')
|
|
128
|
+
.option('--json', 'Machine-readable output')
|
|
129
|
+
.action((o, command) => {
|
|
130
|
+
const json = !!o.json || !!command.optsWithGlobals().json;
|
|
131
|
+
return runOrDie(async () => {
|
|
132
|
+
const spaces = await listSpaces();
|
|
133
|
+
if (json)
|
|
134
|
+
return console.log(JSON.stringify(spaces, null, 2));
|
|
135
|
+
printSpaces(spaces);
|
|
136
|
+
}, { json });
|
|
137
|
+
});
|
|
138
|
+
space
|
|
139
|
+
.command('create <name>')
|
|
140
|
+
.description('Create a space')
|
|
141
|
+
.option('--slug <slug>', 'URL-safe name (defaults to a slug of <name>)')
|
|
142
|
+
.option('--json', 'Machine-readable output')
|
|
143
|
+
.action((name, o, command) => {
|
|
144
|
+
const json = !!o.json || !!command.optsWithGlobals().json;
|
|
145
|
+
return runOrDie(async () => {
|
|
146
|
+
const created = await createSpace({ name, slug: o.slug ?? slugify(name) });
|
|
147
|
+
if (json)
|
|
148
|
+
return console.log(JSON.stringify(created, null, 2));
|
|
149
|
+
console.log(chalk.green(`Created ${created.name} (${created.slug}).`));
|
|
150
|
+
}, { json });
|
|
151
|
+
});
|
|
152
|
+
space
|
|
153
|
+
.command('members [space]')
|
|
154
|
+
.description('Who is in a space')
|
|
155
|
+
.option('--json', 'Machine-readable output')
|
|
156
|
+
.action((ref, o, command) => {
|
|
157
|
+
const json = !!o.json || !!command.optsWithGlobals().json;
|
|
158
|
+
return runOrDie(async () => {
|
|
159
|
+
const target = await requireSpace(ref);
|
|
160
|
+
const members = await listSpaceMembers(target.id);
|
|
161
|
+
if (json)
|
|
162
|
+
return console.log(JSON.stringify(members, null, 2));
|
|
163
|
+
for (const m of members)
|
|
164
|
+
console.log(` ${m.email} ${chalk.gray(m.role)}`);
|
|
165
|
+
}, { json });
|
|
166
|
+
});
|
|
167
|
+
space
|
|
168
|
+
.command('invite <email>')
|
|
169
|
+
.description('Invite someone to a space')
|
|
170
|
+
.option('--space <space>', 'Which space (defaults to your only one)')
|
|
171
|
+
.option('--role <role>', 'admin or member', 'member')
|
|
172
|
+
.option('--json', 'Machine-readable output')
|
|
173
|
+
.action((email, o, command) => {
|
|
174
|
+
const json = !!o.json || !!command.optsWithGlobals().json;
|
|
175
|
+
return runOrDie(async () => {
|
|
176
|
+
if (o.role !== 'admin' && o.role !== 'member') {
|
|
177
|
+
throw new Error(`--role must be admin or member (got '${o.role}').`);
|
|
178
|
+
}
|
|
179
|
+
const target = await requireSpace(o.space);
|
|
180
|
+
const result = await createSpaceInvite(target.id, { email, role: o.role });
|
|
181
|
+
if (json)
|
|
182
|
+
return console.log(JSON.stringify(result, null, 2));
|
|
183
|
+
console.log(result.member_added
|
|
184
|
+
? chalk.green(`Added ${email} to ${target.name} as ${o.role}.`)
|
|
185
|
+
: chalk.green(`Invited ${email} to ${target.name} as ${o.role}. Invite code: ${result.invite_code}`));
|
|
186
|
+
}, { json });
|
|
187
|
+
});
|
|
188
|
+
space
|
|
189
|
+
.command('role <email> <role>')
|
|
190
|
+
.description('Change a member\'s role (owner only for admin)')
|
|
191
|
+
.option('--space <space>', 'Which space (defaults to your only one)')
|
|
192
|
+
.option('--json', 'Machine-readable output')
|
|
193
|
+
.action((email, role, o, command) => {
|
|
194
|
+
const json = !!o.json || !!command.optsWithGlobals().json;
|
|
195
|
+
return runOrDie(async () => {
|
|
196
|
+
if (role !== 'admin' && role !== 'member') {
|
|
197
|
+
throw new Error(`role must be admin or member (got '${role}').`);
|
|
198
|
+
}
|
|
199
|
+
const target = await requireSpace(o.space);
|
|
200
|
+
const member = resolveMemberFromList(await listSpaceMembers(target.id), email);
|
|
201
|
+
if (!member)
|
|
202
|
+
throw new Error(`${email} is not in ${target.name}.`);
|
|
203
|
+
const updated = await updateSpaceMemberRole(target.id, member.user_id, role);
|
|
204
|
+
if (json)
|
|
205
|
+
return console.log(JSON.stringify(updated, null, 2));
|
|
206
|
+
console.log(chalk.green(`${email} is now ${role} in ${target.name}.`));
|
|
207
|
+
}, { json });
|
|
208
|
+
});
|
|
209
|
+
space
|
|
210
|
+
.command('remove <email>')
|
|
211
|
+
.description('Remove a member (or yourself) from a space')
|
|
212
|
+
.option('--space <space>', 'Which space (defaults to your only one)')
|
|
213
|
+
.option('--json', 'Machine-readable output')
|
|
214
|
+
.action((email, o, command) => {
|
|
215
|
+
const json = !!o.json || !!command.optsWithGlobals().json;
|
|
216
|
+
return runOrDie(async () => {
|
|
217
|
+
const target = await requireSpace(o.space);
|
|
218
|
+
const member = resolveMemberFromList(await listSpaceMembers(target.id), email);
|
|
219
|
+
if (!member)
|
|
220
|
+
throw new Error(`${email} is not in ${target.name}.`);
|
|
221
|
+
await removeSpaceMember(target.id, member.user_id);
|
|
222
|
+
if (json)
|
|
223
|
+
return console.log(JSON.stringify({ removed: true, email, space: target.slug }, null, 2));
|
|
224
|
+
console.log(chalk.green(`Removed ${email} from ${target.name}.`));
|
|
225
|
+
}, { json });
|
|
111
226
|
});
|
|
112
227
|
}
|
|
@@ -49,24 +49,7 @@ import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/
|
|
|
49
49
|
import { registerMixCommands } from '../lib/analytics/mix-commands.js';
|
|
50
50
|
import { registerCostCommand } from './cost.js';
|
|
51
51
|
import { registerOutputCommand } from './output.js';
|
|
52
|
-
import { getTier } from '../lib/entitlement.js';
|
|
53
52
|
const execFileAsync = promisify(execFile);
|
|
54
|
-
/**
|
|
55
|
-
* Plan-tier gate for the behavioural report (RUSH-2424). Free keeps top-line
|
|
56
|
-
* counts and the harness mix (and `insights mix` / `agents perf`, which never
|
|
57
|
-
* enter this file's gating since they're separate command trees); the
|
|
58
|
-
* friction/correction-signal sections, grouping `--by account`, and
|
|
59
|
-
* `--narrative` are paid. `insights mix`/`cost`/`output` are unaffected — this
|
|
60
|
-
* gate applies only to the default behavioural report.
|
|
61
|
-
*/
|
|
62
|
-
const PAID_PLAN_NOTICE = 'Friction and account-split analysis are on the paid plan.';
|
|
63
|
-
function resolveInsightsGate(tier, dim) {
|
|
64
|
-
return {
|
|
65
|
-
tier,
|
|
66
|
-
groupGated: !tier.isPaid && dim === 'account',
|
|
67
|
-
frictionGated: !tier.isPaid,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
53
|
function collectAgent(value, previous) {
|
|
71
54
|
return [...previous, value];
|
|
72
55
|
}
|
|
@@ -225,7 +208,7 @@ function renderHours(hours, out) {
|
|
|
225
208
|
out.push(` ${chalk.cyan(spark)}`);
|
|
226
209
|
out.push(` ${chalk.gray('0h'.padEnd(6))}${chalk.gray('6h'.padEnd(6))}${chalk.gray('12h'.padEnd(6))}${chalk.gray('18h'.padEnd(5))}${chalk.gray('23h')}`);
|
|
227
210
|
}
|
|
228
|
-
function renderReport(groups, dim, meta, actions, harnesses
|
|
211
|
+
function renderReport(groups, dim, meta, actions, harnesses) {
|
|
229
212
|
const out = [];
|
|
230
213
|
const scope = meta.since ? `last ${meta.since}` : 'all time';
|
|
231
214
|
out.push(chalk.bold('Insights') + chalk.gray(` ${scope} · ${meta.analyzed} of ${meta.scanned} sessions`));
|
|
@@ -235,44 +218,28 @@ function renderReport(groups, dim, meta, actions, harnesses, gate) {
|
|
|
235
218
|
console.log(out.join('\n'));
|
|
236
219
|
return;
|
|
237
220
|
}
|
|
238
|
-
let noticePrinted = false;
|
|
239
|
-
const printPlanNotice = () => {
|
|
240
|
-
if (noticePrinted)
|
|
241
|
-
return;
|
|
242
|
-
noticePrinted = true;
|
|
243
|
-
out.push('');
|
|
244
|
-
out.push(chalk.gray(` ${PAID_PLAN_NOTICE}`));
|
|
245
|
-
};
|
|
246
221
|
// Per-group table — the headline, and the thing no sibling command produces.
|
|
247
222
|
// Includes silent-stall counts so harness/account laziness is visible without --json.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
out.push(
|
|
265
|
-
`${
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const dur = g.durationMs > 0 ? formatDuration(g.durationMs) : '—';
|
|
269
|
-
const stalls = gate.frictionGated ? '—' : String(stallOf(g));
|
|
270
|
-
const resumes = gate.frictionGated ? '' : String(resumeOf(g));
|
|
271
|
-
out.push(` ${padToWidth(truncateToWidth(g.label, labelW), labelW)} ` +
|
|
272
|
-
`${chalk.gray(String(g.sessions).padStart(sessW))} ${chalk.gray('sess')} ` +
|
|
273
|
-
`${chalk.green(padToWidth(cost, 9))} ${chalk.gray(padToWidth(dur, 8))} ` +
|
|
274
|
-
`${chalk.cyan(stalls.padStart(stallW))} ${chalk.cyan(resumes)}`);
|
|
275
|
-
}
|
|
223
|
+
out.push('');
|
|
224
|
+
out.push(chalk.bold(`By ${dim}`));
|
|
225
|
+
const labelW = Math.min(Math.max(...groups.map((g) => stringWidth(g.label)), 5), Math.max(16, terminalWidth() - 58));
|
|
226
|
+
const sessW = Math.max(...groups.map((g) => String(g.sessions).length), 3);
|
|
227
|
+
const stallOf = (g) => Object.entries(g.facets.frictionSignals)
|
|
228
|
+
.filter(([k]) => k.startsWith('silent stall:'))
|
|
229
|
+
.reduce((n, [, c]) => n + c, 0);
|
|
230
|
+
const resumeOf = (g) => g.facets.correctionSignals['resume after silent stall'] ?? 0;
|
|
231
|
+
const stallW = Math.max(...groups.map((g) => String(stallOf(g)).length), 5);
|
|
232
|
+
out.push(chalk.gray(` ${padToWidth('', labelW)} ${''.padStart(sessW)} ` +
|
|
233
|
+
`${''.padStart(9)} ${''.padStart(8)} ${'stalls'.padStart(stallW)} resume`));
|
|
234
|
+
for (const g of groups) {
|
|
235
|
+
const cost = g.costUsd > 0 ? formatUsd(g.costUsd) : '—';
|
|
236
|
+
const dur = g.durationMs > 0 ? formatDuration(g.durationMs) : '—';
|
|
237
|
+
const stalls = String(stallOf(g));
|
|
238
|
+
const resumes = String(resumeOf(g));
|
|
239
|
+
out.push(` ${padToWidth(truncateToWidth(g.label, labelW), labelW)} ` +
|
|
240
|
+
`${chalk.gray(String(g.sessions).padStart(sessW))} ${chalk.gray('sess')} ` +
|
|
241
|
+
`${chalk.green(padToWidth(cost, 9))} ${chalk.gray(padToWidth(dur, 8))} ` +
|
|
242
|
+
`${chalk.cyan(stalls.padStart(stallW))} ${chalk.cyan(resumes)}`);
|
|
276
243
|
}
|
|
277
244
|
// Everything below is the whole scope folded together; per-group detail is in --json.
|
|
278
245
|
const all = newFacetAccumulator();
|
|
@@ -281,64 +248,54 @@ function renderReport(groups, dim, meta, actions, harnesses, gate) {
|
|
|
281
248
|
renderCounts('Top tools', topEntries(all.toolCounts, 8), out);
|
|
282
249
|
renderCounts('Languages', topEntries(all.languages, 6), out);
|
|
283
250
|
renderCounts('Models', topEntries(all.models, 6), out);
|
|
284
|
-
// Friction — the section that earns the command.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
251
|
+
// Friction — the section that earns the command.
|
|
252
|
+
renderCounts('Silent stalls by model', topEntries(all.silentStallsByModel ?? {}, 8), out);
|
|
253
|
+
const gaps = all.responseGaps;
|
|
254
|
+
const silentStalls = Object.entries(all.frictionSignals)
|
|
255
|
+
.filter(([k]) => k.startsWith('silent stall:'))
|
|
256
|
+
.reduce((n, [, c]) => n + c, 0);
|
|
257
|
+
const resumeNudges = all.correctionSignals['resume after silent stall'] ?? 0;
|
|
258
|
+
out.push('');
|
|
259
|
+
out.push(chalk.bold('Friction'));
|
|
260
|
+
out.push(` ${padToWidth('interruptions', 18)} ${chalk.cyan(String(all.interruptions))}` +
|
|
261
|
+
chalk.gray(' turns you cut short'));
|
|
262
|
+
out.push(` ${padToWidth('tool errors', 18)} ${chalk.cyan(String(all.errorCount))}`);
|
|
263
|
+
if (gaps.length > 0) {
|
|
264
|
+
// Same timestamps as silent stalls; this line is the distribution. Silent
|
|
265
|
+
// stalls (below) are the agent-attributed long gaps after the model stopped.
|
|
266
|
+
out.push(` ${padToWidth('gap until next msg', 18)} ` +
|
|
267
|
+
chalk.cyan(`p50 ${Math.round(percentile(gaps, 50))}s`) + chalk.gray(` · p90 ${Math.round(percentile(gaps, 90))}s`) +
|
|
268
|
+
chalk.gray(' after assistant last spoke'));
|
|
288
269
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (gaps.length > 0) {
|
|
302
|
-
// Same timestamps as silent stalls; this line is the distribution. Silent
|
|
303
|
-
// stalls (below) are the agent-attributed long gaps after the model stopped.
|
|
304
|
-
out.push(` ${padToWidth('gap until next msg', 18)} ` +
|
|
305
|
-
chalk.cyan(`p50 ${Math.round(percentile(gaps, 50))}s`) + chalk.gray(` · p90 ${Math.round(percentile(gaps, 90))}s`) +
|
|
306
|
-
chalk.gray(' after assistant last spoke'));
|
|
307
|
-
}
|
|
308
|
-
if (silentStalls > 0) {
|
|
309
|
-
out.push(` ${padToWidth('silent stalls', 18)} ${chalk.cyan(String(silentStalls))}` +
|
|
310
|
-
chalk.gray(' agent idle ≥5m until you resumed (also in By ' + dim + ' table)'));
|
|
311
|
-
}
|
|
312
|
-
if (resumeNudges > 0) {
|
|
313
|
-
out.push(` ${padToWidth('resume nudges', 18)} ${chalk.cyan(String(resumeNudges))}` +
|
|
314
|
-
chalk.gray(' "continue"/"keep going" after a silent stall'));
|
|
315
|
-
}
|
|
316
|
-
const errs = topEntries(all.errorCategories, 6);
|
|
317
|
-
if (errs.length > 0) {
|
|
318
|
-
for (const e of errs)
|
|
319
|
-
out.push(` ${chalk.gray('·')} ${padToWidth(e.name, 16)} ${chalk.gray(String(e.count))}`);
|
|
320
|
-
}
|
|
321
|
-
renderCounts('Friction / thrash', topEntries(all.frictionSignals, 10), out);
|
|
322
|
-
renderCounts('Dissatisfaction / corrections', topEntries(all.correctionSignals, 10), out);
|
|
270
|
+
if (silentStalls > 0) {
|
|
271
|
+
out.push(` ${padToWidth('silent stalls', 18)} ${chalk.cyan(String(silentStalls))}` +
|
|
272
|
+
chalk.gray(' agent idle ≥5m until you resumed (also in By ' + dim + ' table)'));
|
|
273
|
+
}
|
|
274
|
+
if (resumeNudges > 0) {
|
|
275
|
+
out.push(` ${padToWidth('resume nudges', 18)} ${chalk.cyan(String(resumeNudges))}` +
|
|
276
|
+
chalk.gray(' "continue"/"keep going" after a silent stall'));
|
|
277
|
+
}
|
|
278
|
+
const errs = topEntries(all.errorCategories, 6);
|
|
279
|
+
if (errs.length > 0) {
|
|
280
|
+
for (const e of errs)
|
|
281
|
+
out.push(` ${chalk.gray('·')} ${padToWidth(e.name, 16)} ${chalk.gray(String(e.count))}`);
|
|
323
282
|
}
|
|
283
|
+
renderCounts('Friction / thrash', topEntries(all.frictionSignals, 10), out);
|
|
284
|
+
renderCounts('Dissatisfaction / corrections', topEntries(all.correctionSignals, 10), out);
|
|
324
285
|
renderCounts('Automatable repeats', topEntries(all.automationSignals, 10), out);
|
|
325
286
|
renderCounts('Harness split', harnesses, out);
|
|
326
287
|
// Actions are built from frictionSignals/correctionSignals/automationSignals
|
|
327
|
-
// together (buildInsightActions)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
out.push('');
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
for (const action of actions.slice(0, 12)) {
|
|
339
|
-
out.push(` ${padToWidth(action.priority, 7)} ${padToWidth(action.category, 11)} ` +
|
|
340
|
-
`${String(action.evidenceCount).padStart(8)} ${padToWidth(action.sampleSessionIds.join(', '), 25)} ${action.action}`);
|
|
341
|
-
}
|
|
288
|
+
// together (buildInsightActions).
|
|
289
|
+
out.push('');
|
|
290
|
+
out.push(chalk.bold('Actions'));
|
|
291
|
+
if (actions.length === 0) {
|
|
292
|
+
out.push(chalk.gray(' No repeated action pattern met the evidence threshold in this window.'));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
out.push(chalk.gray(' pri category evidence sample sessions action'));
|
|
296
|
+
for (const action of actions.slice(0, 12)) {
|
|
297
|
+
out.push(` ${padToWidth(action.priority, 7)} ${padToWidth(action.category, 11)} ` +
|
|
298
|
+
`${String(action.evidenceCount).padStart(8)} ${padToWidth(action.sampleSessionIds.join(', '), 25)} ${action.action}`);
|
|
342
299
|
}
|
|
343
300
|
}
|
|
344
301
|
// Output
|
|
@@ -394,9 +351,7 @@ function renderReport(groups, dim, meta, actions, harnesses, gate) {
|
|
|
394
351
|
out.push('');
|
|
395
352
|
out.push(chalk.yellow(` ${meta.unreadable} transcripts could not be read; their behaviour is missing from these totals.`));
|
|
396
353
|
}
|
|
397
|
-
|
|
398
|
-
// "silent stall" outright — must not render on the free plan.
|
|
399
|
-
if (!gate.frictionGated && all.gapsOverCeiling > 0) {
|
|
354
|
+
if (all.gapsOverCeiling > 0) {
|
|
400
355
|
out.push(chalk.gray(` ${all.gapsOverCeiling} gaps over an hour excluded from p50/p90 (still counted as silent stall: 1h+ when the assistant last spoke).`));
|
|
401
356
|
}
|
|
402
357
|
out.push('');
|
|
@@ -451,23 +406,8 @@ async function renderNarrative(payload) {
|
|
|
451
406
|
process.exitCode = 1;
|
|
452
407
|
}
|
|
453
408
|
}
|
|
454
|
-
/** Facet keys that belong to the paid friction/correction sections — stripped from `--json` on free (RUSH-2424). */
|
|
455
|
-
const PAID_FACET_KEYS = new Set([
|
|
456
|
-
'frictionSignals', 'correctionSignals', 'silentStallsByModel',
|
|
457
|
-
'interruptions', 'errorCount', 'errorCategories', 'responseGaps', 'gapsOverCeiling',
|
|
458
|
-
]);
|
|
459
|
-
function freeFacetSubset(facets) {
|
|
460
|
-
const out = {};
|
|
461
|
-
for (const [key, value] of Object.entries(facets)) {
|
|
462
|
-
if (!PAID_FACET_KEYS.has(key))
|
|
463
|
-
out[key] = value;
|
|
464
|
-
}
|
|
465
|
-
return out;
|
|
466
|
-
}
|
|
467
409
|
async function insightsAction(options) {
|
|
468
410
|
const dim = resolveGroup(options.by);
|
|
469
|
-
const tier = await getTier();
|
|
470
|
-
const gate = resolveInsightsGate(tier, dim);
|
|
471
411
|
const minMessages = Number.parseInt(options.minMessages ?? '2', 10);
|
|
472
412
|
if (!Number.isFinite(minMessages) || minMessages < 0) {
|
|
473
413
|
console.error(chalk.red('error: --min-messages must be a non-negative integer'));
|
|
@@ -533,37 +473,31 @@ async function insightsAction(options) {
|
|
|
533
473
|
unreadable,
|
|
534
474
|
minMessages,
|
|
535
475
|
by: dim,
|
|
536
|
-
plan: { tierName: tier.tierName, isPaid: tier.isPaid },
|
|
537
|
-
...(gate.groupGated || gate.frictionGated ? { notice: PAID_PLAN_NOTICE } : {}),
|
|
538
476
|
overlap,
|
|
539
477
|
// Built from frictionSignals/correctionSignals/automationSignals together
|
|
540
478
|
// (buildInsightActions) — same paid friction/correction data as above.
|
|
541
|
-
actions
|
|
479
|
+
actions,
|
|
542
480
|
harnesses,
|
|
543
|
-
groups:
|
|
481
|
+
groups: groups.map((g) => ({
|
|
544
482
|
key: g.key,
|
|
545
483
|
label: g.label,
|
|
546
484
|
sessions: g.sessions,
|
|
547
485
|
costUsd: g.costUsd,
|
|
548
486
|
durationMs: g.durationMs,
|
|
549
487
|
outputTokens: g.outputTokens,
|
|
550
|
-
...
|
|
488
|
+
...{
|
|
551
489
|
...g.facets,
|
|
552
490
|
responseGapP50: Math.round(percentile(g.facets.responseGaps, 50)),
|
|
553
491
|
responseGapP90: Math.round(percentile(g.facets.responseGaps, 90)),
|
|
554
492
|
responseGapBuckets: bucketGaps(g.facets.responseGaps),
|
|
555
493
|
// The raw sample is large and uninteresting once bucketed.
|
|
556
494
|
responseGaps: undefined,
|
|
557
|
-
}
|
|
495
|
+
},
|
|
558
496
|
})),
|
|
559
497
|
};
|
|
560
498
|
console.log(JSON.stringify(payload, null, 2));
|
|
561
|
-
if (options.narrative)
|
|
562
|
-
|
|
563
|
-
console.error(chalk.gray(` ${PAID_PLAN_NOTICE}`));
|
|
564
|
-
else
|
|
565
|
-
await renderNarrative(payload);
|
|
566
|
-
}
|
|
499
|
+
if (options.narrative)
|
|
500
|
+
await renderNarrative(payload);
|
|
567
501
|
return;
|
|
568
502
|
}
|
|
569
503
|
renderReport(groups, dim, {
|
|
@@ -574,24 +508,18 @@ async function insightsAction(options) {
|
|
|
574
508
|
unreadable,
|
|
575
509
|
minMessages,
|
|
576
510
|
overlap,
|
|
577
|
-
}, actions, harnesses
|
|
511
|
+
}, actions, harnesses);
|
|
578
512
|
if (options.narrative) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
interruptions: g.facets.interruptions,
|
|
590
|
-
linesTouchedAfter: g.facets.linesTouchedAfter, linesTouchedBefore: g.facets.linesTouchedBefore,
|
|
591
|
-
gitCommits: g.facets.gitCommits,
|
|
592
|
-
replyP50s: Math.round(percentile(g.facets.responseGaps, 50)),
|
|
593
|
-
})));
|
|
594
|
-
}
|
|
513
|
+
await renderNarrative(groups.map((g) => ({
|
|
514
|
+
account: g.label, sessions: g.sessions, costUsd: g.costUsd,
|
|
515
|
+
topTools: topEntries(g.facets.toolCounts, 8),
|
|
516
|
+
languages: topEntries(g.facets.languages, 6),
|
|
517
|
+
errorCategories: topEntries(g.facets.errorCategories, 6),
|
|
518
|
+
interruptions: g.facets.interruptions,
|
|
519
|
+
linesTouchedAfter: g.facets.linesTouchedAfter, linesTouchedBefore: g.facets.linesTouchedBefore,
|
|
520
|
+
gitCommits: g.facets.gitCommits,
|
|
521
|
+
replyP50s: Math.round(percentile(g.facets.responseGaps, 50)),
|
|
522
|
+
})));
|
|
595
523
|
}
|
|
596
524
|
}
|
|
597
525
|
function configureInsightsCommand(cmd) {
|