codeep 3.4.0 → 3.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.
Files changed (39) hide show
  1. package/dist/acp/commands.d.ts +15 -0
  2. package/dist/acp/commands.js +39 -5
  3. package/dist/acp/server.d.ts +13 -0
  4. package/dist/acp/server.js +283 -27
  5. package/dist/acp/serverHandlers.js +10 -10
  6. package/dist/acp/session.d.ts +13 -2
  7. package/dist/acp/transport.d.ts +6 -0
  8. package/dist/acp/transport.js +98 -3
  9. package/dist/api/index.js +6 -3
  10. package/dist/config/index.js +12 -4
  11. package/dist/config/providers.d.ts +48 -4
  12. package/dist/config/providers.js +325 -88
  13. package/dist/renderer/agentExecution.js +116 -69
  14. package/dist/renderer/commands.js +36 -11
  15. package/dist/renderer/main.d.ts +24 -0
  16. package/dist/renderer/main.js +57 -2
  17. package/dist/utils/agent.d.ts +33 -2
  18. package/dist/utils/agent.js +86 -8
  19. package/dist/utils/agentChat.js +22 -10
  20. package/dist/utils/checkpoints.js +3 -0
  21. package/dist/utils/codeReview.js +28 -23
  22. package/dist/utils/git.d.ts +262 -4
  23. package/dist/utils/git.js +1928 -61
  24. package/dist/utils/gitHookInstaller.d.ts +32 -1
  25. package/dist/utils/gitHookInstaller.js +76 -8
  26. package/dist/utils/headlessReview.js +26 -5
  27. package/dist/utils/personalities.js +8 -2
  28. package/dist/utils/shell.d.ts +108 -0
  29. package/dist/utils/shell.js +364 -5
  30. package/dist/utils/taskPlanner.js +12 -4
  31. package/dist/utils/telegramApproval.d.ts +10 -2
  32. package/dist/utils/telegramApproval.js +22 -4
  33. package/dist/utils/tokenTracker.d.ts +13 -5
  34. package/dist/utils/tokenTracker.js +163 -34
  35. package/dist/utils/toolExecution.d.ts +41 -0
  36. package/dist/utils/toolExecution.js +357 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/package.json +1 -1
@@ -14,6 +14,8 @@ import { takeRunFromPhone } from '../utils/telegramInbox.js';
14
14
  import { isFlatFeeProvider } from '../config/providers.js';
15
15
  import { raceApproval } from '../utils/approvalRace.js';
16
16
  import { describeAuditTarget } from '../utils/auditLog.js';
17
+ import { trustBearingWrite, forgetHooksDirectory } from '../utils/toolExecution.js';
18
+ import { shellCommandEnv } from '../utils/shell.js';
17
19
  import { charWidth } from './ansi.js';
18
20
  import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
19
21
  import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
@@ -235,75 +237,95 @@ export async function executeAgentTask(task, dryRun, ctx) {
235
237
  const asksPerTool = confirmationMode === 'dangerous' || confirmationMode === 'always';
236
238
  // Read the Telegram credentials once for the whole run rather than per tool
237
239
  // call: they come from the OS keychain, and paying that on every dangerous
238
- // tool would put a keychain round-trip in front of each confirmation.
239
- // Null means the feature is off or half-configured, and the terminal is
240
- // then the only place the question appears — exactly as before.
241
- const telegramCredentials = asksPerTool
242
- ? await loadTelegramCredentials()
243
- : null;
244
- // The finish notice does not depend on the confirmation mode — a run with
245
- // confirmations off is exactly the one you are most likely to walk away
246
- // from. Reuse the credentials already read above when there are any, so
247
- // this costs a second keychain round-trip only when there are not.
248
- const noticeCredentials = telegramCredentials ?? await loadTelegramCredentials();
240
+ // tool would put a keychain round-trip in front of each confirmation. Read
241
+ // in every mode, for the finish notice — and because 'never' now asks
242
+ // about a file that decides what runs later, which is exactly the kind of
243
+ // run someone has walked away from. Null means the feature is off or
244
+ // half-configured, and the terminal is then the only place the question
245
+ // appears — exactly as before.
246
+ const telegramCredentials = await loadTelegramCredentials();
249
247
  const runStartedAt = Date.now();
250
- const onRequestPermission = asksPerTool
251
- ? async (toolCall) => {
252
- // `parameters.command` is the binary alone — `git`, not `git status`.
253
- // Showing that asks someone to approve a command they have not been
254
- // shown, which is the one thing this gate must not do. The audit
255
- // record already joins the binary with its arguments; reuse it rather
256
- // than writing a second, subtly different answer.
257
- const target = describeAuditTarget(toolCall);
258
- // Indented by two in the dialog.
259
- const targetLines = wrapConfirmTarget(target, (process.stdout.columns || 80) - 4)
260
- .map(line => ` ${line}`);
261
- const inTerminal = {
262
- answer: new Promise((resolve) => {
263
- app.showConfirm({
264
- title: '⚠️ Confirm Action',
265
- message: [
266
- 'The agent wants to execute:',
267
- '',
268
- ` ${showControls(toolCall.tool)}`,
269
- ...targetLines,
270
- '',
271
- telegramCredentials ? 'Allow this action? (or answer on Telegram)' : 'Allow this action?',
272
- ],
273
- confirmLabel: 'Allow',
274
- cancelLabel: 'Deny',
275
- extraOption: { label: 'Always Allow', onSelect: () => resolve('allow_always') },
276
- onConfirm: () => resolve('allow_once'),
277
- onCancel: () => resolve('reject_always'),
278
- });
279
- }),
280
- // Answered on the phone: take the dialog down without running either
281
- // callback, since the decision is already made and taken.
282
- withdraw: (winner) => app.dismissConfirm(`Answered on Telegram — ${winner}.`),
248
+ // 'never' still gets a callback. A write to a file that decides what runs
249
+ // later — `.git/config`, a hook, an MCP server list — is asked about in
250
+ // every mode (the agent gate decides which calls those are), and "never
251
+ // ask" is then answered here for everything else, exactly as before:
252
+ // without a callback the agent would have to refuse those writes instead.
253
+ const onRequestPermission = async (toolCall,
254
+ // What the agent gate already worked out about this call, passed rather
255
+ // than worked out twice: trustBearingWrite() stats the path, resolves a
256
+ // symlinked ancestor and may ask git where this repo keeps its hooks.
257
+ // Undefined means the question came from somewhere that has not looked
258
+ // — a skill's shell line asks through this same callback — so it is
259
+ // only then that this side looks for itself. `null` is an answer.
260
+ known) => {
261
+ const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, context.root || process.cwd());
262
+ if (!asksPerTool && !trustBearing)
263
+ return 'allow_once';
264
+ // `parameters.command` is the binary alone — `git`, not `git status`.
265
+ // Showing that asks someone to approve a command they have not been
266
+ // shown, which is the one thing this gate must not do. The audit
267
+ // record already joins the binary with its arguments; reuse it rather
268
+ // than writing a second, subtly different answer.
269
+ const target = describeAuditTarget(toolCall);
270
+ // Indented by two in the dialog.
271
+ const targetLines = wrapConfirmTarget(target, (process.stdout.columns || 80) - 4)
272
+ .map(line => ` ${line}`);
273
+ const inTerminal = {
274
+ answer: new Promise((resolve) => {
275
+ app.showConfirm({
276
+ title: '⚠️ Confirm Action',
277
+ message: [
278
+ 'The agent wants to execute:',
279
+ '',
280
+ ` ${showControls(toolCall.tool)}`,
281
+ ...targetLines,
282
+ // What the file does, not that it is "sensitive": someone
283
+ // deciding in one second needs the consequence, not a label.
284
+ ...(trustBearing ? ['', ...wrapConfirmTarget(`⚠️ ${trustBearing.reason}`, (process.stdout.columns || 80) - 4)] : []),
285
+ '',
286
+ telegramCredentials ? 'Allow this action? (or answer on Telegram)' : 'Allow this action?',
287
+ ],
288
+ confirmLabel: 'Allow',
289
+ cancelLabel: 'Deny',
290
+ // No "Always Allow" for one of those files: the agent answers
291
+ // about this file only and would not remember it anyway.
292
+ extraOption: trustBearing ? undefined : { label: 'Always Allow', onSelect: () => resolve('allow_always') },
293
+ onConfirm: () => resolve('allow_once'),
294
+ // The one "no" there is, and it answers reject_always. For one of
295
+ // those files the agent remembers that against the FILE rather
296
+ // than the tool, so refusing a `.git/config` prompt does not also
297
+ // switch delete_file off for the rest of the run.
298
+ onCancel: () => resolve('reject_always'),
299
+ });
300
+ }),
301
+ // Answered on the phone: take the dialog down without running either
302
+ // callback, since the decision is already made and taken.
303
+ withdraw: (winner) => app.dismissConfirm(`Answered on Telegram — ${winner}.`),
304
+ };
305
+ let onPhone = null;
306
+ if (telegramCredentials) {
307
+ // Report a failure to *ask* once, in the terminal. Without this a
308
+ // wrong chat id looks exactly like a phone nobody picked up.
309
+ const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
310
+ onPhone = {
311
+ answer: telegram
312
+ // The reason goes with it: a phone showing less than the terminal
313
+ // asks for a decision on less than the terminal had.
314
+ .ask(target, toolCall.tool, true, undefined, trustBearing?.reason)
315
+ .then(answer => (answer ? outcomeForAnswer(answer) : null))
316
+ // A phone that cannot be reached is not a denial. Step aside and
317
+ // let the terminal decide, however long that takes.
318
+ .catch(() => null),
319
+ withdraw: (winner) => telegram.withdraw(winner),
283
320
  };
284
- let onPhone = null;
285
- if (telegramCredentials) {
286
- // Report a failure to *ask* once, in the terminal. Without this a
287
- // wrong chat id looks exactly like a phone nobody picked up.
288
- const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
289
- onPhone = {
290
- answer: telegram
291
- .ask(target, toolCall.tool, true)
292
- .then(answer => (answer ? outcomeForAnswer(answer) : null))
293
- // A phone that cannot be reached is not a denial. Step aside and
294
- // let the terminal decide, however long that takes.
295
- .catch(() => null),
296
- withdraw: (winner) => telegram.withdraw(winner),
297
- };
298
- }
299
- const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
300
- // Nobody answered — neither side could even ask. `classifyPermissionOutcome`
301
- // fails closed on anything it does not recognise, and this is spelled
302
- // out rather than left to that: a question that was never put must
303
- // never read as a yes.
304
- return answer ?? 'reject_once';
305
321
  }
306
- : undefined;
322
+ const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
323
+ // Nobody answered — neither side could even ask. `classifyPermissionOutcome`
324
+ // fails closed on anything it does not recognise, and this is spelled
325
+ // out rather than left to that: a question that was never put must
326
+ // never read as a yes.
327
+ return answer ?? 'reject_once';
328
+ };
307
329
  const result = await runAgent(enrichedTask, context, {
308
330
  dryRun,
309
331
  onRequestPermission,
@@ -558,7 +580,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
558
580
  // Told once the run is over, and only when it ran long enough that you
559
581
  // could plausibly have stopped watching. Awaited so the process does not
560
582
  // exit from under the request, but never allowed to fail the run.
561
- if (noticeCredentials) {
583
+ if (telegramCredentials) {
562
584
  const elapsedMs = Date.now() - runStartedAt;
563
585
  const fromPhone = startedFromPhone;
564
586
  // The one-minute threshold exists so a phone is not buzzed about work you
@@ -578,7 +600,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
578
600
  costUsd: payPerUse.reduce((sum, e) => sum + e.estimatedCost, 0),
579
601
  });
580
602
  for (const message of messages) {
581
- await sendTelegramNotice(noticeCredentials, message).catch(() => false);
603
+ await sendTelegramNotice(telegramCredentials, message).catch(() => false);
582
604
  }
583
605
  }
584
606
  }
@@ -657,12 +679,37 @@ export async function runSkill(nameOrShortcut, args, ctx) {
657
679
  try {
658
680
  const result = await executeSkill(skill, params, {
659
681
  onCommand: async (cmd) => {
682
+ const cwd = ctx.projectPath || process.cwd();
683
+ // A raw process.env here handed the repository's own `.git/config`
684
+ // back to git: `/commit` runs `git commit`, and a repo-scope
685
+ // `gpg.program` that Codeep's own commit path neutralises executed
686
+ // through this spawn instead.
687
+ //
688
+ // Built before the spawn, and caught: shellCommandEnv() refuses a git
689
+ // line in a repository whose config names a program no override
690
+ // switches off, and a refusal escaping here would abort the whole
691
+ // skill rather than fail the step that asked for git. A step that
692
+ // cannot run is reported the same way a step that failed is.
693
+ let env;
694
+ try {
695
+ env = shellCommandEnv(cmd, cwd);
696
+ }
697
+ catch (error) {
698
+ const why = error instanceof Error ? error.message : String(error);
699
+ ctx.app.addMessage({ role: 'system', content: `\`${cmd}\` was not run:\n\`\`\`\n${why}\n\`\`\`` });
700
+ throw new Error(why);
701
+ }
702
+ // A command line is the one thing in a skill that can move this
703
+ // repository's hooks (`git config core.hooksPath .evil`), and the
704
+ // write gate caches where they are for the run.
705
+ forgetHooksDirectory();
660
706
  const proc = spawnSync(cmd, {
661
- cwd: ctx.projectPath || process.cwd(),
707
+ cwd,
662
708
  encoding: 'utf-8',
663
709
  timeout: 60000,
664
710
  shell: true,
665
711
  stdio: ['pipe', 'pipe', 'pipe'],
712
+ env,
666
713
  });
667
714
  const stdout = (proc.stdout || '').trim();
668
715
  const stderr = (proc.stderr || '').trim();
@@ -9,7 +9,7 @@ import { config, getCurrentProvider, getModelsForCurrentProvider, PROTOCOLS, LAN
9
9
  import { setTelegramToken, clearTelegramToken, hasTelegramToken } from '../utils/telegramCredentials.js';
10
10
  import { getProjectContext } from '../utils/project.js';
11
11
  import { getCurrentVersion } from '../utils/update.js';
12
- import { getProviderList, getProvider, modelSupportsReasoningEffort, reasoningParamsFor, availableReasoningTiers, resolveReasoningTier, REASONING_TIERS } from '../config/providers.js';
12
+ import { getProviderList, getProvider, modelSupportsReasoningEffort, reasoningParamsFor, availableReasoningTiers, resolveReasoningTier, agentTurnReasoningNote, replacementModelFor, REASONING_TIERS } from '../config/providers.js';
13
13
  import { setProjectContext } from '../api/index.js';
14
14
  import { runSkill, runCommandChain } from './agentExecution.js';
15
15
  import { loadProjectIntelligence, saveProjectIntelligence, INTELLIGENCE_NOT_SAVED } from '../utils/projectIntelligence.js';
@@ -301,14 +301,15 @@ export async function handleCommand(command, args, ctx) {
301
301
  ctx.app.notify('Thinking effort: auto — each model uses its own default.');
302
302
  }
303
303
  else if (!supported) {
304
- ctx.app.notify(`Thinking effort set to "${sub}", but ${model} has no graded thinking control — it will be ignored until you switch to a model that does (e.g. Opus 5, GPT-5.x or GPT-6, Gemini 3, DeepSeek V4.1 Flash, GLM-5.x, Kimi K3).`);
304
+ ctx.app.notify(`Thinking effort set to "${sub}", but ${model} has no graded thinking control — it will be ignored until you switch to a model that does (e.g. Opus 5.5, GPT-5.x or GPT-6, Gemini 3, DeepSeek V4, GLM-5.x, Kimi K3).`);
305
305
  }
306
306
  else {
307
307
  // Tell the user what THIS model will actually run (the tier may
308
308
  // collapse onto a level the model distinguishes, e.g. medium→high on Kimi K3).
309
309
  const resolved = resolveReasoningTier(providerId, model, sub);
310
310
  const note = resolved === sub ? '' : ` (${model} runs this as "${resolved}")`;
311
- ctx.app.notify(`Thinking effort: ${sub}${note} — sending ${JSON.stringify(reasoningParamsFor(providerId, model, sub))}.`);
311
+ const agentNote = agentTurnReasoningNote(providerId, model);
312
+ ctx.app.notify(`Thinking effort: ${sub}${note} — sending ${JSON.stringify(reasoningParamsFor(providerId, model, sub))}.${agentNote ? ` ${agentNote}` : ''}`);
312
313
  }
313
314
  break;
314
315
  }
@@ -333,8 +334,11 @@ export async function handleCommand(command, args, ctx) {
333
334
  }
334
335
  if (supported)
335
336
  tLines.push(`**Available** ${available.join(' · ')}`);
337
+ const agentNote = agentTurnReasoningNote(providerId, model);
338
+ if (agentNote)
339
+ tLines.push(`**Agent turns** reasoning off — ${agentNote}`);
336
340
  tLines.push('');
337
- tLines.push('Sets how hard the model reasons. Each model offers only the levels it distinguishes (DeepSeek → high · max; Kimi K3 → low · high · max; Gemini → low · high; Opus/Sonnet & GPT-5.x → the full set). The setting is global and clamps to the active model, so it never sends a value the API rejects. `/effort` is an alias.');
341
+ tLines.push('Sets how hard the model reasons. Each model offers only the levels it distinguishes (DeepSeek & Kimi K3 → low · high · max; Gemini → low · medium · high; Opus/Sonnet & GPT-5.x/6 → the full set). The setting is global and clamps to the active model, so it never sends a value the API rejects. `/effort` is an alias.');
338
342
  ctx.app.addMessage({ role: 'system', content: tLines.join('\n') });
339
343
  break;
340
344
  }
@@ -1215,9 +1219,25 @@ Format: use headers per category, only include categories where you found issues
1215
1219
  ctx.app.notify('Usage: /git-commit <message>');
1216
1220
  return;
1217
1221
  }
1218
- // Use execFile to avoid shell injection — pass commit message as a direct argument
1219
- import('child_process').then(({ execFile }) => {
1220
- execFile('git', ['commit', '-m', message], { cwd: ctx.projectPath, encoding: 'utf-8' }, (err) => {
1222
+ // Use execFile to avoid shell injection — pass commit message as a direct argument.
1223
+ // hardenedGitEnv() on top of that: the repo's own .git/config can name programs
1224
+ // git would run for this commit (core.fsmonitor, core.hooksPath, …).
1225
+ Promise.all([import('child_process'), import('../utils/git.js')]).then(([{ execFile }, { hardenedGitEnv }]) => {
1226
+ // The SAME cwd the commit runs in. Called with no argument, the scan
1227
+ // read process.cwd() instead — so a project opened anywhere other
1228
+ // than the directory Codeep was launched from was "hardened" against
1229
+ // a different repository's config entirely.
1230
+ let env;
1231
+ try {
1232
+ env = hardenedGitEnv({ cwd: ctx.projectPath });
1233
+ }
1234
+ catch (error) {
1235
+ // It refuses rather than hand git a half-scanned environment, and
1236
+ // its message names the key and what to do about it.
1237
+ ctx.app.notify(error instanceof Error ? error.message : 'Commit failed');
1238
+ return;
1239
+ }
1240
+ execFile('git', ['commit', '-m', message], { cwd: ctx.projectPath, encoding: 'utf-8', env }, (err) => {
1221
1241
  if (err) {
1222
1242
  ctx.app.notify(`Commit failed: ${err.message}`);
1223
1243
  }
@@ -1721,14 +1741,19 @@ Format: use headers per category, only include categories where you found issues
1721
1741
  const replacedCount = ctx.app.getMessages().length;
1722
1742
  ctx.app.setMessages(cp.messages);
1723
1743
  saveSession(ctx.sessionId, cp.messages, ctx.projectPath);
1724
- // Switch provider/model back to checkpoint state if different.
1744
+ // Switch provider/model back to checkpoint state if different. A
1745
+ // checkpoint predates any later retirement, so its model goes through the
1746
+ // same map as a stored config (`gpt-6-astra` comes back as `gpt-6-sol`),
1747
+ // looked up on the provider actually active after the switch.
1725
1748
  if (cp.provider && cp.provider !== getCurrentProvider().id)
1726
1749
  setProvider(cp.provider);
1727
- if (cp.model && cp.model !== config.get('model'))
1728
- config.set('model', cp.model);
1750
+ const cpModel = cp.model && (replacementModelFor(config.get('provider'), cp.model) ?? cp.model);
1751
+ if (cpModel && cpModel !== config.get('model'))
1752
+ config.set('model', cpModel);
1753
+ const movedNote = cpModel !== cp.model ? ` (the checkpoint's \`${cp.model}\` is no longer offered)` : '';
1729
1754
  ctx.app.addMessage({
1730
1755
  role: 'system',
1731
- content: `# Rewound to ${cp.name ? `**${cp.name}**` : `\`${cp.id}\``}\n\nRestored ${cp.messages.length} message${cp.messages.length === 1 ? '' : 's'} (was ${replacedCount}). Provider: \`${cp.provider}\` · Model: \`${cp.model}\`\n\n${buildRewindGitHint(cp)}`,
1756
+ content: `# Rewound to ${cp.name ? `**${cp.name}**` : `\`${cp.id}\``}\n\nRestored ${cp.messages.length} message${cp.messages.length === 1 ? '' : 's'} (was ${replacedCount}). Provider: \`${cp.provider}\` · Model: \`${cpModel}\`${movedNote}\n\n${buildRewindGitHint(cp)}`,
1732
1757
  });
1733
1758
  break;
1734
1759
  }
@@ -6,7 +6,31 @@
6
6
  * commands.ts and agent execution in agentExecution.ts.
7
7
  */
8
8
  import { App } from './App';
9
+ import { type GitStatus } from '../utils/git';
9
10
  import type { McpServer } from '../acp/protocol';
11
+ /**
12
+ * What to tell the user when git would not run in this project, or null when
13
+ * there is nothing to tell them.
14
+ *
15
+ * Without it the only symptom is the branch quietly missing from the header,
16
+ * which reads as "not a repository" — so a repository whose own `.git/config`
17
+ * names a program git would run looks like an ordinary folder, and the one
18
+ * thing the user has to do (remove that key) is never said anywhere. The
19
+ * refusal names the key and the `git config --unset` that clears it, so it is
20
+ * passed through verbatim rather than summarised into "git failed".
21
+ *
22
+ * Read off `refusal` and NOT off `error`, which is the field an earlier cut
23
+ * of this used. `error` is every way git can fail in a repository, and the
24
+ * commonest of them is a brand-new `git init` with no commit yet: `git
25
+ * rev-parse --abbrev-ref HEAD` answers `fatal: ambiguous argument 'HEAD'`
26
+ * there (git 2.54), so the first thing a user does in a new project met a
27
+ * warning made of git internals telling them to remove a config key that does
28
+ * not exist. `refusal` is filled on the hardening path and nowhere else, so
29
+ * an ordinary git failure stays as silent as it was before this notice
30
+ * existed — the branch is simply missing from the header, which is what it
31
+ * has always done.
32
+ */
33
+ export declare function gitRefusalNotice(status: GitStatus): string | null;
10
34
  /** Derive a short display name from a user message (first ~5 words, max 48 chars). */
11
35
  export declare function deriveSessionName(message: string): string;
12
36
  /**
@@ -31,8 +31,13 @@ import { symlinkedCodeepNotice } from '../utils/projectPaths.js';
31
31
  // ─── Global state ─────────────────────────────────────────────────────────────
32
32
  let projectPath = process.cwd();
33
33
  /** Cached header branch. Resolved on first use so `--version`/`--help` never
34
- * shell out to git, and cached because getStatus runs on every render frame. */
34
+ * shell out to git, and cached because getStatus runs on every render frame.
35
+ * `refusal` is the message getGitStatus carries back when git would not run
36
+ * here at all — see reportGitRefusal(). */
35
37
  let gitBranchCache = null;
38
+ /** Projects whose refusal has already been put in the transcript, so a cache
39
+ * dropped after every agent run does not repeat it once a minute. */
40
+ const gitRefusalReported = new Set();
36
41
  let projectContext = null;
37
42
  let hasWriteAccess = false;
38
43
  let sessionId = getCurrentSessionId();
@@ -51,10 +56,60 @@ const addedFiles = new Map();
51
56
  * or an agent run finished (an agent can check out a different branch). */
52
57
  function getHeaderBranch() {
53
58
  if (!gitBranchCache || gitBranchCache.path !== projectPath) {
54
- gitBranchCache = { path: projectPath, branch: getGitStatus(projectPath).branch };
59
+ const status = getGitStatus(projectPath);
60
+ const refusal = gitRefusalNotice(status);
61
+ gitBranchCache = { path: projectPath, branch: status.branch, refusal: refusal ?? undefined };
62
+ reportGitRefusal(projectPath, refusal);
55
63
  }
56
64
  return gitBranchCache.branch;
57
65
  }
66
+ /**
67
+ * What to tell the user when git would not run in this project, or null when
68
+ * there is nothing to tell them.
69
+ *
70
+ * Without it the only symptom is the branch quietly missing from the header,
71
+ * which reads as "not a repository" — so a repository whose own `.git/config`
72
+ * names a program git would run looks like an ordinary folder, and the one
73
+ * thing the user has to do (remove that key) is never said anywhere. The
74
+ * refusal names the key and the `git config --unset` that clears it, so it is
75
+ * passed through verbatim rather than summarised into "git failed".
76
+ *
77
+ * Read off `refusal` and NOT off `error`, which is the field an earlier cut
78
+ * of this used. `error` is every way git can fail in a repository, and the
79
+ * commonest of them is a brand-new `git init` with no commit yet: `git
80
+ * rev-parse --abbrev-ref HEAD` answers `fatal: ambiguous argument 'HEAD'`
81
+ * there (git 2.54), so the first thing a user does in a new project met a
82
+ * warning made of git internals telling them to remove a config key that does
83
+ * not exist. `refusal` is filled on the hardening path and nowhere else, so
84
+ * an ordinary git failure stays as silent as it was before this notice
85
+ * existed — the branch is simply missing from the header, which is what it
86
+ * has always done.
87
+ */
88
+ export function gitRefusalNotice(status) {
89
+ const refusal = status.isRepo ? status.refusal : undefined;
90
+ if (!refusal)
91
+ return null;
92
+ return `⚠️ ${refusal}\n\nUntil then the header shows no branch, and everything Codeep does with git here — the status line, \`@git\`, \`/commit\`, the review hook — is off.`;
93
+ }
94
+ /**
95
+ * Put that message in the transcript once per project.
96
+ *
97
+ * A transcript message and not notify(): a toast is gone in three seconds,
98
+ * and this is a thing to act on, not a thing to glance at. Once per project
99
+ * because the cache it rides on is dropped after every agent run, and a
100
+ * warning repeated after each run is one the user learns to scroll past.
101
+ *
102
+ * Queued rather than added inline because the only caller runs inside
103
+ * getStatus(), which the render loop calls — pushing a message onto the list
104
+ * the frame is reading would tear that frame. The microtask lands before the
105
+ * next one.
106
+ */
107
+ function reportGitRefusal(path, notice) {
108
+ if (!notice || gitRefusalReported.has(path))
109
+ return;
110
+ gitRefusalReported.add(path);
111
+ queueMicrotask(() => { app?.addMessage({ role: 'system', content: notice }); });
112
+ }
58
113
  /** Derive a short display name from a user message (first ~5 words, max 48 chars). */
59
114
  export function deriveSessionName(message) {
60
115
  const clean = message.replace(/\s+/g, ' ').trim();
@@ -10,9 +10,26 @@ import { type Personality } from './personalities';
10
10
  export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
11
11
  export type { AgentChatResponse };
12
12
  import { ToolCall, ToolResult, ActionLog } from './tools';
13
+ import { type TrustBearingWrite } from './toolExecution';
13
14
  import { undoLastAction, undoAllActions, getCurrentSession, getRecentSessions, formatSession, ActionSession } from './history';
14
15
  import { VerifyResult } from './verify';
15
16
  import { TaskPlan, SubTask } from './taskPlanner';
17
+ /**
18
+ * The text an assistant turn is kept as in the history sent back next time.
19
+ *
20
+ * The loop stores each turn as plain text, and a turn that only called tools
21
+ * has none: Claude often skips the narration, and Opus 5.5 and Fable 5.1 move
22
+ * it into thinking blocks, which the stream parser does not keep. Stored as
23
+ * '', that turn is an empty non-final message on the next request, which
24
+ * Anthropic's Messages API refuses with a 400 ("all messages must have
25
+ * non-empty content except for the optional final assistant message").
26
+ * agentChat turns that 400 into the text-tool fallback, which sends the same
27
+ * history and fails the same way, so the run died on its second iteration.
28
+ * Naming the tools keeps the turn truthful and non-empty.
29
+ */
30
+ export declare function assistantHistoryText(content: string, toolCalls: ReadonlyArray<{
31
+ tool: string;
32
+ }>): string;
16
33
  export type PermissionOutcome = 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
17
34
  export type PermissionDecision = 'allow-once' | 'allow-always' | 'deny-once' | 'deny-always';
18
35
  /**
@@ -65,7 +82,18 @@ export interface AgentOptions {
65
82
  onVerification?: (results: VerifyResult[]) => void;
66
83
  onTaskPlan?: (plan: TaskPlan) => void;
67
84
  onTaskUpdate?: (task: SubTask) => void;
68
- onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
85
+ /**
86
+ * Ask the user about one tool call.
87
+ *
88
+ * `trustBearing` is what this run already worked out about the call — the
89
+ * file it would write that decides what runs later, or null when it writes
90
+ * no such file. It is passed so the side putting up the dialog can word it
91
+ * without calling trustBearingWrite() a second time: that call stats the
92
+ * path, follows a symlink and may ask git where the repo's hooks live.
93
+ * Optional, so a caller that would rather work it out itself (or was not
94
+ * called from the gate below) still type-checks.
95
+ */
96
+ onRequestPermission?: (toolCall: ToolCall, trustBearing?: TrustBearingWrite | null) => Promise<PermissionOutcome>;
69
97
  /** Tool names to force into the per-run dangerous set, on top of the global
70
98
  * agentConfirm* settings. ACP manual mode passes ['write_file','edit_file']
71
99
  * here to gate them for THIS run only, instead of mutating global config. */
@@ -123,10 +151,13 @@ export interface AgentOptions {
123
151
  roleAddendum?: string;
124
152
  /** "Always allow" / "always deny" answers shared with a delegating parent,
125
153
  * so a sub-agent neither asks again about a tool the user already decided
126
- * on nor runs one the user refused. */
154
+ * on nor runs one the user refused. `alwaysRejectedPaths` holds the same
155
+ * for a single file that decides what runs later, which is refused by name
156
+ * rather than by tool. */
127
157
  permissionMemory?: {
128
158
  alwaysAllowed: Set<string>;
129
159
  alwaysRejected: Set<string>;
160
+ alwaysRejectedPaths?: Set<string>;
130
161
  };
131
162
  /** Provider/model for this run only, used in place of the global selection.
132
163
  * A sub-agent with its own `model:` runs on it this way; the global config