Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c238bc5c4 | |||
| 9d096b61f7 | |||
| 952e8584f0 | |||
| 3f6e903c22 | |||
| 1da46f10f4 |
@@ -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.
|
||||
|
||||
+16
-72
@@ -370,7 +370,7 @@ graph TD
|
||||
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff; `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
|
||||
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||
|
||||
@@ -1512,56 +1512,39 @@ changes.
|
||||
|
||||
---
|
||||
|
||||
## Codex Lane Ingestion (no hooks)
|
||||
## Codex Lane Status (no hooks)
|
||||
|
||||
A `codex` lane has no hook stream at all — Codex does not implement Claude
|
||||
Code's hook protocol — so `touchLaneFromHook` (the only caller of
|
||||
`stage-detect.detect()`) can never see one, and is deliberately pinned to
|
||||
`"claude"` so a Claude hook cannot touch a codex lane sharing the same
|
||||
directory.
|
||||
directory. Codex lanes carry **no pipeline**: nothing detects, advances or
|
||||
nudges their stage, and the Workspace hides the stage chip, time-on-stage,
|
||||
pipeline/feature pickers and PipelineMap for them.
|
||||
|
||||
`server/lib/codex-ingest.js` is the codex-side source. Codex appends a rollout
|
||||
journal per thread at
|
||||
`server/lib/codex-ingest.js` only mirrors a codex lane's `status`. Codex appends
|
||||
a rollout journal per thread at
|
||||
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl`
|
||||
(`$CODEX_HOME` defaults to `~/.codex`); the first line is a `session_meta`
|
||||
payload carrying the thread's `cwd`, and each tool call is a `custom_tool_call`
|
||||
or `function_call` payload. One pass:
|
||||
payload carrying the thread's `cwd`. One pass:
|
||||
|
||||
1. `rolloutFiles()` walks `sessions/` (depth ≤ 4), newest mtime first, capped at
|
||||
40 files.
|
||||
2. `rolloutCwd()` reads the thread's cwd from the file's first line (memoized —
|
||||
it is written once, at thread creation) and matches it to a `codex` lane by
|
||||
it is written once, at thread creation; the cache is pruned to the current
|
||||
scan once it exceeds 4 × the 40-file cap) and matches it to a `codex` lane by
|
||||
deepest path-boundary prefix.
|
||||
3. Only bytes appended since the last pass are read. On the FIRST sight of a
|
||||
file only its recent 64 KiB 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. `completeLines()` does the framing **on the
|
||||
buffer, never on a decoded string** (a tail offset can land inside a
|
||||
multi-byte UTF-8 sequence, where the decoder's U+FFFD would throw the byte
|
||||
math off): a tail read's leading fragment is dropped, the stored offset
|
||||
advances only past the last newline, and a trailing fragment is re-read whole
|
||||
next pass. A read is capped at 4 MiB, and the two tracking maps are pruned to
|
||||
the current scan once they exceed 4 × the 40-file cap.
|
||||
4. `eventsFromPayload()` maps each payload onto the hook-shaped
|
||||
`{tool_name, tool_input}` the matcher already understands: shell calls
|
||||
(`exec`/`exec_command`/`shell`/`local_shell`) → `Bash`, an embedded
|
||||
`apply_patch` heredoc → one `Write`/`Edit` per touched file, `update_plan` →
|
||||
`TaskCreate`, `spawn_agent` → `Agent`. Everything else is ignored. Newlines
|
||||
inside a patch heredoc arrive escaped (it rides inside a JS string literal),
|
||||
so they are unescaped before the file list is scanned.
|
||||
5. The last matching event of the batch goes through the same
|
||||
`recordDetection()` as a hook — same forward-only, declared-wins,
|
||||
amber-never-green rules — and the same pass mirrors the lane's `status`
|
||||
between `idle` and `running` from the journal's mtime, under the identical
|
||||
guard hook ingestion uses (no `run_id`, only from `idle`/`running`). Nothing
|
||||
else is written: not `stage`, not `notes`, not `slot`/`ports`.
|
||||
3. The lane is `running` while that journal's mtime is under two minutes old,
|
||||
`idle` once older **or in the future** (a clock stepped back would otherwise
|
||||
read as live), under the identical guard hook ingestion uses (no `run_id`,
|
||||
only from `idle`/`running`). Nothing else is written: not `stage`, not
|
||||
`notes`, not `slot`/`ports`.
|
||||
|
||||
`startCodexPoll(broadcastLane)` runs it on an unref'd 15 s interval from
|
||||
`server/index.js`, tunable via `DASHBOARD_CODEX_POLL_MS` (`0` disables). Every
|
||||
step is fail-safe: an absent `~/.codex`, a malformed journal, a lane deleted
|
||||
mid-pass or a busy SQLite is swallowed locally, and a pass never throws into the
|
||||
event loop. No schema change, no new route, no new WebSocket message type — the
|
||||
existing `lane_update` broadcast carries it.
|
||||
event loop. The existing `lane_update` broadcast carries the change.
|
||||
|
||||
Codex 0.149 also mirrors threads into version-suffixed SQLite files
|
||||
(`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still points
|
||||
@@ -1569,45 +1552,6 @@ at a live rollout file, so reading the JSONL avoids coupling to a schema the
|
||||
next Codex release renames. `rolloutFiles()` is the single function to re-point
|
||||
if that changes.
|
||||
|
||||
A written detection then **also advances the lane's declared `stage`** —
|
||||
`setStage(lane.id, { stage: nodeId, keepDetection: true })`, the only place in
|
||||
AgentHub where an inference moves the blue `current` ring. It is gated on
|
||||
`recordDetection` having written, so it inherits forward-only/declared-wins/TTL
|
||||
unchanged; it passes no `evidence`, so the node renders `passed-no-evidence`
|
||||
(amber) and never `done`; and `keepDetection` is the sole exception to
|
||||
`setStage`'s clearing of the detection columns, so the card still shows which
|
||||
command moved it rather than looking like an agent's own claim. No template's
|
||||
last node carries `detect` rules, so a lane can never be auto-declared `done`.
|
||||
|
||||
### Codex evidence reliability (`server/lib/codex-nudge.js`)
|
||||
|
||||
Auto-advance moves the ring but can never supply evidence; only the session's
|
||||
own `agenthub stage --evidence` call can, so two mechanisms make that more likely
|
||||
without weakening declared-wins/inferred-never-`done`. (Opening-prompt priming
|
||||
used to be a third: `LaneConsolePane.tsx` pre-filled the prompt box from the
|
||||
lane's pipeline — the driving slash command on a Claude lane, a plain-text
|
||||
stage checklist on a codex one. It is gone: whatever sits in that box is sent
|
||||
as a real prompt when the run starts, so the box now stays empty and the
|
||||
prompt is the user's alone.)
|
||||
|
||||
- **`AGENTS.md` stage block** (`server/lib/lane-agents.js`): on a `codex` lane,
|
||||
`agenthub lanes agents install` writes a marker-delimited
|
||||
`<!-- agenthub:stage:start -->` section into `<lane cwd>/AGENTS.md` instead of
|
||||
copying the two agent templates into `.claude/agents/`, which Codex never
|
||||
reads. Appended to an existing file (user prose preserved), and a reinstall
|
||||
replaces exactly that block.
|
||||
- **Missing-evidence nudge** (`sweepCodexNudges`, an unref'd 60 s interval from
|
||||
`server/index.js`, tunable via `DASHBOARD_CODEX_NUDGE_MS`, `0` disables):
|
||||
when a codex lane's current stage has been held for at least two minutes with
|
||||
`stages[stage].evidence` still null and hasn't already been nudged for that
|
||||
exact stage, it types a one-line `[agenthub] ... run: agenthub stage ... --evidence
|
||||
"..."` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if
|
||||
the pane isn't running an interactive program — `pty-run.js`'s
|
||||
`SHELL_COMMANDS` — since Enter there would execute the reminder as a shell
|
||||
command instead of submitting it as a chat turn). It never writes `stage`,
|
||||
`stages` or `detected_stage` — any resulting evidence is still something the
|
||||
codex session declared itself, through the real `agenthub stage` call.
|
||||
|
||||
---
|
||||
|
||||
## Agent Extension Layer
|
||||
|
||||
+21
-12
@@ -31,7 +31,7 @@ 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` |
|
||||
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
|
||||
@@ -101,9 +101,14 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
|
||||
`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.
|
||||
- **No systemd, so no auto-start on boot.** The server is a detached process
|
||||
that runs until logout or reboot. It comes back at the next Claude Code
|
||||
session start (within about 10 s), or by hand with `agenthub start`. A
|
||||
- **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
|
||||
@@ -124,15 +129,17 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
|
||||
|
||||
**Mac checklist** — please run it and report the result on issue #22:
|
||||
|
||||
1. Fresh plugin install from a Terminal `claude` session: `/agenthub-doctor` is all OK (the systemd row says not available) and the dashboard answers on `http://localhost:4820`.
|
||||
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, open `claude`, and the dashboard comes back within about 10 s.
|
||||
9. `/agenthub-update` completes and the server restarts on the new version.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,13 +294,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
|
||||
```
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ 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 |
|
||||
@@ -193,19 +193,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
|
||||
@@ -252,20 +253,11 @@ 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).
|
||||
|
||||
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
|
||||
|
||||
+5
-5
@@ -67,7 +67,7 @@ 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 |
|
||||
@@ -228,10 +228,10 @@ 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).
|
||||
|
||||
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
+33
-18
@@ -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]"));
|
||||
|
||||
@@ -429,6 +429,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>
|
||||
|
||||
@@ -635,7 +644,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>
|
||||
|
||||
@@ -363,20 +363,23 @@ 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" && (
|
||||
{/* Claude-only: other CLIs have no agents to install. */}
|
||||
{lane.tool === "claude" && (
|
||||
<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 === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -484,18 +487,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ import {
|
||||
Columns2,
|
||||
FileText,
|
||||
FoldVertical,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
Folder,
|
||||
Minus,
|
||||
Pencil,
|
||||
@@ -53,14 +51,14 @@ import {
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
LaneChangeRange,
|
||||
LaneChanges,
|
||||
LaneCommit,
|
||||
LaneDiffHunk,
|
||||
LaneTreeEntry,
|
||||
} from "../../lib/types";
|
||||
|
||||
@@ -121,6 +119,9 @@ function treeMark(code: string): { letter: string; tone: string } {
|
||||
// The tint fills the whole row, not just the letter, so a changed file
|
||||
// stands out of a long tree at a glance.
|
||||
if (code === "??") return { letter: "U", tone: "text-status-success bg-status-success/10" };
|
||||
// A merge conflict (git's unmerged codes) before A/D: `AA`/`DU` are not adds or deletes.
|
||||
if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code))
|
||||
return { letter: "!", tone: "text-status-danger bg-status-danger/10" };
|
||||
if (code.includes("A")) return { letter: "A", tone: "text-status-success bg-status-success/10" };
|
||||
if (code.includes("D")) return { letter: "D", tone: "text-status-danger bg-status-danger/10" };
|
||||
if (code.includes("R")) return { letter: "R", tone: "text-status-warning bg-status-warning/10" };
|
||||
@@ -321,20 +322,22 @@ function ConflictActions({
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<IconButton
|
||||
label={t("changes.takeOurs")}
|
||||
disabled={busy}
|
||||
onClick={() => onResolve(f.path, "ours")}
|
||||
>
|
||||
<ArrowLeftToLine className="h-3 w-3" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={t("changes.takeTheirs")}
|
||||
disabled={busy}
|
||||
onClick={() => onResolve(f.path, "theirs")}
|
||||
>
|
||||
<ArrowRightToLine className="h-3 w-3" />
|
||||
</IconButton>
|
||||
{(["ours", "theirs"] as const).map((side) => (
|
||||
<button
|
||||
key={side}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onResolve(f.path, side)}
|
||||
title={t(side === "ours" ? "changes.takeOursHint" : "changes.takeTheirsHint")}
|
||||
className={`shrink-0 rounded border px-1 text-[10px] disabled:opacity-40 ${
|
||||
side === "ours"
|
||||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||||
: "border-accent/40 text-accent hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
{t(side === "ours" ? "changes.takeOurs" : "changes.takeTheirs")}
|
||||
</button>
|
||||
))}
|
||||
<IconButton
|
||||
label={t("changes.markResolved")}
|
||||
disabled={busy}
|
||||
@@ -450,10 +453,33 @@ export function LaneChangesPanel({
|
||||
// The whole-file editor's text while it is open; null = viewing.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const editWanted = useRef(false);
|
||||
// Where the editor opens: the line clicked in the read view, and that view's
|
||||
// scroll offset, so the text does not jump when the textarea replaces it.
|
||||
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editing = draft !== null;
|
||||
useEffect(() => {
|
||||
const at = editAt.current;
|
||||
const el = editorRef.current;
|
||||
if (!editing || !at || !el) return;
|
||||
editAt.current = null;
|
||||
const offset = el.value
|
||||
.split("\n")
|
||||
.slice(0, at.line)
|
||||
.reduce((n, l) => n + l.length + 1, 0);
|
||||
el.setSelectionRange(offset, offset);
|
||||
el.focus({ preventScroll: true });
|
||||
el.scrollTop = at.scrollTop;
|
||||
}, [editing]);
|
||||
const startEdit = (line: number, scrollTop: number) => {
|
||||
if (!text?.sha) return;
|
||||
editAt.current = { line, scrollTop };
|
||||
setDraft(text.lines.join("\n"));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!editWanted.current || !text) return;
|
||||
editWanted.current = false;
|
||||
if (text.sha) setDraft(text.lines.join("\n"));
|
||||
if (text.sha) startEdit(0, 0);
|
||||
// Asked for from the diff, refused once the file arrived: say why here,
|
||||
// not with an Edit button that silently does nothing.
|
||||
else setError(t(noEditReason(text)));
|
||||
@@ -463,6 +489,8 @@ export function LaneChangesPanel({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
|
||||
const [armedAbort, setArmedAbort] = useState(false);
|
||||
// A conflicted file opens in the conflict view; true = the plain editor.
|
||||
const [rawEdit, setRawEdit] = useState(false);
|
||||
// The hunk whose Undo was clicked once: `${path}\n${header}`.
|
||||
const [armedHunk, setArmedHunk] = useState<string | null>(null);
|
||||
// Files folded shut in review mode. A change-set is read file by file, and
|
||||
@@ -697,9 +725,19 @@ export function LaneChangesPanel({
|
||||
setWhole(full && range !== "commit");
|
||||
setText(null);
|
||||
setDraft(null);
|
||||
setRawEdit(false);
|
||||
setMode("file");
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const back = returnTo.current;
|
||||
pendingScroll.current = back?.scroll ?? 0;
|
||||
returnTo.current = null;
|
||||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||||
setOpenFile(null);
|
||||
setWhole(false);
|
||||
};
|
||||
|
||||
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
|
||||
// rewrote meanwhile comes back 409 ESTALE and the draft stays for a retry.
|
||||
const saveDraft = async () => {
|
||||
@@ -770,16 +808,22 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||||
|
||||
// One hunk, matched by its `@@` line against a fresh diff at the context
|
||||
// this diff was read with — so the server never applies a stale patch.
|
||||
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
|
||||
// One change block, named by its hunk's `@@` line and its index inside that
|
||||
// hunk, matched against a fresh diff at the context this diff was read
|
||||
// with — so the server never applies a stale patch.
|
||||
const actHunk = async (
|
||||
action: "stage-hunk" | "discard-hunk",
|
||||
path: string,
|
||||
change: LaneDiffChange
|
||||
) => {
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, {
|
||||
path,
|
||||
hunk,
|
||||
hunk: change.hunk.header,
|
||||
block: change.block,
|
||||
context: data?.available ? data.context : undefined,
|
||||
...(action === "discard-hunk" ? { confirm: true } : {}),
|
||||
});
|
||||
@@ -794,13 +838,13 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Stage / Undo on each hunk of a file with unstaged changes. The diff is
|
||||
// Stage / Undo on each change block of a file with unstaged changes. The diff is
|
||||
// HEAD → working tree, so a staged-only file gets none: undoing its tree
|
||||
// would leave the index holding the change.
|
||||
const hunkActions = (f: LaneChangeFile) =>
|
||||
range === "work" && f.unstaged && !f.untracked && !f.binary
|
||||
? (hunk: LaneDiffHunk) => {
|
||||
const key = `${f.path}\n${hunk.header}`;
|
||||
? (change: LaneDiffChange) => {
|
||||
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
|
||||
const armed = armedHunk === key;
|
||||
return (
|
||||
<>
|
||||
@@ -810,7 +854,7 @@ export function LaneChangesPanel({
|
||||
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
|
||||
armed ? void actHunk("discard-hunk", f.path, change) : setArmedHunk(key)
|
||||
}
|
||||
className={`${HUNK_CHIP} ${
|
||||
armed
|
||||
@@ -825,7 +869,7 @@ export function LaneChangesPanel({
|
||||
aria-label={t("changes.stageHunk")}
|
||||
title={t("changes.stageHunk")}
|
||||
disabled={busy}
|
||||
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
|
||||
onClick={() => void actHunk("stage-hunk", f.path, change)}
|
||||
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
|
||||
>
|
||||
{t("changes.stageHunkShort")}
|
||||
@@ -871,12 +915,6 @@ export function LaneChangesPanel({
|
||||
return next;
|
||||
});
|
||||
|
||||
// Full width edits in place: an editable whole file IS the editor, and
|
||||
// Cancel just puts the saved text back (this effect re-seeds the draft).
|
||||
const editableText = full && mode === "file" && whole && range !== "commit" && text?.sha;
|
||||
useEffect(() => {
|
||||
if (editableText && draft === null && text) setDraft(text.lines.join("\n"));
|
||||
}, [editableText, draft, text]);
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
|
||||
const files = data?.available ? data.files : [];
|
||||
@@ -896,8 +934,8 @@ export function LaneChangesPanel({
|
||||
}
|
||||
}, [data, files, confirmDiscard]);
|
||||
|
||||
// A tree or quick-open pick opens the whole file. Always-on editing (full
|
||||
// width) makes a stray click here the easy way to lose typed work.
|
||||
// A tree or quick-open pick opens the whole file — and, with an edit open,
|
||||
// would drop it, so a dirty draft asks first.
|
||||
const openWhole = (p: string) => {
|
||||
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
|
||||
show(p);
|
||||
@@ -997,14 +1035,7 @@ export function LaneChangesPanel({
|
||||
{mode === "file" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const back = returnTo.current;
|
||||
pendingScroll.current = back?.scroll ?? 0;
|
||||
returnTo.current = null;
|
||||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||||
setOpenFile(null);
|
||||
setWhole(false);
|
||||
}}
|
||||
onClick={goBack}
|
||||
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
@@ -1368,6 +1399,21 @@ export function LaneChangesPanel({
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
|
||||
<LaneConflictView
|
||||
laneId={laneId}
|
||||
path={openFile}
|
||||
onResolved={() => {
|
||||
refreshGit();
|
||||
void load();
|
||||
goBack();
|
||||
}}
|
||||
onEdit={() => {
|
||||
setRawEdit(true);
|
||||
editWanted.current = true;
|
||||
setWhole(true);
|
||||
}}
|
||||
/>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -1384,6 +1430,9 @@ export function LaneChangesPanel({
|
||||
/>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* The whole file enters the editor by a click on its text;
|
||||
only the diff, which has no whole text to click, keeps
|
||||
an Edit button. */}
|
||||
{range !== "commit" &&
|
||||
(draft === null && whole && text && !text.sha ? (
|
||||
<span
|
||||
@@ -1392,16 +1441,21 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(noEditReason(text))}
|
||||
</span>
|
||||
) : draft === null && whole ? (
|
||||
text && (
|
||||
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
||||
{t("changes.clickToEdit")}
|
||||
</span>
|
||||
)
|
||||
) : draft === null ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-edit"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
// From the diff the file is not loaded yet: the
|
||||
// editor opens as soon as the whole-file read lands.
|
||||
if (whole && text?.sha) setDraft(text.lines.join("\n"));
|
||||
else editWanted.current = true;
|
||||
// The file is not loaded yet: the editor opens as
|
||||
// soon as the whole-file read lands.
|
||||
editWanted.current = true;
|
||||
setWhole(true);
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
@@ -1422,7 +1476,7 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || (full && !dirty)}
|
||||
disabled={busy}
|
||||
onClick={() => setDraft(null)}
|
||||
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
@@ -1433,6 +1487,7 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
{whole && draft !== null ? (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
data-testid="lane-file-editor"
|
||||
aria-label={t("changes.editorLabel", { path: openFile })}
|
||||
value={draft}
|
||||
@@ -1454,6 +1509,7 @@ export function LaneChangesPanel({
|
||||
lines={text.lines}
|
||||
truncated={text.truncated}
|
||||
fill
|
||||
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
@@ -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; the others show their CLI's
|
||||
tag in place of the stage chip. */}
|
||||
{lane.tool !== "claude" ? (
|
||||
<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}
|
||||
|
||||
@@ -96,13 +96,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file straight into the editor", async () => {
|
||||
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: "",
|
||||
@@ -346,19 +346,24 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// No Edit click: the editor is simply there, Save/Cancel idle until typed.
|
||||
// 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(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(editor).toHaveFocus();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel reverts to the saved text and stays in the editor.
|
||||
// Cancel drops the edit and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
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 straight into the editor", async () => {
|
||||
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",
|
||||
@@ -372,10 +377,31 @@ describe("LaneChangesPanel", () => {
|
||||
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-editor")).toHaveValue("a");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
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 () => {
|
||||
@@ -615,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);
|
||||
@@ -676,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"));
|
||||
|
||||
@@ -922,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,
|
||||
});
|
||||
});
|
||||
@@ -959,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,
|
||||
});
|
||||
@@ -1308,6 +1385,27 @@ describe("a merge in progress (issue #45)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,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",
|
||||
@@ -256,6 +257,7 @@
|
||||
"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",
|
||||
@@ -287,5 +289,26 @@
|
||||
"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)"
|
||||
"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",
|
||||
|
||||
@@ -59,6 +59,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",
|
||||
@@ -256,6 +257,7 @@
|
||||
"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",
|
||||
@@ -287,5 +289,26 @@
|
||||
"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)"
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2572,6 +2572,8 @@ 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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,8 @@ 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).
|
||||
const noPipeline = !!currentLane && currentLane.tool !== "claude";
|
||||
|
||||
// Feature list follows the selected lane, resets the viewer on lane switch.
|
||||
useEffect(() => {
|
||||
@@ -815,40 +817,53 @@ 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>
|
||||
{/* Non-claude lanes carry no pipeline: 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"
|
||||
@@ -908,19 +923,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">
|
||||
|
||||
@@ -10364,7 +10364,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.8.9 (rebuild client to match)
|
||||
UI build v1.8.11 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+4
-4
@@ -586,8 +586,8 @@ cannot collide on `index.lock`.
|
||||
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk, off the working tree | `{"path": "...", "hunk": "@@ ... @@", "context": 3, "confirm": true}` |
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
@@ -2061,7 +2061,7 @@ 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
|
||||
@@ -2074,7 +2074,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||
|
||||
`: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` or `codex`). Optional query parameter `?tool=codex` probes for Codex; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
|
||||
**`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.
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+60
-120
@@ -96,7 +96,7 @@ Both modes' path field has a **Browse** button next to it, opening a small folde
|
||||
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane's tool determines which CLI is spawned when a run starts.
|
||||
|
||||
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude lanes render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
|
||||
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
|
||||
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
|
||||
|
||||
### How Codex arguments map
|
||||
|
||||
@@ -116,39 +116,17 @@ Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tr
|
||||
- **Sessions** and **Analytics** tabs — no session history or aggregated statistics
|
||||
- **Cost** surface — no per-run or per-model pricing data
|
||||
|
||||
**These are not bugs; they reflect Codex's architecture.** The stage you declare via `agenthub stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
|
||||
**These are not bugs; they reflect Codex's architecture.** Liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
|
||||
|
||||
**Stage detection is not in that list.** It does work for a codex lane, from
|
||||
Codex's own on-disk rollout journals rather than from hooks, and unlike a claude
|
||||
lane a written detection there also **advances the lane's declared `stage`** —
|
||||
see "Stage detection" below. What a codex lane cannot get for free is
|
||||
*evidence*: an auto-advanced node renders `passed-no-evidence` (amber), never
|
||||
`done`, because only the session itself can say what it actually finished. The
|
||||
run console never pre-fills a Claude slash command into a codex pane (Codex
|
||||
would read `/agenthub:ship-feature-lane` as prose), so a codex session declares
|
||||
evidence only when something tells it to. Three things do:
|
||||
|
||||
- **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's
|
||||
first prompt with a plain-text list of `agenthub stage <stage> --tool codex`
|
||||
calls to make, one per node of the lane's own pipeline template (built from
|
||||
`Lane.pipeline_nodes`, so it always matches whatever template is in use) —
|
||||
the same up-front priming a Claude lane gets from its pre-filled
|
||||
`/agenthub:ship-feature-lane` skill command, just as plain text since Codex has
|
||||
no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`.
|
||||
- **An `AGENTS.md` block.** `agenthub lanes agents install` on a codex lane writes
|
||||
a marker-delimited `<!-- agenthub:stage:start -->` section into
|
||||
`<lane cwd>/AGENTS.md` — the one instruction channel Codex actually reads,
|
||||
since it never loads `.claude/agents/`. Appended, never overwriting your own
|
||||
prose, and a reinstall replaces exactly that block. (On a `claude` lane the
|
||||
same command still copies the two agent templates instead.)
|
||||
- **A missing-evidence nudge.** `server/lib/codex-nudge.js` sweeps every 60s
|
||||
(`DASHBOARD_CODEX_NUDGE_MS` to change, `0` to disable) for a codex lane whose
|
||||
current stage has been held for at least two minutes with no evidence
|
||||
recorded, and types a one-line `[agenthub] ... run: agenthub stage ... --evidence
|
||||
"..."` reminder straight into that lane's live tmux pane (`tmux.sendText`,
|
||||
skipped if the pane is at a bare shell prompt). It never writes `stage`,
|
||||
`stages` or `detected_stage` itself — the evidence that results is a real
|
||||
declaration the session made.
|
||||
**Codex lanes carry no pipeline.** Nothing infers their stage, nothing types
|
||||
a stage reminder into their pane, and `agenthub lanes agents install` installs
|
||||
nothing on them (`installed: []`). The one thing AgentHub reads from Codex is
|
||||
its running/idle status — see "Stage detection" for the journal poll that
|
||||
mirrors it. Every server start also wipes what older builds left behind on a
|
||||
codex lane — its stage columns, stage log (`lane_stage_events`) and feature
|
||||
records (`lane_features`) — and takes the old
|
||||
`<!-- agenthub:stage:start -->` block back out of its `AGENTS.md` (deleting
|
||||
the file if nothing else is in it).
|
||||
|
||||
## Destructive lane actions
|
||||
|
||||
@@ -471,7 +449,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`.
|
||||
@@ -567,9 +545,18 @@ 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** and **Mark resolved** (after editing the
|
||||
`<<<<<<<`/`>>>>>>>` markers out in the file editor; refused while any are
|
||||
left). Pull and branch switch are disabled until the merge is finished or
|
||||
**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**
|
||||
@@ -643,13 +630,18 @@ only one that answers "show me the code" rather than "show me the change"):
|
||||
one green with `U` (`A` added, `D` deleted and struck through, `R`
|
||||
renamed), and a folder holding any of them gets an amber dot. The colours
|
||||
come with the tree read, so **Refresh** re-reads them.
|
||||
Opening a file entry shows the whole file (`/git/file`). Its **Edit** button
|
||||
(any range but a commit) turns that view into a text editor: **Save** (or
|
||||
Opening a file entry shows the whole file (`/git/file`) for reading, at every
|
||||
panel width. **Clicking its text** (or Enter on it; any range but a commit)
|
||||
turns that view into a text editor with the caret on the clicked line and
|
||||
the scroll position kept — a click that only ends a drag-selection does not,
|
||||
so copying never drops you into the editor. **Cancel** returns to reading.
|
||||
From a diff, which has no whole text to click, an **Edit** button does the
|
||||
same once the file has loaded. **Save** (or
|
||||
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
|
||||
exact version that was opened — a file the agent changed meanwhile answers
|
||||
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
|
||||
line endings and final newline. A file the editor cannot take (binary, or
|
||||
over the 1 MB view cap) says so where the Edit button would be.
|
||||
over the 1 MB view cap) says so in the file's header.
|
||||
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
|
||||
it is the cheap shape and the one you leave open while an agent works. Each
|
||||
row carries its own **stage** / **unstage** / **discard** buttons.
|
||||
@@ -661,14 +653,18 @@ only one that answers "show me the code" rather than "show me the change"):
|
||||
a file that appears later is open by default; a lane switch clears it.
|
||||
Each file's header stays **pinned** at the top while its diff scrolls past,
|
||||
and carries the same stage / unstage / discard buttons as its overview row.
|
||||
On a file with unstaged changes every hunk gets an editor-style **review
|
||||
bar**, hung just under its last changed line and pinned to the visible
|
||||
right edge: `^`/`v` to walk the hunks (each brings the next/previous `@@`
|
||||
line to the top of the ONE scroller, below the sticky file header — a
|
||||
On a file with unstaged changes every **change block** — each run of
|
||||
consecutive `+`/`-` lines, not each `@@` hunk: git merges edits up to six
|
||||
lines apart into one hunk, and one bar for all of them acted on every edit
|
||||
at once (issue #50) — gets an editor-style **review bar**, hung just under
|
||||
its last changed line and pinned to the visible right edge: `^`/`v` to walk
|
||||
the blocks (each brings the next/previous block's first line to the top of
|
||||
the ONE scroller, below the sticky file header — a
|
||||
review diff has no scroll box of its own unless it is large enough to
|
||||
window, above 500 rows), `n of N`, **Undo**
|
||||
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`).
|
||||
The single-file view offers the same bar.
|
||||
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`),
|
||||
both sent with the hunk's `@@` line plus the block's index, so they touch
|
||||
that block alone. The single-file view offers the same bar.
|
||||
- **One file** — clicking a file's path in any list opens just that file.
|
||||
**All files** returns to the list it was opened from — the same mode, at the
|
||||
same scroll offset — because reading a change-set is a walk down a long list
|
||||
@@ -848,7 +844,7 @@ one feature's work reads `implement → tests → implement → tests` as the se
|
||||
edits, runs the suite, and edits again — and when any backwards move opened a
|
||||
run, a single feature was recorded as five or six of them. A real pass ends
|
||||
where the session says it ends: a backwards move only starts a new run when it
|
||||
is declared (`agenthub stage`, or a codex lane's ingest-driven declaration). A
|
||||
is declared (`agenthub stage`). A
|
||||
detection that moves backwards continues the run in flight. Reaching the last
|
||||
node still closes the run for both — work after `done` is new work by anyone's
|
||||
reading. On this repo's own lane the rule took a 26-run history down to 5, which
|
||||
@@ -1155,72 +1151,23 @@ also **infers** a stage from the tool events every hook already delivers
|
||||
un-instrumented sessions that never call `agenthub stage` — it fills in a lane's
|
||||
pipeline map without requiring every skill to be rewritten.
|
||||
|
||||
Two event sources feed the identical matcher, one per tool:
|
||||
|
||||
| Lane `tool` | Source | Cadence |
|
||||
| --- | --- | --- |
|
||||
| `claude` | hooks — `touchLaneFromHook` in `server/routes/hooks.js` | every hook, live |
|
||||
| `codex` | Codex's own rollout journals on disk — `server/lib/codex-ingest.js` | poll, `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) |
|
||||
The matcher is fed by hooks — `touchLaneFromHook` in `server/routes/hooks.js`,
|
||||
on every hook, live — so it only ever sees `claude` lanes.
|
||||
|
||||
Codex has no hook mechanism at all, so `touchLaneFromHook` is pinned to
|
||||
`"claude"` — a hook must never touch a `codex` lane sharing the same directory.
|
||||
`codex-ingest.js` is the codex-side equivalent: it tails
|
||||
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`$CODEX_HOME` defaults
|
||||
to `~/.codex`), reads the thread's `cwd` from the `session_meta` on each file's
|
||||
first line, and maps Codex's tool calls onto the same `{tool_name, tool_input}`
|
||||
shape a Claude hook delivers:
|
||||
|
||||
| Codex tool call | Mapped to |
|
||||
| --- | --- |
|
||||
| `exec` / `exec_command` / `shell` / `local_shell` | `Bash` with the extracted `cmd` |
|
||||
| any of the above carrying an `apply_patch` heredoc | one `Write` (for `*** Add File:`) or `Edit` (`Update`/`Delete`) per touched file |
|
||||
| `update_plan` | `TaskCreate` |
|
||||
| `spawn_agent` | `Agent` with the task name as `subagent_type` |
|
||||
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
|
||||
|
||||
Everything past that point is shared: the same templates, the same
|
||||
forward-only/declared-wins rules, the same amber-never-green boundary. Three
|
||||
codex-only details:
|
||||
|
||||
- **A written detection also advances the lane's declared `stage`.** This is
|
||||
the one place in AgentHub where an inference moves the blue `current` ring, and
|
||||
it exists because nothing else can: a codex session has no hook and rarely
|
||||
calls `agenthub stage` on its own, so without it the card sits at `idle` forever
|
||||
while the amber badge is the only thing that ever moves. It is gated on
|
||||
`recordDetection` having *written*, so it inherits that function's
|
||||
forward-only, declared-wins and TTL guards unchanged — a detection behind the
|
||||
declared stage still advances nothing. The `setStage` call passes **no
|
||||
evidence**, so every node it moves through renders `passed-no-evidence`
|
||||
(amber), never `done`: a detection sees a tool call, never its outcome. And
|
||||
it passes `keepDetection: true`, the sole exception to `setStage`'s clearing
|
||||
of the detection columns — here the detection is the *reason* for the
|
||||
declaration, so the card keeps showing which command caused it rather than
|
||||
looking like a claim the agent made. The last node of every template carries
|
||||
no `detect` rules, so this can never auto-declare a lane `done`.
|
||||
|
||||
- Only bytes appended since the last pass are read. On the first sight of a
|
||||
journal only its recent tail is read, and nothing at all when that file is
|
||||
already idle — replaying a thread that finished days ago would paint stages
|
||||
for work nobody is doing. "Idle" means the journal's mtime is more than two
|
||||
minutes old **or in the future**: a file that claims to be from the future
|
||||
(clock stepped back, NTP correction, a restore that preserved timestamps)
|
||||
makes a raw `now - mtime` negative, which would otherwise read as "written
|
||||
milliseconds ago" and replay a finished thread as live work.
|
||||
- The same pass mirrors the lane's `status` between `idle` and `running` from
|
||||
the journal's mtime, under the identical guard hook ingestion uses (only a
|
||||
lane with no `run_id`, and only from `idle`/`running` — never from
|
||||
`provisioning` or `failed`). It writes nothing else: not `stage`, not
|
||||
`notes`, not `slot`/`ports`.
|
||||
|
||||
**A codex lane's *evidence* is a separate matter.** Auto-advance moves the ring
|
||||
but can never supply evidence, so every node a codex lane passes through stays
|
||||
amber until the session itself runs `agenthub stage <node> --evidence "..."`. AgentHub
|
||||
cannot inject an instruction into a CLI it merely spawns, so an `AGENTS.md` in
|
||||
the working copy is the only lever — `agenthub lanes agents install` writes the
|
||||
marker-delimited block for you (see "Installing the QC/gate agents"). Note that
|
||||
from inside a codex session `agenthub stage` needs to know which lane it is: a pane
|
||||
the dashboard started carries `AGENTHUB_LANE_ID`, and anywhere else `--tool codex`
|
||||
disambiguates (see "Which lane a CLI command addresses" below).
|
||||
Codex lanes carry no pipeline, so nothing detects or advances their stage.
|
||||
`server/lib/codex-ingest.js` only mirrors a codex lane's `status` between
|
||||
`idle` and `running`: every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0`
|
||||
disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
|
||||
(`$CODEX_HOME` defaults to `~/.codex`), reads each thread's `cwd` from the
|
||||
`session_meta` on the file's first line, and calls the owning lane `running`
|
||||
while that journal's mtime is under two minutes old — and `idle` once it is
|
||||
older **or in the future** (a clock stepped back makes a raw `now - mtime`
|
||||
negative, which would otherwise read as live). It uses the identical guard hook
|
||||
ingestion uses (only a lane with no `run_id`, and only from `idle`/`running` —
|
||||
never from `provisioning` or `failed`) and writes nothing else: not `stage`,
|
||||
not `notes`, not `slot`/`ports`.
|
||||
|
||||
### The evidence boundary
|
||||
|
||||
@@ -1787,16 +1734,9 @@ agenthub lanes agents install
|
||||
|
||||
Writes both agent definitions (from the repo's own `agents/`, the same files the plugin ships) into `<lane>/.claude/agents/` and adds that directory to the lane's local `.git/info/exclude` (never the tracked `.gitignore` — this is a per-clone runtime concern, not an app-repo change). Idempotent and never automatic, the same shape as `agenthub lanes proof-link` — a session installs the agents explicitly, once, before a lane's first run through Stage 6/7 (or after a AgentHub upgrade ships updated templates — reinstalling overwrites, it doesn't merge).
|
||||
|
||||
**On a `codex` lane the same command installs something else.** Codex never
|
||||
reads `.claude/agents/`, so copying agent definitions there would be dead
|
||||
files. Instead it writes the stage-declaration block into
|
||||
`<lane>/AGENTS.md` — the project doc Codex *does* load from the working
|
||||
directory — delimited by `<!-- agenthub:stage:start -->` / `<!-- agenthub:stage:end -->`
|
||||
so a reinstall replaces exactly that block and nothing else. The file is
|
||||
appended to when it already exists (your own prose is preserved) and created
|
||||
when it does not; `installed` comes back as `["AGENTS.md"]`. This is the lever
|
||||
that gets a codex session to supply the *evidence* auto-advance cannot — see
|
||||
"Stage detection".
|
||||
**On a `codex` lane the same command installs nothing** (`installed: []`, and
|
||||
the Tools menu hides it): Codex never reads `.claude/agents/`, and codex lanes
|
||||
carry no pipeline to drive.
|
||||
|
||||
**Credentials are not embedded.** Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. `qc-local` degrades gracefully: if it hits a login page with no credentials block present, it reports `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile` rather than guessing.
|
||||
|
||||
|
||||
+20
-9
@@ -106,9 +106,8 @@ root holds all four (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`,
|
||||
`.claude/agents/` — the highest-priority agent source — which is what makes the
|
||||
bare `subagent_type: qc-local` in the pipeline skill resolve there. One source
|
||||
file, two ways to reach it; the skill names the bare form first and falls back
|
||||
to the `agenthub:` form. On a `codex` lane the same command installs the
|
||||
`<!-- agenthub:stage:start -->` block in the lane's `AGENTS.md` instead — Codex
|
||||
never reads `.claude/agents/`.
|
||||
to the `agenthub:` form. On a `codex` lane the same command installs nothing —
|
||||
Codex never reads `.claude/agents/`, and codex lanes carry no pipeline.
|
||||
|
||||
Because the plugin ships the hooks itself, `npm run install-hooks` is not needed
|
||||
for plugin users — and must not be run alongside it. Events carry no id, so two
|
||||
@@ -137,10 +136,11 @@ waits on an install:
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
9. **Server** — on Linux with `systemd --user` available, install/refresh a
|
||||
`agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead
|
||||
`agenthub.service` unit — on macOS with a GUI login, the
|
||||
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
|
||||
of a bare spawn, so the server is supervised and comes back after a reboot
|
||||
with no Claude Code session involved. Everywhere else (macOS, Windows, or
|
||||
no systemd), spawn `server/index.js` detached, with `NODE_PATH` at the
|
||||
with no Claude Code session involved. Everywhere else (Windows, no
|
||||
systemd, a Mac reached only over SSH), spawn `server/index.js` detached, with `NODE_PATH` at the
|
||||
runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime
|
||||
`client-dist` — scoped to this session, same as before this fell back to
|
||||
systemd.
|
||||
@@ -233,15 +233,26 @@ anyone logs in; when that needs elevated rights, it's skipped silently (check
|
||||
`agenthub.service` unit not written by this bootstrap is left untouched, and the
|
||||
server falls back to the old per-session detached spawn instead.
|
||||
|
||||
On macOS, Windows, or a Linux box without systemd, nothing changes: the
|
||||
On macOS the same steps write a LaunchAgent instead
|
||||
(`~/Library/LaunchAgents/io.smartgift.agenthub.plist`, `scripts/launchd-service.js`;
|
||||
`scripts/service-manager.js` picks the one for the platform, and both export the
|
||||
same API). `KeepAlive` plays `Restart=always`, `AbandonProcessGroup` keeps the
|
||||
lane panes' tmux server alive across a restart, output goes to
|
||||
`~/Library/Logs/agenthub.log`, and the hand-over restart is `launchctl bootout`
|
||||
+ `bootstrap` (a `kickstart` would keep the old plist). If that bootstrap fails
|
||||
and no server is left running, the bootstrap starts one detached. A LaunchAgent
|
||||
needs the user's GUI login; without one (SSH only) `launchctl print gui/<uid>`
|
||||
fails and the server falls back to the detached spawn below.
|
||||
|
||||
On Windows, a Linux box without systemd, or a Mac with no GUI login, the
|
||||
server is a detached spawn started fresh on the next `SessionStart` after a
|
||||
reboot, same as always. That spawn inherits the Claude Code session's env, so
|
||||
`server/index.js` deletes the parent-session variables (`CLAUDECODE`,
|
||||
`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …)
|
||||
before anything else runs — otherwise tmux hands them to every lane pane. It is
|
||||
a denylist: user configuration such as `CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. There is no
|
||||
launchd agent; see [INSTALL.md § macOS](../INSTALL.md#macos).
|
||||
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. See
|
||||
[INSTALL.md § macOS](../INSTALL.md#macos).
|
||||
|
||||
### Who may stop the server
|
||||
|
||||
|
||||
@@ -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")
|
||||
+4
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.8.9
|
||||
version: 1.8.11
|
||||
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ĩ
|
||||
@@ -11253,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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.9",
|
||||
"version": "1.8.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.8.9",
|
||||
"version": "1.8.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.9",
|
||||
"version": "1.8.11",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+23
-11
@@ -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.
|
||||
*
|
||||
@@ -169,7 +170,7 @@ function fastPathOk(rt = runtimeDir()) {
|
||||
// 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 —
|
||||
@@ -591,7 +592,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 */
|
||||
@@ -724,7 +725,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
|
||||
@@ -763,6 +764,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"),
|
||||
@@ -792,13 +794,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
|
||||
@@ -806,7 +809,7 @@ 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` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -843,8 +846,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)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,16 +233,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 +253,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,11 +262,13 @@ 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`];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
-25
@@ -584,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
|
||||
|
||||
@@ -697,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 |
|
||||
| -------- | ----------------------------- | ----------- |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -661,6 +661,107 @@ 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";
|
||||
|
||||
@@ -16,6 +16,7 @@ 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");
|
||||
@@ -87,6 +88,15 @@ describe("merge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -18,7 +18,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 { installAgents, removeStageDoc } = require("../lib/lane-agents");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
@@ -116,29 +116,79 @@ describe("installAgents", () => {
|
||||
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" });
|
||||
for (const tool of ["codex", "antigravity"]) {
|
||||
it(`installs nothing for a ${tool} lane — no .claude/agents/, no AGENTS.md`, async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool });
|
||||
|
||||
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/);
|
||||
const result = await installAgents(lane);
|
||||
assert.deepEqual(result.installed, []);
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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`;
|
||||
|
||||
it("removes only the AgentHub block, keeping the user's own AGENTS.md prose", () => {
|
||||
const cwd = makeRepoDir();
|
||||
const file = path.join(cwd, "AGENTS.md");
|
||||
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.match(doc, /Keep me\./);
|
||||
});
|
||||
|
||||
it("preserves the user's own AGENTS.md prose and replaces only the block", async () => {
|
||||
it("deletes an AGENTS.md that held nothing but the block", () => {
|
||||
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");
|
||||
fs.writeFileSync(path.join(cwd, "AGENTS.md"), BLOCK);
|
||||
assert.equal(removeStageDoc(cwd), true);
|
||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||
});
|
||||
|
||||
await installAgents(lane);
|
||||
await installAgents(lane); // reinstall must not duplicate the block
|
||||
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");
|
||||
});
|
||||
|
||||
const doc = fs.readFileSync(file, "utf8");
|
||||
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);
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1302,4 +1302,20 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
restorePath();
|
||||
}
|
||||
});
|
||||
|
||||
it("healAllRunIds releases a lane whose pane exited, with no read to trigger it", async () => {
|
||||
// A codex/antigravity pane fires no hook on exit, so nothing re-reads its
|
||||
// lane; the server's periodic sweep is what has to notice.
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { healAllRunIds } = require("../routes/lanes");
|
||||
const lane = await adoptedLane("heal-all-sweep");
|
||||
// No tmux session by this name exists on the suite's private server.
|
||||
lanesLib.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}`, status: "running" });
|
||||
|
||||
healAllRunIds();
|
||||
|
||||
const after = lanesLib.getLane(lane.id);
|
||||
assert.equal(after.run_id, null);
|
||||
assert.equal(after.status, "idle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcp-"));
|
||||
const FAKE_HOME = path.join(SUITE_ROOT, "home");
|
||||
@@ -163,21 +164,77 @@ describe("syncMcp — profile seeding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncMcp — .git/info/exclude idempotency", () => {
|
||||
it("appends .mcp.json once and does not duplicate it on a second call", async () => {
|
||||
describe("syncMcp — keeping .mcp.json out of git", () => {
|
||||
// Real repos, asserted through `git status`: git reads info/exclude from the
|
||||
// COMMON git dir only, which a fake `.git` dir cannot show.
|
||||
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
|
||||
// would point these fixture calls at the outer repo.
|
||||
const git = (cwd, ...args) => {
|
||||
const env = { ...process.env };
|
||||
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
||||
delete env[name];
|
||||
}
|
||||
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
|
||||
};
|
||||
const untracked = (cwd) => git(cwd, "status", "--porcelain").split("\n").filter(Boolean);
|
||||
|
||||
function mcpLane(cwd) {
|
||||
const sourceRepo = path.join(SUITE_ROOT, "src-exclude");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
writeClaudeJson({
|
||||
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
|
||||
});
|
||||
const lane = makeLane(sourceRepo);
|
||||
fs.mkdirSync(path.join(lane.cwd, ".git"), { recursive: true });
|
||||
return { cwd, source_repo: sourceRepo };
|
||||
}
|
||||
|
||||
await laneMcp.syncMcp(lane);
|
||||
await laneMcp.syncMcp(lane);
|
||||
function makeRepo() {
|
||||
const repo = makeLane(null).cwd;
|
||||
git(repo, "init", "-q");
|
||||
git(
|
||||
repo,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init"
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
const exclude = fs.readFileSync(path.join(lane.cwd, ".git", "info", "exclude"), "utf8");
|
||||
const matches = exclude.split("\n").filter((line) => line === ".mcp.json");
|
||||
assert.equal(matches.length, 1);
|
||||
it("hides .mcp.json, once, however many times it syncs", async () => {
|
||||
const repo = makeRepo();
|
||||
await laneMcp.syncMcp(mcpLane(repo));
|
||||
await laneMcp.syncMcp(mcpLane(repo));
|
||||
|
||||
assert.deepEqual(untracked(repo), []);
|
||||
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
|
||||
assert.equal(exclude.split("\n").filter((line) => line.includes(".mcp.json")).length, 1);
|
||||
});
|
||||
|
||||
it("hides it in a worktree lane, whose .git is a pointer file", async () => {
|
||||
const repo = makeRepo();
|
||||
const wt = path.join(SUITE_ROOT, `wt-${laneSeq}`);
|
||||
git(repo, "worktree", "add", "-q", "-b", "lane1", wt);
|
||||
await laneMcp.syncMcp(mcpLane(wt));
|
||||
assert.deepEqual(untracked(wt), []);
|
||||
});
|
||||
|
||||
it("hides it in a lane that is a subdirectory of its repo", async () => {
|
||||
const repo = makeRepo();
|
||||
const sub = path.join(repo, "packages", "app");
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
await laneMcp.syncMcp(mcpLane(sub));
|
||||
assert.deepEqual(untracked(repo), []);
|
||||
});
|
||||
|
||||
it("creates no .git in a lane that is not a repo", async () => {
|
||||
const cwd = makeLane(null).cwd;
|
||||
await laneMcp.syncMcp(mcpLane(cwd));
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".mcp.json")));
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".git")), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @file Tests scripts/launchd-service.js, the macOS LaunchAgent twin of the
|
||||
* systemd unit: the plist it writes (KeepAlive, AbandonProcessGroup so lane
|
||||
* panes survive a restart, the plugin root as WorkingDirectory), that writing
|
||||
* starts nothing, that a restart re-reads the plist (bootout + bootstrap, not
|
||||
* kickstart), and that a foreign plist is never touched. `launchctl` is
|
||||
* stubbed and the platform faked, so this runs on any OS.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, 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 TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-launchd-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = TMP_HOME;
|
||||
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
|
||||
const svc = require("../../scripts/launchd-service");
|
||||
|
||||
const DOMAIN = `gui/${process.getuid()}`;
|
||||
const JOB = `${DOMAIN}/io.smartgift.agenthub`;
|
||||
|
||||
let calls = [];
|
||||
let loaded = false;
|
||||
let running = false;
|
||||
let failBootstrap = false;
|
||||
let noGui = false;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
loaded = running = failBootstrap = noGui = false;
|
||||
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
||||
svc.__setExecImpl((args) => {
|
||||
if (args[0] === "print" && args[1] === DOMAIN) return { status: noGui ? 113 : 0 };
|
||||
calls.push(args.join(" "));
|
||||
if (args[0] === "print") {
|
||||
return {
|
||||
status: loaded ? 0 : 113,
|
||||
stdout: running ? "\tstate = running\n" : "\tstate = not running\n",
|
||||
};
|
||||
}
|
||||
if (args[0] === "bootstrap") return { status: failBootstrap ? 5 : 0 };
|
||||
return { status: 0 };
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
svc.__reset();
|
||||
Object.defineProperty(process, "platform", realPlatform);
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const UNIT = {
|
||||
name: "agenthub",
|
||||
description: "AgentHub dashboard server (Claude Code plugin)",
|
||||
workingDir: "/plugin/root/new & improved",
|
||||
execStart: "/usr/local/bin/node /plugin/root/new/server/index.js",
|
||||
argv: ["/usr/local/bin/node", "/plugin/root/new & improved/server/index.js"],
|
||||
env: { NODE_ENV: "production", PATH: "/usr/bin:/bin" },
|
||||
};
|
||||
|
||||
const plist = () => fs.readFileSync(svc.unitPath("agenthub"), "utf8");
|
||||
|
||||
describe("launchd-service — writeUserService", () => {
|
||||
it("writes a LaunchAgent that restarts forever and spares the lane panes, and starts nothing", () => {
|
||||
assert.equal(svc.writeUserService(UNIT), "written");
|
||||
const body = plist();
|
||||
assert.ok(svc.unitPath("agenthub").startsWith(path.join(TMP_HOME, "Library", "LaunchAgents")));
|
||||
assert.match(body, /<key>Label<\/key>\s*<string>io\.smartgift\.agenthub<\/string>/);
|
||||
assert.match(body, /<key>KeepAlive<\/key>\s*<true\/>/);
|
||||
assert.match(body, /<key>RunAtLoad<\/key>\s*<true\/>/);
|
||||
assert.match(body, /<key>AbandonProcessGroup<\/key>\s*<true\/>/);
|
||||
// argv stays split, so a path with a space is one argument; XML escaped.
|
||||
assert.match(body, /<string>\/plugin\/root\/new & improved\/server\/index\.js<\/string>/);
|
||||
assert.match(body, /<key>PATH<\/key>\s*<string>\/usr\/bin:\/bin<\/string>/);
|
||||
assert.deepEqual(calls, [`enable ${JOB}`], "writing must not load or start the job");
|
||||
});
|
||||
|
||||
it("leaves an identical plist alone", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
calls = [];
|
||||
assert.equal(svc.writeUserService(UNIT), "unchanged");
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
it("never overwrites a plist it did not write", () => {
|
||||
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
||||
fs.writeFileSync(svc.unitPath("agenthub"), "<plist>theirs</plist>");
|
||||
assert.equal(svc.writeUserService(UNIT), "foreign");
|
||||
assert.equal(plist(), "<plist>theirs</plist>");
|
||||
assert.equal(svc.uninstallUserService("agenthub"), "foreign");
|
||||
assert.ok(fs.existsSync(svc.unitPath("agenthub")));
|
||||
});
|
||||
|
||||
it("is unavailable without a GUI login, and writes nothing", () => {
|
||||
noGui = true;
|
||||
assert.equal(svc.systemctlAvailable(), false);
|
||||
assert.equal(svc.writeUserService(UNIT), "unavailable");
|
||||
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchd-service — stale and root", () => {
|
||||
it("reads the working directory back, escaped path included", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
assert.equal(svc.unitRoot("agenthub"), UNIT.workingDir);
|
||||
assert.equal(svc.unitIsStale("agenthub", UNIT.workingDir), false);
|
||||
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/newer"), true);
|
||||
});
|
||||
|
||||
it("calls neither a missing nor a foreign plist stale", () => {
|
||||
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
|
||||
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
svc.unitPath("agenthub"),
|
||||
"<key>WorkingDirectory</key><string>/elsewhere</string>"
|
||||
);
|
||||
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
|
||||
assert.equal(svc.unitRoot("agenthub"), "/elsewhere", "the pruner still must not delete it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchd-service — restart, install, uninstall", () => {
|
||||
it("restarts by bootout + bootstrap, so the new plist is what runs", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
calls = [];
|
||||
assert.equal(svc.restartUserService("agenthub"), "restarted");
|
||||
assert.deepEqual(calls, [`bootout ${JOB}`, `bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`]);
|
||||
});
|
||||
|
||||
it("reports a failed bootstrap, so the caller can fall back", () => {
|
||||
failBootstrap = true;
|
||||
assert.equal(svc.restartUserService("agenthub"), "failed");
|
||||
});
|
||||
|
||||
it("install loads a new plist, and only kickstarts one already loaded", () => {
|
||||
assert.equal(svc.installUserService(UNIT), "written");
|
||||
assert.ok(calls.includes(`bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`));
|
||||
calls = [];
|
||||
loaded = true;
|
||||
assert.equal(svc.installUserService(UNIT), "unchanged");
|
||||
assert.ok(calls.includes(`kickstart ${JOB}`));
|
||||
assert.ok(!calls.some((c) => c.startsWith("bootout")), "a running dashboard is not bounced");
|
||||
});
|
||||
|
||||
it("uninstall unloads and deletes our plist", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
calls = [];
|
||||
assert.equal(svc.uninstallUserService("agenthub"), "removed");
|
||||
assert.deepEqual(calls, [`bootout ${JOB}`]);
|
||||
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
|
||||
assert.equal(svc.uninstallUserService("agenthub"), "absent");
|
||||
});
|
||||
|
||||
it("is active only when launchd says the job is running", () => {
|
||||
assert.equal(svc.unitIsActive("agenthub"), false);
|
||||
loaded = true;
|
||||
assert.equal(svc.unitIsActive("agenthub"), false);
|
||||
running = true;
|
||||
assert.equal(svc.unitIsActive("agenthub"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("service-manager", () => {
|
||||
it("hands out the LaunchAgent on macOS", () => {
|
||||
delete require.cache[require.resolve("../../scripts/service-manager")];
|
||||
assert.equal(require("../../scripts/service-manager").kind, "launchd");
|
||||
});
|
||||
});
|
||||
@@ -398,6 +398,27 @@ describe("pty-run", () => {
|
||||
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
||||
});
|
||||
|
||||
it("spawnRun runs `agy` for an antigravity lane, dropping flags it has no use for", () => {
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 13,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
permissionMode: "bypassPermissions",
|
||||
initialPrompt: "hello",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), ["agy", "-i", "hello"]);
|
||||
|
||||
tmux.__reset();
|
||||
const last = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", tool: "antigravity", resumeLast: true })
|
||||
);
|
||||
assert.deepEqual(paneArgv(last), ["agy", "--continue"]);
|
||||
});
|
||||
|
||||
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
|
||||
// null there persisted nothing at all while every call still looked fine: the
|
||||
// runs modal rendered a row with no cwd, model, prompt or start time, and a
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
@@ -144,50 +145,84 @@ describe("router-apply — Claude lane", () => {
|
||||
});
|
||||
|
||||
describe("router-apply — the written file must survive a lane reset", () => {
|
||||
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
|
||||
// Real repos, asserted through `git status`: a fake `.git` dir let a test pass
|
||||
// while git itself never read the exclude file it checked.
|
||||
const REPO = path.join(SUITE_ROOT, "repo");
|
||||
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
|
||||
// would point these fixture calls at the outer repo.
|
||||
const git = (cwd, ...args) => {
|
||||
const env = { ...process.env };
|
||||
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
||||
delete env[name];
|
||||
}
|
||||
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
|
||||
};
|
||||
const untracked = (cwd) =>
|
||||
git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no")
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
// A machine-wide ignore (Claude Code adds one for settings.local.json) would
|
||||
// hide the very file these tests look for.
|
||||
beforeEach(() => {
|
||||
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
git(REPO, "init", "-q");
|
||||
git(
|
||||
REPO,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init"
|
||||
);
|
||||
});
|
||||
|
||||
const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" });
|
||||
|
||||
it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => {
|
||||
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
|
||||
// Without -x that spares IGNORED files only — an untracked settings file
|
||||
// would be deleted, silently detaching the lane from its router. The same
|
||||
// entry also stops an API key from being committable.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("excludes it for a native lane too, not only a lane on a router", () => {
|
||||
// The blanked connection pair is load-bearing in its own right: losing it
|
||||
// to `git clean -fd` lets a USER-level router take over a lane that
|
||||
// explicitly asked for native.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => {
|
||||
// git reads info/exclude from the common git dir only; a copy under
|
||||
// .git/worktrees/<name>/info/ is never consulted.
|
||||
const wt = path.join(SUITE_ROOT, "wt");
|
||||
fs.rmSync(wt, { recursive: true, force: true });
|
||||
git(REPO, "worktree", "add", "-q", "-b", "lane1", wt);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(laneAt(wt), name);
|
||||
applyRouterToLane(laneAt(wt), "native");
|
||||
assert.deepEqual(untracked(wt), []);
|
||||
});
|
||||
|
||||
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
|
||||
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
|
||||
// would be a path that cannot exist.
|
||||
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
|
||||
it("hides them in a lane that is a subdirectory of its repo", () => {
|
||||
const sub = path.join(REPO, "packages", "app");
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
applyRouterToLane(laneAt(sub), name);
|
||||
applyRouterToLane(laneAt(sub), "native");
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("adds the entry once, however many times a router is applied", () => {
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
|
||||
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
|
||||
});
|
||||
|
||||
@@ -198,6 +233,24 @@ describe("router-apply — the written file must survive a lane reset", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — Antigravity lane", () => {
|
||||
const agyLane = () => ({ id: 9, cwd: LANE_CWD, tool: "antigravity" });
|
||||
|
||||
it("accepts native and writes nothing", () => {
|
||||
assert.equal(applyRouterToLane(agyLane(), "native").file, null);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
|
||||
it("refuses a real router instead of writing Claude's settings file", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.throws(
|
||||
() => applyRouterToLane(agyLane(), name),
|
||||
(err) => err.code === "EUNSUPPORTED"
|
||||
);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — Codex lane", () => {
|
||||
it("writes a profile layer file, never the base config", () => {
|
||||
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
||||
|
||||
+30
-11
@@ -480,9 +480,22 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
console.warn("workflow poll failed to start:", err.message);
|
||||
}
|
||||
// Codex lane stage detection. Codex emits no hooks at all, so its rollout
|
||||
// journals on disk are the only signal a `codex` lane is working — without
|
||||
// this poll such a lane's pipeline map never moves. `broadcastLane` (not the
|
||||
// Codex lanes carry no pipeline. Older builds inferred one and wrote a stage
|
||||
// block into their AGENTS.md; take both back out. Idempotent, every start.
|
||||
try {
|
||||
const lanesLib = require("./lib/lanes");
|
||||
const { removeStageDoc } = require("./lib/lane-agents");
|
||||
const wiped = lanesLib.clearCodexPipelines();
|
||||
if (wiped > 0) console.log(`Cleared leftover pipeline data from ${wiped} codex lane(s)`);
|
||||
for (const lane of lanesLib.listLanes().filter((l) => l.tool === "codex")) {
|
||||
if (removeStageDoc(lane.cwd))
|
||||
console.log(`Removed the AgentHub stage block from ${lane.cwd}/AGENTS.md`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("codex pipeline cleanup failed:", err.message);
|
||||
}
|
||||
// Codex lane status. Codex emits no hooks at all, so its rollout journals on
|
||||
// disk are the only signal a `codex` lane is working. `broadcastLane` (not the
|
||||
// bare `broadcast(type, data)` above) is the single-lane push the hook route
|
||||
// uses.
|
||||
try {
|
||||
@@ -491,14 +504,20 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
console.warn("codex poll failed to start:", err.message);
|
||||
}
|
||||
// Safety net: a codex lane only declares stage when the session calls
|
||||
// `agenthub stage` itself. When auto-detection has clearly moved on without a
|
||||
// matching declaration, nudge the session's own pane to make the real call
|
||||
// — never writes stage itself, so the declared-wins invariant is untouched.
|
||||
try {
|
||||
require("./lib/codex-nudge").startCodexNudge();
|
||||
} catch (err) {
|
||||
console.warn("codex nudge failed to start:", err.message);
|
||||
// A codex/antigravity pane that exits fires no hook, so release its lane on
|
||||
// a beat instead (one `tmux has-session` per lane with a run). Off with
|
||||
// DASHBOARD_RUN_HEAL_MS=0.
|
||||
const HEAL_MS = Number(process.env.DASHBOARD_RUN_HEAL_MS ?? 10_000);
|
||||
if (Number.isFinite(HEAL_MS) && HEAL_MS > 0) {
|
||||
const { healAllRunIds } = require("./routes/lanes");
|
||||
const healTimer = setInterval(() => {
|
||||
try {
|
||||
healAllRunIds();
|
||||
} catch (err) {
|
||||
console.warn("run heal failed:", err.message);
|
||||
}
|
||||
}, HEAL_MS);
|
||||
if (healTimer.unref) healTimer.unref();
|
||||
}
|
||||
// Continuous discovery of sessions under ~/.claude/projects. The one-time
|
||||
// legacy backfill above runs only once (marker-gated), so a project added
|
||||
|
||||
+22
-222
@@ -1,26 +1,16 @@
|
||||
/**
|
||||
* @file Codex stage ingestion. Codex emits no hooks, so the path that infers a
|
||||
* lane's stage (server/routes/hooks.js → stage-detect.detect) can never see a
|
||||
* `codex` lane — and it is deliberately pinned to `claude`, because a Claude
|
||||
* hook must never touch a codex lane sharing the same directory. Codex's
|
||||
* on-disk equivalent is the rollout journal it appends live, one file per
|
||||
* thread:
|
||||
* @file Codex lane status mirror. Codex emits no hooks, so nothing else can
|
||||
* tell the dashboard that a `codex` lane's agent is working. Its on-disk
|
||||
* signal is the rollout journal it appends live, one file per thread:
|
||||
*
|
||||
* $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl
|
||||
* line 1: {"type":"session_meta","payload":{"cwd":…,"session_id":…}}
|
||||
* then: {"type":"response_item","payload":{"type":"custom_tool_call",…}}
|
||||
*
|
||||
* This module tails those journals for the cwd of every `codex` lane, maps each
|
||||
* tool-call payload onto the same `{tool_name, tool_input}` shape a Claude hook
|
||||
* delivers, and feeds the EXISTING detect() + recordDetection() path — so a
|
||||
* codex lane paints the same amber "detected" badge under the same rules
|
||||
* (forward-only, declared-wins) and never renders `done`. A written detection
|
||||
* then ALSO advances the lane's declared `stage` to that node, without
|
||||
* evidence: codex lanes are the one place where nothing else can move the
|
||||
* pipeline map, so the node renders `passed-no-evidence` (amber) rather than
|
||||
* `done`, and codex-nudge.js asks the session for the real evidence. Like
|
||||
* workflow-ingest.js, everything here is fail-safe: a malformed or partial
|
||||
* journal throws only locally and is skipped, and a poll pass never throws.
|
||||
* This module maps each journal to the `codex` lane owning its cwd and mirrors
|
||||
* idle↔running from how recently the journal was written. It reads nothing but
|
||||
* the first line (for the cwd) and never touches a lane's stage — codex lanes
|
||||
* carry no pipeline. Fail-safe like workflow-ingest.js: a malformed journal is
|
||||
* skipped, and a poll pass never throws.
|
||||
*
|
||||
* Codex 0.149 also mirrors threads into version-suffixed SQLite files
|
||||
* (`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still
|
||||
@@ -35,70 +25,10 @@ const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
|
||||
// `*** Update File: <path>` lines inside an apply_patch heredoc.
|
||||
const PATCH_FILE_RE = /^\*\*\* (Add|Update|Delete) File: (.+)$/gm;
|
||||
const SHELL_NAMES = new Set(["exec", "exec_command", "shell", "local_shell", "container.exec"]);
|
||||
|
||||
const CODEX_ACTIVE_MS = 120_000; // a journal written within 2 min means the agent is working
|
||||
const TAIL_BYTES = 64 * 1024; // first sight of a live file: read only its recent tail
|
||||
const MAX_FILES = 40; // newest-first cap so a years-deep sessions/ tree can't stall a pass
|
||||
const MAX_READ_BYTES = 4 * 1024 * 1024; // one pass never buffers more than this per journal
|
||||
const MAX_DEPTH = 4; // sessions/<YYYY>/<MM>/<DD>/<file>
|
||||
|
||||
/** The `cmd` of a shell call, whether it arrived as JSON arguments or an exec script. */
|
||||
function commandFromPayload(payload, text) {
|
||||
try {
|
||||
const parsed = JSON.parse(payload.arguments);
|
||||
if (parsed && typeof parsed.cmd === "string") return parsed.cmd;
|
||||
if (parsed && Array.isArray(parsed.command)) return parsed.command.join(" ");
|
||||
} catch {
|
||||
/* not JSON — an exec script; the detect rules match inside it verbatim */
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-shaped events for one rollout payload. A patch script yields one event
|
||||
* per touched file (Write for a new file, Edit otherwise — the split the
|
||||
* pipeline templates' detect rules assume); a shell call yields one Bash event.
|
||||
* Anything else — reasoning, messages, tool OUTPUT, a web search — yields
|
||||
* nothing, so an unknown Codex tool can never be mistaken for work.
|
||||
*
|
||||
* @param {object|null} payload - One rollout line's `payload` object.
|
||||
* @returns {Array<{tool_name: string, tool_input: object}>}
|
||||
*/
|
||||
function eventsFromPayload(payload) {
|
||||
if (!payload || typeof payload !== "object") return [];
|
||||
if (payload.type !== "custom_tool_call" && payload.type !== "function_call") return [];
|
||||
const name = typeof payload.name === "string" ? payload.name : "";
|
||||
const text = typeof payload.input === "string" ? payload.input : String(payload.arguments || "");
|
||||
|
||||
if (name === "update_plan") return [{ tool_name: "TaskCreate", tool_input: {} }];
|
||||
if (name === "spawn_agent") {
|
||||
let taskName = "";
|
||||
try {
|
||||
taskName = JSON.parse(payload.arguments).task_name || "";
|
||||
} catch {
|
||||
/* encrypted or malformed arguments — the tool name alone still matters */
|
||||
}
|
||||
return [{ tool_name: "Agent", tool_input: { subagent_type: taskName } }];
|
||||
}
|
||||
if (!SHELL_NAMES.has(name)) return [];
|
||||
|
||||
// A patch heredoc reaches us inside a JS string literal, so its line breaks
|
||||
// are usually a literal backslash-n rather than newlines (real rollouts carry
|
||||
// both forms in the same script). Unescape before scanning or the file list
|
||||
// comes back empty and a whole edit looks like a plain shell command.
|
||||
const patched = [...text.replace(/\\n/g, "\n").matchAll(PATCH_FILE_RE)];
|
||||
if (patched.length) {
|
||||
return patched.map(([, verb, file]) => ({
|
||||
tool_name: verb === "Add" ? "Write" : "Edit",
|
||||
tool_input: { file_path: file.trim() },
|
||||
}));
|
||||
}
|
||||
return [{ tool_name: "Bash", tool_input: { command: commandFromPayload(payload, text) } }];
|
||||
}
|
||||
|
||||
/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */
|
||||
function rolloutFiles() {
|
||||
const out = [];
|
||||
@@ -151,101 +81,29 @@ function rolloutCwd(file) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
const offsets = new Map(); // rollout path → bytes already ingested
|
||||
|
||||
/** Test seam: forget every journal's ingest position (and cached cwd). */
|
||||
function __resetOffsets() {
|
||||
offsets.clear();
|
||||
/** Test seam: forget every cached cwd. */
|
||||
function __resetCache() {
|
||||
cwdCache.clear();
|
||||
}
|
||||
|
||||
// Both maps are keyed by file path, so in a process that runs for months they
|
||||
// would otherwise hold one dead entry per journal Codex ever wrote. Pruned only
|
||||
// once well past the per-pass scan size, so ordinary churn keeps its positions
|
||||
// and a journal that drops out of the scan behaves exactly as it does after a
|
||||
// server restart: re-seen as new, and read only if it is being written to.
|
||||
// Keyed by file path, so in a process that runs for months it would otherwise
|
||||
// hold one dead entry per journal Codex ever wrote. Pruned only once well past
|
||||
// the per-pass scan size, so ordinary churn keeps its entries.
|
||||
const MAX_TRACKED = MAX_FILES * 4;
|
||||
|
||||
function pruneTracking(seen) {
|
||||
for (const map of [offsets, cwdCache]) {
|
||||
if (map.size <= MAX_TRACKED) continue;
|
||||
for (const key of map.keys()) if (!seen.has(key)) map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read `[from, size)` of a file, or an empty buffer when it can't be read. */
|
||||
function readRange(file, from, size) {
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(size - from);
|
||||
const read = fs.readSync(fd, buf, 0, buf.length, from);
|
||||
return buf.subarray(0, read);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (cwdCache.size <= MAX_TRACKED) return;
|
||||
for (const key of cwdCache.keys()) if (!seen.has(key)) cwdCache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The COMPLETE lines in `[from, size)`, plus the offset to resume from.
|
||||
*
|
||||
* Two fragments have to be handled, and only one of them is dropped:
|
||||
* - a tail read (`firstSight` with `from > 0`) starts mid-line, so its leading
|
||||
* fragment is discarded;
|
||||
* - a read that catches the writer mid-line ends with a fragment, so the
|
||||
* returned offset stops at the last newline and that fragment is re-read
|
||||
* whole on the next pass.
|
||||
*
|
||||
* Every non-tail read therefore starts exactly on a line boundary, where the
|
||||
* first line is a WHOLE record. Dropping it there — the obvious "the offset
|
||||
* always lands mid-line" shortcut — loses every append that arrives one line at
|
||||
* a time, which is how a journal actually grows.
|
||||
*
|
||||
* All of it is done on the BUFFER, never on a decoded string: a tail offset can
|
||||
* land inside a multi-byte UTF-8 sequence, where the decoder emits a 3-byte
|
||||
* U+FFFD for 1-2 consumed bytes. Measuring a decoded slice with
|
||||
* `Buffer.byteLength` would then move the offset off the real line boundary and
|
||||
* every later record would fail to parse.
|
||||
*/
|
||||
const NEWLINE = 0x0a;
|
||||
|
||||
function completeLines(file, from, size, firstSight) {
|
||||
let chunk = readRange(file, from, size);
|
||||
let start = from;
|
||||
if (firstSight && from > 0) {
|
||||
const firstBreak = chunk.indexOf(NEWLINE);
|
||||
if (firstBreak === -1) return { lines: [], next: size };
|
||||
start += firstBreak + 1;
|
||||
chunk = chunk.subarray(firstBreak + 1);
|
||||
}
|
||||
const lastBreak = chunk.lastIndexOf(NEWLINE);
|
||||
if (lastBreak === -1) return { lines: [], next: start };
|
||||
return {
|
||||
lines: chunk.subarray(0, lastBreak).toString("utf8").split("\n"),
|
||||
next: start + lastBreak + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll pass. Reads only the bytes appended since the last pass; on the
|
||||
* first sight of a file, only its recent tail, and nothing at all when the file
|
||||
* is already idle — replaying a thread that finished days ago would paint
|
||||
* stages from work nobody is doing.
|
||||
*
|
||||
* Detection uses the LAST matching event of the batch, mirroring "one hook, one
|
||||
* detection"; recordDetection is forward-only, so ordering inside a batch can
|
||||
* never drag a lane backwards.
|
||||
* One poll pass: mirror each codex lane's idle↔running from its journals.
|
||||
*
|
||||
* @param {{broadcast?: (laneId: number) => void}} [options]
|
||||
* @returns {number} How many lanes actually changed (and were broadcast).
|
||||
*/
|
||||
function ingestCodexOnce({ broadcast } = {}) {
|
||||
const lanesLib = require("./lanes");
|
||||
const { detect } = require("./stage-detect");
|
||||
const { getPipeline } = require("./pipelines");
|
||||
|
||||
let codexLanes = [];
|
||||
try {
|
||||
@@ -266,67 +124,12 @@ function ingestCodexOnce({ broadcast } = {}) {
|
||||
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
||||
if (!lane) continue;
|
||||
|
||||
const firstSight = !offsets.has(file.path);
|
||||
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
|
||||
// preserved timestamps) makes the raw difference negative, which reads as
|
||||
// "written milliseconds ago" and would let a finished thread's journal
|
||||
// replay as live work. A file that claims to be from the future is not
|
||||
// evidence of an agent working now, so treat it as idle.
|
||||
// "written milliseconds ago". A file that claims to be from the future is
|
||||
// not evidence of an agent working now, so treat it as idle.
|
||||
const age = Date.now() - file.mtime;
|
||||
const active = age >= 0 && age < CODEX_ACTIVE_MS;
|
||||
let from = firstSight ? Math.max(0, file.size - TAIL_BYTES) : offsets.get(file.path);
|
||||
// A stored offset past EOF means the file was truncated or a new thread took
|
||||
// the same path. Restart from a bounded tail, never from 0: reading a
|
||||
// multi-gigabyte journal whole would allocate it all in one buffer.
|
||||
let tailOnly = firstSight;
|
||||
if (from > file.size || file.size - from > MAX_READ_BYTES) {
|
||||
from = Math.max(0, file.size - TAIL_BYTES);
|
||||
tailOnly = true;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
// A journal seen for the first time while already idle is history, not work:
|
||||
// note where it ends and read nothing.
|
||||
if (firstSight && !active) {
|
||||
offsets.set(file.path, file.size);
|
||||
} else if (file.size > from) {
|
||||
const { lines, next } = completeLines(file.path, from, file.size, tailOnly);
|
||||
offsets.set(file.path, next);
|
||||
const pipeline = getPipeline(lane.pipeline);
|
||||
let detection = null;
|
||||
for (const raw of lines) {
|
||||
if (!raw.trim()) continue;
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(raw).payload;
|
||||
} catch {
|
||||
continue; // not a record this module understands
|
||||
}
|
||||
for (const event of eventsFromPayload(payload)) {
|
||||
const hit = detect(pipeline, event);
|
||||
if (hit) detection = hit;
|
||||
}
|
||||
}
|
||||
if (detection) {
|
||||
try {
|
||||
changed = lanesLib.recordDetection(lane.id, detection).written;
|
||||
// Auto-advance: a codex session has no hook and rarely calls `agenthub
|
||||
// stage` on its own, so without this its card sits at `idle` forever
|
||||
// while the amber badge is the only thing that ever moves. Gated on
|
||||
// recordDetection having WRITTEN, so it inherits that function's
|
||||
// forward-only + declared-wins + TTL guards for free. Deliberately
|
||||
// passes NO evidence: the node renders `passed-no-evidence` (amber),
|
||||
// never `done` — detection sees a tool call, never its outcome. The
|
||||
// last node has no detect rules in any template, so this can never
|
||||
// auto-declare a lane `done`.
|
||||
if (changed) {
|
||||
lanesLib.setStage(lane.id, { stage: detection.nodeId, keepDetection: true });
|
||||
}
|
||||
} catch {
|
||||
/* lane deleted mid-pass, or sqlite busy — an inference is never worth throwing for */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status mirror, under the same guard hook ingestion uses: only idle↔running,
|
||||
// and only for a lane the dashboard did not launch — provisioning and failed
|
||||
@@ -340,12 +143,11 @@ function ingestCodexOnce({ broadcast } = {}) {
|
||||
) {
|
||||
try {
|
||||
lanesLib.updateLane(lane.id, { status: next });
|
||||
changed = true;
|
||||
touched.add(lane.id);
|
||||
} catch {
|
||||
/* as above */
|
||||
/* lane deleted mid-pass, or sqlite busy — a status mirror is never worth throwing for */
|
||||
}
|
||||
}
|
||||
if (changed) touched.add(lane.id);
|
||||
}
|
||||
|
||||
for (const id of touched) {
|
||||
@@ -361,8 +163,7 @@ function ingestCodexOnce({ broadcast } = {}) {
|
||||
/**
|
||||
* Unref'd interval so it never blocks shutdown; disable with
|
||||
* DASHBOARD_CODEX_POLL_MS=0. Codex appends its journal continuously, so a 15s
|
||||
* beat keeps a codex card within one node of live without ever re-reading what
|
||||
* it has already ingested.
|
||||
* beat keeps a codex card's running/idle within one beat of live.
|
||||
*/
|
||||
function startCodexPoll(broadcast) {
|
||||
const POLL_MS = process.env.DASHBOARD_CODEX_POLL_MS
|
||||
@@ -380,9 +181,8 @@ function startCodexPoll(broadcast) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
eventsFromPayload,
|
||||
rolloutCwd,
|
||||
ingestCodexOnce,
|
||||
startCodexPoll,
|
||||
__resetOffsets,
|
||||
__resetCache,
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* @file Safety-net reminder for codex lanes sitting at a stage nobody has
|
||||
* backed with evidence. `codex-ingest.js` auto-advances a codex lane's stage
|
||||
* from its rollout journal — that keeps the pipeline map moving, but an
|
||||
* inferred stage carries no evidence, so its node renders
|
||||
* `passed-no-evidence` (amber) rather than `done`. Only the session's own
|
||||
* `agenthub stage <node> --evidence "..."` call can supply that, and a codex
|
||||
* session has no hook or wrapper that does it for it.
|
||||
*
|
||||
* This module never writes `stage`, `stages` or `detected_stage` itself. It
|
||||
* only types a plain-text reminder into the lane's own live tmux pane
|
||||
* (`tmux.sendText`), the same way a human watching the dashboard would nudge
|
||||
* the agent — so the evidence that results is still a REAL declaration the
|
||||
* codex session made itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const NUDGE_DELAY_MS = 120_000; // give the session 2 min to declare on its own first
|
||||
|
||||
// Per-lane memory of the last stage already nudged for, so the 60s sweep
|
||||
// doesn't retype the same reminder every pass. Cleared implicitly: once the
|
||||
// session declares with evidence, or the lane moves to another node, the value
|
||||
// here no longer matches and a fresh nudge is due.
|
||||
// ponytail: in-memory, resets on server restart — worst case one duplicate
|
||||
// nudge after a restart, not worth a DB column for.
|
||||
const nudgedFor = new Map();
|
||||
|
||||
/**
|
||||
* One sweep pass: nudge every codex lane whose CURRENT stage has been held for
|
||||
* at least NUDGE_DELAY_MS with no evidence recorded, and hasn't already been
|
||||
* nudged for that exact stage.
|
||||
* @returns {number} How many lanes were nudged.
|
||||
*/
|
||||
function sweepCodexNudges() {
|
||||
const lanes = require("./lanes");
|
||||
const tmux = require("./tmux");
|
||||
const { SHELL_COMMANDS, runIdForLane } = require("./pty-run");
|
||||
|
||||
let codexLanes = [];
|
||||
try {
|
||||
codexLanes = lanes.listLanes().filter((l) => l.tool === "codex" && l.run_id);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let nudged = 0;
|
||||
for (const lane of codexLanes) {
|
||||
try {
|
||||
// No record under the current stage means the lane never entered a
|
||||
// pipeline node (a fresh lane sits at `idle`) — nothing to ask about.
|
||||
const record = (lane.stages || {})[lane.stage];
|
||||
if (!record || record.evidence) {
|
||||
nudgedFor.delete(lane.id);
|
||||
continue;
|
||||
}
|
||||
const enteredAt = Date.parse(record.enteredAt);
|
||||
if (!Number.isFinite(enteredAt) || Date.now() - enteredAt < NUDGE_DELAY_MS) continue;
|
||||
if (nudgedFor.get(lane.id) === lane.stage) continue;
|
||||
|
||||
const runId = runIdForLane(lane.id);
|
||||
if (!tmux.hasSession(runId)) continue;
|
||||
// A bare shell prompt has no chat turn to submit into — pressing Enter
|
||||
// there would run the reminder text as a shell command instead.
|
||||
if (SHELL_COMMANDS.has(tmux.paneCommand(runId) || "")) continue;
|
||||
|
||||
tmux.sendText(
|
||||
runId,
|
||||
`[agenthub] Stage "${lane.stage}" was inferred and has no evidence — run: agenthub stage ${lane.stage} --tool codex --evidence "..."`
|
||||
);
|
||||
nudgedFor.set(lane.id, lane.stage);
|
||||
nudged += 1;
|
||||
} catch {
|
||||
/* one lane's failure must not abort the sweep */
|
||||
}
|
||||
}
|
||||
return nudged;
|
||||
}
|
||||
|
||||
/** Unref'd so it never blocks shutdown; disable with DASHBOARD_CODEX_NUDGE_MS=0. */
|
||||
function startCodexNudge() {
|
||||
const POLL_MS = process.env.DASHBOARD_CODEX_NUDGE_MS
|
||||
? Number(process.env.DASHBOARD_CODEX_NUDGE_MS)
|
||||
: 60_000;
|
||||
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
sweepCodexNudges();
|
||||
} catch {
|
||||
/* a sweep pass never throws into the event loop */
|
||||
}
|
||||
}, POLL_MS);
|
||||
if (timer.unref) timer.unref();
|
||||
}
|
||||
|
||||
function __resetNudgeState() {
|
||||
nudgedFor.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sweepCodexNudges,
|
||||
startCodexNudge,
|
||||
__resetNudgeState,
|
||||
};
|
||||
@@ -89,7 +89,7 @@ function recordRun(handle) {
|
||||
started_at: startedAt,
|
||||
ended_at: endedAt,
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
tool: handle.tool === "codex" ? "codex" : "claude",
|
||||
tool: handle.tool === "codex" || handle.tool === "antigravity" ? handle.tool : "claude",
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
|
||||
+62
-3
@@ -760,7 +760,9 @@ async function lsFiles(dir, args) {
|
||||
const all = out.split("\0");
|
||||
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
|
||||
const cut = all.pop() !== "";
|
||||
return { all: all.filter(Boolean), cut };
|
||||
// Mid-merge, `ls-files --cached` prints a conflicted path once per stage
|
||||
// (base, ours, theirs): one path is one file.
|
||||
return { all: [...new Set(all.filter(Boolean))], cut };
|
||||
}
|
||||
|
||||
/** What the explorer lists: tracked files plus untracked ones git does not
|
||||
@@ -849,8 +851,13 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
* applied is always git's own text of the current tree — never a patch a
|
||||
* request carried. Null when no hunk matches (the tree moved on, or the file
|
||||
* is a secret, binary or too large to read whole).
|
||||
*
|
||||
* `block` narrows it to one CHANGE BLOCK of that hunk — a run of consecutive
|
||||
* +/- lines. git merges changes up to 2×context lines apart into one hunk, so
|
||||
* without this a single Undo/Keep acted on every nearby edit at once (#50).
|
||||
* `reverse` says which side the patch will be applied to (see `onlyBlock`).
|
||||
*/
|
||||
async function hunkPatch(dir, file, header, context) {
|
||||
async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) {
|
||||
if (!header || (await isSecretOnDisk(dir, file))) return null;
|
||||
const u = `-U${normalizeContext(context)}`;
|
||||
const raw = await readCapped(
|
||||
@@ -869,7 +876,59 @@ async function hunkPatch(dir, file, header, context) {
|
||||
.slice(at + 1)
|
||||
.split(/^(?=@@ )/m)
|
||||
.find((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
return hunk ? head + (hunk.endsWith("\n") ? hunk : `${hunk}\n`) : null;
|
||||
if (!hunk) return null;
|
||||
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
|
||||
return body ? head + (body.endsWith("\n") ? body : `${body}\n`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One hunk's text cut down to its `block`-th change block, the way an edited
|
||||
* `git add -p` hunk is: every OTHER block is folded into the side the patch
|
||||
* will be checked against, so that side stays byte-for-byte the hunk's own and
|
||||
* `git apply` finds it exactly where git put it.
|
||||
*
|
||||
* - forward (stage, applied to the index = the OLD side): another block's
|
||||
* "-" lines become context and its "+" lines are dropped;
|
||||
* - reverse (undo, `apply -R` on the working tree = the NEW side): its "+"
|
||||
* lines become context and its "-" lines are dropped.
|
||||
*
|
||||
* A "\ No newline" marker follows the line it annotates, kept or dropped with
|
||||
* it. The header's counts are recomputed; its starts do not move, because the
|
||||
* checked side is unchanged. Null when the hunk has no such block.
|
||||
*/
|
||||
function onlyBlock(hunk, block, reverse) {
|
||||
if (!Number.isInteger(block) || block < 0) return null;
|
||||
const [header, ...lines] = hunk.replace(/\n$/, "").split("\n");
|
||||
const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||
if (!m) return null;
|
||||
const out = [];
|
||||
let index = -1;
|
||||
let inBlock = false;
|
||||
let keptPrev = false;
|
||||
let oldCount = 0;
|
||||
let newCount = 0;
|
||||
for (const line of lines) {
|
||||
const mark = line[0];
|
||||
if (mark === "\\") {
|
||||
if (keptPrev) out.push(line);
|
||||
continue;
|
||||
}
|
||||
const change = mark === "+" || mark === "-";
|
||||
if (change && !inBlock) index += 1;
|
||||
inBlock = change;
|
||||
let kept = line;
|
||||
if (change && index !== block) {
|
||||
const fold = reverse ? "+" : "-";
|
||||
kept = mark === fold ? ` ${line.slice(1)}` : null;
|
||||
}
|
||||
keptPrev = kept !== null;
|
||||
if (kept === null) continue;
|
||||
out.push(kept);
|
||||
if (kept[0] !== "+") oldCount += 1;
|
||||
if (kept[0] !== "-") newCount += 1;
|
||||
}
|
||||
if (block > index) return null;
|
||||
return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Keeps files AgentHub writes into a lane's directory out of git, through
|
||||
* the one `info/exclude` git actually reads for that lane. That is the COMMON
|
||||
* git dir's, not the lane's own: a linked worktree — every managed lane — has a
|
||||
* private `.git/worktrees/<name>/`, and git never reads an exclude file there.
|
||||
* A lane that is a subdirectory of its repo has no `.git` of its own at all.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Hook variables that would point git at ANOTHER repo (the one whose hook, or
|
||||
// shell, started us) — the same set `worktree.js`'s git() scrubs. With
|
||||
// GIT_DIR set, `rev-parse` would name that repo and the entry land there.
|
||||
const REPO_VARS = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_PREFIX",
|
||||
"GIT_NAMESPACE",
|
||||
];
|
||||
|
||||
/**
|
||||
* Idempotently exclude `relPaths` (relative to `laneDir`), each anchored to the
|
||||
* lane's own path so the entry hides nothing outside it. Best effort: a lane
|
||||
* that is not a repo, or a git dir that cannot be written, is left alone —
|
||||
* never a reason to fail the write that needed the exclude.
|
||||
*
|
||||
* @param {string} laneDir
|
||||
* @param {string[]} relPaths e.g. [".mcp.json", ".claude/.cc-config-backups/"]
|
||||
*/
|
||||
function excludeFromGit(laneDir, relPaths) {
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
for (const name of REPO_VARS) delete env[name];
|
||||
const [commonDir, prefix = ""] = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--git-common-dir", "--show-prefix"],
|
||||
{ cwd: laneDir, env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
).split("\n");
|
||||
const lines = relPaths.map((rel) => `/${prefix}${rel}`);
|
||||
const excludePath = path.join(path.resolve(laneDir, commonDir), "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const present = new Set(existing.split("\n"));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
if (missing.length === 0) return;
|
||||
if (existing && !existing.endsWith("\n")) existing += "\n";
|
||||
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
} catch {
|
||||
/* not a repo, or unwritable — see above */
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { excludeFromGit };
|
||||
@@ -390,11 +390,14 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (target.code === "??") {
|
||||
throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file");
|
||||
}
|
||||
// `block` (optional) picks one change block inside the hunk (#50). It is
|
||||
// an index into git's own fresh text, never text from the request.
|
||||
const patch = await hunkPatch(
|
||||
dir,
|
||||
target.path,
|
||||
typeof body.hunk === "string" ? body.hunk : "",
|
||||
body.context
|
||||
body.context,
|
||||
body.block === undefined ? {} : { block: body.block, reverse: action === "discard-hunk" }
|
||||
);
|
||||
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
|
||||
+32
-46
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Installs a lane's driving instructions. For a `claude` lane that is
|
||||
* the ship-feature-lane pipeline's agent templates
|
||||
* @file Installs a `claude` lane's driving instructions: the
|
||||
* ship-feature-lane pipeline's agent templates
|
||||
* (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
|
||||
* driving session can launch them by the BARE subagent_type name. Source is
|
||||
* the repo's own `agents/` — the same files the agenthub plugin ships, where
|
||||
@@ -20,50 +20,36 @@ const TEMPLATE_DIR = path.join(__dirname, "..", "..", "agents");
|
||||
const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
|
||||
const EXCLUDE_LINE = ".claude/agents/";
|
||||
|
||||
// Codex reads no `.claude/agents/`, so for a codex lane the useful thing to
|
||||
// install is the one instruction it DOES read: AGENTS.md, the project doc it
|
||||
// loads from the working directory. Marker-delimited so a reinstall replaces
|
||||
// exactly this block and never the user's own prose, and appended rather than
|
||||
// written over an existing file.
|
||||
// The block older builds wrote into a codex lane's AGENTS.md. Codex lanes no
|
||||
// longer carry a pipeline, so `removeStageDoc` takes it back out.
|
||||
const STAGE_BLOCK_START = "<!-- agenthub:stage:start -->";
|
||||
const STAGE_BLOCK_END = "<!-- agenthub:stage:end -->";
|
||||
const STAGE_BLOCK = `${STAGE_BLOCK_START}
|
||||
## Declare your lane stage as you work
|
||||
|
||||
This directory is watched by AgentHub. Its pipeline map advances on its own by
|
||||
*inferring* your stage from your tool calls, but an inference carries no
|
||||
evidence — the node stays amber, never green. Only your own declaration does:
|
||||
|
||||
\`\`\`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.
|
||||
- \`--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. Inside
|
||||
a pane the dashboard started you need nothing — it carries \`AGENTHUB_LANE_ID\`.
|
||||
- \`agenthub stage\` warns on stderr and names the valid nodes if a stage matches
|
||||
none of this lane's pipeline template.
|
||||
- If it reports that no lane owns this directory, skip the stage calls and
|
||||
carry on. Stage reporting is reporting, never control flow.
|
||||
- A one-line \`[agenthub]\` reminder may be typed straight into this pane asking for
|
||||
evidence on the current stage. That is the dashboard, not the user — treat it
|
||||
as the same instruction as this section and make the real call.
|
||||
${STAGE_BLOCK_END}`;
|
||||
|
||||
/** Append the stage block to `<cwd>/AGENTS.md`, or replace an existing one. */
|
||||
function installStageDoc(cwd) {
|
||||
/**
|
||||
* Remove AgentHub's stage block from `<cwd>/AGENTS.md`, leaving every other
|
||||
* line as it was. A file left with nothing else in it was AgentHub's own
|
||||
* creation, so it is deleted. Never throws.
|
||||
*
|
||||
* @returns {boolean} whether the file changed
|
||||
*/
|
||||
function removeStageDoc(cwd) {
|
||||
const file = path.join(cwd, "AGENTS.md");
|
||||
const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
||||
const start = existing.indexOf(STAGE_BLOCK_START);
|
||||
const end = existing.indexOf(STAGE_BLOCK_END);
|
||||
const next =
|
||||
start !== -1 && end > start
|
||||
? existing.slice(0, start) + STAGE_BLOCK + existing.slice(end + STAGE_BLOCK_END.length)
|
||||
: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${STAGE_BLOCK}\n`;
|
||||
fs.writeFileSync(file, next);
|
||||
return "AGENTS.md";
|
||||
try {
|
||||
const doc = fs.readFileSync(file, "utf8");
|
||||
const start = doc.indexOf(STAGE_BLOCK_START);
|
||||
const end = doc.indexOf(STAGE_BLOCK_END, start);
|
||||
if (start === -1 || end === -1) return false;
|
||||
const rest = (
|
||||
doc.slice(0, start).trimEnd() +
|
||||
"\n\n" +
|
||||
doc.slice(end + STAGE_BLOCK_END.length).trimStart()
|
||||
).trim();
|
||||
if (rest) fs.writeFileSync(file, `${rest}\n`);
|
||||
else fs.rmSync(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // no file, or unreadable — nothing of ours to take out
|
||||
}
|
||||
}
|
||||
|
||||
/** The dir shared across every worktree of a repo — where info/exclude
|
||||
@@ -80,8 +66,8 @@ async function commonGitDir(cwd) {
|
||||
* any existing copy — this is a reinstall, not a merge) and idempotently
|
||||
* git-exclude that directory in the shared common git dir.
|
||||
*
|
||||
* A `codex` lane gets the AGENTS.md stage block instead — Codex never reads
|
||||
* `.claude/agents/`, and its own working copy is the only channel it does read.
|
||||
* A `codex` or `antigravity` lane gets nothing: neither CLI reads
|
||||
* `.claude/agents/`, and those lanes carry no pipeline to drive.
|
||||
*
|
||||
* @param {{cwd: string, tool?: string}} lane
|
||||
* @returns {Promise<{installed: string[]}>}
|
||||
@@ -93,7 +79,7 @@ async function installAgents(lane) {
|
||||
});
|
||||
}
|
||||
|
||||
if (lane.tool === "codex") return { installed: [installStageDoc(lane.cwd)] };
|
||||
if (lane.tool && lane.tool !== "claude") return { installed: [] };
|
||||
|
||||
const dest = path.join(lane.cwd, ".claude", "agents");
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
@@ -116,4 +102,4 @@ async function installAgents(lane) {
|
||||
return { installed };
|
||||
}
|
||||
|
||||
module.exports = { installAgents };
|
||||
module.exports = { installAgents, removeStageDoc };
|
||||
|
||||
+5
-19
@@ -9,8 +9,8 @@
|
||||
* logins — a QA account only needs to log in once per machine).
|
||||
*
|
||||
* Deliberately does NOT write any permission or settings.local.json content
|
||||
* — see the F1 design spec's Scope section for why. Plain file I/O; no git
|
||||
* calls, unlike lane-sync.js (E2) or lane-agents.js (E3).
|
||||
* — see the F1 design spec's Scope section for why. Plain file I/O apart
|
||||
* from the one `git rev-parse` behind `excludeFromGit`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,8 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { excludeFromGit } = require("./git-exclude");
|
||||
|
||||
/** Read the source repo's mcpServers from ~/.claude.json. Throws
|
||||
* ENOMCPCONFIG if the file is missing or the project has none declared —
|
||||
* a lane with zero MCP servers synced would silently break Stage 3/6 much
|
||||
@@ -99,22 +101,6 @@ function seedProfiles(sourceRepo, laneDir) {
|
||||
return seeded;
|
||||
}
|
||||
|
||||
/** Idempotently append a line to <laneDir>/.git/info/exclude — same
|
||||
* read-existing-then-append-if-missing pattern E2/E3 already use for
|
||||
* .git/info/attributes and .git/info/exclude. .mcp.json sits at the lane
|
||||
* root (not inside .git), so this needs no --git-common-dir resolution —
|
||||
* the exclude file itself is always local to this lane's own working copy. */
|
||||
function excludeFromGit(laneDir, line) {
|
||||
const excludePath = path.join(laneDir, ".git", "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const lines = existing.split("\n").filter(Boolean);
|
||||
if (!lines.includes(line)) {
|
||||
lines.push(line);
|
||||
fs.writeFileSync(excludePath, lines.join("\n") + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{cwd: string, source_repo: string}} lane
|
||||
* @returns {{servers: string[], profilesSeeded: string[]}}
|
||||
@@ -129,7 +115,7 @@ async function syncMcp(lane) {
|
||||
path.join(lane.cwd, ".mcp.json"),
|
||||
JSON.stringify({ mcpServers: relocated }, null, 2) + "\n"
|
||||
);
|
||||
excludeFromGit(lane.cwd, ".mcp.json");
|
||||
excludeFromGit(lane.cwd, [".mcp.json"]);
|
||||
|
||||
const profilesSeeded = seedProfiles(sourceRepo, lane.cwd);
|
||||
|
||||
|
||||
+32
-12
@@ -96,7 +96,7 @@ function validateKind(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_TOOLS = new Set(["claude", "codex"]);
|
||||
const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
||||
|
||||
function validateTool(tool) {
|
||||
if (!VALID_TOOLS.has(tool)) {
|
||||
@@ -410,13 +410,7 @@ function reservedPorts(excludeLaneId = null) {
|
||||
* of where the agent now says it is has not been passed for this pass; it has
|
||||
* to be re-earned. Records under a key matching no node in the pipeline are
|
||||
* left untouched — their position is unknown, so they cannot be proven ahead.
|
||||
*
|
||||
* `keepDetection` is the ONE exception to that clearing, and exists for exactly
|
||||
* one caller: codex-ingest.js, which advances a codex lane's stage FROM its own
|
||||
* detection. There the detection is not stale evidence of something the
|
||||
* declaration superseded — it is the reason for the declaration, and the card
|
||||
* has to keep saying so, or an inferred stage becomes indistinguishable from
|
||||
* one the agent actually claimed.
|
||||
|
||||
*/
|
||||
/**
|
||||
* Append one row to the stage log — the history `lanes.stages` cannot keep,
|
||||
@@ -472,7 +466,7 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) {
|
||||
function setStage(id, { stage, status, evidence, note, result } = {}) {
|
||||
const lane = getLane(id);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
|
||||
const next = stage || lane.stage;
|
||||
@@ -495,9 +489,7 @@ function setStage(id, { stage, status, evidence, note, result, keepDetection } =
|
||||
};
|
||||
db.prepare(
|
||||
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${
|
||||
changed && !keepDetection
|
||||
? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL"
|
||||
: ""
|
||||
changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : ""
|
||||
}
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
@@ -864,6 +856,33 @@ function purgeLaneSessions(laneId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex lanes carry no pipeline, but older builds inferred and advanced one for
|
||||
* them. Wipe what those builds left — the stage columns, the stage log and any
|
||||
* feature records — so nothing stale survives into the Workflows statistics or
|
||||
* resurfaces if a view ever reads it. Idempotent; run at every server start.
|
||||
*
|
||||
* @returns {number} how many codex lanes still carried pipeline data
|
||||
*/
|
||||
function clearCodexPipelines() {
|
||||
const CODEX = "SELECT id FROM lanes WHERE tool = 'codex'";
|
||||
let changed = 0;
|
||||
db.transaction(() => {
|
||||
changed = db
|
||||
.prepare(
|
||||
`UPDATE lanes SET stage = 'idle', stage_since = NULL, stages = '{}',
|
||||
detected_stage = NULL, detected_signal = NULL, detected_at = NULL
|
||||
WHERE tool = 'codex' AND (stage <> 'idle' OR stages <> '{}' OR detected_stage IS NOT NULL
|
||||
OR id IN (SELECT lane_id FROM lane_stage_events)
|
||||
OR id IN (SELECT lane_id FROM lane_features))`
|
||||
)
|
||||
.run().changes;
|
||||
db.prepare(`DELETE FROM lane_stage_events WHERE lane_id IN (${CODEX})`).run();
|
||||
db.prepare(`DELETE FROM lane_features WHERE lane_id IN (${CODEX})`).run();
|
||||
})();
|
||||
return changed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEAD_SEC,
|
||||
VALID_TOOLS,
|
||||
@@ -875,6 +894,7 @@ module.exports = {
|
||||
updateLane,
|
||||
deleteLane,
|
||||
setStage,
|
||||
clearCodexPipelines,
|
||||
setRouterProfile,
|
||||
lanesByRouter,
|
||||
recordDetection,
|
||||
|
||||
+20
-4
@@ -19,7 +19,8 @@
|
||||
* the session (one-click Start/Resume) and is just one of possibly several
|
||||
* attached clients tmux already keeps in sync.
|
||||
*
|
||||
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
|
||||
* Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on
|
||||
* the tool parameter.
|
||||
*
|
||||
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
|
||||
@@ -55,7 +56,7 @@ const ALLOWED_PERMISSION_MODES = new Set([
|
||||
]);
|
||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
const VALID_TOOLS = new Set(["claude", "codex"]);
|
||||
const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||
@@ -137,8 +138,23 @@ function buildCodexArgv({
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity's `agy` has no model, effort or permission-mode flag, so only
|
||||
* resume and the first prompt reach it. `-i` keeps the session interactive
|
||||
* after that prompt (`-p` would print and exit). `--dangerously-skip-permissions`
|
||||
* is never emitted, same rule as Codex's bypass flags.
|
||||
*/
|
||||
function buildAntigravityArgv({ resumeLast, initialPrompt }) {
|
||||
const argv = ["agy"];
|
||||
if (resumeLast) argv.push("--continue");
|
||||
if (initialPrompt) argv.push("-i", initialPrompt);
|
||||
return argv;
|
||||
}
|
||||
|
||||
function buildArgv(args) {
|
||||
return args.tool === "codex" ? buildCodexArgv(args) : buildClaudeArgv(args);
|
||||
if (args.tool === "codex") return buildCodexArgv(args);
|
||||
if (args.tool === "antigravity") return buildAntigravityArgv(args);
|
||||
return buildClaudeArgv(args);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,7 +274,7 @@ function spawnRun(args) {
|
||||
// separate `external` row. Every run here is one interactive pane.
|
||||
mode: "interactive",
|
||||
cwd,
|
||||
tool: tool === "codex" ? "codex" : "claude",
|
||||
tool,
|
||||
model: model || null,
|
||||
permissionMode: permissionMode || "auto",
|
||||
effort: effort || null,
|
||||
|
||||
+12
-40
@@ -34,6 +34,7 @@ const path = require("node:path");
|
||||
|
||||
const routers = require("./ai-routers");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const { excludeFromGit } = require("./git-exclude");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { lanesByRouter, setRouterProfile } = require("./lanes");
|
||||
|
||||
@@ -72,26 +73,6 @@ function readOrNull(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's own git directory. For a normal clone that is `<cwd>/.git`; for a
|
||||
* LINKED WORKTREE — which is what every managed lane is — `.git` is a pointer
|
||||
* FILE, and joining `info/exclude` onto it names a path that cannot exist.
|
||||
*
|
||||
* @returns {string|null} absolute git dir, or null when this is not a repo
|
||||
*/
|
||||
function gitDirOf(laneDir) {
|
||||
const dotGit = path.join(laneDir, ".git");
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(dotGit);
|
||||
} catch {
|
||||
return null; // not a git repo — a perfectly normal adopted directory
|
||||
}
|
||||
if (stat.isDirectory()) return dotGit;
|
||||
const match = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
|
||||
return match ? path.resolve(laneDir, match[1].trim()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons
|
||||
* that both matter:
|
||||
@@ -101,28 +82,12 @@ function gitDirOf(laneDir) {
|
||||
* is deleted and the lane silently loses its router.
|
||||
* - The file holds an API key. Untracked means `git add -A` can commit it.
|
||||
*
|
||||
* Best effort: a lane that is not a repo, or a git dir that cannot be written,
|
||||
* must not fail an otherwise-successful attach.
|
||||
* The backup directory beside it, for the same reason plus one more: those
|
||||
* copies hold the API key too, and they are what a user sees as a dozen
|
||||
* untracked files appearing in their project while they switch routers.
|
||||
*/
|
||||
function excludeSettingsFromGit(laneDir) {
|
||||
// The backup directory beside it, for the same reason plus one more: those
|
||||
// copies hold the API key too, and they are what a user sees as a dozen
|
||||
// untracked files appearing in their project while they switch routers.
|
||||
const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"];
|
||||
try {
|
||||
const gitDir = gitDirOf(laneDir);
|
||||
if (!gitDir) return;
|
||||
const excludePath = path.join(gitDir, "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const present = new Set(existing.split("\n"));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
if (missing.length === 0) return;
|
||||
if (existing && !existing.endsWith("\n")) existing += "\n";
|
||||
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
} catch {
|
||||
/* best effort — see above */
|
||||
}
|
||||
excludeFromGit(laneDir, [".claude/settings.local.json", ".claude/.cc-config-backups/"]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +187,13 @@ function applyToCodexLane(lane, profile) {
|
||||
*/
|
||||
function applyRouterToLane(lane, profileName) {
|
||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||
// `agy` has no endpoint/model override to write, so only native fits.
|
||||
if (lane.tool === "antigravity") {
|
||||
if (profile.builtin) return { tool: "antigravity", file: null, profile: profile.name };
|
||||
throw Object.assign(new Error("an Antigravity lane cannot use an AI Router"), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
if (lane.tool === "codex") {
|
||||
// Native is the base config as it already stands. A layer that overrode
|
||||
// nothing would still shadow whatever model the user set in the base.
|
||||
|
||||
@@ -686,6 +686,11 @@ const paths = {
|
||||
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.",
|
||||
|
||||
+13
-1
@@ -106,6 +106,16 @@ function healRunId(lane) {
|
||||
return lanesLib.getLane(lane.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal every lane at once. A claude pane that exits fires a hook, which
|
||||
* broadcasts the lane and so re-reads it; a codex or antigravity pane fires
|
||||
* nothing, so without this sweep its lane read "running" until something
|
||||
* else happened to fetch it.
|
||||
*/
|
||||
function healAllRunIds() {
|
||||
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane);
|
||||
}
|
||||
|
||||
function payload(lane) {
|
||||
const healed = healRunId(lane);
|
||||
return lanesLib.lanePayload(healed, lastEventAge(healed));
|
||||
@@ -697,7 +707,8 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
broadcastLane(lane.id);
|
||||
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
|
||||
} catch (err) {
|
||||
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400, EUNSUPPORTED: 400 }[err.code] || 500;
|
||||
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
||||
}
|
||||
});
|
||||
@@ -1777,3 +1788,4 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
|
||||
module.exports = router;
|
||||
module.exports.broadcastLane = broadcastLane;
|
||||
module.exports.healAllRunIds = healAllRunIds;
|
||||
|
||||
@@ -290,12 +290,13 @@ router.get("/binary", (req, res) => {
|
||||
// taken from a fixed allowlist, never from the query string directly — this
|
||||
// string reaches `which`.
|
||||
const tool = req.query.tool === undefined ? "claude" : String(req.query.tool);
|
||||
if (tool !== "claude" && tool !== "codex") {
|
||||
const bins = { claude: "claude", codex: "codex", antigravity: "agy" };
|
||||
if (!Object.hasOwn(bins, tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
}
|
||||
const which = require("node:child_process").spawnSync(
|
||||
process.platform === "win32" ? "where" : "which",
|
||||
[tool],
|
||||
[bins[tool]],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
const stdout = (which.stdout || "").trim();
|
||||
|
||||
Reference in New Issue
Block a user