codeep 3.3.3 → 3.4.1

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.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -14,7 +14,38 @@ export declare function parseHookArgs(argv: string[]): HookArgs;
14
14
  export declare function buildHookScript(hookType: HookType, failOn: FailOn): string;
15
15
  /** True when a hook file was created by Codeep (safe to overwrite/remove). */
16
16
  export declare function isCodeepHook(content: string): boolean;
17
- /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
17
+ /**
18
+ * Where this repository keeps its hooks — as a three-way answer, because two
19
+ * of the three used to come back as the same `null`.
20
+ *
21
+ * `none` means git answered and there is no hook directory to speak of (not a
22
+ * repository, no git on PATH). `unknown` means git was REFUSED: hardenedGitEnv
23
+ * would not build an environment for this repository, so nobody can say where
24
+ * its hooks live or whether a path is one.
25
+ *
26
+ * Folding `unknown` into `none` is a fail-OPEN, and it is a live hole rather
27
+ * than a theoretical one: the write gate in utils/toolExecution.ts reads a
28
+ * null hook directory as "this repository has no hook directory" and stops
29
+ * gating, so the one repository whose config Codeep refuses to scan is
30
+ * exactly the one whose `.githooks/pre-commit` an agent could write
31
+ * unprompted. A caller that has to decide something must branch on `kind`.
32
+ */
33
+ export type HooksDirResult = {
34
+ kind: 'hooks';
35
+ dir: string;
36
+ } | {
37
+ kind: 'none';
38
+ } | {
39
+ kind: 'unknown';
40
+ reason: string;
41
+ };
42
+ export declare function resolveHooksDirResult(cwd: string): HooksDirResult;
43
+ /**
44
+ * Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if
45
+ * not a repo — and THROWS `GitHardeningError` when git was refused, so a
46
+ * refusal can never be mistaken for "no hooks here". Callers that must not
47
+ * throw (the write gate) use resolveHooksDirResult() above instead.
48
+ */
18
49
  export declare function resolveHooksDir(cwd: string): string | null;
19
50
  export interface HookDeps {
20
51
  resolveHooksDir: (cwd: string) => string | null;
@@ -7,6 +7,7 @@
7
7
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs';
8
8
  import { join, dirname, isAbsolute } from 'path';
9
9
  import { execSync } from 'child_process';
10
+ import { hardenedGitEnv, GitHardeningError } from './git.js';
10
11
  const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
11
12
  const MARKER_START = '# >>> codeep hook >>>';
12
13
  const MARKER_END = '# <<< codeep hook <<<';
@@ -91,16 +92,71 @@ export function buildHookScript(hookType, failOn) {
91
92
  export function isCodeepHook(content) {
92
93
  return content.includes(MARKER_START);
93
94
  }
94
- /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
95
- export function resolveHooksDir(cwd) {
95
+ export function resolveHooksDirResult(cwd) {
96
+ let env;
96
97
  try {
97
- execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' });
98
- const hooks = execSync('git rev-parse --git-path hooks', { cwd, encoding: 'utf8' }).trim();
99
- return isAbsolute(hooks) ? hooks : join(cwd, hooks);
98
+ // noHooks stays off: answering where the hooks live is this function's
99
+ // entire job, and an override would have git report the no-hooks path and
100
+ // send `codeep hook install` there.
101
+ env = hardenedGitEnv({ cwd });
102
+ }
103
+ catch (error) {
104
+ if (error instanceof GitHardeningError)
105
+ return { kind: 'unknown', reason: error.message };
106
+ throw error;
100
107
  }
101
- catch {
102
- return null;
108
+ try {
109
+ // stderr is piped rather than ignored so the catch below can tell git's
110
+ // own answer apart from everything else. Ignoring it is what made the two
111
+ // indistinguishable in the first place.
112
+ execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: ['ignore', 'ignore', 'pipe'], env });
113
+ const hooks = execSync('git rev-parse --git-path hooks', {
114
+ cwd,
115
+ encoding: 'utf8',
116
+ stdio: ['ignore', 'pipe', 'pipe'],
117
+ env,
118
+ }).trim();
119
+ return { kind: 'hooks', dir: isAbsolute(hooks) ? hooks : join(cwd, hooks) };
103
120
  }
121
+ catch (error) {
122
+ // Only git's own "there is no repository here" is `none`. The bare
123
+ // `catch { kind: 'none' }` this replaces folded a timeout, an EACCES on
124
+ // `cwd` and a git that died mid-answer into the same result — and `none`
125
+ // is what the write gate in utils/toolExecution.ts reads as "this
126
+ // repository has no hook directory, stop gating", so every one of those
127
+ // failures turned the gate off. `unknown` keeps it on, which is the only
128
+ // safe way round to be wrong.
129
+ const err = error;
130
+ const stderr = String(err.stderr ?? '');
131
+ // git's own answer for "there is no repository here". A BARE repository
132
+ // is deliberately not in this branch: `--is-inside-work-tree` prints
133
+ // `false` and exits 0 there (verified, git 2.54), so it comes back as a
134
+ // hooks directory, which is what it has.
135
+ if (/not a git repository/i.test(stderr))
136
+ return { kind: 'none' };
137
+ // git never started: not on PATH, or `cwd` is gone. The caller's own git
138
+ // calls cannot run either, so there is no hook directory to speak of.
139
+ if (err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'ENOTDIR')
140
+ return { kind: 'none' };
141
+ const detail = stderr.trim() || err.code || `exit ${err.status ?? '?'}`;
142
+ return {
143
+ kind: 'unknown',
144
+ reason: `Cannot tell where ${cwd} keeps its git hooks: git failed to answer (${detail}). ` +
145
+ 'Codeep treats that as "there may be hooks here" rather than as "there are none".',
146
+ };
147
+ }
148
+ }
149
+ /**
150
+ * Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if
151
+ * not a repo — and THROWS `GitHardeningError` when git was refused, so a
152
+ * refusal can never be mistaken for "no hooks here". Callers that must not
153
+ * throw (the write gate) use resolveHooksDirResult() above instead.
154
+ */
155
+ export function resolveHooksDir(cwd) {
156
+ const result = resolveHooksDirResult(cwd);
157
+ if (result.kind === 'unknown')
158
+ throw new GitHardeningError(result.reason);
159
+ return result.kind === 'hooks' ? result.dir : null;
104
160
  }
105
161
  export function runHookCommand(argv, deps = defaultHookDeps()) {
106
162
  const args = parseHookArgs(argv);
@@ -108,7 +164,19 @@ export function runHookCommand(argv, deps = defaultHookDeps()) {
108
164
  deps.write(HOOK_HELP);
109
165
  return 0;
110
166
  }
111
- const hooksDir = deps.resolveHooksDir(process.cwd());
167
+ // `resolveHooksDir` throws rather than answer null when git was refused, so
168
+ // the two failures get the two different messages they need: "you are not in
169
+ // a repo" is not a useful thing to tell someone whose `.git/config` names a
170
+ // program Codeep will not run through.
171
+ let hooksDir;
172
+ try {
173
+ hooksDir = deps.resolveHooksDir(process.cwd());
174
+ }
175
+ catch (error) {
176
+ deps.write(`${error instanceof Error ? error.message : String(error)}\n` +
177
+ 'Fix that config (or run the hook installer yourself) and try again.');
178
+ return 1;
179
+ }
112
180
  if (!hooksDir) {
113
181
  deps.write('Not a git repository — run `codeep hook` inside a repo.');
114
182
  return 1;
@@ -21,4 +21,12 @@ export declare function loadIgnoreRules(projectRoot: string): IgnoreRules;
21
21
  * @returns true if the path should be ignored
22
22
  */
23
23
  export declare function isIgnored(filePath: string, rules: IgnoreRules): boolean;
24
+ /**
25
+ * The rules that still apply beneath a directory the user named on purpose
26
+ * (`@dir dist`). Drops every pattern that ignores the directory itself
27
+ * (`dist/`, `/dist`) or all of its children at once (`dist/*`, `dist/**`),
28
+ * and keeps the rest (`*.log`, `secrets.json`, negations), so naming a
29
+ * directory never hides its whole contents.
30
+ */
31
+ export declare function rulesBelow(dir: string, rules: IgnoreRules): IgnoreRules;
24
32
  export {};
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * .gitignore parser — loads ignore patterns and tests file paths against them.
3
3
  */
4
- import { existsSync, readFileSync } from 'fs';
4
+ import { existsSync, readFileSync, statSync } from 'fs';
5
5
  import { join, relative, sep } from 'path';
6
6
  /**
7
7
  * Always-ignored directories (even without a .gitignore)
@@ -26,6 +26,8 @@ const BUILTIN_IGNORES = [
26
26
  'out',
27
27
  '.output',
28
28
  ];
29
+ /** Largest .gitignore we'll parse (1 MB). */
30
+ const MAX_GITIGNORE_BYTES = 1024 * 1024;
29
31
  /**
30
32
  * Load .gitignore rules from a project root.
31
33
  * Falls back to built-in ignores if no .gitignore exists.
@@ -40,9 +42,16 @@ export function loadIgnoreRules(projectRoot) {
40
42
  const gitignorePath = join(projectRoot, '.gitignore');
41
43
  if (existsSync(gitignorePath)) {
42
44
  try {
43
- const content = readFileSync(gitignorePath, 'utf-8');
44
- const parsed = parseGitignore(content);
45
- patterns.push(...parsed);
45
+ // statSync follows symlinks: a cloned repo can commit `.gitignore ->
46
+ // /dev/zero` (or a FIFO), and readFileSync on either never returns.
47
+ // Every agent run and every @dir walk loads these rules, so check the
48
+ // kind and size before reading.
49
+ const stat = statSync(gitignorePath);
50
+ if (stat.isFile() && stat.size <= MAX_GITIGNORE_BYTES) {
51
+ const content = readFileSync(gitignorePath, 'utf-8');
52
+ const parsed = parseGitignore(content);
53
+ patterns.push(...parsed);
54
+ }
46
55
  }
47
56
  catch {
48
57
  // Ignore read errors
@@ -57,12 +66,7 @@ export function loadIgnoreRules(projectRoot) {
57
66
  * @returns true if the path should be ignored
58
67
  */
59
68
  export function isIgnored(filePath, rules) {
60
- // Normalize to forward-slash relative path
61
- let rel = filePath;
62
- if (filePath.startsWith(rules.projectRoot)) {
63
- rel = relative(rules.projectRoot, filePath);
64
- }
65
- rel = rel.split(sep).join('/');
69
+ const rel = toRulePath(filePath, rules);
66
70
  // Empty path is never ignored
67
71
  if (!rel)
68
72
  return false;
@@ -74,6 +78,33 @@ export function isIgnored(filePath, rules) {
74
78
  }
75
79
  return ignored;
76
80
  }
81
+ /**
82
+ * The rules that still apply beneath a directory the user named on purpose
83
+ * (`@dir dist`). Drops every pattern that ignores the directory itself
84
+ * (`dist/`, `/dist`) or all of its children at once (`dist/*`, `dist/**`),
85
+ * and keeps the rest (`*.log`, `secrets.json`, negations), so naming a
86
+ * directory never hides its whole contents.
87
+ */
88
+ export function rulesBelow(dir, rules) {
89
+ const rel = toRulePath(dir, rules);
90
+ if (!rel)
91
+ return rules;
92
+ // No real file name holds a NUL, so a pattern matches this child only when
93
+ // it matches every child.
94
+ const anyChild = `${rel}/\0`;
95
+ return {
96
+ projectRoot: rules.projectRoot,
97
+ patterns: rules.patterns.filter((p) => p.negated || !(p.regex.test(rel) || p.regex.test(anyChild))),
98
+ };
99
+ }
100
+ /** Normalize to the forward-slash, root-relative form the patterns expect. */
101
+ function toRulePath(filePath, rules) {
102
+ let rel = filePath;
103
+ if (filePath.startsWith(rules.projectRoot)) {
104
+ rel = relative(rules.projectRoot, filePath);
105
+ }
106
+ return rel.split(sep).join('/');
107
+ }
77
108
  /**
78
109
  * Parse .gitignore content into patterns.
79
110
  */
@@ -1,5 +1,6 @@
1
1
  import { type FixPlan } from './reviewFix.js';
2
2
  import { ReviewResult } from './codeReview.js';
3
+ import { ProjectContext } from './project.js';
3
4
  export type FailOn = 'error' | 'warning' | 'info' | 'none';
4
5
  export interface ReviewArgs {
5
6
  files: string[];
@@ -44,3 +45,13 @@ export interface ReviewDeps {
44
45
  * unit-testable. The exit code is ALWAYS deterministic — `--ai` is advisory.
45
46
  */
46
47
  export declare function runHeadlessReview(argv: string[], deps?: ReviewDeps): Promise<number>;
48
+ /**
49
+ * Run a fix plan through the agent.
50
+ *
51
+ * The plan's personality is passed as the active one, so the same enforcement
52
+ * any custom bot gets applies here: the model is offered `files` and `tests`
53
+ * and nothing else. It edits the working tree and stops — branching, committing
54
+ * and opening a pull request belong to whatever called this, which in CI is the
55
+ * action that holds the token.
56
+ */
57
+ export declare function runFixPlan(plan: FixPlan, context: ProjectContext): Promise<string | null>;
@@ -212,7 +212,7 @@ function defaultDeps() {
212
212
  * and opening a pull request belong to whatever called this, which in CI is the
213
213
  * action that holds the token.
214
214
  */
215
- async function runFixPlan(plan, context) {
215
+ export async function runFixPlan(plan, context) {
216
216
  try {
217
217
  // Populate the key cache before anything asks for it. `getApiKey` is
218
218
  // synchronous and reads the cache alone — it does not consult the
@@ -227,6 +227,7 @@ async function runFixPlan(plan, context) {
227
227
  return `${summariseFixPlan(plan)} No API key is configured for the current provider, so the fix agent could not start.`;
228
228
  }
229
229
  const { runAgent } = await import('./agent.js');
230
+ const { NO_CONFIRMER_REFUSAL } = await import('./toolExecution.js');
230
231
  // No cast here. `as never` on this call once hid the fact that
231
232
  // personalityOverride did not exist, which would have run the CI fix with
232
233
  // no boundary at all while the tests happily asserted otherwise.
@@ -239,17 +240,44 @@ async function runFixPlan(plan, context) {
239
240
  // the plan, which buildFixPlan caps, and the action's wall-clock.
240
241
  maxIterations: 25,
241
242
  });
243
+ // `result === 'success'` matters: a write the agent ATTEMPTED is logged
244
+ // whether or not it happened, so a refused `.git/config` was being
245
+ // reported to CI as a file this run edited — the exact opposite of the
246
+ // truth, and the file is not in the diff for anyone to check against.
242
247
  const edited = new Set(result.actions
243
- .filter(a => a.type === 'write' || a.type === 'edit')
248
+ .filter(a => (a.type === 'write' || a.type === 'edit') && a.result === 'success')
244
249
  .map(a => a.target));
245
250
  const activity = describeAgentActivity(result.actions);
251
+ // A headless run passes no permission callback, so a write to a file that
252
+ // decides what runs later — `.git/config`, a hook, an MCP server list —
253
+ // is refused rather than done unasked. That is the right answer for CI:
254
+ // an unattended run must not be the thing that installs a hook. But the
255
+ // refusal only ever reached the model, so the run looked like it had
256
+ // simply chosen not to fix that finding, and the one action a human has
257
+ // to take was in nobody's log. Name it in the summary, which is what the
258
+ // action prints. An opt-in flag was the alternative and is worse: it
259
+ // would exist to be set in a YAML file once and then never read again.
260
+ const refused = [...new Set(result.actions
261
+ .filter(a => a.result === 'error' && a.details?.includes(NO_CONFIRMER_REFUSAL))
262
+ .map(a => a.target))];
263
+ const refusalNote = refused.length
264
+ ? ` It was refused ${refused.length} write${refused.length === 1 ? '' : 's'} to ${refused.join(', ')}: ` +
265
+ 'those files decide what runs later, and a headless run has nobody to confirm them. Edit them yourself.'
266
+ : '';
267
+ const editedList = `Edited ${edited.size} file${edited.size === 1 ? '' : 's'}: ${[...edited].join(', ')}.`;
246
268
  if (!result.success) {
247
- return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}. ${activity}`;
269
+ // The run did its work and the checks after it failed. The edits are in
270
+ // the working tree all the same, so name them next to the checks.
271
+ if (result.failedChecks?.length) {
272
+ const changed = edited.size > 0 ? editedList : activity;
273
+ return `${summariseFixPlan(plan)} ${changed}${refusalNote} These checks still fail afterwards: ${result.failedChecks.join(', ')}.`;
274
+ }
275
+ return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}. ${activity}${refusalNote}`;
248
276
  }
249
277
  if (edited.size === 0) {
250
- return `${summariseFixPlan(plan)} Nothing was changed. ${activity}`;
278
+ return `${summariseFixPlan(plan)} Nothing was changed. ${activity}${refusalNote}`;
251
279
  }
252
- return `${summariseFixPlan(plan)} Edited ${edited.size} file${edited.size === 1 ? '' : 's'}: ${[...edited].join(', ')}.`;
280
+ return `${summariseFixPlan(plan)} ${editedList}${refusalNote}`;
253
281
  }
254
282
  catch (error) {
255
283
  // A missing key or an unreachable provider must not fail the review. The
@@ -10,6 +10,9 @@ export interface ActionRecord {
10
10
  previousExisted?: boolean;
11
11
  wasDirectory?: boolean;
12
12
  deletedContent?: string;
13
+ /** Hash of what the agent left in the file (write/edit). Undo only puts
14
+ * the old content back while the file still holds exactly that. */
15
+ resultHash?: string;
13
16
  command?: string;
14
17
  args?: string[];
15
18
  undone?: boolean;
@@ -51,13 +54,25 @@ export declare function recordMkdir(path: string): ActionRecord | null;
51
54
  */
52
55
  export declare function recordCommand(command: string, args: string[]): ActionRecord | null;
53
56
  /**
54
- * Get current session
57
+ * Note what a write or edit left in the file, once it happened.
55
58
  */
56
- export declare function getCurrentSession(): ActionSession | null;
59
+ export declare function recordResult(record: ActionRecord | null | undefined, content: string): void;
57
60
  /**
58
- * Undo the last action in current session
61
+ * Drop a record whose change never happened (the write failed or the
62
+ * editor refused it). Left in place, undo would write its saved content
63
+ * over whatever the file holds by then.
59
64
  */
60
- export declare function undoLastAction(): {
65
+ export declare function discardAction(record: ActionRecord | null | undefined): void;
66
+ /**
67
+ * Get the run in progress, or else the last finished run that changed
68
+ * something. Pass the workspace to leave out runs from another one.
69
+ */
70
+ export declare function getCurrentSession(projectRoot?: string): ActionSession | null;
71
+ /**
72
+ * Undo the most recent file change of the run undo acts on. Pass the
73
+ * workspace the user is in so a run in another one is left alone.
74
+ */
75
+ export declare function undoLastAction(projectRoot?: string): {
61
76
  success: boolean;
62
77
  message: string;
63
78
  };
@@ -69,9 +84,10 @@ export declare function undoAction(action: ActionRecord): {
69
84
  message: string;
70
85
  };
71
86
  /**
72
- * Undo all actions in current session
87
+ * Undo every action of the run undo acts on. Pass the workspace the user is
88
+ * in so a run in another one is left alone.
73
89
  */
74
- export declare function undoAllActions(): {
90
+ export declare function undoAllActions(projectRoot?: string): {
75
91
  success: boolean;
76
92
  results: string[];
77
93
  };
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Agent action history for undo/rollback functionality
3
3
  */
4
- import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync, statSync, readdirSync } from 'fs';
5
- import { dirname, join } from 'path';
4
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmdirSync, statSync, readdirSync } from 'fs';
5
+ import { dirname, join, resolve } from 'path';
6
6
  import { homedir } from 'os';
7
+ import { createHash } from 'crypto';
7
8
  // In-memory current session
8
9
  let currentSession = null;
10
+ // The last finished run that changed something. Every run ends before the
11
+ // user can type /undo, so undo has to reach back to it.
12
+ let lastSession = null;
9
13
  // History storage path
10
14
  const HISTORY_DIR = join(homedir(), '.codeep', 'history');
11
15
  /**
@@ -16,6 +20,47 @@ function ensureHistoryDir() {
16
20
  mkdirSync(HISTORY_DIR, { recursive: true });
17
21
  }
18
22
  }
23
+ function writeSessionFile(session) {
24
+ ensureHistoryDir();
25
+ writeFileSync(join(HISTORY_DIR, `${session.id}.json`), JSON.stringify(session, null, 2));
26
+ }
27
+ /** Whether a run recorded anything undo can put back. Commands never can. */
28
+ function hasFileActions(session) {
29
+ return session.actions.some(a => a.type !== 'command');
30
+ }
31
+ /**
32
+ * The session undo and the change listings act on: the run in progress, or
33
+ * else the last finished run that changed something.
34
+ *
35
+ * A run in progress that has not touched a file yet does not hide the
36
+ * finished one: /undo typed while a run is still winding down would
37
+ * otherwise find nothing. `projectRoot` keeps one workspace's /undo away
38
+ * from a run in another; without it any run is in reach.
39
+ */
40
+ function undoableSession(projectRoot) {
41
+ const inScope = (session) => session && (projectRoot === undefined || resolve(session.projectRoot) === resolve(projectRoot))
42
+ ? session
43
+ : null;
44
+ const current = inScope(currentSession);
45
+ if (current && hasFileActions(current))
46
+ return current;
47
+ return inScope(lastSession) ?? current;
48
+ }
49
+ /**
50
+ * Keep a finished run's saved record in step with what has been undone.
51
+ * The run in progress is written when it ends.
52
+ */
53
+ function saveUndone(session) {
54
+ if (session === currentSession)
55
+ return;
56
+ try {
57
+ writeSessionFile(session);
58
+ }
59
+ catch {
60
+ // The files are already restored. A record that could not be updated
61
+ // must not turn that into a reported failure.
62
+ }
63
+ }
19
64
  /**
20
65
  * Generate unique ID
21
66
  */
@@ -45,10 +90,12 @@ export function endSession() {
45
90
  currentSession.endTime = Date.now();
46
91
  // Only save if there were actions
47
92
  if (currentSession.actions.length > 0) {
48
- ensureHistoryDir();
49
- const filename = `${currentSession.id}.json`;
50
- const filepath = join(HISTORY_DIR, filename);
51
- writeFileSync(filepath, JSON.stringify(currentSession, null, 2));
93
+ writeSessionFile(currentSession);
94
+ }
95
+ // Only a run that changed a file replaces the one to undo: a read-only or
96
+ // command-only follow-up must not put the previous edits out of reach.
97
+ if (hasFileActions(currentSession)) {
98
+ lastSession = currentSession;
52
99
  }
53
100
  currentSession = null;
54
101
  }
@@ -160,31 +207,90 @@ export function recordCommand(command, args) {
160
207
  currentSession.actions.push(record);
161
208
  return record;
162
209
  }
210
+ function contentHash(content) {
211
+ return createHash('sha256').update(content).digest('hex');
212
+ }
213
+ /**
214
+ * Note what a write or edit left in the file, once it happened.
215
+ */
216
+ export function recordResult(record, content) {
217
+ if (record)
218
+ record.resultHash = contentHash(content);
219
+ }
220
+ /**
221
+ * Why putting a file back would lose someone else's change, or null. The
222
+ * user (or a later command) may have edited the file since the run; undo
223
+ * must not overwrite that, or delete a file that has become theirs.
224
+ */
225
+ function changedSince(action) {
226
+ const path = action.path;
227
+ if ((action.type === 'write' || action.type === 'edit') && action.resultHash !== undefined && existsSync(path)) {
228
+ let now = null;
229
+ try {
230
+ now = readFileSync(path, 'utf-8');
231
+ }
232
+ catch { /* unreadable: treat as changed */ }
233
+ if (now === null || contentHash(now) !== action.resultHash) {
234
+ return `Not undone: ${path} has changed since the agent wrote it. Use git to restore it if you need to.`;
235
+ }
236
+ }
237
+ if (action.type === 'delete' && !action.wasDirectory && existsSync(path)) {
238
+ return `Not undone: ${path} exists again, and restoring the deleted copy would overwrite it.`;
239
+ }
240
+ return null;
241
+ }
242
+ /**
243
+ * Drop a record whose change never happened (the write failed or the
244
+ * editor refused it). Left in place, undo would write its saved content
245
+ * over whatever the file holds by then.
246
+ */
247
+ export function discardAction(record) {
248
+ if (!record || !currentSession)
249
+ return;
250
+ const i = currentSession.actions.indexOf(record);
251
+ if (i !== -1)
252
+ currentSession.actions.splice(i, 1);
253
+ }
163
254
  /**
164
- * Get current session
255
+ * Get the run in progress, or else the last finished run that changed
256
+ * something. Pass the workspace to leave out runs from another one.
165
257
  */
166
- export function getCurrentSession() {
167
- return currentSession;
258
+ export function getCurrentSession(projectRoot) {
259
+ return undoableSession(projectRoot);
168
260
  }
169
261
  /**
170
- * Undo the last action in current session
262
+ * Undo the most recent file change of the run undo acts on. Pass the
263
+ * workspace the user is in so a run in another one is left alone.
171
264
  */
172
- export function undoLastAction() {
173
- if (!currentSession || currentSession.actions.length === 0) {
265
+ export function undoLastAction(projectRoot) {
266
+ const session = undoableSession(projectRoot);
267
+ if (!session || session.actions.length === 0) {
174
268
  return { success: false, message: 'No actions to undo' };
175
269
  }
176
- // Find last non-undone action
177
- const action = [...currentSession.actions].reverse().find(a => !a.undone);
270
+ // Commands cannot be undone, so they are passed over: a run that edits and
271
+ // then runs the tests must still have its edits undoable.
272
+ const pending = [...session.actions].reverse().filter(a => !a.undone);
273
+ const action = pending.find(a => a.type !== 'command');
178
274
  if (!action) {
179
- return { success: false, message: 'All actions already undone' };
275
+ // Only commands are left. Once the file changes are undone that is
276
+ // "all undone"; a run that only ran commands says why nothing happens.
277
+ return hasFileActions(session) || pending.length === 0
278
+ ? { success: false, message: 'All actions already undone' }
279
+ : undoAction(pending[0]);
180
280
  }
181
- return undoAction(action);
281
+ const result = undoAction(action);
282
+ if (result.success)
283
+ saveUndone(session);
284
+ return result;
182
285
  }
183
286
  /**
184
287
  * Undo a specific action
185
288
  */
186
289
  export function undoAction(action) {
187
290
  try {
291
+ const conflict = action.path ? changedSince(action) : null;
292
+ if (conflict)
293
+ return { success: false, message: conflict };
188
294
  switch (action.type) {
189
295
  case 'write':
190
296
  if (action.previousExisted && action.previousContent !== undefined) {
@@ -226,9 +332,9 @@ export function undoAction(action) {
226
332
  break;
227
333
  case 'mkdir':
228
334
  if (!action.previousExisted && existsSync(action.path)) {
229
- // Only remove if empty
335
+ // Only remove if empty (rmdirSync refuses anything else)
230
336
  try {
231
- rmSync(action.path, { recursive: false });
337
+ rmdirSync(action.path);
232
338
  action.undone = true;
233
339
  return { success: true, message: `Removed directory: ${action.path}` };
234
340
  }
@@ -248,25 +354,31 @@ export function undoAction(action) {
248
354
  }
249
355
  }
250
356
  /**
251
- * Undo all actions in current session
357
+ * Undo every action of the run undo acts on. Pass the workspace the user is
358
+ * in so a run in another one is left alone.
252
359
  */
253
- export function undoAllActions() {
254
- if (!currentSession || currentSession.actions.length === 0) {
360
+ export function undoAllActions(projectRoot) {
361
+ const session = undoableSession(projectRoot);
362
+ if (!session || session.actions.length === 0) {
255
363
  return { success: false, results: ['No actions to undo'] };
256
364
  }
257
365
  const results = [];
258
- let allSuccess = true;
366
+ let restored = 0;
259
367
  // Undo in reverse order
260
- const actions = [...currentSession.actions].reverse();
368
+ const actions = [...session.actions].reverse();
261
369
  for (const action of actions) {
262
370
  if (action.undone)
263
371
  continue;
264
372
  const result = undoAction(action);
265
373
  results.push(result.message);
266
- if (!result.success)
267
- allSuccess = false;
374
+ if (result.success)
375
+ restored++;
268
376
  }
269
- return { success: allSuccess, results };
377
+ if (restored > 0)
378
+ saveUndone(session);
379
+ // Success means something was put back. A command in the run can never be
380
+ // undone, and must not make restored files read as "Nothing to undo".
381
+ return { success: restored > 0, results };
270
382
  }
271
383
  /**
272
384
  * Get list of recent sessions
@@ -335,6 +447,8 @@ export function formatSession(session) {
335
447
  * Clear all history
336
448
  */
337
449
  export function clearHistory() {
450
+ // The finished run's record goes with the files.
451
+ lastSession = null;
338
452
  ensureHistoryDir();
339
453
  try {
340
454
  const files = readdirSync(HISTORY_DIR);