@phnx-labs/agents-cli 1.22.56 → 1.22.57
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 +14 -0
- package/README.md +4 -4
- package/dist/bootstrap.js +3 -1
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +0 -3
- package/dist/commands/exec.js +1 -1
- package/dist/commands/hooks.js +4 -4
- package/dist/commands/insights.d.ts +7 -5
- package/dist/commands/insights.js +16 -9
- package/dist/commands/perf.d.ts +16 -7
- package/dist/commands/perf.js +29 -20
- package/dist/commands/rules.js +1 -1
- package/dist/commands/ssh.js +24 -14
- package/dist/commands/trash.d.ts +2 -2
- package/dist/commands/trash.js +2 -6
- package/dist/commands/versions.d.ts +2 -2
- package/dist/commands/versions.js +1 -10
- package/dist/commands/view.d.ts +2 -2
- package/dist/commands/view.js +7 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/lib/accounting/usage-ingest.d.ts +1 -0
- package/dist/lib/accounting/usage-ingest.js +75 -0
- package/dist/lib/accounting/usage-sync.d.ts +69 -0
- package/dist/lib/accounting/usage-sync.js +129 -0
- package/dist/lib/accounting/usage.d.ts +48 -2
- package/dist/lib/accounting/usage.js +72 -1
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/analytics/mix-commands.d.ts +8 -7
- package/dist/lib/analytics/mix-commands.js +50 -73
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/runner.js +9 -8
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
- package/dist/lib/daemon/usage-sync-service.js +36 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +17 -6
- package/dist/lib/device-config.js +25 -11
- package/dist/lib/devices/pool.d.ts +4 -3
- package/dist/lib/devices/pool.js +13 -5
- package/dist/lib/exec.d.ts +6 -41
- package/dist/lib/exec.js +6 -41
- package/dist/lib/git.d.ts +13 -1
- package/dist/lib/git.js +36 -7
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapters/claude.js +3 -2
- package/dist/lib/hosts/remote-cmd.d.ts +9 -0
- package/dist/lib/hosts/remote-cmd.js +22 -0
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +1 -1
- package/dist/lib/session/active.d.ts +3 -31
- package/dist/lib/session/active.js +8 -68
- package/dist/lib/session/db.d.ts +4 -35
- package/dist/lib/session/db.js +4 -35
- package/dist/lib/session/discover.d.ts +6 -58
- package/dist/lib/session/discover.js +5 -43
- package/dist/lib/session/parse.d.ts +1 -19
- package/dist/lib/session/parse.js +2 -15
- package/dist/lib/startup/command-registry.d.ts +8 -2
- package/dist/lib/startup/command-registry.js +12 -4
- package/package.json +1 -1
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* These used to live as the top-level `agents trends` tree. That name was a
|
|
5
5
|
* peer of `agents insights` with overlapping "analytics" meaning, so agents and
|
|
6
6
|
* humans kept picking the wrong verb. The cheap counter path (sessions index +
|
|
7
|
-
* usage.db) still exists — it is now `agents insights mix
|
|
8
|
-
*
|
|
7
|
+
* usage.db) still exists — it is now `agents insights mix`. Latency stays on
|
|
8
|
+
* `agents insights perf`; quota on `agents view`.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* `agents insights
|
|
10
|
+
* One surface, not five: the board is `agents insights mix`, one section is
|
|
11
|
+
* `agents insights mix <recipe>`, and `--list` names the recipe ids. The former
|
|
12
|
+
* per-recipe shortcut commands (`harness-mix`, `model-mix`, …), the `recipes`
|
|
13
|
+
* lister, and the `trends` alias were removed — `mix` already did all three.
|
|
12
14
|
*/
|
|
13
15
|
import chalk from 'chalk';
|
|
14
16
|
import { setHelpSections } from '../help.js';
|
|
@@ -60,23 +62,53 @@ export function renderMixDashboard(days, asJson, bannerLabel = 'agents insights
|
|
|
60
62
|
*
|
|
61
63
|
* Layout:
|
|
62
64
|
* <parent> mix multi-recipe board
|
|
63
|
-
* <parent>
|
|
64
|
-
* <parent>
|
|
65
|
+
* <parent> mix <recipe> one baked recipe (harness-mix, model-mix, …)
|
|
66
|
+
* <parent> mix --list list recipe ids
|
|
65
67
|
* <parent> query raw usage.db rows
|
|
66
|
-
* <parent> harness-mix|… one baked recipe
|
|
67
68
|
*/
|
|
68
69
|
export function registerMixCommands(parent) {
|
|
69
70
|
const banner = 'agents insights mix';
|
|
70
71
|
const mix = parent
|
|
71
|
-
.command('mix')
|
|
72
|
-
.description('Counter recipes — harness/model mix, token ratios, resource frequency (sessions index + usage.db)')
|
|
72
|
+
.command('mix [recipe]')
|
|
73
|
+
.description('Counter recipes — harness/model mix, token ratios, resource frequency (sessions index + usage.db). Bare shows the board; pass a recipe id for one section.')
|
|
73
74
|
.option('--days <n>', 'Days of history to include', '7')
|
|
75
|
+
.option('--list', 'List baked recipe ids and exit')
|
|
74
76
|
.option('--json', 'Emit JSON instead of tables')
|
|
75
|
-
.action(function summary() {
|
|
77
|
+
.action(function summary(recipe) {
|
|
76
78
|
// optsWithGlobals(): --json collides by name with the `insights` parent, so
|
|
77
79
|
// commander binds it to the parent and this.opts() never sees it. Merging
|
|
78
|
-
// ancestor opts is what the per-recipe
|
|
80
|
+
// ancestor opts is what the per-recipe path below already does.
|
|
79
81
|
const o = this.optsWithGlobals();
|
|
82
|
+
if (o.list) {
|
|
83
|
+
const list = listRecipes();
|
|
84
|
+
if (o.json) {
|
|
85
|
+
console.log(JSON.stringify(list, null, 2));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const r of list) {
|
|
89
|
+
console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (recipe) {
|
|
94
|
+
if (!RECIPE_IDS.includes(recipe)) {
|
|
95
|
+
console.error(`Unknown recipe '${recipe}'. Known: ${RECIPE_IDS.join(', ')} (or 'agents insights mix --list').`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const win = analyticsWindow(parseMixDays(o.days));
|
|
100
|
+
const section = runRecipe(recipe, win);
|
|
101
|
+
if (o.json) {
|
|
102
|
+
console.log(JSON.stringify({ window: win, section }, null, 2));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (section.empty) {
|
|
106
|
+
console.log(chalk.gray(`No data for recipe '${recipe}' in the last ${win.days} days.`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
printMixSection(section);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
80
112
|
renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
|
|
81
113
|
});
|
|
82
114
|
setHelpSections(mix, {
|
|
@@ -88,36 +120,23 @@ export function registerMixCommands(parent) {
|
|
|
88
120
|
agents insights mix --days 30
|
|
89
121
|
|
|
90
122
|
# One recipe as JSON
|
|
91
|
-
agents insights harness-mix --json
|
|
123
|
+
agents insights mix harness-mix --json
|
|
124
|
+
|
|
125
|
+
# List baked recipe ids
|
|
126
|
+
agents insights mix --list
|
|
92
127
|
|
|
93
128
|
# Raw usage events
|
|
94
129
|
agents insights query --kind secret --days 7
|
|
95
|
-
|
|
96
|
-
# List baked recipe ids
|
|
97
|
-
agents insights recipes
|
|
98
130
|
`,
|
|
99
131
|
notes: `
|
|
100
132
|
Session recipes read sessions.db; resource recipes read ~/.agents/.history/analytics/usage.db.
|
|
101
|
-
Empty recipes are skipped on the default mix board.
|
|
133
|
+
Empty recipes are skipped on the default mix board. Pass one recipe id
|
|
134
|
+
(\`agents insights mix harness-mix\`) for just that section; \`--list\` names them.
|
|
102
135
|
This is the cheap counter path. Behavioural report (transcript content, account split)
|
|
103
|
-
is bare \`agents insights\`. Latency is \`agents perf\`; quota is \`agents view\`.
|
|
136
|
+
is bare \`agents insights\`. Latency is \`agents insights perf\`; quota is \`agents view\`.
|
|
104
137
|
Skill/slash-command popularity is \`agents sessions stats\`.
|
|
105
138
|
`,
|
|
106
139
|
});
|
|
107
|
-
parent.command('recipes')
|
|
108
|
-
.description('List baked mix-recipe ids')
|
|
109
|
-
.option('--json', 'Emit JSON')
|
|
110
|
-
.action(function recipes() {
|
|
111
|
-
const o = this.optsWithGlobals();
|
|
112
|
-
const list = listRecipes();
|
|
113
|
-
if (o.json) {
|
|
114
|
-
console.log(JSON.stringify(list, null, 2));
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
for (const r of list) {
|
|
118
|
-
console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
140
|
parent.command('query')
|
|
122
141
|
.description('Raw usage-event query (usage.db)')
|
|
123
142
|
.option('--kind <kind>', `One of: ${USAGE_KINDS.join(', ')}`)
|
|
@@ -164,46 +183,4 @@ export function registerMixCommands(parent) {
|
|
|
164
183
|
})),
|
|
165
184
|
});
|
|
166
185
|
});
|
|
167
|
-
for (const id of RECIPE_IDS) {
|
|
168
|
-
parent.command(id)
|
|
169
|
-
.description(`Mix recipe: ${id}`)
|
|
170
|
-
.option('--days <n>', 'Days of history', '7')
|
|
171
|
-
.option('--json', 'Emit JSON')
|
|
172
|
-
.action(function recipeAction() {
|
|
173
|
-
// optsWithGlobals() merges the `insights` parent opts, so the name-colliding
|
|
174
|
-
// --json/--since reach this leaf (see the sibling commands above).
|
|
175
|
-
const o = this.optsWithGlobals();
|
|
176
|
-
const win = analyticsWindow(parseMixDays(o.days));
|
|
177
|
-
const section = runRecipe(id, win);
|
|
178
|
-
if (o.json) {
|
|
179
|
-
console.log(JSON.stringify({ window: win, section }, null, 2));
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (section.empty) {
|
|
183
|
-
console.log(chalk.gray(`No data for recipe '${id}' in the last ${win.days} days.`));
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
printMixSection(section);
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
// Nested home for the former top-level `agents trends` spelling.
|
|
190
|
-
const trends = parent
|
|
191
|
-
.command('trends')
|
|
192
|
-
.description('Alias of `mix` — former top-level `agents trends`')
|
|
193
|
-
.option('--days <n>', 'Days of history to include', '7')
|
|
194
|
-
.option('--json', 'Emit JSON instead of tables')
|
|
195
|
-
.action(function summary() {
|
|
196
|
-
const o = this.optsWithGlobals();
|
|
197
|
-
renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
|
|
198
|
-
});
|
|
199
|
-
setHelpSections(trends, {
|
|
200
|
-
examples: `
|
|
201
|
-
agents insights trends
|
|
202
|
-
agents insights mix
|
|
203
|
-
`,
|
|
204
|
-
notes: `
|
|
205
|
-
\`agents insights trends\` is the nested spelling of the former top-level
|
|
206
|
-
\`agents trends\`. Prefer \`agents insights mix\`.
|
|
207
|
-
`,
|
|
208
|
-
});
|
|
209
186
|
}
|
|
@@ -41,6 +41,7 @@ import { DeviceProbeService } from './device-probe-service.js';
|
|
|
41
41
|
import { SelfHealService } from './self-heal-service.js';
|
|
42
42
|
import { KeychainReapService } from './keychain-reap-service.js';
|
|
43
43
|
import { AuthSyncService } from './auth-sync-service.js';
|
|
44
|
+
import { UsageSyncService } from './usage-sync-service.js';
|
|
44
45
|
import { StateDirCheckService } from './state-dir-check-service.js';
|
|
45
46
|
import { SessionStateService } from './session-state-service.js';
|
|
46
47
|
import { WebhookReceiverService } from './webhook-receiver-service.js';
|
|
@@ -858,6 +859,10 @@ export async function runDaemon() {
|
|
|
858
859
|
supervisor.register(new AuthSyncService());
|
|
859
860
|
else
|
|
860
861
|
log('INFO', 'Auth-sync service disabled');
|
|
862
|
+
if (isEnabled('usage-sync'))
|
|
863
|
+
supervisor.register(new UsageSyncService());
|
|
864
|
+
else
|
|
865
|
+
log('INFO', 'Usage-sync service disabled');
|
|
861
866
|
if (isEnabled('webhook-receiver'))
|
|
862
867
|
supervisor.register(new WebhookReceiverService());
|
|
863
868
|
else
|
|
@@ -39,7 +39,7 @@ import { resolveClaudeSetupToken } from '../claude-account-token.js';
|
|
|
39
39
|
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from '../accounting/rotate.js';
|
|
40
40
|
import { readAuthHealth, isDeadVerdict } from '../auth-health.js';
|
|
41
41
|
import { machineId } from '../machine-id.js';
|
|
42
|
-
import { selfConfiguredDeviceRole } from '../device-config.js';
|
|
42
|
+
import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
|
|
43
43
|
import { isSelfUpdatingAgent, ROUTINE_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
|
|
44
44
|
import { isCustomHarnessName, readProfile } from '../profiles.js';
|
|
45
45
|
export class RoutineAlreadyRunningError extends Error {
|
|
@@ -1121,13 +1121,14 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
|
|
|
1121
1121
|
const setupToken = agent === 'claude' && version
|
|
1122
1122
|
? resolveClaudeSetupToken(getVersionHomePath('claude', version))
|
|
1123
1123
|
: null;
|
|
1124
|
-
// A
|
|
1125
|
-
// login for EVERY run, routines
|
|
1126
|
-
// credential (RUSH-2395, mirrors
|
|
1127
|
-
// On a
|
|
1128
|
-
// setup-token by value so a leaked ambient
|
|
1129
|
-
// token the user set deliberately is a
|
|
1130
|
-
|
|
1124
|
+
// A headed device (personal or desktop — the user's own interactive box or a
|
|
1125
|
+
// headed always-on box) uses the per-version login for EVERY run, routines
|
|
1126
|
+
// included — the setup-token is a worker-only credential (RUSH-2395, mirrors
|
|
1127
|
+
// the claude adapter's headed-device branch). On a headed box, only strip an
|
|
1128
|
+
// inherited copy of the account's OWN setup-token by value so a leaked ambient
|
|
1129
|
+
// value can't override the login; a token the user set deliberately is a
|
|
1130
|
+
// different string and survives.
|
|
1131
|
+
if (isHeadedDeviceRole(selfConfiguredDeviceRole())) {
|
|
1131
1132
|
if (setupToken && out.CLAUDE_CODE_OAUTH_TOKEN === setupToken)
|
|
1132
1133
|
delete out.CLAUDE_CODE_OAUTH_TOKEN;
|
|
1133
1134
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
|
|
3
|
+
*
|
|
4
|
+
* On a headed box (personal/desktop) each tick pushes the local identity-keyed
|
|
5
|
+
* Claude usage rows to worker peers that cannot read usage themselves. A no-op on
|
|
6
|
+
* a worker/unmarked box or when the local cache is empty — the driver
|
|
7
|
+
* ({@link syncFleetUsageSnapshots}) gates on the self role, so this service is
|
|
8
|
+
* safe to register everywhere. Single-executor per destination: each daemon only
|
|
9
|
+
* writes the DESTINATION's own cache, and the merge is newest-wins + idempotent.
|
|
10
|
+
*/
|
|
11
|
+
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
12
|
+
import type { DaemonServiceId } from '../daemon-services.js';
|
|
13
|
+
export declare class UsageSyncService extends BasePeriodicService {
|
|
14
|
+
readonly id: DaemonServiceId;
|
|
15
|
+
readonly intervalMs: number;
|
|
16
|
+
readonly deadlineMs: number;
|
|
17
|
+
readonly startupDelayMs = 90000;
|
|
18
|
+
protected onStart(_ctx: DaemonContext): Promise<void>;
|
|
19
|
+
protected onStop(): Promise<void>;
|
|
20
|
+
protected onTick(ctx: DaemonContext): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
|
|
3
|
+
*
|
|
4
|
+
* On a headed box (personal/desktop) each tick pushes the local identity-keyed
|
|
5
|
+
* Claude usage rows to worker peers that cannot read usage themselves. A no-op on
|
|
6
|
+
* a worker/unmarked box or when the local cache is empty — the driver
|
|
7
|
+
* ({@link syncFleetUsageSnapshots}) gates on the self role, so this service is
|
|
8
|
+
* safe to register everywhere. Single-executor per destination: each daemon only
|
|
9
|
+
* writes the DESTINATION's own cache, and the merge is newest-wins + idempotent.
|
|
10
|
+
*/
|
|
11
|
+
import { BasePeriodicService } from './service.js';
|
|
12
|
+
const USAGE_SYNC_TICK_MS = 15 * 60_000;
|
|
13
|
+
const USAGE_SYNC_DEADLINE_MS = 2 * 60_000;
|
|
14
|
+
const USAGE_SYNC_KICKOFF_MS = 90_000;
|
|
15
|
+
export class UsageSyncService extends BasePeriodicService {
|
|
16
|
+
id = 'usage-sync';
|
|
17
|
+
intervalMs = USAGE_SYNC_TICK_MS;
|
|
18
|
+
deadlineMs = USAGE_SYNC_DEADLINE_MS;
|
|
19
|
+
startupDelayMs = USAGE_SYNC_KICKOFF_MS;
|
|
20
|
+
async onStart(_ctx) {
|
|
21
|
+
// No connections to open — each tick re-reads the local cache + registry.
|
|
22
|
+
}
|
|
23
|
+
async onStop() {
|
|
24
|
+
// Nothing to release — the supervisor's timer teardown is the only cleanup.
|
|
25
|
+
}
|
|
26
|
+
async onTick(ctx) {
|
|
27
|
+
const { syncFleetUsageSnapshots } = await import('../accounting/usage-sync.js');
|
|
28
|
+
const result = syncFleetUsageSnapshots();
|
|
29
|
+
if (result.pushed.length > 0) {
|
|
30
|
+
ctx.log('INFO', `usage-sync: pushed usage to ${result.pushed.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
for (const err of result.errors) {
|
|
33
|
+
ctx.log('WARN', `usage-sync: ${err.device}: ${err.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* enabled without pulling in the whole daemon lifecycle.
|
|
8
8
|
*/
|
|
9
9
|
/** Every service the daemon can host. IDs are kebab-case and stable. */
|
|
10
|
-
export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
|
|
10
|
+
export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
|
|
11
11
|
/** Human-readable metadata for each service. */
|
|
12
12
|
export interface DaemonServiceDef {
|
|
13
13
|
id: DaemonServiceId;
|
|
@@ -97,6 +97,11 @@ export const DAEMON_SERVICES = [
|
|
|
97
97
|
title: 'Auth bundle sync',
|
|
98
98
|
description: 'Pushes the reserved file-backed auth bundle (setup-tokens) to pinned fleet devices that do not yet have it.',
|
|
99
99
|
},
|
|
100
|
+
{
|
|
101
|
+
id: 'usage-sync',
|
|
102
|
+
title: 'Usage snapshot sync',
|
|
103
|
+
description: 'From a personal/desktop box, pushes the per-account usage snapshot to worker peers that cannot read it themselves.',
|
|
104
|
+
},
|
|
100
105
|
];
|
|
101
106
|
/** Stable order of service IDs. */
|
|
102
107
|
export const DAEMON_SERVICE_IDS = DAEMON_SERVICES.map((s) => s.id);
|
|
@@ -87,7 +87,7 @@ export interface ConfigTarget {
|
|
|
87
87
|
fleet?: boolean;
|
|
88
88
|
}
|
|
89
89
|
/** Roles a device can be marked with — see the `role` key below. */
|
|
90
|
-
declare const DEVICE_ROLES: readonly ["worker", "personal"];
|
|
90
|
+
declare const DEVICE_ROLES: readonly ["worker", "personal", "desktop"];
|
|
91
91
|
/** Which devices automatic placement may pick — see the `auto.pool` key below. */
|
|
92
92
|
declare const AUTO_POOL_MODES: readonly ["workers", "all"];
|
|
93
93
|
export declare const CONFIG_KEYS: readonly ConfigKeySpec[];
|
|
@@ -150,13 +150,24 @@ export declare function configuredDeviceRole(name: string): ConfiguredDeviceRole
|
|
|
150
150
|
* it was never marked. Keyed off {@link machineId} (overridable via
|
|
151
151
|
* AGENTS_SYNC_MACHINE_ID), so it matches the device's own config-folder key.
|
|
152
152
|
*
|
|
153
|
-
* The auth strategy reads this: a
|
|
154
|
-
*
|
|
155
|
-
* run, interactive or headless; only a `worker`
|
|
156
|
-
* (RUSH-2395). `undefined` is treated as
|
|
157
|
-
* that gate — an unmarked box has no login to
|
|
153
|
+
* The auth strategy reads this: a headed device (`personal` or `desktop`, see
|
|
154
|
+
* {@link isHeadedDeviceRole}) holds a real per-version login and MUST
|
|
155
|
+
* authenticate from it for EVERY run, interactive or headless; only a `worker`
|
|
156
|
+
* uses the file-based setup-token (RUSH-2395). `undefined` is treated as
|
|
157
|
+
* non-headed (worker-equivalent) by that gate — an unmarked box has no login to
|
|
158
|
+
* defer to.
|
|
158
159
|
*/
|
|
159
160
|
export declare function selfConfiguredDeviceRole(): ConfiguredDeviceRole | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* A "headed" device — one with an interactive desktop login, so it authenticates
|
|
163
|
+
* from its own per-version Claude login (which carries the `user:profile` scope
|
|
164
|
+
* the usage endpoint needs) rather than the headless file setup-token. Both
|
|
165
|
+
* `personal` (the box you sit at) and `desktop` (a headed always-on box like a
|
|
166
|
+
* Mac mini) qualify; a `worker` and an unmarked box do NOT. This is the single
|
|
167
|
+
* predicate the auth-bucket sites read, so `personal` and `desktop` never drift
|
|
168
|
+
* apart (claude adapter, routine spawn env, `agents view` usage login).
|
|
169
|
+
*/
|
|
170
|
+
export declare function isHeadedDeviceRole(role: ConfiguredDeviceRole | undefined): boolean;
|
|
160
171
|
/** Mark a device's role fleet-wide; `undefined` clears the mark. */
|
|
161
172
|
export declare function setConfiguredDeviceRole(name: string, role: ConfiguredDeviceRole | undefined): void;
|
|
162
173
|
/**
|
|
@@ -41,7 +41,7 @@ import { migrateDeviceConfigStores } from './devices/config-migration.js';
|
|
|
41
41
|
const DEVICE_PLATFORMS = ['windows', 'linux', 'macos', 'unknown'];
|
|
42
42
|
const SSH_AUTH_METHODS = ['key', 'password'];
|
|
43
43
|
/** Roles a device can be marked with — see the `role` key below. */
|
|
44
|
-
const DEVICE_ROLES = ['worker', 'personal'];
|
|
44
|
+
const DEVICE_ROLES = ['worker', 'personal', 'desktop'];
|
|
45
45
|
/** Which devices automatic placement may pick — see the `auto.pool` key below. */
|
|
46
46
|
const AUTO_POOL_MODES = ['workers', 'all'];
|
|
47
47
|
export const CONFIG_KEYS = [
|
|
@@ -67,8 +67,8 @@ export const CONFIG_KEYS = [
|
|
|
67
67
|
scope: 'user',
|
|
68
68
|
type: 'string',
|
|
69
69
|
description: "Which devices automatic placement (`--device auto`) may pick: 'workers' (default — only devices marked role=worker, " +
|
|
70
|
-
"once at least one is marked) or 'all' (every online device, ignoring worker marks). A device marked personal
|
|
71
|
-
'never picked automatically under either mode.',
|
|
70
|
+
"once at least one is marked) or 'all' (every online device, ignoring worker marks). A device marked personal or " +
|
|
71
|
+
'desktop is never picked automatically under either mode.',
|
|
72
72
|
defaultValue: 'workers',
|
|
73
73
|
validate: (v) => AUTO_POOL_MODES.includes(v)
|
|
74
74
|
? null
|
|
@@ -279,9 +279,10 @@ export const CONFIG_KEYS = [
|
|
|
279
279
|
scope: 'device',
|
|
280
280
|
visibility: 'shared',
|
|
281
281
|
type: 'string',
|
|
282
|
-
description: "What this device is for, fleet-wide: 'worker' (a box agents run on)
|
|
283
|
-
|
|
284
|
-
'
|
|
282
|
+
description: "What this device is for, fleet-wide: 'worker' (a box agents run on), 'personal' (a machine you sit at — your " +
|
|
283
|
+
"interactive seat), or 'desktop' (a headed always-on box like a Mac mini — the release/credential home). Personal " +
|
|
284
|
+
'and desktop are never picked automatically. Marking ANY device worker turns automatic placement into an allowlist: ' +
|
|
285
|
+
'`--device auto` then picks only from the marked workers.',
|
|
285
286
|
validate: (v) => DEVICE_ROLES.includes(v)
|
|
286
287
|
? null
|
|
287
288
|
: `role must be one of ${DEVICE_ROLES.join(' | ')}.`,
|
|
@@ -630,15 +631,28 @@ export function configuredDeviceRole(name) {
|
|
|
630
631
|
* it was never marked. Keyed off {@link machineId} (overridable via
|
|
631
632
|
* AGENTS_SYNC_MACHINE_ID), so it matches the device's own config-folder key.
|
|
632
633
|
*
|
|
633
|
-
* The auth strategy reads this: a
|
|
634
|
-
*
|
|
635
|
-
* run, interactive or headless; only a `worker`
|
|
636
|
-
* (RUSH-2395). `undefined` is treated as
|
|
637
|
-
* that gate — an unmarked box has no login to
|
|
634
|
+
* The auth strategy reads this: a headed device (`personal` or `desktop`, see
|
|
635
|
+
* {@link isHeadedDeviceRole}) holds a real per-version login and MUST
|
|
636
|
+
* authenticate from it for EVERY run, interactive or headless; only a `worker`
|
|
637
|
+
* uses the file-based setup-token (RUSH-2395). `undefined` is treated as
|
|
638
|
+
* non-headed (worker-equivalent) by that gate — an unmarked box has no login to
|
|
639
|
+
* defer to.
|
|
638
640
|
*/
|
|
639
641
|
export function selfConfiguredDeviceRole() {
|
|
640
642
|
return configuredDeviceRole(machineId());
|
|
641
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* A "headed" device — one with an interactive desktop login, so it authenticates
|
|
646
|
+
* from its own per-version Claude login (which carries the `user:profile` scope
|
|
647
|
+
* the usage endpoint needs) rather than the headless file setup-token. Both
|
|
648
|
+
* `personal` (the box you sit at) and `desktop` (a headed always-on box like a
|
|
649
|
+
* Mac mini) qualify; a `worker` and an unmarked box do NOT. This is the single
|
|
650
|
+
* predicate the auth-bucket sites read, so `personal` and `desktop` never drift
|
|
651
|
+
* apart (claude adapter, routine spawn env, `agents view` usage login).
|
|
652
|
+
*/
|
|
653
|
+
export function isHeadedDeviceRole(role) {
|
|
654
|
+
return role === 'personal' || role === 'desktop';
|
|
655
|
+
}
|
|
642
656
|
/** Mark a device's role fleet-wide; `undefined` clears the mark. */
|
|
643
657
|
export function setConfiguredDeviceRole(name, role) {
|
|
644
658
|
assertValidDeviceName(name);
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* |---|---|
|
|
16
16
|
* | no device marked | every online device (unchanged behavior) |
|
|
17
17
|
* | some marked `worker` | ONLY those workers |
|
|
18
|
-
* | marked `personal` | never, under either state |
|
|
18
|
+
* | marked `personal` / `desktop` | never, under either state |
|
|
19
19
|
*
|
|
20
|
-
* `auto.pool all` turns the allowlist off; `personal`
|
|
21
|
-
*
|
|
20
|
+
* `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
|
|
21
|
+
* excluded, because a box the user sits at (personal) or a headed always-on
|
|
22
|
+
* release/credential box (desktop) is marked precisely so agents stay off it.
|
|
22
23
|
*/
|
|
23
24
|
import { type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
|
|
24
25
|
export interface AutoPoolOptions {
|
package/dist/lib/devices/pool.js
CHANGED
|
@@ -15,15 +15,23 @@
|
|
|
15
15
|
* |---|---|
|
|
16
16
|
* | no device marked | every online device (unchanged behavior) |
|
|
17
17
|
* | some marked `worker` | ONLY those workers |
|
|
18
|
-
* | marked `personal` | never, under either state |
|
|
18
|
+
* | marked `personal` / `desktop` | never, under either state |
|
|
19
19
|
*
|
|
20
|
-
* `auto.pool all` turns the allowlist off; `personal`
|
|
21
|
-
*
|
|
20
|
+
* `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
|
|
21
|
+
* excluded, because a box the user sits at (personal) or a headed always-on
|
|
22
|
+
* release/credential box (desktop) is marked precisely so agents stay off it.
|
|
22
23
|
*/
|
|
23
24
|
import { autoPoolMode, listConfiguredDeviceRoles } from '../device-config.js';
|
|
24
25
|
import { normalizeHost } from '../machine-id.js';
|
|
25
|
-
/**
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Roles that automatic placement never picks, whatever the pool mode.
|
|
28
|
+
*
|
|
29
|
+
* `personal` (a box you sit at) and `desktop` (a headed always-on box — the
|
|
30
|
+
* release/credential home, e.g. a Mac mini) are both off-limits to `--device
|
|
31
|
+
* auto`: neither is headless fan-out capacity, and landing agent work on them
|
|
32
|
+
* is the outcome the mark exists to prevent. Only `worker` is auto-eligible.
|
|
33
|
+
*/
|
|
34
|
+
const NEVER_AUTO = new Set(['personal', 'desktop']);
|
|
27
35
|
/**
|
|
28
36
|
* Narrow a candidate host list to the devices automatic placement may pick.
|
|
29
37
|
*
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -502,49 +502,10 @@ export type TmuxWrapDecision =
|
|
|
502
502
|
kind: 'undurable';
|
|
503
503
|
};
|
|
504
504
|
/**
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
* The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
|
|
509
|
-
* unique `%pane` handle so `agents sessions --active` can tell co-located
|
|
510
|
-
* agents apart and `agents focus` re-attaches without forking, plus scrollback
|
|
511
|
-
* and mouse at the operator's keyboard. Off means OFF, for local and remote
|
|
512
|
-
* runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
|
|
513
|
-
* reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
|
|
514
|
-
* when one exists and resumes the harness session from disk when it does not.
|
|
515
|
-
* The RUSH-3125 forced remote wrap conflated durability with that preference
|
|
516
|
-
* and surprised every operator who had explicitly left tmux off.
|
|
517
|
-
*
|
|
518
|
-
* One case still wraps regardless: a followed remote run whose launcher has
|
|
519
|
-
* no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
|
|
520
|
-
* the detached pane is the run's only interface, not an ergonomics choice.
|
|
521
|
-
*
|
|
522
|
-
* The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
|
|
523
|
-
* `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
|
|
524
|
-
* escape hatch that silently stopped applying over `--device` would be worse
|
|
525
|
-
* than the bare run the user asked for.
|
|
526
|
-
*
|
|
527
|
-
* Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
|
|
505
|
+
* Tmux is opt-in except when a TTY-less remote launch would otherwise have no
|
|
506
|
+
* interface. Explicit per-run opt-outs always win.
|
|
528
507
|
*/
|
|
529
508
|
export declare function resolveTmuxWrap(ctx: TmuxWrapContext): TmuxWrapDecision;
|
|
530
|
-
/**
|
|
531
|
-
* Build the shell command that runs an agent inside a tmux pane with the exact
|
|
532
|
-
* env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
|
|
533
|
-
* K=V … <agent> <args…>` so:
|
|
534
|
-
* - `env` materializes the full agent env INTO the pane, independent of the
|
|
535
|
-
* (possibly stale, shared) tmux server environment — additive, so tmux's own
|
|
536
|
-
* $TMUX / $TMUX_PANE still reach the agent for provenance detection;
|
|
537
|
-
* - `exec` replaces the shell so the agent is the pane's leaf process (clean
|
|
538
|
-
* `#{pane_pid}`, clean signal delivery on detach/kill).
|
|
539
|
-
* Keys are filtered to valid identifiers so exported shell functions
|
|
540
|
-
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
541
|
-
*
|
|
542
|
-
* `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
|
|
543
|
-
* the KEY names. The env map here carries resolved secrets bundles (options.env),
|
|
544
|
-
* so the real string would embed secret VALUES — which get persisted verbatim
|
|
545
|
-
* into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
|
|
546
|
-
* real values; the stored/informational copy uses the redacted form (RUSH-1758).
|
|
547
|
-
*/
|
|
548
509
|
/**
|
|
549
510
|
* True when `options.sessionId` is an id the HARNESS actually received, and so a
|
|
550
511
|
* real, resumable handle — rather than one the launcher generated for its own
|
|
@@ -562,6 +523,10 @@ export declare function resolveTmuxWrap(ctx: TmuxWrapContext): TmuxWrapDecision;
|
|
|
562
523
|
* or this reports a genuine id as fabricated.
|
|
563
524
|
*/
|
|
564
525
|
export declare function isHarnessKnownSessionId(agent: AgentId, sessionId: string | undefined, resume: boolean | undefined): boolean;
|
|
526
|
+
/**
|
|
527
|
+
* Build the pane command without inheriting stale tmux-server env. Redacted
|
|
528
|
+
* copies keep secret values out of persisted SessionMeta commands.
|
|
529
|
+
*/
|
|
565
530
|
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
|
|
566
531
|
redactEnvValues?: boolean;
|
|
567
532
|
envFile?: string;
|
package/dist/lib/exec.js
CHANGED
|
@@ -1241,29 +1241,8 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
|
|
|
1241
1241
|
return code === 0 && stdout.trim() === '0';
|
|
1242
1242
|
}
|
|
1243
1243
|
/**
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1247
|
-
* The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
|
|
1248
|
-
* unique `%pane` handle so `agents sessions --active` can tell co-located
|
|
1249
|
-
* agents apart and `agents focus` re-attaches without forking, plus scrollback
|
|
1250
|
-
* and mouse at the operator's keyboard. Off means OFF, for local and remote
|
|
1251
|
-
* runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
|
|
1252
|
-
* reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
|
|
1253
|
-
* when one exists and resumes the harness session from disk when it does not.
|
|
1254
|
-
* The RUSH-3125 forced remote wrap conflated durability with that preference
|
|
1255
|
-
* and surprised every operator who had explicitly left tmux off.
|
|
1256
|
-
*
|
|
1257
|
-
* One case still wraps regardless: a followed remote run whose launcher has
|
|
1258
|
-
* no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
|
|
1259
|
-
* the detached pane is the run's only interface, not an ergonomics choice.
|
|
1260
|
-
*
|
|
1261
|
-
* The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
|
|
1262
|
-
* `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
|
|
1263
|
-
* escape hatch that silently stopped applying over `--device` would be worse
|
|
1264
|
-
* than the bare run the user asked for.
|
|
1265
|
-
*
|
|
1266
|
-
* Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
|
|
1244
|
+
* Tmux is opt-in except when a TTY-less remote launch would otherwise have no
|
|
1245
|
+
* interface. Explicit per-run opt-outs always win.
|
|
1267
1246
|
*/
|
|
1268
1247
|
export function resolveTmuxWrap(ctx) {
|
|
1269
1248
|
// A headless `-p` run has no TTY to attach, Windows has no tmux path, and
|
|
@@ -1297,24 +1276,6 @@ export function resolveTmuxWrap(ctx) {
|
|
|
1297
1276
|
return ctx.remoteDispatch ? { kind: 'undurable' } : { kind: 'bare' };
|
|
1298
1277
|
return { kind: 'wrap' };
|
|
1299
1278
|
}
|
|
1300
|
-
/**
|
|
1301
|
-
* Build the shell command that runs an agent inside a tmux pane with the exact
|
|
1302
|
-
* env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
|
|
1303
|
-
* K=V … <agent> <args…>` so:
|
|
1304
|
-
* - `env` materializes the full agent env INTO the pane, independent of the
|
|
1305
|
-
* (possibly stale, shared) tmux server environment — additive, so tmux's own
|
|
1306
|
-
* $TMUX / $TMUX_PANE still reach the agent for provenance detection;
|
|
1307
|
-
* - `exec` replaces the shell so the agent is the pane's leaf process (clean
|
|
1308
|
-
* `#{pane_pid}`, clean signal delivery on detach/kill).
|
|
1309
|
-
* Keys are filtered to valid identifiers so exported shell functions
|
|
1310
|
-
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
1311
|
-
*
|
|
1312
|
-
* `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
|
|
1313
|
-
* the KEY names. The env map here carries resolved secrets bundles (options.env),
|
|
1314
|
-
* so the real string would embed secret VALUES — which get persisted verbatim
|
|
1315
|
-
* into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
|
|
1316
|
-
* real values; the stored/informational copy uses the redacted form (RUSH-1758).
|
|
1317
|
-
*/
|
|
1318
1279
|
/**
|
|
1319
1280
|
* True when `options.sessionId` is an id the HARNESS actually received, and so a
|
|
1320
1281
|
* real, resumable handle — rather than one the launcher generated for its own
|
|
@@ -1338,6 +1299,10 @@ export function isHarnessKnownSessionId(agent, sessionId, resume) {
|
|
|
1338
1299
|
return true;
|
|
1339
1300
|
return agent === 'claude';
|
|
1340
1301
|
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Build the pane command without inheriting stale tmux-server env. Redacted
|
|
1304
|
+
* copies keep secret values out of persisted SessionMeta commands.
|
|
1305
|
+
*/
|
|
1341
1306
|
export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
1342
1307
|
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
1343
1308
|
// envFile: source the values instead of inlining them, so no VALUE ever lands
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -152,6 +152,15 @@ export declare function getCurrentBranch(repoPath: string): Promise<string>;
|
|
|
152
152
|
* repo" as a requested source before adopting it.
|
|
153
153
|
*/
|
|
154
154
|
export declare function canonicalGitRemote(url: string): string;
|
|
155
|
+
/**
|
|
156
|
+
* True when a git remote URL (any transport form: ssh, https, scp-style) points
|
|
157
|
+
* at the system DotAgents repo — {@link DEFAULT_SYSTEM_REPO}'s current slug OR
|
|
158
|
+
* its `phnx-labs/.agents` rename target (PHNX-3394), which
|
|
159
|
+
* {@link canonicalGitRemote} folds onto it via {@link RENAMED_REMOTE_ALIASES}.
|
|
160
|
+
* Pure string check with no git spawn, so it is unit-testable off a live
|
|
161
|
+
* checkout; {@link isSystemRepoOrigin} reads a dir's origin and delegates here.
|
|
162
|
+
*/
|
|
163
|
+
export declare function isSystemRepoRemote(remote: string | null | undefined): boolean;
|
|
155
164
|
/** True when two git remote URLs point at the same repo across transport forms. */
|
|
156
165
|
export declare function sameGitRemote(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
157
166
|
/**
|
|
@@ -354,7 +363,10 @@ export declare function adoptUserRepoIfNeeded(dir: string, opts?: {
|
|
|
354
363
|
needsUrl?: boolean;
|
|
355
364
|
}) | null>;
|
|
356
365
|
/**
|
|
357
|
-
* Check if the repo's origin points to the system repo
|
|
366
|
+
* Check if the repo's origin points to the system repo — `phnx-labs/.agents-system`
|
|
367
|
+
* or its GitHub rename target `phnx-labs/.agents` (PHNX-3394), across any
|
|
368
|
+
* transport form. Reads the dir's origin and delegates the match to the pure
|
|
369
|
+
* {@link isSystemRepoRemote}.
|
|
358
370
|
*/
|
|
359
371
|
export declare function isSystemRepoOrigin(dir: string): Promise<boolean>;
|
|
360
372
|
/**
|