antcolony
All repositories: gitoria
11.0 KB
// claude.hl — running ONE headless Claude Code session (shared by the worker in work.hl and the// controller in controller.hl, mission 019) and reading its `--output-format json` answer.import { spawnArgs, env } from 'hl:proc'import { exists } from 'hl:fs'import { after, now } from 'hl:time'import { randomBytes } from 'hl:crypto'import { readJson, option, isList, isObject } from './util.hl'// every mode Claude Code has EXCEPT bypassPermissions (the mission rule: never bypass)static MODES = ['auto' 'acceptEdits' 'default' 'dontAsk' 'plan' 'manual']// `--flag value`, else env NAME, else the fallbackstatic setting = (argv, flag, name, fallback) => {let v = option(argv, flag)if (v == null || v == '') { v = env(name) }if (v == null || v == '') { return fallback }return v}static isWholeNumber = (s) => {if (s == null || s == '') { return false }let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (c < 48 || c > 57) { return false }i = i + 1}return true}// a random UUID v4 for `claude --session-id`static uuid = () => {let h = randomBytes(16, 'hex')return h.slice(0, 8) + '-' + h.slice(8, 12) + '-4' + h.slice(13, 16) + '-8' + h.slice(17, 20) + '-' + h.slice(20, 32)}static money = (n) => {if (n == null) { return '$?' }return '$' + n}// dollars rounded to 4 decimals (no Math in Hybriel: `%` does the floor)static round4 = (x) => {let n = x * 10000 + 0.5n = n - (n % 1)return n / 10000}// Claude's `--output-format json`: ONE result object, or (with `verbose` in the user's settings, as on// Loreana) the whole message LIST whose `result` entry is the one we want → { result, mode, modes }// `mode` = the permission mode Claude last reported (system init/status), null if not visible.static claudeResult = (value) => {if (isObject(value)) { return { result = value.type == 'result' ? value : null mode = null modes = [] } }let result = nulllet mode = nulllet modes = []if (isList(value)) {for (m of value) {if (isObject(m)) {if (m.type == 'result') { result = m }if (m.type == 'system' && m.permissionMode != null) {mode = m.permissionModemodes.push(m.subtype + ':' + m.permissionMode)}}}}return { result = result mode = mode modes = modes }}// the USAGE line of one session (`label` e.g. 'USAGE' / 'CONTROLLER USAGE')static usageLine = (label, res) => {let u = res.usage == null ? {} : res.usagelet models = res.modelUsage == null ? [] : res.modelUsage.keys()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}// after the exit: read the output file → { reason, res, cr, value }// reason = why there is no usable result (null when `res` is there); who = 'worker' / 'controller'static readClaude = (outFile, exit, timedOut, timeoutSeconds, who) => {let reason = nulllet res = nulllet cr = nulllet value = nullif (timedOut) { reason = 'timed out after ' + timeoutSeconds + ' s (killed)' }if (!exists(outFile)) {if (reason == null) { reason = 'no output from the ' + who + ' (exit ' + exit + ')' }} else {let rj = readJson(outFile)if (rj.error != null) {if (reason == null) { reason = 'the ' + who + ' output is not JSON (exit ' + exit + '): ' + rj.error }} else {value = rj.valuecr = claudeResult(rj.value)res = cr.resultif (res == null && reason == null) { reason = 'no result in the ' + who + ' output (exit ' + exit + ')' }}}return { reason = reason res = res cr = cr value = value }}// the usage part of a result, for session.jsonstatic usageMeta = (res) => {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 }}// mission 026 (ticket antcolony#1 — the first live worker left two dev servers running on its ports): after a session// part ended, stop what it left behind — every process LISTENING on a port of its range `ports` ('A-B') that STARTED// after `since` (epoch ms), belongs to this user, is not the scheduler itself and carries no OTHER colony session in its// environment (lib/cleanup.sh; never others). Prints `<label>: CLEANUP — stopped pid …` / `… skipped pid …` per process.// Calls done(lines) — the script's lines except "nothing listening …" (for session.json). → the process or nullstatic cleanupPorts = (label, ports, since, session, done) => {let home = env('COLONY_HOME')let d = ports == null ? -1 : ports.indexOf('-')if (home == null || home == '' || d <= 0) {console.log(label + ': CLEANUP skipped — ' + (d <= 0 ? 'no port range' : 'COLONY_HOME is not set (run through ./colony)'))done([])return null}let out = []let all = []let q = spawnArgs(['bash' home + '/lib/cleanup.sh' ports.slice(0, d) ports.slice(d + 1) '' + since session])on q.line(ev) {let l = ev.stream == 'stderr' ? 'error: ' + ev.line : ev.lineif (l.trim() != '') {all.push(l)if (!l.startsWith('nothing listening')) { out.push(l) }}return null}on q.exit(ev) {for (l of all) { console.log(label + ': CLEANUP — ' + l) }done(out)return null}return q}// runs `bin args…` in `o.folder` with `o.inFile` on stdin, stdout → o.outFile, stderr → o.errFile// (umask 077, COLONY_TOKEN_FILE + COLONY_STOP_FILE unset, `o.env` entries set); kills it after o.timeout seconds; prints// `<o.label>: TIMEOUT …`. Mission 026: with `o.session` + `o.ports` the child also gets COLONY_SESSION=<session>, and// after its exit `cleanupPorts` stops what it left listening on its ports. Calls done({ ev, timedOut, lines, cleanup })// once it has exited (and the cleanup is over). → the processstatic runClaude = (o, done) => {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"'let name = 'colony-' + o.labellet cmd = ['sh' '-c' script name o.folder o.inFile o.outFile o.errFile]// o.env (mission 020): 'NAME=value' entries for the child (e.g. its port range) — `env` execs the binary, same pidlet envs = []if (o.env != null) { for (e of o.env) { envs.push(e) } }let cleaning = o.session != null && o.session != '' && o.ports != null && o.ports != ''if (cleaning) { envs.push('COLONY_SESSION=' + o.session) }if (envs.length > 0) {cmd.push('env')for (e of envs) { cmd.push(e) }}// antcolony#18: o.sandbox = the bwrap argv prefix (sandbox.hl) — the session runs in the box; null / [] = host accessif (o.sandbox != null) { for (b of o.sandbox) { cmd.push(b) } }cmd.push(o.bin)for (a of o.args) { cmd.push(a) }let lines = []let timedOut = falselet since = now()let p = spawnArgs(cmd)let timer = after(toNumber(o.timeout))on timer.tick(t) {timedOut = trueconsole.log(o.label + ': TIMEOUT after ' + o.timeout + ' s — killing pid ' + p.pid)p.kill()return null}on p.line(ev) {lines.push(ev.stream + ': ' + ev.line)return null}on p.exit(ev) {timer.stop()if (!cleaning) {done({ ev = ev lines = lines timedOut = timedOut cleanup = [] })return null}cleanupPorts(o.label, o.ports, since, o.session, (cl) => {done({ ev = ev lines = lines timedOut = timedOut cleanup = cl })return null})return null}return p}// the environment of a session with a port range 'A-B' (mission 020: the agent hands each session its range)static portEnv = (ports) => {if (ports == null || ports == '') { return [] }let d = ports.indexOf('-')return ['COLONY_PORTS=' + ports 'COLONY_PORT_FROM=' + ports.slice(0, d) 'COLONY_PORT_TO=' + ports.slice(d + 1)]}// the last `rate_limit_event` of a verbose session output (Claude Code 2.1.280 sends one per API call:// { status: allowed | allowed_warning | rejected, resetsAt (epoch s), rateLimitType, unifiedWindows:// { five_hour: { utilization 0..1, resetsAt }, seven_day: … } }) → the rate_limit_info or nullstatic lastRateInfo = (value) => {let info = nullif (isList(value)) {for (m of value) { if (isObject(m) && m.type == 'rate_limit_event' && m.rate_limit_info != null) { info = m.rate_limit_info } }}return info}// mission 020: did the session end on the Claude usage / rate limit? → { limited, resetMs (null = unknown), text }// Signals (any one): a `rate_limit_event` with status `rejected`; an error result with api_error_status 429;// an error result whose text says so ("You've hit your limit …", "Claude AI usage limit reached|<epoch s>").// Mission 034 (antcolony#25): the shape seen live 2026-09-25 (librarian call, Claude Code 2.1.280) — `rate_limit_event`// rejected, then a SYNTHETIC assistant message with `error: "rate_limit"` + text "You've hit your session limit · resets// 8:50pm (Europe/Vienna)", then a result with subtype `success` (!), is_error true, api_error_status 429, 0 tokens, $0.// Also recognised: an assistant message with `error` "rate_limit" alone (its text is used), a 429 without is_error,// and the words "session limit" / "weekly limit".static rateLimitOf = (value, res) => {let rejected = nulllet apiLimit = falselet apiText = ''if (isList(value)) {for (m of value) {if (isObject(m) && m.type == 'rate_limit_event' && m.rate_limit_info != null && m.rate_limit_info.status == 'rejected') { rejected = m.rate_limit_info }if (isObject(m) && m.type == 'assistant' && m.error != null && ('' + m.error).toLowerCase().indexOf('rate_limit') >= 0) {apiLimit = trueif (m.message != null && isList(m.message.content)) {for (c of m.message.content) { if (apiText == '' && isObject(c) && c.type == 'text' && c.text != null) { apiText = '' + c.text } }}}}}let text = ''if (res != null && res.result != null && hlTypeName(res.result) == 'String') { text = res.result }if (text == '') { text = apiText }let limited = rejected != null || apiLimitif (res != null && res.api_error_status == 429) { limited = true }if (res != null && (res.is_error == true || apiLimit)) {let low = text.toLowerCase()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 }}let resetMs = nullif (rejected != null && rejected.resetsAt != null) { resetMs = rejected.resetsAt * 1000 }if (resetMs == null && limited) {let bar = text.lastIndexOf('|')if (bar >= 0) {let n = toNumber(text.slice(bar + 1).trim())if (n != null && n > 1000000000) { resetMs = n * 1000 }}}if (text == '' && rejected != null) { text = 'rate_limit_event rejected (' + rejected.rateLimitType + ')' }return { limited = limited resetMs = resetMs text = text }}
Branches
- mainmain branch
Latest commits
- 7f9660eeState of 2026-09-27, before the move to gitoriamre