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
@@ -291,8 +291,16 @@ export declare function resolveBaseUrl(providerId: string, protocol: 'openai' |
291
291
  export { PROVIDERS } from './providers';
292
292
  export declare function getCurrentSessionId(): string;
293
293
  export declare function startNewSession(): string;
294
- export declare function autoSaveSession(history: Message[], projectPath?: string): boolean;
294
+ export declare function autoSaveSession(history: Message[], projectPath?: string, sessionId?: string): boolean;
295
295
  export declare function flushAutoSave(): boolean;
296
+ /**
297
+ * Why a session name cannot be used, or null when it can. A session is kept
298
+ * as <name>.json inside the sessions directory, so a name holding a path
299
+ * separator (`../../x`, `feature/auth`) would read or write a file elsewhere.
300
+ * A backslash is a separator only on Windows; elsewhere it is an ordinary
301
+ * filename character, and sessions named with one already exist.
302
+ */
303
+ export declare function sessionNameProblem(name: string): string | null;
296
304
  export declare function saveSession(name: string, history: Message[], projectPath?: string): boolean;
297
305
  /**
298
306
  * Generate and persist an AI title for a session, if it doesn't have
@@ -303,6 +311,13 @@ export declare function maybeGenerateSessionTitle(name: string, projectPath?: st
303
311
  export declare function loadSession(name: string, projectPath?: string): Message[] | null;
304
312
  export declare function listSessions(projectPath?: string): string[];
305
313
  export declare function deleteSession(name: string, projectPath?: string): boolean;
314
+ /**
315
+ * True when `name` already belongs to a saved conversation other than
316
+ * `currentName`'s. A name differing only in case can resolve to the same file
317
+ * on a case-insensitive disk; that is the same conversation, not a clash.
318
+ * Inodes are compared as bigints: on Windows they can exceed 2^53.
319
+ */
320
+ export declare function sessionNameTaken(name: string, currentName: string, projectPath?: string): boolean;
306
321
  export declare function renameSession(oldName: string, newName: string, projectPath?: string): boolean;
307
322
  export declare function getSessionInfo(name: string, projectPath?: string): {
308
323
  name: string;
@@ -321,15 +336,17 @@ export interface SessionInfo {
321
336
  */
322
337
  export declare function listSessionsWithInfo(projectPath?: string): SessionInfo[];
323
338
  /**
324
- * Get project permission from local .codeep/config.json or global config
339
+ * Get the permission the user granted for a project.
325
340
  */
326
341
  export declare function getProjectPermission(projectPath: string): ProjectPermission | null;
327
342
  /**
328
- * Set project permission in local .codeep/config.json or global config
343
+ * Record a permission grant in the global config.
329
344
  */
330
345
  export declare function setProjectPermission(projectPath: string, read: boolean, write: boolean): void;
331
346
  /**
332
- * Remove project permission from local .codeep/config.json or global config
347
+ * Remove a project's permission from the global config. A grant an older
348
+ * version wrote into the project for this same directory is removed too, so
349
+ * it does not linger in a file that may be committed.
333
350
  */
334
351
  export declare function removeProjectPermission(projectPath: string): boolean;
335
352
  export declare function hasReadPermission(projectPath: string): boolean;
@@ -1,10 +1,11 @@
1
1
  import Conf from 'conf';
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'fs';
3
- import { join, dirname } from 'path';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync, renameSync } from 'fs';
3
+ import { join, dirname, resolve, sep, isAbsolute } from 'path';
4
4
  import { randomUUID } from 'crypto';
5
5
  import { PROVIDERS, getProvider, getProviderBaseUrl, replacementModelFor } from './providers.js';
6
6
  import { logSession } from '../utils/logger.js';
7
7
  import { createSecureStorage } from '../utils/keychain.js';
8
+ import { isSafeProjectDir, leadsOutsideProject, writeFileNoFollow, writeProjectFile } from '../utils/projectPaths.js';
8
9
  // We'll initialize GLOBAL_SESSIONS_DIR after config is created (to use config.path)
9
10
  /**
10
11
  * Get sessions directory - local .codeep/sessions/ if in project, otherwise global
@@ -12,25 +13,17 @@ import { createSecureStorage } from '../utils/keychain.js';
12
13
  export function getSessionsDir(projectPath) {
13
14
  if (projectPath && isProjectDirectory(projectPath)) {
14
15
  const localDir = join(projectPath, '.codeep', 'sessions');
15
- if (!existsSync(localDir)) {
16
- mkdirSync(localDir, { recursive: true });
16
+ // .codeep/ can come with a cloned repo; a symlinked sessions directory
17
+ // would have every save written wherever it points.
18
+ if (isSafeProjectDir(projectPath, localDir)) {
19
+ if (!existsSync(localDir)) {
20
+ mkdirSync(localDir, { recursive: true });
21
+ }
22
+ return localDir;
17
23
  }
18
- return localDir;
19
24
  }
20
25
  return GLOBAL_SESSIONS_DIR;
21
26
  }
22
- /**
23
- * Get local project config path
24
- */
25
- function getLocalConfigPath(projectPath) {
26
- if (!isProjectDirectory(projectPath))
27
- return null;
28
- const configDir = join(projectPath, '.codeep');
29
- if (!existsSync(configDir)) {
30
- mkdirSync(configDir, { recursive: true });
31
- }
32
- return join(configDir, 'config.json');
33
- }
34
27
  /**
35
28
  * Check if directory is a project
36
29
  * Looks for common project indicators: package.json, pyproject.toml, Cargo.toml, go.mod, composer.json, etc.
@@ -80,17 +73,13 @@ export function hasStandardProjectMarkers(path) {
80
73
  */
81
74
  export function initializeAsProject(path) {
82
75
  try {
83
- const codeepDir = join(path, '.codeep');
84
- if (!existsSync(codeepDir)) {
85
- mkdirSync(codeepDir, { recursive: true });
86
- }
87
- const projectFile = join(codeepDir, 'project.json');
76
+ const projectFile = join(path, '.codeep', 'project.json');
88
77
  const projectData = {
89
78
  name: path.split('/').pop() || 'project',
90
79
  initializedAt: new Date().toISOString(),
91
80
  version: '1.0',
92
81
  };
93
- writeFileSync(projectFile, JSON.stringify(projectData, null, 2));
82
+ writeProjectFile(path, projectFile, JSON.stringify(projectData, null, 2));
94
83
  return true;
95
84
  }
96
85
  catch {
@@ -821,46 +810,87 @@ export function startNewSession() {
821
810
  config.set('currentSessionId', sessionId);
822
811
  return sessionId;
823
812
  }
824
- // Auto-save debounce state
813
+ // Auto-save debounce state. The target session is fixed when the save is
814
+ // requested, not when the timer fires: by then the user may have loaded or
815
+ // started another conversation, and this history would overwrite its file.
825
816
  let autoSaveTimeout = null;
826
817
  let pendingAutoSave = null;
827
- // Auto-save current session (debounced - saves max every 5 seconds)
828
- export function autoSaveSession(history, projectPath) {
818
+ function writePendingAutoSave() {
819
+ if (!pendingAutoSave)
820
+ return false;
821
+ const { sessionId, history, projectPath } = pendingAutoSave;
822
+ pendingAutoSave = null;
823
+ return saveSession(sessionId, history, projectPath);
824
+ }
825
+ // Auto-save a conversation (debounced - saves max every 5 seconds).
826
+ // Pass the id of the conversation the history belongs to. The global
827
+ // currentSessionId is only the fallback: it names one conversation, and
828
+ // callers holding another (a loaded session, a second ACP thread) would
829
+ // otherwise save over it.
830
+ export function autoSaveSession(history, projectPath, sessionId) {
829
831
  if (!config.get('autoSave') || history.length === 0) {
830
832
  return false;
831
833
  }
832
- // Store pending save data
833
- pendingAutoSave = { history, projectPath };
834
+ const target = sessionId || getCurrentSessionId();
835
+ // Only one save is held back at a time. One queued for a different
836
+ // conversation would be dropped by the overwrite below, so write it now.
837
+ if (pendingAutoSave && (pendingAutoSave.sessionId !== target || pendingAutoSave.projectPath !== projectPath)) {
838
+ writePendingAutoSave();
839
+ }
840
+ pendingAutoSave = { history, projectPath, sessionId: target };
834
841
  // If already scheduled, don't reschedule
835
842
  if (autoSaveTimeout) {
836
843
  return true;
837
844
  }
838
845
  // Schedule save after 5 seconds
839
846
  autoSaveTimeout = setTimeout(() => {
840
- if (pendingAutoSave) {
841
- const sessionId = getCurrentSessionId();
842
- saveSession(sessionId, pendingAutoSave.history, pendingAutoSave.projectPath);
843
- pendingAutoSave = null;
844
- }
845
847
  autoSaveTimeout = null;
848
+ writePendingAutoSave();
846
849
  }, 5000);
847
850
  return true;
848
851
  }
849
- // Force immediate save (for explicit save commands)
852
+ // Write any debounced save now. Anything about to exit must call this: the
853
+ // 5-second timer never fires once the process is gone.
850
854
  export function flushAutoSave() {
851
855
  if (autoSaveTimeout) {
852
856
  clearTimeout(autoSaveTimeout);
853
857
  autoSaveTimeout = null;
854
858
  }
855
- if (pendingAutoSave) {
856
- const sessionId = getCurrentSessionId();
857
- const result = saveSession(sessionId, pendingAutoSave.history, pendingAutoSave.projectPath);
858
- pendingAutoSave = null;
859
- return result;
860
- }
861
- return false;
859
+ return writePendingAutoSave();
862
860
  }
863
861
  // Session management
862
+ /**
863
+ * Why a session name cannot be used, or null when it can. A session is kept
864
+ * as <name>.json inside the sessions directory, so a name holding a path
865
+ * separator (`../../x`, `feature/auth`) would read or write a file elsewhere.
866
+ * A backslash is a separator only on Windows; elsewhere it is an ordinary
867
+ * filename character, and sessions named with one already exist.
868
+ */
869
+ export function sessionNameProblem(name) {
870
+ if (!name || !name.trim())
871
+ return 'Session name cannot be empty.';
872
+ if (sep === '\\') {
873
+ if (/[\/\\]/.test(name))
874
+ return `Session name "${name}" cannot contain "/" or "\\".`;
875
+ }
876
+ else if (name.includes('/')) {
877
+ return `Session name "${name}" cannot contain "/".`;
878
+ }
879
+ // eslint-disable-next-line no-control-regex
880
+ if (/[\x00-\x1f]/.test(name))
881
+ return 'Session name cannot contain control characters.';
882
+ if (name === '.' || name === '..')
883
+ return `Session name "${name}" is not allowed.`;
884
+ return null;
885
+ }
886
+ /** The file a session is kept in, or null for a name that would leave the sessions directory. */
887
+ function sessionFile(name, projectPath) {
888
+ if (sessionNameProblem(name))
889
+ return null;
890
+ const sessionsDir = getSessionsDir(projectPath);
891
+ const filePath = join(sessionsDir, `${name}.json`);
892
+ return dirname(resolve(filePath)) === resolve(sessionsDir) ? filePath : null;
893
+ }
864
894
  export function saveSession(name, history, projectPath) {
865
895
  try {
866
896
  // Derive a human-readable title from the first user message
@@ -868,8 +898,11 @@ export function saveSession(name, history, projectPath) {
868
898
  const title = firstUserMsg
869
899
  ? firstUserMsg.content.replace(/\n/g, ' ').trim().slice(0, 60)
870
900
  : name;
871
- const sessionsDir = getSessionsDir(projectPath);
872
- const filePath = join(sessionsDir, `${name}.json`);
901
+ const filePath = sessionFile(name, projectPath);
902
+ if (!filePath) {
903
+ logSession('save', name, false);
904
+ return false;
905
+ }
873
906
  // Preserve an existing aiTitle across re-saves so we don't regenerate.
874
907
  let existingAiTitle;
875
908
  if (existsSync(filePath)) {
@@ -886,7 +919,7 @@ export function saveSession(name, history, projectPath) {
886
919
  history,
887
920
  createdAt: new Date().toISOString(),
888
921
  };
889
- writeFileSync(filePath, JSON.stringify(session, null, 2));
922
+ writeFileNoFollow(filePath, JSON.stringify(session, null, 2));
890
923
  logSession('save', name, true);
891
924
  // Fire-and-forget: generate an AI title once the session has enough
892
925
  // content. The orchestrator early-returns if one already exists, so
@@ -926,9 +959,8 @@ const titlesInFlight = new Set();
926
959
  export async function maybeGenerateSessionTitle(name, projectPath) {
927
960
  if (titlesInFlight.has(name))
928
961
  return;
929
- const sessionsDir = getSessionsDir(projectPath);
930
- const filePath = join(sessionsDir, `${name}.json`);
931
- if (!existsSync(filePath))
962
+ const filePath = sessionFile(name, projectPath);
963
+ if (!filePath || !existsSync(filePath))
932
964
  return;
933
965
  let data;
934
966
  try {
@@ -949,7 +981,7 @@ export async function maybeGenerateSessionTitle(name, projectPath) {
949
981
  try {
950
982
  const fresh = JSON.parse(readFileSync(filePath, 'utf-8'));
951
983
  fresh.aiTitle = aiTitle;
952
- writeFileSync(filePath, JSON.stringify(fresh, null, 2));
984
+ writeFileNoFollow(filePath, JSON.stringify(fresh, null, 2));
953
985
  }
954
986
  catch { /* ignore */ }
955
987
  }
@@ -960,9 +992,8 @@ export async function maybeGenerateSessionTitle(name, projectPath) {
960
992
  }
961
993
  export function loadSession(name, projectPath) {
962
994
  try {
963
- const sessionsDir = getSessionsDir(projectPath);
964
- const filePath = join(sessionsDir, `${name}.json`);
965
- if (existsSync(filePath)) {
995
+ const filePath = sessionFile(name, projectPath);
996
+ if (filePath && existsSync(filePath)) {
966
997
  const data = JSON.parse(readFileSync(filePath, 'utf-8'));
967
998
  logSession('load', name, true);
968
999
  return data.history;
@@ -989,9 +1020,8 @@ export function listSessions(projectPath) {
989
1020
  }
990
1021
  export function deleteSession(name, projectPath) {
991
1022
  try {
992
- const sessionsDir = getSessionsDir(projectPath);
993
- const filePath = join(sessionsDir, `${name}.json`);
994
- if (existsSync(filePath)) {
1023
+ const filePath = sessionFile(name, projectPath);
1024
+ if (filePath && existsSync(filePath)) {
995
1025
  unlinkSync(filePath);
996
1026
  logSession('delete', name, true);
997
1027
  return true;
@@ -1004,26 +1034,72 @@ export function deleteSession(name, projectPath) {
1004
1034
  return false;
1005
1035
  }
1006
1036
  }
1037
+ /**
1038
+ * True when `name` already belongs to a saved conversation other than
1039
+ * `currentName`'s. A name differing only in case can resolve to the same file
1040
+ * on a case-insensitive disk; that is the same conversation, not a clash.
1041
+ * Inodes are compared as bigints: on Windows they can exceed 2^53.
1042
+ */
1043
+ export function sessionNameTaken(name, currentName, projectPath) {
1044
+ if (name === currentName)
1045
+ return false;
1046
+ const target = sessionFile(name, projectPath);
1047
+ if (!target || !existsSync(target))
1048
+ return false;
1049
+ const current = sessionFile(currentName, projectPath);
1050
+ if (!current || !existsSync(current))
1051
+ return true;
1052
+ const a = statSync(target, { bigint: true });
1053
+ const b = statSync(current, { bigint: true });
1054
+ return a.ino !== b.ino || a.dev !== b.dev;
1055
+ }
1007
1056
  export function renameSession(oldName, newName, projectPath) {
1008
1057
  try {
1009
1058
  const sessionsDir = getSessionsDir(projectPath);
1010
- const oldPath = join(sessionsDir, `${oldName}.json`);
1011
- const newPath = join(sessionsDir, `${newName}.json`);
1012
- if (!existsSync(oldPath)) {
1059
+ const oldPath = sessionFile(oldName, projectPath);
1060
+ const newPath = sessionFile(newName, projectPath);
1061
+ if (!oldPath || !newPath || !existsSync(oldPath)) {
1062
+ logSession('rename', `${oldName} -> ${newName}`, false);
1063
+ return false;
1064
+ }
1065
+ // Renaming onto itself changes nothing.
1066
+ if (newName === oldName) {
1067
+ logSession('rename', `${oldName} -> ${newName}`, true);
1068
+ return true;
1069
+ }
1070
+ // Never replace another saved conversation: it would be gone for good.
1071
+ if (sessionNameTaken(newName, oldName, projectPath)) {
1013
1072
  logSession('rename', `${oldName} -> ${newName}`, false);
1014
1073
  return false;
1015
1074
  }
1016
1075
  // Read existing session
1017
1076
  const data = JSON.parse(readFileSync(oldPath, 'utf-8'));
1018
- // Update name and save to new path
1019
1077
  data.name = newName;
1020
- writeFileSync(newPath, JSON.stringify(data, null, 2));
1021
- // Delete old file
1022
- unlinkSync(oldPath);
1078
+ // Write the renamed copy aside, move the session, then put the copy in
1079
+ // place. The old file is not touched until the move: writing the new
1080
+ // name into it first left a failed move (`feature/auth`) with a session
1081
+ // that lists under a name it cannot be loaded by. Writing the new path
1082
+ // and unlinking the old one would delete the session when a case-only
1083
+ // rename makes both one file.
1084
+ const staged = join(sessionsDir, `.${randomUUID()}.rename.tmp`);
1085
+ writeFileSync(staged, JSON.stringify(data, null, 2));
1086
+ try {
1087
+ renameSync(oldPath, newPath);
1088
+ renameSync(staged, newPath);
1089
+ }
1090
+ finally {
1091
+ if (existsSync(staged))
1092
+ unlinkSync(staged);
1093
+ }
1023
1094
  // Update current session ID if it was the renamed one
1024
1095
  if (config.get('currentSessionId') === oldName) {
1025
1096
  config.set('currentSessionId', newName);
1026
1097
  }
1098
+ // A debounced save still aimed at the old name would bring the old file
1099
+ // back beside the renamed one.
1100
+ if (pendingAutoSave?.sessionId === oldName && getSessionsDir(pendingAutoSave.projectPath) === sessionsDir) {
1101
+ pendingAutoSave.sessionId = newName;
1102
+ }
1027
1103
  logSession('rename', `${oldName} -> ${newName}`, true);
1028
1104
  return true;
1029
1105
  }
@@ -1034,9 +1110,8 @@ export function renameSession(oldName, newName, projectPath) {
1034
1110
  }
1035
1111
  export function getSessionInfo(name, projectPath) {
1036
1112
  try {
1037
- const sessionsDir = getSessionsDir(projectPath);
1038
- const filePath = join(sessionsDir, `${name}.json`);
1039
- if (existsSync(filePath)) {
1113
+ const filePath = sessionFile(name, projectPath);
1114
+ if (filePath && existsSync(filePath)) {
1040
1115
  const data = JSON.parse(readFileSync(filePath, 'utf-8'));
1041
1116
  return {
1042
1117
  name: data.name,
@@ -1092,91 +1167,71 @@ export function listSessionsWithInfo(projectPath) {
1092
1167
  }
1093
1168
  }
1094
1169
  // Project permission management
1170
+ //
1171
+ // Grants live only in the user's global config, which no repository can
1172
+ // write. Older versions also stored them in the project's own
1173
+ // `.codeep/config.json`; that file can arrive with a cloned repo, and no rule
1174
+ // about its contents holds up (a relative `path` resolves against wherever
1175
+ // Codeep was started), so a grant there no longer counts. A user who granted
1176
+ // access with an older version is asked once more.
1177
+ /** The project's own config file, without creating anything. */
1178
+ function localConfigFile(projectPath) {
1179
+ return isProjectDirectory(projectPath) ? join(projectPath, '.codeep', 'config.json') : null;
1180
+ }
1181
+ function grantMatches(perm, projectPath) {
1182
+ return !!perm && typeof perm === 'object'
1183
+ && typeof perm.path === 'string'
1184
+ && isAbsolute(perm.path)
1185
+ && resolve(perm.path) === resolve(projectPath);
1186
+ }
1095
1187
  /**
1096
- * Get project permission from local .codeep/config.json or global config
1188
+ * Get the permission the user granted for a project.
1097
1189
  */
1098
1190
  export function getProjectPermission(projectPath) {
1099
- // First try local .codeep/config.json (for recognized projects)
1100
- const configPath = getLocalConfigPath(projectPath);
1101
- if (configPath && existsSync(configPath)) {
1102
- try {
1103
- const data = JSON.parse(readFileSync(configPath, 'utf-8'));
1104
- if (data.permission && typeof data.permission === 'object')
1105
- return data.permission;
1106
- }
1107
- catch {
1108
- // Fall through to global config
1109
- }
1110
- }
1111
- // Fallback to global config for non-project folders
1112
1191
  const permissions = config.get('projectPermissions') || [];
1113
- return permissions.find((p) => p.path === projectPath) || null;
1192
+ return permissions.find((p) => grantMatches(p, projectPath)) ?? null;
1114
1193
  }
1115
1194
  /**
1116
- * Set project permission in local .codeep/config.json or global config
1195
+ * Record a permission grant in the global config.
1117
1196
  */
1118
1197
  export function setProjectPermission(projectPath, read, write) {
1119
1198
  const permission = {
1120
- path: projectPath,
1199
+ path: resolve(projectPath),
1121
1200
  readPermission: read,
1122
1201
  writePermission: write,
1123
1202
  grantedAt: new Date().toISOString(),
1124
1203
  };
1125
- // Try to save to local .codeep/config.json (for recognized projects)
1126
- const configPath = getLocalConfigPath(projectPath);
1127
- if (configPath) {
1128
- let data = {};
1129
- if (existsSync(configPath)) {
1130
- try {
1131
- data = JSON.parse(readFileSync(configPath, 'utf-8'));
1132
- }
1133
- catch {
1134
- // Invalid JSON, start fresh
1135
- }
1136
- }
1137
- data.permission = permission;
1138
- writeFileSync(configPath, JSON.stringify(data, null, 2));
1139
- return;
1140
- }
1141
- // Fallback to global config for non-project folders
1142
- const permissions = config.get('projectPermissions') || [];
1143
- const existingIndex = permissions.findIndex((p) => p.path === projectPath);
1144
- if (existingIndex >= 0) {
1145
- permissions[existingIndex] = permission;
1146
- }
1147
- else {
1148
- permissions.push(permission);
1149
- }
1204
+ const permissions = (config.get('projectPermissions') || []).filter((p) => !grantMatches(p, projectPath));
1205
+ permissions.push(permission);
1150
1206
  config.set('projectPermissions', permissions);
1151
1207
  }
1152
1208
  /**
1153
- * Remove project permission from local .codeep/config.json or global config
1209
+ * Remove a project's permission from the global config. A grant an older
1210
+ * version wrote into the project for this same directory is removed too, so
1211
+ * it does not linger in a file that may be committed.
1154
1212
  */
1155
1213
  export function removeProjectPermission(projectPath) {
1156
- // Try to remove from local .codeep/config.json
1157
- const configPath = getLocalConfigPath(projectPath);
1158
- if (configPath && existsSync(configPath)) {
1214
+ let removed = false;
1215
+ const permissions = config.get('projectPermissions') || [];
1216
+ const kept = permissions.filter((p) => !grantMatches(p, projectPath));
1217
+ if (kept.length !== permissions.length) {
1218
+ config.set('projectPermissions', kept);
1219
+ removed = true;
1220
+ }
1221
+ const configPath = localConfigFile(projectPath);
1222
+ if (configPath && existsSync(configPath) && !leadsOutsideProject(configPath, projectPath)) {
1159
1223
  try {
1160
1224
  const data = JSON.parse(readFileSync(configPath, 'utf-8'));
1161
- if (data.permission) {
1225
+ if (data && typeof data === 'object' && grantMatches(data.permission, projectPath)) {
1162
1226
  delete data.permission;
1163
- writeFileSync(configPath, JSON.stringify(data, null, 2));
1164
- return true;
1227
+ writeProjectFile(projectPath, configPath, JSON.stringify(data, null, 2));
1165
1228
  }
1166
1229
  }
1167
1230
  catch {
1168
- // Fall through to global config
1231
+ // Unreadable, symlinked or unwritable: it no longer grants anything.
1169
1232
  }
1170
1233
  }
1171
- // Try to remove from global config
1172
- const permissions = config.get('projectPermissions') || [];
1173
- const existingIndex = permissions.findIndex((p) => p.path === projectPath);
1174
- if (existingIndex >= 0) {
1175
- permissions.splice(existingIndex, 1);
1176
- config.set('projectPermissions', permissions);
1177
- return true;
1178
- }
1179
- return false;
1234
+ return removed;
1180
1235
  }
1181
1236
  export function hasReadPermission(projectPath) {
1182
1237
  const perm = getProjectPermission(projectPath);
@@ -23,17 +23,45 @@ export interface AppExecutionContext {
23
23
  setAbortController: (ctrl: AbortController | null) => void;
24
24
  formatAddedFilesContext: () => string;
25
25
  handleCommand: (command: string, args: string[]) => Promise<void>;
26
+ /** The conversation this run belongs to. Saved under this id; the global
27
+ * currentSessionId is only a fallback and can name a different one. */
28
+ sessionId?: string;
29
+ /** The conversation on screen now. A run can outlive a switch to another
30
+ * one (/new, /sessions, /rename), since tools do not stop on abort. */
31
+ getSessionId?: () => string;
26
32
  sessionDisplayName?: string;
27
33
  setSessionDisplayName?: (name: string | null) => void;
28
34
  }
29
35
  export declare function isDangerousTool(toolName: string, parameters: Record<string, unknown>): boolean;
30
36
  export declare function requestToolConfirmation(app: App, tool: string, parameters: Record<string, unknown>, onConfirm: () => void, onCancel: () => void): void;
37
+ /**
38
+ * Model-written text shown in a permission dialog, with every character that
39
+ * could change how the rest of it looks spelled out: an ESC sequence would be
40
+ * read as a style (conceal, black on black) and a bidi override or zero-width
41
+ * character reorders or hides text, so the user could approve a command they
42
+ * were not shown. Newlines are left for the caller to lay out.
43
+ */
44
+ export declare function showControls(text: string): string;
45
+ /**
46
+ * The target of a tool call as dialog lines, each `width` terminal columns at
47
+ * most. Shown whole where it fits: an MCP call's arguments or a long command
48
+ * matter from the first character. Past `maxLines` the middle gives way, and a
49
+ * line says how much of it is not shown.
50
+ */
51
+ export declare function wrapConfirmTarget(target: string, width: number, maxLines?: number): string[];
31
52
  export interface PendingInteractiveContext {
32
53
  originalTask: string;
33
54
  context: import('../utils/interactive').InteractiveContext;
34
55
  dryRun: boolean;
35
56
  }
36
- export declare function runAgentTask(task: string, dryRun: boolean, ctx: AppExecutionContext, getPendingInteractive: () => PendingInteractiveContext | null, setPendingInteractive: (v: PendingInteractiveContext | null) => void): Promise<void>;
37
- export declare function executeAgentTask(task: string, dryRun: boolean, ctx: AppExecutionContext): Promise<void>;
57
+ /** How a run ended. 'not-started' covers a run that was refused, declined at
58
+ * a confirmation, or held for clarifying questions. */
59
+ export type AgentRunOutcome = 'success' | 'failed' | 'aborted' | 'interrupted' | 'not-started';
60
+ export interface RunAgentTaskOptions {
61
+ /** Called once, when the run has ended or will not start. */
62
+ onFinished?: (outcome: AgentRunOutcome) => void;
63
+ }
64
+ export declare function runAgentTask(task: string, dryRun: boolean, ctx: AppExecutionContext, getPendingInteractive: () => PendingInteractiveContext | null, setPendingInteractive: (v: PendingInteractiveContext | null) => void, opts?: RunAgentTaskOptions): Promise<void>;
65
+ export declare function executeAgentTask(task: string, dryRun: boolean, ctx: AppExecutionContext): Promise<AgentRunOutcome>;
38
66
  export declare function runSkill(nameOrShortcut: string, args: string[], ctx: AppExecutionContext): Promise<boolean>;
39
67
  export declare function runCommandChain(commands: string[], index: number, ctx: AppExecutionContext): void;