@phnx-labs/agents-cli 1.20.86 → 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 +112 -0
- package/dist/bin/agents +0 -0
- 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 +83 -7
- 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/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/db.d.ts +21 -0
- package/dist/lib/session/db.js +45 -4
- package/dist/lib/session/remote-list.d.ts +7 -0
- package/dist/lib/session/remote-list.js +8 -4
- package/dist/lib/session/state.js +69 -2
- 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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The per-account key an email maps to inside the `auth` bundle. */
|
|
2
|
+
export declare function claudeAccountTokenKey(account: string): string;
|
|
3
|
+
/** Signed-in account email for a version home, from `.claude.json` (no keychain). */
|
|
4
|
+
export declare function readClaudeAccountEmail(home?: string): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a long-lived `claude setup-token` for the account signed into `home`
|
|
7
|
+
* from the reserved FILE-BASED `auth` bundle. Returns the token or null. Reads
|
|
8
|
+
* ONLY when the bundle is file-backed (never keychain), so this path itself can
|
|
9
|
+
* never trigger a Touch ID prompt — that is the entire point: usage/probe reads
|
|
10
|
+
* authenticate with the shareable setup-token, not the ACL-bound login item.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveClaudeSetupToken(home?: string): string | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
5
|
+
/**
|
|
6
|
+
* Reserved FILE-BASED secrets bundle holding long-lived, non-rotating Claude
|
|
7
|
+
* setup-tokens. Usage/probe reads authenticate with these instead of Claude
|
|
8
|
+
* Code's ACL-bound login item, so they never pop Touch ID. Keyed strictly
|
|
9
|
+
* per-account (`CLAUDE_CODE_OAUTH_TOKEN_<slug>` from the account email) — never a
|
|
10
|
+
* bare key, so one account's token can't be misapplied to another in a
|
|
11
|
+
* multi-account fleet.
|
|
12
|
+
*/
|
|
13
|
+
const AUTH_BUNDLE = 'auth';
|
|
14
|
+
/** The per-account key an email maps to inside the `auth` bundle. */
|
|
15
|
+
export function claudeAccountTokenKey(account) {
|
|
16
|
+
const slug = account
|
|
17
|
+
.trim()
|
|
18
|
+
.toUpperCase()
|
|
19
|
+
.replace(/@/g, '_AT_')
|
|
20
|
+
.replace(/\./g, '_DOT_')
|
|
21
|
+
.replace(/[^A-Z0-9_]/g, '_');
|
|
22
|
+
return `CLAUDE_CODE_OAUTH_TOKEN_${slug}`;
|
|
23
|
+
}
|
|
24
|
+
/** Signed-in account email for a version home, from `.claude.json` (no keychain). */
|
|
25
|
+
export function readClaudeAccountEmail(home) {
|
|
26
|
+
const base = home ?? os.homedir();
|
|
27
|
+
for (const p of [path.join(base, '.claude', '.claude.json'), path.join(base, '.claude.json')]) {
|
|
28
|
+
try {
|
|
29
|
+
const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
|
|
30
|
+
if (typeof email === 'string' && email.trim().length > 0)
|
|
31
|
+
return email.trim();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Missing/unreadable at this location — try the next.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a long-lived `claude setup-token` for the account signed into `home`
|
|
41
|
+
* from the reserved FILE-BASED `auth` bundle. Returns the token or null. Reads
|
|
42
|
+
* ONLY when the bundle is file-backed (never keychain), so this path itself can
|
|
43
|
+
* never trigger a Touch ID prompt — that is the entire point: usage/probe reads
|
|
44
|
+
* authenticate with the shareable setup-token, not the ACL-bound login item.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveClaudeSetupToken(home) {
|
|
47
|
+
try {
|
|
48
|
+
// Require a known account (email) up front: without it we cannot key a
|
|
49
|
+
// per-account token, and we must NOT fall back to a bare shared key that
|
|
50
|
+
// would misapply one account's setup-token to another.
|
|
51
|
+
const email = readClaudeAccountEmail(home);
|
|
52
|
+
if (!email)
|
|
53
|
+
return null;
|
|
54
|
+
if (!bundleExists(AUTH_BUNDLE) || bundleBackend(AUTH_BUNDLE) !== 'file')
|
|
55
|
+
return null;
|
|
56
|
+
const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
|
|
57
|
+
const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
|
|
58
|
+
return v.length > 0 ? v : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -142,3 +142,28 @@ export declare function addIgnored(name: string): Promise<Set<string>>;
|
|
|
142
142
|
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
143
143
|
* not ignored. */
|
|
144
144
|
export declare function removeIgnored(name: string): Promise<boolean>;
|
|
145
|
+
/**
|
|
146
|
+
* Auto-launch preferences: which registered devices are eligible for Factory's
|
|
147
|
+
* auto-host selection, and which are preferred. Stored as a sibling to the
|
|
148
|
+
* registry and ignore-list under ~/.agents/.history/devices/.
|
|
149
|
+
*/
|
|
150
|
+
export interface AutoLaunchPreference {
|
|
151
|
+
enabled?: boolean;
|
|
152
|
+
preferred?: boolean;
|
|
153
|
+
}
|
|
154
|
+
export interface AutoLaunchPreferences {
|
|
155
|
+
devices: Record<string, AutoLaunchPreference>;
|
|
156
|
+
updatedAt: string;
|
|
157
|
+
}
|
|
158
|
+
/** Load auto-launch preferences. Missing or malformed file => empty map. */
|
|
159
|
+
export declare function loadAutoLaunchPreferences(): Promise<Record<string, AutoLaunchPreference>>;
|
|
160
|
+
/** True if the device is enabled for auto-launch. Missing entry defaults to true. */
|
|
161
|
+
export declare function isAutoLaunchEnabled(name: string): Promise<boolean>;
|
|
162
|
+
/** Set whether a device is enabled for auto-launch. Setting to the default
|
|
163
|
+
* (enabled) removes the entry to keep the file minimal. */
|
|
164
|
+
export declare function setAutoLaunchEnabled(name: string, enabled: boolean): Promise<void>;
|
|
165
|
+
/** True if the device is preferred for auto-launch ranking. */
|
|
166
|
+
export declare function isAutoLaunchPreferred(name: string): Promise<boolean>;
|
|
167
|
+
/** Set whether a device is preferred for auto-launch. Setting to the default
|
|
168
|
+
* (not preferred) removes the flag to keep the file minimal. */
|
|
169
|
+
export declare function setAutoLaunchPreferred(name: string, preferred: boolean): Promise<void>;
|
|
@@ -18,7 +18,7 @@ import * as fsSync from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { randomBytes } from 'crypto';
|
|
20
20
|
import lockfile from 'proper-lockfile';
|
|
21
|
-
import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
|
|
21
|
+
import { getDevicesRegistryPath, getDevicesIgnoredPath, getDevicesAutoLaunchPath } from '../state.js';
|
|
22
22
|
/** A device's effective role, defaulting to `worker` when unset. */
|
|
23
23
|
export function deviceRole(d) {
|
|
24
24
|
return d.role ?? 'worker';
|
|
@@ -288,3 +288,84 @@ export async function removeIgnored(name) {
|
|
|
288
288
|
return true;
|
|
289
289
|
});
|
|
290
290
|
}
|
|
291
|
+
function autoLaunchPath() {
|
|
292
|
+
return getDevicesAutoLaunchPath();
|
|
293
|
+
}
|
|
294
|
+
/** Load auto-launch preferences. Missing or malformed file => empty map. */
|
|
295
|
+
export async function loadAutoLaunchPreferences() {
|
|
296
|
+
const p = autoLaunchPath();
|
|
297
|
+
let raw;
|
|
298
|
+
try {
|
|
299
|
+
raw = await fs.readFile(p, 'utf-8');
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
if (err && err.code === 'ENOENT')
|
|
303
|
+
return {};
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const parsed = JSON.parse(raw);
|
|
308
|
+
return parsed.devices && typeof parsed.devices === 'object' ? parsed.devices : {};
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
throw new Error(`Device auto-launch preferences corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** True if the device is enabled for auto-launch. Missing entry defaults to true. */
|
|
315
|
+
export async function isAutoLaunchEnabled(name) {
|
|
316
|
+
assertValidDeviceName(name);
|
|
317
|
+
const prefs = await loadAutoLaunchPreferences();
|
|
318
|
+
return prefs[name]?.enabled !== false;
|
|
319
|
+
}
|
|
320
|
+
/** Set whether a device is enabled for auto-launch. Setting to the default
|
|
321
|
+
* (enabled) removes the entry to keep the file minimal. */
|
|
322
|
+
export async function setAutoLaunchEnabled(name, enabled) {
|
|
323
|
+
assertValidDeviceName(name);
|
|
324
|
+
const p = autoLaunchPath();
|
|
325
|
+
await withRegistryLock(p, async () => {
|
|
326
|
+
const prefs = await loadAutoLaunchPreferences();
|
|
327
|
+
if (enabled) {
|
|
328
|
+
if (prefs[name]) {
|
|
329
|
+
const { enabled: _, ...rest } = prefs[name];
|
|
330
|
+
if (Object.keys(rest).length === 0) {
|
|
331
|
+
delete prefs[name];
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
prefs[name] = rest;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
prefs[name] = { ...prefs[name], enabled: false };
|
|
340
|
+
}
|
|
341
|
+
await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/** True if the device is preferred for auto-launch ranking. */
|
|
345
|
+
export async function isAutoLaunchPreferred(name) {
|
|
346
|
+
assertValidDeviceName(name);
|
|
347
|
+
const prefs = await loadAutoLaunchPreferences();
|
|
348
|
+
return prefs[name]?.preferred === true;
|
|
349
|
+
}
|
|
350
|
+
/** Set whether a device is preferred for auto-launch. Setting to the default
|
|
351
|
+
* (not preferred) removes the flag to keep the file minimal. */
|
|
352
|
+
export async function setAutoLaunchPreferred(name, preferred) {
|
|
353
|
+
assertValidDeviceName(name);
|
|
354
|
+
const p = autoLaunchPath();
|
|
355
|
+
await withRegistryLock(p, async () => {
|
|
356
|
+
const prefs = await loadAutoLaunchPreferences();
|
|
357
|
+
if (preferred) {
|
|
358
|
+
prefs[name] = { ...prefs[name], preferred: true };
|
|
359
|
+
}
|
|
360
|
+
else if (prefs[name]) {
|
|
361
|
+
const { preferred: _, ...rest } = prefs[name];
|
|
362
|
+
if (Object.keys(rest).length === 0) {
|
|
363
|
+
delete prefs[name];
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
prefs[name] = rest;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
|
|
370
|
+
});
|
|
371
|
+
}
|
package/dist/lib/events.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { type ActorKind } from './actor.js';
|
|
15
15
|
export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
|
|
16
|
-
export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
|
|
16
|
+
export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
|
|
17
17
|
export declare function levelFor(event: EventType): EventLevel;
|
|
18
18
|
export interface EventMeta {
|
|
19
19
|
ts: string;
|
|
@@ -156,6 +156,13 @@ export declare function emitCommand(command: string, args?: string[], payload?:
|
|
|
156
156
|
* Emit an error event with full details.
|
|
157
157
|
*/
|
|
158
158
|
export declare function emitError(err: Error | string, payload?: EventPayload): void;
|
|
159
|
+
/**
|
|
160
|
+
* Emit a friction event — a structured, point-of-use record of a failure or
|
|
161
|
+
* block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
|
|
162
|
+
* guard, …); `failureId` is a stable slug that lets the nightly routine group
|
|
163
|
+
* the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
|
|
164
|
+
*/
|
|
165
|
+
export declare function emitFriction(surface: string, failureId: string, payload?: EventPayload): void;
|
|
159
166
|
/**
|
|
160
167
|
* Remove log files older than the retention period.
|
|
161
168
|
* Removes numbered gzip archives whose filesystem mtime exceeds retention.
|
package/dist/lib/events.js
CHANGED
|
@@ -523,6 +523,19 @@ export function emitError(err, payload = {}) {
|
|
|
523
523
|
errorStack: truncate(error.stack, 1000),
|
|
524
524
|
});
|
|
525
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Emit a friction event — a structured, point-of-use record of a failure or
|
|
528
|
+
* block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
|
|
529
|
+
* guard, …); `failureId` is a stable slug that lets the nightly routine group
|
|
530
|
+
* the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
|
|
531
|
+
*/
|
|
532
|
+
export function emitFriction(surface, failureId, payload = {}) {
|
|
533
|
+
emit('friction', {
|
|
534
|
+
...payload,
|
|
535
|
+
surface,
|
|
536
|
+
failureId,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
526
539
|
// ─── Gzip rotation ──────────────────────────────────────────────────────────
|
|
527
540
|
/** Rotate the active file while its append lock is held. */
|
|
528
541
|
function maybeGzipRotateLocked(logPath) {
|
package/dist/lib/exec.js
CHANGED
|
@@ -28,6 +28,7 @@ import { mailboxDir, isValidMailboxId } from './mailbox.js';
|
|
|
28
28
|
import { composeWin32CommandLine } from './platform/index.js';
|
|
29
29
|
import { isTmuxInstalled } from './tmux/binary.js';
|
|
30
30
|
import { shellQuote } from './ssh-exec.js';
|
|
31
|
+
import { resolveClaudeSetupToken } from './claude-account-token.js';
|
|
31
32
|
/**
|
|
32
33
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
33
34
|
*
|
|
@@ -259,7 +260,15 @@ export function buildExecEnv(options) {
|
|
|
259
260
|
? resolvedVersion
|
|
260
261
|
: (resolvedVersion && isVersionInstalled('claude', resolvedVersion) ? resolvedVersion : null);
|
|
261
262
|
if (version) {
|
|
262
|
-
|
|
263
|
+
const versionHome = getVersionHomePath('claude', version);
|
|
264
|
+
result.CLAUDE_CONFIG_DIR = path.join(versionHome, '.claude');
|
|
265
|
+
const setupToken = resolveClaudeSetupToken(versionHome);
|
|
266
|
+
if (setupToken) {
|
|
267
|
+
// A token keyed to this version home's own account replaces any ambient
|
|
268
|
+
// shared value inherited from the launcher. options.env still wins below
|
|
269
|
+
// for explicit caller overrides.
|
|
270
|
+
result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
|
|
271
|
+
}
|
|
263
272
|
// A managed pin lives in a per-version dir; Claude Code's own background
|
|
264
273
|
// auto-updater would rewrite that pinned binary in place (and has left it
|
|
265
274
|
// half-swapped and broken). Disable it so a pin stays a pin. Honor an
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export declare function formatDie(msg: string, opts?: DieOptions): {
|
|
|
27
27
|
* keep the original red-stderr behavior.
|
|
28
28
|
*/
|
|
29
29
|
export declare function die(msg: string, code?: number, opts?: DieOptions): never;
|
|
30
|
+
/**
|
|
31
|
+
* `die()` with a structured friction event attached. Use this at CLI error
|
|
32
|
+
* chokepoints so the nightly routine can classify and rank recurring failures
|
|
33
|
+
* without re-parsing transcripts. `surface` is the subsystem (teams, browser,
|
|
34
|
+
* secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
|
|
35
|
+
*/
|
|
36
|
+
export declare function dieFriction(surface: string, failureId: string, msg: string, code?: number, opts?: DieOptions): never;
|
|
30
37
|
/**
|
|
31
38
|
* Truncate `s` to at most `max` characters, appending a single-char ellipsis
|
|
32
39
|
* (`…`) when shortened. Character-count based (not ANSI/width aware — use
|
package/dist/lib/format.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { readSync } from 'node:fs';
|
|
12
|
+
import { emitFriction } from './events.js';
|
|
12
13
|
/**
|
|
13
14
|
* Render a fatal error to the right stream. Pure — no I/O, no `process.exit` — so
|
|
14
15
|
* the human-vs-agent split is unit-testable. A `--json` caller gets
|
|
@@ -43,6 +44,16 @@ export function die(msg, code = 1, opts = {}) {
|
|
|
43
44
|
console.error(text);
|
|
44
45
|
process.exit(code);
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* `die()` with a structured friction event attached. Use this at CLI error
|
|
49
|
+
* chokepoints so the nightly routine can classify and rank recurring failures
|
|
50
|
+
* without re-parsing transcripts. `surface` is the subsystem (teams, browser,
|
|
51
|
+
* secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
|
|
52
|
+
*/
|
|
53
|
+
export function dieFriction(surface, failureId, msg, code = 1, opts = {}) {
|
|
54
|
+
emitFriction(surface, failureId, { error: msg });
|
|
55
|
+
die(msg, code, opts);
|
|
56
|
+
}
|
|
46
57
|
/**
|
|
47
58
|
* Truncate `s` to at most `max` characters, appending a single-char ellipsis
|
|
48
59
|
* (`…`) when shortened. Character-count based (not ANSI/width aware — use
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import Database from '../sqlite.js';
|
|
10
10
|
import type { SessionAgentId, SessionMeta } from './types.js';
|
|
11
|
+
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
12
|
+
* assert against the constant instead of hardcoding a number that every bump
|
|
13
|
+
* then has to chase (docs/05-sessions.md calls the constant the source of truth). */
|
|
14
|
+
export declare const SCHEMA_VERSION = 21;
|
|
11
15
|
/** Raw row shape returned from the sessions table. */
|
|
12
16
|
export interface SessionRow {
|
|
13
17
|
id: string;
|
|
@@ -40,6 +44,7 @@ export interface SessionRow {
|
|
|
40
44
|
pr_number: number | null;
|
|
41
45
|
worktree_slug: string | null;
|
|
42
46
|
ticket_id: string | null;
|
|
47
|
+
spawned_team: string | null;
|
|
43
48
|
plan: string | null;
|
|
44
49
|
machine: string | null;
|
|
45
50
|
todos: string | null;
|
|
@@ -297,6 +302,22 @@ export declare function queryAffinityRollup(options: {
|
|
|
297
302
|
export declare function queryUsageRollup(options: QueryOptions & {
|
|
298
303
|
groupBy: UsageRollupGroup;
|
|
299
304
|
}): UsageRollupRow[];
|
|
305
|
+
/** Who spawned a team: the orchestrator session, from its transcript. */
|
|
306
|
+
export interface TeamSpawner {
|
|
307
|
+
sessionId: string;
|
|
308
|
+
shortId: string;
|
|
309
|
+
/** The human the orchestrator ran as, when the row carries actor provenance. */
|
|
310
|
+
actor?: string;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Map every team name to the session that ran `agents teams create/add` for it.
|
|
314
|
+
*
|
|
315
|
+
* One scan over the rows that carry a `spawned_team`, rather than a query per
|
|
316
|
+
* team — `agents teams list` needs the whole map at once, and the column has no
|
|
317
|
+
* index. When two sessions spawned the same team name (a team re-created after a
|
|
318
|
+
* disband), the most recent wins, which is the one whose work the name refers to.
|
|
319
|
+
*/
|
|
320
|
+
export declare function teamSpawners(): Map<string, TeamSpawner>;
|
|
300
321
|
/** A session with its cost, for the top-N-by-cost listing. */
|
|
301
322
|
export interface TopCostSession {
|
|
302
323
|
meta: SessionMeta;
|
package/dist/lib/session/db.js
CHANGED
|
@@ -16,8 +16,10 @@ import { machineForSessionFile } from './origin-machine.js';
|
|
|
16
16
|
import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
|
|
17
17
|
const SESSIONS_DIR = getSessionsDir();
|
|
18
18
|
const DB_PATH = getSessionsDbPath();
|
|
19
|
-
/** Current schema version; bumped when migrations are added.
|
|
20
|
-
|
|
19
|
+
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
20
|
+
* assert against the constant instead of hardcoding a number that every bump
|
|
21
|
+
* then has to chase (docs/05-sessions.md calls the constant the source of truth). */
|
|
22
|
+
export const SCHEMA_VERSION = 21;
|
|
21
23
|
/**
|
|
22
24
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
23
25
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -74,6 +76,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
74
76
|
pr_number INTEGER,
|
|
75
77
|
worktree_slug TEXT,
|
|
76
78
|
ticket_id TEXT,
|
|
79
|
+
spawned_team TEXT,
|
|
77
80
|
plan TEXT,
|
|
78
81
|
machine TEXT,
|
|
79
82
|
todos TEXT,
|
|
@@ -383,6 +386,19 @@ function migrateSchema(db, fromVersion) {
|
|
|
383
386
|
db.exec(`ALTER TABLE sessions ADD COLUMN model TEXT`);
|
|
384
387
|
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
385
388
|
}
|
|
389
|
+
if (fromVersion < 21) {
|
|
390
|
+
// v20 → v21: persist the team a session SPAWNED (`agents teams create/add`).
|
|
391
|
+
// The value was already derived at scan time (discover.ts detectSpawnedTeam)
|
|
392
|
+
// and set on SessionMeta, but had no column — so it was dropped at the write
|
|
393
|
+
// and no consumer ever saw it. Wipe BOTH ledgers, not just scan_ledger: with
|
|
394
|
+
// dir_ledger intact, collectChangedFilesInLeafDirs treats every non-live-root
|
|
395
|
+
// dir as unchanged and derives its hot set from the scan stamps just deleted,
|
|
396
|
+
// so archived dirs would never be re-parsed and would stay NULL forever.
|
|
397
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
398
|
+
if (!cols.some(c => c.name === 'spawned_team'))
|
|
399
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN spawned_team TEXT`);
|
|
400
|
+
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
401
|
+
}
|
|
386
402
|
}
|
|
387
403
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
388
404
|
export function getDB() {
|
|
@@ -744,7 +760,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
744
760
|
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
745
761
|
output_tokens, cost_usd, duration_ms, model,
|
|
746
762
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
747
|
-
pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
|
|
763
|
+
pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
|
|
748
764
|
recent_directories_touched, linear_project, linear_project_url, machine,
|
|
749
765
|
actor, initiated_by
|
|
750
766
|
) VALUES (
|
|
@@ -753,7 +769,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
753
769
|
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
754
770
|
@output_tokens, @cost_usd, @duration_ms, @model,
|
|
755
771
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
756
|
-
@pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
|
|
772
|
+
@pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
|
|
757
773
|
@recent_directories_touched, @linear_project, @linear_project_url, @machine,
|
|
758
774
|
@actor, @initiated_by
|
|
759
775
|
)
|
|
@@ -795,6 +811,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
795
811
|
pr_number = excluded.pr_number,
|
|
796
812
|
worktree_slug = excluded.worktree_slug,
|
|
797
813
|
ticket_id = excluded.ticket_id,
|
|
814
|
+
spawned_team = excluded.spawned_team,
|
|
798
815
|
plan = excluded.plan,
|
|
799
816
|
todos = excluded.todos,
|
|
800
817
|
recent_directories_touched = excluded.recent_directories_touched,
|
|
@@ -911,6 +928,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
911
928
|
pr_number: meta.prNumber ?? null,
|
|
912
929
|
worktree_slug: meta.worktreeSlug ?? null,
|
|
913
930
|
ticket_id: meta.ticketId ?? null,
|
|
931
|
+
spawned_team: meta.spawnedTeam ?? null,
|
|
914
932
|
plan: meta.plan ?? null,
|
|
915
933
|
todos: meta.todos ? JSON.stringify(meta.todos) : null,
|
|
916
934
|
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
@@ -1031,6 +1049,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
1031
1049
|
pr_number: meta.prNumber ?? null,
|
|
1032
1050
|
worktree_slug: meta.worktreeSlug ?? null,
|
|
1033
1051
|
ticket_id: meta.ticketId ?? null,
|
|
1052
|
+
spawned_team: meta.spawnedTeam ?? null,
|
|
1034
1053
|
plan: meta.plan ?? null,
|
|
1035
1054
|
todos: meta.todos ? JSON.stringify(meta.todos) : null,
|
|
1036
1055
|
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
@@ -1219,6 +1238,7 @@ function rowToMeta(row) {
|
|
|
1219
1238
|
prNumber: row.pr_number ?? undefined,
|
|
1220
1239
|
worktreeSlug: row.worktree_slug ?? undefined,
|
|
1221
1240
|
ticketId: row.ticket_id ?? undefined,
|
|
1241
|
+
spawnedTeam: row.spawned_team ?? undefined,
|
|
1222
1242
|
plan: row.plan ?? undefined,
|
|
1223
1243
|
todos: parseJsonColumn(row.todos),
|
|
1224
1244
|
recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
|
|
@@ -1483,6 +1503,27 @@ export function queryUsageRollup(options) {
|
|
|
1483
1503
|
`;
|
|
1484
1504
|
return db.prepare(sql).all(...params);
|
|
1485
1505
|
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Map every team name to the session that ran `agents teams create/add` for it.
|
|
1508
|
+
*
|
|
1509
|
+
* One scan over the rows that carry a `spawned_team`, rather than a query per
|
|
1510
|
+
* team — `agents teams list` needs the whole map at once, and the column has no
|
|
1511
|
+
* index. When two sessions spawned the same team name (a team re-created after a
|
|
1512
|
+
* disband), the most recent wins, which is the one whose work the name refers to.
|
|
1513
|
+
*/
|
|
1514
|
+
export function teamSpawners() {
|
|
1515
|
+
const db = getDB();
|
|
1516
|
+
const rows = db
|
|
1517
|
+
.prepare(`SELECT spawned_team, id, short_id, actor FROM sessions
|
|
1518
|
+
WHERE spawned_team IS NOT NULL AND spawned_team != ''
|
|
1519
|
+
ORDER BY timestamp ASC`)
|
|
1520
|
+
.all();
|
|
1521
|
+
const out = new Map();
|
|
1522
|
+
for (const r of rows) {
|
|
1523
|
+
out.set(r.spawned_team, { sessionId: r.id, shortId: r.short_id, actor: r.actor ?? undefined });
|
|
1524
|
+
}
|
|
1525
|
+
return out;
|
|
1526
|
+
}
|
|
1486
1527
|
/**
|
|
1487
1528
|
* Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
|
|
1488
1529
|
* honoring the same filter shape as querySessions. Drops rows whose JSONL
|
|
@@ -20,6 +20,13 @@ export interface RemoteListResult {
|
|
|
20
20
|
sessions: SessionMeta[];
|
|
21
21
|
/** How many peer machines we attempted to reach (drives the empty-fleet tip). */
|
|
22
22
|
deviceCount: number;
|
|
23
|
+
/**
|
|
24
|
+
* Peers that failed to answer, by display name. The stderr note above is
|
|
25
|
+
* enough for a printed listing, but the interactive browser repaints over it —
|
|
26
|
+
* so callers rendering a full-screen UI need the outcome as data to tell
|
|
27
|
+
* "that box is asleep" apart from "that box has no matching sessions".
|
|
28
|
+
*/
|
|
29
|
+
unreachable: string[];
|
|
23
30
|
}
|
|
24
31
|
/**
|
|
25
32
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -97,9 +97,9 @@ async function fetchByTarget(target, machine, display, forwardedArgs, os) {
|
|
|
97
97
|
const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
|
|
98
98
|
if (code !== 0) {
|
|
99
99
|
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
100
|
-
return [];
|
|
100
|
+
return { sessions: [], unreachable: display };
|
|
101
101
|
}
|
|
102
|
-
return parseRemoteList(stdout, machine);
|
|
102
|
+
return { sessions: parseRemoteList(stdout, machine) };
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -122,7 +122,7 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
122
122
|
reg = await loadDevices();
|
|
123
123
|
}
|
|
124
124
|
catch {
|
|
125
|
-
return { sessions: [], deviceCount: 0 };
|
|
125
|
+
return { sessions: [], deviceCount: 0, unreachable: [] };
|
|
126
126
|
}
|
|
127
127
|
for (const d of Object.values(reg)) {
|
|
128
128
|
if (d.tailscale?.online !== true)
|
|
@@ -147,7 +147,11 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, forwardedArgs, t.os)));
|
|
150
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
sessions: results.flatMap((r) => r.sessions),
|
|
152
|
+
deviceCount: targets.length,
|
|
153
|
+
unreachable: results.map((r) => r.unreachable).filter((n) => !!n),
|
|
154
|
+
};
|
|
151
155
|
}
|
|
152
156
|
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
153
157
|
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
@@ -201,12 +201,66 @@ const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
|
|
|
201
201
|
const GH_ISSUE_CREATE_RE = /\bgh\s+issue\s+create\b/;
|
|
202
202
|
/** A created GitHub issue URL (…/issues/123) in tool-result output. */
|
|
203
203
|
const GH_ISSUE_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/issues\/(\d+)/;
|
|
204
|
+
/**
|
|
205
|
+
* Flags of `teams create` / `teams add` that take a value, so the value is not
|
|
206
|
+
* mistaken for the positional team name. Mirrors their value-taking flags in
|
|
207
|
+
* `commands/teams.ts` — most are `.option('… <x>')` registrations, but
|
|
208
|
+
* `--device`/`--host` come from `addHostOption`, so auditing this list against
|
|
209
|
+
* `.option(` alone would wrongly drop them. A flag missing here degrades to "no
|
|
210
|
+
* team detected", never to a wrong one.
|
|
211
|
+
*/
|
|
212
|
+
const TEAM_VALUE_FLAGS = [
|
|
213
|
+
'-d', '--description', '--use-worktree', '--devices', '--hosts', '--repo',
|
|
214
|
+
'-n', '--name', '-m', '--mode', '-e', '--effort', '--model', '--env',
|
|
215
|
+
'--cwd', '--worktree', '--after', '--task-type', '--cloud', '--branch',
|
|
216
|
+
'--device', '--host',
|
|
217
|
+
];
|
|
218
|
+
/**
|
|
219
|
+
* One flag value: a quoted string or a bare token. `-d "sessions lineage"` is the
|
|
220
|
+
* common shape — `--description` is usually a phrase — and a value pattern of
|
|
221
|
+
* `\S+` alone stops at the first space, leaving the rest of the phrase to be read
|
|
222
|
+
* as the positional team name (`… -d "sessions lineage" my-team` detected
|
|
223
|
+
* `lineage`). Quotes are matched as a unit so the whole value is consumed.
|
|
224
|
+
*
|
|
225
|
+
* A value containing an ESCAPED quote (`-d "say \"hi\" now"`) stops the quoted
|
|
226
|
+
* branch early and the match then fails outright — which is the intended failure
|
|
227
|
+
* direction: no team detected rather than a wrong one.
|
|
228
|
+
*/
|
|
229
|
+
const FLAG_VALUE = String.raw `(?:"[^"\n]*"|'[^'\n]*'|\S+)`;
|
|
204
230
|
/**
|
|
205
231
|
* `agents teams create <name>` / `agents teams add <team> …` (also the `ag` alias).
|
|
206
232
|
* The team NAME is the first bareword after the sub-verb, skipping any flags. This
|
|
207
233
|
* is the structural signal that a session SPAWNED a team (vs. was spawned by one).
|
|
234
|
+
*
|
|
235
|
+
* The separators are spaces/tabs, never `\s`: a command string routinely embeds
|
|
236
|
+
* documentation and quoted output, and `\s` let the flag-skip run across newlines
|
|
237
|
+
* to capture a word from a completely different line (a real scan produced
|
|
238
|
+
* `team:installed` from a heredoc). For the same reason the flag-skip is bounded
|
|
239
|
+
* rather than unlimited — a real invocation carries a handful of flags before the
|
|
240
|
+
* name, not dozens.
|
|
208
241
|
*/
|
|
209
|
-
const TEAMS_SPAWN_RE =
|
|
242
|
+
const TEAMS_SPAWN_RE = new RegExp(
|
|
243
|
+
// Start of an actually-executed command: string start, a newline, or a shell
|
|
244
|
+
// separator. Without this, a backticked mention inside prose or tool output
|
|
245
|
+
// ("… and `agents teams add --device auto`") reads as a spawn.
|
|
246
|
+
String.raw `(?:^|[\n;&|(]|&&|\|\|)[ \t]*` +
|
|
247
|
+
String.raw `ag(?:ents)?[ \t]+teams?[ \t]+(?:create|add)[ \t]+` +
|
|
248
|
+
// Flags before the positional name. A value-taking flag must swallow its
|
|
249
|
+
// value, or `--device auto` leaves `auto` looking like the team name — and
|
|
250
|
+
// the generic branch must exclude those flags, or it swallows the flag alone
|
|
251
|
+
// and hands the value back as the name.
|
|
252
|
+
String.raw `(?:(?:${TEAM_VALUE_FLAGS.join('|')})[= \t]${FLAG_VALUE}[ \t]+` +
|
|
253
|
+
String.raw `|(?!(?:${TEAM_VALUE_FLAGS.join('|')})[= \t])--?[a-z][\w-]*(?:=\S+)?[ \t]+){0,6}` +
|
|
254
|
+
// A team name may start with a digit — `createTeam` validates nothing, and
|
|
255
|
+
// `2fa-migration` is a legal name — so the class stays [A-Za-z0-9]. The
|
|
256
|
+
// all-digits case is rejected in the guard below instead.
|
|
257
|
+
String.raw `([A-Za-z0-9][\w-]*)`);
|
|
258
|
+
/**
|
|
259
|
+
* Sub-verbs that can follow `teams create|add` in prose ("teams add a teammate")
|
|
260
|
+
* but are never a team name. Guards the common case where the match came from a
|
|
261
|
+
* sentence rather than an executed command.
|
|
262
|
+
*/
|
|
263
|
+
const NON_TEAM_WORDS = new Set(['a', 'an', 'the', 'to', 'for', 'with', 'and', 'this', 'your', 'my', 'it']);
|
|
210
264
|
/** Collapse to a single trimmed line for a one-row preview cell. */
|
|
211
265
|
function oneLine(s) {
|
|
212
266
|
return s.replace(/\s+/g, ' ').trim();
|
|
@@ -259,7 +313,20 @@ export function detectSpawnedTeam(command) {
|
|
|
259
313
|
if (!command)
|
|
260
314
|
return undefined;
|
|
261
315
|
const m = command.match(TEAMS_SPAWN_RE);
|
|
262
|
-
|
|
316
|
+
if (!m)
|
|
317
|
+
return undefined;
|
|
318
|
+
const name = m[1];
|
|
319
|
+
// A single character is a doc placeholder (`agents teams create t --host <box>`)
|
|
320
|
+
// far more often than a real team, and an English article is prose. Both used to
|
|
321
|
+
// land in the index as a team name, and now that the name is rendered on the row
|
|
322
|
+
// a wrong one is worse than none.
|
|
323
|
+
// A single character is a doc placeholder (`agents teams create t --host <name>`)
|
|
324
|
+
// far more often than a real team; an all-digits token is a flag value or a list
|
|
325
|
+
// index that leaked through, never a name someone typed. Both had reached the
|
|
326
|
+
// index, and now that the name is rendered a wrong one is worse than none.
|
|
327
|
+
if (name.length < 2 || /^\d+$/.test(name) || NON_TEAM_WORDS.has(name.toLowerCase()))
|
|
328
|
+
return undefined;
|
|
329
|
+
return name;
|
|
263
330
|
}
|
|
264
331
|
/**
|
|
265
332
|
* True when a tool_use call CREATES a tracker ticket — a Linear MCP `create_issue`
|