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
@@ -3,7 +3,8 @@ import type { AcpSession } from './commands.js';
3
3
  /** Session record held by the running server. Lives in server.ts; re-exposed
4
4
  * here so handler signatures can name it without re-declaring the shape. */
5
5
  export interface AcpServerSession extends AcpSession {
6
- abortController: AbortController | null;
6
+ /** Controllers of the prompts still running; session/cancel aborts all. */
7
+ activePrompts: Set<AbortController>;
7
8
  currentModeId: string;
8
9
  titleSent: boolean;
9
10
  hadHistory: boolean;
@@ -18,6 +18,7 @@
18
18
  import { AGENT_MODES, buildConfigOptions } from './server.js';
19
19
  import { config, setProvider, setApiKey, listSessionsWithInfo, deleteSession as deleteSessionFile, } from '../config/index.js';
20
20
  import { disposeSession as disposeMcpSession } from '../utils/mcpRegistry.js';
21
+ import { clearPendingPlan } from '../utils/planMode.js';
21
22
  // ─── session/set_mode ─────────────────────────────────────────────────────────
22
23
  /**
23
24
  * Switch the agent confirmation mode for a session (auto = no prompts,
@@ -192,6 +193,8 @@ export function handleSessionDelete(msg, deps) {
192
193
  const { sessionId, cwd } = (msg.params ?? {});
193
194
  // Remove from in-memory sessions map if present
194
195
  sessions.delete(sessionId);
196
+ // Its pending /plan goes with it.
197
+ clearPendingPlan(sessionId);
195
198
  // Tear down any MCP server processes attached to this session — leaks
196
199
  // children otherwise. Fire-and-forget; client doesn't wait on stop().
197
200
  disposeMcpSession(sessionId).catch(() => { });
@@ -1,7 +1,8 @@
1
1
  import { PermissionOutcome } from '../utils/agent.js';
2
2
  import { ProjectContext } from '../utils/project.js';
3
3
  import { ToolCall } from '../utils/tools.js';
4
- import type { FsCallbacks } from '../utils/toolExecution.js';
4
+ import type { FsCallbacks, TrustBearingWrite } from '../utils/toolExecution.js';
5
+ import type { Message } from '../config/index.js';
5
6
  export interface AgentSessionOptions {
6
7
  prompt: string;
7
8
  workspaceRoot: string;
@@ -10,7 +11,18 @@ export interface AgentSessionOptions {
10
11
  onChunk: (text: string) => void;
11
12
  onThought?: (text: string) => void;
12
13
  onToolCall?: (toolCallId: string, toolName: string, kind: string, title: string, status: 'pending' | 'running' | 'finished' | 'error', locations?: string[], rawOutput?: string) => void;
13
- onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
14
+ /**
15
+ * Ask the user about one tool call. Handed straight to runAgent, so the
16
+ * signature is runAgent's: `trustBearing` is what the agent's own gate
17
+ * already worked out about the call — the file it would write that decides
18
+ * what runs later, or null when it writes no such file — and the dialog
19
+ * words itself from that instead of resolving the path a second time.
20
+ *
21
+ * Declared with one parameter, this type said the second argument did not
22
+ * exist while runAgent passed it on every call, so the one caller that
23
+ * needs it had to cast its way back to the truth.
24
+ */
25
+ onRequestPermission?: (toolCall: ToolCall, trustBearing?: TrustBearingWrite | null) => Promise<PermissionOutcome>;
14
26
  /** Tools to force into the per-run dangerous set (ACP manual mode). */
15
27
  extraDangerousTools?: string[];
16
28
  onExecuteCommand?: (command: string, args: string[], cwd: string) => Promise<{
@@ -20,7 +32,21 @@ export interface AgentSessionOptions {
20
32
  }>;
21
33
  /** Optional fs delegation when the ACP client advertises `fs` capability. */
22
34
  fs?: FsCallbacks;
35
+ /**
36
+ * Earlier turns of this conversation. ACP clients send only the new
37
+ * message, so without this the agent would start every turn blind to a
38
+ * loaded, rewound or compacted session.
39
+ */
40
+ chatHistory?: Message[];
23
41
  }
42
+ /**
43
+ * The part of a session's history the agent sees: user and assistant turns,
44
+ * as the TUI passes them (`App.getChatHistory`).
45
+ */
46
+ export declare function toAgentChatHistory(history: Message[]): Array<{
47
+ role: 'user' | 'assistant';
48
+ content: string;
49
+ }>;
24
50
  /**
25
51
  * Build a ProjectContext from a workspace root directory.
26
52
  * Falls back to a minimal synthetic context if scanning fails.
@@ -3,6 +3,15 @@
3
3
  import { join, isAbsolute } from 'path';
4
4
  import { runAgent } from '../utils/agent.js';
5
5
  import { getProjectContext } from '../utils/project.js';
6
+ /**
7
+ * The part of a session's history the agent sees: user and assistant turns,
8
+ * as the TUI passes them (`App.getChatHistory`).
9
+ */
10
+ export function toAgentChatHistory(history) {
11
+ return history
12
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
13
+ .map(m => ({ role: m.role, content: m.content }));
14
+ }
6
15
  /**
7
16
  * Build a ProjectContext from a workspace root directory.
8
17
  * Falls back to a minimal synthetic context if scanning fails.
@@ -184,18 +193,28 @@ export async function runAgentSession(opts) {
184
193
  // `conversationId` is the ACP session id, which is what
185
194
  // registerSessionServers keyed by in server.ts handleSessionNew.
186
195
  mcpSessionId: opts.conversationId,
196
+ chatHistory: opts.chatHistory ? toAgentChatHistory(opts.chatHistory) : undefined,
187
197
  });
188
- // result.finalResponse is already emitted via onChunk streaming above;
189
- // only emit it here if nothing was streamed (e.g. non-streaming fallback path)
190
- // — except a paused/interrupted run, whose finalResponse is a fresh "say
198
+ // result.finalResponse is mostly emitted via onChunk streaming above;
199
+ // emit all of it here if nothing was streamed (e.g. non-streaming fallback path)
200
+ // — or a paused/interrupted run, whose finalResponse is a fresh "say
191
201
  // continue" notice that was never streamed and must always reach the client.
202
+ // Otherwise send what the loop wrote itself and never streamed (the
203
+ // verification result, a reviewer's notes, a notice that replaced the
204
+ // answer): without it an editor never hears that verification failed.
192
205
  if (result.finalResponse && (chunksEmitted === 0 || result.interrupted)) {
193
206
  opts.onChunk(result.finalResponse);
194
207
  }
208
+ else if (result.finalResponse && !result.aborted) {
209
+ const tail = result.unstreamedText ?? '';
210
+ if (tail)
211
+ opts.onChunk(`\n\n${tail}`);
212
+ }
195
213
  // Surface errors as thrown exceptions so the ACP server can handle them correctly.
196
- // Exception: if finalResponse was already sent as a chunk (e.g. "Agent reached the
197
- // iteration limit", "Agent stopped due to repeated API timeouts"), don't also throw —
198
- // the user already received the explanation and Zed would show a confusing second error.
214
+ // Exception: once chunks were streamed, the rest of finalResponse was sent
215
+ // just above (e.g. "✗ Verification failed", "Agent stopped due to repeated
216
+ // API timeouts"), so don't also throw — the user already received the
217
+ // explanation and Zed would show a confusing second error.
199
218
  if (!result.success && !result.aborted) {
200
219
  const alreadyExplained = result.finalResponse && chunksEmitted > 0;
201
220
  if (!alreadyExplained) {
@@ -1,12 +1,43 @@
1
1
  import { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './protocol.js';
2
- type MessageHandler = (msg: JsonRpcRequest | JsonRpcNotification) => void;
2
+ /**
3
+ * A frame with its obvious credentials blanked.
4
+ *
5
+ * Exported for unit testing (see transport.test.ts).
6
+ */
7
+ export declare function redactCredentials(frame: string): string;
8
+ type MessageHandler = (msg: JsonRpcRequest | JsonRpcNotification) => void | Promise<unknown>;
9
+ export interface RequestOptions {
10
+ /**
11
+ * How long to wait for the client's answer. Defaults to 30s; 0 waits
12
+ * indefinitely, for calls that wait on a person (permission dialogs) or
13
+ * on a running process (terminal/wait_for_exit).
14
+ */
15
+ timeoutMs?: number;
16
+ /** Stop waiting when this fires (e.g. the prompt was cancelled). */
17
+ signal?: AbortSignal;
18
+ }
19
+ /** The client answered one of our requests with a JSON-RPC error. */
20
+ export declare class AcpRequestError extends Error {
21
+ readonly method: string;
22
+ readonly code: number;
23
+ constructor(method: string, code: number, message: string);
24
+ }
25
+ /** The client did not answer one of our requests in time. */
26
+ export declare class AcpRequestTimeoutError extends Error {
27
+ readonly method: string;
28
+ readonly timeoutMs: number;
29
+ constructor(method: string, timeoutMs: number);
30
+ }
3
31
  export declare class StdioTransport {
4
32
  private buffer;
5
- private handler;
33
+ protected handler: MessageHandler | null;
6
34
  private pendingRequests;
7
35
  private requestIdCounter;
36
+ private unanswered;
8
37
  start(handler: MessageHandler): void;
9
- private onData;
38
+ protected onData(chunk: string): void;
39
+ private dispatch;
40
+ protected write(line: string): void;
10
41
  send(msg: JsonRpcResponse | JsonRpcNotification): void;
11
42
  respond(id: number | string, result: unknown): void;
12
43
  error(id: number | string, code: number, message: string): void;
@@ -14,7 +45,12 @@ export declare class StdioTransport {
14
45
  /**
15
46
  * Send a JSON-RPC request to the client and wait for the response.
16
47
  * Used for agent-initiated requests like session/request_permission.
48
+ *
49
+ * Rejects with AcpRequestError when the client answers with an error,
50
+ * with AcpRequestTimeoutError when it does not answer in time, and with
51
+ * an AbortError when `signal` fires. A result is never invented: callers
52
+ * that treat a missing answer as "no" must say so with their own catch.
17
53
  */
18
- request(method: string, params: unknown): Promise<unknown>;
54
+ request(method: string, params: unknown, options?: RequestOptions): Promise<unknown>;
19
55
  }
20
56
  export {};
@@ -1,36 +1,157 @@
1
1
  // acp/transport.ts
2
2
  // Newline-delimited JSON-RPC over stdio
3
- import { appendFileSync, mkdirSync } from 'node:fs';
3
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  // Debug log destination — when CODEEP_ACP_DEBUG is set we mirror every
7
7
  // inbound and outbound JSON-RPC frame here. Using a file (not stderr) because
8
8
  // most ACP clients (Zed included) do not pipe agent stderr to anywhere the
9
9
  // user can easily read; a known on-disk path is reliable everywhere.
10
+ //
11
+ // WHAT IS IN IT, because a user asked for it in a bug report will attach the
12
+ // whole file: every frame of the session. That is the prompts, the model's
13
+ // replies, the contents of every file read or written through fs/*, the
14
+ // commands run in the client's terminal and their output, and the `env` of
15
+ // every terminal/create. redactCredentials() blanks the obvious credential
16
+ // shapes on the way in, but it is a filter over text and not a guarantee — a
17
+ // secret that does not look like one survives it. So: session-private, 0600
18
+ // in a 0700 directory, and not something to paste anywhere unread.
10
19
  const ACP_DEBUG_PATH = process.env.CODEEP_ACP_DEBUG_FILE
11
20
  || join(homedir(), '.cache', 'codeep', 'acp-debug.log');
12
21
  const ACP_DEBUG = !!process.env.CODEEP_ACP_DEBUG;
22
+ /**
23
+ * Roll the log over at 8MB, keeping one previous file.
24
+ *
25
+ * It had no limit at all: every frame was appended and nothing ever truncated
26
+ * or removed the file, and a frame carries whole file contents and whole
27
+ * command outputs — so a user who left CODEEP_ACP_DEBUG set grew it until the
28
+ * disk stopped them. One previous file rather than a truncate because the
29
+ * frames that explain a broken session are usually the handshake at the top,
30
+ * which is exactly what a truncate throws away. Bounded at twice this, then.
31
+ */
32
+ const ACP_DEBUG_MAX_BYTES = 8 * 1024 * 1024;
13
33
  if (ACP_DEBUG) {
34
+ // 0700: the directory holds a file with the whole session in it.
14
35
  try {
15
- mkdirSync(dirname(ACP_DEBUG_PATH), { recursive: true });
36
+ mkdirSync(dirname(ACP_DEBUG_PATH), { recursive: true, mode: 0o700 });
16
37
  }
17
38
  catch { /* ignore */ }
18
39
  }
40
+ /** Bytes written so far, so the size check costs no syscall per frame. Null
41
+ * until the first write reads what an earlier run left on disk. */
42
+ let acpDebugBytes = null;
19
43
  function debugLog(direction, payload) {
20
44
  if (!ACP_DEBUG)
21
45
  return;
46
+ const line = `${new Date().toISOString()} [ACP${direction}client] ${redactCredentials(payload)}\n`;
47
+ const bytes = Buffer.byteLength(line);
22
48
  try {
23
- appendFileSync(ACP_DEBUG_PATH, `${new Date().toISOString()} [ACP${direction}client] ${payload}\n`);
49
+ if (acpDebugBytes === null)
50
+ acpDebugBytes = adoptExistingLog();
51
+ if (acpDebugBytes > 0 && acpDebugBytes + bytes > ACP_DEBUG_MAX_BYTES) {
52
+ renameSync(ACP_DEBUG_PATH, `${ACP_DEBUG_PATH}.1`);
53
+ acpDebugBytes = 0;
54
+ }
55
+ // `mode` applies only when the file is created, which after the rename
56
+ // above is every rollover as well as the first frame of the first run.
57
+ appendFileSync(ACP_DEBUG_PATH, line, { mode: 0o600 });
58
+ acpDebugBytes += bytes;
24
59
  }
25
60
  catch { /* swallow — never break the protocol over a logging failure */ }
26
61
  }
62
+ /** The size of the log already on disk, 0 when there is none. */
63
+ function adoptExistingLog() {
64
+ try {
65
+ const stat = statSync(ACP_DEBUG_PATH);
66
+ // A log this build did not create is one an older Codeep created 0644 —
67
+ // world-readable, with everything listed above in it. Tighten it, but
68
+ // only at our own path: CODEEP_ACP_DEBUG_FILE may name something whose
69
+ // mode is not ours to change (a fifo, a tty, a shared file).
70
+ if (!process.env.CODEEP_ACP_DEBUG_FILE && (stat.mode & 0o077) !== 0) {
71
+ chmodSync(ACP_DEBUG_PATH, 0o600);
72
+ }
73
+ return stat.size;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ }
79
+ /**
80
+ * Credential shapes blanked before a frame is mirrored to the debug log.
81
+ *
82
+ * The log exists to debug the protocol, so this is deliberately narrow: it
83
+ * blanks what is unmistakably a secret and leaves everything else readable.
84
+ * Matched on the frame TEXT rather than on a parsed object because an inbound
85
+ * frame is logged before it is parsed and may not be JSON at all.
86
+ *
87
+ * Nothing here changes the frame on the wire — only the copy on disk.
88
+ */
89
+ const ACP_DEBUG_REDACTIONS = [
90
+ // `"apiKey": "…"`, `"authorization": "…"` — the MEMBER NAME says it is a
91
+ // secret, whatever the value looks like.
92
+ [/("[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?key|secret|token|password|passwd|credential|authorization|cookie|private[_-]?key)[A-Za-z0-9_.-]*"\s*:\s*)"(?:[^"\\]|\\.)*"/gi, '$1"[redacted]"'],
93
+ // ACP spells an environment as `{"name":…,"value":…}`, so the secret-looking
94
+ // string is the VALUE of `name` and the rule above cannot see it. This is
95
+ // the shape terminal/create used to leak the whole of process.env in.
96
+ [/("name"\s*:\s*"[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE)[A-Za-z0-9_.-]*"\s*,\s*"value"\s*:\s*)"(?:[^"\\]|\\.)*"/gi, '$1"[redacted]"'],
97
+ // And the shapes that are a credential wherever they turn up — a command
98
+ // line the agent ran, a terminal's own output, a file it read.
99
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, '[redacted]'], // OpenAI / Anthropic
100
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, '[redacted]'], // GitHub
101
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[redacted]'],
102
+ [/\bglpat-[A-Za-z0-9_-]{16,}/g, '[redacted]'], // GitLab
103
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[redacted]'], // Slack
104
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'], // AWS access key id
105
+ [/\bAIza[0-9A-Za-z_-]{20,}/g, '[redacted]'], // Google
106
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted]'], // JWT
107
+ [/\bBearer\s+[A-Za-z0-9._~+/-]{16,}={0,2}/gi, 'Bearer [redacted]'],
108
+ // `https://user:password@host` — keep the structure, drop the password.
109
+ [/((?:https?|ssh|git):\/\/[^\s"'/@]+:)[^\s"'/@]+@/g, '$1[redacted]@'],
110
+ ];
111
+ /**
112
+ * A frame with its obvious credentials blanked.
113
+ *
114
+ * Exported for unit testing (see transport.test.ts).
115
+ */
116
+ export function redactCredentials(frame) {
117
+ let out = frame;
118
+ for (const [pattern, replacement] of ACP_DEBUG_REDACTIONS)
119
+ out = out.replace(pattern, replacement);
120
+ return out;
121
+ }
27
122
  const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB
28
123
  const REQUEST_TIMEOUT_MS = 30_000; // 30s
124
+ /** The client answered one of our requests with a JSON-RPC error. */
125
+ export class AcpRequestError extends Error {
126
+ method;
127
+ code;
128
+ constructor(method, code, message) {
129
+ super(`${method} failed: ${message}`);
130
+ this.method = method;
131
+ this.code = code;
132
+ this.name = 'AcpRequestError';
133
+ }
134
+ }
135
+ /** The client did not answer one of our requests in time. */
136
+ export class AcpRequestTimeoutError extends Error {
137
+ method;
138
+ timeoutMs;
139
+ constructor(method, timeoutMs) {
140
+ super(`${method} got no response within ${timeoutMs}ms`);
141
+ this.method = method;
142
+ this.timeoutMs = timeoutMs;
143
+ this.name = 'AcpRequestTimeoutError';
144
+ }
145
+ }
29
146
  export class StdioTransport {
30
147
  buffer = '';
31
148
  handler = null;
32
149
  pendingRequests = new Map();
33
150
  requestIdCounter = 1000;
151
+ // Ids of inbound requests we have not answered yet. A handler that fails
152
+ // before answering gets an error reply; one that fails after answering
153
+ // must not produce a second response for the same id.
154
+ unanswered = new Set();
34
155
  start(handler) {
35
156
  this.handler = handler;
36
157
  process.stdin.setEncoding('utf8');
@@ -49,31 +170,67 @@ export class StdioTransport {
49
170
  const trimmed = line.trim();
50
171
  if (!trimmed)
51
172
  continue;
173
+ debugLog('←', trimmed);
174
+ let msg;
52
175
  try {
53
- debugLog('←', trimmed);
54
- const msg = JSON.parse(trimmed);
55
- // Check if this is a response to one of our outbound requests
56
- if ('result' in msg || 'error' in msg) {
57
- const response = msg;
58
- const resolve = this.pendingRequests.get(response.id);
59
- if (resolve) {
60
- this.pendingRequests.delete(response.id);
61
- resolve(response.result ?? null);
62
- continue;
176
+ msg = JSON.parse(trimmed);
177
+ }
178
+ catch {
179
+ continue; // ignore malformed messages
180
+ }
181
+ if (!msg || typeof msg !== 'object')
182
+ continue;
183
+ // A frame without a method answers one of our outbound requests.
184
+ if (!('method' in msg) && ('result' in msg || 'error' in msg)) {
185
+ const response = msg;
186
+ const pending = this.pendingRequests.get(response.id);
187
+ if (pending) {
188
+ this.pendingRequests.delete(response.id);
189
+ if (response.error) {
190
+ pending.reject(new AcpRequestError(pending.method, response.error.code, response.error.message));
191
+ }
192
+ else {
193
+ pending.resolve(response.result ?? null);
63
194
  }
64
195
  }
65
- this.handler?.(msg);
196
+ // Nobody is waiting for it any more (timed out or cancelled). It is
197
+ // a response, so answering it would break JSON-RPC: drop it.
198
+ continue;
66
199
  }
67
- catch {
68
- // ignore malformed messages
200
+ this.dispatch(msg);
201
+ }
202
+ }
203
+ dispatch(msg) {
204
+ const id = 'id' in msg ? msg.id : undefined;
205
+ if (id !== undefined)
206
+ this.unanswered.add(id);
207
+ const fail = (err) => {
208
+ const message = err instanceof Error ? err.message : String(err);
209
+ // stderr only: stdout carries nothing but JSON-RPC frames.
210
+ process.stderr.write(`[codeep-acp] ${msg.method} failed: ${message}\n`);
211
+ if (id !== undefined && this.unanswered.has(id)) {
212
+ this.error(id, -32603, message);
213
+ }
214
+ };
215
+ try {
216
+ const out = this.handler?.(msg);
217
+ if (out && typeof out.then === 'function') {
218
+ out.then(undefined, fail);
69
219
  }
70
220
  }
221
+ catch (err) {
222
+ fail(err);
223
+ }
71
224
  }
72
- send(msg) {
73
- const line = JSON.stringify(msg);
225
+ write(line) {
74
226
  debugLog('→', line);
75
227
  process.stdout.write(line + '\n');
76
228
  }
229
+ send(msg) {
230
+ if ('id' in msg)
231
+ this.unanswered.delete(msg.id);
232
+ this.write(JSON.stringify(msg));
233
+ }
77
234
  respond(id, result) {
78
235
  this.send({ jsonrpc: '2.0', id, result });
79
236
  }
@@ -86,17 +243,53 @@ export class StdioTransport {
86
243
  /**
87
244
  * Send a JSON-RPC request to the client and wait for the response.
88
245
  * Used for agent-initiated requests like session/request_permission.
246
+ *
247
+ * Rejects with AcpRequestError when the client answers with an error,
248
+ * with AcpRequestTimeoutError when it does not answer in time, and with
249
+ * an AbortError when `signal` fires. A result is never invented: callers
250
+ * that treat a missing answer as "no" must say so with their own catch.
89
251
  */
90
- request(method, params) {
252
+ request(method, params, options = {}) {
91
253
  const id = ++this.requestIdCounter;
92
- return new Promise((resolve) => {
93
- this.pendingRequests.set(id, resolve);
94
- process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
95
- setTimeout(() => {
254
+ const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
255
+ const { signal } = options;
256
+ return new Promise((resolve, reject) => {
257
+ if (signal?.aborted) {
258
+ reject(abortError(method));
259
+ return;
260
+ }
261
+ let timer;
262
+ const settle = () => {
263
+ if (timer)
264
+ clearTimeout(timer);
265
+ signal?.removeEventListener('abort', onAbort);
266
+ };
267
+ const onAbort = () => {
96
268
  if (this.pendingRequests.delete(id)) {
97
- resolve(null);
269
+ settle();
270
+ reject(abortError(method));
98
271
  }
99
- }, REQUEST_TIMEOUT_MS);
272
+ };
273
+ this.pendingRequests.set(id, {
274
+ method,
275
+ resolve: (result) => { settle(); resolve(result); },
276
+ reject: (err) => { settle(); reject(err); },
277
+ });
278
+ signal?.addEventListener('abort', onAbort, { once: true });
279
+ if (timeoutMs > 0) {
280
+ timer = setTimeout(() => {
281
+ if (this.pendingRequests.delete(id)) {
282
+ settle();
283
+ reject(new AcpRequestTimeoutError(method, timeoutMs));
284
+ }
285
+ }, timeoutMs);
286
+ }
287
+ this.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
100
288
  });
101
289
  }
102
290
  }
291
+ function abortError(method) {
292
+ const err = new Error(`${method} was cancelled`);
293
+ err.name = 'AbortError';
294
+ return err;
295
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Recording a finished turn in an ACP thread's conversation.
3
+ */
4
+ import { type Message } from '../config/index.js';
5
+ /** The part of a session a turn needs. */
6
+ export interface TurnSession {
7
+ workspaceRoot: string;
8
+ history: Message[];
9
+ codeepSessionId: string;
10
+ /** Bumped whenever the thread moves to another conversation. */
11
+ conversation?: number;
12
+ }
13
+ /**
14
+ * Start a turn: returns the function that records its messages once it ran.
15
+ * If the thread moved to another conversation meanwhile, the turn belongs to
16
+ * the one it started in — it is saved there, not appended to the new one —
17
+ * and after a /rewind of that same conversation it is dropped, since saving
18
+ * it would undo the rewind.
19
+ */
20
+ export declare function beginTurn(session: TurnSession): (entries: Message[]) => void;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Recording a finished turn in an ACP thread's conversation.
3
+ */
4
+ import { config, saveSession } from '../config/index.js';
5
+ /**
6
+ * Start a turn: returns the function that records its messages once it ran.
7
+ * If the thread moved to another conversation meanwhile, the turn belongs to
8
+ * the one it started in — it is saved there, not appended to the new one —
9
+ * and after a /rewind of that same conversation it is dropped, since saving
10
+ * it would undo the rewind.
11
+ */
12
+ export function beginTurn(session) {
13
+ const conversation = session.conversation ?? 0;
14
+ const id = session.codeepSessionId;
15
+ const history = session.history;
16
+ return (entries) => {
17
+ const autoSave = config.get('autoSave');
18
+ if ((session.conversation ?? 0) === conversation) {
19
+ session.history.push(...entries);
20
+ if (autoSave && session.history.length > 0)
21
+ saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
22
+ return;
23
+ }
24
+ if (id === session.codeepSessionId)
25
+ return;
26
+ history.push(...entries);
27
+ if (autoSave)
28
+ saveSession(id, history, session.workspaceRoot);
29
+ };
30
+ }
package/dist/api/index.js CHANGED
@@ -445,6 +445,8 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
445
445
  temperature: omitTemperature ? undefined : temperature,
446
446
  timeoutMs: timeout,
447
447
  onChunk: stream ? onChunk : undefined,
448
+ // Carries the user's Stop as well as this request's own timeout.
449
+ signal: controller.signal,
448
450
  });
449
451
  if (result.promptTokens != null && result.completionTokens != null) {
450
452
  recordTokenUsage({ promptTokens: result.promptTokens, completionTokens: result.completionTokens, totalTokens: result.promptTokens + result.completionTokens }, model, providerId);
@@ -94,6 +94,9 @@ export interface OllamaChatOptions {
94
94
  keepAlive?: string;
95
95
  temperature?: number;
96
96
  timeoutMs?: number;
97
+ /** Stops the request when it fires (Stop / cancel). The promise then
98
+ * rejects with an error named 'AbortError', as fetch does. */
99
+ signal?: AbortSignal;
97
100
  onChunk?: (text: string) => void;
98
101
  /** Tool definitions in OpenAI function format. Ollama's /api/chat accepts the
99
102
  * same `{type:'function',function:{...}}` shape and returns `tool_calls`. */
@@ -179,10 +179,40 @@ export function streamOllamaNativeChat(opts) {
179
179
  ...(Object.keys(options).length ? { options } : {}),
180
180
  ...(opts.keepAlive ? { keep_alive: opts.keepAlive } : {}),
181
181
  });
182
- return new Promise((resolve, reject) => {
182
+ return new Promise((resolveChat, rejectChat) => {
183
+ const signal = opts.signal;
184
+ // Settles once: a request destroyed on abort goes on to emit errors of
185
+ // its own, which must not replace the abort.
186
+ let settled = false;
187
+ const settle = () => {
188
+ if (settled)
189
+ return false;
190
+ settled = true;
191
+ signal?.removeEventListener('abort', onAbort);
192
+ return true;
193
+ };
194
+ const resolve = (result) => { if (settle())
195
+ resolveChat(result); };
196
+ const reject = (error) => { if (settle())
197
+ rejectChat(error); };
198
+ const abortError = () => {
199
+ const error = new Error('The operation was aborted');
200
+ error.name = 'AbortError';
201
+ return error;
202
+ };
203
+ const onAbort = () => {
204
+ reject(abortError());
205
+ req?.destroy();
206
+ };
207
+ let req;
208
+ if (signal?.aborted) {
209
+ reject(abortError());
210
+ return;
211
+ }
212
+ signal?.addEventListener('abort', onAbort, { once: true });
183
213
  const u = new URL(url);
184
214
  const lib = u.protocol === 'https:' ? https : http;
185
- const req = lib.request({
215
+ req = lib.request({
186
216
  hostname: u.hostname,
187
217
  port: u.port,
188
218
  path: u.pathname + u.search,
@@ -200,6 +230,8 @@ export function streamOllamaNativeChat(opts) {
200
230
  let acc = initialOllamaAccumulator();
201
231
  const decoder = new TextDecoder();
202
232
  res.on('data', (chunk) => {
233
+ if (settled)
234
+ return; // stopped: nothing more reaches the caller
203
235
  buffer += decoder.decode(chunk, { stream: true });
204
236
  const { lines, rest } = splitOllamaLines(buffer);
205
237
  buffer = rest;
@@ -221,7 +253,7 @@ export function streamOllamaNativeChat(opts) {
221
253
  res.on('error', reject);
222
254
  });
223
255
  req.on('error', reject);
224
- req.on('timeout', () => { req.destroy(new Error('Ollama request timed out')); });
256
+ req.on('timeout', () => { req?.destroy(new Error('Ollama request timed out')); });
225
257
  req.write(body);
226
258
  req.end();
227
259
  });