@ucsandman/legcli 0.10.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.
@@ -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
+ }
@@ -1,5 +1,15 @@
1
- // grok adapter - Grok CLI headless (`grok -p <prompt> --output-format json`).
2
- // Registered in index.mjs alongside claude, codex, and agy.
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 args = ['-p', opts.prompt ?? '', '--output-format', 'json', '--permission-mode', mode]
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
- try {
35
- const j = JSON.parse(s.slice(i))
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 ?? j.sessionId ?? null,
38
- last_message: typeof j.response === 'string' ? j.response : (typeof j.result === 'string' ? j.result : (typeof j.text === 'string' ? j.text : null)),
39
- stop_reason: j.stop_reason ?? j.stopReason ?? null,
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
- } catch { continue }
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
  },
@@ -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.mjs exists but is NOT registered: the build machine had no grok login
5
- // (the probe printed a device-code prompt and exited "Cancelled"). Register it
6
- // after `grok` is logged in and scripts/probe.mjs --adapter grok passes.
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 function names() { return Object.keys(REGISTRY) }
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 (!entry) throw new Error(`unknown adapter: ${name}`)
25
- const mod = await import(entry.path)
26
- return entry.fake ? mod.makeFake(...entry.fake) : mod.default
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
@@ -263,7 +263,10 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
263
263
  }
264
264
 
265
265
  // ---- one agent leg ----
266
- // 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
+
267
270
  async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove() }) {
268
271
  const sid = session.session_id
269
272
  refreshAccount(agent, account)
@@ -474,8 +477,12 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
474
477
  // record can carry both; End (stop entirely) is the stronger, latest intent
475
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 }
476
479
  if (ctl?.handoff) {
477
- updateSession(sid, { status: 'handing_off', handoff: { reason: `requested from the board${ctl.by ? ` by ${ctl.by}` : ''}`, at: new Date().toISOString(), by: ctl.by ?? null } }, { event: { type: 'handoff_requested', by: ctl.by ?? null, summary: `hand off requested from the board${ctl.by ? ` by ${ctl.by}` : ''}` } })
478
- clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null }); return
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
479
486
  }
480
487
  // a stale warning patch can overwrite status:'limit' from the hook, but the
481
488
  // limit OBJECT survives the clobber — hand off on either signal
@@ -529,14 +536,14 @@ function messagesFor(agent, s) {
529
536
  // an order save is consumed here, or the editor sees handing_off and refuses.
530
537
  // No eligible choice leaves the session unclaimed so all-out waiting can keep
531
538
  // accepting order edits.
532
- 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 }) {
533
540
  let choice = { next: null, out: [] }
534
541
  let claimed = false
535
542
  const session = updateSession(sid, (current) => {
536
543
  const accounts = readAccounts()
537
544
  const order = normalizeHandoffOrder(current.handoff_order)
538
- choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude })
539
- 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 }
540
547
  if (!choice.next) return {}
541
548
  claimed = true
542
549
  return {
@@ -548,6 +555,10 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
548
555
  to: choice.next,
549
556
  bundle_id: bundle?.id ?? null,
550
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,
551
562
  at: new Date().toISOString(),
552
563
  },
553
564
  }
@@ -669,7 +680,9 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
669
680
  const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
670
681
  // destinations the strict harness policy refused during this hand-off
671
682
  const excluded = []
672
- let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
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 })
673
686
  let choice = claim.choice
674
687
  let cancelled = false
675
688
  let blocked = false
@@ -690,7 +703,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
690
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'}` } })
691
704
  const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
692
705
  if (r2 === 'cancelled') { cancelled = true; break }
693
- 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 })
694
707
  choice = claim.choice
695
708
  }
696
709
  if (cancelled) {
@@ -706,7 +719,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
706
719
  if (prepared.proceed) break
707
720
  excluded.push(choice.next)
708
721
  say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
709
- 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 })
710
723
  choice = claim.choice
711
724
  if (!choice.next) blocked = true
712
725
  }
@@ -717,6 +730,19 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
717
730
  break
718
731
  }
719
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
+ }
720
746
  // bound the number of hand-offs in one terminal so a chain that limits
721
747
  // instantly can never loop forever; stopping is explicit, not a silent exit 0
722
748
  if (leg >= 11) {
package/src/audit.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // audit — one list of who did what on this board, across every terminal and
2
+ // every card, newest first.
3
+ //
4
+ // The ledger already names an actor on every event; until now you could only
5
+ // read that one session or one card at a time, which is no use when the
6
+ // question is "who landed that?" or "who handed my terminal off last night".
7
+ // Nothing new is recorded here: this reads what is already on disk.
8
+ //
9
+ // Owner only (src/server.mjs): the trail names repositories and people.
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { home } from './store.mjs'
13
+ import { listSessions, readEvents as readSessionEvents } from './sessions.mjs'
14
+ import { readEvents as readCardEvents } from './ledger.mjs'
15
+
16
+ // The types worth a line in an audit: something a person or an agent DID, not
17
+ // the running commentary. A `status` line is commentary; a hand-off is not.
18
+ export const AUDITED = [
19
+ 'handoff', 'handoff_requested', 'handed_off', 'landed', 'land', 'bounced', 'killed',
20
+ 'approved', 'approval_needed', 'reassigned', 'paused', 'resumed', 'ended', 'done',
21
+ 'failed', 'trust', 'harness', 'worktree', 'station_done', 'leg_started', 'rerun',
22
+ ]
23
+
24
+ export const ACTOR_KINDS = ['human', 'agent', 'leg']
25
+
26
+ function actorOf(ev) {
27
+ // a card event carries a structured actor; a session event carries `by`
28
+ if (ev.actor && typeof ev.actor === 'object') {
29
+ if (ev.actor.type === 'human') return { kind: 'human', name: String(ev.actor.id ?? 'unknown') }
30
+ if (ev.actor.type === 'agent') return { kind: 'agent', name: String(ev.actor.adapter ?? 'agent') }
31
+ return { kind: 'leg', name: 'leg' }
32
+ }
33
+ if (ev.by) return { kind: 'human', name: String(ev.by) }
34
+ return { kind: 'leg', name: 'leg' }
35
+ }
36
+
37
+ function cardIds() {
38
+ const root = join(home(), 'cards')
39
+ if (!existsSync(root)) return []
40
+ try { return readdirSync(root).filter((d) => existsSync(join(root, d, 'card.json'))) } catch { return [] }
41
+ }
42
+
43
+ function cardMeta(id) {
44
+ try { return JSON.parse(readFileSync(join(home(), 'cards', id, 'card.json'), 'utf8')) } catch { return null }
45
+ }
46
+
47
+ // → { entries: [...], truncated, scanned: { sessions, cards, events } }
48
+ // `scanned` is on the record deliberately: an empty audit from a board that
49
+ // looked at nothing reads exactly like a quiet week, and the two are not the
50
+ // same thing.
51
+ export function auditTrail({ limit = 200, since = null, who = null, kind = null, types = null } = {}) {
52
+ const sinceMs = since ? Date.parse(since) : null
53
+ const wanted = Array.isArray(types) && types.length ? new Set(types) : new Set(AUDITED)
54
+ const rows = []
55
+ let events = 0
56
+
57
+ const sessions = listSessions()
58
+ for (const s of sessions) {
59
+ for (const ev of readSessionEvents(s.session_id)) {
60
+ events++
61
+ if (!wanted.has(ev.type)) continue
62
+ const at = Date.parse(ev.ts)
63
+ if (sinceMs && Number.isFinite(at) && at < sinceMs) continue
64
+ const actor = actorOf(ev)
65
+ rows.push({
66
+ at: ev.ts,
67
+ who: actor.name,
68
+ kind: actor.kind,
69
+ what: ev.type,
70
+ summary: String(ev.summary ?? ''),
71
+ where: 'terminal',
72
+ id: s.session_id,
73
+ agent: s.agent ?? null,
74
+ repo: s.repo ?? null,
75
+ branch: s.branch ?? null,
76
+ })
77
+ }
78
+ }
79
+
80
+ const ids = cardIds()
81
+ for (const id of ids) {
82
+ const card = cardMeta(id)
83
+ for (const ev of readCardEvents(id)) {
84
+ events++
85
+ if (!wanted.has(ev.type)) continue
86
+ const at = Date.parse(ev.ts)
87
+ if (sinceMs && Number.isFinite(at) && at < sinceMs) continue
88
+ const actor = actorOf(ev)
89
+ rows.push({
90
+ at: ev.ts,
91
+ who: actor.name,
92
+ kind: actor.kind,
93
+ what: ev.type,
94
+ summary: String(ev.summary ?? ''),
95
+ where: 'card',
96
+ id,
97
+ agent: ev.actor?.type === 'agent' ? ev.actor.adapter : null,
98
+ repo: card?.repo ?? null,
99
+ branch: card?.branch ?? null,
100
+ })
101
+ }
102
+ }
103
+
104
+ let filtered = rows
105
+ if (who) filtered = filtered.filter((r) => r.who.toLowerCase() === String(who).toLowerCase())
106
+ if (kind) filtered = filtered.filter((r) => r.kind === kind)
107
+ filtered.sort((a, b) => (Date.parse(b.at) || 0) - (Date.parse(a.at) || 0))
108
+ const capped = filtered.slice(0, Math.max(1, Math.min(limit, 1000)))
109
+
110
+ return {
111
+ entries: capped,
112
+ truncated: filtered.length > capped.length,
113
+ matched: filtered.length,
114
+ // L2: a verdict carries the volume it processed
115
+ scanned: { sessions: sessions.length, cards: ids.length, events },
116
+ people: [...new Set(rows.filter((r) => r.kind === 'human').map((r) => r.who))].sort(),
117
+ }
118
+ }