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.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/acp/server.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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: '
|
|
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
|
-
|
|
399
|
-
export
|
|
400
|
-
|
|
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)
|
|
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 {
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
1116
|
+
sendSessionTitle(acpSessionId, history, pathBasename(params.cwd));
|
|
744
1117
|
// Send restored session welcome
|
|
745
1118
|
transport.notify('session/update', {
|
|
746
|
-
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1267
|
-
session.abortController = null;
|
|
1689
|
+
session.activePrompts.delete(abortController);
|
|
1268
1690
|
planEntries.clear();
|
|
1269
1691
|
});
|
|
1270
1692
|
})
|
|
1271
1693
|
.catch((err) => {
|
|
1272
|
-
|
|
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
|
-
|
|
1284
|
-
session.abortController = null;
|
|
1710
|
+
session.activePrompts.delete(abortController);
|
|
1285
1711
|
});
|
|
1286
1712
|
});
|
|
1287
1713
|
}
|