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

- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
2026-09-25 17:30:28 +07:00
parent 3f6e903c22
commit 952e8584f0
48 changed files with 3336 additions and 1579 deletions
+66 -9
View File
@@ -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);
});
});