copillm 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # copillm
2
2
 
3
3
  [![PR gate](https://github.com/jcjc-dev/copillm/actions/workflows/pr-gate.yml/badge.svg?branch=main)](https://github.com/jcjc-dev/copillm/actions/workflows/pr-gate.yml)
4
- [![Release gate](https://github.com/jcjc-dev/copillm/actions/workflows/release-gate.yml/badge.svg?branch=main&event=schedule)](https://github.com/jcjc-dev/copillm/actions/workflows/release-gate.yml)
4
+ [![Upstream e2e (nightly)](https://github.com/jcjc-dev/copillm/actions/workflows/upstream-e2e.yml/badge.svg?branch=main&event=schedule)](https://github.com/jcjc-dev/copillm/actions/workflows/upstream-e2e.yml)
5
5
  [![npm version](https://img.shields.io/npm/v/copillm.svg)](https://www.npmjs.com/package/copillm)
6
6
  [![Node.js version](https://img.shields.io/node/v/copillm.svg)](https://www.npmjs.com/package/copillm)
7
7
 
@@ -1,6 +1,7 @@
1
- import path from "node:path";
2
1
  import fs from "node:fs";
3
- import { stringify as stringifyToml } from "smol-toml";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
4
5
  import { AgentConfigError } from "./load.js";
5
6
  import { getCopillmHome } from "../config/home.js";
6
7
  import { HASH_COMMENT, HTML_COMMENT, upsertManagedBlock } from "./markerBlock.js";
@@ -8,27 +9,35 @@ import { HASH_COMMENT, HTML_COMMENT, upsertManagedBlock } from "./markerBlock.js
8
9
  export function renderCodex(input) {
9
10
  const writes = [];
10
11
  const notes = [];
11
- // 1. MCP block inside <CODEX_HOME>/config.toml. The file is generated by
12
- // src/codex/init.ts and ends with a blank line; we append the marker
13
- // section after it.
14
12
  const codexConfigPath = path.join(input.codexHomeDir, "config.toml");
15
- const mcpToml = renderCodexMcpToml(input.resolved.mcpServers);
16
- if (fs.existsSync(codexConfigPath)) {
17
- const existing = fs.readFileSync(codexConfigPath, "utf8");
18
- const next = upsertManagedBlock(existing, mcpToml, HASH_COMMENT);
19
- if (next !== existing) {
20
- writes.push({
21
- path: codexConfigPath,
22
- content: next,
23
- mode: 0o600,
24
- description: "Codex config.toml MCP block"
25
- });
13
+ const existing = fs.existsSync(codexConfigPath) ? fs.readFileSync(codexConfigPath, "utf8") : "";
14
+ let next = existing;
15
+ if (input.codexBaseConfigSourcePath) {
16
+ if (fs.existsSync(input.codexBaseConfigSourcePath)) {
17
+ const source = fs.readFileSync(input.codexBaseConfigSourcePath, "utf8");
18
+ next = mergeCodexBaseConfig(next, source, codexConfigPath, input.codexBaseConfigSourcePath);
19
+ }
20
+ else {
21
+ notes.push(`Codex source config not found at ${input.codexBaseConfigSourcePath}; ` +
22
+ `run \`copillm start\` or \`copillm codex\` once first.`);
26
23
  }
27
24
  }
28
- else {
25
+ const mcpToml = renderCodexMcpToml(input.resolved.mcpServers);
26
+ if (next.length === 0 && mcpToml.length > 0) {
29
27
  notes.push(`Codex config not found at ${codexConfigPath}; skipping MCP injection. ` +
30
28
  `Run \`copillm start\` first.`);
31
29
  }
30
+ else {
31
+ next = upsertManagedBlock(next, mcpToml, HASH_COMMENT);
32
+ }
33
+ if (next !== existing) {
34
+ writes.push({
35
+ path: codexConfigPath,
36
+ content: next,
37
+ mode: 0o600,
38
+ description: "Codex config.toml"
39
+ });
40
+ }
32
41
  // 2. AGENTS.md instruction block.
33
42
  if (input.resolved.instructions) {
34
43
  const agentsPath = path.join(input.codexHomeDir, "AGENTS.md");
@@ -45,6 +54,53 @@ export function renderCodex(input) {
45
54
  }
46
55
  return { writes, envOverlay: {}, cliArgs: [], notes };
47
56
  }
57
+ function mergeCodexBaseConfig(targetRaw, sourceRaw, targetPath, sourcePath) {
58
+ const targetDoc = parseCodexToml(targetRaw, targetPath);
59
+ const sourceDoc = parseCodexToml(sourceRaw, sourcePath);
60
+ const providerId = getStringField(sourceDoc, "model_provider");
61
+ if (!providerId) {
62
+ throw new AgentConfigError(`Codex source config at ${sourcePath} is missing model_provider.`);
63
+ }
64
+ for (const key of ["model", "model_provider", "model_reasoning_effort", "approvals_reviewer"]) {
65
+ if (key in sourceDoc) {
66
+ targetDoc[key] = sourceDoc[key];
67
+ }
68
+ }
69
+ const sourceProviders = asRecord(sourceDoc.model_providers);
70
+ const selectedProvider = asRecord(sourceProviders?.[providerId]);
71
+ if (!selectedProvider) {
72
+ throw new AgentConfigError(`Codex source config at ${sourcePath} is missing [model_providers.${providerId}].`);
73
+ }
74
+ const targetProviders = asRecord(targetDoc.model_providers) ?? {};
75
+ targetProviders[providerId] = selectedProvider;
76
+ targetDoc.model_providers = targetProviders;
77
+ return `${stringifyToml(targetDoc).trimEnd()}\n`;
78
+ }
79
+ function parseCodexToml(raw, filePath) {
80
+ if (raw.trim().length === 0) {
81
+ return {};
82
+ }
83
+ try {
84
+ const parsed = parseToml(raw);
85
+ return asRecord(parsed) ?? {};
86
+ }
87
+ catch (error) {
88
+ if (error instanceof TomlError) {
89
+ throw new AgentConfigError(`Failed to parse ${filePath}: ${error.message}`);
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+ function asRecord(value) {
95
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
96
+ return null;
97
+ }
98
+ return value;
99
+ }
100
+ function getStringField(doc, key) {
101
+ const value = doc[key];
102
+ return typeof value === "string" && value.length > 0 ? value : null;
103
+ }
48
104
  function renderCodexMcpToml(servers) {
49
105
  if (Object.keys(servers).length === 0)
50
106
  return "";
@@ -82,17 +138,23 @@ function isValidTomlIdent(name) {
82
138
  }
83
139
  // ─── Claude Code ──────────────────────────────────────────────────────────
84
140
  /**
85
- * copillm writes Claude's MCP config to ~/.copillm/claude/mcp.json (a
86
- * copillm-owned location) and injects `--mcp-config <path>` into the launch
87
- * argv. This is purely additive: Claude continues to read its own project
88
- * (./.mcp.json) and user (~/.claude.json) scopes, and copillm never touches
89
- * cwd. Instructions fan-out is not supported for Claude — put project
90
- * guidance in your own CLAUDE.md and global guidance in ~/.claude/CLAUDE.md.
141
+ * Launcher mode writes a copillm-owned MCP config and returns --mcp-config.
142
+ * Native sync mode writes the user-level Claude config that Claude reads
143
+ * without a copillm wrapper.
91
144
  */
92
145
  export function renderClaude(input) {
93
146
  const writes = [];
94
147
  const notes = [];
95
148
  const cliArgs = [];
149
+ if (input.nativeSync) {
150
+ writes.push(...renderClaudeNativeWrites(input));
151
+ if (input.resolved.instructions) {
152
+ notes.push("Claude: instructions fan-out is unsupported (Claude has no out-of-tree " +
153
+ "instructions hook). Move guidance to ~/.claude/CLAUDE.md or your " +
154
+ "project's CLAUDE.md manually.");
155
+ }
156
+ return { writes, envOverlay: {}, cliArgs, notes };
157
+ }
96
158
  const claudeDir = path.join(getCopillmHome(), "claude");
97
159
  const mcpJsonPath = path.join(claudeDir, "mcp.json");
98
160
  const serverCount = Object.keys(input.resolved.mcpServers).length;
@@ -122,6 +184,80 @@ export function renderClaude(input) {
122
184
  }
123
185
  return { writes, envOverlay: {}, cliArgs, notes };
124
186
  }
187
+ function renderClaudeNativeWrites(input) {
188
+ const writes = [];
189
+ const homeDir = userHomeDir();
190
+ const userConfigPath = path.join(homeDir, ".claude.json");
191
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
192
+ const manifestPath = path.join(getCopillmHome(), "claude", "native-mcp-manifest.json");
193
+ const serverNames = Object.keys(input.resolved.mcpServers);
194
+ const previousServerNames = readClaudeNativeManifest(manifestPath);
195
+ if (serverNames.length > 0 || previousServerNames.length > 0) {
196
+ const existing = readJsonObject(userConfigPath);
197
+ const mcpServers = asRecord(existing.mcpServers) ?? {};
198
+ for (const name of previousServerNames) {
199
+ if (!serverNames.includes(name)) {
200
+ delete mcpServers[name];
201
+ }
202
+ }
203
+ for (const [name, server] of Object.entries(input.resolved.mcpServers)) {
204
+ mcpServers[name] = serverToClaudeShape(server);
205
+ }
206
+ if (Object.keys(mcpServers).length > 0) {
207
+ existing.mcpServers = mcpServers;
208
+ }
209
+ else {
210
+ delete existing.mcpServers;
211
+ }
212
+ writes.push({
213
+ path: userConfigPath,
214
+ content: `${JSON.stringify(existing, null, 2)}\n`,
215
+ mode: 0o600,
216
+ description: "Claude Code user MCP config"
217
+ });
218
+ writes.push({
219
+ path: manifestPath,
220
+ content: `${JSON.stringify({ servers: serverNames }, null, 2)}\n`,
221
+ mode: 0o600,
222
+ description: "Claude Code native MCP manifest"
223
+ });
224
+ }
225
+ if (input.env && Object.keys(input.env).length > 0) {
226
+ const settings = readJsonObject(settingsPath);
227
+ const env = asRecord(settings.env) ?? {};
228
+ settings.env = { ...env, ...input.env };
229
+ writes.push({
230
+ path: settingsPath,
231
+ content: `${JSON.stringify(settings, null, 2)}\n`,
232
+ mode: 0o600,
233
+ description: "Claude Code settings.json env block"
234
+ });
235
+ }
236
+ return writes;
237
+ }
238
+ function userHomeDir() {
239
+ return process.env.HOME ?? os.homedir();
240
+ }
241
+ function readClaudeNativeManifest(filePath) {
242
+ const doc = readJsonObject(filePath);
243
+ const servers = doc.servers;
244
+ if (!Array.isArray(servers)) {
245
+ return [];
246
+ }
247
+ return servers.filter((server) => typeof server === "string");
248
+ }
249
+ function readJsonObject(filePath) {
250
+ if (!fs.existsSync(filePath)) {
251
+ return {};
252
+ }
253
+ try {
254
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
255
+ return asRecord(parsed) ?? {};
256
+ }
257
+ catch (error) {
258
+ throw new AgentConfigError(`Failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
259
+ }
260
+ }
125
261
  function renderClaudeMcp(servers) {
126
262
  const out = {};
127
263
  for (const [name, server] of Object.entries(servers)) {
@@ -267,10 +403,18 @@ export function planRender(opts, load) {
267
403
  if (!opts.codexHomeDir) {
268
404
  throw new AgentConfigError("renderCodex requires codexHomeDir");
269
405
  }
270
- return renderCodex({ ...baseInput, codexHomeDir: opts.codexHomeDir });
406
+ return renderCodex({
407
+ ...baseInput,
408
+ codexHomeDir: opts.codexHomeDir,
409
+ codexBaseConfigSourcePath: opts.codexBaseConfigSourcePath
410
+ });
271
411
  }
272
412
  case "claude":
273
- return renderClaude(baseInput);
413
+ return renderClaude({
414
+ ...baseInput,
415
+ nativeSync: opts.claudeNativeSync,
416
+ env: opts.claudeEnv
417
+ });
274
418
  case "pi":
275
419
  return renderPi(baseInput);
276
420
  case "copilot":
@@ -1,10 +1,13 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4
5
  import { getCopillmHome } from "../config/home.js";
5
6
  import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
7
+ import { loadConfig } from "../config/config.js";
6
8
  import { AgentConfigError, loadAgentConfig } from "../agentconfig/load.js";
7
9
  import { applyAgentConfig, formatApplyNotes } from "../agentconfig/apply.js";
10
+ import { buildClaudeEnvBundle } from "./agentEnv.js";
8
11
  const SCAFFOLD_TOML = `# copillm agent config — one source of truth for instructions and MCP servers
9
12
  # fanned out to each coding agent on \`copillm <agent>\` launch.
10
13
  # See: https://github.com/jcjc-dev/copillm (plans/unified-booping-mango.md)
@@ -112,7 +115,7 @@ export function registerConfigCommands(program) {
112
115
  });
113
116
  config
114
117
  .command("sync")
115
- .description("Run fan-out without launching an agent (debug aid)")
118
+ .description("Sync resolved config to the agent's native paths without launching")
116
119
  .requiredOption("--agent <kind>", "codex | claude | pi | copilot")
117
120
  .option("--profile <name>", "Override active profile for this run")
118
121
  .action((opts) => {
@@ -122,11 +125,21 @@ export function registerConfigCommands(program) {
122
125
  process.exit(1);
123
126
  }
124
127
  try {
128
+ const claudeEnv = agent === "claude"
129
+ ? buildClaudeEnvBundle({
130
+ port: loadConfig().preferredPort,
131
+ callerSecret: null,
132
+ enableGatewayDiscovery: true
133
+ }).env
134
+ : undefined;
125
135
  const result = applyAgentConfig({
126
136
  agent,
127
137
  cwd: process.cwd(),
128
138
  profileOverride: opts.profile ?? null,
129
- codexHomeDir: agent === "codex" ? path.join(getCopillmHome(), "codex") : undefined
139
+ codexHomeDir: agent === "codex" ? path.join(os.homedir(), ".codex") : undefined,
140
+ codexBaseConfigSourcePath: agent === "codex" ? path.join(getCopillmHome(), "codex", "config.toml") : undefined,
141
+ claudeNativeSync: agent === "claude",
142
+ claudeEnv
130
143
  });
131
144
  for (const line of formatApplyNotes(result, agent)) {
132
145
  process.stdout.write(`${line}\n`);
@@ -52,6 +52,14 @@ function installHint(agent) {
52
52
  " npm i -g @earendil-works/pi-coding-agent"
53
53
  ].join("\n");
54
54
  }
55
+ if (agent === "copilot") {
56
+ return [
57
+ "Hint: install GitHub Copilot CLI manually with one of:",
58
+ " brew install --cask github-copilot-cli",
59
+ " npm i -g @github/copilot",
60
+ " https://github.com/github/copilot-cli"
61
+ ].join("\n");
62
+ }
55
63
  return [
56
64
  "Hint: install Claude Code manually with:",
57
65
  " npm i -g @anthropic-ai/claude-code"
@@ -0,0 +1,52 @@
1
+ import { isBenignSocketError } from "../server/requestLifecycle.js";
2
+ /**
3
+ * Install a process-level safety net for the daemon. When an unexpected
4
+ * `uncaughtException` or `unhandledRejection` escapes the per-request error
5
+ * handling, we log it loudly and keep the process alive. A daemon dying on
6
+ * a per-request bug is strictly worse than continuing to serve the next
7
+ * request: clients (Codex, Claude Code, pi) lose ALL in-flight streams when
8
+ * the process exits, and the user has to manually `copillm start` again.
9
+ *
10
+ * Benign socket errors (ECONNRESET / EPIPE / ERR_STREAM_DESTROYED / aborted
11
+ * fetches) are downgraded to debug — they're a normal part of SSE life and
12
+ * would otherwise spam the logs.
13
+ *
14
+ * Returns a disposer that uninstalls the handlers (used by tests).
15
+ */
16
+ export function installProcessSafetyNet(logger) {
17
+ const onUncaught = (error) => {
18
+ if (isBenignSocketError(error)) {
19
+ logger.debug({ event: "process_safety_net", kind: "uncaught_exception", err: toErrLike(error) }, "swallowed benign socket error at process level");
20
+ return;
21
+ }
22
+ logger.error({ event: "process_safety_net", kind: "uncaught_exception", err: toErrLike(error) }, "uncaught exception in daemon — keeping process alive");
23
+ };
24
+ const onUnhandled = (reason) => {
25
+ if (isBenignSocketError(reason)) {
26
+ logger.debug({ event: "process_safety_net", kind: "unhandled_rejection", err: toErrLike(reason) }, "swallowed benign socket rejection at process level");
27
+ return;
28
+ }
29
+ logger.error({ event: "process_safety_net", kind: "unhandled_rejection", err: toErrLike(reason) }, "unhandled promise rejection in daemon — keeping process alive");
30
+ };
31
+ process.on("uncaughtException", onUncaught);
32
+ process.on("unhandledRejection", onUnhandled);
33
+ return () => {
34
+ process.off("uncaughtException", onUncaught);
35
+ process.off("unhandledRejection", onUnhandled);
36
+ };
37
+ }
38
+ function toErrLike(value) {
39
+ if (value instanceof Error) {
40
+ const out = {
41
+ type: value.name,
42
+ message: value.message
43
+ };
44
+ if (value.stack)
45
+ out.stack = value.stack;
46
+ const code = value.code;
47
+ if (typeof code === "string")
48
+ out.code = code;
49
+ return out;
50
+ }
51
+ return { type: typeof value, message: String(value) };
52
+ }
@@ -6,12 +6,14 @@ import { getCopillmHome } from "../config/home.js";
6
6
  const NPM_PACKAGES = {
7
7
  codex: "@openai/codex",
8
8
  claude: "@anthropic-ai/claude-code",
9
- pi: "@earendil-works/pi-coding-agent"
9
+ pi: "@earendil-works/pi-coding-agent",
10
+ copilot: "@github/copilot"
10
11
  };
11
12
  const BIN_NAMES = {
12
13
  codex: "codex",
13
14
  claude: "claude",
14
- pi: "pi"
15
+ pi: "pi",
16
+ copilot: "copilot"
15
17
  };
16
18
  export function packageNameFor(agent) {
17
19
  return NPM_PACKAGES[agent];
package/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ import { buildClaudeEnvBundle, buildCodexEnvBundle, buildPiEnvBundle } from "./c
25
25
  import { launchAgent } from "./cli/launchAgent.js";
26
26
  import { applyAgentConfig, formatApplyNotes } from "./agentconfig/apply.js";
27
27
  import { registerConfigCommands } from "./cli/configCommands.js";
28
+ import { installProcessSafetyNet } from "./cli/processSafetyNet.js";
28
29
  const logger = createLogger();
29
30
  const program = new Command();
30
31
  program.name("copillm").description("Local Copilot proxy").version("0.1.0");
@@ -754,6 +755,49 @@ program
754
755
  });
755
756
  process.exit(exitCode);
756
757
  });
758
+ program
759
+ .command("copilot")
760
+ .description("Launch GitHub Copilot CLI reusing copillm's stored GitHub token (no second device flow)")
761
+ .option("--copillm-use <spec>", "Pin copilot package version (e.g. 1.0.52 or @github/copilot@1.0.52)")
762
+ .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
763
+ .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
764
+ .allowUnknownOption(true)
765
+ .passThroughOptions()
766
+ .helpOption(false)
767
+ .argument("[args...]", "Args forwarded to copilot")
768
+ .action(async (forwardedArgs, opts) => {
769
+ const credential = await loadStoredCredential();
770
+ if (!credential) {
771
+ process.stderr.write("copillm: no stored GitHub credential — run `copillm auth login` first.\n");
772
+ process.exit(1);
773
+ return;
774
+ }
775
+ const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_COPILOT_VERSION ?? undefined;
776
+ const applyResult = applyAgentConfig({
777
+ agent: "copilot",
778
+ cwd: process.cwd(),
779
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
780
+ skip: Boolean(opts.copillmNoConfig)
781
+ });
782
+ for (const line of formatApplyNotes(applyResult, "copilot")) {
783
+ process.stderr.write(`${line}\n`);
784
+ }
785
+ // Inject the stored GitHub OAuth token into the child env only — never
786
+ // export to the parent shell and never persist. Copilot CLI honours
787
+ // COPILOT_GITHUB_TOKEN ahead of its own stored credentials, so this
788
+ // short-circuits its device-flow login when copillm already has a token.
789
+ const env = {
790
+ ...applyResult.envOverlay,
791
+ COPILOT_GITHUB_TOKEN: credential.token
792
+ };
793
+ const exitCode = await launchAgent({
794
+ agent: "copilot",
795
+ args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
796
+ env,
797
+ pinnedSpec
798
+ });
799
+ process.exit(exitCode);
800
+ });
757
801
  registerConfigCommands(program);
758
802
  program.parseAsync(process.argv).catch((error) => {
759
803
  if (error instanceof Error) {
@@ -814,6 +858,7 @@ async function runDaemon(options) {
814
858
  tokenManager.clear();
815
859
  throw new Error(`No available port in configured range (${ports[0]}-${ports[ports.length - 1]}).`);
816
860
  }
861
+ installProcessSafetyNet(logger);
817
862
  let shuttingDown = false;
818
863
  const shutdown = async () => {
819
864
  if (shuttingDown) {
@@ -10,6 +10,7 @@ import { translateOpenAIStreamToAnthropic, writeAnthropicPrelude } from "../tran
10
10
  import { buildCodexCatalog } from "./codexSchema.js";
11
11
  import { getGithubUserSummary, GithubUserFetchError } from "./debugInfo.js";
12
12
  import { buildAnthropicModelsResponse } from "./anthropicModelsResponse.js";
13
+ import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson, safeWrite } from "./requestLifecycle.js";
13
14
  const COPILOT_HEADERS = {
14
15
  "Content-Type": "application/json",
15
16
  "Copilot-Integration-Id": "vscode-chat",
@@ -49,6 +50,7 @@ export async function startProxyServer(input) {
49
50
  const requestId = randomUUID();
50
51
  const startedAt = Date.now();
51
52
  const pathname = safePathname(req.url);
53
+ const lifecycle = attachRequestLifecycle(req, res, input.logger, requestId);
52
54
  res.on("finish", () => {
53
55
  input.logger.info({
54
56
  event: "http_request",
@@ -199,11 +201,17 @@ export async function startProxyServer(input) {
199
201
  body: upstreamBody,
200
202
  requestId,
201
203
  logger: input.logger,
202
- upstreamPath
204
+ upstreamPath,
205
+ signal: lifecycle.signal
203
206
  });
204
207
  await forwardResponse(upstream, route.anthroShape, res, requestedModel ?? undefined, prelude);
205
208
  }
206
209
  catch (error) {
210
+ if (isBenignSocketError(error)) {
211
+ input.logger.debug({ event: "upstream_aborted", request_id: requestId, err: error }, "upstream request aborted (client disconnected)");
212
+ safeEnd(res);
213
+ return;
214
+ }
207
215
  if (error instanceof CopilotTokenManagerError) {
208
216
  if (prelude) {
209
217
  writeAnthropicSseError(res, prelude, "token_refresh_failed");
@@ -232,8 +240,31 @@ export async function startProxyServer(input) {
232
240
  sendJson(res, 400, { error: error.code, detail: error.message });
233
241
  return;
234
242
  }
235
- input.logger.error({ err: error }, "request failed");
243
+ if (isBenignSocketError(error)) {
244
+ input.logger.debug({ event: "request_client_gone", request_id: requestId, err: error }, "request handler aborted because client disconnected");
245
+ safeEnd(res);
246
+ return;
247
+ }
248
+ input.logger.error({ err: error, request_id: requestId }, "request failed");
236
249
  sendJson(res, 500, { error: "internal_error" });
250
+ safeEnd(res);
251
+ }
252
+ });
253
+ server.on("clientError", (err, socket) => {
254
+ input.logger.debug({ event: "client_error", code: err?.code }, "malformed HTTP from client");
255
+ if (socket.writable) {
256
+ try {
257
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
258
+ }
259
+ catch {
260
+ // Socket likely already gone — nothing to do.
261
+ }
262
+ }
263
+ try {
264
+ socket.destroy();
265
+ }
266
+ catch {
267
+ // best effort
237
268
  }
238
269
  });
239
270
  await new Promise((resolve, reject) => {
@@ -256,8 +287,11 @@ async function postToCopilot(input) {
256
287
  let forceRefresh = false;
257
288
  let authRefreshRetried = false;
258
289
  for (let attempt = 1; attempt <= MAX_UPSTREAM_ATTEMPTS; attempt += 1) {
290
+ if (input.signal?.aborted) {
291
+ throw abortErrorFromSignal(input.signal);
292
+ }
259
293
  try {
260
- const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath);
294
+ const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath, input.signal);
261
295
  forceRefresh = false;
262
296
  if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
263
297
  authRefreshRetried = true;
@@ -275,6 +309,10 @@ async function postToCopilot(input) {
275
309
  return response;
276
310
  }
277
311
  catch (error) {
312
+ if (isBenignSocketError(error)) {
313
+ // Client disconnected — propagate so the request handler can clean up.
314
+ throw error;
315
+ }
278
316
  if (!isRetryableTransportError(error) || attempt >= MAX_UPSTREAM_ATTEMPTS) {
279
317
  throw error;
280
318
  }
@@ -284,7 +322,7 @@ async function postToCopilot(input) {
284
322
  }
285
323
  throw new Error("Upstream retry budget exhausted unexpectedly.");
286
324
  }
287
- async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath) {
325
+ async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath, signal) {
288
326
  const bearer = await tokenManager.ensureToken({ forceRefresh });
289
327
  return fetch(`${accountBaseUrl(accountType)}${upstreamPath}`, {
290
328
  method: "POST",
@@ -293,9 +331,19 @@ async function postWithCurrentBearer(tokenManager, accountType, body, forceRefre
293
331
  Authorization: `Bearer ${bearer}`,
294
332
  "X-Request-Id": requestId
295
333
  },
296
- body: JSON.stringify(body)
334
+ body: JSON.stringify(body),
335
+ signal
297
336
  });
298
337
  }
338
+ function abortErrorFromSignal(signal) {
339
+ const reason = signal.reason;
340
+ if (reason instanceof Error) {
341
+ return reason;
342
+ }
343
+ const err = new Error("Request aborted by client");
344
+ err.name = "AbortError";
345
+ return err;
346
+ }
299
347
  async function forwardResponse(upstream, anthroShape, res, requestedModel, prelude) {
300
348
  if (!upstream.ok) {
301
349
  await discardUpstreamBody(upstream);
@@ -386,7 +434,17 @@ async function pipeEventStream(upstream, res) {
386
434
  if (res.socket && typeof res.socket.setNoDelay === "function") {
387
435
  res.socket.setNoDelay(true);
388
436
  }
389
- await pipeline(Readable.fromWeb(upstream.body), res);
437
+ try {
438
+ await pipeline(Readable.fromWeb(upstream.body), res);
439
+ }
440
+ catch (error) {
441
+ if (isBenignSocketError(error)) {
442
+ // Client went away mid-stream — normal for SSE consumers (Codex,
443
+ // Claude Code, pi) that cancel pending responses on user input.
444
+ return;
445
+ }
446
+ throw error;
447
+ }
390
448
  }
391
449
  function isEventStream(upstream) {
392
450
  const contentType = upstream.headers.get("content-type");
@@ -415,16 +473,16 @@ function beginAnthropicSseResponse(res, req) {
415
473
  export function writeAnthropicSseError(res, prelude, code) {
416
474
  void prelude;
417
475
  try {
418
- res.write(`event: message_delta\ndata: ${JSON.stringify({
476
+ safeWrite(res, `event: message_delta\ndata: ${JSON.stringify({
419
477
  type: "message_delta",
420
478
  delta: { stop_reason: "end_turn", stop_sequence: null },
421
479
  usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 }
422
480
  })}\n\n`);
423
- res.write(`event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: code } })}\n\n`);
424
- res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
481
+ safeWrite(res, `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: code } })}\n\n`);
482
+ safeWrite(res, `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
425
483
  }
426
484
  finally {
427
- res.end();
485
+ safeEnd(res);
428
486
  }
429
487
  }
430
488
  function isLocalRequest(req) {
@@ -455,9 +513,7 @@ async function readJson(req) {
455
513
  }
456
514
  }
457
515
  function sendJson(res, status, payload) {
458
- res.statusCode = status;
459
- res.setHeader("Content-Type", "application/json");
460
- res.end(JSON.stringify(payload));
516
+ safeSendJson(res, status, payload);
461
517
  }
462
518
  function readRequestedModel(payload) {
463
519
  if (!payload || typeof payload !== "object") {
@@ -0,0 +1,115 @@
1
+ // Node error codes we treat as "client went away, this is normal" — they
2
+ // must never crash the daemon and must not be reported as errors. SSE clients
3
+ // (Codex, Claude Code, pi) abort streams constantly: user types another
4
+ // prompt, hits Esc, switches turns, etc.
5
+ const BENIGN_SOCKET_ERROR_CODES = new Set([
6
+ "ECONNRESET",
7
+ "EPIPE",
8
+ "ERR_STREAM_PREMATURE_CLOSE",
9
+ "ERR_STREAM_DESTROYED",
10
+ "ERR_STREAM_WRITE_AFTER_END",
11
+ "ERR_HTTP_HEADERS_SENT"
12
+ ]);
13
+ export function isBenignSocketError(error) {
14
+ if (!error || typeof error !== "object") {
15
+ return false;
16
+ }
17
+ const err = error;
18
+ if (typeof err.code === "string" && BENIGN_SOCKET_ERROR_CODES.has(err.code)) {
19
+ return true;
20
+ }
21
+ if (err.name === "AbortError") {
22
+ return true;
23
+ }
24
+ if (err.cause && isBenignSocketError(err.cause)) {
25
+ return true;
26
+ }
27
+ return false;
28
+ }
29
+ export function attachRequestLifecycle(req, res, logger, requestId) {
30
+ const controller = new AbortController();
31
+ let alive = true;
32
+ const markGone = (source, err) => {
33
+ if (!alive) {
34
+ return;
35
+ }
36
+ alive = false;
37
+ if (err && !isBenignSocketError(err)) {
38
+ logger.debug({ event: "request_lifecycle_error", request_id: requestId, source, err }, "request stream errored");
39
+ }
40
+ else if (source !== "close" || err) {
41
+ logger.debug({ event: "request_lifecycle_closed", request_id: requestId, source }, "client disconnected");
42
+ }
43
+ if (!controller.signal.aborted) {
44
+ controller.abort();
45
+ }
46
+ };
47
+ res.on("close", () => markGone("close"));
48
+ res.on("error", (err) => markGone("error", err));
49
+ req.on("aborted", () => markGone("aborted"));
50
+ req.on("error", (err) => markGone("error", err));
51
+ return {
52
+ signal: controller.signal,
53
+ isAlive: () => alive && res.writable && !res.writableEnded
54
+ };
55
+ }
56
+ /**
57
+ * Writes a JSON response, but is a no-op when the response is already
58
+ * committed or the socket is gone. This is the safe replacement for
59
+ * `res.setHeader(...) + res.end(...)` in any path that might run after a
60
+ * streaming response has started flushing.
61
+ */
62
+ export function safeSendJson(res, status, payload) {
63
+ if (res.headersSent || !res.writable || res.writableEnded) {
64
+ return;
65
+ }
66
+ try {
67
+ res.statusCode = status;
68
+ res.setHeader("Content-Type", "application/json");
69
+ res.end(JSON.stringify(payload));
70
+ }
71
+ catch (error) {
72
+ if (isBenignSocketError(error)) {
73
+ return;
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+ /**
79
+ * Best-effort write to a downstream Writable. Returns false when the chunk
80
+ * could not be delivered (because the stream is destroyed or the write
81
+ * threw a benign socket error). Non-benign errors are re-thrown so they're
82
+ * still visible to the caller.
83
+ */
84
+ export function safeWrite(downstream, chunk) {
85
+ if (!downstream.writable || downstream.writableEnded || downstream.destroyed) {
86
+ return false;
87
+ }
88
+ try {
89
+ return downstream.write(chunk);
90
+ }
91
+ catch (error) {
92
+ if (isBenignSocketError(error)) {
93
+ return false;
94
+ }
95
+ throw error;
96
+ }
97
+ }
98
+ /**
99
+ * Best-effort end of a downstream Writable. Swallows benign socket errors;
100
+ * never throws on a destroyed stream.
101
+ */
102
+ export function safeEnd(downstream) {
103
+ if (downstream.writableEnded || downstream.destroyed) {
104
+ return;
105
+ }
106
+ try {
107
+ downstream.end();
108
+ }
109
+ catch (error) {
110
+ if (isBenignSocketError(error)) {
111
+ return;
112
+ }
113
+ throw error;
114
+ }
115
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { isBenignSocketError } from "../server/requestLifecycle.js";
2
3
  const PING_INTERVAL_MS = 1000;
3
4
  export async function translateOpenAIStreamToAnthropic(options) {
4
5
  const { upstream, downstream } = options;
@@ -16,11 +17,37 @@ export async function translateOpenAIStreamToAnthropic(options) {
16
17
  let nextAnthropicIndex = 0;
17
18
  let streamErrored = false;
18
19
  let pingTimer = null;
20
+ let downstreamGone = false;
21
+ function isDownstreamAlive() {
22
+ if (downstreamGone)
23
+ return false;
24
+ return downstream.writable && !downstream.writableEnded && !downstream.destroyed;
25
+ }
26
+ function markDownstreamGone() {
27
+ if (downstreamGone)
28
+ return;
29
+ downstreamGone = true;
30
+ stopPings();
31
+ // Best-effort: also stop reading upstream so we don't pull a megabyte
32
+ // of SSE we'll never deliver.
33
+ try {
34
+ upstream.destroy();
35
+ }
36
+ catch {
37
+ // ignore
38
+ }
39
+ }
40
+ downstream.on("close", markDownstreamGone);
41
+ downstream.on("error", markDownstreamGone);
19
42
  function startPings() {
20
43
  if (pingTimer !== null) {
21
44
  return;
22
45
  }
23
46
  pingTimer = setInterval(() => {
47
+ if (!isDownstreamAlive()) {
48
+ stopPings();
49
+ return;
50
+ }
24
51
  writeEvent("ping", { type: "ping" });
25
52
  }, PING_INTERVAL_MS);
26
53
  if (typeof pingTimer.unref === "function") {
@@ -34,7 +61,20 @@ export async function translateOpenAIStreamToAnthropic(options) {
34
61
  }
35
62
  }
36
63
  function writeEvent(eventName, data) {
37
- downstream.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
64
+ if (!isDownstreamAlive()) {
65
+ markDownstreamGone();
66
+ return;
67
+ }
68
+ try {
69
+ downstream.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
70
+ }
71
+ catch (error) {
72
+ if (isBenignSocketError(error)) {
73
+ markDownstreamGone();
74
+ return;
75
+ }
76
+ throw error;
77
+ }
38
78
  }
39
79
  function emitMessageStart() {
40
80
  if (messageStarted) {
@@ -198,6 +238,10 @@ export async function translateOpenAIStreamToAnthropic(options) {
198
238
  startPings();
199
239
  try {
200
240
  for await (const chunk of upstream) {
241
+ if (!isDownstreamAlive()) {
242
+ markDownstreamGone();
243
+ return;
244
+ }
201
245
  const text = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
202
246
  buffer += text;
203
247
  let newlineIndex;
@@ -213,8 +257,15 @@ export async function translateOpenAIStreamToAnthropic(options) {
213
257
  }
214
258
  }
215
259
  catch (error) {
216
- streamErrored = true;
217
260
  stopPings();
261
+ if (!isDownstreamAlive() || isBenignSocketError(error)) {
262
+ // Either we destroyed the upstream because downstream went away, or
263
+ // the upstream rejected with a benign socket error. Either way, no
264
+ // recovery write is possible — just stop.
265
+ markDownstreamGone();
266
+ return;
267
+ }
268
+ streamErrored = true;
218
269
  emitMessageStart();
219
270
  closeAllBlocks();
220
271
  writeEvent("message_delta", {
@@ -227,13 +278,16 @@ export async function translateOpenAIStreamToAnthropic(options) {
227
278
  error: { type: "api_error", message: error instanceof Error ? error.message : "upstream stream error" }
228
279
  });
229
280
  writeEvent("message_stop", { type: "message_stop" });
230
- downstream.end();
281
+ safeEndDownstream();
231
282
  return;
232
283
  }
233
284
  if (streamErrored) {
234
285
  return;
235
286
  }
236
287
  stopPings();
288
+ if (!isDownstreamAlive()) {
289
+ return;
290
+ }
237
291
  emitMessageStart();
238
292
  closeAllBlocks();
239
293
  writeEvent("message_delta", {
@@ -242,7 +296,18 @@ export async function translateOpenAIStreamToAnthropic(options) {
242
296
  usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens }
243
297
  });
244
298
  writeEvent("message_stop", { type: "message_stop" });
245
- downstream.end();
299
+ safeEndDownstream();
300
+ function safeEndDownstream() {
301
+ if (downstream.writableEnded || downstream.destroyed)
302
+ return;
303
+ try {
304
+ downstream.end();
305
+ }
306
+ catch (error) {
307
+ if (!isBenignSocketError(error))
308
+ throw error;
309
+ }
310
+ }
246
311
  }
247
312
  /**
248
313
  * Write the Anthropic `message_start` event (and an initial ping) to the
@@ -269,8 +334,14 @@ export function writeAnthropicPrelude(downstream, model) {
269
334
  usage: { input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 }
270
335
  }
271
336
  };
272
- downstream.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
273
- downstream.write(`event: ping\ndata: ${JSON.stringify({ type: "ping" })}\n\n`);
337
+ try {
338
+ downstream.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
339
+ downstream.write(`event: ping\ndata: ${JSON.stringify({ type: "ping" })}\n\n`);
340
+ }
341
+ catch (error) {
342
+ if (!isBenignSocketError(error))
343
+ throw error;
344
+ }
274
345
  return { messageId };
275
346
  }
276
347
  function mapFinishReason(reason) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",