@phnx-labs/agents-cli 1.20.88 → 1.20.89
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 +263 -0
- package/README.md +9 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +23 -1
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +39 -12
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/commands.js +9 -1
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +58 -4
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +2 -0
- package/dist/lib/runner.js +21 -8
- 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/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/package.json +1 -1
|
@@ -70,6 +70,8 @@ function fireConditionLabel(job) {
|
|
|
70
70
|
job.trigger.action ? `action=${job.trigger.action}` : null,
|
|
71
71
|
job.trigger.teamKey ? `team=${job.trigger.teamKey}` : null,
|
|
72
72
|
job.trigger.label ? `label=${job.trigger.label}` : null,
|
|
73
|
+
job.trigger.stateTo ? `stateTo=${job.trigger.stateTo}` : null,
|
|
74
|
+
job.trigger.stateFrom ? `stateFrom=${job.trigger.stateFrom}` : null,
|
|
73
75
|
].filter(Boolean).join(', ');
|
|
74
76
|
return `on linear:${job.trigger.event}${filters ? ` (${filters})` : ''}`;
|
|
75
77
|
}
|
|
@@ -261,6 +263,10 @@ function parseRoutineTrigger(options) {
|
|
|
261
263
|
trigger.teamKey = options.teamKey;
|
|
262
264
|
if (typeof options.label === 'string')
|
|
263
265
|
trigger.label = options.label;
|
|
266
|
+
if (typeof options.stateTo === 'string')
|
|
267
|
+
trigger.stateTo = options.stateTo;
|
|
268
|
+
if (typeof options.stateFrom === 'string')
|
|
269
|
+
trigger.stateFrom = options.stateFrom;
|
|
264
270
|
return trigger;
|
|
265
271
|
}
|
|
266
272
|
throw new Error('--on source must be github or linear');
|
|
@@ -591,6 +597,8 @@ export function registerRoutinesCommands(program) {
|
|
|
591
597
|
.option('--action <name>', 'Webhook action filter for --on triggers (GitHub: labeled/opened; Linear: update)')
|
|
592
598
|
.option('--team-key <key>', 'Linear team key filter for --on linear:<event> (e.g. RUSH)')
|
|
593
599
|
.option('--label <name>', 'Label filter for --on triggers (GitHub label name or Linear issue label)')
|
|
600
|
+
.option('--state-to <name>', 'Linear current-state filter for --on linear:<event> (e.g. Plan)')
|
|
601
|
+
.option('--state-from <name>', 'Linear previous-state filter for --on linear:<event> (e.g. Triage)')
|
|
594
602
|
.option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
|
|
595
603
|
.option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
|
|
596
604
|
.option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
|
|
@@ -884,12 +892,26 @@ export function registerRoutinesCommands(program) {
|
|
|
884
892
|
routinesCmd
|
|
885
893
|
.command('edit [name]')
|
|
886
894
|
.description('Open a routine in $EDITOR. Creates a new YAML template if the routine does not exist.')
|
|
887
|
-
.
|
|
895
|
+
.option('--state-to <name>', 'Update the Linear current-state filter before opening the editor')
|
|
896
|
+
.option('--state-from <name>', 'Update the Linear previous-state filter before opening the editor')
|
|
897
|
+
.action(async (name, options) => {
|
|
888
898
|
if (!name) {
|
|
889
899
|
name = await pickJob('Select job to edit', undefined, ['agents routines edit <name>']) ?? undefined;
|
|
890
900
|
if (!name)
|
|
891
901
|
return;
|
|
892
902
|
}
|
|
903
|
+
const existing = readJob(name);
|
|
904
|
+
if (existing && (options.stateTo !== undefined || options.stateFrom !== undefined)) {
|
|
905
|
+
if (!existing.trigger || existing.trigger.type !== 'linear_event') {
|
|
906
|
+
console.error(chalk.red(`'${name}' does not have a Linear trigger; --state-to/--state-from only apply to linear triggers`));
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
if (options.stateTo !== undefined)
|
|
910
|
+
existing.trigger.stateTo = options.stateTo || undefined;
|
|
911
|
+
if (options.stateFrom !== undefined)
|
|
912
|
+
existing.trigger.stateFrom = options.stateFrom || undefined;
|
|
913
|
+
writeJob(existing);
|
|
914
|
+
}
|
|
893
915
|
const jobPath = getJobPath(name);
|
|
894
916
|
if (!jobPath) {
|
|
895
917
|
// Job doesn't exist - create a new one
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets rotate-passphrase` — rotate the encrypted file store's
|
|
3
|
+
* machine-local master passphrase (RUSH-1975).
|
|
4
|
+
*
|
|
5
|
+
* Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
|
|
6
|
+
* 0600 key file in place, atomically: the new store is staged, verified
|
|
7
|
+
* (round-trip + count), fsync'd, then swapped by directory rename, so a crash
|
|
8
|
+
* leaves the old store intact and readable. Headless-safe and Linux-first — the
|
|
9
|
+
* remediation path for a compromised passphrase (RUSH-1968), where the two
|
|
10
|
+
* supported alternatives are unacceptable (a hand-rolled non-atomic script, or
|
|
11
|
+
* export-to-plaintext which is the exposure being fixed).
|
|
12
|
+
*
|
|
13
|
+
* Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
|
|
14
|
+
*/
|
|
15
|
+
import type { Command } from 'commander';
|
|
16
|
+
/** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
|
|
17
|
+
export declare function registerSecretsRotatePassphraseCommand(secrets: Command): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents secrets rotate-passphrase` — rotate the encrypted file store's
|
|
3
|
+
* machine-local master passphrase (RUSH-1975).
|
|
4
|
+
*
|
|
5
|
+
* Re-encrypts every `<item>.enc` under a freshly generated key and rewrites the
|
|
6
|
+
* 0600 key file in place, atomically: the new store is staged, verified
|
|
7
|
+
* (round-trip + count), fsync'd, then swapped by directory rename, so a crash
|
|
8
|
+
* leaves the old store intact and readable. Headless-safe and Linux-first — the
|
|
9
|
+
* remediation path for a compromised passphrase (RUSH-1968), where the two
|
|
10
|
+
* supported alternatives are unacceptable (a hand-rolled non-atomic script, or
|
|
11
|
+
* export-to-plaintext which is the exposure being fixed).
|
|
12
|
+
*
|
|
13
|
+
* Dry-run by default, matching `import-keyring`; `--commit` performs the swap.
|
|
14
|
+
*/
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { machinePassphraseExists, rotatePassphrase } from '../lib/secrets/filestore.js';
|
|
17
|
+
import { agentStatus } from '../lib/secrets/agent.js';
|
|
18
|
+
/** Register `agents secrets rotate-passphrase` on the parent secrets Command. */
|
|
19
|
+
export function registerSecretsRotatePassphraseCommand(secrets) {
|
|
20
|
+
secrets
|
|
21
|
+
.command('rotate-passphrase')
|
|
22
|
+
.description('Re-key the encrypted file store under a new machine-local passphrase (atomic, headless-safe). Dry-run by default.')
|
|
23
|
+
.option('--commit', 'Perform the rotation (default is dry-run reporting only)')
|
|
24
|
+
.option('--dry-run', 'Report bundle count and round-trip result without re-keying (the default). Still heals an interrupted rotation — that is the one thing it writes.')
|
|
25
|
+
.option('--force', 'Override the safety refusals (held broker unlocks, or a passphrase exported in the environment)')
|
|
26
|
+
.addHelpText('after', `
|
|
27
|
+
Rotates the auto-provisioned file-store key at ~/.agents/.secrets-key/passphrase:
|
|
28
|
+
decrypts every item under the current key, re-encrypts under a new one, verifies
|
|
29
|
+
every item round-trips, then swaps the store and key file atomically. A crash
|
|
30
|
+
before the swap leaves the old store readable with the old key; a crash inside the
|
|
31
|
+
swap self-heals on the next rotate-passphrase run (not on an ordinary get). No
|
|
32
|
+
plaintext secret value or passphrase is ever written to disk, argv, or a log.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
# Report what would rotate (no writes)
|
|
36
|
+
agents secrets rotate-passphrase
|
|
37
|
+
|
|
38
|
+
# Perform the rotation
|
|
39
|
+
agents secrets rotate-passphrase --commit`)
|
|
40
|
+
.action(async (opts) => {
|
|
41
|
+
try {
|
|
42
|
+
if (opts.commit && opts.dryRun) {
|
|
43
|
+
throw new Error('--commit and --dry-run are mutually exclusive.');
|
|
44
|
+
}
|
|
45
|
+
const dryRun = !opts.commit;
|
|
46
|
+
if (!machinePassphraseExists()) {
|
|
47
|
+
throw new Error('No machine-local passphrase is provisioned on this box, so there is nothing to rotate. ' +
|
|
48
|
+
'This command re-keys the file store\'s auto-provisioned key at ~/.agents/.secrets-key/passphrase.');
|
|
49
|
+
}
|
|
50
|
+
// Guard 1: refuse while the secrets-agent holds live unlocks (macOS), so a
|
|
51
|
+
// concurrent read cannot land against a half-swapped store. --force overrides.
|
|
52
|
+
if (!dryRun && !opts.force) {
|
|
53
|
+
const held = await agentStatus();
|
|
54
|
+
if (held.length > 0) {
|
|
55
|
+
throw new Error(`The secrets-agent is holding ${held.length} unlocked bundle(s) (${held.map((e) => e.name).join(', ')}). ` +
|
|
56
|
+
'Lock them first (`agents secrets lock --all`) so no concurrent read races the rotation, or pass --force.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Guard 2: a passphrase exported in the environment shadows the on-disk key
|
|
60
|
+
// file with a now-stale value after the rotation, breaking every read. This
|
|
61
|
+
// is the exact RUSH-1968 footgun — refuse the commit and point at the fix.
|
|
62
|
+
if (!dryRun && !opts.force && (process.env.AGENTS_SECRETS_PASSPHRASE ?? '').length > 0) {
|
|
63
|
+
throw new Error('AGENTS_SECRETS_PASSPHRASE is set in this environment; it would shadow the rotated key file ' +
|
|
64
|
+
'with a stale value and break every read. Unset it (this is the RUSH-1968 fix) before rotating, or pass --force.');
|
|
65
|
+
}
|
|
66
|
+
const report = rotatePassphrase({ dryRun });
|
|
67
|
+
console.log(`${chalk.bold(String(report.bundleCount))} item(s) decrypt under the current key and ` +
|
|
68
|
+
`${report.roundTripOk ? chalk.green('round-trip cleanly') : chalk.red('failed to round-trip')} under a new key.`);
|
|
69
|
+
if (report.skipped.length > 0) {
|
|
70
|
+
console.log(chalk.gray(`skipped ${report.skipped.length} orphan file(s) (not re-keyed):`));
|
|
71
|
+
for (const s of report.skipped)
|
|
72
|
+
console.log(chalk.gray(` - ${s}`));
|
|
73
|
+
}
|
|
74
|
+
if (report.dryRun) {
|
|
75
|
+
if (report.recoveredInterruptedRotation) {
|
|
76
|
+
// Be precise: this run DID write. Recovery is deliberately not gated on
|
|
77
|
+
// --commit, because healing is how a crashed store becomes readable
|
|
78
|
+
// again without re-keying it.
|
|
79
|
+
console.log(chalk.yellow('Recovered an interrupted rotation: the store was healed back to a single ' +
|
|
80
|
+
'readable state. That is the only thing this dry run wrote — no re-keying happened.'));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(chalk.gray('Dry-run: nothing written.'));
|
|
84
|
+
}
|
|
85
|
+
console.log(chalk.gray(`Pass --commit to re-encrypt the store and swap the key file (${report.keyFilePath}).`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log(chalk.green(`Rotated: re-encrypted ${report.bundleCount} item(s) and rewrote ${report.keyFilePath} (mode 0600).`));
|
|
89
|
+
console.log(chalk.gray('The previous key and ciphertext were removed. Any process still holding the old passphrase in its environment must be restarted.'));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(chalk.red(err.message));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
package/dist/commands/secrets.js
CHANGED
|
@@ -38,6 +38,7 @@ import { getVaultSession, vaultExists } from '../lib/secrets/vault.js';
|
|
|
38
38
|
import { registerSecretsSyncCommands } from './secrets-sync.js';
|
|
39
39
|
import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
|
|
40
40
|
import { registerSecretsImportKeyringCommand } from './secrets-import.js';
|
|
41
|
+
import { registerSecretsRotatePassphraseCommand } from './secrets-rotate-passphrase.js';
|
|
41
42
|
/** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
|
|
42
43
|
async function promptForSecret(message) {
|
|
43
44
|
if (!isInteractiveTerminal()) {
|
|
@@ -2419,6 +2420,7 @@ Examples:
|
|
|
2419
2420
|
registerSecretsSyncCommands(cmd);
|
|
2420
2421
|
registerSecretsMigrateAclCommand(cmd);
|
|
2421
2422
|
registerSecretsImportKeyringCommand(cmd);
|
|
2423
|
+
registerSecretsRotatePassphraseCommand(cmd);
|
|
2422
2424
|
}
|
|
2423
2425
|
/** Validate a prompt-policy value, throwing a clear message on a bad one (the
|
|
2424
2426
|
* caller's try/catch renders it and exits). Accepts the legacy `biometry` /
|
|
@@ -136,11 +136,17 @@ export declare function linkCwdCell(s: Pick<SessionMeta, 'cwd' | '_remote'>, lab
|
|
|
136
136
|
* (null when unknown) alongside the raw fields, so every active row is joinable.
|
|
137
137
|
* `project` uses the same derivation SessionMeta does — basename(cwd) (see
|
|
138
138
|
* discover.ts) — so the active view and the history view join identically.
|
|
139
|
+
*
|
|
140
|
+
* `viewingIn` flattens to the same display string the row renderer prints —
|
|
141
|
+
* `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
|
|
142
|
+
* session from an orphaned one (its terminal died, the agent is still running)
|
|
143
|
+
* without re-implementing the tmux client lookup.
|
|
139
144
|
*/
|
|
140
|
-
export declare function serializeActiveSessionsForJson(sessions: ActiveSession[]): Array<ActiveSession & {
|
|
145
|
+
export declare function serializeActiveSessionsForJson(sessions: ActiveSession[]): Array<Omit<ActiveSession, 'viewingIn'> & {
|
|
141
146
|
ticketId: string | null;
|
|
142
147
|
project: string | null;
|
|
143
148
|
prLink: string | null;
|
|
149
|
+
viewingIn: string | null;
|
|
144
150
|
}>;
|
|
145
151
|
/**
|
|
146
152
|
* Compact owner display for the `--active` owner column: the local-part of a
|
|
@@ -20,7 +20,7 @@ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecut
|
|
|
20
20
|
import { getActiveSessions } from '../lib/session/active.js';
|
|
21
21
|
import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
|
|
22
22
|
import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
|
|
23
|
-
import { resolveViewingIn } from '../lib/session/viewing-in.js';
|
|
23
|
+
import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
|
|
24
24
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
25
25
|
import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
26
26
|
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
@@ -442,6 +442,11 @@ function modelLabel(model) {
|
|
|
442
442
|
* (null when unknown) alongside the raw fields, so every active row is joinable.
|
|
443
443
|
* `project` uses the same derivation SessionMeta does — basename(cwd) (see
|
|
444
444
|
* discover.ts) — so the active view and the history view join identically.
|
|
445
|
+
*
|
|
446
|
+
* `viewingIn` flattens to the same display string the row renderer prints —
|
|
447
|
+
* `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
|
|
448
|
+
* session from an orphaned one (its terminal died, the agent is still running)
|
|
449
|
+
* without re-implementing the tmux client lookup.
|
|
445
450
|
*/
|
|
446
451
|
export function serializeActiveSessionsForJson(sessions) {
|
|
447
452
|
return sessions.map((s) => ({
|
|
@@ -449,6 +454,7 @@ export function serializeActiveSessionsForJson(sessions) {
|
|
|
449
454
|
ticketId: s.ticket?.id ?? null,
|
|
450
455
|
project: s.cwd ? path.basename(s.cwd) : null,
|
|
451
456
|
prLink: s.pr?.url ?? null,
|
|
457
|
+
viewingIn: viewingInLabel(s) ?? null,
|
|
452
458
|
}));
|
|
453
459
|
}
|
|
454
460
|
/**
|
|
@@ -496,13 +502,9 @@ function locatorBadge(s) {
|
|
|
496
502
|
// For a tmux-hosted session, say which app+tab is looking at it right now
|
|
497
503
|
// (or that it's running detached). Only meaningful for tmux (the pane is the
|
|
498
504
|
// durable handle; the viewer is transient).
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
parts.push(chalk.gray(`viewing in ${
|
|
502
|
-
}
|
|
503
|
-
else {
|
|
504
|
-
parts.push(chalk.gray('detached'));
|
|
505
|
-
}
|
|
505
|
+
const label = viewingInLabel(s);
|
|
506
|
+
if (label)
|
|
507
|
+
parts.push(chalk.gray(label === 'detached' ? label : `viewing in ${label}`));
|
|
506
508
|
}
|
|
507
509
|
else if (p?.mux?.kind === 'screen') {
|
|
508
510
|
parts.push(chalk.green('screen'));
|
|
@@ -860,13 +862,33 @@ async function enrichLocalLocators(local) {
|
|
|
860
862
|
}
|
|
861
863
|
}
|
|
862
864
|
catch { /* non-fatal */ }
|
|
863
|
-
//
|
|
865
|
+
// One Ghostty enumeration shared across every socket's viewing-in resolve
|
|
866
|
+
// (a tmux client can be attached from a Ghostty tab).
|
|
867
|
+
await enrichTmuxLocators(local, await enumerateGhosttyTabsQuietly());
|
|
868
|
+
}
|
|
869
|
+
/** {@link enumerateGhosttyTabs}, best-effort — an osascript failure yields no surfaces. */
|
|
870
|
+
async function enumerateGhosttyTabsQuietly() {
|
|
871
|
+
try {
|
|
872
|
+
return await enumerateGhosttyTabs();
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
return [];
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* The tmux half of {@link enrichLocalLocators}: the `session:window.pane` attach
|
|
880
|
+
* target and "viewing in <app> tab N" / detached, one batched query per socket.
|
|
881
|
+
*
|
|
882
|
+
* Split out because it is the only locator the `--json` path can afford. It costs
|
|
883
|
+
* tmux queries and a `ps` read — no osascript — so scriptable output stays cheap
|
|
884
|
+
* while still answering the question a consumer actually needs: is anyone looking
|
|
885
|
+
* at this session, or is it running orphaned? Without `surfaces`, a Ghostty-attached
|
|
886
|
+
* client still resolves as attached, just without its tab number.
|
|
887
|
+
*/
|
|
888
|
+
async function enrichTmuxLocators(local, surfaces = []) {
|
|
864
889
|
try {
|
|
865
890
|
const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
|
|
866
891
|
if (tmux.length > 0) {
|
|
867
|
-
// One Ghostty enumeration shared across every socket's viewing-in resolve
|
|
868
|
-
// (a tmux client can be attached from a Ghostty tab).
|
|
869
|
-
const surfaces = await enumerateGhosttyTabs();
|
|
870
892
|
const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
|
|
871
893
|
for (const socket of sockets) {
|
|
872
894
|
const paneMap = await mapPanesToTargets(socket);
|
|
@@ -964,6 +986,11 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
964
986
|
// present so a supervising agent or hook can poll it as a gate.
|
|
965
987
|
const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
|
|
966
988
|
if (asJson) {
|
|
989
|
+
// Resolve who is watching each local tmux pane before serializing: `viewingIn`
|
|
990
|
+
// is how a consumer distinguishes a session someone is looking at from one
|
|
991
|
+
// running orphaned after its terminal died. tmux-only (no osascript) so the
|
|
992
|
+
// scriptable path stays cheap — see enrichTmuxLocators.
|
|
993
|
+
await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
|
|
967
994
|
process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
|
|
968
995
|
if (waitingOnly && sessions.length > 0)
|
|
969
996
|
process.exitCode = 1;
|
package/dist/commands/webhook.js
CHANGED
|
@@ -77,9 +77,14 @@ export function registerWebhookCommand(program) {
|
|
|
77
77
|
// Durable delivery dedup: replays survive a receiver restart (an
|
|
78
78
|
// in-memory store would forget every seen delivery on restart).
|
|
79
79
|
deliveryStore: createFileDeliveryStore(path.join(getRuntimeStateDir(), 'webhook', 'deliveries.json')),
|
|
80
|
-
onDelivery: (webhook, fired) => {
|
|
80
|
+
onDelivery: (webhook, fired, handlers) => {
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (fired.length)
|
|
83
|
+
parts.push(`routines ${fired.map((f) => f.jobName).join(', ')}`);
|
|
84
|
+
if (handlers.length)
|
|
85
|
+
parts.push(`handlers ${handlers.map((h) => h.handlerName).join(', ')}`);
|
|
81
86
|
console.log(`${new Date().toISOString()} ${webhook.source}:${webhook.event} ` +
|
|
82
|
-
|
|
87
|
+
(parts.length ? `fired ${parts.join('; ')}` : 'no match'));
|
|
83
88
|
},
|
|
84
89
|
});
|
|
85
90
|
await waitForListening(server);
|
package/dist/lib/commands.js
CHANGED
|
@@ -11,6 +11,7 @@ import * as path from 'path';
|
|
|
11
11
|
import * as yaml from 'yaml';
|
|
12
12
|
import { AGENTS, ensureCommandsDir, agentConfigDirName, resolveAgentName } from './agents.js';
|
|
13
13
|
import { capableAgents, isCapable, supports } from './capabilities.js';
|
|
14
|
+
import { isDirectoryDoc } from './resources.js';
|
|
14
15
|
import { markdownToToml } from './convert.js';
|
|
15
16
|
import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
|
|
16
17
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
|
|
@@ -150,6 +151,8 @@ export function discoverCommands(repoPath) {
|
|
|
150
151
|
for (const file of fs.readdirSync(commandsDir)) {
|
|
151
152
|
if (file.endsWith('.md')) {
|
|
152
153
|
const name = file.replace('.md', '');
|
|
154
|
+
if (isDirectoryDoc('commands', name))
|
|
155
|
+
continue;
|
|
153
156
|
const sourcePath = path.join(commandsDir, file);
|
|
154
157
|
const metadata = parseCommandMetadata(sourcePath);
|
|
155
158
|
const validation = validateCommandMetadata(metadata, name);
|
|
@@ -694,7 +697,12 @@ export function listCentralCommands() {
|
|
|
694
697
|
if (!fs.existsSync(dir))
|
|
695
698
|
continue;
|
|
696
699
|
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.md'))) {
|
|
697
|
-
|
|
700
|
+
const name = f.replace('.md', '');
|
|
701
|
+
// A directory's README/AGENTS/CLAUDE/GEMINI documents the dir, it is not a
|
|
702
|
+
// command. Without this the picker offers a name resolveResource refuses.
|
|
703
|
+
if (isDirectoryDoc('commands', name))
|
|
704
|
+
continue;
|
|
705
|
+
seen.add(name);
|
|
698
706
|
}
|
|
699
707
|
}
|
|
700
708
|
return Array.from(seen);
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -86,6 +86,23 @@ export declare function log(level: string, message: string): void;
|
|
|
86
86
|
* anchoring failed (logged, non-fatal).
|
|
87
87
|
*/
|
|
88
88
|
export declare function anchorDaemonCwd(): string | null;
|
|
89
|
+
/**
|
|
90
|
+
* Surface, at the daemon's OWN startup, that it was launched from an ephemeral
|
|
91
|
+
* root that will wedge it if the directory is removed. This is the runtime
|
|
92
|
+
* companion to the launch-time check in validateDaemonBinary (which only runs
|
|
93
|
+
* when the daemon is *spawned* via getDaemonLaunch): a direct
|
|
94
|
+
* `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
|
|
95
|
+
* checkout under /tmp — never passes through that path, so without this the
|
|
96
|
+
* wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
|
|
97
|
+
* imports. Best-effort and non-fatal; the cwd is already handled by
|
|
98
|
+
* anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
|
|
99
|
+
*
|
|
100
|
+
* `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
|
|
101
|
+
* predicate call, the WARN, and the non-fatal guard around a throwing resolver —
|
|
102
|
+
* is testable. Returns the warning message it logged, or null when the launch
|
|
103
|
+
* root is stable (or could not be resolved).
|
|
104
|
+
*/
|
|
105
|
+
export declare function warnEphemeralDaemonRoot(resolveBin?: () => string): string | null;
|
|
89
106
|
export declare function runDaemon(): Promise<void>;
|
|
90
107
|
/**
|
|
91
108
|
* Write a launchd plist or systemd unit with owner-only permissions atomically.
|
|
@@ -175,6 +192,18 @@ export declare function getAgentsInvocation(subArgs: string[], agentsBin?: strin
|
|
|
175
192
|
command: string;
|
|
176
193
|
args: string[];
|
|
177
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* A daemon binary living under an ephemeral path — a git worktree, or a temp
|
|
197
|
+
* directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
|
|
198
|
+
* is long-lived but resolves its own job modules by dynamic `import()` rooted at
|
|
199
|
+
* this entry (getAgentsBinPath → process.argv[1]). If that directory is later
|
|
200
|
+
* removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
|
|
201
|
+
* teardown) the running daemon keeps ENOENT-ing on every job it loads —
|
|
202
|
+
* `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
|
|
203
|
+
* tree. Returns a human phrase naming the ephemeral kind, or null for a stable
|
|
204
|
+
* install path (version home, a global npm prefix, a normal source checkout).
|
|
205
|
+
*/
|
|
206
|
+
export declare function describeEphemeralDaemonRoot(binPath: string): string | null;
|
|
178
207
|
export declare function validateDaemonBinary(binPath: string): {
|
|
179
208
|
warnings: string[];
|
|
180
209
|
};
|
package/dist/lib/daemon.js
CHANGED
|
@@ -321,6 +321,39 @@ export function anchorDaemonCwd() {
|
|
|
321
321
|
return null;
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Surface, at the daemon's OWN startup, that it was launched from an ephemeral
|
|
326
|
+
* root that will wedge it if the directory is removed. This is the runtime
|
|
327
|
+
* companion to the launch-time check in validateDaemonBinary (which only runs
|
|
328
|
+
* when the daemon is *spawned* via getDaemonLaunch): a direct
|
|
329
|
+
* `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
|
|
330
|
+
* checkout under /tmp — never passes through that path, so without this the
|
|
331
|
+
* wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
|
|
332
|
+
* imports. Best-effort and non-fatal; the cwd is already handled by
|
|
333
|
+
* anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
|
|
334
|
+
*
|
|
335
|
+
* `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
|
|
336
|
+
* predicate call, the WARN, and the non-fatal guard around a throwing resolver —
|
|
337
|
+
* is testable. Returns the warning message it logged, or null when the launch
|
|
338
|
+
* root is stable (or could not be resolved).
|
|
339
|
+
*/
|
|
340
|
+
export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
|
|
341
|
+
try {
|
|
342
|
+
const bin = resolveBin();
|
|
343
|
+
const ephemeralRoot = describeEphemeralDaemonRoot(bin);
|
|
344
|
+
if (!ephemeralRoot)
|
|
345
|
+
return null;
|
|
346
|
+
const message = `Daemon launched from ${ephemeralRoot} (${bin}); if that directory is removed, ` +
|
|
347
|
+
`every routine will fail with ENOENT on its module imports. Run the daemon from the ` +
|
|
348
|
+
`globally installed binary instead (npm i -g @phnx-labs/agents-cli), then restart it.`;
|
|
349
|
+
log('WARN', message);
|
|
350
|
+
return message;
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
log('WARN', `Could not check daemon launch root: ${err.message}`);
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
324
357
|
export async function runDaemon() {
|
|
325
358
|
// Single-instance guard: a direct `agents __daemon-run` (manual, or a
|
|
326
359
|
// service-manager restart racing a live predecessor) must not clobber a
|
|
@@ -334,6 +367,7 @@ export async function runDaemon() {
|
|
|
334
367
|
}
|
|
335
368
|
log('INFO', `Daemon started (PID: ${process.pid})`);
|
|
336
369
|
anchorDaemonCwd();
|
|
370
|
+
warnEphemeralDaemonRoot();
|
|
337
371
|
// The daemon holds NO Claude credential of its own. Routine runs authenticate
|
|
338
372
|
// exactly like an interactive `agents run`: through the per-account
|
|
339
373
|
// CLAUDE_CONFIG_DIR login on this device (its own auto-refreshing
|
|
@@ -1106,15 +1140,35 @@ function daemonPathValue(agentsBin, systemDirs) {
|
|
|
1106
1140
|
export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
|
|
1107
1141
|
return getCliLaunch(subArgs, agentsBin);
|
|
1108
1142
|
}
|
|
1143
|
+
/**
|
|
1144
|
+
* A daemon binary living under an ephemeral path — a git worktree, or a temp
|
|
1145
|
+
* directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
|
|
1146
|
+
* is long-lived but resolves its own job modules by dynamic `import()` rooted at
|
|
1147
|
+
* this entry (getAgentsBinPath → process.argv[1]). If that directory is later
|
|
1148
|
+
* removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
|
|
1149
|
+
* teardown) the running daemon keeps ENOENT-ing on every job it loads —
|
|
1150
|
+
* `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
|
|
1151
|
+
* tree. Returns a human phrase naming the ephemeral kind, or null for a stable
|
|
1152
|
+
* install path (version home, a global npm prefix, a normal source checkout).
|
|
1153
|
+
*/
|
|
1154
|
+
export function describeEphemeralDaemonRoot(binPath) {
|
|
1155
|
+
if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath))
|
|
1156
|
+
return 'a git worktree';
|
|
1157
|
+
if (/^(?:\/private)?\/tmp[/\\]|^(?:\/private)?\/var\/folders[/\\]|^\/dev\/shm[/\\]/.test(binPath)) {
|
|
1158
|
+
return 'a temporary directory';
|
|
1159
|
+
}
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1109
1162
|
export function validateDaemonBinary(binPath) {
|
|
1110
1163
|
const warnings = [];
|
|
1111
1164
|
if (BUN_VIRTUAL_ROOT.test(binPath)) {
|
|
1112
1165
|
throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
|
|
1113
1166
|
`Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
|
|
1114
1167
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1168
|
+
const ephemeralRoot = describeEphemeralDaemonRoot(binPath);
|
|
1169
|
+
if (ephemeralRoot) {
|
|
1170
|
+
warnings.push(`Warning: daemon binary is inside ${ephemeralRoot} (${binPath}). ` +
|
|
1171
|
+
`Deleting it will wedge the daemon. Use the globally installed binary instead.`);
|
|
1118
1172
|
}
|
|
1119
1173
|
if (!fs.existsSync(binPath) && !/\.(c|m)?js$/.test(binPath)) {
|
|
1120
1174
|
warnings.push(`Warning: daemon binary does not exist on disk (${binPath}).`);
|
|
@@ -1132,7 +1186,7 @@ export function startDetached(opts = {}) {
|
|
|
1132
1186
|
// and a console-close event tears it down when the launcher exits (#556).
|
|
1133
1187
|
const child = spawn(command, args, {
|
|
1134
1188
|
stdio: ['ignore', logFd, logFd],
|
|
1135
|
-
...backgroundSpawnOptions({ fdStdio: true }),
|
|
1189
|
+
...backgroundSpawnOptions({ cwd: os.homedir(), fdStdio: true }),
|
|
1136
1190
|
env: opts.env ?? process.env,
|
|
1137
1191
|
});
|
|
1138
1192
|
// A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
|
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' | 'friction' | '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' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.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;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ActiveSession } from '../session/active.js';
|
|
2
|
+
import { serializeActiveSessionsForJson } from '../../commands/sessions.js';
|
|
3
|
+
import { readAuthHealthCache, type AuthVerdict } from '../auth-health.js';
|
|
4
|
+
import { readStatsCache } from '../devices/stats-cache.js';
|
|
5
|
+
export declare const FACTORY_PROJECTS: readonly [{
|
|
6
|
+
readonly name: "Prix";
|
|
7
|
+
readonly repo: "phnx-labs/prix";
|
|
8
|
+
}, {
|
|
9
|
+
readonly name: "Rush App";
|
|
10
|
+
readonly repo: "phnx-labs/rush";
|
|
11
|
+
}, {
|
|
12
|
+
readonly name: "Rush CLI";
|
|
13
|
+
readonly repo: "phnx-labs/rush-cli";
|
|
14
|
+
}, {
|
|
15
|
+
readonly name: "Agents CLI";
|
|
16
|
+
readonly repo: "phnx-labs/agents-cli";
|
|
17
|
+
}, {
|
|
18
|
+
readonly name: "Linear CLI";
|
|
19
|
+
readonly repo: "phnx-labs/linear-cli";
|
|
20
|
+
}];
|
|
21
|
+
export interface FactoryConfig {
|
|
22
|
+
source: 'default' | 'file';
|
|
23
|
+
ceiling: number;
|
|
24
|
+
max_dispatch_per_tick: number;
|
|
25
|
+
per_project: Record<string, {
|
|
26
|
+
weight: number;
|
|
27
|
+
cap: number;
|
|
28
|
+
}>;
|
|
29
|
+
idle_boxes: string[];
|
|
30
|
+
digest: {
|
|
31
|
+
times: string[];
|
|
32
|
+
tz: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface FactorySnapshot {
|
|
36
|
+
generatedAt: string;
|
|
37
|
+
sessions: ReturnType<typeof serializeActiveSessionsForJson>;
|
|
38
|
+
queues: Record<string, {
|
|
39
|
+
todo: number;
|
|
40
|
+
inProgress: number;
|
|
41
|
+
blocked: number;
|
|
42
|
+
}>;
|
|
43
|
+
prs: Array<{
|
|
44
|
+
repo: string;
|
|
45
|
+
number: number;
|
|
46
|
+
ci: string;
|
|
47
|
+
review: string;
|
|
48
|
+
mergeable: string;
|
|
49
|
+
}>;
|
|
50
|
+
devices: Array<{
|
|
51
|
+
name: string;
|
|
52
|
+
load: number | null;
|
|
53
|
+
idle: boolean;
|
|
54
|
+
}>;
|
|
55
|
+
recentRuns: Array<{
|
|
56
|
+
routine: string;
|
|
57
|
+
status: string;
|
|
58
|
+
durationMs: number | null;
|
|
59
|
+
}>;
|
|
60
|
+
auth: {
|
|
61
|
+
claude: AuthVerdict | null;
|
|
62
|
+
};
|
|
63
|
+
config: FactoryConfig;
|
|
64
|
+
}
|
|
65
|
+
export interface SnapshotDependencies {
|
|
66
|
+
home: string;
|
|
67
|
+
now: () => Date;
|
|
68
|
+
activeSessions: () => Promise<ActiveSession[]>;
|
|
69
|
+
run: (file: string, args: string[]) => Promise<string>;
|
|
70
|
+
readAuth: () => ReturnType<typeof readAuthHealthCache>;
|
|
71
|
+
readDeviceStats: () => ReturnType<typeof readStatsCache>;
|
|
72
|
+
}
|
|
73
|
+
export declare function readFactoryConfig(home: string): FactoryConfig;
|
|
74
|
+
export declare function queueCounts(todoPayload: unknown, openPayload: unknown): FactorySnapshot['queues'][string];
|
|
75
|
+
export declare function parsePullRequests(repo: string, payload: unknown): FactorySnapshot['prs'];
|
|
76
|
+
export declare function parseDevices(payload: unknown, cached?: ReturnType<typeof readStatsCache>): FactorySnapshot['devices'];
|
|
77
|
+
export declare function readRecentRuns(home: string, limit?: number): FactorySnapshot['recentRuns'];
|
|
78
|
+
export declare function buildFactorySnapshot(overrides?: Partial<SnapshotDependencies>): Promise<FactorySnapshot>;
|