codeep 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1050,6 +1050,7 @@ In `dangerous` mode, configure which tools require confirmation via `/settings`:
1050
1050
  | `codeep account` | Link CLI to codeep.dev (GitHub OAuth) |
1051
1051
  | `codeep account sync` | Pull API keys + personalities + commands from codeep.dev → local |
1052
1052
  | `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. |
1053
1054
 
1054
1055
  ### Authentication
1055
1056
 
@@ -154,7 +154,9 @@ export async function runAgentSession(opts) {
154
154
  });
155
155
  // result.finalResponse is already emitted via onChunk streaming above;
156
156
  // only emit it here if nothing was streamed (e.g. non-streaming fallback path)
157
- if (result.finalResponse && chunksEmitted === 0) {
157
+ // — except a paused/interrupted run, whose finalResponse is a fresh "say
158
+ // continue" notice that was never streamed and must always reach the client.
159
+ if (result.finalResponse && (chunksEmitted === 0 || result.interrupted)) {
158
160
  opts.onChunk(result.finalResponse);
159
161
  }
160
162
  // Surface errors as thrown exceptions so the ACP server can handle them correctly.
@@ -343,6 +343,14 @@ export async function executeAgentTask(task, dryRun, ctx) {
343
343
  else if (result.aborted) {
344
344
  app.addMessage({ role: 'assistant', content: 'Agent stopped by user.' });
345
345
  }
346
+ else if (result.interrupted) {
347
+ // Paused at a step/time safety limit — resumable, not a failure. Show the
348
+ // agent's partial summary and nudge the user to resume.
349
+ if (result.finalResponse) {
350
+ app.addMessage({ role: 'assistant', content: result.finalResponse });
351
+ }
352
+ app.notify('Paused at the safety limit — say "continue" to keep going');
353
+ }
346
354
  else {
347
355
  // Show the agent's summary if available, with error details below
348
356
  if (result.finalResponse) {
@@ -375,6 +375,13 @@ function showSessionPickerInline() {
375
375
  // ─── Main ─────────────────────────────────────────────────────────────────────
376
376
  async function main() {
377
377
  const args = process.argv.slice(2);
378
+ // Headless, deterministic code review for CI (no API key, no TUI). Handled
379
+ // before the global --help/--version checks so `codeep review --help` shows
380
+ // the review usage rather than the top-level help.
381
+ if (args[0] === 'review') {
382
+ const { runHeadlessReview } = await import('../utils/headlessReview.js');
383
+ process.exit(runHeadlessReview(args.slice(1)));
384
+ }
378
385
  if (args.includes('--version') || args.includes('-v')) {
379
386
  console.log(`Codeep v${getCurrentVersion()}`);
380
387
  process.exit(0);
@@ -389,6 +396,7 @@ Usage:
389
396
  codeep account sync Pull keys + personalities + commands + profile from codeep.dev
390
397
  codeep account push Push local keys + personalities + commands + profile to codeep.dev
391
398
  codeep acp Start ACP server (for Zed editor integration)
399
+ codeep review Offline code review for CI (--json, --fail-on <level>)
392
400
  codeep --version Show version
393
401
  codeep --help Show this help
394
402
 
@@ -825,6 +833,43 @@ async function gracefulShutdown() {
825
833
  }),
826
834
  ]);
827
835
  }
836
+ // ─── Last-resort crash handlers ───────────────────────────────────────────────
837
+ // Without these, a stray throw or rejected promise (deep in the agent loop or a
838
+ // background cloud sync) crashes Node with the terminal still in raw mode +
839
+ // alternate screen — leaving the user's shell garbled — or vanishes silently.
840
+ process.on('uncaughtException', (error) => {
841
+ logAppError(error instanceof Error ? error : new Error(String(error)), 'uncaughtException');
842
+ // After an uncaught exception the process state is undefined; Node's guidance
843
+ // is to clean up synchronously and exit rather than limp on. Restore the
844
+ // terminal and best-effort save the conversation so the crash doesn't lose it.
845
+ try {
846
+ if (app)
847
+ app.stop();
848
+ }
849
+ catch { /* ignore */ }
850
+ try {
851
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
852
+ }
853
+ catch { /* ignore */ }
854
+ console.error('Fatal error:', error);
855
+ try {
856
+ if (app)
857
+ autoSaveSession(app.getMessages(), projectPath);
858
+ }
859
+ catch { /* ignore */ }
860
+ process.exit(1);
861
+ });
862
+ process.on('unhandledRejection', (reason) => {
863
+ logAppError(reason instanceof Error ? reason : new Error(String(reason)), 'unhandledRejection');
864
+ // A rejected promise is usually recoverable (failed background sync, network
865
+ // blip), so surface it and keep the TUI alive instead of tearing it down.
866
+ // Fall back to stderr if the app isn't up yet.
867
+ const message = reason instanceof Error ? reason.message : String(reason);
868
+ if (app)
869
+ app.notifyWarn(`Background error: ${message}`);
870
+ else
871
+ console.error('Unhandled rejection:', reason);
872
+ });
828
873
  process.on('SIGINT', () => {
829
874
  gracefulShutdown().finally(() => process.exit(0));
830
875
  });
@@ -71,6 +71,8 @@ export interface AgentOptions {
71
71
  /** Role system-prompt addendum injected for a delegated sub-agent. */
72
72
  roleAddendum?: string;
73
73
  }
74
+ /** Why a run stopped early at a safety limit — both are resumable, not errors. */
75
+ export type InterruptKind = 'iteration_limit' | 'time_limit';
74
76
  export interface AgentResult {
75
77
  success: boolean;
76
78
  iterations: number;
@@ -78,7 +80,22 @@ export interface AgentResult {
78
80
  finalResponse: string;
79
81
  error?: string;
80
82
  aborted?: boolean;
83
+ /** Set when the run paused at a step/time safety limit. The caller can offer
84
+ * a "continue" affordance instead of treating it as a failure. */
85
+ interrupted?: InterruptKind;
81
86
  }
87
+ /**
88
+ * Build the result for a run that paused at a safety limit. Pausing is a normal,
89
+ * resumable state — not an error — so the summary tells the user how to resume.
90
+ * Shared by both limit checks in the loop so the wording + `interrupted` signal
91
+ * stay in sync.
92
+ */
93
+ export declare function buildPausedResult(kind: InterruptKind, ctx: {
94
+ iterations: number;
95
+ actions: ActionLog[];
96
+ maxIterations?: number;
97
+ durationMin?: number;
98
+ }): AgentResult;
82
99
  /**
83
100
  * Run the agent loop
84
101
  */
@@ -103,6 +103,32 @@ function compressMessages(messages, actions) {
103
103
  debug(`Context compressed: ${totalChars} chars → keeping first + summary + last ${keep} messages`);
104
104
  return [firstMessage, summaryMessage, ...recentMessages];
105
105
  }
106
+ /**
107
+ * Build the result for a run that paused at a safety limit. Pausing is a normal,
108
+ * resumable state — not an error — so the summary tells the user how to resume.
109
+ * Shared by both limit checks in the loop so the wording + `interrupted` signal
110
+ * stay in sync.
111
+ */
112
+ export function buildPausedResult(kind, ctx) {
113
+ const editedFiles = [...new Set(ctx.actions.filter(a => a.type === 'write' || a.type === 'edit').map(a => a.target))];
114
+ const head = kind === 'time_limit'
115
+ ? `⏸ Paused after the ${ctx.durationMin}-minute time limit.`
116
+ : `⏸ Paused after ${ctx.maxIterations} tool steps (the safety limit).`;
117
+ const lines = [head, '', 'This is a safety limit, not an error — say **continue** to pick up where it left off.'];
118
+ if (editedFiles.length > 0) {
119
+ lines.push('', '**Progress so far — files written/edited:**', ...editedFiles.map(f => ` ✓ \`${f}\``));
120
+ }
121
+ return {
122
+ success: false,
123
+ iterations: ctx.iterations,
124
+ actions: ctx.actions,
125
+ finalResponse: lines.join('\n'),
126
+ error: kind === 'time_limit'
127
+ ? `Exceeded maximum duration of ${ctx.durationMin} min`
128
+ : `Exceeded maximum of ${ctx.maxIterations} iterations`,
129
+ interrupted: kind,
130
+ };
131
+ }
106
132
  const DEFAULT_OPTIONS = {
107
133
  // Modern models (GLM-5.1, Claude 4.5, GPT-4.1) complete typical coding tasks in
108
134
  // 3–8 iterations. The old cap of 100 mostly let broken loops wander for minutes
@@ -452,21 +478,8 @@ export async function runAgent(prompt, projectContext, options = {}) {
452
478
  while (iteration < opts.maxIterations) {
453
479
  // Check timeout
454
480
  if (Date.now() - startTime > opts.maxDuration) {
455
- const filesDone = actions.filter(a => a.type === 'write' || a.type === 'edit').map(a => a.target);
456
481
  const durationMin = Math.round(opts.maxDuration / 60000);
457
- const partialLines = [`Agent reached the time limit (${durationMin} min).`];
458
- if (filesDone.length > 0) {
459
- partialLines.push(`\n**Partial progress — files written/edited:**`);
460
- [...new Set(filesDone)].forEach(f => partialLines.push(` ✓ \`${f}\``));
461
- partialLines.push(`\nYou can continue by running the agent again.`);
462
- }
463
- result = {
464
- success: false,
465
- iterations: iteration,
466
- actions,
467
- finalResponse: partialLines.join('\n'),
468
- error: `Exceeded maximum duration of ${durationMin} min`,
469
- };
482
+ result = buildPausedResult('time_limit', { iterations: iteration, actions, durationMin });
470
483
  if (!opts.nested)
471
484
  writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
472
485
  return result;
@@ -894,20 +907,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
894
907
  }
895
908
  // Check if we hit max iterations — build partial summary from actions log
896
909
  if (iteration >= opts.maxIterations && !finalResponse) {
897
- const filesDone = actions.filter(a => a.type === 'write' || a.type === 'edit').map(a => a.target);
898
- const partialLines = [`Agent reached the iteration limit (${opts.maxIterations} steps).`];
899
- if (filesDone.length > 0) {
900
- partialLines.push(`\n**Partial progress — files written/edited:**`);
901
- [...new Set(filesDone)].forEach(f => partialLines.push(` ✓ \`${f}\``));
902
- partialLines.push(`\nThe task may be incomplete. You can continue by running the agent again.`);
903
- }
904
- result = {
905
- success: false,
906
- iterations: iteration,
907
- actions,
908
- finalResponse: partialLines.join('\n'),
909
- error: `Exceeded maximum of ${opts.maxIterations} iterations`,
910
- };
910
+ result = buildPausedResult('iteration_limit', { iterations: iteration, actions, maxIterations: opts.maxIterations });
911
911
  if (!opts.nested)
912
912
  writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
913
913
  return result;
@@ -68,10 +68,13 @@ export async function runAccountFlow() {
68
68
  if (!res.ok)
69
69
  continue;
70
70
  const data = await res.json();
71
- if (data.status === 'authorized' && data.github_id) {
71
+ // Require the sync token, not just github_id: the server can briefly
72
+ // report authorized after claiming the code but before the token is
73
+ // issued. Linking without it leaves `account sync` broken ("Not linked"),
74
+ // so keep polling until the token is present.
75
+ if (data.status === 'authorized' && data.github_id && data.sync_token) {
72
76
  setGithubAccount(data.github_id, data.username ?? '');
73
- if (data.sync_token)
74
- setSyncToken(data.sync_token);
77
+ setSyncToken(data.sync_token);
75
78
  // Register device info
76
79
  try {
77
80
  await fetch(`${API_BASE}/api/auth/cli/device`, {
@@ -283,7 +283,9 @@ export function createEditDiff(path, oldText, newText, projectRoot) {
283
283
  if (!content.includes(oldText)) {
284
284
  return null;
285
285
  }
286
- const newContent = content.replace(oldText, newText);
286
+ // Literal replacement ($-safe): a plain-string replace would interpret
287
+ // $&, $1, $$ in newText and mis-render the diff.
288
+ const newContent = content.replace(oldText, () => newText);
287
289
  const hunks = generateDiff(content, newContent);
288
290
  return {
289
291
  path,
@@ -0,0 +1,24 @@
1
+ import { ReviewResult } from './codeReview.js';
2
+ export type FailOn = 'error' | 'warning' | 'info' | 'none';
3
+ export interface ReviewArgs {
4
+ files: string[];
5
+ json: boolean;
6
+ failOn: FailOn;
7
+ help: boolean;
8
+ }
9
+ export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
10
+ /** Parse `codeep review` argv (everything after the subcommand). Pure. */
11
+ export declare function parseReviewArgs(argv: string[]): ReviewArgs;
12
+ /** Exit code for a result under a fail-on threshold. Pure. */
13
+ export declare function exitCodeForResult(result: ReviewResult, failOn: FailOn): number;
14
+ export interface ReviewDeps {
15
+ /** Run the review over optional specific files. */
16
+ review: (files?: string[]) => ReviewResult;
17
+ /** Sink for the report (one call). */
18
+ write: (text: string) => void;
19
+ }
20
+ /**
21
+ * Orchestrate a headless review and return the process exit code. Side effects
22
+ * (filesystem, stdout) live behind `deps` so the flow is unit-testable.
23
+ */
24
+ export declare function runHeadlessReview(argv: string[], deps?: ReviewDeps): number;
@@ -0,0 +1,91 @@
1
+ // Headless `codeep review` — a non-interactive entry point around the
2
+ // deterministic reviewer in codeReview.ts. No API key, no TUI: it scans, prints
3
+ // a report (markdown or JSON), and exits non-zero when issues at/above a chosen
4
+ // severity are found, so it drops cleanly into CI (e.g. a GitHub Action).
5
+ import { performCodeReview, formatReviewResult } from './codeReview.js';
6
+ const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
7
+ // Higher = more severe. `suggestion` sits below `info` so `--fail-on info`
8
+ // never trips on a mere suggestion.
9
+ const SEVERITY_RANK = { suggestion: 0, info: 1, warning: 2, error: 3 };
10
+ export const REVIEW_HELP = `Usage: codeep review [options] [files...]
11
+
12
+ Run a deterministic, offline code review (no API key required). With no files,
13
+ reviews your unstaged git changes, falling back to a src/ scan when the tree is
14
+ clean. Pass files (or let your CI pass the PR's changed files) to scope it.
15
+
16
+ Options:
17
+ --json Print the result as JSON instead of the markdown report
18
+ --fail-on <level> Exit non-zero when an issue at or above <level> is found:
19
+ error | warning | info | none (default: error)
20
+ -h, --help Show this help
21
+
22
+ Exit code: 0 when nothing at/above --fail-on is found, 1 otherwise.`;
23
+ /** Parse `codeep review` argv (everything after the subcommand). Pure. */
24
+ export function parseReviewArgs(argv) {
25
+ const out = { files: [], json: false, failOn: 'error', help: false };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const arg = argv[i];
28
+ if (arg === '--json') {
29
+ out.json = true;
30
+ }
31
+ else if (arg === '-h' || arg === '--help') {
32
+ out.help = true;
33
+ }
34
+ else if (arg === '--fail-on') {
35
+ const v = argv[++i];
36
+ if (FAIL_ON_VALUES.includes(v))
37
+ out.failOn = v;
38
+ }
39
+ else if (arg.startsWith('--fail-on=')) {
40
+ const v = arg.slice('--fail-on='.length);
41
+ if (FAIL_ON_VALUES.includes(v))
42
+ out.failOn = v;
43
+ }
44
+ else if (!arg.startsWith('-')) {
45
+ out.files.push(arg);
46
+ }
47
+ // Unknown flags are ignored so a future flag doesn't hard-fail old clients.
48
+ }
49
+ return out;
50
+ }
51
+ /** Exit code for a result under a fail-on threshold. Pure. */
52
+ export function exitCodeForResult(result, failOn) {
53
+ if (failOn === 'none')
54
+ return 0;
55
+ const threshold = SEVERITY_RANK[failOn];
56
+ const tripped = result.issues.some((i) => (SEVERITY_RANK[i.severity] ?? 0) >= threshold);
57
+ return tripped ? 1 : 0;
58
+ }
59
+ /**
60
+ * Orchestrate a headless review and return the process exit code. Side effects
61
+ * (filesystem, stdout) live behind `deps` so the flow is unit-testable.
62
+ */
63
+ export function runHeadlessReview(argv, deps = defaultDeps()) {
64
+ const args = parseReviewArgs(argv);
65
+ if (args.help) {
66
+ deps.write(REVIEW_HELP);
67
+ return 0;
68
+ }
69
+ const result = deps.review(args.files.length ? args.files : undefined);
70
+ deps.write(args.json ? JSON.stringify(result, null, 2) : formatReviewResult(result));
71
+ return exitCodeForResult(result, args.failOn);
72
+ }
73
+ // Only the reviewer's `.root` is read, so a minimal context rooted at cwd is
74
+ // enough — no need for a full (slower) project scan just to lint.
75
+ function minimalContext(root) {
76
+ return {
77
+ root,
78
+ name: root.split('/').pop() ?? 'workspace',
79
+ type: 'Unknown',
80
+ structure: '',
81
+ keyFiles: [],
82
+ fileCount: 0,
83
+ summary: `Workspace at ${root}`,
84
+ };
85
+ }
86
+ function defaultDeps() {
87
+ return {
88
+ review: (files) => performCodeReview(minimalContext(process.cwd()), files),
89
+ write: (text) => process.stdout.write(text + '\n'),
90
+ };
91
+ }
@@ -741,7 +741,11 @@ function sanitizeForShell(text) {
741
741
  export function interpolateParams(content, params) {
742
742
  let result = content;
743
743
  for (const [key, value] of Object.entries(params)) {
744
- result = result.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value);
744
+ // Literal find/replace-all via split+join. Avoids two bugs of the old
745
+ // regex form: a regex-meta char in `key` (e.g. `.`, `(`) breaking the match
746
+ // or throwing, and `$`-sequences in `value` ($&, $$, $1) being interpreted
747
+ // as replacement patterns and corrupting the output.
748
+ result = result.split('${' + key + '}').join(value);
745
749
  }
746
750
  return result;
747
751
  }
@@ -456,7 +456,10 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
456
456
  return { success: false, output: '', error: `old_text matches ${matchCount} locations in the file. Provide more surrounding context to make it unique (only 1 match allowed).`, tool, parameters };
457
457
  }
458
458
  recordEdit(validation.absolutePath);
459
- const updated = content.replace(oldText, newText);
459
+ // Function replacer so newText is written literally — a plain-string
460
+ // replacement interprets $&, $1, $$ etc., which silently corrupts any
461
+ // edit whose new_text contains `$` (shell vars, template literals, regex).
462
+ const updated = content.replace(oldText, () => newText);
460
463
  if (fs?.writeTextFile) {
461
464
  try {
462
465
  await fs.writeTextFile(validation.absolutePath, updated);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",