Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/main/ipc/gitHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { IpcChannels } from '@shared/ipc-channels';
import { GIT_LIMITS } from '@shared/constants';
import type {
GenerateCommitMessageResult,
GitBlameLine,
GitBranch,
GitCheckoutResult,
Expand All @@ -23,6 +24,7 @@ import type {
} from '@shared/types';
import { handle } from './registry';
import type { GitManager } from '../managers/GitManager';
import type { AgentManager } from '../managers/AgentManager';

function assertId(id: unknown, label = 'id'): asserts id is string {
if (typeof id !== 'string' || id.length === 0 || id.length > 128) {
Expand Down Expand Up @@ -55,7 +57,7 @@ function assertText(value: unknown, max: number, label: string): asserts value i
}
}

export function registerGitHandlers(git: GitManager): void {
export function registerGitHandlers(git: GitManager, agent: AgentManager): void {
handle<[string], GitStatus>(IpcChannels.gitStatus, (_e, wsId) => {
assertId(wsId, 'workspaceId');
return git.status(wsId);
Expand Down Expand Up @@ -100,6 +102,26 @@ export function registerGitHandlers(git: GitManager): void {
return git.commit(wsId, message);
});

// AI commit-message generation: the ONLY renderer input is the workspace id —
// all git context (status / staged diff / recent subjects) is assembled in the
// main process by GitManager and size-capped by GIT_LIMITS.commitGen. The
// sub-agent run is tool-less and only proposes text; it never commits.
handle<[string], GenerateCommitMessageResult>(
IpcChannels.gitCommitMessageGenerate,
async (_e, wsId) => {
assertId(wsId, 'workspaceId');
const ctx = await git.buildCommitContext(wsId);
if (!ctx) return { ok: false, reason: 'error', error: 'Not a git repository' };
if (ctx.files.length === 0) return { ok: false, reason: 'no-staged' };
return agent.generateCommitMessage(wsId, ctx);
},
);

handle<[string], void>(IpcChannels.gitCommitMessageCancel, (_e, wsId) => {
assertId(wsId, 'workspaceId');
agent.cancelCommitMessage(wsId);
});

handle<[string, { limit?: number; offset?: number }?], GitCommit[]>(
IpcChannels.gitLog,
(_e, wsId, opts) => {
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function registerAllIpc(deps: IpcDeps): void {
registerAgentHandlers(deps.agent);
registerFsHandlers(deps.fs);
registerTerminalHandlers(deps.terminal);
registerGitHandlers(deps.git);
registerGitHandlers(deps.git, deps.agent);
registerWorktreeHandlers(deps.worktree);
registerServiceHandlers(deps.services);
registerMemoryHandlers(deps.memory);
Expand Down
268 changes: 267 additions & 1 deletion src/main/managers/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import type {
DiagnosticSeverity,
FileChange,
FileChangeStatus,
GenerateCommitMessageResult,
GitCommitContext,
GitCommitMessageStreamEvent,
PermissionDecision,
PermissionRequest,
PlanMeta,
Expand All @@ -59,7 +62,7 @@ import type {
TerminalCommandRecord,
ToolRisk,
} from '@shared/types';
import { ACTIVITY_LIMITS, AGENT_LIMITS } from '@shared/constants';
import { ACTIVITY_LIMITS, AGENT_LIMITS, GIT_LIMITS } from '@shared/constants';
import { IpcEvents } from '@shared/ipc-channels';
import { getDb } from '../db/database';
import { logger } from '../logger';
Expand Down Expand Up @@ -172,6 +175,26 @@ const IDLE_REQUEST: RequestState = {
const DELTA_FLUSH_CHARS = 24;
const DELTA_FLUSH_MS = 16;

/* ------------------------------------------------------------------ */
/* Git commit-message sub-agent — an isolated, tool-less one-shot run. */
/* ------------------------------------------------------------------ */

/**
* Pinned haiku-class model for utility one-shots (commit messages). Deliberately
* NOT the user's configured chat model: summarizing a staged diff into a ≤72-char
* subject is fast-model work, and pinning keeps latency/cost predictable.
*/
const COMMIT_MESSAGE_MODEL = 'claude-haiku-4-5-20251001';

const COMMIT_SYSTEM_PROMPT =
'You write git commit messages. Output ONLY the commit message text — no ' +
'preamble, no explanations, no code fences, no surrounding quotes. First ' +
'line: an imperative-mood subject of at most 72 characters. If the change ' +
'needs explanation, add one blank line then a short body wrapped at ~72 ' +
'columns. If the repository\'s recent commit subjects follow a consistent ' +
'convention (e.g. "feat:", "fix(scope):"), match it; otherwise use a plain ' +
'imperative subject.';

function classifyTool(name: string): ToolRisk {
if (WRITE_TOOLS.has(name)) return 'write';
if (COMMAND_TOOLS.has(name)) return 'command';
Expand Down Expand Up @@ -350,6 +373,15 @@ export class AgentManager {
* whichever session most recently touched the state.
*/
private readonly requests = new Map<string, RequestState>();
/**
* One in-flight commit-message generation per workspace. Kept fully apart
* from {@link runs}: these one-shots never touch lifecycle, transcripts,
* plans, or the conversation event stream.
*/
private readonly commitGenRuns = new Map<
string,
{ abort: AbortController; query: { close?: () => void } | null }
>();
/** Pending permission prompts awaiting a renderer decision, keyed by request id. */
private readonly pending = new Map<
string,
Expand Down Expand Up @@ -858,11 +890,202 @@ export class AgentManager {
/** Abort every active run + stop all supervision timers. Called on quit. */
cleanup(): void {
for (const sessionId of [...this.runs.keys()]) this.stop(sessionId);
for (const workspaceId of [...this.commitGenRuns.keys()]) this.cancelCommitMessage(workspaceId);
this.stopHeartbeat();
this.clearRateLimitTimer();
this.clearIdleTimer();
}

/* ---------------------------------------------------------------- */
/* Git commit-message sub-agent (isolated one-shot) */
/* ---------------------------------------------------------------- */

/**
* Generate a commit message for the staged changes described by `ctx` (built
* main-side by GitManager — never renderer-supplied). Runs an isolated,
* tool-less, single-turn SDK query and streams the text to the renderer over
* `IpcEvents.gitCommitMessageStream`. It only PROPOSES a message: nothing here
* ever calls `git commit`. The run never touches lifecycle, transcripts,
* memory/search context, MCP servers, or the conversation event stream.
*/
async generateCommitMessage(
workspaceId: string,
ctx: GitCommitContext,
): Promise<GenerateCommitMessageResult> {
const install = this.getInstall();
if (!install.installed) {
return { ok: false, reason: 'agent-unavailable', error: install.error };
}
if (this.state.lifecycle === 'rate-limited') {
return {
ok: false,
reason: 'rate-limited',
error: this.state.rateLimit?.message ?? 'The agent is rate limited right now.',
};
}
if (this.commitGenRuns.has(workspaceId)) {
return { ok: false, reason: 'busy', error: 'A commit message is already being generated.' };
}

const requestId = newId();
const abort = new AbortController();
const run: { abort: AbortController; query: { close?: () => void } | null } = {
abort,
query: null,
};
this.commitGenRuns.set(workspaceId, run);

const emit = (ev: Omit<GitCommitMessageStreamEvent, 'workspaceId' | 'requestId'>): void => {
const payload: GitCommitMessageStreamEvent = { workspaceId, requestId, ...ev };
this.broadcastChannel(IpcEvents.gitCommitMessageStream, payload);
};

// Coalesced-delta state, mirroring runOnce's DELTA_FLUSH_* pattern.
let pendingDelta = '';
let flushTimer: NodeJS.Timeout | null = null;
const flushDelta = (): void => {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (pendingDelta.length === 0) return;
const text = pendingDelta;
pendingDelta = '';
emit({ kind: 'delta', text });
};
const queueDelta = (text: string): void => {
pendingDelta += text;
if (pendingDelta.length >= DELTA_FLUSH_CHARS) flushDelta();
else if (!flushTimer) flushTimer = setTimeout(flushDelta, DELTA_FLUSH_MS);
};

try {
const prompt = buildCommitPrompt(ctx);
if (prompt.length > AGENT_LIMITS.promptMax) {
// Belt + braces: GitManager's commitGen caps keep us far below this.
throw new Error('Commit context too large.');
}
Comment on lines +962 to +967

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'promptMax' src/shared/constants.ts

Repository: BotCoder254/limboo

Length of output: 181


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== constants ==\n'
sed -n '1,120p' src/shared/constants.ts

printf '\n== AgentManager around buildCommitPrompt call ==\n'
sed -n '930,995p' src/main/managers/AgentManager.ts

printf '\n== buildCommitPrompt definition/usages ==\n'
rg -n "function buildCommitPrompt|const buildCommitPrompt|buildCommitPrompt\(" src/main src/shared -n -A40 -B20

printf '\n== GIT_LIMITS commitGen ==\n'
rg -n "commitGen|diffCharsMax" src -n -A20 -B10

Repository: BotCoder254/limboo

Length of output: 43871


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== GitManager buildCommitContext around file/subject shaping ==\n'
sed -n '292,380p' src/main/managers/GitManager.ts

printf '\n== any truncation/redaction helpers used there ==\n'
rg -n "redactRemote|sanitize|truncate|branch" src/main/managers/GitManager.ts -n -A8 -B8

printf '\n== Git status shape and branch source ==\n'
rg -n "interface GitStatus|type GitStatus|branch:" src/shared src/main -n -A20 -B10

Repository: BotCoder254/limboo

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== GitFileChange / status parsing ==\n'
rg -n "interface GitFileChange|parseStatus\\(|files:" src/shared src/main -n -A30 -B10

printf '\n== any explicit path-length caps in git status/context flow ==\n'
rg -n "pathMax|file.*Max|status.*Max|commitGen|filesMax|branchMax|refNameMax" src/main src/shared -n -A4 -B4

Repository: BotCoder254/limboo

Length of output: 50374


Cap the non-diff parts of the commit prompt. buildCommitPrompt() still includes up to 200 staged paths and 20 recent subjects, and those strings aren’t length-capped here. A large repo can still push the prompt past promptMax even when the diff stays within commitGen.diffCharsMax.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/managers/AgentManager.ts` around lines 962 - 967, Cap the non-diff
sections of the commit prompt in buildCommitPrompt() so staged paths and recent
subjects cannot push the prompt over AGENT_LIMITS.promptMax. Update the prompt
assembly in AgentManager to truncate or limit the 200 staged paths and 20 recent
subjects before concatenation, and keep the existing prompt.length guard as a
final safety check. Locate the change in buildCommitPrompt() and the commit
prompt generation path used by commitGen so the full prompt stays bounded even
when the diff is already within commitGen.diffCharsMax.

const sdk = await loadSdk();
const options = this.buildUtilityOptions(ctx.root, abort);
const q = sdk.query({ prompt, options }) as unknown as AsyncIterable<SDKMessage> & {
close?: () => void;
};
run.query = q;

let finalText = '';
let resultOk: boolean | null = null;
let resultText = '';
for await (const msg of q) {
if (abort.signal.aborted) break;
switch (msg.type) {
case 'stream_event': {
const ev = msg.event as unknown as {
type?: string;
delta?: { type?: string; text?: string };
};
if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
queueDelta(ev.delta.text);
}
break;
}
case 'assistant': {
if (msg.error) throw new Error(String(msg.error));
const content = (msg.message?.content ?? []) as unknown as Array<Record<string, unknown>>;
const text = content
.filter((b) => b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text as string)
.join('');
if (text.trim().length > 0) finalText = text;
break;
}
case 'result': {
resultOk = msg.subtype === 'success';
resultText = 'result' in msg && typeof msg.result === 'string' ? msg.result : '';
break;
}
default:
break;
}
}
flushDelta();

if (abort.signal.aborted) {
emit({ kind: 'canceled' });
return { ok: false, reason: 'canceled' };
}
if (resultOk === false) {
throw new Error(resultText || 'The commit-message run ended with errors.');
}
const message = polishCommitMessage(finalText || resultText);
if (!message) throw new Error('The model returned an empty commit message.');
emit({ kind: 'done', text: message });
return { ok: true, message };
} catch (err) {
if (abort.signal.aborted) {
emit({ kind: 'canceled' });
return { ok: false, reason: 'canceled' };
}
const raw = err instanceof Error ? err.message : String(err);
const safe = redact(raw);
logger.warn('[claude:commit-msg] generation failed', safe);
const cls = classifyAgentError(raw);
const reason: GenerateCommitMessageResult['reason'] =
cls.outcome === 'auth-required'
? 'agent-unavailable'
: cls.outcome === 'rate-limited'
? 'rate-limited'
: 'error';
emit({ kind: 'error', error: safe });
return { ok: false, reason, error: safe };
} finally {
if (flushTimer) clearTimeout(flushTimer);
this.commitGenRuns.delete(workspaceId);
}
}

/** Abort an in-flight commit-message generation for a workspace. */
cancelCommitMessage(workspaceId: string): void {
const run = this.commitGenRuns.get(workspaceId);
if (!run) return;
run.abort.abort();
try {
run.query?.close?.();
} catch {
/* already closed */
}
}

/**
* Options for utility one-shots — deliberately parallel to (not shared with)
* {@link buildOptions}; the divergence list is the point: tool-less
* (`allowedTools: []` AND a deny-all canUseTool, belt + braces), single-turn,
* no thinking, plain-string system prompt (no claude_code preset, no
* memory/search append), no settings sources, no resume, no MCP servers.
* The model can only emit text.
*/
private buildUtilityOptions(cwd: string, abort: AbortController): Options {
const options: Options = {
cwd,
model: COMMIT_MESSAGE_MODEL,
maxTurns: 1,
includePartialMessages: true,
abortController: abort,
thinking: { type: 'disabled' },
systemPrompt: COMMIT_SYSTEM_PROMPT,
allowedTools: [],
canUseTool: async () => ({
behavior: 'deny' as const,
message: 'Tools are disabled for commit-message generation.',
}),
settingSources: [],
env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: '0' },
stderr: (data: string) => logger.warn('[claude:commit-msg]', redact(data)),
};
const claudeExe = resolveClaudeExecutable();
if (claudeExe) options.pathToClaudeCodeExecutable = claudeExe;
return options;
}

/**
* Run a prompt for a session. Streams the agent's work as structured events,
* with transparent recovery on transient failures. A failed *request* never
Expand Down Expand Up @@ -2241,6 +2464,49 @@ export class AgentManager {
/* Pure helpers */
/* ------------------------------------------------------------------ */

/** Render the main-side git context into the sub-agent's user prompt. */
function buildCommitPrompt(ctx: GitCommitContext): string {
const parts: string[] = [];
if (ctx.branch) parts.push(`Branch: ${ctx.branch}`);

const fileLines = ctx.files.map((f) => {
const counts =
f.binary
? ' (binary)'
: typeof f.adds === 'number' || typeof f.dels === 'number'
? ` (+${f.adds ?? 0} -${f.dels ?? 0})`
: '';
return `- ${f.status}: ${f.path}${counts}`;
});
parts.push(`Staged files (${ctx.files.length}):\n${fileLines.join('\n')}`);

if (ctx.recentSubjects.length > 0) {
parts.push(`Recent commit subjects (newest first):\n${ctx.recentSubjects.map((s) => `- ${s}`).join('\n')}`);
} else {
parts.push("This is the repository's first commit.");
}

if (ctx.diff.trim().length > 0) {
parts.push(
`Staged diff${ctx.diffTruncated ? ' (truncated)' : ''}:\n\`\`\`diff\n${ctx.diff}\n\`\`\``,
);
}

parts.push('Write the commit message for these staged changes.');
return parts.join('\n\n');
}

/** Normalize the model's output into a clean, size-capped commit message. */
function polishCommitMessage(raw: string): string {
let text = (raw ?? '').trim();
// Strip a single wrapping code fence the model may have added despite orders.
const fence = /^```[a-z]*\n([\s\S]*?)\n?```$/i.exec(text);
if (fence) text = fence[1].trim();
// Strip one pair of wrapping quotes.
if (/^"[\s\S]*"$/.test(text) || /^'[\s\S]*'$/.test(text)) text = text.slice(1, -1).trim();
return text.slice(0, GIT_LIMITS.commitGen.messageMax);
}

function mapThinking(thinking: 'off' | 'on' | 'adaptive'): Options['thinking'] {
if (thinking === 'off') return { type: 'disabled' };
if (thinking === 'on') return { type: 'enabled', budgetTokens: 10_000 };
Expand Down
Loading
Loading