Add plugin/hook/skill support, images, @mentions, dashboard, and stability fixes

Builds out locode's Claude Code plugin parity: MCP servers, slash commands,
sub-agents, hooks (12 lifecycle events), and skills, all loadable from a
local path or git URL. Also adds image support (read_file, /import), @-mention
file autocomplete, a /dashboard stats view, /export with an editable filename
prompt, an expanded git tool (reset/stash/merge/rebase/delete_branch), and a
redesigned status bar.

On top of that, a review pass found and fixed 10 correctness/stability bugs:
argument-injection in git reset/merge/rebase (a ref like "--hard" was parsed
as a flag), tool-call image results interleaving with native tool_call_id
messages and breaking OpenAI-compatible message ordering, backgrounded bash
jobs still being silently killed by their original timeout with the kill
masked as a clean exit, a SubagentStart hook's block being ignored, the
sub-agent timeout clock starting before the hook it should exclude, a
template-expansion bug that could re-substitute $1..$9 placeholders,
unbounded background-job output buffers, a missing directory-target fallback
in /export, and image size caps checked after reading the whole file instead
of before.

Session saves and exports now go through a shared atomic
write-then-rename helper so a crash can't leave a truncated file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-07 18:17:45 +09:00
co-authored by Claude Sonnet 5
parent 265f8930a3
commit 2a35f40c73
64 changed files with 3840 additions and 338 deletions
+223 -75
View File
@@ -12,6 +12,10 @@ async function tryGit(args: string[], cwd: string): Promise<string> {
}
}
function runGit(args: string[], cwd: string) {
return execa("git", args, { cwd, reject: false });
}
// --- git_status: read-only inspection, auto-runs like grep/read_file ---
const statusSchema = z.object({
@@ -47,7 +51,7 @@ export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
}
})();
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
const result = await runGit(args, ctx.cwd);
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
}
@@ -57,94 +61,238 @@ export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
// --- git_commit: mutating operations, gated behind a confirmation prompt ---
// Ref-like arguments (branch names, refs, remote names) are passed to git as bare positional
// args with no `--` separator. A value starting with `-` would otherwise be parsed by git as a
// flag instead of a ref/branch (e.g. ref: "--hard" turning a plain reset into a hard reset) —
// reject it at the schema boundary so it can never reach execa's argv.
function refLike(description: string) {
return z
.string()
.refine((v) => !v.startsWith("-"), { message: 'must not start with "-" (would be parsed as a git flag, not a ref/branch name)' })
.describe(description)
.optional();
}
const commitSchema = z.object({
operation: z.enum(["add", "commit", "create_branch", "checkout", "push"]),
paths: z.array(z.string()).optional().describe("For `add`: paths to stage, relative to the working directory. Omit to stage all changes."),
message: z.string().optional().describe("Commit message. Required for `commit`."),
branchName: z.string().optional().describe("Branch name. Required for `create_branch`/`checkout`."),
operation: z.enum([
"add",
"commit",
"create_branch",
"checkout",
"push",
"reset",
"stash",
"merge",
"rebase",
"delete_branch",
]),
paths: z.array(z.string()).optional().describe("For `add`: paths to stage. For `stash push`: paths to stash. Omit to stage all changes."),
message: z.string().optional().describe("For `commit`: commit message. For `stash push`: stash message."),
branchName: refLike("Branch name. Required for create_branch/checkout/merge/rebase/delete_branch."),
createIfMissing: z.boolean().optional().describe("For `checkout`: create the branch if it doesn't exist yet (like `checkout -b`)."),
remote: z.string().optional().describe("For `push`: remote name (default \"origin\")."),
remote: refLike('For `push`: remote name (default "origin").'),
setUpstream: z.boolean().optional().describe("For `push`: set upstream tracking — needed the first time a new branch is pushed."),
ref: refLike("For `reset`: ref to reset to (default HEAD). For `merge`/`rebase`: alternative ref if branchName omitted."),
mode: z.enum(["soft", "mixed", "hard"]).optional().describe("For `reset`: reset mode (default mixed)."),
strategy: z.enum(["fast-forward", "no-ff", "ff-only"]).optional().describe("For `merge`: merge strategy."),
stashRef: refLike("For `stash pop`: stash reference (default stash@{0})."),
force: z.boolean().optional().describe("For `delete_branch`: force delete an unmerged branch."),
});
export const gitCommitTool: ToolDef<z.infer<typeof commitSchema>> = {
name: "git_commit",
description:
"Make changes to the git repository at the working directory: `add` (stage paths, or all changes if omitted), `commit` " +
"(requires `message`; commit the currently staged changes), `create_branch` (requires `branchName`; branch at current HEAD " +
"without switching), `checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), or " +
"`push` (push the current branch, optionally with setUpstream for a new branch's first push). Each use requires the user's " +
"confirmation, which shows the actual diff/status/commits affected.",
"(requires `message`; commit staged changes), `create_branch` (requires `branchName`; branch at current HEAD without switching), " +
"`checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), `push` (push current branch), " +
"`reset` (reset current branch; default mixed, default ref HEAD), `stash` (push or pop a stash), `merge` (merge branchName into " +
"current branch), `rebase` (rebase current branch onto branchName), or `delete_branch` (requires `branchName`; delete a local branch). " +
"Each mutating use requires the user's confirmation with a preview of the affected state.",
schema: commitSchema,
mutating: true,
preview: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
switch (operation) {
case "add": {
const target = paths?.length ? paths.join(", ") : "all changes";
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], ctx.cwd);
return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`;
}
case "commit": {
const staged = await tryGit(["diff", "--cached"], ctx.cwd);
if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first.";
return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`;
}
case "create_branch":
return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`;
case "checkout": {
const status = await tryGit(["status", "--short"], ctx.cwd);
const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`;
return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`;
}
case "push": {
const remoteName = remote ?? "origin";
const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], ctx.cwd)).trim() || "HEAD";
// `@{u}..` only resolves once upstream tracking exists — on a branch's first push
// (exactly when setUpstream is used) it doesn't, so fall back to recent commits on HEAD.
const ahead =
(await tryGit(["log", "--oneline", "@{u}.."], ctx.cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], ctx.cwd));
const upstreamNote = setUpstream ? " (setting upstream tracking)" : "";
return ahead
? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}`
: `Push "${branch}" to "${remoteName}"${upstreamNote}.`;
}
}
},
handler: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
const args: string[] = (() => {
switch (operation) {
case "add":
return ["add", ...(paths?.length ? paths : ["-A"])];
case "commit":
if (!message) throw new Error("`message` is required for the commit operation.");
return ["commit", "-m", message];
case "create_branch":
if (!branchName) throw new Error("`branchName` is required for the create_branch operation.");
return ["branch", branchName];
case "checkout":
if (!branchName) throw new Error("`branchName` is required for the checkout operation.");
return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName];
case "push":
return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"];
}
})();
preview: async (args, ctx) => buildPreview(args, ctx.cwd),
handler: async (args, ctx) => runMutatingOperation(args, ctx.cwd),
};
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
async function buildPreview(
{
operation,
paths,
message,
branchName,
createIfMissing,
remote,
setUpstream,
ref,
mode,
strategy,
stashRef,
force,
}: z.infer<typeof commitSchema>,
cwd: string,
): Promise<string> {
switch (operation) {
case "add": {
const target = paths?.length ? paths.join(", ") : "all changes";
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd);
return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`;
}
// `git push` writes its useful progress/summary output to stderr even on success.
const output = truncate(result.stdout || result.stderr);
case "commit": {
const staged = await tryGit(["diff", "--cached"], cwd);
if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first.";
return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`;
}
case "create_branch":
return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`;
case "checkout": {
const status = await tryGit(["status", "--short"], cwd);
const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`;
return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`;
}
case "push": {
const remoteName = remote ?? "origin";
const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
const ahead =
(await tryGit(["log", "--oneline", "@{u}.."], cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], cwd));
const upstreamNote = setUpstream ? " (setting upstream tracking)" : "";
return ahead
? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}`
: `Push "${branch}" to "${remoteName}"${upstreamNote}.`;
}
case "reset": {
const targetRef = ref ?? "HEAD";
const modeText = mode ?? "mixed";
const diff = modeText === "hard" ? await tryGit(["diff", "--stat", "HEAD"], cwd) : await tryGit(["diff", "--cached", "--stat"], cwd);
return `Reset (${modeText}) to ${targetRef}.${diff ? `\n\nFiles affected:\n${diff}` : ""}`;
}
case "stash": {
if (stashRef) {
const stash = await tryGit(["stash", "show", "-p", stashRef], cwd);
return stash ? `Pop stash ${stashRef}:\n\n${stash}` : `Pop stash ${stashRef}.`;
}
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd);
return status
? `Stash ${message ? `"${message}"` : "changes"}${paths?.length ? ` (paths: ${paths.join(", ")})` : ""}:\n\n${status}`
: "No local changes to stash.";
}
case "merge": {
const into = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
const target = branchName ?? ref;
const log = target ? await tryGit(["log", "--oneline", `${into}..${target}`], cwd) : "";
return target
? `Merge "${target}" into "${into}"${strategy ? ` (strategy: ${strategy})` : ""}.${log ? `\n\nCommits to merge:\n${log}` : ""}`
: "merge requires `branchName` or `ref`.";
}
case "rebase": {
const onto = branchName ?? ref;
const current = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
const log = onto ? await tryGit(["log", "--oneline", `${onto}..${current}`], cwd) : "";
return onto
? `Rebase "${current}" onto "${onto}".${log ? `\n\nCommits that will be replayed:\n${log}` : ""}`
: "rebase requires `branchName` or `ref`.";
}
case "delete_branch": {
const log = branchName ? await tryGit(["log", "--oneline", `${branchName}..HEAD`], cwd) : "";
const note = force ? " (force delete)" : "";
return branchName
? `Delete local branch "${branchName}"${note}.${log ? `\n\nCommits on ${branchName} not in HEAD:\n${log}` : ""}`
: "delete_branch requires `branchName`.";
}
}
}
async function runMutatingOperation(args: z.infer<typeof commitSchema>, cwd: string): Promise<unknown> {
const {
operation,
paths,
message,
branchName,
createIfMissing,
remote,
setUpstream,
ref,
mode,
strategy,
stashRef,
force,
} = args;
const gitArgs: string[] = (() => {
switch (operation) {
case "add":
return { staged: paths?.length ? paths : ["(all changes)"] };
case "create_branch":
return { created: branchName };
case "checkout":
return { switchedTo: branchName };
default:
return { output };
return ["add", ...(paths?.length ? paths : ["-A"])];
case "commit": {
if (!message) throw new Error("`message` is required for the commit operation.");
return ["commit", "-m", message];
}
case "create_branch": {
if (!branchName) throw new Error("`branchName` is required for the create_branch operation.");
return ["branch", branchName];
}
case "checkout": {
if (!branchName) throw new Error("`branchName` is required for the checkout operation.");
return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName];
}
case "push":
return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"];
case "reset": {
const targetRef = ref ?? "HEAD";
return ["reset", ...(mode ? [`--${mode}`] : []), targetRef];
}
case "stash": {
if (stashRef) {
return ["stash", "pop", stashRef];
}
const stashArgs = ["stash", "push"];
if (message) stashArgs.push("-m", message);
if (paths?.length) stashArgs.push("--", ...paths);
return stashArgs;
}
case "merge": {
const target = branchName ?? ref;
if (!target) throw new Error("`merge` requires `branchName` or `ref`.");
const mergeArgs = ["merge"];
if (strategy === "no-ff") mergeArgs.push("--no-ff");
if (strategy === "ff-only") mergeArgs.push("--ff-only");
mergeArgs.push(target);
return mergeArgs;
}
case "rebase": {
const onto = branchName ?? ref;
if (!onto) throw new Error("`rebase` requires `branchName` or `ref`.");
return ["rebase", onto];
}
case "delete_branch": {
if (!branchName) throw new Error("`branchName` is required for the delete_branch operation.");
return ["branch", force ? "-D" : "-d", branchName];
}
}
},
};
})();
const result = await runGit(gitArgs, cwd);
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
}
const output = truncate(result.stdout || result.stderr);
switch (operation) {
case "add":
return { staged: paths?.length ? paths : ["(all changes)"] };
case "create_branch":
return { created: branchName };
case "checkout":
return { switchedTo: branchName };
case "reset":
return { resetTo: ref ?? "HEAD", mode: mode ?? "mixed" };
case "stash":
return stashRef ? { popped: stashRef } : { stashed: message ?? "(no message)" };
case "merge":
return { merged: branchName ?? ref, output };
case "rebase":
return { rebasedOnto: branchName ?? ref, output };
case "delete_branch":
return { deleted: branchName };
default:
return { output };
}
}