Merge PR #155 and fix isolation merge regressions

This commit is contained in:
can1357
2026-02-26 08:53:25 +01:00
17 changed files with 891 additions and 126 deletions
+19 -1
View File
@@ -2,9 +2,28 @@
## [Unreleased]
### Breaking Changes
- Renamed `task.isolation.enabled` (boolean) setting to `task.isolation.mode` (enum: `none`, `worktree`, `fuse-overlay`). Existing `true`/`false` values are auto-migrated to `worktree`/`none`.
### Added
- Added `PERPLEXITY_COOKIES` env var for Perplexity web search via session cookies extracted from desktop app
- Added `fuse-overlay` isolation mode for subagents using `fuse-overlayfs` (copy-on-write overlay, no baseline patch apply needed)
- Added `task.isolation.merge` setting (`patch` or `branch`) to control how isolated task changes are integrated back. `branch` mode commits each task to a temp branch and cherry-picks for clean commit history
- Added `task.isolation.commits` setting (`generic` or `ai`) for commit messages on isolated task branches and nested repos. `ai` mode uses a smol model to generate conventional commit messages from diffs
- Nested non-submodule git repos are now discovered and handled during task isolation (changes captured and applied independently from parent repo)
- Added `task.eager` setting to encourage the agent to delegate work to subagents by default
### Fixed
- Fixed nested repo changes being lost when tasks commit inside the isolation (baseline state is now committed before task runs, so delta correctly excludes it)
- Fixed nested repo patches conflicting when multiple tasks contribute to the same repo (baseline untracked files no longer leak into patches)
- Nested repo changes are now committed after patch application (previously left as untracked files)
- Failed tasks no longer create stale branches or capture garbage patches (gated on exit code)
- Merge failures (e.g. conflicting patches) are now non-fatal — agent output is preserved with `merge failed` status instead of `failed`
- Stale branches are cleaned up when `commitToBranch` fails
- Commit message generator filters lock files from diffs before AI summarization
## [13.2.1] - 2026-02-24
@@ -15,7 +34,6 @@
### Changed
- Extracted non-interactive environment config from `bash-interactive.ts` into shared `non-interactive-env.ts` module, applied consistently to all bash execution paths
## [13.2.0] - 2026-02-23
### Breaking Changes
+8 -1
View File
@@ -906,7 +906,14 @@ Despite the name `runSubprocess`, `packages/coding-agent/src/task/executor.ts` c
What _is_ isolated is execution context and artifacts, not process memory:
- Optional git worktree isolation is handled by `TaskTool.execute(...)` in `index.ts` using `ensureWorktree(...)`, `applyBaseline(...)`, `captureDeltaPatch(...)`, `cleanupWorktree(...)`.
- Optional filesystem isolation is controlled by the `task.isolation.mode` setting (`"none"`, `"worktree"`, or `"fuse-overlay"`).
- **worktree**: `ensureWorktree(...)`, `applyBaseline(...)`, `captureDeltaPatch(...)`, `cleanupWorktree(...)`. Nested non-submodule git repos are discovered and handled independently.
- **fuse-overlay**: `ensureFuseOverlay(...)` (mounts a copy-on-write overlay via `fuse-overlayfs`), `captureDeltaPatch(...)`, `cleanupFuseOverlay(...)`. No baseline apply needed since the overlay reflects the full working tree. Fails outright if mount fails.
- The `task.isolation.merge` setting controls how isolated changes are integrated back:
- **patch** (default): captures a diff via `captureDeltaPatch(...)`, combines patches, and applies with `git apply`.
- **branch**: each task commits to a temp branch (`omp/task/<id>`) via `commitToBranch(...)`, then `mergeTaskBranches(...)` cherry-picks them sequentially onto HEAD. If `git apply` fails inside `commitToBranch`, the error is non-fatal — the agent result is preserved with a `merge failed` status.
- The `task.isolation.commits` setting (`generic` or `ai`) controls commit messages for branch commits and nested repo patches. `ai` mode uses a smol model to generate conventional commit messages from diffs.
- Nested repo patches are applied via `applyNestedPatches(...)` after the parent merge, grouped by repo with one commit per repo.
- Child session JSONL/markdown outputs are written under the task artifacts directory (`<id>.jsonl`, `<id>.md`, and in isolated mode `<id>.patch`).
### Tooling Surface in Child Sessions
@@ -544,14 +544,46 @@ export const SETTINGS_SCHEMA = {
// ─────────────────────────────────────────────────────────────────────────
// Task tool settings
// ─────────────────────────────────────────────────────────────────────────
"task.isolation.enabled": {
"task.isolation.mode": {
type: "enum",
values: ["none", "worktree", "fuse-overlay"] as const,
default: "none",
ui: {
tab: "tools",
label: "Task isolation",
description: "Isolation mode for subagents (none, git worktree, or fuse-overlay)",
submenu: true,
},
},
"task.isolation.merge": {
type: "enum",
values: ["patch", "branch"] as const,
default: "patch",
ui: {
tab: "tools",
label: "Task isolation merge",
description: "How isolated task changes are integrated (patch apply or branch merge)",
submenu: true,
},
},
"task.isolation.commits": {
type: "enum",
values: ["generic", "ai"] as const,
default: "generic",
ui: {
tab: "tools",
label: "Task isolation commits",
description: "Commit message style for nested repo changes (generic or AI-generated)",
submenu: true,
},
},
"task.eager": {
type: "boolean",
default: false,
ui: {
tab: "tools",
label: "Task isolation",
description: "Run subagents in isolated git worktrees",
submenu: true,
label: "Eager task delegation",
description: "Encourage the agent to delegate work to subagents unless changes are trivial",
},
},
"task.maxConcurrency": {
@@ -553,6 +553,16 @@ export class Settings {
}
}
// task.isolation.enabled (boolean) -> task.isolation.mode (enum)
const taskObj = raw.task as Record<string, unknown> | undefined;
const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
if (isolationObj && "enabled" in isolationObj) {
if (typeof isolationObj.enabled === "boolean") {
isolationObj.mode = isolationObj.enabled ? "worktree" : "none";
}
delete isolationObj.enabled;
}
return raw;
}
@@ -93,6 +93,22 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
{ value: "2", label: "Double" },
{ value: "3", label: "Triple" },
],
// Task isolation mode
"task.isolation.mode": [
{ value: "none", label: "None", description: "No isolation" },
{ value: "worktree", label: "Worktree", description: "Git worktree isolation" },
{ value: "fuse-overlay", label: "Fuse Overlay", description: "COW overlay via fuse-overlayfs" },
],
// Task isolation merge strategy
"task.isolation.merge": [
{ value: "patch", label: "Patch", description: "Combine diffs and git apply" },
{ value: "branch", label: "Branch", description: "Commit per task, merge with --no-ff" },
],
// Task isolation commit messages
"task.isolation.commits": [
{ value: "generic", label: "Generic", description: "Static commit message" },
{ value: "ai", label: "AI", description: "AI-generated commit message from diff" },
],
// Todo max reminders
"todo.reminders.max": [
{ value: "1", label: "1 reminder" },
@@ -0,0 +1,2 @@
Generate a concise git commit message from the provided diff. Use conventional commit format: `type(scope): description` where type is feat/fix/refactor/chore/test/docs and scope is optional. The description MUST be lowercase, imperative mood, no trailing period. Keep it under 72 characters.
You MUST output ONLY the commit message, nothing else.
@@ -159,6 +159,19 @@ Semantic questions **MUST** be answered with semantic tools.
- What is this thing? → `lsp hover`
{{/has}}
{{#if eagerTasks}}
<eager-tasks>
You SHOULD delegate work to subagents by default. Working alone is the exception, not the rule.
Use the Task tool unless the change is:
- A single-file edit under ~30 lines
- A direct answer or explanation with no code changes
- A command the user asked you to run yourself
For everything else — multi-file changes, refactors, new features, test additions, investigations — break the work into tasks and delegate. Err on the side of delegating. You are an orchestrator first, a coder second.
</eager-tasks>
{{/if}}
{{#has tools "ssh"}}
### SSH: match commands to host shell
@@ -20,9 +20,9 @@
{{/unless}}
{{/each}}
{{#if patchApplySummary}}
<patch-summary>
{{patchApplySummary}}
</patch-summary>
{{#if mergeSummary}}
<merge-summary>
{{mergeSummary}}
</merge-summary>
{{/if}}
</task-summary>
@@ -16,7 +16,7 @@ Subagents lack your conversation history. Every decision, file content, and user
- `context`: Shared background prepended to every assignment. Session-specific info only.
- `schema`: JTD schema for expected output. Format lives here — **MUST NOT** be duplicated in assignments.
- `tasks`: Tasks to execute in parallel.
- `isolated`: Run in isolated git worktree; returns patches. Use when tasks edit overlapping files.
- `isolated`: Run in isolated environment; returns patches. Use when tasks edit overlapping files.
</parameters>
<critical>
+3
View File
@@ -1120,6 +1120,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
const repeatToolDescriptions = settings.get("repeatToolDescriptions");
const eagerTasks = settings.get("task.eager");
const intentField = settings.get("tools.intentTracing") || $env.PI_INTENT_TRACING === "1" ? INTENT_FIELD : undefined;
const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
toolContextStore.setToolNames(toolNames);
@@ -1135,6 +1136,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
skillsSettings: settings.getGroup("skills") as SkillsSettings,
appendSystemPrompt: memoryInstructions,
repeatToolDescriptions,
eagerTasks,
intentField,
});
@@ -1154,6 +1156,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
customPrompt: options.systemPrompt,
appendSystemPrompt: memoryInstructions,
repeatToolDescriptions,
eagerTasks,
intentField,
});
}
@@ -358,6 +358,8 @@ export interface BuildSystemPromptOptions {
rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
/** Intent field name injected into every tool schema. If set, explains the field in the prompt. */
intentField?: string;
/** Encourage the agent to delegate via tasks unless changes are trivial. */
eagerTasks?: boolean;
}
/** Build the system prompt with tools, guidelines, and context */
@@ -379,6 +381,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
preloadedSkills: providedPreloadedSkills,
rules,
intentField,
eagerTasks = false,
} = options;
const resolvedCwd = cwd ?? getProjectDir();
const preloadedSkills = providedPreloadedSkills;
@@ -535,6 +538,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
cwd: resolvedCwd,
intentTracing: !!intentField,
intentField: intentField ?? "",
eagerTasks,
};
return renderPromptTemplate(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
}
+214 -73
View File
@@ -29,6 +29,7 @@ import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type:
import { formatBytes, formatDuration } from "../tools/render-utils";
// Import review tools for side effects (registers subagent tool handlers)
import "../tools/review";
import { generateCommitMessage } from "../utils/commit-message-generator";
import { discoverAgents, getAgent } from "./discovery";
import { runSubprocess } from "./executor";
import { AgentOutputManager } from "./output-manager";
@@ -47,11 +48,17 @@ import {
} from "./types";
import {
applyBaseline,
applyNestedPatches,
captureBaseline,
captureDeltaPatch,
cleanupFuseOverlay,
cleanupTaskBranches,
cleanupWorktree,
commitToBranch,
ensureFuseOverlay,
ensureWorktree,
getRepoRoot,
mergeTaskBranches,
type WorktreeBaseline,
} from "./worktree";
@@ -145,11 +152,11 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
get description(): string {
const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
const maxConcurrency = this.session.settings.get("task.maxConcurrency");
const isolationEnabled = this.session.settings.get("task.isolation.enabled");
const isolationMode = this.session.settings.get("task.isolation.mode");
return renderDescription(
this.#discoveredAgents,
maxConcurrency,
isolationEnabled,
isolationMode !== "none",
this.session.settings.get("async.enabled"),
disabledAgents,
);
@@ -168,9 +175,9 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
* Create a TaskTool instance with async agent discovery.
*/
static async create(session: ToolSession): Promise<TaskTool> {
const isolationEnabled = session.settings.get("task.isolation.enabled");
const isolationMode = session.settings.get("task.isolation.mode");
const { agents } = await discoverAgents(session.cwd);
return new TaskTool(session, agents, isolationEnabled);
return new TaskTool(session, agents, isolationMode !== "none");
}
async execute(
@@ -422,18 +429,20 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
const startTime = Date.now();
const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
const { agent: agentName, context, schema: outputSchema } = params;
const isolationEnabled = this.session.settings.get("task.isolation.enabled");
const isolationMode = this.session.settings.get("task.isolation.mode");
const isolationRequested = "isolated" in params ? params.isolated === true : false;
const isIsolated = isolationEnabled && isolationRequested;
const isIsolated = isolationMode !== "none" && isolationRequested;
const mergeMode = this.session.settings.get("task.isolation.merge");
const commitStyle = this.session.settings.get("task.isolation.commits");
const maxConcurrency = this.session.settings.get("task.maxConcurrency");
const taskDepth = this.session.taskDepth ?? 0;
if (!isolationEnabled && "isolated" in params) {
if (isolationMode === "none" && "isolated" in params) {
return {
content: [
{
type: "text",
text: "Task isolation is disabled. Remove the isolated argument to run subagents.",
text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree' or 'fuse-overlay'.",
},
],
details: {
@@ -789,16 +798,23 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
}
const taskStart = Date.now();
let worktreeDir: string | undefined;
let isolationDir: string | undefined;
try {
if (!repoRoot || !baseline) {
throw new Error("Isolated task execution not initialized.");
}
worktreeDir = await ensureWorktree(repoRoot, task.id);
await applyBaseline(worktreeDir, baseline);
const taskBaseline = structuredClone(baseline);
if (isolationMode === "fuse-overlay") {
isolationDir = await ensureFuseOverlay(repoRoot, task.id);
} else {
isolationDir = await ensureWorktree(repoRoot, task.id);
await applyBaseline(isolationDir, taskBaseline);
}
const result = await runSubprocess({
cwd: this.session.cwd,
worktree: worktreeDir,
worktree: isolationDir,
agent,
task: task.task,
description: task.description,
@@ -830,13 +846,56 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
preloadedSkills: task.preloadedSkills,
promptTemplates,
});
const patch = await captureDeltaPatch(worktreeDir, baseline);
const patchPath = path.join(effectiveArtifactsDir, `${task.id}.patch`);
await Bun.write(patchPath, patch);
return {
...result,
patchPath,
};
if (mergeMode === "branch" && result.exitCode === 0) {
try {
const commitMsg =
commitStyle === "ai" && this.session.modelRegistry
? async (diff: string) => {
const smolModel = this.session.settings.getModelRole("smol");
return generateCommitMessage(
diff,
this.session.modelRegistry!,
smolModel,
this.session.getSessionId?.() ?? undefined,
);
}
: undefined;
const commitResult = await commitToBranch(
isolationDir,
taskBaseline,
task.id,
task.description,
commitMsg,
);
return {
...result,
branchName: commitResult?.branchName,
nestedPatches: commitResult?.nestedPatches,
};
} catch (mergeErr) {
// Agent succeeded but branch commit failed — clean up stale branch
const branchName = `omp/task/${task.id}`;
await $`git branch -D ${branchName}`.cwd(repoRoot).quiet().nothrow();
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
return { ...result, error: `Merge failed: ${msg}` };
}
}
if (result.exitCode === 0) {
try {
const delta = await captureDeltaPatch(isolationDir, taskBaseline);
const patchPath = path.join(effectiveArtifactsDir, `${task.id}.patch`);
await Bun.write(patchPath, delta.rootPatch);
return {
...result,
patchPath,
nestedPatches: delta.nestedPatches,
};
} catch (patchErr) {
const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
return { ...result, error: `Patch capture failed: ${msg}` };
}
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
@@ -856,8 +915,12 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
error: message,
};
} finally {
if (worktreeDir) {
await cleanupWorktree(worktreeDir);
if (isolationDir) {
if (isolationMode === "fuse-overlay") {
await cleanupFuseOverlay(isolationDir);
} else {
await cleanupWorktree(isolationDir);
}
}
}
};
@@ -917,74 +980,152 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
}
}
let patchApplySummary = "";
let patchesApplied: boolean | null = null;
if (isIsolated) {
const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
const missingPatch = results.some(result => !result.patchPath);
if (!repoRoot || missingPatch) {
patchesApplied = false;
} else {
const patchStats = await Promise.all(
patchesInOrder.map(async patchPath => ({
patchPath,
size: (await fs.stat(patchPath)).size,
})),
);
const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
if (nonEmptyPatches.length === 0) {
patchesApplied = true;
let mergeSummary = "";
let changesApplied: boolean | null = null;
let mergedBranchesForNestedPatches: Set<string> | null = null;
if (isIsolated && repoRoot) {
if (mergeMode === "branch") {
// Branch mode: merge task branches sequentially
const branchEntries = results
.filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
.map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
if (branchEntries.length === 0) {
changesApplied = true;
} else {
const patchTexts = await Promise.all(
nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
);
const combinedPatch = patchTexts.map(text => (text.endsWith("\n") ? text : `${text}\n`)).join("");
if (!combinedPatch.trim()) {
patchesApplied = true;
const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
mergedBranchesForNestedPatches = new Set(mergeResult.merged);
changesApplied = mergeResult.failed.length === 0;
if (changesApplied) {
mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
} else {
const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${Snowflake.next()}.patch`);
try {
await Bun.write(combinedPatchPath, combinedPatch);
const checkResult = await $`git apply --check --binary ${combinedPatchPath}`
.cwd(repoRoot)
.quiet()
.nothrow();
if (checkResult.exitCode !== 0) {
patchesApplied = false;
} else {
const applyResult = await $`git apply --binary ${combinedPatchPath}`
const mergedPart =
mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
}
}
// Clean up merged branches (keep failed ones for manual resolution)
const allBranches = branchEntries.map(b => b.branchName);
if (changesApplied) {
await cleanupTaskBranches(repoRoot, allBranches);
}
} else {
// Patch mode: combine and apply patches
const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
const missingPatch = results.some(result => !result.patchPath);
if (missingPatch) {
changesApplied = false;
} else {
const patchStats = await Promise.all(
patchesInOrder.map(async patchPath => ({
patchPath,
size: (await fs.stat(patchPath)).size,
})),
);
const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
if (nonEmptyPatches.length === 0) {
changesApplied = true;
} else {
const patchTexts = await Promise.all(
nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
);
const combinedPatch = patchTexts.map(text => (text.endsWith("\n") ? text : `${text}\n`)).join("");
if (!combinedPatch.trim()) {
changesApplied = true;
} else {
const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${Snowflake.next()}.patch`);
try {
await Bun.write(combinedPatchPath, combinedPatch);
const checkResult = await $`git apply --check --binary ${combinedPatchPath}`
.cwd(repoRoot)
.quiet()
.nothrow();
patchesApplied = applyResult.exitCode === 0;
if (checkResult.exitCode !== 0) {
changesApplied = false;
} else {
const applyResult = await $`git apply --binary ${combinedPatchPath}`
.cwd(repoRoot)
.quiet()
.nothrow();
changesApplied = applyResult.exitCode === 0;
}
} finally {
await fs.rm(combinedPatchPath, { force: true });
}
} finally {
await fs.rm(combinedPatchPath, { force: true });
}
}
}
}
if (patchesApplied) {
patchApplySummary = "\n\nApplied patches: yes";
} else {
const notification =
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
const patchList =
patchPaths.length > 0
? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
: "";
patchApplySummary = `\n\n${notification}${patchList}`;
if (changesApplied) {
mergeSummary = "\n\nApplied patches: yes";
} else {
const notification =
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
const patchList =
patchPaths.length > 0
? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
: "";
mergeSummary = `\n\n${notification}${patchList}`;
}
}
}
// Apply nested repo patches (separate from parent git)
if (isIsolated && repoRoot && (mergeMode === "branch" || changesApplied !== false)) {
const allNestedPatches = results
.filter(r => {
if (!r.nestedPatches || r.nestedPatches.length === 0 || r.exitCode !== 0 || r.aborted) {
return false;
}
if (mergeMode !== "branch") {
return true;
}
if (!r.branchName || !mergedBranchesForNestedPatches) {
return false;
}
return mergedBranchesForNestedPatches.has(r.branchName);
})
.flatMap(r => r.nestedPatches!);
if (allNestedPatches.length > 0) {
try {
const commitMsg =
commitStyle === "ai" && this.session.modelRegistry
? async (diff: string) => {
const smolModel = this.session.settings.getModelRole("smol");
return generateCommitMessage(
diff,
this.session.modelRegistry!,
smolModel,
this.session.getSessionId?.() ?? undefined,
);
}
: undefined;
await applyNestedPatches(repoRoot, allNestedPatches, commitMsg);
} catch {
// Nested patch failures are non-fatal to the parent merge
mergeSummary +=
"\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
}
}
}
// Build final output - match plugin format
const successCount = results.filter(r => r.exitCode === 0).length;
const successCount = results.filter(r => r.exitCode === 0 && !r.error).length;
const cancelledCount = results.filter(r => r.aborted).length;
const totalDuration = Date.now() - startTime;
const summaries = results.map(r => {
const status = r.aborted ? "cancelled" : r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`;
const status = r.aborted
? "cancelled"
: r.exitCode === 0 && r.error
? "merge failed"
: r.exitCode === 0
? "completed"
: `failed (exit ${r.exitCode})`;
const output = r.output.trim() || r.stderr.trim() || "(no output)";
const outputCharCount = r.outputMeta?.charCount ?? output.length;
const fullOutputThreshold = 5000;
@@ -1021,12 +1162,12 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
summaries,
outputIds,
agentName,
patchApplySummary,
mergeSummary,
});
// Cleanup temp directory if used
const shouldCleanupTempArtifacts =
tempArtifactsDir && (!isIsolated || patchesApplied === true || patchesApplied === null);
tempArtifactsDir && (!isIsolated || changesApplied === true || changesApplied === null);
if (shouldCleanupTempArtifacts) {
await fs.rm(tempArtifactsDir, { recursive: true, force: true });
}
+24 -8
View File
@@ -728,7 +728,8 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
result.output,
);
const aborted = result.aborted ?? false;
const success = !aborted && result.exitCode === 0;
const mergeFailed = !aborted && result.exitCode === 0 && !!result.error;
const success = !aborted && result.exitCode === 0 && !result.error;
const needsWarning = Boolean(missingCompleteWarning) && success;
const icon = aborted
? theme.status.aborted
@@ -737,8 +738,16 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
: success
? theme.status.success
: theme.status.error;
const iconColor = needsWarning ? "warning" : success ? "success" : "error";
const statusText = aborted ? "aborted" : needsWarning ? "warning" : success ? "done" : "failed";
const iconColor = needsWarning ? "warning" : success ? "success" : mergeFailed ? "warning" : "error";
const statusText = aborted
? "aborted"
: needsWarning
? "warning"
: success
? "done"
: mergeFailed
? "merge failed"
: "failed";
// Main status line: id: description [status] · stats · ⟨agent⟩
const description = result.description?.trim();
@@ -847,11 +856,13 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
if (result.patchPath && !aborted && result.exitCode === 0) {
lines.push(`${continuePrefix}${theme.fg("dim", `Patch: ${result.patchPath}`)}`);
} else if (result.branchName && !aborted && result.exitCode === 0) {
lines.push(`${continuePrefix}${theme.fg("dim", `Branch: ${result.branchName}`)}`);
}
// Error message
if (result.error && !success) {
lines.push(`${continuePrefix}${theme.fg("error", truncateToWidth(result.error, 70))}`);
if (result.error && (!success || mergeFailed)) {
lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", truncateToWidth(result.error, 70))}`);
}
return lines;
@@ -902,15 +913,20 @@ export function renderResult(
});
const abortedCount = details.results.filter(r => r.aborted).length;
const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0).length;
const failCount = details.results.length - successCount - abortedCount;
const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
let summary = `${theme.fg("dim", "Total:")} `;
if (abortedCount > 0) {
summary += theme.fg("error", `${abortedCount} aborted`);
if (successCount > 0 || failCount > 0) summary += theme.sep.dot;
if (successCount > 0 || mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
}
if (successCount > 0) {
summary += theme.fg("success", `${successCount} succeeded`);
if (mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
}
if (mergeFailedCount > 0) {
summary += theme.fg("warning", `${mergeFailedCount} merge failed`);
if (failCount > 0) summary += theme.sep.dot;
}
if (failCount > 0) {
+6 -1
View File
@@ -2,6 +2,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import { $env } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { NestedRepoPatch } from "./worktree";
/** Source of an agent definition */
export type AgentSource = "bundled" | "user" | "project";
@@ -77,7 +78,7 @@ const createTaskSchema = (options: { isolationEnabled: boolean }) => {
...properties,
isolated: Type.Optional(
Type.Boolean({
description: "Run in isolated git worktree; returns patches. Use when tasks edit overlapping files.",
description: "Run in isolated environment; returns patches. Use when tasks edit overlapping files.",
}),
),
});
@@ -179,6 +180,10 @@ export interface SingleResult {
outputPath?: string;
/** Patch path for isolated worktree output */
patchPath?: string;
/** Branch name for isolated branch-mode output */
branchName?: string;
/** Nested repo patches to apply after parent merge */
nestedPatches?: NestedRepoPatch[];
/** Data extracted by registered subprocess tool handlers (keyed by tool name) */
extractedToolData?: Record<string, unknown[]>;
/** Output metadata for agent:// URL integration */
+394 -31
View File
@@ -1,16 +1,26 @@
import type { Dirent } from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import path from "node:path";
import { getWorktreeDir, isEnoent, Snowflake } from "@oh-my-pi/pi-utils";
import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
export interface WorktreeBaseline {
/** Baseline state for a single git repository. */
export interface RepoBaseline {
repoRoot: string;
headCommit: string;
staged: string;
unstaged: string;
untracked: string[];
}
/** Baseline state for the project, including any nested git repos. */
export interface WorktreeBaseline {
root: RepoBaseline;
/** Nested git repos (path relative to root.repoRoot). */
nested: Array<{ relativePath: string; baseline: RepoBaseline }>;
}
export function getEncodedProjectName(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
@@ -38,7 +48,56 @@ export async function ensureWorktree(baseCwd: string, id: string): Promise<strin
return worktreeDir;
}
export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
/** Find nested git repositories (non-submodule) under the given root. */
async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
// Get submodule paths so we can exclude them
const submoduleRaw = await $`git submodule --quiet foreach --recursive 'echo $sm_path'`
.cwd(repoRoot)
.quiet()
.nothrow()
.text();
const submodulePaths = new Set(
submoduleRaw
.split("\n")
.map(l => l.trim())
.filter(Boolean),
);
// Find all .git dirs/files that aren't the root or known submodules
const result: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
if (!entry.isDirectory()) continue;
const full = path.join(dir, entry.name);
const rel = path.relative(repoRoot, full);
// Check if this directory is itself a git repo
const gitDir = path.join(full, ".git");
let hasGit = false;
try {
await fs.access(gitDir);
hasGit = true;
} catch {}
if (hasGit && !submodulePaths.has(rel)) {
result.push(rel);
// Don't recurse into nested repos — they manage their own tree
continue;
}
await walk(full);
}
}
await walk(repoRoot);
return result;
}
async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
const headCommit = (await $`git rev-parse HEAD`.cwd(repoRoot).quiet().text()).trim();
const staged = await $`git diff --cached --binary`.cwd(repoRoot).quiet().text();
const unstaged = await $`git diff --binary`.cwd(repoRoot).quiet().text();
const untrackedRaw = await $`git ls-files --others --exclude-standard`.cwd(repoRoot).quiet().text();
@@ -46,13 +105,18 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0);
return { repoRoot, headCommit, staged, unstaged, untracked };
}
return {
repoRoot,
staged,
unstaged,
untracked,
};
export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
const [root, nestedPaths] = await Promise.all([captureRepoBaseline(repoRoot), discoverNestedRepos(repoRoot)]);
const nested = await Promise.all(
nestedPaths.map(async relativePath => ({
relativePath,
baseline: await captureRepoBaseline(path.join(repoRoot, relativePath)),
})),
);
return { root, nested };
}
async function writeTempPatchFile(patch: string): Promise<string> {
@@ -80,13 +144,13 @@ async function applyPatch(
}
}
export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise<void> {
await applyPatch(worktreeDir, baseline.staged, { cached: true });
await applyPatch(worktreeDir, baseline.staged);
await applyPatch(worktreeDir, baseline.unstaged);
async function applyRepoBaseline(worktreeDir: string, rb: RepoBaseline, sourceRoot: string): Promise<void> {
await applyPatch(worktreeDir, rb.staged, { cached: true });
await applyPatch(worktreeDir, rb.staged);
await applyPatch(worktreeDir, rb.unstaged);
for (const entry of baseline.untracked) {
const source = path.join(baseline.repoRoot, entry);
for (const entry of rb.untracked) {
const source = path.join(sourceRoot, entry);
const destination = path.join(worktreeDir, entry);
try {
await fs.mkdir(path.dirname(destination), { recursive: true });
@@ -98,6 +162,39 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel
}
}
export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise<void> {
await applyRepoBaseline(worktreeDir, baseline.root, baseline.root.repoRoot);
// Restore nested repos into the worktree
for (const entry of baseline.nested) {
const nestedDir = path.join(worktreeDir, entry.relativePath);
// Copy the nested repo wholesale (it's not managed by root git)
const sourceDir = path.join(baseline.root.repoRoot, entry.relativePath);
try {
await fs.cp(sourceDir, nestedDir, { recursive: true });
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
// Apply any uncommitted changes from the nested baseline
await applyRepoBaseline(nestedDir, entry.baseline, entry.baseline.repoRoot);
// Commit baseline state so captureRepoDeltaPatch can cleanly subtract it.
// Without this, `git add -A && git commit` by the task would include
// baseline untracked files in the diff-tree output.
const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
if (hasChanges) {
await $`git add -A`.cwd(nestedDir).quiet();
await $`git commit -m omp-baseline --allow-empty`.cwd(nestedDir).quiet();
// Update baseline to reflect the committed state — prevents double-apply
// in captureRepoDeltaPatch's temp-index path
entry.baseline.headCommit = (await $`git rev-parse HEAD`.cwd(nestedDir).quiet().text()).trim();
entry.baseline.staged = "";
entry.baseline.unstaged = "";
entry.baseline.untracked = [];
}
}
}
async function applyPatchToIndex(cwd: string, patch: string, indexFile: string): Promise<void> {
if (!patch.trim()) return;
const tempPath = await writeTempPatchFile(patch);
@@ -121,31 +218,62 @@ async function listUntracked(cwd: string): Promise<string[]> {
.filter(line => line.length > 0);
}
export async function captureDeltaPatch(worktreeDir: string, baseline: WorktreeBaseline): Promise<string> {
async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
// Check if HEAD advanced (task committed changes)
const currentHead = (await $`git rev-parse HEAD`.cwd(repoDir).quiet().nothrow().text()).trim();
const headAdvanced = currentHead && currentHead !== rb.headCommit;
if (headAdvanced) {
// HEAD moved: use diff-tree to capture committed changes, plus any uncommitted on top
const parts: string[] = [];
// Committed changes since baseline
const committedDiff = await $`git diff-tree -r -p --binary ${rb.headCommit} ${currentHead}`
.cwd(repoDir)
.quiet()
.nothrow()
.text();
if (committedDiff.trim()) parts.push(committedDiff);
// Uncommitted changes on top of the new HEAD
const staged = await $`git diff --cached --binary`.cwd(repoDir).quiet().text();
const unstaged = await $`git diff --binary`.cwd(repoDir).quiet().text();
if (staged.trim()) parts.push(staged);
if (unstaged.trim()) parts.push(unstaged);
// New untracked files (relative to both baseline and current tracking)
const currentUntracked = await listUntracked(repoDir);
const baselineUntracked = new Set(rb.untracked);
const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
if (newUntracked.length > 0) {
const untrackedDiffs = await Promise.all(
newUntracked.map(entry =>
$`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
),
);
parts.push(...untrackedDiffs.filter(d => d.trim()));
}
return parts.join("\n");
}
// HEAD unchanged: use temp index approach (subtracts baseline from delta)
const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
try {
await $`git read-tree HEAD`.cwd(worktreeDir).env({
GIT_INDEX_FILE: tempIndex,
});
await applyPatchToIndex(worktreeDir, baseline.staged, tempIndex);
await applyPatchToIndex(worktreeDir, baseline.unstaged, tempIndex);
const diff = await $`git diff --binary`
.cwd(worktreeDir)
.env({
GIT_INDEX_FILE: tempIndex,
})
.quiet()
.text();
await $`git read-tree ${rb.headCommit}`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex });
await applyPatchToIndex(repoDir, rb.staged, tempIndex);
await applyPatchToIndex(repoDir, rb.unstaged, tempIndex);
const diff = await $`git diff --binary`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex }).quiet().text();
const currentUntracked = await listUntracked(worktreeDir);
const baselineUntracked = new Set(baseline.untracked);
const currentUntracked = await listUntracked(repoDir);
const baselineUntracked = new Set(rb.untracked);
const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
if (newUntracked.length === 0) return diff;
const untrackedDiffs = await Promise.all(
newUntracked.map(entry =>
$`git diff --binary --no-index /dev/null ${entry}`.cwd(worktreeDir).quiet().nothrow().text(),
$`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
),
);
return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
@@ -154,6 +282,76 @@ export async function captureDeltaPatch(worktreeDir: string, baseline: WorktreeB
}
}
export interface NestedRepoPatch {
relativePath: string;
patch: string;
}
export interface DeltaPatchResult {
rootPatch: string;
nestedPatches: NestedRepoPatch[];
}
export async function captureDeltaPatch(isolationDir: string, baseline: WorktreeBaseline): Promise<DeltaPatchResult> {
const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root);
const nestedPatches: NestedRepoPatch[] = [];
for (const { relativePath, baseline: nb } of baseline.nested) {
const nestedDir = path.join(isolationDir, relativePath);
try {
await fs.access(path.join(nestedDir, ".git"));
} catch {
continue;
}
const patch = await captureRepoDeltaPatch(nestedDir, nb);
if (patch.trim()) nestedPatches.push({ relativePath, patch });
}
return { rootPatch, nestedPatches };
}
/**
* Apply nested repo patches directly to their working directories after parent merge.
* @param commitMessage Optional async function to generate a commit message from the combined diff.
* If omitted or returns null, falls back to a generic message.
*/
export async function applyNestedPatches(
repoRoot: string,
patches: NestedRepoPatch[],
commitMessage?: (diff: string) => Promise<string | null>,
): Promise<void> {
// Group patches by target repo to apply all at once and commit
const byRepo = new Map<string, NestedRepoPatch[]>();
for (const p of patches) {
if (!p.patch.trim()) continue;
const group = byRepo.get(p.relativePath) ?? [];
group.push(p);
byRepo.set(p.relativePath, group);
}
for (const [relativePath, repoPatches] of byRepo) {
const nestedDir = path.join(repoRoot, relativePath);
try {
await fs.access(path.join(nestedDir, ".git"));
} catch {
continue;
}
const combinedDiff = repoPatches.map(p => p.patch).join("\n");
for (const { patch } of repoPatches) {
await applyPatch(nestedDir, patch);
}
// Commit so nested repo history reflects the task changes
const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
if (hasChanges) {
const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
await $`git add -A`.cwd(nestedDir).quiet();
await $`git commit -m ${msg}`.cwd(nestedDir).quiet();
}
}
}
export async function cleanupWorktree(dir: string): Promise<void> {
try {
const commonDirRaw = await $`git rev-parse --git-common-dir`.cwd(dir).quiet().nothrow().text();
@@ -167,3 +365,168 @@ export async function cleanupWorktree(dir: string): Promise<void> {
await fs.rm(dir, { recursive: true, force: true });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Fuse-overlay isolation
// ═══════════════════════════════════════════════════════════════════════════
export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<string> {
const repoRoot = await getRepoRoot(baseCwd);
const encodedProject = getEncodedProjectName(repoRoot);
const baseDir = getWorktreeDir(encodedProject, id);
const upperDir = path.join(baseDir, "upper");
const workDir = path.join(baseDir, "work");
const mergedDir = path.join(baseDir, "merged");
// Clean up any stale mount at this path
const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
if (fusermount) {
await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
}
await fs.rm(baseDir, { recursive: true, force: true });
await fs.mkdir(upperDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await fs.mkdir(mergedDir, { recursive: true });
const binary = Bun.which("fuse-overlayfs");
if (!binary) {
await fs.rm(baseDir, { recursive: true, force: true });
throw new Error(
"fuse-overlayfs not found. Install it (e.g. `apt install fuse-overlayfs` or `pacman -S fuse-overlayfs`) to use fuse-overlay isolation.",
);
}
const result = await $`${binary} -o lowerdir=${repoRoot},upperdir=${upperDir},workdir=${workDir} ${mergedDir}`
.quiet()
.nothrow();
if (result.exitCode !== 0) {
const stderr = result.stderr.toString().trim();
await fs.rm(baseDir, { recursive: true, force: true });
throw new Error(`fuse-overlayfs mount failed (exit ${result.exitCode}): ${stderr}`);
}
return mergedDir;
}
export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
try {
const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
if (fusermount) {
await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
}
} finally {
// baseDir is the parent of the merged directory
const baseDir = path.dirname(mergedDir);
await fs.rm(baseDir, { recursive: true, force: true });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Branch-mode isolation
// ═══════════════════════════════════════════════════════════════════════════
export interface CommitToBranchResult {
branchName?: string;
nestedPatches: NestedRepoPatch[];
}
/**
* Commit task-only changes to a new branch.
* Only root repo changes go on the branch. Nested repo patches are returned
* separately since the parent git can't track files inside gitlinks.
*/
export async function commitToBranch(
isolationDir: string,
baseline: WorktreeBaseline,
taskId: string,
description: string | undefined,
commitMessage?: (diff: string) => Promise<string | null>,
): Promise<CommitToBranchResult | null> {
const { rootPatch, nestedPatches } = await captureDeltaPatch(isolationDir, baseline);
if (!rootPatch.trim() && nestedPatches.length === 0) return null;
const repoRoot = baseline.root.repoRoot;
const branchName = `omp/task/${taskId}`;
const fallbackMessage = description || taskId;
// Only create a branch if the root repo has changes
if (rootPatch.trim()) {
await $`git branch ${branchName} HEAD`.cwd(repoRoot).quiet();
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
try {
await $`git worktree add ${tmpDir} ${branchName}`.cwd(repoRoot).quiet();
const patchPath = path.join(os.tmpdir(), `omp-branch-patch-${Snowflake.next()}.patch`);
try {
await Bun.write(patchPath, rootPatch);
const applyResult = await $`git apply --binary ${patchPath}`.cwd(tmpDir).quiet().nothrow();
if (applyResult.exitCode !== 0) {
const stderr = applyResult.stderr.toString().slice(0, 2000);
logger.error("commitToBranch: git apply failed", {
taskId,
exitCode: applyResult.exitCode,
stderr,
patchSize: rootPatch.length,
patchHead: rootPatch.slice(0, 500),
});
throw new Error(`git apply failed for task ${taskId}: ${stderr}`);
}
} finally {
await fs.rm(patchPath, { force: true });
}
await $`git add -A`.cwd(tmpDir).quiet();
const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
await $`git commit -m ${msg}`.cwd(tmpDir).quiet();
} finally {
await $`git worktree remove -f ${tmpDir}`.cwd(repoRoot).quiet().nothrow();
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
return { branchName: rootPatch.trim() ? branchName : undefined, nestedPatches };
}
export interface MergeBranchResult {
merged: string[];
failed: string[];
conflict?: string;
}
/**
* Cherry-pick task branch commits sequentially onto HEAD.
* Each branch has a single commit that gets replayed cleanly.
* Stops on first conflict and reports which branches succeeded.
*/
export async function mergeTaskBranches(
repoRoot: string,
branches: Array<{ branchName: string; taskId: string; description?: string }>,
): Promise<MergeBranchResult> {
const merged: string[] = [];
const failed: string[] = [];
for (const { branchName } of branches) {
const result = await $`git cherry-pick ${branchName}`.cwd(repoRoot).quiet().nothrow();
if (result.exitCode !== 0) {
await $`git cherry-pick --abort`.cwd(repoRoot).quiet().nothrow();
const stderr = result.stderr.toString().trim();
failed.push(branchName);
return {
merged,
failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
conflict: `${branchName}: ${stderr}`,
};
}
merged.push(branchName);
}
return { merged, failed };
}
/** Clean up temporary task branches. */
export async function cleanupTaskBranches(repoRoot: string, branches: string[]): Promise<void> {
for (const branch of branches) {
await $`git branch -D ${branch}`.cwd(repoRoot).quiet().nothrow();
}
}
@@ -0,0 +1,132 @@
/**
* Generate commit messages from diffs using a smol, fast model.
* Follows the same pattern as title-generator.ts.
*/
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { parseModelString } from "../config/model-resolver";
import { renderPromptTemplate } from "../config/prompt-templates";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
const COMMIT_SYSTEM_PROMPT = renderPromptTemplate(commitSystemPrompt);
const MAX_DIFF_CHARS = 4000;
/** File patterns that should be excluded from commit message generation diffs. */
const NOISE_SUFFIXES = [".lock", ".lockb", "-lock.json", "-lock.yaml"];
/** Strip diff hunks for noisy files that drown out real changes. */
function filterDiffNoise(diff: string): string {
const lines = diff.split("\n");
const filtered: string[] = [];
let skip = false;
for (const line of lines) {
if (line.startsWith("diff --git ")) {
const bPath = line.split(" b/")[1];
skip = bPath != null && NOISE_SUFFIXES.some(s => bPath.endsWith(s));
}
if (!skip) filtered.push(line);
}
return filtered.join("\n");
}
function getSmolModelCandidates(registry: ModelRegistry, savedSmolModel?: string): Model<Api>[] {
const availableModels = registry.getAvailable();
if (availableModels.length === 0) return [];
const candidates: Model<Api>[] = [];
const addCandidate = (model?: Model<Api>): void => {
if (!model) return;
if (candidates.some(c => c.provider === model.provider && c.id === model.id)) return;
candidates.push(model);
};
if (savedSmolModel) {
const parsed = parseModelString(savedSmolModel);
if (parsed) {
const match = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
addCandidate(match);
}
}
for (const pattern of MODEL_PRIO.smol) {
const needle = pattern.toLowerCase();
addCandidate(availableModels.find(m => m.id.toLowerCase() === needle));
addCandidate(availableModels.find(m => m.id.toLowerCase().includes(needle)));
}
for (const model of availableModels) {
addCandidate(model);
}
return candidates;
}
/**
* Generate a commit message from a unified diff.
* Returns null if generation fails (caller should fall back to generic message).
*/
export async function generateCommitMessage(
diff: string,
registry: ModelRegistry,
savedSmolModel?: string,
sessionId?: string,
): Promise<string | null> {
const candidates = getSmolModelCandidates(registry, savedSmolModel);
if (candidates.length === 0) {
logger.debug("commit-msg-generator: no smol model found");
return null;
}
const cleanDiff = filterDiffNoise(diff);
const truncatedDiff =
cleanDiff.length > MAX_DIFF_CHARS ? `${cleanDiff.slice(0, MAX_DIFF_CHARS)}\n… (truncated)` : cleanDiff;
if (!truncatedDiff.trim()) {
logger.debug("commit-msg-generator: diff is empty after noise filtering");
return null;
}
const userMessage = `<diff>\n${truncatedDiff}\n</diff>`;
for (const model of candidates) {
const apiKey = await registry.getApiKey(model, sessionId);
if (!apiKey) continue;
try {
const response = await completeSimple(
model,
{
systemPrompt: COMMIT_SYSTEM_PROMPT,
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
},
{ apiKey, maxTokens: 60 },
);
if (response.stopReason === "error") {
logger.debug("commit-msg-generator: error", { model: model.id, error: response.errorMessage });
continue;
}
let msg = "";
for (const content of response.content) {
if (content.type === "text") msg += content.text;
}
msg = msg.trim();
if (!msg) continue;
// Clean up: remove wrapping quotes, backticks, trailing period
msg = msg.replace(/^[`"']|[`"']$/g, "").replace(/\.$/, "");
logger.debug("commit-msg-generator: generated", { model: model.id, msg });
return msg;
} catch (err) {
logger.debug("commit-msg-generator: error", {
model: model.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
return null;
}
+5 -2
View File
@@ -234,9 +234,12 @@ function calculateImageFit(
const fittedWidthPx = imageDimensions.widthPx * scale;
const fittedHeightPx = imageDimensions.heightPx * scale;
const columns = Math.max(1, Math.floor(fittedWidthPx / cellDims.widthPx));
const rows = Math.max(1, Math.ceil(fittedHeightPx / cellDims.heightPx));
return {
columns: Math.max(1, Math.floor(fittedWidthPx / cellDims.widthPx)),
rows: Math.max(1, Math.ceil(fittedHeightPx / cellDims.heightPx)),
columns: maxColumns !== undefined ? Math.min(columns, maxColumns) : columns,
rows: maxRows !== undefined ? Math.min(rows, maxRows) : rows,
};
}