@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.
@@ -347,6 +347,19 @@ export declare function validateJob(config: Partial<JobConfig>): string[];
347
347
  export declare function validateTrigger(trigger: unknown): string[];
348
348
  /** True when a job's endAt has already elapsed. False when endAt is unset or in the future. */
349
349
  export declare function isPastEndAt(config: Pick<JobConfig, 'endAt'>, now?: Date): boolean;
350
+ export interface OneShotScheduleParts {
351
+ minute: number;
352
+ hour: number;
353
+ day: number;
354
+ month: number;
355
+ }
356
+ export declare function parseOneShotLikeSchedule(schedule: string | undefined | null): OneShotScheduleParts | null;
357
+ export declare function isOneShotLikeSchedule(schedule: string | undefined | null): boolean;
358
+ export declare function isOneShotRoutine(config: Pick<JobConfig, 'schedule' | 'runOnce'>): boolean;
359
+ export declare function oneShotScheduleFireDate(schedule: string | undefined | null, now?: Date, timezone?: string): Date | null;
360
+ export declare function isPastOneShotRoutine(config: Pick<JobConfig, 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
361
+ export declare function hasCompletedOneShotRun(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
362
+ export declare function shouldPurgeCompletedOneShotRoutine(config: Pick<JobConfig, 'name' | 'schedule' | 'runOnce' | 'timezone'>, now?: Date): boolean;
350
363
  /** Expand built-in and user-defined template variables in a job's prompt string. */
351
364
  export declare function resolveJobPrompt(config: JobConfig): string;
352
365
  /** Parse a human-readable timeout string (e.g. "10m", "2h", "1h30m", "3d", "1w") into milliseconds.
@@ -586,6 +586,122 @@ export function isPastEndAt(config, now = new Date()) {
586
586
  return false;
587
587
  return now.getTime() >= end;
588
588
  }
589
+ export function parseOneShotLikeSchedule(schedule) {
590
+ if (!schedule)
591
+ return null;
592
+ const parts = schedule.trim().split(/\s+/);
593
+ if (parts.length !== 5)
594
+ return null;
595
+ const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = parts;
596
+ if (weekdayRaw !== '*')
597
+ return null;
598
+ if (![minuteRaw, hourRaw, dayRaw, monthRaw].every((p) => /^\d+$/.test(p)))
599
+ return null;
600
+ const minute = parseInt(minuteRaw, 10);
601
+ const hour = parseInt(hourRaw, 10);
602
+ const day = parseInt(dayRaw, 10);
603
+ const month = parseInt(monthRaw, 10);
604
+ if (minute < 0 || minute > 59)
605
+ return null;
606
+ if (hour < 0 || hour > 23)
607
+ return null;
608
+ if (month < 1 || month > 12)
609
+ return null;
610
+ if (day < 1 || day > 31)
611
+ return null;
612
+ return { minute, hour, day, month };
613
+ }
614
+ export function isOneShotLikeSchedule(schedule) {
615
+ return parseOneShotLikeSchedule(schedule) !== null;
616
+ }
617
+ export function isOneShotRoutine(config) {
618
+ return Boolean(config.runOnce || isOneShotLikeSchedule(config.schedule));
619
+ }
620
+ function zonedParts(date, timezone) {
621
+ const parts = new Intl.DateTimeFormat('en-US', {
622
+ timeZone: timezone,
623
+ year: 'numeric',
624
+ month: 'numeric',
625
+ day: 'numeric',
626
+ hour: 'numeric',
627
+ minute: 'numeric',
628
+ hour12: false,
629
+ hourCycle: 'h23',
630
+ }).formatToParts(date);
631
+ const get = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
632
+ return {
633
+ year: get('year'),
634
+ month: get('month'),
635
+ day: get('day'),
636
+ hour: get('hour'),
637
+ minute: get('minute'),
638
+ };
639
+ }
640
+ function zonedDateToUtc(year, parts, timezone) {
641
+ const targetUtc = Date.UTC(year, parts.month - 1, parts.day, parts.hour, parts.minute, 0, 0);
642
+ let candidate = new Date(targetUtc);
643
+ for (let i = 0; i < 4; i++) {
644
+ const actual = zonedParts(candidate, timezone);
645
+ const actualUtc = Date.UTC(actual.year, actual.month - 1, actual.day, actual.hour, actual.minute, 0, 0);
646
+ const delta = actualUtc - targetUtc;
647
+ if (delta === 0)
648
+ break;
649
+ candidate = new Date(candidate.getTime() - delta);
650
+ }
651
+ const verify = zonedParts(candidate, timezone);
652
+ if (verify.year !== year ||
653
+ verify.month !== parts.month ||
654
+ verify.day !== parts.day ||
655
+ verify.hour !== parts.hour ||
656
+ verify.minute !== parts.minute) {
657
+ return null;
658
+ }
659
+ return candidate;
660
+ }
661
+ export function oneShotScheduleFireDate(schedule, now = new Date(), timezone) {
662
+ const parts = parseOneShotLikeSchedule(schedule);
663
+ if (!parts)
664
+ return null;
665
+ if (timezone) {
666
+ try {
667
+ const year = zonedParts(now, timezone).year;
668
+ return zonedDateToUtc(year, parts, timezone);
669
+ }
670
+ catch {
671
+ return null;
672
+ }
673
+ }
674
+ const fireAt = new Date(now.getFullYear(), parts.month - 1, parts.day, parts.hour, parts.minute, 0, 0);
675
+ if (fireAt.getFullYear() !== now.getFullYear() ||
676
+ fireAt.getMonth() !== parts.month - 1 ||
677
+ fireAt.getDate() !== parts.day ||
678
+ fireAt.getHours() !== parts.hour ||
679
+ fireAt.getMinutes() !== parts.minute) {
680
+ return null;
681
+ }
682
+ return fireAt;
683
+ }
684
+ export function isPastOneShotRoutine(config, now = new Date()) {
685
+ if (!isOneShotRoutine(config))
686
+ return false;
687
+ const fireAt = oneShotScheduleFireDate(config.schedule, now, config.timezone);
688
+ return Boolean(fireAt && now.getTime() >= fireAt.getTime());
689
+ }
690
+ export function hasCompletedOneShotRun(config, now = new Date()) {
691
+ if (!isPastOneShotRoutine(config, now))
692
+ return false;
693
+ const fireAt = oneShotScheduleFireDate(config.schedule, now, config.timezone);
694
+ if (!fireAt)
695
+ return false;
696
+ const latest = getLatestRun(config.name);
697
+ if (!latest || latest.status === 'running')
698
+ return false;
699
+ const startedAt = Date.parse(latest.startedAt);
700
+ return Number.isFinite(startedAt) && startedAt >= fireAt.getTime() - 60_000;
701
+ }
702
+ export function shouldPurgeCompletedOneShotRoutine(config, now = new Date()) {
703
+ return hasCompletedOneShotRun(config, now);
704
+ }
589
705
  /** Expand built-in and user-defined template variables in a job's prompt string. */
590
706
  export function resolveJobPrompt(config) {
591
707
  const now = new Date();
@@ -6,7 +6,7 @@
6
6
  * on startup and reloads them on SIGHUP.
7
7
  */
8
8
  import { Cron } from 'croner';
9
- import { listJobs, deleteJob, isPastEndAt, setJobEnabled, jobRunsOnThisDevice } from './routines.js';
9
+ import { listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, } from './routines.js';
10
10
  /** In-memory cron scheduler that triggers a callback when jobs fire. */
11
11
  export class JobScheduler {
12
12
  jobs = new Map();
@@ -20,9 +20,15 @@ export class JobScheduler {
20
20
  // Trigger-only jobs (no cron schedule) fire via the webhook receiver,
21
21
  // not the cron loop — skip them here. Jobs pinned to another device
22
22
  // (routines are fleet-synced) never enter this machine's cron loop.
23
- if (config.enabled && config.schedule && jobRunsOnThisDevice(config)) {
24
- this.schedule(config);
23
+ if (!config.enabled || !config.schedule || !jobRunsOnThisDevice(config))
24
+ continue;
25
+ if (shouldPurgeCompletedOneShotRoutine(config)) {
26
+ deleteJob(config.name);
27
+ continue;
25
28
  }
29
+ if (isPastOneShotRoutine(config))
30
+ continue;
31
+ this.schedule(config);
26
32
  }
27
33
  }
28
34
  schedule(config) {
@@ -30,6 +36,12 @@ export class JobScheduler {
30
36
  if (!config.schedule)
31
37
  return;
32
38
  this.unschedule(config.name);
39
+ if (shouldPurgeCompletedOneShotRoutine(config)) {
40
+ deleteJob(config.name);
41
+ return;
42
+ }
43
+ if (isPastOneShotRoutine(config))
44
+ return;
33
45
  // catch: true — a throw from one job's callback should not kill the
34
46
  // whole cron loop. Each invocation of onTrigger is already wrapped in
35
47
  // try/catch, but a synchronous throw before the await would otherwise
@@ -59,7 +71,7 @@ export class JobScheduler {
59
71
  console.error(`Job '${config.name}' failed:`, err.message);
60
72
  }
61
73
  // One-shot jobs: remove after first execution
62
- if (config.runOnce) {
74
+ if (isOneShotRoutine(config)) {
63
75
  this.unschedule(config.name);
64
76
  deleteJob(config.name);
65
77
  }
@@ -147,6 +147,17 @@ export interface ActiveSession {
147
147
  */
148
148
  machine?: string;
149
149
  teamName?: string;
150
+ /**
151
+ * For a teams teammate: the session id of the ORCHESTRATOR that spawned the
152
+ * team (the agent that ran `agents teams add`, captured from AGENTS_SESSION_ID
153
+ * at spawn). Lets the listing answer "which session spun up this team" and
154
+ * group teammates under their orchestrator. Distinct from `sessionId`, which is
155
+ * the teammate's OWN transcript.
156
+ */
157
+ orchestratorSessionId?: string;
158
+ /** Display label for the orchestrator (its topic/label), resolved when the
159
+ * orchestrator is itself present in the active set. Display-only. */
160
+ orchestratorLabel?: string;
150
161
  agentId?: string;
151
162
  cloudProvider?: string;
152
163
  cloudTaskId?: string;
@@ -445,6 +456,13 @@ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
445
456
  * terminal/headless row for the same session id.
446
457
  */
447
458
  export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
459
+ /**
460
+ * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
461
+ * when that orchestrator session is itself in the active set (it usually is — the
462
+ * agent that ran `agents teams add` is running). Falls back to nothing, so the
463
+ * renderer shows the short id. Pure over the array; exported for tests.
464
+ */
465
+ export declare function annotateOrchestratorLabels(sessions: ActiveSession[]): void;
448
466
  /**
449
467
  * Match an SSH client IP to a registered device (pure — testable with a plain
450
468
  * registry object). Returns the device name + ssh login user when the IP is a
@@ -581,16 +581,24 @@ export async function listTeamsActive() {
581
581
  const mgr = new AgentManager();
582
582
  const running = await mgr.listRunning();
583
583
  return running.map((a) => {
584
- const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
585
- const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
584
+ // The teammate's OWN transcript is `remoteSessionId` (captured from its first
585
+ // stream event). `parentSessionId` is the ORCHESTRATOR that spawned the team
586
+ // (AGENTS_SESSION_ID at spawn) — a link, not this teammate's id. Keying the
587
+ // row off the orchestrator conflated the two (a teammate showed the
588
+ // orchestrator's id/topic and lineage was invisible); resolve the teammate's
589
+ // own session for the row and expose the orchestrator separately.
590
+ const ownSessionId = a.remoteSessionId ?? undefined;
591
+ const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, ownSessionId);
586
592
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
587
593
  const pidAlive = a.pid ? isPidAlive(a.pid) : true;
588
594
  const { state, tokPerSec } = computeLiveSignals(a.agentType, sessionFile, a.cwd ?? undefined, pidAlive);
595
+ const resolvedId = ownSessionId ?? sessionIdFromFile(sessionFile);
589
596
  return applyState({
590
597
  context: 'teams',
591
598
  kind: a.agentType,
592
599
  pid: a.pid ?? undefined,
593
- sessionId: sessionId ?? sessionIdFromFile(sessionFile),
600
+ sessionId: resolvedId,
601
+ orchestratorSessionId: a.parentSessionId ?? undefined,
594
602
  cwd: a.cwd ?? undefined,
595
603
  label: a.name ?? undefined,
596
604
  topic,
@@ -603,7 +611,7 @@ export async function listTeamsActive() {
603
611
  // The frozen actor stamped on the teammate record (RUSH-2028) — who ran
604
612
  // this teammate, surfaced as the owner in --active (RUSH-2018); sidecar
605
613
  // fallback for a teammate record predating the actor field.
606
- owner: resolveOwner(a.actor, sessionId ?? sessionIdFromFile(sessionFile)),
614
+ owner: resolveOwner(a.actor, resolvedId),
607
615
  }, state, sessionFile, pidAlive);
608
616
  });
609
617
  }
@@ -1287,8 +1295,28 @@ export async function getActiveSessions(opts = {}) {
1287
1295
  await enrichProvenance(merged);
1288
1296
  await resolveOrigins(merged);
1289
1297
  foldPresence(merged);
1298
+ annotateOrchestratorLabels(merged);
1290
1299
  return merged;
1291
1300
  }
1301
+ /**
1302
+ * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
1303
+ * when that orchestrator session is itself in the active set (it usually is — the
1304
+ * agent that ran `agents teams add` is running). Falls back to nothing, so the
1305
+ * renderer shows the short id. Pure over the array; exported for tests.
1306
+ */
1307
+ export function annotateOrchestratorLabels(sessions) {
1308
+ const byId = new Map();
1309
+ for (const s of sessions)
1310
+ if (s.sessionId)
1311
+ byId.set(s.sessionId, s);
1312
+ for (const s of sessions) {
1313
+ if (!s.orchestratorSessionId)
1314
+ continue;
1315
+ const orch = byId.get(s.orchestratorSessionId);
1316
+ if (orch)
1317
+ s.orchestratorLabel = orch.label || orch.topic || undefined;
1318
+ }
1319
+ }
1292
1320
  /**
1293
1321
  * Fold detach/attach presence onto each row from the detach store. A stored
1294
1322
  * record wins (`background`/`parked`); otherwise a live terminal session is
package/dist/lib/state.js CHANGED
@@ -619,6 +619,62 @@ function writeIfChanged(filePath, content) {
619
619
  * All callers funnel through writeMeta → here, so nothing else changes. Empty
620
620
  * `agents:` / `versions:` are not written (no empty committed files).
621
621
  */
622
+ /**
623
+ * Serialize the central (synced) meta to `agents.yaml` WITHOUT destroying the
624
+ * hand-written comments in the committed file.
625
+ *
626
+ * `yaml.stringify(central)` drops every comment, so the freshly-written bytes
627
+ * never equal the comment-annotated file on disk — `writeIfChanged`'s byte
628
+ * compare then rewrites on EVERY meta write, leaving `agents.yaml` perpetually
629
+ * dirty and wedging `agents sync` ("Blocked by local changes"). Instead we parse
630
+ * the existing file into a `yaml.Document` (which preserves comments + ordering)
631
+ * and edit only the keys that actually changed — untouched keys, and all their
632
+ * comments, are left byte-stable. If nothing central changed we return the exact
633
+ * existing bytes, so a device-field-only write no longer touches `agents.yaml` at
634
+ * all. Falls back to plain stringify only when the file doesn't exist yet.
635
+ */
636
+ function serializeCentral(central) {
637
+ const isEmpty = Object.keys(central).length === 0;
638
+ let existing = null;
639
+ try {
640
+ existing = fs.readFileSync(META_FILE, 'utf-8');
641
+ }
642
+ catch {
643
+ /* first write — no file yet */
644
+ }
645
+ if (existing == null) {
646
+ // Empty central → header only. `yaml.stringify({})` emits `{}` (a FLOW empty
647
+ // map); once that lands on disk, a later parseDocument sees a flow root and
648
+ // doc.set() below would inherit flow, flow-ifying the whole file. Writing just
649
+ // the header avoids seeding that poison.
650
+ return isEmpty ? META_HEADER : META_HEADER + yaml.stringify(central);
651
+ }
652
+ const doc = yaml.parseDocument(existing);
653
+ const current = doc.toJSON() ?? {};
654
+ let changed = false;
655
+ for (const [k, v] of Object.entries(central)) {
656
+ if (JSON.stringify(current[k]) !== JSON.stringify(v)) {
657
+ doc.set(k, v);
658
+ changed = true;
659
+ }
660
+ }
661
+ for (const k of Object.keys(current)) {
662
+ if (!(k in central)) {
663
+ doc.delete(k);
664
+ changed = true;
665
+ }
666
+ }
667
+ // No central field changed → keep the file byte-identical (comments intact), so
668
+ // writeIfChanged skips it and the churn loop never starts.
669
+ if (!changed)
670
+ return existing;
671
+ // Everything cleared → header only (never leave a flow `{}` behind).
672
+ // Otherwise force BLOCK style: an existing flow root (e.g. a legacy `{}`) would
673
+ // otherwise make the edited nodes render flow (`disabledCommands: [ teams ]`
674
+ // instead of a `- teams` block list). collectionStyle pins the whole doc block
675
+ // while parseDocument still preserves comments + key ordering.
676
+ return isEmpty ? META_HEADER : doc.toString({ collectionStyle: 'block' });
677
+ }
622
678
  function writeMetaUnlocked(meta) {
623
679
  const { agents, isolatedAgents, versions, defaultBrowserProfile, ...central } = meta;
624
680
  // Write the machine-local files FIRST, then strip central — so a crash mid-write
@@ -656,7 +712,7 @@ function writeMetaUnlocked(meta) {
656
712
  fs.mkdirSync(path.dirname(vrPath), { recursive: true });
657
713
  writeIfChanged(vrPath, JSON.stringify(versions, null, 2) + '\n');
658
714
  }
659
- writeIfChanged(META_FILE, META_HEADER + yaml.stringify(central));
715
+ writeIfChanged(META_FILE, serializeCentral(central));
660
716
  metaCache = null;
661
717
  }
662
718
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.82",
3
+ "version": "1.20.83",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",