@phnx-labs/agents-cli 1.22.25 → 1.22.26
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 +183 -0
- package/README.md +17 -2
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +8 -0
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +43 -0
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +33 -1
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +23 -0
- package/dist/lib/hosts/passthrough.js +45 -0
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +1 -14
- 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/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +221 -13
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/package.json +1 -1
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Insights command —
|
|
2
|
+
* Insights command — one observe verb for "how work looks".
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* any of them:
|
|
4
|
+
* Two data paths under one name (do not re-split into peer top-level commands):
|
|
6
5
|
*
|
|
7
|
-
* agents
|
|
8
|
-
*
|
|
9
|
-
* agents
|
|
10
|
-
*
|
|
11
|
-
* agents
|
|
12
|
-
* agents insights
|
|
6
|
+
* agents insights HOW you work (transcript content: tools, friction,
|
|
7
|
+
* rhythm, edits) — split by Claude account by default
|
|
8
|
+
* agents insights mix COUNTERS (sessions index + usage.db recipes:
|
|
9
|
+
* harness/model mix, token ratios, secrets, browser)
|
|
10
|
+
* agents insights <recipe> One baked mix recipe (harness-mix, tools-per-session, …)
|
|
11
|
+
* agents insights query Raw usage.db rows
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
13
|
+
* Sibling observe verbs (stay separate — different questions):
|
|
14
|
+
*
|
|
15
|
+
* agents cost what you spent ($ and duration)
|
|
16
|
+
* agents output what shipped (burn vs PRs and commits)
|
|
17
|
+
* agents usage live quota headroom
|
|
18
|
+
* agents perf latency (hooks, CLI commands, agent.run) — not popularity
|
|
19
|
+
* agents sessions stats which skills/slash-commands were explicitly invoked
|
|
20
|
+
*
|
|
21
|
+
* Why mix lives here (not a second top-level `trends`): two abstract "analytics"
|
|
22
|
+
* nouns taught agents and humans to guess. One verb, two engines — cheap SQL mix
|
|
23
|
+
* vs transcript facets. Latency stays on `perf` so it is never confused with mix.
|
|
21
24
|
*
|
|
22
25
|
* Modelled on Claude Code's `/insights`, with the difference that motivated it: that
|
|
23
26
|
* command reads one account's directory, while `balanced` rotation sprays sessions
|
|
@@ -27,6 +30,8 @@
|
|
|
27
30
|
* The deterministic report makes zero network calls. `--narrative` is opt-in and adds
|
|
28
31
|
* the coaching prose by piping the AGGREGATE (never raw transcripts) through a headless
|
|
29
32
|
* `claude -p`.
|
|
33
|
+
*
|
|
34
|
+
* `agents trends` is a thin deprecated alias of the mix tree only (see commands/trends.ts).
|
|
30
35
|
*/
|
|
31
36
|
import * as fs from 'fs';
|
|
32
37
|
import chalk from 'chalk';
|
|
@@ -37,11 +42,25 @@ import { setHelpSections } from '../lib/help.js';
|
|
|
37
42
|
import { discoverSessions, parseTimeFilter } from '../lib/session/discover.js';
|
|
38
43
|
import { querySessions, readSessionInsights, writeSessionInsights, clearSessionInsights, } from '../lib/session/db.js';
|
|
39
44
|
import { parseSession } from '../lib/session/parse.js';
|
|
40
|
-
import { computeInsightFacets, mergeFacets, newFacetAccumulator, detectOverlap, percentile, bucketGaps, topEntries, } from '../lib/session/insights.js';
|
|
45
|
+
import { computeInsightFacets, mergeFacets, newFacetAccumulator, detectOverlap, percentile, bucketGaps, topEntries, buildInsightActions, } from '../lib/session/insights.js';
|
|
41
46
|
import { formatUsd } from '../lib/pricing/index.js';
|
|
42
47
|
import { formatDuration } from '../lib/session/render.js';
|
|
43
48
|
import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
|
|
49
|
+
import { registerMixCommands } from '../lib/analytics/mix-commands.js';
|
|
44
50
|
const execFileAsync = promisify(execFile);
|
|
51
|
+
function collectAgent(value, previous) {
|
|
52
|
+
return [...previous, value];
|
|
53
|
+
}
|
|
54
|
+
function agentsFromArgv(argv) {
|
|
55
|
+
const values = [];
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
if (argv[i] === '--agent' && argv[i + 1])
|
|
58
|
+
values.push(argv[++i]);
|
|
59
|
+
else if (argv[i].startsWith('--agent='))
|
|
60
|
+
values.push(argv[i].slice('--agent='.length));
|
|
61
|
+
}
|
|
62
|
+
return values;
|
|
63
|
+
}
|
|
45
64
|
function resolveGroup(by) {
|
|
46
65
|
if (by === undefined)
|
|
47
66
|
return 'account';
|
|
@@ -187,7 +206,7 @@ function renderHours(hours, out) {
|
|
|
187
206
|
out.push(` ${chalk.cyan(spark)}`);
|
|
188
207
|
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')}`);
|
|
189
208
|
}
|
|
190
|
-
function renderReport(groups, dim, meta) {
|
|
209
|
+
function renderReport(groups, dim, meta, actions, harnesses) {
|
|
191
210
|
const out = [];
|
|
192
211
|
const scope = meta.since ? `last ${meta.since}` : 'all time';
|
|
193
212
|
out.push(chalk.bold('Insights') + chalk.gray(` ${scope} · ${meta.analyzed} of ${meta.scanned} sessions`));
|
|
@@ -232,6 +251,22 @@ function renderReport(groups, dim, meta) {
|
|
|
232
251
|
for (const e of errs)
|
|
233
252
|
out.push(` ${chalk.gray('·')} ${padToWidth(e.name, 16)} ${chalk.gray(String(e.count))}`);
|
|
234
253
|
}
|
|
254
|
+
renderCounts('Friction / thrash', topEntries(all.frictionSignals, 10), out);
|
|
255
|
+
renderCounts('Dissatisfaction / corrections', topEntries(all.correctionSignals, 10), out);
|
|
256
|
+
renderCounts('Automatable repeats', topEntries(all.automationSignals, 10), out);
|
|
257
|
+
renderCounts('Harness split', harnesses, out);
|
|
258
|
+
out.push('');
|
|
259
|
+
out.push(chalk.bold('Actions'));
|
|
260
|
+
if (actions.length === 0) {
|
|
261
|
+
out.push(chalk.gray(' No repeated action pattern met the evidence threshold in this window.'));
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
out.push(chalk.gray(' pri category evidence sample sessions action'));
|
|
265
|
+
for (const action of actions.slice(0, 12)) {
|
|
266
|
+
out.push(` ${padToWidth(action.priority, 7)} ${padToWidth(action.category, 11)} ` +
|
|
267
|
+
`${String(action.evidenceCount).padStart(8)} ${padToWidth(action.sampleSessionIds.join(', '), 25)} ${action.action}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
235
270
|
// Output
|
|
236
271
|
out.push('');
|
|
237
272
|
out.push(chalk.bold('What you changed'));
|
|
@@ -351,11 +386,11 @@ async function insightsAction(options) {
|
|
|
351
386
|
if (options.refresh)
|
|
352
387
|
clearSessionInsights();
|
|
353
388
|
const filter = { sinceMs };
|
|
354
|
-
if (options.agent)
|
|
355
|
-
filter.agent = options.agent;
|
|
356
389
|
const scanned = querySessions(filter);
|
|
357
390
|
const wanted = options.account?.toLowerCase();
|
|
358
391
|
const inScope = scanned.filter((m) => {
|
|
392
|
+
if (options.agent?.length && !options.agent.includes(m.agent))
|
|
393
|
+
return false;
|
|
359
394
|
if (!wanted)
|
|
360
395
|
return true;
|
|
361
396
|
return [m.accountKey, m.account, m.accountOrg]
|
|
@@ -381,6 +416,15 @@ async function insightsAction(options) {
|
|
|
381
416
|
});
|
|
382
417
|
const overlap = detectOverlap(spans);
|
|
383
418
|
const groups = buildGroups(substantive, facetsById, dim);
|
|
419
|
+
const evidence = substantive.flatMap((m) => {
|
|
420
|
+
const facets = facetsById.get(m.id);
|
|
421
|
+
return facets ? [{ id: m.id, facets }] : [];
|
|
422
|
+
});
|
|
423
|
+
const actions = buildInsightActions(evidence);
|
|
424
|
+
const harnesses = topEntries(substantive.reduce((counts, row) => {
|
|
425
|
+
counts[row.agent] = (counts[row.agent] ?? 0) + 1;
|
|
426
|
+
return counts;
|
|
427
|
+
}, {}), 20);
|
|
384
428
|
if (options.json) {
|
|
385
429
|
const payload = {
|
|
386
430
|
generatedAt: new Date().toISOString(),
|
|
@@ -392,6 +436,8 @@ async function insightsAction(options) {
|
|
|
392
436
|
minMessages,
|
|
393
437
|
by: dim,
|
|
394
438
|
overlap,
|
|
439
|
+
actions,
|
|
440
|
+
harnesses,
|
|
395
441
|
groups: groups.map((g) => ({
|
|
396
442
|
key: g.key,
|
|
397
443
|
label: g.label,
|
|
@@ -420,7 +466,7 @@ async function insightsAction(options) {
|
|
|
420
466
|
unreadable,
|
|
421
467
|
minMessages,
|
|
422
468
|
overlap,
|
|
423
|
-
});
|
|
469
|
+
}, actions, harnesses);
|
|
424
470
|
if (options.narrative) {
|
|
425
471
|
await renderNarrative(groups.map((g) => ({
|
|
426
472
|
account: g.label, sessions: g.sessions, costUsd: g.costUsd,
|
|
@@ -434,49 +480,77 @@ async function insightsAction(options) {
|
|
|
434
480
|
})));
|
|
435
481
|
}
|
|
436
482
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
.description('How
|
|
483
|
+
function configureInsightsCommand(cmd) {
|
|
484
|
+
addHostOption(cmd)
|
|
485
|
+
.description('How work looks — behavioural report (default) or counter mix (`mix`, recipes)')
|
|
440
486
|
.option('--json', 'Output the full report as JSON')
|
|
441
487
|
.option('--since <time>', 'Window: 7d, 4w, 3mo, an ISO date, or "all" (default 30d)')
|
|
442
488
|
.option('--all', 'Every session ever indexed. Alias for --since all')
|
|
443
489
|
.option('--by <dimension>', 'Group by: account (default), agent, project, or day')
|
|
444
490
|
.option('--account <match>', 'Only sessions whose account key, email, or org contains this')
|
|
445
|
-
.option('--agent <id>', 'Only
|
|
491
|
+
.option('--agent <id>', 'Only these harnesses; repeat for more than one', collectAgent, [])
|
|
446
492
|
.option('--min-messages <n>', 'Skip sessions under this many messages, both roles counted (default 2)')
|
|
447
493
|
.option('--refresh', 'Discard cached facets and re-read every transcript')
|
|
448
494
|
.option('--narrative', 'Add a written read on the numbers via a headless `claude -p`')
|
|
449
495
|
.action(async (options) => {
|
|
450
|
-
|
|
496
|
+
const inherited = cmd.parent?.name() === 'sessions'
|
|
497
|
+
? cmd.parent.opts()
|
|
498
|
+
: {};
|
|
499
|
+
const inheritedAgent = typeof inherited.agent === 'string' ? [inherited.agent] : [];
|
|
500
|
+
const rawAgents = agentsFromArgv(process.argv.slice(2));
|
|
501
|
+
await insightsAction({
|
|
502
|
+
...inherited,
|
|
503
|
+
...options,
|
|
504
|
+
agent: rawAgents.length > 0 ? rawAgents : [...inheritedAgent, ...(options.agent ?? [])],
|
|
505
|
+
json: options.json ?? inherited.json,
|
|
506
|
+
since: options.since ?? inherited.since,
|
|
507
|
+
});
|
|
451
508
|
});
|
|
509
|
+
// Cheap counter recipes (former top-level `agents trends`) — same parent, no peer verb.
|
|
510
|
+
registerMixCommands(cmd);
|
|
452
511
|
setHelpSections(cmd, {
|
|
453
512
|
examples: `
|
|
454
|
-
#
|
|
513
|
+
# Behavioural report — last 30 days, split by Claude account (default)
|
|
455
514
|
agents insights
|
|
456
515
|
|
|
457
516
|
# Which repo is eating the time
|
|
458
517
|
agents insights --by project --since 90d
|
|
459
518
|
|
|
519
|
+
# Counter mix board (harness/model/token/secrets recipes) — former agents trends
|
|
520
|
+
agents insights mix
|
|
521
|
+
agents insights mix --days 30
|
|
522
|
+
agents insights harness-mix --json
|
|
523
|
+
agents insights query --kind secret --days 7
|
|
524
|
+
|
|
460
525
|
# One account only, all of its history
|
|
461
526
|
agents insights --account "Turing Labs" --all
|
|
462
527
|
|
|
463
528
|
# Machine-readable, for a dashboard or a slash command
|
|
464
|
-
agents insights --json
|
|
529
|
+
agents sessions insights --agent claude --agent codex --json
|
|
465
530
|
|
|
466
531
|
# Add a written read on what to change
|
|
467
532
|
agents insights --narrative
|
|
468
533
|
`,
|
|
469
534
|
notes: `
|
|
470
|
-
|
|
471
|
-
|
|
535
|
+
Two paths under one verb:
|
|
536
|
+
bare \`agents insights\` — transcript behaviour (tools, friction, rhythm, by account)
|
|
537
|
+
\`agents insights mix\` — cheap counters from sessions.db + usage.db
|
|
538
|
+
Latency is \`agents perf\` (not mix). Quota is \`agents usage\`. Skill/slash popularity
|
|
539
|
+
is \`agents sessions stats\`. \`agents trends\` is a deprecated alias of the mix tree.
|
|
472
540
|
|
|
473
|
-
The
|
|
541
|
+
The behavioural report parses in-scope transcripts once and caches facets; later runs
|
|
474
542
|
re-read only files that changed. \`--refresh\` forces a full re-read.
|
|
475
543
|
|
|
476
|
-
|
|
477
|
-
|
|
544
|
+
\`agents insights\` is the top-level alias of \`agents sessions insights\`.
|
|
545
|
+
Repeat \`--agent\` to compare several harnesses in one report.
|
|
478
546
|
|
|
479
547
|
Everything except \`--narrative\` is local and makes no network calls.
|
|
480
548
|
`,
|
|
481
549
|
});
|
|
482
550
|
}
|
|
551
|
+
export function registerInsightsCommand(program) {
|
|
552
|
+
configureInsightsCommand(program.command('insights'));
|
|
553
|
+
}
|
|
554
|
+
export function registerSessionsInsightsCommand(sessions) {
|
|
555
|
+
configureInsightsCommand(sessions.command('insights'));
|
|
556
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents reconnect [session-id]` — get me back into an agent terminal whose ssh
|
|
3
|
+
* link dropped.
|
|
4
|
+
*
|
|
5
|
+
* The auto-reconnect in lib/hosts/reconnect.ts already re-attaches a live remote
|
|
6
|
+
* pane over its own bounded backoff on a transient blink. This is the MANUAL
|
|
7
|
+
* recovery for after it gave up — a sustained outage, or a VS Code terminal tab
|
|
8
|
+
* that closed with the dead ssh client. One verb that always tries hardest to put
|
|
9
|
+
* you back into that agent: attach the live pane if it still exists, else resume
|
|
10
|
+
* the session; live pane > resumed copy > a clear message about what was lost.
|
|
11
|
+
*
|
|
12
|
+
* It delegates to `focus`, which already does exactly attach-else-resume for a
|
|
13
|
+
* resolved id ({@link focusAction} → `focusResolvedSession`), so there is no second
|
|
14
|
+
* recovery path to keep in sync. What `reconnect` adds is the recovery framing and,
|
|
15
|
+
* with no id, targeting the MOST RECENT session in this shell's directory — the one
|
|
16
|
+
* that most likely just dropped — instead of opening the full fleet picker.
|
|
17
|
+
*/
|
|
18
|
+
import type { Command } from 'commander';
|
|
19
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
20
|
+
/**
|
|
21
|
+
* The recency signal a no-id reconnect targets: last activity if the scan
|
|
22
|
+
* computed one, else the creation time. A row with neither parseable sorts last.
|
|
23
|
+
* Pure so target selection is unit-tested without the scanner.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sessionRecency(s: Pick<SessionMeta, 'lastActivity' | 'timestamp'>): number;
|
|
26
|
+
/**
|
|
27
|
+
* Pick the session to reconnect when the user gave no id: the most recently
|
|
28
|
+
* active one in scope — the terminal that most likely just dropped. Pure; returns
|
|
29
|
+
* undefined for an empty scope so the caller can print guidance.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pickMostRecentSession(sessions: SessionMeta[]): SessionMeta | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the no-id target: prefer the most recent session started from THIS
|
|
34
|
+
* directory (the shell the user is reconnecting from), falling back to the most
|
|
35
|
+
* recent session anywhere when the current directory has none. Best-effort — a
|
|
36
|
+
* discovery failure yields undefined, never throws. Injectable `discover` keeps
|
|
37
|
+
* the two-step scope preference testable without touching the real scanner.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveRecentTarget(cwd: string, discover?: (opts: {
|
|
40
|
+
cwd?: string;
|
|
41
|
+
all?: boolean;
|
|
42
|
+
since?: string;
|
|
43
|
+
limit?: number;
|
|
44
|
+
}) => Promise<SessionMeta[]>): Promise<SessionMeta | undefined>;
|
|
45
|
+
export declare function reconnectAction(id: string | undefined): Promise<void>;
|
|
46
|
+
export declare function registerReconnectCommand(program: Command): void;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents reconnect [session-id]` — get me back into an agent terminal whose ssh
|
|
3
|
+
* link dropped.
|
|
4
|
+
*
|
|
5
|
+
* The auto-reconnect in lib/hosts/reconnect.ts already re-attaches a live remote
|
|
6
|
+
* pane over its own bounded backoff on a transient blink. This is the MANUAL
|
|
7
|
+
* recovery for after it gave up — a sustained outage, or a VS Code terminal tab
|
|
8
|
+
* that closed with the dead ssh client. One verb that always tries hardest to put
|
|
9
|
+
* you back into that agent: attach the live pane if it still exists, else resume
|
|
10
|
+
* the session; live pane > resumed copy > a clear message about what was lost.
|
|
11
|
+
*
|
|
12
|
+
* It delegates to `focus`, which already does exactly attach-else-resume for a
|
|
13
|
+
* resolved id ({@link focusAction} → `focusResolvedSession`), so there is no second
|
|
14
|
+
* recovery path to keep in sync. What `reconnect` adds is the recovery framing and,
|
|
15
|
+
* with no id, targeting the MOST RECENT session in this shell's directory — the one
|
|
16
|
+
* that most likely just dropped — instead of opening the full fleet picker.
|
|
17
|
+
*/
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import { focusAction } from './focus.js';
|
|
20
|
+
import { discoverSessions } from '../lib/session/discover.js';
|
|
21
|
+
import { setHelpSections } from '../lib/help.js';
|
|
22
|
+
/**
|
|
23
|
+
* The recency signal a no-id reconnect targets: last activity if the scan
|
|
24
|
+
* computed one, else the creation time. A row with neither parseable sorts last.
|
|
25
|
+
* Pure so target selection is unit-tested without the scanner.
|
|
26
|
+
*/
|
|
27
|
+
export function sessionRecency(s) {
|
|
28
|
+
const t = Date.parse(s.lastActivity ?? s.timestamp ?? '');
|
|
29
|
+
return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Pick the session to reconnect when the user gave no id: the most recently
|
|
33
|
+
* active one in scope — the terminal that most likely just dropped. Pure; returns
|
|
34
|
+
* undefined for an empty scope so the caller can print guidance.
|
|
35
|
+
*/
|
|
36
|
+
export function pickMostRecentSession(sessions) {
|
|
37
|
+
let best;
|
|
38
|
+
for (const s of sessions) {
|
|
39
|
+
if (!best || sessionRecency(s) > sessionRecency(best))
|
|
40
|
+
best = s;
|
|
41
|
+
}
|
|
42
|
+
return best;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the no-id target: prefer the most recent session started from THIS
|
|
46
|
+
* directory (the shell the user is reconnecting from), falling back to the most
|
|
47
|
+
* recent session anywhere when the current directory has none. Best-effort — a
|
|
48
|
+
* discovery failure yields undefined, never throws. Injectable `discover` keeps
|
|
49
|
+
* the two-step scope preference testable without touching the real scanner.
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveRecentTarget(cwd, discover = discoverSessions) {
|
|
52
|
+
try {
|
|
53
|
+
const here = await discover({ cwd, since: '7d', limit: 200 });
|
|
54
|
+
const local = pickMostRecentSession(here);
|
|
55
|
+
if (local)
|
|
56
|
+
return local;
|
|
57
|
+
const any = await discover({ all: true, since: '7d', limit: 200 });
|
|
58
|
+
return pickMostRecentSession(any);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function reconnectAction(id) {
|
|
65
|
+
if (id) {
|
|
66
|
+
// focus already attaches a live pane or recovers a dropped one for a resolved
|
|
67
|
+
// id — on the origin device for a remote session. Reconnect is that path with
|
|
68
|
+
// a recovery-first name; no reason to duplicate the resolution/attach logic.
|
|
69
|
+
await focusAction(id, {});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const recent = await resolveRecentTarget(process.cwd());
|
|
73
|
+
if (!recent) {
|
|
74
|
+
console.log(chalk.gray('No recent session to reconnect from here.'));
|
|
75
|
+
console.log(chalk.gray(' reconnect a specific one: agents reconnect <session-id>'));
|
|
76
|
+
console.log(chalk.gray(' or pick from the fleet: agents sessions'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const where = recent.machine ? ` on ${recent.machine}` : '';
|
|
80
|
+
console.log(chalk.gray(`Reconnecting ${recent.shortId}${where} (most recent here)…`));
|
|
81
|
+
await focusAction(recent.id, {});
|
|
82
|
+
}
|
|
83
|
+
export function registerReconnectCommand(program) {
|
|
84
|
+
const cmd = program
|
|
85
|
+
.command('reconnect')
|
|
86
|
+
.argument('[session-id]', 'Session id/prefix to reconnect (default: the most recent session started here)')
|
|
87
|
+
.description('Re-enter a dropped agent terminal: attach the live pane if it survived, else resume the session')
|
|
88
|
+
.action(async (id) => {
|
|
89
|
+
await reconnectAction(id);
|
|
90
|
+
});
|
|
91
|
+
setHelpSections(cmd, {
|
|
92
|
+
examples: `
|
|
93
|
+
# Reconnect the session that just dropped in this shell (most recent here)
|
|
94
|
+
agents reconnect
|
|
95
|
+
|
|
96
|
+
# Reconnect one specific agent terminal by id/prefix
|
|
97
|
+
agents reconnect 74b13b0b
|
|
98
|
+
|
|
99
|
+
# Same, spelled under the sessions group
|
|
100
|
+
agents sessions reconnect 74b13b0b
|
|
101
|
+
`,
|
|
102
|
+
notes: `
|
|
103
|
+
- Best-effort recovery: a living remote tmux pane is JOINED (a second client, no fork); a dropped one is RESUMED on its origin device (a copy if it was mid-run, a /continue if idle).
|
|
104
|
+
- With no id, targets the most recent session started from this directory (the terminal that most likely just dropped), not the full fleet picker. Falls back to the most recent session anywhere when this directory has none.
|
|
105
|
+
- This is the manual companion to the automatic reconnect that runs during a live 'agents run --device <box>' when the network blinks; use it after that gave up or the terminal tab closed.
|
|
106
|
+
- Related: 'agents sessions focus' (attach/recover with a picker) and 'agents sessions resume' (multi-select history -> tabs).
|
|
107
|
+
`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -12,7 +12,7 @@ import * as path from 'path';
|
|
|
12
12
|
import * as yaml from 'yaml';
|
|
13
13
|
import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
|
|
14
14
|
import { assertSchedulerEnabled } from '../lib/device-config.js';
|
|
15
|
-
import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
|
|
15
|
+
import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
|
|
16
16
|
import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
|
|
17
17
|
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, computeProjectGroup, computeProjectGroupKind, projectGroupKey, projectGroupTitle, projectGroupOrder, normalizeProjects, } from '../lib/routines.js';
|
|
18
18
|
import { listProjectDefs, isSafeProjectName } from '../lib/projects.js';
|
|
@@ -21,7 +21,7 @@ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js'
|
|
|
21
21
|
import { getRoutinesDir } from '../lib/state.js';
|
|
22
22
|
import { IS_WINDOWS } from '../lib/platform/index.js';
|
|
23
23
|
import { safeJoin } from '../lib/paths.js';
|
|
24
|
-
import { executeJob, monitorRunningJobs
|
|
24
|
+
import { executeJob, monitorRunningJobs } from '../lib/runner.js';
|
|
25
25
|
import { JobScheduler } from '../lib/scheduler.js';
|
|
26
26
|
import { detectOverdueJobs } from '../lib/overdue.js';
|
|
27
27
|
import { runCatchup } from '../lib/catchup.js';
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { type Command } from 'commander';
|
|
9
9
|
import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
|
|
10
10
|
import { quoteWin32ExecArg } from '../lib/platform/index.js';
|
|
11
|
+
import { bundleEnvToDotenv } from '../lib/secrets/push.js';
|
|
12
|
+
export { bundleEnvToDotenv };
|
|
11
13
|
import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
|
|
12
14
|
import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
|
|
13
15
|
import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
|
|
@@ -78,14 +80,6 @@ export { SSH_TARGET_RE, assertValidSshTarget };
|
|
|
78
80
|
* the executed command.
|
|
79
81
|
*/
|
|
80
82
|
export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secretEnv: Record<string, string>): NodeJS.ProcessEnv;
|
|
81
|
-
/**
|
|
82
|
-
* Serialize a resolved env map to `.env` lines that round-trip losslessly through
|
|
83
|
-
* `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
|
|
84
|
-
* quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
|
|
85
|
-
* value survives unchanged with no escaping. Newlines would break its line-based
|
|
86
|
-
* parse, so multi-line values are rejected rather than silently corrupted.
|
|
87
|
-
*/
|
|
88
|
-
export declare function bundleEnvToDotenv(env: Record<string, string>): string;
|
|
89
83
|
/**
|
|
90
84
|
* Encrypt a resolved env map to an offline bundle file using AES-256-GCM
|
|
91
85
|
* (the same EncFile envelope as the per-item file store). Inner plaintext is
|
package/dist/commands/secrets.js
CHANGED
|
@@ -12,12 +12,12 @@ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/widt
|
|
|
12
12
|
import * as fs from 'fs';
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import * as path from 'path';
|
|
15
|
-
import { SSH_TARGET_RE, assertValidSshTarget
|
|
15
|
+
import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
|
|
16
16
|
import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
|
|
17
17
|
import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
18
|
-
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget,
|
|
19
|
-
import {
|
|
20
|
-
|
|
18
|
+
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
|
|
19
|
+
import { resolveBundleForPush, pushResolvedBundleToHost, bundleEnvToDotenv } from '../lib/secrets/push.js';
|
|
20
|
+
export { bundleEnvToDotenv };
|
|
21
21
|
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
22
22
|
import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
|
|
23
23
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
@@ -354,24 +354,6 @@ function getCliVersion() {
|
|
|
354
354
|
return '0.0.0';
|
|
355
355
|
}
|
|
356
356
|
}
|
|
357
|
-
/**
|
|
358
|
-
* Serialize a resolved env map to `.env` lines that round-trip losslessly through
|
|
359
|
-
* `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
|
|
360
|
-
* quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
|
|
361
|
-
* value survives unchanged with no escaping. Newlines would break its line-based
|
|
362
|
-
* parse, so multi-line values are rejected rather than silently corrupted.
|
|
363
|
-
*/
|
|
364
|
-
export function bundleEnvToDotenv(env) {
|
|
365
|
-
const lines = [];
|
|
366
|
-
for (const [k, v] of Object.entries(env)) {
|
|
367
|
-
if (/[\r\n]/.test(v)) {
|
|
368
|
-
throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
|
|
369
|
-
`Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
|
|
370
|
-
}
|
|
371
|
-
lines.push(`${k}="${v}"`);
|
|
372
|
-
}
|
|
373
|
-
return lines.join('\n') + '\n';
|
|
374
|
-
}
|
|
375
357
|
/**
|
|
376
358
|
* Encrypt a resolved env map to an offline bundle file using AES-256-GCM
|
|
377
359
|
* (the same EncFile envelope as the per-item file store). Inner plaintext is
|
|
@@ -2115,100 +2097,42 @@ Examples:
|
|
|
2115
2097
|
if (hosts.length > 0) {
|
|
2116
2098
|
for (const h of hosts)
|
|
2117
2099
|
assertValidSshTarget(h);
|
|
2118
|
-
const
|
|
2100
|
+
const parsedBackend = parseBackendOpt(opts.remoteBackend);
|
|
2101
|
+
// `--remote-backend` documents keychain|file only, and parseBackendOpt
|
|
2102
|
+
// exits on anything else — but its return type still admits 'vault',
|
|
2103
|
+
// which has no remote-push path. Refuse it here rather than let it fall
|
|
2104
|
+
// through to the keychain branch and half-work.
|
|
2105
|
+
if (parsedBackend === 'vault') {
|
|
2106
|
+
console.error(chalk.red("--remote-backend vault is not supported; use 'keychain' or 'file'."));
|
|
2107
|
+
process.exit(1);
|
|
2108
|
+
}
|
|
2109
|
+
const remoteBackend = parsedBackend;
|
|
2119
2110
|
// For a file-backed remote bundle a passphrase is OPTIONAL. The file
|
|
2120
2111
|
// store is passphrase-free by default: with AGENTS_SECRETS_PASSPHRASE
|
|
2121
2112
|
// unset the remote `import --backend file` auto-provisions the remote's
|
|
2122
2113
|
// own machine-local key (0600 under ~/.agents/.secrets-key/), so reads
|
|
2123
2114
|
// are HEADLESS. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE only when
|
|
2124
|
-
// the operator opts in by setting it
|
|
2125
|
-
//
|
|
2126
|
-
// lands in argv / `ps` / the remote shell history. Forcing a shared
|
|
2127
|
-
// passphrase would defeat headless reads, so we no longer require one.
|
|
2115
|
+
// the operator opts in by setting it. Forcing a shared passphrase would
|
|
2116
|
+
// defeat headless reads, so we no longer require one.
|
|
2128
2117
|
const remotePassphrase = remoteBackend === 'file' ? (process.env.AGENTS_SECRETS_PASSPHRASE ?? '') : '';
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
const
|
|
2132
|
-
|
|
2133
|
-
// land in its chosen backend, reading the .env off ssh stdin (never
|
|
2134
|
-
// parsed by a remote shell — `--from -` replaces the POSIX-only
|
|
2135
|
-
// `/dev/stdin`). The keychain path is built OS-aware via
|
|
2136
|
-
// `remoteSecretsRaw` (bash -lc on POSIX, PowerShell on Windows), so it
|
|
2137
|
-
// works on macOS, Linux AND Windows targets. `import` auto-creates the
|
|
2138
|
-
// bundle, so no separate `create` (the old `|| true` was a POSIXism
|
|
2139
|
-
// that broke on PowerShell: `'true' is not recognized`).
|
|
2118
|
+
// Resolve ONCE for N hosts — reading a bundle can prompt, and doing it
|
|
2119
|
+
// per host would prompt per host.
|
|
2120
|
+
const resolvedForPush = resolveBundleForPush(resolvedBundleName, 'ssh export');
|
|
2121
|
+
const keyCount = resolvedForPush.keyCount;
|
|
2140
2122
|
let failures = 0;
|
|
2141
2123
|
for (const host of hosts) {
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
// emit broken PowerShell.
|
|
2150
|
-
if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
|
|
2151
|
-
failures++;
|
|
2152
|
-
console.error(chalk.red(`${host}: file backend export to a Windows target is not yet supported.`));
|
|
2153
|
-
continue;
|
|
2154
|
-
}
|
|
2155
|
-
const { remoteCmd, input } = buildRemoteFileImportCommand(resolvedBundleName, dotenv, {
|
|
2156
|
-
passphrase: remotePassphrase,
|
|
2157
|
-
force: opts.force,
|
|
2158
|
-
});
|
|
2159
|
-
res = sshExec(host, remoteCmd, { input });
|
|
2160
|
-
}
|
|
2161
|
-
else if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
|
|
2162
|
-
// Keychain on a Windows target: the `agents.ps1` shim doesn't
|
|
2163
|
-
// forward ssh-piped stdin to node, so `--from -` would hang.
|
|
2164
|
-
// Bridge the piped .env through PowerShell into a temp file and
|
|
2165
|
-
// import `--from <file>` (deleted afterwards). Same hardened ssh
|
|
2166
|
-
// engine, .env still only ever crosses the wire over ssh stdin.
|
|
2167
|
-
res = sshExec(host, buildWindowsStdinImportCommand(resolvedBundleName, { force: opts.force }), { input: dotenv });
|
|
2168
|
-
}
|
|
2169
|
-
else {
|
|
2170
|
-
// Keychain on a POSIX target: OS-aware wrapping + hardened ssh
|
|
2171
|
-
// engine (BatchMode, ConnectTimeout, keepalive, control-socket
|
|
2172
|
-
// reuse) via the same path the READ inverse (`remoteResolveEnv`)
|
|
2173
|
-
// uses. `--from -` reads the .env off ssh stdin.
|
|
2174
|
-
res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv, osLookupName: host });
|
|
2175
|
-
}
|
|
2176
|
-
if (res.code === null) {
|
|
2177
|
-
failures++;
|
|
2178
|
-
console.error(chalk.red(`${host}: ${res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed')}`));
|
|
2179
|
-
continue;
|
|
2180
|
-
}
|
|
2181
|
-
if (res.code !== 0) {
|
|
2124
|
+
const out = pushResolvedBundleToHost(resolvedForPush, resolvedBundleName, host, {
|
|
2125
|
+
remoteBackend,
|
|
2126
|
+
force: opts.force,
|
|
2127
|
+
passphrase: remotePassphrase,
|
|
2128
|
+
operation: 'ssh export',
|
|
2129
|
+
});
|
|
2130
|
+
if (!out.ok) {
|
|
2182
2131
|
failures++;
|
|
2183
|
-
|
|
2184
|
-
console.error(chalk.red(`${host}: remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`));
|
|
2132
|
+
console.error(chalk.red(`${host}: ${out.message}`));
|
|
2185
2133
|
continue;
|
|
2186
2134
|
}
|
|
2187
|
-
|
|
2188
|
-
// the bundle metadata but no READABLE value items: the remote login
|
|
2189
|
-
// keychain is locked in the non-interactive SSH context, so Security
|
|
2190
|
-
// accepts the write but the biometry-ACL'd item is unreadable, and the
|
|
2191
|
-
// remote `import` still exits 0. Read the bundle back the same way a
|
|
2192
|
-
// release will (drops the plaintext, keeps only key presence; the
|
|
2193
|
-
// remote's headless `agentOnly` guard fails fast, so no Touch ID) and
|
|
2194
|
-
// FAIL LOUDLY if the keys didn't materialize — rather than leave a
|
|
2195
|
-
// metadata-only bundle that breaks later with "stored item not found".
|
|
2196
|
-
// The file backend is headless-readable by construction, so skip it.
|
|
2197
|
-
if (remoteBackend === 'keychain') {
|
|
2198
|
-
const verdict = verifyRemoteKeychainPush(host, resolvedBundleName, Object.keys(env), { osLookupName: host });
|
|
2199
|
-
if (!verdict.ok) {
|
|
2200
|
-
failures++;
|
|
2201
|
-
if (verdict.kind === 'locked-keychain') {
|
|
2202
|
-
console.error(chalk.red(keychainWriteFailureMessage(host, resolvedBundleName, verdict.reason)));
|
|
2203
|
-
}
|
|
2204
|
-
else {
|
|
2205
|
-
console.error(chalk.red(`${host}: pushed '${resolvedBundleName}' but could not verify it on the remote: ${verdict.reason}`));
|
|
2206
|
-
}
|
|
2207
|
-
continue;
|
|
2208
|
-
}
|
|
2209
|
-
}
|
|
2210
|
-
const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
|
|
2211
|
-
console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
|
|
2135
|
+
console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${out.message}`));
|
|
2212
2136
|
}
|
|
2213
2137
|
emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: `export --host ${hosts.join(',')}`, source: 'ssh', host: hosts.join(','), status: failures > 0 ? 'error' : 'success', keyCount });
|
|
2214
2138
|
if (failures > 0)
|