@ucsandman/legcli 0.8.0 → 0.10.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 +121 -0
- package/NOTICE +8 -0
- package/README.md +639 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +43 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +205 -0
- package/docs/README.md +5 -1
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +22 -0
- package/docs/board-guide.md +33 -1
- package/docs/cli-contracts.md +36 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +23 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +18 -4
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +6 -4
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +125 -23
- package/src/auth.mjs +2 -2
- package/src/board/board.css +23 -1
- package/src/board/board.js +17 -5
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +95 -7
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- 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/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +24 -6
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +224 -18
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +15 -3
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +177 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/taps/mod.mjs +340 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +6 -5
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
package/src/preferences.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Machine-wide defaults for interactive
|
|
1
|
+
// Machine-wide defaults for interactive Leg terminals. A new terminal takes
|
|
2
2
|
// a copy of these preferences when it starts; later edits do not silently
|
|
3
3
|
// change terminals that are already running.
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
@@ -27,6 +27,38 @@ export function requireHandoffOrder(value) {
|
|
|
27
27
|
return [...value]
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Portable harness (src/harness/): off for every existing install. `enabled`
|
|
31
|
+
// is the explicit consent `leg harness enable` records; `policy` is what an
|
|
32
|
+
// unattended hand-off may do (warn: report only; sync: write managed state
|
|
33
|
+
// when it is safe; strict: refuse a destination that cannot be made safe);
|
|
34
|
+
// `source` is the client whose harness is the one being carried.
|
|
35
|
+
export const HARNESS_POLICIES = ['warn', 'sync', 'strict']
|
|
36
|
+
export const HARNESS_SOURCES = ['claude', 'codex']
|
|
37
|
+
export const HARNESS_DEFAULTS = Object.freeze({ enabled: false, policy: 'warn', source: null })
|
|
38
|
+
|
|
39
|
+
export function normalizeHarness(value) {
|
|
40
|
+
const v = value && typeof value === 'object' ? value : {}
|
|
41
|
+
return {
|
|
42
|
+
enabled: v.enabled === true,
|
|
43
|
+
policy: HARNESS_POLICIES.includes(v.policy) ? v.policy : HARNESS_DEFAULTS.policy,
|
|
44
|
+
source: HARNESS_SOURCES.includes(v.source) ? v.source : null,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requireHarness(patch, current = HARNESS_DEFAULTS) {
|
|
49
|
+
const next = { ...normalizeHarness(current) }
|
|
50
|
+
if (patch?.enabled !== undefined) next.enabled = Boolean(patch.enabled)
|
|
51
|
+
if (patch?.policy !== undefined) {
|
|
52
|
+
if (!HARNESS_POLICIES.includes(patch.policy)) throw new TypeError(`harness policy must be one of ${HARNESS_POLICIES.join(', ')}`)
|
|
53
|
+
next.policy = patch.policy
|
|
54
|
+
}
|
|
55
|
+
if (patch?.source !== undefined) {
|
|
56
|
+
if (patch.source !== null && !HARNESS_SOURCES.includes(patch.source)) throw new TypeError(`harness source must be one of ${HARNESS_SOURCES.join(', ')}`)
|
|
57
|
+
next.source = patch.source
|
|
58
|
+
}
|
|
59
|
+
return next
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
export function preferencesFile() { return join(home(), 'preferences.json') }
|
|
31
63
|
|
|
32
64
|
export function resolveAutoApprove({ env = process.env, preferences = null, cliFlag = null } = {}) {
|
|
@@ -41,15 +73,16 @@ export function resolveAutoApprove({ env = process.env, preferences = null, cliF
|
|
|
41
73
|
|
|
42
74
|
export function readPreferences() {
|
|
43
75
|
const file = preferencesFile()
|
|
44
|
-
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
|
|
76
|
+
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
|
|
45
77
|
try {
|
|
46
78
|
const value = JSON.parse(readFileSync(file, 'utf8'))
|
|
47
79
|
return {
|
|
48
80
|
handoff_order: normalizeHandoffOrder(value?.handoff_order),
|
|
49
81
|
auto_approve: value?.auto_approve !== false,
|
|
82
|
+
harness: normalizeHarness(value?.harness),
|
|
50
83
|
}
|
|
51
84
|
} catch {
|
|
52
|
-
return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
|
|
85
|
+
return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
|
|
53
86
|
}
|
|
54
87
|
}
|
|
55
88
|
|
|
@@ -61,6 +94,7 @@ export function writePreferences(patch) {
|
|
|
61
94
|
const next = { ...current }
|
|
62
95
|
if (order !== undefined) next.handoff_order = order
|
|
63
96
|
if (patch?.auto_approve !== undefined) next.auto_approve = Boolean(patch.auto_approve)
|
|
97
|
+
if (patch?.harness !== undefined) next.harness = requireHarness(patch.harness, current.harness)
|
|
64
98
|
writeJsonAtomic(preferencesFile(), next)
|
|
65
99
|
return next
|
|
66
100
|
})
|
package/src/redact.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// redact — the one list of secret shapes. `scrub()` rewrites (logs, bundles,
|
|
2
2
|
// launcher output); the ledger's assertNoSecrets refuses (src/ledger.mjs).
|
|
3
|
-
// The source tooling kept two copies on purpose;
|
|
3
|
+
// The source tooling kept two copies on purpose; Leg keeps one here.
|
|
4
4
|
// Values the launcher's own process holds for the well-known key variables
|
|
5
5
|
// are read once at startup and never printed.
|
|
6
6
|
// Every prefix shape starts at a token boundary: the `sk-` inside
|
|
@@ -11,19 +11,37 @@ const PATTERNS = [
|
|
|
11
11
|
['Anthropic key (sk-ant-)', /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{8,}/g],
|
|
12
12
|
['DashClaw key (oc_live_)', /(?<![A-Za-z0-9_-])oc_live_[a-f0-9]\w*/g],
|
|
13
13
|
['bearer token', /Bearer\s+[A-Za-z0-9._-]{16,}/g],
|
|
14
|
-
['GitHub token (
|
|
15
|
-
['GitHub server token (ghs_)', /(?<![A-Za-z0-9_-])ghs_[A-Za-z0-9]{20,}/g],
|
|
14
|
+
['GitHub token (gh[pousr]_)', /(?<![A-Za-z0-9_-])gh[pousr]_[A-Za-z0-9]{20,}/g],
|
|
16
15
|
['GitHub fine-grained token (github_pat_)', /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/g],
|
|
17
16
|
['AWS key (AKIA)', /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{12,}/g],
|
|
18
|
-
['Slack token (xox)', /(?<![A-Za-z0-9_-])xox[
|
|
19
|
-
['key=value secret', /api[_-]?key\
|
|
17
|
+
['Slack token (xox/xapp)', /(?<![A-Za-z0-9_-])(?:xox[baprs]|xapp)-\S+/g],
|
|
18
|
+
['key=value secret', /api[_-]?key[ \t]*[=:][ \t]*\S+/gi],
|
|
19
|
+
// shapes a discovered transcript from another agent carries that the list
|
|
20
|
+
// above missed (measured 2026-09-16: 15 of 18 common shapes went through)
|
|
21
|
+
['Stripe key (sk_live_/rk_live_)', /(?<![A-Za-z0-9_-])[sr]k_(?:live|test)_[A-Za-z0-9]{8,}/g],
|
|
22
|
+
['Google API key (AIza)', /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{30,}/g],
|
|
23
|
+
['xAI key (xai-)', /(?<![A-Za-z0-9_-])xai-[A-Za-z0-9]{16,}/g],
|
|
24
|
+
['npm token (npm_)', /(?<![A-Za-z0-9_-])npm_[A-Za-z0-9]{20,}/g],
|
|
25
|
+
['GitLab token (glpat-)', /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{16,}/g],
|
|
26
|
+
['Hugging Face token (hf_)', /(?<![A-Za-z0-9_-])hf_[A-Za-z0-9]{20,}/g],
|
|
27
|
+
['JWT', /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
28
|
+
['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
|
|
29
|
+
// a digit or two capitals somewhere: base64 of any credential has one of
|
|
30
|
+
// them, "Basic authentication/authorization" in prose has neither
|
|
31
|
+
['basic auth header', /Basic\s+(?=[A-Za-z0-9+/=]*(?:[0-9]|[A-Z][A-Za-z0-9+/=]*[A-Z]))[A-Za-z0-9+/=]{16,}/g],
|
|
32
|
+
['URL with credentials', /(?<=[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@/]{4,}(?=@)/gi],
|
|
33
|
+
// an environment-style NAME (upper case): `cache_key = build(...)` and
|
|
34
|
+
// `refresh_token: string` in code are not secrets; a value never crosses a
|
|
35
|
+
// line break, so `SECRET_KEY=` at the end of a line takes nothing after it
|
|
36
|
+
['env-style secret assign', /(?:[A-Z0-9_]*_(?:KEY|TOKEN|SECRET|PASSWORD)|aws_secret_access_key)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/g],
|
|
37
|
+
['password=value', /\b(?:password|passwd)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/gi],
|
|
20
38
|
]
|
|
21
39
|
|
|
22
40
|
export const SECRET_RES = PATTERNS.map(([, re]) => re)
|
|
23
41
|
// non-global copies for `.test()` (a /g regex carries lastIndex state)
|
|
24
42
|
export const SECRET_PATTERNS = PATTERNS.map(([name, re]) => [name, new RegExp(re.source, re.flags.replace('g', ''))])
|
|
25
43
|
|
|
26
|
-
const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'BATON_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN']
|
|
44
|
+
const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'LEG_TOKEN', 'BATON_TOKEN', 'LEG_LICENSE_PRIVATE_KEY', 'BATON_LICENSE_PRIVATE_KEY', 'GITHUB_TOKEN', 'GH_TOKEN', 'STRIPE_SECRET_KEY', 'STRIPE_TEST_SECRET_KEY', 'RESEND_API_KEY', 'NPM_TOKEN']
|
|
27
45
|
let envValues = null
|
|
28
46
|
function heldValues() {
|
|
29
47
|
if (envValues) return envValues
|
package/src/resume.mjs
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// resume — the pointer that cannot describe a picture that is no longer true.
|
|
2
2
|
//
|
|
3
|
-
// `.
|
|
3
|
+
// `.leg/RESUME.md` is the file humans and other agents open by habit (legacy
|
|
4
|
+
// `.baton/RESUME.md` is still read). It used
|
|
4
5
|
// to be an unowned convenience copy: written once per hand-off, never touched
|
|
5
6
|
// again, with no stamp and no expiry, so a normally exited terminal left hours
|
|
6
7
|
// old text sitting there looking live.
|
|
7
8
|
//
|
|
8
9
|
// Two rules fix that, and they are the whole module:
|
|
9
|
-
// 1. Every resume file
|
|
10
|
+
// 1. Every resume file Leg writes carries a stamp of the git state and the
|
|
10
11
|
// live terminals it was written against (an HTML comment, invisible in
|
|
11
12
|
// rendered markdown).
|
|
12
13
|
// 2. Freshness is never remembered — it is recomputed from git at READ time.
|
|
13
14
|
// A file cannot lie about HEAD to a reader who re-asks git.
|
|
14
|
-
//
|
|
15
|
+
// Leg owns the file: it rewrites it when a session ends and when the board
|
|
15
16
|
// starts, so nothing is left describing a terminal that is gone.
|
|
16
17
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
18
|
import { spawnSync } from 'node:child_process'
|
|
@@ -24,8 +25,8 @@ export const STAMP_PREFIX = '<!-- leg-resume '
|
|
|
24
25
|
export const LEGACY_STAMP_PREFIX = '<!-- baton-resume '
|
|
25
26
|
const STAMP_SUFFIX = ' -->'
|
|
26
27
|
const STAMP_VERSION = 1
|
|
27
|
-
//
|
|
28
|
-
//
|
|
28
|
+
// Leg's own directories dirty the tree on every write; a reader must not see
|
|
29
|
+
// Leg's bookkeeping as the human's work moving on.
|
|
29
30
|
const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
|
|
30
31
|
const MAX_REASONS = 5
|
|
31
32
|
|
|
@@ -81,6 +82,7 @@ export function perSessionFile(cwd, id) {
|
|
|
81
82
|
if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
|
|
82
83
|
return leg
|
|
83
84
|
}
|
|
85
|
+
export { synthesisFile } from './synthesis.mjs'
|
|
84
86
|
|
|
85
87
|
// An agent started deeper in the tree still finds its checkout's pointer.
|
|
86
88
|
export function findResume(startDir) {
|
|
@@ -117,7 +119,7 @@ export function readStamp(text) {
|
|
|
117
119
|
} catch { return null }
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
// The text without its stamp: what a human reads and what `
|
|
122
|
+
// The text without its stamp: what a human reads and what `leg resume` prints.
|
|
121
123
|
export function bodyOf(text) {
|
|
122
124
|
const s = String(text ?? '')
|
|
123
125
|
return readStamp(s) ? s.slice(s.indexOf('\n') + 1).replace(/^\n+/, '') : s
|
|
@@ -200,10 +202,10 @@ export function lastHandoffIn(root, sessions = listSessions()) {
|
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
function idleBody(last, live) {
|
|
203
|
-
const lines = ['#
|
|
205
|
+
const lines = ['# Leg: nothing in flight', '']
|
|
204
206
|
lines.push(live.length
|
|
205
207
|
? `No hand-off is waiting to be picked up here. Still live in this checkout: ${live.map((s) => `${s.session_id} (${s.agent})`).join(', ')}.`
|
|
206
|
-
: 'No
|
|
208
|
+
: 'No Leg terminal is live in this checkout.')
|
|
207
209
|
lines.push('')
|
|
208
210
|
if (last) {
|
|
209
211
|
const at = last.at ? String(last.at).slice(0, 16).replace('T', ' ') : 'an unrecorded time'
|
|
@@ -221,7 +223,7 @@ function idleBody(last, live) {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// The "nothing in flight" pointer. Always writes; the callers that must not
|
|
224
|
-
// create a file in a checkout
|
|
226
|
+
// create a file in a checkout Leg never handed off in check first.
|
|
225
227
|
export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
|
|
226
228
|
if (!root) return null
|
|
227
229
|
const dir = existsSync(join(root, '.baton')) && !existsSync(join(root, '.leg')) ? join(root, '.baton') : join(root, '.leg')
|
|
@@ -235,7 +237,7 @@ export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
|
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
// A session ending must not leave its hand-off sitting there looking live.
|
|
238
|
-
// Only ever rewrites a pointer that already exists:
|
|
240
|
+
// Only ever rewrites a pointer that already exists: Leg owns RESUME.md where
|
|
239
241
|
// it wrote one, and creates none in a checkout it never handed off in.
|
|
240
242
|
export function endSessionPointer(session) {
|
|
241
243
|
const root = workRoot(session)
|
|
@@ -243,7 +245,7 @@ export function endSessionPointer(session) {
|
|
|
243
245
|
return writeIdlePointer(root)
|
|
244
246
|
}
|
|
245
247
|
|
|
246
|
-
// Board start: every checkout
|
|
248
|
+
// Board start: every checkout Leg wrote a pointer in gets it recomputed, so a
|
|
247
249
|
// terminal that crashed instead of exiting cannot leave a live-looking hand-off
|
|
248
250
|
// behind. A checkout whose terminal really is live keeps its hand-off text.
|
|
249
251
|
// Returns the roots rewritten.
|
|
@@ -260,9 +262,9 @@ export function refreshPointers() {
|
|
|
260
262
|
const v = resumeVerdict(root, { sessions })
|
|
261
263
|
if (v.state === 'fresh') continue
|
|
262
264
|
// The terminal that wrote a hand-off owns it while it is still running: its
|
|
263
|
-
// text is the live description, and "the repo moved on" is for `
|
|
265
|
+
// text is the live description, and "the repo moved on" is for `leg resume
|
|
264
266
|
// --check` to report, not for the board to overwrite. Anything else — a
|
|
265
|
-
// hand-off from a terminal that is gone, a file no
|
|
267
|
+
// hand-off from a terminal that is gone, a file no Leg stamped — is
|
|
266
268
|
// replaced even when some OTHER terminal happens to be live in the
|
|
267
269
|
// checkout, which is the case that left three day old text sitting there.
|
|
268
270
|
if (v.session?.active) continue
|
|
@@ -284,14 +286,14 @@ function describe(list) { return list.map((s) => `${s.id ?? s.session_id} (${s.a
|
|
|
284
286
|
export function resumeVerdict(cwd, { sessions = listSessions() } = {}) {
|
|
285
287
|
const found = findResume(cwd)
|
|
286
288
|
if (!found) {
|
|
287
|
-
return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .
|
|
289
|
+
return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .leg/RESUME.md in this checkout'], summary: 'no resume pointer in this checkout', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
|
|
288
290
|
}
|
|
289
291
|
const { root, file } = found
|
|
290
292
|
let text = ''
|
|
291
293
|
try { text = readFileSync(file, 'utf8') } catch { /* raced a rewrite */ }
|
|
292
294
|
const stamp = readStamp(text)
|
|
293
295
|
if (!stamp) {
|
|
294
|
-
return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no
|
|
296
|
+
return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no Leg stamp, so its freshness cannot be checked against git'], summary: 'cannot be checked: no Leg stamp', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
|
|
295
297
|
}
|
|
296
298
|
|
|
297
299
|
const now = gitState(root)
|
package/src/runner.mjs
CHANGED
|
@@ -129,7 +129,7 @@ function errTail(path, lines = 10) {
|
|
|
129
129
|
|
|
130
130
|
function killTree(pid, log) {
|
|
131
131
|
if ((process.env.LEG_SKIP_KILL || process.env.BATON_SKIP_KILL) === '1') { // test seam: unkillable agent
|
|
132
|
-
log('
|
|
132
|
+
log('LEG_SKIP_KILL=1: killTree skipped')
|
|
133
133
|
return
|
|
134
134
|
}
|
|
135
135
|
if (process.platform === 'win32') {
|
|
@@ -151,7 +151,7 @@ function gitHead(cwd) {
|
|
|
151
151
|
function gitDiff(cwd, headAtStart) {
|
|
152
152
|
const r = spawnSync('git', ['status', '--porcelain'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
153
153
|
if (r.status !== 0) return null
|
|
154
|
-
const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.baton[\\/]/.test(l)).length
|
|
154
|
+
const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.(baton|leg)[\\/]/.test(l)).length
|
|
155
155
|
const head = gitHead(cwd)
|
|
156
156
|
return { changed: files > 0 || (headAtStart !== null && head !== headAtStart), files, head_at_start: headAtStart, head }
|
|
157
157
|
}
|
package/src/scheduler.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set()
|
|
|
46
46
|
|
|
47
47
|
export function pidfile() { return join(home(), 'scheduler.pid') }
|
|
48
48
|
|
|
49
|
-
export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: '
|
|
49
|
+
export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'leg' } } = {}) {
|
|
50
50
|
const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
|
|
51
51
|
|
|
52
52
|
async function tick() {
|
package/src/server.mjs
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// server — the board's HTTP + SSE backend. Ledger-backed: every handler reads
|
|
3
3
|
// card.json / events-*.jsonl on demand (no module-level card store), so a
|
|
4
4
|
// restart shows the same board and a second process sees the same truth.
|
|
5
|
-
//
|
|
6
|
-
// multiplayer seams (src/auth.mjs).
|
|
5
|
+
// LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
|
|
6
|
+
// multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
|
|
7
7
|
import http from 'node:http'
|
|
8
|
-
import { spawnSync } from 'node:child_process'
|
|
9
|
-
import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
8
|
+
import { spawnSync, execFile } from 'node:child_process'
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
|
|
10
10
|
import { join, dirname, resolve, extname, sep } from 'node:path'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
|
|
@@ -26,15 +26,28 @@ import { scrub } from './runner.mjs'
|
|
|
26
26
|
import { resolveChb } from './handoff.mjs'
|
|
27
27
|
import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
|
|
28
28
|
import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
|
|
29
|
+
import { hasRecentSynthesis } from './synthesis.mjs'
|
|
29
30
|
import { refreshPointers } from './resume.mjs'
|
|
30
31
|
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
31
32
|
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
32
33
|
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
33
34
|
import { readCodexUsage } from './taps/codex.mjs'
|
|
34
35
|
import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
|
|
36
|
+
import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
|
|
37
|
+
import { listWorktrees } from './history/worktrees.mjs'
|
|
35
38
|
|
|
36
39
|
const SELF = fileURLToPath(import.meta.url)
|
|
37
|
-
|
|
40
|
+
export function resolveBoardDir() {
|
|
41
|
+
const dir = join(dirname(SELF), 'board')
|
|
42
|
+
if (existsSync(dir)) return dir
|
|
43
|
+
const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(dirname(SELF))
|
|
44
|
+
if (wtMatch) {
|
|
45
|
+
const root = dirname(SELF).slice(0, wtMatch.index)
|
|
46
|
+
const fallback = join(root, 'src', 'board')
|
|
47
|
+
if (existsSync(fallback)) return fallback
|
|
48
|
+
}
|
|
49
|
+
return dir
|
|
50
|
+
}
|
|
38
51
|
const VERSION = JSON.parse(readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8')).version
|
|
39
52
|
const DEFAULT_ORDER = ['plan', 'build', 'review', 'test', 'land']
|
|
40
53
|
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2' }
|
|
@@ -215,6 +228,64 @@ async function adaptersInfo() {
|
|
|
215
228
|
}
|
|
216
229
|
|
|
217
230
|
// ---- sessions (baton claude|codex|agy) ----
|
|
231
|
+
// The worktree list runs git once per known repository: cached for a short
|
|
232
|
+
// while so a board that polls does not fork fifty processes a second.
|
|
233
|
+
const WORKTREES_TTL = 20000
|
|
234
|
+
const worktreesCache = new Map()
|
|
235
|
+
// A stale index is refreshed by a child `leg history refresh`, never in this
|
|
236
|
+
// process: the scan stats thousands of files and walks every cwd, and inside
|
|
237
|
+
// the board's event loop that is seconds of no SSE frames and no clicks. The
|
|
238
|
+
// child takes the same index lock a CLI refresh would, so the two never tear
|
|
239
|
+
// one file; the next listing reads what it wrote.
|
|
240
|
+
const LEG_BIN = join(dirname(SELF), '..', 'bin', 'leg.mjs')
|
|
241
|
+
let historyRefreshing = false
|
|
242
|
+
function backgroundHistoryRefresh() {
|
|
243
|
+
if (historyRefreshing) return
|
|
244
|
+
historyRefreshing = true
|
|
245
|
+
try {
|
|
246
|
+
execFile(process.execPath, [LEG_BIN, 'history', 'refresh', '--json'], { env: process.env, windowsHide: true, timeout: 120000 }, () => { historyRefreshing = false })
|
|
247
|
+
} catch { historyRefreshing = false }
|
|
248
|
+
}
|
|
249
|
+
function worktreesFor({ repo = null, dirty = true } = {}) {
|
|
250
|
+
const key = `${repo ?? ''}|${dirty}`
|
|
251
|
+
const hit = worktreesCache.get(key)
|
|
252
|
+
if (hit && Date.now() - hit.at < WORKTREES_TTL) return hit.data
|
|
253
|
+
const data = listWorktrees({ repo, dirty, dirtyLimit: 20, repoLimit: 20 })
|
|
254
|
+
worktreesCache.set(key, { at: Date.now(), data })
|
|
255
|
+
return data
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// canLand shells out to git several times for one worktree, and the view runs
|
|
259
|
+
// it for every terminal that ever had one — over a second of subprocesses on a
|
|
260
|
+
// board with a few dozen records, paid again on every SSE push. A terminal that
|
|
261
|
+
// has ended never moves, so the answer is cached against the record's own
|
|
262
|
+
// revision with the short TTL the trunk already uses, which still notices a
|
|
263
|
+
// commit made by hand in the worktree within that window.
|
|
264
|
+
// A terminal that is still running can change what it can land from one turn to
|
|
265
|
+
// the next. One that has ended only moves if someone works in its worktree by
|
|
266
|
+
// hand, and a landing clears this cache outright, so it is re-read a great deal
|
|
267
|
+
// less often.
|
|
268
|
+
const CAN_LAND_TTL = 15000
|
|
269
|
+
const CAN_LAND_TTL_ENDED = 60000
|
|
270
|
+
const canLandCache = new Map()
|
|
271
|
+
function canLandFor(s) {
|
|
272
|
+
const key = `${s.session_id}|${s.updated_at ?? ''}`
|
|
273
|
+
const hit = canLandCache.get(key)
|
|
274
|
+
if (hit && Date.now() < hit.until) return hit.data
|
|
275
|
+
const data = canLand(s)
|
|
276
|
+
// Expiries are spread across the window instead of falling together: twenty
|
|
277
|
+
// worktrees re-read in one pass is another second of git inside the event
|
|
278
|
+
// loop, which is the stall this cache exists to remove. Staggered, the board
|
|
279
|
+
// pays for about one of them per push and never blocks on the set.
|
|
280
|
+
const ttl = isActive(s) ? CAN_LAND_TTL : CAN_LAND_TTL_ENDED
|
|
281
|
+
const until = Date.now() + ttl / 2 + Math.random() * ttl
|
|
282
|
+
// the key carries updated_at, so a busy terminal leaves a dead entry per
|
|
283
|
+
// write: drop the whole map rather than grow it for the life of the process
|
|
284
|
+
if (canLandCache.size > 500) canLandCache.clear()
|
|
285
|
+
canLandCache.set(key, { until, data })
|
|
286
|
+
return data
|
|
287
|
+
}
|
|
288
|
+
|
|
218
289
|
const trunkCache = new Map()
|
|
219
290
|
function trunkFor(repo) {
|
|
220
291
|
const hit = trunkCache.get(repo)
|
|
@@ -258,6 +329,8 @@ function redactSession(s) {
|
|
|
258
329
|
// the branch is already on the worktree chip: naming it again costs nothing
|
|
259
330
|
// and is what the board's land line reads
|
|
260
331
|
land: s.land ? { state: s.land.state, branch: s.land.branch ?? null, base: s.land.base ?? null, sha: s.land.sha ?? null, reason: s.land.reason ?? null } : null,
|
|
332
|
+
// the chip's word only: paths, dropped items and attention text stay on this machine
|
|
333
|
+
harness: s.harness ? { state: s.harness.state, target: s.harness.target ?? null } : null,
|
|
261
334
|
task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
|
|
262
335
|
land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
|
|
263
336
|
}
|
|
@@ -280,7 +353,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
280
353
|
const preferredNext = chain[0] ?? null
|
|
281
354
|
const availabilityKnown = Boolean(s.installed)
|
|
282
355
|
const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
|
|
283
|
-
const can = s.worktree ?
|
|
356
|
+
const can = s.worktree ? canLandFor(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
|
|
284
357
|
return {
|
|
285
358
|
...s,
|
|
286
359
|
handoff_order: handoffOrder,
|
|
@@ -290,6 +363,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
290
363
|
handoff_availability_known: availabilityKnown,
|
|
291
364
|
can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
|
|
292
365
|
active: isActive(s),
|
|
366
|
+
has_synthesis: hasRecentSynthesis(s),
|
|
293
367
|
overlap: ov.get(s.session_id) ?? [],
|
|
294
368
|
elapsed_ms: Date.now() - Date.parse(s.started_at),
|
|
295
369
|
// Older attached Codex processes can retain one parser mistake where an
|
|
@@ -352,16 +426,17 @@ function readBody(req) {
|
|
|
352
426
|
}
|
|
353
427
|
|
|
354
428
|
function serveStatic(res, urlPath) {
|
|
355
|
-
const map = { '/': 'index.html', '/floor': 'floor.html' }
|
|
429
|
+
const map = { '/': 'index.html', '/board': 'index.html', '/board/': 'index.html', '/floor': 'floor.html', '/floor/': 'floor.html' }
|
|
356
430
|
const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
|
|
357
|
-
const
|
|
358
|
-
|
|
431
|
+
const boardDir = resolveBoardDir()
|
|
432
|
+
const file = resolve(boardDir, rel)
|
|
433
|
+
if (!file.startsWith(boardDir + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
|
|
359
434
|
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
|
|
360
435
|
res.end(readFileSync(file))
|
|
361
436
|
}
|
|
362
437
|
|
|
363
438
|
// ---- SSE: watch $BATON_HOME/cards for fs events and push only what changed ----
|
|
364
|
-
function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
|
|
439
|
+
function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounceMs = 300, sessionsMinIntervalMs = 2000, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
|
|
365
440
|
const clients = new Set() // { res, viewer, token, loopback, sig }
|
|
366
441
|
let watcher = null
|
|
367
442
|
let healthTimer = null
|
|
@@ -411,13 +486,70 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
|
|
|
411
486
|
}
|
|
412
487
|
let sessionsWatcher = null
|
|
413
488
|
let sessionsTimer = null
|
|
414
|
-
|
|
489
|
+
let lastSessionsPush = 0
|
|
490
|
+
const pushSessions = () => {
|
|
491
|
+
sessionsTimer = null
|
|
492
|
+
try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) }
|
|
493
|
+
lastSessionsPush = Date.now()
|
|
494
|
+
// the health tick pushes on its own schedule: record what it sent, so the
|
|
495
|
+
// next watcher hint is measured against the page's real contents
|
|
496
|
+
lastFingerprint = sessionsFingerprint()
|
|
497
|
+
}
|
|
498
|
+
// One live agent rewrites its record about every six seconds and takes a
|
|
499
|
+
// control lock about once a second, and every one of those touches the
|
|
500
|
+
// sessions tree. Rebuilding the whole view costs a second or more of `git`,
|
|
501
|
+
// so a watcher that answers every touch turns a single running terminal into
|
|
502
|
+
// a permanent busy loop on the one event loop this board serves every
|
|
503
|
+
// request from: the board then takes seconds to hand over a stylesheet and
|
|
504
|
+
// `leg` itself times out probing /api/health.
|
|
505
|
+
//
|
|
506
|
+
// A watcher event is only a hint. Locks and the temp files an atomic write
|
|
507
|
+
// leaves behind are dropped by name, but taking a lock inside a session
|
|
508
|
+
// directory also changes that directory's own mtime, and that event arrives
|
|
509
|
+
// carrying nothing but the directory name — no filter on the name can tell
|
|
510
|
+
// it from a real write. So the hint is checked against the data: a stat over
|
|
511
|
+
// the files the view is actually built from costs a fraction of a
|
|
512
|
+
// millisecond and answers the question the event cannot.
|
|
513
|
+
const NOISE = /(\.lock|\.tmp)$/i
|
|
514
|
+
const sessionsChangeMatters = (filename) => !filename || !NOISE.test(String(filename))
|
|
515
|
+
const sessionsFingerprint = () => {
|
|
516
|
+
const root = sessionsRoot()
|
|
517
|
+
let dirs
|
|
518
|
+
try { dirs = readdirSync(root) } catch { return '' }
|
|
519
|
+
let sig = ''
|
|
520
|
+
for (const name of dirs) {
|
|
521
|
+
if (!name.startsWith('s-')) continue
|
|
522
|
+
for (const file of ['session.json', 'land.json', 'requests.json']) {
|
|
523
|
+
try { const st = statSync(join(root, name, file)); sig += `${name}/${file}:${st.mtimeMs}:${st.size};` } catch { /* not written yet */ }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return sig
|
|
527
|
+
}
|
|
528
|
+
let lastFingerprint = null
|
|
529
|
+
const pushIfChanged = () => {
|
|
530
|
+
sessionsTimer = null
|
|
531
|
+
const sig = sessionsFingerprint()
|
|
532
|
+
// the hint was noise: the view would rebuild to exactly what the page
|
|
533
|
+
// already has, so nothing is rebuilt and nothing is sent
|
|
534
|
+
if (sig === lastFingerprint) return
|
|
535
|
+
lastFingerprint = sig
|
|
536
|
+
pushSessions()
|
|
537
|
+
}
|
|
538
|
+
const scheduleSessionsPush = () => {
|
|
539
|
+
if (sessionsTimer) return
|
|
540
|
+
const wait = Math.max(sessionsDebounceMs, sessionsMinIntervalMs - (Date.now() - lastSessionsPush))
|
|
541
|
+
sessionsTimer = setTimeout(pushIfChanged, wait)
|
|
542
|
+
}
|
|
415
543
|
const startWatch = () => {
|
|
416
544
|
if (watcher) return
|
|
417
545
|
try {
|
|
418
546
|
const sdir = sessionsRoot()
|
|
419
547
|
mkdirSync(sdir, { recursive: true })
|
|
420
|
-
|
|
548
|
+
// the client that opened this watch was handed the current view with its
|
|
549
|
+
// hello frame, so the fingerprint starts from what it already has: an
|
|
550
|
+
// unprimed one makes the first hint of any kind look like a change
|
|
551
|
+
lastFingerprint = sessionsFingerprint()
|
|
552
|
+
sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, (_event, filename) => { if (sessionsChangeMatters(filename)) scheduleSessionsPush() })
|
|
421
553
|
} catch (err) { log(`sessions watch: ${err.message}`); sessionsWatcher = null }
|
|
422
554
|
// watch the real long path: libuv's recursive watcher asserts when the
|
|
423
555
|
// watched dir is an 8.3 short path (fs-event.c, seen on a GitHub runner)
|
|
@@ -460,7 +592,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
|
|
|
460
592
|
// ---- the server ----
|
|
461
593
|
export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
|
|
462
594
|
// An explicit `share` (tests) is fixed; the real server passes none and reads
|
|
463
|
-
// share.json from disk, re-reading it per request (mtime-cached) so `
|
|
595
|
+
// share.json from disk, re-reading it per request (mtime-cached) so `leg
|
|
464
596
|
// share add|rotate|rm` takes effect on a live board — a new link works at
|
|
465
597
|
// once and a removed or rotated one stops at once — without a restart.
|
|
466
598
|
const explicitShare = share !== undefined
|
|
@@ -560,7 +692,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
560
692
|
const guest = shared && viewer.role !== 'owner'
|
|
561
693
|
const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
|
|
562
694
|
const parts = path.split('/').filter(Boolean) // ['api', ...]
|
|
563
|
-
|
|
695
|
+
// history and worktrees are the whole machine's project map (every path,
|
|
696
|
+
// every conversation on it): the owner's, never a guest's, as a group
|
|
697
|
+
if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk', 'history', 'worktrees'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
|
|
564
698
|
try {
|
|
565
699
|
if (req.method === 'GET' && path === '/api/health') {
|
|
566
700
|
const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
|
|
@@ -599,7 +733,20 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
599
733
|
if (req.method === 'POST' || req.method === 'PATCH') {
|
|
600
734
|
const body = await readBody(req)
|
|
601
735
|
try {
|
|
602
|
-
const
|
|
736
|
+
const patch = {}
|
|
737
|
+
if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
|
|
738
|
+
if (body.harness !== undefined) {
|
|
739
|
+
// the board may narrow the policy or turn the feature off; turning
|
|
740
|
+
// it on is the first-run consent flow, which shows what will be
|
|
741
|
+
// written before it writes (leg harness enable)
|
|
742
|
+
if (body.harness?.enabled === true) return send(res, 400, { error: 'turn the portable harness on from a terminal: leg harness enable shows what it will write before it writes it' })
|
|
743
|
+
const rank = ['warn', 'sync', 'strict']
|
|
744
|
+
const current = readPreferences().harness
|
|
745
|
+
if (body.harness?.policy !== undefined && rank.indexOf(body.harness.policy) > rank.indexOf(current.policy)) return send(res, 400, { error: `the board may only narrow the harness policy (now ${current.policy}); widen it from a terminal: leg harness policy ${body.harness.policy}` })
|
|
746
|
+
patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
|
|
747
|
+
}
|
|
748
|
+
if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
|
|
749
|
+
const preferences = writePreferences(patch)
|
|
603
750
|
sse.broadcast('sessions', (v) => viewFor(v))
|
|
604
751
|
return send(res, 200, { preferences })
|
|
605
752
|
} catch (err) {
|
|
@@ -710,7 +857,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
710
857
|
if (why) return send(res, 409, { error: why })
|
|
711
858
|
landSession(sess, { by: actor.id, autoCommit: true })
|
|
712
859
|
.catch((err) => log(`land ${id}: ${err.message}`))
|
|
713
|
-
|
|
860
|
+
// a landing moves the branch under every worktree cut from it, so
|
|
861
|
+
// the cached land-ability goes with the cached trunk
|
|
862
|
+
.finally(() => { trunkCache.clear(); canLandCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
|
|
714
863
|
log(`land requested for ${id} by ${actor.id}`)
|
|
715
864
|
return send(res, 202, { ok: true, requested: 'land' })
|
|
716
865
|
}
|
|
@@ -738,6 +887,63 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
738
887
|
return send(res, 200, { removed: id, worktree })
|
|
739
888
|
}
|
|
740
889
|
}
|
|
890
|
+
// ---- history: the read-only index over every agent's own store ----
|
|
891
|
+
if (parts[1] === 'history') {
|
|
892
|
+
const q = url.searchParams
|
|
893
|
+
const int = (v, def, max) => { const n = parseInt(v ?? '', 10); return Number.isFinite(n) && n >= 0 ? Math.min(n, max) : def }
|
|
894
|
+
if (req.method === 'GET' && parts.length === 2) {
|
|
895
|
+
const provider = q.get('provider') || null
|
|
896
|
+
if (provider && provider.split(',').some((p) => !PROVIDER_NAMES.includes(p.trim()))) return send(res, 400, { error: `unknown provider in "${provider}" (${PROVIDER_NAMES.join('|')})` })
|
|
897
|
+
const tri = (v) => (v === '1' || v === 'true' ? true : v === '0' || v === 'false' ? false : null)
|
|
898
|
+
const explicitRefresh = tri(q.get('refresh')) === true
|
|
899
|
+
let refreshArg = null
|
|
900
|
+
if (!explicitRefresh) {
|
|
901
|
+
const idx = readIndex()
|
|
902
|
+
if (idx) {
|
|
903
|
+
const age = idx.refreshed_at ? Date.now() - Date.parse(idx.refreshed_at) : Infinity
|
|
904
|
+
if (age > 60000) backgroundHistoryRefresh()
|
|
905
|
+
refreshArg = false
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
refreshArg = true
|
|
909
|
+
}
|
|
910
|
+
return send(res, 200, listHistory({
|
|
911
|
+
provider, repo: q.get('repo') || null, search: q.get('search') || null,
|
|
912
|
+
before: q.get('before') || null,
|
|
913
|
+
// never the whole index in one response: a page is 1 to 200 rows
|
|
914
|
+
limit: Math.max(1, int(q.get('limit'), 50, 200)), offset: int(q.get('offset'), 0, 1e6),
|
|
915
|
+
managed: tri(q.get('managed')), live: tri(q.get('live')), includeHidden: tri(q.get('hidden')) === true,
|
|
916
|
+
refresh: refreshArg,
|
|
917
|
+
}))
|
|
918
|
+
}
|
|
919
|
+
if (req.method === 'GET' && parts[2] === 'providers') return send(res, 200, { providers: providerSupport() })
|
|
920
|
+
if (req.method === 'POST' && parts[2] === 'refresh') {
|
|
921
|
+
const body = await readBody(req)
|
|
922
|
+
const t = Date.now()
|
|
923
|
+
try {
|
|
924
|
+
const r = refreshIndex({ force: body.full === true })
|
|
925
|
+
return send(res, 200, { ms: Date.now() - t, refreshed_at: r.index.refreshed_at, stats: r.stats })
|
|
926
|
+
} catch (err) { return send(res, 409, { error: `refresh did not run: ${err.message}` }) }
|
|
927
|
+
}
|
|
928
|
+
if (req.method === 'GET' && parts.length === 3) {
|
|
929
|
+
// the id is a lookup key, never a path: findRecord compares strings,
|
|
930
|
+
// and the transcript it names is read only from inside a known store
|
|
931
|
+
let rec
|
|
932
|
+
let wanted
|
|
933
|
+
try { wanted = decodeURIComponent(parts[2]) } catch { return send(res, 400, { error: 'malformed id' }) }
|
|
934
|
+
try { rec = findRecord(wanted, { refresh: false }) } catch (err) {
|
|
935
|
+
if (err instanceof HistoryInputError) return send(res, 400, { error: err.message })
|
|
936
|
+
throw err
|
|
937
|
+
}
|
|
938
|
+
if (!rec) return send(res, 404, { error: `no conversation matches ${parts[2]}` })
|
|
939
|
+
return send(res, 200, recordDetail(rec, { messages: int(q.get('messages'), 8, 50) }))
|
|
940
|
+
}
|
|
941
|
+
return send(res, 404, { error: 'not found' })
|
|
942
|
+
}
|
|
943
|
+
if (req.method === 'GET' && path === '/api/worktrees') {
|
|
944
|
+
const q = url.searchParams
|
|
945
|
+
return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
|
|
946
|
+
}
|
|
741
947
|
if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
|
|
742
948
|
if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
|
|
743
949
|
if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
|
|
@@ -814,12 +1020,12 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
814
1020
|
const addr = server.address()
|
|
815
1021
|
log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
|
|
816
1022
|
// A terminal that crashed instead of exiting left its hand-off in
|
|
817
|
-
// .
|
|
1023
|
+
// .leg/RESUME.md looking live. The board is the thing that starts
|
|
818
1024
|
// after a crash, so it is where that gets corrected.
|
|
819
1025
|
setImmediate(() => {
|
|
820
1026
|
try {
|
|
821
1027
|
const touched = refreshPointers()
|
|
822
|
-
if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no
|
|
1028
|
+
if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Leg stamped it`)
|
|
823
1029
|
} catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
|
|
824
1030
|
})
|
|
825
1031
|
if (scheduler) {
|