gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmain3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmremain/lib/claude.hl

11.0 KB

  1. // claude.hl — running ONE headless Claude Code session (shared by the worker in work.hl and the
  2. // controller in controller.hl, mission 019) and reading its `--output-format json` answer.
  3. import { spawnArgs, env } from 'hl:proc'
  4. import { exists } from 'hl:fs'
  5. import { after, now } from 'hl:time'
  6. import { randomBytes } from 'hl:crypto'
  7. import { readJson, option, isList, isObject } from './util.hl'
  8. // every mode Claude Code has EXCEPT bypassPermissions (the mission rule: never bypass)
  9. static MODES = ['auto' 'acceptEdits' 'default' 'dontAsk' 'plan' 'manual']
  10. // `--flag value`, else env NAME, else the fallback
  11. static setting = (argv, flag, name, fallback) => {
  12. let v = option(argv, flag)
  13. if (v == null || v == '') { v = env(name) }
  14. if (v == null || v == '') { return fallback }
  15. return v
  16. }
  17. static isWholeNumber = (s) => {
  18. if (s == null || s == '') { return false }
  19. let i = 0
  20. while (i < s.length) {
  21. let c = s.charCodeAt(i)
  22. if (c < 48 || c > 57) { return false }
  23. i = i + 1
  24. }
  25. return true
  26. }
  27. // a random UUID v4 for `claude --session-id`
  28. static uuid = () => {
  29. let h = randomBytes(16, 'hex')
  30. return h.slice(0, 8) + '-' + h.slice(8, 12) + '-4' + h.slice(13, 16) + '-8' + h.slice(17, 20) + '-' + h.slice(20, 32)
  31. }
  32. static money = (n) => {
  33. if (n == null) { return '$?' }
  34. return '$' + n
  35. }
  36. // dollars rounded to 4 decimals (no Math in Hybriel: `%` does the floor)
  37. static round4 = (x) => {
  38. let n = x * 10000 + 0.5
  39. n = n - (n % 1)
  40. return n / 10000
  41. }
  42. // Claude's `--output-format json`: ONE result object, or (with `verbose` in the user's settings, as on
  43. // Loreana) the whole message LIST whose `result` entry is the one we want → { result, mode, modes }
  44. // `mode` = the permission mode Claude last reported (system init/status), null if not visible.
  45. static claudeResult = (value) => {
  46. if (isObject(value)) { return { result = value.type == 'result' ? value : null mode = null modes = [] } }
  47. let result = null
  48. let mode = null
  49. let modes = []
  50. if (isList(value)) {
  51. for (m of value) {
  52. if (isObject(m)) {
  53. if (m.type == 'result') { result = m }
  54. if (m.type == 'system' && m.permissionMode != null) {
  55. mode = m.permissionMode
  56. modes.push(m.subtype + ':' + m.permissionMode)
  57. }
  58. }
  59. }
  60. }
  61. return { result = result mode = mode modes = modes }
  62. }
  63. // the USAGE line of one session (`label` e.g. 'USAGE' / 'CONTROLLER USAGE')
  64. static usageLine = (label, res) => {
  65. let u = res.usage == null ? {} : res.usage
  66. let models = res.modelUsage == null ? [] : res.modelUsage.keys()
  67. return label + ': ' + money(res.total_cost_usd) + ' · ' + res.num_turns + ' turns · ' + (res.duration_ms / 1000) + ' s · tokens in ' + u.input_tokens + ' / out ' + u.output_tokens + ' / cache read ' + u.cache_read_input_tokens + ' / cache write ' + u.cache_creation_input_tokens + ' · ' + models.join(', ') + ' · ' + res.subtype + ' · claude session ' + res.session_id
  68. }
  69. // after the exit: read the output file → { reason, res, cr, value }
  70. // reason = why there is no usable result (null when `res` is there); who = 'worker' / 'controller'
  71. static readClaude = (outFile, exit, timedOut, timeoutSeconds, who) => {
  72. let reason = null
  73. let res = null
  74. let cr = null
  75. let value = null
  76. if (timedOut) { reason = 'timed out after ' + timeoutSeconds + ' s (killed)' }
  77. if (!exists(outFile)) {
  78. if (reason == null) { reason = 'no output from the ' + who + ' (exit ' + exit + ')' }
  79. } else {
  80. let rj = readJson(outFile)
  81. if (rj.error != null) {
  82. if (reason == null) { reason = 'the ' + who + ' output is not JSON (exit ' + exit + '): ' + rj.error }
  83. } else {
  84. value = rj.value
  85. cr = claudeResult(rj.value)
  86. res = cr.result
  87. if (res == null && reason == null) { reason = 'no result in the ' + who + ' output (exit ' + exit + ')' }
  88. }
  89. }
  90. return { reason = reason res = res cr = cr value = value }
  91. }
  92. // the usage part of a result, for session.json
  93. static usageMeta = (res) => {
  94. return { claudeSession = res.session_id subtype = res.subtype isError = res.is_error numTurns = res.num_turns durationMs = res.duration_ms costUsd = res.total_cost_usd usage = res.usage modelUsage = res.modelUsage permissionDenials = res.permission_denials }
  95. }
  96. // mission 026 (ticket antcolony#1 — the first live worker left two dev servers running on its ports): after a session
  97. // part ended, stop what it left behind — every process LISTENING on a port of its range `ports` ('A-B') that STARTED
  98. // after `since` (epoch ms), belongs to this user, is not the scheduler itself and carries no OTHER colony session in its
  99. // environment (lib/cleanup.sh; never others). Prints `<label>: CLEANUP — stopped pid …` / `… skipped pid …` per process.
  100. // Calls done(lines) — the script's lines except "nothing listening …" (for session.json). → the process or null
  101. static cleanupPorts = (label, ports, since, session, done) => {
  102. let home = env('COLONY_HOME')
  103. let d = ports == null ? -1 : ports.indexOf('-')
  104. if (home == null || home == '' || d <= 0) {
  105. console.log(label + ': CLEANUP skipped — ' + (d <= 0 ? 'no port range' : 'COLONY_HOME is not set (run through ./colony)'))
  106. done([])
  107. return null
  108. }
  109. let out = []
  110. let all = []
  111. let q = spawnArgs(['bash' home + '/lib/cleanup.sh' ports.slice(0, d) ports.slice(d + 1) '' + since session])
  112. on q.line(ev) {
  113. let l = ev.stream == 'stderr' ? 'error: ' + ev.line : ev.line
  114. if (l.trim() != '') {
  115. all.push(l)
  116. if (!l.startsWith('nothing listening')) { out.push(l) }
  117. }
  118. return null
  119. }
  120. on q.exit(ev) {
  121. for (l of all) { console.log(label + ': CLEANUP — ' + l) }
  122. done(out)
  123. return null
  124. }
  125. return q
  126. }
  127. // runs `bin args…` in `o.folder` with `o.inFile` on stdin, stdout → o.outFile, stderr → o.errFile
  128. // (umask 077, COLONY_TOKEN_FILE + COLONY_STOP_FILE unset, `o.env` entries set); kills it after o.timeout seconds; prints
  129. // `<o.label>: TIMEOUT …`. Mission 026: with `o.session` + `o.ports` the child also gets COLONY_SESSION=<session>, and
  130. // after its exit `cleanupPorts` stops what it left listening on its ports. Calls done({ ev, timedOut, lines, cleanup })
  131. // once it has exited (and the cleanup is over). → the process
  132. static runClaude = (o, done) => {
  133. let script = 'umask 077; cd "$1" || { echo "colony: cannot cd to $1" >&2; exit 97; }; in=$2; out=$3; err=$4; shift 4; unset COLONY_TOKEN_FILE COLONY_STOP_FILE; exec "$@" <"$in" >"$out" 2>"$err"'
  134. let name = 'colony-' + o.label
  135. let cmd = ['sh' '-c' script name o.folder o.inFile o.outFile o.errFile]
  136. // o.env (mission 020): 'NAME=value' entries for the child (e.g. its port range) — `env` execs the binary, same pid
  137. let envs = []
  138. if (o.env != null) { for (e of o.env) { envs.push(e) } }
  139. let cleaning = o.session != null && o.session != '' && o.ports != null && o.ports != ''
  140. if (cleaning) { envs.push('COLONY_SESSION=' + o.session) }
  141. if (envs.length > 0) {
  142. cmd.push('env')
  143. for (e of envs) { cmd.push(e) }
  144. }
  145. // antcolony#18: o.sandbox = the bwrap argv prefix (sandbox.hl) — the session runs in the box; null / [] = host access
  146. if (o.sandbox != null) { for (b of o.sandbox) { cmd.push(b) } }
  147. cmd.push(o.bin)
  148. for (a of o.args) { cmd.push(a) }
  149. let lines = []
  150. let timedOut = false
  151. let since = now()
  152. let p = spawnArgs(cmd)
  153. let timer = after(toNumber(o.timeout))
  154. on timer.tick(t) {
  155. timedOut = true
  156. console.log(o.label + ': TIMEOUT after ' + o.timeout + ' s — killing pid ' + p.pid)
  157. p.kill()
  158. return null
  159. }
  160. on p.line(ev) {
  161. lines.push(ev.stream + ': ' + ev.line)
  162. return null
  163. }
  164. on p.exit(ev) {
  165. timer.stop()
  166. if (!cleaning) {
  167. done({ ev = ev lines = lines timedOut = timedOut cleanup = [] })
  168. return null
  169. }
  170. cleanupPorts(o.label, o.ports, since, o.session, (cl) => {
  171. done({ ev = ev lines = lines timedOut = timedOut cleanup = cl })
  172. return null
  173. })
  174. return null
  175. }
  176. return p
  177. }
  178. // the environment of a session with a port range 'A-B' (mission 020: the agent hands each session its range)
  179. static portEnv = (ports) => {
  180. if (ports == null || ports == '') { return [] }
  181. let d = ports.indexOf('-')
  182. return ['COLONY_PORTS=' + ports 'COLONY_PORT_FROM=' + ports.slice(0, d) 'COLONY_PORT_TO=' + ports.slice(d + 1)]
  183. }
  184. // the last `rate_limit_event` of a verbose session output (Claude Code 2.1.280 sends one per API call:
  185. // { status: allowed | allowed_warning | rejected, resetsAt (epoch s), rateLimitType, unifiedWindows:
  186. // { five_hour: { utilization 0..1, resetsAt }, seven_day: … } }) → the rate_limit_info or null
  187. static lastRateInfo = (value) => {
  188. let info = null
  189. if (isList(value)) {
  190. for (m of value) { if (isObject(m) && m.type == 'rate_limit_event' && m.rate_limit_info != null) { info = m.rate_limit_info } }
  191. }
  192. return info
  193. }
  194. // mission 020: did the session end on the Claude usage / rate limit? → { limited, resetMs (null = unknown), text }
  195. // Signals (any one): a `rate_limit_event` with status `rejected`; an error result with api_error_status 429;
  196. // an error result whose text says so ("You've hit your limit …", "Claude AI usage limit reached|<epoch s>").
  197. // Mission 034 (antcolony#25): the shape seen live 2026-09-25 (librarian call, Claude Code 2.1.280) — `rate_limit_event`
  198. // rejected, then a SYNTHETIC assistant message with `error: "rate_limit"` + text "You've hit your session limit · resets
  199. // 8:50pm (Europe/Vienna)", then a result with subtype `success` (!), is_error true, api_error_status 429, 0 tokens, $0.
  200. // Also recognised: an assistant message with `error` "rate_limit" alone (its text is used), a 429 without is_error,
  201. // and the words "session limit" / "weekly limit".
  202. static rateLimitOf = (value, res) => {
  203. let rejected = null
  204. let apiLimit = false
  205. let apiText = ''
  206. if (isList(value)) {
  207. for (m of value) {
  208. if (isObject(m) && m.type == 'rate_limit_event' && m.rate_limit_info != null && m.rate_limit_info.status == 'rejected') { rejected = m.rate_limit_info }
  209. if (isObject(m) && m.type == 'assistant' && m.error != null && ('' + m.error).toLowerCase().indexOf('rate_limit') >= 0) {
  210. apiLimit = true
  211. if (m.message != null && isList(m.message.content)) {
  212. for (c of m.message.content) { if (apiText == '' && isObject(c) && c.type == 'text' && c.text != null) { apiText = '' + c.text } }
  213. }
  214. }
  215. }
  216. }
  217. let text = ''
  218. if (res != null && res.result != null && hlTypeName(res.result) == 'String') { text = res.result }
  219. if (text == '') { text = apiText }
  220. let limited = rejected != null || apiLimit
  221. if (res != null && res.api_error_status == 429) { limited = true }
  222. if (res != null && (res.is_error == true || apiLimit)) {
  223. let low = text.toLowerCase()
  224. if (low.indexOf('limit') >= 0 && (low.indexOf('hit your') >= 0 || low.indexOf('usage limit') >= 0 || low.indexOf('session limit') >= 0 || low.indexOf('weekly limit') >= 0 || low.indexOf('rate limit') >= 0 || low.indexOf('rate_limit') >= 0)) { limited = true }
  225. }
  226. let resetMs = null
  227. if (rejected != null && rejected.resetsAt != null) { resetMs = rejected.resetsAt * 1000 }
  228. if (resetMs == null && limited) {
  229. let bar = text.lastIndexOf('|')
  230. if (bar >= 0) {
  231. let n = toNumber(text.slice(bar + 1).trim())
  232. if (n != null && n > 1000000000) { resetMs = n * 1000 }
  233. }
  234. }
  235. if (text == '' && rejected != null) { text = 'rate_limit_event rejected (' + rejected.rateLimitType + ')' }
  236. return { limited = limited resetMs = resetMs text = text }
  237. }

Branches

  • mainmain branch

Latest commits

  • 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
  • a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
  • c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
  • 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
  • 7f9660eeState of 2026-09-27, before the move to gitoriamre