@ucsandman/legcli 0.9.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 +76 -0
- package/README.md +40 -2
- package/bin/leg.mjs +23 -1
- package/docs/ERRORS.md +134 -0
- package/docs/README.md +3 -1
- package/docs/VOCABULARY.md +1 -0
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +14 -0
- package/docs/configuration.md +1 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +7 -0
- package/src/accounts.mjs +5 -2
- package/src/attach.mjs +50 -4
- package/src/board/board.css +23 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +20 -6
- 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 +186 -8
- package/src/sessions.mjs +9 -0
- package/src/taps/claude.mjs +11 -4
- package/src/taps/mod.mjs +340 -0
- package/src/worktree.mjs +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Runtime tap: turn boundaries, usage and a safe place to hand off
|
|
2
|
+
|
|
3
|
+
`src/taps/mod.mjs` is Leg's seam for an agent runtime that publishes structured
|
|
4
|
+
events about itself while it runs. It is optional, it is off until one line
|
|
5
|
+
wires it in, and nothing in Leg depends on it existing.
|
|
6
|
+
|
|
7
|
+
## The gap it closes
|
|
8
|
+
|
|
9
|
+
Leg's per-agent taps read whatever each CLI leaves behind: an OAuth usage
|
|
10
|
+
endpoint every 60 s, a rollout file, a transcript tail. None of those says when
|
|
11
|
+
a turn ends. So when a limit lands, `killTree()` fires at an arbitrary instant:
|
|
12
|
+
mid-tool, mid-answer, with a subagent still running. The handoff bundle then
|
|
13
|
+
describes a moment nobody chose.
|
|
14
|
+
|
|
15
|
+
A runtime that publishes events closes that gap. The tap folds the stream into
|
|
16
|
+
one small record of signals, and the one Leg never had is `cleanBoundary`: the
|
|
17
|
+
turn is closed, no tool call is in flight, no subagent is still running.
|
|
18
|
+
|
|
19
|
+
| Field | What Leg gains |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `turnOpen`, `lastTurnCompletedAt` | where a turn starts and ends, to the millisecond |
|
|
22
|
+
| `inFlightTools` | tool calls requested with no completion yet |
|
|
23
|
+
| `subagentsLive` | subagents still running under this session |
|
|
24
|
+
| `cleanBoundary` | all three at rest: a handoff here loses no work and no answer |
|
|
25
|
+
| `usage.contextPercent`, `contextTokens`, `contextWindow` | how full the window is, which degrades an agent long before a rate limit stops it |
|
|
26
|
+
| `usage.fiveHourPercent`, `sevenDayPercent` (+ their reset times) | the same two windows Leg already stores, without the 60 s poll |
|
|
27
|
+
| `usage.costUsd` | what the session has spent |
|
|
28
|
+
| `model`, `lastError` | which model is answering, and the last tool failure or denial |
|
|
29
|
+
|
|
30
|
+
## Why the runtime side stays optional
|
|
31
|
+
|
|
32
|
+
The events come from a plugin installed in the agent's own harness, not from
|
|
33
|
+
anything Leg installs or launches. Leg's launcher needs no new flag, no
|
|
34
|
+
`--plugin-dir`, and no change to any adapter's argv: the plugin is already
|
|
35
|
+
installed, or it is not.
|
|
36
|
+
|
|
37
|
+
`findEventsFile()` returns `null` when the session has no events file, and that
|
|
38
|
+
is the ordinary case, never an error:
|
|
39
|
+
|
|
40
|
+
- the runtime has no such plugin installed
|
|
41
|
+
- the plugin is installed but this session has not flushed yet
|
|
42
|
+
- the agent is not the one that writes these events at all (codex, agy, grok)
|
|
43
|
+
|
|
44
|
+
In every one of those cases the tap does nothing, calls back never, and Leg
|
|
45
|
+
behaves exactly as it does today. That is the acceptance condition for this
|
|
46
|
+
seam: with the events file absent, no Leg behaviour changes.
|
|
47
|
+
|
|
48
|
+
## Where the events come from
|
|
49
|
+
|
|
50
|
+
One file per runtime session, JSON per line, appended and flushed on a timer
|
|
51
|
+
and at the end of every main-loop turn:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
<config dir>/mods/state/events/<sessionId>.jsonl
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`<sessionId>` is the runtime's own session id, which Leg stores as
|
|
58
|
+
`agent_session_id` once its transcript names it (not Leg's `sid`).
|
|
59
|
+
`<config dir>` is the config directory that session ran under, which for Leg is
|
|
60
|
+
`spec.env.CLAUDE_CONFIG_DIR` (each account gets its own). `eventsDirFor()`
|
|
61
|
+
builds that path; `LEG_RUNTIME_EVENTS_DIR` overrides it outright, which is what
|
|
62
|
+
the tests use.
|
|
63
|
+
|
|
64
|
+
The runtime's own event names live in exactly one place in Leg: the `KIND`
|
|
65
|
+
table at the top of `src/taps/mod.mjs`. `deriveSignals()` and `toLegEvents()`
|
|
66
|
+
read that table, and every shape the tap exports is Leg's own, so a second
|
|
67
|
+
runtime with a different vocabulary is a second table and nothing else.
|
|
68
|
+
|
|
69
|
+
## Wiring it in (one line)
|
|
70
|
+
|
|
71
|
+
Two lines total, both in `src/attach.mjs`, neither of which changes any
|
|
72
|
+
existing behaviour.
|
|
73
|
+
|
|
74
|
+
The import, with the other tap imports (after the `./taps/claude-usage.mjs`
|
|
75
|
+
line):
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import { pollRuntimeTap, eventsDirFor } from './taps/mod.mjs'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The wiring itself, inside `if (agent === 'claude') {`, on the line straight
|
|
82
|
+
after `usageTimer.unref?.()`:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const runtimeStop = pollRuntimeTap({ sessionId: () => readSession(sid)?.agent_session_id, dir: eventsDirFor(spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home()), onSignals: (signals, { legEvents, advice }) => { for (const ev of legEvents) appendEvent(sid, ev); updateSession(sid, { runtime: { ...signals, advice } }) } })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`readSession`, `updateSession`, `appendEvent` and `LAYOUT` are already imported
|
|
89
|
+
there. `sessionId` is a getter on purpose: neither the id nor the file exists
|
|
90
|
+
when a leg starts, so the tap keeps looking until both do and the caller needs
|
|
91
|
+
no lazy bookkeeping in the poll loop.
|
|
92
|
+
|
|
93
|
+
Teardown is optional. The interval is `unref`'d, so it never holds the process
|
|
94
|
+
open, and a finished session's file simply stops growing (one `stat` every
|
|
95
|
+
2 s until the leg exits). To stop it exactly, declare `let runtimeStop = null`
|
|
96
|
+
beside `let usageTimer = null`, drop the `const` above, and add
|
|
97
|
+
`runtimeStop?.()` next to `if (usageTimer) clearInterval(usageTimer)`.
|
|
98
|
+
|
|
99
|
+
What the line buys, immediately: turn-level board events (`turn_done`, the
|
|
100
|
+
prompt, subagents, tool failures) instead of a poll-shaped guess, and a
|
|
101
|
+
`session.runtime` record the board can render.
|
|
102
|
+
|
|
103
|
+
Two follow-ups this seam makes possible, both deliberately not wired here:
|
|
104
|
+
|
|
105
|
+
- feed the percentages to the chooser through the door every other tap uses:
|
|
106
|
+
`recordUsage('claude', account, toLegUsage(signals), 'runtime events')`
|
|
107
|
+
(`recordUsage` is already imported in `attach.mjs`)
|
|
108
|
+
- hand off at a boundary Leg chose, by acting on `advice.shouldHandoff` in the
|
|
109
|
+
same place the limit handoff already fires
|
|
110
|
+
|
|
111
|
+
## Handing off on purpose
|
|
112
|
+
|
|
113
|
+
`handoffAdvice(signals, thresholds)` answers with `{ shouldHandoff, reason }`.
|
|
114
|
+
Defaults:
|
|
115
|
+
|
|
116
|
+
| Threshold | Default | Why |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `contextPercent` | 80 | a full window degrades an agent long before a limit stops it |
|
|
119
|
+
| `fiveHourPercent` | 90 | just under the wall Leg already hands off at |
|
|
120
|
+
| `sevenDayPercent` | 95 | a last resort; the 7-day window rarely moves first |
|
|
121
|
+
|
|
122
|
+
Being over a threshold is not enough. Over a threshold but mid-turn returns
|
|
123
|
+
`shouldHandoff: false` with a reason that says what it is waiting for
|
|
124
|
+
("context at 84% of the window, waiting for a clean boundary (a turn is
|
|
125
|
+
open)"), so the board can show the wait rather than a silent stall. A
|
|
126
|
+
percentage the runtime has not published yet never triggers a handoff.
|
|
127
|
+
|
|
128
|
+
## The API
|
|
129
|
+
|
|
130
|
+
| Function | Answers |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `findEventsFile(sessionId, { dir })` | the path, or `null` when this session publishes nothing |
|
|
133
|
+
| `readRuntimeEvents(path, cursor)` | `{ events, cursor }` from a byte offset; a half-written last line is left for the next read, a truncated file restarts at 0, a corrupt line is skipped |
|
|
134
|
+
| `deriveSignals(events, prev)` | the signals record above; pure, and folding in batches equals folding at once |
|
|
135
|
+
| `toLegEvents(events)` | `{ type, summary }` board events, as `appendEvent(sid, ev)` takes them |
|
|
136
|
+
| `toLegUsage(signals)` | `{ five_hour: { pct, resets_at }, seven_day: ... }`, the window shape `recordUsage()` already stores |
|
|
137
|
+
| `handoffAdvice(signals, thresholds)` | `{ shouldHandoff, reason }` |
|
|
138
|
+
| `pollRuntimeTap({ sessionId, dir, intervalMs, thresholds, onSignals })` | the whole tap on a timer; returns `stop()` |
|
|
139
|
+
|
|
140
|
+
`emptySignals()` is the zero state, and it is what `deriveSignals([])` returns:
|
|
141
|
+
nothing seen, nothing in flight, every percentage `null`.
|
|
142
|
+
|
|
143
|
+
## Tests
|
|
144
|
+
|
|
145
|
+
`test/taps-mod.test.mjs`, 11 tests, run by `node --test`. The fixture
|
|
146
|
+
`fixtures/runtime-events.jsonl` is one real captured session (29 events, one
|
|
147
|
+
turn, four tool calls, the usage frame last) with the local user name scrubbed
|
|
148
|
+
out of the paths and nothing else changed. Subagent, denial and error cases are
|
|
149
|
+
built in the test, because that capture has none.
|
|
150
|
+
|
|
151
|
+
Covered: incremental reads across a torn line, truncation and a corrupt line;
|
|
152
|
+
`cleanBoundary` shut by an open turn and by a tool with no completion, and
|
|
153
|
+
released by the turn that closes; a live subagent, a denied one, and a tool
|
|
154
|
+
call inside a subagent's own loop; usage extraction and its mapping to Leg's
|
|
155
|
+
window shape; advice at 80% context only at a clean boundary; and the no-file
|
|
156
|
+
fallback, where every entry point answers and nothing throws.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ucsandman/legcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. $79 once, 30-day money-back guarantee.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -65,6 +65,13 @@ const PAGES = [
|
|
|
65
65
|
title: 'The portable harness: carry rules, hooks, skills and MCP servers between agents',
|
|
66
66
|
description: 'How Leg carries the source agent\'s working environment to the agent a handoff lands on, what moves and what does not, the policies, ownership and backups, and how secrets are handled.',
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
slug: 'history',
|
|
70
|
+
nav: 'History',
|
|
71
|
+
source: 'docs/history.md',
|
|
72
|
+
title: 'Every conversation on this machine: leg history and leg worktrees',
|
|
73
|
+
description: 'One read-only index over the conversations Claude Code, Codex, Grok, Antigravity and Copilot CLI keep in their own stores, plus the sessions Leg started itself, and every checkout: the support matrix, what is read, what is written, and how to continue one.',
|
|
74
|
+
},
|
|
68
75
|
{
|
|
69
76
|
slug: 'cli-contracts',
|
|
70
77
|
nav: 'What it reads',
|
package/src/accounts.mjs
CHANGED
|
@@ -43,12 +43,15 @@ export const LAYOUT = {
|
|
|
43
43
|
export function accountsFile() { return join(home(), 'accounts.json') }
|
|
44
44
|
export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
|
|
45
45
|
|
|
46
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/i
|
|
47
|
+
|
|
46
48
|
export function readAccounts() {
|
|
47
49
|
const base = { claude: ['default'], codex: ['default'], agy: ['default'], grok: ['default'] }
|
|
48
50
|
if (!existsSync(accountsFile())) return base
|
|
49
51
|
try {
|
|
50
52
|
const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
|
|
51
|
-
|
|
53
|
+
// the same shape addAccount accepts: a name is a directory segment, never a path
|
|
54
|
+
for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => typeof n === 'string' && n !== 'default' && NAME_RE.test(n))]
|
|
52
55
|
} catch {}
|
|
53
56
|
return base
|
|
54
57
|
}
|
|
@@ -74,7 +77,7 @@ function junction(target, link) {
|
|
|
74
77
|
|
|
75
78
|
// Create the account dir, junction the shared harness in, copy the settings.
|
|
76
79
|
export function addAccount(agent, name) {
|
|
77
|
-
if (
|
|
80
|
+
if (!NAME_RE.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
|
|
78
81
|
const l = LAYOUT[agent]
|
|
79
82
|
if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
80
83
|
if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
|
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' } })
|
|
@@ -269,6 +297,13 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
269
297
|
|
|
270
298
|
// taps
|
|
271
299
|
let rollout = null; let tail = null
|
|
300
|
+
// a continued codex thread appends to its old rollout, which findRollout
|
|
301
|
+
// (newest file since this leg started) would never pick: bind it up front
|
|
302
|
+
// and read only what the thread writes from here on
|
|
303
|
+
if (agent === 'codex' && !prompt && session.transcript_path && session.agent_session_id && existsSync(session.transcript_path)) {
|
|
304
|
+
rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
|
|
305
|
+
tail = createTail(rollout.path, { from: logSize(rollout.path) })
|
|
306
|
+
}
|
|
272
307
|
let polls = 0; let warned = false
|
|
273
308
|
let stop = null
|
|
274
309
|
const done = new Promise((res) => { stop = res })
|
|
@@ -536,14 +571,18 @@ function prepareLegHarness({ sid, from, to }) {
|
|
|
536
571
|
}
|
|
537
572
|
|
|
538
573
|
// ---- the command ----
|
|
539
|
-
|
|
574
|
+
// `cwd` and `continued` are how `leg history continue` starts a leg on a
|
|
575
|
+
// conversation the agent's own store holds (src/history/cli.mjs): the leg runs
|
|
576
|
+
// in that conversation's folder, shares the checkout (its files are already
|
|
577
|
+
// there), and the session record carries the agent's id from the start.
|
|
578
|
+
export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null, continued = null } = {}) {
|
|
540
579
|
if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
541
580
|
// the paid gate: a valid key, or no session (exit 4). The bare agent is never
|
|
542
581
|
// affected; only what Leg adds is licensed.
|
|
543
582
|
const ent = entitlement()
|
|
544
583
|
if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
|
|
545
584
|
// --no-worktree is Leg's flag, not the agent's: it never passes through
|
|
546
|
-
const shareCheckout = args.includes('--no-worktree')
|
|
585
|
+
const shareCheckout = args.includes('--no-worktree') || Boolean(continued)
|
|
547
586
|
args = args.filter((a) => a !== '--no-worktree')
|
|
548
587
|
let autoApproveCli = null
|
|
549
588
|
if (args.includes('--no-auto-approve')) {
|
|
@@ -554,7 +593,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
554
593
|
args = args.filter((a) => a !== '--auto-approve')
|
|
555
594
|
}
|
|
556
595
|
const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
|
|
557
|
-
const cwd = process.cwd()
|
|
596
|
+
const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
|
|
558
597
|
const board = await ensureBoard({ open })
|
|
559
598
|
let accounts = readAccounts()
|
|
560
599
|
const installed = await installedAgents()
|
|
@@ -587,6 +626,13 @@ export async function attach(agent, args = [], { open = true } = {}) {
|
|
|
587
626
|
// `git worktree add` still leaves a card (with a Remove button), never a
|
|
588
627
|
// silent orphan under .baton-worktrees with no record and no button
|
|
589
628
|
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] })
|
|
629
|
+
if (continued) {
|
|
630
|
+
// the agent's own id and transcript are known before the first turn, so
|
|
631
|
+
// history dedups this leg against the conversation it continues at once
|
|
632
|
+
const safeTranscript = (continued.transcript_path && insideKnownStore(continued.transcript_path)) ? continued.transcript_path : null
|
|
633
|
+
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 } },
|
|
634
|
+
{ event: { type: 'continued', summary: `continuing ${continued.id}${continued.title ? `: ${String(continued.title).slice(0, 120)}` : ''}` } })
|
|
635
|
+
}
|
|
590
636
|
let iso = null
|
|
591
637
|
if (g.repo && !shareCheckout) {
|
|
592
638
|
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 {} }
|
package/src/board/board.css
CHANGED
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
--id-codex: #69DBBA; /* oklch(0.815 0.115 172) cool green */
|
|
131
131
|
--id-agy: #CC97F3; /* oklch(0.760 0.140 310) violet, 46 degrees clear of the accent */
|
|
132
132
|
--id-grok: #70B8FF; /* oklch(0.750 0.130 240) azure blue */
|
|
133
|
+
--id-copilot: #F0A3B8; /* oklch(0.790 0.110 5) rose; lists and reads, never runs */
|
|
133
134
|
--id-fake: #A0A6AE; /* oklch(0.720 0.010 258) the scripted adapter, neutral in hue */
|
|
134
135
|
|
|
135
136
|
/* type scale. Fixed rem, range 13 to 52. The old board ran 13 to 21, which
|
|
@@ -222,6 +223,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
222
223
|
.dot.id-codex { background: var(--id-codex); }
|
|
223
224
|
.dot.id-agy { background: var(--id-agy); }
|
|
224
225
|
.dot.id-grok { background: var(--id-grok); }
|
|
226
|
+
.dot.id-copilot { background: var(--id-copilot); }
|
|
225
227
|
|
|
226
228
|
/* ---- logins ------------------------------------------------------------- */
|
|
227
229
|
.logins { display: grid; gap: 20px; }
|
|
@@ -323,6 +325,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
323
325
|
.chip-id-codex { color: var(--id-codex); }
|
|
324
326
|
.chip-id-agy { color: var(--id-agy); }
|
|
325
327
|
.chip-id-grok { color: var(--id-grok); }
|
|
328
|
+
.chip-id-copilot { color: var(--id-copilot); }
|
|
326
329
|
.chip-id-fake { color: var(--id-fake); }
|
|
327
330
|
.chip-state-ok { color: var(--ok-text); }
|
|
328
331
|
.chip-state-warn { color: var(--warn-text); }
|
|
@@ -370,7 +373,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
370
373
|
.drawer-msg-when { font-size: var(--t--1); color: var(--text-3); font-family: var(--mono); }
|
|
371
374
|
|
|
372
375
|
/* ---- ledger: on the ground, unpanelled --------------------------------- */
|
|
373
|
-
.ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(
|
|
376
|
+
.ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
|
|
374
377
|
.ledger-cell h3 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
|
|
375
378
|
.ledger-cell .region-meta { margin-top: 6px; font-size: var(--t-0); color: var(--text-3); }
|
|
376
379
|
.ledger-actions { margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }
|
|
@@ -384,6 +387,23 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
384
387
|
.line-main { color: var(--text); }
|
|
385
388
|
.line-meta { color: var(--text-2); }
|
|
386
389
|
.line-when { color: var(--text-3); text-align: right; }
|
|
390
|
+
/* history: the conversations every agent keeps, one row each, opening in place */
|
|
391
|
+
.history-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; margin: 4px 0 18px; max-width: 64ch; }
|
|
392
|
+
.history-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
393
|
+
.history-filters label:has(input[type="checkbox"]) { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font-weight: var(--w-text); }
|
|
394
|
+
.history-list { display: block; }
|
|
395
|
+
.history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 12px 0; font-size: var(--t-0); }
|
|
396
|
+
.history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
|
|
397
|
+
.history-title { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; font: inherit; color: var(--text); cursor: pointer; line-height: 1.4; }
|
|
398
|
+
.history-title:hover { color: var(--accent-hi); }
|
|
399
|
+
.history-title:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--r-control); }
|
|
400
|
+
.history-register { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
|
|
401
|
+
.history-when { color: var(--text-3); white-space: nowrap; font-size: var(--t--1); }
|
|
402
|
+
.history-detail { padding: 14px 0 20px; }
|
|
403
|
+
.history-detail .kv { margin-bottom: 14px; }
|
|
404
|
+
.history-command { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; font-size: var(--t-0); }
|
|
405
|
+
.history-empty { font-size: var(--t-0); color: var(--text-3); padding: 12px 0; }
|
|
406
|
+
|
|
387
407
|
.group-head { display: flex; align-items: baseline; gap: 10px; margin-top: 24px; margin-bottom: 4px; font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
388
408
|
.group-head:first-child { margin-top: 0; }
|
|
389
409
|
.group-head span { font-weight: var(--w-text); color: var(--text-3); }
|
|
@@ -556,6 +576,8 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
|
|
|
556
576
|
.kv { grid-template-columns: 1fr; gap: 4px 0; }
|
|
557
577
|
.kv-val + .kv-key { margin-top: 12px; }
|
|
558
578
|
.ledger { grid-template-columns: 1fr; gap: 32px; margin-top: 40px; }
|
|
579
|
+
.history-row { grid-template-columns: 1fr; }
|
|
580
|
+
.history-filters { grid-template-columns: 1fr; }
|
|
559
581
|
.line { grid-template-columns: 1fr auto; gap: 6px 14px; }
|
|
560
582
|
.line-main { grid-column: 1 / -1; }
|
|
561
583
|
.line-meta { grid-column: 1; }
|
package/src/board/board.js
CHANGED
|
@@ -292,6 +292,15 @@
|
|
|
292
292
|
: `${s === 'reconnecting' ? 'Reconnecting' : 'Connecting'} to Leg on ${state.bind}.`
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// sessions.js owns the terminals region and listens for `leg:sessions`.
|
|
296
|
+
// `baton:sessions` is the old name, still emitted for anything outside this
|
|
297
|
+
// page that listens for it; nothing in the board may listen for both, because
|
|
298
|
+
// each listener rebuilds the whole grid.
|
|
299
|
+
function publishSessions(detail) {
|
|
300
|
+
window.dispatchEvent(new CustomEvent('leg:sessions', { detail }))
|
|
301
|
+
window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
|
|
302
|
+
}
|
|
303
|
+
|
|
295
304
|
function connectSse() {
|
|
296
305
|
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
297
306
|
const request = ++state.sseRequest
|
|
@@ -312,9 +321,12 @@
|
|
|
312
321
|
// nothing between the drop and this hello was replayed: an open detail
|
|
313
322
|
// region is as old as the gap
|
|
314
323
|
scheduleDrawerRefresh()
|
|
315
|
-
if (data.sessions)
|
|
324
|
+
if (data.sessions) publishSessions(data.sessions)
|
|
316
325
|
})
|
|
317
|
-
|
|
326
|
+
// one parse, one publish, and both inside the staleness guard: the missing
|
|
327
|
+
// braces meant a superseded EventSource still drove a full rebuild, and the
|
|
328
|
+
// payload (a quarter of a megabyte) was parsed twice to do it
|
|
329
|
+
es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) publishSessions(JSON.parse(e.data)) })
|
|
318
330
|
es.addEventListener('card', (e) => { if (state.es === es && request === state.sseRequest) upsertCard(JSON.parse(e.data)) })
|
|
319
331
|
es.addEventListener('removed', (e) => { if (state.es === es && request === state.sseRequest) dropCard(JSON.parse(e.data).card_id) })
|
|
320
332
|
es.addEventListener('event', (e) => { if (state.es === es && request === state.sseRequest) onLedgerEvent(JSON.parse(e.data)) })
|