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 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];