Skip to content

Commit 96b0064

Browse files
feat: add codeep review, resumable limits, fix $ corruption in edits
- Add `codeep review` for offline, CI-friendly code review (markdown/JSON, --fail-on) - Pause at step/time safety limits with a resumable "continue" prompt - Fix edit_file and skill params silently corrupting text containing `$` ($&, ${…})
1 parent 61c576a commit 96b0064

19 files changed

Lines changed: 686 additions & 36 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,35 @@ For releases before v1.3.35, see [GitHub Releases](https://github.com/VladoIvank
1111
> as the social-share summary (IFTTT → X/Bluesky), capped at 220 chars.
1212
> If omitted, the feed falls back to the first paragraph.
1313
14+
## [2.5.0] — 2026-06-04
15+
16+
> New: `codeep review` (offline, CI-friendly code review) and Continue (a paused-at-the-limit run resumes when you say "continue" instead of dead-ending). Plus a fix where file edits or skill params containing `$` ($&, ${…}) could be written corrupted.
17+
18+
### Added
19+
20+
- **`codeep review`** — a headless, deterministic code review you can drop into
21+
CI (no API key, no TUI). Reviews the files you pass (or your unstaged git
22+
changes, falling back to a `src/` scan), prints a markdown report or `--json`,
23+
and exits non-zero when an issue at or above `--fail-on <error|warning|info|none>`
24+
is found (default `error`). Pairs with a GitHub Action to gate PRs.
25+
- **Continue after a safety limit.** When the agent reaches its step or time
26+
limit it no longer dead-ends — the run pauses with a clear, resumable notice
27+
(`⏸ Paused … say **continue** to pick up where it left off`) instead of looking
28+
like a failure, and saying *continue* resumes it with full context. Works in
29+
the TUI and in ACP clients (Zed, the VS Code extension).
30+
31+
### Fixed
32+
33+
- **Edits containing `$` are written literally.** `edit_file` (and the diff
34+
preview) applied replacements with `String.replace(text, newText)`, which
35+
interprets `$&`, `$$`, `$1` etc. in the replacement — so any edit whose new
36+
text contained `$` (template literals, shell variables, regex) was silently
37+
corrupted on write. Replacements are now inserted verbatim.
38+
- **Skill parameter expansion is `$`-safe and literal.** `${param}` substitution
39+
had the same `$`-interpretation bug in the value, and interpolated the param
40+
name into a regex unescaped (so a `.` over-matched and a `(` could throw).
41+
Both are fixed.
42+
1443
## [2.4.2] — 2026-06-02
1544

1645
> Stability: an unexpected error no longer crashes Codeep to a garbled terminal — it's logged, your conversation is saved, and recoverable background errors keep the session alive. Also fixes `codeep account` occasionally linking without storing the sync token.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,7 @@ In `dangerous` mode, configure which tools require confirmation via `/settings`:
10501050
| `codeep account` | Link CLI to codeep.dev (GitHub OAuth) |
10511051
| `codeep account sync` | Pull API keys + personalities + commands from codeep.dev → local |
10521052
| `codeep account push` | Push local API keys + personalities + commands → codeep.dev |
1053+
| `codeep review [files…]` | Offline, deterministic code review for CI — markdown or `--json`, `--fail-on <error\|warning\|info\|none>` sets the exit code. No API key needed. |
10531054

10541055
### Authentication
10551056

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeep",
3-
"version": "2.4.2",
3+
"version": "2.5.0",
44
"description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
55
"type": "module",
66
"main": "dist/index.js",

src/acp/session.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ export async function runAgentSession(opts: AgentSessionOptions): Promise<void>
184184

185185
// result.finalResponse is already emitted via onChunk streaming above;
186186
// only emit it here if nothing was streamed (e.g. non-streaming fallback path)
187-
if (result.finalResponse && chunksEmitted === 0) {
187+
// — except a paused/interrupted run, whose finalResponse is a fresh "say
188+
// continue" notice that was never streamed and must always reach the client.
189+
if (result.finalResponse && (chunksEmitted === 0 || result.interrupted)) {
188190
opts.onChunk(result.finalResponse);
189191
}
190192

src/acp/transport.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { StdioTransport } from './transport';
3+
4+
// StdioTransport is the ACP wire layer: newline-delimited JSON-RPC over stdio.
5+
// We exercise its framing/routing by driving the private onData() directly (so
6+
// we never touch the real process.stdin) and spying on process.stdout for the
7+
// outbound side.
8+
9+
function makeTransport(handler = vi.fn()) {
10+
const t = new StdioTransport();
11+
(t as unknown as { handler: unknown }).handler = handler;
12+
return { t, handler };
13+
}
14+
function feed(t: StdioTransport, chunk: string) {
15+
(t as unknown as { onData(c: string): void }).onData(chunk);
16+
}
17+
18+
afterEach(() => vi.restoreAllMocks());
19+
20+
describe('StdioTransport — inbound framing', () => {
21+
it('parses a complete line and forwards it to the handler', () => {
22+
const { t, handler } = makeTransport();
23+
feed(t, '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n');
24+
expect(handler).toHaveBeenCalledTimes(1);
25+
expect(handler.mock.calls[0][0]).toMatchObject({ id: 1, method: 'initialize' });
26+
});
27+
28+
it('buffers a partial message until the newline arrives', () => {
29+
const { t, handler } = makeTransport();
30+
feed(t, '{"jsonrpc":"2.0",');
31+
expect(handler).not.toHaveBeenCalled();
32+
feed(t, '"method":"x"}\n');
33+
expect(handler).toHaveBeenCalledTimes(1);
34+
expect(handler.mock.calls[0][0]).toMatchObject({ method: 'x' });
35+
});
36+
37+
it('splits multiple messages in one chunk, in order', () => {
38+
const { t, handler } = makeTransport();
39+
feed(t, '{"jsonrpc":"2.0","method":"a"}\n{"jsonrpc":"2.0","method":"b"}\n');
40+
expect(handler.mock.calls.map((c) => (c[0] as { method: string }).method)).toEqual(['a', 'b']);
41+
});
42+
43+
it('ignores malformed JSON and blank lines without throwing', () => {
44+
const { t, handler } = makeTransport();
45+
expect(() => feed(t, 'not json\n\n \n')).not.toThrow();
46+
expect(handler).not.toHaveBeenCalled();
47+
});
48+
49+
it('routes a response to the matching pending request, not the handler', () => {
50+
const { t, handler } = makeTransport();
51+
const resolve = vi.fn();
52+
(t as unknown as { pendingRequests: Map<number, unknown> }).pendingRequests.set(5, resolve);
53+
feed(t, '{"jsonrpc":"2.0","id":5,"result":{"ok":true}}\n');
54+
expect(resolve).toHaveBeenCalledWith({ ok: true });
55+
expect(handler).not.toHaveBeenCalled();
56+
expect((t as unknown as { pendingRequests: Map<number, unknown> }).pendingRequests.has(5)).toBe(false);
57+
});
58+
59+
it('resets the buffer instead of growing past the 10MB cap', () => {
60+
const { t, handler } = makeTransport();
61+
feed(t, 'x'.repeat(10 * 1024 * 1024 + 1)); // no newline — would otherwise buffer forever
62+
expect(handler).not.toHaveBeenCalled();
63+
expect((t as unknown as { buffer: string }).buffer).toBe('');
64+
});
65+
});
66+
67+
describe('StdioTransport — outbound frames', () => {
68+
it('respond() writes a JSON-RPC result line', () => {
69+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
70+
new StdioTransport().respond(1, { x: 1 });
71+
expect(write).toHaveBeenCalledWith('{"jsonrpc":"2.0","id":1,"result":{"x":1}}\n');
72+
});
73+
74+
it('error() writes a JSON-RPC error line', () => {
75+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
76+
new StdioTransport().error(2, -32601, 'Method not found');
77+
const sent = JSON.parse((write.mock.calls[0][0] as string).trim());
78+
expect(sent).toEqual({ jsonrpc: '2.0', id: 2, error: { code: -32601, message: 'Method not found' } });
79+
});
80+
81+
it('notify() writes a JSON-RPC notification (no id)', () => {
82+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
83+
new StdioTransport().notify('session/update', { a: 1 });
84+
const sent = JSON.parse((write.mock.calls[0][0] as string).trim());
85+
expect(sent).toEqual({ jsonrpc: '2.0', method: 'session/update', params: { a: 1 } });
86+
expect('id' in sent).toBe(false);
87+
});
88+
});
89+
90+
describe('StdioTransport — outbound request round-trip', () => {
91+
it('sends a request with an incrementing id and resolves on the matching response', async () => {
92+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
93+
const t = new StdioTransport();
94+
const p = t.request('session/request_permission', { foo: 1 });
95+
96+
const sent = JSON.parse((write.mock.calls[0][0] as string).trim());
97+
expect(sent).toMatchObject({ jsonrpc: '2.0', method: 'session/request_permission', params: { foo: 1 } });
98+
expect(typeof sent.id).toBe('number');
99+
100+
feed(t, JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: { ok: true } }) + '\n');
101+
await expect(p).resolves.toEqual({ ok: true });
102+
});
103+
104+
it('resolves to null when the request times out', async () => {
105+
vi.useFakeTimers();
106+
try {
107+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
108+
const p = new StdioTransport().request('m', {});
109+
vi.advanceTimersByTime(30_000);
110+
await expect(p).resolves.toBeNull();
111+
} finally {
112+
vi.useRealTimers();
113+
}
114+
});
115+
});

src/renderer/agentExecution.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,13 @@ export async function executeAgentTask(
405405
}
406406
} else if (result.aborted) {
407407
app.addMessage({ role: 'assistant', content: 'Agent stopped by user.' });
408+
} else if (result.interrupted) {
409+
// Paused at a step/time safety limit — resumable, not a failure. Show the
410+
// agent's partial summary and nudge the user to resume.
411+
if (result.finalResponse) {
412+
app.addMessage({ role: 'assistant', content: result.finalResponse });
413+
}
414+
app.notify('Paused at the safety limit — say "continue" to keep going');
408415
} else {
409416
// Show the agent's summary if available, with error details below
410417
if (result.finalResponse) {

src/renderer/ansi.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
styled,
4+
stripAnsi,
5+
stringWidth,
6+
charWidth,
7+
visibleLength,
8+
truncate,
9+
wordWrap,
10+
gradientText,
11+
style,
12+
fg,
13+
} from './ansi';
14+
15+
// These pure helpers underpin every bit of TUI layout — width math, wrapping,
16+
// truncation. A subtle bug here misaligns the whole renderer, so pin the
17+
// behaviour down.
18+
19+
describe('charWidth', () => {
20+
it('counts ASCII as one column', () => {
21+
expect(charWidth('a')).toBe(1);
22+
expect(charWidth(' ')).toBe(1);
23+
});
24+
25+
it('counts CJK and emoji as two columns', () => {
26+
expect(charWidth('中')).toBe(2); // CJK ideograph
27+
expect(charWidth('あ')).toBe(2); // hiragana
28+
expect(charWidth('😀')).toBe(2); // emoji (astral plane)
29+
});
30+
31+
it('counts control characters as zero', () => {
32+
expect(charWidth('\x00')).toBe(0);
33+
expect(charWidth('\x1b')).toBe(0);
34+
});
35+
});
36+
37+
describe('stringWidth', () => {
38+
it('sums display columns, wide chars included', () => {
39+
expect(stringWidth('hello')).toBe(5);
40+
expect(stringWidth('中文')).toBe(4);
41+
expect(stringWidth('a中')).toBe(3);
42+
expect(stringWidth('')).toBe(0);
43+
});
44+
});
45+
46+
describe('stripAnsi', () => {
47+
it('removes SGR escape sequences, keeps the text', () => {
48+
expect(stripAnsi(fg.red + 'x' + style.reset)).toBe('x');
49+
expect(stripAnsi(style.bold + fg.green + 'hi' + style.reset)).toBe('hi');
50+
});
51+
52+
it('leaves plain text untouched', () => {
53+
expect(stripAnsi('plain')).toBe('plain');
54+
});
55+
});
56+
57+
describe('visibleLength', () => {
58+
it('measures display width ignoring ANSI codes', () => {
59+
expect(visibleLength(fg.green + '中' + style.reset)).toBe(2);
60+
expect(visibleLength(fg.red + 'abc' + style.reset)).toBe(3);
61+
});
62+
});
63+
64+
describe('styled', () => {
65+
it('wraps text in the given codes and a reset', () => {
66+
expect(styled('x', fg.red)).toBe(fg.red + 'x' + style.reset);
67+
expect(styled('x', style.bold, fg.red)).toBe(style.bold + fg.red + 'x' + style.reset);
68+
});
69+
70+
it('returns the text unchanged when no styles are given', () => {
71+
expect(styled('x')).toBe('x');
72+
});
73+
});
74+
75+
describe('truncate', () => {
76+
it('returns the string unchanged when it already fits', () => {
77+
expect(truncate('short', 10)).toBe('short');
78+
});
79+
80+
it('truncates to the target width including the suffix', () => {
81+
const out = truncate('hello world', 8);
82+
expect(stripAnsi(out)).toBe('hello...');
83+
expect(visibleLength(out)).toBe(8);
84+
});
85+
86+
it('never exceeds the requested width', () => {
87+
expect(visibleLength(truncate('a'.repeat(20), 10))).toBe(10);
88+
});
89+
});
90+
91+
describe('wordWrap', () => {
92+
it('keeps a short string on one line', () => {
93+
expect(wordWrap('hello', 10)).toEqual(['hello']);
94+
});
95+
96+
it('wraps long input across lines without losing or reordering words', () => {
97+
const lines = wordWrap('hello world foo bar', 11);
98+
expect(lines.length).toBeGreaterThan(1);
99+
expect(lines.join(' ')).toBe('hello world foo bar');
100+
});
101+
102+
it('returns an empty array for an empty string', () => {
103+
expect(wordWrap('', 10)).toEqual([]);
104+
});
105+
});
106+
107+
describe('gradientText', () => {
108+
it('returns the text unchanged with no colour stops', () => {
109+
expect(gradientText('x', [])).toBe('x');
110+
});
111+
112+
it('only adds colour — the underlying text is preserved', () => {
113+
expect(stripAnsi(gradientText('abc', [[255, 0, 0], [0, 0, 255]]))).toBe('abc');
114+
});
115+
});

src/renderer/highlight.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { highlightCode, SYNTAX } from './highlight';
3+
import { stripAnsi } from './ansi';
4+
5+
// highlightCode only *adds* colour — stripping the ANSI must give back exactly
6+
// the original source (for the line-based tokenizer + HTML). That invariant is
7+
// what keeps copied/selected code intact in the TUI.
8+
9+
describe('highlightCode — text preservation', () => {
10+
const samples: Record<string, string> = {
11+
js: 'const x = 42;\nfunction add(a, b) { return a + b }',
12+
ts: 'let n: number = 1\ninterface Foo { bar: string }',
13+
py: 'def f(x):\n return x # done',
14+
go: 'func main() {\n\tx := 1\n}',
15+
rust: 'fn main() { let mut x = 1; }',
16+
};
17+
for (const [lang, code] of Object.entries(samples)) {
18+
it(`leaves ${lang} source unchanged after stripping ANSI`, () => {
19+
expect(stripAnsi(highlightCode(code, lang))).toBe(code);
20+
});
21+
}
22+
23+
it('preserves HTML text', () => {
24+
expect(stripAnsi(highlightCode('<div>hi</div>', 'html'))).toBe('<div>hi</div>');
25+
});
26+
});
27+
28+
describe('highlightCode — token colouring', () => {
29+
it('colours keywords', () => {
30+
expect(highlightCode('const x', 'js')).toContain(SYNTAX.keyword + 'const');
31+
});
32+
33+
it('colours numbers', () => {
34+
expect(highlightCode('x = 42', 'js')).toContain(SYNTAX.number + '42');
35+
});
36+
37+
it('colours strings', () => {
38+
expect(highlightCode('"hi"', 'js')).toContain(SYNTAX.string + '"hi"');
39+
});
40+
41+
it('colours line comments (and language-specific # comments)', () => {
42+
expect(highlightCode('// note', 'js')).toContain(SYNTAX.comment);
43+
expect(highlightCode('# note', 'py')).toContain(SYNTAX.comment);
44+
});
45+
46+
it('colours a call target as a function', () => {
47+
expect(highlightCode('foo()', 'js')).toContain(SYNTAX.function + 'foo');
48+
});
49+
50+
it('colours a capitalised identifier as a type', () => {
51+
expect(highlightCode('Foo bar', 'js')).toContain(SYNTAX.type + 'Foo');
52+
});
53+
});
54+
55+
describe('highlightCode — language aliases', () => {
56+
it('maps "python" → py keywords', () => {
57+
expect(highlightCode('def f', 'python')).toContain(SYNTAX.keyword + 'def');
58+
});
59+
60+
it('maps "typescript" → ts keywords', () => {
61+
expect(highlightCode('interface X', 'typescript')).toContain(SYNTAX.keyword + 'interface');
62+
});
63+
});

src/renderer/main.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,14 @@ function showSessionPickerInline(): void {
430430
async function main(): Promise<void> {
431431
const args = process.argv.slice(2);
432432

433+
// Headless, deterministic code review for CI (no API key, no TUI). Handled
434+
// before the global --help/--version checks so `codeep review --help` shows
435+
// the review usage rather than the top-level help.
436+
if (args[0] === 'review') {
437+
const { runHeadlessReview } = await import('../utils/headlessReview.js');
438+
process.exit(runHeadlessReview(args.slice(1)));
439+
}
440+
433441
if (args.includes('--version') || args.includes('-v')) {
434442
console.log(`Codeep v${getCurrentVersion()}`);
435443
process.exit(0);
@@ -444,6 +452,7 @@ Usage:
444452
codeep account sync Pull keys + personalities + commands + profile from codeep.dev
445453
codeep account push Push local keys + personalities + commands + profile to codeep.dev
446454
codeep acp Start ACP server (for Zed editor integration)
455+
codeep review Offline code review for CI (--json, --fail-on <level>)
447456
codeep --version Show version
448457
codeep --help Show this help
449458

0 commit comments

Comments
 (0)