@sentropic/h2a-runtime 0.81.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 (253) hide show
  1. package/dist/account-pool.d.ts +168 -0
  2. package/dist/account-pool.d.ts.map +1 -0
  3. package/dist/account-pool.js +385 -0
  4. package/dist/account-pool.js.map +1 -0
  5. package/dist/agents-projection.d.ts +56 -0
  6. package/dist/agents-projection.d.ts.map +1 -0
  7. package/dist/agents-projection.js +82 -0
  8. package/dist/agents-projection.js.map +1 -0
  9. package/dist/attach.d.ts +75 -0
  10. package/dist/attach.d.ts.map +1 -0
  11. package/dist/attach.js +432 -0
  12. package/dist/attach.js.map +1 -0
  13. package/dist/auth-bundle.d.ts +21 -0
  14. package/dist/auth-bundle.d.ts.map +1 -0
  15. package/dist/auth-bundle.js +112 -0
  16. package/dist/auth-bundle.js.map +1 -0
  17. package/dist/auth-diagnostics.d.ts +18 -0
  18. package/dist/auth-diagnostics.d.ts.map +1 -0
  19. package/dist/auth-diagnostics.js +23 -0
  20. package/dist/auth-diagnostics.js.map +1 -0
  21. package/dist/auth-login.d.ts +9 -0
  22. package/dist/auth-login.d.ts.map +1 -0
  23. package/dist/auth-login.js +21 -0
  24. package/dist/auth-login.js.map +1 -0
  25. package/dist/auth-refresh.d.ts +32 -0
  26. package/dist/auth-refresh.d.ts.map +1 -0
  27. package/dist/auth-refresh.js +83 -0
  28. package/dist/auth-refresh.js.map +1 -0
  29. package/dist/auth-tools.d.ts +55 -0
  30. package/dist/auth-tools.d.ts.map +1 -0
  31. package/dist/auth-tools.js +163 -0
  32. package/dist/auth-tools.js.map +1 -0
  33. package/dist/browser-bridge-local.d.ts +27 -0
  34. package/dist/browser-bridge-local.d.ts.map +1 -0
  35. package/dist/browser-bridge-local.js +51 -0
  36. package/dist/browser-bridge-local.js.map +1 -0
  37. package/dist/browser.d.ts +53 -0
  38. package/dist/browser.d.ts.map +1 -0
  39. package/dist/browser.js +61 -0
  40. package/dist/browser.js.map +1 -0
  41. package/dist/conductor-launch.d.ts +179 -0
  42. package/dist/conductor-launch.d.ts.map +1 -0
  43. package/dist/conductor-launch.js +414 -0
  44. package/dist/conductor-launch.js.map +1 -0
  45. package/dist/config.d.ts +182 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +334 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/conv-guard.d.ts +76 -0
  50. package/dist/conv-guard.d.ts.map +1 -0
  51. package/dist/conv-guard.js +131 -0
  52. package/dist/conv-guard.js.map +1 -0
  53. package/dist/convsync.d.ts +24 -0
  54. package/dist/convsync.d.ts.map +1 -0
  55. package/dist/convsync.js +108 -0
  56. package/dist/convsync.js.map +1 -0
  57. package/dist/cred-health.d.ts +96 -0
  58. package/dist/cred-health.d.ts.map +1 -0
  59. package/dist/cred-health.js +170 -0
  60. package/dist/cred-health.js.map +1 -0
  61. package/dist/delegate.d.ts +406 -0
  62. package/dist/delegate.d.ts.map +1 -0
  63. package/dist/delegate.js +739 -0
  64. package/dist/delegate.js.map +1 -0
  65. package/dist/enroll.d.ts +91 -0
  66. package/dist/enroll.d.ts.map +1 -0
  67. package/dist/enroll.js +248 -0
  68. package/dist/enroll.js.map +1 -0
  69. package/dist/fanout.d.ts +63 -0
  70. package/dist/fanout.d.ts.map +1 -0
  71. package/dist/fanout.js +115 -0
  72. package/dist/fanout.js.map +1 -0
  73. package/dist/forward.d.ts +66 -0
  74. package/dist/forward.d.ts.map +1 -0
  75. package/dist/forward.js +141 -0
  76. package/dist/forward.js.map +1 -0
  77. package/dist/gitdiff.d.ts +31 -0
  78. package/dist/gitdiff.d.ts.map +1 -0
  79. package/dist/gitdiff.js +133 -0
  80. package/dist/gitdiff.js.map +1 -0
  81. package/dist/h2a-bridge.d.ts +102 -0
  82. package/dist/h2a-bridge.d.ts.map +1 -0
  83. package/dist/h2a-bridge.js +333 -0
  84. package/dist/h2a-bridge.js.map +1 -0
  85. package/dist/h2a-jobs.d.ts +235 -0
  86. package/dist/h2a-jobs.d.ts.map +1 -0
  87. package/dist/h2a-jobs.js +388 -0
  88. package/dist/h2a-jobs.js.map +1 -0
  89. package/dist/h2a-ping.d.ts +38 -0
  90. package/dist/h2a-ping.d.ts.map +1 -0
  91. package/dist/h2a-ping.js +58 -0
  92. package/dist/h2a-ping.js.map +1 -0
  93. package/dist/index.d.ts +98 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +5887 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/interactive-throttle.d.ts +164 -0
  98. package/dist/interactive-throttle.d.ts.map +1 -0
  99. package/dist/interactive-throttle.js +194 -0
  100. package/dist/interactive-throttle.js.map +1 -0
  101. package/dist/lineage-client.d.ts +77 -0
  102. package/dist/lineage-client.d.ts.map +1 -0
  103. package/dist/lineage-client.js +117 -0
  104. package/dist/lineage-client.js.map +1 -0
  105. package/dist/lineage-lease.d.ts +152 -0
  106. package/dist/lineage-lease.d.ts.map +1 -0
  107. package/dist/lineage-lease.js +303 -0
  108. package/dist/lineage-lease.js.map +1 -0
  109. package/dist/llm-gateway-runtime/accounts.d.ts +48 -0
  110. package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -0
  111. package/dist/llm-gateway-runtime/accounts.js +226 -0
  112. package/dist/llm-gateway-runtime/accounts.js.map +1 -0
  113. package/dist/llm-gateway-runtime/index.d.ts +4 -0
  114. package/dist/llm-gateway-runtime/index.d.ts.map +1 -0
  115. package/dist/llm-gateway-runtime/index.js +58 -0
  116. package/dist/llm-gateway-runtime/index.js.map +1 -0
  117. package/dist/llm-gateway-runtime/model-catalog.d.ts +36 -0
  118. package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -0
  119. package/dist/llm-gateway-runtime/model-catalog.js +146 -0
  120. package/dist/llm-gateway-runtime/model-catalog.js.map +1 -0
  121. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +3 -0
  122. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -0
  123. package/dist/llm-gateway-runtime/proxy-anthropic.js +139 -0
  124. package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -0
  125. package/dist/llm-gateway-runtime/proxy-openai.d.ts +117 -0
  126. package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -0
  127. package/dist/llm-gateway-runtime/proxy-openai.js +1132 -0
  128. package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -0
  129. package/dist/llm-gateway-runtime/session-ledger.d.ts +32 -0
  130. package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -0
  131. package/dist/llm-gateway-runtime/session-ledger.js +62 -0
  132. package/dist/llm-gateway-runtime/session-ledger.js.map +1 -0
  133. package/dist/llm-gateway-runtime/sticky.d.ts +40 -0
  134. package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -0
  135. package/dist/llm-gateway-runtime/sticky.js +254 -0
  136. package/dist/llm-gateway-runtime/sticky.js.map +1 -0
  137. package/dist/llm-mesh.d.ts +102 -0
  138. package/dist/llm-mesh.d.ts.map +1 -0
  139. package/dist/llm-mesh.js +433 -0
  140. package/dist/llm-mesh.js.map +1 -0
  141. package/dist/migrate-candidates.d.ts +35 -0
  142. package/dist/migrate-candidates.d.ts.map +1 -0
  143. package/dist/migrate-candidates.js +128 -0
  144. package/dist/migrate-candidates.js.map +1 -0
  145. package/dist/migrate.d.ts +125 -0
  146. package/dist/migrate.d.ts.map +1 -0
  147. package/dist/migrate.js +561 -0
  148. package/dist/migrate.js.map +1 -0
  149. package/dist/plugin-manifest.d.ts +143 -0
  150. package/dist/plugin-manifest.d.ts.map +1 -0
  151. package/dist/plugin-manifest.js +233 -0
  152. package/dist/plugin-manifest.js.map +1 -0
  153. package/dist/plugin.d.ts +274 -0
  154. package/dist/plugin.d.ts.map +1 -0
  155. package/dist/plugin.js +919 -0
  156. package/dist/plugin.js.map +1 -0
  157. package/dist/pod-liveness.d.ts +51 -0
  158. package/dist/pod-liveness.d.ts.map +1 -0
  159. package/dist/pod-liveness.js +80 -0
  160. package/dist/pod-liveness.js.map +1 -0
  161. package/dist/profile-menu.d.ts +7 -0
  162. package/dist/profile-menu.d.ts.map +1 -0
  163. package/dist/profile-menu.js +52 -0
  164. package/dist/profile-menu.js.map +1 -0
  165. package/dist/profiles.d.ts +23 -0
  166. package/dist/profiles.d.ts.map +1 -0
  167. package/dist/profiles.js +66 -0
  168. package/dist/profiles.js.map +1 -0
  169. package/dist/protocol-local.d.ts +27 -0
  170. package/dist/protocol-local.d.ts.map +1 -0
  171. package/dist/protocol-local.js +12 -0
  172. package/dist/protocol-local.js.map +1 -0
  173. package/dist/pty.d.ts +26 -0
  174. package/dist/pty.d.ts.map +1 -0
  175. package/dist/pty.js +45 -0
  176. package/dist/pty.js.map +1 -0
  177. package/dist/readiness.d.ts +38 -0
  178. package/dist/readiness.d.ts.map +1 -0
  179. package/dist/readiness.js +169 -0
  180. package/dist/readiness.js.map +1 -0
  181. package/dist/registry.d.ts +258 -0
  182. package/dist/registry.d.ts.map +1 -0
  183. package/dist/registry.js +570 -0
  184. package/dist/registry.js.map +1 -0
  185. package/dist/relaunch.d.ts +52 -0
  186. package/dist/relaunch.d.ts.map +1 -0
  187. package/dist/relaunch.js +76 -0
  188. package/dist/relaunch.js.map +1 -0
  189. package/dist/restore.d.ts +165 -0
  190. package/dist/restore.d.ts.map +1 -0
  191. package/dist/restore.js +530 -0
  192. package/dist/restore.js.map +1 -0
  193. package/dist/run.d.ts +29 -0
  194. package/dist/run.d.ts.map +1 -0
  195. package/dist/run.js +206 -0
  196. package/dist/run.js.map +1 -0
  197. package/dist/secrets.d.ts +25 -0
  198. package/dist/secrets.d.ts.map +1 -0
  199. package/dist/secrets.js +98 -0
  200. package/dist/secrets.js.map +1 -0
  201. package/dist/session-restore.d.ts +26 -0
  202. package/dist/session-restore.d.ts.map +1 -0
  203. package/dist/session-restore.js +122 -0
  204. package/dist/session-restore.js.map +1 -0
  205. package/dist/skills-sync.d.ts +126 -0
  206. package/dist/skills-sync.d.ts.map +1 -0
  207. package/dist/skills-sync.js +255 -0
  208. package/dist/skills-sync.js.map +1 -0
  209. package/dist/smoke.d.ts +23 -0
  210. package/dist/smoke.d.ts.map +1 -0
  211. package/dist/smoke.js +138 -0
  212. package/dist/smoke.js.map +1 -0
  213. package/dist/soft-refresh.d.ts +107 -0
  214. package/dist/soft-refresh.d.ts.map +1 -0
  215. package/dist/soft-refresh.js +426 -0
  216. package/dist/soft-refresh.js.map +1 -0
  217. package/dist/sync-status.d.ts +22 -0
  218. package/dist/sync-status.d.ts.map +1 -0
  219. package/dist/sync-status.js +41 -0
  220. package/dist/sync-status.js.map +1 -0
  221. package/dist/sync.d.ts +77 -0
  222. package/dist/sync.d.ts.map +1 -0
  223. package/dist/sync.js +302 -0
  224. package/dist/sync.js.map +1 -0
  225. package/dist/throttle-signatures.d.ts +43 -0
  226. package/dist/throttle-signatures.d.ts.map +1 -0
  227. package/dist/throttle-signatures.js +116 -0
  228. package/dist/throttle-signatures.js.map +1 -0
  229. package/dist/tmux.d.ts +286 -0
  230. package/dist/tmux.d.ts.map +1 -0
  231. package/dist/tmux.js +970 -0
  232. package/dist/tmux.js.map +1 -0
  233. package/dist/tunnel.d.ts +25 -0
  234. package/dist/tunnel.d.ts.map +1 -0
  235. package/dist/tunnel.js +127 -0
  236. package/dist/tunnel.js.map +1 -0
  237. package/dist/workspace-merge.d.ts +17 -0
  238. package/dist/workspace-merge.d.ts.map +1 -0
  239. package/dist/workspace-merge.js +153 -0
  240. package/dist/workspace-merge.js.map +1 -0
  241. package/dist/workspace-sync-incremental.d.ts +64 -0
  242. package/dist/workspace-sync-incremental.d.ts.map +1 -0
  243. package/dist/workspace-sync-incremental.js +199 -0
  244. package/dist/workspace-sync-incremental.js.map +1 -0
  245. package/dist/workspace-sync.d.ts +3 -0
  246. package/dist/workspace-sync.d.ts.map +1 -0
  247. package/dist/workspace-sync.js +187 -0
  248. package/dist/workspace-sync.js.map +1 -0
  249. package/dist/workspace.d.ts +70 -0
  250. package/dist/workspace.d.ts.map +1 -0
  251. package/dist/workspace.js +173 -0
  252. package/dist/workspace.js.map +1 -0
  253. package/package.json +44 -0
@@ -0,0 +1,739 @@
1
+ /**
2
+ * `remote delegate <type> "<task>"` — P1 of cross-type agent delegation.
3
+ *
4
+ * Spawns a LIVE, INTERACTIVE agent (claude / codex / agy) in a DETACHED tmux
5
+ * session, PRIMED with the task, with the h2a MCP server in a side window (the
6
+ * existing `remote run --h2a` path) so the parent/master feedback loop works.
7
+ * Returns a job id (slug). The job is enrolled in the SAME registry as sessions
8
+ * with `role: "job"` (no second jobs.json), so `listLive` + the liveness guards
9
+ * + the atomic write are reused as-is.
10
+ *
11
+ * Isolation: by default, if the cwd is a git repo, each job runs in its OWN git
12
+ * worktree under `<cwd>/.remote/jobs/<jobId>/wt` — the single-writer guard only
13
+ * protects the conversation `.jsonl`, not the file tree, so concurrent jobs in
14
+ * the shared cwd would clobber each other. `--cwd` overrides; a non-repo cwd is
15
+ * used as-is.
16
+ *
17
+ * `--headless` is the run-once-exit variant (NOT drop-to-shell): it runs
18
+ * `claude -p` / `codex exec`, redirects stdout+stderr to `<dir>/output.log`,
19
+ * writes `<dir>/result.json {state,exitCode}`, then ENDS the tmux session.
20
+ *
21
+ * SECURITY: the task is passed as a SINGLE argv token — never concatenated into
22
+ * a shell string. The jobId / `--name` pass `assertSafeName` before becoming a
23
+ * tmux slug, a directory, or a filename.
24
+ */
25
+ import { spawnSync } from "node:child_process";
26
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { occupiesSlot } from "./registry.js";
29
+ import { humanAge } from "./migrate-candidates.js";
30
+ const DELEGATE_BIN = {
31
+ claude: "claude",
32
+ codex: "codex",
33
+ agy: "agy",
34
+ };
35
+ /** Job id / `--name`: becomes a tmux slug, a dir, a filename — keep it tame. */
36
+ const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
37
+ export function assertSafeName(name) {
38
+ if (!SAFE_NAME.test(name)) {
39
+ throw new Error(`invalid job name "${name}" (allowed: letters, digits, "_", "-")`);
40
+ }
41
+ }
42
+ export function isDelegateType(value) {
43
+ return value === "claude" || value === "codex" || value === "agy";
44
+ }
45
+ /**
46
+ * The EXACT argv for the agent binary, by type and mode. Pure, exported for
47
+ * tests. The task is ALWAYS a single trailing token (no shell concat):
48
+ * - interactive: the agent starts live, primed with the task as a positional
49
+ * initial prompt (claude/codex/agy all accept a bare positional prompt);
50
+ * - headless: a run-once-exit print mode — claude `-p`, codex `exec`. agy has
51
+ * no confirmed headless mode (design R3), so headless agy throws.
52
+ *
53
+ * Returns `{ command, args }` where args ends with the task token.
54
+ */
55
+ export function buildDelegateArgs(type, task, headless, model, effort) {
56
+ const command = DELEGATE_BIN[type];
57
+ // Flags prepended before the task/subcommand. Claude supports both
58
+ // --model and --effort; Codex exposes model via -m (global flag before
59
+ // subcommands); agy: ignore unknown flags (passed positional only).
60
+ const modelFlags = (() => {
61
+ if (!model && !effort)
62
+ return [];
63
+ switch (type) {
64
+ case "claude": {
65
+ const flags = [];
66
+ if (model)
67
+ flags.push("--model", model);
68
+ if (effort)
69
+ flags.push("--effort", effort);
70
+ return flags;
71
+ }
72
+ case "codex":
73
+ return model ? ["-m", model] : [];
74
+ case "agy":
75
+ return [];
76
+ }
77
+ })();
78
+ if (!headless) {
79
+ // Interactive + initial prompt — task is the last argv token.
80
+ return { command, args: [...modelFlags, task] };
81
+ }
82
+ switch (type) {
83
+ case "claude":
84
+ return { command, args: [...modelFlags, "-p", task] };
85
+ case "codex":
86
+ return { command, args: [...modelFlags, "exec", task] };
87
+ case "agy":
88
+ throw new Error("agy has no confirmed headless mode — run it interactively (drop --headless)");
89
+ }
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // Reliability slice 1 — rate-limit "throttled" RESUME (HEADLESS LOCAL only).
93
+ //
94
+ // When a HEADLESS LOCAL job finished on a transient provider rate-limit, the
95
+ // conductor RELAUNCHES the SAME job in its `runCwd` with the tool's CONTINUE
96
+ // flag so it picks up its prior conversation instead of starting over. The
97
+ // continue flag is derived from the job's type; the task still rides as a single
98
+ // trailing argv token (NEVER `bash -lc` string-concat — same safe channel as
99
+ // `buildDelegateArgs`). PURE arg derivation, exported for tests.
100
+ // ---------------------------------------------------------------------------
101
+ /** Hard cap on auto-resume attempts before a throttled job is failed `rate-limited`. */
102
+ export const THROTTLE_MAX_ATTEMPTS = 6;
103
+ /** Backoff base (ms) for the resume schedule. */
104
+ export const THROTTLE_BACKOFF_BASE_MS = 60_000; // 60s
105
+ /** Backoff cap (ms) for the resume schedule. */
106
+ export const THROTTLE_BACKOFF_CAP_MS = 30 * 60_000; // 30 min
107
+ /**
108
+ * The EXACT argv to RESUME a throttled HEADLESS job, by type. The job continues
109
+ * its prior conversation (rather than re-running from scratch) and re-prints to
110
+ * the same log, so a fresh success settles it `done`:
111
+ * - claude: `-p --continue <task>` (print mode, continue the last conversation).
112
+ * - codex: `exec resume --last <task>` (resume the most recent rollout).
113
+ * - agy: no confirmed headless mode → throws (same as buildDelegateArgs).
114
+ *
115
+ * The task stays a single trailing token (no shell concat). Pure, exported for
116
+ * tests.
117
+ *
118
+ * TODO(phase-2, interactive): an INTERACTIVE throttled tmux job would instead be
119
+ * nudged via `send-keys` (guarded by an attached-pane check so we never type
120
+ * into a pane the user is driving). Not in this slice.
121
+ */
122
+ export function buildThrottleResumeArgs(type, task) {
123
+ const command = DELEGATE_BIN[type];
124
+ switch (type) {
125
+ case "claude":
126
+ return { command, args: ["-p", "--continue", task] };
127
+ case "codex":
128
+ return { command, args: ["exec", "resume", "--last", task] };
129
+ case "agy":
130
+ throw new Error("agy has no confirmed headless resume mode — interactive resume is phase 2");
131
+ }
132
+ }
133
+ /**
134
+ * Full-jitter exponential backoff delay for the throttle RESUME schedule —
135
+ * copied from the session-agent's `websocket-transport.ts` `jitteredDelay`
136
+ * (same formula, kept local so this module stays pure/dependency-free).
137
+ * Returns a random value in [0, min(cap, base * 2^attempt)]. `attempt` is
138
+ * 0-based (the FIRST throttle passes attempt 0). `rand` is injectable so tests
139
+ * pin the jitter. Pure, exported for tests.
140
+ */
141
+ export function jitteredBackoffMs(attempt, baseMs = THROTTLE_BACKOFF_BASE_MS, capMs = THROTTLE_BACKOFF_CAP_MS, rand = Math.random) {
142
+ const ceiling = Math.min(capMs, baseMs * Math.pow(2, Math.max(0, attempt)));
143
+ return rand() * ceiling;
144
+ }
145
+ /**
146
+ * The CEILING of the full-jitter window for a given attempt: `min(cap, base *
147
+ * 2^attempt)`. Monotonic non-decreasing in `attempt`, capped at `capMs`. The
148
+ * jittered delay is always in `[0, ceiling]`. Pure, exported so tests can assert
149
+ * the schedule is monotonic + bounded without depending on the random draw.
150
+ */
151
+ export function backoffCeilingMs(attempt, baseMs = THROTTLE_BACKOFF_BASE_MS, capMs = THROTTLE_BACKOFF_CAP_MS) {
152
+ return Math.min(capMs, baseMs * Math.pow(2, Math.max(0, attempt)));
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Reliability slice 1 — AIMD circuit breaker for ADMITTING pending jobs.
156
+ //
157
+ // A provider rate-limit is ACCOUNT-WIDE: if jobs are already throttling, starting
158
+ // MORE pending jobs just burns the same quota and they throttle too. So we
159
+ // MULTIPLICATIVELY DECREASE the effective admission cap when a burst of throttles
160
+ // is observed (≥2 jobs entered `throttled` within the last 10 min → halve), and
161
+ // ADDITIVELY INCREASE it (+1) on every clean pass (no new throttle), restoring up
162
+ // to the configured cap. Classic AIMD. PURE: given (configuredCap,
163
+ // throttleHistory, now[, lastCap]) it returns the effective cap; the conductor
164
+ // keeps `lastCap` across passes. Exported for tests.
165
+ //
166
+ // `throttled` jobs KEEP their slot (registry.occupiesSlot), so this only governs
167
+ // admitting NEW `pending` work — it never evicts a throttled job.
168
+ // ---------------------------------------------------------------------------
169
+ /** Window over which throttle events are counted for the breaker (10 min). */
170
+ export const AIMD_WINDOW_MS = 10 * 60_000;
171
+ /** ≥ this many throttles in the window trips the multiplicative decrease. */
172
+ export const AIMD_TRIP_THRESHOLD = 2;
173
+ /**
174
+ * The effective admission cap under AIMD. Pure.
175
+ *
176
+ * - Count throttle events with `firstAt`/`at` within `[now - AIMD_WINDOW_MS, now]`.
177
+ * - If that count ≥ AIMD_TRIP_THRESHOLD → HALVE the previous effective cap
178
+ * (`Math.floor`), floored at 1 (never admit zero — a single slot keeps the
179
+ * queue making progress so the breaker can observe recovery).
180
+ * - Else (a clean pass) → ADDITIVELY restore +1, capped at `configuredCap`.
181
+ * - Always clamped to `[1, configuredCap]` (never below 1, never above the
182
+ * configured cap). A `configuredCap <= 0` returns 0 (a degenerate config
183
+ * admits nothing — mirrors planNextStarts).
184
+ *
185
+ * `lastCap` is the effective cap from the PREVIOUS pass (defaults to
186
+ * `configuredCap` on the first pass — we start fully open). `recentThrottles` is
187
+ * the list of throttle-event timestamps (ISO or ms) the conductor tracks.
188
+ */
189
+ export function aimdEffectiveCap(configuredCap, recentThrottles, now = Date.now(), lastCap) {
190
+ if (configuredCap <= 0)
191
+ return 0;
192
+ const prev = lastCap === undefined
193
+ ? configuredCap
194
+ : Math.min(configuredCap, Math.max(1, Math.trunc(lastCap)));
195
+ const windowStart = now - AIMD_WINDOW_MS;
196
+ const inWindow = recentThrottles.filter((t) => {
197
+ const ms = typeof t === "number" ? t : Date.parse(t);
198
+ return Number.isFinite(ms) && ms >= windowStart && ms <= now;
199
+ }).length;
200
+ let next;
201
+ if (inWindow >= AIMD_TRIP_THRESHOLD) {
202
+ next = Math.max(1, Math.floor(prev / 2)); // multiplicative decrease
203
+ }
204
+ else {
205
+ next = prev + 1; // additive increase
206
+ }
207
+ // Clamp to [1, configuredCap].
208
+ if (next < 1)
209
+ next = 1;
210
+ if (next > configuredCap)
211
+ next = configuredCap;
212
+ return next;
213
+ }
214
+ /**
215
+ * Decide what to do with a HEADLESS LOCAL job whose latest finished run was
216
+ * classified as a transient rate-limit. PURE — no clock/registry/IO; the caller
217
+ * passes `now`, the prior `throttle` bookkeeping (undefined on the first hit),
218
+ * the matched `signature`, and a `delay` (a jittered backoff in ms, computed via
219
+ * `jitteredBackoffMs`).
220
+ *
221
+ * - First/next throttle while `attempts < maxAttempts`: bump `attempts`,
222
+ * preserve `firstAt`, schedule `nextRetryAt = now + delay`.
223
+ * - When the NEXT attempt would exceed `maxAttempts` (i.e. prior attempts already
224
+ * == maxAttempts): give up → `{action:"fail", reason:"rate-limited"}`.
225
+ *
226
+ * Exported for tests.
227
+ */
228
+ export function planThrottleStep(opts) {
229
+ const max = opts.maxAttempts ?? THROTTLE_MAX_ATTEMPTS;
230
+ const priorAttempts = opts.prior?.attempts ?? 0;
231
+ if (priorAttempts >= max) {
232
+ return { action: "fail", reason: "rate-limited" };
233
+ }
234
+ const nowIso = new Date(opts.nowMs).toISOString();
235
+ const attempts = priorAttempts + 1;
236
+ return {
237
+ action: "throttle",
238
+ attempts,
239
+ firstAt: opts.prior?.firstAt ?? nowIso,
240
+ nextRetryAt: new Date(opts.nowMs + opts.delayMs).toISOString(),
241
+ ...(opts.signature !== undefined ? { signature: opts.signature } : {}),
242
+ };
243
+ }
244
+ /**
245
+ * Is a `throttled` job DUE for resume right now (now >= nextRetryAt)? Pure,
246
+ * tolerant of a missing/unparseable `nextRetryAt` (treated as due — better to
247
+ * retry than to strand a job forever). Exported for tests.
248
+ */
249
+ export function isThrottleResumeDue(throttle, nowMs) {
250
+ const at = throttle?.nextRetryAt;
251
+ if (at === undefined)
252
+ return true;
253
+ const ms = Date.parse(at);
254
+ if (!Number.isFinite(ms))
255
+ return true;
256
+ return nowMs >= ms;
257
+ }
258
+ /**
259
+ * Minutes-until-retry label for a `throttled` row (`jobs ls`), e.g. "retry in
260
+ * 3m" / "retry now". Pure, exported for tests. Rounds UP so "<1 min away" still
261
+ * reads as 1m rather than 0.
262
+ */
263
+ export function throttleRetryLabel(throttle, nowMs = Date.now()) {
264
+ const at = throttle?.nextRetryAt;
265
+ if (at === undefined)
266
+ return "retry now";
267
+ const ms = Date.parse(at);
268
+ if (!Number.isFinite(ms) || ms <= nowMs)
269
+ return "retry now";
270
+ const mins = Math.ceil((ms - nowMs) / 60_000);
271
+ return `retry in ${mins}m`;
272
+ }
273
+ /**
274
+ * P2 — REMOTE delegation. Map a (type, task) to the control-plane session body
275
+ * for a delegated agent running in a Pod. The agent CLI is the session PROFILE
276
+ * (profiles claude/codex/agy launch the matching binary on the Pod), and the
277
+ * task rides the ALREADY-SAFE argv channel `startupArgs` — exactly the args that
278
+ * follow the binary, which the session-agent reads from `SESSION_STARTUP_ARGS`
279
+ * (a JSON array, never a `bash -lc` string). We reuse `buildDelegateArgs` and
280
+ * DROP its `command` (the profile already provides the binary): `args` is what
281
+ * trails it (the bare task token interactive, `-p <task>` / `exec <task>`
282
+ * headless). Pure, exported for tests.
283
+ *
284
+ * SECURITY: the task stays a single argv element through `startupArgs` → JSON →
285
+ * the agent's argv; it is NEVER concatenated into a shell string.
286
+ */
287
+ export function buildRemoteDelegate(type, task, headless, model, effort) {
288
+ const { args } = buildDelegateArgs(type, task, headless, model, effort);
289
+ return { profile: type, startupArgs: args };
290
+ }
291
+ // ---------------------------------------------------------------------------
292
+ // P4 — concurrency cap + queue + spawn-depth (pure, conductor-driving)
293
+ // ---------------------------------------------------------------------------
294
+ /**
295
+ * Default concurrency cap (Hermes `max_concurrent_children`), applied to BOTH
296
+ * local AND remote jobs: the shared RWX volume is mounted RW across all nodes
297
+ * (subPath per job — no CSI packing limit, design F3 correction), so remote is
298
+ * not sequential. A new `delegate` beyond `running` jobs is enrolled `pending`.
299
+ */
300
+ export const DEFAULT_MAX_CONCURRENT = 16;
301
+ /** Default / min / max spawn depth (Hermes `max_spawn_depth`, clamp 1–3). */
302
+ export const DEFAULT_MAX_DEPTH = 1;
303
+ export const MIN_MAX_DEPTH = 1;
304
+ export const MAX_MAX_DEPTH = 3;
305
+ /** The env channel carrying the REMAINING delegation depth into a job's agent. */
306
+ export const DEPTH_ENV = "REMOTE_DELEGATE_DEPTH";
307
+ /**
308
+ * Env channel carrying a delegated job's id INTO its agent process (H1). The
309
+ * local-tmux spawn stamps `REMOTE_JOB_ID=<jobId>` so the claude SessionStart/End
310
+ * hooks — which only receive claude's conversation uuid (`session_id`), never the
311
+ * job slug — can resolve the job and complete it. Without this, an interactive
312
+ * tmux job never advances past `running`.
313
+ */
314
+ export const JOB_ID_ENV = "REMOTE_JOB_ID";
315
+ /**
316
+ * Clamp a requested `--max-depth` into [1, 3] (à la Hermes). A missing /
317
+ * non-finite value falls back to the default (1). Pure, exported for tests.
318
+ */
319
+ export function clampDepth(requested) {
320
+ if (requested === undefined || !Number.isFinite(requested)) {
321
+ return DEFAULT_MAX_DEPTH;
322
+ }
323
+ const n = Math.trunc(requested);
324
+ if (n < MIN_MAX_DEPTH)
325
+ return MIN_MAX_DEPTH;
326
+ if (n > MAX_MAX_DEPTH)
327
+ return MAX_MAX_DEPTH;
328
+ return n;
329
+ }
330
+ /**
331
+ * The depth a job INHERITS from its parent, read off `REMOTE_DELEGATE_DEPTH`
332
+ * (decremented at each launch). When a `delegate` runs OUTSIDE a job (no env),
333
+ * the requested `--max-depth` is the budget. When it runs INSIDE a job, the
334
+ * inherited budget wins (a job cannot grant itself more than it was given).
335
+ * Pure, exported for tests; `env` is injectable.
336
+ *
337
+ * Returns the budget the CURRENT `delegate` may spend:
338
+ * - no env (top-level): clamp(requestedMaxDepth).
339
+ * - env present: the inherited value (already a remaining budget), clamped
340
+ * to [0, 3] — 0 means "this job may not delegate further".
341
+ */
342
+ export function inheritedDepthBudget(requestedMaxDepth, env = process.env) {
343
+ const raw = env[DEPTH_ENV];
344
+ if (raw === undefined)
345
+ return clampDepth(requestedMaxDepth);
346
+ const parsed = Number.parseInt(raw, 10);
347
+ if (!Number.isFinite(parsed))
348
+ return clampDepth(requestedMaxDepth);
349
+ if (parsed < 0)
350
+ return 0;
351
+ if (parsed > MAX_MAX_DEPTH)
352
+ return MAX_MAX_DEPTH;
353
+ return parsed;
354
+ }
355
+ /**
356
+ * May a `delegate` proceed given its current depth budget? A budget of 0 means
357
+ * the calling job has exhausted its delegation depth and must REFUSE. Pure.
358
+ */
359
+ export function canDelegateAtDepth(budget) {
360
+ return budget > 0;
361
+ }
362
+ /**
363
+ * REMOTE depth clamp (YAGNI/safety): there is NO env channel carrying
364
+ * `REMOTE_DELEGATE_DEPTH` into a Pod (`createRemoteSession` only passes
365
+ * `startupArgs`, and the session-agent doesn't forward a depth budget), so a
366
+ * remote job could NOT enforce a budget even if we recorded one > 1. Until that
367
+ * channel exists we clamp a REMOTE job's recorded budget to AT MOST 1 — i.e. a
368
+ * job-in-a-Pod does not re-delegate. (Local jobs DO inherit the budget via the
369
+ * env, so they keep the full 1–3 range.) Pure, exported for tests.
370
+ */
371
+ export function clampRemoteDepthBudget(budget) {
372
+ return Math.min(budget, 1);
373
+ }
374
+ /**
375
+ * The depth value to PROPAGATE to a freshly launched job: the parent's budget
376
+ * minus one (never below 0), so the child knows how much further it may go.
377
+ * Returned as the string the env channel carries. Pure, exported for tests.
378
+ */
379
+ export function childDepthEnvValue(parentBudget) {
380
+ return String(Math.max(0, parentBudget - 1));
381
+ }
382
+ /**
383
+ * Decide which `pending` jobs to START this pass, given the cap. Pure, the heart
384
+ * of the queue: count jobs currently occupying a slot (`running`), and admit
385
+ * `pending` jobs (oldest first — FIFO by `enrolledAt`) until `running` reaches
386
+ * the cap. Terminal jobs (done/failed) free their slot. Returns the ids to
387
+ * start, in admission order. Exported for tests.
388
+ *
389
+ * `cap <= 0` admits nothing (a degenerate config never starts work).
390
+ */
391
+ export function planNextStarts(jobs, cap) {
392
+ if (cap <= 0)
393
+ return [];
394
+ const onlyJobs = jobs.filter((j) => j.role === "job");
395
+ // `running` AND `throttled` jobs occupy a slot (a throttled job keeps its slot
396
+ // — the rate-limit is account-wide; see registry.occupiesSlot).
397
+ const running = onlyJobs.filter((j) => occupiesSlot(j.jobState ?? "pending")).length;
398
+ let free = cap - running;
399
+ if (free <= 0)
400
+ return [];
401
+ const pending = onlyJobs
402
+ .filter((j) => (j.jobState ?? "pending") === "pending")
403
+ .slice()
404
+ .sort((a, b) => Date.parse(a.enrolledAt) - Date.parse(b.enrolledAt));
405
+ const out = [];
406
+ for (const j of pending) {
407
+ if (free <= 0)
408
+ break;
409
+ out.push(j.id);
410
+ free -= 1;
411
+ }
412
+ return out;
413
+ }
414
+ /**
415
+ * Is there room to start a job RIGHT NOW (used by `delegate` to decide launch vs
416
+ * enqueue)? True when `running < cap`. Pure, exported for tests.
417
+ */
418
+ export function hasFreeSlot(jobs, cap) {
419
+ if (cap <= 0)
420
+ return false;
421
+ const running = jobs.filter((j) => j.role === "job" && occupiesSlot(j.jobState ?? "pending")).length;
422
+ return running < cap;
423
+ }
424
+ // ---------------------------------------------------------------------------
425
+ // P4 — track mirror (job graph = backlog). PURE arg derivation; best-effort.
426
+ // ---------------------------------------------------------------------------
427
+ /**
428
+ * The `track item new` argv that mirrors a delegated job as a child item under
429
+ * the workpackage `wpId`. PURE (no spawn): the caller resolves the track binary
430
+ * (realpath) and runs it best-effort. `--role item`, parented under the WP, with
431
+ * a stable title derived from the job. Exported for tests.
432
+ *
433
+ * SECURITY: jobId is assertSafeName-checked at delegate time; wpId/task ride
434
+ * structured argv (never a shell string).
435
+ */
436
+ export function trackItemNewArgs(wpId, job) {
437
+ const title = job.task && job.task.length > 0 ? job.task : `job ${job.id}`;
438
+ return [
439
+ "item",
440
+ "new",
441
+ "--parent",
442
+ wpId,
443
+ "--role",
444
+ "item",
445
+ "--title",
446
+ title,
447
+ "--ref",
448
+ `job:${job.id}`,
449
+ ];
450
+ }
451
+ /**
452
+ * The `track item realize` argv to close the mirror item when a job reaches a
453
+ * terminal state. We address the item by the same `job:<id>` ref we created it
454
+ * with. PURE; the caller runs it best-effort. Exported for tests.
455
+ */
456
+ export function trackItemRealizeArgs(job) {
457
+ return ["item", "realize", "--ref", `job:${job.id}`];
458
+ }
459
+ /**
460
+ * Resolve the `track` binary to its REALPATH (some track versions have an
461
+ * entrypoint guard that breaks through the npm-global bin symlink — same note as
462
+ * config.ts PluginMcp). Returns `node <realpath>` argv, or undefined when track
463
+ * is not installed. Best-effort, exported for tests (the `which`+realpath are
464
+ * injectable).
465
+ */
466
+ export function resolveTrackBin(which = defaultWhich, realpath = (p) => p) {
467
+ const found = which("track");
468
+ if (!found)
469
+ return undefined;
470
+ try {
471
+ return { command: "node", prefix: [realpath(found)] };
472
+ }
473
+ catch {
474
+ return { command: found, prefix: [] };
475
+ }
476
+ }
477
+ function defaultWhich(bin) {
478
+ const r = spawnSync("sh", ["-lc", `command -v ${bin}`], {
479
+ encoding: "utf8",
480
+ stdio: ["ignore", "pipe", "ignore"],
481
+ });
482
+ const out = r.status === 0 ? r.stdout.trim() : "";
483
+ return out.length > 0 ? out : undefined;
484
+ }
485
+ /**
486
+ * Best-effort track-mirror call: resolve `track`, run `track <args>` in `cwd`,
487
+ * swallow ANY failure (track absent/erroring must NEVER couple delivery to
488
+ * track). Returns whether it ran. The runner is injectable for tests.
489
+ */
490
+ export function runTrackMirror(args, cwd, run) {
491
+ try {
492
+ if (run) {
493
+ run(args);
494
+ return true;
495
+ }
496
+ const bin = resolveTrackBin();
497
+ if (!bin)
498
+ return false;
499
+ spawnSync(bin.command, [...bin.prefix, ...args], { cwd, stdio: "ignore" });
500
+ return true;
501
+ }
502
+ catch {
503
+ return false;
504
+ }
505
+ }
506
+ /** Per-job directory under the ORIGIN cwd (where worktree + logs live). */
507
+ export function jobDir(originCwd, jobId) {
508
+ return join(originCwd, ".remote", "jobs", jobId);
509
+ }
510
+ /**
511
+ * The terminal result a HEADLESS job wrote to `result.json` (run-once-exit), or
512
+ * undefined when absent / unreadable. Lets supervision reconcile a finished
513
+ * headless job (whose tmux session has ended) to its REAL state instead of the
514
+ * default "session-gone → failed". Pure-ish (reads one file), injectable read.
515
+ */
516
+ export function readJobResult(originCwd, jobId, read = (p) => readFileSync(p, "utf8")) {
517
+ const path = join(jobDir(originCwd, jobId), "result.json");
518
+ try {
519
+ const parsed = JSON.parse(read(path));
520
+ if ((parsed.state === "done" || parsed.state === "failed") &&
521
+ typeof parsed.exitCode === "number") {
522
+ return { state: parsed.state, exitCode: parsed.exitCode };
523
+ }
524
+ }
525
+ catch {
526
+ // missing / unreadable / malformed → no result yet
527
+ }
528
+ return undefined;
529
+ }
530
+ function defaultIsGitRepo(cwd) {
531
+ if (!existsSync(cwd))
532
+ return false;
533
+ const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
534
+ cwd,
535
+ encoding: "utf8",
536
+ stdio: ["ignore", "pipe", "ignore"],
537
+ });
538
+ return r.status === 0 && r.stdout.trim() === "true";
539
+ }
540
+ function defaultRunGit(cmd, args, cwd) {
541
+ const r = spawnSync(cmd, [...args], { cwd, stdio: "inherit" });
542
+ return { status: r.status };
543
+ }
544
+ /**
545
+ * Decide WHERE the job runs and create isolation if warranted:
546
+ * - `--cwd <path>` → that path, as-is (caller owns isolation).
547
+ * - else if the origin cwd is a git repo → a dedicated worktree at
548
+ * `<originCwd>/.remote/jobs/<jobId>/wt` (so concurrent jobs never clobber the
549
+ * working tree; the single-writer guard only protects the `.jsonl`).
550
+ * - else → the origin cwd, as-is (non-repo: nothing to isolate cheaply).
551
+ *
552
+ * Throws if the worktree creation fails (we must NOT silently run in the shared
553
+ * cwd, which would defeat isolation).
554
+ */
555
+ export function resolveJobCwd(originCwd, jobId, opts = {}) {
556
+ if (opts.explicitCwd !== undefined) {
557
+ return { runCwd: opts.explicitCwd, isolated: false };
558
+ }
559
+ const isRepo = (opts.isGitRepo ?? defaultIsGitRepo)(originCwd);
560
+ if (!isRepo)
561
+ return { runCwd: originCwd, isolated: false };
562
+ const dir = jobDir(originCwd, jobId);
563
+ const wt = join(dir, "wt");
564
+ (opts.mkdir ?? ((p) => mkdirSync(p, { recursive: true })))(dir);
565
+ const run = opts.runGit ?? defaultRunGit;
566
+ // Detached worktree on current HEAD — a fresh, independent file tree.
567
+ const r = run("git", ["worktree", "add", "--detach", wt, "HEAD"], originCwd);
568
+ if (r.status !== 0) {
569
+ throw new Error(`git worktree add failed (exit ${r.status ?? "?"}) for job ${jobId} — ` +
570
+ `pass --cwd to run in an explicit directory instead`);
571
+ }
572
+ return { runCwd: wt, isolated: true };
573
+ }
574
+ /**
575
+ * The DISPLAY state of a job, reconciling its persisted `jobState` with live
576
+ * liveness: a job still marked `running`/`pending` whose tmux session is gone
577
+ * has crashed/finished → show `failed` (P1 has no headless-success poll in the
578
+ * pure layer; the headless result.json is surfaced by `jobs status`). A job
579
+ * already in a terminal state keeps it. Pure, exported for tests.
580
+ */
581
+ export function reconcileJobState(entry, live) {
582
+ const persisted = entry.jobState ?? "pending";
583
+ if (persisted === "done" || persisted === "failed")
584
+ return persisted;
585
+ // A `throttled` job has DELIBERATELY finished its run (its tmux session is gone
586
+ // — not live) and is waiting for the conductor's backoff resume. Its
587
+ // non-liveness is expected, NOT a crash → keep `throttled` (the resume path /
588
+ // attempt cap drives it terminal). Reliability slice 1.
589
+ if (persisted === "throttled")
590
+ return "throttled";
591
+ if (entry.endedAt)
592
+ return "failed";
593
+ if (!live)
594
+ return "failed";
595
+ return persisted;
596
+ }
597
+ /**
598
+ * P2 — CLUSTER reconciliation for REMOTE jobs (`kind:"remote"`). The registry
599
+ * reports a remote entry as `isLive` ALWAYS (it can't probe the cluster —
600
+ * registry.ts), so a delegated Pod that died/finished would stay "running"
601
+ * forever. Given the set of session ids the control-plane still lists
602
+ * (`listRemoteSessions`), this decides the transitions to apply: a remote job
603
+ * still in a non-terminal state whose `remoteId` is NOT in the live set has
604
+ * ended → `done` when it left a `result.json` (success/known exit), else
605
+ * `failed`. Jobs whose Pod is still listed, or already terminal, are untouched.
606
+ * Pure (the result read is injected), exported for tests.
607
+ */
608
+ export function reconcileRemoteJobs(jobs, liveRemoteIds, readResult) {
609
+ const out = [];
610
+ for (const job of jobs) {
611
+ if (job.kind !== "remote" || job.role !== "job")
612
+ continue;
613
+ const state = job.jobState ?? "pending";
614
+ if (state === "done" || state === "failed")
615
+ continue;
616
+ // The Pod is still listed by the control-plane → leave the job alone.
617
+ if (job.remoteId !== undefined && liveRemoteIds.has(job.remoteId))
618
+ continue;
619
+ const result = readResult(job);
620
+ out.push({ id: job.id, to: result?.state ?? "failed" });
621
+ }
622
+ return out;
623
+ }
624
+ /**
625
+ * M2 — SWEEP stale jobs. A job stuck in a non-terminal `running` /
626
+ * `awaiting-decision`-ish state (we only persist `running`; awaiting-decision is
627
+ * a DISPLAY overlay, so a job awaiting a decision is `running` on disk) whose
628
+ * runtime signal was lost — NOT live AND no `result.json` AND older than
629
+ * `maxAgeMs` — would otherwise occupy a concurrency slot forever. This returns
630
+ * the ids to transition to `failed`. Bounds BOTH plain running jobs and ones
631
+ * showing awaiting-decision (same persisted `running`). `pending` jobs are NOT
632
+ * swept here (they were never launched — the conductor owns them). Terminal jobs
633
+ * are skipped. Pure (liveness + result presence injected), exported for tests.
634
+ */
635
+ export function sweepStaleJobs(jobs, opts) {
636
+ const now = opts.nowMs ?? Date.now();
637
+ const out = [];
638
+ for (const job of jobs) {
639
+ if (job.role !== "job")
640
+ continue;
641
+ const state = job.jobState ?? "pending";
642
+ if (state !== "running")
643
+ continue; // pending/terminal are not our concern
644
+ if (opts.isJobLive(job))
645
+ continue; // still alive → leave it
646
+ if (opts.hasResult(job))
647
+ continue; // headless finished → reconcile handles it
648
+ // Age from last activity (lastSeenAt), falling back to enrolledAt. Use
649
+ // Number.isFinite (NOT `||`) so a legitimate epoch-0 timestamp isn't treated
650
+ // as missing.
651
+ const seen = Date.parse(job.lastSeenAt);
652
+ const enrolled = Date.parse(job.enrolledAt);
653
+ const last = Number.isFinite(seen)
654
+ ? seen
655
+ : Number.isFinite(enrolled)
656
+ ? enrolled
657
+ : now;
658
+ if (now - last < opts.maxAgeMs)
659
+ continue; // not stale yet
660
+ out.push(job.id);
661
+ }
662
+ return out;
663
+ }
664
+ /**
665
+ * Build `jobs ls` rows from job entries + an injectable liveness probe. Pure,
666
+ * exported for tests. `nowMs` makes the age deterministic.
667
+ */
668
+ export function buildJobRows(jobs, isJobLive, nowMs = Date.now(), resolveAccountLabel) {
669
+ return jobs.map((e) => {
670
+ const state = reconcileJobState(e, isJobLive(e));
671
+ // A `throttled` row shows its backoff "retry in Xm" (derived from
672
+ // nextRetryAt) plus, when present, the matched signature. Reliability slice 1.
673
+ const note = state === "throttled"
674
+ ? `${throttleRetryLabel(e.throttle, nowMs)}` +
675
+ (e.throttle?.attempts !== undefined ? ` (try ${e.throttle.attempts}/${THROTTLE_MAX_ATTEMPTS})` : "")
676
+ : "";
677
+ const row = {
678
+ id: e.id,
679
+ type: e.tool,
680
+ state,
681
+ age: humanAge(Date.parse(e.enrolledAt), nowMs),
682
+ cwd: e.cwd,
683
+ note,
684
+ };
685
+ if (e.remoteId)
686
+ row.session = e.remoteId;
687
+ if (resolveAccountLabel) {
688
+ const label = resolveAccountLabel(e.id);
689
+ if (label !== undefined)
690
+ row.account = label;
691
+ }
692
+ return row;
693
+ });
694
+ }
695
+ /**
696
+ * M3 — advisory line for `jobs ls`: when there are QUEUED (`pending`) jobs but
697
+ * NO conductor running to drain them, warn the user (we do NOT self-heal — a
698
+ * one-shot `jobs ls` can't start the queue; only `jobs conduct` does). Returns
699
+ * undefined when there's nothing to warn about. Pure (the conductor-liveness
700
+ * probe is passed in), exported for tests.
701
+ */
702
+ export function conductorAdvisory(jobs, conductorRunning) {
703
+ if (conductorRunning)
704
+ return undefined;
705
+ const pending = jobs.filter((j) => j.role === "job" && (j.jobState ?? "pending") === "pending").length;
706
+ if (pending === 0)
707
+ return undefined;
708
+ return (`[remote] ${pending} pending job(s) and no active conductor — they will NOT start on their own. ` +
709
+ `Run: remote jobs conduct (or remote jobs conduct --watch <min> to keep draining)`);
710
+ }
711
+ /** Render `jobs ls` rows as an aligned plain-text table. Pure. */
712
+ export function renderJobsTable(rows) {
713
+ if (rows.length === 0)
714
+ return "(no delegated jobs)";
715
+ const cols = [
716
+ ["id", "ID"],
717
+ ["type", "TYPE"],
718
+ ["state", "STATE"],
719
+ ["age", "AGE"],
720
+ ["cwd", "CWD"],
721
+ ];
722
+ // Show SESSION for remote jobs so `remote attach <session>` is copy-paste-ready.
723
+ if (rows.some((r) => r.session !== undefined))
724
+ cols.push(["session", "SESSION"]);
725
+ // Show ACCOUNT when any row has a pool binding.
726
+ if (rows.some((r) => r.account !== undefined))
727
+ cols.push(["account", "ACCOUNT"]);
728
+ // Only show the NOTE column when something needs it (a throttled "retry in Xm"
729
+ // hint) — keeps the common all-running table unchanged. Reliability slice 1.
730
+ if (rows.some((r) => r.note.length > 0))
731
+ cols.push(["note", "NOTE"]);
732
+ const widths = cols.map(([key, title]) => Math.max(title.length, ...rows.map((r) => String(r[key]).length)));
733
+ const line = (vals) => vals.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
734
+ const out = [line(cols.map(([, t]) => t))];
735
+ for (const r of rows)
736
+ out.push(line(cols.map(([k]) => String(r[k]))));
737
+ return out.join("\n");
738
+ }
739
+ //# sourceMappingURL=delegate.js.map