Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 887dc4640e | |||
| 2b8f83d2dd | |||
| 3c238bc5c4 | |||
| 9d096b61f7 | |||
| 952e8584f0 | |||
| 3f6e903c22 | |||
| 1da46f10f4 | |||
| 52392962fb | |||
| 40fe41bb77 | |||
| 99a2a8277c | |||
| 04b6b98d97 | |||
| 478418f602 | |||
| aff79a787c | |||
| f514602697 | |||
| 605ca9d65e | |||
| 158783d5d5 |
@@ -12,25 +12,7 @@
|
||||
"commands": [
|
||||
"./plugins/agenthub/commands/agenthub-doctor.md",
|
||||
"./plugins/agenthub/commands/agenthub-update.md",
|
||||
"./plugins/agenthub/commands/agenthub-open.md",
|
||||
"./plugins/agenthub/commands/intake.md",
|
||||
"./plugins/agenthub/commands/plan.md",
|
||||
"./plugins/agenthub/commands/implement.md",
|
||||
"./plugins/agenthub/commands/tests.md",
|
||||
"./plugins/agenthub/commands/e2e.md",
|
||||
"./plugins/agenthub/commands/e2e-passed.md",
|
||||
"./plugins/agenthub/commands/review.md",
|
||||
"./plugins/agenthub/commands/qc-plan.md",
|
||||
"./plugins/agenthub/commands/qc.md",
|
||||
"./plugins/agenthub/commands/gate.md",
|
||||
"./plugins/agenthub/commands/publishing.md",
|
||||
"./plugins/agenthub/commands/pr-open.md",
|
||||
"./plugins/agenthub/commands/reported.md",
|
||||
"./plugins/agenthub/commands/watching-pr.md",
|
||||
"./plugins/agenthub/commands/merged.md",
|
||||
"./plugins/agenthub/commands/done.md",
|
||||
"./plugins/agenthub/commands/ship-full.md",
|
||||
"./plugins/agenthub/commands/ship-quick.md"
|
||||
"./plugins/agenthub/commands/agenthub-open.md"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
|
||||
@@ -11,42 +11,11 @@
|
||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
||||
|
||||
## Declare your lane stage as you work
|
||||
## Lane stage
|
||||
|
||||
This repository is watched by AgentHub, its own dashboard. A **lane** is keyed by
|
||||
working directory, and the pipeline map only moves forward when the agent doing
|
||||
the work declares its stage:
|
||||
|
||||
```bash
|
||||
agenthub stage <stage> --tool codex --evidence "<what is actually done>"
|
||||
```
|
||||
|
||||
- Call it at every real transition — starting to plan, starting to implement,
|
||||
running tests, opening the PR — not once at the end.
|
||||
- Stage names, `default` template: `intake → plan → implement → tests → review
|
||||
→ gate → ship → done` (the 16-node `ship-feature` template accepts these as
|
||||
aliases too; `agenthub stage` warns on stderr and names the valid nodes if a stage
|
||||
matches none).
|
||||
- **`--tool codex` matters.** One directory can back both a `claude` lane and a
|
||||
`codex` lane, and without a tool the command fails rather than guess (it names
|
||||
both candidates). Inside a pane the dashboard started you need nothing — that
|
||||
pane carries `AGENTHUB_LANE_ID`, which outranks everything but `--lane`.
|
||||
- If `agenthub stage` reports that no lane owns the directory, this checkout was
|
||||
never adopted: skip the stage calls and carry on. Stage reporting is
|
||||
reporting, never control flow — a failed `agenthub stage` never justifies skipping
|
||||
a step of the actual work.
|
||||
|
||||
The dashboard also *infers* your stage from your tool calls (it reads Codex's
|
||||
own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane
|
||||
that inference does move the pipeline map forward on its own. It still never
|
||||
counts as **evidence**: a node it moved through stays amber and never renders
|
||||
green. Only your own declaration does that — which is why the call above is
|
||||
worth making even when the map already looks right.
|
||||
|
||||
If you hold a stage for a while with no evidence recorded, the dashboard may
|
||||
type a one-line `[agenthub]` reminder straight into this pane asking you to run
|
||||
`agenthub stage ... --evidence "..."`. That is the dashboard, not the user — treat
|
||||
it as the same instruction as this section and just make the real call.
|
||||
AgentHub's `codex` (and `antigravity`) lanes carry no pipeline: the dashboard shows no stage for
|
||||
them and never infers or advances one, so there is no `agenthub stage` call to
|
||||
make while you work here as Codex.
|
||||
|
||||
## Where to work
|
||||
- `server/` for API/routes/data processing.
|
||||
|
||||
+21
-87
File diff suppressed because one or more lines are too long
+64
-9
@@ -31,11 +31,10 @@ dashboard, run it from source, or stay on Node 20.
|
||||
| npm | 9+ | Comes with Node.js |
|
||||
| Claude Code | 2.x+ | Required for hook integration |
|
||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, and the Codex nudge. See [Step 0](#step-0--system-packages) |
|
||||
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
|
||||
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
|
||||
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` and the PR stages of `/agenthub:ship-full` |
|
||||
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` |
|
||||
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
|
||||
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +85,61 @@ Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
|
||||
the browser terminal and your local terminal are two clients on one pane, and
|
||||
restarting the dashboard never stops a run.
|
||||
|
||||
### macOS
|
||||
|
||||
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
|
||||
covered by Linux-side unit tests only; the checklist at the end of this section
|
||||
is what a Mac user runs to confirm them.
|
||||
|
||||
```bash
|
||||
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
|
||||
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
|
||||
service install`), so the dashboard comes back after every login with no
|
||||
Claude Code session, and launchd restarts it if it dies. Lane panes survive
|
||||
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
|
||||
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
|
||||
`launchctl` fails, the server is a detached process started by the next
|
||||
Claude Code session (within about 10 s) or by `agenthub start`, as before. A
|
||||
discovery entry left over from before the reboot is ignored, so a recycled
|
||||
pid can never pass for a live dashboard.
|
||||
- **Started from a Claude Code session.** The server drops the variables that
|
||||
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
|
||||
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
|
||||
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
|
||||
an older build started keeps its dirty global env until it exits
|
||||
(`tmux kill-server` once, with no run you care about open).
|
||||
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
|
||||
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
|
||||
volume becomes the one on disk — that is what sessions report, so they bind.
|
||||
Lanes created before this change keep the string they were created with.
|
||||
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
|
||||
macOS may ask once for permission the first time.
|
||||
- **Option key** in the browser terminal types special characters (it is not
|
||||
sent as Meta).
|
||||
|
||||
**Mac checklist** — please run it and report the result on issue #22:
|
||||
|
||||
1. Fresh plugin install from a Terminal `claude` session: the dashboard answers on `http://localhost:4820`, `~/Library/LaunchAgents/io.smartgift.agenthub.plist` exists, and `/agenthub-doctor`'s launchd row says the LaunchAgent is current and running.
|
||||
2. `tmux show-environment -g | grep -i claude` prints nothing.
|
||||
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
|
||||
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form.
|
||||
5. Changes panel: diff, stage and commit on a lane.
|
||||
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
|
||||
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
|
||||
8. Reboot and log in WITHOUT opening `claude`: the dashboard answers within about 10 s (`agenthub service status` shows `state = running`).
|
||||
9. `kill <server pid>` (from `agenthub status`): it is back within a few seconds.
|
||||
10. With a lane run open, `/agenthub-update` completes, the server restarts on the new version, the plist names the new cache directory, and the run's terminal is still alive.
|
||||
11. `agenthub service uninstall`: the plist is gone and nothing restarts the server after a `kill`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Clone the repository
|
||||
@@ -239,13 +293,15 @@ npm start # Start Express serving client/dist on port 4820
|
||||
Open **http://localhost:4820** in your browser.
|
||||
|
||||
`npm start` (and `agenthub start`, its backgrounded form) only run while that
|
||||
process is alive — a reboot loses it. On Linux, run the server as a systemd
|
||||
`--user` service instead so it comes back on its own after every reboot, with
|
||||
no manual step and no Claude Code (or any other tool) needed to trigger it:
|
||||
process is alive — a reboot loses it. Run the server as a systemd `--user`
|
||||
service (Linux) or a LaunchAgent (macOS) instead so it comes back on its own
|
||||
after every reboot, with no manual step and no Claude Code (or any other tool)
|
||||
needed to trigger it:
|
||||
|
||||
```bash
|
||||
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
|
||||
agenthub service status # systemctl --user status agenthub
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
@@ -262,7 +318,6 @@ This repository includes extension packs for both Claude Code and Codex.
|
||||
- `CLAUDE.md`
|
||||
- `.claude/rules/`
|
||||
- `skills/` (shipped as the `agenthub` plugin's skills)
|
||||
- `agents/` (shipped as the `agenthub` plugin's agents)
|
||||
- Codex project packs live under `.codex/`:
|
||||
- `AGENTS.md`
|
||||
- `.codex/rules/`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
||||
monitoring is one part of it, not the whole. Hooks POST every tool call to an
|
||||
Express + SQLite server and a React UI updates over WebSocket; **lanes** track
|
||||
parallel agent work through a pipeline; and the `agenthub` CLI drives real terminal
|
||||
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
||||
sessions, lane runtimes, and Claude Code's own configuration.
|
||||
|
||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||
@@ -31,11 +31,13 @@ Internal build — all rights reserved.
|
||||
|
||||
**Drive**
|
||||
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. A lane
|
||||
moves through pipeline stages and the dashboard shows where it is.
|
||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||
that never calls `agenthub stage` still shows progress — rendered dashed amber and
|
||||
never as done, because an inference is not evidence.
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. Once you
|
||||
install a pipeline template (none ships), a lane moves through its stages and
|
||||
the dashboard shows where it is.
|
||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
||||
tool stream, so a session that never calls `agenthub stage` still shows
|
||||
progress — rendered dashed amber and never as done, because an inference is
|
||||
not evidence.
|
||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
||||
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
|
||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||
@@ -51,8 +53,6 @@ Internal build — all rights reserved.
|
||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per lane (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
|
||||
feature from intake to PR, declaring every stage to the dashboard as it goes.
|
||||
|
||||
**Configure**
|
||||
|
||||
@@ -78,10 +78,10 @@ Internal build — all rights reserved.
|
||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
||||
| **npm** | 9+ | Ships with Node |
|
||||
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane |
|
||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||
| **Codex CLI** | any | Optional — only for `codex` lanes |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` |
|
||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||
|
||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||
@@ -104,6 +104,12 @@ sudo pacman -S tmux # Arch
|
||||
|
||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||
|
||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Verify, both from your shell and from the server's point of view:
|
||||
|
||||
```bash
|
||||
@@ -116,34 +122,6 @@ terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
|
||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||
dashboard never kills the run.
|
||||
|
||||
### The superpowers plugin
|
||||
|
||||
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
|
||||
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
|
||||
`superpowers:brainstorming`, `writing-plans`, `test-driven-development`,
|
||||
`requesting-code-review` and `verification-before-completion` phase by phase,
|
||||
and both built-in pipeline templates name those same slash commands as their
|
||||
per-node drivers. Without it the dashboard, hooks and CLI all work
|
||||
normally — the two pipeline skills are what stop partway. `/agenthub-doctor` reports
|
||||
it as a `WARN` row.
|
||||
|
||||
**You do not normally have to install it.** The plugin's `SessionStart`
|
||||
bootstrap installs it for you, once, in its detached worker — so a session never
|
||||
waits on the clone, and the current session needs `/reload-plugins` before the
|
||||
skills resolve (any later session picks them up on its own). The outcome is
|
||||
logged to `~/.claude/agenthub/runtime/bootstrap.log`, and `/agenthub-update`
|
||||
retries a failed one. To install it by hand instead, or after opting out:
|
||||
|
||||
```bash
|
||||
claude plugin install superpowers@claude-plugins-official
|
||||
```
|
||||
|
||||
Set `AGENTHUB_AUTO_INSTALL_PLUGINS=0` to opt out of the automatic install. Only the
|
||||
plugins on a fixed allowlist in `server/lib/skill-plugins.js` are ever installed
|
||||
this way — `superpowers` today. A custom pipeline template naming some other
|
||||
plugin's command is *reported* by `/agenthub-doctor` and never installed, because a
|
||||
template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`.
|
||||
|
||||
## Install as a Claude Code plugin
|
||||
|
||||
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||
@@ -187,19 +165,20 @@ every event is counted twice.
|
||||
### Run as a service (auto-start on boot)
|
||||
|
||||
`npm start` only runs while that terminal (or `agenthub start`'s background
|
||||
process) is alive — a reboot loses it. On Linux, `agenthub service install` sets
|
||||
up a systemd `--user` service instead, so `:4820` is up again on its own after
|
||||
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
||||
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
||||
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
||||
to trigger it:
|
||||
|
||||
```bash
|
||||
npm run build # the service serves the built client, same as npm start
|
||||
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
|
||||
agenthub service status # systemctl --user status agenthub
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
`agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. Plugin installs get the same persistence automatically — see
|
||||
@@ -217,7 +196,6 @@ agenthub sessions # recent sessions
|
||||
agenthub lanes # lanes with stage and progress
|
||||
agenthub lanes pipeline # this lane's pipeline template, or switch it
|
||||
agenthub lanes node <id> # run ONE pipeline node's own command (no id: list them)
|
||||
# (in a session: /intake, /qc, /ship-full, /ship-quick …)
|
||||
agenthub stage <name> # declare the current lane's stage
|
||||
agenthub tail # live event feed
|
||||
```
|
||||
@@ -246,20 +224,17 @@ Every lane also carries the CLI that drives it — `claude` (the default) or
|
||||
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||
choices, and resumes with `codex resume --last`. Codex emits none of this
|
||||
dashboard's hooks, so a Codex lane records its runs but stays absent from
|
||||
Sessions and Analytics. Its **stage detection** works anyway, from Codex's own
|
||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection
|
||||
also **advances the declared stage** — the one place an inference moves the blue
|
||||
`current` ring, because a Codex session has no hook and nothing else would ever
|
||||
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
|
||||
node stays amber (`passed-no-evidence`), never green, since only the session
|
||||
itself can say what it finished. Two things help it get there (the run console
|
||||
no longer pre-fills anything into the prompt box — that box is the user's to
|
||||
type): `agenthub lanes agents install` writes the stage-declaration
|
||||
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
|
||||
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
|
||||
a lane's pane if its current stage sits without evidence for more than two
|
||||
minutes.
|
||||
Sessions and Analytics. A Codex lane also carries **no pipeline** — no stage,
|
||||
no pipeline map, nothing inferred or advanced; the dashboard only mirrors its
|
||||
running/idle status from Codex's own rollout journals on disk
|
||||
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a lane has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
||||
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
||||
Further development is planned.
|
||||
|
||||
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
|
||||
|
||||
+26
-31
@@ -3,7 +3,7 @@
|
||||
Bản build nội bộ SmartGift. **Bàn điều khiển** local-first cho agent Claude Code —
|
||||
monitoring chỉ là một phần, không phải tất cả. Hooks POST mỗi tool call lên server
|
||||
Express + SQLite và React UI cập nhật qua WebSocket; **lane** (làn đường) theo dõi
|
||||
công việc song song của agent qua một pipeline; còn CLI `agenthub` điều khiển phiên
|
||||
công việc song song của agent, mỗi working directory một lane; còn CLI `agenthub` điều khiển phiên
|
||||
terminal thật, runtime của lane, và cả cấu hình của chính Claude Code.
|
||||
|
||||
AgentHub ghi nhận và trình bày — nó không điều phối. Phiên đang chạy mới là nơi ra
|
||||
@@ -31,10 +31,12 @@ Internal build — all rights reserved.
|
||||
|
||||
**Điều khiển**
|
||||
|
||||
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
|
||||
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
|
||||
session không bao giờ gọi `agenthub stage` vẫn hiển thị progress — render dạng
|
||||
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Khi bạn
|
||||
cài một pipeline template (không có template nào đi kèm), lane đi qua các
|
||||
stage của nó và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Khi đã cài template, stage được suy luận (infer) từ tool
|
||||
stream, nên một session không bao giờ gọi `agenthub stage` vẫn hiển thị
|
||||
progress — render dạng
|
||||
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
|
||||
không phải bằng chứng.
|
||||
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
|
||||
@@ -44,8 +46,6 @@ Internal build — all rights reserved.
|
||||
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
|
||||
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
|
||||
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
|
||||
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
|
||||
|
||||
**Cấu hình**
|
||||
|
||||
@@ -67,10 +67,10 @@ Internal build — all rights reserved.
|
||||
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
|
||||
| **npm** | 9+ | Đi kèm Node |
|
||||
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống |
|
||||
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
|
||||
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` |
|
||||
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
|
||||
|
||||
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
|
||||
@@ -92,6 +92,12 @@ sudo pacman -S tmux # Arch
|
||||
|
||||
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
|
||||
|
||||
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
|
||||
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
||||
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
||||
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
||||
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||
|
||||
```bash
|
||||
@@ -104,23 +110,6 @@ trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub l
|
||||
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
|
||||
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
|
||||
|
||||
### Plugin superpowers
|
||||
|
||||
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
|
||||
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
|
||||
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
|
||||
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
|
||||
|
||||
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
|
||||
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
|
||||
thì skill mới resolve được (session sau tự nhận). Cài tay:
|
||||
|
||||
```bash
|
||||
claude plugin install superpowers@claude-plugins-official
|
||||
```
|
||||
|
||||
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
|
||||
|
||||
## Cài như Claude Code plugin
|
||||
|
||||
Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không cần
|
||||
@@ -194,7 +183,6 @@ agenthub sessions # session gần đây
|
||||
agenthub lanes # danh sách lane kèm stage và progress
|
||||
agenthub lanes pipeline # pipeline template của lane này, hoặc đổi nó
|
||||
agenthub lanes node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
|
||||
# (trong session: /intake, /qc, /ship-full, /ship-quick …)
|
||||
agenthub stage <name> # khai báo stage hiện tại của lane
|
||||
agenthub tail # xem live event feed
|
||||
```
|
||||
@@ -222,10 +210,17 @@ chọn trong modal Add lane và cố định suốt vòng đời của lane. Lan
|
||||
chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và
|
||||
resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên
|
||||
lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
|
||||
**Stage detection thì vẫn chạy**, đọc từ rollout journal của chính Codex trên đĩa
|
||||
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, poll mỗi `DASHBOARD_CODEX_POLL_MS` ms,
|
||||
mặc định 15000) — thứ lane Codex không tự có là stage **khai báo**, vì chỉ chính
|
||||
session đó mới gọi được `agenthub stage`.
|
||||
Lane Codex cũng **không có pipeline** — không stage, không pipeline map, không
|
||||
suy luận hay tự nhảy stage; dashboard chỉ phản ánh trạng thái running/idle từ
|
||||
rollout journal của chính Codex trên đĩa (`$CODEX_HOME/sessions/**/rollout-*.jsonl`,
|
||||
poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
|
||||
|
||||
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
|
||||
nào, nên lane không có stage cho tới khi bạn thả một file template JSON vào
|
||||
`DASHBOARD_PIPELINES_DIR` (xem
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
|
||||
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
|
||||
này sẽ được phát triển tiếp.
|
||||
|
||||
Stage khai báo (declared) đến từ `agenthub stage`. Stage suy luận (inferred) đến
|
||||
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: backend-reviewer
|
||||
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a backend reviewer for this repository. Review ONLY files under `server/` in the diff you are given — ignore `client/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
|
||||
|
||||
Focus on:
|
||||
- Hook event lifecycle correctness.
|
||||
- Session/agent state-machine regressions.
|
||||
- API contract compatibility.
|
||||
- Transaction and persistence correctness.
|
||||
- Missing or weak verification coverage.
|
||||
|
||||
## Output (MANDATORY — the caller parses your last line)
|
||||
|
||||
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
|
||||
- End with exactly one of:
|
||||
- `BACKEND-REVIEW: CLEAN`
|
||||
- `BACKEND-REVIEW: FINDINGS — <n>`
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record recurring backend defect patterns and false-positive traps you find here (e.g. hook shapes that look wrong but aren't, common state-machine edge cases). Future backend-reviewer runs read this.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: frontend-reviewer
|
||||
description: Review React UI changes for behavior regressions, state consistency, and UX breakage.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a frontend reviewer for this repository. Review ONLY files under `client/` in the diff you are given — ignore `server/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
|
||||
|
||||
Focus on:
|
||||
- Routing and navigation consistency.
|
||||
- State updates from websocket and API responses.
|
||||
- Loading/empty/error state correctness.
|
||||
- Breaking visual or interaction regressions.
|
||||
- Missing tests for changed UI behavior.
|
||||
|
||||
## Output (MANDATORY — the caller parses your last line)
|
||||
|
||||
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
|
||||
- End with exactly one of:
|
||||
- `FRONTEND-REVIEW: CLEAN`
|
||||
- `FRONTEND-REVIEW: FINDINGS — <n>`
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record recurring frontend defect patterns and false-positive traps you find here (e.g. websocket race conditions that look wrong but aren't, sibling components that should stay in sync). Future frontend-reviewer runs read this.
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: "qc-local"
|
||||
description: "Local browser-QC agent for the ship-feature-lane pipeline in AgentHub. Launched by the ship-feature-lane skill at Stage 6 (foreground — it gates the pipeline) after e2e, against the lane's OWN feature-branch stack (booted via `agenthub lanes up --qc`; the branch is cut from origin/development, so the stack is development + this feature). Exercises the feature + smoke + reload/re-login coverage via the lane's local-QC Playwright MCP, saves every screenshot to `proof/<feature-slug>/qc-local/` (the AgentHub proof gallery's canonical path), and returns a parseable LOCAL-QC: PASS/FAIL verdict that doubles as the feature user-flow review for the senior gate. <example>Context: ship-feature-lane in a lane finished Stage 3's e2e green. assistant: 'e2e is green — launching the qc-local agent to browser-QC the feature-branch stack and capture the proof gallery.' <commentary>Stage-6 browser QC is the qc-local agent's job; the main session stays off the browser.</commentary></example>"
|
||||
model: opus
|
||||
color: green
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an elite QA automation engineer validating ONE lane's feature on that lane's OWN local stack (isolated FE/API/DB, nothing shared with other lanes), right after the feature branch's e2e passed (the branch is cut from `origin/development`, so this stack IS development + the feature as of the branch point). You are rigorous, evidence-driven, and autonomous: every claim in your report is backed by a screenshot. You never commit, push, or fix code — you verify, capture proof, and report. Your report IS the feature user-flow review the senior gate reads.
|
||||
|
||||
## Context you will be given (by the ship-feature-lane skill)
|
||||
|
||||
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically; there is no marker file to check).
|
||||
- **Feature slug** (the `<slug>` of `feat/<slug>`), feature title, and the acceptance points to verify (from the lane spec at `docs/superpowers/specs/lane-<slug>.md` — read it yourself if the summary is thin; `git diff origin/development...HEAD` shows the change surface).
|
||||
|
||||
Setup: your cwd is the lane clone. You do NOT write `stage`/`status`/`notes` — the main session owns all state; you have no heartbeat call to make (you run as a synchronous subagent inside the driving session's own turn, so the lane is never seen as stalled while you work).
|
||||
|
||||
## Target environment
|
||||
|
||||
- **App**: the lane's own booted frontend — check `agenthub lanes runtime` (or the `FE_PORT`/equivalent env the skill already resolved) for the actual port; it is isolated per lane. If it's down, don't boot it yourself blindly — return `LOCAL-QC: FAIL — lane stack down (re-run: agenthub lanes up --no-build)`.
|
||||
- **QC boot env**: the QC stack boots via `agenthub lanes up --qc`, which applies the profile's `QC_BOOT_ENV` (typically mock/stub flags) — LLM/paid-backend features then return CANNED responses. Canned/stub content is EXPECTED there, not a bug; judge the surrounding UX (loading states, rendering, persistence), not the stub text itself.
|
||||
- **Account / login.** Two paths:
|
||||
- **If a credentials block is embedded at the TOP of this agent** ("⚙ This lane's local-QC credentials"): those ARE your login — type them into the form yourself (`browser_type`, submit) when you hit a login page. You already have them; never read a file/script to fetch them.
|
||||
- **If there is NO embedded block:** the app should already be in a usable/logged-in state (or requires no login for the flows you're testing). If you hit a login page you cannot get past, this profile has no seeded QC account configured yet — return `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile`. Do not guess at credentials or invent a login flow.
|
||||
- **Tooling**: the **dedicated local-QC Playwright MCP server** (isolated Chromium profile — e.g. `playwright-qa-local` if the profile names it that). You MUST use that server's `mcp__<name>__browser_*` tool family for ALL browser interaction — never the unscoped `mcp__playwright__browser_*` or any other Playwright server (other agents/sessions own those browsers concurrently). If the tools aren't available, return `LOCAL-QC: FAIL — local-QC Playwright MCP not loaded (configure it in .mcp.json and restart the lane session — see the ship-feature-lane skill's Setup section)`.
|
||||
- **Upload fixtures**: if an **"Upload fixtures dir"** line is injected at the TOP of this agent, use the files in that dir for any data-upload scenario (quote the path; it may contain spaces). If no such line is present, no fixture dataset is configured for this stack — skip data-upload-only scenarios and note that in the report. Put ad-hoc non-fixture files in the lane's `.playwright-mcp/` dir; never source uploads from `/tmp/`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Load the QC Plan, then execute against it.** The ship-feature-lane skill wrote a `## QC Plan` section into the lane spec (`docs/superpowers/specs/lane-<slug>.md`) at Stage 5 — it is the authoritative scope. Read it first.
|
||||
- **Cover every in-scope scenario** in the plan — skip none.
|
||||
- **Stay in scope** — don't wander into the plan's out-of-scope areas (that over-testing is exactly what the plan exists to prevent); the only unaffected areas you touch are the plan's smoke set.
|
||||
- **If the plan is missing or thin**, derive scenarios yourself from the acceptance points + `git diff origin/development...HEAD` and proceed, covering the same shape: **primary** (each acceptance point, positive AND negative — invalid input, empty state, permission edges), **adjacent** flows sharing routes/components/data, **smoke** (login + main nav + ≥3 unaffected major areas), and **state coverage** (a reload (Cmd+R) on every stateful screen touched, one logout → re-login cycle, back-and-forth navigation between key pages).
|
||||
- **When you discover a real scenario the plan missed** (a genuine risk it didn't anticipate), TEST it AND list it in your report under **"Scenarios discovered during QC"** so the skill folds it into the plan. Do NOT edit the plan file yourself — the skill is its single writer.
|
||||
- **UI/UX & layout rigor — apply to EVERY form/screen the feature touches** (never just a representative one; sibling forms drift):
|
||||
- **Overflow in BOTH axes.** Resize the window NARROWER (width) AND SHORTER (height — e.g. ~560px then ~350px tall), and ALSO grow the content by opening every expandable thing (dropdowns, collapsible sections, "add row" repeaters, multi-select pickers) so a step becomes taller than the viewport. Then confirm: nothing is cut off at the **top** or bottom; any fixed/sticky chrome (page header, wizard **stepper/tabs**, toolbars) stays visible and is NOT clipped; the scroll container actually scrolls; and the primary actions (Save/Next/Submit/Cancel) stay reachable and clickable. A form taller than the page must never hide its header, its step nav, or its buttons.
|
||||
- **Every control has a visible label.** Each input/toggle/select/picker shows a field label. Compare sibling forms — if one labels a control and the other doesn't, that's a defect.
|
||||
- **Visual hierarchy is correct.** Section/group headers are MORE prominent than the field labels inside them (size/weight/color); field labels are consistent across steps and across sibling forms; helper text is least prominent. Flag any inverted or inconsistent hierarchy.
|
||||
- No text truncation/overlap; spacing, alignment, and contrast are reasonable; the form reads as natural, polished UI.
|
||||
2. **Execute with the local-QC MCP's tools**:
|
||||
- Use `browser_type` for React controlled inputs — never direct DOM value assignment (it doesn't fire onChange).
|
||||
- After each meaningful action: `browser_snapshot` to verify state, then screenshot (see proof convention).
|
||||
- Don't trust `browser_network_requests` alone for HTTP verification (it double-lists requests); prefer the lane's own backend/service logs if the profile exposes them (`agenthub lanes logs <svc>`).
|
||||
- **Failure triage**: capture a failure screenshot + `browser_snapshot` + `browser_console_messages`, note repro steps, expected vs actual, severity (blocker/major/minor/cosmetic) — then CONTINUE with remaining scenarios unless the app is unusable.
|
||||
3. **Proof convention (mandatory — the path is fixed infrastructure, NOT your choice):** EVERY screenshot via `browser_take_screenshot` with `filename: "proof/<feature-slug>/qc-local/<NN>-<what>.png"`, numbered in flow order (e.g. `proof/edit-report/qc-local/03-dialog-open.png`). The MCP server's pinned `--output-dir` lands these in `<lane clone>/.playwright-mcp/proof/...` — the ONLY place the dashboard's gallery reads (`agenthub lanes proof-link` converges a stray `proof/` symlink if needed). Absolute paths, other folders, or invented layouts = the proof is lost.
|
||||
|
||||
## Checkpoint & resume *(rate-limits and crashes must not zero your work)*
|
||||
|
||||
- **Checkpoint as you go:** after EACH scenario, append one line to `proof/<feature-slug>/qc-local/RESULTS.partial.md` (via the Write/Edit tools on `<lane clone>/.playwright-mcp/proof/...`): `<NN> <scenario> — ✅|❌ — <proof file> — <HEAD sha>`. A mid-run tool error or rate-limit then leaves a resumable trail instead of zeroing the run.
|
||||
- **Resume on start:** if `RESULTS.partial.md` already exists for this feature AND its HEAD sha matches the tree you're QCing, treat its ✅ scenarios as done (spot-check one) and continue from the first unfinished one. Sha differs → start fresh (delete the stale partial).
|
||||
- **Bounded stalls:** a single browser step hanging >2 min → close the browser, reload, retry that step ONCE; still stuck → record ❌ with proof and move on. If the run approaches ~45 min, finish the current scenario, mark the rest ⚠️ not-run, and return honestly rather than stalling silently.
|
||||
|
||||
## Quality bar (verify before returning)
|
||||
|
||||
- [ ] Every in-scope QC-Plan item was executed (or marked N/A with a reason); any discovered scenarios are listed for fold-back.
|
||||
- [ ] Every primary scenario has a screenshot of its end state (pass or fail).
|
||||
- [ ] Reload tested on every stateful screen touched; one full logout → re-login cycle done.
|
||||
- [ ] Smoke covered login, navigation, and ≥3 unaffected areas.
|
||||
- [ ] UI/UX pass done on EACH touched form: tested at narrow AND short viewports AND with dropdowns/expandables open (content taller than the page) — fixed header/stepper/buttons never cut off, scroll works, actions reachable; every control labeled; section headers more prominent than field labels; consistent label styling; no overlap/truncation; natural polished layout.
|
||||
- [ ] All screenshots under `proof/<feature-slug>/qc-local/` (check with `ls .playwright-mcp/proof/<feature-slug>/qc-local/`).
|
||||
- [ ] No commits made; password never echoed anywhere.
|
||||
|
||||
## Output format (MANDATORY — the skill parses your last line)
|
||||
|
||||
A scannable report (this doubles as the user-flow review for the senior gate): a **QC-Plan coverage** view (each in-scope plan item → ✅/❌/⚠️), a **Scenarios discovered during QC** list (anything you tested that wasn't in the plan, for the skill to fold back), issues with severity + repro + which proof file shows each, proof filename list, reload/re-login coverage note, UX observations worth fixing. Then end with exactly one of:
|
||||
|
||||
- `LOCAL-QC: PASS`
|
||||
- `LOCAL-QC: FAIL — <comma-separated reasons>`
|
||||
|
||||
A FAIL must be specific enough for the lane's fix-loop to act on (page/flow + what broke + proof file).
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record local-stack QA knowledge as you find it: flaky selectors/flows, features that break after reload or re-login, stub-mode quirks (what canned responses look like per feature), data-setup prerequisites, upload-fixture behaviors, noise-vs-real console errors. Future qc-local runs (any lane) read this.
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: "senior-gate-reviewer"
|
||||
description: "Final GO/NO-GO publish-readiness gate for the ship-feature-lane pipeline in AgentHub. Invoke AFTER the code-review and feature-user-flow-review (qc-local) and AFTER local CI gates + e2e + QC have passed, immediately BEFORE the feature branch is pushed and its PR is opened (base `development`). Returns a single GO or NO-GO verdict with a required-fix list. A GO is the sole authorization to publish the PR. <example>Context: ship-feature-lane in a lane has finished implementation, gates, and reviews and is about to publish the PR. assistant: 'I'll launch the senior-gate-reviewer agent to make the final go/no-go decision before pushing the branch and opening the PR.' <commentary>This is the publish gate — it decides whether the feature is allowed in front of reviewers.</commentary></example>"
|
||||
model: opus
|
||||
color: red
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are the **Senior Gate Reviewer** — the final, independent publish-readiness authority for the ship-feature-lane pipeline. Nothing is pushed or PR'd without your **GO**; the PR a human later merges into `development` is exactly what you approved. You are deliberately skeptical: when in doubt, you return **NO-GO** with a precise, actionable fix list. You do NOT make fixes yourself — you judge, decide, and report.
|
||||
|
||||
## Context you will be given (by the ship-feature-lane skill)
|
||||
|
||||
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically).
|
||||
- **The original requirement** and the **upfront Q&A answers** (acceptance criteria).
|
||||
- The **feature branch name** (cut from `origin/development` — the PR base it will target).
|
||||
- A summary of the **two prior reviews** (code-review + the qc-local user-flow review) and which findings were resolved.
|
||||
- The **`agenthub lanes sync-base --check` preflight output** (migration-collision check + how far `origin/development` moved since the branch's merge-base).
|
||||
- Confirmation that **local CI gates** (the profile's lint / test / contract checks), **e2e**, and **browser QC** already passed.
|
||||
|
||||
If any of this context is missing, gather it yourself (read the lane spec file the skill wrote, run `git -C <lane> log/diff`).
|
||||
|
||||
## Your checks
|
||||
|
||||
**Review the PR the reviewers will see.** The PR is `<feature-branch>` based on `origin/development` — judge the change against **the PR diff**: `git -C <lane> diff origin/development...<feature-branch>` (`<feature-branch>` is in your context). There is NO local integration tree in this flow — the branch itself is what ships, and the first combined validation after a human merges it is dev CI + dev-QC; your merge-safety check (#3) is what stands between this branch and that merge. Run checks with `git -C <lane> ...`, `Read`, `Grep`, `make` as needed.
|
||||
|
||||
1. **Acceptance** — Does the implementation actually satisfy the original requirement + every upfront Q&A answer? Inspect **the PR diff** (defined above) and, where feasible, confirm the acceptance criteria are met in code. Missing/partial requirement coverage ⇒ NO-GO.
|
||||
|
||||
2. **Findings resolved** — Were the PR-code-review and user-flow-review findings actually addressed (not just acknowledged)? Spot-check **the PR diff** for each claimed fix. Unresolved material findings ⇒ NO-GO.
|
||||
|
||||
3. **Merge-safety** — Independently verify the branch is safe for a human to merge into `development`. **General principle (any stack):** "the branch is green standalone" is NOT proof the MERGE will be — check for collisions that DON'T surface as git conflicts — duplicate migration identifiers, duplicate fixture/test IDs, API/schema-contract drift, lockfile divergence — in whatever form this stack expresses them, comparing the branch against the CURRENT `origin/development`. Re-run the deterministic preflight yourself if the provided output is stale: `git -C <lane> fetch origin && agenthub lanes sync-base --check <feature-branch>` (exit 5 = collision ⇒ NO-GO with the printed rename; also read its `DEV_DELTA`/`DEV_OVERLAP` — a large overlapping upstream delta means the locally-QC'd behavior may not match post-merge dev, weigh it).
|
||||
- **Stack-specific checks (from the active profile):** read `<lane>/.agenthub/profile/review-checks.md` with the Read tool if it exists, and apply every check it lists (e.g. migration-identifier collisions, API/contract regeneration). Missing or empty → derive the equivalents yourself from the general principle by inspecting the branch vs `origin/development`.
|
||||
- **Obvious regressions / scope creep**: scan **the PR diff** for debug code, secrets, commented-out blocks, unrelated churn, `console.log`/`print` debugging, TODO/FIXME left in critical paths.
|
||||
|
||||
4. **UI/UX diligence (when the feature touches any form/screen)** — don't rubber-stamp the QC report; confirm it actually exercised layout rigor, because these defects slip through happy-path QC:
|
||||
- **Overflow** was tested at narrow AND **short** viewports, AND with dropdowns/collapsibles/repeaters **open** so content exceeds the viewport — and no fixed chrome (page header, wizard **stepper/tabs**, action bar) is cut off (especially at the **top**), scroll works, and primary actions stay reachable. A report that only shows one viewport / the happy path has NOT verified this.
|
||||
- Every control is **labeled**, and **section headers are more prominent than field labels** (no inverted hierarchy); labels are consistent across **sibling forms** (if the feature changed two similar forms, they must match).
|
||||
- Spot-check **the PR diff** yourself for UI/UX regressions in these classes (a scroll container that lost `min-h-0`, a removed/altered label, a muted section header, a form-wide container that scrolls the whole page instead of an inner region).
|
||||
If the feature is UI-heavy and the QC didn't demonstrably check the above ⇒ NO-GO with a specific "re-QC: verify <X> at short viewport / with <dropdown> open" instruction.
|
||||
|
||||
5. **QC-plan coverage** — read the `## QC Plan` section of the lane spec and the qc-local report (the user-flow review; on a follow-up fix run, also the parent's dev-QC findings the follow-up exists to fix). Confirm every **in-scope** plan item has a result (pass/fail, with proof) — any uncovered in-scope item ⇒ NO-GO ("re-QC: cover <item>"). If a QC report spent effort on the plan's **out-of-scope** areas while leaving in-scope items thin, flag it. Scenarios the agents discovered mid-run should appear folded into the plan.
|
||||
|
||||
## Output format (MANDATORY — the skill parses your last line)
|
||||
|
||||
Write a short report (acceptance ✓/✗, findings ✓/✗, merge-safety ✓/✗ with one line each), then end with **exactly one** of these as the FINAL line:
|
||||
|
||||
- `VERDICT: GO`
|
||||
- `VERDICT: NO-GO — <comma-separated required fixes>`
|
||||
|
||||
Rules:
|
||||
- Only return `GO` when all three checks pass with high confidence.
|
||||
- A NO-GO fix list must be specific and actionable (file/area + what to do), so the skill's fix-loop can act and re-submit.
|
||||
- Never push, merge, commit, or modify files. You are read-only. Your verdict is the deliverable.
|
||||
+34
-174
@@ -1463,16 +1463,24 @@ async function cmdStart(flags) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs/removes a systemd `--user` service so the dashboard survives
|
||||
* reboots without anything (Claude Code included) needing to start it first.
|
||||
* Linux/systemd only; other platforms are told to keep using `agenthub start`.
|
||||
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
|
||||
* (macOS) so the dashboard survives reboots without anything (Claude Code
|
||||
* included) needing to start it first. Elsewhere, `agenthub start` by hand.
|
||||
*/
|
||||
async function cmdService(sub, flags) {
|
||||
const svc = require(path.join(REPO_ROOT, "scripts", "systemd-service.js"));
|
||||
const svc = require(path.join(REPO_ROOT, "scripts", "service-manager.js"));
|
||||
if (!svc.systemctlAvailable()) {
|
||||
console.error(c.red("✖ systemd --user is not available on this machine."));
|
||||
console.error(
|
||||
c.dim(" Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead.")
|
||||
c.red(
|
||||
`✖ ${svc.kind === "launchd" ? "launchd (a GUI login session)" : "systemd --user"} is not available here.`
|
||||
)
|
||||
);
|
||||
console.error(
|
||||
c.dim(
|
||||
svc.kind === "launchd"
|
||||
? " A LaunchAgent needs the user logged in at the Mac itself, not only over SSH. Use `agenthub start` meanwhile."
|
||||
: " Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead."
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1493,24 +1501,27 @@ async function cmdService(sub, flags) {
|
||||
description: "AgentHub dashboard server",
|
||||
workingDir: REPO_ROOT,
|
||||
execStart: `${process.execPath} ${path.join(REPO_ROOT, "server", "index.js")}`,
|
||||
argv: [process.execPath, path.join(REPO_ROOT, "server", "index.js")],
|
||||
env,
|
||||
});
|
||||
if (result === "foreign") {
|
||||
console.error(
|
||||
c.red(
|
||||
`✖ ~/.config/systemd/user/${name}.service already exists and wasn't written by agenthub.`
|
||||
)
|
||||
c.red(`✖ ${svc.unitPath(name)} already exists and wasn't written by agenthub.`)
|
||||
);
|
||||
console.error(c.dim(" Remove or rename it, then re-run `agenthub service install`."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (result === "failed") {
|
||||
console.error(c.red("✖ Failed to write/enable the systemd unit."));
|
||||
console.error(c.red(`✖ Failed to write/enable the ${svc.label}.`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`${c.green("✔")} systemd --user service '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
|
||||
`${c.green("✔")} ${svc.label} '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
|
||||
);
|
||||
if (svc.kind === "launchd") {
|
||||
console.log(c.dim(` It starts at every login. Server log: ${svc.logPath()}`));
|
||||
return;
|
||||
}
|
||||
const lingered = svc.enableLinger();
|
||||
if (lingered) {
|
||||
console.log(
|
||||
@@ -1531,20 +1542,24 @@ async function cmdService(sub, flags) {
|
||||
if (sub === "uninstall") {
|
||||
const result = svc.uninstallUserService(name);
|
||||
if (result === "foreign") {
|
||||
console.error(
|
||||
c.red(
|
||||
`✖ ~/.config/systemd/user/${name}.service wasn't written by agenthub — not touching it.`
|
||||
)
|
||||
);
|
||||
console.error(c.red(`✖ ${svc.unitPath(name)} wasn't written by agenthub — not touching it.`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`${c.green("✔")} systemd service ${result === "absent" ? "was not installed" : "removed"}.`
|
||||
`${c.green("✔")} ${svc.label} ${result === "absent" ? "was not installed" : "removed"}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "status") {
|
||||
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], { stdio: "inherit" });
|
||||
if (svc.kind === "launchd") {
|
||||
spawn("launchctl", ["print", `gui/${process.getuid()}/io.smartgift.${name}`], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} else {
|
||||
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(c.red("✖ Usage: agenthub service install|uninstall|status [--port N]"));
|
||||
@@ -1639,27 +1654,6 @@ async function cmdLanesAdd(args) {
|
||||
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub skills install` — copy this checkout's `skills/ship-feature-lane/`
|
||||
* into `~/.claude/skills/ship-feature-lane/`, so `/ship-feature-lane` is
|
||||
* discoverable from a session running inside any lane's own working
|
||||
* directory, not just inside this repo. Pure filesystem action; does not
|
||||
* talk to the dashboard server at all. Reinstall, not merge — overwrites an
|
||||
* existing global copy with the current one.
|
||||
*/
|
||||
function cmdSkillsInstall() {
|
||||
const { installShipFeatureLaneSkill } = require(
|
||||
path.join(REPO_ROOT, "server", "lib", "skills-install.js")
|
||||
);
|
||||
try {
|
||||
const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
|
||||
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
|
||||
} catch (err) {
|
||||
console.error(`✖ ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* (owning session died) and cap oversized hook logs, across every lane on
|
||||
@@ -2402,25 +2396,6 @@ async function cmdLanesProofLink(args) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub lanes agents install [<id>] [--cwd path]` — write the ship-feature-lane
|
||||
* agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
|
||||
* git-excluded. Never run automatically; a session calls it explicitly. */
|
||||
async function cmdLanesAgentsInstall(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const result = await post(
|
||||
`/api/lanes/${resolved.laneId}/agents/install`,
|
||||
{},
|
||||
{ allowError: true }
|
||||
);
|
||||
if (result.status) {
|
||||
console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
|
||||
}
|
||||
|
||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||
* profiles. Never run automatically; a session calls it explicitly. */
|
||||
@@ -2499,8 +2474,7 @@ async function cmdLanesCi(args) {
|
||||
* was display-only: the dashboard rendered a copy button and the operator
|
||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||
*
|
||||
* - a slash command (`/superpowers:brainstorming`) starts `claude` with it
|
||||
* as the prompt, exactly like `agenthub ship-full` does with the whole skill;
|
||||
* - a slash command starts `claude` with it as the prompt;
|
||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
||||
*
|
||||
* With no node id it lists the nodes and their commands, which is also the
|
||||
@@ -2663,88 +2637,6 @@ async function cmdLanesShell(args) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a `claude` session in this lane driven by a pipeline's own skill.
|
||||
* Two commands, two depths of work:
|
||||
*
|
||||
* agenthub ship-full → the 16-node `ship-feature` template, `/agenthub:ship-feature-lane`
|
||||
* — autonomous to a published PR, then watched until a
|
||||
* human merges it.
|
||||
* agenthub ship-quick → the 8-node `default` template, `/agenthub:ship-feature`
|
||||
* — in-repo feature work that stops at review/gate.
|
||||
*
|
||||
* Each command puts the lane on its own template, so the choice is the command
|
||||
* you type; the skill string itself lives in the template's `skill` field and
|
||||
* is never duplicated here.
|
||||
*
|
||||
* The prompt is passed as ONE argv element to `claude`, never through a shell —
|
||||
* a requirement containing quotes, `$`, or `;` is text, not code. `--print`
|
||||
* shows the exact command, switches nothing, and runs nothing.
|
||||
*/
|
||||
const SHIP_TEMPLATES = {
|
||||
"ship-full": "ship-feature",
|
||||
"ship-quick": "default",
|
||||
};
|
||||
|
||||
async function cmdShip(command, args) {
|
||||
const templateId = SHIP_TEMPLATES[command];
|
||||
const { flags, positional } = parseArgs(args);
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
let { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const skill = pipelines.find((p) => p.id === templateId)?.skill;
|
||||
if (!skill) {
|
||||
console.error(
|
||||
`✖ pipeline template "${templateId}" declares no driving skill — ` +
|
||||
`add a "skill" field to it (see docs/LANES.md § Custom template format)`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// `resolveLaneArg` only strips a leading bare lane id; the rest of the
|
||||
// positionals are the requirement, joined back into one prompt line.
|
||||
const requirement = positional
|
||||
.filter((p) => p !== String(resolved.laneId))
|
||||
.join(" ")
|
||||
.trim();
|
||||
const prompt = requirement ? `${skill} ${requirement}` : skill;
|
||||
|
||||
if (flags.print || !requirement) {
|
||||
// `--print` shows what would run, so it switches nothing.
|
||||
if (!requirement) console.error(`usage: agenthub ${command} "<requirement>" [<id>] [--print]`);
|
||||
console.log(`cd ${lane.cwd} && claude ${JSON.stringify(prompt)}`);
|
||||
if (!requirement) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// The command picks the pipeline, not the lane's current setting: put the
|
||||
// lane on the matching template first so the dashboard renders the right
|
||||
// node count from the start rather than after the skill self-corrects.
|
||||
if (lane.pipeline !== templateId) {
|
||||
({ lane } = await patch(`/api/lanes/${lane.id}`, { pipeline: templateId }));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${c.dim(`lane #${lane.id} · pipeline ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`)}\n` +
|
||||
`${c.cyan("→")} claude ${JSON.stringify(prompt)}`
|
||||
);
|
||||
const child = spawn("claude", [prompt], { stdio: "inherit", cwd: lane.cwd });
|
||||
await new Promise((resolve) => {
|
||||
child.on("close", (code) => {
|
||||
if (code) process.exitCode = code;
|
||||
resolve();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
console.error(c.red(`✖ ${err?.message || err}`));
|
||||
process.exitCode = 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cmdFeatureShow(args) {
|
||||
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!slug) {
|
||||
@@ -2923,16 +2815,6 @@ const COMMAND_GROUPS = [
|
||||
"[<node-id>] [extra…] [<id>] [--print]",
|
||||
"Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)",
|
||||
],
|
||||
[
|
||||
"ship-full",
|
||||
'"<requirement>" [<id>] [--print]',
|
||||
"Run the 16-stage lane pipeline: implement → gates → e2e → review → QC → senior gate → PR, watched until a human merges (--print: show the command, run nothing)",
|
||||
],
|
||||
[
|
||||
"ship-quick",
|
||||
'"<requirement>" [<id>] [--print]',
|
||||
"Run the 8-stage in-repo pipeline: plan → implement → tests → review → gate → ship. No PR, no browser QC",
|
||||
],
|
||||
[
|
||||
"lanes shell",
|
||||
"[<id>]",
|
||||
@@ -2961,11 +2843,6 @@ const COMMAND_GROUPS = [
|
||||
"[<id>]",
|
||||
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
|
||||
],
|
||||
[
|
||||
"lanes agents install",
|
||||
"[<id>]",
|
||||
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
|
||||
],
|
||||
[
|
||||
"lanes mcp sync",
|
||||
"[<id>]",
|
||||
@@ -2986,11 +2863,6 @@ const COMMAND_GROUPS = [
|
||||
"[--dry-run]",
|
||||
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
|
||||
],
|
||||
[
|
||||
"skills install",
|
||||
"",
|
||||
"Install skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
|
||||
],
|
||||
[
|
||||
"lock status|acquire|release",
|
||||
"[<name>] [--holder X] [--timeout N]",
|
||||
@@ -3817,9 +3689,6 @@ async function runCommand(argv) {
|
||||
return cmdLanesRuntime(rest[0], rest.slice(1));
|
||||
}
|
||||
if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
|
||||
if (rest[0] === "agents" && rest[1] === "install") {
|
||||
return cmdLanesAgentsInstall(rest.slice(2));
|
||||
}
|
||||
if (rest[0] === "mcp" && rest[1] === "sync") {
|
||||
return cmdLanesMcpSync(rest.slice(2));
|
||||
}
|
||||
@@ -3842,12 +3711,6 @@ async function runCommand(argv) {
|
||||
return cmdLanesGc(rest.slice(1));
|
||||
}
|
||||
return cmdLanes();
|
||||
case "skills": {
|
||||
if (rest[0] === "install") return cmdSkillsInstall();
|
||||
console.error("usage: agenthub skills install");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
case "lock": {
|
||||
const sub = rest[0];
|
||||
if (sub === "status") return cmdLockStatus(rest.slice(1));
|
||||
@@ -3861,9 +3724,6 @@ async function runCommand(argv) {
|
||||
}
|
||||
case "stage":
|
||||
return cmdStage(rest);
|
||||
case "ship-full":
|
||||
case "ship-quick":
|
||||
return cmdShip(cmd, rest);
|
||||
case "feature": {
|
||||
const sub = rest[0];
|
||||
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
||||
|
||||
@@ -115,7 +115,6 @@ export function AddLaneModal({
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<{
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
agents: "ok" | "failed";
|
||||
mcp: "ok" | "failed";
|
||||
router: "ok" | "skipped" | "failed";
|
||||
} | null>(null);
|
||||
@@ -294,7 +293,7 @@ export function AddLaneModal({
|
||||
// POST /worktree returns as soon as the DB row exists (202) — the
|
||||
// actual `git worktree add` runs afterward, in the background, on the
|
||||
// server. Firing setup against the lane's cwd before that finishes
|
||||
// means agents/mcp write into a directory that doesn't exist yet, so
|
||||
// means mcp writes into a directory that doesn't exist yet, so
|
||||
// wait for provisioning to leave the "provisioning" status first.
|
||||
const provisionedLane = await waitForProvisioned(result.lane.id);
|
||||
if (!provisionedLane || provisionedLane.status === "failed") {
|
||||
@@ -303,9 +302,8 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
const [profileOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
api.lanes.profileInit(result.lane.id),
|
||||
api.lanes.agentsInstall(result.lane.id),
|
||||
api.lanes.mcpSync(result.lane.id),
|
||||
applyRouter(result.lane.id),
|
||||
]);
|
||||
@@ -316,7 +314,6 @@ export function AddLaneModal({
|
||||
? "scaffolded"
|
||||
: "skipped"
|
||||
: "failed",
|
||||
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
router:
|
||||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||||
@@ -328,7 +325,6 @@ export function AddLaneModal({
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||
// Never the profile itself: it would put an endpoint and a lane id in
|
||||
// the browser console. (The key is never sent here at all.)
|
||||
@@ -379,7 +375,7 @@ export function AddLaneModal({
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
/** branch name -> the worktree directory already holding it. git allows one
|
||||
* checkout per branch, so these are the branches this form cannot use. */
|
||||
@@ -429,6 +425,15 @@ export function AddLaneModal({
|
||||
label={t("addLaneToolCodex")}
|
||||
onClick={() => setTool("codex")}
|
||||
/>
|
||||
<Seg
|
||||
active={tool === "antigravity"}
|
||||
label={t("addLaneToolAntigravity")}
|
||||
onClick={() => {
|
||||
setTool("antigravity");
|
||||
// `agy` cannot run behind an AI Router (see router-apply.js).
|
||||
setRouter("native");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -553,7 +558,18 @@ export function AddLaneModal({
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
// No commit, nothing to fork: the server refuses this with
|
||||
// EEMPTYREPO, so the one way forward is offered instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("repo")}
|
||||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("addLaneNoBranchesUseRepo")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
@@ -624,7 +640,10 @@ export function AddLaneModal({
|
||||
hid it. The pipeline picker is gone entirely: every lane is created
|
||||
on `default` (the server's own default) and the template is changed
|
||||
from the lane's own header afterwards. */}
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2">
|
||||
<div
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
hidden={tool === "antigravity"}
|
||||
>
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
@@ -717,7 +736,6 @@ export function AddLaneModal({
|
||||
ok: setupResult.profile !== "failed",
|
||||
skipped: setupResult.profile === "skipped",
|
||||
},
|
||||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
{
|
||||
label: t("addLaneSetupRouter"),
|
||||
|
||||
@@ -271,7 +271,7 @@ export function since(sec: number | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check, and the
|
||||
* Repo tooling (MCP sync / dev-sync check, and the
|
||||
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
|
||||
* collapsed into a single dropdown. Lives outside LaneCard so it can be
|
||||
* placed in the Workspace page header, next to "Add lane" — it polls its
|
||||
@@ -281,16 +281,11 @@ export function since(sec: number | null): string {
|
||||
export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<
|
||||
"agents" | "mcp" | "sync-check" | "docs" | null
|
||||
>(null);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<"mcp" | "sync-check" | "docs" | null>(null);
|
||||
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||
const runtime = useLaneRuntime(lane.id, 0);
|
||||
|
||||
const runLaneAction = async (
|
||||
which: "agents" | "mcp" | "sync-check" | "docs",
|
||||
fn: () => Promise<string>
|
||||
) => {
|
||||
const runLaneAction = async (which: "mcp" | "sync-check" | "docs", fn: () => Promise<string>) => {
|
||||
setLaneActionBusy(which);
|
||||
setLaneActionResult(null);
|
||||
try {
|
||||
@@ -302,12 +297,6 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentsInstall = () =>
|
||||
runLaneAction("agents", async () => {
|
||||
const result = await api.lanes.agentsInstall(lane.id);
|
||||
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
||||
});
|
||||
|
||||
// A skill only exists inside a CLI session, so there is nothing server-side
|
||||
// to call: this opens the lane's own terminal run with the slash command as
|
||||
// its first message. Claude-only — codex has no slash commands.
|
||||
@@ -363,20 +352,7 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
role="menu"
|
||||
className="absolute right-0 z-10 mt-1 min-w-44 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-agents-install"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setToolsMenuOpen(false);
|
||||
void handleAgentsInstall();
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("actions.agentsInstall")}
|
||||
</button>
|
||||
{lane.tool !== "codex" && (
|
||||
{lane.tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -484,18 +460,11 @@ export default function LaneCard({
|
||||
// renders no frame at all rather than an empty box.
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
|
||||
>
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
{lane.ci_status && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{lane.ci_status && (
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
||||
CI {lane.ci_status}
|
||||
</span>
|
||||
)}
|
||||
{lane.tool === "codex" && (
|
||||
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
||||
Codex
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
||||
CI {lane.ci_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @file LaneConflictView.tsx
|
||||
* @description The Changes panel's view of ONE conflicted file during a merge
|
||||
* (issue #45): the file with each conflict block drawn as its two sides —
|
||||
* "mine" and "theirs", labelled from git's own markers and tinted apart — and
|
||||
* a Keep mine / Take theirs / Keep both choice per block, undoable. Save and
|
||||
* mark resolved writes the chosen text over exactly the version it opened
|
||||
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
|
||||
* `resolve done`, which re-checks that no marker is left. Take all mine /
|
||||
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
|
||||
* file to the panel's plain editor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
applyChoices,
|
||||
parseConflicts,
|
||||
type ConflictChoice,
|
||||
type ConflictPart,
|
||||
} from "../../lib/conflicts";
|
||||
|
||||
const BASE =
|
||||
"rounded border px-1.5 py-0.5 text-[11px] hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
const BTN = `${BASE} border-border-light text-fg-secondary hover:text-fg-primary`;
|
||||
|
||||
function Lines({ lines, className = "" }: { lines: string[]; className?: string }) {
|
||||
return (
|
||||
<pre className={`whitespace-pre px-2 font-mono text-[11px] leading-5 ${className}`}>
|
||||
{lines.length ? lines.join("\n") : " "}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneConflictView({
|
||||
laneId,
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
}: {
|
||||
laneId: number;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
/** Open the file in the panel's plain editor instead. */
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [file, setFile] = useState<{ lines: string[]; sha?: string; usable: boolean } | null>(null);
|
||||
const [choices, setChoices] = useState<(ConflictChoice | undefined)[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [at, setAt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setFile(null);
|
||||
setChoices([]);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.gitFile(laneId, path, "work")
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setFile(
|
||||
r.available
|
||||
? { lines: r.lines, sha: r.sha, usable: !r.binary && !r.truncated && !!r.sha }
|
||||
: { lines: [], usable: false }
|
||||
);
|
||||
})
|
||||
.catch((e) => live && setError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneId, path]);
|
||||
|
||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||
const chosen = choices.filter(Boolean).length;
|
||||
const done = total > 0 && chosen === total;
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
onResolved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () =>
|
||||
run(async () => {
|
||||
if (!parts || !file?.sha) return;
|
||||
await api.lanes.gitSaveFile(laneId, path, applyChoices(parts, choices).join("\n"), file.sha);
|
||||
await api.lanes.gitAction(laneId, "resolve", { path, side: "done" });
|
||||
});
|
||||
const takeAll = (side: "ours" | "theirs") =>
|
||||
run(() => api.lanes.gitAction(laneId, "resolve", { path, side }));
|
||||
|
||||
const choose = (i: number, c: ConflictChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = c;
|
||||
return next;
|
||||
});
|
||||
|
||||
const jump = (step: number) => {
|
||||
if (!total) return;
|
||||
const i = (at + step + total) % total;
|
||||
setAt(i);
|
||||
blockRefs.current[i]?.scrollIntoView({ block: "center" });
|
||||
};
|
||||
|
||||
let blockIndex = -1;
|
||||
return (
|
||||
<div data-testid="lane-conflict-view" className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
|
||||
{path}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
<span className="text-[11px] text-status-warning">
|
||||
{t("conflict.left", { left: total - chosen, total })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("conflict.prev")}
|
||||
disabled={!total}
|
||||
onClick={() => jump(-1)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("conflict.next")}
|
||||
disabled={!total}
|
||||
onClick={() => jump(1)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void takeAll("ours")}>
|
||||
{t("conflict.allOurs")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void takeAll("theirs")}
|
||||
>
|
||||
{t("conflict.allTheirs")}
|
||||
</button>
|
||||
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
||||
{t("conflict.editByHand")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-wrap text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!file ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !parts ? (
|
||||
<p className="text-[11px] text-status-warning">
|
||||
{file.usable ? t("conflict.unbalanced") : t("conflict.unusable")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0">
|
||||
{parts.map((p, n) => {
|
||||
if (p.kind === "text")
|
||||
return <Lines key={n} lines={p.lines} className="text-fg-primary" />;
|
||||
const i = ++blockIndex;
|
||||
const c = choices[i];
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
ref={(el) => {
|
||||
blockRefs.current[i] = el;
|
||||
}}
|
||||
data-testid="conflict-block"
|
||||
className="my-1 border-y-2 border-status-warning/70"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-2 py-0.5 text-[11px]">
|
||||
{c ? (
|
||||
<>
|
||||
<span className="text-fg-muted">{t(`conflict.chose.${c}`)}</span>
|
||||
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
||||
{t("conflict.undo")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BASE} border-status-success/70 font-medium text-status-success`}
|
||||
onClick={() => choose(i, "ours")}
|
||||
>
|
||||
{t("conflict.keepOurs")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BASE} border-accent/70 font-medium text-accent`}
|
||||
onClick={() => choose(i, "theirs")}
|
||||
>
|
||||
{t("conflict.takeTheirs")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => choose(i, "both")}>
|
||||
{t("conflict.keepBoth")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-fg-muted">
|
||||
{i + 1}/{total}
|
||||
</span>
|
||||
</div>
|
||||
{c ? (
|
||||
<Lines
|
||||
lines={
|
||||
c === "ours" ? p.ours : c === "theirs" ? p.theirs : [...p.ours, ...p.theirs]
|
||||
}
|
||||
className="bg-surface-2 text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-l-4 border-status-success bg-status-success/20">
|
||||
<div className="bg-status-success/30 px-2 py-0.5 text-[11px] font-semibold text-status-success">
|
||||
{t("conflict.ours", { label: p.oursLabel || "HEAD" })}
|
||||
</div>
|
||||
<Lines lines={p.ours} className="text-fg-primary" />
|
||||
</div>
|
||||
<div className="border-l-4 border-accent bg-accent/20">
|
||||
<div className="bg-accent/30 px-2 py-0.5 text-[11px] font-semibold text-accent">
|
||||
{t("conflict.theirs", { label: p.theirsLabel || "?" })}
|
||||
</div>
|
||||
<Lines lines={p.theirs} className="text-fg-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parts && total > 0 && (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy || !chosen}
|
||||
onClick={() => setChoices([])}
|
||||
>
|
||||
{t("conflict.reset")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !done}
|
||||
onClick={() => void save()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("conflict.save")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -273,10 +273,13 @@ function Gutter({ n }: { n: number | null }) {
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
anchor,
|
||||
children,
|
||||
}: {
|
||||
tint: string;
|
||||
style?: CSSProperties;
|
||||
/** This row starts change block `anchor` (file-wide): the bar's arrows land here. */
|
||||
anchor?: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -284,6 +287,7 @@ function Row({
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
data-change-index={anchor}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -305,40 +309,82 @@ function markerClass(type: LaneDiffLine["type"]) {
|
||||
return "text-fg-muted";
|
||||
}
|
||||
|
||||
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
|
||||
* last changed line. A context rather than a prop because lines render from
|
||||
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
|
||||
/** One change block: a run of consecutive +/- lines inside a hunk. git merges
|
||||
* edits a few lines apart into one `@@` hunk, so the review bar acts on these
|
||||
* instead (#50) — `block` is the index the server's `stage-hunk` /
|
||||
* `discard-hunk` take beside the hunk's header. */
|
||||
export type LaneDiffChange = {
|
||||
hunk: LaneDiffHunk;
|
||||
block: number;
|
||||
/** First/last index in `hunk.lines` (unified). */
|
||||
firstLine: number;
|
||||
lastLine: number;
|
||||
/** First/last split-view row. */
|
||||
firstRow: number;
|
||||
lastRow: number;
|
||||
};
|
||||
|
||||
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
||||
|
||||
/** A hunk's change blocks, cached per hunk object like `analyseHunk`. */
|
||||
export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
|
||||
const hit = blocksOf.get(hunk);
|
||||
if (hit) return hit;
|
||||
const out: LaneDiffChange[] = [];
|
||||
const owner = new Map<LaneDiffLine, LaneDiffChange>();
|
||||
hunk.lines.forEach((line, i) => {
|
||||
if (line.type === "ctx") return;
|
||||
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
||||
if (!c) {
|
||||
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
|
||||
out.push(c);
|
||||
}
|
||||
c.lastLine = i;
|
||||
owner.set(line, c);
|
||||
});
|
||||
analyseHunk(hunk).rows.forEach((r, i) => {
|
||||
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
|
||||
if (!c) return;
|
||||
if (c.firstRow < 0) c.firstRow = i;
|
||||
c.lastRow = i;
|
||||
});
|
||||
blocksOf.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The block whose last line (unified) / last row (split) is `i`, if any. */
|
||||
const barAtLine = (hunk: LaneDiffHunk, i: number) =>
|
||||
changeBlocks(hunk).find((c) => c.lastLine === i);
|
||||
const barAtRow = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.lastRow === i);
|
||||
const startAtLine = (hunk: LaneDiffHunk, i: number) =>
|
||||
changeBlocks(hunk).find((c) => c.firstLine === i);
|
||||
const startAtRow = (hunk: LaneDiffHunk, i: number) =>
|
||||
changeBlocks(hunk).find((c) => c.firstRow === i);
|
||||
|
||||
/** Per-block review controls (Undo / Keep), drawn in a bar under each change
|
||||
* block's last line. A context rather than a prop because lines render from
|
||||
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (hunk: LaneDiffHunk) => ReactNode;
|
||||
hunks: LaneDiffHunk[];
|
||||
actions: (change: LaneDiffChange) => ReactNode;
|
||||
changes: LaneDiffChange[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
|
||||
function lastChangedLine(hunk: LaneDiffHunk) {
|
||||
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
|
||||
if (hunk.lines[i]!.type !== "ctx") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function lastChangedRow(hunk: LaneDiffHunk) {
|
||||
const rows = analyseHunk(hunk).rows;
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const r = rows[i]!;
|
||||
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
|
||||
}
|
||||
return -1;
|
||||
/** File-wide index of `change`, for a row's `anchor`. */
|
||||
function useAnchor(change?: LaneDiffChange) {
|
||||
const ctx = useContext(HunkActions);
|
||||
return change && ctx ? ctx.changes.indexOf(change) : undefined;
|
||||
}
|
||||
|
||||
/** The review bar: hangs just BELOW the last changed line, pinned to the
|
||||
/** The review bar: hangs just BELOW a change block's last line, pinned to the
|
||||
* visible right edge, so it sits on the change it acts on — the editor-style
|
||||
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
|
||||
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
function HunkBar({ change }: { change: LaneDiffChange }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
const index = ctx.hunks.indexOf(hunk);
|
||||
const total = ctx.hunks.length;
|
||||
const index = ctx.changes.indexOf(change);
|
||||
const total = ctx.changes.length;
|
||||
const nav =
|
||||
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
|
||||
return (
|
||||
@@ -370,14 +416,13 @@ function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
<span className="px-0.5 tabular-nums text-fg-muted">
|
||||
{t("changes.hunkOf", { n: index + 1, total })}
|
||||
</span>
|
||||
{ctx.actions(hunk)}
|
||||
{ctx.actions(change)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
|
||||
const ctx = useContext(HunkActions);
|
||||
const at = hunk.header.split("@@")[1];
|
||||
// Windowed, the header is one ROW_PX row like the lines around it.
|
||||
return (
|
||||
@@ -387,7 +432,6 @@ function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
|
||||
}`}
|
||||
style={fixed ? FIXED : undefined}
|
||||
data-diff-item={fixed ? "" : undefined}
|
||||
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
@@ -403,16 +447,20 @@ function UnifiedLine({
|
||||
emph,
|
||||
style,
|
||||
bar,
|
||||
start,
|
||||
}: {
|
||||
line: LaneDiffLine;
|
||||
lang: string;
|
||||
emph?: [number, number];
|
||||
style?: CSSProperties;
|
||||
/** This is the hunk's last changed line: hang the review bar under it. */
|
||||
bar?: LaneDiffHunk;
|
||||
/** This is a change block's last line: hang its review bar under it. */
|
||||
bar?: LaneDiffChange;
|
||||
/** This is a change block's first line: the bar's arrows scroll here. */
|
||||
start?: LaneDiffChange;
|
||||
}) {
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style}>
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
@@ -421,14 +469,13 @@ function UnifiedLine({
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||||
const { emph } = analyseHunk(hunk);
|
||||
const last = lastChangedLine(hunk);
|
||||
return (
|
||||
<div>
|
||||
<HunkHeader hunk={hunk} />
|
||||
@@ -438,7 +485,8 @@ function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||||
line={line}
|
||||
lang={lang}
|
||||
emph={emph.get(i)}
|
||||
bar={i === last ? hunk : undefined}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -473,16 +521,19 @@ function SplitLine({
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
start,
|
||||
}: {
|
||||
row: PairRow;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffHunk;
|
||||
bar?: LaneDiffChange;
|
||||
start?: LaneDiffChange;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style}>
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
@@ -493,7 +544,7 @@ function SplitLine({
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -519,8 +570,9 @@ function SplitColumn({
|
||||
row={row}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per hunk: on the right (new) column only.
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
// One bar per change block: on the right (new) column only.
|
||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||
start={startAtRow(hunk, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -555,7 +607,8 @@ function SplitSlice({
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||
start={startAtRow(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -652,8 +705,8 @@ export function LaneDiffView({
|
||||
layout: DiffLayout;
|
||||
/** Take the parent's height (single-file view) instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Controls drawn in each hunk's review bar. */
|
||||
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
|
||||
/** Controls drawn in each change block's review bar. */
|
||||
hunkActions?: (change: LaneDiffChange) => ReactNode;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
@@ -672,12 +725,15 @@ export function LaneDiffView({
|
||||
[layout, hunks]
|
||||
);
|
||||
|
||||
// The bar's arrows: bring hunk `index`'s header into view. A windowed
|
||||
// unified diff may not have it in the DOM, so there it is found by offset.
|
||||
const changes = useMemo(() => (hunks ? hunks.flatMap(changeBlocks) : []), [hunks]);
|
||||
// The bar's arrows: bring change block `index`'s first line into view. A
|
||||
// windowed unified diff may not have it in the DOM, so there it is found by
|
||||
// offset.
|
||||
const go = (index: number) => {
|
||||
const el = root.current;
|
||||
if (!el || !hunks) return;
|
||||
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
|
||||
const target = changes[index];
|
||||
if (!el || !target) return;
|
||||
const header = el.querySelector<HTMLElement>(`[data-change-index="${index}"]`);
|
||||
// The review list pins each file's header over its own diff.
|
||||
const sticky = el
|
||||
.closest("[data-review-file]")
|
||||
@@ -688,10 +744,10 @@ export function LaneDiffView({
|
||||
return;
|
||||
}
|
||||
// Windowed: the header is not in the DOM, but its offset is known.
|
||||
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
|
||||
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
@@ -727,7 +783,8 @@ export function LaneDiffView({
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={i === lastChangedLine(hunk) ? hunk : undefined}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -751,12 +808,16 @@ export function LaneFileView({
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
onEditAt,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Set when the file is editable: a click on the text (0-based line, and
|
||||
* where the view was scrolled) asks to open the editor there. */
|
||||
onEditAt?: (line: number, scrollTop: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
@@ -765,9 +826,12 @@ export function LaneFileView({
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
||||
<span data-ln={i} className="contents">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
@@ -775,7 +839,27 @@ export function LaneFileView({
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
tabIndex={onEditAt ? 0 : undefined}
|
||||
onClick={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
// A click that ends a drag-selection is copying, not asking to edit.
|
||||
if (window.getSelection()?.toString()) return;
|
||||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
||||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
onKeyDown={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
onEditAt(0, e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
||||
onEditAt ? " cursor-text" : ""
|
||||
}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, and create/delete a branch. Committing is
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
* NOT here: it is the Changes panel's staged-only commit box, beside the
|
||||
* per-file stage/unstage/discard it commits — one way to commit, and it only
|
||||
* ever takes what the user staged.
|
||||
@@ -12,17 +15,41 @@
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write.
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CloudUpload,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type GitAuthNeeded } from "../../lib/api";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete";
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
|
||||
function Btn({
|
||||
label,
|
||||
@@ -85,6 +112,13 @@ export function LaneGitBar({
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// Set when git needed a login for `action`; the form resends that action.
|
||||
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
@@ -102,6 +136,10 @@ export function LaneGitBar({
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
@@ -109,21 +147,31 @@ export function LaneGitBar({
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: { branch?: string }) => {
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, body || {});
|
||||
const res = await api.lanes.gitAction(lane, action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
// A merge that stopped on conflicts still succeeded in starting; the
|
||||
// Changes panel below lists the files to resolve.
|
||||
if (res?.conflicts?.length)
|
||||
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
// The branch list this bar is showing is stale the moment a branch is
|
||||
// created, deleted or switched to, so it is re-read rather than patched.
|
||||
if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "publish") {
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
} else if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
@@ -136,7 +184,12 @@ export function LaneGitBar({
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
if (laneNow.current === lane)
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
} else if (laneNow.current === lane)
|
||||
setError(
|
||||
(err as { code?: string })?.code === "ENOREMOTE"
|
||||
? t("gitBar.errNoRemote")
|
||||
@@ -170,6 +223,11 @@ export function LaneGitBar({
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const merge = (branch: string) => {
|
||||
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
|
||||
void run("merge", { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
@@ -183,7 +241,11 @@ export function LaneGitBar({
|
||||
{/* Null counts with an upstream named means git could not compare
|
||||
the two (the upstream ref is gone): claiming "in sync" there
|
||||
would be a lie, so it reads as not pushed. */}
|
||||
{git.upstream && git.ahead != null && git.behind != null ? (
|
||||
{git.unborn ? (
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noCommits")}
|
||||
</span>
|
||||
) : git.upstream && git.ahead != null && git.behind != null ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
@@ -211,45 +273,88 @@ export function LaneGitBar({
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
|
||||
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!!busy || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
|
||||
onClick={() => merge(git.upstream as string)}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pushHint")}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A repo with no commit has nothing to push, fetch into or branch
|
||||
from — the one next step is the commit box below. */}
|
||||
{git.unborn ? (
|
||||
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
|
||||
) : (
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy || merging}
|
||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
// Same `push -u <remote> HEAD` either way; a branch with no
|
||||
// upstream is being published, not updated, so it says that.
|
||||
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={
|
||||
git.hasRemote && !git.upstream
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.publish")}
|
||||
icon={<CloudUpload className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => {
|
||||
setPublishing((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging")
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
@@ -281,6 +386,14 @@ export function LaneGitBar({
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
|
||||
onClick={() => merge(picked)}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
@@ -339,6 +452,102 @@ export function LaneGitBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publishing && !git.hasRemote && !git.unborn && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
|
||||
}}
|
||||
// The server decides which URLs are acceptable (https without a
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
placeholder={t("gitBar.publishUrl")}
|
||||
aria-label={t("gitBar.publishUrl")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.publishApply")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "publish"}
|
||||
disabled={!url.trim()}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => void run("publish", { url: url.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{login && (
|
||||
<form
|
||||
data-testid="lane-git-login"
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const { protocol, host } = login.auth;
|
||||
void run(login.action, {
|
||||
credentials: { protocol, host, username: username.trim(), password, remember },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-fg-primary">
|
||||
{t("gitBar.loginTitle", { host: login.auth.host })}
|
||||
</p>
|
||||
{login.auth.rejected && (
|
||||
<p role="alert" className="text-status-danger">
|
||||
{t("gitBar.loginRejected")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
aria-label={t("gitBar.loginUser")}
|
||||
placeholder={t("gitBar.loginUser")}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitBar.loginPassword")}
|
||||
placeholder={t("gitBar.loginPassword")}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
{t("gitBar.loginRemember")}
|
||||
</label>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
||||
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
||||
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
||||
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username.trim() || !password || !!busy}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitBar.loginSubmit")}
|
||||
</button>
|
||||
<Btn
|
||||
label={t("gitBar.loginCancel")}
|
||||
onClick={() => {
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||
|
||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
@@ -41,7 +42,7 @@ const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
* recordDetection refuses any detection that isn't strictly ahead.
|
||||
*/
|
||||
function detectionLeads(lane: Lane): boolean {
|
||||
if (!lane.detected_stage) return false;
|
||||
if (!lane.detected_stage || !lane.pipeline_nodes.length) return false;
|
||||
const nodes = lane.pipeline_nodes;
|
||||
return (
|
||||
nodes.findIndex((n) => n.id === lane.detected_stage) >=
|
||||
@@ -115,15 +116,18 @@ export default function LaneStripCard({
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||
#{lane.id}
|
||||
</span>
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
{lane.tool === "codex" && (
|
||||
{/* Only claude lanes carry a pipeline, and only while a template is
|
||||
installed; the others show their CLI's tag in place of the stage chip. */}
|
||||
{lane.tool !== "claude" || lane.pipeline_nodes.length === 0 ? (
|
||||
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
||||
Codex
|
||||
{LANE_TOOL_LABEL[lane.tool]}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
)}
|
||||
{detectionLeads(lane) && (
|
||||
{lane.tool === "claude" && detectionLeads(lane) && (
|
||||
<span
|
||||
data-testid={`lane-tile-auto-${lane.id}`}
|
||||
title={lane.detected_signal || undefined}
|
||||
|
||||
@@ -27,7 +27,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
browse: vi.fn(),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
@@ -120,7 +119,6 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.profileInit)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
|
||||
tool: "claude",
|
||||
@@ -305,6 +303,28 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: [],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/empty");
|
||||
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Title"), "demo");
|
||||
// There is nothing to branch from, so the worktree cannot be created.
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
@@ -361,7 +381,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||
it("fires profileInit and mcpSync after a successful worktree call", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
@@ -379,7 +399,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
|
||||
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
|
||||
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
|
||||
expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
|
||||
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
@@ -394,7 +413,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||
});
|
||||
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
|
||||
vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -460,7 +478,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
|
||||
expect(api.lanes.profileInit).not.toHaveBeenCalled();
|
||||
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
|
||||
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
syncBaseCheck: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
@@ -96,13 +95,16 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
}
|
||||
|
||||
describe("LaneCard lane tool badge", () => {
|
||||
it("badges a codex lane and leaves a claude lane unbadged", () => {
|
||||
const { rerender } = render(
|
||||
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
|
||||
);
|
||||
expect(screen.getByText("Codex")).toBeInTheDocument();
|
||||
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
|
||||
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
|
||||
// The strip tile already names the CLI; a card holding only that tag was an
|
||||
// empty frame in the lane detail.
|
||||
it("renders nothing for a codex or antigravity lane with nothing else to show", () => {
|
||||
for (const tool of ["codex", "antigravity"] as const) {
|
||||
const { container, unmount } = render(
|
||||
<LaneCard {...{ lane: makeLane({ tool }), onAction: vi.fn() }} />
|
||||
);
|
||||
expect(container.querySelector('[data-testid="lane-card-1"]')).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,17 +308,6 @@ describe("LaneToolsMenu", () => {
|
||||
} as unknown as LaneRuntime);
|
||||
});
|
||||
|
||||
it("clicking Install agents calls the API and shows the result", async () => {
|
||||
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
||||
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
||||
});
|
||||
render(<LaneToolsMenu lane={makeLane()} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-agents-install"));
|
||||
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
||||
});
|
||||
|
||||
it("clicking Sync MCP calls the API and shows the result", async () => {
|
||||
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
|
||||
servers: ["playwright"],
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -80,6 +80,13 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
||||
name: "Discard this file's changes",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
@@ -301,13 +308,108 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
// The sidebar IS the Files view: its finder lives there, and the tab is gone.
|
||||
expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// Opening a file only shows it: no editor until the text itself is clicked.
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
await user.click(view);
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(editor).toHaveFocus();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel drops the edit and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["copy me"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "copy",
|
||||
} as unknown as Selection);
|
||||
await user.click(view);
|
||||
spy.mockRestore();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
@@ -341,8 +443,7 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
|
||||
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
|
||||
const base = changes();
|
||||
@@ -540,9 +641,7 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
|
||||
await user.click(screen.getByTestId("lane-file-edit"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
await user.clear(editor);
|
||||
@@ -601,8 +700,7 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await user.click(screen.getByTestId("lane-file-edit"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
@@ -847,6 +945,59 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -9,2 +10,3 @@",
|
||||
block: 0,
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,4 +1,5 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
|
||||
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
|
||||
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 4, text: "new" },
|
||||
{ type: "ctx" as const, oldLine: 4, newLine: 5, text: "d" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
|
||||
const bars = screen.getAllByTestId("lane-hunk-bar");
|
||||
expect(bars.map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 2"),
|
||||
expect.stringContaining("2 of 2"),
|
||||
]);
|
||||
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,4 +1,5 @@",
|
||||
block: 1,
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
@@ -884,6 +1035,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,2 +1,3 @@",
|
||||
block: 0,
|
||||
context: 3,
|
||||
confirm: true,
|
||||
});
|
||||
@@ -1170,3 +1322,103 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a merge in progress (issue #45)", () => {
|
||||
const merging = (conflicts: number) => ({
|
||||
available: true as const,
|
||||
branch: "main",
|
||||
head: "abc1234",
|
||||
subject: "main",
|
||||
dirty: 1,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
merging: true,
|
||||
conflicts,
|
||||
});
|
||||
const conflicted = changes({
|
||||
files: [
|
||||
{
|
||||
path: "a.txt",
|
||||
status: "UU",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
unstaged: true,
|
||||
conflicted: true,
|
||||
added: 3,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
{
|
||||
path: "b.txt",
|
||||
status: "A ",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
|
||||
const group = await screen.findByTestId("lane-changes-conflicts");
|
||||
expect(within(group).getByText("a.txt")).toBeInTheDocument();
|
||||
// Not offered as an ordinary stage: that would mark it resolved blind.
|
||||
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
||||
|
||||
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "theirs",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a conflicted file in the conflict view, not the diff", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: ["<<<<<<< HEAD", "mine", "=======", "theirs", ">>>>>>> feat"],
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
await user.click(
|
||||
within(await screen.findByTestId("lane-changes-conflicts")).getByText("a.txt")
|
||||
);
|
||||
expect(await screen.findByTestId("lane-conflict-view")).toHaveTextContent("Theirs (feat)");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(3, "a.txt", "work");
|
||||
});
|
||||
|
||||
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Complete merge" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {}));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abort merge" }));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything());
|
||||
await user.click(screen.getByRole("button", { name: "Click again to abort the merge" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file Tests for the merge-conflict view: both sides of each block shown
|
||||
* under their own labels, one choice per block with undo, Save only once
|
||||
* every block is chosen — and Save writes the chosen text over the version
|
||||
* opened, then marks the file resolved. Whole-file ours/theirs reuse
|
||||
* `resolve`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneConflictView } from "../LaneConflictView";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
|
||||
}));
|
||||
|
||||
const LINES = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine",
|
||||
"=======",
|
||||
"theirs",
|
||||
">>>>>>> origin/main",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> origin/main",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitFile)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: LINES,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
vi.mocked(api.lanes.gitSaveFile)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ available: true } as never);
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ action: "resolve", ok: true, output: "" });
|
||||
});
|
||||
|
||||
const blocks = () => screen.getAllByTestId("conflict-block");
|
||||
|
||||
describe("LaneConflictView", () => {
|
||||
it("shows both sides under their labels and saves once every block is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
|
||||
|
||||
const [first, second] = (await screen.findAllByTestId("conflict-block")) as [
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
];
|
||||
expect(first).toHaveTextContent("Mine (HEAD)");
|
||||
expect(first).toHaveTextContent("Theirs (origin/main)");
|
||||
const save = screen.getByRole("button", { name: "Save and mark resolved" });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await user.click(within(first).getByRole("button", { name: "Keep mine" }));
|
||||
// Chosen: the block is plain text now, with an undo.
|
||||
await user.click(within(blocks()[0]!).getByRole("button", { name: "Undo" }));
|
||||
await user.click(within(blocks()[0]!).getByRole("button", { name: "Take theirs" }));
|
||||
await user.click(within(second).getByRole("button", { name: "Keep both" }));
|
||||
expect(save).toBeEnabled();
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
|
||||
3,
|
||||
"a.txt",
|
||||
"top\ntheirs\nmiddle\na\nb",
|
||||
"s1"
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "done",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes a whole side for the file through resolve", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
|
||||
await screen.findAllByTestId("conflict-block");
|
||||
await user.click(screen.getByRole("button", { name: "Take all theirs" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "theirs",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
expect(api.lanes.gitSaveFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the plain editor when the markers do not balance", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: ["<<<<<<< HEAD", "x"],
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
const onEdit = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={vi.fn()} onEdit={onEdit} />);
|
||||
await screen.findByText(/markers do not line up/);
|
||||
await user.click(screen.getByRole("button", { name: "Edit by hand" }));
|
||||
expect(onEdit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -422,8 +422,8 @@ describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 100 });
|
||||
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
|
||||
const k = Number(el.dataset.hunkIndex);
|
||||
for (const el of view.container.querySelectorAll<HTMLElement>("[data-change-index]")) {
|
||||
const k = Number(el.dataset.changeIndex);
|
||||
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
|
||||
}
|
||||
return scroller;
|
||||
@@ -454,6 +454,49 @@ describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hangs one bar under EACH +/- block of a single hunk, in both layouts (#50)", () => {
|
||||
const merged: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,6 +1,6 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
|
||||
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
|
||||
{ type: "del" as const, oldLine: 3, newLine: null, text: "c" },
|
||||
{ type: "ctx" as const, oldLine: 4, newLine: 3, text: "d" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 4, text: "E" },
|
||||
{ type: "ctx" as const, oldLine: 5, newLine: 5, text: "f" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const seen: number[] = [];
|
||||
for (const layout of ["unified", "split"] as const) {
|
||||
const { unmount } = render(
|
||||
<LaneDiffView
|
||||
file={merged}
|
||||
layout={layout}
|
||||
hunkActions={(c) => {
|
||||
seen.push(c.block);
|
||||
return <span />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId("lane-hunk-bar").map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 3"),
|
||||
expect.stringContaining("2 of 3"),
|
||||
expect.stringContaining("3 of 3"),
|
||||
]);
|
||||
unmount();
|
||||
}
|
||||
expect(seen).toEqual([0, 1, 2, 0, 1, 2]);
|
||||
});
|
||||
|
||||
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
|
||||
render(<LaneDiffView file={three} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press, that it offers no commit (the
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
@@ -24,6 +25,7 @@ function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}):
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
unborn: false,
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
@@ -79,6 +81,65 @@ describe("LaneGitBar", () => {
|
||||
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({
|
||||
branch: "main",
|
||||
head: null,
|
||||
unborn: true,
|
||||
subject: "",
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
})}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
|
||||
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a repo with no remote to a typed URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
||||
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
|
||||
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
|
||||
url: "git@host:owner/repo.git",
|
||||
})
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: null, ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Publish branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
@@ -255,3 +316,91 @@ describe("LaneGitBar", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git login (issue #45)", () => {
|
||||
const authErr = (rejected = false) =>
|
||||
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
|
||||
code: "EGITAUTH",
|
||||
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
|
||||
});
|
||||
|
||||
it("asks for a login when git needs one, then resends the same action with it", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
const form = await screen.findByTestId("lane-git-login");
|
||||
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
||||
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
||||
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
|
||||
credentials: {
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "s3cret",
|
||||
remember: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockRejectedValueOnce(authErr())
|
||||
.mockRejectedValueOnce(authErr(true));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await user.type(await screen.findByLabelText("Password or access token"), "bad");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
|
||||
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge (issue #45)", () => {
|
||||
it("merges the picked branch into the current one after a confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers to merge the upstream when the branch has diverged", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
|
||||
);
|
||||
});
|
||||
|
||||
it("disables pull and switch while a merge is in progress", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
CodexApproval,
|
||||
} from "../../lib/api";
|
||||
import type { Session, Lane, LaneTool } from "../../lib/types";
|
||||
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
@@ -149,7 +150,7 @@ export function LaneConsolePane({
|
||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
||||
const toolLabel = LANE_TOOL_LABEL[laneTool];
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
@@ -568,7 +569,12 @@ export function LaneConsolePane({
|
||||
{binaryStatus && !binaryStatus[laneTool]?.found && (
|
||||
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("binary.missing", { cli: laneTool, tool: toolLabel })}</span>
|
||||
<span>
|
||||
{t("binary.missing", {
|
||||
cli: laneTool === "antigravity" ? "agy" : laneTool,
|
||||
tool: toolLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* "Active runs" counts everything actually running in scope. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||
* from them for the same reason.
|
||||
|
||||
@@ -56,6 +56,7 @@ import type {
|
||||
CcMdItem,
|
||||
} from "../../lib/api";
|
||||
import type { Session, LaneTool } from "../../lib/types";
|
||||
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
|
||||
export interface SlashCommand {
|
||||
@@ -430,10 +431,13 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
{/* `agy` takes no model, permission or effort flag. */}
|
||||
{props.tool !== "antigravity" && (
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
)}
|
||||
{props.tool === "antigravity" ? null : props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
@@ -472,17 +476,19 @@ export function RunSetup(props: RunSetupProps) {
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
{props.tool !== "antigravity" && (
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
@@ -542,9 +548,9 @@ export function RunSetup(props: RunSetupProps) {
|
||||
value={props.prompt}
|
||||
onChange={props.onPromptChange}
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||
placeholder={t("fields.promptPlaceholderTerminal", { tool: LANE_TOOL_LABEL[props.tool] })}
|
||||
rows={5}
|
||||
slash={props.tool !== "codex"}
|
||||
slash={props.tool === "claude"}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-fg-muted">
|
||||
@@ -609,18 +615,18 @@ export function RunSetup(props: RunSetupProps) {
|
||||
function handleStart(props: RunSetupProps, session?: Session) {
|
||||
const resume = session ?? props.resumeSession;
|
||||
const codex = props.tool === "codex";
|
||||
const agy = props.tool === "antigravity";
|
||||
props.onStart({
|
||||
laneId: props.laneId,
|
||||
// A resume is pinned to its own session's folder — that's what the locked
|
||||
// cwd field shows, so it's what gets sent.
|
||||
cwd: resume?.cwd || props.cwd || undefined,
|
||||
model: props.model || undefined,
|
||||
permissionMode: codex ? undefined : props.permissionMode || undefined,
|
||||
model: agy ? undefined : props.model || undefined,
|
||||
permissionMode: codex || agy ? undefined : props.permissionMode || undefined,
|
||||
sandbox: codex ? props.sandbox : undefined,
|
||||
approval: codex ? props.approval : undefined,
|
||||
effort: props.effort || undefined,
|
||||
resumeSessionId: codex ? undefined : resume?.id || undefined,
|
||||
resumeLast: codex && !resume ? true : undefined,
|
||||
effort: agy ? undefined : props.effort || undefined,
|
||||
resumeSessionId: codex || agy ? undefined : resume?.id || undefined,
|
||||
// A resume continues a session that already has its own history — sending
|
||||
// whatever is sitting in the prompt box would fire an extra turn nobody
|
||||
// asked for, so a resume carries no prompt at all.
|
||||
|
||||
@@ -11,15 +11,23 @@
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -45,6 +53,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
const paletteRef = useRef(palette);
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { t } = useTranslation("run");
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// dragenter/dragleave fire for every child crossed, so count them: the hint
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -249,10 +293,44 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
className="relative h-full w-full"
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
{drop && (
|
||||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ function baseProps() {
|
||||
binaryStatus: {
|
||||
claude: { found: true, path: "/usr/local/bin/claude" },
|
||||
codex: { found: true, path: "/usr/local/bin/codex" },
|
||||
antigravity: { found: true, path: "/usr/local/bin/agy" },
|
||||
},
|
||||
cwdSuggestions: [],
|
||||
activeRuns: { items: [] },
|
||||
|
||||
@@ -380,7 +380,7 @@ describe("RunSetup — per-tool fields", () => {
|
||||
});
|
||||
|
||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
|
||||
const box = () => screen.getByPlaceholderText(/ anything…$/);
|
||||
|
||||
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
@@ -423,3 +423,26 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
expect(api.ccConfig.commands).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — antigravity lanes", () => {
|
||||
it("names the lane's CLI in the prompt box and drops the flags `agy` lacks", () => {
|
||||
const { spies } = renderSetup({ tool: "antigravity", model: "opus", effort: "high" });
|
||||
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
|
||||
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
|
||||
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: undefined, effort: undefined, permissionMode: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — codex lanes", () => {
|
||||
it("starts a fresh codex run instead of resuming the last one", () => {
|
||||
const { spies } = renderSetup({ tool: "codex" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledOnce();
|
||||
expect(spies.onStart.mock.calls[0][0].resumeLast).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -18,6 +18,11 @@ const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -61,6 +66,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
@@ -314,4 +320,31 @@ describe("TerminalView", () => {
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"action.stop": "stop",
|
||||
"actionError": "Lane action failed: {{message}}",
|
||||
"actionErrorUnknown": "Unknown error",
|
||||
"actions.agentsInstall": "Install agents",
|
||||
"actions.agentsInstallResult": "Installed: {{files}}",
|
||||
"actions.busy": "…",
|
||||
"actions.mcpSync": "Sync MCP",
|
||||
"actions.mcpSyncResult": "Synced: {{servers}}",
|
||||
@@ -32,7 +30,8 @@
|
||||
"addLaneBranchModeExisting": "Existing branch",
|
||||
"addLaneBranchModeNew": "New branch",
|
||||
"addLaneBranchPlaceholder": "Automatic — named from the title",
|
||||
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
||||
"addLaneNoBranches": "This repo has no commits yet, so a worktree has nothing to branch from. Add it as a Repo lane: commit first, then publish it from the Changes panel.",
|
||||
"addLaneNoBranchesUseRepo": "Use as a Repo lane",
|
||||
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
|
||||
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
|
||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
||||
@@ -48,7 +47,6 @@
|
||||
"addLaneRouterLoading": "Loading router profiles…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneSetupAgents": "Agents",
|
||||
"addLaneSetupClose": "Close",
|
||||
"addLaneSetupMcp": "MCP servers",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -58,6 +56,7 @@
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolAntigravity": "Antigravity",
|
||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} more",
|
||||
@@ -112,6 +111,9 @@
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
"changes.stagedGroup": "Staged Changes",
|
||||
"changes.unstagedGroup": "Changes",
|
||||
"changes.stage": "Stage this file",
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
@@ -178,7 +180,14 @@
|
||||
"gitBar.newLane": "New lane here",
|
||||
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
|
||||
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
|
||||
"gitBar.noCommits": "no commits yet",
|
||||
"gitBar.noUpstream": "not pushed",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish branch",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — this branch has no upstream yet",
|
||||
"gitBar.publishHint": "Add a remote named origin and push this branch to it (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "Remote URL: https://host/owner/repo.git or git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||
"gitBar.push": "Push",
|
||||
@@ -189,6 +198,7 @@
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
@@ -244,11 +254,58 @@
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.clickToEdit": "Click the text to edit",
|
||||
"changes.editorLabel": "Edit {{path}}",
|
||||
"changes.prevHunk": "Previous change",
|
||||
"changes.nextHunk": "Next change",
|
||||
"changes.hunkOf": "{{n}} of {{total}}",
|
||||
"changes.noEditBinary": "Binary file — not editable here",
|
||||
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
|
||||
"changes.noEdit": "Not editable here"
|
||||
"changes.noEdit": "Not editable here",
|
||||
"gitBar.loginTitle": "Git needs a login for {{host}}",
|
||||
"gitBar.loginRejected": "The server rejected that username or token.",
|
||||
"gitBar.loginUser": "Username",
|
||||
"gitBar.loginPassword": "Password or access token",
|
||||
"gitBar.loginRemember": "Remember for everyone using this AgentHub",
|
||||
"gitBar.loginTokenHint": "On Gitea: Settings → Applications → Generate token, with repository read/write.",
|
||||
"gitBar.loginInsecure": "This dashboard is on plain http — the token crosses the network unencrypted.",
|
||||
"gitBar.loginSubmit": "Log in and retry",
|
||||
"gitBar.loginCancel": "Cancel",
|
||||
"gitBar.merge": "Merge into current",
|
||||
"gitBar.mergeHint": "Merge the selected branch into the branch this lane is on",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this lane change now — if an agent is working in this lane, let it finish first.",
|
||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
||||
"gitBar.merging": "A merge is in progress: finish or abort it first",
|
||||
"changes.conflictsGroup": "Merge conflicts",
|
||||
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "Merge in progress — every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete merge",
|
||||
"changes.mergeAbort": "Abort merge",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
||||
"changes.takeOurs": "Keep mine",
|
||||
"changes.takeTheirs": "Take theirs",
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
||||
"conflict.left": "{{left}} of {{total}} conflicts left",
|
||||
"conflict.prev": "Previous conflict",
|
||||
"conflict.next": "Next conflict",
|
||||
"conflict.allOurs": "Keep all mine",
|
||||
"conflict.allTheirs": "Take all theirs",
|
||||
"conflict.editByHand": "Edit by hand",
|
||||
"conflict.unbalanced": "The conflict markers do not line up (edited by hand?) — use Edit by hand.",
|
||||
"conflict.unusable": "This file cannot be shown here (binary or too large) — use Keep all mine / Take all theirs, or a terminal.",
|
||||
"conflict.keepOurs": "Keep mine",
|
||||
"conflict.takeTheirs": "Take theirs",
|
||||
"conflict.keepBoth": "Keep both",
|
||||
"conflict.chose.ours": "Kept mine",
|
||||
"conflict.chose.theirs": "Took theirs",
|
||||
"conflict.chose.both": "Kept both",
|
||||
"conflict.undo": "Undo",
|
||||
"conflict.ours": "Mine ({{label}})",
|
||||
"conflict.theirs": "Theirs ({{label}})",
|
||||
"conflict.reset": "Cancel",
|
||||
"conflict.save": "Save and mark resolved",
|
||||
"changes.takeOursHint": "Keep your version of the whole file",
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file"
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"permissionPlan": "plan (read-only planning)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Ask Claude anything…",
|
||||
"promptPlaceholderTerminal": "Ask Claude anything…",
|
||||
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
|
||||
"sandbox": "Sandbox",
|
||||
"sandboxFullAccess": "Full access (dangerous)",
|
||||
"sandboxReadOnly": "Read only",
|
||||
@@ -164,5 +164,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop image to attach",
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,7 @@
|
||||
},
|
||||
"lanesSection": {
|
||||
"title": "Lanes",
|
||||
"description": "Machine-wide housekeeping and one-time setup for the ship-feature-lane pipeline.",
|
||||
"installSkill": "Install ship-feature-lane skill",
|
||||
"installSkillSuccess": "Installed to {{path}}",
|
||||
"description": "Machine-wide lane housekeeping.",
|
||||
"runHousekeeping": "Run housekeeping",
|
||||
"dryRun": "Dry run",
|
||||
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
|
||||
@@ -452,7 +450,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -509,5 +508,23 @@
|
||||
"lightThemes": "Light themes",
|
||||
"active": "· in use now",
|
||||
"default": "Default"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push — one per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
||||
"add": "Add login",
|
||||
"replace": "Replace token",
|
||||
"remove": "Delete",
|
||||
"removeConfirm": "Delete this git login? Dashboard fetch/pull/push to that host will ask for a login again.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"fieldHost": "Server URL",
|
||||
"fieldUser": "Username",
|
||||
"fieldToken": "Password or access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Username",
|
||||
"colSaved": "Saved",
|
||||
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
|
||||
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"action.stop": "Dừng",
|
||||
"actionError": "Thao tác làn đường thất bại: {{message}}",
|
||||
"actionErrorUnknown": "Lỗi không xác định",
|
||||
"actions.agentsInstall": "Cài agent",
|
||||
"actions.agentsInstallResult": "Đã cài: {{files}}",
|
||||
"actions.busy": "…",
|
||||
"actions.mcpSync": "Đồng bộ MCP",
|
||||
"actions.mcpSyncResult": "Đã đồng bộ: {{servers}}",
|
||||
@@ -32,7 +30,8 @@
|
||||
"addLaneBranchModeExisting": "Nhánh có sẵn",
|
||||
"addLaneBranchModeNew": "Nhánh mới",
|
||||
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào nên worktree không có gì để rẽ nhánh. Hãy thêm nó thành lane Repo: commit trước, rồi publish từ bảng Changes.",
|
||||
"addLaneNoBranchesUseRepo": "Dùng làm lane Repo",
|
||||
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
|
||||
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
|
||||
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
|
||||
@@ -48,7 +47,6 @@
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneSetupAgents": "Agent",
|
||||
"addLaneSetupClose": "Đóng",
|
||||
"addLaneSetupMcp": "MCP server",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -58,6 +56,7 @@
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolAntigravity": "Antigravity",
|
||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||
@@ -112,6 +111,9 @@
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
"changes.stagedGroup": "Đã stage",
|
||||
"changes.unstagedGroup": "Thay đổi",
|
||||
"changes.stage": "Đưa file này vào index",
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
@@ -178,7 +180,14 @@
|
||||
"gitBar.newLane": "Tạo lane mới",
|
||||
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
|
||||
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
|
||||
"gitBar.noCommits": "chưa có commit",
|
||||
"gitBar.noUpstream": "chưa push",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish nhánh",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — nhánh này chưa có upstream",
|
||||
"gitBar.publishHint": "Thêm remote tên origin rồi push nhánh này lên đó (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "URL remote: https://host/owner/repo.git hoặc git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||
"gitBar.push": "Push",
|
||||
@@ -189,6 +198,7 @@
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
@@ -244,11 +254,58 @@
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.clickToEdit": "Bấm vào nội dung để sửa",
|
||||
"changes.editorLabel": "Sửa {{path}}",
|
||||
"changes.prevHunk": "Thay đổi trước",
|
||||
"changes.nextHunk": "Thay đổi sau",
|
||||
"changes.hunkOf": "{{n}} / {{total}}",
|
||||
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
|
||||
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
|
||||
"changes.noEdit": "Không sửa được ở đây"
|
||||
"changes.noEdit": "Không sửa được ở đây",
|
||||
"gitBar.loginTitle": "Git cần đăng nhập {{host}}",
|
||||
"gitBar.loginRejected": "Máy chủ từ chối (rejected) tài khoản hoặc token này.",
|
||||
"gitBar.loginUser": "Tên đăng nhập",
|
||||
"gitBar.loginPassword": "Mật khẩu hoặc access token",
|
||||
"gitBar.loginRemember": "Ghi nhớ cho mọi người dùng AgentHub",
|
||||
"gitBar.loginTokenHint": "Trên Gitea: Settings → Applications → Generate token, cấp quyền đọc/ghi repository.",
|
||||
"gitBar.loginInsecure": "Dashboard đang chạy http thường - token sẽ đi qua mạng không mã hoá.",
|
||||
"gitBar.loginSubmit": "Đăng nhập và thử lại",
|
||||
"gitBar.loginCancel": "Huỷ",
|
||||
"gitBar.merge": "Merge vào nhánh hiện tại",
|
||||
"gitBar.mergeHint": "Merge nhánh đang chọn vào nhánh lane này đang đứng",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong lane sẽ thay đổi ngay - nếu có agent đang làm việc trong lane này, hãy để nó xong trước.",
|
||||
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
|
||||
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
|
||||
"changes.conflictsGroup": "Conflict khi merge",
|
||||
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất merge",
|
||||
"changes.mergeAbort": "Huỷ merge",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
||||
"changes.takeOurs": "Giữ bản của mình",
|
||||
"changes.takeTheirs": "Lấy bản kia",
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
||||
"conflict.left": "Còn {{left}}/{{total}} khối conflict",
|
||||
"conflict.prev": "Khối trước",
|
||||
"conflict.next": "Khối sau",
|
||||
"conflict.allOurs": "Giữ hết của mình",
|
||||
"conflict.allTheirs": "Lấy hết bản kia",
|
||||
"conflict.editByHand": "Sửa tay",
|
||||
"conflict.unbalanced": "Dấu conflict không khớp nhau (đã sửa tay?) - hãy dùng Sửa tay.",
|
||||
"conflict.unusable": "Không hiển thị được file này ở đây (nhị phân hoặc quá lớn) - hãy dùng Giữ hết của mình / Lấy hết bản kia, hoặc terminal.",
|
||||
"conflict.keepOurs": "Giữ của mình",
|
||||
"conflict.takeTheirs": "Lấy bản kia",
|
||||
"conflict.keepBoth": "Giữ cả hai",
|
||||
"conflict.chose.ours": "Đã giữ của mình",
|
||||
"conflict.chose.theirs": "Đã lấy bản kia",
|
||||
"conflict.chose.both": "Đã giữ cả hai",
|
||||
"conflict.undo": "Hoàn tác",
|
||||
"conflict.ours": "Của mình ({{label}})",
|
||||
"conflict.theirs": "Bản kia ({{label}})",
|
||||
"conflict.reset": "Huỷ",
|
||||
"conflict.save": "Lưu và đánh dấu đã xong",
|
||||
"changes.takeOursHint": "Giữ bản của mình cho cả file",
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
|
||||
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…",
|
||||
"promptPlaceholderTerminal": "Hỏi {{tool}} bất cứ điều gì…",
|
||||
"sandbox": "Sandbox",
|
||||
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
|
||||
"sandboxReadOnly": "Chỉ đọc",
|
||||
@@ -163,5 +163,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả ảnh để đính kèm",
|
||||
"uploading": "Đang tải ảnh lên…",
|
||||
"failed": "Không đính kèm được ảnh",
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,7 @@
|
||||
},
|
||||
"lanesSection": {
|
||||
"title": "Lanes",
|
||||
"description": "Bảo trì toàn máy và cài đặt một lần cho pipeline ship-feature-lane.",
|
||||
"installSkill": "Cài skill ship-feature-lane",
|
||||
"installSkillSuccess": "Đã cài vào {{path}}",
|
||||
"description": "Dọn dẹp lane trên toàn máy.",
|
||||
"runHousekeeping": "Chạy dọn dẹp",
|
||||
"dryRun": "Chạy thử (dry run)",
|
||||
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
|
||||
@@ -452,7 +450,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -509,5 +508,23 @@
|
||||
"lightThemes": "Theme sáng",
|
||||
"active": "· đang dùng",
|
||||
"default": "Mặc định"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Tài khoản AgentHub dùng cho git khi fetch, pull, push từ dashboard - mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
|
||||
"add": "Thêm tài khoản",
|
||||
"replace": "Thay token",
|
||||
"remove": "Xoá",
|
||||
"removeConfirm": "Xoá tài khoản git này? Fetch/pull/push tới host đó từ dashboard sẽ hỏi đăng nhập lại.",
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"fieldHost": "URL máy chủ",
|
||||
"fieldUser": "Tên đăng nhập",
|
||||
"fieldToken": "Mật khẩu hoặc access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Tên đăng nhập",
|
||||
"colSaved": "Đã lưu",
|
||||
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
|
||||
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file Tests for the merge-conflict parser behind the Changes panel's
|
||||
* conflict view: plain and diff3 blocks, the two side labels, applying a
|
||||
* choice per block, and refusing a file whose markers do not balance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConflicts, applyChoices } from "../conflicts";
|
||||
|
||||
const FILE = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine 1",
|
||||
"mine 2",
|
||||
"=======",
|
||||
"theirs 1",
|
||||
">>>>>>> origin/main",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"||||||| base",
|
||||
"old",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> feat",
|
||||
"end",
|
||||
];
|
||||
|
||||
describe("parseConflicts", () => {
|
||||
it("splits text from conflict blocks and reads both labels", () => {
|
||||
const parts = parseConflicts(FILE)!;
|
||||
expect(parts.map((p) => p.kind)).toEqual(["text", "conflict", "text", "conflict", "text"]);
|
||||
expect(parts[1]).toMatchObject({
|
||||
ours: ["mine 1", "mine 2"],
|
||||
theirs: ["theirs 1"],
|
||||
oursLabel: "HEAD",
|
||||
theirsLabel: "origin/main",
|
||||
});
|
||||
// diff3: the base section is not either side.
|
||||
expect(parts[3]).toMatchObject({ ours: ["a"], theirs: ["b"], theirsLabel: "feat" });
|
||||
});
|
||||
|
||||
it("returns an empty list of blocks for a file without markers", () => {
|
||||
expect(parseConflicts(["a", "b"])).toEqual([{ kind: "text", lines: ["a", "b"] }]);
|
||||
});
|
||||
|
||||
it("refuses markers that do not balance", () => {
|
||||
expect(parseConflicts(["<<<<<<< HEAD", "x", "======="])).toBeNull();
|
||||
expect(parseConflicts(["x", ">>>>>>> feat"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyChoices", () => {
|
||||
it("writes each block as the chosen side, both sides in order for 'both'", () => {
|
||||
const parts = parseConflicts(FILE)!;
|
||||
expect(applyChoices(parts, ["ours", "both"])).toEqual([
|
||||
"top",
|
||||
"mine 1",
|
||||
"mine 2",
|
||||
"middle",
|
||||
"a",
|
||||
"b",
|
||||
"end",
|
||||
]);
|
||||
expect(applyChoices(parts, ["theirs", "theirs"])).toEqual([
|
||||
"top",
|
||||
"theirs 1",
|
||||
"middle",
|
||||
"b",
|
||||
"end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+72
-12
@@ -539,6 +539,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||
code: body?.error?.code,
|
||||
status: res.status,
|
||||
// EGITAUTH from a lane git action: which host wants a login.
|
||||
auth: body?.error?.auth as GitAuthNeeded | undefined,
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
@@ -1128,8 +1130,6 @@ export const api = {
|
||||
purged_events: number;
|
||||
purged_agents: number;
|
||||
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
||||
skillsInstall: () =>
|
||||
request<{ installed: true; path: string }>("/skills/install", { method: "POST", body: "{}" }),
|
||||
gc: (body: { dryRun?: boolean } = {}) =>
|
||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
|
||||
method: "POST",
|
||||
@@ -1921,6 +1921,24 @@ export const api = {
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
/** AgentHub's own git credentials (issue #45), shared by every user of this
|
||||
* install. No response ever carries a password — only `hasToken`. */
|
||||
gitCredentials: {
|
||||
/** GET /api/git-credentials */
|
||||
list: () => request<{ items: GitCredential[] }>("/git-credentials"),
|
||||
/** POST /api/git-credentials - save, or replace the one for that host. */
|
||||
save: (body: GitCredentialInput) =>
|
||||
request<{ items: GitCredential[] }>("/git-credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** DELETE /api/git-credentials/:protocol/:host */
|
||||
remove: (protocol: string, host: string) =>
|
||||
request<{ items: GitCredential[] }>(
|
||||
`/git-credentials/${encodeURIComponent(protocol)}/${encodeURIComponent(host)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
@@ -1982,6 +2000,14 @@ export const api = {
|
||||
if (q) qs.set("q", q);
|
||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||
},
|
||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||
* server's machine; the returned path is what gets pasted into the pane. */
|
||||
dropImage: (file: Blob) =>
|
||||
request<{ path: string }>("/run/drop-image", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -2505,12 +2531,13 @@ export const api = {
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
|
||||
* `branch-delete`.
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
|
||||
* `branch-delete` or `publish`.
|
||||
* @param body `{message, only}` for commit; `{branch, create}` for switch;
|
||||
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
|
||||
* `{paths, confirm: true}` for discard — which is irreversible, so the
|
||||
* server refuses it without that flag.
|
||||
* server refuses it without that flag; `{url}` for publish (a repo with
|
||||
* no remote yet: adds `origin` and pushes HEAD with an upstream).
|
||||
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
|
||||
*/
|
||||
gitAction: (
|
||||
@@ -2527,8 +2554,14 @@ export const api = {
|
||||
| "stage-hunk"
|
||||
| "discard-hunk"
|
||||
| "branch-create"
|
||||
| "branch-delete",
|
||||
| "branch-delete"
|
||||
| "publish"
|
||||
| "merge"
|
||||
| "resolve"
|
||||
| "merge-commit"
|
||||
| "merge-abort",
|
||||
body: {
|
||||
url?: string;
|
||||
message?: string;
|
||||
only?: "staged";
|
||||
branch?: string;
|
||||
@@ -2537,11 +2570,18 @@ export const api = {
|
||||
paths?: string[];
|
||||
path?: string;
|
||||
hunk?: string;
|
||||
/** One change block (a run of +/- lines) inside `hunk`, by index. */
|
||||
block?: number;
|
||||
context?: number;
|
||||
confirm?: boolean;
|
||||
/** resolve: which side wins, or `done` for a hand-edited file. */
|
||||
side?: "ours" | "theirs" | "done";
|
||||
/** fetch/pull/push after `EGITAUTH`: used for this one command, saved
|
||||
* only when git accepted it and `remember` is set. */
|
||||
credentials?: GitCredentialInput & { remember: boolean };
|
||||
} = {}
|
||||
) =>
|
||||
request<{ action: string; ok: true; output: string; branch?: string }>(
|
||||
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
|
||||
`/lanes/${id}/git/${action}`,
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
@@ -2677,11 +2717,6 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
agentsInstall: (id: number) =>
|
||||
request<{ installed: string[] }>(`/lanes/${id}/agents/install`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/**
|
||||
* POST /lanes/:id/router/apply - point this lane at an AI Router profile,
|
||||
* or back at `native`. Writes the lane's own config on disk; only the
|
||||
@@ -2903,6 +2938,31 @@ export interface AiRouterModels {
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
/** A saved git login, as the server reports it: never the password. */
|
||||
export interface GitCredential {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string;
|
||||
savedAt: string;
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
export interface GitCredentialInput {
|
||||
protocol: string;
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** The `auth` block of a 409 `EGITAUTH`: the host git needed a login for. */
|
||||
export interface GitAuthNeeded {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string | null;
|
||||
/** true when a login WAS sent and the host refused it. */
|
||||
rejected: boolean;
|
||||
}
|
||||
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Parses git's merge-conflict markers out of a file's lines, and writes
|
||||
* the file back with one side (or both) chosen per block — the model behind
|
||||
* the Changes panel's conflict view. Handles diff3 style (`|||||||` base
|
||||
* section, dropped: it is neither side). Pure, no React.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type ConflictChoice = "ours" | "theirs" | "both";
|
||||
|
||||
export type ConflictPart =
|
||||
| { kind: "text"; lines: string[] }
|
||||
| { kind: "conflict"; ours: string[]; theirs: string[]; oursLabel: string; theirsLabel: string };
|
||||
|
||||
const OPEN = /^<{7}(?: (.*))?$/;
|
||||
const BASE = /^\|{7}(?: .*)?$/;
|
||||
const MID = /^={7}$/;
|
||||
const CLOSE = /^>{7}(?: (.*))?$/;
|
||||
|
||||
/**
|
||||
* The file as text runs and conflict blocks, or null when its markers do not
|
||||
* balance (a hand edit left half a block) — the caller falls back to the
|
||||
* plain editor then rather than guess.
|
||||
*/
|
||||
export function parseConflicts(lines: string[]): ConflictPart[] | null {
|
||||
const parts: ConflictPart[] = [];
|
||||
let text: string[] = [];
|
||||
let block: { ours: string[]; theirs: string[]; oursLabel: string } | null = null;
|
||||
let section: "ours" | "base" | "theirs" = "ours";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!block) {
|
||||
const open = line.match(OPEN);
|
||||
if (open) {
|
||||
if (text.length) parts.push({ kind: "text", lines: text });
|
||||
text = [];
|
||||
block = { ours: [], theirs: [], oursLabel: open[1] ?? "" };
|
||||
section = "ours";
|
||||
} else if (CLOSE.test(line) || MID.test(line)) {
|
||||
return null;
|
||||
} else {
|
||||
text.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (section === "ours" && BASE.test(line)) section = "base";
|
||||
else if (section !== "theirs" && MID.test(line)) section = "theirs";
|
||||
else if (section === "theirs" && CLOSE.test(line)) {
|
||||
const close = line.match(CLOSE)!;
|
||||
parts.push({ kind: "conflict", ...block, theirsLabel: close[1] ?? "" });
|
||||
block = null;
|
||||
} else if (OPEN.test(line)) return null;
|
||||
else if (section === "ours") block.ours.push(line);
|
||||
else if (section === "theirs") block.theirs.push(line);
|
||||
}
|
||||
if (block) return null;
|
||||
if (text.length || !parts.length) parts.push({ kind: "text", lines: text });
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The file's lines with block `i` replaced by `choices[i]`; an unchosen block keeps its markers. */
|
||||
export function applyChoices(
|
||||
parts: ConflictPart[],
|
||||
choices: (ConflictChoice | undefined)[]
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
for (const p of parts) {
|
||||
if (p.kind === "text") {
|
||||
out.push(...p.lines);
|
||||
continue;
|
||||
}
|
||||
const c = choices[i++];
|
||||
if (c === "ours") out.push(...p.ours);
|
||||
else if (c === "theirs") out.push(...p.theirs);
|
||||
else if (c === "both") out.push(...p.ours, ...p.theirs);
|
||||
else
|
||||
out.push(
|
||||
`<<<<<<< ${p.oursLabel}`.trimEnd(),
|
||||
...p.ours,
|
||||
"=======",
|
||||
...p.theirs,
|
||||
`>>>>>>> ${p.theirsLabel}`.trimEnd()
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+18
-4
@@ -2410,7 +2410,14 @@ export interface ProofFeature {
|
||||
}
|
||||
|
||||
/** Which CLI drives a lane's pane. Fixed at lane creation and never patched. */
|
||||
export type LaneTool = "claude" | "codex";
|
||||
export type LaneTool = "claude" | "codex" | "antigravity";
|
||||
|
||||
/** How each CLI is named on screen (strip tag, console title, prompt box). */
|
||||
export const LANE_TOOL_LABEL: Record<LaneTool, string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
};
|
||||
|
||||
export interface Lane {
|
||||
id: number;
|
||||
@@ -2437,6 +2444,7 @@ export interface Lane {
|
||||
gate_decision: string | null;
|
||||
ci_status: string | null;
|
||||
needs_action: string | null;
|
||||
needs_action_session?: string | null;
|
||||
links: Record<string, string>;
|
||||
stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>;
|
||||
notes: string | null;
|
||||
@@ -2584,9 +2592,11 @@ export type LaneGitFacts =
|
||||
available: true;
|
||||
/** Branch name, or the literal `HEAD` for a detached checkout. */
|
||||
branch: string;
|
||||
/** Short commit hash of HEAD. */
|
||||
head: string;
|
||||
/** Subject line of the HEAD commit. */
|
||||
/** Short commit hash of HEAD; null in a repo with no commit yet. */
|
||||
head: string | null;
|
||||
/** True when the repo has no commit yet (a fresh `git init`). */
|
||||
unborn?: boolean;
|
||||
/** Subject line of the HEAD commit; "" when unborn. */
|
||||
subject: string;
|
||||
/** Tracked files with uncommitted changes. */
|
||||
dirty: number;
|
||||
@@ -2603,6 +2613,10 @@ export type LaneGitFacts =
|
||||
ahead: number | null;
|
||||
/** Commits the upstream has that HEAD does not; null without an upstream. */
|
||||
behind: number | null;
|
||||
/** A merge is in progress (issue #45). Absent on an older server. */
|
||||
merging?: boolean;
|
||||
/** Files the merge still holds unmerged, repo-wide. */
|
||||
conflicts?: number;
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @file GitCredentialsConfig.tsx
|
||||
* @description The **Git credentials** tab of Settings (issue #45) — the
|
||||
* logins AgentHub itself gives git for a dashboard fetch/pull/push, one per
|
||||
* host, shared by everyone using this install. Lists them, replaces a token,
|
||||
* adds one, deletes one. A token is write-only here: the server never returns
|
||||
* one, so the form starts blank and the list shows only that one is saved.
|
||||
* The git bar's login form saves into the same store.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { api, type GitCredential } from "../lib/api";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
|
||||
export function GitCredentialsConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<GitCredential[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await api.gitCredentials.list()).items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return;
|
||||
setError(null);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(form.url.trim());
|
||||
} catch {
|
||||
setError(t("gitCreds.badUrl"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.gitCredentials.save({
|
||||
protocol: url.protocol.replace(/:$/, ""),
|
||||
host: url.host,
|
||||
username: form.username.trim(),
|
||||
password: form.token,
|
||||
});
|
||||
setItems(r.items);
|
||||
setForm(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (c: GitCredential) => {
|
||||
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
|
||||
try {
|
||||
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" />
|
||||
{t("gitCreds.title")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ url: "", username: "", token: "" })}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitCreds.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldHost")}
|
||||
placeholder="https://git.example.com"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldUser")}
|
||||
placeholder={t("gitCreds.fieldUser")}
|
||||
autoComplete="off"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitCreds.fieldToken")}
|
||||
placeholder={t("gitCreds.fieldToken")}
|
||||
autoComplete="new-password"
|
||||
value={form.token}
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!form.url.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(null)}
|
||||
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colHost")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colUser")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colSaved")}</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((c) => (
|
||||
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
{new Date(c.savedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitCreds.replace")} ${c.host}`}
|
||||
title={t("gitCreds.replace")}
|
||||
onClick={() =>
|
||||
setForm({
|
||||
url: `${c.protocol}://${c.host}`,
|
||||
username: c.username,
|
||||
token: "",
|
||||
})
|
||||
}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitCreds.remove")} ${c.host}`}
|
||||
title={t("gitCreds.remove")}
|
||||
onClick={() => void remove(c)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
|
||||
{t("gitCreds.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -699,12 +699,6 @@ export function Settings() {
|
||||
return res.ok ? t("hooks.success") : t("hooks.failed");
|
||||
});
|
||||
|
||||
const handleSkillsInstall = () =>
|
||||
runAction("skills-install", async () => {
|
||||
const res = await api.settings.skillsInstall();
|
||||
return t("lanesSection.installSkillSuccess", { path: res.path });
|
||||
});
|
||||
|
||||
const handleGc = () =>
|
||||
runAction("gc", async () => {
|
||||
const res = await api.settings.gc({ dryRun: gcDryRun });
|
||||
@@ -1354,23 +1348,6 @@ export function Settings() {
|
||||
<p className="text-xs text-fg-muted mb-4">{t("lanesSection.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<span className="text-xs text-fg-secondary">{t("lanesSection.installSkill")}</span>
|
||||
<button
|
||||
onClick={handleSkillsInstall}
|
||||
disabled={actionLoading !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "skills-install" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("lanesSection.installSkill")}
|
||||
</button>
|
||||
</div>
|
||||
{actionBanner(["skills-install"])}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at, with the active tab kept in the `?tool=` query param.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Boxes, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { Boxes, KeyRound, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
@@ -33,6 +34,12 @@ const TOOL_TABS: ToolTab[] = [
|
||||
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
{
|
||||
id: "git-credentials",
|
||||
labelKey: "tools.gitCredentials",
|
||||
Icon: KeyRound,
|
||||
Panel: GitCredentialsConfig,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -395,8 +395,8 @@ export function Workspace() {
|
||||
}, [lanes, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.run.binary("claude"), api.run.binary("codex")])
|
||||
.then(([claude, codex]) => setBinaryStatus({ claude, codex }))
|
||||
Promise.all([api.run.binary("claude"), api.run.binary("codex"), api.run.binary("antigravity")])
|
||||
.then(([claude, codex, antigravity]) => setBinaryStatus({ claude, codex, antigravity }))
|
||||
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
|
||||
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
|
||||
// showing a misleading "binary missing" banner for an unrelated fault.
|
||||
@@ -523,6 +523,10 @@ export function Workspace() {
|
||||
}, [refreshList]);
|
||||
|
||||
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
||||
// Only claude lanes carry a pipeline (codex and antigravity emit no hooks),
|
||||
// and only while a template is installed — none ships, so it is dormant.
|
||||
const noPipeline =
|
||||
!!currentLane && (currentLane.tool !== "claude" || currentLane.pipeline_nodes.length === 0);
|
||||
|
||||
// Feature list follows the selected lane, resets the viewer on lane switch.
|
||||
useEffect(() => {
|
||||
@@ -583,8 +587,11 @@ export function Workspace() {
|
||||
|
||||
// Viewport locked only when a live run is showing (TerminalView needs locked
|
||||
// viewport for chat scrolling). The config-card screen needs normal page flow
|
||||
// so the form is fully reachable on short windows.
|
||||
const viewportLocked = paneHasActiveRun;
|
||||
// so the form is fully reachable on short windows. A full-width Changes panel
|
||||
// hides that form anyway, and without a locked height it shrinks to its
|
||||
// content — the editor inside it collapsed to a few lines (no live run).
|
||||
const viewportLocked =
|
||||
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -812,40 +819,54 @@ export function Workspace() {
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
{/* A lane with no pipeline (non-claude, or no template
|
||||
installed): no time-at-stage, no pipeline or feature
|
||||
picker, no map. */}
|
||||
{!noPipeline && (
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{showInfo && (
|
||||
<>
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{!noPipeline && (
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [
|
||||
{
|
||||
id: currentLane.pipeline,
|
||||
name: currentLane.pipeline_name,
|
||||
nodes: [],
|
||||
},
|
||||
]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* `agy` has no endpoint to point at a router. */}
|
||||
{currentLane.tool !== "antigravity" && <LaneRouterRow lane={currentLane} />}
|
||||
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||
PipelineMap right below this header already draws both
|
||||
— the declared node as the current ring, the detected
|
||||
one as its amber badge — so the chips were a second,
|
||||
smaller copy of the same two facts. */}
|
||||
{features.length > 0 && (
|
||||
{!noPipeline && features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
@@ -864,7 +885,7 @@ export function Workspace() {
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||
{/* Per-lane actions (mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
@@ -905,19 +926,21 @@ export function Workspace() {
|
||||
onSelectLane={setSelectedLaneId}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<PipelineMap
|
||||
nodes={
|
||||
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||
}
|
||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||
/>
|
||||
{viewedFeature && (
|
||||
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
||||
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!noPipeline && (
|
||||
<div className="mb-3">
|
||||
<PipelineMap
|
||||
nodes={
|
||||
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||
}
|
||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||
/>
|
||||
{viewedFeature && (
|
||||
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
||||
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{proofFeature &&
|
||||
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
||||
<div data-testid="proof-gallery" className="mt-2">
|
||||
@@ -1032,6 +1055,7 @@ export function Workspace() {
|
||||
</div>
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file Tests for the Git credentials settings tab (issue #45): lists saved
|
||||
* logins without ever rendering a token, replaces the token for a host, adds
|
||||
* one from a URL, and deletes only after a confirm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitCredentialsConfig } from "../GitCredentialsConfig";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
protocol: "https" as const,
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
savedAt: "2026-09-25T01:00:00.000Z",
|
||||
hasToken: true as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.gitCredentials.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [saved] });
|
||||
vi.mocked(api.gitCredentials.save)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [saved] });
|
||||
vi.mocked(api.gitCredentials.remove).mockReset().mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
describe("GitCredentialsConfig", () => {
|
||||
it("lists a saved login and replaces its token", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
expect(await screen.findByText("git.smartgift.io.vn")).toBeInTheDocument();
|
||||
expect(screen.getByText("vi")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.replace git.smartgift.io.vn" }));
|
||||
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveValue("https://git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("gitCreds.fieldToken")).toHaveValue("");
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "newtok");
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.save).toHaveBeenCalledWith({
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "newtok",
|
||||
})
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("newtok");
|
||||
});
|
||||
|
||||
it("adds a login from a URL with a port", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
await user.click(await screen.findByRole("button", { name: "gitCreds.add" }));
|
||||
await user.type(
|
||||
screen.getByLabelText("gitCreds.fieldHost"),
|
||||
"http://gitea.lan:3000/org/repo.git"
|
||||
);
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldUser"), "bot");
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "t");
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.save).toHaveBeenCalledWith({
|
||||
protocol: "http",
|
||||
host: "gitea.lan:3000",
|
||||
username: "bot",
|
||||
password: "t",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes only after a confirm", async () => {
|
||||
const confirm = vi
|
||||
.spyOn(window, "confirm")
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
|
||||
await user.click(del);
|
||||
expect(api.gitCredentials.remove).not.toHaveBeenCalled();
|
||||
await user.click(del);
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
gitTree: r({ entries: [] }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
|
||||
@@ -8435,44 +8435,11 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
Machine-wide housekeeping and one-time setup for the ship-feature-lane pipeline.
|
||||
Machine-wide lane housekeeping.
|
||||
</p>
|
||||
<div
|
||||
class="card p-5 space-y-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between flex-wrap gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
>
|
||||
Install ship-feature-lane skill
|
||||
</span>
|
||||
<button
|
||||
class="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-rotate-ccw w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
||||
/>
|
||||
<path
|
||||
d="M3 3v5h5"
|
||||
/>
|
||||
</svg>
|
||||
Install ship-feature-lane skill
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between flex-wrap gap-3"
|
||||
>
|
||||
@@ -10364,7 +10331,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.8.7 (rebuild client to match)
|
||||
UI build v1.8.12 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+95
-13
@@ -135,7 +135,7 @@ POST /api/lanes/ensure
|
||||
```
|
||||
|
||||
```json
|
||||
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature", "tool": "codex" }
|
||||
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "default", "tool": "codex" }
|
||||
```
|
||||
|
||||
`cwd` is optional — when missing or empty the server falls back to
|
||||
@@ -192,14 +192,22 @@ slugified from its title once, at creation.
|
||||
|
||||
`pipeline` is patchable, which is how a lane moves between templates after
|
||||
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
|
||||
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
|
||||
to the default template when READING, so an unvalidated write would be
|
||||
rejected with `400 EBADPIPELINE` rather than stored (`default` is always
|
||||
accepted): `getPipeline` falls back to the default template when READING, so an unvalidated write would be
|
||||
accepted and then silently render the wrong map forever. Same 400 treatment as
|
||||
`EBADKIND`. Switching a template re-resolves the lane's existing declared
|
||||
`stage` string against the new node list — a stage the old template knew may
|
||||
resolve to nothing in the new one, leaving `progress: 0` and no `current` node
|
||||
until the next `agenthub stage`.
|
||||
|
||||
**Pipelines are dormant until a template is installed.** AgentHub ships no
|
||||
pipeline template; templates come only from `DASHBOARD_PIPELINES_DIR` (see
|
||||
`docs/LANES.md#custom-pipeline-templates`). With none installed, every lane is
|
||||
on an empty `default` pipeline (`pipeline_nodes: []`, `progress: 0`),
|
||||
`GET /api/lanes/pipelines` returns `{ "pipelines": [] }`, and
|
||||
`POST /api/lanes/:id/stage` answers
|
||||
`400 { "error": { "code": "ENOPIPELINE", "message": "no pipeline template is installed, so this lane has no stages to declare (drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)", "stages": [] } }`.
|
||||
|
||||
#### Provision a managed worktree
|
||||
|
||||
```http
|
||||
@@ -224,7 +232,10 @@ existing `lane_update` message when the lane becomes `idle` or `failed`.
|
||||
runs will spawn. Immutable once the lane is created; an attempt to update it on
|
||||
an existing lane returns `400 EBADTOOL`.
|
||||
|
||||
`sourceRepo` must exist, be absolute, and be a git repository. When `base` is
|
||||
`sourceRepo` must exist, be absolute, and be a git repository with at least
|
||||
one commit — a repo with no commits has nothing to branch from and is refused
|
||||
with `400 EEMPTYREPO` before any lane row is created (adopt it as a Repo lane
|
||||
with `POST /api/lanes/ensure` instead, commit, then `publish`). When `base` is
|
||||
omitted, it defaults to the `LANE_BASE_BRANCH` env var, or `main` when that is
|
||||
also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
|
||||
defaults to `feat/`). `title` (or an optional `slug`) determines the
|
||||
@@ -380,6 +391,7 @@ What the lane's directory looks like to git right now:
|
||||
"available": true,
|
||||
"branch": "feat/rename-metric",
|
||||
"head": "9b3e74a",
|
||||
"unborn": false,
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1,
|
||||
@@ -399,6 +411,9 @@ What the lane's directory looks like to git right now:
|
||||
not count its siblings' files (the same scoping as `/git/changes`).
|
||||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||||
`{"available": false}` with HTTP **200** — a normal state, not a fault.
|
||||
- A repo with **no commit yet** (a fresh `git init`) is available too: it
|
||||
reports `unborn: true`, `head: null`, `subject: ""` and the branch the first
|
||||
commit will land on.
|
||||
- `files` names the files the counts came from, **capped at 20** (the counts
|
||||
themselves are never capped). `status` is the two-character
|
||||
`git status --porcelain` code; `path` is the new path for a rename.
|
||||
@@ -579,12 +594,17 @@ 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": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
|
||||
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
|
||||
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
|
||||
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
|
||||
| `merge-abort` | `git merge --abort` | `{"confirm": true}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
@@ -592,8 +612,22 @@ cannot collide on `index.lock`.
|
||||
|
||||
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
|
||||
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
|
||||
running agent. There is deliberately no merge, rebase, reset or stash here —
|
||||
running agent. There is deliberately no rebase, reset or stash here —
|
||||
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
- **Merge** (issue #45) happens only when asked for by name: on a clean tree
|
||||
(`400 EDIRTY`), from a lane at the repo root (`400 ESUBDIR` — a merge is
|
||||
repo-wide), for a branch listed under `refs/heads` or `refs/remotes`
|
||||
(`400 ENOBRANCH`). A merge that stops on conflicts is still a **200**, with
|
||||
`conflicts: ["path", …]`; a clean one answers `conflicts: []`. While a merge
|
||||
is in progress `pull`, `switch`, `merge`, `stage-hunk` and `discard-hunk` are
|
||||
`409 EMERGING`, and `GET /api/lanes/:id/git` reports `merging: true` plus
|
||||
`conflicts` (unmerged files, repo-wide). `resolve` accepts only a path git
|
||||
reports as conflicted (`400 EBADPATH`) and a `side` of `ours`/`theirs`/`done`
|
||||
(`400 EBADSIDE`); `done` and a plain `stage` of a conflicted file both refuse
|
||||
while a conflict marker is left in it (`400 EMARKERS`). A side that deleted
|
||||
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
|
||||
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
|
||||
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
|
||||
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
|
||||
only push the branch it is on.
|
||||
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
|
||||
@@ -617,10 +651,21 @@ cannot collide on `index.lock`.
|
||||
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
|
||||
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
|
||||
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
|
||||
- `publish` is the one write whose **destination comes from the request**, so
|
||||
it only ever gives a remote to a repo that has none: `400 ENOCOMMITS` before
|
||||
the first commit, `400 EREMOTEEXISTS` once any remote exists (it never
|
||||
re-points one), and the name is always `origin`. The URL is allowlisted —
|
||||
`https://` with no userinfo (a token in the URL would land in `.git/config`),
|
||||
`ssh://` and scp-like `user@host:path`; a local path, `file://`, `ext::`,
|
||||
`fd::`, `git://`, `http://` and anything starting with `-` are
|
||||
`400 EBADURL`. If the push fails, `origin` is removed again, so a typo or a
|
||||
missing credential can simply be retried. The response adds
|
||||
`"remote": "origin"`.
|
||||
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
|
||||
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
|
||||
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
|
||||
non-zero (a rejected push, a credential failure, a diverged pull) is
|
||||
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`, `EBADURL`, `EREMOTEEXISTS`,
|
||||
`ENOCOMMITS`); git itself exiting
|
||||
non-zero (a rejected push, a diverged pull) is
|
||||
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
|
||||
rejection reads better in git's words. Anything else (git not installed, a
|
||||
timeout kill) is **500**, still under a string code (`EGIT` when there is
|
||||
@@ -629,8 +674,42 @@ cannot collide on `index.lock`.
|
||||
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
|
||||
`git commit` takes the whole index whatever the cwd, and scoping it with a
|
||||
pathspec would commit the working-tree copy (unstaged edits included).
|
||||
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
|
||||
- Network actions (`fetch`/`pull`/`push`/`publish`) are killed after 60s so an unreachable
|
||||
remote fails instead of holding the request open.
|
||||
- **A remote that wants a login** (issue #45) is **409 `EGITAUTH`**, not
|
||||
`EGIT`: the dashboard's git has no terminal to prompt in. The error carries
|
||||
`auth: { protocol, host, username, rejected }` — `rejected: true` when a
|
||||
login WAS sent and refused. Resend the same action with
|
||||
`credentials: { protocol, host, username, password, remember }`: accepted
|
||||
only for a host that is one of the repo's own remotes (`400 EBADHOST`
|
||||
otherwise), used for that one command, and saved to AgentHub's store (below)
|
||||
only after git succeeded AND `remember` is true. Empty or multi-line values
|
||||
are `400 EBADCRED`.
|
||||
|
||||
#### Git credentials
|
||||
|
||||
```
|
||||
GET /api/git-credentials Saved logins: { items: [{ protocol, host, username, savedAt, hasToken }] }
|
||||
POST /api/git-credentials Save or replace — Body: { protocol, host, username, password }
|
||||
DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
|
||||
```
|
||||
|
||||
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
|
||||
this install; the **Git credentials** tab of Settings
|
||||
(`/settings?tool=git-credentials`) and the git bar's login form both write
|
||||
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
|
||||
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
|
||||
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
|
||||
`400 EBADCRED`.
|
||||
|
||||
A network action reaches git with `-c credential.helper= -c
|
||||
credential.helper=<AgentHub's helper>` whenever AgentHub holds a login for one
|
||||
of the repo's remote hosts (or the request carries one) — ONLY AgentHub's
|
||||
helper, so a stale token in the machine's helper cannot win over the one just
|
||||
typed, and git's post-success `store` cannot copy a use-once token into the
|
||||
OS user's helper. Otherwise the command is unchanged and the machine's own
|
||||
helpers answer as before. The secret never reaches argv: the helper reads the
|
||||
store file, or a use-once login from its own process env.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
@@ -967,7 +1046,7 @@ every number. `status` filters by session lifecycle, same convention as
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{ "name": "agenthub:ship-feature", "plugin": "agenthub", "count": 12, "sessions": 5 },
|
||||
{ "name": "agenthub:repo-onboarding", "plugin": "agenthub", "count": 12, "sessions": 5 },
|
||||
{ "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
|
||||
],
|
||||
"plugins": [
|
||||
@@ -1990,17 +2069,20 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
|
||||
```http
|
||||
GET /api/run List all live runs (computed fresh from tmux state)
|
||||
GET /api/run/tmux { available: bool } — whether tmux is on PATH
|
||||
GET /api/run/binary?tool= { found, path } for the `claude` or `codex` binary
|
||||
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
|
||||
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||
```
|
||||
|
||||
**`GET /api/run/binary`:** Probes for the binary (`claude` or `codex`). Optional query parameter `?tool=codex` probes for Codex; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
|
||||
`:id` must be a lane pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
|
||||
|
||||
**`GET /api/run/binary`:** Probes for the binary (`claude`, `codex` or `agy`). Optional query parameter `?tool=codex` probes for Codex and `?tool=antigravity` for Antigravity's `agy`; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
|
||||
|
||||
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
|
||||
|
||||
|
||||
+2
-7
@@ -235,19 +235,14 @@ A lane is a durable unit of parallel agent work — one working directory, many
|
||||
| `agenthub lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane |
|
||||
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane |
|
||||
| `agenthub lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.agenthub/profile/`. Refuses to overwrite an existing one without `--force` |
|
||||
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
|
||||
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts (none until you install one in `DASHBOARD_PIPELINES_DIR`); pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
|
||||
| `agenthub lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
||||
| `agenthub lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
|
||||
| `agenthub ship-full "<requirement>" [<id>] [--print]` | **The 16-stage lane pipeline.** Puts the lane on the `ship-feature` template and starts a `claude` session running its skill (`/agenthub:ship-feature-lane`): implement → gates → e2e → review → local QC → senior GO/NO-GO → push branch + open PR, then watches the PR until a human merges it. Use when the work should end in a reviewable PR |
|
||||
| `agenthub ship-quick "<requirement>" [<id>] [--print]` | **The 8-stage in-repo pipeline.** Puts the lane on the `default` template and runs its skill (`/agenthub:ship-feature`): plan → implement → tests → review → gate → ship. No PR, no browser QC. Use for work that stays in the working copy |
|
||||
| `agenthub stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` lane sharing one directory |
|
||||
| `agenthub stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. With no pipeline template installed (AgentHub ships none — see [Custom pipeline templates](LANES.md#custom-pipeline-templates)) it answers `400 ENOPIPELINE`. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` lane sharing one directory |
|
||||
| `agenthub feature list [<id>]` | List every feature this lane has activated, archived or live |
|
||||
| `agenthub feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
||||
| `agenthub feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
|
||||
|
||||
`agenthub ship-full` / `agenthub ship-quick` both resolve the lane from the working directory (or take a lane id), pass the requirement to `claude` as one argv element — never through a shell — and accept `--print`, which shows the exact command, switches no template and runs nothing. The skill string itself lives in the template's `skill` field; a template without one makes the command refuse rather than guess.
|
||||
|
||||
|
||||
**Runtime** — the lane's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one lane id. Each requires the repository to declare a profile at `<repo>/.agenthub/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
|
||||
|
||||
| Command | Description |
|
||||
|
||||
+3
-1
@@ -389,7 +389,9 @@ CREATE TABLE notifications (
|
||||
|
||||
### model_pricing
|
||||
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD).
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
||||
|
||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_pricing (
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+185
-442
@@ -22,7 +22,7 @@ agenthub lanes add --cwd /path/to/repo --title "My Feature"
|
||||
|
||||
The lane is now ready to track work. When you run a Claude Code session in that directory (or a subdirectory), the dashboard will bind the session to the lane automatically by matching the cwd.
|
||||
|
||||
**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists).
|
||||
**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists). A `cwd` that exists is stored by its real path (`fs.realpathSync.native`), so `/tmp/wt` on macOS is stored as `/private/tmp/wt` and a differently-cased path on a case-insensitive volume as the spelling on disk — the path a session reports. A path that does not exist yet is resolved through its deepest existing ancestor (`/tmp/new` → `/private/tmp/new`); rows created before this rule keep theirs.
|
||||
|
||||
To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository:
|
||||
|
||||
@@ -32,9 +32,9 @@ agenthub lanes add --repo /path/to/repo --title "My Feature" --base main --slug
|
||||
|
||||
`--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes.
|
||||
|
||||
Adopting your main repo (`agenthub lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree — see "Superpowers skill invocations" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
|
||||
Adopting your main repo (`agenthub lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
|
||||
|
||||
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `agenthub lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub lanes agents install`, and `agenthub lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
|
||||
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `agenthub lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub lanes mcp sync`, and the AI Router pick. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
|
||||
|
||||
### The "+ Add lane" modal
|
||||
|
||||
@@ -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,16 @@ 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 and nothing types
|
||||
a stage reminder into their pane. 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
|
||||
|
||||
@@ -417,8 +394,8 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
|
||||
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add lane** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-lane modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
|
||||
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
|
||||
- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the lane strip's selection — there is no per-pane lane picker — and each pane owns its own tmux session on that lane: pane 0 is the lane's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane lane id from an older build is ignored.
|
||||
@@ -438,7 +415,7 @@ The **Active runs** button in the console header opens the merged run list. It s
|
||||
|
||||
1. live in-memory tmux runs from `GET /api/run`,
|
||||
2. persisted dashboard runs from `GET /api/run/history`,
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes them, so two hand-started agents read as "2 active runs".
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes the ones its lane owns (next paragraph), so two hand-started agents in the lane's folder read as "2 active runs".
|
||||
|
||||
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
||||
|
||||
@@ -471,7 +448,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
|
||||
}
|
||||
```
|
||||
|
||||
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
|
||||
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude`, `codex` or `antigravity`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
|
||||
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
|
||||
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
|
||||
- A relative or missing `cwd` returns `400 EBADCWD`.
|
||||
@@ -524,6 +501,8 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
top of the Changes panel opens this list as a panel: one row per file, its status letter
|
||||
colour-coded, the directory truncated before the filename ever is, and each
|
||||
row's `+N −M` aligned on the right under a total.
|
||||
- A repo with no commit yet is **not** unavailable: it reports `unborn: true`,
|
||||
`head: null`, an empty `subject` and the branch its first commit will land on.
|
||||
- An unknown lane id is a `404`.
|
||||
|
||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||
@@ -547,17 +526,37 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
||||
| --- | --- | --- |
|
||||
| **Fetch** | `git fetch --all --prune` | the repo has no remote |
|
||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||
| **Push** | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
|
||||
| **Merge into current** | `git merge --no-edit <branch>` (after a confirm) | the tree is dirty, the lane is a subdirectory of its repo, or a merge is already in progress |
|
||||
| **Merge `<upstream>`** | the same, offered when the branch has diverged (ahead AND behind) | as above |
|
||||
|
||||
These sit in the panel rather than in the lane card's ⋯ menu deliberately: that menu holds
|
||||
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
|
||||
lane needs several times an hour. The boring half of git is the whole list on
|
||||
purpose — there is no merge, rebase, reset or stash button, because a dashboard
|
||||
click is the wrong place to start an operation whose recovery needs a terminal.
|
||||
lane needs several times an hour. There is no rebase, reset or stash button,
|
||||
because a dashboard click is the wrong place to start an operation whose
|
||||
recovery needs a terminal. **Merge** is the one exception (issue #45), because
|
||||
its recovery is a button too: a merge that stops on conflicts puts a banner at
|
||||
the top of the Changes panel — **Complete merge** (enabled once nothing is
|
||||
conflicted) and **Abort merge** (two clicks; back to where the branch was) —
|
||||
and lists the conflicted files in their own **Merge conflicts** group with
|
||||
**Keep mine** / **Take theirs** for the whole file and **Mark resolved**.
|
||||
Opening a conflicted file shows the **conflict view** instead of its diff:
|
||||
each conflict block drawn as its two sides — *Mine (HEAD)* tinted green,
|
||||
*Theirs (<branch>)* tinted blue, labelled from git's own markers — with
|
||||
**Keep mine**, **Take theirs** or **Keep both** per block (undoable, ^/v to
|
||||
jump between blocks). **Save and mark resolved** is enabled once every block
|
||||
is chosen; it saves over exactly the version opened (a file changed meanwhile
|
||||
is refused, `409 ESTALE`) and then marks the file resolved. **Edit by hand**
|
||||
opens the plain editor, for a block neither side gets right; marking resolved
|
||||
is refused while any `<<<<<<<`/`>>>>>>>` marker is left. In the Files tree a
|
||||
conflicted file is listed once (git indexes it once per side) and marked with
|
||||
a red **!**. Pull and branch switch are disabled until the merge is finished or
|
||||
aborted.
|
||||
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
|
||||
inventing a merge commit or a conflict underneath a running agent. **Create**
|
||||
deliberately does not switch: the usual reason to make a branch from a lane is
|
||||
@@ -568,14 +567,48 @@ one hidden in the git bar. There is no **Commit** here: committing is the
|
||||
overview's staged-only box below, the one commit path, and it only ever takes
|
||||
what was staged.
|
||||
|
||||
**Publish…** is the one button whose destination the user types, so the server
|
||||
keeps it narrow: it only gives a remote to a repo that has none (it never
|
||||
re-points one), always names it `origin`, and accepts only `https://` without
|
||||
credentials in the URL, `ssh://` and scp-like `git@host:owner/repo.git` — never
|
||||
a local path, `file://`, `ext::` or `http://`. Credentials come from the
|
||||
machine's own credential helper, ssh agent or AgentHub's own git login store
|
||||
(the login form below), exactly as for **Push**.
|
||||
|
||||
A repo with **no commits yet** (a fresh `git init`) still gets the header: its
|
||||
branch, "no commits yet", and a hint to commit first — the network and branch
|
||||
buttons are hidden because there is nothing to push, fetch into or branch from.
|
||||
The commit box below makes the first commit, and **Publish…** appears once it
|
||||
exists. **Add lane** in Worktree mode refuses such a repo (`400 EEMPTYREPO`,
|
||||
nothing to fork a worktree from) and offers **Use as a Repo lane** instead.
|
||||
|
||||
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
|
||||
says more than any sentence the dashboard could write.
|
||||
|
||||
The exception is a remote that wants a **login** (issue #45). The dashboard
|
||||
runs git without a terminal, so where a shell would prompt, the bar opens a
|
||||
form instead — "Git needs a login for `<host>`": username, password or access
|
||||
token (on Gitea: Settings → Applications → Generate token), and **Remember for
|
||||
everyone using this AgentHub** (on by default) — and resends the same action
|
||||
with it. A saved login lives in AgentHub's own store, one per host, shared by
|
||||
every user of the install, and is managed from Settings → **Git credentials**;
|
||||
it is saved only after git accepted it. While AgentHub holds a login for a
|
||||
repo's host, that login is the only one git is given for the command — the
|
||||
machine's own credential helpers are left out, so a stale token there cannot
|
||||
win. Over plain `http://` on a LAN address the form warns that the token
|
||||
crosses the network unencrypted. See `docs/API.md` § Git credentials.
|
||||
|
||||
### The Changes panel
|
||||
|
||||
To the right of the lane detail sits a collapsible **Changes** card (its state
|
||||
persists per viewer in `localStorage`). Its header holds **one** control, which
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted) → closed (`agenthub.workspace.changesSize`
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
|
||||
height-locked so the panel fills the viewport even with no live run; the whole
|
||||
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
|
||||
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
|
||||
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
|
||||
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
|
||||
restores the saved text) → closed (`agenthub.workspace.changesSize`
|
||||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||||
narrow. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
|
||||
which one is "work" changes several times an hour, so the width is a step, not
|
||||
@@ -596,13 +629,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.
|
||||
@@ -614,14 +652,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
|
||||
@@ -667,7 +709,11 @@ there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
|
||||
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
|
||||
irreversible action in the panel: it asks twice in the UI, and the server
|
||||
refuses it without `confirm: true` and accepts only paths `git status` is
|
||||
already reporting as changed.
|
||||
already reporting as changed. In the `work` range the overview splits like VS
|
||||
Code's Source Control: **Staged Changes** (what the next commit takes, each row
|
||||
offering Unstage) above **Changes** (unstaged and untracked, each offering
|
||||
Stage); a partly staged file appears in both. In the tree, a changed file's
|
||||
whole row takes its status tint, not only its letter.
|
||||
|
||||
Review, single-file, history and browse do **not** poll: re-fetching under the
|
||||
reader's scroll position is worse than a slightly stale view, and those are the
|
||||
@@ -687,7 +733,7 @@ This shows each lane's:
|
||||
- **status**: `running` (a session is active), `idle` (no active session), or `provisioning`
|
||||
- **liveness**: `active` (recently updated), `dead` (silent for too long), or `idle` (not expected to be running)
|
||||
- **progress**: percentage of the pipeline completed (0–100%)
|
||||
- **needs_action**: a notification if the session is blocked waiting for input
|
||||
- **needs_action**: a notification if the session is blocked waiting for input. `needs_action_session` records which session raised it, and only that session's next hook clears it — a second session in the same cwd (a hand-started terminal beside the dashboard pane) rebinds `session_id` but cannot clear a prompt it is not the one waiting on
|
||||
|
||||
A lane row also gets a `⇢ detected:<stage>` suffix when the server's inferred
|
||||
stage (see "Stage detection" below) leads the declared `stage` shown in the
|
||||
@@ -699,7 +745,9 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
|
||||
|
||||
## Pipeline stages and the five node states
|
||||
|
||||
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
|
||||
> **Pipelines are dormant.** AgentHub ships no pipeline template. Until you install one (drop a template JSON into `DASHBOARD_PIPELINES_DIR` — see "Custom pipeline templates" below), every lane sits on an empty `default` pipeline: no nodes, progress `0`. `GET /api/lanes/pipelines` returns `{"pipelines": []}`, `agenthub stage <x>` (and `POST /api/lanes/:id/stage`) answers `400 ENOPIPELINE`, and the Workspace hides the time-on-stage timer, the pipeline and feature pickers and the PipelineMap — the lane tile shows its tool tag instead of a stage chip. Install a template and all of it comes back; a template with id `default` becomes every lane's default. Further development of pipelines is planned. The rest of this section, and the stage sections after it, describe a lane on an installed template.
|
||||
|
||||
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below).
|
||||
|
||||
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal does not ask — it always creates on `default`. Pick another with `agenthub lanes add --pipeline <id>` at creation, or change it afterwards from the *Pipeline template* select in the lane's detail header in Workspace.
|
||||
|
||||
@@ -707,63 +755,31 @@ For a lane that already exists, switch it from the terminal:
|
||||
|
||||
```bash
|
||||
agenthub lanes pipeline # which template this lane uses, and what else exists
|
||||
agenthub lanes pipeline ship-feature # switch it
|
||||
agenthub lanes pipeline ml-training # switch it (any installed template id)
|
||||
```
|
||||
|
||||
or from the dashboard: the pipeline-template select next to the lane's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot).
|
||||
|
||||
**The template names the skill that drives it.** `"skill": "/agenthub:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/agenthub:ship-feature"` on the 8-node `default` one. It rides along on every lane payload as `pipeline_skill`, and from a terminal there is one command per depth of work — the command you type picks the pipeline, so no `agenthub lanes pipeline` step first:
|
||||
|
||||
```bash
|
||||
cd ~/lanes/myrepo__feat-export
|
||||
|
||||
# 16 stages: ends in a published PR, watched until a human merges it
|
||||
agenthub ship-full "add an export button"
|
||||
# → lane switched to `ship-feature`, then: claude "/agenthub:ship-feature-lane add an export button"
|
||||
|
||||
# 8 stages: in-repo feature work, stops at review/gate — no PR, no browser QC
|
||||
agenthub ship-quick "fix the CSV separator"
|
||||
# → lane switched to `default`, then: claude "/agenthub:ship-feature fix the CSV separator"
|
||||
|
||||
agenthub ship-full "add an export button" --print # show the command, switch nothing, run nothing
|
||||
```
|
||||
|
||||
| | `agenthub ship-full` | `agenthub ship-quick` |
|
||||
|---|---|---|
|
||||
| Template | `ship-feature` (16 nodes) | `default` (8 nodes) |
|
||||
| Skill | `/agenthub:ship-feature-lane` | `/agenthub:ship-feature` |
|
||||
| Ends at | PR open on `development`, watched until merged | `ship` in the working copy |
|
||||
| Includes | unit-test agent, CI gates, e2e, code review, local browser QC, senior GO/NO-GO | plan, implement, tests, review, gate |
|
||||
| Use when | the work should reach reviewers | the work stays local |
|
||||
|
||||
The lane is resolved from the working directory, so no id is needed (pass one to target another lane). Each command puts the lane on its own template *before* spawning, so the dashboard renders the right node count from the first stage rather than after the skill self-corrects; `--print` switches nothing. The requirement is handed to `claude` as ONE argv element — never through a shell — so quotes, `$` and `;` in it are text. A template with no `skill` field makes the command refuse rather than guess.
|
||||
A template may name the slash command that drives it (its optional `skill` field). It rides along on every lane payload as `pipeline_skill`; the dashboard never executes it.
|
||||
|
||||
### Running ONE node
|
||||
|
||||
`agenthub ship-full` / `agenthub ship-quick` hand the whole pipeline to a skill. When you only want one step — re-run the CI gate, redo the brainstorm, open the PR by hand — run that node's own `command`:
|
||||
When you only want one step — re-run a CI gate, redo a brainstorm — run that node's own `command`:
|
||||
|
||||
```bash
|
||||
agenthub lanes node # list every node of this lane's pipeline and its command
|
||||
agenthub lanes node intake # → claude "/superpowers:brainstorming"
|
||||
agenthub lanes node intake the export button # extra words are appended to the prompt
|
||||
agenthub lanes node review # → claude "/code-review"
|
||||
agenthub lanes node gates # → agenthub lanes hook ci-gate
|
||||
agenthub lanes node pr-open --print # show the argv, run nothing
|
||||
agenthub lanes node # list every node of this lane's pipeline and its command
|
||||
agenthub lanes node <node-id> # run that node's command in the lane's cwd
|
||||
agenthub lanes node <node-id> extra words # extra words are appended to the prompt
|
||||
agenthub lanes node <node-id> --print # show the argv, run nothing
|
||||
```
|
||||
|
||||
Both shipped templates give **every** node a command, so any of the 16 (or 8) is runnable. A node starting with `/` is spawned as `claude "<command> <extra args>"`; anything else is spawned directly. The lane comes from the working directory as usual, and the command always runs in the lane's own `cwd`.
|
||||
|
||||
The node is matched by **id or alias**, so one name works on either template: `default` calls the step `implement` and `ship-feature` calls it `implementing`, and `agenthub lanes node implement` finds it on both.
|
||||
|
||||
**From inside a Claude session, use the slash commands instead.** The plugin ships one per node — `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/reported`, `/watching-pr`, `/merged`, `/done` — plus `/ship-full` and `/ship-quick` for the whole pipeline. Each resolves the node's command through `agenthub lanes node <id> --print` and runs it *in the current session*: a slash-command node is invoked directly (never as a nested `claude` process), anything else runs as a shell argv in the lane's cwd. Extra words are appended to the prompt: `/intake the export button`.
|
||||
A node is runnable when its template gives it a `command`. A command starting with `/` is spawned as `claude "<command> <extra args>"`; anything else is spawned directly. The lane comes from the working directory as usual, and the command always runs in the lane's own `cwd`. The node is matched by **id or alias**.
|
||||
|
||||
Two deliberate limits:
|
||||
|
||||
- **The command is split on whitespace and spawned without a shell.** A template is data — files anyone can drop into `DASHBOARD_PIPELINES_DIR` — so its `command` is never interpreted as shell. A node needing a pipe or a redirect names a script that contains them.
|
||||
- **Running a node does not declare its stage.** A command executing is not proof the node passed — the same reason the run console never writes a lane's stage. Declare with `agenthub stage <id>` when the work is actually done. (The exception is a node whose command *is* `agenthub stage <id>`: there the declaration is the whole step.)
|
||||
|
||||
**The template also decides what the Workspace run console pre-fills.** A template may name the slash command that drives it — `"skill": "/agenthub:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/agenthub:ship-feature"` on the 8-node `default` one. That string rides along on every lane payload as `pipeline_skill`, and the run console starts its prompt box with it (plus a trailing space) so you only type the requirement: pick the 16-node template and Run starts the 16-step skill, pick the 8-node one and it starts the 8-step skill. The dashboard never executes it — it is text in a prompt box, exactly as if you had typed it. A prompt you have already edited is never overwritten by a later template switch, and a template with no `skill` field pre-fills nothing.
|
||||
|
||||
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `agenthub stage` fixes it.
|
||||
|
||||
### Aggregate pass rates across lanes
|
||||
@@ -797,7 +813,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
|
||||
@@ -812,8 +828,6 @@ offering every other lane even while the counts show only one.
|
||||
|
||||
`agenthub lanes shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this lane (`agenthub-lane-<id>`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live.
|
||||
|
||||
**In practice you never have to pick correctly at creation.** The template only has to match whichever skill is actually driving the lane, and the skill enforces that itself: `ship-feature-lane` runs `agenthub lanes pipeline ship-feature` and `ship-feature` runs `agenthub lanes pipeline default` before their first `agenthub stage` call — a no-op if the lane is already on that template, a self-correction if it isn't. A human chatting with the lane never needs to open the picker; whichever skill gets invoked decides the template.
|
||||
|
||||
The dashboard renders every node in the pipeline in one of five **states**:
|
||||
|
||||
| State | Color | Meaning |
|
||||
@@ -829,13 +843,13 @@ The dashboard renders every node in the pipeline in one of five **states**:
|
||||
A stage in this state was **claimed but not proven**. Two scenarios:
|
||||
|
||||
1. **Declared but no artifact left.** A skill called `agenthub stage review` without `--evidence`, claiming the stage happened but leaving no proof that work occurred.
|
||||
2. **Implicitly skipped.** The lane jumped from `plan` to `implement` (to stage index 2), leaving `intake` (index 0) in `passed-no-evidence` state.
|
||||
2. **Implicitly skipped.** On a template whose nodes start `intake`, `plan`, `implement`, the lane jumped from `plan` to `implement` (to stage index 2), leaving `intake` (index 0) in `passed-no-evidence` state.
|
||||
|
||||
In both cases, the stage is not **done** — it's incomplete or untested. Amber signals that the agent claimed to have done this work but the dashboard has no evidence to show it was actually completed. This matters in a pipeline: if your gate checks `done` not `passed-no-evidence`, a lane stuck in amber will block until evidence is provided.
|
||||
|
||||
## Reporting a stage
|
||||
|
||||
`agenthub stage` is not skill-only. Any Claude session working inside an adopted lane's cwd — including a plain chat session where no skill was ever invoked — should call it on real stage transitions. Tool-event detection (see "Inferred vs. declared" below) can only paint the amber "detected" badge; it never moves the blue `current` ring. A lane worked entirely through plain chat that never calls `agenthub stage` will keep showing whatever stage it was last declared at (often `intake`), regardless of how much real work happens.
|
||||
`agenthub stage` is not skill-only. Any Claude session working inside an adopted lane's cwd — including a plain chat session where no skill was ever invoked — should call it on real stage transitions. Tool-event detection (see "Inferred vs. declared" below) can only paint the amber "detected" badge; it never moves the blue `current` ring. A lane worked entirely through plain chat that never calls `agenthub stage` will keep showing whatever stage it was last declared at, regardless of how much real work happens. With no pipeline template installed there is nothing to declare: the command answers `400 ENOPIPELINE`.
|
||||
|
||||
When a skill — or a plain chat session — reaches a significant milestone, it reports the current stage to the dashboard using `agenthub stage`:
|
||||
|
||||
@@ -857,18 +871,9 @@ agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--sta
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `<stage>` (required): the pipeline stage name or alias. The default pipeline aliases are:
|
||||
- `intake` / `assigned` / `claimed` / `start`
|
||||
- `plan` / `planning` / `brainstorm` / `design`
|
||||
- `implement` / `implementing` / `coding` / `build`
|
||||
- `tests` / `testing` / `unit` / `gates` / `pre-push-gate`
|
||||
- `review` / `reviewing` / `code-review` / `self-review`
|
||||
- `gate` / `verify` / `verification` / `sr-gate` / `gate-blocked`
|
||||
- `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push`
|
||||
- `done` / `complete` / `completed` / `merged`
|
||||
|
||||
Aliases are **per template** — the `ship-feature` pipeline has its own set
|
||||
(see "Pipeline template: ship-feature" below). A name matching no node and no
|
||||
- `<stage>` (required): a node id or one of its aliases, as the lane's
|
||||
pipeline template defines them (see "Custom template format"). Aliases are
|
||||
**per template**. A name matching no node and no
|
||||
alias is still recorded verbatim, but `phaseIdx` then resolves it to nothing:
|
||||
no node renders `current` and progress reads `0`. The CLI prints a warning to
|
||||
stderr and still exits `0` in that case — a typo must not break a
|
||||
@@ -925,8 +930,7 @@ Such keys only exist on lanes that predate validation: `POST
|
||||
/api/lanes/:id/stage` now rejects a stage naming no node of the lane's pipeline
|
||||
with **400 `EBADSTAGE`** and lists the ones that exist. Aliases still resolve,
|
||||
so this catches typos and names borrowed from another template — `agenthub
|
||||
stage test` on a `ship-feature` lane, whose CI node is `gates` — which used to
|
||||
be stored, warned about on stderr, and then counted nowhere.
|
||||
stage test` on a template whose CI node is `gates` — which used to be stored, warned about on stderr, and then counted nowhere.
|
||||
|
||||
Every declaration is also appended to `lane_stage_events`, the append-only log
|
||||
the pass-rate report reads. `lanes.stages` describes the pass in flight and is
|
||||
@@ -991,14 +995,11 @@ agenthub stage tests --evidence "Pre-push gate: all linters passing" --result pa
|
||||
|
||||
## Custom pipeline templates
|
||||
|
||||
The default pipeline is suitable for feature work: `intake` → `plan` → `implement` → `tests` → `review` → `gate` → `ship` → `done`. For different workflows (data pipelines, research phases, deployment stages), you can provide custom templates.
|
||||
AgentHub ships no template, so a custom template is the only way to turn the pipeline map, stage reporting and stage detection on. A template models whatever workflow you run — feature work, data pipelines, research phases, deployment stages.
|
||||
|
||||
### Where templates come from
|
||||
|
||||
1. **Built-in:** `server/data/pipelines/default.json` (always available)
|
||||
2. **User override:** directory specified by the `DASHBOARD_PIPELINES_DIR` environment variable
|
||||
|
||||
If both exist, **files in the user directory override built-ins** with the same `id`.
|
||||
Every `*.json` file in the directory named by the `DASHBOARD_PIPELINES_DIR` environment variable is loaded once at server start; a malformed file is skipped, never fatal. A template whose `id` is `default` becomes the pipeline of every lane that names no other.
|
||||
|
||||
### Custom template format
|
||||
|
||||
@@ -1023,14 +1024,15 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.agenthub/pipelin
|
||||
|
||||
- **id** (string, required): unique identifier for this template
|
||||
- **name** (string, optional): display name shown in the UI
|
||||
- **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/agenthub:ship-feature-lane"`). The Workspace run console pre-fills its prompt with it for lanes on this template; omit it and the prompt starts empty. Never executed by the dashboard.
|
||||
- **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/my-training-skill"`). Carried on every lane payload as `pipeline_skill`. Never executed by the dashboard.
|
||||
- **nodes** (array, required): list of pipeline stages, in order
|
||||
- **id** (string): stage identifier (used in `agenthub stage`)
|
||||
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
|
||||
- **icon** (string, optional): unicode emoji or single character shown before the stage name
|
||||
- **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false`
|
||||
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/superpowers:writing-plans"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; both shipped templates fill every node, using `"agenthub stage <id>"` for the nodes whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a lane card and as copyable text per node on the Workflows page, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
|
||||
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`)
|
||||
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a lane card and as copyable text per node on the Workflows page, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
|
||||
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`). An alias may also name a **sub-state** — *why* the lane sits on a node — without adding a node to the map. Two constraints: no alias may collide with another node's id or alias (`phaseIdx` takes the FIRST match, so a duplicate silently resolves a declaration onto the wrong node), and all aliases of one node share one record slot (`stageRecords` resolves each declared key onto its node, and the canonical id's record wins), so a sub-state that needs its own `--evidence` has to be a real node.
|
||||
- **detect** (array, optional): stage-detection rules for this node — see "Stage detection → Where the rules live".
|
||||
|
||||
Then set the environment variable:
|
||||
|
||||
@@ -1044,7 +1046,7 @@ When you create a new lane, you can optionally specify which pipeline to use:
|
||||
agenthub lanes add --cwd /path/to/ml-repo --title "Training run #1" --pipeline ml-training
|
||||
```
|
||||
|
||||
(The pipeline is selected at creation time. Lanes created without `--pipeline` default to the `default` pipeline.)
|
||||
(The pipeline is selected at creation time. Lanes created without `--pipeline` default to the `default` pipeline — yours if you installed one with that id, otherwise the empty one.)
|
||||
|
||||
|
||||
## Per-feature state and archive
|
||||
@@ -1104,72 +1106,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
|
||||
|
||||
@@ -1198,9 +1151,8 @@ leads the declared one (see "Viewing lanes" below), and `LaneStripCard.tsx`'s
|
||||
and the browser never disagree about which one is the headline. Both locate the
|
||||
declaration by the node the server already resolved it onto (the one whose
|
||||
`state` is `current`), never by looking `lane.stage` up as a node id: most of
|
||||
what the ship-feature skill declares is an ALIAS (`tests`, `booting`, `ship`,
|
||||
`gate-blocked`), which is not an id, and an id lookup scores it -1 — every
|
||||
detection, however far behind, would then read as leading.
|
||||
what a skill declares may be an ALIAS, which is not an id, and an id lookup
|
||||
scores it -1 — every detection, however far behind, would then read as leading.
|
||||
|
||||
### Which lane a signal is credited to
|
||||
|
||||
@@ -1243,7 +1195,7 @@ contain whole code blocks:
|
||||
Each rule is tested against these fields **one at a time**, never against a join
|
||||
of them. An `Agent` event really carries `description` + `prompt` +
|
||||
`subagent_type` together, so a rule anchored to the agent's identity —
|
||||
`{"tool": "Agent", "match": "^Explore$"}` in `ship-feature.json` — would never
|
||||
`{"tool": "Agent", "match": "^Explore$"}` — would never
|
||||
fire against a concatenation of all three, and a rule could otherwise match
|
||||
across the seam between two unrelated fields. Write anchored rules against the
|
||||
one field that identifies the tool call.
|
||||
@@ -1259,18 +1211,6 @@ to the flattened input. Either way the result is capped at 120 characters
|
||||
lane's tooltip sometimes shows a file path or a skill name rather than a shell
|
||||
command: it's whichever of the fields above the matching rule's `tool` carried.
|
||||
|
||||
### Superpowers skill invocations
|
||||
|
||||
The built-in `default` pipeline's `intake`, `plan`, `implement`, `review`, and
|
||||
`ship` nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
|
||||
workflow skill names they expect (`brainstorming`, `writing-plans`,
|
||||
`executing-plans`/`subagent-driven-development`, Claude Code's own
|
||||
`code-review` plus `requesting-code-review`/`receiving-code-review`,
|
||||
`finishing-a-development-branch`). Invoking one of
|
||||
these skills is a much stronger signal than a matched Bash command, but it is
|
||||
still detection, not declaration — it renders dashed amber and never `done`,
|
||||
same as every other detected stage.
|
||||
|
||||
Detection only ever attributes to a lane whose `cwd` matches the hook's
|
||||
session `cwd` (see "Which lane a signal is credited to" above). A session
|
||||
working directly in a source repo that was never itself adopted as a lane —
|
||||
@@ -1310,90 +1250,36 @@ read per call, so no restart is needed for a test harness that sets it.
|
||||
### Where the rules live
|
||||
|
||||
Detection rules are not code — they live in the pipeline template itself, as
|
||||
a `detect` array on each node. The built-in `default.json`
|
||||
(`server/data/pipelines/default.json`) ships these rules today, node by node:
|
||||
a `detect` array on each node. AgentHub ships no template, so no rule fires
|
||||
until you install one.
|
||||
|
||||
| Node | Detect rules |
|
||||
|---|---|
|
||||
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle; `Skill` matching `brainstorming`, which is intake's own declared command |
|
||||
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
|
||||
| `implement` | `Skill` matching `executing-plans\|subagent-driven-development\|test-driven-development`; `Edit` (any); `Write` to any path NOT under a `docs/` directory (`^(?!.*(?:^\|/)docs/)`) |
|
||||
| `tests` | `Bash` matching `\b(npm (run )?test\|pytest\|vitest\|jest\|go test\|cargo test\|node\s+--test\|rspec\|phpunit\|dotnet test\|mvn test\|gradle test)\b`; `Agent` matching `Test Results Analyzer\|API Tester\|Performance Benchmarker` |
|
||||
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Bash` matching `\bgh\b[^;&\|]*\bpr diff\b`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind), `Evidence Collector`, or `Accessibility Auditor` |
|
||||
| `gate` | `Skill` matching `verification-before-completion`; `Agent` matching `senior-gate-reviewer\|Reality Checker` — entering the gate, never its verdict |
|
||||
| `ship` | `Bash` matching a `git push` **invocation** — `git`, then option groups, then `push` as the first non-flag token — or `gh … pr create` |
|
||||
| `done` | none — see below |
|
||||
A few lessons from rules that ran against real sessions, worth keeping when you
|
||||
write your own:
|
||||
|
||||
**`git diff` is deliberately NOT a review signal.** It was, and measured on a
|
||||
real session four `git diff --stat` runs — used to verify a generated file —
|
||||
outranked 81 test runs and 30 edits and pinned the lane at `review`, because
|
||||
`detect()` takes the LAST matching node and `recordDetection` is forward-only.
|
||||
Reading a diff is constant background activity, so it carries no stage
|
||||
information. `gh pr diff` stays: that one really is a review act.
|
||||
- **Detection is a safety net, not the mechanism.** Because `recordDetection`
|
||||
is forward-only AND never overrides a higher declared stage, a fix-loop
|
||||
re-entry that drops back a node is invisible to detection — only an
|
||||
`agenthub stage` declaration moves the lane back down. What detection adds is
|
||||
the stage an agent forgot to declare and the heartbeat that keeps a working
|
||||
lane from reading STALLED.
|
||||
- **Give a terminal node (`done`, `merged`) no rule.** Inference must never
|
||||
reach a terminal state on its own; that would let a heuristic assert an
|
||||
outcome instead of observing activity. A rule on a gate node should detect
|
||||
that the gate *activity* started, never its verdict.
|
||||
- **`git diff` is not a review signal.** Measured on a real session, four
|
||||
`git diff --stat` runs — used to verify a generated file — outranked 81 test
|
||||
runs and 30 edits and pinned the lane at `review`, because `detect()` takes
|
||||
the LAST matching node and detection is forward-only. Reading a diff is
|
||||
constant background activity; `gh pr diff` really is a review act.
|
||||
- **Match a `git push` *invocation*, not the word `push`.** `git[^;&|]*push`
|
||||
misses `git -c credential.helper='!f() { … }; f' push` (a `;` inside a quoted
|
||||
value), and `git[\s\S]*push` over-matches `git log --oneline -1 && echo "… push …"`.
|
||||
Consume option groups explicitly, treating a quoted option value as one unit,
|
||||
then require `push` as the first non-flag token — that accepts `git push` and
|
||||
`git -c core.hooksPath=/dev/null push` while rejecting `git commit -m "don't push"`,
|
||||
`docker push` and `npm run push-docs`.
|
||||
|
||||
**The `ship` pattern matches a git *invocation*, not the word `push`.** Two
|
||||
weaker forms were tried and both failed against real commands:
|
||||
|
||||
- `git[^;&|]*push` (same shell segment) **misses** this repo's own push, because
|
||||
`git -c credential.helper='!f() { … }; f' push` puts a `;` between the two
|
||||
inside a quoted value. Six real pushes went undetected that way.
|
||||
- `git[\s\S]*push` (anywhere after `git`) **over-matches**, and did so on a live
|
||||
lane within minutes: `git log --oneline -1 && echo "… push …"` pinned the lane
|
||||
at `ship`. Since `ship` sits near the end of the pipeline and detection is
|
||||
forward-only, that single over-read stuck — the same failure `git diff` caused
|
||||
for `review`.
|
||||
|
||||
So the pattern consumes option groups explicitly, treating a quoted option value
|
||||
as one unit, and then requires `push` to be the first non-flag token. That
|
||||
accepts `git push`, `git -c core.hooksPath=/dev/null push` and the credential
|
||||
-helper form, while rejecting `git log … "push"`, `git commit -m "don't push"`,
|
||||
`docker push`, and `npm run push-docs`.
|
||||
|
||||
The `ship-feature` template (`server/data/pipelines/ship-feature.json`) carries
|
||||
its own rules, aimed at the commands and Superpowers skills its driving skill
|
||||
actually runs:
|
||||
|
||||
| Node | Detect rules |
|
||||
|---|---|
|
||||
| `intake` | `Skill` matching `brainstorming`; `Bash` matching `agenthub … feature activate`; `Agent` matching subagent `Explore` |
|
||||
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/superpowers/specs/lane-.*\.md`; `Agent` matching `Plan\|Software Architect`; `TaskCreate` (any) |
|
||||
| `implementing` | `Skill` matching `test-driven-development\|executing-plans\|subagent-driven-development\|systematic-debugging`; `Edit`/`Write` to any path NOT under `docs/superpowers/specs/` (resp. not under `docs/`) |
|
||||
| `gates` | `Bash` matching `agenthub … hook ci-gate` or `agenthub … sync-base --check` |
|
||||
| `e2e-feature` | `Bash` matching `agenthub … up --qc` or `agenthub … hook e2e` |
|
||||
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind) |
|
||||
| `qc` | `Agent` matching `qc-local\|Evidence Collector\|Accessibility Auditor`; `Bash` matching `agenthub … lanes proof-link` |
|
||||
| `gate` | `Agent` matching `senior-gate-reviewer\|Reality Checker`; `Skill` matching `verification-before-completion` |
|
||||
| `publishing` | `Skill` matching `finishing-a-development-branch`; `Bash` matching a `git push` **invocation** (the same pattern `default.json`'s `ship` uses) |
|
||||
| `pr-open` | `Bash` matching `gh … pr create` |
|
||||
| `watching-pr` | `Bash` matching `gh … pr view` |
|
||||
| `e2e-feature-passed`, `qc-plan`, `reported`, `merged`, `done` | none — declaration-only |
|
||||
|
||||
Two rules are deliberately absent from this template. There is **no `git diff`
|
||||
rule on `review`**, for the reason the `default` template learned the hard way
|
||||
below; the `code-review` skill invocation is the honest signal. And `merged` /
|
||||
`done` carry no rule at all, because inference must never reach a terminal
|
||||
state — a test pins that.
|
||||
|
||||
Detection here is a **safety net, not the mechanism**: the `ship-feature-lane`
|
||||
skill declares every one of these stages with `agenthub stage` itself. What
|
||||
detection adds is the stage an agent forgot after a context compaction, the
|
||||
heartbeat that keeps a working lane from reading STALLED, and coverage for a
|
||||
lane running Superpowers skills without the driving skill at all. It cannot
|
||||
substitute for the skill's own declarations: because `recordDetection` is
|
||||
forward-only AND never overrides a higher declared stage, a fix-loop re-entry
|
||||
that drops back to `gates` is invisible to detection — only the skill's
|
||||
`agenthub stage gates` moves the lane back down.
|
||||
|
||||
`done` in `default.json` deliberately has no rule — it is explicitly out of
|
||||
scope for inference (see the task's "Out of scope" list): `done` is the one
|
||||
state detection must never reach on its own, since inferring it would let a
|
||||
heuristic assert an outcome instead of observing activity. `intake` and `gate`
|
||||
now carry entry-activity rules (an `Explore` agent, a `senior-gate-reviewer`/
|
||||
`Reality Checker` agent or `verification-before-completion` skill) — these
|
||||
detect that the *activity* of exploring or gate-reviewing started, never a
|
||||
gate's pass/fail verdict, so the "no outcome inference" rule still holds.
|
||||
|
||||
To add a rule, drop a template into the directory named by the
|
||||
To add a rule, put a template in the directory named by the
|
||||
`DASHBOARD_PIPELINES_DIR` environment variable (see "Custom pipeline
|
||||
templates" above) with a `detect` array on the node you want to match:
|
||||
|
||||
@@ -1417,8 +1303,8 @@ Detection never rewinds a lane and never fights a declaration:
|
||||
- **Declared beats detected.** A detection is written only when the lane's
|
||||
*declared* stage index is strictly less than the detection's. A lane
|
||||
already declared at `review` ignores an `implement` detection outright.
|
||||
**One exception: a declaration on the pipeline's LAST node** (`done`, in both
|
||||
built-in templates) stops vetoing. The pipeline is finished there, so a later
|
||||
**One exception: a declaration on the pipeline's LAST node** (typically
|
||||
`done`) stops vetoing. The pipeline is finished there, so a later
|
||||
detection is new work rather than a stale rerun of what the claim covers —
|
||||
without the exception a lane left at `done` rejects every detection for the
|
||||
rest of its life, which reads as "auto-detect is broken". The exception
|
||||
@@ -1505,7 +1391,7 @@ Terminate the running session. If the lane has no live run, this is a no-op (no
|
||||
curl -X POST http://localhost:4820/api/lanes/5/stop
|
||||
```
|
||||
|
||||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
|
||||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete / publish
|
||||
|
||||
The git writes the Changes panel and its action row offer. See
|
||||
"Acting on a lane's working copy" above for what each one refuses and why.
|
||||
@@ -1525,6 +1411,11 @@ curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paths": ["src/scratch.ts"], "confirm": true}'
|
||||
|
||||
# A repo with commits but no remote: add `origin` and push HEAD to it
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/publish \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "git@github.com:owner/repo.git"}'
|
||||
```
|
||||
|
||||
### clear
|
||||
@@ -1634,51 +1525,9 @@ The lane card shows a lock badge when the lane **holds** a named lock (its `lane
|
||||
- **Never delete the lock directory by hand.** A lock that outlives its holder (e.g. after a hard reboot) is cleaned up automatically — the next `acquire` on that name finds it stale (older than `LOCK_MAX_HOLD`) and breaks it. Deleting `LANES_ROOT/.locks/<name>/` yourself only races whoever else's `acquire` is about to do the same check safely.
|
||||
- **Never shrink `LOCK_MAX_HOLD` to force through a wait.** The 300-second floor exists specifically to stop this mistake. If the true holder is gone, the 300-second minimum wait is the price of safety. If the true holder is still running (e.g., a build with a network stall), shortening the timeout from 2700 to 300 does not help — it converts a slow success into a premature timeout, leaving the lock held and every other lane blocked forever.
|
||||
|
||||
## The ship-feature-lane skill (E1)
|
||||
## Lane tooling: QC boot, sync-base, MCP, integrations, CI
|
||||
|
||||
The **ship-feature-lane** skill is the ported Shipyard driving skill for autonomous end-to-end feature pipelines in a single AgentHub lane. It coordinates the entire development flow: implementation (TDD) → independent unit tests → pre-push CI gates + dev preflight → e2e testing → code review → local QC → senior gate authorization → PR push and publication → CI watch → final report.
|
||||
|
||||
**Every stage whose job is to judge the work runs in a fresh-context subagent, never in the driving session.** The plan debate (Stage 0b), unit tests (Stage 1b), the e2e run and its triage (Stage 3), code review (Stage 4), browser QC (Stage 6), the senior gate (Stage 7), and a re-triage of CI that stayed red after a fix (Stage 10) are each dispatched with the Agent tool to a subagent that starts with an empty context and receives only artifacts it can verify itself — working directory, lane spec, diff range, commands, and the other agents' reports verbatim, never the driving session's paraphrase of why a finding was fine. The stages the driving session keeps are the ones that *do* rather than judge: intake, implementation, the mechanical gates, publishing, the CI/PR watch, and applying every fix the judges ask for. `subagent_type: fork` is explicitly forbidden for these: a fork inherits the implementing session's context, so it inherits the belief that the code is already correct. The driving session implements and applies fixes; it never grades its own work. Stages 1b/3 use the generic `general-purpose` type (nothing to install). Stage 4 uses the generic `Code Reviewer` type as its catch-all, plus — in parallel, routed by which of `server/`, `client/` the diff touches — the plugin-qualified `agenthub:backend-reviewer` / `agenthub:frontend-reviewer` domain reviewers (nothing to install; `agenthub:` addressing works from any lane). Stages 6/7 use the lane-installed `qc-local`/`senior-gate-reviewer` agents.
|
||||
|
||||
### Getting the skill (nothing to do, if you run the plugin)
|
||||
|
||||
The skill lives in `skills/ship-feature-lane/`, and the repo root **is** the `agenthub` plugin root (`"source": "./"`), so `skills/` is the plugin's own skills directory. Anyone with the `agenthub` plugin installed already has it in every session, from any lane's working directory, as **`/agenthub:ship-feature-lane`** — with the bare `/ship-feature-lane` working as an alias unless another skill or command claims that name.
|
||||
|
||||
Only a plain checkout without the plugin needs the fallback:
|
||||
|
||||
```bash
|
||||
agenthub skills install
|
||||
```
|
||||
|
||||
Copies `skills/ship-feature-lane/` into `~/.claude/skills/ship-feature-lane/` (overwrites on reinstall — run it again after pulling an update to the skill), which restores the bare `/ship-feature-lane` name. One-time per machine, same as `agenthub lanes agents install` is one-time per lane.
|
||||
|
||||
**Agents come from the same plugin, under two names.** `qc-local` and `senior-gate-reviewer` live in `agents/` at the repo root, so the plugin exposes them as `agenthub:qc-local` / `agenthub:senior-gate-reviewer`. `agenthub lanes agents install` copies those same files into the lane's own `.claude/agents/` — the highest-priority agent source — which is what makes the bare `subagent_type: qc-local` the skill prefers resolve inside the lane. Either name reaches the same definition; the skill falls back to the `agenthub:` form when the lane copy is missing.
|
||||
|
||||
### Invoking the skill
|
||||
|
||||
```bash
|
||||
# Inside a lane's working directory:
|
||||
/ship-feature-lane <requirement>
|
||||
```
|
||||
|
||||
The skill frontloads all clarifying questions once, then runs unattended. The only interactive touchpoints are **Stage 0 (Q&A intake)** and **merging the PR on GitHub**; everything else runs to completion or escalates with a `blocked` status visible on the dashboard. Progress is declared via `agenthub stage` commands at every step.
|
||||
|
||||
### Current status: stages 0–8, 10–12, 14 work; stage 9 deferred, stage 13 partially wired (CI-wait works, dev-QC doesn't); stages 6–7 agent-gated
|
||||
|
||||
**Stages implemented now:**
|
||||
- **0-8**: Intake & frontloaded Q&A → plan → implementation → independent unit tests (1b) → gates+preflight → e2e on feature branch → code review → QC plan → QC → senior gate → publish PR
|
||||
- **10-12**: CI watch on the PR → report → watch PR until merge (gated fix-loop re-entries on review feedback)
|
||||
- **14**: Done
|
||||
|
||||
**Stages currently hardcoded skipped** (pending F's integrations):
|
||||
- **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely.
|
||||
|
||||
**Partially wired:**
|
||||
- **13 (Post-merge verification)**: CI-wait half now runs for real via `agenthub lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean.
|
||||
|
||||
**Stages 6 and 7 are now unblocked** — run `agenthub lanes agents install` once per lane (see "Installing the QC/gate agents" below) before a lane's first run through Stage 6. A lane that reaches Stage 6/7 without having installed the agents will fail to find the `qc-local`/`senior-gate-reviewer` subagent type; install and re-run.
|
||||
|
||||
See [the skill text](../skills/ship-feature-lane/SKILL.md) for the full pipeline definition.
|
||||
The commands below are primitives a driving session (or you) calls explicitly — AgentHub never sequences them.
|
||||
|
||||
### QC boot flag and profile integration
|
||||
|
||||
@@ -1701,7 +1550,7 @@ These environment variables are passed to the profile's `boot` hook during `agen
|
||||
|
||||
### Dev preflight and merge safety: sync-base
|
||||
|
||||
`agenthub lanes sync-base` is the ONE sanctioned merge in the pipeline — `origin/development` into a feature branch — used at Stages 2, 8, and 12. Three modes:
|
||||
`agenthub lanes sync-base` is the ONE sanctioned merge a session runs from the CLI — `origin/development` into a feature branch. Three modes:
|
||||
|
||||
```bash
|
||||
agenthub lanes sync-base --check feat/<slug> # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
|
||||
@@ -1721,34 +1570,9 @@ GENERATED_MERGE_PATHS="api/openapi.json api/client.ts"
|
||||
|
||||
`MIGRATIONS_DIR` enables the collision preflight against a numbered-migrations directory. `GENERATED_MERGE_PATHS` gives the listed files a keep-ours merge driver (never hand-merged) and folds the profile's `regen` hook output into the sync commit — the single most common cross-lane conflict, for a repo that generates an API contract/client.
|
||||
|
||||
### Installing the QC/gate agents: agents install
|
||||
|
||||
Stage 6 (browser QC) and Stage 7 (senior GO/NO-GO gate) run as subagents — `qc-local` and `senior-gate-reviewer`. The plugin already ships them as `agenthub:qc-local` / `agenthub:senior-gate-reviewer`; installing them into the lane's own `.claude/agents/` is what makes the bare names work there:
|
||||
|
||||
```bash
|
||||
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".
|
||||
|
||||
**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.
|
||||
|
||||
**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — `ticketer` is invoked by Stage 9 (skipped), and `dev-qc` by Stage 13's dev-QC half (unavailable); `pr-reviewer` isn't referenced anywhere in this skill's text.
|
||||
|
||||
### Syncing MCP servers: mcp sync
|
||||
|
||||
A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run Stage 3/6. `agenthub lanes mcp sync` gives it those:
|
||||
A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run e2e or browser QC. `agenthub lanes mcp sync` gives it those:
|
||||
|
||||
```bash
|
||||
agenthub lanes mcp sync
|
||||
@@ -1770,11 +1594,11 @@ A profile can declare `.agenthub/profile/integrations.env` with `TRACKER_ENABLED
|
||||
agenthub lanes integration tracker # exit 0 = on, 1 = off
|
||||
```
|
||||
|
||||
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the lane card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing.
|
||||
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. Nothing in AgentHub acts on an enabled `tracker` or `dev_qc` toggle. `ci_wait`'s consumer is `agenthub lanes ci status` (below). The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the lane card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing.
|
||||
|
||||
### Post-merge CI status: agenthub lanes ci status
|
||||
|
||||
Check GitHub Actions status for a commit SHA or branch ref — used by Stage 13's CI-wait loop:
|
||||
Check GitHub Actions status for a commit SHA or branch ref — one shot, so a session waiting on CI polls it:
|
||||
|
||||
```bash
|
||||
agenthub lanes ci status <branch-or-sha> [<id>]
|
||||
@@ -1782,87 +1606,6 @@ agenthub lanes ci status <branch-or-sha> [<id>]
|
||||
|
||||
Exit codes: `0` = all checks passed, `1` = at least one check failed, `3` = checks pending or in progress. The `<id>` parameter is optional and filters results to a specific lane if provided; if omitted, it's not needed.
|
||||
|
||||
### Pipeline template: ship-feature (16 node stages)
|
||||
|
||||
The skill uses the `ship-feature` pipeline template, which defines the following 16 stages (node IDs):
|
||||
|
||||
1. **intake** — frontloaded Q&A and feature activation
|
||||
2. **plan** — investigation and plan debate
|
||||
3. **implementing** — TDD implementation
|
||||
4. **gates** — CI gates + preflight check
|
||||
5. **e2e-feature** — run e2e on feature branch
|
||||
6. **e2e-feature-passed** — e2e result gate
|
||||
7. **review** — code review
|
||||
8. **qc-plan** — bound QC scope
|
||||
9. **qc** — browser QC (agent-gated until qc-local lands)
|
||||
10. **gate** — senior GO/NO-GO gate (agent-gated until senior-gate-reviewer lands)
|
||||
11. **publishing** — push branch + open/update PR
|
||||
12. **pr-open** — PR open and published
|
||||
13. **reported** — report posted
|
||||
14. **watching-pr** — watch for comments, conflicts, merge
|
||||
15. **merged** — PR merged, post-verify transition
|
||||
16. **done** — pipeline complete
|
||||
|
||||
The Workspace page (`/run`) displays this template with nodes rendered in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped), and `pending` (not reached).
|
||||
|
||||
### More stage names than nodes
|
||||
|
||||
Each node carries aliases, and they do two different jobs. Some absorb a
|
||||
near-miss (`implement` for `implementing`). Others are **sub-states**: a name
|
||||
the skill declares to say *why* the lane is sitting on a node, without adding a
|
||||
node to the map.
|
||||
|
||||
This is the shape Shipyard converged on — its dashboard renders 13 nodes while
|
||||
its `PHASES` table folds roughly 35 stage names onto them. A pipeline map is
|
||||
read at a glance across many lanes at once, so it stays coarse; the stage name
|
||||
is read one lane at a time, so it can be specific.
|
||||
|
||||
| Node | Near-miss aliases | Sub-states the skill declares |
|
||||
|---|---|---|
|
||||
| `intake` | `assigned`, `claimed`, `start` | `bootstrapping` |
|
||||
| `plan` | `planning`, `brainstorm`, `design` | — |
|
||||
| `implementing` | `implement`, `coding`, `build` | — |
|
||||
| `gates` | `pre-push-gate`, `tests` | `migration-collision`, `sync-conflict` |
|
||||
| `e2e-feature` | `e2e`, `live` | `booting`, `e2e-scoped` |
|
||||
| `e2e-feature-passed` | `e2e-passed` | — |
|
||||
| `review` | `reviewing`, `code-review`, `self-review` | — |
|
||||
| `qc-plan` | — | — |
|
||||
| `qc` | — | — |
|
||||
| `gate` | `sr-gate`, `verify`, `verification` | `gate-blocked` |
|
||||
| `publishing` | `push` | — |
|
||||
| `pr-open` | `ship`, `pr`, `push-conflict` | `push-revalidate` |
|
||||
| `reported` | — | — |
|
||||
| `watching-pr` | — | `pr-comment-fix` |
|
||||
| `merged` | — | — |
|
||||
| `done` | `complete`, `completed` | — |
|
||||
|
||||
50 names over 16 nodes. Every one of them has a **source**: the skill declares
|
||||
it, or `default.json` uses it (an agent moving between pipelines will type
|
||||
what the other one taught it), or Shipyard's `PHASES` lists it. An alias with
|
||||
no source is not "flexibility" — it is a synonym someone imagined, and the
|
||||
`agenthub stage` warning already catches a name that resolves to nothing, which is
|
||||
better feedback than silently absorbing every plausible spelling. Twelve
|
||||
sourceless aliases were written and then cut for exactly this reason.
|
||||
|
||||
Two constraints on this:
|
||||
|
||||
- **No alias may collide** with another node's id or alias. `phaseIdx` takes the
|
||||
FIRST match, so a duplicate would silently resolve a declaration onto the
|
||||
wrong node. A test asserts the whole template is collision-free.
|
||||
- **Aliases of one node share one record slot.** `lane.stages` is keyed by the
|
||||
declared string, and `stageRecords` resolves each key onto its node — so
|
||||
declaring `migration-collision` and then `gates` leaves ONE record for that
|
||||
node (the canonical `gates` one wins). A sub-state that needs to keep its own
|
||||
`--evidence` separately has to be a real node, not an alias.
|
||||
|
||||
`integrate`, `dev-gates`, `e2e-on-dev` and `push-dev` are deliberately **not**
|
||||
nodes here. Shipyard had them and retired them on 2026-07-16 (see the comment
|
||||
above `PHASES` in its `dashboard/src/lib/constants.js`); it now folds those
|
||||
names into the surviving phases so old feature cards still render. AgentHub never
|
||||
shipped them, so there is nothing to fold.
|
||||
|
||||
These nodes also carry `detect` rules; see "Stage detection → Where the rules live".
|
||||
|
||||
## Orchestration: what AgentHub does NOT do
|
||||
|
||||
**AgentHub does not chain, queue, retry, or evaluate gates.**
|
||||
|
||||
+36
-81
@@ -87,28 +87,22 @@ checkout unnecessary.
|
||||
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
|
||||
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
|
||||
| `/agenthub-doctor`, `/agenthub-update`, `/agenthub-open` | `plugins/agenthub/commands/` |
|
||||
| `/ship-full`, `/ship-quick` and one command per pipeline node (`/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/review`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/done`, …) | `plugins/agenthub/commands/`, one file per name |
|
||||
| The skills (`ship-feature-lane`, `ship-feature`, `repo-onboarding`, `debug-live-issue`, `update-project-docs`, `file-headers`) | `skills/` at the repo root — which IS the plugin root, so they are auto-discovered as plugin skills |
|
||||
| The agents (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`, `frontend-reviewer`) | `agents/` at the repo root, auto-discovered the same way — exposed as `agenthub:<name>` |
|
||||
| The skills (`repo-onboarding`, `debug-live-issue`, `update-project-docs`, `file-headers`) | `skills/` at the repo root — which IS the plugin root, so they are auto-discovered as plugin skills |
|
||||
|
||||
Plugin skills are namespaced: they are invoked as `/agenthub:<name>`, with the bare
|
||||
`/<name>` working as an alias unless another skill or command claims that name.
|
||||
This is why `skills/` sits at the repo root instead of `.claude/skills/` — the
|
||||
latter only loads for a session whose cwd is inside this checkout, which is
|
||||
useless for a lane working in a different repo. `agenthub skills install` survives
|
||||
as the fallback for a plain checkout without the plugin.
|
||||
useless for a lane working in a different repo.
|
||||
|
||||
**The agents ship the same way, from the same files.** `agents/` at the repo
|
||||
root holds all four (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`,
|
||||
`frontend-reviewer`), so the plugin exposes them as
|
||||
`agenthub:<name>`. `agenthub lanes agents install` copies `qc-local` +
|
||||
`senior-gate-reviewer` from that same directory into a lane's own
|
||||
`.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/`.
|
||||
That is the whole plugin: the hooks, the CLI, the dashboard, three commands and
|
||||
four skills. It ships **no subagents** and no pipeline template, and it neither
|
||||
depends on nor installs any other plugin (Superpowers included). The pipeline
|
||||
slash commands, the `ship-feature` / `ship-feature-lane` skills and the
|
||||
`qc-local` / `senior-gate-reviewer` / `backend-reviewer` / `frontend-reviewer`
|
||||
agents were removed; the stage/pipeline machinery they drove stays in the
|
||||
dashboard, dormant until you install a template (see `docs/LANES.md` §
|
||||
"Pipeline stages and the five node states").
|
||||
|
||||
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
|
||||
@@ -128,19 +122,20 @@ check.
|
||||
milliseconds — the real work happens in a detached worker, so a session never
|
||||
waits on an install:
|
||||
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit. "Live" means a discovery entry whose pid answers AND whose `startedAt` is after this boot (60 s margin): an entry left over from before a reboot names a pid the OS may have handed to an unrelated process, and without systemd (macOS) nothing else clears it.
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
6. **Legacy hook cleanup** — see above.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
||||
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,9 +228,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.
|
||||
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. See
|
||||
[INSTALL.md § macOS](../INSTALL.md#macos).
|
||||
|
||||
### Who may stop the server
|
||||
|
||||
@@ -298,66 +310,9 @@ courtesy of `Restart=always`.
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), **skill plugins** (see below), UI bundle |
|
||||
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), UI bundle |
|
||||
| `/agenthub-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@<marketplace> -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. |
|
||||
| `/agenthub-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes |
|
||||
| `/ship-full <requirement>` | Put the lane on the 16-node `ship-feature` template, then run `/agenthub:ship-feature-lane` **in this session**. The in-session twin of `agenthub ship-full` (which spawns its own `claude` from a terminal) |
|
||||
| `/ship-quick <requirement>` | The same for the 8-node `default` template and `/agenthub:ship-feature` — stops at review/gate, no PR, no browser QC |
|
||||
| One per pipeline node: `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/reported`, `/watching-pr`, `/merged`, `/done` | Run just that node's own `command`, resolved through `agenthub lanes node <id> --print` against whichever template the lane is on (matched by id **or alias**, so `/implement` works on `default`'s `implement` and `ship-feature`'s `implementing`). A slash-command node is invoked in the current session, never as a nested `claude`; anything else runs as a shell argv in the lane's cwd. None of them declares a stage |
|
||||
|
||||
#### The `Skill plugins` row
|
||||
|
||||
Derived from the loaded pipeline templates, not hardcoded: every `/plugin:skill`
|
||||
string a template names — as the pipeline's own `skill` or as one node's
|
||||
`command` — has to come from an installed, enabled plugin. Today that means
|
||||
**`superpowers`**, which `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`
|
||||
drive step by step (`brainstorming`, `writing-plans`,
|
||||
`test-driven-development`, `verification-before-completion`,
|
||||
`finishing-a-development-branch`). The `review` node is the exception: it names
|
||||
the bare `/code-review`, Claude Code's own review skill, which carries no
|
||||
`plugin:` prefix and so requires nothing to be installed. Install it
|
||||
with `claude plugin install superpowers@claude-plugins-official`.
|
||||
|
||||
The row is a **WARN, never a FAIL**: the dashboard, hooks and CLI all work
|
||||
without it, and only those two pipeline skills stop partway. A plugin listed but
|
||||
explicitly disabled counts as missing — Claude Code will not resolve its skills.
|
||||
The skills present as plain user skills under `~/.claude/skills/` count as
|
||||
satisfied, so a hand-copied setup is not reported as broken. A custom template
|
||||
in `DASHBOARD_PIPELINES_DIR` naming some other plugin's command is checked the
|
||||
same way.
|
||||
|
||||
Both the derivation and the missing/satisfied rules live in
|
||||
`server/lib/skill-plugins.js`, shared with the bootstrap below so the row and
|
||||
the install can never disagree about what is missing.
|
||||
|
||||
#### Automatic install, and why it is allowlisted
|
||||
|
||||
`plugin-bootstrap.js` installs the missing plugins itself, at the very end of
|
||||
its **detached worker** — never in the 10 s `SessionStart` hook, because the
|
||||
install clones from the network. It runs `claude plugin install
|
||||
superpowers@claude-plugins-official --scope user -y`; if that fails it registers
|
||||
the marketplace (`claude plugin marketplace add anthropics/claude-plugins-official`)
|
||||
and retries exactly once. Failure is logged with the manual command and nothing
|
||||
throws — the dashboard, hooks and CLI do not need these plugins.
|
||||
|
||||
The result is recorded as `skillPluginsSettled` in the bootstrap's `state.json`,
|
||||
which is also part of `fastPathOk()`: the check runs **once** per install, then
|
||||
the hot path reads one flag. `/agenthub-update` (`--force`) is the retry.
|
||||
`AGENTHUB_AUTO_INSTALL_PLUGINS=0` (or `false`/`off`) turns it off entirely.
|
||||
|
||||
An installed plugin only resolves in the **current** session after
|
||||
`/reload-plugins`; a new session picks it up on its own. The log says so, since
|
||||
the worker is detached and cannot tell the session that started it.
|
||||
|
||||
**Reporting is derived, installing is allowlisted.** The row above reads plugin
|
||||
names out of the templates, which is right for a report. Installing from the
|
||||
same source would not be: a template is a file anyone can drop into
|
||||
`DASHBOARD_PIPELINES_DIR`, so it would turn "edit a JSON file" into "run this
|
||||
vendor's plugin install command on the next session". Auto-install therefore
|
||||
consults `AUTO_INSTALLABLE`, a fixed map in `server/lib/skill-plugins.js`, and
|
||||
skips every other requirement — visible in the doctor, never installed. Adding
|
||||
an entry there is a decision to install someone else's code on a user's machine
|
||||
without asking; it belongs in a review, not in a config file.
|
||||
|
||||
### Where things live
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Git credentials for dashboard fetch/pull/push (issue #45, part A)
|
||||
|
||||
## Problem
|
||||
|
||||
The dashboard runs git with no terminal and `GIT_TERMINAL_PROMPT=0`. When no
|
||||
credential helper on the machine holds a credential for the remote's host, git
|
||||
wants to prompt, cannot, and fails:
|
||||
`fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled`.
|
||||
Reproduced with `git -c credential.helper= push --dry-run`.
|
||||
|
||||
## Decisions (approved 2026-09-25)
|
||||
|
||||
- AgentHub keeps its OWN credentials, shared by everyone using the AgentHub
|
||||
install (multi-user login is planned; accounts and settings are shared).
|
||||
- The dashboard asks for a credential when git needs one, then retries.
|
||||
- Merge and conflict resolution is part B and gets its own design.
|
||||
|
||||
## Design
|
||||
|
||||
**Store** — `server/lib/git-credentials.js`, one file
|
||||
`<CLAUDE_HOME>/agenthub-git-credentials/credentials.json` (dir 0700, file 0600,
|
||||
written through a temp file), one record per `protocol + host`:
|
||||
`{protocol, host, username, password, savedAt}`. No route returns `password`;
|
||||
the list reports `hasToken`. Routes: `GET /api/git-credentials`,
|
||||
`DELETE /api/git-credentials/:protocol/:host`.
|
||||
|
||||
**Delivery** — `server/lib/git-credential-helper.js`, a git credential helper
|
||||
speaking git's own protocol. For a network action, when AgentHub holds a
|
||||
credential for one of the repo's remote hosts, or the request carries one, git
|
||||
runs with `-c credential.helper= -c credential.helper=<ours>`: ONLY our helper.
|
||||
The machine's helpers are left out on purpose — a stale token there would win
|
||||
over the one just typed, and git's post-success `store` would copy a
|
||||
"use once" token into the OS user's own helper. With nothing from AgentHub for
|
||||
the host, the command is unchanged. The secret never reaches argv: the helper
|
||||
reads the store file, or `AGENTHUB_GIT_ONCE` in that one git process's env.
|
||||
|
||||
**Flow** — an auth failure (`could not read Username|Password`,
|
||||
`Authentication failed`, `terminal prompts disabled`, `Invalid username or
|
||||
password`) answers `409 EGITAUTH` with `{protocol, host, username?}`. The git
|
||||
bar shows a form (username, password/token, "remember for everyone on this
|
||||
AgentHub", on by default) and resends the action with
|
||||
`credentials: {protocol, host, username, password, remember}`. The server
|
||||
accepts it only for a host that is one of the repo's remotes, runs the action,
|
||||
and saves the credential only after git succeeded and `remember` is set.
|
||||
|
||||
**Limits** — one credential per host, so everyone on one AgentHub pushes as
|
||||
the same account. `fetch --all` over remotes on several hosts: a host with no
|
||||
AgentHub credential gets no machine helper either while one does. Over plain
|
||||
HTTP on a LAN the token crosses the network unencrypted; the form says so.
|
||||
|
||||
## Testing
|
||||
|
||||
Store: save/list/remove, 0600, list never carries the password, bad host
|
||||
refused. Helper: real `git credential fill` through our helper, stored and
|
||||
once. Route: a local HTTP remote answering 401 → `EGITAUTH` with the host; a
|
||||
resend with credentials reaches that server as Basic auth; nothing saved on
|
||||
failure. Client: the form appears on `EGITAUTH`, resends, never renders a token.
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nhúng mọi ảnh cục bộ của slide vào thẳng file HTML dưới dạng data URI.
|
||||
|
||||
Đọc agenthub-introduction.html, thay từng src="*.png" bằng chuỗi base64, ghi ra
|
||||
agenthub-introduction-standalone.html - một file duy nhất, gửi cho ai cũng thấy ảnh.
|
||||
Chạy lại sau mỗi lần sửa slide.
|
||||
|
||||
@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
"""
|
||||
import base64
|
||||
import mimetypes
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
SRC = pathlib.Path(__file__).parent / "agenthub-introduction.html"
|
||||
OUT = SRC.with_name("agenthub-introduction-standalone.html")
|
||||
|
||||
|
||||
def inline(match):
|
||||
raw = match.group(1)
|
||||
if raw.startswith(("data:", "http://", "https://")):
|
||||
return match.group(0)
|
||||
path = SRC.parent / urllib.parse.unquote(raw)
|
||||
if not path.is_file():
|
||||
sys.exit(f"thiếu ảnh: {path}")
|
||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
b64 = base64.b64encode(path.read_bytes()).decode()
|
||||
return f'src="data:{mime};base64,{b64}"'
|
||||
|
||||
|
||||
html = SRC.read_text(encoding="utf-8")
|
||||
OUT.write_text(re.sub(r'src="([^"]+)"', inline, html), encoding="utf-8")
|
||||
print(f"{OUT.name} - {OUT.stat().st_size / 1024 / 1024:.1f} MB")
|
||||
+15
-8
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.8.7
|
||||
version: 1.8.12
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -10636,7 +10636,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: Find or adopt the lane owning a working directory
|
||||
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
description: 'Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
operationId: ensureLane
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -10718,7 +10718,7 @@ paths:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'400':
|
||||
description: sourceRepo is relative, missing, or not a git repository.
|
||||
description: 'EBADSOURCEREPO: sourceRepo is relative, missing, or not a git repository. EEMPTYREPO: it has no commits, so there is nothing to branch a worktree from (no lane row is created).'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -10807,7 +10807,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
description: available:true with {branch, head, unborn, subject, dirty, untracked, files, hasRemote, upstream, ahead, behind}, or available:false alone. A repo with no commit yet is available with unborn:true, head:null and subject:"". files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/runtime:
|
||||
@@ -11188,7 +11188,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: One git write on the lane's working copy
|
||||
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch''s upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file''s `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git''s own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree''s git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
|
||||
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch''s upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file''s `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git''s own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote''s host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree''s git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
|
||||
operationId: runLaneGitAction
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -11214,6 +11214,7 @@ paths:
|
||||
- switch
|
||||
- branch-create
|
||||
- branch-delete
|
||||
- publish
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -11221,6 +11222,9 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.
|
||||
message:
|
||||
type: string
|
||||
description: commit only; required, max 4000 chars.
|
||||
@@ -11249,6 +11253,9 @@ paths:
|
||||
hunk:
|
||||
type: string
|
||||
description: stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.
|
||||
block:
|
||||
type: integer
|
||||
description: stage-hunk, discard-hunk (optional); act on only the block-th run of consecutive +/- lines inside that hunk — the other runs are folded into context. Out of range is 409 EHUNKGONE.
|
||||
context:
|
||||
type: integer
|
||||
description: stage-hunk, discard-hunk; the context the hunk was read at.
|
||||
@@ -11257,13 +11264,13 @@ paths:
|
||||
description: discard and discard-hunk; the call is refused without it.
|
||||
responses:
|
||||
'200':
|
||||
description: '{action, ok:true, output} — git''s own stdout+stderr.'
|
||||
description: '{action, ok:true, output} — git''s own stdout+stderr; publish adds remote:"origin".'
|
||||
'400':
|
||||
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.
|
||||
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE, ENOREMOTE, EUNTRACKED, EBADURL, EREMOTEEXISTS, ENOCOMMITS, EBADHOST, EBADCRED, ESUBDIR, EBADSIDE or EMARKERS.
|
||||
'404':
|
||||
description: Lane not found.
|
||||
'409':
|
||||
description: EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.
|
||||
description: EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.
|
||||
'500':
|
||||
description: Unexpected server-side failure.
|
||||
/api/lanes/{id}/{action}:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.12",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.12",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -25,12 +25,6 @@ Print the output verbatim, then add at most three lines of guidance based on the
|
||||
- `FAIL npm` or `FAIL Disk` → nothing else in the report can be trusted until
|
||||
these are fixed; both silently truncate installs.
|
||||
- `WARN agenthub CLI` → print the exact `export PATH=...` line from the output.
|
||||
- `WARN Skill plugins` → the pipelines name slash commands no installed plugin
|
||||
provides. The dashboard is unaffected; `/agenthub:ship-feature(-lane)` is what
|
||||
stops partway. The bootstrap normally installs these itself, so this row also
|
||||
means that attempt has not run or did not succeed — say `/agenthub-update` retries
|
||||
it, and print the `claude plugin install …` line from the output as the manual
|
||||
route.
|
||||
- `WARN systemd` → when it says the unit names a **previous plugin version**,
|
||||
the dashboard is running now but will NOT come back after a reboot. Starting
|
||||
a new Claude Code session repairs it; print the `--force` command from the
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Declare the lane done
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `done` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node done $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Declare e2e passed (ship-feature only)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `e2e-passed` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node e2e-passed $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: E2e on the feature branch (ship-feature only)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `e2e` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node e2e $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Senior GO/NO-GO gate
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `gate` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node gate $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Implement (TDD)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `implement` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node implement $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Intake — brainstorm the requirement
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `intake` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node intake $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Declare merged (ship-feature only)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `merged` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node merged $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Plan — write the implementation plan
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `plan` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node plan $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Open the PR
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `pr-open` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node pr-open $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Publish the branch
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `publishing` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node publishing $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Declare the QC plan (ship-feature only)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `qc-plan` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node qc-plan $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Browser QC + proof gallery (ship-feature only)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `qc` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node qc $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Declare the run reported (ship-feature only)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `reported` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node reported $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Code review
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `review` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node review $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Run the full 16-stage lane pipeline (implement → gates → e2e → review → QC → senior gate → PR, watched until merged)
|
||||
argument-hint: <requirement>
|
||||
allowed-tools: Bash(agenthub lanes pipeline:*)
|
||||
---
|
||||
|
||||
Put this lane on the 16-node `ship-feature` template first, so the dashboard
|
||||
renders the right node count from the first stage rather than after the skill
|
||||
self-corrects:
|
||||
|
||||
!`agenthub lanes pipeline ship-feature`
|
||||
|
||||
Then run the pipeline **in this session** by invoking `/agenthub:ship-feature-lane`
|
||||
with this requirement:
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
- No requirement given → ask for one. Never guess what to build.
|
||||
- The switch above failed (no lane owns this directory) → say so and stop; the
|
||||
skill has no lane to report stages against.
|
||||
|
||||
This is the in-session twin of `agenthub ship-full "<requirement>"`, which does the
|
||||
same two steps from a terminal by spawning its own `claude`.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Run the 8-stage in-repo pipeline (plan → implement → tests → review → gate → ship). No PR, no browser QC
|
||||
argument-hint: <requirement>
|
||||
allowed-tools: Bash(agenthub lanes pipeline:*)
|
||||
---
|
||||
|
||||
Put this lane on the 8-node `default` template first, so the dashboard renders
|
||||
the right node count from the first stage:
|
||||
|
||||
!`agenthub lanes pipeline default`
|
||||
|
||||
Then run the pipeline **in this session** by invoking `/agenthub:ship-feature` with
|
||||
this requirement:
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
- No requirement given → ask for one. Never guess what to build.
|
||||
- The switch above failed (no lane owns this directory) → say so and stop; the
|
||||
skill has no lane to report stages against.
|
||||
|
||||
This is the in-session twin of `agenthub ship-quick "<requirement>"`. It stops at
|
||||
review/gate: no PR is opened and no browser QC runs — use `/ship-full` when the
|
||||
work should reach reviewers.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Tests / pre-push CI gates
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `tests` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node tests $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Watch the PR / CI (ship-feature only)
|
||||
argument-hint: [extra prompt words]
|
||||
allowed-tools: Bash(agenthub lanes node:*)
|
||||
---
|
||||
|
||||
The command this lane pipeline template declares for its `watching-pr` node, resolved
|
||||
against whichever template the lane is actually on (matched by id or alias):
|
||||
|
||||
!`agenthub lanes node watching-pr $ARGUMENTS --print`
|
||||
|
||||
Run that here, in THIS session - never start a nested `claude` process:
|
||||
|
||||
- Line reads `cd <dir> && "claude" "<slash command>"` -> invoke that slash
|
||||
command yourself, passing any extra words as its argument.
|
||||
- Anything else is a shell argv -> run it with Bash from `<dir>`.
|
||||
- Line is an error (`no node ...`, `declares no command`, no lane owns this
|
||||
directory) -> print it verbatim and stop. Do not guess a substitute.
|
||||
|
||||
Do NOT call `agenthub stage` afterwards: a command running is not proof the node
|
||||
passed. Declare the stage when the work is actually done. (The exception is a
|
||||
node whose command already IS `agenthub stage <id>` - there the declaration is the
|
||||
whole step.)
|
||||
@@ -165,7 +165,7 @@ function reloadPanes() {
|
||||
try {
|
||||
paneCmd = execFileSync(
|
||||
"tmux",
|
||||
["display-message", "-p", "-t", name, "#{pane_current_command}"],
|
||||
["display-message", "-p", "-t", `=${name}:`, "#{pane_current_command}"],
|
||||
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
} catch {
|
||||
@@ -184,8 +184,8 @@ function reloadPanes() {
|
||||
// detached and serves no requests.
|
||||
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
|
||||
first = false;
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "Enter"], { timeout: 15_000 });
|
||||
}
|
||||
reloaded.push(name);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file launchd-service.js
|
||||
* @description The macOS twin of `systemd-service.js`: a per-user LaunchAgent
|
||||
* (`~/Library/LaunchAgents/io.smartgift.<name>.plist`) that starts the AgentHub
|
||||
* dashboard at login and keeps it running, without Claude Code being opened
|
||||
* first. Same exports and the same return values as the systemd module, so
|
||||
* `service-manager.js` can hand either one to the bootstrap, the CLI and the
|
||||
* doctor. Every `launchctl` call goes through one seam (`__setExecImpl`) so
|
||||
* tests never touch a real launchd.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const MARKER = "<!-- agenthub-managed-unit — regenerated on install, do not edit by hand -->";
|
||||
|
||||
const kind = "launchd";
|
||||
const label = "LaunchAgent";
|
||||
|
||||
function jobLabel(name) {
|
||||
return `io.smartgift.${name}`;
|
||||
}
|
||||
|
||||
function unitPath(name) {
|
||||
return path.join(os.homedir(), "Library", "LaunchAgents", `${jobLabel(name)}.plist`);
|
||||
}
|
||||
|
||||
function logPath() {
|
||||
return path.join(os.homedir(), "Library", "Logs", "agenthub.log");
|
||||
}
|
||||
|
||||
function domain() {
|
||||
return `gui/${process.getuid()}`;
|
||||
}
|
||||
|
||||
let execImpl = (args, opts) => spawnSync("launchctl", args, opts);
|
||||
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
function __reset() {
|
||||
execImpl = (args, opts) => spawnSync("launchctl", args, opts);
|
||||
}
|
||||
|
||||
function launchctl(args) {
|
||||
return execImpl(args, { encoding: "utf8", stdio: "pipe" });
|
||||
}
|
||||
|
||||
/**
|
||||
* A LaunchAgent lives in the user's GUI domain, which exists only while that
|
||||
* user is logged in at the console. Over a bare SSH login there is none, and
|
||||
* the caller falls back to its detached spawn.
|
||||
*/
|
||||
function systemctlAvailable() {
|
||||
if (process.platform !== "darwin") return false;
|
||||
return launchctl(["print", domain()]).status === 0;
|
||||
}
|
||||
|
||||
function readPlist(name) {
|
||||
try {
|
||||
return fs.readFileSync(unitPath(name), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function unitIsOurs(name) {
|
||||
const body = readPlist(name);
|
||||
return !!body && body.includes(MARKER);
|
||||
}
|
||||
|
||||
const xmlEscape = (s) =>
|
||||
String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const xmlUnescape = (s) => s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
||||
|
||||
function workingDirOf(body) {
|
||||
const m = body.match(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/);
|
||||
return m ? xmlUnescape(m[1]) : null;
|
||||
}
|
||||
|
||||
/** Same contract as the systemd twin: pure fs, never a foreign plist. */
|
||||
function unitIsStale(name, root) {
|
||||
const body = readPlist(name);
|
||||
if (!body || !body.includes(MARKER)) return false;
|
||||
return workingDirOf(body) !== root;
|
||||
}
|
||||
|
||||
/** The directory the plist runs from — ours or not (the cache pruner's guard). */
|
||||
function unitRoot(name) {
|
||||
const body = readPlist(name);
|
||||
return body ? workingDirOf(body) : null;
|
||||
}
|
||||
|
||||
function isLoaded(name) {
|
||||
return launchctl(["print", `${domain()}/${jobLabel(name)}`]).status === 0;
|
||||
}
|
||||
|
||||
function unitIsActive(name) {
|
||||
if (!systemctlAvailable()) return false;
|
||||
const res = launchctl(["print", `${domain()}/${jobLabel(name)}`]);
|
||||
return res.status === 0 && /^\s*state = running$/m.test(String(res.stdout || ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* `argv` is the program and its arguments as separate strings (the systemd
|
||||
* twin's `execStart` is one line; a path with a space would split here).
|
||||
* `KeepAlive` true is `Restart=always`. `AbandonProcessGroup` keeps the lane
|
||||
* panes alive: they run in a tmux server the dashboard spawned, and launchd
|
||||
* otherwise kills a stopped job's whole process group — every running agent
|
||||
* with it, on every update.
|
||||
*/
|
||||
function unitContent({ name, workingDir, argv, env }) {
|
||||
const str = (v) => `<string>${xmlEscape(v)}</string>`;
|
||||
const envLines = Object.entries(env)
|
||||
.map(([k, v]) => `\t\t<key>${xmlEscape(k)}</key>\n\t\t${str(v)}`)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
${MARKER}
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
${str(jobLabel(name))}
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${argv.map((a) => `\t\t${str(a)}`).join("\n")}
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
${str(workingDir)}
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
${envLines}
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>3</integer>
|
||||
<key>AbandonProcessGroup</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
${str(logPath())}
|
||||
<key>StandardErrorPath</key>
|
||||
${str(logPath())}
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the plist and clears any "disabled" override — and starts nothing.
|
||||
* A loaded job keeps running its OLD definition until `restartUserService`;
|
||||
* an unloaded one is picked up at the next login. Same split, same reason, as
|
||||
* the systemd twin.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function writeUserService({ name, workingDir, argv, env = {} }) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const body = unitContent({ name, workingDir, argv, env });
|
||||
const target = unitPath(name);
|
||||
try {
|
||||
const current = readPlist(name);
|
||||
if (current !== null) {
|
||||
if (!current.includes(MARKER)) return "foreign";
|
||||
if (current === body) return "unchanged";
|
||||
}
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(logPath()), { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the job from its plist. `kickstart -k` would restart the process but
|
||||
* keep the definition launchd already holds — the old plugin root — so it is
|
||||
* bootout + bootstrap. Unlike systemd, the caller is not reaped with the job:
|
||||
* it runs in its own process group and the job abandons its group anyway.
|
||||
*/
|
||||
function restartUserService(name) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
launchctl(["bootout", `${domain()}/${jobLabel(name)}`]); // not loaded: fine
|
||||
const res = launchctl(["bootstrap", domain(), unitPath(name)]);
|
||||
return res.status === 0 ? "restarted" : "failed";
|
||||
}
|
||||
|
||||
/** Write, then load and start. For `agenthub service install` and a bootstrap
|
||||
* that owns no running job. */
|
||||
function installUserService(opts) {
|
||||
const result = writeUserService(opts);
|
||||
if (result === "written" || (result === "unchanged" && !isLoaded(opts.name))) {
|
||||
if (restartUserService(opts.name) !== "restarted") return "failed";
|
||||
} else if (result === "unchanged") {
|
||||
launchctl(["kickstart", `${domain()}/${jobLabel(opts.name)}`]); // start if stopped
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
|
||||
function uninstallUserService(name) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const body = readPlist(name);
|
||||
if (body === null) return "absent";
|
||||
if (!body.includes(MARKER)) return "foreign";
|
||||
launchctl(["bootout", `${domain()}/${jobLabel(name)}`]);
|
||||
fs.rmSync(unitPath(name), { force: true });
|
||||
return "removed";
|
||||
}
|
||||
|
||||
/** A LaunchAgent starts at login by definition; there is no linger to grant. */
|
||||
function enableLinger() {
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
kind,
|
||||
label,
|
||||
systemctlAvailable,
|
||||
unitIsOurs,
|
||||
unitIsStale,
|
||||
unitRoot,
|
||||
unitIsActive,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
uninstallUserService,
|
||||
enableLinger,
|
||||
unitPath,
|
||||
logPath,
|
||||
__setExecImpl,
|
||||
__reset,
|
||||
};
|
||||
+34
-68
@@ -9,7 +9,8 @@
|
||||
* The worker installs runtime dependencies, removes hook entries left behind by
|
||||
* an older `npm run install-hooks` (they would double-count every event), puts
|
||||
* the `agenthub` CLI on PATH, and starts the dashboard server — as a systemd
|
||||
* `--user` service when available (see `systemd-service.js`), so it survives
|
||||
* `--user` service or a macOS LaunchAgent when available (see
|
||||
* `service-manager.js`), so it survives
|
||||
* reboots without Claude Code being opened first; otherwise a detached spawn
|
||||
* scoped to this session, same as before.
|
||||
*
|
||||
@@ -160,16 +161,12 @@ function fastPathOk(rt = runtimeDir()) {
|
||||
if (state.pluginRoot !== PLUGIN_ROOT) return false;
|
||||
if (state.depsHash !== depsHash()) return false;
|
||||
if (!fs.existsSync(path.join(rt, "node_modules"))) return false;
|
||||
// A machine can be fully set up and still be missing the plugins the
|
||||
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the
|
||||
// actual check (manifest + every user SKILL.md) runs once, in the worker.
|
||||
if (!state.skillPluginsSettled) return false;
|
||||
// A live server says nothing about whether it will come back. When our unit
|
||||
// still names a previous plugin cache directory, everything looks healthy
|
||||
// until the next reboot — and then the dashboard is simply gone, from a path
|
||||
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
|
||||
// path; `unitIsStale` deliberately spawns nothing.
|
||||
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
|
||||
if (require("./service-manager").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
|
||||
// Leftover checkout hooks are the one defect that corrupts the DATA rather
|
||||
// than the install: events carry no id, so a second handler doubles every
|
||||
// event, every subagent row and every turn. `stripLegacyHooks` removes them —
|
||||
@@ -359,13 +356,15 @@ function cliDir() {
|
||||
*
|
||||
* @returns {"written"|"exists"|"foreign"|"failed"}
|
||||
*/
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath) {
|
||||
const marker = "# agenthub-plugin-launcher";
|
||||
const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
|
||||
const body =
|
||||
process.platform === "win32"
|
||||
? `@rem agenthub-plugin-launcher\r\n@node "${path.join(root, "bin", "agenthub.js")}" %*\r\n`
|
||||
: `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`;
|
||||
: // The pinned node is a versioned path (Homebrew Cellar, nvm) that an
|
||||
// upgrade deletes; fall back to whatever `node` PATH has then.
|
||||
`#!/bin/sh\n${marker}\nN="${nodePath}"; [ -x "$N" ] || N=node\nexec "$N" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`;
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
const current = fs.readFileSync(target, "utf8");
|
||||
@@ -419,7 +418,13 @@ function liveServers() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
|
||||
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
|
||||
return servers.filter((s) => s && isPidAlive(s.pid));
|
||||
// An entry older than this boot is a leftover whose pid may now belong to
|
||||
// an unrelated process (macOS has no systemd to clean up after a reboot).
|
||||
// 60 s margin for clock skew; an unparseable startedAt is kept.
|
||||
// ponytail: an NTP step larger than 60 s after boot misreads a live entry
|
||||
// as stale; compare against a monotonic boot id if that ever bites.
|
||||
const bootedAt = Date.now() - os.uptime() * 1000 - 60_000;
|
||||
return servers.filter((s) => s && isPidAlive(s.pid) && !(Date.parse(s.startedAt) < bootedAt));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -491,46 +496,6 @@ function log(rt, line) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the plugins AgentHub's pipelines drive but this machine cannot resolve —
|
||||
* today `superpowers`, which `/agenthub:ship-feature(-lane)` calls step by step. A
|
||||
* fresh `agenthub` install otherwise runs those skills partway and fails at the
|
||||
* first call with no warning beforehand.
|
||||
*
|
||||
* Off by setting `AGENTHUB_AUTO_INSTALL_PLUGINS` to `0`, `false` or `off`; that
|
||||
* leaves `/agenthub-doctor`'s WARN row as the only notice, which is the right
|
||||
* behaviour for anyone who wants to choose their own plugins.
|
||||
*
|
||||
* One-shot: the outcome is recorded in the runtime state and the fast path
|
||||
* takes it into account, so a machine with no network does not re-attempt a
|
||||
* clone on every session. `/agenthub-update` (`--force`) retries.
|
||||
*
|
||||
* Best-effort throughout. Nothing here may throw or block: the dashboard,
|
||||
* hooks and CLI all work without these plugins.
|
||||
*
|
||||
* @returns {"off"|"nothing-missing"|"installed"|"failed"|"error"} or the previously
|
||||
* recorded outcome when this run had nothing to settle.
|
||||
*/
|
||||
function autoInstallSkillPlugins(rt, { force = false } = {}) {
|
||||
const flag = process.env.AGENTHUB_AUTO_INSTALL_PLUGINS;
|
||||
if (flag === "0" || flag === "false" || flag === "off") return "off";
|
||||
// Return the PREVIOUS outcome, not a marker: writeState records whatever this
|
||||
// returns, and overwriting "installed" with "already-settled" would lose the
|
||||
// only record of what actually happened.
|
||||
const settled = readState(rt)?.skillPluginsSettled;
|
||||
if (!force && settled) return settled;
|
||||
|
||||
try {
|
||||
const { autoInstallSkillPlugins: install } = require("../server/lib/skill-plugins");
|
||||
const summary = install((line) => log(rt, line));
|
||||
if (!summary.attempted.length) return "nothing-missing";
|
||||
return summary.failed.length ? "failed" : "installed";
|
||||
} catch (err) {
|
||||
log(rt, `skill-plugin auto-install skipped: ${err.message}`);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ plugin-cache pruning */
|
||||
|
||||
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
|
||||
@@ -583,7 +548,7 @@ function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) {
|
||||
/* no record to read — the other three protections still apply */
|
||||
}
|
||||
try {
|
||||
const unitRoot = require("./systemd-service").unitRoot("agenthub");
|
||||
const unitRoot = require("./service-manager").unitRoot("agenthub");
|
||||
if (unitRoot) keep.add(path.resolve(unitRoot));
|
||||
} catch {
|
||||
/* best effort */
|
||||
@@ -716,7 +681,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
log(rt, "built the dashboard UI bundle");
|
||||
}
|
||||
|
||||
const svc = require("./systemd-service");
|
||||
const svc = require("./service-manager");
|
||||
// Who owns the running server decides how it may be retired. A unit of
|
||||
// ours means systemd owns it — and this process is a descendant of that
|
||||
// server, so it sits in the unit's cgroup. SIGTERM-ing the main process
|
||||
@@ -755,6 +720,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
description: "AgentHub dashboard server (Claude Code plugin)",
|
||||
workingDir: PLUGIN_ROOT,
|
||||
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
|
||||
argv: [process.execPath, path.join(PLUGIN_ROOT, "server", "index.js")],
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: path.join(rt, "node_modules"),
|
||||
@@ -784,13 +750,14 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
log(
|
||||
rt,
|
||||
result === "unchanged"
|
||||
? "systemd --user service already up to date"
|
||||
? `${svc.label} already up to date`
|
||||
: unitStale && !needsServer
|
||||
? "repointed the systemd --user service at the current plugin version (it named a retired one)"
|
||||
: "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
|
||||
? `repointed the ${svc.label} at the current plugin version (it named a retired one)`
|
||||
: `installed a ${svc.label} — the dashboard now survives reboots without Claude Code`
|
||||
);
|
||||
} else if (needsServer) {
|
||||
// No systemd (macOS/Windows/WSL without it), or the unit exists but
|
||||
// No service manager (Windows, WSL without systemd, a Mac over SSH with
|
||||
// no GUI login), or the unit exists but
|
||||
// wasn't ours to touch — same detached-spawn fallback as before,
|
||||
// scoped to this session only. Guarded by `needsServer`: entering this
|
||||
// block for a stale unit alone must never spawn a second server onto
|
||||
@@ -798,26 +765,17 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
const pid = startDashboard(rt);
|
||||
log(
|
||||
rt,
|
||||
`started the dashboard server (pid ${pid})${result === "foreign" ? " — a foreign systemd unit named 'agenthub' already exists" : ""}`
|
||||
`started the dashboard server (pid ${pid})${result === "foreign" ? ` — a foreign ${svc.label} named 'agenthub' already exists` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Last, and deliberately so: it clones from the network, and nothing above
|
||||
// may wait on it. Runs here rather than in the foreground pass because this
|
||||
// whole function is already detached from the 10 s SessionStart hook.
|
||||
const skillPlugins = autoInstallSkillPlugins(rt, { force });
|
||||
|
||||
writeState(
|
||||
{
|
||||
pluginVersion: pluginVersion(),
|
||||
depsHash: wantDeps,
|
||||
pluginRoot: PLUGIN_ROOT,
|
||||
claudeHome: getClaudeHome(),
|
||||
// Settles the fast path: whatever the outcome, the next SessionStart
|
||||
// must not spawn a worker just to retry a network clone. Cleared by
|
||||
// `/agenthub-update` (force), which is the retry.
|
||||
skillPluginsSettled: skillPlugins,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
rt
|
||||
@@ -835,8 +793,17 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
// file is written and the lock is gone, so even being killed here leaves
|
||||
// no debris and no half-recorded bootstrap.
|
||||
if (pendingSystemdRestart) {
|
||||
log(rt, "handing the server over to systemd (restarting the unit)");
|
||||
require("./systemd-service").restartUserService("agenthub");
|
||||
const svc = require("./service-manager");
|
||||
log(rt, `handing the server over to ${svc.kind} (restarting the unit)`);
|
||||
// On launchd the restart is bootout + bootstrap, and a failed bootstrap
|
||||
// leaves NOTHING running (systemd's restart either happens or leaves the
|
||||
// old server up). This process survives it there, so fall back.
|
||||
if (svc.restartUserService("agenthub") === "failed" && !serverIsLive()) {
|
||||
log(
|
||||
rt,
|
||||
`${svc.kind} could not start the server — started it detached (pid ${startDashboard(rt)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -910,6 +877,5 @@ module.exports = {
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
autoInstallSkillPlugins,
|
||||
bootstrap,
|
||||
};
|
||||
|
||||
+17
-46
@@ -6,10 +6,8 @@
|
||||
* declared package, not just that node_modules exists), server liveness,
|
||||
* duplicate hook entries (the one failure that silently doubles every token and
|
||||
* cost figure), the `agenthub` CLI launcher and its PATH, the `tmux` binary
|
||||
* every browser-started run depends on, whether the slash
|
||||
* commands the pipeline templates name as node drivers are actually resolvable
|
||||
* (the `superpowers` plugin, today), and the dashboard bundle — quoting the last
|
||||
* build error when it is missing.
|
||||
* every browser-started run depends on, and the dashboard bundle — quoting the
|
||||
* last build error when it is missing.
|
||||
* Read-only — it diagnoses, it never repairs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -85,7 +83,6 @@ function diagnose() {
|
||||
add(...cliStatus());
|
||||
add(...systemdStatus());
|
||||
add(...tmuxStatus());
|
||||
add(...skillPluginStatus());
|
||||
|
||||
add(...dashboardUiStatus(rt));
|
||||
|
||||
@@ -190,37 +187,6 @@ function lastBuildError(logPath) {
|
||||
return hit ? hit.trim().slice(0, 200) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WARN (never FAIL) when a plugin the pipelines depend on cannot be resolved:
|
||||
* the dashboard, hooks and CLI all work without it — only the pipeline
|
||||
* skills that drive it stop partway, so this must not fail a doctor run.
|
||||
*
|
||||
* The requirement derivation and the missing/satisfied rules live in
|
||||
* `server/lib/skill-plugins.js`, shared with the bootstrap that installs them.
|
||||
*/
|
||||
function skillPluginStatus() {
|
||||
const skillPlugins = require("../server/lib/skill-plugins");
|
||||
const required = skillPlugins.requiredSkillPlugins();
|
||||
if (!required.size) return ["ok", "Skill plugins", "no pipeline names an external plugin"];
|
||||
|
||||
let missing;
|
||||
try {
|
||||
missing = skillPlugins.missingSkillPlugins();
|
||||
} catch {
|
||||
return ["warn", "Skill plugins", "could not read the installed-plugin manifest"];
|
||||
}
|
||||
if (!missing.length) {
|
||||
return ["ok", "Skill plugins", `${[...required.keys()].sort().join(", ")} available`];
|
||||
}
|
||||
return [
|
||||
"warn",
|
||||
"Skill plugins",
|
||||
`${missing.map((m) => `${m.name} (needed for ${m.skills.join(", ")})`).join("; ")} — ` +
|
||||
`the pipelines name these commands; install with ` +
|
||||
`claude plugin install ${missing.map((m) => m.autoInstall?.spec || m.name).join(" ")}`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure this row exists for is silent by construction: a unit still
|
||||
* naming a retired plugin cache directory keeps a running dashboard looking
|
||||
@@ -233,16 +199,17 @@ function skillPluginStatus() {
|
||||
* about.
|
||||
*/
|
||||
function systemdStatus() {
|
||||
const svc = require("./systemd-service");
|
||||
const svc = require("./service-manager");
|
||||
const unit = svc.unitPath("agenthub");
|
||||
const row = svc.kind;
|
||||
|
||||
if (!svc.unitIsOurs("agenthub")) {
|
||||
if (fs.existsSync(unit)) {
|
||||
return ["warn", "systemd", `${unit} exists but was not written by AgentHub — left untouched`];
|
||||
return ["warn", row, `${unit} exists but was not written by AgentHub — left untouched`];
|
||||
}
|
||||
return [
|
||||
"ok",
|
||||
"systemd",
|
||||
row,
|
||||
svc.systemctlAvailable()
|
||||
? "no unit — the server is started per session (install one with `agenthub service install`)"
|
||||
: "not available on this platform — the server is started per session",
|
||||
@@ -252,7 +219,7 @@ function systemdStatus() {
|
||||
if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) {
|
||||
return [
|
||||
"warn",
|
||||
"systemd",
|
||||
row,
|
||||
`${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` +
|
||||
`Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`,
|
||||
];
|
||||
@@ -261,19 +228,21 @@ function systemdStatus() {
|
||||
if (!svc.unitIsActive("agenthub")) {
|
||||
return [
|
||||
"warn",
|
||||
"systemd",
|
||||
"unit is installed and current but not running — start it with `systemctl --user start agenthub`",
|
||||
row,
|
||||
svc.kind === "launchd"
|
||||
? "LaunchAgent is installed and current but not running — start it with `agenthub service install`"
|
||||
: "unit is installed and current but not running — start it with `systemctl --user start agenthub`",
|
||||
];
|
||||
}
|
||||
return ["ok", "systemd", "unit is current and running — the dashboard survives reboots"];
|
||||
return ["ok", row, `${svc.label} is current and running — the dashboard survives reboots`];
|
||||
}
|
||||
|
||||
/**
|
||||
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
||||
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
||||
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
|
||||
* sessions, analytics, lanes and the CLI keep working. Same reasoning as the
|
||||
* skill-plugins row: a partial feature loss must not fail a doctor run.
|
||||
* sessions, analytics, lanes and the CLI keep working. A partial feature
|
||||
* loss must not fail a doctor run.
|
||||
*
|
||||
* This checks the doctor's own PATH. A server started as a systemd `--user`
|
||||
* service can still see a different one — `GET /api/run/tmux` is the server's
|
||||
@@ -316,7 +285,9 @@ function cliStatus() {
|
||||
: [
|
||||
"warn",
|
||||
"agenthub CLI",
|
||||
`${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`,
|
||||
`${file} exists but ${dir} is not on PATH — add${
|
||||
process.platform === "darwin" ? " to ~/.zshrc" : ""
|
||||
}: export PATH="${dir}:$PATH"`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file service-manager.js
|
||||
* @description Picks the per-user service manager that keeps the dashboard
|
||||
* running without Claude Code: a LaunchAgent on macOS (`launchd-service.js`),
|
||||
* a systemd `--user` unit everywhere else (`systemd-service.js`, which reports
|
||||
* itself unavailable off Linux). Both export the same API.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
module.exports =
|
||||
process.platform === "darwin" ? require("./launchd-service") : require("./systemd-service");
|
||||
@@ -224,6 +224,8 @@ function enableLinger() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
kind: "systemd",
|
||||
label: "systemd --user service",
|
||||
systemctlAvailable,
|
||||
unitIsOurs,
|
||||
unitIsStale,
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
* (including the pane the test was launched from). Dropping `TMUX` and
|
||||
* pointing `TMUX_TMPDIR` at a fresh directory gives every tmux call — the
|
||||
* app's own and the tests' direct `execFileSync("tmux", …)` — its own socket.
|
||||
* It also points `DASHBOARD_PIPELINES_DIR` at `server/__tests__/fixtures/
|
||||
* pipelines/`: AgentHub ships no pipeline template any more, and the two old
|
||||
* ones kept there are what the dormant stage/detection code is tested against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,7 +29,11 @@ const files = fs
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
|
||||
const env = { ...process.env, TMUX_TMPDIR: tmuxDir };
|
||||
const env = {
|
||||
...process.env,
|
||||
TMUX_TMPDIR: tmuxDir,
|
||||
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
||||
};
|
||||
delete env.TMUX;
|
||||
delete env.TMUX_PANE;
|
||||
|
||||
|
||||
+13
-27
@@ -524,7 +524,7 @@ Request body shape:
|
||||
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
|
||||
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
|
||||
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
@@ -532,7 +532,10 @@ Request body shape:
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
|
||||
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
|
||||
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
|
||||
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
an existing absolute git repository. The response lane starts as
|
||||
@@ -581,30 +584,12 @@ pipeline's last node, where the pipeline is finished and a later detection is
|
||||
new work — and, the one rule that matters, never counts as evidence and never
|
||||
renders a node as `done` (see `docs/LANES.md#stage-detection`).
|
||||
|
||||
A `codex` lane has no hooks to read, so `server/lib/codex-ingest.js` feeds the
|
||||
same matcher from Codex's own rollout journals under
|
||||
`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables) and mapping Codex's
|
||||
tool calls onto the `{tool_name, tool_input}` shape a hook delivers. The same
|
||||
pass mirrors that lane's `status` between `idle` and `running` from the
|
||||
journal's mtime, under the guard hook ingestion uses (no `run_id`, only from
|
||||
`idle`/`running`); it writes nothing else.
|
||||
|
||||
Getting a codex lane's *declared* stage to actually happen is a separate
|
||||
problem — only the session itself can call `agenthub stage`, and unlike a Claude
|
||||
lane it has no slash command to pre-fill — and the run console pre-fills
|
||||
nothing into the prompt box any more, for either tool, since anything sitting
|
||||
there is sent as a real prompt on start. What is left is
|
||||
`server/lib/codex-nudge.js`'s `sweepCodexNudges()` runs on an unref'd 60s
|
||||
interval (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables): when a codex lane's fresh
|
||||
`detected_stage` has sat ahead of its declared `stage` for at least two
|
||||
minutes, it types a one-line `[agenthub] ... run: agenthub stage ...` reminder into
|
||||
the lane's live tmux pane via `tmux.sendText` — skipped if the pane sits at a
|
||||
bare shell prompt (`pty-run.js`'s `SHELL_COMMANDS`), since pressing Enter there
|
||||
would run the reminder as a shell command instead of submitting it to Codex.
|
||||
It never writes `stage` or `detected_stage`, so declared-wins and
|
||||
inferred-never-`done` are untouched; any resulting declaration is still the
|
||||
codex session's own real `agenthub stage` call.
|
||||
A `codex` lane has no hooks to read and carries no pipeline, so nothing detects
|
||||
or advances its stage. `server/lib/codex-ingest.js` only mirrors its `status`
|
||||
between `idle` and `running` from the mtime of Codex's own rollout journals
|
||||
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook
|
||||
ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else.
|
||||
|
||||
### Remote Data Sources
|
||||
|
||||
@@ -694,7 +679,7 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
|
||||
|
||||
### Run a lane's CLI (`/api/run`)
|
||||
|
||||
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares (`claude` or `codex`). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
|
||||
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares (`claude`, `codex`, or `agy` for an `antigravity` lane). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ----------------------------- | ----------- |
|
||||
@@ -703,6 +688,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
|
||||
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
|
||||
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
||||
|
||||
@@ -43,13 +43,13 @@ function stubTmux(sessions, panes = {}) {
|
||||
calls.push(args);
|
||||
if (args[0] === "list-sessions") return sessions.join("\n");
|
||||
if (args[0] === "has-session") {
|
||||
if (sessions.includes(args[2])) return "";
|
||||
if (sessions.includes(args[2].slice(1))) return "";
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
if (args[0] === "display-message") {
|
||||
const name = args[3];
|
||||
const name = args[3].slice(1, -1);
|
||||
const cmd = panes[name] ?? "claude";
|
||||
if (cmd === null) {
|
||||
const e = new Error("pane gone");
|
||||
@@ -59,7 +59,7 @@ function stubTmux(sessions, panes = {}) {
|
||||
return `${cmd}\n`;
|
||||
}
|
||||
if (args[0] === "send-keys") {
|
||||
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
if (panes[args[2].slice(1, -1)] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Unit tests for Codex stage ingestion: the payload→hook-event mapping,
|
||||
* and one full pass over a fixture rollout journal (detection + status mirror +
|
||||
* broadcast + the no-new-bytes no-op).
|
||||
* @file Unit tests for the Codex lane status mirror: one pass over fixture
|
||||
* rollout journals flips a codex lane idle↔running, broadcasts it, and never
|
||||
* touches a lane's stage.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,87 +26,11 @@ function line(payload, type = "response_item") {
|
||||
return JSON.stringify({ timestamp: new Date().toISOString(), ordinal: 1, type, payload });
|
||||
}
|
||||
|
||||
describe("codex payload mapping", () => {
|
||||
it("maps a function_call exec_command to a Bash event", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: "/repo" }),
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps a custom_tool_call exec to a Bash event carrying the raw script", () => {
|
||||
const [event] = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input: 'const r = await tools.exec_command({cmd:"git push origin feat/x"}); text(r.output);',
|
||||
});
|
||||
assert.equal(event.tool_name, "Bash");
|
||||
assert.match(event.tool_input.command, /git push origin feat\/x/);
|
||||
});
|
||||
|
||||
it("maps an apply_patch script to one event per touched file", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input:
|
||||
'const patch = "*** Begin Patch\n*** Update File: server/lib/lanes.js\n+x\n' +
|
||||
'*** Add File: docs/NEW.md\n+y\n*** End Patch";',
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Edit", tool_input: { file_path: "server/lib/lanes.js" } },
|
||||
{ tool_name: "Write", tool_input: { file_path: "docs/NEW.md" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps an apply_patch script whose newlines are still escaped", () => {
|
||||
// Verbatim from a real rollout: the patch heredoc lives inside a JS string
|
||||
// literal, so after JSON.parse the line breaks are a literal backslash-n,
|
||||
// not newlines. Matching only real newlines silently sees no files at all.
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input:
|
||||
'const patch = "*** Begin Patch\\n*** Add File: config/demo.yml\\n+# demo\\n*** End Patch";',
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Write", tool_input: { file_path: "config/demo.yml" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps update_plan to TaskCreate and spawn_agent to Agent", () => {
|
||||
assert.deepEqual(
|
||||
codex.eventsFromPayload({ type: "function_call", name: "update_plan", arguments: "{}" }),
|
||||
[{ tool_name: "TaskCreate", tool_input: {} }]
|
||||
);
|
||||
assert.deepEqual(
|
||||
codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: "spawn_agent",
|
||||
arguments: JSON.stringify({ task_name: "code_reviewer", message: "…" }),
|
||||
}),
|
||||
[{ tool_name: "Agent", tool_input: { subagent_type: "code_reviewer" } }]
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores anything that is not a tool call", () => {
|
||||
assert.deepEqual(codex.eventsFromPayload({ type: "reasoning" }), []);
|
||||
assert.deepEqual(
|
||||
codex.eventsFromPayload({ type: "custom_tool_call_output", name: "exec" }),
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(codex.eventsFromPayload({ type: "function_call", name: "web_search" }), []);
|
||||
assert.deepEqual(codex.eventsFromPayload(null), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex ingest pass", () => {
|
||||
describe("codex status mirror", () => {
|
||||
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-"));
|
||||
const DIR = path.join(HOME, "sessions", "2026", "08", "27");
|
||||
let prevHome;
|
||||
let seq = 0;
|
||||
|
||||
before(() => {
|
||||
prevHome = process.env.CODEX_HOME;
|
||||
@@ -117,21 +41,18 @@ describe("codex ingest pass", () => {
|
||||
after(() => {
|
||||
if (prevHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevHome;
|
||||
codex.__resetOffsets();
|
||||
try {
|
||||
fs.rmSync(HOME, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
codex.__resetCache();
|
||||
fs.rmSync(HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A rollout journal for `cwd` with one `npm run test:server` shell call. */
|
||||
function writeRollout(name, cwd) {
|
||||
const file = path.join(DIR, name);
|
||||
function writeRollout(cwd, { mtime } = {}) {
|
||||
seq += 1;
|
||||
const file = path.join(DIR, `rollout-2026-08-27T10-00-00-${seq}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd, session_id: "abc" }, "session_meta"),
|
||||
line({ cwd, session_id: `s${seq}` }, "session_meta"),
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
@@ -139,256 +60,97 @@ describe("codex ingest pass", () => {
|
||||
}),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
if (mtime !== undefined) fs.utimesSync(file, mtime / 1000, mtime / 1000);
|
||||
codex.__resetCache();
|
||||
return file;
|
||||
}
|
||||
|
||||
it("detects a stage for a codex lane and mirrors its status", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-00-aaa.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
const codexLane = (prefix = "codex-lane-") =>
|
||||
lanes.createLane({
|
||||
title: "cx",
|
||||
cwd: fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||
tool: "codex",
|
||||
});
|
||||
|
||||
it("marks a codex lane running while its journal is fresh, and broadcasts it", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.detected_stage, "tests"); // `npm run test:server` → default template's tests node
|
||||
assert.match(after.detected_signal, /test:server/);
|
||||
assert.equal(after.status, "running"); // a journal written just now means work is happening
|
||||
assert.equal(lanes.getLane(lane.id).status, "running");
|
||||
assert.deepEqual(seen, [lane.id]);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("advances the declared stage from the detection, without evidence", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-10-adv.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
it("never writes a stage or a detection, whatever the journal ran", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
// The ring moves — a codex session has nothing else that would move it…
|
||||
assert.equal(after.stage, "tests");
|
||||
// …but the node stays amber: an inference sees the tool call, not its result.
|
||||
assert.equal(after.stages.tests.evidence, null);
|
||||
// …and the badge survives, so the card still says WHY it moved.
|
||||
assert.equal(after.detected_stage, "tests");
|
||||
assert.match(after.detected_signal, /test:server/);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never drags an auto-advanced stage backwards", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
lanes.setStage(lane.id, { stage: "review", evidence: "declared by the session" });
|
||||
writeRollout("rollout-2026-08-27T10-00-11-back.jsonl", cwd); // detects `tests`, behind `review`
|
||||
codex.__resetOffsets();
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.stage, "review"); // declared-wins: recordDetection refused, so no advance
|
||||
assert.equal(after.stage, "idle");
|
||||
assert.equal(after.detected_stage, null);
|
||||
assert.equal(after.stages.review.evidence, "declared by the session");
|
||||
assert.equal(after.detected_signal, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("a second pass with no appended bytes changes nothing", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-01-bbb.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
it("a second pass with nothing new changes nothing", () => {
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const first = lanes.getLane(lane.id).detected_at;
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_at, first);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("puts a running lane back to idle once its journal goes quiet", () => {
|
||||
const lane = codexLane();
|
||||
lanes.updateLane(lane.id, { status: "running" });
|
||||
writeRollout(lane.cwd, { mtime: Date.now() - 6 * 60 * 60 * 1000 });
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("treats a journal whose mtime is in the FUTURE as idle", () => {
|
||||
// A clock stepped back (or a restore that preserved timestamps) leaves a
|
||||
// finished thread's mtime ahead of now; raw `now - mtime` would read as live.
|
||||
const lane = codexLane();
|
||||
writeRollout(lane.cwd, { mtime: Date.now() + 6 * 60 * 60 * 1000 });
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never touches a claude lane, even at the journal's own cwd", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "claude-lane-"));
|
||||
const lane = lanes.createLane({ title: "cc", cwd, tool: "claude" });
|
||||
writeRollout("rollout-2026-08-27T10-00-02-ccc.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(cwd);
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("ignores a journal whose cwd belongs to no lane", () => {
|
||||
const orphan = fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-03-ddd.jsonl", orphan);
|
||||
codex.__resetOffsets();
|
||||
const lane = codexLane();
|
||||
writeRollout(fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-")));
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("keeps byte offsets exact when a tail read starts mid multi-byte character", () => {
|
||||
// The first sight of a >64 KiB journal reads only its tail, and that offset
|
||||
// can land inside a 3-byte UTF-8 sequence. Splitting the decoded STRING and
|
||||
// measuring it back with Buffer.byteLength then counts the decoder's U+FFFD
|
||||
// replacement instead of the bytes actually consumed, so the next pass
|
||||
// starts 1-2 bytes off and every later record is unparseable.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-2026-08-27T10-00-06-utf8.jsonl");
|
||||
const record = (cmd) =>
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd, workdir: cwd }),
|
||||
});
|
||||
|
||||
// Grow an all-multi-byte filler line until the tail boundary (size - 64 KiB)
|
||||
// falls on a UTF-8 continuation byte.
|
||||
const head = line({ cwd, session_id: "utf8" }, "session_meta") + "\n";
|
||||
const tailRecord = record("npm run test:server") + "\n";
|
||||
let filler = "→".repeat(30_000);
|
||||
for (let pad = 0; pad < 8; pad += 1) {
|
||||
const body = head + filler + "\n" + tailRecord;
|
||||
const buf = Buffer.from(body, "utf8");
|
||||
const boundary = buf.length - 64 * 1024;
|
||||
if (boundary > 0 && (buf[boundary] & 0xc0) === 0x80) break;
|
||||
filler += "x";
|
||||
}
|
||||
fs.writeFileSync(file, head + filler + "\n" + tailRecord);
|
||||
const buf = Buffer.from(fs.readFileSync(file));
|
||||
assert.equal(buf[buf.length - 64 * 1024] & 0xc0, 0x80, "fixture must straddle a UTF-8 char");
|
||||
|
||||
codex.__resetOffsets();
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
|
||||
|
||||
// The real proof: the next append must still parse, which it only does if
|
||||
// the stored offset is exactly on the previous line boundary.
|
||||
fs.appendFileSync(file, record("git push origin feat/x") + "\n");
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "ship");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("ingests a line appended exactly at the previous read's boundary", () => {
|
||||
// The offset after a pass lands on a line boundary, so the next chunk's
|
||||
// FIRST line is a whole record — dropping it as torn loses every append
|
||||
// that arrives one line at a time, which is how a journal actually grows.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-2026-08-27T10-00-04-fff.jsonl");
|
||||
fs.writeFileSync(file, line({ cwd, session_id: "boundary" }, "session_meta") + "\n");
|
||||
codex.__resetOffsets();
|
||||
codex.ingestCodexOnce({}); // consumes the header, offset now at EOF
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
|
||||
fs.appendFileSync(
|
||||
file,
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
}) + "\n"
|
||||
);
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("re-reads a torn line whole on the next pass", () => {
|
||||
// A pass that catches the writer mid-line must not consume that fragment:
|
||||
// the record it belongs to would be lost forever.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-2026-08-27T10-00-05-ggg.jsonl");
|
||||
const record = line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
});
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
line({ cwd, session_id: "torn" }, "session_meta") + "\n" + record.slice(0, 40)
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
codex.ingestCodexOnce({});
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
|
||||
fs.appendFileSync(file, record.slice(40) + "\n"); // the writer finishes the line
|
||||
assert.equal(codex.ingestCodexOnce({}), 1);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, "tests");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("does not replay a journal it sees for the first time already idle", () => {
|
||||
// A thread that finished days ago must not paint a stage now — the first
|
||||
// sight of a stale file only records where it ends.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = writeRollout("rollout-2026-08-20T10-00-00-eee.jsonl", cwd);
|
||||
const old = Date.now() - 6 * 60 * 60 * 1000;
|
||||
fs.utimesSync(file, old / 1000, old / 1000);
|
||||
codex.__resetOffsets();
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("does not replay a journal whose mtime is in the FUTURE", () => {
|
||||
// A clock stepped back (or a restore that preserved timestamps) leaves a
|
||||
// finished thread's mtime ahead of now. Raw `now - mtime` is then negative,
|
||||
// which reads as "written milliseconds ago" and would replay it as live work.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = writeRollout("rollout-2026-08-27T10-00-00-fut.jsonl", cwd);
|
||||
const future = Date.now() + 6 * 60 * 60 * 1000;
|
||||
fs.utimesSync(file, future / 1000, future / 1000);
|
||||
codex.__resetOffsets();
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("ignores a torn/partial JSONL line at the end of a file", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-torn.jsonl");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd, session_id: "torn" }, "session_meta"),
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
}),
|
||||
// A torn last line: not valid JSON, so it will be skipped on next pass
|
||||
'{"timestamp":"2026-08-27T10:00:00.000Z","type":"response_item","paylo',
|
||||
].join("\n")
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.detected_stage, "tests"); // the valid line was processed
|
||||
assert.deepEqual(seen, [lane.id]);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("handles a journal whose session_meta is unparseable gracefully", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-bad-meta-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-bad-meta.jsonl");
|
||||
// Write an invalid JSON line as the first line
|
||||
fs.writeFileSync(file, "not valid json at all\n");
|
||||
codex.__resetOffsets();
|
||||
const lane = codexLane();
|
||||
fs.writeFileSync(path.join(DIR, "rollout-bad-meta.jsonl"), "not valid json at all\n");
|
||||
codex.__resetCache();
|
||||
|
||||
assert.equal(codex.ingestCodexOnce({}), 0);
|
||||
assert.equal(lanes.getLane(lane.id).detected_stage, null);
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
@@ -397,50 +159,31 @@ describe("codex ingest pass", () => {
|
||||
const inner = fs.mkdtempSync(path.join(outer, "inner"));
|
||||
const outerLane = lanes.createLane({ title: "outer", cwd: outer, tool: "codex" });
|
||||
const innerLane = lanes.createLane({ title: "inner", cwd: inner, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-nested.jsonl");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd: inner, session_id: "nested" }, "session_meta"),
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: inner }),
|
||||
}),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(inner);
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
// Inner lane should be the one detected, not outer
|
||||
assert.equal(lanes.getLane(innerLane.id).detected_stage, "tests");
|
||||
assert.equal(lanes.getLane(outerLane.id).detected_stage, null);
|
||||
assert.equal(lanes.getLane(innerLane.id).status, "running");
|
||||
assert.equal(lanes.getLane(outerLane.id).status, "idle");
|
||||
assert.deepEqual(seen, [innerLane.id]);
|
||||
lanes.deleteLane(innerLane.id);
|
||||
lanes.deleteLane(outerLane.id);
|
||||
});
|
||||
|
||||
it("does not touch status of a lane with an active run_id", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-run-id-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const lane = codexLane();
|
||||
lanes.updateLane(lane.id, { run_id: "active-run" });
|
||||
writeRollout("rollout-2026-08-27T10-00-10-rrr.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
// Status should not change because run_id is present
|
||||
assert.equal(after.status, "idle");
|
||||
assert.equal(lanes.getLane(lane.id).status, "idle");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("does not change status from provisioning or failed", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-status-protected-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const lane = codexLane();
|
||||
lanes.updateLane(lane.id, { status: "provisioning" });
|
||||
writeRollout("rollout-2026-08-27T10-00-11-ppp.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
writeRollout(lane.cwd);
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
assert.equal(lanes.getLane(lane.id).status, "provisioning");
|
||||
@@ -455,119 +198,13 @@ describe("codex ingest pass", () => {
|
||||
const oldMs = process.env.DASHBOARD_CODEX_POLL_MS;
|
||||
try {
|
||||
process.env.DASHBOARD_CODEX_POLL_MS = "0";
|
||||
// startCodexPoll should return early and not set up an interval
|
||||
const timer = codex.startCodexPoll(() => {});
|
||||
assert.equal(timer, undefined);
|
||||
assert.equal(
|
||||
codex.startCodexPoll(() => {}),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
if (oldMs === undefined) delete process.env.DASHBOARD_CODEX_POLL_MS;
|
||||
else process.env.DASHBOARD_CODEX_POLL_MS = oldMs;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles a shell call with command as an array in arguments", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ command: ["npm", "run", "test:server"], workdir: "/repo" }),
|
||||
});
|
||||
// Should join array elements
|
||||
assert.deepEqual(events, [
|
||||
{ tool_name: "Bash", tool_input: { command: "npm run test:server" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a payload with a non-string name", () => {
|
||||
// A payload with name as null or number should not match any tool
|
||||
const events1 = codex.eventsFromPayload({
|
||||
type: "function_call",
|
||||
name: null,
|
||||
arguments: "{}",
|
||||
});
|
||||
assert.deepEqual(events1, []);
|
||||
|
||||
const events2 = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: 123,
|
||||
input: "some script",
|
||||
});
|
||||
assert.deepEqual(events2, []);
|
||||
});
|
||||
|
||||
it("uses the LAST detection in a batch to move the lane forward", () => {
|
||||
// Within one ingest pass, if multiple tool calls map to different stages,
|
||||
// recordDetection is called in order. Only the LAST one that actually writes
|
||||
// should move the lane forward (forward-only). Simulate two tool calls:
|
||||
// first impl call (implement), then tests call (tests).
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-batch-last-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
const file = path.join(DIR, "rollout-batch-last.jsonl");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
line({ cwd, session_id: "batch" }, "session_meta"),
|
||||
// Edit event (implement node)
|
||||
line({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input: 'await tools.edit("src/main.js", "add x");',
|
||||
}),
|
||||
// Bash event (tests node)
|
||||
line({
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
|
||||
}),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
codex.__resetOffsets();
|
||||
|
||||
const seen = [];
|
||||
assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1);
|
||||
const after = lanes.getLane(lane.id);
|
||||
// Should detect the tests node (later in batch), not implement
|
||||
assert.equal(after.detected_stage, "tests");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("limits file scan to MAX_FILES newest files", () => {
|
||||
// Create more than MAX_FILES journal files and verify only newest are scanned
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-max-files-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
|
||||
// Create 50 old files (more than MAX_FILES=40)
|
||||
const oldTime = Date.now() - 2 * 60 * 60 * 1000;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const file = path.join(DIR, `rollout-old-${String(i).padStart(2, "0")}.jsonl`);
|
||||
fs.writeFileSync(file, line({ cwd, session_id: `old-${i}` }, "session_meta") + "\n");
|
||||
fs.utimesSync(file, oldTime / 1000, oldTime / 1000);
|
||||
}
|
||||
codex.__resetOffsets();
|
||||
|
||||
// The scan should not stall and should return (no detection expected since all files are stale)
|
||||
const result = codex.ingestCodexOnce({});
|
||||
// All files are stale and not active, so no broadcast
|
||||
assert.equal(result, 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("detects correctly with Delete File in patch heredoc", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input: 'const patch = "*** Begin Patch\\n*** Delete File: old/removed.js\\n*** End Patch";',
|
||||
});
|
||||
// Delete creates an Edit event (not Write, not ignored)
|
||||
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "old/removed.js" } }]);
|
||||
});
|
||||
|
||||
it("handles file paths with whitespace in patch patches", () => {
|
||||
const events = codex.eventsFromPayload({
|
||||
type: "custom_tool_call",
|
||||
name: "exec",
|
||||
input:
|
||||
'const patch = "*** Begin Patch\\n*** Update File: src/my file.js \\n*** End Patch";',
|
||||
});
|
||||
// File path should be trimmed
|
||||
assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "src/my file.js" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* @file Unit tests for the codex evidence safety net: nudges a live codex pane
|
||||
* to run `agenthub stage --evidence` when the lane has sat at an inferred (
|
||||
* evidence-less) stage past the delay, without ever writing stage itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const SUITE_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`dashboard-codex-nudge-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { db } = require("../db");
|
||||
const lanes = require("../lib/lanes");
|
||||
const tmux = require("../lib/tmux");
|
||||
const nudge = require("../lib/codex-nudge");
|
||||
|
||||
/** Backdate the current stage's enteredAt so it looks `minutes` old. */
|
||||
function backdateStage(laneId, minutes) {
|
||||
const lane = lanes.getLane(laneId);
|
||||
const stages = { ...lane.stages };
|
||||
stages[lane.stage] = {
|
||||
...stages[lane.stage],
|
||||
enteredAt: new Date(Date.now() - minutes * 60_000).toISOString(),
|
||||
};
|
||||
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId);
|
||||
}
|
||||
|
||||
describe("codex-nudge sweep", () => {
|
||||
let calls;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "codex"; // pane running the codex CLI
|
||||
return "";
|
||||
});
|
||||
nudge.__resetNudgeState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tmux.__reset();
|
||||
});
|
||||
|
||||
function makeCodexLane({ cwd, stage = "tests", evidence }) {
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
|
||||
lanes.setStage(lane.id, { stage, evidence });
|
||||
return lane;
|
||||
}
|
||||
|
||||
it("nudges once the lane has held an evidence-less stage past the delay", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` });
|
||||
backdateStage(lane.id, 3); // older than the 2 min delay
|
||||
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
|
||||
assert.equal(sendKeys.length, 1);
|
||||
assert.match(sendKeys[0][4], /agenthub stage tests --tool codex --evidence/);
|
||||
|
||||
// A second pass for the same still-unevidenced stage must not repeat it.
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges before the delay has elapsed", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` });
|
||||
// enteredAt is "now" — setStage just wrote it.
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a stage the session already backed with evidence", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" });
|
||||
backdateStage(lane.id, 10);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a lane that has not entered a pipeline node", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" });
|
||||
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a claude lane", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
|
||||
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
|
||||
lanes.setStage(lane.id, { stage: "tests" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a lane with no live run", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" });
|
||||
lanes.setStage(lane.id, { stage: "tests" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never types into a pane sitting at a bare shell prompt", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` });
|
||||
backdateStage(lane.id, 3);
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "bash";
|
||||
return "";
|
||||
});
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("nudges again once the lane moves to another evidence-less stage", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
|
||||
lanes.setStage(lane.id, { stage: "review" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Tests that the server drops the variables describing the Claude Code
|
||||
* session that started it (always the case on macOS, where there is no
|
||||
* systemd unit to give it a clean env) — tmux and every lane pane inherit the
|
||||
* server's env, so a leaked CLAUDECODE made each pane look like a nested
|
||||
* session. User configuration (CLAUDE_CONFIG_DIR, ANTHROPIC_*, …) must survive.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-envscrub-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
|
||||
process.env.CLAUDECODE = "1";
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||
process.env.CLAUDE_CODE_SESSION_ID = "abc";
|
||||
process.env.CLAUDE_CODE_MESSAGING_SOCKET = "/tmp/x.sock";
|
||||
process.env.CLAUDE_PLUGIN_ROOT = "/some/cache/dir";
|
||||
process.env.CLAUDE_CONFIG_DIR = "/Users/x/.claude-work";
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.ANTHROPIC_BASE_URL = "https://example.invalid";
|
||||
|
||||
require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
after(() => {
|
||||
db.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("strips the parent session's variables and keeps user configuration", () => {
|
||||
for (const k of [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION_ID",
|
||||
"CLAUDE_CODE_MESSAGING_SOCKET",
|
||||
"CLAUDE_PLUGIN_ROOT",
|
||||
]) {
|
||||
assert.equal(process.env[k], undefined, k);
|
||||
}
|
||||
assert.equal(process.env.CLAUDE_CONFIG_DIR, "/Users/x/.claude-work");
|
||||
assert.equal(process.env.CLAUDE_CODE_USE_BEDROCK, "1");
|
||||
assert.equal(process.env.ANTHROPIC_BASE_URL, "https://example.invalid");
|
||||
});
|
||||
+8
-44
@@ -9,11 +9,7 @@
|
||||
"icon": "📝",
|
||||
"gate": false,
|
||||
"command": "/superpowers:brainstorming",
|
||||
"aliases": [
|
||||
"assigned",
|
||||
"claimed",
|
||||
"start"
|
||||
],
|
||||
"aliases": ["assigned", "claimed", "start"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
@@ -31,11 +27,7 @@
|
||||
"icon": "🧭",
|
||||
"gate": false,
|
||||
"command": "/superpowers:writing-plans",
|
||||
"aliases": [
|
||||
"planning",
|
||||
"brainstorm",
|
||||
"design"
|
||||
],
|
||||
"aliases": ["planning", "brainstorm", "design"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -60,11 +52,7 @@
|
||||
"icon": "🛠",
|
||||
"gate": false,
|
||||
"command": "/superpowers:test-driven-development",
|
||||
"aliases": [
|
||||
"implementing",
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"aliases": ["implementing", "coding", "build"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -85,12 +73,7 @@
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"command": "npm test",
|
||||
"aliases": [
|
||||
"testing",
|
||||
"unit",
|
||||
"gates",
|
||||
"pre-push-gate"
|
||||
],
|
||||
"aliases": ["testing", "unit", "gates", "pre-push-gate"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
@@ -108,11 +91,7 @@
|
||||
"icon": "👀",
|
||||
"gate": true,
|
||||
"command": "/code-review",
|
||||
"aliases": [
|
||||
"reviewing",
|
||||
"code-review",
|
||||
"self-review"
|
||||
],
|
||||
"aliases": ["reviewing", "code-review", "self-review"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -134,12 +113,7 @@
|
||||
"icon": "🚦",
|
||||
"gate": true,
|
||||
"command": "/superpowers:verification-before-completion",
|
||||
"aliases": [
|
||||
"verify",
|
||||
"verification",
|
||||
"sr-gate",
|
||||
"gate-blocked"
|
||||
],
|
||||
"aliases": ["verify", "verification", "sr-gate", "gate-blocked"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -157,13 +131,7 @@
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"command": "/superpowers:finishing-a-development-branch",
|
||||
"aliases": [
|
||||
"pr",
|
||||
"pr-open",
|
||||
"publishing",
|
||||
"commit",
|
||||
"push"
|
||||
],
|
||||
"aliases": ["pr", "pr-open", "publishing", "commit", "push"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -181,11 +149,7 @@
|
||||
"icon": "✅",
|
||||
"gate": false,
|
||||
"command": "agenthub stage done",
|
||||
"aliases": [
|
||||
"complete",
|
||||
"completed",
|
||||
"merged"
|
||||
]
|
||||
"aliases": ["complete", "completed", "merged"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
-58
@@ -9,12 +9,7 @@
|
||||
"icon": "📝",
|
||||
"gate": false,
|
||||
"command": "/superpowers:brainstorming",
|
||||
"aliases": [
|
||||
"assigned",
|
||||
"claimed",
|
||||
"start",
|
||||
"bootstrapping"
|
||||
],
|
||||
"aliases": ["assigned", "claimed", "start", "bootstrapping"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -36,11 +31,7 @@
|
||||
"icon": "🧭",
|
||||
"gate": false,
|
||||
"command": "/superpowers:writing-plans",
|
||||
"aliases": [
|
||||
"planning",
|
||||
"brainstorm",
|
||||
"design"
|
||||
],
|
||||
"aliases": ["planning", "brainstorm", "design"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -65,11 +56,7 @@
|
||||
"icon": "🛠",
|
||||
"gate": false,
|
||||
"command": "/superpowers:test-driven-development",
|
||||
"aliases": [
|
||||
"implement",
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"aliases": ["implement", "coding", "build"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -91,12 +78,7 @@
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"command": "agenthub lanes hook ci-gate",
|
||||
"aliases": [
|
||||
"pre-push-gate",
|
||||
"tests",
|
||||
"migration-collision",
|
||||
"sync-conflict"
|
||||
],
|
||||
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
@@ -114,12 +96,7 @@
|
||||
"icon": "🧪",
|
||||
"gate": false,
|
||||
"command": "agenthub lanes up --qc",
|
||||
"aliases": [
|
||||
"e2e",
|
||||
"e2e-scoped",
|
||||
"booting",
|
||||
"live"
|
||||
],
|
||||
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
@@ -133,9 +110,7 @@
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"command": "agenthub stage e2e-feature-passed",
|
||||
"aliases": [
|
||||
"e2e-passed"
|
||||
]
|
||||
"aliases": ["e2e-passed"]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
@@ -143,11 +118,7 @@
|
||||
"icon": "👀",
|
||||
"gate": true,
|
||||
"command": "/code-review",
|
||||
"aliases": [
|
||||
"reviewing",
|
||||
"code-review",
|
||||
"self-review"
|
||||
],
|
||||
"aliases": ["reviewing", "code-review", "self-review"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -191,12 +162,7 @@
|
||||
"icon": "🚦",
|
||||
"gate": true,
|
||||
"command": "/superpowers:verification-before-completion",
|
||||
"aliases": [
|
||||
"sr-gate",
|
||||
"verify",
|
||||
"verification",
|
||||
"gate-blocked"
|
||||
],
|
||||
"aliases": ["sr-gate", "verify", "verification", "gate-blocked"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
@@ -214,9 +180,7 @@
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"command": "/superpowers:finishing-a-development-branch",
|
||||
"aliases": [
|
||||
"push"
|
||||
],
|
||||
"aliases": ["push"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
@@ -234,12 +198,7 @@
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"command": "gh pr create --base development",
|
||||
"aliases": [
|
||||
"ship",
|
||||
"pr",
|
||||
"push-conflict",
|
||||
"push-revalidate"
|
||||
],
|
||||
"aliases": ["ship", "pr", "push-conflict", "push-revalidate"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
@@ -261,9 +220,7 @@
|
||||
"icon": "👁",
|
||||
"gate": false,
|
||||
"command": "agenthub lanes ci status",
|
||||
"aliases": [
|
||||
"pr-comment-fix"
|
||||
],
|
||||
"aliases": ["pr-comment-fix"],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
@@ -285,10 +242,7 @@
|
||||
"icon": "✅",
|
||||
"gate": false,
|
||||
"command": "agenthub stage done",
|
||||
"aliases": [
|
||||
"complete",
|
||||
"completed"
|
||||
]
|
||||
"aliases": ["complete", "completed"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file Route tests for git credentials (issue #45): /api/git-credentials
|
||||
* never returns a password and refuses a cross-origin write, and a lane's
|
||||
* fetch against a remote that wants a login answers 409 EGITAUTH with the
|
||||
* host the git bar's login form needs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-route-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
|
||||
let app;
|
||||
let remote;
|
||||
let base;
|
||||
let host;
|
||||
|
||||
function request(method, p, body, headers = {}) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
remote = http.createServer((_req, res) => {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
|
||||
host = `127.0.0.1:${remote.address().port}`;
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => app.close(r));
|
||||
await new Promise((r) => remote.close(r));
|
||||
});
|
||||
|
||||
describe("/api/git-credentials", () => {
|
||||
it("saves, lists without the secret, and deletes", async () => {
|
||||
const saved = await request("POST", "/api/git-credentials", {
|
||||
protocol: "https",
|
||||
host: "git.example.test",
|
||||
username: "vi",
|
||||
password: "super-secret-token",
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
const list = await request("GET", "/api/git-credentials");
|
||||
assert.equal(list.body.items[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list.body).includes("super-secret-token"));
|
||||
const del = await request("DELETE", "/api/git-credentials/https/git.example.test");
|
||||
assert.deepEqual(del.body.items, []);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
"/api/git-credentials",
|
||||
{ protocol: "https", host: "h.test", username: "u", password: "p" },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/lanes/:id/git/fetch", () => {
|
||||
it("answers 409 EGITAUTH with the host when the remote wants a login", async () => {
|
||||
const repo = path.join(ROOT, "repo");
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
const lane = await request("POST", "/api/lanes/ensure", { cwd: repo, tool: "claude" });
|
||||
const r = await request("POST", `/api/lanes/${lane.body.lane.id}/git/fetch`, {});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "EGITAUTH");
|
||||
assert.equal(r.body.error.auth.host, host);
|
||||
assert.equal(r.body.error.auth.rejected, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file Tests for AgentHub's own git credential store and the credential
|
||||
* helper that hands it to git: save/list/remove at 0600, a list that never
|
||||
* carries the secret, auth-failure detection, and a REAL `git credential fill`
|
||||
* run through the helper — stored and use-once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
function fill(dir, cfg, env = {}) {
|
||||
// Exactly what git-ops passes, then git's own credential lookup.
|
||||
const out = execFileSync("git", [...cfg.args, "credential", "fill"], {
|
||||
cwd: dir,
|
||||
input: "protocol=https\nhost=git.example.test\n\n",
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...cfg.env, ...env },
|
||||
encoding: "utf8",
|
||||
});
|
||||
return Object.fromEntries(
|
||||
out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)])
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => fs.rmSync(path.join(ROOT, ".claude"), { recursive: true, force: true }));
|
||||
|
||||
describe("git credential store", () => {
|
||||
it("saves one record per protocol+host at 0600 and never lists the secret", () => {
|
||||
creds.save({ protocol: "https", host: "Git.Example.Test", username: "a", password: "t1" });
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "b", password: "t2" });
|
||||
const list = creds.list();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].host, "git.example.test");
|
||||
assert.equal(list[0].username, "b");
|
||||
assert.equal(list[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list).includes("t2"));
|
||||
assert.equal(fs.statSync(creds.credentialsFile()).mode & 0o777, 0o600);
|
||||
assert.equal(creds.remove("https", "git.example.test"), true);
|
||||
assert.deepEqual(creds.list(), []);
|
||||
});
|
||||
|
||||
it("refuses a host that is not a host", () => {
|
||||
for (const host of ["", "a/b", "x y", "../etc", "h\nost"]) {
|
||||
assert.throws(() => creds.save({ protocol: "https", host, username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
}
|
||||
assert.throws(() => creds.save({ protocol: "ssh", host: "h", username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises git's auth failures and names the host", () => {
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled"
|
||||
),
|
||||
{ protocol: "https", host: "git.smartgift.io.vn" }
|
||||
);
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"remote: Invalid username or password.\nfatal: Authentication failed for 'http://h.local:3000/o/r.git/'"
|
||||
),
|
||||
{ protocol: "http", host: "h.local:3000" }
|
||||
);
|
||||
assert.equal(creds.authFailure("! [rejected] main -> main (fetch first)"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git credential helper, through real git", () => {
|
||||
const repo = fs.mkdtempSync(path.join(ROOT, "repo-"));
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", "https://git.example.test/o/r.git"]);
|
||||
|
||||
it("answers from the store, and only our helper is asked", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.equal(cfg.args[1], "credential.helper=");
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.username, "u1");
|
||||
assert.equal(got.password, "stored");
|
||||
assert.ok(!cfg.args.join(" ").includes("stored"), "secret must not reach argv");
|
||||
});
|
||||
|
||||
it("prefers a use-once credential, which never touches the store", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const once = { protocol: "https", host: "git.example.test", username: "u2", password: "once" };
|
||||
const cfg = await creds.gitConfigFor(repo, once);
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.password, "once");
|
||||
assert.equal(creds.list()[0].username, "u1");
|
||||
assert.ok(!cfg.args.join(" ").includes("once"));
|
||||
});
|
||||
|
||||
it("changes nothing when AgentHub holds nothing for the repo's hosts", async () => {
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.deepEqual(cfg, { args: [], env: {} });
|
||||
});
|
||||
|
||||
it("refuses a use-once credential for a host the repo does not use", async () => {
|
||||
await assert.rejects(
|
||||
creds.gitConfigFor(repo, {
|
||||
protocol: "https",
|
||||
host: "evil.test",
|
||||
username: "u",
|
||||
password: "p",
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ const {
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
} = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { runGitAction, validateRemoteUrl } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
|
||||
@@ -660,3 +660,241 @@ describe("runGitAction — one hunk at a time", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runGitAction — one change block inside a hunk (issue #50)", () => {
|
||||
beforeEach(freshFixture);
|
||||
|
||||
const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
||||
/** Three change blocks close enough for git to merge into ONE hunk: lines
|
||||
* 4-5 replaced, a line inserted after 8, line 11 deleted. */
|
||||
function threeBlocks() {
|
||||
write(LANE, "near.txt", base.join("\n") + "\n");
|
||||
gc(LANE, "add", "near.txt");
|
||||
gc(LANE, "commit", "-qm", "near");
|
||||
const next = [...base];
|
||||
next.splice(3, 2, "FOUR", "FIVE");
|
||||
next.splice(8, 0, "INSERTED");
|
||||
next.splice(11, 1); // "line 11"
|
||||
write(LANE, "near.txt", next.join("\n") + "\n");
|
||||
return next;
|
||||
}
|
||||
const hunkOf = async () => {
|
||||
const [file] = (await collectChanges(LANE, { range: "work", file: "near.txt" })).files;
|
||||
return file.hunks;
|
||||
};
|
||||
const read = () => fs.readFileSync(path.join(LANE, "near.txt"), "utf8").split("\n");
|
||||
|
||||
it("sees the three blocks as one git hunk (the shape the issue reports)", async () => {
|
||||
threeBlocks();
|
||||
assert.equal((await hunkOf()).length, 1);
|
||||
});
|
||||
|
||||
it("stages ONE block, leaving the other two in the working tree", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 1 });
|
||||
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
|
||||
assert.match(staged, /^\+INSERTED$/m);
|
||||
assert.doesNotMatch(staged, /FOUR|FIVE|^-line 11$/m);
|
||||
const unstaged = g(LANE, "diff", "--", "near.txt");
|
||||
assert.match(unstaged, /^\+FOUR$/m);
|
||||
assert.match(unstaged, /^-line 11$/m);
|
||||
assert.doesNotMatch(unstaged, /^\+INSERTED$/m); // staged: only context now
|
||||
});
|
||||
|
||||
it("stages the LAST block after earlier ones shifted the line count", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 2 });
|
||||
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
|
||||
assert.match(staged, /^-line 11$/m);
|
||||
assert.doesNotMatch(staged, /FOUR|INSERTED/);
|
||||
});
|
||||
|
||||
it("undoes ONE block in the working tree, keeping the blocks around it", async () => {
|
||||
const edited = threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await runGitAction(LANE, "discard-hunk", {
|
||||
path: "near.txt",
|
||||
hunk: hunk.header,
|
||||
block: 0,
|
||||
confirm: true,
|
||||
});
|
||||
const want = [...edited];
|
||||
want.splice(3, 2, "line 4", "line 5");
|
||||
assert.deepEqual(read(), [...want, ""]);
|
||||
});
|
||||
|
||||
it("splits a hunk whose last block only drops the final newline", async () => {
|
||||
// One hunk: line 2 replaced, and line 6 losing its final newline — git
|
||||
// writes that second block with a "\\ No newline" marker after its "+".
|
||||
write(LANE, "tail.txt", "l1\nl2\nl3\nl4\nl5\nl6\n");
|
||||
gc(LANE, "add", "tail.txt");
|
||||
gc(LANE, "commit", "-qm", "tail");
|
||||
write(LANE, "tail.txt", "l1\nTWO\nl3\nl4\nl5\nl6");
|
||||
const [file] = (await collectChanges(LANE, { range: "work", file: "tail.txt" })).files;
|
||||
assert.equal(file.hunks.length, 1);
|
||||
const header = file.hunks[0].header;
|
||||
|
||||
await runGitAction(LANE, "stage-hunk", { path: "tail.txt", hunk: header, block: 1 });
|
||||
const staged = g(LANE, "diff", "--cached", "--", "tail.txt");
|
||||
assert.match(staged, /No newline at end of file/);
|
||||
assert.doesNotMatch(staged, /TWO/);
|
||||
|
||||
await runGitAction(LANE, "discard-hunk", {
|
||||
path: "tail.txt",
|
||||
hunk: header,
|
||||
block: 0,
|
||||
confirm: true,
|
||||
});
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
|
||||
});
|
||||
|
||||
it("refuses a block the hunk does not have", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
for (const block of [3, -1, 1.5, "0"]) {
|
||||
await assert.rejects(
|
||||
() => runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block }),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unborn repo and `publish`", () => {
|
||||
const EMPTY = path.join(ROOT, "empty");
|
||||
const URL_OK = "https://example.test/owner/r.git";
|
||||
|
||||
/** A `git init`ed repo with no commit and no remote. */
|
||||
function unborn() {
|
||||
fs.rmSync(EMPTY, { recursive: true, force: true });
|
||||
g(ROOT, "init", "-q", "-b", "main", EMPTY);
|
||||
g(EMPTY, "config", "user.email", "t@h");
|
||||
g(EMPTY, "config", "user.name", "t");
|
||||
}
|
||||
|
||||
function firstCommit() {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
gc(EMPTY, "add", "-A");
|
||||
gc(EMPTY, "commit", "-qm", "first");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
freshFixture();
|
||||
unborn();
|
||||
});
|
||||
|
||||
it("reports an unborn repo instead of failing to read it", async () => {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
const facts = await gitFacts(EMPTY);
|
||||
assert.equal(facts.unborn, true);
|
||||
assert.equal(facts.head, null);
|
||||
assert.equal(facts.branch, "main");
|
||||
assert.equal(facts.subject, "");
|
||||
assert.equal(facts.untracked, 1);
|
||||
assert.equal(facts.hasRemote, false);
|
||||
assert.equal((await gitFacts(LANE)).unborn, false);
|
||||
});
|
||||
|
||||
it("makes the first commit of an unborn repo", async () => {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
const out = await runGitAction(EMPTY, "commit", { message: "first" });
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal((await gitFacts(EMPTY)).unborn, false);
|
||||
});
|
||||
|
||||
it("refuses to publish before the first commit", async () => {
|
||||
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }), {
|
||||
code: "ENOCOMMITS",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to publish a repo that already has a remote", async () => {
|
||||
await assert.rejects(() => runGitAction(LANE, "publish", { url: URL_OK }), {
|
||||
code: "EREMOTEEXISTS",
|
||||
});
|
||||
assert.equal(g(LANE, "remote", "get-url", "origin").trim(), ORIGIN);
|
||||
});
|
||||
|
||||
it("refuses every URL outside https-without-userinfo, ssh:// and scp-like", async () => {
|
||||
firstCommit();
|
||||
for (const url of [
|
||||
ORIGIN, // a local path
|
||||
"/tmp/x",
|
||||
"../sibling.git",
|
||||
"file:///tmp/x.git",
|
||||
"ext::sh -c id",
|
||||
"ext::sh",
|
||||
"fd::3",
|
||||
"git://host/r.git",
|
||||
"http://host/r.git",
|
||||
"-oProxyCommand=x",
|
||||
"--upload-pack=x",
|
||||
"https://u:p@host/r.git",
|
||||
"https://token@host/r.git",
|
||||
"https://host/r .git",
|
||||
"https://host/r.git\n",
|
||||
"ssh://-oProxyCommand=x/r.git",
|
||||
// WHATWG parses these as https://host/x, but git sees no "://" and reads
|
||||
// them as scp-like ssh to a host named "https" — the checked string and
|
||||
// the used string must be the same string.
|
||||
"https:/host/r.git",
|
||||
"https:host/r.git",
|
||||
"https:\\\\host\\r.git",
|
||||
// Percent-encoded userinfo/host that git decodes into a leading "-".
|
||||
"ssh://%2DoProxyCommand=x@host/r.git",
|
||||
"ssh://git@%2Dhost/r.git",
|
||||
"-git@host:r.git",
|
||||
"",
|
||||
42,
|
||||
undefined,
|
||||
]) {
|
||||
await assert.rejects(
|
||||
() => runGitAction(EMPTY, "publish", { url }),
|
||||
{ code: "EBADURL" },
|
||||
`accepted ${JSON.stringify(url)}`
|
||||
);
|
||||
}
|
||||
assert.equal(g(EMPTY, "remote").trim(), "");
|
||||
});
|
||||
|
||||
it("accepts https, ssh:// and scp-like URLs", () => {
|
||||
for (const url of [
|
||||
URL_OK,
|
||||
"https://host:8443/owner/r",
|
||||
"ssh://git@host:2222/owner/r.git",
|
||||
"ssh://host/r.git",
|
||||
"git@github.com:owner/r.git",
|
||||
]) {
|
||||
assert.equal(validateRemoteUrl(url), url);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds origin and pushes HEAD with an upstream", async () => {
|
||||
firstCommit();
|
||||
const bare = path.join(ROOT, "published.git");
|
||||
g(ROOT, "init", "-q", "--bare", bare);
|
||||
// Repo-local insteadOf maps the https URL onto the bare fixture, so the
|
||||
// real https branch of the allowlist is exercised without a network.
|
||||
g(EMPTY, "config", `url.${bare}.insteadOf`, URL_OK);
|
||||
|
||||
const out = await runGitAction(EMPTY, "publish", { url: URL_OK });
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal(out.remote, "origin");
|
||||
// `remote add origin -- <url>` stores the URL itself, not "--".
|
||||
assert.equal(g(EMPTY, "config", "remote.origin.url").trim(), URL_OK);
|
||||
assert.equal(g(bare, "rev-parse", "main").trim(), g(EMPTY, "rev-parse", "HEAD").trim());
|
||||
const facts = await gitFacts(EMPTY);
|
||||
assert.equal(facts.upstream, "origin/main");
|
||||
assert.equal(facts.ahead, 0);
|
||||
});
|
||||
|
||||
it("removes the remote again when the push fails", async () => {
|
||||
firstCommit();
|
||||
g(EMPTY, "config", `url.${path.join(ROOT, "nowhere.git")}.insteadOf`, URL_OK);
|
||||
|
||||
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }));
|
||||
assert.equal(g(EMPTY, "remote").trim(), "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Issue #45: a dashboard fetch/pull/push against a remote that needs a
|
||||
* login. A local HTTP "remote" answers 401 to every request and records the
|
||||
* Authorization header it was sent, so the tests see exactly what git sent:
|
||||
* nothing → EGITAUTH naming the host; a typed credential → that credential,
|
||||
* as Basic auth, to that host; saved only when git succeeded AND asked to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-ops-auth-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
// The machine's own helpers must not answer for the fake host.
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
let server;
|
||||
let host;
|
||||
const seen = [];
|
||||
const LANE = path.join(ROOT, "lane");
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
seen.push(req.headers.authorization || null);
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
host = `127.0.0.1:${server.address().port}`;
|
||||
execFileSync("git", ["init", "-q", LANE]);
|
||||
execFileSync("git", ["-C", LANE, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
});
|
||||
after(() => new Promise((r) => server.close(r)));
|
||||
beforeEach(() => {
|
||||
seen.length = 0;
|
||||
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("git auth on fetch", () => {
|
||||
it("answers EGITAUTH naming the host when git has no credential", async () => {
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), (e) => {
|
||||
assert.equal(e.code, "EGITAUTH");
|
||||
assert.deepEqual(e.auth, { protocol: "http", host, username: null, rejected: false });
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a typed credential as Basic auth, and does not save a failed one", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "http", host, username: "vi", password: "tok", remember: true },
|
||||
}),
|
||||
(e) => e.code === "EGITAUTH" && e.auth.rejected === true
|
||||
);
|
||||
const basic = `Basic ${Buffer.from("vi:tok").toString("base64")}`;
|
||||
assert.ok(seen.includes(basic), `expected ${basic} in ${JSON.stringify(seen)}`);
|
||||
assert.deepEqual(creds.list(), [], "a credential git rejected is never saved");
|
||||
});
|
||||
|
||||
it("uses a stored credential without being asked", async () => {
|
||||
creds.save({ protocol: "http", host, username: "saved", password: "s3" });
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), { code: "EGITAUTH" });
|
||||
assert.ok(seen.includes(`Basic ${Buffer.from("saved:s3").toString("base64")}`));
|
||||
});
|
||||
|
||||
it("refuses a credential for a host that is not this repo's remote", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "https", host: "evil.test", username: "u", password: "p" },
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file Issue #45 part B: merge and conflict resolution from the dashboard,
|
||||
* against a REAL repo whose `main` and `feat` edit the same line. Pins the
|
||||
* guards (clean tree, repo-root lane, a ref that exists, one merge at a time,
|
||||
* a path that IS conflicted, markers gone before "resolved", confirm on
|
||||
* abort) and the happy paths (ours, theirs, hand-edited, commit, abort).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
const { listTree, listPaths } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-merge-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
fs.mkdirSync(path.join(REPO, "sub"));
|
||||
write("a.txt", "base\n");
|
||||
write("sub/s.txt", "s\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
g("switch", "-qc", "feat");
|
||||
write("a.txt", "theirs\n");
|
||||
write("b.txt", "only on feat\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "feat");
|
||||
g("switch", "-q", "main");
|
||||
write("a.txt", "ours\n");
|
||||
g("commit", "-qam", "main");
|
||||
});
|
||||
|
||||
const merge = () => runGitAction(REPO, "merge", { branch: "feat" });
|
||||
|
||||
describe("merge", () => {
|
||||
it("reports conflicts instead of failing, and gitFacts shows the merge", async () => {
|
||||
const r = await merge();
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.conflicts, ["a.txt"]);
|
||||
const facts = await gitFacts(REPO);
|
||||
assert.equal(facts.merging, true);
|
||||
assert.equal(facts.conflicts, 1);
|
||||
});
|
||||
|
||||
it("merges cleanly when there is nothing to resolve", async () => {
|
||||
write("a.txt", "theirs\n");
|
||||
g("commit", "-qam", "agree");
|
||||
const r = await merge();
|
||||
assert.deepEqual(r.conflicts, []);
|
||||
assert.equal(read("b.txt"), "only on feat\n");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
});
|
||||
|
||||
it("refuses a dirty tree, an unknown ref, a subdirectory lane and a second merge", async () => {
|
||||
write("a.txt", "wip\n");
|
||||
await assert.rejects(merge(), { code: "EDIRTY" });
|
||||
g("checkout", "--", "a.txt");
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "nope" }), { code: "ENOBRANCH" });
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "--help" }), { code: "EBADBRANCH" });
|
||||
await assert.rejects(runGitAction(path.join(REPO, "sub"), "merge", { branch: "feat" }), {
|
||||
code: "ESUBDIR",
|
||||
});
|
||||
await merge();
|
||||
await assert.rejects(merge(), { code: "EMERGING" });
|
||||
await assert.rejects(runGitAction(REPO, "pull"), { code: "EMERGING" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listing files mid-merge", () => {
|
||||
it("lists a conflicted file once, not once per merge stage", async () => {
|
||||
await merge();
|
||||
const root = await listTree(REPO, {});
|
||||
assert.equal(root.entries.filter((e) => e.name === "a.txt").length, 1);
|
||||
assert.deepEqual((await listPaths(REPO, { q: "a.txt" })).paths, ["a.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("takes our side or theirs, and only for a conflicted path", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "b.txt", side: "ours" }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "mine" }), {
|
||||
code: "EBADSIDE",
|
||||
});
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
|
||||
assert.equal(read("a.txt"), "theirs\n");
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
|
||||
it("marks a hand-edited file resolved only once its markers are gone", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "done" }), {
|
||||
code: "EMARKERS",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "stage", { paths: ["a.txt"] }), { code: "EMARKERS" });
|
||||
write("a.txt", "both\n");
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "done" });
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("finish or abort", () => {
|
||||
it("commits the merge only when nothing is left unresolved", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" });
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" });
|
||||
await runGitAction(REPO, "merge-commit");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(g("rev-list", "--parents", "-n1", "HEAD").trim().split(" ").length, 3);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" });
|
||||
});
|
||||
|
||||
it("aborts only with confirm, back to where it started", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
|
||||
await runGitAction(REPO, "merge-abort", { confirm: true });
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
assert.equal(fs.existsSync(path.join(REPO, "b.txt")), false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* @file Tests for lane-agents.js: agent-template installation into a lane's
|
||||
* .claude/agents/, including path resolution, git-exclude idempotence, and
|
||||
* missing-repo rejection.
|
||||
* @file Tests for lane-agents.js's removeStageDoc and the codex pipeline
|
||||
* cleanup it pairs with (clearCodexPipelines).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,7 +17,7 @@ const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { installAgents } = require("../lib/lane-agents");
|
||||
const { removeStageDoc } = require("../lib/lane-agents");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
@@ -48,97 +47,66 @@ function makeRepoDir() {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
/** Create a lane record backed by an existing directory. */
|
||||
function makeLane(cwd) {
|
||||
return lanesLib.createLane({ title: "agents-test", cwd, kind: "managed" });
|
||||
}
|
||||
describe("codex pipeline cleanup", () => {
|
||||
const START = "<!-- agenthub:stage:start -->";
|
||||
const END = "<!-- agenthub:stage:end -->";
|
||||
const BLOCK = `${START}\n## Declare your lane stage as you work\n\nagenthub stage …\n${END}\n`;
|
||||
|
||||
describe("installAgents", () => {
|
||||
it("copies agent templates from agents/ dir into lane's .claude/agents/", async () => {
|
||||
it("removes only the AgentHub block, keeping the user's own AGENTS.md prose", () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = makeLane(cwd);
|
||||
const result = await installAgents(lane);
|
||||
|
||||
assert.deepEqual([...result.installed].sort(), ["qc-local.md", "senior-gate-reviewer.md"]);
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents", "qc-local.md")));
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents", "senior-gate-reviewer.md")));
|
||||
|
||||
// Verify the source path is new (agents/) not old (data/agent-templates/)
|
||||
const oldPath = path.join(__dirname, "..", "data", "agent-templates", "ship-feature-lane");
|
||||
assert.equal(fs.existsSync(oldPath), false);
|
||||
});
|
||||
|
||||
it("throws ENOTGITREPO when lane cwd has no .git", async () => {
|
||||
const cwd = path.join(SUITE_ROOT, "no-git");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const lane = makeLane(cwd);
|
||||
await assert.rejects(
|
||||
() => installAgents(lane),
|
||||
(err) => err.code === "ENOTGITREPO"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds .claude/agents/ line to git info/exclude idempotently", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = makeLane(cwd);
|
||||
|
||||
await installAgents(lane); // first
|
||||
await installAgents(lane); // reinstall — should not duplicate
|
||||
|
||||
const excludePath = path.join(cwd, ".git", "info", "exclude");
|
||||
const content = fs.readFileSync(excludePath, "utf8");
|
||||
const matches = content.split("\n").filter((l) => l === ".claude/agents/");
|
||||
assert.equal(matches.length, 1);
|
||||
});
|
||||
|
||||
it("overwrites existing agents on reinstall", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = makeLane(cwd);
|
||||
|
||||
await installAgents(lane);
|
||||
|
||||
// Tamper with the installed file
|
||||
const agentPath = path.join(cwd, ".claude", "agents", "qc-local.md");
|
||||
fs.writeFileSync(agentPath, "tampered");
|
||||
|
||||
// Reinstall
|
||||
await installAgents(lane);
|
||||
const content = fs.readFileSync(agentPath, "utf8");
|
||||
assert.ok(content.includes("#"), "reinstalled file should look like markdown, not 'tampered'");
|
||||
assert.notEqual(content, "tampered");
|
||||
});
|
||||
|
||||
it("creates .claude/agents/ dir if it does not exist", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = makeLane(cwd);
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||
await installAgents(lane);
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
|
||||
});
|
||||
|
||||
it("installs the AGENTS.md stage block for a codex lane, not .claude/agents/", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
|
||||
|
||||
const result = await installAgents(lane);
|
||||
assert.deepEqual(result.installed, ["AGENTS.md"]);
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||
const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
|
||||
assert.match(doc, /agenthub stage <stage> --tool codex/);
|
||||
});
|
||||
|
||||
it("preserves the user's own AGENTS.md prose and replaces only the block", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
|
||||
const file = path.join(cwd, "AGENTS.md");
|
||||
fs.writeFileSync(file, "# House rules\n\nNever force-push.\n");
|
||||
|
||||
await installAgents(lane);
|
||||
await installAgents(lane); // reinstall must not duplicate the block
|
||||
fs.writeFileSync(file, `# House rules\n\nNever force-push.\n\n${BLOCK}\n## More\n\nKeep me.\n`);
|
||||
|
||||
assert.equal(removeStageDoc(cwd), true);
|
||||
const doc = fs.readFileSync(file, "utf8");
|
||||
assert.ok(!doc.includes(START) && !doc.includes(END));
|
||||
assert.match(doc, /Never force-push\./);
|
||||
assert.equal(doc.split("<!-- agenthub:stage:start -->").length - 1, 1);
|
||||
assert.equal(doc.split("<!-- agenthub:stage:end -->").length - 1, 1);
|
||||
assert.match(doc, /Keep me\./);
|
||||
});
|
||||
|
||||
it("deletes an AGENTS.md that held nothing but the block", () => {
|
||||
const cwd = makeRepoDir();
|
||||
fs.writeFileSync(path.join(cwd, "AGENTS.md"), BLOCK);
|
||||
assert.equal(removeStageDoc(cwd), true);
|
||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||
});
|
||||
|
||||
it("leaves a file without the block, or no file at all, untouched", () => {
|
||||
const cwd = makeRepoDir();
|
||||
assert.equal(removeStageDoc(cwd), false);
|
||||
fs.writeFileSync(path.join(cwd, "AGENTS.md"), "# Mine\n");
|
||||
assert.equal(removeStageDoc(cwd), false);
|
||||
assert.equal(fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"), "# Mine\n");
|
||||
});
|
||||
|
||||
it("wipes a codex lane's stage, detection, stage log and features — and no claude lane's", () => {
|
||||
const codex = lanesLib.createLane({ title: "cx", cwd: makeRepoDir(), tool: "codex" });
|
||||
const claude = lanesLib.createLane({ title: "cc", cwd: makeRepoDir(), tool: "claude" });
|
||||
for (const lane of [codex, claude]) {
|
||||
lanesLib.setStage(lane.id, { stage: "tests", evidence: "old" });
|
||||
lanesLib.recordDetection(lane.id, { nodeId: "review", signal: "npm test" });
|
||||
}
|
||||
const { db } = require("../db");
|
||||
const insertFeature = db.prepare("INSERT INTO lane_features (lane_id, slug) VALUES (?, ?)");
|
||||
insertFeature.run(codex.id, "old-feature");
|
||||
insertFeature.run(claude.id, "kept-feature");
|
||||
|
||||
assert.equal(lanesLib.clearCodexPipelines(), 1);
|
||||
|
||||
const cx = lanesLib.getLane(codex.id);
|
||||
assert.equal(cx.stage, "idle");
|
||||
assert.deepEqual(cx.stages, {});
|
||||
assert.equal(cx.detected_stage, null);
|
||||
const count = (table, id) =>
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE lane_id = ?`).get(id).n;
|
||||
assert.equal(count("lane_stage_events", codex.id), 0);
|
||||
assert.equal(count("lane_features", codex.id), 0);
|
||||
|
||||
const cc = lanesLib.getLane(claude.id);
|
||||
assert.equal(cc.stage, "tests");
|
||||
assert.ok(count("lane_stage_events", claude.id) > 0);
|
||||
assert.equal(count("lane_features", claude.id), 1);
|
||||
|
||||
assert.equal(lanesLib.clearCodexPipelines(), 0); // idempotent
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user