@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 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` marks each session's `presence` -- `attached` (you're watching it), `background` (running headless), or `parked` (its background run finished) -- so the menu bar and Factory show where every agent is. In the Factory extension, **Agents: Detach** (`Cmd/Ctrl+K B`) and **Agents: Attach** (`Cmd/Ctrl+K A`) do the same over the focused terminal.
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;
@@ -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).
@@ -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: nextRun ? nextRun.toISOString() : null,
364
- nextRunHuman: humanizeNextRun(nextRun ?? null, nowJson, job.timezone),
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
- const NAME_W = 24;
383
- const AGENT_W = 10;
384
- const REPO_W = REPO_DISPLAY_MAX;
385
- const DEVICE_W = 22;
386
- const SCHED_W = 22;
387
- const ENABLED_W = 10;
388
- const NEXT_W = 22;
389
- 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`;
390
- console.log(chalk.gray(header));
391
- console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + DEVICE_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
392
- for (const job of jobs) {
393
- const nextRun = scheduler.getNextRun(job.name);
394
- const nextStr = humanizeNextRun(nextRun ?? null, now, job.timezone);
395
- let schedStr = fireConditionLabel(job);
396
- if (job.endAt) {
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.')