@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -10,6 +10,7 @@
10
10
  * rsync / `agents sessions --host` resolve the same logical names.
11
11
  */
12
12
  import type { Command } from 'commander';
13
+ import { type FanOutDeviceTarget } from '../lib/devices/fleet.js';
13
14
  import { type CrabboxBox } from '../lib/crabbox/cli.js';
14
15
  /**
15
16
  * Live "Leased boxes" section for `agents devices` (F4, RUSH-1923), computed
@@ -19,5 +20,17 @@ import { type CrabboxBox } from '../lib/crabbox/cli.js';
19
20
  * the row formatting is deterministic in tests.
20
21
  */
21
22
  export declare function renderLeasedBoxesSection(boxes: CrabboxBox[], nowSecs: number): string[];
23
+ /**
24
+ * Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
25
+ *
26
+ * If `fanOut` settles first the result passes through unchanged. If the
27
+ * deadline fires first every pending remote target is mapped to `failed` (or
28
+ * `skipped` for pre-skipped targets), so callers always get a complete result
29
+ * array and the command exits promptly rather than hanging.
30
+ *
31
+ * Exported so the unit test can exercise the real path with a hanging probe
32
+ * instead of reimplementing the logic.
33
+ */
34
+ export declare function raceFleetPingDeadline<T, Target extends FanOutDeviceTarget>(fanOut: Promise<import('../lib/devices/fleet.js').FanOutDeviceResult<T>[]>, remoteTargets: Target[], overallTimeoutMs: number): Promise<import('../lib/devices/fleet.js').FanOutDeviceResult<T>[]>;
22
35
  /** Register both `agents ssh` and `agents devices`. */
23
36
  export declare function registerSshCommands(program: Command): void;
@@ -37,6 +37,7 @@ import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, r
37
37
  import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
38
38
  import { buildFleetHealthReport, renderFleetMatrix, renderFleetSummary, renderFleetWarnings, } from '../lib/devices/health-report.js';
39
39
  import { loadFleetStats, readStatsCache } from '../lib/devices/stats-cache.js';
40
+ import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
40
41
  import { checkSyncStatus, countOrphans } from '../lib/drift.js';
41
42
  import { checkAllClis } from '../lib/teams/agents.js';
42
43
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
@@ -321,6 +322,9 @@ function localHealthRow(self, stats) {
321
322
  clis: checkAllClis(),
322
323
  sync: checkSyncStatus(process.cwd()),
323
324
  orphans: countOrphans(),
325
+ // Local baseline inventory for cross-device divergence (RUSH-2027) — the
326
+ // yardstick every remote box is compared against.
327
+ inventory: collectLocalFleetInventory(process.cwd()),
324
328
  };
325
329
  }
326
330
  async function probeRemoteHealth(target) {
@@ -344,6 +348,10 @@ async function probeRemoteHealth(target) {
344
348
  // so the Auth column is current without a prior fleet-wide `fleet ping`.
345
349
  // Older remotes that don't emit it fall back to this host's cache below.
346
350
  auth: parsed.auth,
351
+ // Harness inventory (resources / agent versions / repo state) for
352
+ // cross-device divergence detection (RUSH-2027). Undefined on an older CLI
353
+ // that doesn't emit the `fleet` field — the comparator skips it.
354
+ inventory: parsed.fleet,
347
355
  };
348
356
  }
349
357
  async function runFleetStatus(opts) {
@@ -416,7 +424,7 @@ async function runFleetStatus(opts) {
416
424
  row.lastSeen = profile.tailscale?.lastSeen ?? profile.reachability?.checkedAt;
417
425
  }
418
426
  }
419
- const report = buildFleetHealthReport(rows);
427
+ const report = buildFleetHealthReport(rows, new Date(), { self });
420
428
  if (opts.json) {
421
429
  console.log(JSON.stringify(report, null, 2));
422
430
  }
@@ -441,13 +449,43 @@ async function probeRemoteAuth(target) {
441
449
  const isWin = /^win/i.test((target.platform ?? '').trim());
442
450
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
443
451
  const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
444
- const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 60000, multiplex: true });
452
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
445
453
  if (res.code !== 0) {
446
454
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
447
455
  }
448
456
  const parsed = JSON.parse(res.stdout);
449
457
  return parsed.rows ?? [];
450
458
  }
459
+ /**
460
+ * Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
461
+ *
462
+ * If `fanOut` settles first the result passes through unchanged. If the
463
+ * deadline fires first every pending remote target is mapped to `failed` (or
464
+ * `skipped` for pre-skipped targets), so callers always get a complete result
465
+ * array and the command exits promptly rather than hanging.
466
+ *
467
+ * Exported so the unit test can exercise the real path with a hanging probe
468
+ * instead of reimplementing the logic.
469
+ */
470
+ export async function raceFleetPingDeadline(fanOut, remoteTargets, overallTimeoutMs) {
471
+ const overallDeadline = new Promise((_, reject) => setTimeout(() => reject(new Error('fleet ping overall deadline exceeded')), overallTimeoutMs));
472
+ try {
473
+ return await Promise.race([fanOut, overallDeadline]);
474
+ }
475
+ catch (err) {
476
+ // Overall deadline hit before all devices settled — mark every pending
477
+ // device as failed so the command exits promptly. Individual probes that
478
+ // already settled are not retrievable (Promise.all internals), so we
479
+ // record all remote targets as timed out / skipped.
480
+ const errMsg = err instanceof Error ? err.message : String(err);
481
+ return remoteTargets.map((t) => ({
482
+ name: t.name,
483
+ status: t.skip ? 'skipped' : 'failed',
484
+ reason: t.skip,
485
+ error: t.skip ? undefined : errMsg,
486
+ }));
487
+ }
488
+ }
451
489
  async function runFleetPing(opts) {
452
490
  const self = machineId();
453
491
  const cliVersion = getCliVersion();
@@ -484,9 +522,16 @@ async function runFleetPing(opts) {
484
522
  const spinner = isInteractiveTerminal() && !opts.json
485
523
  ? ora(`Pinging ${probeable} device${probeable === 1 ? '' : 's'}…`).start()
486
524
  : undefined;
525
+ // Per-device: 15 s (matches the version probe budget; enough for the ~8 s
526
+ // provider-fetch inside the remote local auth probe, with headroom).
527
+ // Overall: 30 s hard cap so the command can never outlast a reasonable
528
+ // budget when several devices are simultaneously unreachable (RUSH-2041).
529
+ const FLEET_PING_DEVICE_TIMEOUT_MS = 15_000;
530
+ const FLEET_PING_OVERALL_TIMEOUT_MS = 30_000;
487
531
  let remote;
488
532
  try {
489
- remote = await fanOutDevices(remoteTargets, probeRemoteAuth);
533
+ const fanOut = fanOutDevices(remoteTargets, probeRemoteAuth, { perDeviceTimeoutMs: FLEET_PING_DEVICE_TIMEOUT_MS });
534
+ remote = await raceFleetPingDeadline(fanOut, remoteTargets, FLEET_PING_OVERALL_TIMEOUT_MS);
490
535
  }
491
536
  finally {
492
537
  spinner?.stop();
package/dist/index.js CHANGED
@@ -64,7 +64,8 @@ if (process.argv[2] === SYNC_GET_CMD ||
64
64
  process.argv[2] === SYNC_LOCK_CMD) {
65
65
  const { runAgentGetSync, runAgentPingSync, runAgentLockSync } = await import('./lib/secrets/agent.js');
66
66
  const name = process.argv[3] ?? '';
67
- const code = process.argv[2] === SYNC_GET_CMD ? await runAgentGetSync(name)
67
+ const harness = process.argv[4] ?? 'cli';
68
+ const code = process.argv[2] === SYNC_GET_CMD ? await runAgentGetSync(name, harness)
68
69
  : process.argv[2] === SYNC_PING_CMD ? await runAgentPingSync()
69
70
  : await runAgentLockSync(name);
70
71
  process.exit(code);
@@ -959,12 +960,10 @@ if (brandDisabled.size > 0) {
959
960
  if (!helpOrVersionRequested) {
960
961
  // Run update check before parsing so the upgrade notice/prompt precedes output.
961
962
  await checkForUpdates();
962
- // Surface any "behind upstream" notices from the previous detached sync, then
963
- // fire-and-forget the next background sync. System repo gets a real fast-forward
963
+ // Fire-and-forget the background sync. System repo gets a real fast-forward
964
964
  // pull (read-only locally, safe). User repo and extras get fetch-only + a
965
- // status marker that we'll print on the *next* invocation.
966
- const { spawnDetachedSync, printPendingUpdateNotices } = await import('./lib/auto-pull.js');
967
- printPendingUpdateNotices();
965
+ // status marker that `agents doctor` surfaces as a repo-behind warning.
966
+ const { spawnDetachedSync } = await import('./lib/auto-pull.js');
968
967
  spawnDetachedSync();
969
968
  }
970
969
  // First-run experience: no args + no config yet + TTY -> launch interactive setup.
@@ -1,6 +1,6 @@
1
1
  import type { EventRecord } from './events.js';
2
2
  /** Recognizable milestone events, ordered first in any activity lane. */
3
- export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created'
3
+ export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created'
4
4
  /** Deliberate agent-authored progress post (`agents feed post`). */
5
5
  | 'status.posted';
6
6
  /** Routine activity events, collapsed to counts by readers. */
@@ -117,10 +117,11 @@ export declare function formatActivityLine(ev: ActivityEvent, opts?: {
117
117
  * line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
118
118
  *
119
119
  * Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
120
- * Edit|MultiEdit) so read-only tools never pay the hook cost. Fail-open: any
121
- * error is swallowed so a logging hiccup never blocks a tool call.
120
+ * Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate) so
121
+ * read-only tools never pay the hook cost. Fail-open: any error is swallowed so
122
+ * a logging hiccup never blocks a tool call.
122
123
  */
123
- export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit). One append-only file per session; read-only tools never trigger it\nbecause the manifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\", \"status.posted\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return None, tool_name\n\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n if url:\n record[\"url\"] = url\n record[\"tool\"] = tool_name\n return record, tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n record, tool_name = build_event(payload, hook_event)\n if not record:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n if record[\"tier\"] != \"milestone\":\n try:\n if os.path.getsize(target) > MAX_LOG_BYTES:\n return\n except OSError:\n pass\n with open(target, \"a\") as f:\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
124
+ export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One\nappend-only file per session; read-only tools never trigger it because the\nmanifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\", \"task.completed\", \"checklist.created\", \"status.posted\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n# Checklist tools across harnesses. The value is the key that holds the item list.\nCHECKLIST_TOOLS = {\n \"TodoWrite\": \"todos\",\n \"todo_write\": \"todos\",\n \"TaskUpdate\": \"tasks\",\n \"update_plan\": \"plan\",\n}\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef _checklist_items(tool_input, kind):\n \"\"\"Extract normalized checklist items from tool input.\"\"\"\n if not isinstance(tool_input, dict):\n return []\n if kind == \"todos\":\n arr = tool_input.get(\"todos\") or []\n elif kind == \"tasks\":\n arr = tool_input.get(\"tasks\") or []\n if not arr and \"taskId\" in tool_input:\n arr = [tool_input]\n elif kind == \"plan\":\n arr = tool_input.get(\"plan\") or []\n else:\n return []\n if not isinstance(arr, list):\n return []\n\n items = []\n for t in arr:\n if not isinstance(t, dict):\n continue\n subject = (\n t.get(\"content\") or\n t.get(\"text\") or\n t.get(\"step\") or\n t.get(\"title\") or\n t.get(\"description\") or\n t.get(\"activeForm\") or\n \"\"\n )\n if not isinstance(subject, str):\n subject = str(subject)\n subject = subject.strip()\n item_id = t.get(\"id\") or t.get(\"taskId\") or subject\n if not item_id:\n continue\n status = str(t.get(\"status\", \"\") or \"\").lower()\n items.append({\"id\": item_id, \"subject\": subject, \"status\": status})\n return items\n\n\ndef _read_transcript_checklists(transcript_path, current_tool, current_items):\n \"\"\"Tail-read the session transcript and return the previous checklist state.\n\n The most recent checklist entry is assumed to be the current tool call if\n it carries the same ids; skip that one and return the next older checklist\n state (or None if there isn't one).\n \"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return None\n try:\n size = os.path.getsize(transcript_path)\n start = max(0, size - 512 * 1024)\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n f.seek(start)\n if start > 0:\n f.readline() # drop a leading partial line\n lines = f.readlines()\n except Exception:\n return None\n\n current_ids = {str(item.get(\"id\")) for item in current_items if item.get(\"id\")}\n skipped_current = False\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n if isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n elif record.get(\"tool_name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or tu.get(\"tool_name\") or \"\"\n if name not in CHECKLIST_TOOLS:\n continue\n kind = CHECKLIST_TOOLS[name]\n args = tu.get(\"input\") or tu.get(\"tool_input\") or tu.get(\"arguments\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n items = _checklist_items(args, kind)\n if not items:\n continue\n ids = {str(item.get(\"id\")) for item in items if item.get(\"id\")}\n # Skip the most recent matching checklist entry once; that is the\n # current call already reflected in the transcript.\n if not skipped_current and name == current_tool and ids == current_ids:\n skipped_current = True\n continue\n return items\n return None\n\n\ndef _has_previous_task_create(transcript_path, current_tool_use_id=\"\"):\n \"\"\"Return True if the transcript contains a TaskCreate before the current one.\"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return False\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return False\n\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n if (tu.get(\"name\") or tu.get(\"tool_name\")) == \"TaskCreate\":\n if tu.get(\"id\") != current_tool_use_id:\n return True\n return False\n\n\ndef _claude_task_state(transcript_path, exclude_task_id=None):\n \"\"\"Fold Claude TaskCreate/TaskUpdate calls into a task id -> subject/status map.\n\n TaskCreate provides the subject (and id via toolUseResult); TaskUpdate\n provides the status. If exclude_task_id is given, the last TaskUpdate for\n that id is skipped (it is the current call already reflected in the\n transcript).\n \"\"\"\n state = {}\n if not transcript_path or not os.path.exists(transcript_path):\n return state\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return state\n\n updates = [] # (task_id, status, line_index)\n for idx, line in enumerate(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in tool_uses:\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or \"\"\n args = tu.get(\"input\") or tu.get(\"tool_input\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n if name == \"TaskCreate\":\n subject = (\n args.get(\"subject\") or\n args.get(\"description\") or\n args.get(\"title\") or\n \"\"\n )\n if subject:\n # Link subject to the tool_use id so the result can map it.\n tool_id = tu.get(\"id\")\n if tool_id:\n state.setdefault(\"__pending_subject\", {})[tool_id] = subject\n\n tool_result = record.get(\"toolUseResult\") or {}\n if not isinstance(tool_result, dict):\n continue\n task = tool_result.get(\"task\")\n if isinstance(task, dict):\n task_id = str(task.get(\"id\") or task.get(\"taskId\") or \"\")\n if task_id:\n state.setdefault(task_id, {})\n if task.get(\"subject\"):\n state[task_id][\"subject\"] = task[\"subject\"]\n if task.get(\"status\"):\n state[task_id][\"status\"] = str(task.get(\"status\")).lower()\n # Link any pending subject from the matching tool_use id.\n tool_use_id = record.get(\"tool_use_id\") or \"\"\n pending = state.get(\"__pending_subject\", {})\n if tool_use_id and tool_use_id in pending:\n state[task_id][\"subject\"] = pending[tool_use_id]\n task_id = str(tool_result.get(\"taskId\") or \"\")\n status_change = tool_result.get(\"statusChange\") or {}\n status = status_change.get(\"to\") or tool_result.get(\"status\")\n if task_id and status:\n state.setdefault(task_id, {})\n state[task_id][\"status\"] = str(status).lower()\n updates.append((task_id, str(status).lower(), idx))\n\n if exclude_task_id and updates:\n for i in range(len(updates) - 1, -1, -1):\n if updates[i][0] == exclude_task_id:\n del updates[i]\n break\n # Rebuild statuses from remaining updates.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if \"subject\" not in state[task_id]:\n del state[task_id]\n else:\n state[task_id].pop(\"status\", None)\n for task_id, status, _ in updates:\n if task_id in state:\n state[task_id][\"status\"] = status\n\n # Drop tasks removed from the checklist (deleted/cancelled): they are gone\n # from the user-visible list, so they must not count toward the N/M total.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if state[task_id].get(\"status\") in (\"deleted\", \"cancelled\", \"canceled\", \"removed\"):\n del state[task_id]\n\n # Drop internal bookkeeping.\n state.pop(\"__pending_subject\", None)\n return state\n\n\ndef _checklist_events(payload, hook_event):\n \"\"\"Return a list of (event, detail) tuples for checklist tool calls.\"\"\"\n if hook_event != \"PostToolUse\":\n return []\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n\n if tool_name == \"TaskCreate\":\n subject = (\n tool_input.get(\"subject\") or\n tool_input.get(\"description\") or\n tool_input.get(\"title\") or\n \"task\"\n )\n # Only announce the checklist on the very first task creation.\n current_tool_use_id = payload.get(\"tool_use_id\", \"\")\n transcript_path = payload.get(\"transcript_path\")\n if _has_previous_task_create(transcript_path, current_tool_use_id):\n return []\n return [(\"checklist.created\", subject)]\n\n if tool_name not in CHECKLIST_TOOLS:\n return []\n\n kind = CHECKLIST_TOOLS[tool_name]\n items = _checklist_items(tool_input, kind)\n if not items:\n return []\n\n # Full-list tools (TodoWrite, update_plan) send the whole checklist every\n # time. Per-task update tools (TaskUpdate) send only the changed item, so\n # totals and subject must be resolved from the previous full-list state.\n list_key = {\"todos\": \"todos\", \"tasks\": \"tasks\", \"plan\": \"plan\"}.get(kind)\n is_full_list = list_key is not None and list_key in tool_input\n\n previous = _read_transcript_checklists(\n payload.get(\"transcript_path\"), tool_name, items\n )\n\n if not is_full_list and previous:\n total = len(previous)\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n done_count = len(previous_done)\n events = []\n for item in items:\n if item.get(\"status\") != \"completed\":\n continue\n item_id = str(item.get(\"id\"))\n if item_id in previous_done:\n continue\n subject = item.get(\"subject\")\n if not subject:\n for p in previous:\n if str(p.get(\"id\")) == item_id and p.get(\"subject\"):\n subject = p[\"subject\"]\n break\n if not subject:\n subject = \"task\"\n done_count += 1\n events.append((\"task.completed\", f\"{subject} {done_count}/{total} done\"))\n return events\n\n # For TaskUpdate without a previous full-list state, fold the Claude\n # TaskCreate/TaskUpdate history to resolve subject and N/M.\n if tool_name == \"TaskUpdate\" and previous is None:\n task_id = str(items[0].get(\"id\")) if items else \"\"\n task_state = _claude_task_state(\n payload.get(\"transcript_path\"), exclude_task_id=task_id\n )\n if task_state:\n total = len(task_state)\n previous_done = {\n tid for tid, info in task_state.items()\n if info.get(\"status\") == \"completed\"\n }\n done_count = len(previous_done)\n if task_id in task_state and items[0].get(\"status\") == \"completed\" and task_id not in previous_done:\n subject = task_state[task_id].get(\"subject\") or \"task\"\n return [(\"task.completed\", f\"{subject} {done_count + 1}/{total} done\")]\n return []\n\n total = len(items)\n completed = [i for i in items if i.get(\"status\") == \"completed\"]\n\n if previous is None:\n # First checklist call in this session.\n events = []\n events.append((\"checklist.created\", f\"{total} task{'s' if total != 1 else ''}\"))\n for item in completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {len(completed)}/{total} done\",\n ))\n return events\n\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n newly_completed = [\n i for i in completed\n if str(i.get(\"id\")) not in previous_done\n ]\n if not newly_completed:\n return []\n\n done_count = len(completed)\n events = []\n for item in newly_completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {done_count}/{total} done\",\n ))\n return events\n\n\ndef _make_record(event, detail, tool_name):\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n record[\"tool\"] = tool_name\n return record\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n # Checklist completions first -- cheap guard above already filtered tool name.\n checklist = _checklist_events(payload, hook_event)\n if checklist:\n return [_make_record(event, detail, tool_name) for event, detail in checklist], tool_name\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return [], tool_name\n\n record = _make_record(event, detail, tool_name)\n if url:\n record[\"url\"] = url\n return [record], tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n records, tool_name = build_event(payload, hook_event)\n if not records:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n for record in records:\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n try:\n over_limit = os.path.getsize(target) > MAX_LOG_BYTES\n except OSError:\n over_limit = False\n with open(target, \"a\") as f:\n for record in records:\n if over_limit and record.get(\"tier\") != \"milestone\":\n continue\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
124
125
  /** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
125
126
  export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
126
127
  /**