kimaki 0.4.90 → 0.4.91

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 (114) hide show
  1. package/dist/agent-model.e2e.test.js +80 -2
  2. package/dist/anthropic-auth-plugin.js +246 -195
  3. package/dist/anthropic-auth-plugin.test.js +125 -0
  4. package/dist/anthropic-auth-state.js +231 -0
  5. package/dist/bin.js +6 -3
  6. package/dist/cli-parsing.test.js +23 -0
  7. package/dist/cli-send-thread.e2e.test.js +2 -2
  8. package/dist/cli.js +72 -46
  9. package/dist/commands/merge-worktree.js +6 -3
  10. package/dist/commands/new-worktree.js +18 -7
  11. package/dist/commands/worktrees.js +71 -7
  12. package/dist/context-awareness-plugin.js +52 -50
  13. package/dist/context-awareness-plugin.test.js +68 -1
  14. package/dist/discord-bot.js +126 -54
  15. package/dist/discord-utils.test.js +19 -0
  16. package/dist/errors.js +0 -5
  17. package/dist/exec-async.js +26 -0
  18. package/dist/external-opencode-sync.js +33 -72
  19. package/dist/forum-sync/config.js +2 -2
  20. package/dist/forum-sync/markdown.js +4 -8
  21. package/dist/hrana-server.js +11 -3
  22. package/dist/image-optimizer-plugin.js +153 -0
  23. package/dist/ipc-tools-plugin.js +11 -4
  24. package/dist/kimaki-opencode-plugin.js +1 -0
  25. package/dist/logger.js +0 -1
  26. package/dist/markdown.js +2 -2
  27. package/dist/message-preprocessing.js +100 -16
  28. package/dist/onboarding-tutorial.js +1 -1
  29. package/dist/opencode-command-detection.js +70 -0
  30. package/dist/opencode-command-detection.test.js +210 -0
  31. package/dist/opencode-interrupt-plugin.js +64 -8
  32. package/dist/opencode-interrupt-plugin.test.js +23 -39
  33. package/dist/opencode.js +16 -20
  34. package/dist/pkce.js +23 -0
  35. package/dist/plugin-logger.js +59 -0
  36. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  37. package/dist/queue-advanced-question.e2e.test.js +127 -42
  38. package/dist/sentry.js +7 -114
  39. package/dist/session-handler/event-stream-state.js +1 -1
  40. package/dist/session-handler/thread-runtime-state.js +9 -0
  41. package/dist/session-handler/thread-session-runtime.js +197 -45
  42. package/dist/session-title-rename.test.js +80 -0
  43. package/dist/store.js +1 -2
  44. package/dist/system-message.js +105 -49
  45. package/dist/system-message.test.js +598 -15
  46. package/dist/task-runner.js +7 -4
  47. package/dist/task-schedule.js +2 -0
  48. package/dist/thread-message-queue.e2e.test.js +18 -11
  49. package/dist/unnest-code-blocks.js +11 -1
  50. package/dist/unnest-code-blocks.test.js +32 -0
  51. package/dist/voice-handler.js +15 -5
  52. package/dist/voice.js +53 -23
  53. package/dist/voice.test.js +2 -0
  54. package/dist/worktrees.js +111 -120
  55. package/package.json +15 -19
  56. package/skills/lintcn/SKILL.md +6 -1
  57. package/skills/new-skill/SKILL.md +211 -0
  58. package/skills/npm-package/SKILL.md +3 -2
  59. package/skills/spiceflow/SKILL.md +1 -1
  60. package/skills/usecomputer/SKILL.md +174 -249
  61. package/src/agent-model.e2e.test.ts +95 -2
  62. package/src/anthropic-auth-plugin.test.ts +159 -0
  63. package/src/anthropic-auth-plugin.ts +474 -403
  64. package/src/anthropic-auth-state.ts +282 -0
  65. package/src/bin.ts +6 -3
  66. package/src/cli-parsing.test.ts +32 -0
  67. package/src/cli-send-thread.e2e.test.ts +2 -2
  68. package/src/cli.ts +93 -62
  69. package/src/commands/merge-worktree.ts +8 -3
  70. package/src/commands/new-worktree.ts +22 -10
  71. package/src/commands/worktrees.ts +86 -5
  72. package/src/context-awareness-plugin.test.ts +77 -1
  73. package/src/context-awareness-plugin.ts +85 -64
  74. package/src/discord-bot.ts +135 -56
  75. package/src/discord-utils.test.ts +21 -0
  76. package/src/errors.ts +0 -6
  77. package/src/exec-async.ts +35 -0
  78. package/src/external-opencode-sync.ts +39 -85
  79. package/src/forum-sync/config.ts +2 -2
  80. package/src/forum-sync/markdown.ts +5 -9
  81. package/src/hrana-server.ts +15 -3
  82. package/src/image-optimizer-plugin.ts +194 -0
  83. package/src/ipc-tools-plugin.ts +16 -8
  84. package/src/kimaki-opencode-plugin.ts +1 -0
  85. package/src/logger.ts +0 -1
  86. package/src/markdown.ts +2 -2
  87. package/src/message-preprocessing.ts +117 -16
  88. package/src/onboarding-tutorial.ts +1 -1
  89. package/src/opencode-command-detection.test.ts +268 -0
  90. package/src/opencode-command-detection.ts +79 -0
  91. package/src/opencode-interrupt-plugin.test.ts +93 -50
  92. package/src/opencode-interrupt-plugin.ts +86 -9
  93. package/src/opencode.ts +16 -22
  94. package/src/plugin-logger.ts +68 -0
  95. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  96. package/src/queue-advanced-question.e2e.test.ts +243 -158
  97. package/src/sentry.ts +7 -120
  98. package/src/session-handler/event-stream-state.ts +1 -1
  99. package/src/session-handler/thread-runtime-state.ts +17 -0
  100. package/src/session-handler/thread-session-runtime.ts +232 -46
  101. package/src/session-title-rename.test.ts +112 -0
  102. package/src/store.ts +3 -8
  103. package/src/system-message.test.ts +612 -0
  104. package/src/system-message.ts +136 -63
  105. package/src/task-runner.ts +7 -4
  106. package/src/task-schedule.ts +3 -0
  107. package/src/thread-message-queue.e2e.test.ts +22 -11
  108. package/src/undici.d.ts +12 -0
  109. package/src/unnest-code-blocks.test.ts +34 -0
  110. package/src/unnest-code-blocks.ts +18 -1
  111. package/src/voice-handler.ts +18 -4
  112. package/src/voice.test.ts +2 -0
  113. package/src/voice.ts +68 -23
  114. package/src/worktrees.ts +152 -156
@@ -1,13 +1,14 @@
1
1
  // Core Discord bot module that handles message events and bot lifecycle.
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
- import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, deleteChannelDirectoryById, } from './database.js';
4
+ import { initDatabase, closeDatabase, getThreadWorktree, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, getPrisma, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorktree, setWorktreeReady, } from './database.js';
5
5
  import { stopOpencodeServer, } from './opencode.js';
6
6
  import { formatWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
7
+ import { validateWorktreeDirectory, git } from './worktrees.js';
7
8
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
8
9
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
9
10
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
10
- import yaml from 'js-yaml';
11
+ import YAML from 'yaml';
11
12
  import { getTextAttachments, resolveMentions, } from './message-formatting.js';
12
13
  import { isVoiceAttachment } from './voice-attachment.js';
13
14
  import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
@@ -19,7 +20,7 @@ import { cancelHtmlActionsForThread } from './html-actions.js';
19
20
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
20
21
  import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
21
22
  import {} from './session-handler/model-utils.js';
22
- import { getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-session-runtime.js';
23
+ import { getRuntime, getOrCreateRuntime, disposeRuntime, } from './session-handler/thread-session-runtime.js';
23
24
  import { runShellCommand } from './commands/run-command.js';
24
25
  import { registerInteractionHandler } from './interaction-handler.js';
25
26
  import { getDiscordRestApiUrl } from './discord-urls.js';
@@ -35,15 +36,16 @@ export { getOpencodeSystemMessage } from './system-message.js';
35
36
  export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, createDefaultKimakiChannel, getChannelsWithDescriptions, } from './channel-management.js';
36
37
  import { ChannelType, Client, Events, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
37
38
  import fs from 'node:fs';
39
+ import path from 'node:path';
38
40
  import * as errore from 'errore';
39
41
  import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
40
42
  import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js';
41
43
  import { startTaskRunner } from './task-runner.js';
42
- import { setGlobalDispatcher, Agent } from 'undici';
43
44
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
44
45
  // Each session's event.subscribe() holds a connection; without enough connections,
45
46
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
46
- setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }));
47
+ // undici is a transitive dep from discord.js — not listed in our package.json.
48
+ // Types are declared in src/undici.d.ts.
47
49
  const discordLogger = createLogger(LogPrefix.DISCORD);
48
50
  const voiceLogger = createLogger(LogPrefix.VOICE);
49
51
  // Well-known WebSocket and Discord Gateway close codes for diagnostic logging.
@@ -90,7 +92,7 @@ function parseEmbedFooterMarker({ footer, }) {
90
92
  return undefined;
91
93
  }
92
94
  try {
93
- const parsed = yaml.load(footer);
95
+ const parsed = YAML.parse(footer);
94
96
  if (!parsed || typeof parsed !== 'object') {
95
97
  return undefined;
96
98
  }
@@ -369,10 +371,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
369
371
  projectDirectory = channelConfig.directory;
370
372
  }
371
373
  }
372
- // Check if this thread is a worktree thread
374
+ // Check if this thread is a worktree thread.
375
+ // When the runtime exists in memory, pending worktrees are handled by
376
+ // the preprocess chain (messages queue behind the worktree promise).
377
+ // After a bot restart the runtime is gone, so we must reject messages
378
+ // for pending worktrees to avoid running in the base directory.
373
379
  const worktreeInfo = await getThreadWorktree(thread.id);
374
380
  if (worktreeInfo) {
375
- if (worktreeInfo.status === 'pending') {
381
+ if (worktreeInfo.status === 'pending' && !getRuntime(thread.id)) {
376
382
  await message.reply({
377
383
  content: '⏳ Worktree is still being created. Please wait...',
378
384
  flags: SILENT_MESSAGE_FLAGS,
@@ -401,9 +407,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
401
407
  });
402
408
  return;
403
409
  }
404
- // ! prefix runs a shell command instead of starting/continuing a session
405
- // Use worktree directory if available, so commands run in the worktree cwd
406
- if (message.content?.startsWith('!') && projectDirectory) {
410
+ // ! prefix runs a shell command instead of starting/continuing a session.
411
+ // Use worktree directory if available, so commands run in the worktree cwd.
412
+ // Skip shell commands while worktree is pending — they'd run in the base dir.
413
+ if (message.content?.startsWith('!') &&
414
+ projectDirectory &&
415
+ worktreeInfo?.status !== 'pending') {
407
416
  const shellCmd = message.content.slice(1).trim();
408
417
  if (shellCmd) {
409
418
  const shellDir = worktreeInfo?.status === 'ready' &&
@@ -451,28 +460,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
451
460
  reason: 'user sent a new message while permission was pending',
452
461
  });
453
462
  }
454
- // For text messages: pass the content as the question answer so the
455
- // model sees the user's response. The early return prevents the message
456
- // from also being sent as a new prompt (duplicate).
457
- // For voice/image messages: message.content is "" (audio is in
458
- // attachments, transcription happens later). Passing "" as the answer
459
- // loses the content entirely. Instead, reply with "" to properly
460
- // unblock OpenCode's question.waitForReply (without a reply the next
461
- // promptAsync immediately fails with MessageAbortedError), then let
462
- // the voice message flow through normal preprocessing — it gets
463
- // transcribed and queued as the next user message after the model
464
- // finishes responding to the empty answer.
465
- if (message.content.trim().length > 0) {
466
- const questionResult = await cancelPendingQuestion(thread.id, message.content);
467
- if (questionResult === 'replied') {
468
- void cancelPendingFileUpload(thread.id);
469
- return;
470
- }
471
- }
472
- else if (hasPendingQuestionForThread(thread.id)) {
473
- // Reply empty to unblock the question tool — no early return so
474
- // the voice/image message continues through to enqueueIncoming.
475
- await cancelPendingQuestion(thread.id, '');
463
+ const dismissedQuestion = hasPendingQuestionForThread(thread.id);
464
+ if (dismissedQuestion) {
465
+ await cancelPendingQuestion(thread.id);
466
+ await runtime.abortActiveRunAndWait({
467
+ reason: 'user sent a new message while question was pending',
468
+ });
476
469
  }
477
470
  void cancelPendingFileUpload(thread.id);
478
471
  }
@@ -593,8 +586,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
593
586
  // Add user to thread so it appears in their sidebar
594
587
  await thread.members.add(message.author.id);
595
588
  discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
596
- // Create worktree if worktrees are enabled (CLI flag OR channel setting)
597
- let sessionDirectory = projectDirectory;
589
+ // Create runtime immediately so follow-up messages queue naturally
590
+ // via the preprocess chain instead of being rejected with "please wait".
591
+ // When worktrees are enabled, the worktree promise runs concurrently
592
+ // and the first message's preprocess callback awaits it before resolving.
593
+ let worktreePromise;
598
594
  if (shouldUseWorktrees) {
599
595
  const worktreeName = formatWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
600
596
  discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
@@ -604,22 +600,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
604
600
  flags: SILENT_MESSAGE_FLAGS,
605
601
  })
606
602
  .catch(() => undefined);
607
- const result = await createWorktreeInBackground({
603
+ worktreePromise = createWorktreeInBackground({
608
604
  thread,
609
605
  starterMessage: worktreeStatusMessage,
610
606
  worktreeName,
611
607
  projectDirectory,
612
608
  rest: discordClient.rest,
613
609
  });
614
- if (!(result instanceof Error)) {
615
- sessionDirectory = result;
616
- }
617
610
  }
618
611
  const channelRuntime = getOrCreateRuntime({
619
612
  threadId: thread.id,
620
613
  thread,
621
- projectDirectory: sessionDirectory,
622
- sdkDirectory: sessionDirectory,
614
+ projectDirectory,
615
+ sdkDirectory: projectDirectory,
623
616
  channelId: textChannel.id,
624
617
  appId: currentAppId,
625
618
  });
@@ -630,7 +623,20 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
630
623
  sourceMessageId: message.id,
631
624
  sourceThreadId: thread.id,
632
625
  appId: currentAppId,
633
- preprocess: () => {
626
+ preprocess: async () => {
627
+ // Wait for worktree creation + install before preprocessing.
628
+ // Follow-up messages queue behind this in the preprocess chain.
629
+ let sessionDirectory = projectDirectory;
630
+ if (worktreePromise) {
631
+ const result = await worktreePromise;
632
+ if (!(result instanceof Error)) {
633
+ sessionDirectory = result;
634
+ channelRuntime.handleDirectoryChanged({
635
+ oldDirectory: projectDirectory,
636
+ newDirectory: sessionDirectory,
637
+ });
638
+ }
639
+ }
634
640
  return preprocessNewThreadMessage({
635
641
  message,
636
642
  thread,
@@ -688,6 +694,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
688
694
  if (!embedFooter) {
689
695
  return;
690
696
  }
697
+ // Only process markers from our own bot messages to prevent crafted embeds
698
+ if (starterMessage.author?.id !== discordClient.user?.id) {
699
+ return;
700
+ }
691
701
  const marker = parseEmbedFooterMarker({
692
702
  footer: embedFooter,
693
703
  });
@@ -722,11 +732,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
722
732
  });
723
733
  return;
724
734
  }
725
- // Create worktree if requested
726
- const sessionDirectory = await (async () => {
727
- if (!marker.worktree) {
728
- return projectDirectory;
729
- }
735
+ // Start worktree creation concurrently if requested.
736
+ // The runtime is created immediately so follow-up messages queue
737
+ // naturally; the worktree promise is awaited inside enqueueIncoming.
738
+ let worktreePromise;
739
+ if (marker.worktree) {
730
740
  discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`);
731
741
  const worktreeStatusMessage = await thread
732
742
  .send({
@@ -734,30 +744,72 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
734
744
  flags: SILENT_MESSAGE_FLAGS,
735
745
  })
736
746
  .catch(() => undefined);
737
- const result = await createWorktreeInBackground({
747
+ worktreePromise = createWorktreeInBackground({
738
748
  thread,
739
749
  starterMessage: worktreeStatusMessage,
740
750
  worktreeName: marker.worktree,
741
751
  projectDirectory,
742
752
  rest: discordClient.rest,
743
753
  });
744
- if (result instanceof Error) {
745
- return projectDirectory;
754
+ }
755
+ // --cwd: reuse an existing worktree directory. Revalidate at bot-time
756
+ // (CLI validated at send-time but the path could become stale).
757
+ // Store in thread_worktrees as ready with origin=external so
758
+ // destructive actions (merge, delete) are gated.
759
+ // --cwd: if it matches projectDirectory, ignore silently (already the default).
760
+ // Otherwise revalidate as a git worktree and store with origin=external.
761
+ let cwdDirectory;
762
+ if (marker.cwd) {
763
+ const cwdResult = await validateWorktreeDirectory({
764
+ projectDirectory,
765
+ candidatePath: marker.cwd,
766
+ });
767
+ if (cwdResult instanceof Error) {
768
+ discordLogger.error(`[BOT_SESSION] --cwd validation failed: ${cwdResult.message}`);
769
+ await thread.send({
770
+ content: `✗ --cwd validation failed: ${cwdResult.message.slice(0, 1900)}`,
771
+ flags: NOTIFY_MESSAGE_FLAGS,
772
+ });
773
+ return;
774
+ }
775
+ // If cwd is the same as projectDirectory, skip worktree setup entirely
776
+ if (path.resolve(cwdResult) !== path.resolve(projectDirectory)) {
777
+ cwdDirectory = cwdResult;
778
+ // Resolve actual branch name instead of using directory basename
779
+ const branchResult = await git(cwdDirectory, 'symbolic-ref --short HEAD');
780
+ const cwdWorktreeName = branchResult instanceof Error
781
+ ? path.basename(cwdDirectory)
782
+ : branchResult;
783
+ await createPendingWorktree({
784
+ threadId: thread.id,
785
+ worktreeName: cwdWorktreeName,
786
+ projectDirectory,
787
+ });
788
+ await setWorktreeReady({
789
+ threadId: thread.id,
790
+ worktreeDirectory: cwdDirectory,
791
+ });
792
+ // React with tree emoji to mark as worktree thread
793
+ await reactToThread({
794
+ rest: discordClient.rest,
795
+ threadId: thread.id,
796
+ channelId: parent.id,
797
+ emoji: '🌳',
798
+ });
746
799
  }
747
- return result;
748
- })();
800
+ }
749
801
  discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
750
802
  const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
751
803
  const runtime = getOrCreateRuntime({
752
804
  threadId: thread.id,
753
805
  thread,
754
806
  projectDirectory,
755
- sdkDirectory: sessionDirectory,
807
+ sdkDirectory: projectDirectory,
756
808
  channelId: parent.id,
757
809
  appId: currentAppId,
758
810
  });
759
811
  await runtime.enqueueIncoming({
760
- prompt,
812
+ prompt: '',
761
813
  userId: marker.userId || '',
762
814
  username: marker.username || 'bot',
763
815
  appId: currentAppId,
@@ -772,6 +824,26 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
772
824
  scheduledTaskId: botThreadStartSource.scheduledTaskId,
773
825
  }
774
826
  : undefined,
827
+ preprocess: async () => {
828
+ // Wait for worktree creation + install before starting session.
829
+ if (worktreePromise) {
830
+ const result = await worktreePromise;
831
+ if (!(result instanceof Error)) {
832
+ runtime.handleDirectoryChanged({
833
+ oldDirectory: projectDirectory,
834
+ newDirectory: result,
835
+ });
836
+ }
837
+ }
838
+ // --cwd: switch sdkDirectory to the existing worktree path
839
+ if (cwdDirectory) {
840
+ runtime.handleDirectoryChanged({
841
+ oldDirectory: projectDirectory,
842
+ newDirectory: cwdDirectory,
843
+ });
844
+ }
845
+ return { prompt, mode: 'opencode' };
846
+ },
775
847
  });
776
848
  }
777
849
  catch (error) {
@@ -69,6 +69,25 @@ describe('splitMarkdownForDiscord', () => {
69
69
  ]
70
70
  `);
71
71
  });
72
+ test('task list code block does not duplicate checkbox marker when splitting', () => {
73
+ const content = `- [ ] Do thing
74
+ \`\`\`sh
75
+ echo hi
76
+ \`\`\`
77
+ `;
78
+ const result = splitMarkdownForDiscord({ content, maxLength: 80 });
79
+ expect(result.join('')).toContain('- [ ] Do thing\n');
80
+ expect(result.join('')).not.toContain('- [ ] [ ] Do thing');
81
+ expect(result).toMatchInlineSnapshot(`
82
+ [
83
+ "- [ ] Do thing
84
+ \`\`\`sh
85
+ echo hi
86
+ \`\`\`
87
+ ",
88
+ ]
89
+ `);
90
+ });
72
91
  });
73
92
  describe('hasKimakiBotPermission', () => {
74
93
  test('allows API interaction member when kimaki role exists', () => {
package/dist/errors.js CHANGED
@@ -125,11 +125,6 @@ export class NothingToMergeError extends createTaggedError({
125
125
  message: 'No commits to merge -- branch is already up to date with $target',
126
126
  }) {
127
127
  }
128
- export class SquashError extends createTaggedError({
129
- name: 'SquashError',
130
- message: 'Squash failed: $reason',
131
- }) {
132
- }
133
128
  export class RebaseConflictError extends createTaggedError({
134
129
  name: 'RebaseConflictError',
135
130
  message: 'Rebase conflict while rebasing onto $target. Resolve conflicts, then run merge again.',
@@ -0,0 +1,26 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const DEFAULT_EXEC_TIMEOUT_MS = 10_000;
4
+ const _execAsync = promisify(exec);
5
+ export function execAsync(command, options) {
6
+ const timeoutMs = options?.timeout || DEFAULT_EXEC_TIMEOUT_MS;
7
+ const execPromise = _execAsync(command, options);
8
+ let timer;
9
+ const timeoutPromise = new Promise((_, reject) => {
10
+ timer = setTimeout(() => {
11
+ const pid = execPromise.child?.pid;
12
+ if (pid) {
13
+ try {
14
+ process.kill(-pid, 'SIGTERM');
15
+ }
16
+ catch {
17
+ execPromise.child?.kill('SIGTERM');
18
+ }
19
+ }
20
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`));
21
+ }, timeoutMs);
22
+ });
23
+ return Promise.race([execPromise, timeoutPromise]).finally(() => {
24
+ clearTimeout(timer);
25
+ });
26
+ }
@@ -367,8 +367,7 @@ async function pulseTypingForBusySessions({ discordClient, statuses, }) {
367
367
  }
368
368
  }
369
369
  }
370
- // Use experimental.session.list (global, all directories) to reduce from
371
- // N*2 HTTP calls to 1 global list + per-active-directory status calls.
370
+ const EXTERNAL_SYNC_MAX_SESSIONS = 50;
372
371
  async function pollExternalSessions({ discordClient, }) {
373
372
  const trackedChannels = await listTrackedTextChannels();
374
373
  const directoryTargets = groupTrackedChannelsByDirectory(trackedChannels)
@@ -378,89 +377,52 @@ async function pollExternalSessions({ discordClient, }) {
378
377
  if (directoryTargets.length === 0) {
379
378
  return;
380
379
  }
381
- // Build a lookup: directory → { channelId, startMs }
382
- const directoryMap = new Map();
383
380
  for (const target of directoryTargets) {
384
- directoryMap.set(target.directory, {
385
- channelId: target.channelId,
386
- startMs: target.startMs,
381
+ const directory = target.directory;
382
+ const channelId = target.channelId;
383
+ const startMs = target.startMs;
384
+ const clientResult = await initializeOpencodeForDirectory(directory, {
385
+ channelId,
387
386
  });
388
- }
389
- // Use earliest startMs across all directories for the global query
390
- const globalStartMs = Math.min(...directoryTargets.map((t) => {
391
- return t.startMs;
392
- }));
393
- // Get one opencode client — try each existing directory until one succeeds
394
- let client;
395
- for (const target of directoryTargets) {
396
- const result = await initializeOpencodeForDirectory(target.directory, {
397
- channelId: target.channelId,
398
- });
399
- if (!(result instanceof Error)) {
400
- client = result();
401
- break;
402
- }
403
- }
404
- if (!client) {
405
- return;
406
- }
407
- // One global API call for all sessions across all directories.
408
- // Results are sorted by most recently updated, so a fixed limit of 50
409
- // is enough — we always get the most active sessions first.
410
- const sessionsResponse = await client.experimental.session.list({
411
- roots: true,
412
- start: globalStartMs,
413
- limit: 50,
414
- }).catch((error) => {
415
- return new Error('Failed to list global sessions', { cause: error });
416
- });
417
- if (sessionsResponse instanceof Error) {
418
- logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`);
419
- return;
420
- }
421
- const allSessions = sessionsResponse.data || [];
422
- // Group sessions by directory, filtering to tracked directories only
423
- const sessionsByDirectory = new Map();
424
- for (const session of allSessions) {
425
- const target = directoryMap.get(session.directory);
426
- if (!target) {
427
- continue;
428
- }
429
- // Filter by per-directory startMs (time.updated or time.created)
430
- if ((session.time.updated || session.time.created || 0) < target.startMs) {
387
+ if (clientResult instanceof Error) {
388
+ logger.warn(`[EXTERNAL_SYNC] Failed to initialize OpenCode for ${directory}: ${clientResult.message}`);
431
389
  continue;
432
390
  }
433
- // Skip sessions whose title hasn't been generated yet
434
- if (/^new session\s*-/i.test(session.title || '')) {
391
+ const client = clientResult();
392
+ const sessionsResponse = await client.session.list({
393
+ directory,
394
+ start: startMs,
395
+ limit: EXTERNAL_SYNC_MAX_SESSIONS,
396
+ }).catch((error) => {
397
+ return new Error(`Failed to list sessions for ${directory}`, {
398
+ cause: error,
399
+ });
400
+ });
401
+ if (sessionsResponse instanceof Error) {
402
+ logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`);
435
403
  continue;
436
404
  }
437
- const existing = sessionsByDirectory.get(session.directory) || [];
438
- existing.push(session);
439
- sessionsByDirectory.set(session.directory, existing);
440
- }
441
- // Fetch session.status() only for directories that have sessions to sync.
442
- // session.status() is instance-scoped (uses x-opencode-directory header),
443
- // so we must call it per directory — but only for active ones, not all 30+.
444
- const activeDirectories = [...sessionsByDirectory.keys()];
445
- const statusResults = await Promise.all(activeDirectories.map(async (directory) => {
446
- const res = await client.session.status({ directory }).catch(() => {
405
+ const statusesResponse = await client.session.status({
406
+ directory,
407
+ }).catch(() => {
447
408
  return null;
448
409
  });
449
- return res?.data ? Object.entries(res.data) : [];
450
- }));
451
- const mergedStatuses = Object.fromEntries(statusResults.flat());
452
- // Pulse typing for busy sessions
453
- await pulseTypingForBusySessions({ discordClient, statuses: mergedStatuses }).catch(() => { });
454
- for (const [directory, sessions] of sessionsByDirectory) {
455
- const target = directoryMap.get(directory);
410
+ if (statusesResponse?.data) {
411
+ await pulseTypingForBusySessions({
412
+ discordClient,
413
+ statuses: statusesResponse.data,
414
+ }).catch(() => { });
415
+ }
416
+ const sessions = (sessionsResponse.data || []).filter((session) => {
417
+ return !/^new session\s*-/i.test(session.title || '');
418
+ });
456
419
  const sorted = sortSessionsByRecency(sessions);
457
- logger.log(`[EXTERNAL_SYNC] ${directory}: ${sorted.length} sessions to sync`);
458
420
  for (const session of sorted) {
459
421
  await syncSessionToThread({
460
422
  client,
461
423
  discordClient,
462
424
  directory,
463
- channelId: target.channelId,
425
+ channelId,
464
426
  sessionId: session.id,
465
427
  sessionTitle: session.title,
466
428
  }).catch((error) => {
@@ -478,7 +440,6 @@ export function startExternalOpencodeSessionSync({ discordClient, }) {
478
440
  if (externalSyncInterval) {
479
441
  return;
480
442
  }
481
- logger.log(`[EXTERNAL_SYNC] started, polling every ${EXTERNAL_SYNC_INTERVAL_MS}ms`);
482
443
  let polling = false;
483
444
  const runPoll = async () => {
484
445
  if (polling) {
@@ -3,7 +3,7 @@
3
3
  // On first run, migrates any existing forum-sync.json into the DB.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import yaml from 'js-yaml';
6
+ import YAML from 'yaml';
7
7
  import { getDataDir } from '../config.js';
8
8
  import { getForumSyncConfigs, upsertForumSyncConfig } from '../database.js';
9
9
  import { createLogger } from '../logger.js';
@@ -29,7 +29,7 @@ async function migrateLegacyConfig({ appId }) {
29
29
  const raw = fs.readFileSync(configPath, 'utf8');
30
30
  let parsed;
31
31
  try {
32
- parsed = yaml.load(raw);
32
+ parsed = YAML.parse(raw);
33
33
  }
34
34
  catch {
35
35
  forumLogger.warn(`Failed to parse legacy ${LEGACY_CONFIG_FILE}, skipping migration`);
@@ -1,7 +1,7 @@
1
1
  // Markdown parsing, serialization, and section formatting for forum sync.
2
2
  // Handles frontmatter extraction, message section building, and
3
3
  // conversion between Discord messages and markdown format.
4
- import yaml from 'js-yaml';
4
+ import YAML from 'yaml';
5
5
  import * as errore from 'errore';
6
6
  import { ForumFrontmatterParseError, } from './types.js';
7
7
  export function toStringArray({ value }) {
@@ -25,7 +25,7 @@ export function parseFrontmatter({ markdown, }) {
25
25
  const rawFrontmatter = markdown.slice(4, end);
26
26
  const body = markdown.slice(end + 5).trim();
27
27
  const parsed = errore.try({
28
- try: () => yaml.load(rawFrontmatter),
28
+ try: () => YAML.parse(rawFrontmatter),
29
29
  catch: (cause) => new ForumFrontmatterParseError({ reason: 'yaml parse failed', cause }),
30
30
  });
31
31
  if (parsed instanceof Error || !parsed || typeof parsed !== 'object') {
@@ -34,13 +34,9 @@ export function parseFrontmatter({ markdown, }) {
34
34
  return { frontmatter: parsed, body };
35
35
  }
36
36
  export function stringifyFrontmatter({ frontmatter, body, }) {
37
- const yamlText = yaml
38
- .dump(frontmatter, {
37
+ const yamlText = YAML.stringify(frontmatter, null, {
39
38
  lineWidth: 120,
40
- noRefs: true,
41
- sortKeys: false,
42
- })
43
- .trim();
39
+ }).trim();
44
40
  return `---\n${yamlText}\n---\n\n${body.trim()}\n`;
45
41
  }
46
42
  export function splitSections({ body }) {
@@ -227,7 +227,9 @@ export async function evictExistingInstance({ port }) {
227
227
  try: () => {
228
228
  process.kill(targetPid, 'SIGTERM');
229
229
  },
230
- catch: (e) => e,
230
+ catch: (e) => new Error('Failed to send SIGTERM to existing kimaki process', {
231
+ cause: e,
232
+ }),
231
233
  });
232
234
  if (killResult instanceof Error) {
233
235
  hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`);
@@ -243,12 +245,18 @@ export async function evictExistingInstance({ port }) {
243
245
  if (secondProbe instanceof Error)
244
246
  return;
245
247
  hranaLogger.log(`PID ${targetPid} still alive after SIGTERM, sending SIGKILL`);
246
- errore.try({
248
+ const forceKillResult = errore.try({
247
249
  try: () => {
248
250
  process.kill(targetPid, 'SIGKILL');
249
251
  },
250
- catch: (e) => e,
252
+ catch: (e) => new Error('Failed to send SIGKILL to existing kimaki process', {
253
+ cause: e,
254
+ }),
251
255
  });
256
+ if (forceKillResult instanceof Error) {
257
+ hranaLogger.log(`Failed to force-kill PID ${targetPid}: ${forceKillResult.message}`);
258
+ return;
259
+ }
252
260
  await new Promise((resolve) => {
253
261
  setTimeout(resolve, 1000);
254
262
  });