jeo-code 0.12.8 → 0.12.9

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/CHANGELOG.md CHANGED
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
9
9
 
10
- ## [Unreleased]
10
+ ## [0.12.9] - 2026-11-02
11
+ ≔14c4+
12
+ - **Discord model policy** — optionally pin a Discord worker model and fall back on persistent usage or quota exhaustion before any tool execution; other platforms and global routing retain their own settings.
11
13
 
12
14
  ### Added
13
15
  - **Discord session files and control** — local opt-in enables authorized Discord prompts to use host session tools and exchange staged attachments; see the [Discord guide](docs/integrations/discord.md) for limits, public visibility, and revocation boundaries.
package/README.ja.md CHANGED
@@ -417,7 +417,7 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
417
417
  ## 変更履歴 (Changelog)
418
418
 
419
419
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
420
- - **[Unreleased]**
420
+ - **[0.12.9]** (2026-11-02)
421
421
  - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
422
422
  - **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
423
423
  - **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
package/README.ko.md CHANGED
@@ -418,7 +418,7 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
418
418
  ## 변경 이력 (Changelog)
419
419
 
420
420
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
421
- - **[Unreleased]**
421
+ - **[0.12.9]** (2026-11-02)
422
422
  - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
423
423
  - **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
424
424
  - **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
package/README.md CHANGED
@@ -410,7 +410,7 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
410
410
  ## Changelog
411
411
 
412
412
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
413
- - **[Unreleased]**
413
+ - **[0.12.9]** (2026-11-02)
414
414
  - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
415
415
  - **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
416
416
  - **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
package/README.zh.md CHANGED
@@ -415,7 +415,7 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
415
415
  ## 更新日志 (Changelog)
416
416
 
417
417
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
418
- - **[Unreleased]**
418
+ - **[0.12.9]** (2026-11-02)
419
419
  - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
420
420
  - **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
421
421
  - **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.12.8",
3
+ "version": "0.12.9",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -53,6 +53,7 @@
53
53
  "changelog:sync": "bun scripts/sync-changelog.ts",
54
54
  "test": "bun test",
55
55
  "verify:browser": "bun scripts/verify-browser.ts",
56
+ "verify:discord": "bun scripts/verify-discord-integration.ts",
56
57
  "verify:tmux": "bash scripts/tmux-verify.sh",
57
58
  "dev:link": "bun scripts/dev-link.ts link",
58
59
  "dev:doctor": "bun scripts/dev-link.ts doctor"
@@ -1,10 +1,12 @@
1
1
  import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
 
4
- const MAX_FILES = 10;
4
+ /** Limit enforced on presented attachment entries, before validation or deduplication. */
5
+ export const MAX_ARTIFACTS = 10;
5
6
  const MAX_FILE_BYTES = 25 * 1024 * 1024;
6
7
  const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
7
- const CDN_HOSTS = ["cdn.discordapp.com", "media.discordapp.net"];
8
+ export const DISCORD_ATTACHMENT_CDN_HOSTS = ["cdn.discordapp.com", "media.discordapp.net"] as const;
9
+ export const MAX_DISCORD_ATTACHMENT_REDIRECTS = 5;
8
10
 
9
11
  interface Attachment {
10
12
  filename: string;
@@ -23,12 +25,62 @@ export interface DiscordAttachmentStage {
23
25
  cleanup: () => Promise<void>;
24
26
  }
25
27
 
28
+ /**
29
+ * Parse and validate one Discord CDN URL without accepting normalization that
30
+ * could conceal an ambiguous authority. Host matching is exact after URL's
31
+ * ASCII/case normalization; Unicode, percent-encoded authorities, trailing
32
+ * dots, user-info, and explicit ports are rejected.
33
+ */
34
+ export function validateDiscordAttachmentUrl(value: string): URL {
35
+ if (typeof value !== "string") throw new Error("Untrusted Discord attachment URL");
36
+ const authority = value.match(/^https:\/\/([^/?#]*)/i)?.[1];
37
+ if (!authority || /[^\x00-\x7f]/.test(authority) || authority.includes("%") || authority.endsWith(".")) {
38
+ throw new Error("Untrusted Discord attachment URL");
39
+ }
40
+ if (authority.includes("@") || authority.includes(":")) throw new Error("Untrusted Discord attachment URL");
41
+ let url: URL;
42
+ try { url = new URL(value); } catch { throw new Error("Invalid Discord attachment URL"); }
43
+ if (url.protocol !== "https:" ||
44
+ !(DISCORD_ATTACHMENT_CDN_HOSTS as readonly string[]).includes(url.hostname) ||
45
+ url.username || url.password || url.port) {
46
+ throw new Error("Untrusted Discord attachment URL");
47
+ }
48
+ return url;
49
+ }
50
+
51
+ async function fetchTrustedAttachment(
52
+ initialUrl: string,
53
+ fetchImpl: typeof fetch,
54
+ signal: AbortSignal,
55
+ ): Promise<Response> {
56
+ let current = validateDiscordAttachmentUrl(initialUrl);
57
+ for (let redirects = 0; ; redirects++) {
58
+ const response = await fetchImpl(current.href, { redirect: "manual", signal });
59
+ if (response.status >= 300 && response.status < 400) {
60
+ const location = response.headers.get("location");
61
+ await response.body?.cancel().catch(() => {});
62
+ if (!location || redirects >= MAX_DISCORD_ATTACHMENT_REDIRECTS) throw new Error("Discord attachment redirect rejected");
63
+ // Validate an authority-bearing Location before URL resolution can erase
64
+ // percent-encoding, IDNA, case, or default-port evidence.
65
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(location)) validateDiscordAttachmentUrl(location);
66
+ else if (location.startsWith("//")) validateDiscordAttachmentUrl(`${current.protocol}${location}`);
67
+ current = validateDiscordAttachmentUrl(new URL(location, current).href);
68
+ continue;
69
+ }
70
+ // Native fetch exposes the final URL. Test doubles commonly leave it blank.
71
+ if (response.url) validateDiscordAttachmentUrl(response.url);
72
+ return response;
73
+ }
74
+ }
75
+
26
76
  /** Staged files are private and must be removed with cleanup after the request finishes. */
27
77
  export async function stageDiscordAttachments(
28
78
  attachments: unknown[],
29
79
  options: DiscordAttachmentOptions,
30
80
  ): Promise<DiscordAttachmentStage> {
31
- if (!Array.isArray(attachments) || attachments.length > MAX_FILES) throw new Error("Invalid Discord attachment count");
81
+ // Every presented entry consumes the limit, including duplicates and entries
82
+ // that would later fail metadata validation. Overflow rejects the whole input.
83
+ if (!Array.isArray(attachments) || attachments.length > MAX_ARTIFACTS) throw new Error("Invalid Discord attachment count");
32
84
  const validated: Attachment[] = [];
33
85
  let declaredTotal = 0;
34
86
  for (const value of attachments) {
@@ -38,11 +90,7 @@ export async function stageDiscordAttachments(
38
90
  !Number.isSafeInteger(item.size) || (item.size as number) < 0 || (item.size as number) > MAX_FILE_BYTES) {
39
91
  throw new Error("Invalid Discord attachment metadata");
40
92
  }
41
- let url: URL;
42
- try { url = new URL(item.url); } catch { throw new Error("Invalid Discord attachment URL"); }
43
- if (url.protocol !== "https:" || !CDN_HOSTS.includes(url.hostname) || url.username || url.password || url.port) {
44
- throw new Error("Untrusted Discord attachment URL");
45
- }
93
+ const url = validateDiscordAttachmentUrl(item.url);
46
94
  declaredTotal += item.size as number;
47
95
  if (declaredTotal > MAX_TOTAL_BYTES) throw new Error("Discord attachments exceed total size limit");
48
96
  validated.push({ filename: item.filename, url: url.href, size: item.size as number });
@@ -69,7 +117,7 @@ export async function stageDiscordAttachments(
69
117
  const signal = AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]);
70
118
  let response: Response;
71
119
  try {
72
- response = await (options.fetchImpl ?? fetch)(item.url, { redirect: "error", signal });
120
+ response = await fetchTrustedAttachment(item.url, options.fetchImpl ?? fetch, signal);
73
121
  } catch {
74
122
  throw new Error("Discord attachment download failed or aborted");
75
123
  }
@@ -67,6 +67,7 @@ import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFrom
67
67
  import { RouteHistory } from "../agent/route-history";
68
68
 
69
69
  import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
70
+ import { messengerUsageFallback } from "../messenger/model-policy";
70
71
 
71
72
 
72
73
  import { listAliases } from "../ai/model-registry";
@@ -1241,7 +1242,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1241
1242
  // global default (rememberModelPatch), so re-reading turnConfig.defaultModel here let
1242
1243
  // that write silently switch THIS running session's model mid-run. Per-session model
1243
1244
  // selection must stay session-local: running sessions never influence each other.
1244
- const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
1245
+ const routingEnabled = flags.literalWorker && process.env.JEO_MESSENGER_FIXED_MODEL === "1"
1246
+ ? false : sessionRouteOverride ?? !!turnConfig.routing?.enabled;
1245
1247
  let routeCredentialNotice: string | undefined;
1246
1248
  let routeVetoNote: string | undefined;
1247
1249
  // Credential SCOPES that have shown an ACCOUNT-LEVEL failure (rate limit,
@@ -1968,6 +1970,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1968
1970
  return null;
1969
1971
  };
1970
1972
  result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
1973
+ const messengerFallback = flags.literalWorker && process.env.JEO_MESSENGER_FIXED_MODEL === "1"
1974
+ ? messengerUsageFallback(process.env.JEO_MESSENGER_USAGE_FALLBACK_MODEL, activeModel, result)
1975
+ : undefined;
1976
+ if (messengerFallback) {
1977
+ activeModel = messengerFallback;
1978
+ turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
1979
+ contextTokens = catalogMetadata(activeModel)?.contextTokens;
1980
+ result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
1981
+ }
1971
1982
  const routeFallbackMaxRaw = Number(jeoEnv("ROUTE_FALLBACK_MAX_ATTEMPTS") ?? NaN);
1972
1983
  const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
1973
1984
  ? Math.floor(routeFallbackMaxRaw)
@@ -98,7 +98,9 @@ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof B
98
98
  const sourceEntry = process.argv[1]?.endsWith(".ts") ? process.argv[1] : undefined;
99
99
  const tools = [...new Set(config.toolNames.map(name => name === "grep" ? "search" : name === "glob" ? "find" : name))];
100
100
  const toolFlags = tools.length ? ["--tools", tools.join(",")] : ["--no-tools"];
101
- const command = [executable, ...(sourceEntry ? ["--no-env-file", sourceEntry] : []), "launch", "--print", "--no-tui", "--no-skills", "--literal-worker", ...toolFlags, "--resume", id, "--", "worker"];
101
+ const platformConfig = config.platforms[message.platform];
102
+ const modelFlags = platformConfig?.model ? ["--model", platformConfig.model] : [];
103
+ const command = [executable, ...(sourceEntry ? ["--no-env-file", sourceEntry] : []), "launch", "--print", "--no-tui", "--no-skills", "--literal-worker", ...toolFlags, ...modelFlags, "--resume", id, "--", "worker"];
102
104
  const secretNames = new Set<string>();
103
105
  for (const platform of Object.values(config.platforms)) {
104
106
  if (!platform) continue;
@@ -112,6 +114,11 @@ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof B
112
114
  const env = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !secretNames.has(name))) as Record<string, string>;
113
115
  for (const name of secretNames) env[name] = "";
114
116
  env.JEO_SESSION_DIR = config.sessionDir;
117
+ // Never inherit another platform's fallback policy from the gateway environment.
118
+ delete env.JEO_MESSENGER_USAGE_FALLBACK_MODEL;
119
+ if (platformConfig?.usageFallbackModel) env.JEO_MESSENGER_USAGE_FALLBACK_MODEL = platformConfig.usageFallbackModel;
120
+ delete env.JEO_MESSENGER_FIXED_MODEL;
121
+ if (platformConfig?.model) env.JEO_MESSENGER_FIXED_MODEL = "1";
115
122
  return pool.turn(id, command, config.cwd, env, [message.text, artifactPrompt].filter(Boolean).join("\n\n"), signal, onActivity);
116
123
  };
117
124
  return Object.assign(reply, { close: pool.close });
@@ -75,7 +75,7 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
75
75
  platform === "slack"
76
76
  ? ["tokenEnv", "appTokenEnv", "allowedUserIds", "allowedChannelIds"]
77
77
  : platform === "discord"
78
- ? ["tokenEnv", "allowedUserIds", "allowedChannelIds", "artifactDir"]
78
+ ? ["tokenEnv", "allowedUserIds", "allowedChannelIds", "artifactDir", "model", "usageFallbackModel"]
79
79
  : ["tokenEnv", "allowedUserIds", "allowedChannelIds"],
80
80
  `${platform} config`,
81
81
  );
@@ -89,6 +89,12 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
89
89
  config.allowedChannelIds = ids(item.allowedChannelIds, `${platform}.allowedChannelIds`);
90
90
  if (platform === "discord" && item.artifactDir !== undefined)
91
91
  config.artifactDir = path.resolve(baseDir, string(item.artifactDir, "discord.artifactDir"));
92
+ if (platform === "discord" && item.model !== undefined)
93
+ config.model = string(item.model, "discord.model");
94
+ if (platform === "discord" && item.usageFallbackModel !== undefined) {
95
+ if (!config.model) throw new Error("discord.usageFallbackModel requires discord.model");
96
+ config.usageFallbackModel = string(item.usageFallbackModel, "discord.usageFallbackModel");
97
+ }
92
98
  platforms[platform] = config;
93
99
  }
94
100
  const toolNames = input.toolNames === undefined ? [] : input.toolNames;
@@ -0,0 +1,8 @@
1
+ import { isUsageLimitError } from "../util/retry";
2
+
3
+ /** Only retry a worker's first failed step, before any tools could have executed. */
4
+ export function messengerUsageFallback(fallback: string | undefined, current: string, result: { done: boolean; steps: number; stopClass?: unknown; doneReason?: string }): string | undefined {
5
+ if (!fallback || fallback === current || result.done || result.stopClass || result.steps !== 1) return undefined;
6
+ const reason = result.doneReason ?? "";
7
+ return isUsageLimitError(reason) || /usage\/quota limit reached/i.test(reason) ? fallback : undefined;
8
+ }
@@ -45,6 +45,9 @@ export interface MessengerPlatformConfig {
45
45
  allowedChannelIds?: string[];
46
46
  /** Dedicated operator-approved directory for Discord output files. */
47
47
  artifactDir?: string;
48
+ /** Optional platform-specific model and persistent-usage-limit fallback. */
49
+ model?: string;
50
+ usageFallbackModel?: string;
48
51
  }
49
52
 
50
53
  export interface MessengerConfig {