jeo-code 0.12.6 → 0.12.8

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ja.md +3 -1
  3. package/README.ko.md +3 -1
  4. package/README.md +5 -3
  5. package/README.zh.md +3 -1
  6. package/package.json +1 -1
  7. package/src/agent/compaction.ts +6 -7
  8. package/src/agent/engine.ts +6 -2
  9. package/src/agent/notify/discord-api.ts +125 -28
  10. package/src/agent/notify/discord-artifacts.ts +126 -0
  11. package/src/agent/notify/discord-attachments.ts +116 -0
  12. package/src/agent/notify/discord-daemon.ts +213 -39
  13. package/src/agent/notify/session-endpoint.ts +127 -5
  14. package/src/agent/session.ts +7 -5
  15. package/src/agent/tools.ts +1 -0
  16. package/src/ai/providers/anthropic.ts +1 -0
  17. package/src/ai/providers/openai-responses.ts +1 -0
  18. package/src/cli/runner.ts +9 -0
  19. package/src/cli.ts +4 -0
  20. package/src/commands/computer.ts +46 -15
  21. package/src/commands/launch/agy-turn.ts +20 -7
  22. package/src/commands/launch/confirm.ts +78 -0
  23. package/src/commands/launch/flags.ts +7 -0
  24. package/src/commands/launch/input.ts +18 -1
  25. package/src/commands/launch.ts +320 -40
  26. package/src/commands/messenger.ts +146 -0
  27. package/src/commands/notify.ts +3 -0
  28. package/src/messenger/activity.ts +30 -0
  29. package/src/messenger/artifacts.ts +167 -0
  30. package/src/messenger/config.ts +145 -0
  31. package/src/messenger/discord-setup.ts +568 -0
  32. package/src/messenger/discord.ts +707 -0
  33. package/src/messenger/gateway.ts +444 -0
  34. package/src/messenger/http.ts +88 -0
  35. package/src/messenger/service.ts +122 -0
  36. package/src/messenger/session-pool.ts +109 -0
  37. package/src/messenger/slack.ts +333 -0
  38. package/src/messenger/telegram.ts +241 -0
  39. package/src/messenger/types.ts +82 -0
  40. package/src/tui/components/slash.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,21 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Added
13
+ - **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.
14
+ - **Discord messenger gateway** — guided `jeo messenger setup`, local configuration checking, and a separate gateway with per-conversation persisted sessions, opt-in host tools, threaded replies, and optional bounded attachments. See the [messenger guide](docs/integrations/messenger.md).
15
+
16
+ ## [0.12.7] - 2026-10-18
17
+ _Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization._
18
+
19
+ ### Fixed
20
+ - **Screenshot vision feedback** — carry PNG attachments through tool execution, batches, and native Anthropic/OpenAI Responses replay instead of spilling base64 as text. Preserve CLI screenshot base64 stdout.
21
+ - **Desktop input** — use Quartz scroll events on macOS (cliclick `w:` waits rather than scrolling), release the mouse after dragging on macOS/Linux, normalize macOS key aliases/modifier combinations, and terminate Linux text options before user text.
22
+ - **Execution reliability** — drain subprocess pipes concurrently with exit, use collision-resistant screenshot paths, reject non-finite numeric inputs and out-of-range waits/scrolls, and explicitly reject unsupported platforms. Existing enablement/supervisor gates remain unchanged.
23
+
24
+ ### Documentation
25
+ - Added [desktop setup and troubleshooting](docs/computer-use.md), including platform requirements, OS permissions, coordinate caveats, and supported action semantics.
26
+
12
27
  ## [0.12.6] - 2026-10-17
13
28
  _Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls._
14
29
 
package/README.ja.md CHANGED
@@ -30,6 +30,8 @@
30
30
 
31
31
  ## ドキュメント
32
32
 
33
+ - [双方向メッセンジャーゲートウェイ (Discord, Telegram, Slack)](docs/integrations/messenger.md)
34
+
33
35
  📖 **[使い方ガイド](docs/usage-guide.md)** — インストール、TUI操作(↑履歴、Ctrl+O、`!`シェル)、スラッシュコマンド、`/resume`、スペックファーストワークフローをデモ動画付きで解説。
34
36
 
35
37
  <video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
@@ -416,10 +418,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
416
418
 
417
419
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
418
420
  - **[Unreleased]**
421
+ - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
419
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.
420
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.
421
424
  - **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
422
- - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
423
425
 
424
426
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
425
427
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -30,6 +30,8 @@
30
30
 
31
31
  ## 문서
32
32
 
33
+ - [양방향 메신저 게이트웨이 (Discord, Telegram, Slack)](docs/integrations/messenger.md)
34
+
33
35
  📖 **[사용 가이드](docs/usage-guide.md)** — 설치, TUI 조작(↑ 이전 쿼리, Ctrl+O, `!` 셸), 슬래시 명령, `/resume`, 스펙 우선 워크플로를 데모 영상과 함께 설명합니다.
34
36
 
35
37
  <video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
@@ -417,10 +419,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
417
419
 
418
420
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
419
421
  - **[Unreleased]**
422
+ - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
420
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.
421
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.
422
425
  - **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
423
- - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
424
426
 
425
427
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
426
428
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -32,6 +32,8 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
32
32
 
33
33
  ## Documentation
34
34
 
35
+ - [Bidirectional messenger gateway (Discord, Telegram, Slack)](docs/integrations/messenger.md)
36
+
35
37
  📖 **[Usage guide](docs/usage-guide.md)** — install, TUI controls (↑ recall, Ctrl+O, `!` shell), slash commands, `/resume`, and the spec-first workflow, with a demo video.
36
38
 
37
39
  <video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
@@ -258,7 +260,7 @@ jeo daemon start|stop|status|reload # the shared daemon has no provider select
258
260
  | Destination | private chat via challenge pairing (send the displayed `/start jeo_<code>` to the bot within 120 s) or explicit `--chat-id`; groups need `--allowed-user-ids` | explicit `--channel-id` (text channel, DM, announcement, or an existing thread) | explicit `--channel-id` (`C…`) the bot has joined; workspace is pinned to the token's team |
259
261
  | Allowlist | optional for a private chat, required for groups | required (human user IDs; bots are ignored) | required (human user IDs) |
260
262
  | Transport | direct Bot API `getUpdates` long-poll, one poll owner per bot token | REST for sends, Gateway WebSocket for inbound | Web API for sends, Socket Mode WebSocket for inbound |
261
- | Extras | forum topics, inline cancel buttons, image attachments, optional per-session topics (`notifications.telegram.perSessionTopics`, private chat with Threaded Mode only) | reply to a session notification to send that session text (24 h) | reply in a session notification's thread to send that session text (24 h); registered Slack slash commands or `@bot /command` both work |
263
+ | Extras | forum topics, inline cancel buttons, image attachments, optional per-session topics (`notifications.telegram.perSessionTopics`, private chat with Threaded Mode only) | session replies, public channel reply threads, and staged attachments; see the [Discord guide](docs/integrations/discord.md) for limits and local control | reply in a session notification's thread to send that session text (24 h); registered Slack slash commands or `@bot /command` both work |
262
264
  | Commands | `/subagents` `/steer <session> <subagent> <msg>` `/cancel <session> <subagent>` `/help` | `/sessions` `/subagents` `/send <session> <text>` `/steer …` `/cancel …` `/help` | same as Discord |
263
265
 
264
266
  What the words mean, because they are not interchangeable:
@@ -266,7 +268,7 @@ What the words mean, because they are not interchangeable:
266
268
  - **Setup** validates bot identity and destination access with the official API (Telegram `getMe`, Discord bot + channel lookup, Slack `auth.test`/`bots.info`/`conversations.info` and a Socket Mode URL) and stores the result in `~/.jeo/config.json` under `notifications.<provider>` — **plaintext**; keep that file private. It does not send a message and does not open a socket.
267
269
  - **`initialized`** (`jeo daemon status`) means the daemon process owns its lock and has started its transports; it is not proof of a successful Telegram poll, Discord Gateway handshake, or Slack Socket Mode connection. Use `notify health` / `notify test` for that.
268
270
  - **Changing a token or destination** resets the allowlist for Discord and Slack (you must pass `--allowed-user-ids` again) and the per-session topic map for Telegram; re-running setup with the exact same credentials and destination keeps them.
269
- - **Remote commands are literal text** (`/subagents`, …) authorized to the configured chat/channel and allowlist; Slack additionally accepts the same names as registered slash commands. Allowlisted operators effectively hold the local agent's capabilities — treat the list as a trust boundary.
271
+ - **Remote commands are literal text** (`/subagents`, …) authorized to the configured chat/channel and allowlist; Slack additionally accepts the same names as registered slash commands. Discord session control defaults off and requires the host operator to submit `/discord control on --allow-full-control` locally; `/discord control off` revokes it. Authorized users can exercise the host session's tool permissions; this is not a sandbox. See the [Discord guide](docs/integrations/discord.md) for file handling and revocation limits. Never put credentials in chat.
270
272
  - **Retries are bounded per provider**: Telegram honours a 429 `retry_after` cooldown (clamped 1 s–1 h) with separate poll backoff; Discord and Slack retry at most 3 times on 429 using the server's `Retry-After`, and never re-send a POST whose outcome is unknown (the platform may already have it).
271
273
  - **Orphan recovery**: if a daemon crashes, its lock becomes `stale`; the next `jeo daemon start` reclaims it. `jeo daemon stop` refuses to signal a recycled PID when the host can report process start times, and says so when it cannot.
272
274
  - **Verified so far**: Telegram end to end (real send, polling, clean shutdown). Discord and Slack are implemented behind the same daemon with offline transport/wiring tests and code, security, and type reviews; live connectivity has not been exercised against real bot/app credentials.
@@ -409,10 +411,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
409
411
 
410
412
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
411
413
  - **[Unreleased]**
414
+ - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
412
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.
413
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.
414
417
  - **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
415
- - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
416
418
 
417
419
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
418
420
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -30,6 +30,8 @@
30
30
 
31
31
  ## 文档
32
32
 
33
+ - [双向消息网关(Discord、Telegram、Slack)](docs/integrations/messenger.md)
34
+
33
35
  📖 **[使用指南](docs/usage-guide.md)** — 安装、TUI 操作(↑ 历史、Ctrl+O、`!` shell)、斜杠命令、`/resume`、规格优先工作流,附演示视频。
34
36
 
35
37
  <video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
@@ -414,10 +416,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
414
416
 
415
417
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
416
418
  - **[Unreleased]**
419
+ - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
417
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.
418
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.
419
422
  - **[0.12.4]** (2026-10-17) — Refresh Antigravity provider (`antigravity/*`) account catalogs with `antigravity/hub/2.23.1` so entitled Gemini 4 Argon and Gemini 3.8 Flash tiers appear directly in `/model provider antigravity`.
420
- - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
421
423
 
422
424
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
423
425
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -315,7 +315,8 @@ export function extractTouchedFiles(messages: Message[], max = 20): string[] {
315
315
 
316
316
  export async function maybeCompact(
317
317
  history: Message[],
318
- opts: CompactionOptions = {}
318
+ opts: CompactionOptions = {},
319
+ summarize: typeof callLlm = callLlm,
319
320
  ): Promise<CompactionResult> {
320
321
  const maxMessages = opts.maxMessages ?? (opts.force ? 1 : 40);
321
322
 
@@ -396,7 +397,7 @@ export async function maybeCompact(
396
397
  break;
397
398
  }
398
399
  try {
399
- summary = await callLlm(
400
+ summary = await summarize(
400
401
  [
401
402
  { role: "user", content: olderFormatted }
402
403
  ],
@@ -417,6 +418,9 @@ export async function maybeCompact(
417
418
  }
418
419
  }
419
420
 
421
+ // A provider can resolve after cancellation; never mutate the cancelled turn's history.
422
+ if (opts.signal?.aborted) return { compacted: false, removed: 0 };
423
+
420
424
  if (summary !== undefined) {
421
425
  // Rung 1 success: behave exactly as before — summary message + bounded recent.
422
426
  const systemMessages = hasSystem ? [history[0]] : [];
@@ -452,11 +456,6 @@ export async function maybeCompact(
452
456
  };
453
457
  }
454
458
 
455
- // Aborted (user cancelled the turn): do NOT mutate history as a side effect of a
456
- // cancelled compaction — leave it untouched and report no compaction.
457
- if (opts.signal?.aborted) {
458
- return { compacted: false, removed: 0 };
459
- }
460
459
 
461
460
  // Rung 2: KEEP-RECENT fallback. The summarizer persistently failed, so rather than inject
462
461
  // a placeholder the model could mistake for real conversation, keep the N most-recent
@@ -1215,6 +1215,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1215
1215
  const { tool, arguments: args } = call;
1216
1216
  let success: boolean;
1217
1217
  let output: string;
1218
+ let images: import("../ai/types").ImageAttachment[] | undefined;
1218
1219
 
1219
1220
  if (tool === "done") {
1220
1221
  success = false;
@@ -1242,6 +1243,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1242
1243
  const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
1243
1244
  const res = await handler(args ?? {}, cwd, onProgress);
1244
1245
  success = res.success;
1246
+ images = res.images;
1245
1247
  output = res.success ? res.output : (res.error ? (res.output ? `${res.error}\n${res.output}` : res.error) : res.output);
1246
1248
  } catch (err: any) {
1247
1249
  success = false;
@@ -1250,7 +1252,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1250
1252
  }
1251
1253
  }
1252
1254
  }
1253
- return { success, output };
1255
+ return { success, output, images };
1254
1256
  };
1255
1257
 
1256
1258
  const WRITE_TOOLS = new Set(["write", "edit"]);
@@ -1293,7 +1295,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1293
1295
  }
1294
1296
  }
1295
1297
 
1296
- const results: { success: boolean; output: string; executed: boolean }[] = Array.from(
1298
+ const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[] }[] = Array.from(
1297
1299
  { length: toolCalls.length },
1298
1300
  () => ({ success: false, output: "", executed: false })
1299
1301
  );
@@ -1385,6 +1387,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1385
1387
  }));
1386
1388
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf, toolUse);
1387
1389
  const resultMsg: Message = { role: "user", content: resultBlocks.join("\n\n"), toolResults };
1390
+ const images = indices.flatMap(idx => results[idx].images ?? []);
1391
+ if (images.length) resultMsg.images = images;
1388
1392
  if (hookExtra) resultMsg.toolResultExtra = hookExtra;
1389
1393
  history.push(resultMsg);
1390
1394
  };
@@ -30,48 +30,87 @@ function delay(ms: number, signal: AbortSignal): Promise<void> {
30
30
  return promise;
31
31
  }
32
32
 
33
+ const DISCORD_WHITESPACE = /^[\s\u0085\u001c-\u001f]$/u;
34
+ const DISCORD_BOUNDARY_GUARD = "\u200b";
35
+
36
+ function splitDiscordText(text: string): { chunks: string[]; needsAttachment: boolean } {
37
+ const chunks: string[] = [];
38
+ let needsAttachment = false;
39
+ for (let start = 0; start < text.length;) {
40
+ const leading = DISCORD_WHITESPACE.test(text[start]!) ? DISCORD_BOUNDARY_GUARD : "";
41
+ let fallback = 0;
42
+ let end = Math.min(start + 2000 - leading.length, text.length);
43
+ for (; end > start; end--) {
44
+ const last = text.charCodeAt(end - 1);
45
+ if (end < text.length && last >= 0xd800 && last <= 0xdbff && text.charCodeAt(end) >= 0xdc00 && text.charCodeAt(end) <= 0xdfff) continue;
46
+ const trailing = DISCORD_WHITESPACE.test(text[end - 1]!);
47
+ if (end - start + leading.length + Number(trailing) > 2000) continue;
48
+ if (!fallback) fallback = end;
49
+ if (end === text.length || (!trailing && !DISCORD_WHITESPACE.test(text[end]!))) break;
50
+ }
51
+ if (end === start) end = fallback;
52
+ const chunk = text.slice(start, end);
53
+ const trailing = DISCORD_WHITESPACE.test(chunk[chunk.length - 1]!) ? DISCORD_BOUNDARY_GUARD : "";
54
+ if (leading || trailing) needsAttachment = true;
55
+ chunks.push(`${leading}${chunk}${trailing}`);
56
+ start = end;
57
+ }
58
+ return { chunks, needsAttachment };
59
+ }
60
+
33
61
  export class DiscordApi {
34
62
  private readonly controller = new AbortController();
35
63
  private cooldownUntil = 0;
64
+ private reactionCooldownUntil = 0;
65
+ private globalCooldownUntil = 0;
36
66
  private fatalStatus: number | undefined;
37
67
  private queue = Promise.resolve();
38
68
  private queued = 0;
69
+ private reactionQueue = Promise.resolve();
70
+ private reactionsQueued = 0;
71
+ private sendQueue = Promise.resolve();
39
72
 
40
73
  constructor(private readonly token: string, private readonly options: DiscordApiOptions = {}) {}
41
74
 
42
75
  stop(): void { this.controller.abort(); }
43
76
 
44
- private request<T>(route: string, body?: unknown): Promise<T> {
77
+ private request<T>(route: string, body?: unknown, method = body === undefined ? "GET" : "POST", eligible?: () => boolean): Promise<T> {
45
78
  if (this.queued >= 128) return Promise.reject(new DiscordApiError("Discord request queue full"));
46
79
  this.queued++;
47
- const result = this.queue.then(() => this.perform<T>(route, body));
80
+ const result = this.queue.then(() => this.perform<T>(route, body, method, eligible));
48
81
  this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
49
82
  return result;
50
83
  }
51
84
 
52
- private async perform<T>(route: string, body?: unknown): Promise<T> {
85
+ private async perform<T>(route: string, body: unknown, method: string, eligible?: () => boolean, reaction = false): Promise<T> {
53
86
  const now = this.options.now ?? Date.now;
54
87
  const retries = Math.max(0, Math.min(this.options.maxRetries ?? 2, 3));
55
88
  for (let attempt = 0; attempt <= retries; attempt++) {
56
89
  if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
57
90
  if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
58
- const wait = this.cooldownUntil - now();
59
- if (wait > 30_000) throw new DiscordApiError("Discord rate limit cooldown active", 429, wait);
60
- if (wait > 0) await (this.options.sleep ?? delay)(wait, this.controller.signal);
91
+ for (;;) {
92
+ const wait = Math.max(this.globalCooldownUntil, reaction ? this.reactionCooldownUntil : this.cooldownUntil) - now();
93
+ if (wait > 30_000) throw new DiscordApiError("Discord rate limit cooldown active", 429, wait);
94
+ if (wait <= 0) break;
95
+ await (this.options.sleep ?? delay)(wait, this.controller.signal);
96
+ if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
97
+ }
61
98
  if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
62
- const timeout = AbortSignal.timeout(Math.max(1, Math.min(this.options.requestTimeoutMs ?? 10_000, 30_000)));
99
+ if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
100
+ if (eligible && !eligible()) throw new DiscordApiError("Discord delivery no longer eligible");
101
+ const timeout = AbortSignal.timeout(Math.max(1, Math.min(this.options.requestTimeoutMs ?? (reaction ? 2000 : 10_000), reaction ? 2000 : 30_000)));
63
102
  let response: Response;
64
103
  let data: unknown;
65
104
  try {
66
105
  response = await (this.options.fetchImpl ?? fetch)(`https://discord.com/api/v10${route}`, {
67
- method: body === undefined ? "GET" : "POST",
68
- headers: { Authorization: `Bot ${this.token}`, "Content-Type": "application/json", "User-Agent": `DiscordBot (https://github.com/akillness/jeo-code, ${version})` },
69
- body: body === undefined ? undefined : JSON.stringify(body),
106
+ method,
107
+ headers: { Authorization: `Bot ${this.token}`, ...(body === undefined || body instanceof FormData ? {} : { "Content-Type": "application/json" }), "User-Agent": `DiscordBot (https://github.com/akillness/jeo-code, ${version})` },
108
+ body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
70
109
  signal: AbortSignal.any([this.controller.signal, timeout]),
71
110
  redirect: "error",
72
111
  });
73
- if (response.status === 401 || response.status === 403) this.fatalStatus = response.status;
74
- data = await response.json().catch(error => {
112
+ if (response.status === 401) this.fatalStatus = response.status;
113
+ data = response.status === 204 ? null : await response.json().catch(error => {
75
114
  if (response.ok) throw error;
76
115
  return null; // HTTP denial/cooldown headers remain authoritative without a JSON body.
77
116
  });
@@ -86,11 +125,18 @@ export class DiscordApi {
86
125
  ? record.retry_after ?? response.headers.get("Retry-After")
87
126
  : response.headers.get("X-RateLimit-Reset-After"));
88
127
  const ms = Number.isFinite(raw) && raw > 0 ? raw * 1000 : 30_000;
89
- // One serialized bot lane conservatively honors both route and global cooldowns.
90
- this.cooldownUntil = Math.max(this.cooldownUntil, now() + ms);
128
+ const until = now() + ms;
129
+ if (response.status === 429 && (record.global === true || response.headers.get("X-RateLimit-Global") === "true" || response.headers.get("X-RateLimit-Scope") === "global")) {
130
+ this.globalCooldownUntil = Math.max(this.globalCooldownUntil, until);
131
+ } else if (reaction) {
132
+ this.reactionCooldownUntil = Math.max(this.reactionCooldownUntil, until);
133
+ } else {
134
+ this.cooldownUntil = Math.max(this.cooldownUntil, until);
135
+ }
91
136
  }
92
137
  if (response.status === 429 && attempt < retries) continue;
93
- if (!response.ok) throw new DiscordApiError(`Discord HTTP ${response.status}`, response.status, Math.max(0, this.cooldownUntil - now()));
138
+ if (!response.ok) throw new DiscordApiError(`Discord HTTP ${response.status}`, response.status, Math.max(0, Math.max(this.globalCooldownUntil, reaction ? this.reactionCooldownUntil : this.cooldownUntil) - now()));
139
+ if (response.status === 204) return undefined as T;
94
140
  if (!data || typeof data !== "object" || Array.isArray(data)) throw new DiscordApiError("Invalid Discord response");
95
141
  return data as T;
96
142
  }
@@ -105,9 +151,9 @@ export class DiscordApi {
105
151
  return me;
106
152
  }
107
153
 
108
- async getChannel(channelId: string): Promise<{ id: string; type: number }> {
154
+ async getChannel(channelId: string): Promise<{ id: string; type: number; parent_id?: string | null; owner_id?: string | null }> {
109
155
  this.checkId(channelId);
110
- const channel = await this.request<{ id: string; type: number }>(`/channels/${channelId}`);
156
+ const channel = await this.request<{ id: string; type: number; parent_id?: string | null; owner_id?: string | null }>(`/channels/${channelId}`);
111
157
  if (channel.id !== channelId || !Number.isInteger(channel.type)) throw new DiscordApiError("Invalid Discord channel");
112
158
  return channel;
113
159
  }
@@ -121,27 +167,78 @@ export class DiscordApi {
121
167
  return gateway;
122
168
  }
123
169
 
124
- async sendMessage(channelId: string, content: string): Promise<{ id: string }[]> {
170
+ sendMessage(channelId: string, content: string, eligible?: () => boolean): Promise<{ id: string }[]> {
125
171
  this.checkId(channelId);
126
- if (!content.trim()) return [];
127
- if (content.length > 256_000) throw new DiscordApiError("Discord message exceeds relay limit");
172
+ const result = this.sendQueue.then(() => this.sendMessageOrdered(channelId, content, eligible));
173
+ this.sendQueue = result.then(() => {}, () => {});
174
+ return result;
175
+ }
176
+
177
+ sendFile(channelId: string, name: string, data: Uint8Array, eligible?: () => boolean): Promise<{ id: string }[]> {
178
+ this.checkId(channelId);
179
+ if (!name || name.startsWith(".") || /[\/\\\x00-\x1f\x7f]/.test(name) || data.byteLength > 8 * 1024 * 1024) throw new DiscordApiError("Invalid Discord attachment");
180
+ const result = this.sendQueue.then(async () => {
181
+ const form = new FormData();
182
+ form.append("payload_json", JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }));
183
+ form.append("files[0]", new Blob([new Uint8Array(data)], { type: "application/octet-stream" }), name);
184
+ const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, form, "POST", eligible);
185
+ if (!message || typeof message.id !== "string" || !/^\d{1,20}$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID");
186
+ return [message];
187
+ });
188
+ this.sendQueue = result.then(() => {}, () => {});
189
+ return result;
190
+ }
191
+
192
+ private async sendMessageOrdered(channelId: string, content: string, eligible?: () => boolean): Promise<{ id: string }[]> {
193
+ const { chunks, needsAttachment } = splitDiscordText(content);
128
194
  const messages: { id: string }[] = [];
129
- for (let offset = 0; offset < content.length;) {
130
- let end = Math.min(offset + 2000, content.length);
131
- if (end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]!)) end--;
132
- const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, {
133
- content: content.slice(offset, end), allowed_mentions: { parse: [], replied_user: false },
134
- }).catch(error => {
195
+ for (let index = 0; index < chunks.length + Number(needsAttachment); index++) {
196
+ let body: unknown = { content: chunks[index], allowed_mentions: { parse: [], replied_user: false } };
197
+ if (index === chunks.length) {
198
+ const form = new FormData();
199
+ form.append("payload_json", JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }));
200
+ form.append("files[0]", new Blob([content], { type: "text/plain; charset=utf-8" }), "response.txt");
201
+ body = form;
202
+ }
203
+ const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, body, "POST", eligible).catch(error => {
135
204
  if (error instanceof DiscordApiError) throw new DiscordApiError(error.message, error.status, error.retryAfterMs, messages);
136
205
  throw new DiscordApiError("Discord delivery failed", undefined, undefined, messages);
137
206
  });
138
- if (typeof message.id !== "string" || !/^\d+$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID", undefined, undefined, messages);
207
+ if (!message || typeof message.id !== "string" || !/^\d{1,20}$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID", undefined, undefined, messages);
139
208
  messages.push(message);
140
- offset = end;
141
209
  }
142
210
  return messages;
143
211
  }
144
212
 
213
+ async createThread(channelId: string, messageId: string, name: string): Promise<{ id: string }> {
214
+ this.checkId(channelId);
215
+ this.checkId(messageId);
216
+ if (typeof name !== "string" || name.length < 1 || name.length > 100) throw new DiscordApiError("Invalid Discord thread name");
217
+ const thread = await this.request<{ id: string }>(`/channels/${channelId}/messages/${messageId}/threads`, { name });
218
+ if (!thread) throw new DiscordApiError("Invalid Discord thread response");
219
+ this.checkId(thread.id);
220
+ return thread;
221
+ }
222
+
223
+ async addReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
224
+ await this.reaction(channelId, messageId, emoji, "PUT");
225
+ }
226
+
227
+ async removeReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
228
+ await this.reaction(channelId, messageId, emoji, "DELETE");
229
+ }
230
+
231
+ private async reaction(channelId: string, messageId: string, emoji: string, method: "PUT" | "DELETE"): Promise<void> {
232
+ this.checkId(channelId);
233
+ this.checkId(messageId);
234
+ if (typeof emoji !== "string" || !emoji || emoji.length > 128 || !emoji.isWellFormed()) throw new DiscordApiError("Invalid Discord emoji");
235
+ if (this.reactionsQueued >= 128) throw new DiscordApiError("Discord reaction queue full");
236
+ this.reactionsQueued++;
237
+ const result = this.reactionQueue.then(() => this.perform<void>(`/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, undefined, method, undefined, true));
238
+ this.reactionQueue = result.then(() => {}, () => {}).finally(() => { this.reactionsQueued--; });
239
+ await result;
240
+ }
241
+
145
242
  private checkId(id: string): void {
146
243
  if (typeof id !== "string" || !/^\d{1,20}$/.test(id)) throw new DiscordApiError("Invalid Discord ID");
147
244
  }
@@ -0,0 +1,126 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { notifySessionsDir } from "./paths";
4
+ import * as path from "node:path";
5
+
6
+ const MAX_FILES = 4;
7
+ const MAX_BYTES = 8 * 1024 * 1024;
8
+
9
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
10
+ const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
11
+
12
+ async function privateDirectory(directory: string): Promise<void> {
13
+ const info = await fs.lstat(directory);
14
+ if (!info.isDirectory() || (info.mode & 0o077) !== 0 || (typeof process.getuid === "function" && info.uid !== process.getuid())) throw new Error("Invalid private artifact directory");
15
+ if (await fs.realpath(directory) !== directory) throw new Error("Artifact directory contains a link");
16
+ }
17
+
18
+ export async function prepareDiscordArtifactRoot(sessionId: string): Promise<string> {
19
+ if (!SESSION_ID.test(sessionId)) throw new Error("Invalid session ID");
20
+ await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
21
+ const sessions = await fs.realpath(notifySessionsDir());
22
+ const base = path.join(sessions, "discord-artifacts");
23
+ await fs.mkdir(base, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
24
+ await privateDirectory(base);
25
+ const root = path.join(base, sessionId);
26
+ await fs.mkdir(root, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
27
+ await privateDirectory(root);
28
+ return root;
29
+ }
30
+
31
+ export interface DiscordArtifactPaths { output: string; input?: string }
32
+
33
+ export async function resolveDiscordArtifactStage(sessionId: string, requestId: string): Promise<DiscordArtifactPaths> {
34
+ if (!SESSION_ID.test(sessionId) || !REQUEST_ID.test(requestId)) throw new Error("Invalid artifact request ID");
35
+ const sessions = await fs.realpath(notifySessionsDir());
36
+ const base = path.join(sessions, "discord-artifacts");
37
+ const root = path.join(base, sessionId);
38
+ const request = path.join(root, requestId);
39
+ const output = path.join(request, "output");
40
+ for (const directory of [base, root, request, output]) await privateDirectory(directory);
41
+ const input = path.join(request, "input");
42
+ try { await privateDirectory(input); }
43
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { output }; throw error; }
44
+ const names = await fs.readdir(input);
45
+ if (names.length > 10) throw new Error("Too many input files");
46
+ let total = 0;
47
+ for (const name of names) {
48
+ if (name.startsWith(".") || /[\x00-\x1f\x7f]/.test(name)) throw new Error("Invalid input filename");
49
+ const info = await fs.lstat(path.join(input, name));
50
+ if (!info.isFile() || info.nlink !== 1 || info.size > 25 * 1024 * 1024) throw new Error("Invalid input file");
51
+ total += info.size;
52
+ if (total > 50 * 1024 * 1024) throw new Error("Input files exceed size limit");
53
+ }
54
+ return { output, input };
55
+ }
56
+
57
+ export interface DiscordArtifactStage {
58
+ directory: string;
59
+ requestDirectory: string;
60
+ collect(): Promise<Array<{ name: string; data: Uint8Array }>>;
61
+ cleanup(): Promise<void>;
62
+ }
63
+
64
+ /** A private, per-request output directory; never upload arbitrary session paths. */
65
+ export async function createDiscordArtifactStage(sessionId: string, requestId: string): Promise<DiscordArtifactStage> {
66
+ if (!REQUEST_ID.test(requestId)) throw new Error("Invalid artifact request ID");
67
+ const root = await prepareDiscordArtifactRoot(sessionId);
68
+ const requestDirectory = path.join(root, requestId);
69
+ await fs.mkdir(requestDirectory, { mode: 0o700 });
70
+ const requestInitial = await fs.lstat(requestDirectory);
71
+ const directory = path.join(requestDirectory, "output");
72
+ try { await fs.mkdir(directory, { mode: 0o700 }); }
73
+ catch (error) { await fs.rm(requestDirectory, { recursive: true, force: true }); throw error; }
74
+ const initial = await fs.lstat(directory);
75
+ async function checkDirectory(): Promise<void> {
76
+ if ((await resolveDiscordArtifactStage(sessionId, requestId)).output !== directory) throw new Error("Artifact staging path changed");
77
+ const current = await fs.lstat(directory);
78
+ if (!current.isDirectory() || current.dev !== initial.dev || current.ino !== initial.ino) throw new Error("Artifact staging directory changed");
79
+ }
80
+ return {
81
+ directory,
82
+ requestDirectory,
83
+ async collect() {
84
+ await checkDirectory();
85
+ const names = await fs.readdir(directory);
86
+ if (names.length > MAX_FILES) throw new Error("Too many output files (maximum 4)");
87
+ const result: Array<{ name: string; data: Uint8Array }> = [];
88
+ let total = 0;
89
+ for (const name of names) {
90
+ if (name.startsWith(".") || /[\x00-\x1f\x7f]/.test(name)) throw new Error("Invalid output filename");
91
+ await checkDirectory();
92
+ const file = path.join(directory, name);
93
+ const before = await fs.lstat(file);
94
+ if (!before.isFile() || before.nlink !== 1) throw new Error("Output must be a regular file, not a link or directory");
95
+ if (before.size > MAX_BYTES - total) throw new Error("Output files exceed 8 MiB total");
96
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
97
+ try {
98
+ const opened = await handle.stat();
99
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) throw new Error("Output file changed during collection");
100
+ const data = new Uint8Array(opened.size);
101
+ let offset = 0;
102
+ while (offset < data.length) {
103
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
104
+ if (!bytesRead) throw new Error("Output file changed during collection");
105
+ offset += bytesRead;
106
+ }
107
+ const after = await handle.stat();
108
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.nlink !== 1) throw new Error("Output file changed during collection");
109
+ await checkDirectory();
110
+ total += data.length;
111
+ result.push({ name, data });
112
+ } finally {
113
+ await handle.close();
114
+ }
115
+ }
116
+ return result;
117
+ },
118
+ async cleanup() {
119
+ await privateDirectory(root);
120
+ await privateDirectory(requestDirectory);
121
+ const current = await fs.lstat(requestDirectory);
122
+ if (current.dev !== requestInitial.dev || current.ino !== requestInitial.ino) throw new Error("Artifact request directory changed");
123
+ await fs.rm(requestDirectory, { recursive: true, force: true });
124
+ },
125
+ };
126
+ }