@phnx-labs/agents-cli 1.22.10 → 1.22.12

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/beta.js +21 -9
  4. package/dist/commands/cli.js +12 -12
  5. package/dist/commands/events.d.ts +20 -1
  6. package/dist/commands/events.js +71 -55
  7. package/dist/commands/logs.js +14 -138
  8. package/dist/commands/projects.d.ts +1 -2
  9. package/dist/commands/projects.js +39 -47
  10. package/dist/commands/repo.d.ts +1 -1
  11. package/dist/commands/repo.js +3 -3
  12. package/dist/commands/view.d.ts +1 -1
  13. package/dist/commands/view.js +7 -7
  14. package/dist/index.js +1 -1
  15. package/dist/lib/beta.d.ts +1 -1
  16. package/dist/lib/beta.js +1 -1
  17. package/dist/lib/cli-resources.js +2 -2
  18. package/dist/lib/event-stream.d.ts +1 -1
  19. package/dist/lib/event-stream.js +1 -1
  20. package/dist/lib/events.d.ts +21 -12
  21. package/dist/lib/events.js +311 -64
  22. package/dist/lib/exec.js +1 -2
  23. package/dist/lib/feed-broadcast.d.ts +19 -5
  24. package/dist/lib/feed-broadcast.js +41 -8
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  27. package/dist/lib/migrate.d.ts +8 -0
  28. package/dist/lib/migrate.js +26 -1
  29. package/dist/lib/project-probe.d.ts +1 -1
  30. package/dist/lib/project-probe.js +1 -1
  31. package/dist/lib/projects.d.ts +1 -1
  32. package/dist/lib/resources.d.ts +1 -1
  33. package/dist/lib/runner.js +1 -2
  34. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  36. package/dist/lib/secrets/audit.js +1 -1
  37. package/dist/lib/startup/command-registry.js +1 -1
  38. package/dist/lib/state.d.ts +1 -1
  39. package/dist/lib/state.js +60 -2
  40. package/dist/lib/types.d.ts +1 -1
  41. package/dist/lib/watchdog/runner.d.ts +13 -0
  42. package/dist/lib/watchdog/runner.js +75 -6
  43. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Centralized event logging for agents-cli.
3
3
  *
4
- * Structured JSONL audit log at ~/.agents/events.jsonl with lossless numbered
5
- * gzip rotation at 10 MB and rich metadata for debugging/auditing.
4
+ * Structured JSONL audit logs at ~/.agents/.history/events/YYYY-MM-DD/events.jsonl with
5
+ * lossless numbered gzip rotation at 10 MiB and bounded retention.
6
6
  *
7
7
  * Features:
8
8
  * - Rich metadata: hostname, platform, arch, pid, timezone
@@ -47,14 +47,42 @@ function recordPerfTiming(payload) {
47
47
  // unlike _resetForTest it survives a bare reset AND propagates to CLI subprocesses
48
48
  // a test spawns, so fixture events can never land in the user's real log (#910).
49
49
  let _eventsPath;
50
- function eventsPath() {
51
- return (_eventsPath ??= process.env.AGENTS_EVENTS_PATH || path.join(getUserAgentsDir(), 'events.jsonl'));
50
+ let _eventsPathOverride = false;
51
+ let _legacyMigrationChecked = false;
52
+ let _userAgentsDirOverride;
53
+ function userAgentsDir() {
54
+ return _userAgentsDirOverride ?? getUserAgentsDir();
52
55
  }
53
- function eventsDir() {
54
- return path.dirname(eventsPath());
56
+ function localDateKey(date = new Date()) {
57
+ const year = date.getFullYear();
58
+ const month = String(date.getMonth() + 1).padStart(2, '0');
59
+ const day = String(date.getDate()).padStart(2, '0');
60
+ return `${year}-${month}-${day}`;
61
+ }
62
+ function eventsRoot() {
63
+ if (_eventsPathOverride && _eventsPath)
64
+ return path.dirname(_eventsPath);
65
+ return path.join(userAgentsDir(), '.history', 'events');
66
+ }
67
+ function eventsPath(date = new Date()) {
68
+ if (_eventsPathOverride && _eventsPath)
69
+ return _eventsPath;
70
+ const override = _userAgentsDirOverride ? undefined : process.env.AGENTS_EVENTS_PATH;
71
+ if (override) {
72
+ _eventsPathOverride = true;
73
+ return (_eventsPath = override);
74
+ }
75
+ return path.join(eventsRoot(), localDateKey(date), 'events.jsonl');
76
+ }
77
+ function eventsDir(date = new Date()) {
78
+ return path.dirname(eventsPath(date));
55
79
  }
56
80
  /** Default retention period in days. */
57
81
  const DEFAULT_RETENTION_DAYS = 7;
82
+ /** Default total footprint for the active log plus gzip archives (50 MiB). */
83
+ const DEFAULT_MAX_STORAGE_BYTES = 50 * 1024 * 1024;
84
+ /** Cross-process marker used to avoid a full archive scan on every append. */
85
+ const PRUNE_MARKER = '.last-prune';
58
86
  /** Default max length for truncated strings. */
59
87
  const DEFAULT_TRUNCATE_LENGTH = 500;
60
88
  /** Gzip rotation threshold in bytes (10 MB). */
@@ -154,6 +182,8 @@ function getTimezoneName() {
154
182
  }
155
183
  }
156
184
  function ensureLogsDir() {
185
+ eventsPath(); // Resolve an explicit AGENTS_EVENTS_PATH before migration checks.
186
+ migrateLegacyEventLogs();
157
187
  if (!fs.existsSync(eventsDir())) {
158
188
  fs.mkdirSync(eventsDir(), { recursive: true, mode: DIR_MODE });
159
189
  }
@@ -167,6 +197,102 @@ function ensureLogsDir() {
167
197
  }
168
198
  }
169
199
  }
200
+ /**
201
+ * Move root-level and interim flat-history event families into dated directories.
202
+ *
203
+ * The common case is a whole-family rename into an empty destination. A
204
+ * Each segment is assigned to the local calendar day of its filesystem mtime;
205
+ * new writes are split by day at source. A partially completed migration keeps
206
+ * the destination active file authoritative and assigns a fresh archive number,
207
+ * so no record is overwritten or silently discarded. The legacy active-file
208
+ * lock serializes this with older installed processes that still append there.
209
+ */
210
+ export function migrateLegacyEventLogs(userDir = userAgentsDir()) {
211
+ if (_eventsPathOverride || _legacyMigrationChecked)
212
+ return 0;
213
+ _legacyMigrationChecked = true;
214
+ const destinationRoot = path.join(userDir, '.history', 'events');
215
+ const legacyActive = path.join(userDir, 'events.jsonl');
216
+ const interimActive = path.join(destinationRoot, 'events.jsonl');
217
+ const listArchives = (dir) => {
218
+ try {
219
+ return fs.readdirSync(dir)
220
+ .map((file) => ({ file, match: file.match(/^events\.(\d+)\.jsonl\.gz$/) }))
221
+ .filter((entry) => entry.match !== null)
222
+ .map((entry) => ({ file: entry.file, number: Number(entry.match[1]) }))
223
+ .sort((a, b) => a.number - b.number);
224
+ }
225
+ catch {
226
+ return [];
227
+ }
228
+ };
229
+ const hasSourceFiles = fs.existsSync(legacyActive) || fs.existsSync(interimActive) ||
230
+ listArchives(userDir).length > 0 || listArchives(destinationRoot).length > 0;
231
+ if (!hasSourceFiles)
232
+ return 0;
233
+ try {
234
+ fs.mkdirSync(destinationRoot, { recursive: true, mode: DIR_MODE });
235
+ ensureLockTarget(legacyActive, '', DIR_MODE);
236
+ return withFileLock(legacyActive, () => {
237
+ ensureLockTarget(interimActive, '', DIR_MODE);
238
+ return withFileLock(interimActive, () => {
239
+ const sourceFamilies = [
240
+ { dir: userDir, active: legacyActive, archives: listArchives(userDir) },
241
+ { dir: destinationRoot, active: interimActive, archives: listArchives(destinationRoot) },
242
+ ];
243
+ let moved = 0;
244
+ const nextArchiveNumber = (dir) => {
245
+ const numbers = fs.readdirSync(dir)
246
+ .map((file) => file.match(/^events\.(\d+)\.jsonl\.gz$/))
247
+ .filter((match) => match !== null)
248
+ .map((match) => Number(match[1]));
249
+ return (numbers.length ? Math.max(...numbers) : 0) + 1;
250
+ };
251
+ const withDestinationLock = (dayDir, fn) => {
252
+ const destinationActive = path.join(dayDir, 'events.jsonl');
253
+ fs.mkdirSync(dayDir, { recursive: true, mode: DIR_MODE });
254
+ ensureLockTarget(destinationActive, '', DIR_MODE);
255
+ return withFileLock(destinationActive, fn);
256
+ };
257
+ for (const family of sourceFamilies) {
258
+ const activeBytes = fs.existsSync(family.active) ? fs.statSync(family.active).size : 0;
259
+ if (activeBytes > 0) {
260
+ const activeStat = fs.statSync(family.active);
261
+ const dayDir = path.join(destinationRoot, localDateKey(activeStat.mtime));
262
+ withDestinationLock(dayDir, () => {
263
+ const archivePath = path.join(dayDir, `events.${nextArchiveNumber(dayDir)}.jsonl.gz`);
264
+ fs.writeFileSync(archivePath, gzipSync(fs.readFileSync(family.active)), { mode: FILE_MODE });
265
+ fs.utimesSync(archivePath, activeStat.atime, activeStat.mtime);
266
+ fs.truncateSync(family.active, 0);
267
+ });
268
+ moved++;
269
+ }
270
+ for (const archive of family.archives) {
271
+ const source = path.join(family.dir, archive.file);
272
+ const dayDir = path.join(destinationRoot, localDateKey(fs.statSync(source).mtime));
273
+ withDestinationLock(dayDir, () => {
274
+ const destination = path.join(dayDir, `events.${nextArchiveNumber(dayDir)}.jsonl.gz`);
275
+ fs.renameSync(source, destination);
276
+ });
277
+ moved++;
278
+ }
279
+ try {
280
+ if (fs.existsSync(family.active) && fs.statSync(family.active).size === 0)
281
+ fs.unlinkSync(family.active);
282
+ }
283
+ catch { /* an older process may have reopened it */ }
284
+ }
285
+ return moved;
286
+ });
287
+ });
288
+ }
289
+ catch {
290
+ // Audit logging must remain fail-soft. Files stay at the legacy path and a
291
+ // later process retries because this guard is process-local.
292
+ _legacyMigrationChecked = false;
293
+ return 0;
294
+ }
295
+ }
170
296
  // ─── Redaction ────────────────────────────────────────────────────────────────
171
297
  /**
172
298
  * Replace a prompt string with length + short SHA so we can correlate runs
@@ -380,7 +506,8 @@ export function emit(event, payload = {}, overrides = {}) {
380
506
  // May fail if not owner
381
507
  }
382
508
  }
383
- maybeGzipRotateLocked(logPath);
509
+ const rotated = maybeGzipRotateLocked(logPath);
510
+ maybePruneLocked(rotated);
384
511
  });
385
512
  }
386
513
  catch {
@@ -657,7 +784,7 @@ export function emitFriction(surface, failureId, payload = {}) {
657
784
  function maybeGzipRotateLocked(logPath) {
658
785
  const stat = fs.statSync(logPath);
659
786
  if (stat.size < GZIP_ROTATION_BYTES)
660
- return;
787
+ return false;
661
788
  const raw = fs.readFileSync(logPath);
662
789
  const tmpArchive = path.join(eventsDir(), `.events.1.jsonl.gz.${process.pid}.tmp`);
663
790
  fs.writeFileSync(tmpArchive, gzipSync(raw), { mode: FILE_MODE });
@@ -672,6 +799,7 @@ function maybeGzipRotateLocked(logPath) {
672
799
  }
673
800
  fs.renameSync(tmpArchive, path.join(eventsDir(), 'events.1.jsonl.gz'));
674
801
  fs.truncateSync(logPath, 0);
802
+ return true;
675
803
  }
676
804
  catch (err) {
677
805
  try {
@@ -681,45 +809,179 @@ function maybeGzipRotateLocked(logPath) {
681
809
  throw err;
682
810
  }
683
811
  }
684
- // ─── Rotation ─────────────────────────────────────────────────────────────────
685
- /**
686
- * Remove log files older than the retention period.
687
- * Removes numbered gzip archives whose filesystem mtime exceeds retention.
688
- *
689
- * @param retentionDays - Number of days to keep (default 7, from DEFAULT_RETENTION_DAYS)
690
- * @returns Number of files removed
691
- */
692
- export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
693
- try {
694
- if (!fs.existsSync(eventsDir()))
695
- return 0;
696
- const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
697
- const files = fs.readdirSync(eventsDir()).filter(f => /^events\.\d+\.jsonl\.gz$/.test(f));
698
- let removed = 0;
699
- for (const file of files) {
700
- const filePath = path.join(eventsDir(), file);
701
- if (fs.statSync(filePath).mtimeMs < cutoff) {
702
- fs.unlinkSync(filePath);
703
- removed++;
812
+ function listEventLogFiles() {
813
+ const current = eventsPath();
814
+ const files = [];
815
+ const addFile = (filePath, gzip) => {
816
+ try {
817
+ const stat = fs.statSync(filePath);
818
+ files.push({
819
+ path: filePath,
820
+ gzip,
821
+ currentActive: filePath === current,
822
+ mtimeMs: stat.mtimeMs,
823
+ size: stat.size,
824
+ });
825
+ }
826
+ catch { /* file may rotate while an unlocked reader enumerates */ }
827
+ };
828
+ const addDirectory = (dir) => {
829
+ let names = [];
830
+ try {
831
+ names = fs.readdirSync(dir);
832
+ }
833
+ catch {
834
+ return;
835
+ }
836
+ for (const name of names) {
837
+ if (name !== 'events.jsonl' && !/^events\.\d+\.jsonl\.gz$/.test(name))
838
+ continue;
839
+ addFile(path.join(dir, name), name.endsWith('.gz'));
840
+ }
841
+ };
842
+ if (_eventsPathOverride) {
843
+ if (path.basename(current) !== 'events.jsonl') {
844
+ addFile(current, false);
845
+ let names = [];
846
+ try {
847
+ names = fs.readdirSync(eventsDir());
848
+ }
849
+ catch {
850
+ return files;
851
+ }
852
+ for (const name of names) {
853
+ if (/^events\.\d+\.jsonl\.gz$/.test(name))
854
+ addFile(path.join(eventsDir(), name), true);
704
855
  }
856
+ return files;
705
857
  }
706
- return removed;
858
+ addDirectory(eventsDir());
859
+ return files;
860
+ }
861
+ let days = [];
862
+ try {
863
+ days = fs.readdirSync(eventsRoot()).filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name));
707
864
  }
708
865
  catch {
709
- return 0;
866
+ return files;
710
867
  }
868
+ for (const day of days)
869
+ addDirectory(path.join(eventsRoot(), day));
870
+ return files;
711
871
  }
712
- /**
713
- * Lazy rotation - runs at most once per day per process.
714
- */
715
- let lastRotationCheck = 0;
716
- export function maybeRotate() {
717
- const now = Date.now();
872
+ function removeEmptyDayDirectories() {
873
+ if (_eventsPathOverride)
874
+ return;
875
+ let days = [];
876
+ try {
877
+ days = fs.readdirSync(eventsRoot()).filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name));
878
+ }
879
+ catch {
880
+ return;
881
+ }
882
+ for (const day of days) {
883
+ const dir = path.join(eventsRoot(), day);
884
+ try {
885
+ if (fs.readdirSync(dir).length === 0)
886
+ fs.rmdirSync(dir);
887
+ }
888
+ catch { /* best effort */ }
889
+ }
890
+ }
891
+ function finalizePastDayLogs() {
892
+ if (_eventsPathOverride)
893
+ return;
894
+ const currentDay = localDateKey();
895
+ for (const file of listEventLogFiles()) {
896
+ if (path.basename(file.path) !== 'events.jsonl')
897
+ continue;
898
+ if (path.basename(path.dirname(file.path)) === currentDay)
899
+ continue;
900
+ try {
901
+ withFileLock(file.path, () => {
902
+ const dir = path.dirname(file.path);
903
+ const numbers = fs.readdirSync(dir)
904
+ .map((name) => name.match(/^events\.(\d+)\.jsonl\.gz$/))
905
+ .filter((match) => match !== null)
906
+ .map((match) => Number(match[1]));
907
+ const next = (numbers.length ? Math.max(...numbers) : 0) + 1;
908
+ const target = path.join(dir, `events.${next}.jsonl.gz`);
909
+ const sourceStat = fs.statSync(file.path);
910
+ fs.writeFileSync(target, gzipSync(fs.readFileSync(file.path)), { mode: FILE_MODE });
911
+ fs.utimesSync(target, sourceStat.atime, sourceStat.mtime);
912
+ fs.unlinkSync(file.path);
913
+ });
914
+ }
915
+ catch { /* retry on the next prune */ }
916
+ }
917
+ }
918
+ function pruneEventLogsLocked(retentionDays = DEFAULT_RETENTION_DAYS, maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES) {
919
+ finalizePastDayLogs();
920
+ const result = { removedByAge: 0, removedBySize: 0, bytesReclaimed: 0 };
921
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
922
+ let files = listEventLogFiles();
923
+ for (const file of files) {
924
+ if (file.currentActive || file.mtimeMs >= cutoff)
925
+ continue;
926
+ try {
927
+ fs.unlinkSync(file.path);
928
+ result.removedByAge++;
929
+ result.bytesReclaimed += file.size;
930
+ }
931
+ catch { /* another process may already have removed it */ }
932
+ }
933
+ files = listEventLogFiles();
934
+ let totalBytes = files.reduce((sum, file) => sum + file.size, 0);
935
+ const oldestFirst = files
936
+ .filter((file) => !file.currentActive)
937
+ .sort((a, b) => a.mtimeMs - b.mtimeMs || a.path.localeCompare(b.path));
938
+ for (const file of oldestFirst) {
939
+ if (totalBytes <= maxStorageBytes)
940
+ break;
941
+ try {
942
+ fs.unlinkSync(file.path);
943
+ totalBytes -= file.size;
944
+ result.removedBySize++;
945
+ result.bytesReclaimed += file.size;
946
+ }
947
+ catch { /* another process may already have removed it */ }
948
+ }
949
+ removeEmptyDayDirectories();
950
+ return result;
951
+ }
952
+ /** Apply age retention and the total-size ceiling immediately. */
953
+ export function rotate(retentionDays = DEFAULT_RETENTION_DAYS, maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES) {
954
+ try {
955
+ ensureLogsDir();
956
+ const active = eventsPath();
957
+ ensureLockTarget(active, '', DIR_MODE);
958
+ return withFileLock(active, () => pruneEventLogsLocked(retentionDays, maxStorageBytes));
959
+ }
960
+ catch {
961
+ return { removedByAge: 0, removedBySize: 0, bytesReclaimed: 0 };
962
+ }
963
+ }
964
+ /** Prune daily across processes, and immediately after a size rotation. */
965
+ function maybePruneLocked(force) {
966
+ const marker = path.join(eventsRoot(), PRUNE_MARKER);
718
967
  const oneDayMs = 24 * 60 * 60 * 1000;
719
- if (now - lastRotationCheck > oneDayMs) {
720
- lastRotationCheck = now;
721
- rotate();
968
+ let due = force;
969
+ try {
970
+ due ||= Date.now() - fs.statSync(marker).mtimeMs > oneDayMs;
722
971
  }
972
+ catch {
973
+ due = true;
974
+ }
975
+ if (!due)
976
+ return;
977
+ const maxBytes = force
978
+ ? DEFAULT_MAX_STORAGE_BYTES - GZIP_ROTATION_BYTES
979
+ : DEFAULT_MAX_STORAGE_BYTES;
980
+ pruneEventLogsLocked(DEFAULT_RETENTION_DAYS, maxBytes);
981
+ try {
982
+ fs.writeFileSync(marker, '', { mode: FILE_MODE });
983
+ }
984
+ catch { /* retry next append */ }
723
985
  }
724
986
  // ─── Query ────────────────────────────────────────────────────────────────────
725
987
  /**
@@ -731,19 +993,9 @@ export function maybeRotate() {
731
993
  export function query(options) {
732
994
  const { startDate, endDate = new Date(), eventTypes, level, agent, sessionId, caller, command, module, bundle, limit } = options;
733
995
  const results = [];
734
- if (!fs.existsSync(eventsDir()))
735
- return results;
736
- const files = [];
737
- if (fs.existsSync(eventsPath()))
738
- files.push({ path: eventsPath(), gzip: false });
739
- const archives = fs.readdirSync(eventsDir())
740
- .map((file) => ({ file, match: file.match(/^events\.(\d+)\.jsonl\.gz$/) }))
741
- .filter((entry) => entry.match !== null)
742
- .map((entry) => ({ file: entry.file, number: Number(entry.match[1]) }))
743
- .sort((a, b) => a.number - b.number);
744
- for (const archive of archives) {
745
- files.push({ path: path.join(eventsDir(), archive.file), gzip: true });
746
- }
996
+ eventsPath(); // Resolve AGENTS_EVENTS_PATH before deciding whether to migrate.
997
+ migrateLegacyEventLogs();
998
+ const files = listEventLogFiles().sort((a, b) => Number(b.currentActive) - Number(a.currentActive) || b.mtimeMs - a.mtimeMs || b.path.localeCompare(a.path));
747
999
  const startMs = startDate?.getTime();
748
1000
  const endMs = endDate?.getTime();
749
1001
  for (const file of files) {
@@ -849,16 +1101,9 @@ export function stats(options = {}) {
849
1101
  let fileCount = 0;
850
1102
  let totalBytes = 0;
851
1103
  try {
852
- if (fs.existsSync(eventsDir())) {
853
- const files = fs.readdirSync(eventsDir()).filter(f => f === 'events.jsonl' || /^events\.\d+\.jsonl\.gz$/.test(f));
854
- fileCount = files.length;
855
- for (const f of files) {
856
- try {
857
- totalBytes += fs.statSync(path.join(eventsDir(), f)).size;
858
- }
859
- catch { /* skip */ }
860
- }
861
- }
1104
+ const files = listEventLogFiles();
1105
+ fileCount = files.length;
1106
+ totalBytes = files.reduce((sum, file) => sum + file.size, 0);
862
1107
  }
863
1108
  catch { /* skip */ }
864
1109
  return {
@@ -876,9 +1121,11 @@ export function stats(options = {}) {
876
1121
  export function getLogsPath() {
877
1122
  return eventsPath();
878
1123
  }
879
- export function _resetForTest(overrideEventsPath) {
1124
+ export function _resetForTest(overrideEventsPath, overrideUserAgentsDir) {
880
1125
  _eventsPath = overrideEventsPath;
1126
+ _eventsPathOverride = Boolean(overrideEventsPath);
1127
+ _userAgentsDirOverride = overrideUserAgentsDir;
1128
+ _legacyMigrationChecked = false;
881
1129
  resetEventProvenanceForTest();
882
1130
  _chmoddedPath = undefined;
883
- lastRotationCheck = 0;
884
1131
  }
package/dist/lib/exec.js CHANGED
@@ -14,7 +14,7 @@ import { parseTimeout } from './routines.js';
14
14
  import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { isTierToken, resolveTier } from './model-tiers.js';
17
- import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
+ import { createTimer, redactPrompt, redactArgs } from './events.js';
18
18
  import { sanitizeProcessEnv } from './secrets/bundles.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
20
  import { getShimsDir, getHistoryDir, getUserAgentsDir } from './state.js';
@@ -1399,7 +1399,6 @@ async function spawnAgent(options) {
1399
1399
  const runId = launchId;
1400
1400
  options = { ...options, env: { ...options.env, AGENT_LAUNCH_ID: launchId } };
1401
1401
  const watcherState = await setupBudgetWatcher(options, cwd, runId);
1402
- maybeRotate();
1403
1402
  const timer = createTimer('agent.run', {
1404
1403
  agent: options.agent,
1405
1404
  version: options.version,
@@ -48,8 +48,14 @@ export interface FeedBroadcastContext {
48
48
  class?: string;
49
49
  /** Block-only: cost-of-delay tag used by the urgency filter. */
50
50
  cost?: string;
51
- /** Block-only: the literal `agents focus <id>` command that unblocks it. */
51
+ /** Block-only: the literal `agents focus <id>` command (for a `{focus}` sink). */
52
52
  focus?: string;
53
+ /** Block-only: the answer choices the operator can pick, in order. */
54
+ options?: string[];
55
+ /** Block-only: the fallback applied if nobody answers in time. */
56
+ safeDefault?: string;
57
+ /** Block-only: minutes before `safeDefault` applies. */
58
+ timeoutMinutes?: number;
53
59
  }
54
60
  /**
55
61
  * Map an open block onto the broadcast context, so a block reaches the same sinks
@@ -69,9 +75,14 @@ export declare function blockBroadcastContext(block: {
69
75
  host?: string;
70
76
  questions?: Array<{
71
77
  text?: string;
78
+ options?: Array<{
79
+ label?: string;
80
+ }>;
72
81
  }>;
73
82
  blockClass?: string;
74
83
  costOfDelay?: string;
84
+ safeDefault?: string;
85
+ timeoutMinutes?: number;
75
86
  ticket?: string;
76
87
  pr?: string;
77
88
  }, extras?: {
@@ -137,14 +148,17 @@ export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): strin
137
148
  * Title in a few words
138
149
  *
139
150
  * Body of what happened or the ask.
151
+ * Options: publish / wait (blocks with choices)
152
+ * Default in 15 min: wait (blocks with a safe default)
140
153
  *
141
154
  * Sent from grok/a02da0e2 on mac-mini
142
- * agents focus a02da0e2 (blocks only)
143
- * https://… (optional attach URL)
155
+ * https://… (optional attach URL)
144
156
  * ```
145
157
  *
146
- * Title first (scannable subject). Blank line. Body. Footer provenance so a
147
- * fleet of agents is attributable without crowding the ask. Prefer `{message}`
158
+ * Title first (scannable subject). Blank line. Body. Then the phone-actionable
159
+ * choices + default (a block that has stopped for the human), then footer
160
+ * provenance. No `agents focus <id>` line: a CLI command is unusable from a phone,
161
+ * so the safe default is the fallback and the message carries it. Prefer `{message}`
148
162
  * over bare `{text}` in messaging sinks.
149
163
  */
150
164
  export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
@@ -62,6 +62,12 @@ export function parseFeedPostLevel(raw) {
62
62
  export function blockBroadcastContext(block, extras = {}) {
63
63
  const ask = block.questions?.[0]?.text?.trim() || 'agent is blocked';
64
64
  const links = [block.pr].filter((l) => !!l && /^https?:\/\//i.test(l));
65
+ // The answer choices + the safe default are what make the phone message
66
+ // actionable: the operator sees the options and what happens if they do not
67
+ // reply, instead of a `agents focus <id>` CLI command they cannot run from a phone.
68
+ const options = (block.questions?.[0]?.options ?? [])
69
+ .map((o) => o?.label?.trim())
70
+ .filter((l) => !!l);
65
71
  // Prefer explicit title/body from the feed post; fall back to the ask as body.
66
72
  const title = extras.title?.trim() || undefined;
67
73
  const text = extras.body?.trim() || ask;
@@ -78,8 +84,12 @@ export function blockBroadcastContext(block, extras = {}) {
78
84
  class: block.blockClass,
79
85
  cost: block.costOfDelay,
80
86
  // Short id: `agents focus` matches on a prefix, and a full uuid in a phone
81
- // message is noise the operator has to skip past to reach the verb.
87
+ // message is noise. Kept for a `{focus}` sink; the human message no longer
88
+ // shows it (a CLI command is unusable from a phone).
82
89
  focus: `agents focus ${block.sessionId.slice(0, 8)}`,
90
+ ...(options.length ? { options } : {}),
91
+ ...(block.safeDefault ? { safeDefault: block.safeDefault } : {}),
92
+ ...(block.timeoutMinutes ? { timeoutMinutes: block.timeoutMinutes } : {}),
83
93
  ...(links.length ? { links } : {}),
84
94
  };
85
95
  }
@@ -179,14 +189,17 @@ export function composeBroadcastFooter(ctx) {
179
189
  * Title in a few words
180
190
  *
181
191
  * Body of what happened or the ask.
192
+ * Options: publish / wait (blocks with choices)
193
+ * Default in 15 min: wait (blocks with a safe default)
182
194
  *
183
195
  * Sent from grok/a02da0e2 on mac-mini
184
- * agents focus a02da0e2 (blocks only)
185
- * https://… (optional attach URL)
196
+ * https://… (optional attach URL)
186
197
  * ```
187
198
  *
188
- * Title first (scannable subject). Blank line. Body. Footer provenance so a
189
- * fleet of agents is attributable without crowding the ask. Prefer `{message}`
199
+ * Title first (scannable subject). Blank line. Body. Then the phone-actionable
200
+ * choices + default (a block that has stopped for the human), then footer
201
+ * provenance. No `agents focus <id>` line: a CLI command is unusable from a phone,
202
+ * so the safe default is the fallback and the message carries it. Prefer `{message}`
190
203
  * over bare `{text}` in messaging sinks.
191
204
  */
192
205
  export function composeBroadcastMessage(ctx) {
@@ -197,9 +210,21 @@ export function composeBroadcastMessage(ctx) {
197
210
  const mid = title && body && title !== body ? body : undefined;
198
211
  const footer = composeBroadcastFooter(ctx);
199
212
  const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
200
- // Block focus and link trail after the "Sent from" footer so the human
201
- // sentence stays at the top and the action/link are still one glance away.
202
- const trail = [footer, ctx.focus, link].filter(Boolean);
213
+ // The action block: the one thing the operator can act on from a phone. Show the
214
+ // choices, then what happens if they do not answer. Deliberately NOT a CLI command
215
+ // (`agents focus <id>` is unusable from a phone) -- the safe default is the real
216
+ // fallback, so a block meant for a phone should always carry one.
217
+ const choices = ctx.options?.length
218
+ ? `Options: ${ctx.options.map((o) => scrubOutboundDashes(o)).join(' / ')}`
219
+ : undefined;
220
+ const fallback = ctx.safeDefault
221
+ ? (ctx.timeoutMinutes && ctx.timeoutMinutes > 0
222
+ ? `Default in ${ctx.timeoutMinutes} min: ${scrubOutboundDashes(ctx.safeDefault)}`
223
+ : `Default: ${scrubOutboundDashes(ctx.safeDefault)}`)
224
+ : undefined;
225
+ const action = [choices, fallback].filter(Boolean).join('\n') || undefined;
226
+ // Link trail after the "Sent from" footer so the human sentence stays at the top.
227
+ const trail = [footer, link].filter(Boolean);
203
228
  const parts = [];
204
229
  if (head)
205
230
  parts.push(head);
@@ -208,6 +233,12 @@ export function composeBroadcastMessage(ctx) {
208
233
  parts.push('');
209
234
  parts.push(mid);
210
235
  }
236
+ if (action) {
237
+ // The choices/default hug the ask under a blank line so they read as the reply.
238
+ if (parts.length)
239
+ parts.push('');
240
+ parts.push(action);
241
+ }
211
242
  if (trail.length) {
212
243
  // Blank line before the footer block (iPhone "Sent from my iPhone" spacing).
213
244
  if (parts.length)
@@ -233,6 +264,8 @@ function templateVars(ctx) {
233
264
  class: ctx.class,
234
265
  cost: ctx.cost,
235
266
  focus: ctx.focus,
267
+ options: ctx.options?.length ? ctx.options.join(' / ') : undefined,
268
+ default: ctx.safeDefault,
236
269
  };
237
270
  }
238
271
  /**
@@ -133,5 +133,13 @@ export declare function migrateRoutineDeviceToDevices(routinesDir?: string): voi
133
133
  * real routines dir (and without racing the routine module's own tests).
134
134
  */
135
135
  export declare function migrateWatchdogSentinelToRoutine(sentinelPath?: string, ensure?: (enabled: boolean) => void): void;
136
+ /**
137
+ * Rename cli/ → clis/ in each of the given `.agents/` directories.
138
+ *
139
+ * One-way, idempotent: no-op when src is absent. Throws when both
140
+ * `<dir>/cli` and `<dir>/clis` exist — the user must resolve the conflict
141
+ * manually before proceeding. Exported for unit-testing with temp dirs.
142
+ */
143
+ export declare function migrateCliDirToClis(agentsDirs: string[]): void;
136
144
  /** Run all idempotent migrations. Safe to call multiple times. */
137
145
  export declare function runMigration(): Promise<void>;