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
@@ -4,16 +4,20 @@ import { randomUUID } from 'crypto';
4
4
  import { basename as pathBasename } from 'path';
5
5
  import { readFile } from 'fs/promises';
6
6
  import { fileURLToPath } from 'url';
7
- import { StdioTransport } from './transport.js';
7
+ import { StdioTransport, AcpRequestError } from './transport.js';
8
8
  import { runAgentSession } from './session.js';
9
9
  import { loadCustomCommands } from '../utils/customCommands.js';
10
10
  import { registerSessionServers, disposeAllSessions as disposeAllMcpSessions } from '../utils/mcpRegistry.js';
11
- import { loadMcpServerConfigSplit, isWorkspaceMcpTrusted, mergeMcpServers } from '../utils/mcpConfig.js';
11
+ import { selectSessionMcpServers } from '../utils/mcpConfig.js';
12
12
  import { handleMcpSamplingRequest } from '../utils/mcpSamplingBridge.js';
13
- import { executeCommandAsync } from '../utils/shell.js';
13
+ import { executeCommandAsync, validateCommandAsync, commandEnv } from '../utils/shell.js';
14
+ import { checkCommandRateLimit } from '../utils/ratelimit.js';
15
+ import { recordCommand } from '../utils/history.js';
16
+ import { trustBearingWrite } from '../utils/toolExecution.js';
14
17
  import { initWorkspace, loadWorkspace, handleCommand } from './commands.js';
18
+ import { beginTurn } from './turns.js';
15
19
  import { handleSetMode as handleSetModeExternal, handleSetConfigOption as handleSetConfigOptionExternal, handleSessionList as handleSessionListExternal, handleSessionDelete as handleSessionDeleteExternal, handleListProviders as handleListProvidersExternal, } from './serverHandlers.js';
16
- import { autoSaveSession, config, getApiKey, getConfiguredProviders } from '../config/index.js';
20
+ import { saveSession, startNewSession, config, getApiKey, getConfiguredProviders } from '../config/index.js';
17
21
  import { ApiError } from '../api/index.js';
18
22
  import { PROVIDERS } from '../config/providers.js';
19
23
  import { getCurrentVersion } from '../utils/update.js';
@@ -65,7 +69,7 @@ const AVAILABLE_COMMANDS = [
65
69
  { name: 'insights', description: 'Activity summary over the last N days (default 7)', input: { hint: '[--days N]' } },
66
70
  // Project intelligence
67
71
  { name: 'scan', description: 'Scan project structure and generate summary' },
68
- { name: 'review', description: 'Run code review on project or specific files', input: { hint: '[file…]' } },
72
+ { name: 'review', description: 'AI review of git changes (--staged), or static analysis (--static / files)', input: { hint: '[--staged | --static | file…]' } },
69
73
  { name: 'learn', description: 'Learn coding preferences from project files' },
70
74
  { name: 'memory', description: 'Project memory notes — add / list / remove / clear', input: { hint: '<note> | list | remove <n> | clear' } },
71
75
  { name: 'profile', description: 'Save / load / delete provider+model presets', input: { hint: 'save | load | delete | list | <name>' } },
@@ -395,9 +399,336 @@ export function resolvePersonalitySelection(personalityId, workspaceRoot) {
395
399
  const personality = findPersonality(personalityId, workspaceRoot);
396
400
  return personality && isPersonalityAvailable(personality, workspaceRoot) ? personality : null;
397
401
  }
398
- // ─── Server ───────────────────────────────────────────────────────────────────
399
- export function startAcpServer() {
400
- const transport = new StdioTransport();
402
+ /** Per-command budget, the same one a local run gets. */
403
+ export const ACP_COMMAND_TIMEOUT_MS = 120_000;
404
+ /**
405
+ * Read the exit code from a terminal/wait_for_exit answer. ACP sends
406
+ * `{ exitCode, signal }`; older clients nest `{ type, code }` under
407
+ * `exitStatus`. Returns null when the answer carries no exit status.
408
+ *
409
+ * Exported for unit testing (see server.command.test.ts).
410
+ */
411
+ export function exitCodeFromWaitResult(result) {
412
+ if (!result || typeof result !== 'object')
413
+ return null;
414
+ const outer = result;
415
+ const status = (outer.exitStatus && typeof outer.exitStatus === 'object' ? outer.exitStatus : outer);
416
+ if (typeof status.exitCode === 'number')
417
+ return status.exitCode;
418
+ if (status.type === 'exited' && typeof status.code === 'number')
419
+ return status.code;
420
+ // Ended by a signal
421
+ if (status.type === 'killed' || (typeof status.signal === 'string' && status.signal))
422
+ return 1;
423
+ return null;
424
+ }
425
+ /**
426
+ * `GIT_CONFIG_COUNT` and the `GIT_CONFIG_KEY_<n>` / `GIT_CONFIG_VALUE_<n>`
427
+ * pairs it counts — the numbered half of what hardenedGitEnv() produces.
428
+ */
429
+ const GIT_CONFIG_ENV_NAME = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/;
430
+ /**
431
+ * The rest of what the terminal has to be given by name — see acpEnvList().
432
+ *
433
+ * `GIT_PAGER` and `GIT_TERMINAL_PROMPT` are hardenedGitEnv()'s two
434
+ * non-numbered outputs. `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` /
435
+ * `GIT_CONFIG_NOSYSTEM` are the variables the SCAN itself read the config
436
+ * through: leaving them behind would let the terminal's git resolve a
437
+ * different global config than the one Codeep just decided was safe.
438
+ *
439
+ * Everything after them is there for a client that REPLACES its environment
440
+ * with this list rather than extending it, and it is the half the first cut
441
+ * of this hotfix got wrong: the list was `PATH` and `HOME` — enough for the
442
+ * hardened `git status` it was written for — while acpEnvList() is applied
443
+ * to EVERY command handed to terminal/create. Against a replacing client
444
+ * that cost an ordinary command things that are not secrets and that it had
445
+ * in the user's own shell: `git push` over SSH had no agent socket to sign
446
+ * with and fell back to asking for a password on a terminal whose
447
+ * GIT_TERMINAL_PROMPT is `0`, which fails it outright; a test that sorts
448
+ * strings or formats a date ran under the C locale instead of the user's; a
449
+ * build had nowhere but the default /tmp to put its temporaries; anything
450
+ * behind a corporate proxy could not reach the network at all; and a
451
+ * toolchain installed under a version manager lost the variable its shim
452
+ * reads to pick a version.
453
+ *
454
+ * It stays an ALLOWLIST rather than becoming "process.env minus the names
455
+ * that look like credentials", because what made the change necessary is
456
+ * that `env` is not a private channel (see acpEnvList) and a name-shaped
457
+ * denylist does not recognise `DATABASE_URL`, a company's own `ACME_CREDS`,
458
+ * or anything else whose name does not say what it holds. A name gets in
459
+ * here only when it is known not to hold one.
460
+ *
461
+ * The one value below that CAN carry a credential is a proxy URL
462
+ * (`https_proxy=http://user:pass@proxy`), which is why redactCredentials()
463
+ * in src/acp/transport.ts has a rule for that exact shape. A command behind
464
+ * a proxy cannot reach the network without it.
465
+ */
466
+ const ACP_TERMINAL_ENV_NAMES = new Set([
467
+ // hardenedGitEnv()'s own, and what the scan read the config through.
468
+ 'GIT_PAGER',
469
+ 'GIT_TERMINAL_PROMPT',
470
+ 'GIT_CONFIG_GLOBAL',
471
+ 'GIT_CONFIG_SYSTEM',
472
+ 'GIT_CONFIG_NOSYSTEM',
473
+ // Where programs are found, and whose account runs them. No `PWD`: the
474
+ // terminal's working directory is the `cwd` of the terminal/create, and
475
+ // this process's would tell a shell script it is somewhere it is not.
476
+ 'PATH',
477
+ 'HOME',
478
+ 'SHELL',
479
+ 'USER',
480
+ 'LOGNAME',
481
+ // The agent socket `git push` and `git fetch` over SSH sign with.
482
+ 'SSH_AUTH_SOCK',
483
+ // Where a build puts its temporaries.
484
+ 'TMPDIR',
485
+ 'TMP',
486
+ 'TEMP',
487
+ // The locale a test that sorts strings or formats a date asserts against.
488
+ 'LANG',
489
+ 'LANGUAGE',
490
+ // What the command may draw with, and what it thinks the time is.
491
+ 'TERM',
492
+ 'COLORTERM',
493
+ 'TERM_PROGRAM',
494
+ 'TZ',
495
+ // Toolchains under a version manager: the shim is on PATH, but the shim
496
+ // reads one of these to find the version to run.
497
+ 'ASDF_DIR',
498
+ 'ASDF_DATA_DIR',
499
+ 'NVM_DIR',
500
+ 'NVM_BIN',
501
+ 'PYENV_ROOT',
502
+ 'RBENV_ROOT',
503
+ 'SDKMAN_DIR',
504
+ 'VOLTA_HOME',
505
+ 'PNPM_HOME',
506
+ 'BUN_INSTALL',
507
+ 'VIRTUAL_ENV',
508
+ 'CONDA_PREFIX',
509
+ 'CARGO_HOME',
510
+ 'RUSTUP_HOME',
511
+ 'GOPATH',
512
+ 'GOROOT',
513
+ 'JAVA_HOME',
514
+ ]);
515
+ /**
516
+ * The same allowlist for the two families whose members cannot be listed:
517
+ * the locale categories (`LC_ALL`, `LC_TIME`, `LC_COLLATE`, …), and the
518
+ * proxy variables, which every tool spells in whichever case it was written
519
+ * in — curl and most of Unix read the lowercase ones, Windows-born tools the
520
+ * uppercase, and a machine behind a proxy usually sets both.
521
+ */
522
+ const ACP_TERMINAL_ENV_FAMILY = /^(?:LC_[A-Z]+|(?:HTTP|HTTPS|FTP|ALL|NO)_PROXY|(?:http|https|ftp|all|no)_proxy)$/;
523
+ /**
524
+ * An environment in the shape `terminal/create` takes it: ACP spells it as a
525
+ * list of `{ name, value }`, not as the map Node keeps in `process.env`.
526
+ *
527
+ * An allowlist goes in it — the hardening's own variables and the shell
528
+ * essentials above — and not the WHOLE of `process.env`, which is what this
529
+ * used to serialise. `env` is not a private channel: src/acp/transport.ts
530
+ * mirrors every outbound frame verbatim into ~/.cache/codeep/acp-debug.log
531
+ * when CODEEP_ACP_DEBUG is set, so every `ANTHROPIC_API_KEY`,
532
+ * `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY` and session cookie in the user's
533
+ * shell was written to a plaintext file on disk — and handed to the editor,
534
+ * which is free to log the protocol traffic itself. None of them makes the
535
+ * hardening work; the numbered GIT_CONFIG_* pairs do.
536
+ *
537
+ * What else the terminal inherits is the CLIENT's decision, not ours: ACP
538
+ * does not say whether `env` extends the client's environment or replaces it.
539
+ * A client that extends gives the command the user's shell environment
540
+ * anyway; a client that replaces gives it only this list — which is why the
541
+ * list has to cover what an ORDINARY command needs to run at all and not
542
+ * only what a hardened git spawn does. A terminal that got `GIT_CONFIG_COUNT`
543
+ * and nothing else would be running without a PATH.
544
+ *
545
+ * Unset variables are dropped rather than sent as `value: undefined` — that
546
+ * is what `process.env` holds for a variable that is not set, and JSON has no
547
+ * way to carry it.
548
+ *
549
+ * `hardened` says whether commandEnv() actually hardened this spawn, which it
550
+ * does for `git` and for nothing else. It only decides the last entry below:
551
+ * a command that cannot reach git is left with the environment it would have
552
+ * had, which is the same line the refusal path draws.
553
+ */
554
+ function acpEnvList(env, hardened) {
555
+ const list = Object.entries(env)
556
+ .filter((entry) => typeof entry[1] === 'string')
557
+ .filter(([name]) => ACP_TERMINAL_ENV_NAMES.has(name)
558
+ || ACP_TERMINAL_ENV_FAMILY.test(name)
559
+ || GIT_CONFIG_ENV_NAME.test(name))
560
+ .map(([name, value]) => ({ name, value }));
561
+ // The one thing this list cannot express is a REMOVAL — an absent name is
562
+ // not a request to unset one — and hardenedGitEnv() removes exactly one
563
+ // variable, so it is sent EMPTY instead.
564
+ //
565
+ // `GIT_CONFIG_PARAMETERS` is read after the numbered `GIT_CONFIG_*` pairs
566
+ // and beats them: with `GIT_CONFIG_KEY_0=core.fsmonitor` and an empty value
567
+ // right there, a `GIT_CONFIG_PARAMETERS='core.fsmonitor=<program>'` still
568
+ // ran the program on `git status` (verified, git 2.54). So against a client
569
+ // that EXTENDS its own environment rather than replacing it, one variable
570
+ // the editor happened to inherit switched this whole hardening off. Git
571
+ // parses an empty value as no parameters at all (verified, same version),
572
+ // which is the unset this list has no other way to ask for.
573
+ if (hardened)
574
+ list.push({ name: 'GIT_CONFIG_PARAMETERS', value: '' });
575
+ return list;
576
+ }
577
+ /**
578
+ * Run an execute_command tool call for an ACP session, in the client's
579
+ * terminal when it offers one, otherwise locally.
580
+ *
581
+ * Never throws: the agent loop reports a throw to the model as a failed
582
+ * command, which hides whether the client terminal already ran it. A
583
+ * failure comes back as exitCode -1 with the reason instead.
584
+ *
585
+ * Exported for unit testing (see server.command.test.ts).
586
+ */
587
+ export async function executeAcpCommand(command, args, cwd, ctx) {
588
+ const fail = (stderr, stdout = '') => ({ stdout, stderr, exitCode: -1 });
589
+ const timeoutMs = ctx.timeoutMs ?? ACP_COMMAND_TIMEOUT_MS;
590
+ // A cancelled prompt runs nothing more, wherever it would run, and a
591
+ // command that never ran is neither recorded nor counted.
592
+ if (ctx.signal.aborted)
593
+ return fail('Command cancelled');
594
+ // The same checks and bookkeeping as a local execute_command. They come
595
+ // before the terminal branch: the client's terminal is another place to
596
+ // run the command, not a way around the whitelist, the blocked patterns
597
+ // or the SSRF guard.
598
+ const validation = await validateCommandAsync(command, args, { cwd, projectRoot: cwd });
599
+ if (!validation.valid)
600
+ return fail(validation.reason || 'Command validation failed');
601
+ const cmdRate = checkCommandRateLimit();
602
+ if (!cmdRate.allowed)
603
+ return fail(cmdRate.message || 'Command rate limit exceeded');
604
+ recordCommand(command, args);
605
+ // Cancelling the prompt kills a local command too, and reports it as the
606
+ // client terminal branch does.
607
+ const runLocally = async () => {
608
+ const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: timeoutMs, signal: ctx.signal });
609
+ if (r.cancelled)
610
+ return fail('Command cancelled', r.stdout ?? '');
611
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
612
+ };
613
+ // Per ACP spec, only call terminal/* if the client advertised the
614
+ // capability in initialize. Otherwise execute locally.
615
+ if (!ctx.clientSupportsTerminal)
616
+ return runLocally();
617
+ // The client's terminal is a spawn like any other, and it inherits none of
618
+ // the hardening executeCommandAsync puts on the local one — so over ACP,
619
+ // which is how Zed runs Codeep, a `git status` in a hostile repository ran
620
+ // that repository's `core.fsmonitor` and `filter.<d>.clean` exactly as it
621
+ // did before this hotfix. The validation above stops the argv forms that
622
+ // redirect git, but nothing was stopping its config.
623
+ //
624
+ // Same helper as the local runner, so there is one answer to "what does a
625
+ // spawn that may reach git run with" — see commandEnv(). A refusal fails
626
+ // the command with git's own wording rather than handing it to a terminal
627
+ // this process cannot harden.
628
+ //
629
+ // commandEnv() and NOT shellCommandEnv(): this call site has the argv, and
630
+ // shellCommandEnv() can only scan the spawn's `cwd`. That made one argument
631
+ // the whole difference between the two runners — `git -C vendor/lib status`
632
+ // over ACP got the outer project scanned, so the nested checkout was left
633
+ // with only the always-on GIT_EXECUTING_CONFIG pairs behind it and its
634
+ // `filter.<driver>.clean` still ran (proven, git 2.54), while the same
635
+ // command locally reads `-C` out of the argv and scans where git will
636
+ // actually run.
637
+ let env;
638
+ try {
639
+ env = commandEnv(command, args, cwd);
640
+ }
641
+ catch (error) {
642
+ return fail(error instanceof Error ? error.message : String(error));
643
+ }
644
+ const { transport, sessionId, signal } = ctx;
645
+ let terminalId;
646
+ try {
647
+ const created = await transport.request('terminal/create', {
648
+ sessionId,
649
+ command,
650
+ args,
651
+ cwd,
652
+ env: acpEnvList(env, command === 'git'),
653
+ outputByteLimit: 1_000_000,
654
+ });
655
+ if (!created || typeof created.terminalId !== 'string') {
656
+ return fail('terminal/create returned no terminalId');
657
+ }
658
+ terminalId = created.terminalId;
659
+ }
660
+ catch (err) {
661
+ // The client refused to create the terminal, so nothing ran there and
662
+ // running the command here cannot run it twice. Without an answer that
663
+ // is unknown, so report the failure instead.
664
+ if (err instanceof AcpRequestError)
665
+ return runLocally();
666
+ return fail(`Client terminal unavailable: ${err.message}`);
667
+ }
668
+ const ref = { sessionId, terminalId };
669
+ // Stop waiting when the prompt is cancelled or the command outlives its
670
+ // budget. The transport's own timeout does not apply here: the command,
671
+ // not the client, decides how long wait_for_exit takes.
672
+ const stopWait = new AbortController();
673
+ let stopped = null;
674
+ const stop = (why) => {
675
+ stopped ??= why;
676
+ stopWait.abort();
677
+ };
678
+ const onAbort = () => stop('cancelled');
679
+ if (signal.aborted)
680
+ onAbort();
681
+ else
682
+ signal.addEventListener('abort', onAbort, { once: true });
683
+ const timer = setTimeout(() => stop('timeout'), timeoutMs);
684
+ try {
685
+ let exitCode = null;
686
+ let waitError = '';
687
+ try {
688
+ // Spec method is snake_case `terminal/wait_for_exit` and takes
689
+ // only { sessionId, terminalId } — no timeoutMs.
690
+ const waitResult = await transport.request('terminal/wait_for_exit', ref, { timeoutMs: 0, signal: stopWait.signal });
691
+ exitCode = exitCodeFromWaitResult(waitResult);
692
+ if (exitCode === null)
693
+ waitError = 'terminal/wait_for_exit returned no exit status';
694
+ }
695
+ catch (err) {
696
+ if (!stopped)
697
+ waitError = err.message;
698
+ }
699
+ if (stopped === 'cancelled') {
700
+ transport.request('terminal/kill', ref).catch(() => null);
701
+ return fail('Command cancelled');
702
+ }
703
+ if (stopped === 'timeout') {
704
+ await transport.request('terminal/kill', ref).catch(() => null);
705
+ }
706
+ const outputResult = await transport.request('terminal/output', ref).catch(() => null);
707
+ const output = typeof outputResult?.output === 'string' ? outputResult.output : '';
708
+ if (stopped === 'timeout')
709
+ return fail(`Command timed out after ${timeoutMs}ms`, output);
710
+ if (exitCode === null)
711
+ return fail(`Client terminal failed: ${waitError}`, output);
712
+ return { stdout: output, stderr: '', exitCode };
713
+ }
714
+ finally {
715
+ clearTimeout(timer);
716
+ signal.removeEventListener('abort', onAbort);
717
+ transport.request('terminal/release', ref).catch(() => null);
718
+ }
719
+ }
720
+ /**
721
+ * Save a session's history under its own id. The id travels with the ACP
722
+ * session (a reopened thread, a second thread in the same editor), so the
723
+ * global current-session id may name another conversation. Written right
724
+ * away: the editor may stop the agent at any moment after a turn.
725
+ */
726
+ function persistSessionHistory(session) {
727
+ if (!config.get('autoSave') || session.history.length === 0)
728
+ return;
729
+ saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
730
+ }
731
+ export function startAcpServer(transport = new StdioTransport()) {
401
732
  // ACP sessionId → full AcpSession (includes history + codeep session tracking)
402
733
  const sessions = new Map();
403
734
  // Shared deps object for the extracted handlers in serverHandlers.ts.
@@ -427,11 +758,13 @@ export function startAcpServer() {
427
758
  if (process.listenerCount(sig) === 0)
428
759
  process.on(sig, onShutdown);
429
760
  }
761
+ // A handler that throws, or an async one that rejects, is answered with a
762
+ // JSON-RPC error by the transport — so async handlers return their promise.
430
763
  transport.start((msg) => {
431
764
  // Notifications have no id — handle separately
432
765
  if (!('id' in msg)) {
433
766
  handleNotification(msg);
434
- return;
767
+ return undefined;
435
768
  }
436
769
  const req = msg;
437
770
  switch (req.method) {
@@ -451,9 +784,7 @@ export function startAcpServer() {
451
784
  case 'session/resume':
452
785
  handleSessionResume(req);
453
786
  break;
454
- case 'session/prompt':
455
- handleSessionPrompt(req);
456
- break;
787
+ case 'session/prompt': return handleSessionPrompt(req);
457
788
  case 'session/set_mode':
458
789
  handleSetMode(req);
459
790
  break;
@@ -475,19 +806,21 @@ export function startAcpServer() {
475
806
  case 'session/set_personality':
476
807
  handleSetPersonality(req);
477
808
  break;
478
- case 'session/sync_personalities':
479
- handleSyncPersonalities(req);
480
- break;
809
+ case 'session/sync_personalities': return handleSyncPersonalities(req);
481
810
  default:
482
811
  process.stderr.write(`[codeep-acp] Unknown method: ${req.method}\n`);
483
812
  transport.error(req.id, -32601, `Method not found: ${req.method}`);
484
813
  }
814
+ return undefined;
485
815
  });
486
816
  // ── Notification handler (no id, no response) ──────────────────────────────
487
817
  function handleNotification(msg) {
488
818
  if (msg.method === 'session/cancel') {
489
819
  const { sessionId } = (msg.params ?? {});
490
- sessions.get(sessionId)?.abortController?.abort();
820
+ const session = sessions.get(sessionId);
821
+ if (session)
822
+ for (const controller of session.activePrompts)
823
+ controller.abort();
491
824
  }
492
825
  }
493
826
  // ── initialize ──────────────────────────────────────────────────────────────
@@ -581,17 +914,14 @@ export function startAcpServer() {
581
914
  // with the repo, so they spawn only for workspaces the user has trusted
582
915
  // (same gate the TUI prompts for at startup). ACP-provided servers are
583
916
  // the editor's own config and global ~/.codeep entries are the user's —
584
- // both spawn unconditionally.
585
- const { global: globalServers, workspace: workspaceServers } = loadMcpServerConfigSplit(cwd);
586
- const workspaceTrusted = isWorkspaceMcpTrusted(cwd);
587
- if (workspaceServers.length > 0 && !workspaceTrusted) {
588
- process.stderr.write(`[codeep-acp] MCP (${label}): skipped ${workspaceServers.length} workspace server(s) — untrusted workspace. ` +
917
+ // both spawn unconditionally. /mcp in commands.ts selects the same way.
918
+ const { servers: merged, skipped } = selectSessionMcpServers(cwd, { fromClient: acpServers });
919
+ if (skipped.length > 0) {
920
+ process.stderr.write(`[codeep-acp] MCP (${label}): skipped ${skipped.length} workspace server(s) — untrusted workspace. ` +
589
921
  `Run /mcp trust (or \`codeep\` in the repo once) to enable.\n`);
590
922
  }
591
- const fromConfig = workspaceTrusted ? [...globalServers, ...workspaceServers] : globalServers;
592
- const merged = mergeMcpServers(fromConfig, acpServers);
593
- if (merged.length === 0)
594
- return;
923
+ // Registered even when empty: on a reload that stops the servers the
924
+ // session no longer has.
595
925
  registerSessionServers(acpSessionId, merged, {
596
926
  workspaceRoot: cwd,
597
927
  // Servers that opted into the `sampling` capability can ask us to
@@ -612,19 +942,48 @@ export function startAcpServer() {
612
942
  .catch(err => process.stderr.write(`[codeep-acp] MCP registration crashed (${label}): ${err.message}\n`));
613
943
  }
614
944
  // ── session/new ─────────────────────────────────────────────────────────────
945
+ /**
946
+ * Check the fields a session request cannot do without, answering
947
+ * -32602 for the first one missing. Without this a missing cwd surfaced
948
+ * as a raw Node error, and a missing sessionId registered a session
949
+ * under `undefined`.
950
+ */
951
+ function hasSessionParams(msg, needSessionId) {
952
+ const p = (msg.params ?? {});
953
+ if (needSessionId && (typeof p.sessionId !== 'string' || !p.sessionId)) {
954
+ transport.error(msg.id, -32602, 'sessionId is required');
955
+ return false;
956
+ }
957
+ if (typeof p.cwd !== 'string' || !p.cwd) {
958
+ transport.error(msg.id, -32602, 'cwd is required');
959
+ return false;
960
+ }
961
+ return true;
962
+ }
615
963
  function handleSessionNew(msg) {
964
+ if (!hasSessionParams(msg, false))
965
+ return;
616
966
  const params = msg.params;
617
967
  const acpSessionId = randomUUID();
618
- // Spin up MCP servers in the background. Errors surface via /mcp.
619
- spawnMcpServersForSession(acpSessionId, params.cwd, params.mcpServers, 'session/new');
620
- const { codeepSessionId, history, welcomeText } = initWorkspace(params.cwd, params.fresh);
968
+ const workspace = initWorkspace(params.cwd, params.fresh);
969
+ const { history } = workspace;
970
+ let { codeepSessionId, welcomeText } = workspace;
971
+ // Two threads bound to one saved conversation would overwrite each
972
+ // other's file on every turn: a resume of a conversation that another
973
+ // thread already has open continues under a new name.
974
+ const inUse = [...sessions.values()].some((s) => s.codeepSessionId === codeepSessionId && s.workspaceRoot === params.cwd);
975
+ if (inUse) {
976
+ codeepSessionId = startNewSession();
977
+ welcomeText += `\n\n_That conversation is open in another thread, so this one continues as \`${codeepSessionId}\`._`;
978
+ }
621
979
  sessions.set(acpSessionId, {
622
980
  sessionId: acpSessionId,
623
981
  workspaceRoot: params.cwd,
624
982
  history,
625
983
  codeepSessionId,
626
984
  addedFiles: new Map(),
627
- abortController: null,
985
+ clientMcpServers: params.mcpServers,
986
+ activePrompts: new Set(),
628
987
  currentModeId: 'auto',
629
988
  titleSent: false,
630
989
  hadHistory: history.length > 0,
@@ -638,6 +997,10 @@ export function startAcpServer() {
638
997
  modes: AGENT_MODES,
639
998
  configOptions: buildConfigOptions(),
640
999
  };
1000
+ // Spin up MCP servers in the background. Errors surface via /mcp.
1001
+ // Started only now: if setting up the session throws, the client gets
1002
+ // an error and no session id, so nothing could ever dispose them.
1003
+ spawnMcpServersForSession(acpSessionId, params.cwd, params.mcpServers, 'session/new');
641
1004
  transport.respond(msg.id, result);
642
1005
  // Advertise slash commands AFTER a short delay. Zed processes
643
1006
  // `AvailableCommandsUpdated` events synchronously and silently drops them
@@ -698,6 +1061,8 @@ export function startAcpServer() {
698
1061
  }
699
1062
  // ── session/load ────────────────────────────────────────────────────────────
700
1063
  function handleSessionLoad(msg) {
1064
+ if (!hasSessionParams(msg, true))
1065
+ return;
701
1066
  const params = msg.params;
702
1067
  // Try to restore existing Codeep session or fall back to fresh workspace
703
1068
  const existing = sessions.get(params.sessionId);
@@ -706,17 +1071,24 @@ export function startAcpServer() {
706
1071
  existing.workspaceRoot = params.cwd;
707
1072
  // Re-spawn any MCP servers the client passed in (they may have changed
708
1073
  // since session/new; old ones get disposed by registerSessionServers).
709
- spawnMcpServersForSession(params.sessionId, params.cwd, params.mcpServers, 'session/load (warm)');
1074
+ existing.clientMcpServers = params.mcpServers ?? existing.clientMcpServers;
1075
+ spawnMcpServersForSession(params.sessionId, params.cwd, existing.clientMcpServers, 'session/load (warm)');
1076
+ // A load replays the conversation, warm or cold: clients such as the
1077
+ // VS Code extension clear the chat and show what this returns.
710
1078
  const result = {
1079
+ sessionId: params.sessionId,
1080
+ history: existing.history.filter(m => m.role === 'user' || m.role === 'assistant'),
711
1081
  modes: AGENT_MODES,
712
1082
  configOptions: buildConfigOptions(),
713
1083
  };
714
1084
  transport.respond(msg.id, result);
715
1085
  return;
716
1086
  }
717
- // Session not in memory — try to load from disk
1087
+ // Session not in memory — try to load from disk. It is registered under
1088
+ // the id the client asked for: ACP clients keep using that id for
1089
+ // prompts and route updates by it.
718
1090
  const { codeepSessionId, history, welcomeText } = loadWorkspace(params.cwd, params.sessionId);
719
- const acpSessionId = randomUUID();
1091
+ const acpSessionId = params.sessionId;
720
1092
  spawnMcpServersForSession(acpSessionId, params.cwd, params.mcpServers, 'session/load (cold)');
721
1093
  sessions.set(acpSessionId, {
722
1094
  sessionId: acpSessionId,
@@ -724,7 +1096,8 @@ export function startAcpServer() {
724
1096
  history,
725
1097
  codeepSessionId,
726
1098
  addedFiles: new Map(),
727
- abortController: null,
1099
+ clientMcpServers: params.mcpServers,
1100
+ activePrompts: new Set(),
728
1101
  titleSent: true,
729
1102
  hadHistory: history.length > 0,
730
1103
  currentModeId: 'auto',
@@ -740,10 +1113,10 @@ export function startAcpServer() {
740
1113
  // session/new — see sendCommandsDelayed comment).
741
1114
  sendCommandsDelayed(acpSessionId, params.cwd);
742
1115
  // Send title immediately so Zed "Recent" panel shows something useful
743
- sendSessionTitle(params.sessionId, history, pathBasename(params.cwd));
1116
+ sendSessionTitle(acpSessionId, history, pathBasename(params.cwd));
744
1117
  // Send restored session welcome
745
1118
  transport.notify('session/update', {
746
- sessionId: params.sessionId,
1119
+ sessionId: acpSessionId,
747
1120
  update: {
748
1121
  sessionUpdate: 'agent_message_chunk',
749
1122
  content: { type: 'text', text: welcomeText },
@@ -756,13 +1129,16 @@ export function startAcpServer() {
756
1129
  // (modes + config). No history replay → instant reconnect on UI reload.
757
1130
  // Falls back to `session/load` semantics if the session isn't in memory yet.
758
1131
  function handleSessionResume(msg) {
1132
+ if (!hasSessionParams(msg, true))
1133
+ return;
759
1134
  const params = msg.params;
760
1135
  const existing = sessions.get(params.sessionId);
761
1136
  if (existing) {
762
1137
  existing.workspaceRoot = params.cwd;
763
1138
  // Resume can carry an updated mcpServers list (e.g. workspace switched
764
1139
  // config) — re-register so old servers are torn down and new ones spawn.
765
- spawnMcpServersForSession(params.sessionId, params.cwd, params.mcpServers, 'session/resume (warm)');
1140
+ existing.clientMcpServers = params.mcpServers ?? existing.clientMcpServers;
1141
+ spawnMcpServersForSession(params.sessionId, params.cwd, existing.clientMcpServers, 'session/resume (warm)');
766
1142
  const result = {
767
1143
  sessionId: params.sessionId,
768
1144
  modes: AGENT_MODES,
@@ -775,8 +1151,9 @@ export function startAcpServer() {
775
1151
  }
776
1152
  // Session not in memory — load from disk but skip the welcome banner and
777
1153
  // the history echo (resume contract: client already has history).
1154
+ // Registered under the requested id, as in session/load.
778
1155
  const { codeepSessionId, history } = loadWorkspace(params.cwd, params.sessionId);
779
- const acpSessionId = randomUUID();
1156
+ const acpSessionId = params.sessionId;
780
1157
  spawnMcpServersForSession(acpSessionId, params.cwd, params.mcpServers, 'session/resume (cold)');
781
1158
  sessions.set(acpSessionId, {
782
1159
  sessionId: acpSessionId,
@@ -784,7 +1161,8 @@ export function startAcpServer() {
784
1161
  history,
785
1162
  codeepSessionId,
786
1163
  addedFiles: new Map(),
787
- abortController: null,
1164
+ clientMcpServers: params.mcpServers,
1165
+ activePrompts: new Set(),
788
1166
  titleSent: true,
789
1167
  hadHistory: history.length > 0,
790
1168
  currentModeId: 'auto',
@@ -954,7 +1332,7 @@ export function startAcpServer() {
954
1332
  });
955
1333
  session.history.push({ role: 'user', content: prompt ? `[Image] ${prompt}` : '[Image pasted from clipboard]' });
956
1334
  session.history.push({ role: 'assistant', content: description });
957
- autoSaveSession(session.history, session.workspaceRoot);
1335
+ persistSessionHistory(session);
958
1336
  }
959
1337
  catch (err) {
960
1338
  transport.notify('session/update', {
@@ -965,7 +1343,7 @@ export function startAcpServer() {
965
1343
  return;
966
1344
  }
967
1345
  const abortController = new AbortController();
968
- session.abortController = abortController;
1346
+ session.activePrompts.add(abortController);
969
1347
  // Plan tracking: build a live plan from tool calls as the agent works
970
1348
  // ACP spec: send complete list on every update, client replaces current plan
971
1349
  const planEntries = new Map();
@@ -978,10 +1356,6 @@ export function startAcpServer() {
978
1356
  },
979
1357
  });
980
1358
  };
981
- // Manual mode gates write/edit for THIS run via a per-call option passed to
982
- // runAgentSession (extraDangerousTools, below) — NOT by mutating the global
983
- // `agentConfirmWriteFile` config, which leaked the session's mode into the
984
- // TUI/other processes and raced on a non-atomic restore.
985
1359
  const agentResponseChunks = [];
986
1360
  const sendChunk = (text) => {
987
1361
  agentResponseChunks.push(text);
@@ -993,6 +1367,141 @@ export function startAcpServer() {
993
1367
  },
994
1368
  });
995
1369
  };
1370
+ // Ask the user through the client. A person answers this: wait as long
1371
+ // as the dialog is open. Only cancelling the prompt stops the wait. No
1372
+ // answer (error, cancelled prompt, a reply without an outcome) is null,
1373
+ // which callers must treat as a refusal.
1374
+ const askUser = (toolCall, options) => transport.request('session/request_permission', {
1375
+ sessionId: params.sessionId,
1376
+ toolCall,
1377
+ options,
1378
+ }, {
1379
+ timeoutMs: 0,
1380
+ signal: abortController.signal,
1381
+ }).then((reply) => {
1382
+ const outcome = reply?.outcome;
1383
+ return outcome && typeof outcome === 'object' ? reply : null;
1384
+ }, () => null);
1385
+ // How the agent runs for this prompt. Built once so slash commands that
1386
+ // run the agent (/go, custom commands, skill agent steps) run it exactly
1387
+ // like a plain prompt.
1388
+ const manualMode = session.currentModeId === 'manual';
1389
+ // The one permission dialog this session puts in front of the user.
1390
+ const askAboutToolCall = async (toolCall,
1391
+ // What the agent gate already worked out about this call. Passed rather
1392
+ // than worked out again: trustBearingWrite() stats the path, resolves a
1393
+ // symlinked ancestor and may ask git where this repository keeps its
1394
+ // hooks. `null` is an answer ("writes no such file"); undefined means
1395
+ // the question came from somewhere that has not looked, which is the
1396
+ // only case that pays for the lookup here.
1397
+ known) => {
1398
+ // A write to a file that decides what runs later says so in the
1399
+ // dialog — the editor shows `toolInput`, and "this file controls what
1400
+ // commands git runs" is the part that makes the answer an informed one.
1401
+ const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, session.workspaceRoot);
1402
+ const result = await askUser({
1403
+ toolCallId: `perm_${randomUUID()}`,
1404
+ toolName: toolCall.tool,
1405
+ toolInput: {
1406
+ ...formatToolInputForPermission(toolCall.tool, toolCall.parameters),
1407
+ ...(trustBearing ? { warning: trustBearing.reason } : {}),
1408
+ },
1409
+ status: 'pending',
1410
+ content: [],
1411
+ }, [
1412
+ { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
1413
+ // No "always" for one of those files: the agent answers about this
1414
+ // file only and would not remember the answer anyway.
1415
+ ...(trustBearing ? [] : [{ optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' }]),
1416
+ { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
1417
+ { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
1418
+ ]);
1419
+ // Map ACP outcome back to PermissionOutcome. No answer
1420
+ // (error, cancelled prompt) denies.
1421
+ if (!result || result.outcome.type === 'cancelled')
1422
+ return 'reject_once';
1423
+ return result.outcome.optionId;
1424
+ };
1425
+ // Auto mode's answer to the agent's permission gate: yes to everything
1426
+ // except a write to a file that decides what runs later, which is asked
1427
+ // about in every mode. Without it the agent would have to refuse those
1428
+ // writes outright, having nobody to ask. It travels under its own key on
1429
+ // `agentRun` and never as `onRequestPermission`: a slash command reads
1430
+ // that key being set as "this session asks the user" (see
1431
+ // acp/commands.ts), and auto mode still runs a skill's shell lines
1432
+ // without asking, as it promises.
1433
+ const autoModeAnswer = async (toolCall, known) => {
1434
+ const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, session.workspaceRoot);
1435
+ // Handed on, so the dialog does not look the same file up a third time.
1436
+ return trustBearing ? askAboutToolCall(toolCall, trustBearing) : 'allow_once';
1437
+ };
1438
+ const agentRun = {
1439
+ // Manual mode gates write_file/edit_file for this run only, per call —
1440
+ // NOT by mutating the global `agentConfirmWriteFile` config, which
1441
+ // leaked the session's mode into the TUI/other processes and raced on
1442
+ // a non-atomic restore.
1443
+ extraDangerousTools: manualMode ? ['write_file', 'edit_file'] : undefined,
1444
+ // Only request permission in Manual mode
1445
+ onRequestPermission: manualMode ? askAboutToolCall : undefined,
1446
+ // …and in auto mode, the answer a command that runs the agent uses in
1447
+ // its place, so /go and a skill's agent step get the same one prompt a
1448
+ // plain prompt gets instead of a refusal.
1449
+ onAutoModePermission: manualMode ? undefined : autoModeAnswer,
1450
+ // A skill's confirm step ("Deploy to production?") — a one-off
1451
+ // question, so no "always" answers.
1452
+ confirm: manualMode
1453
+ ? async (message) => {
1454
+ const result = await askUser({
1455
+ toolCallId: `confirm_${randomUUID()}`,
1456
+ toolName: 'confirm',
1457
+ toolInput: { question: message },
1458
+ status: 'pending',
1459
+ content: [],
1460
+ }, [
1461
+ { optionId: 'allow_once', name: 'Yes', kind: 'allow_once' },
1462
+ { optionId: 'reject_once', name: 'No', kind: 'reject_once' },
1463
+ ]);
1464
+ return result?.outcome.type === 'selected' && result.outcome.optionId === 'allow_once';
1465
+ }
1466
+ : undefined,
1467
+ // Per ACP spec, `fs/read_text_file` and `fs/write_text_file` are
1468
+ // CLIENT methods — only safe to call when the client advertised
1469
+ // the capability in `initialize`. Routing through the client
1470
+ // means the editor's dirty buffers + undo history stay correct
1471
+ // (otherwise an in-editor unsaved change would be invisible to
1472
+ // the agent, or worse, silently overwritten).
1473
+ fs: {
1474
+ readTextFile: clientSupportsFsRead
1475
+ ? async (absolutePath) => {
1476
+ const result = await transport.request('fs/read_text_file', {
1477
+ sessionId: params.sessionId,
1478
+ path: absolutePath,
1479
+ });
1480
+ if (!result || typeof result.content !== 'string') {
1481
+ throw new Error('fs/read_text_file returned no content');
1482
+ }
1483
+ return result.content;
1484
+ }
1485
+ : undefined,
1486
+ writeTextFile: clientSupportsFsWrite
1487
+ ? async (absolutePath, content) => {
1488
+ // Rejects when the client refuses the write, so the tool
1489
+ // never reports a file it did not write.
1490
+ await transport.request('fs/write_text_file', {
1491
+ sessionId: params.sessionId,
1492
+ path: absolutePath,
1493
+ content,
1494
+ });
1495
+ }
1496
+ : undefined,
1497
+ },
1498
+ onExecuteCommand: (command, args, cwd) => executeAcpCommand(command, args, cwd, {
1499
+ transport,
1500
+ sessionId: params.sessionId,
1501
+ clientSupportsTerminal,
1502
+ signal: abortController.signal,
1503
+ }),
1504
+ };
996
1505
  // Try slash commands first.
997
1506
  // Run the whole prompt lifecycle inside THIS ACP session's token scope so
998
1507
  // (a) concurrent sessions on one process can't mix usage totals, and
@@ -1002,7 +1511,7 @@ export function startAcpServer() {
1002
1511
  session.tokenRecords ??= createTokenScope();
1003
1512
  runWithTokenScope(session.tokenRecords, () => {
1004
1513
  const tokenReportStart = getRecordCount();
1005
- return handleCommand(prompt, session, sendChunk, abortController.signal)
1514
+ return handleCommand(prompt, session, sendChunk, abortController.signal, agentRun)
1006
1515
  .then((cmd) => {
1007
1516
  if (cmd.handled) {
1008
1517
  if (cmd.response)
@@ -1022,7 +1531,10 @@ export function startAcpServer() {
1022
1531
  session.titleSent = true;
1023
1532
  sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
1024
1533
  }
1025
- transport.respond(msg.id, { stopReason: 'end_turn' });
1534
+ session.activePrompts.delete(abortController);
1535
+ // A command that runs the agent (/go, skills) or the model (/diff,
1536
+ // /compact) can be cancelled part way; the client must hear so.
1537
+ transport.respond(msg.id, { stopReason: abortController.signal.aborted ? 'cancelled' : 'end_turn' });
1026
1538
  return;
1027
1539
  }
1028
1540
  // Not a command — run agent loop
@@ -1034,6 +1546,8 @@ export function startAcpServer() {
1034
1546
  }
1035
1547
  enrichedPrompt = parts.join('\n') + '\n\n' + prompt;
1036
1548
  }
1549
+ // Captured now: the user may move to another conversation before this ends.
1550
+ const recordTurn = beginTurn(session);
1037
1551
  runAgentSession({
1038
1552
  prompt: enrichedPrompt,
1039
1553
  workspaceRoot: session.workspaceRoot,
@@ -1095,110 +1609,17 @@ export function startAcpServer() {
1095
1609
  }
1096
1610
  }
1097
1611
  },
1098
- // Manual mode gates write_file/edit_file for this run only (per-call,
1099
- // no global config mutation).
1100
- extraDangerousTools: session.currentModeId === 'manual' ? ['write_file', 'edit_file'] : undefined,
1101
- // Only request permission in Manual mode
1102
- onRequestPermission: session.currentModeId === 'manual'
1103
- ? async (toolCall) => {
1104
- const permToolCallId = `perm_${randomUUID()}`;
1105
- const result = await transport.request('session/request_permission', {
1106
- sessionId: params.sessionId,
1107
- toolCall: {
1108
- toolCallId: permToolCallId,
1109
- toolName: toolCall.tool,
1110
- toolInput: formatToolInputForPermission(toolCall.tool, toolCall.parameters),
1111
- status: 'pending',
1112
- content: [],
1113
- },
1114
- options: [
1115
- { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
1116
- { optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' },
1117
- { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
1118
- { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
1119
- ],
1120
- });
1121
- // Map ACP outcome back to PermissionOutcome
1122
- if (!result || result.outcome.type === 'cancelled')
1123
- return 'reject_once';
1124
- return result.outcome.optionId;
1125
- }
1126
- : undefined,
1127
- // Per ACP spec, `fs/read_text_file` and `fs/write_text_file` are
1128
- // CLIENT methods — only safe to call when the client advertised
1129
- // the capability in `initialize`. Routing through the client
1130
- // means the editor's dirty buffers + undo history stay correct
1131
- // (otherwise an in-editor unsaved change would be invisible to
1132
- // the agent, or worse, silently overwritten).
1133
- fs: {
1134
- readTextFile: clientSupportsFsRead
1135
- ? async (absolutePath) => {
1136
- const result = await transport.request('fs/read_text_file', {
1137
- sessionId: params.sessionId,
1138
- path: absolutePath,
1139
- });
1140
- if (!result || typeof result.content !== 'string') {
1141
- throw new Error('fs/read_text_file returned no content');
1142
- }
1143
- return result.content;
1144
- }
1145
- : undefined,
1146
- writeTextFile: clientSupportsFsWrite
1147
- ? async (absolutePath, content) => {
1148
- await transport.request('fs/write_text_file', {
1149
- sessionId: params.sessionId,
1150
- path: absolutePath,
1151
- content,
1152
- });
1153
- }
1154
- : undefined,
1155
- },
1156
- onExecuteCommand: async (command, args, cwd) => {
1157
- // Per ACP spec, only call terminal/* if the client advertised the
1158
- // capability in initialize. Otherwise execute locally.
1159
- if (!clientSupportsTerminal) {
1160
- const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
1161
- return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
1162
- }
1163
- try {
1164
- const createResult = await transport.request('terminal/create', {
1165
- sessionId: params.sessionId,
1166
- command,
1167
- args,
1168
- cwd,
1169
- outputByteLimit: 1_000_000,
1170
- });
1171
- const { terminalId } = createResult;
1172
- // Spec method is snake_case `terminal/wait_for_exit` and takes
1173
- // only { sessionId, terminalId } — no timeoutMs.
1174
- const waitResult = await transport.request('terminal/wait_for_exit', {
1175
- sessionId: params.sessionId,
1176
- terminalId,
1177
- });
1178
- const outputResult = await transport.request('terminal/output', {
1179
- sessionId: params.sessionId,
1180
- terminalId,
1181
- });
1182
- await transport.request('terminal/release', {
1183
- sessionId: params.sessionId,
1184
- terminalId,
1185
- });
1186
- const exitCode = waitResult.exitStatus.type === 'exited' ? waitResult.exitStatus.code : 1;
1187
- return { stdout: outputResult.output ?? '', stderr: '', exitCode };
1188
- }
1189
- catch (err) {
1190
- // Client terminal failed — fall back to local execution
1191
- const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
1192
- return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
1193
- }
1194
- },
1612
+ onRequestPermission: agentRun.onRequestPermission ?? agentRun.onAutoModePermission,
1613
+ extraDangerousTools: agentRun.extraDangerousTools,
1614
+ fs: agentRun.fs,
1615
+ onExecuteCommand: agentRun.onExecuteCommand,
1616
+ // Earlier turns only: the prompt joins the history once it ran.
1617
+ chatHistory: [...session.history],
1195
1618
  }).then(() => {
1196
- session.history.push({ role: 'user', content: prompt });
1197
1619
  const agentResponse = agentResponseChunks.join('');
1198
- if (agentResponse) {
1199
- session.history.push({ role: 'assistant', content: agentResponse });
1200
- }
1201
- autoSaveSession(session.history, session.workspaceRoot);
1620
+ recordTurn(agentResponse
1621
+ ? [{ role: 'user', content: prompt }, { role: 'assistant', content: agentResponse }]
1622
+ : [{ role: 'user', content: prompt }]);
1202
1623
  // Report token usage to dashboard
1203
1624
  const projectCtx = getProjectContext(session.workspaceRoot);
1204
1625
  const sharedFields = {
@@ -1241,9 +1662,11 @@ export function startAcpServer() {
1241
1662
  session.titleSent = true;
1242
1663
  sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
1243
1664
  }
1244
- transport.respond(msg.id, { stopReason: 'end_turn' });
1665
+ transport.respond(msg.id, { stopReason: abortController.signal.aborted ? 'cancelled' : 'end_turn' });
1245
1666
  }).catch((err) => {
1246
- if (err.name === 'AbortError') {
1667
+ // Once the user cancelled, the answer is "cancelled", whatever the
1668
+ // run failed with on the way out.
1669
+ if (err.name === 'AbortError' || abortController.signal.aborted) {
1247
1670
  // Clear plan UI on the client side when session is cancelled
1248
1671
  if (planEntries.size > 0) {
1249
1672
  planEntries.clear();
@@ -1263,13 +1686,17 @@ export function startAcpServer() {
1263
1686
  transport.error(msg.id, -32000, err.message);
1264
1687
  }
1265
1688
  }).finally(() => {
1266
- if (session)
1267
- session.abortController = null;
1689
+ session.activePrompts.delete(abortController);
1268
1690
  planEntries.clear();
1269
1691
  });
1270
1692
  })
1271
1693
  .catch((err) => {
1272
- if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1694
+ // A command that streams (/review, /diff) stops with an AbortError
1695
+ // when cancelled. The client asked for that, so it is not an error.
1696
+ if (err.name === 'AbortError' || abortController.signal.aborted) {
1697
+ transport.respond(msg.id, { stopReason: 'cancelled' });
1698
+ }
1699
+ else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1273
1700
  sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1274
1701
  transport.respond(msg.id, { stopReason: 'end_turn' });
1275
1702
  }
@@ -1280,8 +1707,7 @@ export function startAcpServer() {
1280
1707
  else {
1281
1708
  transport.error(msg.id, -32000, err.message);
1282
1709
  }
1283
- if (session)
1284
- session.abortController = null;
1710
+ session.activePrompts.delete(abortController);
1285
1711
  });
1286
1712
  });
1287
1713
  }