copillm 0.4.1 → 0.4.4

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.
@@ -20,7 +20,8 @@ import { writeCommandOutput, writeHealthOutput } from "../shared/output.js";
20
20
  import { spawnDetachedDaemon } from "../daemon/spawnDetached.js";
21
21
  import { resolveRestartDecision } from "../daemon/restart.js";
22
22
  import { selfUpdateToLatest, describeSelfUpdate } from "../daemon/selfUpdate.js";
23
- import { getPackageInfo } from "../packageInfo.js";
23
+ import { computeVersionStatus } from "../daemon/versionStatus.js";
24
+ import { getPackageInfo } from "../../config/packageInfo.js";
24
25
  export function register(program) {
25
26
  program
26
27
  .command("start")
@@ -267,11 +268,13 @@ export function register(program) {
267
268
  .command("status")
268
269
  .description("Show daemon status")
269
270
  .option("--json", "JSON output")
271
+ .option("--no-registry-check", "Skip the npm registry lookup for latest-version comparison (also via NO_UPDATE_NOTIFIER / COPILLM_UPDATE_CHECK=0)")
270
272
  .action(async (opts) => {
271
273
  const config = loadConfig();
272
274
  const lockState = inspectLock();
273
275
  const checkedAtIso = new Date().toISOString();
274
276
  const uptimeSeconds = lockState.state === "running" ? computeUptimeSeconds(lockState.lock.started_at_iso) : null;
277
+ const cliPackageInfo = getPackageInfo();
275
278
  // inspectStoredCredential never returns the token itself, so it's safe to
276
279
  // include the result in the status payload.
277
280
  let authInfo;
@@ -303,6 +306,16 @@ export function register(program) {
303
306
  health_state: null,
304
307
  health_error: null,
305
308
  health_status: "unknown",
309
+ // Version reporting — populated below. cli_version is always known
310
+ // (read from this CLI binary). daemon_version is `null` until the
311
+ // /healthz probe completes; older daemons (predating the field) also
312
+ // report null. latest_version is best-effort and `null` if the
313
+ // registry lookup is skipped or fails.
314
+ daemon_version: null,
315
+ cli_version: cliPackageInfo.version,
316
+ latest_version: null,
317
+ update_available: false,
318
+ version_hint: null,
306
319
  checked_at_iso: checkedAtIso,
307
320
  stale_reason: lockState.state === "stale" ? lockState.reason : null
308
321
  };
@@ -313,7 +326,19 @@ export function register(program) {
313
326
  status.health_check_status_code = health.statusCode;
314
327
  status.health_state = health.status;
315
328
  status.health_error = health.error;
329
+ status.daemon_version = health.version;
316
330
  }
331
+ // Commander turns `--no-registry-check` into `opts.registryCheck === false`.
332
+ const versionStatus = await computeVersionStatus({
333
+ cliPackageInfo,
334
+ daemonVersion: status.daemon_version,
335
+ daemonRunning: status.running,
336
+ noRegistryCheck: opts.registryCheck === false
337
+ });
338
+ status.daemon_version = versionStatus.daemon_version;
339
+ status.latest_version = versionStatus.latest_version;
340
+ status.update_available = versionStatus.update_available;
341
+ status.version_hint = versionStatus.hint;
317
342
  if (opts.json) {
318
343
  process.stdout.write(JSON.stringify(status, null, 2) + "\n");
319
344
  return;
@@ -332,6 +357,7 @@ export function register(program) {
332
357
  process.stdout.write(` error=${status.health_error}`);
333
358
  }
334
359
  process.stdout.write("\n");
360
+ writeVersionLine(status);
335
361
  if (status.bearer_ttl_seconds !== null) {
336
362
  process.stdout.write(`bearer_ttl_seconds: ${status.bearer_ttl_seconds}\n`);
337
363
  }
@@ -344,10 +370,12 @@ export function register(program) {
344
370
  }
345
371
  if (lockState.state === "stale") {
346
372
  process.stdout.write(`stale lock (${lockState.reason})\n`);
373
+ writeVersionLine(status);
347
374
  writeAuthStatusLine(authInfo);
348
375
  return;
349
376
  }
350
377
  process.stdout.write("not running\n");
378
+ writeVersionLine(status);
351
379
  writeAuthStatusLine(authInfo);
352
380
  });
353
381
  program
@@ -453,6 +481,37 @@ function toDaemonLockState(lockState) {
453
481
  }
454
482
  return { state: "missing" };
455
483
  }
484
+ /**
485
+ * Render the `version: ...` line for `copillm status` human output.
486
+ *
487
+ * When the daemon is running and reports the same version as this CLI, the
488
+ * line is just `version: 0.4.3` — the common, boring case. Mismatches surface
489
+ * inline so users see immediately whether they need to restart (cli > daemon)
490
+ * or `npm install -g copillm` (latest > cli). When the daemon is stopped, the
491
+ * line only reports the CLI's version, since there is no live daemon version
492
+ * to compare against.
493
+ */
494
+ function writeVersionLine(status) {
495
+ let line = "version: ";
496
+ if (status.running && status.daemon_version !== null) {
497
+ line += status.daemon_version;
498
+ if (status.daemon_version !== status.cli_version) {
499
+ line += ` (cli ${status.cli_version})`;
500
+ }
501
+ }
502
+ else if (status.running && status.daemon_version === null) {
503
+ // Either an older daemon that predates this field, or a malformed
504
+ // /healthz response. The version_hint below will prompt a restart.
505
+ line += `? (cli ${status.cli_version})`;
506
+ }
507
+ else {
508
+ line += `cli ${status.cli_version}`;
509
+ }
510
+ if (status.version_hint) {
511
+ line += ` — ${status.version_hint}`;
512
+ }
513
+ process.stdout.write(line + "\n");
514
+ }
456
515
  /**
457
516
  * Load the shared credential/config/discovery context for `copillm start`'s
458
517
  * codex + pi init steps, ONLY when at least one of them is going to run.
@@ -94,7 +94,8 @@ export async function probeHealth(port, options) {
94
94
  statusCode: response.status,
95
95
  status: typeof payload.status === "string" ? payload.status : null,
96
96
  error: typeof payload.error === "string" ? payload.error : null,
97
- bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null
97
+ bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null,
98
+ version: typeof payload.version === "string" && payload.version.length > 0 ? payload.version : null
98
99
  },
99
100
  failed: false
100
101
  };
@@ -104,7 +105,7 @@ export async function probeHealth(port, options) {
104
105
  }
105
106
  }, options);
106
107
  return result.failed
107
- ? { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed" }
108
+ ? { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed", version: null }
108
109
  : result.ok;
109
110
  }
110
111
  export async function readLiveLock() {
@@ -0,0 +1,63 @@
1
+ import { fetchLatestNpmVersion, isNewerVersion, parseBooleanOverride } from "../updateNotifier.js";
2
+ const DEFAULT_REGISTRY_TIMEOUT_MS = 1_500;
3
+ /**
4
+ * Resolve the three version data points (daemon, cli, latest-on-npm) and
5
+ * compute the `copillm status` actionable hint from them. Pulled out of the
6
+ * status command so it stays unit-testable end-to-end without spinning up a
7
+ * real proxy.
8
+ *
9
+ * Opt-out precedence (any of these skips the npm lookup):
10
+ * - `noRegistryCheck` argument (driven by the `--no-registry-check` flag)
11
+ * - `NO_UPDATE_NOTIFIER` env (matches the update-notifier convention)
12
+ * - `COPILLM_UPDATE_CHECK=0|false|no|off`
13
+ *
14
+ * The registry lookup itself is best-effort: timeouts / network errors
15
+ * silently yield `latest_version: null`. We do *not* surface a "registry
16
+ * unreachable" message in status — that would be more noise than signal for
17
+ * a daemon-status command.
18
+ */
19
+ export async function computeVersionStatus(options) {
20
+ const env = options.env ?? process.env;
21
+ const cliVersion = options.cliPackageInfo.version;
22
+ const daemonVersion = options.daemonVersion;
23
+ const registryCheckDisabled = options.noRegistryCheck === true ||
24
+ "NO_UPDATE_NOTIFIER" in env ||
25
+ parseBooleanOverride(env.COPILLM_UPDATE_CHECK) === false;
26
+ let latestVersion = null;
27
+ if (!registryCheckDisabled) {
28
+ latestVersion = await fetchLatestNpmVersion(options.cliPackageInfo.name, {
29
+ fetchImpl: options.fetchImpl,
30
+ registryUrl: options.registryUrl ?? env.COPILLM_UPDATE_REGISTRY_URL,
31
+ timeoutMs: options.timeoutMs ?? DEFAULT_REGISTRY_TIMEOUT_MS
32
+ });
33
+ }
34
+ const daemonStale = options.daemonRunning && daemonVersion !== null && isNewerVersion(cliVersion, daemonVersion);
35
+ const cliStale = latestVersion !== null && isNewerVersion(latestVersion, cliVersion);
36
+ const daemonReportsNoVersion = options.daemonRunning && daemonVersion === null;
37
+ return {
38
+ daemon_version: daemonVersion,
39
+ cli_version: cliVersion,
40
+ latest_version: latestVersion,
41
+ update_available: daemonStale || cliStale,
42
+ hint: buildVersionHint({ daemonStale, cliStale, daemonReportsNoVersion, cliVersion, latestVersion })
43
+ };
44
+ }
45
+ /**
46
+ * Pure helper. Public for direct unit testing of the messaging matrix.
47
+ */
48
+ export function buildVersionHint(input) {
49
+ const parts = [];
50
+ if (input.cliStale && input.latestVersion !== null) {
51
+ parts.push(`newer version available: v${input.latestVersion} (npm install -g copillm)`);
52
+ }
53
+ if (input.daemonStale) {
54
+ parts.push(`restart to apply cli v${input.cliVersion}`);
55
+ }
56
+ if (parts.length === 0 && input.daemonReportsNoVersion) {
57
+ return "restart to start reporting version";
58
+ }
59
+ if (parts.length === 0) {
60
+ return null;
61
+ }
62
+ return parts.join("; ");
63
+ }
package/dist/cli/index.js CHANGED
@@ -11,7 +11,7 @@ import * as piCmd from "./commands/agents/pi.js";
11
11
  import * as copilotCmd from "./commands/agents/copilot.js";
12
12
  import { setRootLogger, setRootProgram } from "./shared/debug.js";
13
13
  import { applyDevModeEnv } from "./shared/devMode.js";
14
- import { getPackageInfo } from "./packageInfo.js";
14
+ import { getPackageInfo } from "../config/packageInfo.js";
15
15
  import { maybeNotifyAboutUpdate } from "./updateNotifier.js";
16
16
  const logger = createLogger();
17
17
  const program = new Command();
@@ -67,6 +67,24 @@ export function distTagsUrl(packageName, registryUrl = DEFAULT_REGISTRY_URL) {
67
67
  export function isNewerVersion(candidate, current) {
68
68
  return compareSemver(candidate, current) > 0;
69
69
  }
70
+ /**
71
+ * Parse `"1" | "true" | "yes" | "on"` → true, `"0" | "false" | "no" | "off"` → false,
72
+ * anything else → null. Exposed so the status command can mirror the
73
+ * `COPILLM_UPDATE_CHECK` opt-out semantics without re-implementing the parsing.
74
+ */
75
+ export function parseBooleanOverride(value) {
76
+ if (value === undefined) {
77
+ return null;
78
+ }
79
+ const normalized = value.trim().toLowerCase();
80
+ if (["1", "true", "yes", "on"].includes(normalized)) {
81
+ return true;
82
+ }
83
+ if (["0", "false", "no", "off"].includes(normalized)) {
84
+ return false;
85
+ }
86
+ return null;
87
+ }
70
88
  function shouldRunUpdateCheck(opts) {
71
89
  if (opts.stderr.isTTY !== true)
72
90
  return false;
@@ -168,19 +186,6 @@ function notifyIfNewer(stderr, packageInfo, latestVersion) {
168
186
  function hasArg(argv, arg) {
169
187
  return argv.slice(2).includes(arg);
170
188
  }
171
- function parseBooleanOverride(value) {
172
- if (value === undefined) {
173
- return null;
174
- }
175
- const normalized = value.trim().toLowerCase();
176
- if (["1", "true", "yes", "on"].includes(normalized)) {
177
- return true;
178
- }
179
- if (["0", "false", "no", "off"].includes(normalized)) {
180
- return false;
181
- }
182
- return null;
183
- }
184
189
  function isTruthyCi(value) {
185
190
  if (value === undefined) {
186
191
  return false;
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.4.1"
4
+ version: "0.4.4"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -1,5 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { getPackageInfo } from "../config/packageInfo.js";
3
4
  import { singleAccountResolver } from "./accountResolver.js";
4
5
  import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson } from "./requestLifecycle.js";
5
6
  import { InvalidRequestShapeError, JsonRequestParseError, RequestBodyTooLargeError } from "./errors.js";
@@ -11,6 +12,7 @@ import { handleProxyForward } from "./routes/proxyForward.js";
11
12
  import { isLocalRequest, resolveRoute, safePathname } from "./routes/shared.js";
12
13
  export async function startProxyServer(input) {
13
14
  const debugEnabled = input.debug === true;
15
+ const packageVersion = getPackageInfo().version;
14
16
  const resolver = input.accountResolver ??
15
17
  singleAccountResolver({
16
18
  tokenManager: input.tokenManager,
@@ -57,10 +59,10 @@ export async function startProxyServer(input) {
57
59
  }
58
60
  switch (route.kind) {
59
61
  case "livez":
60
- handleLivez(res);
62
+ handleLivez(res, { version: packageVersion });
61
63
  return;
62
64
  case "healthz":
63
- await handleHealthz(res, resolver.default.tokenManager);
65
+ await handleHealthz(res, resolver.default.tokenManager, { version: packageVersion });
64
66
  return;
65
67
  case "models":
66
68
  await handleModels(res, route.kind, resolver.default);
@@ -86,7 +88,8 @@ export async function startProxyServer(input) {
86
88
  tokenManager: resolver.default.tokenManager,
87
89
  githubToken: resolver.default.githubToken,
88
90
  port: input.port,
89
- accounts: resolver.describe()
91
+ accounts: resolver.describe(),
92
+ packageVersion
90
93
  });
91
94
  return;
92
95
  case "not_found":
@@ -35,6 +35,7 @@ export async function handleDebug(res, input) {
35
35
  port: input.port,
36
36
  pid: process.pid,
37
37
  node_version: process.version,
38
+ version: input.packageVersion ?? null,
38
39
  started_at_iso: DAEMON_STARTED_AT_ISO,
39
40
  uptime_seconds: uptimeSeconds,
40
41
  account_type: input.config.accountType,
@@ -1,17 +1,22 @@
1
1
  import { healthFailure } from "../errors.js";
2
2
  import { safeSendJson } from "../requestLifecycle.js";
3
3
  const HEALTH_REFRESH_THRESHOLD_SECONDS = 60;
4
- export function handleLivez(res) {
5
- safeSendJson(res, 200, { status: "ok", uptime_seconds: Math.floor(process.uptime()) });
4
+ export function handleLivez(res, meta) {
5
+ safeSendJson(res, 200, {
6
+ status: "ok",
7
+ uptime_seconds: Math.floor(process.uptime()),
8
+ version: meta.version
9
+ });
6
10
  }
7
- export async function handleHealthz(res, tokenManager) {
11
+ export async function handleHealthz(res, tokenManager, meta) {
8
12
  const ttl = tokenManager.expiresInSeconds();
9
13
  if (ttl !== null && ttl > HEALTH_REFRESH_THRESHOLD_SECONDS) {
10
14
  safeSendJson(res, 200, {
11
15
  status: "ok",
12
16
  token_state: "fresh",
13
17
  refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
14
- bearer_ttl_seconds: ttl
18
+ bearer_ttl_seconds: ttl,
19
+ version: meta.version
15
20
  });
16
21
  return;
17
22
  }
@@ -22,11 +27,12 @@ export async function handleHealthz(res, tokenManager) {
22
27
  status: "ok",
23
28
  token_state: "refreshed",
24
29
  refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
25
- bearer_ttl_seconds: refreshedTtl
30
+ bearer_ttl_seconds: refreshedTtl,
31
+ version: meta.version
26
32
  });
27
33
  }
28
34
  catch (error) {
29
35
  const failed = healthFailure(error);
30
- safeSendJson(res, failed.httpStatus, failed.payload);
36
+ safeSendJson(res, failed.httpStatus, { ...failed.payload, version: meta.version });
31
37
  }
32
38
  }
@@ -1,6 +1,6 @@
1
1
  import { resolveModelId } from "../../models/discovery.js";
2
2
  import { CopilotTokenManagerError } from "../../auth/copilotToken.js";
3
- import { anthropicToOpenAI } from "../../translation/openaiAnthropic.js";
3
+ import { anthropicToOpenAI, ProtocolTranslationError } from "../../translation/openaiAnthropic.js";
4
4
  import { writeAnthropicPrelude } from "../../translation/streamingOpenAIToAnthropic.js";
5
5
  import { isBenignSocketError, safeEnd, safeSendJson } from "../requestLifecycle.js";
6
6
  import { InvalidRequestShapeError, writeAnthropicSseError } from "../errors.js";
@@ -15,6 +15,9 @@ function translateRequestBody(routeKind, body) {
15
15
  return anthropicToOpenAI(body);
16
16
  }
17
17
  catch (error) {
18
+ if (error instanceof ProtocolTranslationError) {
19
+ throw error;
20
+ }
18
21
  if (error instanceof Error) {
19
22
  throw new InvalidRequestShapeError(error.message);
20
23
  }
@@ -297,14 +297,68 @@ function translateImageSource(source) {
297
297
  }
298
298
  throw new ProtocolTranslationError("unsupported_image_source", `Unsupported Anthropic image source type: ${String(source.type)}.`);
299
299
  }
300
+ /**
301
+ * Translate Anthropic `tool_result.content` into the string that goes into the
302
+ * OpenAI `tool` message's `content` field.
303
+ *
304
+ * Anthropic permits a `tool_result.content` array to contain `text` blocks
305
+ * *and* `image` blocks (and tolerates unknown future block types), but the
306
+ * OpenAI chat-completions `tool` role only accepts string content. Rather
307
+ * than reject the whole request — which historically surfaced to Claude Code
308
+ * as the cryptic `invalid_request_shape: Anthropic tool_result content only
309
+ * supports text blocks.` 400 whenever a tool (e.g. a screenshot MCP, an image
310
+ * read) returned an image — we degrade gracefully:
311
+ *
312
+ * - text blocks → extracted verbatim
313
+ * - image blocks → a `[image: <media_type_or_url>]` placeholder so the
314
+ * model at least sees that the tool returned an image
315
+ * - other types → a `[unsupported tool_result content block: type=X]`
316
+ * placeholder
317
+ *
318
+ * Full image pass-through (forwarding the image bytes to the model as a
319
+ * follow-up multi-part user message) is a deliberate non-goal for this
320
+ * function — it would change the request structure and the upstream contract
321
+ * for `tool` messages. We can revisit if/when there is a confirmed need.
322
+ *
323
+ * Malformed `text` blocks (missing `.text`) still throw — that is a real
324
+ * client bug, not a content shape we should silently invent text for.
325
+ */
300
326
  function translateToolResultContent(content) {
301
327
  if (typeof content === "string") {
302
328
  return content;
303
329
  }
304
330
  if (!Array.isArray(content)) {
305
- throw new ProtocolTranslationError("invalid_tool_result_content", "Anthropic tool_result content must be string or text blocks.");
331
+ throw new ProtocolTranslationError("invalid_tool_result_content", "Anthropic tool_result content must be a string or an array of content blocks.");
332
+ }
333
+ return content.map(stringifyToolResultContentBlock).join("\n");
334
+ }
335
+ function stringifyToolResultContentBlock(block) {
336
+ if (!isObject(block) || typeof block.type !== "string") {
337
+ throw new ProtocolTranslationError("invalid_tool_result_content", "Anthropic tool_result content blocks must be objects with a string type.");
338
+ }
339
+ if (block.type === "text") {
340
+ if (typeof block.text !== "string") {
341
+ throw new ProtocolTranslationError("invalid_text_block", "Anthropic text block must include string text.");
342
+ }
343
+ return block.text;
344
+ }
345
+ if (block.type === "image") {
346
+ const descriptor = describeImageSource(block.source);
347
+ return descriptor ? `[image: ${descriptor}]` : "[image]";
348
+ }
349
+ return `[unsupported tool_result content block: type=${block.type}]`;
350
+ }
351
+ function describeImageSource(source) {
352
+ if (!isObject(source) || typeof source.type !== "string") {
353
+ return null;
354
+ }
355
+ if (source.type === "base64" && typeof source.media_type === "string" && source.media_type.length > 0) {
356
+ return source.media_type;
357
+ }
358
+ if (source.type === "url" && typeof source.url === "string" && source.url.length > 0) {
359
+ return source.url;
306
360
  }
307
- return joinTextBlocks(content, "Anthropic tool_result content");
361
+ return null;
308
362
  }
309
363
  function translateToolDefinition(tool) {
310
364
  if (!isObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.4.1",
3
+ "version": "0.4.4",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",