copillm 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agentconfig/render.js +169 -25
- package/dist/cli/configCommands.js +15 -2
- package/dist/cli/launchAgent.js +8 -0
- package/dist/cli/processSafetyNet.js +52 -0
- package/dist/cli/resolveAgent.js +4 -2
- package/dist/cli.js +139 -18
- package/dist/config/home.js +3 -0
- package/dist/config/logging.js +27 -5
- package/dist/models/anthropicDefaults.js +1 -0
- package/dist/server/proxy.js +292 -26
- package/dist/server/requestLifecycle.js +115 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +77 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# copillm
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jcjc-dev/copillm/actions/workflows/pr-gate.yml)
|
|
4
|
-
[](https://github.com/jcjc-dev/copillm/actions/workflows/upstream-e2e.yml)
|
|
5
5
|
[](https://www.npmjs.com/package/copillm)
|
|
6
6
|
[](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
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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({
|
|
406
|
+
return renderCodex({
|
|
407
|
+
...baseInput,
|
|
408
|
+
codexHomeDir: opts.codexHomeDir,
|
|
409
|
+
codexBaseConfigSourcePath: opts.codexBaseConfigSourcePath
|
|
410
|
+
});
|
|
271
411
|
}
|
|
272
412
|
case "claude":
|
|
273
|
-
return renderClaude(
|
|
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("
|
|
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(
|
|
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`);
|
package/dist/cli/launchAgent.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/cli/resolveAgent.js
CHANGED
|
@@ -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];
|