kimaki 0.4.80 → 0.4.81

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.
@@ -15,6 +15,7 @@
15
15
  * - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
16
16
  */
17
17
  import { generatePKCE } from "@openauthjs/openauth/pkce";
18
+ import { spawn } from "node:child_process";
18
19
  import * as fs from "node:fs/promises";
19
20
  import { createServer } from "node:http";
20
21
  import { homedir } from "node:os";
@@ -62,37 +63,88 @@ const OPENCODE_TO_CLAUDE_CODE_TOOL_NAME = {
62
63
  write: "Write",
63
64
  };
64
65
  // --- HTTP helpers ---
65
- const MAX_RETRIES = 3;
66
- const BASE_DELAY_MS = 2_000;
67
- async function sleep(ms) {
68
- return new Promise((resolve) => { setTimeout(resolve, ms); });
66
+ // Claude OAuth token exchange can 429 when this runs inside the opencode auth
67
+ // process, even with the same payload that succeeds in a plain Node process.
68
+ // Run these OAuth-only HTTP calls in an isolated Node child to avoid whatever
69
+ // parent-process runtime state is affecting the in-process requests.
70
+ async function requestText(urlString, options) {
71
+ return new Promise((resolve, reject) => {
72
+ const payload = JSON.stringify({
73
+ body: options.body,
74
+ headers: options.headers,
75
+ method: options.method,
76
+ url: urlString,
77
+ });
78
+ const child = spawn("node", ["-e", `
79
+ const input = JSON.parse(process.argv[1]);
80
+ (async () => {
81
+ const response = await fetch(input.url, {
82
+ method: input.method,
83
+ headers: input.headers,
84
+ body: input.body,
85
+ });
86
+ const text = await response.text();
87
+ if (!response.ok) {
88
+ console.error(JSON.stringify({ status: response.status, body: text }));
89
+ process.exit(1);
90
+ }
91
+ process.stdout.write(text);
92
+ })().catch((error) => {
93
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
94
+ process.exit(1);
95
+ });
96
+ `.trim(), payload], {
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ });
99
+ let stdout = "";
100
+ let stderr = "";
101
+ const timeout = setTimeout(() => {
102
+ child.kill();
103
+ reject(new Error(`Request timed out. url=${urlString}`));
104
+ }, 30_000);
105
+ child.stdout.on("data", (chunk) => {
106
+ stdout += String(chunk);
107
+ });
108
+ child.stderr.on("data", (chunk) => {
109
+ stderr += String(chunk);
110
+ });
111
+ child.on("error", (error) => {
112
+ clearTimeout(timeout);
113
+ reject(error);
114
+ });
115
+ child.on("close", (code) => {
116
+ clearTimeout(timeout);
117
+ if (code !== 0) {
118
+ let details = stderr.trim();
119
+ try {
120
+ const parsed = JSON.parse(details);
121
+ if (typeof parsed.status === "number") {
122
+ reject(new Error(`HTTP ${parsed.status} from ${urlString}: ${parsed.body ?? ""}`));
123
+ return;
124
+ }
125
+ }
126
+ catch {
127
+ // fall back to raw stderr
128
+ }
129
+ reject(new Error(details || `Node helper exited with code ${code}`));
130
+ return;
131
+ }
132
+ resolve(stdout);
133
+ });
134
+ });
69
135
  }
70
136
  async function postJson(url, body) {
71
- const jsonBody = JSON.stringify(body);
72
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
73
- const response = await fetch(url, {
74
- method: "POST",
75
- headers: { "Content-Type": "application/json", Accept: "application/json" },
76
- body: jsonBody,
77
- signal: AbortSignal.timeout(30_000),
78
- });
79
- if (response.status === 429 && attempt < MAX_RETRIES) {
80
- // Respect Retry-After header if present, otherwise exponential backoff
81
- const retryAfter = response.headers.get("retry-after");
82
- const delayMs = retryAfter
83
- ? Math.min(Number(retryAfter) * 1000, 60_000)
84
- : BASE_DELAY_MS * 2 ** attempt;
85
- console.warn(`[anthropic-auth] 429 from ${url}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
86
- await sleep(delayMs);
87
- continue;
88
- }
89
- if (!response.ok) {
90
- const text = await response.text().catch(() => "");
91
- throw new Error(`HTTP ${response.status} from ${url}: ${text}`);
92
- }
93
- return response.json();
94
- }
95
- throw new Error(`Exhausted retries for ${url}`);
137
+ const requestBody = JSON.stringify(body);
138
+ const responseText = await requestText(url, {
139
+ method: "POST",
140
+ headers: {
141
+ Accept: "application/json",
142
+ "Content-Length": String(Buffer.byteLength(requestBody)),
143
+ "Content-Type": "application/json",
144
+ },
145
+ body: requestBody,
146
+ });
147
+ return JSON.parse(responseText);
96
148
  }
97
149
  // --- File lock for token refresh ---
98
150
  let pendingRefresh;
@@ -163,33 +215,16 @@ async function refreshAnthropicToken(refreshToken) {
163
215
  };
164
216
  }
165
217
  async function createApiKey(accessToken) {
166
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
167
- const response = await fetch(CREATE_API_KEY_URL, {
168
- method: "POST",
169
- headers: {
170
- Accept: "application/json",
171
- authorization: `Bearer ${accessToken}`,
172
- "Content-Type": "application/json",
173
- },
174
- signal: AbortSignal.timeout(30_000),
175
- });
176
- if (response.status === 429 && attempt < MAX_RETRIES) {
177
- const retryAfter = response.headers.get("retry-after");
178
- const delayMs = retryAfter
179
- ? Math.min(Number(retryAfter) * 1000, 60_000)
180
- : BASE_DELAY_MS * 2 ** attempt;
181
- console.warn(`[anthropic-auth] 429 creating API key, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
182
- await sleep(delayMs);
183
- continue;
184
- }
185
- if (!response.ok) {
186
- const text = await response.text().catch(() => "");
187
- throw new Error(`HTTP ${response.status} creating API key: ${text}`);
188
- }
189
- const json = (await response.json());
190
- return { type: "success", key: json.raw_key };
191
- }
192
- throw new Error(`Exhausted retries for ${CREATE_API_KEY_URL}`);
218
+ const responseText = await requestText(CREATE_API_KEY_URL, {
219
+ method: "POST",
220
+ headers: {
221
+ Accept: "application/json",
222
+ authorization: `Bearer ${accessToken}`,
223
+ "Content-Type": "application/json",
224
+ },
225
+ });
226
+ const json = JSON.parse(responseText);
227
+ return { type: "success", key: json.raw_key };
193
228
  }
194
229
  async function startCallbackServer(expectedState) {
195
230
  return new Promise((resolve, reject) => {
@@ -317,6 +352,7 @@ function buildAuthorizeHandler(mode) {
317
352
  return async () => {
318
353
  const auth = await beginAuthorizationFlow();
319
354
  const isRemote = Boolean(process.env.KIMAKI);
355
+ let pendingAuthResult;
320
356
  const finalize = async (result) => {
321
357
  const verifier = auth.verifier;
322
358
  const creds = await exchangeAuthorizationCode(result.code, result.state || verifier, verifier, REDIRECT_URI);
@@ -331,14 +367,17 @@ function buildAuthorizeHandler(mode) {
331
367
  instructions: "Complete login in your browser on this machine. OpenCode will catch the localhost callback automatically.",
332
368
  method: "auto",
333
369
  callback: async () => {
334
- try {
335
- const result = await waitForCallback(auth.callbackServer);
336
- return finalize(result);
337
- }
338
- catch (error) {
339
- console.error(`[anthropic-auth] ${error}`);
340
- return { type: "failed" };
341
- }
370
+ pendingAuthResult ??= (async () => {
371
+ try {
372
+ const result = await waitForCallback(auth.callbackServer);
373
+ return await finalize(result);
374
+ }
375
+ catch (error) {
376
+ console.error(`[anthropic-auth] ${error}`);
377
+ return { type: "failed" };
378
+ }
379
+ })();
380
+ return pendingAuthResult;
342
381
  },
343
382
  };
344
383
  }
@@ -347,14 +386,17 @@ function buildAuthorizeHandler(mode) {
347
386
  instructions: "Complete login in your browser, then paste the final redirect URL from the address bar here. Pasting just the authorization code also works.",
348
387
  method: "code",
349
388
  callback: async (input) => {
350
- try {
351
- const result = await waitForCallback(auth.callbackServer, input);
352
- return finalize(result);
353
- }
354
- catch (error) {
355
- console.error(`[anthropic-auth] ${error}`);
356
- return { type: "failed" };
357
- }
389
+ pendingAuthResult ??= (async () => {
390
+ try {
391
+ const result = await waitForCallback(auth.callbackServer, input);
392
+ return await finalize(result);
393
+ }
394
+ catch (error) {
395
+ console.error(`[anthropic-auth] ${error}`);
396
+ return { type: "failed" };
397
+ }
398
+ })();
399
+ return pendingAuthResult;
358
400
  },
359
401
  };
360
402
  };
package/dist/cli.js CHANGED
@@ -768,6 +768,11 @@ async function registerCommands({ token, appId, guildIds, userCommands = [], age
768
768
  .setDescription(truncateCommandDescription('Show current session ID and opencode attach command for this thread'))
769
769
  .setDMPermission(false)
770
770
  .toJSON(),
771
+ new SlashCommandBuilder()
772
+ .setName('memory-snapshot')
773
+ .setDescription(truncateCommandDescription('Write a V8 heap snapshot to disk for memory debugging'))
774
+ .setDMPermission(false)
775
+ .toJSON(),
771
776
  new SlashCommandBuilder()
772
777
  .setName('upgrade-and-restart')
773
778
  .setDescription(truncateCommandDescription('Upgrade kimaki to the latest version and restart the bot'))
@@ -0,0 +1,24 @@
1
+ // /memory-snapshot command - Write a V8 heap snapshot and show the file path.
2
+ // Reuses writeHeapSnapshot() from heap-monitor.ts which writes gzip-compressed
3
+ // .heapsnapshot.gz files to ~/.kimaki/heap-snapshots/.
4
+ import { MessageFlags } from 'discord.js';
5
+ import { writeHeapSnapshot } from '../heap-monitor.js';
6
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { createLogger, LogPrefix } from '../logger.js';
8
+ const logger = createLogger(LogPrefix.HEAP);
9
+ export async function handleMemorySnapshotCommand({ command, }) {
10
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
11
+ try {
12
+ const filepath = await writeHeapSnapshot();
13
+ await command.editReply({
14
+ content: `Heap snapshot written:\n\`${filepath}\``,
15
+ });
16
+ logger.log(`Memory snapshot requested via /memory-snapshot: ${filepath}`);
17
+ }
18
+ catch (e) {
19
+ const msg = e instanceof Error ? e.message : String(e);
20
+ await command.editReply({
21
+ content: `Failed to write heap snapshot: ${msg}`,
22
+ });
23
+ }
24
+ }
@@ -336,12 +336,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
336
336
  if (isThread) {
337
337
  const thread = channel;
338
338
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
339
- // Only respond in threads kimaki knows about (has a session row in DB)
340
- // or where the bot is explicitly @mentioned. This prevents the bot from
341
- // hijacking user-created threads in project channels. (GitHub #84)
339
+ // Only respond in threads kimaki knows about (has a session row in DB),
340
+ // where the bot is explicitly @mentioned, or where the bot created the
341
+ // thread itself (e.g. /new-worktree, /fork, kimaki send). This prevents
342
+ // the bot from hijacking user-created threads in project channels while
343
+ // still responding to bot-created threads that may not yet have a session
344
+ // row with a non-empty session_id (createPendingWorktree sets ''). (GitHub #84)
342
345
  const hasExistingSession = await getThreadSession(thread.id);
343
346
  const botMentioned = discordClient.user && message.mentions.has(discordClient.user.id);
344
- if (!hasExistingSession && !botMentioned && !isCliInjectedPrompt) {
347
+ const botCreatedThread = discordClient.user && thread.ownerId === discordClient.user.id;
348
+ if (!hasExistingSession &&
349
+ !botMentioned &&
350
+ !isCliInjectedPrompt &&
351
+ !botCreatedThread) {
345
352
  discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
346
353
  return;
347
354
  }
@@ -36,6 +36,7 @@ import { handleRestartOpencodeServerCommand } from './commands/restart-opencode-
36
36
  import { handleRunCommand } from './commands/run-command.js';
37
37
  import { handleContextUsageCommand } from './commands/context-usage.js';
38
38
  import { handleSessionIdCommand } from './commands/session-id.js';
39
+ import { handleMemorySnapshotCommand } from './commands/memory-snapshot.js';
39
40
  import { handleUpgradeAndRestartCommand } from './commands/upgrade.js';
40
41
  import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
41
42
  import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
@@ -204,6 +205,12 @@ export function registerInteractionHandler({ discordClient, appId, }) {
204
205
  case 'session-id':
205
206
  await handleSessionIdCommand({ command: interaction, appId });
206
207
  return;
208
+ case 'memory-snapshot':
209
+ await handleMemorySnapshotCommand({
210
+ command: interaction,
211
+ appId,
212
+ });
213
+ return;
207
214
  case 'upgrade-and-restart':
208
215
  await handleUpgradeAndRestartCommand({
209
216
  command: interaction,
@@ -574,9 +574,53 @@ export class ThreadSessionRuntime {
574
574
  }
575
575
  return `${text.slice(0, ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS)}…`;
576
576
  }
577
+ isDefinedEventBufferValue(value) {
578
+ return value !== undefined;
579
+ }
580
+ pruneLargeStringsForEventBuffer(value, seen) {
581
+ if (typeof value !== 'object' || value === null) {
582
+ return;
583
+ }
584
+ if (seen.has(value)) {
585
+ return;
586
+ }
587
+ seen.add(value);
588
+ if (Array.isArray(value)) {
589
+ const compactedItems = value
590
+ .map((item) => {
591
+ if (typeof item === 'string') {
592
+ if (item.length > ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
593
+ return undefined;
594
+ }
595
+ return item;
596
+ }
597
+ this.pruneLargeStringsForEventBuffer(item, seen);
598
+ return item;
599
+ })
600
+ .filter((item) => {
601
+ return this.isDefinedEventBufferValue(item);
602
+ });
603
+ value.splice(0, value.length, ...compactedItems);
604
+ return;
605
+ }
606
+ const objectValue = value;
607
+ for (const [key, nestedValue] of Object.entries(objectValue)) {
608
+ if (typeof nestedValue === 'string') {
609
+ if (nestedValue.length > ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
610
+ delete objectValue[key];
611
+ }
612
+ continue;
613
+ }
614
+ this.pruneLargeStringsForEventBuffer(nestedValue, seen);
615
+ }
616
+ }
617
+ finalizeCompactedEventForEventBuffer(event) {
618
+ this.pruneLargeStringsForEventBuffer(event, new WeakSet());
619
+ return event;
620
+ }
577
621
  compactEventForEventBuffer(event) {
578
- if (event.type !== 'message.updated' && event.type !== 'message.part.updated') {
579
- return event;
622
+ if (event.type === 'session.diff') {
623
+ return undefined;
580
624
  }
581
625
  const compacted = structuredClone(event);
582
626
  if (compacted.type === 'message.updated') {
@@ -590,27 +634,30 @@ export class ThreadSessionRuntime {
590
634
  delete info.summary;
591
635
  delete info.tools;
592
636
  delete info.parts;
593
- return compacted;
637
+ return this.finalizeCompactedEventForEventBuffer(compacted);
638
+ }
639
+ if (compacted.type !== 'message.part.updated') {
640
+ return this.finalizeCompactedEventForEventBuffer(compacted);
594
641
  }
595
642
  const part = compacted.properties.part;
596
643
  if (part.type === 'text') {
597
644
  part.text = this.compactTextForEventBuffer(part.text);
598
- return compacted;
645
+ return this.finalizeCompactedEventForEventBuffer(compacted);
599
646
  }
600
647
  if (part.type === 'reasoning') {
601
648
  part.text = this.compactTextForEventBuffer(part.text);
602
- return compacted;
649
+ return this.finalizeCompactedEventForEventBuffer(compacted);
603
650
  }
604
651
  if (part.type === 'snapshot') {
605
652
  part.snapshot = this.compactTextForEventBuffer(part.snapshot);
606
- return compacted;
653
+ return this.finalizeCompactedEventForEventBuffer(compacted);
607
654
  }
608
655
  if (part.type === 'step-start' && part.snapshot) {
609
656
  part.snapshot = this.compactTextForEventBuffer(part.snapshot);
610
- return compacted;
657
+ return this.finalizeCompactedEventForEventBuffer(compacted);
611
658
  }
612
659
  if (part.type !== 'tool') {
613
- return compacted;
660
+ return this.finalizeCompactedEventForEventBuffer(compacted);
614
661
  }
615
662
  const state = part.state;
616
663
  // Preserve subagent_type for task tools so derivation can build labels
@@ -622,28 +669,32 @@ export class ThreadSessionRuntime {
622
669
  }
623
670
  if (state.status === 'pending') {
624
671
  state.raw = this.compactTextForEventBuffer(state.raw);
625
- return compacted;
672
+ return this.finalizeCompactedEventForEventBuffer(compacted);
626
673
  }
627
674
  if (state.status === 'running') {
628
- return compacted;
675
+ return this.finalizeCompactedEventForEventBuffer(compacted);
629
676
  }
630
677
  if (state.status === 'completed') {
631
678
  state.output = this.compactTextForEventBuffer(state.output);
632
679
  delete state.attachments;
633
- return compacted;
680
+ return this.finalizeCompactedEventForEventBuffer(compacted);
634
681
  }
635
682
  if (state.status === 'error') {
636
683
  state.error = this.compactTextForEventBuffer(state.error);
637
- return compacted;
684
+ return this.finalizeCompactedEventForEventBuffer(compacted);
638
685
  }
639
- return compacted;
686
+ return this.finalizeCompactedEventForEventBuffer(compacted);
640
687
  }
641
688
  appendEventToBuffer(event) {
689
+ const compactedEvent = this.compactEventForEventBuffer(event);
690
+ if (!compactedEvent) {
691
+ return;
692
+ }
642
693
  const timestamp = Date.now();
643
694
  const eventIndex = this.nextEventIndex;
644
695
  this.nextEventIndex += 1;
645
696
  this.eventBuffer.push({
646
- event: this.compactEventForEventBuffer(event),
697
+ event: compactedEvent,
647
698
  timestamp,
648
699
  eventIndex,
649
700
  });
@@ -791,6 +842,12 @@ export class ThreadSessionRuntime {
791
842
  // Global events (tui.toast.show) bypass the guard.
792
843
  // Subtask sessions also bypass — they're tracked in subtaskSessions.
793
844
  async handleEvent(event) {
845
+ // session.diff can carry repeated full-file before/after snapshots and is
846
+ // not used by event-derived runtime state, queueing, typing, or UI routing.
847
+ // Drop it at ingress so large diff payloads never hit memory buffers.
848
+ if (event.type === 'session.diff') {
849
+ return;
850
+ }
794
851
  // Skip message.part.delta from the event buffer — no derivation function
795
852
  // (isSessionBusy, doesLatestUserTurnHaveNaturalCompletion, waitForEvent,
796
853
  // etc.) uses them. During long streaming responses they flood the 1000-slot
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.80",
5
+ "version": "0.4.81",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [