feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent

- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
2026-09-25 17:30:28 +07:00
parent 3f6e903c22
commit 952e8584f0
48 changed files with 3336 additions and 1579 deletions
+2 -2
View File
@@ -586,8 +586,8 @@ cannot collide on `index.lock`.
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk, off the working tree | `{"path": "...", "hunk": "@@ ... @@", "context": 3, "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
+1 -1
View File
@@ -253,7 +253,7 @@ DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
# Ingestion polls (0 disables either one)
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane stage detection
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane running/idle
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
# Logging
+47 -116
View File
@@ -96,7 +96,7 @@ Both modes' path field has a **Browse** button next to it, opening a small folde
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane's tool determines which CLI is spawned when a run starts.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude lanes render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
### How Codex arguments map
@@ -116,39 +116,17 @@ Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tr
- **Sessions** and **Analytics** tabs — no session history or aggregated statistics
- **Cost** surface — no per-run or per-model pricing data
**These are not bugs; they reflect Codex's architecture.** The stage you declare via `agenthub stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
**These are not bugs; they reflect Codex's architecture.** Liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
**Stage detection is not in that list.** It does work for a codex lane, from
Codex's own on-disk rollout journals rather than from hooks, and unlike a claude
lane a written detection there also **advances the lane's declared `stage`** —
see "Stage detection" below. What a codex lane cannot get for free is
*evidence*: an auto-advanced node renders `passed-no-evidence` (amber), never
`done`, because only the session itself can say what it actually finished. The
run console never pre-fills a Claude slash command into a codex pane (Codex
would read `/agenthub:ship-feature-lane` as prose), so a codex session declares
evidence only when something tells it to. Three things do:
- **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's
first prompt with a plain-text list of `agenthub stage <stage> --tool codex`
calls to make, one per node of the lane's own pipeline template (built from
`Lane.pipeline_nodes`, so it always matches whatever template is in use) —
the same up-front priming a Claude lane gets from its pre-filled
`/agenthub:ship-feature-lane` skill command, just as plain text since Codex has
no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`.
- **An `AGENTS.md` block.** `agenthub lanes agents install` on a codex lane writes
a marker-delimited `<!-- agenthub:stage:start -->` section into
`<lane cwd>/AGENTS.md` — the one instruction channel Codex actually reads,
since it never loads `.claude/agents/`. Appended, never overwriting your own
prose, and a reinstall replaces exactly that block. (On a `claude` lane the
same command still copies the two agent templates instead.)
- **A missing-evidence nudge.** `server/lib/codex-nudge.js` sweeps every 60s
(`DASHBOARD_CODEX_NUDGE_MS` to change, `0` to disable) for a codex lane whose
current stage has been held for at least two minutes with no evidence
recorded, and types a one-line `[agenthub] ... run: agenthub stage ... --evidence
"..."` reminder straight into that lane's live tmux pane (`tmux.sendText`,
skipped if the pane is at a bare shell prompt). It never writes `stage`,
`stages` or `detected_stage` itself — the evidence that results is a real
declaration the session made.
**Codex lanes carry no pipeline.** Nothing infers their stage, nothing types
a stage reminder into their pane, and `agenthub lanes agents install` installs
nothing on them (`installed: []`). The one thing AgentHub reads from Codex is
its running/idle status — see "Stage detection" for the journal poll that
mirrors it. Every server start also wipes what older builds left behind on a
codex lane — its stage columns, stage log (`lane_stage_events`) and feature
records (`lane_features`) — and takes the old
`<!-- agenthub:stage:start -->` block back out of its `AGENTS.md` (deleting
the file if nothing else is in it).
## Destructive lane actions
@@ -652,13 +630,18 @@ only one that answers "show me the code" rather than "show me the change"):
one green with `U` (`A` added, `D` deleted and struck through, `R`
renamed), and a folder holding any of them gets an amber dot. The colours
come with the tree read, so **Refresh** re-reads them.
Opening a file entry shows the whole file (`/git/file`). Its **Edit** button
(any range but a commit) turns that view into a text editor: **Save** (or
Opening a file entry shows the whole file (`/git/file`) for reading, at every
panel width. **Clicking its text** (or Enter on it; any range but a commit)
turns that view into a text editor with the caret on the clicked line and
the scroll position kept — a click that only ends a drag-selection does not,
so copying never drops you into the editor. **Cancel** returns to reading.
From a diff, which has no whole text to click, an **Edit** button does the
same once the file has loaded. **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
line endings and final newline. A file the editor cannot take (binary, or
over the 1 MB view cap) says so where the Edit button would be.
over the 1 MB view cap) says so in the file's header.
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons.
@@ -670,14 +653,18 @@ only one that answers "show me the code" rather than "show me the change"):
a file that appears later is open by default; a lane switch clears it.
Each file's header stays **pinned** at the top while its diff scrolls past,
and carries the same stage / unstage / discard buttons as its overview row.
On a file with unstaged changes every hunk gets an editor-style **review
bar**, hung just under its last changed line and pinned to the visible
right edge: `^`/`v` to walk the hunks (each brings the next/previous `@@`
line to the top of the ONE scroller, below the sticky file header — a
On a file with unstaged changes every **change block** — each run of
consecutive `+`/`-` lines, not each `@@` hunk: git merges edits up to six
lines apart into one hunk, and one bar for all of them acted on every edit
at once (issue #50) — gets an editor-style **review bar**, hung just under
its last changed line and pinned to the visible right edge: `^`/`v` to walk
the blocks (each brings the next/previous block's first line to the top of
the ONE scroller, below the sticky file header — a
review diff has no scroll box of its own unless it is large enough to
window, above 500 rows), `n of N`, **Undo**
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`).
The single-file view offers the same bar.
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`),
both sent with the hunk's `@@` line plus the block's index, so they touch
that block alone. The single-file view offers the same bar.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
@@ -857,7 +844,7 @@ one feature's work reads `implement → tests → implement → tests` as the se
edits, runs the suite, and edits again — and when any backwards move opened a
run, a single feature was recorded as five or six of them. A real pass ends
where the session says it ends: a backwards move only starts a new run when it
is declared (`agenthub stage`, or a codex lane's ingest-driven declaration). A
is declared (`agenthub stage`). A
detection that moves backwards continues the run in flight. Reaching the last
node still closes the run for both — work after `done` is new work by anyone's
reading. On this repo's own lane the rule took a 26-run history down to 5, which
@@ -1164,72 +1151,23 @@ also **infers** a stage from the tool events every hook already delivers
un-instrumented sessions that never call `agenthub stage` — it fills in a lane's
pipeline map without requiring every skill to be rewritten.
Two event sources feed the identical matcher, one per tool:
| Lane `tool` | Source | Cadence |
| --- | --- | --- |
| `claude` | hooks — `touchLaneFromHook` in `server/routes/hooks.js` | every hook, live |
| `codex` | Codex's own rollout journals on disk — `server/lib/codex-ingest.js` | poll, `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) |
The matcher is fed by hooks — `touchLaneFromHook` in `server/routes/hooks.js`,
on every hook, live — so it only ever sees `claude` lanes.
Codex has no hook mechanism at all, so `touchLaneFromHook` is pinned to
`"claude"` — a hook must never touch a `codex` lane sharing the same directory.
`codex-ingest.js` is the codex-side equivalent: it tails
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`$CODEX_HOME` defaults
to `~/.codex`), reads the thread's `cwd` from the `session_meta` on each file's
first line, and maps Codex's tool calls onto the same `{tool_name, tool_input}`
shape a Claude hook delivers:
| Codex tool call | Mapped to |
| --- | --- |
| `exec` / `exec_command` / `shell` / `local_shell` | `Bash` with the extracted `cmd` |
| any of the above carrying an `apply_patch` heredoc | one `Write` (for `*** Add File:`) or `Edit` (`Update`/`Delete`) per touched file |
| `update_plan` | `TaskCreate` |
| `spawn_agent` | `Agent` with the task name as `subagent_type` |
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
Everything past that point is shared: the same templates, the same
forward-only/declared-wins rules, the same amber-never-green boundary. Three
codex-only details:
- **A written detection also advances the lane's declared `stage`.** This is
the one place in AgentHub where an inference moves the blue `current` ring, and
it exists because nothing else can: a codex session has no hook and rarely
calls `agenthub stage` on its own, so without it the card sits at `idle` forever
while the amber badge is the only thing that ever moves. It is gated on
`recordDetection` having *written*, so it inherits that function's
forward-only, declared-wins and TTL guards unchanged — a detection behind the
declared stage still advances nothing. The `setStage` call passes **no
evidence**, so every node it moves through renders `passed-no-evidence`
(amber), never `done`: a detection sees a tool call, never its outcome. And
it passes `keepDetection: true`, the sole exception to `setStage`'s clearing
of the detection columns — here the detection is the *reason* for the
declaration, so the card keeps showing which command caused it rather than
looking like a claim the agent made. The last node of every template carries
no `detect` rules, so this can never auto-declare a lane `done`.
- Only bytes appended since the last pass are read. On the first sight of a
journal only its recent tail is read, and nothing at all when that file is
already idle — replaying a thread that finished days ago would paint stages
for work nobody is doing. "Idle" means the journal's mtime is more than two
minutes old **or in the future**: a file that claims to be from the future
(clock stepped back, NTP correction, a restore that preserved timestamps)
makes a raw `now - mtime` negative, which would otherwise read as "written
milliseconds ago" and replay a finished thread as live work.
- The same pass mirrors the lane's `status` between `idle` and `running` from
the journal's mtime, under the identical guard hook ingestion uses (only a
lane with no `run_id`, and only from `idle`/`running` — never from
`provisioning` or `failed`). It writes nothing else: not `stage`, not
`notes`, not `slot`/`ports`.
**A codex lane's *evidence* is a separate matter.** Auto-advance moves the ring
but can never supply evidence, so every node a codex lane passes through stays
amber until the session itself runs `agenthub stage <node> --evidence "..."`. AgentHub
cannot inject an instruction into a CLI it merely spawns, so an `AGENTS.md` in
the working copy is the only lever — `agenthub lanes agents install` writes the
marker-delimited block for you (see "Installing the QC/gate agents"). Note that
from inside a codex session `agenthub stage` needs to know which lane it is: a pane
the dashboard started carries `AGENTHUB_LANE_ID`, and anywhere else `--tool codex`
disambiguates (see "Which lane a CLI command addresses" below).
Codex lanes carry no pipeline, so nothing detects or advances their stage.
`server/lib/codex-ingest.js` only mirrors a codex lane's `status` between
`idle` and `running`: every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0`
disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
(`$CODEX_HOME` defaults to `~/.codex`), reads each thread's `cwd` from the
`session_meta` on the file's first line, and calls the owning lane `running`
while that journal's mtime is under two minutes old — and `idle` once it is
older **or in the future** (a clock stepped back makes a raw `now - mtime`
negative, which would otherwise read as live). It uses the identical guard hook
ingestion uses (only a lane with no `run_id`, and only from `idle`/`running` —
never from `provisioning` or `failed`) and writes nothing else: not `stage`,
not `notes`, not `slot`/`ports`.
### The evidence boundary
@@ -1796,16 +1734,9 @@ agenthub lanes agents install
Writes both agent definitions (from the repo's own `agents/`, the same files the plugin ships) into `<lane>/.claude/agents/` and adds that directory to the lane's local `.git/info/exclude` (never the tracked `.gitignore` — this is a per-clone runtime concern, not an app-repo change). Idempotent and never automatic, the same shape as `agenthub lanes proof-link` — a session installs the agents explicitly, once, before a lane's first run through Stage 6/7 (or after a AgentHub upgrade ships updated templates — reinstalling overwrites, it doesn't merge).
**On a `codex` lane the same command installs something else.** Codex never
reads `.claude/agents/`, so copying agent definitions there would be dead
files. Instead it writes the stage-declaration block into
`<lane>/AGENTS.md` — the project doc Codex *does* load from the working
directory — delimited by `<!-- agenthub:stage:start -->` / `<!-- agenthub:stage:end -->`
so a reinstall replaces exactly that block and nothing else. The file is
appended to when it already exists (your own prose is preserved) and created
when it does not; `installed` comes back as `["AGENTS.md"]`. This is the lever
that gets a codex session to supply the *evidence* auto-advance cannot — see
"Stage detection".
**On a `codex` lane the same command installs nothing** (`installed: []`, and
the Tools menu hides it): Codex never reads `.claude/agents/`, and codex lanes
carry no pipeline to drive.
**Credentials are not embedded.** Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. `qc-local` degrades gracefully: if it hits a login page with no credentials block present, it reports `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile` rather than guessing.
+20 -9
View File
@@ -106,9 +106,8 @@ root holds all four (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`,
`.claude/agents/` — the highest-priority agent source — which is what makes the
bare `subagent_type: qc-local` in the pipeline skill resolve there. One source
file, two ways to reach it; the skill names the bare form first and falls back
to the `agenthub:` form. On a `codex` lane the same command installs the
`<!-- agenthub:stage:start -->` block in the lane's `AGENTS.md` instead — Codex
never reads `.claude/agents/`.
to the `agenthub:` form. On a `codex` lane the same command installs nothing —
Codex never reads `.claude/agents/`, and codex lanes carry no pipeline.
Because the plugin ships the hooks itself, `npm run install-hooks` is not needed
for plugin users — and must not be run alongside it. Events carry no id, so two
@@ -137,10 +136,11 @@ waits on an install:
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — on Linux with `systemd --user` available, install/refresh a
`agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead
`agenthub.service` unit — on macOS with a GUI login, the
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
of a bare spawn, so the server is supervised and comes back after a reboot
with no Claude Code session involved. Everywhere else (macOS, Windows, or
no systemd), spawn `server/index.js` detached, with `NODE_PATH` at the
with no Claude Code session involved. Everywhere else (Windows, no
systemd, a Mac reached only over SSH), spawn `server/index.js` detached, with `NODE_PATH` at the
runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime
`client-dist` — scoped to this session, same as before this fell back to
systemd.
@@ -233,15 +233,26 @@ anyone logs in; when that needs elevated rights, it's skipped silently (check
`agenthub.service` unit not written by this bootstrap is left untouched, and the
server falls back to the old per-session detached spawn instead.
On macOS, Windows, or a Linux box without systemd, nothing changes: the
On macOS the same steps write a LaunchAgent instead
(`~/Library/LaunchAgents/io.smartgift.agenthub.plist`, `scripts/launchd-service.js`;
`scripts/service-manager.js` picks the one for the platform, and both export the
same API). `KeepAlive` plays `Restart=always`, `AbandonProcessGroup` keeps the
lane panes' tmux server alive across a restart, output goes to
`~/Library/Logs/agenthub.log`, and the hand-over restart is `launchctl bootout`
+ `bootstrap` (a `kickstart` would keep the old plist). If that bootstrap fails
and no server is left running, the bootstrap starts one detached. A LaunchAgent
needs the user's GUI login; without one (SSH only) `launchctl print gui/<uid>`
fails and the server falls back to the detached spawn below.
On Windows, a Linux box without systemd, or a Mac with no GUI login, the
server is a detached spawn started fresh on the next `SessionStart` after a
reboot, same as always. That spawn inherits the Claude Code session's env, so
`server/index.js` deletes the parent-session variables (`CLAUDECODE`,
`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …)
before anything else runs — otherwise tmux hands them to every lane pane. It is
a denylist: user configuration such as `CLAUDE_CONFIG_DIR`,
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. There is no
launchd agent; see [INSTALL.md § macOS](../INSTALL.md#macos).
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. See
[INSTALL.md § macOS](../INSTALL.md#macos).
### Who may stop the server