@ucsandman/legcli 0.9.0 → 0.11.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.
- package/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- package/src/worktree.mjs +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// custom adapters — any coding-agent CLI becomes a Leg adapter through a JSON
|
|
2
|
+
// spec in $LEG_HOME/adapters/<name>.json, with no code in this package.
|
|
3
|
+
//
|
|
4
|
+
// This is what makes the chain open-ended: claude, codex, agy and grok ship
|
|
5
|
+
// with taps and a probe, and anything else (Muse, Amp, aider, a house script)
|
|
6
|
+
// joins as "argv in, JSON out". A custom adapter is a CARD adapter: it runs
|
|
7
|
+
// headless in a worktree and hands off like any other leg. It is not an
|
|
8
|
+
// interactive `leg <agent>` terminal, because that needs a usage tap and a
|
|
9
|
+
// wall signal that only the four supervised CLIs expose.
|
|
10
|
+
//
|
|
11
|
+
// Spec:
|
|
12
|
+
// {
|
|
13
|
+
// "name": "muse",
|
|
14
|
+
// "bin": "muse", // argv[0]; never a shell string
|
|
15
|
+
// "stdin": "ignore", // "pipe" sends the prompt on stdin
|
|
16
|
+
// "args": ["run", "--json",
|
|
17
|
+
// ["--dir", "{{cwd}}"], // a group is dropped when a
|
|
18
|
+
// ["--model", "{{model}}"], // placeholder inside it is unset
|
|
19
|
+
// "{{prompt}}"],
|
|
20
|
+
// "modes": { "default": "auto", "allowed": ["auto", "readonly"] },
|
|
21
|
+
// "forbiddenFlags": ["--yolo"],
|
|
22
|
+
// "result": { "format": "json", "sessionId": "session_id",
|
|
23
|
+
// "message": "result", "stopReason": "stop_reason" }
|
|
24
|
+
// }
|
|
25
|
+
//
|
|
26
|
+
// Placeholders: {{prompt}} {{promptFile}} {{cwd}} {{mode}} {{model}}
|
|
27
|
+
// {{resume}} {{maxTurns}} {{runDir}}. A bare string is always kept, so put
|
|
28
|
+
// anything optional in a group.
|
|
29
|
+
//
|
|
30
|
+
// result.format: "json" (first parseable object in stdout), "jsonl" (the last
|
|
31
|
+
// line that carries the message field) or "text" (no parsing; the leg is then
|
|
32
|
+
// judged by its .leg/DONE marker and its diff, as every adapter is when
|
|
33
|
+
// parseResult returns null). sessionId/message/stopReason are dotted paths.
|
|
34
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
import { home } from '../store.mjs'
|
|
37
|
+
import { sanitizeEnv } from '../env.mjs'
|
|
38
|
+
import { assertAllowed } from './common.mjs'
|
|
39
|
+
|
|
40
|
+
export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/
|
|
41
|
+
export const PLACEHOLDERS = ['prompt', 'promptFile', 'cwd', 'mode', 'model', 'resume', 'maxTurns', 'runDir']
|
|
42
|
+
export const FORMATS = ['json', 'jsonl', 'text']
|
|
43
|
+
|
|
44
|
+
// Never allowed in a spec, whoever wrote it: these are the flags that turn a
|
|
45
|
+
// supervised agent into an unsupervised one, and Leg's whole permission story
|
|
46
|
+
// is that no adapter passes them. The same list the built-ins are tested for.
|
|
47
|
+
export const NEVER_ALLOWED = [
|
|
48
|
+
'--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', // not allowed
|
|
49
|
+
'--dangerously-bypass-approvals-and-sandbox', '--dangerous-mode', // not allowed
|
|
50
|
+
'--yolo', '--always-approve', '--full-auto', '--approve-for-me', // not allowed
|
|
51
|
+
'bypassPermissions', 'danger-full-access', // not allowed
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
export class SpecError extends Error {
|
|
55
|
+
constructor(msg) { super(msg); this.name = 'SpecError' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function adaptersDir() { return join(home(), 'adapters') }
|
|
59
|
+
export function specPath(name) { return join(adaptersDir(), `${name}.json`) }
|
|
60
|
+
|
|
61
|
+
function str(v) { return typeof v === 'string' ? v : null }
|
|
62
|
+
|
|
63
|
+
// Throws SpecError with a sentence naming the field. Returns the normalized spec.
|
|
64
|
+
export function validateSpec(raw, { reserved = [] } = {}) {
|
|
65
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SpecError('a spec is a JSON object')
|
|
66
|
+
const name = str(raw.name)
|
|
67
|
+
if (!name || !NAME_RE.test(name)) throw new SpecError(`name "${raw.name ?? ''}": lowercase letters, digits, dash and underscore, up to 30 characters`)
|
|
68
|
+
if (reserved.includes(name)) throw new SpecError(`"${name}" is a built-in adapter; choose another name`)
|
|
69
|
+
const bin = str(raw.bin)
|
|
70
|
+
if (!bin) throw new SpecError(`${name}: "bin" must be the executable to run (argv[0]), not a shell command line`)
|
|
71
|
+
if (/[<>|&;]/.test(bin)) throw new SpecError(`${name}: "bin" is spawned directly, never through a shell, so it cannot contain < > | & or ;`)
|
|
72
|
+
const stdin = raw.stdin ?? 'ignore'
|
|
73
|
+
if (!['pipe', 'ignore'].includes(stdin)) throw new SpecError(`${name}: "stdin" is "pipe" or "ignore"`)
|
|
74
|
+
if (!Array.isArray(raw.args)) throw new SpecError(`${name}: "args" must be an array of strings and groups`)
|
|
75
|
+
|
|
76
|
+
const flat = []
|
|
77
|
+
const args = raw.args.map((entry, i) => {
|
|
78
|
+
if (typeof entry === 'string') { flat.push(entry); return entry }
|
|
79
|
+
if (Array.isArray(entry) && entry.every((x) => typeof x === 'string')) { flat.push(...entry); return [...entry] }
|
|
80
|
+
throw new SpecError(`${name}: args[${i}] must be a string or an array of strings`)
|
|
81
|
+
})
|
|
82
|
+
for (const piece of flat) {
|
|
83
|
+
for (const [, key] of piece.matchAll(/\{\{\s*([a-zA-Z]+)\s*\}\}/g)) {
|
|
84
|
+
if (!PLACEHOLDERS.includes(key)) throw new SpecError(`${name}: unknown placeholder {{${key}}} (known: ${PLACEHOLDERS.join(', ')})`)
|
|
85
|
+
}
|
|
86
|
+
const banned = NEVER_ALLOWED.find((f) => piece === f || piece.startsWith(`${f}=`) || piece.endsWith(`=${f}`))
|
|
87
|
+
if (banned) throw new SpecError(`${name}: args carry ${banned}, which no Leg adapter may pass`)
|
|
88
|
+
}
|
|
89
|
+
const usesPrompt = flat.some((p) => p.includes('{{prompt}}') || p.includes('{{promptFile}}'))
|
|
90
|
+
if (stdin !== 'pipe' && !usesPrompt) throw new SpecError(`${name}: nothing carries the prompt — put {{prompt}} or {{promptFile}} in args, or set "stdin": "pipe"`)
|
|
91
|
+
|
|
92
|
+
const modes = raw.modes ?? {}
|
|
93
|
+
const allowed = Array.isArray(modes.allowed) && modes.allowed.length ? modes.allowed.map(String) : ['default']
|
|
94
|
+
const def = str(modes.default) ?? allowed[0]
|
|
95
|
+
if (!allowed.includes(def)) throw new SpecError(`${name}: modes.default "${def}" is not in modes.allowed (${allowed.join(', ')})`)
|
|
96
|
+
for (const m of allowed) if (NEVER_ALLOWED.includes(m)) throw new SpecError(`${name}: mode "${m}" is never allowed`)
|
|
97
|
+
|
|
98
|
+
const forbidden = Array.isArray(raw.forbiddenFlags) ? raw.forbiddenFlags.map(String) : []
|
|
99
|
+
const result = raw.result ?? {}
|
|
100
|
+
const format = str(result.format) ?? 'json'
|
|
101
|
+
if (!FORMATS.includes(format)) throw new SpecError(`${name}: result.format is one of ${FORMATS.join(', ')}`)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
name,
|
|
105
|
+
bin,
|
|
106
|
+
stdin,
|
|
107
|
+
args,
|
|
108
|
+
modes: { default: def, allowed },
|
|
109
|
+
// the never-allowed list is refused on top of whatever the spec adds
|
|
110
|
+
forbiddenFlags: [...new Set([...forbidden, ...NEVER_ALLOWED])],
|
|
111
|
+
result: {
|
|
112
|
+
format,
|
|
113
|
+
sessionId: str(result.sessionId) ?? 'session_id',
|
|
114
|
+
message: str(result.message) ?? 'result',
|
|
115
|
+
stopReason: str(result.stopReason) ?? 'stop_reason',
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function dig(obj, path) {
|
|
121
|
+
let cur = obj
|
|
122
|
+
for (const key of String(path).split('.')) {
|
|
123
|
+
if (cur === null || typeof cur !== 'object') return null
|
|
124
|
+
cur = cur[key]
|
|
125
|
+
}
|
|
126
|
+
return cur ?? null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fill(piece, values) {
|
|
130
|
+
return piece.replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (_, key) => (values[key] === undefined || values[key] === null ? '' : String(values[key])))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasUnset(piece, values) {
|
|
134
|
+
for (const [, key] of piece.matchAll(/\{\{\s*([a-zA-Z]+)\s*\}\}/g)) {
|
|
135
|
+
const v = values[key]
|
|
136
|
+
if (v === undefined || v === null || v === '') return true
|
|
137
|
+
}
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// spec → the adapter object every other module expects (see common.mjs).
|
|
142
|
+
export function makeAdapter(spec) {
|
|
143
|
+
const adapter = {
|
|
144
|
+
name: spec.name,
|
|
145
|
+
stdin: spec.stdin,
|
|
146
|
+
modes: spec.modes,
|
|
147
|
+
forbiddenFlags: spec.forbiddenFlags,
|
|
148
|
+
custom: true,
|
|
149
|
+
spec,
|
|
150
|
+
resolve() {
|
|
151
|
+
const override = process.env[`LEG_${spec.name.toUpperCase().replace(/-/g, '_')}_BIN`]
|
|
152
|
+
const bin = override || spec.bin
|
|
153
|
+
return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
|
|
154
|
+
},
|
|
155
|
+
argv(opts = {}) {
|
|
156
|
+
const mode = assertAllowed(adapter, opts)
|
|
157
|
+
const { bin, viaNode } = adapter.resolve()
|
|
158
|
+
const values = {
|
|
159
|
+
prompt: opts.prompt ?? '',
|
|
160
|
+
promptFile: opts.promptFile ?? null,
|
|
161
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
162
|
+
mode,
|
|
163
|
+
model: opts.model ?? null,
|
|
164
|
+
resume: opts.resume ?? null,
|
|
165
|
+
maxTurns: opts.maxTurns ?? null,
|
|
166
|
+
runDir: opts.runDir ?? null,
|
|
167
|
+
}
|
|
168
|
+
const args = []
|
|
169
|
+
for (const entry of spec.args) {
|
|
170
|
+
if (typeof entry === 'string') { args.push(fill(entry, values)); continue }
|
|
171
|
+
// a group survives only when every placeholder in it resolved
|
|
172
|
+
if (entry.some((piece) => hasUnset(piece, values))) continue
|
|
173
|
+
for (const piece of entry) args.push(fill(piece, values))
|
|
174
|
+
}
|
|
175
|
+
return viaNode ? { bin: process.execPath, args: [bin, ...args] } : { bin, args }
|
|
176
|
+
},
|
|
177
|
+
env(base) { return sanitizeEnv(base) },
|
|
178
|
+
parseResult(text) {
|
|
179
|
+
const s = String(text)
|
|
180
|
+
if (spec.result.format === 'text') return null
|
|
181
|
+
const shape = (j) => ({
|
|
182
|
+
session_id: dig(j, spec.result.sessionId),
|
|
183
|
+
last_message: typeof dig(j, spec.result.message) === 'string' ? dig(j, spec.result.message) : null,
|
|
184
|
+
stop_reason: dig(j, spec.result.stopReason),
|
|
185
|
+
raw: j,
|
|
186
|
+
})
|
|
187
|
+
if (spec.result.format === 'jsonl') {
|
|
188
|
+
const objs = []
|
|
189
|
+
for (const line of s.split('\n')) {
|
|
190
|
+
const t = line.trim()
|
|
191
|
+
if (!t.startsWith('{')) continue
|
|
192
|
+
try { objs.push(JSON.parse(t)) } catch { continue }
|
|
193
|
+
}
|
|
194
|
+
if (!objs.length) return null
|
|
195
|
+
// the last line that actually carries a message, else the last line
|
|
196
|
+
const withMsg = objs.filter((j) => typeof dig(j, spec.result.message) === 'string')
|
|
197
|
+
return shape(withMsg.length ? withMsg[withMsg.length - 1] : objs[objs.length - 1])
|
|
198
|
+
}
|
|
199
|
+
for (let i = s.indexOf('{'); i !== -1; i = s.indexOf('{', i + 1)) {
|
|
200
|
+
try {
|
|
201
|
+
const j = JSON.parse(s.slice(i))
|
|
202
|
+
if (j && typeof j === 'object') return shape(j)
|
|
203
|
+
} catch { continue }
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
return adapter
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// names() is on the board's hot path — /api/health asks every adapter where
|
|
212
|
+
// its binary is, which called this once for the list and once per custom
|
|
213
|
+
// adapter — so the parsed result is cached against the spec directory's mtime
|
|
214
|
+
// and a one-second floor. A spec added or removed changes the directory mtime
|
|
215
|
+
// and is picked up at once; a spec edited in place lands within the second.
|
|
216
|
+
// Without this, one board poll was a readdir plus a readFile and a JSON.parse
|
|
217
|
+
// per spec, on the same event loop the terminals lane is pushed from.
|
|
218
|
+
// The key is the directory listing itself, not its mtime: one readdir is a
|
|
219
|
+
// single cheap syscall, and a directory's mtime is too coarse on Windows to
|
|
220
|
+
// notice a spec added in the same tick as the last read. So a spec appearing
|
|
221
|
+
// or disappearing is seen at once, and only a spec edited in place waits out
|
|
222
|
+
// the one-second floor.
|
|
223
|
+
const SPEC_TTL_MS = 1000
|
|
224
|
+
let specCache = { key: null, at: 0, specs: null }
|
|
225
|
+
|
|
226
|
+
export function clearSpecCache() { specCache = { key: null, at: 0, specs: null } }
|
|
227
|
+
|
|
228
|
+
// Every spec on disk. A broken file is reported, never thrown, so one bad spec
|
|
229
|
+
// cannot stop the board, the scheduler or `leg card add`.
|
|
230
|
+
// → [{ name, spec, adapter, file, error }]
|
|
231
|
+
export function listSpecs({ reserved = [] } = {}) {
|
|
232
|
+
const dir = adaptersDir()
|
|
233
|
+
if (!existsSync(dir)) return []
|
|
234
|
+
const files = readdirSync(dir).sort().filter((f) => f.endsWith('.json'))
|
|
235
|
+
const key = `${dir}|${reserved.join(',')}|${files.join(',')}`
|
|
236
|
+
if (specCache.specs && specCache.key === key && Date.now() - specCache.at < SPEC_TTL_MS) return specCache.specs
|
|
237
|
+
const out = []
|
|
238
|
+
for (const file of files) {
|
|
239
|
+
const full = join(dir, file)
|
|
240
|
+
const stem = file.slice(0, -5)
|
|
241
|
+
try {
|
|
242
|
+
const raw = JSON.parse(readFileSync(full, 'utf8'))
|
|
243
|
+
if (raw && typeof raw === 'object' && raw.name === undefined) raw.name = stem
|
|
244
|
+
const spec = validateSpec(raw, { reserved })
|
|
245
|
+
if (spec.name !== stem) throw new SpecError(`name "${spec.name}" does not match the file name ${file}`)
|
|
246
|
+
out.push({ name: spec.name, spec, adapter: makeAdapter(spec), file: full, error: null })
|
|
247
|
+
} catch (err) {
|
|
248
|
+
out.push({ name: stem, spec: null, adapter: null, file: full, error: err.message })
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
specCache = { key, at: Date.now(), specs: out }
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function loadSpec(name, { reserved = [] } = {}) {
|
|
256
|
+
const f = specPath(name)
|
|
257
|
+
if (!existsSync(f)) return null
|
|
258
|
+
const raw = JSON.parse(readFileSync(f, 'utf8'))
|
|
259
|
+
if (raw && typeof raw === 'object' && raw.name === undefined) raw.name = name
|
|
260
|
+
return validateSpec(raw, { reserved })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const TEMPLATE = {
|
|
264
|
+
name: 'my-agent',
|
|
265
|
+
bin: 'my-agent',
|
|
266
|
+
stdin: 'ignore',
|
|
267
|
+
args: ['--json', ['--dir', '{{cwd}}'], ['--model', '{{model}}'], ['--resume', '{{resume}}'], '{{prompt}}'],
|
|
268
|
+
modes: { default: 'default', allowed: ['default'] },
|
|
269
|
+
forbiddenFlags: [],
|
|
270
|
+
result: { format: 'json', sessionId: 'session_id', message: 'result', stopReason: 'stop_reason' },
|
|
271
|
+
}
|
package/src/adapters/grok.mjs
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
// grok adapter
|
|
2
|
-
//
|
|
1
|
+
// grok adapter — Grok CLI headless (`grok -p <prompt> --output-format json`).
|
|
2
|
+
// Facts and sources: docs/cli-contracts.md § grok. Every flag below was read
|
|
3
|
+
// from `grok --help` on grok 1.0.34 (3736acbc8658) on 2026-09-17:
|
|
4
|
+
// -p, --single <PROMPT> single-turn prompt, prints the response and exits
|
|
5
|
+
// --prompt-file <PATH> the same prompt from a file (no argv length limit)
|
|
6
|
+
// --output-format <plain|json|streaming-json|streaming-messages-json>
|
|
7
|
+
// --permission-mode <default|acceptEdits|auto|dontAsk|plan>, and one more
|
|
8
|
+
// that Leg never passes and refuses in a chain entry (see modes.allowed)
|
|
9
|
+
// --cwd <CWD>, -m <MODEL>, -r <SESSION_ID_OR_TITLE>
|
|
10
|
+
// --cwd is passed explicitly rather than relying on the spawn's cwd: grok can
|
|
11
|
+
// run against a shared leader process (~/.grok/leader.sock), and a leg must
|
|
12
|
+
// edit its own worktree, never whatever directory the leader was started in.
|
|
3
13
|
import { join } from 'node:path'
|
|
4
14
|
import { homedir } from 'node:os'
|
|
5
15
|
import { sanitizeEnv } from '../env.mjs'
|
|
@@ -7,9 +17,10 @@ import { assertAllowed, firstExisting } from './common.mjs'
|
|
|
7
17
|
|
|
8
18
|
const adapter = {
|
|
9
19
|
name: 'grok',
|
|
10
|
-
stdin: 'ignore',
|
|
20
|
+
stdin: 'ignore', // the prompt travels by --prompt-file or argv, never stdin
|
|
11
21
|
modes: {
|
|
12
22
|
default: 'acceptEdits',
|
|
23
|
+
// grok 1.0.34 --permission-mode choices; the bypass mode is never allowed.
|
|
13
24
|
allowed: ['default', 'acceptEdits', 'auto', 'dontAsk', 'plan'],
|
|
14
25
|
},
|
|
15
26
|
forbiddenFlags: ['--always-approve', 'bypassPermissions', '--permission-mode=bypassPermissions'],
|
|
@@ -22,24 +33,54 @@ const adapter = {
|
|
|
22
33
|
argv(opts = {}) {
|
|
23
34
|
const mode = assertAllowed(adapter, opts)
|
|
24
35
|
const { bin, viaNode } = adapter.resolve()
|
|
25
|
-
const
|
|
36
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
37
|
+
// A hand-off prompt carries the whole bundle summary and can run to
|
|
38
|
+
// thousands of characters; Windows caps one command line at ~32k, so the
|
|
39
|
+
// file form is used whenever the runner has written one.
|
|
40
|
+
const args = opts.promptFile
|
|
41
|
+
? ['--prompt-file', opts.promptFile]
|
|
42
|
+
: ['-p', opts.prompt ?? '']
|
|
43
|
+
args.push('--output-format', 'json', '--permission-mode', mode, '--cwd', cwd)
|
|
44
|
+
if (opts.maxTurns) args.push('--max-turns', String(opts.maxTurns))
|
|
26
45
|
if (opts.model) args.push('-m', opts.model)
|
|
27
46
|
if (opts.resume) args.push('-r', opts.resume)
|
|
28
47
|
return viaNode ? { bin: process.execPath, args: [bin, ...args] } : { bin, args }
|
|
29
48
|
},
|
|
30
49
|
env(base) { return sanitizeEnv(base) },
|
|
50
|
+
// grok's headless writer emits the Claude Code result envelope. The field
|
|
51
|
+
// names were read out of the shipped grok.exe on 2026-09-17 ("type":"result",
|
|
52
|
+
// subtype, is_error, session_id, "result", num_turns, stop_reason, total_cost)
|
|
53
|
+
// and the error envelope was observed live the same day:
|
|
54
|
+
// {"type":"error","message":"Internal error: { \"message\": \"API error
|
|
55
|
+
// (status 402 Payment Required): Grok Build usage balance exhausted\" }"}
|
|
56
|
+
// The scan starts at each `{` because --output-format json still lets a
|
|
57
|
+
// plugin or a warning print a line before the envelope.
|
|
31
58
|
parseResult(text) {
|
|
32
59
|
const s = String(text)
|
|
33
60
|
for (let i = s.indexOf('{'); i !== -1; i = s.indexOf('{', i + 1)) {
|
|
34
|
-
|
|
35
|
-
|
|
61
|
+
let j
|
|
62
|
+
try { j = JSON.parse(s.slice(i)) } catch { continue }
|
|
63
|
+
if (!j || typeof j !== 'object') continue
|
|
64
|
+
if (j.type === 'error') {
|
|
36
65
|
return {
|
|
37
|
-
session_id: j.session_id ??
|
|
38
|
-
last_message: typeof j.
|
|
39
|
-
stop_reason:
|
|
66
|
+
session_id: j.session_id ?? null,
|
|
67
|
+
last_message: typeof j.message === 'string' ? j.message : null,
|
|
68
|
+
stop_reason: 'error',
|
|
69
|
+
subtype: null,
|
|
70
|
+
is_error: true,
|
|
71
|
+
num_turns: null,
|
|
40
72
|
raw: j,
|
|
41
73
|
}
|
|
42
|
-
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
session_id: j.session_id ?? j.sessionId ?? null,
|
|
77
|
+
last_message: typeof j.result === 'string' ? j.result : (typeof j.response === 'string' ? j.response : null),
|
|
78
|
+
stop_reason: j.stop_reason ?? j.stopReason ?? null,
|
|
79
|
+
subtype: j.subtype ?? null,
|
|
80
|
+
is_error: j.is_error ?? null,
|
|
81
|
+
num_turns: j.num_turns ?? null,
|
|
82
|
+
raw: j,
|
|
83
|
+
}
|
|
43
84
|
}
|
|
44
85
|
return null
|
|
45
86
|
},
|
package/src/adapters/index.mjs
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
// Adapter registry. Adapters are loaded lazily so a typo fails with a named
|
|
2
2
|
// error before anything is spawned. Every real CLI here was probed live
|
|
3
3
|
// through the runner in phase 3 (fixtures/live/<name>/, docs/cli-contracts.md).
|
|
4
|
-
// grok
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// grok joined on 2026-09-17: its flags were read from `grok --help` on 1.0.34
|
|
5
|
+
// and its headless envelope out of the shipped binary, and the probe reached
|
|
6
|
+
// the account (a 402 "usage balance exhausted", classified `limit`). The
|
|
7
|
+
// success path of a grok leg is still unprobed — that needs balance on the
|
|
8
|
+
// account — so a grok card falls back to the DONE marker and the diff, which
|
|
9
|
+
// is what every adapter does when parseResult comes back null.
|
|
10
|
+
//
|
|
11
|
+
// Anything else is a custom adapter: a JSON spec in $LEG_HOME/adapters/*.json
|
|
12
|
+
// (src/adapters/custom.mjs, `leg adapter add`). Built-in names always win, and
|
|
13
|
+
// the spec directory is re-read on every call so a spec added while the board
|
|
14
|
+
// is up shows in the New card form without a restart.
|
|
15
|
+
import { listSpecs } from './custom.mjs'
|
|
16
|
+
|
|
7
17
|
const REGISTRY = {
|
|
8
18
|
fake: { path: './fake.mjs' },
|
|
9
19
|
'fake-claude': { path: './fake.mjs', fake: ['fake-claude', 'pipe'] },
|
|
@@ -13,15 +23,32 @@ const REGISTRY = {
|
|
|
13
23
|
claude: { path: './claude.mjs' },
|
|
14
24
|
codex: { path: './codex.mjs' },
|
|
15
25
|
agy: { path: './agy.mjs' },
|
|
26
|
+
grok: { path: './grok.mjs' },
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
export
|
|
29
|
+
export const BUILTIN_NAMES = Object.keys(REGISTRY)
|
|
30
|
+
|
|
31
|
+
// Every spec on disk, including the broken ones (each carries its `error`).
|
|
32
|
+
export function customSpecs() { return listSpecs({ reserved: BUILTIN_NAMES }) }
|
|
33
|
+
|
|
34
|
+
// Only the specs that loaded. A broken file is not an adapter; `leg adapter
|
|
35
|
+
// list` is where its error is shown, so a typo is visible instead of silent.
|
|
36
|
+
export function customNames() { return customSpecs().filter((s) => s.adapter).map((s) => s.name) }
|
|
37
|
+
|
|
38
|
+
export function names() { return [...BUILTIN_NAMES, ...customNames()] }
|
|
19
39
|
|
|
20
40
|
export function isFake(name) { return Boolean(REGISTRY[name]?.fake) || name === 'fake' }
|
|
21
41
|
|
|
42
|
+
export function isCustom(name) { return !REGISTRY[name] && customNames().includes(name) }
|
|
43
|
+
|
|
22
44
|
export async function get(name) {
|
|
23
45
|
const entry = REGISTRY[name]
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
46
|
+
if (entry) {
|
|
47
|
+
const mod = await import(entry.path)
|
|
48
|
+
return entry.fake ? mod.makeFake(...entry.fake) : mod.default
|
|
49
|
+
}
|
|
50
|
+
const hit = customSpecs().find((s) => s.name === name)
|
|
51
|
+
if (hit?.adapter) return hit.adapter
|
|
52
|
+
if (hit?.error) throw new Error(`adapter ${name} is on disk but its spec does not load: ${hit.error}`)
|
|
53
|
+
throw new Error(`unknown adapter: ${name}`)
|
|
27
54
|
}
|
package/src/attach.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// same terminal from that bundle. Subscription logins only: API keys are
|
|
9
9
|
// stripped from the child environment (src/env.mjs).
|
|
10
10
|
import http from 'node:http'
|
|
11
|
+
import net from 'node:net'
|
|
11
12
|
import { spawn, spawnSync } from 'node:child_process'
|
|
12
13
|
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
13
14
|
import { join, dirname, resolve, relative } from 'node:path'
|
|
@@ -36,6 +37,7 @@ import { captureLive } from './live-capture.mjs'
|
|
|
36
37
|
import { waitForReset, fmtCountdown } from './wait.mjs'
|
|
37
38
|
import { readPreferences, normalizeHandoffOrder, resolveAutoApprove } from './preferences.mjs'
|
|
38
39
|
import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
|
|
40
|
+
import { insideKnownStore } from './history/index.mjs'
|
|
39
41
|
|
|
40
42
|
const SRC = dirname(fileURLToPath(import.meta.url))
|
|
41
43
|
function resolveServer() {
|
|
@@ -80,6 +82,19 @@ function health(port, host = '127.0.0.1') {
|
|
|
80
82
|
})
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
// Does anything own this port? A completed TCP connect is the question, so a
|
|
86
|
+
// board too busy to answer /api/health still counts as one. Nothing is sent.
|
|
87
|
+
function portTaken(port, host = '127.0.0.1') {
|
|
88
|
+
return new Promise((res) => {
|
|
89
|
+
const sock = net.connect({ host: host === '0.0.0.0' ? '127.0.0.1' : host, port })
|
|
90
|
+
const done = (v) => { sock.destroy(); res(v) }
|
|
91
|
+
sock.setTimeout(2000)
|
|
92
|
+
sock.on('connect', () => done(true))
|
|
93
|
+
sock.on('error', () => done(false))
|
|
94
|
+
sock.on('timeout', () => done(false))
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
export async function ensureBoard({ open = true } = {}) {
|
|
84
99
|
// with share on the board lives on the shared address, not loopback
|
|
85
100
|
const share = readShare()
|
|
@@ -88,7 +103,20 @@ export async function ensureBoard({ open = true } = {}) {
|
|
|
88
103
|
const host = shared ? share.bind : '127.0.0.1'
|
|
89
104
|
const url = `http://${host}:${port}`
|
|
90
105
|
if ((process.env.LEG_NO_BOARD || process.env.BATON_NO_BOARD) === '1') return { url: null, started: false, skipped: true }
|
|
91
|
-
|
|
106
|
+
// the board is opened whether or not this terminal is the one that started
|
|
107
|
+
// it: `leg claude` in a second terminal still means "show me the board"
|
|
108
|
+
if (await health(port, host)) { if (open) openBoard(url); return { url, started: false } }
|
|
109
|
+
// A board that is merely busy misses the health deadline while still owning
|
|
110
|
+
// the port. Treating that as "no board" spawned a second server that could
|
|
111
|
+
// only die of EADDRINUSE, and the poll below then waited the full fifteen
|
|
112
|
+
// seconds for a child already gone — the whole delay before the agent
|
|
113
|
+
// starts, and the reason no browser ever opened. A listener on the port is
|
|
114
|
+
// a board: attach to it and open it.
|
|
115
|
+
if (await portTaken(port, host)) {
|
|
116
|
+
say(`the board on ${url} is busy; attaching to it`)
|
|
117
|
+
if (open) openBoard(url)
|
|
118
|
+
return { url, started: false, busy: true }
|
|
119
|
+
}
|
|
92
120
|
mkdirSync(home(), { recursive: true })
|
|
93
121
|
const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
|
|
94
122
|
const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, LEG_PORT: String(port), LEG_BIND: host, LEG_QUIET: '0', BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
|
|
@@ -235,7 +263,10 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
|
|
|
235
263
|
}
|
|
236
264
|
|
|
237
265
|
// ---- one agent leg ----
|
|
238
|
-
// Returns { reason: 'exit'|'limit'|'handoff', code }
|
|
266
|
+
// Returns { reason: 'exit'|'limit'|'handoff', code, target } — `target` is the
|
|
267
|
+
// destination a human picked on the board ("Hand off now to codex"), carried
|
|
268
|
+
// out to the loop below, which is what chooses the next leg.
|
|
269
|
+
|
|
239
270
|
async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove() }) {
|
|
240
271
|
const sid = session.session_id
|
|
241
272
|
refreshAccount(agent, account)
|
|
@@ -269,6 +300,13 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
269
300
|
|
|
270
301
|
// taps
|
|
271
302
|
let rollout = null; let tail = null
|
|
303
|
+
// a continued codex thread appends to its old rollout, which findRollout
|
|
304
|
+
// (newest file since this leg started) would never pick: bind it up front
|
|
305
|
+
// and read only what the thread writes from here on
|
|
306
|
+
if (agent === 'codex' && !prompt && session.transcript_path && session.agent_session_id && existsSync(session.transcript_path)) {
|
|
307
|
+
rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
|
|
308
|
+
tail = createTail(rollout.path, { from: logSize(rollout.path) })
|
|
309
|
+
}
|
|
272
310
|
let polls = 0; let warned = false
|
|
273
311
|
let stop = null
|
|
274
312
|
const done = new Promise((res) => { stop = res })
|
|
@@ -439,8 +477,12 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
439
477
|
// record can carry both; End (stop entirely) is the stronger, latest intent
|
|
440
478
|
if (ctl?.end) { if (ctl.by) appendEvent(sid, { type: 'status', by: ctl.by, summary: `end requested from the board by ${ctl.by}` }); clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'exit', code: null, ended: true }); return }
|
|
441
479
|
if (ctl?.handoff) {
|
|
442
|
-
|
|
443
|
-
|
|
480
|
+
const picked = ctl.target && typeof ctl.target === 'object' && ctl.target.agent
|
|
481
|
+
? { agent: String(ctl.target.agent), account: String(ctl.target.account ?? 'default') }
|
|
482
|
+
: null
|
|
483
|
+
const toWhom = picked ? ` to ${picked.agent}${picked.account !== 'default' ? '/' + picked.account : ''}` : ''
|
|
484
|
+
updateSession(sid, { status: 'handing_off', handoff: { reason: `requested from the board${ctl.by ? ` by ${ctl.by}` : ''}`, at: new Date().toISOString(), by: ctl.by ?? null, requested_to: picked } }, { event: { type: 'handoff_requested', by: ctl.by ?? null, summary: `hand off${toWhom} requested from the board${ctl.by ? ` by ${ctl.by}` : ''}` } })
|
|
485
|
+
clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null, target: picked }); return
|
|
444
486
|
}
|
|
445
487
|
// a stale warning patch can overwrite status:'limit' from the hook, but the
|
|
446
488
|
// limit OBJECT survives the clobber — hand off on either signal
|
|
@@ -494,14 +536,14 @@ function messagesFor(agent, s) {
|
|
|
494
536
|
// an order save is consumed here, or the editor sees handing_off and refuses.
|
|
495
537
|
// No eligible choice leaves the session unclaimed so all-out waiting can keep
|
|
496
538
|
// accepting order edits.
|
|
497
|
-
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
|
|
539
|
+
export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null }) {
|
|
498
540
|
let choice = { next: null, out: [] }
|
|
499
541
|
let claimed = false
|
|
500
542
|
const session = updateSession(sid, (current) => {
|
|
501
543
|
const accounts = readAccounts()
|
|
502
544
|
const order = normalizeHandoffOrder(current.handoff_order)
|
|
503
|
-
choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude })
|
|
504
|
-
if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [] }
|
|
545
|
+
choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude, prefer })
|
|
546
|
+
if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [], preferred_taken: false }
|
|
505
547
|
if (!choice.next) return {}
|
|
506
548
|
claimed = true
|
|
507
549
|
return {
|
|
@@ -513,6 +555,10 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
|
|
|
513
555
|
to: choice.next,
|
|
514
556
|
bundle_id: bundle?.id ?? null,
|
|
515
557
|
reason: reason === 'limit' ? 'usage limit' : 'requested',
|
|
558
|
+
// what was asked for, beside what was chosen: when a picked
|
|
559
|
+
// destination walled between the click and the hand-off, the card
|
|
560
|
+
// must say so rather than look like the pick was ignored
|
|
561
|
+
requested_to: prefer ?? null,
|
|
516
562
|
at: new Date().toISOString(),
|
|
517
563
|
},
|
|
518
564
|
}
|
|
@@ -536,14 +582,18 @@ function prepareLegHarness({ sid, from, to }) {
|
|
|
536
582
|
}
|
|
537
583
|
|
|
538
584
|
// ---- the command ----
|
|
539
|
-
|
|
585
|
+
// `cwd` and `continued` are how `leg history continue` starts a leg on a
|
|
586
|
+
// conversation the agent's own store holds (src/history/cli.mjs): the leg runs
|
|
587
|
+
// in that conversation's folder, shares the checkout (its files are already
|
|
588
|
+
// there), and the session record carries the agent's id from the start.
|
|
589
|
+
export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null, continued = null } = {}) {
|
|
540
590
|
if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
541
591
|
// the paid gate: a valid key, or no session (exit 4). The bare agent is never
|
|
542
592
|
// affected; only what Leg adds is licensed.
|
|
543
593
|
const ent = entitlement()
|
|
544
594
|
if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
|
|
545
595
|
// --no-worktree is Leg's flag, not the agent's: it never passes through
|
|
546
|
-
const shareCheckout = args.includes('--no-worktree')
|
|
596
|
+
const shareCheckout = args.includes('--no-worktree') || Boolean(continued)
|
|
547
597
|
args = args.filter((a) => a !== '--no-worktree')
|
|
548
598
|
let autoApproveCli = null
|
|
549
599
|
if (args.includes('--no-auto-approve')) {
|
|
@@ -554,7 +604,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
554
604
|
args = args.filter((a) => a !== '--auto-approve')
|
|
555
605
|
}
|
|
556
606
|
const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
|
|
557
|
-
const cwd = process.cwd()
|
|
607
|
+
const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
|
|
558
608
|
const board = await ensureBoard({ open })
|
|
559
609
|
let accounts = readAccounts()
|
|
560
610
|
const installed = await installedAgents()
|
|
@@ -587,6 +637,13 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
587
637
|
// `git worktree add` still leaves a card (with a Remove button), never a
|
|
588
638
|
// silent orphan under .baton-worktrees with no record and no button
|
|
589
639
|
createSession({ id: sid, agent, account, cwd, repo: g.repo, branch: g.branch, argv: args, chain, worktree: null, owner: whoami(), handoffOrder, installed, runtimeCapabilities: [HANDOFF_ORDER_CAPABILITY] })
|
|
640
|
+
if (continued) {
|
|
641
|
+
// the agent's own id and transcript are known before the first turn, so
|
|
642
|
+
// history dedups this leg against the conversation it continues at once
|
|
643
|
+
const safeTranscript = (continued.transcript_path && insideKnownStore(continued.transcript_path)) ? continued.transcript_path : null
|
|
644
|
+
updateSession(sid, { agent_session_id: continued.native_id ?? null, transcript_path: safeTranscript, task: continued.title ?? null, continued_from: { id: continued.id, provider: continued.provider, native_id: continued.native_id ?? null } },
|
|
645
|
+
{ event: { type: 'continued', summary: `continuing ${continued.id}${continued.title ? `: ${String(continued.title).slice(0, 120)}` : ''}` } })
|
|
646
|
+
}
|
|
590
647
|
let iso = null
|
|
591
648
|
if (g.repo && !shareCheckout) {
|
|
592
649
|
try { iso = isolate({ g, cwd, sid }) } catch (err) { say(`could not make a worktree (${String(err.message).split('\n')[0].slice(0, 200)}); sharing the checkout`); try { removeWorktree(g.repo, sid) } catch {} }
|
|
@@ -623,7 +680,9 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
623
680
|
const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
|
|
624
681
|
// destinations the strict harness policy refused during this hand-off
|
|
625
682
|
const excluded = []
|
|
626
|
-
|
|
683
|
+
// the destination a human picked on the board, if they picked one
|
|
684
|
+
const prefer = r.target ?? null
|
|
685
|
+
let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
|
|
627
686
|
let choice = claim.choice
|
|
628
687
|
let cancelled = false
|
|
629
688
|
let blocked = false
|
|
@@ -644,7 +703,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
644
703
|
updateSession(sid, { status: 'waiting', all_out: all, waiting: first ? { agent: first.agent, account: first.account, resets_at: first.resets_at, since: new Date().toISOString() } : null }, { event: { type: 'all_out', summary: `every option is out; waiting for ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}` } })
|
|
645
704
|
const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
|
|
646
705
|
if (r2 === 'cancelled') { cancelled = true; break }
|
|
647
|
-
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
706
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
|
|
648
707
|
choice = claim.choice
|
|
649
708
|
}
|
|
650
709
|
if (cancelled) {
|
|
@@ -660,7 +719,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
660
719
|
if (prepared.proceed) break
|
|
661
720
|
excluded.push(choice.next)
|
|
662
721
|
say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
|
|
663
|
-
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
722
|
+
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
|
|
664
723
|
choice = claim.choice
|
|
665
724
|
if (!choice.next) blocked = true
|
|
666
725
|
}
|
|
@@ -671,6 +730,19 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
671
730
|
break
|
|
672
731
|
}
|
|
673
732
|
const next = choice.next
|
|
733
|
+
// A pick that could not be taken is never silent: between the click and
|
|
734
|
+
// this moment that account can wall, or the strict harness policy can
|
|
735
|
+
// refuse it, and a terminal that quietly went somewhere else is the kind
|
|
736
|
+
// of surprise this board exists to remove.
|
|
737
|
+
if (prefer && !choice.preferred_taken) {
|
|
738
|
+
const asked = `${prefer.agent}${prefer.account !== 'default' ? '/' + prefer.account : ''}`
|
|
739
|
+
const got = `${next.agent}${next.account !== 'default' ? '/' + next.account : ''}`
|
|
740
|
+
const why = excluded.some((x) => x.agent === prefer.agent && x.account === prefer.account)
|
|
741
|
+
? 'the strict harness policy refused it'
|
|
742
|
+
: `it is at its limit until ${fmtReset(readUsage(prefer.agent, prefer.account).limited_until)}`
|
|
743
|
+
say(`${asked} was picked but ${why}; handing off to ${got} instead`)
|
|
744
|
+
appendEvent(sid, { type: 'status', summary: `${asked} was picked for this hand-off but ${why}; ${got} took it instead` })
|
|
745
|
+
}
|
|
674
746
|
// bound the number of hand-offs in one terminal so a chain that limits
|
|
675
747
|
// instantly can never loop forever; stopping is explicit, not a silent exit 0
|
|
676
748
|
if (leg >= 11) {
|