agent-control-plane 0.1.0

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 (317) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +589 -0
  3. package/SKILL.md +149 -0
  4. package/assets/workflow-catalog.json +57 -0
  5. package/bin/audit-issue-routing.sh +74 -0
  6. package/bin/issue-resource-class.sh +58 -0
  7. package/bin/label-follow-up-issues.sh +114 -0
  8. package/bin/pr-risk.sh +532 -0
  9. package/bin/sync-pr-labels.sh +112 -0
  10. package/hooks/heartbeat-hooks.sh +573 -0
  11. package/hooks/issue-reconcile-hooks.sh +217 -0
  12. package/hooks/pr-reconcile-hooks.sh +225 -0
  13. package/npm/bin/agent-control-plane.js +1984 -0
  14. package/npm/public-bin/agent-control-plane +3 -0
  15. package/package.json +61 -0
  16. package/tools/bin/agent-cleanup-worktree +247 -0
  17. package/tools/bin/agent-github-update-labels +66 -0
  18. package/tools/bin/agent-init-worktree +216 -0
  19. package/tools/bin/agent-project-archive-run +52 -0
  20. package/tools/bin/agent-project-capture-worker +46 -0
  21. package/tools/bin/agent-project-catch-up-merged-prs +137 -0
  22. package/tools/bin/agent-project-cleanup-session +244 -0
  23. package/tools/bin/agent-project-detached-launch +107 -0
  24. package/tools/bin/agent-project-heartbeat-loop +2347 -0
  25. package/tools/bin/agent-project-open-issue-worktree +89 -0
  26. package/tools/bin/agent-project-open-pr-worktree +80 -0
  27. package/tools/bin/agent-project-publish-issue-pr +349 -0
  28. package/tools/bin/agent-project-reconcile-issue-session +1128 -0
  29. package/tools/bin/agent-project-reconcile-pr-session +1005 -0
  30. package/tools/bin/agent-project-retry-state +147 -0
  31. package/tools/bin/agent-project-run-claude-session +657 -0
  32. package/tools/bin/agent-project-run-codex-resilient +718 -0
  33. package/tools/bin/agent-project-run-codex-session +316 -0
  34. package/tools/bin/agent-project-run-kilo-session +27 -0
  35. package/tools/bin/agent-project-run-openclaw-session +984 -0
  36. package/tools/bin/agent-project-run-opencode-session +27 -0
  37. package/tools/bin/agent-project-sync-anchor-repo +128 -0
  38. package/tools/bin/agent-project-worker-status +143 -0
  39. package/tools/bin/audit-agent-worktrees.sh +310 -0
  40. package/tools/bin/audit-issue-routing.sh +11 -0
  41. package/tools/bin/audit-retained-layout.sh +58 -0
  42. package/tools/bin/audit-retained-overlap.sh +135 -0
  43. package/tools/bin/audit-retained-worktrees.sh +228 -0
  44. package/tools/bin/branch-verification-guard.sh +351 -0
  45. package/tools/bin/capture-worker.sh +18 -0
  46. package/tools/bin/check-skill-contracts.sh +324 -0
  47. package/tools/bin/cleanup-worktree.sh +44 -0
  48. package/tools/bin/codex-quota +31 -0
  49. package/tools/bin/create-follow-up-issue.sh +114 -0
  50. package/tools/bin/dashboard-launchd-bootstrap.sh +38 -0
  51. package/tools/bin/flow-config-lib.sh +2127 -0
  52. package/tools/bin/flow-resident-worker-lib.sh +683 -0
  53. package/tools/bin/flow-runtime-doctor.sh +97 -0
  54. package/tools/bin/flow-shell-lib.sh +266 -0
  55. package/tools/bin/heartbeat-recovery-preflight.sh +106 -0
  56. package/tools/bin/heartbeat-safe-auto.sh +551 -0
  57. package/tools/bin/install-dashboard-launchd.sh +152 -0
  58. package/tools/bin/install-project-launchd.sh +219 -0
  59. package/tools/bin/issue-publish-scope-guard.sh +242 -0
  60. package/tools/bin/issue-requires-local-workspace-install.sh +31 -0
  61. package/tools/bin/issue-resource-class.sh +12 -0
  62. package/tools/bin/kick-scheduler.sh +75 -0
  63. package/tools/bin/label-follow-up-issues.sh +14 -0
  64. package/tools/bin/new-pr-worktree.sh +50 -0
  65. package/tools/bin/new-worktree.sh +49 -0
  66. package/tools/bin/pr-risk.sh +12 -0
  67. package/tools/bin/prepare-worktree.sh +140 -0
  68. package/tools/bin/profile-activate.sh +109 -0
  69. package/tools/bin/profile-adopt.sh +219 -0
  70. package/tools/bin/profile-smoke.sh +461 -0
  71. package/tools/bin/project-init.sh +189 -0
  72. package/tools/bin/project-launchd-bootstrap.sh +54 -0
  73. package/tools/bin/project-remove.sh +155 -0
  74. package/tools/bin/project-runtime-supervisor.sh +56 -0
  75. package/tools/bin/project-runtimectl.sh +586 -0
  76. package/tools/bin/provider-cooldown-state.sh +166 -0
  77. package/tools/bin/publish-issue-worker.sh +31 -0
  78. package/tools/bin/reconcile-issue-worker.sh +34 -0
  79. package/tools/bin/reconcile-pr-worker.sh +34 -0
  80. package/tools/bin/record-verification.sh +71 -0
  81. package/tools/bin/render-architecture-infographics.sh +110 -0
  82. package/tools/bin/render-dashboard-demo-media.sh +333 -0
  83. package/tools/bin/render-dashboard-snapshot.py +16 -0
  84. package/tools/bin/render-flow-config.sh +86 -0
  85. package/tools/bin/retry-state.sh +31 -0
  86. package/tools/bin/reuse-issue-worktree.sh +75 -0
  87. package/tools/bin/run-codex-bypass.sh +3 -0
  88. package/tools/bin/run-codex-safe.sh +3 -0
  89. package/tools/bin/run-codex-task.sh +231 -0
  90. package/tools/bin/scaffold-profile.sh +374 -0
  91. package/tools/bin/serve-dashboard.sh +5 -0
  92. package/tools/bin/split-retained-slice.sh +124 -0
  93. package/tools/bin/start-issue-worker.sh +796 -0
  94. package/tools/bin/start-pr-fix-worker.sh +458 -0
  95. package/tools/bin/start-pr-merge-repair-worker.sh +8 -0
  96. package/tools/bin/start-pr-review-worker.sh +227 -0
  97. package/tools/bin/start-resident-issue-loop.sh +908 -0
  98. package/tools/bin/sync-agent-repo.sh +52 -0
  99. package/tools/bin/sync-dependency-baseline.sh +247 -0
  100. package/tools/bin/sync-pr-labels.sh +12 -0
  101. package/tools/bin/sync-recurring-issue-checklist.sh +274 -0
  102. package/tools/bin/sync-shared-agent-home.sh +214 -0
  103. package/tools/bin/sync-vscode-workspace.sh +157 -0
  104. package/tools/bin/test-smoke.sh +63 -0
  105. package/tools/bin/uninstall-project-launchd.sh +55 -0
  106. package/tools/bin/update-github-labels.sh +14 -0
  107. package/tools/bin/worker-status.sh +19 -0
  108. package/tools/bin/workflow-catalog.sh +77 -0
  109. package/tools/dashboard/app.js +286 -0
  110. package/tools/dashboard/dashboard_snapshot.py +466 -0
  111. package/tools/dashboard/index.html +41 -0
  112. package/tools/dashboard/server.py +64 -0
  113. package/tools/dashboard/styles.css +351 -0
  114. package/tools/templates/issue-prompt-template.md +109 -0
  115. package/tools/templates/pr-fix-template.md +120 -0
  116. package/tools/templates/pr-merge-repair-template.md +91 -0
  117. package/tools/templates/pr-review-template.md +62 -0
  118. package/tools/templates/scheduled-issue-prompt-template.md +62 -0
  119. package/tools/tests/test-agent-control-plane-npm-cli.sh +279 -0
  120. package/tools/tests/test-agent-github-update-labels-falls-back-to-repository-id.sh +56 -0
  121. package/tools/tests/test-agent-project-claude-session-wrapper-clears-stale-sandbox-artifacts.sh +89 -0
  122. package/tools/tests/test-agent-project-claude-session-wrapper-does-not-retry-provider-quota.sh +82 -0
  123. package/tools/tests/test-agent-project-claude-session-wrapper-retries-transient-failures.sh +90 -0
  124. package/tools/tests/test-agent-project-claude-session-wrapper-times-out.sh +73 -0
  125. package/tools/tests/test-agent-project-claude-session-wrapper.sh +103 -0
  126. package/tools/tests/test-agent-project-cleanup-session-orphan-fallback.sh +90 -0
  127. package/tools/tests/test-agent-project-cleanup-session-skip-worktree-cleanup.sh +90 -0
  128. package/tools/tests/test-agent-project-codex-live-thread-persist.sh +76 -0
  129. package/tools/tests/test-agent-project-codex-recovery.sh +731 -0
  130. package/tools/tests/test-agent-project-codex-session-wrapper-clears-stale-sandbox-artifacts.sh +105 -0
  131. package/tools/tests/test-agent-project-codex-session-wrapper.sh +97 -0
  132. package/tools/tests/test-agent-project-open-pr-worktree-config-prefix.sh +81 -0
  133. package/tools/tests/test-agent-project-openclaw-session-wrapper-clears-stale-sandbox-artifacts.sh +109 -0
  134. package/tools/tests/test-agent-project-openclaw-session-wrapper-infers-blocked-result-contract.sh +89 -0
  135. package/tools/tests/test-agent-project-openclaw-session-wrapper-recovers-literal-env-artifacts.sh +113 -0
  136. package/tools/tests/test-agent-project-openclaw-session-wrapper-recovers-version-mismatch.sh +135 -0
  137. package/tools/tests/test-agent-project-openclaw-session-wrapper-resident.sh +179 -0
  138. package/tools/tests/test-agent-project-openclaw-session-wrapper-reuses-existing-agent-after-add-race.sh +119 -0
  139. package/tools/tests/test-agent-project-openclaw-session-wrapper-terminates-rate-limit-hang.sh +91 -0
  140. package/tools/tests/test-agent-project-openclaw-session-wrapper.sh +117 -0
  141. package/tools/tests/test-agent-project-publish-issue-pr-prunes-stale-worktree-entry.sh +148 -0
  142. package/tools/tests/test-agent-project-publish-issue-pr-reads-archived-session.sh +146 -0
  143. package/tools/tests/test-agent-project-publish-issue-pr-recovers-final-head.sh +145 -0
  144. package/tools/tests/test-agent-project-publish-issue-pr-reuses-existing-worktree.sh +147 -0
  145. package/tools/tests/test-agent-project-reconcile-failure-reason.sh +456 -0
  146. package/tools/tests/test-agent-project-reconcile-issue-archived-session-fallback.sh +96 -0
  147. package/tools/tests/test-agent-project-reconcile-issue-before-blocked.sh +90 -0
  148. package/tools/tests/test-agent-project-reconcile-issue-host-verification-recovery-uses-recovered-worktree.sh +212 -0
  149. package/tools/tests/test-agent-project-reconcile-issue-host-verification-recovery.sh +207 -0
  150. package/tools/tests/test-agent-project-reconcile-issue-provider-quota-schedules-provider-cooldown.sh +101 -0
  151. package/tools/tests/test-agent-project-reconcile-issue-session-backfills-lane-metadata-from-worker-key.sh +113 -0
  152. package/tools/tests/test-agent-project-reconcile-issue-session-clears-stale-failed-summary.sh +117 -0
  153. package/tools/tests/test-agent-project-reconcile-issue-session-initializes-shared-agent-home.sh +55 -0
  154. package/tools/tests/test-agent-project-reconcile-issue-session-normalizes-runner-state.sh +125 -0
  155. package/tools/tests/test-agent-project-reconcile-issue-session-records-invalid-contract-summary.sh +118 -0
  156. package/tools/tests/test-agent-project-reconcile-issue-session-skips-duplicate-blocked-comment.sh +144 -0
  157. package/tools/tests/test-agent-project-reconcile-issue-session-standardizes-no-commits-blocker.sh +145 -0
  158. package/tools/tests/test-agent-project-reconcile-issue-session-synthesizes-blocked-comment.sh +139 -0
  159. package/tools/tests/test-agent-project-reconcile-pr-blocked-host-recovery.sh +242 -0
  160. package/tools/tests/test-agent-project-reconcile-pr-guard-blocked-no-commit.sh +142 -0
  161. package/tools/tests/test-agent-project-reconcile-pr-provider-quota-schedules-provider-cooldown.sh +106 -0
  162. package/tools/tests/test-agent-project-reconcile-pr-session-initializes-shared-agent-home.sh +66 -0
  163. package/tools/tests/test-agent-project-reconcile-pr-updated-branch-noop.sh +129 -0
  164. package/tools/tests/test-audit-agent-worktrees-active-launch-skips-git-inspection.sh +69 -0
  165. package/tools/tests/test-audit-agent-worktrees-broken-worktree.sh +43 -0
  166. package/tools/tests/test-audit-agent-worktrees-pending-launch-owner.sh +46 -0
  167. package/tools/tests/test-audit-agent-worktrees-unreconciled-owner.sh +79 -0
  168. package/tools/tests/test-audit-issue-routing-managed-branch-globs.sh +56 -0
  169. package/tools/tests/test-branch-verification-guard-generated-artifacts.sh +72 -0
  170. package/tools/tests/test-branch-verification-guard-targeted-coverage.sh +125 -0
  171. package/tools/tests/test-codex-quota-manager-failure-driven-rotation.sh +178 -0
  172. package/tools/tests/test-codex-quota-wrapper.sh +37 -0
  173. package/tools/tests/test-contribution-docs.sh +18 -0
  174. package/tools/tests/test-control-plane-dashboard-runtime-smoke.sh +343 -0
  175. package/tools/tests/test-create-follow-up-issue.sh +73 -0
  176. package/tools/tests/test-dashboard-launchd-bootstrap.sh +55 -0
  177. package/tools/tests/test-flow-export-execution-env-exports-repo-id.sh +30 -0
  178. package/tools/tests/test-flow-export-github-cli-auth-env-prefers-git-credential.sh +48 -0
  179. package/tools/tests/test-flow-github-api-repo-fallback-preserves-input.sh +85 -0
  180. package/tools/tests/test-flow-github-api-repo-prefers-explicit-repository-id.sh +60 -0
  181. package/tools/tests/test-flow-github-issue-list-falls-back-to-repository-id.sh +64 -0
  182. package/tools/tests/test-flow-github-pr-list-falls-back-to-repository-id.sh +77 -0
  183. package/tools/tests/test-flow-resident-can-reuse-does-not-leak-metadata.sh +52 -0
  184. package/tools/tests/test-flow-resident-reap-stale-controllers.sh +63 -0
  185. package/tools/tests/test-flow-resolve-codex-quota-tools.sh +104 -0
  186. package/tools/tests/test-flow-runtime-doctor-profile-selection.sh +27 -0
  187. package/tools/tests/test-heartbeat-codex-pr-linked-issue-exclusion.sh +79 -0
  188. package/tools/tests/test-heartbeat-hooks-enqueue-resident-issue-for-idle-controller.sh +115 -0
  189. package/tools/tests/test-heartbeat-hooks-enqueue-resident-issue-for-live-lane-controller.sh +117 -0
  190. package/tools/tests/test-heartbeat-hooks-start-resident-issue-loop-claude.sh +96 -0
  191. package/tools/tests/test-heartbeat-hooks-start-resident-issue-loop-codex.sh +96 -0
  192. package/tools/tests/test-heartbeat-hooks-start-resident-issue-loop.sh +96 -0
  193. package/tools/tests/test-heartbeat-loop-auth-wait-does-not-consume-capacity.sh +170 -0
  194. package/tools/tests/test-heartbeat-loop-blocked-recovery-lane.sh +201 -0
  195. package/tools/tests/test-heartbeat-loop-blocked-recovery-vs-pr-reservation.sh +201 -0
  196. package/tools/tests/test-heartbeat-loop-idle-resident-controller-does-not-block-launches.sh +160 -0
  197. package/tools/tests/test-heartbeat-loop-pr-launch-dedup.sh +133 -0
  198. package/tools/tests/test-heartbeat-loop-provider-cooldown-suppresses-launches.sh +157 -0
  199. package/tools/tests/test-heartbeat-loop-reaps-stale-resident-controller.sh +181 -0
  200. package/tools/tests/test-heartbeat-loop-waiting-provider-resident-controller-does-not-block-launches.sh +160 -0
  201. package/tools/tests/test-heartbeat-ready-issues-blocked-recovery.sh +134 -0
  202. package/tools/tests/test-heartbeat-safe-auto-dynamic-concurrency.sh +162 -0
  203. package/tools/tests/test-heartbeat-safe-auto-no-tmux-sessions.sh +136 -0
  204. package/tools/tests/test-heartbeat-safe-auto-openclaw-skips-codex-quota.sh +139 -0
  205. package/tools/tests/test-heartbeat-safe-auto-quota-health-signal.sh +119 -0
  206. package/tools/tests/test-heartbeat-safe-auto-stale-shared-loop-pid-does-not-skip.sh +140 -0
  207. package/tools/tests/test-heartbeat-safe-auto-static-capacity-without-quota-cache.sh +142 -0
  208. package/tools/tests/test-heartbeat-safe-auto-zero-healthy-pools.sh +141 -0
  209. package/tools/tests/test-heartbeat-sync-issue-labels-empty-schedule.sh +65 -0
  210. package/tools/tests/test-heartbeat-sync-open-agent-prs-terminal-clears-running.sh +179 -0
  211. package/tools/tests/test-install-dashboard-launchd.sh +78 -0
  212. package/tools/tests/test-install-project-launchd-adds-tool-paths.sh +87 -0
  213. package/tools/tests/test-install-project-launchd.sh +110 -0
  214. package/tools/tests/test-issue-local-workspace-install-policy.sh +81 -0
  215. package/tools/tests/test-issue-publish-scope-guard-docs-signal.sh +70 -0
  216. package/tools/tests/test-issue-reconcile-hooks-success-clears-blocked.sh +36 -0
  217. package/tools/tests/test-kick-scheduler-requires-explicit-profile.sh +47 -0
  218. package/tools/tests/test-label-follow-up-issues-falls-back-to-repository-id.sh +132 -0
  219. package/tools/tests/test-manual-operator-entrypoints-require-explicit-profile.sh +64 -0
  220. package/tools/tests/test-package-funding-metadata.sh +21 -0
  221. package/tools/tests/test-package-public-metadata.sh +62 -0
  222. package/tools/tests/test-placeholder-worker-adapters.sh +38 -0
  223. package/tools/tests/test-pr-reconcile-hooks-refreshes-recurring-issue-checklist.sh +110 -0
  224. package/tools/tests/test-pr-risk-cohesive-mobile-locale-scope.sh +70 -0
  225. package/tools/tests/test-pr-risk-fix-label-semantics.sh +114 -0
  226. package/tools/tests/test-pr-risk-local-first-no-checks.sh +70 -0
  227. package/tools/tests/test-prepare-worktree-simple-repo-baseline.sh +67 -0
  228. package/tools/tests/test-profile-activate.sh +33 -0
  229. package/tools/tests/test-profile-adopt-allow-missing-repo.sh +68 -0
  230. package/tools/tests/test-profile-adopt-skip-workspace-sync-missing-file.sh +61 -0
  231. package/tools/tests/test-profile-adopt-syncs-anchor-and-workspace.sh +90 -0
  232. package/tools/tests/test-profile-smoke-collision.sh +44 -0
  233. package/tools/tests/test-profile-smoke-invalid-claude-config.sh +31 -0
  234. package/tools/tests/test-profile-smoke-invalid-provider-pool.sh +68 -0
  235. package/tools/tests/test-profile-smoke-repo-slug-mismatch.sh +36 -0
  236. package/tools/tests/test-profile-smoke.sh +45 -0
  237. package/tools/tests/test-project-init-force-and-skip-sync.sh +61 -0
  238. package/tools/tests/test-project-init-repo-slug-mismatch.sh +29 -0
  239. package/tools/tests/test-project-init.sh +66 -0
  240. package/tools/tests/test-project-launchd-bootstrap.sh +66 -0
  241. package/tools/tests/test-project-remove.sh +150 -0
  242. package/tools/tests/test-project-runtime-supervisor.sh +47 -0
  243. package/tools/tests/test-project-runtimectl-launchd.sh +115 -0
  244. package/tools/tests/test-project-runtimectl-missing-profile.sh +54 -0
  245. package/tools/tests/test-project-runtimectl-start-falls-back-to-bootstrap.sh +108 -0
  246. package/tools/tests/test-project-runtimectl-status-reports-supervisor-as-heartbeat-parent.sh +95 -0
  247. package/tools/tests/test-project-runtimectl-status-supervisor-running.sh +59 -0
  248. package/tools/tests/test-project-runtimectl-stop-cancels-pending-kick.sh +85 -0
  249. package/tools/tests/test-project-runtimectl-stop-clears-running-labels.sh +78 -0
  250. package/tools/tests/test-project-runtimectl.sh +212 -0
  251. package/tools/tests/test-provider-cooldown-state-prefers-runtime-worker-context.sh +39 -0
  252. package/tools/tests/test-provider-cooldown-state.sh +59 -0
  253. package/tools/tests/test-public-repo-docs.sh +159 -0
  254. package/tools/tests/test-reconcile-pr-worker-acp-config-routing.sh +75 -0
  255. package/tools/tests/test-render-dashboard-snapshot.sh +149 -0
  256. package/tools/tests/test-render-flow-config-demo-profile.sh +36 -0
  257. package/tools/tests/test-render-flow-config-provider-pool-fallback.sh +81 -0
  258. package/tools/tests/test-render-flow-config.sh +52 -0
  259. package/tools/tests/test-run-codex-task-claude-routing.sh +125 -0
  260. package/tools/tests/test-run-codex-task-codex-resident-routing.sh +108 -0
  261. package/tools/tests/test-run-codex-task-kilo-routing.sh +98 -0
  262. package/tools/tests/test-run-codex-task-openclaw-resident-routing.sh +117 -0
  263. package/tools/tests/test-run-codex-task-openclaw-routing.sh +113 -0
  264. package/tools/tests/test-run-codex-task-opencode-routing.sh +98 -0
  265. package/tools/tests/test-run-codex-task-provider-pool-fallback-routing.sh +146 -0
  266. package/tools/tests/test-scaffold-profile.sh +108 -0
  267. package/tools/tests/test-serve-dashboard.sh +93 -0
  268. package/tools/tests/test-start-issue-worker-blocked-context.sh +129 -0
  269. package/tools/tests/test-start-issue-worker-blocks-complete-recurring-checklist.sh +189 -0
  270. package/tools/tests/test-start-issue-worker-local-install-routing.sh +157 -0
  271. package/tools/tests/test-start-issue-worker-profile-template-routing.sh +149 -0
  272. package/tools/tests/test-start-issue-worker-recurring-resident-reuse-codex.sh +212 -0
  273. package/tools/tests/test-start-issue-worker-recurring-resident-reuse.sh +219 -0
  274. package/tools/tests/test-start-issue-worker-renders-verification-snippet.sh +155 -0
  275. package/tools/tests/test-start-issue-worker-resident-reuse-falls-back-to-new-worktree.sh +199 -0
  276. package/tools/tests/test-start-pr-fix-worker-host-blocker-context.sh +275 -0
  277. package/tools/tests/test-start-resident-issue-loop-adopts-next-recurring-issue.sh +185 -0
  278. package/tools/tests/test-start-resident-issue-loop-clears-pending-while-waiting-due.sh +152 -0
  279. package/tools/tests/test-start-resident-issue-loop-consumes-queued-lease.sh +186 -0
  280. package/tools/tests/test-start-resident-issue-loop-fails-over-provider-pool.sh +212 -0
  281. package/tools/tests/test-start-resident-issue-loop-immediate-cycles.sh +148 -0
  282. package/tools/tests/test-start-resident-issue-loop-waits-for-provider.sh +194 -0
  283. package/tools/tests/test-start-resident-issue-loop-waits-for-terminal-reconcile-status.sh +198 -0
  284. package/tools/tests/test-start-resident-issue-loop-yields-to-live-lane-controller.sh +145 -0
  285. package/tools/tests/test-sync-pr-labels-fix-lane-uses-repair-queued.sh +67 -0
  286. package/tools/tests/test-sync-recurring-issue-checklist-backfills-workflow-complete-blocker.sh +70 -0
  287. package/tools/tests/test-sync-recurring-issue-checklist.sh +95 -0
  288. package/tools/tests/test-sync-shared-agent-home-local-source-root.sh +66 -0
  289. package/tools/tests/test-sync-shared-agent-home-preserves-unrelated-workflow-catalog-skill.sh +47 -0
  290. package/tools/tests/test-test-smoke.sh +86 -0
  291. package/tools/tests/test-uninstall-project-launchd.sh +37 -0
  292. package/tools/tests/test-update-github-labels-prefers-sibling-helper.sh +49 -0
  293. package/tools/tests/test-workflow-catalog.sh +43 -0
  294. package/tools/vendor/codex-quota/LICENSE +21 -0
  295. package/tools/vendor/codex-quota/README.md +459 -0
  296. package/tools/vendor/codex-quota/codex-quota.js +261 -0
  297. package/tools/vendor/codex-quota/lib/claude-accounts.js +226 -0
  298. package/tools/vendor/codex-quota/lib/claude-oauth.js +174 -0
  299. package/tools/vendor/codex-quota/lib/claude-tokens.js +471 -0
  300. package/tools/vendor/codex-quota/lib/claude-usage.js +929 -0
  301. package/tools/vendor/codex-quota/lib/codex-accounts.js +205 -0
  302. package/tools/vendor/codex-quota/lib/codex-tokens.js +326 -0
  303. package/tools/vendor/codex-quota/lib/codex-usage.js +32 -0
  304. package/tools/vendor/codex-quota/lib/color.js +72 -0
  305. package/tools/vendor/codex-quota/lib/constants.js +57 -0
  306. package/tools/vendor/codex-quota/lib/container.js +143 -0
  307. package/tools/vendor/codex-quota/lib/display.js +1111 -0
  308. package/tools/vendor/codex-quota/lib/fs.js +63 -0
  309. package/tools/vendor/codex-quota/lib/handlers.js +2060 -0
  310. package/tools/vendor/codex-quota/lib/jwt.js +33 -0
  311. package/tools/vendor/codex-quota/lib/oauth.js +486 -0
  312. package/tools/vendor/codex-quota/lib/paths.js +34 -0
  313. package/tools/vendor/codex-quota/lib/prompts.js +44 -0
  314. package/tools/vendor/codex-quota/lib/sync.js +1438 -0
  315. package/tools/vendor/codex-quota/lib/token-match.js +96 -0
  316. package/tools/vendor/codex-quota-manager/scripts/auto-switch.sh +500 -0
  317. package/tools/vendor/codex-quota-manager/scripts/batch-add.sh +123 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Generic OAuth token matching, normalizing, and updating.
3
+ * Zero internal dependencies — pure logic module.
4
+ */
5
+
6
+ /**
7
+ * Unified OAuth token matcher — replaces both isOpenAiOauthTokenMatch and isClaudeOauthTokenMatch.
8
+ * @param {{ storedAccess?: string | null, storedRefresh?: string | null, previousAccess?: string | null, previousRefresh?: string | null, label?: string | null, storedLabel?: string | null }} params
9
+ * @returns {boolean}
10
+ */
11
+ export function isOauthTokenMatch({
12
+ storedAccess,
13
+ storedRefresh,
14
+ previousAccess,
15
+ previousRefresh,
16
+ label,
17
+ storedLabel,
18
+ }) {
19
+ if (previousRefresh && storedRefresh && storedRefresh === previousRefresh) return true;
20
+ if (previousAccess && storedAccess && storedAccess === previousAccess) return true;
21
+ if (!storedAccess && !storedRefresh && label && storedLabel && label === storedLabel) return true;
22
+ return false;
23
+ }
24
+
25
+ /**
26
+ * OpenAI token field map: canonical key → candidate keys in order of preference.
27
+ */
28
+ export const OPENAI_TOKEN_FIELDS = {
29
+ access: ["access", "access_token"],
30
+ refresh: ["refresh", "refresh_token"],
31
+ expires: ["expires", "expires_at"],
32
+ accountId: ["accountId", "account_id"],
33
+ idToken: ["idToken", "id_token"],
34
+ };
35
+
36
+ /**
37
+ * Claude token field map: canonical key → candidate keys in order of preference.
38
+ */
39
+ export const CLAUDE_TOKEN_FIELDS = {
40
+ access: ["oauthToken", "oauth_token", "accessToken", "access_token", "access"],
41
+ refresh: ["oauthRefreshToken", "oauth_refresh_token", "refreshToken", "refresh_token", "refresh"],
42
+ scopes: ["oauthScopes", "oauth_scopes", "scopes"],
43
+ expires: ["oauthExpiresAt", "oauth_expires_at", "expiresAt", "expires_at", "expires"],
44
+ };
45
+
46
+ /**
47
+ * Normalize an entry's tokens using a field map — returns canonical field values.
48
+ * @param {Record<string, unknown>} entry
49
+ * @param {Record<string, string[]>} fieldMap
50
+ * @returns {Record<string, unknown>}
51
+ */
52
+ export function normalizeEntryTokens(entry, fieldMap) {
53
+ const result = {};
54
+ for (const [canonical, candidates] of Object.entries(fieldMap)) {
55
+ let value = null;
56
+ for (const key of candidates) {
57
+ if (entry?.[key] != null) {
58
+ value = entry[key];
59
+ break;
60
+ }
61
+ }
62
+ result[canonical] = value;
63
+ }
64
+ return result;
65
+ }
66
+
67
+ /**
68
+ * Resolve which key to use for writing back into an entry.
69
+ * Returns the first candidate key that already exists in the entry, or the first candidate as default.
70
+ * @param {Record<string, unknown>} entry
71
+ * @param {string[]} candidates
72
+ * @returns {string}
73
+ */
74
+ export function resolveKey(entry, candidates) {
75
+ for (const key of candidates) {
76
+ if (key in entry) return key;
77
+ }
78
+ return candidates[0];
79
+ }
80
+
81
+ /**
82
+ * Update an entry's tokens using a field map and an account object with canonical keys.
83
+ * @param {Record<string, unknown>} entry
84
+ * @param {Record<string, unknown>} account - Object with canonical field names as keys
85
+ * @param {Record<string, string[]>} fieldMap
86
+ * @returns {Record<string, unknown>}
87
+ */
88
+ export function updateEntryTokens(entry, account, fieldMap) {
89
+ for (const [canonical, candidates] of Object.entries(fieldMap)) {
90
+ if (canonical in account) {
91
+ const key = resolveKey(entry, candidates);
92
+ entry[key] = account[canonical];
93
+ }
94
+ }
95
+ return entry;
96
+ }
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Load NVM for Node.js (required for codex-quota in agent environments).
5
+ export NVM_DIR="$HOME/.nvm"
6
+ if [[ -s "$NVM_DIR/nvm.sh" ]]; then
7
+ # shellcheck source=/dev/null
8
+ . "$NVM_DIR/nvm.sh"
9
+ if command -v node >/dev/null 2>&1; then
10
+ NODE_BIN="$(dirname "$(command -v node)")"
11
+ export PATH="$NODE_BIN:$PATH"
12
+ fi
13
+ fi
14
+
15
+ FIVE_HOUR_THRESHOLD="${CODEX_QUOTA_MANAGER_FIVE_HOUR_THRESHOLD:-70}"
16
+ WEEKLY_THRESHOLD="${CODEX_QUOTA_MANAGER_WEEKLY_THRESHOLD:-90}"
17
+ RUNNING_WORKERS="${CODEX_QUOTA_MANAGER_RUNNING_WORKERS:-0}"
18
+ ACTIVE_QUOTA_TIMEOUT_SECONDS="${CODEX_QUOTA_MANAGER_ACTIVE_TIMEOUT_SECONDS:-20}"
19
+ CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/codex-quota-manager"
20
+ STATE_FILE="${CODEX_QUOTA_MANAGER_STATE_FILE:-${CACHE_DIR}/rotation-state.json}"
21
+ SWITCH_STATE_FILE="${CODEX_QUOTA_MANAGER_SWITCH_STATE_FILE:-${CACHE_DIR}/last-switch.env}"
22
+ TRIGGER_REASON="usage-limit"
23
+ CURRENT_LABEL=""
24
+ PREFER_LABEL=""
25
+ CODEX_QUOTA_BIN="${CODEX_QUOTA_BIN:-$(command -v codex-quota 2>/dev/null || true)}"
26
+
27
+ while [[ $# -gt 0 ]]; do
28
+ case "$1" in
29
+ --mode) shift 2 ;; # legacy no-op; scheduler no longer uses preflight scanning
30
+ --trigger-reason) TRIGGER_REASON="${2:-}"; shift 2 ;;
31
+ --current-label) CURRENT_LABEL="${2:-}"; shift 2 ;;
32
+ --threshold|--five-hour-threshold) FIVE_HOUR_THRESHOLD="${2:-}"; shift 2 ;;
33
+ --weekly-threshold) WEEKLY_THRESHOLD="${2:-}"; shift 2 ;;
34
+ --running-workers) RUNNING_WORKERS="${2:-}"; shift 2 ;;
35
+ --prefer-label) PREFER_LABEL="${2:-}"; shift 2 ;;
36
+ --soft-five-hour-threshold|--soft-worker-threshold|--emergency-five-hour-threshold|--emergency-worker-threshold|--switch-cooldown-seconds)
37
+ shift 2
38
+ ;;
39
+ *)
40
+ echo "Unknown option: $1" >&2
41
+ exit 1
42
+ ;;
43
+ esac
44
+ done
45
+
46
+ if [[ -z "${CODEX_QUOTA_BIN}" || ! -x "${CODEX_QUOTA_BIN}" ]]; then
47
+ echo "Error: codex-quota not installed." >&2
48
+ exit 1
49
+ fi
50
+ if ! command -v jq >/dev/null 2>&1; then
51
+ echo "Error: jq not installed." >&2
52
+ exit 1
53
+ fi
54
+
55
+ mkdir -p "$CACHE_DIR"
56
+
57
+ run_with_timeout() {
58
+ local timeout_seconds="$1"
59
+ shift
60
+
61
+ /opt/homebrew/bin/python3 - "$timeout_seconds" "$@" <<'PY'
62
+ import os
63
+ import signal
64
+ import subprocess
65
+ import sys
66
+
67
+ timeout_seconds = float(sys.argv[1])
68
+ argv = sys.argv[2:]
69
+
70
+ if not argv:
71
+ sys.exit(64)
72
+
73
+ proc = subprocess.Popen(argv, start_new_session=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
74
+
75
+ try:
76
+ stdout, stderr = proc.communicate(timeout=timeout_seconds)
77
+ except subprocess.TimeoutExpired:
78
+ try:
79
+ os.killpg(proc.pid, signal.SIGTERM)
80
+ except ProcessLookupError:
81
+ pass
82
+ try:
83
+ stdout, stderr = proc.communicate(timeout=2)
84
+ except subprocess.TimeoutExpired:
85
+ try:
86
+ os.killpg(proc.pid, signal.SIGKILL)
87
+ except ProcessLookupError:
88
+ pass
89
+ stdout, stderr = proc.communicate()
90
+ if stdout:
91
+ sys.stdout.buffer.write(stdout)
92
+ if stderr:
93
+ sys.stderr.buffer.write(stderr)
94
+ sys.exit(124)
95
+
96
+ if stdout:
97
+ sys.stdout.buffer.write(stdout)
98
+ if stderr:
99
+ sys.stderr.buffer.write(stderr)
100
+ sys.exit(proc.returncode)
101
+ PY
102
+ }
103
+
104
+ load_state_json() {
105
+ if [[ -f "$STATE_FILE" ]] && jq -e 'type == "object"' >/dev/null 2>&1 <"$STATE_FILE"; then
106
+ jq -c '. + {accounts: (.accounts // {})}' "$STATE_FILE"
107
+ return 0
108
+ fi
109
+ printf '{"accounts":{}}\n'
110
+ }
111
+
112
+ write_state_json() {
113
+ local tmp_file="${STATE_FILE}.tmp.$$"
114
+ printf '%s\n' "$STATE_JSON" >"$tmp_file"
115
+ mv "$tmp_file" "$STATE_FILE"
116
+ }
117
+
118
+ state_mark_removed() {
119
+ local label="${1:?label required}"
120
+ local reason="${2:?reason required}"
121
+ local now_epoch="${3:?now epoch required}"
122
+ STATE_JSON="$(
123
+ jq --arg label "$label" --arg reason "$reason" --argjson now "$now_epoch" '
124
+ .accounts[$label] = ((.accounts[$label] // {}) + {
125
+ removed: true,
126
+ next_retry_at: 0,
127
+ last_reset_at: 0,
128
+ last_reason: $reason,
129
+ last_checked_at: $now,
130
+ removed_at: $now
131
+ })
132
+ ' <<<"$STATE_JSON"
133
+ )"
134
+ }
135
+
136
+ state_mark_cooldown() {
137
+ local label="${1:?label required}"
138
+ local retry_at="${2:?retry epoch required}"
139
+ local reason="${3:?reason required}"
140
+ local now_epoch="${4:?now epoch required}"
141
+ STATE_JSON="$(
142
+ jq --arg label "$label" --arg reason "$reason" --argjson retryAt "$retry_at" --argjson now "$now_epoch" '
143
+ .accounts[$label] = ((.accounts[$label] // {}) + {
144
+ removed: false,
145
+ next_retry_at: $retryAt,
146
+ last_reset_at: $retryAt,
147
+ last_reason: $reason,
148
+ last_checked_at: $now
149
+ })
150
+ ' <<<"$STATE_JSON"
151
+ )"
152
+ }
153
+
154
+ state_mark_ready() {
155
+ local label="${1:?label required}"
156
+ local reason="${2:?reason required}"
157
+ local now_epoch="${3:?now epoch required}"
158
+ STATE_JSON="$(
159
+ jq --arg label "$label" --arg reason "$reason" --argjson now "$now_epoch" '
160
+ .accounts[$label] = ((.accounts[$label] // {}) + {
161
+ removed: false,
162
+ next_retry_at: 0,
163
+ last_reset_at: 0,
164
+ last_reason: $reason,
165
+ last_checked_at: $now
166
+ })
167
+ ' <<<"$STATE_JSON"
168
+ )"
169
+ }
170
+
171
+ state_removed() {
172
+ local label="${1:?label required}"
173
+ jq -r --arg label "$label" 'if (.accounts[$label].removed // false) then "1" else "0" end' <<<"$STATE_JSON"
174
+ }
175
+
176
+ state_next_retry_at() {
177
+ local label="${1:?label required}"
178
+ jq -r --arg label "$label" '(.accounts[$label].next_retry_at // 0)' <<<"$STATE_JSON"
179
+ }
180
+
181
+ write_switch_state() {
182
+ local label="${1:?label required}"
183
+ local reason="${2:-switch}"
184
+ local now_epoch
185
+ now_epoch="$(date +%s)"
186
+ cat >"$SWITCH_STATE_FILE" <<EOF
187
+ LAST_SWITCH_EPOCH=${now_epoch}
188
+ LAST_SWITCH_LABEL=$(printf '%q' "$label")
189
+ LAST_SWITCH_REASON=$(printf '%q' "$reason")
190
+ EOF
191
+ }
192
+
193
+ load_list_json() {
194
+ local list_json
195
+ list_json="$("${CODEX_QUOTA_BIN}" codex list --json 2>/dev/null || echo '{}')"
196
+ if jq -e 'type == "object"' >/dev/null 2>&1 <<<"$list_json"; then
197
+ printf '%s\n' "$list_json"
198
+ else
199
+ printf '{}\n'
200
+ fi
201
+ }
202
+
203
+ active_label_from_list() {
204
+ jq -r '
205
+ .activeInfo.trackedLabel
206
+ // .activeInfo.activeLabel
207
+ // ([.accounts[]? | select(.isActive == true or .isNativeActive == true)][0].label)
208
+ // empty
209
+ ' <<<"$LIST_JSON"
210
+ }
211
+
212
+ ordered_candidate_labels() {
213
+ local current_label="${1:-}"
214
+ local -a ordered=()
215
+ local -a rotated=()
216
+ local label seen_labels=""
217
+
218
+ while IFS= read -r label; do
219
+ [[ -n "$label" ]] || continue
220
+ case " ${seen_labels} " in
221
+ *" ${label} "*) ;;
222
+ *)
223
+ ordered+=("$label")
224
+ seen_labels="${seen_labels} ${label}"
225
+ ;;
226
+ esac
227
+ done < <(jq -r '.accounts[]?.label // empty' <<<"$LIST_JSON")
228
+
229
+ if [[ -n "$current_label" ]]; then
230
+ local start_index=-1
231
+ local index=0
232
+ for label in "${ordered[@]}"; do
233
+ if [[ "$label" == "$current_label" ]]; then
234
+ start_index="$index"
235
+ break
236
+ fi
237
+ index=$((index + 1))
238
+ done
239
+
240
+ if (( start_index >= 0 )); then
241
+ for (( index=start_index + 1; index<${#ordered[@]}; index++ )); do
242
+ rotated+=("${ordered[index]}")
243
+ done
244
+ for (( index=0; index<start_index; index++ )); do
245
+ rotated+=("${ordered[index]}")
246
+ done
247
+ else
248
+ rotated=("${ordered[@]}")
249
+ fi
250
+ else
251
+ rotated=("${ordered[@]}")
252
+ fi
253
+
254
+ if [[ -n "$PREFER_LABEL" ]]; then
255
+ for label in "${rotated[@]}"; do
256
+ if [[ "$label" == "$PREFER_LABEL" ]]; then
257
+ printf '%s\n' "$label"
258
+ break
259
+ fi
260
+ done
261
+ fi
262
+
263
+ for label in "${rotated[@]}"; do
264
+ if [[ "$label" != "$current_label" && "$label" != "$PREFER_LABEL" ]]; then
265
+ printf '%s\n' "$label"
266
+ fi
267
+ done
268
+ }
269
+
270
+ is_auth_401_output() {
271
+ local payload="${1:-}"
272
+ grep -Eiq '(HTTP[^0-9]*)?401([^0-9]|$)|unauthorized|invalid credentials|invalid api key|authentication failed with status 401|received 401' <<<"$payload"
273
+ }
274
+
275
+ is_banned_output() {
276
+ local payload="${1:-}"
277
+ grep -Eiq 'account (is )?(banned|suspended|disabled)|access revoked|account revoked|forbidden due to policy|account blocked|policy violation' <<<"$payload"
278
+ }
279
+
280
+ load_account_quota_json() {
281
+ local label="${1:?label required}"
282
+ run_with_timeout "$ACTIVE_QUOTA_TIMEOUT_SECONDS" "${CODEX_QUOTA_BIN}" codex quota "$label" --json
283
+ }
284
+
285
+ quota_account_object() {
286
+ local label="${1:?label required}"
287
+ local quota_json="${2:-[]}"
288
+ jq -c --arg label "$label" '
289
+ ([.[] | select((.label // "") == $label)][0] // .[0] // empty)
290
+ ' <<<"$quota_json"
291
+ }
292
+
293
+ account_is_eligible() {
294
+ local label="${1:?label required}"
295
+ local quota_json="${2:-[]}"
296
+ jq -e --arg label "$label" --argjson primaryThresh "$FIVE_HOUR_THRESHOLD" --argjson weeklyThresh "$WEEKLY_THRESHOLD" '
297
+ ([.[] | select((.label // "") == $label)][0] // .[0] // null) as $account
298
+ | $account != null
299
+ and (($account.usage.rate_limit.allowed // true) == true)
300
+ and (($account.usage.rate_limit.limit_reached // false) | not)
301
+ and (($account.usage.rate_limit.primary_window.used_percent // 100) < $primaryThresh)
302
+ and (($account.usage.rate_limit.secondary_window.used_percent // 100) < $weeklyThresh)
303
+ ' >/dev/null 2>&1 <<<"$quota_json"
304
+ }
305
+
306
+ account_retry_epoch() {
307
+ local label="${1:?label required}"
308
+ local quota_json="${2:-[]}"
309
+ jq -r --arg label "$label" --argjson primaryThresh "$FIVE_HOUR_THRESHOLD" --argjson weeklyThresh "$WEEKLY_THRESHOLD" '
310
+ ([.[] | select((.label // "") == $label)][0] // .[0] // null) as $account
311
+ | if $account == null then
312
+ 0
313
+ else
314
+ [
315
+ (
316
+ if (($account.usage.rate_limit.primary_window.used_percent // 0) >= $primaryThresh
317
+ or ($account.usage.rate_limit.limit_reached // false))
318
+ then ($account.usage.rate_limit.primary_window.reset_at // 0)
319
+ else 0
320
+ end
321
+ ),
322
+ (
323
+ if (($account.usage.rate_limit.secondary_window.used_percent // 0) >= $weeklyThresh
324
+ or ($account.usage.rate_limit.limit_reached // false))
325
+ then ($account.usage.rate_limit.secondary_window.reset_at // 0)
326
+ else 0
327
+ end
328
+ )
329
+ ] | max
330
+ end
331
+ ' <<<"$quota_json"
332
+ }
333
+
334
+ switch_account() {
335
+ local label="${1:?label required}"
336
+ "${CODEX_QUOTA_BIN}" codex switch "$label"
337
+ }
338
+
339
+ note_candidate_retry() {
340
+ local label="${1:?label required}"
341
+ local retry_at="${2:?retry epoch required}"
342
+ if (( retry_at <= 0 )); then
343
+ return 0
344
+ fi
345
+ if (( SOONEST_RETRY_AT == 0 || retry_at < SOONEST_RETRY_AT )); then
346
+ SOONEST_RETRY_AT="$retry_at"
347
+ SOONEST_RETRY_LABEL="$label"
348
+ fi
349
+ }
350
+
351
+ now_epoch="$(date +%s)"
352
+ STATE_JSON="$(load_state_json)"
353
+ LIST_JSON="$(load_list_json)"
354
+ ACTIVE_LABEL="$(active_label_from_list)"
355
+ if [[ -z "$CURRENT_LABEL" ]]; then
356
+ CURRENT_LABEL="$ACTIVE_LABEL"
357
+ fi
358
+ SOONEST_RETRY_AT=0
359
+ SOONEST_RETRY_LABEL=""
360
+
361
+ printf 'TRIGGER_REASON=%s\n' "$TRIGGER_REASON"
362
+ printf 'ACTIVE_LABEL=%s\n' "$ACTIVE_LABEL"
363
+ printf 'CURRENT_LABEL=%s\n' "$CURRENT_LABEL"
364
+ printf 'RUNNING_WORKERS=%s\n' "$RUNNING_WORKERS"
365
+
366
+ case "$TRIGGER_REASON" in
367
+ usage-limit)
368
+ if [[ -n "$CURRENT_LABEL" ]]; then
369
+ current_quota_output="$(load_account_quota_json "$CURRENT_LABEL" 2>&1 || true)"
370
+ if jq -e 'type == "array" and length > 0' >/dev/null 2>&1 <<<"$current_quota_output"; then
371
+ current_retry_at="$(account_retry_epoch "$CURRENT_LABEL" "$current_quota_output")"
372
+ if [[ "$current_retry_at" =~ ^[0-9]+$ ]] && (( current_retry_at > now_epoch )); then
373
+ state_mark_cooldown "$CURRENT_LABEL" "$current_retry_at" "usage-limit" "$now_epoch"
374
+ printf 'MARKED_COOLDOWN_LABEL=%s\n' "$CURRENT_LABEL"
375
+ printf 'MARKED_COOLDOWN_UNTIL=%s\n' "$current_retry_at"
376
+ note_candidate_retry "$CURRENT_LABEL" "$current_retry_at"
377
+ fi
378
+ elif is_auth_401_output "$current_quota_output"; then
379
+ state_mark_removed "$CURRENT_LABEL" "auth-401" "$now_epoch"
380
+ printf 'REMOVED_LABEL=%s\n' "$CURRENT_LABEL"
381
+ printf 'REMOVED_REASON=auth-401\n'
382
+ elif is_banned_output "$current_quota_output"; then
383
+ state_mark_removed "$CURRENT_LABEL" "account-banned" "$now_epoch"
384
+ printf 'REMOVED_LABEL=%s\n' "$CURRENT_LABEL"
385
+ printf 'REMOVED_REASON=account-banned\n'
386
+ fi
387
+ fi
388
+ ;;
389
+ auth-401|account-banned)
390
+ if [[ -n "$CURRENT_LABEL" ]]; then
391
+ state_mark_removed "$CURRENT_LABEL" "$TRIGGER_REASON" "$now_epoch"
392
+ printf 'REMOVED_LABEL=%s\n' "$CURRENT_LABEL"
393
+ printf 'REMOVED_REASON=%s\n' "$TRIGGER_REASON"
394
+ fi
395
+ ;;
396
+ *)
397
+ ;;
398
+ esac
399
+
400
+ CANDIDATE_LABELS=()
401
+ while IFS= read -r candidate_label; do
402
+ [[ -n "$candidate_label" ]] || continue
403
+ CANDIDATE_LABELS+=("$candidate_label")
404
+ done < <(ordered_candidate_labels "$CURRENT_LABEL")
405
+
406
+ for label in "${CANDIDATE_LABELS[@]}"; do
407
+ [[ -n "$label" ]] || continue
408
+
409
+ if [[ "$(state_removed "$label")" == "1" ]]; then
410
+ continue
411
+ fi
412
+
413
+ retry_at="$(state_next_retry_at "$label")"
414
+ if [[ "$retry_at" =~ ^[0-9]+$ ]] && (( retry_at > now_epoch )); then
415
+ note_candidate_retry "$label" "$retry_at"
416
+ continue
417
+ fi
418
+
419
+ quota_output="$(load_account_quota_json "$label" 2>&1 || true)"
420
+ if ! jq -e 'type == "array" and length > 0' >/dev/null 2>&1 <<<"$quota_output"; then
421
+ if is_auth_401_output "$quota_output"; then
422
+ state_mark_removed "$label" "auth-401" "$now_epoch"
423
+ printf 'REMOVED_LABEL=%s\n' "$label"
424
+ printf 'REMOVED_REASON=auth-401\n'
425
+ elif is_banned_output "$quota_output"; then
426
+ state_mark_removed "$label" "account-banned" "$now_epoch"
427
+ printf 'REMOVED_LABEL=%s\n' "$label"
428
+ printf 'REMOVED_REASON=account-banned\n'
429
+ else
430
+ short_retry_at=$(( now_epoch + 300 ))
431
+ state_mark_cooldown "$label" "$short_retry_at" "quota-check-failed" "$now_epoch"
432
+ note_candidate_retry "$label" "$short_retry_at"
433
+ printf 'MARKED_COOLDOWN_LABEL=%s\n' "$label"
434
+ printf 'MARKED_COOLDOWN_UNTIL=%s\n' "$short_retry_at"
435
+ fi
436
+ continue
437
+ fi
438
+
439
+ if ! account_is_eligible "$label" "$quota_output"; then
440
+ retry_at="$(account_retry_epoch "$label" "$quota_output")"
441
+ if [[ "$retry_at" =~ ^[0-9]+$ ]] && (( retry_at > now_epoch )); then
442
+ state_mark_cooldown "$label" "$retry_at" "quota-window" "$now_epoch"
443
+ note_candidate_retry "$label" "$retry_at"
444
+ printf 'MARKED_COOLDOWN_LABEL=%s\n' "$label"
445
+ printf 'MARKED_COOLDOWN_UNTIL=%s\n' "$retry_at"
446
+ fi
447
+ continue
448
+ fi
449
+
450
+ set +e
451
+ switch_output="$(switch_account "$label" 2>&1)"
452
+ switch_status=$?
453
+ set -e
454
+ if (( switch_status == 0 )); then
455
+ state_mark_ready "$label" "switched" "$now_epoch"
456
+ write_state_json
457
+ write_switch_state "$label" "$TRIGGER_REASON"
458
+ printf 'SELECTED_LABEL=%s\n' "$label"
459
+ printf 'SWITCH_DECISION=switched\n'
460
+ printf 'Switching to: %s\n' "$label"
461
+ printf '%s\n' "$switch_output"
462
+ exit 0
463
+ fi
464
+
465
+ if is_auth_401_output "$switch_output"; then
466
+ state_mark_removed "$label" "auth-401" "$now_epoch"
467
+ printf 'REMOVED_LABEL=%s\n' "$label"
468
+ printf 'REMOVED_REASON=auth-401\n'
469
+ continue
470
+ fi
471
+
472
+ if is_banned_output "$switch_output"; then
473
+ state_mark_removed "$label" "account-banned" "$now_epoch"
474
+ printf 'REMOVED_LABEL=%s\n' "$label"
475
+ printf 'REMOVED_REASON=account-banned\n'
476
+ continue
477
+ fi
478
+
479
+ short_retry_at=$(( now_epoch + 300 ))
480
+ state_mark_cooldown "$label" "$short_retry_at" "switch-failed" "$now_epoch"
481
+ note_candidate_retry "$label" "$short_retry_at"
482
+ printf 'MARKED_COOLDOWN_LABEL=%s\n' "$label"
483
+ printf 'MARKED_COOLDOWN_UNTIL=%s\n' "$short_retry_at"
484
+ done
485
+
486
+ write_state_json
487
+
488
+ if (( SOONEST_RETRY_AT > 0 )); then
489
+ printf 'SWITCH_DECISION=deferred\n'
490
+ printf 'NEXT_RETRY_AT=%s\n' "$SOONEST_RETRY_AT"
491
+ if [[ -n "$SOONEST_RETRY_LABEL" ]]; then
492
+ printf 'NEXT_RETRY_LABEL=%s\n' "$SOONEST_RETRY_LABEL"
493
+ fi
494
+ printf 'No eligible Codex account is ready yet.\n'
495
+ exit 10
496
+ fi
497
+
498
+ printf 'SWITCH_DECISION=failed\n'
499
+ printf 'No eligible Codex account remains in the rotation list.\n' >&2
500
+ exit 1