@phnx-labs/agents-cli 1.20.82 → 1.20.84

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.
@@ -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,26 @@ 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;
161
+ /**
162
+ * For a teams teammate: a one-line summary of the mission it was spawned with
163
+ * (the `prompt` stored on the teammate record — the team's task/target), so the
164
+ * listing answers "what is this team working on", not just its name. Survives
165
+ * before the teammate has produced any transcript (a pending/staged teammate
166
+ * still shows its target). Distinct from `topic`, which is derived from the
167
+ * teammate's own transcript once it starts.
168
+ */
169
+ assignedTask?: string;
150
170
  agentId?: string;
151
171
  cloudProvider?: string;
152
172
  cloudTaskId?: string;
@@ -327,6 +347,12 @@ interface LiveSignals {
327
347
  * {@link resolveFallbackStatus} reports the honest live floor (`running`).
328
348
  */
329
349
  export declare function computeLiveSignals(kind: string, sessionFile: string | undefined, cwd: string | undefined, pidAlive: boolean): LiveSignals;
350
+ /**
351
+ * One-line summary of a teammate's spawn prompt — the team's task/target. Takes
352
+ * the first non-empty line, strips a leading `MISSION:`/`CONTEXT:`/`TASK:` label,
353
+ * and truncates. Exported for tests.
354
+ */
355
+ export declare function summarizeMission(prompt: string | null | undefined): string | undefined;
330
356
  /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
331
357
  export declare function listTeamsActive(): Promise<ActiveSession[]>;
332
358
  /** Live editor-terminal agents across every IDE window. */
@@ -445,6 +471,13 @@ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
445
471
  * terminal/headless row for the same session id.
446
472
  */
447
473
  export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
474
+ /**
475
+ * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
476
+ * when that orchestrator session is itself in the active set (it usually is — the
477
+ * agent that ran `agents teams add` is running). Falls back to nothing, so the
478
+ * renderer shows the short id. Pure over the array; exported for tests.
479
+ */
480
+ export declare function annotateOrchestratorLabels(sessions: ActiveSession[]): void;
448
481
  /**
449
482
  * Match an SSH client IP to a registered device (pure — testable with a plain
450
483
  * registry object). Returns the device name + ssh login user when the IP is a
@@ -576,21 +576,45 @@ function quickExtractTopic(sessionFile) {
576
576
  }
577
577
  return undefined;
578
578
  }
579
+ /**
580
+ * One-line summary of a teammate's spawn prompt — the team's task/target. Takes
581
+ * the first non-empty line, strips a leading `MISSION:`/`CONTEXT:`/`TASK:` label,
582
+ * and truncates. Exported for tests.
583
+ */
584
+ export function summarizeMission(prompt) {
585
+ if (!prompt)
586
+ return undefined;
587
+ const firstLine = prompt.split('\n').map((l) => l.trim()).find(Boolean);
588
+ if (!firstLine)
589
+ return undefined;
590
+ const cleaned = firstLine.replace(/^(MISSION|CONTEXT|TASK|GOAL|OBJECTIVE)\s*[:\-—]\s*/i, '').trim();
591
+ if (!cleaned)
592
+ return undefined;
593
+ return cleaned.length > 80 ? `${cleaned.slice(0, 79)}…` : cleaned;
594
+ }
579
595
  /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
580
596
  export async function listTeamsActive() {
581
597
  const mgr = new AgentManager();
582
598
  const running = await mgr.listRunning();
583
599
  return running.map((a) => {
584
- const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
585
- const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
600
+ // The teammate's OWN transcript is `remoteSessionId` (captured from its first
601
+ // stream event). `parentSessionId` is the ORCHESTRATOR that spawned the team
602
+ // (AGENTS_SESSION_ID at spawn) — a link, not this teammate's id. Keying the
603
+ // row off the orchestrator conflated the two (a teammate showed the
604
+ // orchestrator's id/topic and lineage was invisible); resolve the teammate's
605
+ // own session for the row and expose the orchestrator separately.
606
+ const ownSessionId = a.remoteSessionId ?? undefined;
607
+ const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, ownSessionId);
586
608
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
587
609
  const pidAlive = a.pid ? isPidAlive(a.pid) : true;
588
610
  const { state, tokPerSec } = computeLiveSignals(a.agentType, sessionFile, a.cwd ?? undefined, pidAlive);
611
+ const resolvedId = ownSessionId ?? sessionIdFromFile(sessionFile);
589
612
  return applyState({
590
613
  context: 'teams',
591
614
  kind: a.agentType,
592
615
  pid: a.pid ?? undefined,
593
- sessionId: sessionId ?? sessionIdFromFile(sessionFile),
616
+ sessionId: resolvedId,
617
+ orchestratorSessionId: a.parentSessionId ?? undefined,
594
618
  cwd: a.cwd ?? undefined,
595
619
  label: a.name ?? undefined,
596
620
  topic,
@@ -599,11 +623,12 @@ export async function listTeamsActive() {
599
623
  startedAtMs: a.startedAt.getTime(),
600
624
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
601
625
  teamName: a.taskName,
626
+ assignedTask: summarizeMission(a.prompt),
602
627
  agentId: a.agentId,
603
628
  // The frozen actor stamped on the teammate record (RUSH-2028) — who ran
604
629
  // this teammate, surfaced as the owner in --active (RUSH-2018); sidecar
605
630
  // fallback for a teammate record predating the actor field.
606
- owner: resolveOwner(a.actor, sessionId ?? sessionIdFromFile(sessionFile)),
631
+ owner: resolveOwner(a.actor, resolvedId),
607
632
  }, state, sessionFile, pidAlive);
608
633
  });
609
634
  }
@@ -1287,8 +1312,28 @@ export async function getActiveSessions(opts = {}) {
1287
1312
  await enrichProvenance(merged);
1288
1313
  await resolveOrigins(merged);
1289
1314
  foldPresence(merged);
1315
+ annotateOrchestratorLabels(merged);
1290
1316
  return merged;
1291
1317
  }
1318
+ /**
1319
+ * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
1320
+ * when that orchestrator session is itself in the active set (it usually is — the
1321
+ * agent that ran `agents teams add` is running). Falls back to nothing, so the
1322
+ * renderer shows the short id. Pure over the array; exported for tests.
1323
+ */
1324
+ export function annotateOrchestratorLabels(sessions) {
1325
+ const byId = new Map();
1326
+ for (const s of sessions)
1327
+ if (s.sessionId)
1328
+ byId.set(s.sessionId, s);
1329
+ for (const s of sessions) {
1330
+ if (!s.orchestratorSessionId)
1331
+ continue;
1332
+ const orch = byId.get(s.orchestratorSessionId);
1333
+ if (orch)
1334
+ s.orchestratorLabel = orch.label || orch.topic || undefined;
1335
+ }
1336
+ }
1292
1337
  /**
1293
1338
  * Fold detach/attach presence onto each row from the detach store. A stored
1294
1339
  * 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.84",
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",
@@ -55,6 +55,7 @@
55
55
  "start": "node dist/index.js",
56
56
  "test": "node ./node_modules/vitest/vitest.mjs run",
57
57
  "test:remote": "scripts/sandbox.sh 'bun install && bun run build && bun run test'",
58
+ "verify-docs": "scripts/verify-docs.sh",
58
59
  "test:watch": "node ./node_modules/vitest/vitest.mjs"
59
60
  },
60
61
  "keywords": [