@phnx-labs/agents-cli 1.20.82 → 1.20.83
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 +108 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +2 -1
- package/dist/commands/doctor.js +25 -6
- package/dist/commands/menubar.js +12 -0
- package/dist/commands/routines.d.ts +8 -1
- package/dist/commands/routines.js +229 -75
- package/dist/commands/sessions.d.ts +84 -0
- package/dist/commands/sessions.js +48 -19
- package/dist/index.js +47 -0
- package/dist/lib/exec.d.ts +7 -0
- package/dist/lib/exec.js +34 -3
- package/dist/lib/hooks.d.ts +29 -0
- package/dist/lib/hooks.js +109 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +24 -0
- package/dist/lib/menubar/install-menubar.js +52 -2
- package/dist/lib/routines.d.ts +13 -0
- package/dist/lib/routines.js +116 -0
- package/dist/lib/scheduler.js +16 -4
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/active.d.ts +18 -0
- package/dist/lib/session/active.js +32 -4
- package/dist/lib/state.js +57 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,113 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.83
|
|
4
|
+
|
|
5
|
+
- **Routines now treat date-specific cron schedules as one-shot jobs (RUSH-2074).**
|
|
6
|
+
`agents routines add --schedule "0 14 29 7 *"` now warns, persists
|
|
7
|
+
`runOnce: true`, marks the routine as one-shot in `routines list`, and
|
|
8
|
+
`agents routines cleanup` removes completed expired one-shots that still have
|
|
9
|
+
user-layer YAML. Source: `apps/cli/src/lib/routines.ts`,
|
|
10
|
+
`apps/cli/src/lib/scheduler.ts`, `apps/cli/src/commands/routines.ts`.
|
|
11
|
+
|
|
12
|
+
- **`agents routines list` groups terminal output by device and placement
|
|
13
|
+
(RUSH-2075).** The default table is bucketed under this machine, fleet-wide,
|
|
14
|
+
cloud, named devices, and named hosts with offline/unknown registry hints;
|
|
15
|
+
`--flat` keeps the legacy single table and `--json` remains a flat payload.
|
|
16
|
+
Source: `apps/cli/src/commands/routines.ts`.
|
|
17
|
+
|
|
18
|
+
- **`agents.yaml` no longer churns on every meta write.** `writeMetaUnlocked` wrote
|
|
19
|
+
the central config with `yaml.stringify`, which strips all comments — so the
|
|
20
|
+
freshly-serialized bytes never matched the comment-annotated file on disk,
|
|
21
|
+
`writeIfChanged` rewrote it on every meta write, and the perpetually-dirty tree
|
|
22
|
+
wedged `agents sync` ("Blocked by local changes") across the fleet. It now
|
|
23
|
+
serializes via a `yaml.Document` round-trip (`serializeCentral`) that edits only
|
|
24
|
+
the keys that actually changed, so comments, key ordering, and untouched
|
|
25
|
+
top-level blocks (e.g. `hosts:`) are byte-stable — and a write that changes no
|
|
26
|
+
central field leaves `agents.yaml` untouched. Source: `apps/cli/src/lib/state.ts`.
|
|
27
|
+
|
|
28
|
+
- **`agents run codex` can now reach the fleet from inside its sandbox.** Codex's
|
|
29
|
+
`workspace-write` sandbox blocks `$HOME` (verified against the live CLI and OpenAI's
|
|
30
|
+
sandbox docs), but the model routinely shells out to `agents ...`, whose runtime state
|
|
31
|
+
lives under `~/.agents` — the SSH askpass shim (`~/.agents/.cache/devices/askpass.sh`),
|
|
32
|
+
the device/stats cache, secrets, session writes, config tunings. Those inner writes hit
|
|
33
|
+
`EROFS` (`agents ssh` died before connecting, so a remote `agents run codex` could not
|
|
34
|
+
SSH or self-tune), and the fix was previously left to the caller (teams pass
|
|
35
|
+
`--add-dir ~/.agents` explicitly; a plain `agents run` never did). `buildExecCommand`
|
|
36
|
+
now grants `~/.agents` as an extra writable root whenever Codex runs `workspace-write`
|
|
37
|
+
(`--mode edit`/`auto`) — via `--add-dir` on fresh runs (deduped against user
|
|
38
|
+
`--add-dir`s) and via `-c sandbox_workspace_write.writable_roots` on resume forms (which
|
|
39
|
+
reject `--add-dir`). This is the officially-recommended way to widen scope "without
|
|
40
|
+
removing the sandbox entirely" — far narrower than `--mode skip` (danger-full-access).
|
|
41
|
+
`plan` (read-only) and `skip` (sandbox already dropped) are unaffected. Source:
|
|
42
|
+
`apps/cli/src/lib/exec.ts` (`buildExecCommand`, `codexWritableRootsConfig`).
|
|
43
|
+
|
|
44
|
+
- **Fix headless release signing (`errSecInternalComponent`).** `headless-sign-context.sh` now runs `security set-key-partition-list` right after unlocking `rush-signing.keychain-db`, authorizing `codesign`/`apple-tool` to use the Developer ID key non-interactively. Without it, the key's ACL prompts for UI approval that a headless SSH release session can't answer, so `codesign` fails and the npm publish halts. Idempotent; runs every release. Source: `apps/cli/scripts/headless-sign-context.sh`.
|
|
45
|
+
|
|
46
|
+
- **Cmd-Shift-V clip paste no longer breaks with an "sshd-keygen-wrapper would like
|
|
47
|
+
to control this computer" prompt.** A menu-bar helper started from an ssh session
|
|
48
|
+
registered the global chords but could never service them: macOS attributes its
|
|
49
|
+
Accessibility request to the responsible process, `/usr/libexec/sshd-keygen-wrapper`,
|
|
50
|
+
not to the helper's bundle, so the prompt named a process whose grant does nothing
|
|
51
|
+
for the paste (and, if granted, hands keystroke synthesis to everything any ssh
|
|
52
|
+
session spawns). `RegisterEventHotKey` is first-come, and the prompt naming
|
|
53
|
+
sshd-keygen-wrapper is itself the evidence that this copy — not the trusted
|
|
54
|
+
launchd-managed one — had registered Cmd-Shift-V and was servicing it. The
|
|
55
|
+
interactive mode now refuses to start over a remote shell, and refuses
|
|
56
|
+
unrecognized arguments: an unknown flag used to fall straight through to the
|
|
57
|
+
status-bar app, which is how a stray `MenubarHelper --self-test` from a verify run
|
|
58
|
+
became a permanent second helper. `launchctl bootstrap` (`agents menubar enable`)
|
|
59
|
+
is unaffected, including when run over ssh. Source:
|
|
60
|
+
`apps/cli/menubar/Sources/MenubarHelper/Guards.swift`.
|
|
61
|
+
|
|
62
|
+
- **`agents menubar status` now names a second helper process instead of reporting a
|
|
63
|
+
healthy `running: yes`.** The check was `pgrep -f MenubarHelper`, which matches any
|
|
64
|
+
process with that name, so a stray copy holding the global chords looked identical
|
|
65
|
+
to a working install. Status now identifies the helper by its resolved executable
|
|
66
|
+
(`ps -o comm=`), reports `running` only for the installed bundle, and lists every
|
|
67
|
+
other live copy with its pid under `foreignInstances` (also in `--json`). Source:
|
|
68
|
+
`apps/cli/src/lib/menubar/install-menubar.ts`.
|
|
69
|
+
|
|
70
|
+
- **The menu bar now says so when a hotkey is unavailable or the paste is not
|
|
71
|
+
permitted.** A `RegisterEventHotKey` conflict only wrote a line to a launchd log,
|
|
72
|
+
and a missing Accessibility grant made `Clip.inject` return silently — both looked
|
|
73
|
+
exactly like a dead hotkey. A stolen chord now posts a notification naming it, and a
|
|
74
|
+
denied grant copies the `host:path` reference to the clipboard and says which
|
|
75
|
+
setting to grant, so the clip is never lost. Source:
|
|
76
|
+
`apps/cli/menubar/Sources/MenubarHelper/Hotkey.swift`,
|
|
77
|
+
`apps/cli/menubar/Sources/MenubarHelper/Clip.swift`.
|
|
78
|
+
|
|
79
|
+
- **Fix the release catch-up path aborting on an unbound variable.** When a release PR had already merged and only the tag + publish remained, `release.sh` re-validated CI and then aborted with `line 933: RELEASE_COMMIT: unbound variable`, so the retry never reached npm. The catch-up block that runs when `main` sits exactly at the release merge commit never set `HISTORICAL_CATCHUP`, so phase 4 took the normal-release branch and read `RELEASE_COMMIT`, which only the branch-creating path defines. It now sets the flag, and phase 4 resolves the release commit from the merged PR (`MERGED_RELEASE_SHA` + `CI_TESTED_HEAD`) as intended. This is why 1.20.79, 1.20.80, and 1.20.81 were tagged but never published. Source: `apps/cli/scripts/release.sh`.
|
|
80
|
+
|
|
81
|
+
- **Removed `agents check` / `agents resources` now forward to their replacements
|
|
82
|
+
instead of erroring (RUSH-1234).** After the command consolidation, running the
|
|
83
|
+
removed names produced a bare `unknown command` (their edit-distance to `doctor`
|
|
84
|
+
was too far to even trigger a "did you mean"). They are now hidden tombstone
|
|
85
|
+
commands that print a one-line deprecation notice to stderr and re-run the
|
|
86
|
+
replacement, preserving flags and exit codes: `agents check …` runs
|
|
87
|
+
`agents doctor --check …` (so `--json` / `--quiet` / `--devices` and the CI
|
|
88
|
+
drift-gate exit code carry through), and `agents resources …` runs
|
|
89
|
+
`agents view --merged …` (with `agents inspect <target>` pointed to for
|
|
90
|
+
per-agent/per-repo detail). The notice goes to stderr so a `--json` consumer's
|
|
91
|
+
stdout stays clean. Source: `apps/cli/src/index.ts`.
|
|
92
|
+
|
|
93
|
+
- **`agents sessions --host`/`--device <box>` now opens the interactive fleet
|
|
94
|
+
browser instead of a raw text dump.** A bare remote listing on a TTY folds the
|
|
95
|
+
named box into the same preview-rich, selectable picker as the local view (it
|
|
96
|
+
previously short-circuited to the legacy per-host stream — non-interactive, no
|
|
97
|
+
previews). A `--host` *query*, a render/filter flag, `--json`, or a
|
|
98
|
+
non-interactive caller keep the streamed output. Source:
|
|
99
|
+
`apps/cli/src/commands/sessions.ts`.
|
|
100
|
+
|
|
101
|
+
- **`agents sessions` now shows which orchestrator spawned each team.** A teams
|
|
102
|
+
teammate row was keyed off its orchestrator's session id (captured from
|
|
103
|
+
`AGENTS_SESSION_ID` at spawn), which both hid the lineage and mislabeled the
|
|
104
|
+
teammate with the orchestrator's id/topic. The teammate now keys off its own
|
|
105
|
+
transcript, exposes the orchestrator as `orchestratorSessionId` (+ a resolved
|
|
106
|
+
`orchestratorLabel`) in `--active --json`, and the listing renders
|
|
107
|
+
`<team> · by <orchestrator>` so "which session spun up this team" is answerable
|
|
108
|
+
at a glance. Source: `apps/cli/src/lib/session/active.ts`,
|
|
109
|
+
`apps/cli/src/commands/sessions.ts`.
|
|
110
|
+
|
|
3
111
|
## 1.20.82
|
|
4
112
|
|
|
5
113
|
- **Codex hook sync no longer leaves startup warnings after upgrades.** The Codex
|
package/README.md
CHANGED
|
@@ -306,7 +306,7 @@ agents sessions detach a1b2c3d4 # go headless in the background, keep workin
|
|
|
306
306
|
agents sessions attach a1b2c3d4 # resume it interactively, right here
|
|
307
307
|
```
|
|
308
308
|
|
|
309
|
-
Both are agent-agnostic -- they route through the same `agents run --resume` path (native resume for Claude/Codex, `/continue` replay for the rest). `agents sessions --active`
|
|
309
|
+
Both are agent-agnostic -- they route through the same `agents run --resume` path (native resume for Claude/Codex, `/continue` replay for the rest). `agents sessions --active` shows each session's **owner** (the human who launched it, resolved from the tailnet identity, or `-` for an unresolved local run) and its `presence` -- `attached` (you're watching it), `background` (running headless), or `parked` (its background run finished) -- so the menu bar and Factory show who is running what, and where. In the Factory extension, **Agents: Detach** (`Cmd/Ctrl+K B`) and **Agents: Attach** (`Cmd/Ctrl+K A`) do the same over the focused terminal.
|
|
310
310
|
|
|
311
311
|
---
|
|
312
312
|
|
package/dist/bin/agents
CHANGED
|
Binary file
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import type { Command } from 'commander';
|
|
25
25
|
import { type FleetDivergenceReport } from '../lib/devices/fleet-divergence.js';
|
|
26
26
|
import { type VersionResourceReport } from '../lib/doctor-diff.js';
|
|
27
|
+
import { type DuplicateVersionHook } from '../lib/hooks.js';
|
|
27
28
|
import { type SyncStatusRow, type OrphanRow } from '../lib/drift.js';
|
|
28
29
|
import { type FetchStatusMarker } from '../lib/auto-pull.js';
|
|
29
30
|
export declare function wrapLine(prefix: string, text: string, width?: number): string[];
|
|
@@ -106,5 +107,5 @@ export declare function healthBlockLines(verdict: DoctorVerdict, opts: {
|
|
|
106
107
|
* and orphan resources into severity-tagged findings. Agent-agnostic: every
|
|
107
108
|
* installed version is classified the same way. Pure, so it is unit-testable.
|
|
108
109
|
*/
|
|
109
|
-
export declare function computeOverviewHealth(syncRows: SyncStatusRow[], orphanRows: OrphanRow[], repoBehindMarkers: FetchStatusMarker[]): DoctorVerdict;
|
|
110
|
+
export declare function computeOverviewHealth(syncRows: SyncStatusRow[], orphanRows: OrphanRow[], repoBehindMarkers: FetchStatusMarker[], duplicateHooks?: DuplicateVersionHook[]): DoctorVerdict;
|
|
110
111
|
export declare function registerDoctorCommand(program: Command): void;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -18,7 +18,7 @@ import { formatSignInBadge } from '../lib/signin-badge.js';
|
|
|
18
18
|
import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
|
|
19
19
|
import { loadManifest, isStale } from '../lib/staleness/index.js';
|
|
20
20
|
import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
|
|
21
|
-
import { checkVersionHookWiring, registerHooksToSettings } from '../lib/hooks.js';
|
|
21
|
+
import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSettings } from '../lib/hooks.js';
|
|
22
22
|
import { isVersionIsolated } from '../lib/versions.js';
|
|
23
23
|
import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
|
|
24
24
|
import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
|
|
@@ -67,11 +67,11 @@ function printWrappedLine(prefix, text) {
|
|
|
67
67
|
for (const line of wrapLine(prefix, text))
|
|
68
68
|
console.log(chalk.gray(line));
|
|
69
69
|
}
|
|
70
|
-
function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers) {
|
|
70
|
+
function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks) {
|
|
71
71
|
// Triaged health banner FIRST, so a user running bare `agents doctor` sees what
|
|
72
72
|
// is unhealthy, why it matters, and the exact fix before scrolling the detail
|
|
73
73
|
// sections below. Same triage model as target mode, aggregated across versions.
|
|
74
|
-
const overviewHealth = computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers);
|
|
74
|
+
const overviewHealth = computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks);
|
|
75
75
|
console.log(chalk.bold('Health'));
|
|
76
76
|
renderHealthBlock(overviewHealth, {
|
|
77
77
|
healthySummary: syncRows.length
|
|
@@ -827,9 +827,26 @@ function renderHealthBlock(verdict, opts) {
|
|
|
827
827
|
* and orphan resources into severity-tagged findings. Agent-agnostic: every
|
|
828
828
|
* installed version is classified the same way. Pure, so it is unit-testable.
|
|
829
829
|
*/
|
|
830
|
-
export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers) {
|
|
830
|
+
export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks = []) {
|
|
831
831
|
const issues = [];
|
|
832
832
|
const pretty = (agent, version) => `${AGENT_NAMES[agent] || agent}@${version}`;
|
|
833
|
+
// critical/warning: same hook resource materialized in several version homes.
|
|
834
|
+
// Different content is more severe because a stale copy can disagree with
|
|
835
|
+
// the active gate; byte-identical copies are noise and duplicate runtime cost.
|
|
836
|
+
for (const finding of duplicateHooks) {
|
|
837
|
+
const versions = finding.copies.map((copy) => copy.version).join(', ');
|
|
838
|
+
const active = finding.authoritative.version;
|
|
839
|
+
const drift = finding.kind === 'drift';
|
|
840
|
+
issues.push({
|
|
841
|
+
severity: drift ? 'critical' : 'warning',
|
|
842
|
+
category: drift ? 'duplicate-hook-drift' : 'duplicate-hook',
|
|
843
|
+
subject: `${finding.agent}/${finding.name}`,
|
|
844
|
+
impact: `${drift ? 'different content' : 'identical content'} across versions ${versions}; ${active} is authoritative`,
|
|
845
|
+
fix: `agents sync ${finding.agent}@${active} --yes`,
|
|
846
|
+
text: `${finding.name} ${drift ? 'drift' : 'duplicated'} across ${versions}`,
|
|
847
|
+
color: drift ? 'red' : 'yellow',
|
|
848
|
+
});
|
|
849
|
+
}
|
|
833
850
|
// critical: unwired hooks / broken settings.json per version
|
|
834
851
|
for (const row of syncRows) {
|
|
835
852
|
const n = row.unwiredHooks ?? 0;
|
|
@@ -1406,6 +1423,7 @@ export function registerDoctorCommand(program) {
|
|
|
1406
1423
|
const orphanRows = countOrphans();
|
|
1407
1424
|
const hostClis = listCliStatus(cwd);
|
|
1408
1425
|
const repoBehindMarkers = readRepoBehindMarkers();
|
|
1426
|
+
const duplicateHooks = inspectDuplicateVersionHooks(cwd);
|
|
1409
1427
|
// Advisory login state per installed agent (file-based getAccountInfo,
|
|
1410
1428
|
// no home → the account-global/active credential). Best-effort: a probe
|
|
1411
1429
|
// failure just leaves that agent's badge as "logged out".
|
|
@@ -1433,7 +1451,8 @@ export function registerDoctorCommand(program) {
|
|
|
1433
1451
|
// Triaged overview health — severity/category/subject/impact/fix per
|
|
1434
1452
|
// finding, aggregated across versions. Additive; existing consumers
|
|
1435
1453
|
// reading `sync`/`orphans`/`repos` are unaffected.
|
|
1436
|
-
health: computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers),
|
|
1454
|
+
health: computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks),
|
|
1455
|
+
duplicateHooks,
|
|
1437
1456
|
// This host's harness inventory — installed resources per kind,
|
|
1438
1457
|
// installed version ids per agent, and `.agents`/`.system` repo
|
|
1439
1458
|
// state — so `agents doctor --devices` can compare presence across
|
|
@@ -1461,7 +1480,7 @@ export function registerDoctorCommand(program) {
|
|
|
1461
1480
|
}, null, 2));
|
|
1462
1481
|
return;
|
|
1463
1482
|
}
|
|
1464
|
-
renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers);
|
|
1483
|
+
renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks);
|
|
1465
1484
|
// Point at the interactive reconcile when anything is out of sync — the
|
|
1466
1485
|
// report shouldn't be a dead end. `agents status` runs the unified
|
|
1467
1486
|
// home-reading engine and offers to sync (opt-in, never auto-fires here).
|
package/dist/commands/menubar.js
CHANGED
|
@@ -65,6 +65,18 @@ export function registerMenubarCommands(program) {
|
|
|
65
65
|
console.log(` current version ${chalk.gray(s.currentVersion)}`);
|
|
66
66
|
console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
|
|
67
67
|
console.log(` disabled by user ${yn(s.disabledByUser)}`);
|
|
68
|
+
if (s.foreignInstances.length > 0) {
|
|
69
|
+
// RegisterEventHotKey is first-come, so the helper that registered the
|
|
70
|
+
// chord first owns Cmd-Shift-V/O. A process list cannot say which that
|
|
71
|
+
// was — only that a rival exists — so report the conflict, not a
|
|
72
|
+
// winner. The loser
|
|
73
|
+
// has no other symptom: its chords simply never fire.
|
|
74
|
+
const n = s.foreignInstances.length;
|
|
75
|
+
console.log(chalk.yellow(`\n ${n} other helper process${n === 1 ? '' : 'es'} running — ${n === 1 ? 'it' : 'they'} may hold Cmd-Shift-V/O instead of the installed one:`));
|
|
76
|
+
for (const p of s.foreignInstances)
|
|
77
|
+
console.log(chalk.gray(` ${p.pid} ${p.executable}`));
|
|
78
|
+
console.log(chalk.gray(' End it, then `agents menubar enable` to restart the installed helper.'));
|
|
79
|
+
}
|
|
68
80
|
if (s.stale) {
|
|
69
81
|
console.log(chalk.yellow('\n Installed helper is stale — runs on next `agents` startup, or `agents menubar enable` now.'));
|
|
70
82
|
}
|
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
* Also exposes scheduler lifecycle controls (start/stop/status/logs).
|
|
7
7
|
*/
|
|
8
8
|
import type { Command } from 'commander';
|
|
9
|
-
import type { RunMeta } from '../lib/routines.js';
|
|
9
|
+
import type { JobConfig, RunMeta } from '../lib/routines.js';
|
|
10
|
+
import type { DeviceRegistry } from '../lib/devices/registry.js';
|
|
10
11
|
/**
|
|
11
12
|
* Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
|
|
12
13
|
* when it hasn't completed or timestamps are unparseable. Leading separator lets
|
|
13
14
|
* callers drop it straight into a status line.
|
|
14
15
|
*/
|
|
15
16
|
export declare function formatRunDuration(startedAt: string, completedAt: string | null): string;
|
|
17
|
+
export interface RoutineListGroup {
|
|
18
|
+
key: string;
|
|
19
|
+
title: string;
|
|
20
|
+
jobs: JobConfig[];
|
|
21
|
+
}
|
|
22
|
+
export declare function groupRoutineJobsByDevice(jobs: JobConfig[], registry: DeviceRegistry, self?: string): RoutineListGroup[];
|
|
16
23
|
export declare function buildRunsJson(runs: RunMeta[]): Record<string, unknown>[];
|
|
17
24
|
/** Register the `agents routines` command tree. */
|
|
18
25
|
export declare function registerRoutinesCommands(program: Command): void;
|
|
@@ -13,7 +13,7 @@ import * as yaml from 'yaml';
|
|
|
13
13
|
import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
|
|
14
14
|
import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
|
|
15
15
|
import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
|
|
16
|
-
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
|
|
16
|
+
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
|
|
17
17
|
import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
|
|
18
18
|
import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
|
|
19
19
|
import { getRoutinesDir } from '../lib/state.js';
|
|
@@ -24,7 +24,7 @@ import { JobScheduler } from '../lib/scheduler.js';
|
|
|
24
24
|
import { detectOverdueJobs } from '../lib/overdue.js';
|
|
25
25
|
import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
|
|
26
26
|
import { setHelpSections } from '../lib/help.js';
|
|
27
|
-
import { loadDevices } from '../lib/devices/registry.js';
|
|
27
|
+
import { loadDevices, loadDevicesSync } from '../lib/devices/registry.js';
|
|
28
28
|
import { machineId, normalizeHost } from '../lib/machine-id.js';
|
|
29
29
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
30
30
|
/**
|
|
@@ -75,6 +75,164 @@ function fireConditionLabel(job) {
|
|
|
75
75
|
}
|
|
76
76
|
return '-';
|
|
77
77
|
}
|
|
78
|
+
function scheduleLabel(job) {
|
|
79
|
+
let label = fireConditionLabel(job);
|
|
80
|
+
if (isOneShotRoutine(job))
|
|
81
|
+
label = `${label} (one-shot)`;
|
|
82
|
+
if (job.endAt) {
|
|
83
|
+
const end = new Date(job.endAt);
|
|
84
|
+
const endLabel = Number.isFinite(end.getTime())
|
|
85
|
+
? end.toLocaleDateString()
|
|
86
|
+
: job.endAt;
|
|
87
|
+
label = `${label} (until ${endLabel})`;
|
|
88
|
+
}
|
|
89
|
+
return label;
|
|
90
|
+
}
|
|
91
|
+
function nextRunForDisplay(job, scheduler) {
|
|
92
|
+
if (isPastOneShotRoutine(job))
|
|
93
|
+
return null;
|
|
94
|
+
return scheduler.getNextRun(job.name);
|
|
95
|
+
}
|
|
96
|
+
function nextRunLabel(job, scheduler, now) {
|
|
97
|
+
if (isPastOneShotRoutine(job))
|
|
98
|
+
return 'expired';
|
|
99
|
+
return humanizeNextRun(scheduler.getNextRun(job.name) ?? null, now, job.timezone);
|
|
100
|
+
}
|
|
101
|
+
function deviceStateLabel(name, registry) {
|
|
102
|
+
const profile = registry[normalizeHost(name)] ?? registry[name];
|
|
103
|
+
if (!profile)
|
|
104
|
+
return 'unknown';
|
|
105
|
+
if (profile.reachability?.reachable === false)
|
|
106
|
+
return 'offline';
|
|
107
|
+
if (profile.reachability?.reachable === true)
|
|
108
|
+
return 'online';
|
|
109
|
+
if (profile.tailscale?.online === false)
|
|
110
|
+
return 'offline';
|
|
111
|
+
if (profile.tailscale?.online === true)
|
|
112
|
+
return 'online';
|
|
113
|
+
return 'unknown';
|
|
114
|
+
}
|
|
115
|
+
function titleWithDeviceState(title, name, registry) {
|
|
116
|
+
const state = deviceStateLabel(name, registry);
|
|
117
|
+
return state && state !== 'online' ? `${title} (${state})` : title;
|
|
118
|
+
}
|
|
119
|
+
function placementTag(job) {
|
|
120
|
+
const strategy = resolveHostStrategy(job);
|
|
121
|
+
if (strategy === 'local')
|
|
122
|
+
return job.host ? `->${job.host}` : '';
|
|
123
|
+
if (strategy === 'host')
|
|
124
|
+
return `->${job.host ?? '?'}`;
|
|
125
|
+
if (strategy === 'fleet')
|
|
126
|
+
return '->fleet';
|
|
127
|
+
return '->cloud';
|
|
128
|
+
}
|
|
129
|
+
function deviceLabel(job, width) {
|
|
130
|
+
const full = [job.devices?.join(',') ?? '', placementTag(job)]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(' ');
|
|
133
|
+
const raw = full.length === 0 ? 'all' : full;
|
|
134
|
+
const display = width && raw.length > width
|
|
135
|
+
? raw.slice(0, width - 1) + '…'
|
|
136
|
+
: raw;
|
|
137
|
+
return { raw, display, dim: full.length === 0 || !jobRunsOnThisDevice(job) };
|
|
138
|
+
}
|
|
139
|
+
export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
|
|
140
|
+
const groups = new Map();
|
|
141
|
+
const add = (key, title, job) => {
|
|
142
|
+
const existing = groups.get(key);
|
|
143
|
+
if (existing) {
|
|
144
|
+
existing.jobs.push(job);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
groups.set(key, { key, title, jobs: [job] });
|
|
148
|
+
};
|
|
149
|
+
for (const job of jobs) {
|
|
150
|
+
const strategy = resolveHostStrategy(job);
|
|
151
|
+
if (strategy === 'cloud') {
|
|
152
|
+
add('cloud', 'Cloud', job);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (strategy === 'fleet') {
|
|
156
|
+
add('fleet', 'Fleet-wide', job);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (strategy === 'host') {
|
|
160
|
+
const host = job.host ?? 'unknown-host';
|
|
161
|
+
add(`host:${normalizeHost(host)}`, titleWithDeviceState(`Host: ${host}`, host, registry), job);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const devices = job.devices ?? [];
|
|
165
|
+
if (devices.length === 0) {
|
|
166
|
+
add('fleet', 'Fleet-wide', job);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const device of devices) {
|
|
170
|
+
const normalized = normalizeHost(device);
|
|
171
|
+
if (normalized === self) {
|
|
172
|
+
add('this-machine', `This machine (${self})`, job);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
add(`device:${normalized}`, titleWithDeviceState(`Device: ${normalized}`, normalized, registry), job);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const order = (group) => {
|
|
180
|
+
if (group.key === 'this-machine')
|
|
181
|
+
return 0;
|
|
182
|
+
if (group.key === 'fleet')
|
|
183
|
+
return 1;
|
|
184
|
+
if (group.key === 'cloud')
|
|
185
|
+
return 2;
|
|
186
|
+
if (group.key.startsWith('device:'))
|
|
187
|
+
return 3;
|
|
188
|
+
if (group.key.startsWith('host:'))
|
|
189
|
+
return 4;
|
|
190
|
+
return 5;
|
|
191
|
+
};
|
|
192
|
+
return [...groups.values()].sort((a, b) => order(a) - order(b) || a.title.localeCompare(b.title));
|
|
193
|
+
}
|
|
194
|
+
function renderRoutineRows({ jobs, scheduler, overdueSet, link, now }) {
|
|
195
|
+
const NAME_W = 24;
|
|
196
|
+
const AGENT_W = 10;
|
|
197
|
+
const REPO_W = REPO_DISPLAY_MAX;
|
|
198
|
+
const DEVICE_W = 22;
|
|
199
|
+
const SCHED_W = 34;
|
|
200
|
+
const ENABLED_W = 10;
|
|
201
|
+
const NEXT_W = 22;
|
|
202
|
+
const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'Devices'.padEnd(DEVICE_W)} ${'Schedule'.padEnd(SCHED_W)} ${'Enabled'.padEnd(ENABLED_W)} ${'Next Run'.padEnd(NEXT_W)} Last Status`;
|
|
203
|
+
console.log(chalk.gray(header));
|
|
204
|
+
console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + DEVICE_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
|
|
205
|
+
for (const job of jobs) {
|
|
206
|
+
const nextStr = nextRunLabel(job, scheduler, now);
|
|
207
|
+
const schedStr = scheduleLabel(job);
|
|
208
|
+
const latestRun = getLatestRun(job.name);
|
|
209
|
+
const lastStatus = latestRun?.status || '-';
|
|
210
|
+
const sourceRepo = job.source?.repo ?? job.repo;
|
|
211
|
+
const sourceLabel = sourceRepo
|
|
212
|
+
? (job.source?.branch ? `${sourceRepo}@${job.source.branch}` : sourceRepo)
|
|
213
|
+
: null;
|
|
214
|
+
const repoInfo = formatRepoLink(sourceLabel ?? job.repo);
|
|
215
|
+
const repoCell = link(repoInfo.display, repoInfo.href);
|
|
216
|
+
const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
|
|
217
|
+
const enabledStr = job.enabled ? chalk.green('yes') : chalk.gray('no');
|
|
218
|
+
const enabledWord = job.enabled ? 'yes' : 'no';
|
|
219
|
+
const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
|
|
220
|
+
const device = deviceLabel(job, DEVICE_W);
|
|
221
|
+
const deviceCell = device.dim ? chalk.gray(device.display) : device.display;
|
|
222
|
+
const devicePad = Math.max(0, DEVICE_W - device.display.length);
|
|
223
|
+
const statusColor = lastStatus === 'completed' ? chalk.green
|
|
224
|
+
: lastStatus === 'failed' ? chalk.red
|
|
225
|
+
: lastStatus === 'timeout' ? chalk.yellow
|
|
226
|
+
: chalk.gray;
|
|
227
|
+
const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
|
|
228
|
+
const agentLabelPadded = job.command
|
|
229
|
+
? chalk.magenta('command'.padEnd(10))
|
|
230
|
+
: job.workflow
|
|
231
|
+
? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
|
|
232
|
+
: (job.agent || '').padEnd(10);
|
|
233
|
+
console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
78
236
|
function parseRoutineTrigger(options) {
|
|
79
237
|
const raw = typeof options.on === 'string' ? options.on : undefined;
|
|
80
238
|
if (!raw)
|
|
@@ -308,7 +466,13 @@ export function registerRoutinesCommands(program) {
|
|
|
308
466
|
.command('list')
|
|
309
467
|
.description('See all scheduled jobs, when they run next, and their last execution status')
|
|
310
468
|
.option('--json', 'Emit machine-readable JSON instead of the table (used by the menu bar helper)')
|
|
469
|
+
.option('--group-by <field>', 'Group table output by device (default for terminal output)')
|
|
470
|
+
.option('--flat', 'Print the legacy flat table instead of grouped sections')
|
|
311
471
|
.action((options) => {
|
|
472
|
+
if (options.groupBy && options.groupBy !== 'device') {
|
|
473
|
+
console.error(chalk.red(`Unsupported --group-by '${options.groupBy}'. Use: device`));
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
312
476
|
try {
|
|
313
477
|
monitorRunningJobs();
|
|
314
478
|
}
|
|
@@ -339,7 +503,6 @@ export function registerRoutinesCommands(program) {
|
|
|
339
503
|
if (options.json) {
|
|
340
504
|
const nowJson = new Date();
|
|
341
505
|
const payload = jobs.map((job) => {
|
|
342
|
-
const nextRun = scheduler.getNextRun(job.name);
|
|
343
506
|
const latestRun = getLatestRun(job.name);
|
|
344
507
|
return {
|
|
345
508
|
name: job.name,
|
|
@@ -357,11 +520,14 @@ export function registerRoutinesCommands(program) {
|
|
|
357
520
|
source: job.source ?? null,
|
|
358
521
|
sourceRepo: job.source?.repo ?? job.repo ?? null,
|
|
359
522
|
sourceBranch: job.source?.branch ?? null,
|
|
523
|
+
runOnce: Boolean(job.runOnce),
|
|
524
|
+
oneShot: isOneShotRoutine(job),
|
|
525
|
+
expired: isPastOneShotRoutine(job, nowJson),
|
|
360
526
|
runsHere: jobRunsOnThisDevice(job),
|
|
361
527
|
enabled: job.enabled,
|
|
362
528
|
overdue: overdueSet.has(job.name),
|
|
363
|
-
nextRun:
|
|
364
|
-
nextRunHuman:
|
|
529
|
+
nextRun: nextRunForDisplay(job, scheduler)?.toISOString() ?? null,
|
|
530
|
+
nextRunHuman: nextRunLabel(job, scheduler, nowJson),
|
|
365
531
|
lastStatus: latestRun?.status ?? null,
|
|
366
532
|
exitCode: latestRun?.exitCode ?? null,
|
|
367
533
|
failureReason: latestRun?.errorMessage ?? null,
|
|
@@ -379,76 +545,21 @@ export function registerRoutinesCommands(program) {
|
|
|
379
545
|
// contains raw ESC ] 8 ;; ... BEL escape sequences.
|
|
380
546
|
const link = (label, url) => url && process.stdout.isTTY ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
|
|
381
547
|
const now = new Date();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const end = new Date(job.endAt);
|
|
398
|
-
const endLabel = Number.isFinite(end.getTime())
|
|
399
|
-
? end.toLocaleDateString()
|
|
400
|
-
: job.endAt;
|
|
401
|
-
schedStr = `${schedStr} (until ${endLabel})`;
|
|
402
|
-
}
|
|
403
|
-
const latestRun = getLatestRun(job.name);
|
|
404
|
-
const lastStatus = latestRun?.status || '-';
|
|
405
|
-
// Prefer project-source repo (with optional @branch) over bare job.repo.
|
|
406
|
-
const sourceRepo = job.source?.repo ?? job.repo;
|
|
407
|
-
const sourceLabel = sourceRepo
|
|
408
|
-
? (job.source?.branch ? `${sourceRepo}@${job.source.branch}` : sourceRepo)
|
|
409
|
-
: null;
|
|
410
|
-
const repoInfo = formatRepoLink(sourceLabel ?? job.repo);
|
|
411
|
-
const repoCell = link(repoInfo.display, repoInfo.href);
|
|
412
|
-
// Pad based on the display string, not the raw cell (which may include escape codes).
|
|
413
|
-
const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
|
|
414
|
-
const enabledStr = job.enabled ? chalk.green('yes') : chalk.gray('no');
|
|
415
|
-
// chalk adds escape codes; pad the raw word and let chalk wrap it.
|
|
416
|
-
const enabledWord = job.enabled ? 'yes' : 'no';
|
|
417
|
-
const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
|
|
418
|
-
// Placement rides in the Devices cell: eligibility →execution strategy.
|
|
419
|
-
const strategy = resolveHostStrategy(job);
|
|
420
|
-
const placementTag = strategy === 'local'
|
|
421
|
-
? (job.host ? `→${job.host}` : '')
|
|
422
|
-
: strategy === 'host'
|
|
423
|
-
? `→${job.host ?? '?'}`
|
|
424
|
-
: strategy === 'fleet'
|
|
425
|
-
? '→fleet'
|
|
426
|
-
: '→cloud';
|
|
427
|
-
const deviceFull = [job.devices?.join(',') ?? '', placementTag]
|
|
428
|
-
.filter(Boolean)
|
|
429
|
-
.join(' ');
|
|
430
|
-
const deviceWord = deviceFull.length === 0
|
|
431
|
-
? 'all'
|
|
432
|
-
: deviceFull.length > DEVICE_W
|
|
433
|
-
? deviceFull.slice(0, DEVICE_W - 1) + '…'
|
|
434
|
-
: deviceFull;
|
|
435
|
-
const deviceCell = deviceFull.length === 0
|
|
436
|
-
? chalk.gray('all')
|
|
437
|
-
: jobRunsOnThisDevice(job)
|
|
438
|
-
? deviceWord
|
|
439
|
-
: chalk.gray(deviceWord);
|
|
440
|
-
const devicePad = Math.max(0, DEVICE_W - deviceWord.length);
|
|
441
|
-
const statusColor = lastStatus === 'completed' ? chalk.green
|
|
442
|
-
: lastStatus === 'failed' ? chalk.red
|
|
443
|
-
: lastStatus === 'timeout' ? chalk.yellow
|
|
444
|
-
: chalk.gray;
|
|
445
|
-
const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
|
|
446
|
-
const agentLabelPadded = job.command
|
|
447
|
-
? chalk.magenta('command'.padEnd(10))
|
|
448
|
-
: job.workflow
|
|
449
|
-
? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
|
|
450
|
-
: (job.agent || '').padEnd(10);
|
|
451
|
-
console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
|
|
548
|
+
if (options.flat) {
|
|
549
|
+
renderRoutineRows({ jobs, scheduler, overdueSet, link, now });
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
let registry = {};
|
|
553
|
+
try {
|
|
554
|
+
registry = loadDevicesSync();
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
console.error(chalk.yellow(`Could not read device registry: ${err.message}`));
|
|
558
|
+
}
|
|
559
|
+
for (const group of groupRoutineJobsByDevice(jobs, registry)) {
|
|
560
|
+
console.log(chalk.bold(`\n${group.title}`));
|
|
561
|
+
renderRoutineRows({ jobs: group.jobs, scheduler, overdueSet, link, now });
|
|
562
|
+
}
|
|
452
563
|
}
|
|
453
564
|
if (overdueSet.size > 0) {
|
|
454
565
|
console.log();
|
|
@@ -520,6 +631,10 @@ export function registerRoutinesCommands(program) {
|
|
|
520
631
|
schedule = parsed.schedule;
|
|
521
632
|
runOnce = parsed.runOnce;
|
|
522
633
|
}
|
|
634
|
+
if (!options.at && isOneShotLikeSchedule(schedule)) {
|
|
635
|
+
runOnce = true;
|
|
636
|
+
console.error(chalk.yellow(`Schedule "${schedule}" pins minute, hour, day, and month; treating it as one-shot. Prefer --at for one-time routines.`));
|
|
637
|
+
}
|
|
523
638
|
if (!schedule && !trigger) {
|
|
524
639
|
console.error(chalk.red('Schedule or trigger is required (use --schedule, --at, or --on)'));
|
|
525
640
|
process.exit(1);
|
|
@@ -661,6 +776,10 @@ export function registerRoutinesCommands(program) {
|
|
|
661
776
|
enabled: true,
|
|
662
777
|
...parsed,
|
|
663
778
|
};
|
|
779
|
+
if (isOneShotLikeSchedule(config.schedule)) {
|
|
780
|
+
config.runOnce = true;
|
|
781
|
+
console.error(chalk.yellow(`Schedule "${config.schedule}" pins minute, hour, day, and month; treating it as one-shot. Prefer --at for one-time routines.`));
|
|
782
|
+
}
|
|
664
783
|
// Same duplicate-fire guard as --placement/--run-on: off-box placement
|
|
665
784
|
// with no eligibility pin would fire from every daemon in the fleet.
|
|
666
785
|
const fileStrategy = resolveHostStrategy(config);
|
|
@@ -688,6 +807,41 @@ export function registerRoutinesCommands(program) {
|
|
|
688
807
|
ensureSchedulerRunning();
|
|
689
808
|
}
|
|
690
809
|
});
|
|
810
|
+
routinesCmd
|
|
811
|
+
.command('cleanup')
|
|
812
|
+
.description('Remove expired one-shot routines that already fired and still have a user-layer YAML file.')
|
|
813
|
+
.option('--dry-run', 'Show routines that would be removed without deleting files')
|
|
814
|
+
.action((options) => {
|
|
815
|
+
const jobs = listAllJobs()
|
|
816
|
+
.filter((job) => getJobPath(job.name) !== null)
|
|
817
|
+
.filter((job) => hasCompletedOneShotRun(job));
|
|
818
|
+
if (jobs.length === 0) {
|
|
819
|
+
console.log(chalk.gray('No completed expired one-shot routines to clean up.'));
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (options.dryRun) {
|
|
823
|
+
console.log(chalk.bold('Expired one-shot routines eligible for cleanup\n'));
|
|
824
|
+
for (const job of jobs) {
|
|
825
|
+
console.log(` ${chalk.cyan(job.name)} ${chalk.gray(scheduleLabel(job))}`);
|
|
826
|
+
}
|
|
827
|
+
console.log(chalk.gray(`\nDry run. Remove with: agents routines cleanup`));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
let removed = 0;
|
|
831
|
+
for (const job of jobs) {
|
|
832
|
+
if (deleteJob(job.name)) {
|
|
833
|
+
removed++;
|
|
834
|
+
console.log(chalk.green(`Removed ${job.name}`));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
console.log(chalk.gray(`Cleaned up ${removed} expired one-shot routine(s).`));
|
|
838
|
+
try {
|
|
839
|
+
signalDaemonReload();
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// The daemon may not be running; the next start will read the cleaned directory.
|
|
843
|
+
}
|
|
844
|
+
});
|
|
691
845
|
routinesCmd
|
|
692
846
|
.command('remove [name]')
|
|
693
847
|
.description('Delete a routine. Stops scheduling future runs; past execution logs remain on disk.')
|