kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -0,0 +1,119 @@
1
+ // E2e test: queued messages must drain immediately when the session is idle,
2
+ // even if action buttons are still pending. The isSessionBusy check is
3
+ // sufficient — hasPendingInteractiveUi() should NOT block queue drain.
4
+ import { describe, test, expect } from 'vitest';
5
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
6
+ import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
7
+ import { getThreadSession } from './database.js';
8
+ import { pendingActionButtonContexts, showActionButtons, } from './commands/action-buttons.js';
9
+ const TEXT_CHANNEL_ID = '200000000000001020';
10
+ describe('queue drain with pending interactive UI', () => {
11
+ const ctx = setupQueueAdvancedSuite({
12
+ channelId: TEXT_CHANNEL_ID,
13
+ channelName: 'qa-drain-interactive-ui',
14
+ dirName: 'qa-drain-interactive-ui',
15
+ username: 'drain-ui-tester',
16
+ });
17
+ test('queued message drains immediately while action buttons are still pending', async () => {
18
+ // 1. Create a thread with a first completed reply
19
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
20
+ content: 'Reply with exactly: drain-button-setup',
21
+ });
22
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
23
+ timeout: 4_000,
24
+ predicate: (t) => {
25
+ return t.name === 'Reply with exactly: drain-button-setup';
26
+ },
27
+ });
28
+ const th = ctx.discord.thread(thread.id);
29
+ await waitForBotMessageContaining({
30
+ discord: ctx.discord,
31
+ threadId: thread.id,
32
+ userId: TEST_USER_ID,
33
+ text: 'ok',
34
+ timeout: 4_000,
35
+ });
36
+ await waitForFooterMessage({
37
+ discord: ctx.discord,
38
+ threadId: thread.id,
39
+ timeout: 4_000,
40
+ afterMessageIncludes: 'ok',
41
+ afterAuthorId: ctx.discord.botUserId,
42
+ });
43
+ // 2. Show action buttons (session is idle, buttons are pending)
44
+ const currentSessionId = await getThreadSession(thread.id);
45
+ if (!currentSessionId) {
46
+ throw new Error('Expected thread session id');
47
+ }
48
+ const channel = await ctx.botClient.channels.fetch(thread.id);
49
+ if (!channel || !channel.isThread()) {
50
+ throw new Error('Expected Discord thread channel');
51
+ }
52
+ await showActionButtons({
53
+ thread: channel,
54
+ sessionId: currentSessionId,
55
+ directory: ctx.directories.projectDirectory,
56
+ buttons: [{ label: 'Pending button', color: 'white' }],
57
+ });
58
+ // Verify buttons are pending
59
+ const start = Date.now();
60
+ while (Date.now() - start < 4_000) {
61
+ const entry = [...pendingActionButtonContexts.entries()].find(([, context]) => {
62
+ return context.thread.id === thread.id && Boolean(context.messageId);
63
+ });
64
+ if (entry) {
65
+ break;
66
+ }
67
+ await new Promise((resolve) => {
68
+ setTimeout(resolve, 100);
69
+ });
70
+ }
71
+ expect([...pendingActionButtonContexts.values()].some((c) => {
72
+ return c.thread.id === thread.id;
73
+ })).toBe(true);
74
+ // 3. Queue a message via /queue while buttons are still pending.
75
+ // The queue should drain immediately because session is idle.
76
+ // Currently FAILS: hasPendingInteractiveUi() blocks tryDrainQueue().
77
+ const { id: queueInteractionId } = await th.user(TEST_USER_ID)
78
+ .runSlashCommand({
79
+ name: 'queue',
80
+ options: [{ name: 'message', type: 3, value: 'Reply with exactly: post-button-drain' }],
81
+ });
82
+ const queueAck = await th.waitForInteractionAck({
83
+ interactionId: queueInteractionId,
84
+ timeout: 4_000,
85
+ });
86
+ if (!queueAck.messageId) {
87
+ throw new Error('Expected /queue response message id');
88
+ }
89
+ // 4. Queued message should dispatch immediately (not stay "Queued").
90
+ // The dispatch indicator should appear quickly.
91
+ await waitForBotMessageContaining({
92
+ discord: ctx.discord,
93
+ threadId: thread.id,
94
+ text: '» **drain-ui-tester:** Reply with exactly: post-button-drain',
95
+ timeout: 4_000,
96
+ });
97
+ // 5. Wait for the footer after the drained message completes
98
+ await waitForFooterMessage({
99
+ discord: ctx.discord,
100
+ threadId: thread.id,
101
+ timeout: 4_000,
102
+ afterMessageIncludes: '» **drain-ui-tester:**',
103
+ afterAuthorId: ctx.discord.botUserId,
104
+ });
105
+ const timeline = await th.text({ showInteractions: true });
106
+ expect(timeline).toMatchInlineSnapshot(`
107
+ "--- from: user (drain-ui-tester)
108
+ Reply with exactly: drain-button-setup
109
+ --- from: assistant (TestBot)
110
+ ⬥ ok
111
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
112
+ **Action Required**
113
+ [user interaction]
114
+ » **drain-ui-tester:** Reply with exactly: post-button-drain
115
+ ⬥ ok
116
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
117
+ `);
118
+ }, 20_000);
119
+ });
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import prettyMilliseconds from 'pretty-ms';
11
11
  import * as errore from 'errore';
12
12
  import * as threadState from './thread-runtime-state.js';
13
- import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissions, subscribeOpencodeServerLifecycle, } from '../opencode.js';
13
+ import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissions, parsePermissionRules, subscribeOpencodeServerLifecycle, } from '../opencode.js';
14
14
  import { isAbortError } from '../utils.js';
15
15
  import { createLogger, LogPrefix } from '../logger.js';
16
16
  import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
@@ -580,12 +580,16 @@ export class ThreadSessionRuntime {
580
580
  }
581
581
  const compacted = structuredClone(event);
582
582
  if (compacted.type === 'message.updated') {
583
- if (compacted.properties.info.role !== 'user') {
584
- return compacted;
585
- }
586
- delete compacted.properties.info.system;
587
- delete compacted.properties.info.summary;
588
- delete compacted.properties.info.tools;
583
+ // Strip heavy fields from ALL roles. Derivation only needs lightweight
584
+ // metadata (id, role, sessionID, parentID, time, finish, error, modelID,
585
+ // providerID, mode, tokens). The parts array on assistant messages grows
586
+ // with every tool call and was the primary OOM vector — 1000 buffer entries
587
+ // each carrying the full cumulative parts array reached 4GB+.
588
+ const info = compacted.properties.info;
589
+ delete info.system;
590
+ delete info.summary;
591
+ delete info.tools;
592
+ delete info.parts;
589
593
  return compacted;
590
594
  }
591
595
  const part = compacted.properties.part;
@@ -787,8 +791,16 @@ export class ThreadSessionRuntime {
787
791
  // Global events (tui.toast.show) bypass the guard.
788
792
  // Subtask sessions also bypass — they're tracked in subtaskSessions.
789
793
  async handleEvent(event) {
790
- // Push into bounded event buffer for waitForEvent() consumers.
791
- this.appendEventToBuffer(event);
794
+ // Skip message.part.delta from the event buffer no derivation function
795
+ // (isSessionBusy, doesLatestUserTurnHaveNaturalCompletion, waitForEvent,
796
+ // etc.) uses them. During long streaming responses they flood the 1000-slot
797
+ // buffer, evicting session.status busy events that isSessionBusy needs,
798
+ // causing tryDrainQueue to drain the local queue while the session is
799
+ // actually still busy. This was the root cause of "? queue" messages
800
+ // interrupting instead of queuing.
801
+ if (event.type !== 'message.part.delta') {
802
+ this.appendEventToBuffer(event);
803
+ }
792
804
  const sessionId = this.state?.sessionId;
793
805
  const eventSessionId = getOpencodeEventSessionId(event);
794
806
  if (shouldLogSessionEvents) {
@@ -1377,7 +1389,7 @@ export class ThreadSessionRuntime {
1377
1389
  });
1378
1390
  if (showResult instanceof Error) {
1379
1391
  logger.error('[ACTION] Failed to show action buttons:', showResult);
1380
- await sendThreadMessage(this.thread, `Failed to show action buttons: ${showResult.message}`);
1392
+ await sendThreadMessage(this.thread, `Failed to show action buttons: ${showResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
1381
1393
  }
1382
1394
  },
1383
1395
  });
@@ -1579,7 +1591,7 @@ export class ThreadSessionRuntime {
1579
1591
  }
1580
1592
  const errorMessage = formatSessionErrorFromProps(properties.error);
1581
1593
  logger.error(`Sending error to thread: ${errorMessage}`);
1582
- await sendThreadMessage(this.thread, `✗ opencode session error: ${errorMessage}`);
1594
+ await sendThreadMessage(this.thread, `✗ opencode session error: ${errorMessage}`, { flags: NOTIFY_MESSAGE_FLAGS });
1583
1595
  await this.persistEventBufferDebounced.flush();
1584
1596
  // Inject synthetic idle so isSessionBusy() returns false and queued
1585
1597
  // messages can drain. Without this, a session error leaves the event
@@ -1803,13 +1815,16 @@ export class ThreadSessionRuntime {
1803
1815
  // Helper: stop typing and drain queued local messages on error.
1804
1816
  const cleanupOnError = async (errorMessage) => {
1805
1817
  this.stopTyping();
1806
- await sendThreadMessage(this.thread, errorMessage);
1818
+ await sendThreadMessage(this.thread, errorMessage, {
1819
+ flags: NOTIFY_MESSAGE_FLAGS,
1820
+ });
1807
1821
  await this.tryDrainQueue({ showIndicator: true });
1808
1822
  };
1809
1823
  // ── Ensure session ──────────────────────────────────────
1810
1824
  const sessionResult = await this.ensureSession({
1811
1825
  prompt: input.prompt,
1812
1826
  agent: input.agent,
1827
+ permissions: input.permissions,
1813
1828
  sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
1814
1829
  sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
1815
1830
  });
@@ -2039,6 +2054,7 @@ export class ThreadSessionRuntime {
2039
2054
  command: input.command,
2040
2055
  agent: input.agent,
2041
2056
  model: input.model,
2057
+ permissions: input.permissions,
2042
2058
  sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
2043
2059
  sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
2044
2060
  };
@@ -2051,7 +2067,6 @@ export class ThreadSessionRuntime {
2051
2067
  const position = stateAfterEnqueue?.queueItems.length ?? 0;
2052
2068
  const willDrainNow = stateAfterEnqueue
2053
2069
  ? (stateAfterEnqueue.queueItems.length > 0
2054
- && !this.hasPendingInteractiveUi()
2055
2070
  && !this.isMainSessionBusy())
2056
2071
  : false;
2057
2072
  result = !willDrainNow && position > 0
@@ -2127,6 +2142,13 @@ export class ThreadSessionRuntime {
2127
2142
  mode: result.mode,
2128
2143
  preprocess: undefined,
2129
2144
  };
2145
+ const hasPromptText = resolvedInput.prompt.trim().length > 0;
2146
+ const hasImages = (resolvedInput.images?.length || 0) > 0;
2147
+ if (!hasPromptText && !hasImages && !resolvedInput.command) {
2148
+ logger.warn(`[INGRESS] Skipping empty preprocessed input threadId=${this.threadId}`);
2149
+ resolveOuter({ queued: false });
2150
+ return;
2151
+ }
2130
2152
  // Route with the resolved mode through normal paths.
2131
2153
  // Await the enqueue so session state (ensureSession, setThreadSession)
2132
2154
  // is persisted before the next message's preprocessing reads it.
@@ -2228,9 +2250,11 @@ export class ThreadSessionRuntime {
2228
2250
  if (thread.queueItems.length === 0) {
2229
2251
  return;
2230
2252
  }
2231
- if (this.hasPendingInteractiveUi()) {
2232
- return;
2233
- }
2253
+ // Interactive UI (action buttons, questions, permissions) does NOT block
2254
+ // queue drain. The isSessionBusy check is sufficient: questions and
2255
+ // permissions keep the OpenCode session busy, so drain is naturally
2256
+ // blocked. Action buttons are fire-and-forget (session already idle),
2257
+ // so queued messages should dispatch immediately.
2234
2258
  const sessionBusy = thread.sessionId
2235
2259
  ? isSessionBusy({ events: this.eventBuffer, sessionId: thread.sessionId })
2236
2260
  : false;
@@ -2284,12 +2308,13 @@ export class ThreadSessionRuntime {
2284
2308
  const sessionResult = await this.ensureSession({
2285
2309
  prompt: input.prompt,
2286
2310
  agent: input.agent,
2311
+ permissions: input.permissions,
2287
2312
  sessionStartScheduleKind: input.sessionStartScheduleKind,
2288
2313
  sessionStartScheduledTaskId: input.sessionStartScheduledTaskId,
2289
2314
  });
2290
2315
  if (sessionResult instanceof Error) {
2291
2316
  this.stopTyping();
2292
- await sendThreadMessage(this.thread, `✗ ${sessionResult.message}`);
2317
+ await sendThreadMessage(this.thread, `✗ ${sessionResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
2293
2318
  // Show indicator: this dispatch failed, so the next queued message
2294
2319
  // has been waiting — the user needs to see which one is starting.
2295
2320
  await this.tryDrainQueue({ showIndicator: true });
@@ -2327,7 +2352,7 @@ export class ThreadSessionRuntime {
2327
2352
  });
2328
2353
  if (earlyAgentResult instanceof Error) {
2329
2354
  this.stopTyping();
2330
- await sendThreadMessage(this.thread, `Failed to resolve agent: ${earlyAgentResult.message}`);
2355
+ await sendThreadMessage(this.thread, `Failed to resolve agent: ${earlyAgentResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
2331
2356
  // Show indicator: dispatch failed mid-setup, next queued message was waiting.
2332
2357
  await this.tryDrainQueue({ showIndicator: true });
2333
2358
  return;
@@ -2363,7 +2388,7 @@ export class ThreadSessionRuntime {
2363
2388
  ]);
2364
2389
  if (earlyModelResult instanceof Error) {
2365
2390
  this.stopTyping();
2366
- await sendThreadMessage(this.thread, `Failed to resolve model: ${earlyModelResult.message}`);
2391
+ await sendThreadMessage(this.thread, `Failed to resolve model: ${earlyModelResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
2367
2392
  // Show indicator: dispatch failed mid-setup, next queued message was waiting.
2368
2393
  await this.tryDrainQueue({ showIndicator: true });
2369
2394
  return;
@@ -2496,7 +2521,7 @@ export class ThreadSessionRuntime {
2496
2521
  if (timedOut) {
2497
2522
  logger.warn(`[DISPATCH] Command timed out after 30s sessionId=${session.id}`);
2498
2523
  this.stopTyping();
2499
- await sendThreadMessage(this.thread, '✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.');
2524
+ await sendThreadMessage(this.thread, '✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.', { flags: NOTIFY_MESSAGE_FLAGS });
2500
2525
  await this.dispatchAction(() => {
2501
2526
  return this.tryDrainQueue({ showIndicator: true });
2502
2527
  });
@@ -2511,7 +2536,7 @@ export class ThreadSessionRuntime {
2511
2536
  logger.error(`[DISPATCH] Command SDK call failed: ${commandResponse.message}`);
2512
2537
  void notifyError(commandResponse, 'Failed to send command to OpenCode');
2513
2538
  this.stopTyping();
2514
- await sendThreadMessage(this.thread, `✗ Unexpected bot Error: ${commandResponse.message}`);
2539
+ await sendThreadMessage(this.thread, `✗ Unexpected bot Error: ${commandResponse.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
2515
2540
  await this.dispatchAction(() => {
2516
2541
  return this.tryDrainQueue({ showIndicator: true });
2517
2542
  });
@@ -2528,7 +2553,9 @@ export class ThreadSessionRuntime {
2528
2553
  logger.error(`[DISPATCH] ${apiError.message}`);
2529
2554
  void notifyError(apiError, 'OpenCode API error during command');
2530
2555
  this.stopTyping();
2531
- await sendThreadMessage(this.thread, `✗ ${apiError.message}`);
2556
+ await sendThreadMessage(this.thread, `✗ ${apiError.message}`, {
2557
+ flags: NOTIFY_MESSAGE_FLAGS,
2558
+ });
2532
2559
  await this.dispatchAction(() => {
2533
2560
  return this.tryDrainQueue({ showIndicator: true });
2534
2561
  });
@@ -2572,7 +2599,9 @@ export class ThreadSessionRuntime {
2572
2599
  logger.error(`[DISPATCH] Prompt API call failed: ${errorMessage}`);
2573
2600
  void notifyError(errorObject, 'OpenCode API error during local queue prompt');
2574
2601
  this.stopTyping();
2575
- await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}`);
2602
+ await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}`, {
2603
+ flags: NOTIFY_MESSAGE_FLAGS,
2604
+ });
2576
2605
  await this.dispatchAction(() => {
2577
2606
  return this.tryDrainQueue({ showIndicator: true });
2578
2607
  });
@@ -2582,7 +2611,7 @@ export class ThreadSessionRuntime {
2582
2611
  }
2583
2612
  // ── Session Ensure ──────────────────────────────────────────
2584
2613
  // Creates or reuses the OpenCode session for this thread.
2585
- async ensureSession({ prompt, agent, sessionStartScheduleKind, sessionStartScheduledTaskId, }) {
2614
+ async ensureSession({ prompt, agent, permissions, sessionStartScheduleKind, sessionStartScheduledTaskId, }) {
2586
2615
  const directory = this.projectDirectory;
2587
2616
  // Resolve worktree info for server initialization
2588
2617
  const worktreeInfo = await getThreadWorktree(this.thread.id);
@@ -2625,10 +2654,15 @@ export class ThreadSessionRuntime {
2625
2654
  // access its own project directory (and worktree origin if applicable)
2626
2655
  // without prompts. These override the server-level 'ask' default via
2627
2656
  // opencode's findLast() rule evaluation.
2628
- const sessionPermissions = buildSessionPermissions({
2629
- directory: this.sdkDirectory,
2630
- originalRepoDirectory,
2631
- });
2657
+ // CLI --permission rules are appended after base rules so they win
2658
+ // via opencode's findLast() evaluation.
2659
+ const sessionPermissions = [
2660
+ ...buildSessionPermissions({
2661
+ directory: this.sdkDirectory,
2662
+ originalRepoDirectory,
2663
+ }),
2664
+ ...parsePermissionRules(permissions ?? []),
2665
+ ];
2632
2666
  const sessionResponse = await getClient().session.create({
2633
2667
  title: sessionTitle,
2634
2668
  directory: this.sdkDirectory,
@@ -2759,7 +2793,12 @@ export class ThreadSessionRuntime {
2759
2793
  : `${folderName} ⋅ `;
2760
2794
  const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`;
2761
2795
  this.stopTyping();
2762
- await sendThreadMessage(this.thread, footerText, { flags: NOTIFY_MESSAGE_FLAGS });
2796
+ // Skip notification if there's a queued message next — the user only
2797
+ // needs to be notified when the entire queue finishes.
2798
+ const queuedNext = (threadState.getThreadState(this.threadId)?.queueItems.length ?? 0) > 0;
2799
+ await sendThreadMessage(this.thread, footerText, {
2800
+ flags: queuedNext ? SILENT_MESSAGE_FLAGS : NOTIFY_MESSAGE_FLAGS,
2801
+ });
2763
2802
  logger.log(`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`);
2764
2803
  }
2765
2804
  /** Reset per-run state for the next prompt dispatch. */
@@ -0,0 +1,295 @@
1
+ // Measures time-to-ready for the kimaki Discord bot startup.
2
+ // Used as a baseline to track startup performance and guide optimizations
3
+ // for scale-to-zero deployments where cold start time is critical.
4
+ //
5
+ // Measures each phase independently:
6
+ // 1. Hrana server start (DB + lock port)
7
+ // 2. Database init (Prisma connect via HTTP)
8
+ // 3. Discord.js client creation + login (Gateway READY)
9
+ // 4. startDiscordBot (event handlers + markDiscordGatewayReady)
10
+ // 5. OpenCode server startup (spawn + health poll)
11
+ // 6. Total wall-clock time from zero to "bot ready"
12
+ //
13
+ // Uses discord-digital-twin so Gateway READY is instant (no real Discord).
14
+ // OpenCode startup uses deterministic provider (no real LLM).
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import url from 'node:url';
18
+ import { describe, test, expect, afterAll } from 'vitest';
19
+ import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
20
+ import { DigitalDiscord } from 'discord-digital-twin/src';
21
+ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
22
+ import { setDataDir } from './config.js';
23
+ import { startDiscordBot } from './discord-bot.js';
24
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, } from './database.js';
25
+ import { startHranaServer, stopHranaServer } from './hrana-server.js';
26
+ import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
27
+ import { chooseLockPort, cleanupTestSessions } from './test-utils.js';
28
+ function createRunDirectories() {
29
+ const root = path.resolve(process.cwd(), 'tmp', 'startup-time-e2e');
30
+ fs.mkdirSync(root, { recursive: true });
31
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
32
+ const projectDirectory = path.join(root, 'project');
33
+ fs.mkdirSync(projectDirectory, { recursive: true });
34
+ return { root, dataDir, projectDirectory };
35
+ }
36
+ function createDiscordJsClient({ restUrl }) {
37
+ return new Client({
38
+ intents: [
39
+ GatewayIntentBits.Guilds,
40
+ GatewayIntentBits.GuildMessages,
41
+ GatewayIntentBits.MessageContent,
42
+ GatewayIntentBits.GuildVoiceStates,
43
+ ],
44
+ partials: [
45
+ Partials.Channel,
46
+ Partials.Message,
47
+ Partials.User,
48
+ Partials.ThreadMember,
49
+ ],
50
+ rest: {
51
+ api: restUrl,
52
+ version: '10',
53
+ },
54
+ });
55
+ }
56
+ function createMinimalMatchers() {
57
+ return [
58
+ {
59
+ id: 'startup-test-reply',
60
+ priority: 10,
61
+ when: {
62
+ lastMessageRole: 'user',
63
+ rawPromptIncludes: 'startup-test',
64
+ },
65
+ then: {
66
+ parts: [
67
+ { type: 'stream-start', warnings: [] },
68
+ { type: 'text-start', id: 'startup-reply' },
69
+ { type: 'text-delta', id: 'startup-reply', delta: 'ok' },
70
+ { type: 'text-end', id: 'startup-reply' },
71
+ {
72
+ type: 'finish',
73
+ finishReason: 'stop',
74
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
75
+ },
76
+ ],
77
+ },
78
+ },
79
+ ];
80
+ }
81
+ const TEST_USER_ID = '900000000000000777';
82
+ const TEXT_CHANNEL_ID = '900000000000000778';
83
+ describe('startup time measurement', () => {
84
+ let directories;
85
+ let discord;
86
+ let botClient = null;
87
+ const testStartTime = Date.now();
88
+ afterAll(async () => {
89
+ if (directories) {
90
+ await cleanupTestSessions({
91
+ projectDirectory: directories.projectDirectory,
92
+ testStartTime,
93
+ });
94
+ }
95
+ if (botClient) {
96
+ botClient.destroy();
97
+ }
98
+ await Promise.all([
99
+ stopOpencodeServer().catch(() => { }),
100
+ closeDatabase().catch(() => { }),
101
+ stopHranaServer().catch(() => { }),
102
+ discord?.stop().catch(() => { }),
103
+ ]);
104
+ delete process.env['KIMAKI_LOCK_PORT'];
105
+ delete process.env['KIMAKI_DB_URL'];
106
+ if (directories) {
107
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
108
+ }
109
+ });
110
+ test('measures per-phase startup timings', async () => {
111
+ directories = createRunDirectories();
112
+ const lockPort = chooseLockPort({ key: 'startup-time-e2e' });
113
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
114
+ setDataDir(directories.dataDir);
115
+ const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
116
+ discord = new DigitalDiscord({
117
+ guild: {
118
+ name: 'Startup Time Guild',
119
+ ownerId: TEST_USER_ID,
120
+ },
121
+ channels: [
122
+ {
123
+ id: TEXT_CHANNEL_ID,
124
+ name: 'startup-time',
125
+ type: ChannelType.GuildText,
126
+ },
127
+ ],
128
+ users: [
129
+ {
130
+ id: TEST_USER_ID,
131
+ username: 'startup-tester',
132
+ },
133
+ ],
134
+ dbUrl: `file:${digitalDiscordDbPath}`,
135
+ });
136
+ await discord.start();
137
+ // Write deterministic opencode config
138
+ const providerNpm = url
139
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
140
+ .toString();
141
+ const opencodeConfig = buildDeterministicOpencodeConfig({
142
+ providerName: 'deterministic-provider',
143
+ providerNpm,
144
+ model: 'deterministic-v2',
145
+ smallModel: 'deterministic-v2',
146
+ settings: {
147
+ strict: false,
148
+ matchers: createMinimalMatchers(),
149
+ },
150
+ });
151
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
152
+ // ── Phase timings ──
153
+ const totalStart = performance.now();
154
+ // Phase 1: Hrana server
155
+ const hranaStart = performance.now();
156
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
157
+ const hranaResult = await startHranaServer({ dbPath });
158
+ if (hranaResult instanceof Error) {
159
+ throw hranaResult;
160
+ }
161
+ process.env['KIMAKI_DB_URL'] = hranaResult;
162
+ const hranaMs = performance.now() - hranaStart;
163
+ // Phase 2: Database init
164
+ const dbStart = performance.now();
165
+ await initDatabase();
166
+ await setBotToken(discord.botUserId, discord.botToken);
167
+ await setChannelDirectory({
168
+ channelId: TEXT_CHANNEL_ID,
169
+ directory: directories.projectDirectory,
170
+ channelType: 'text',
171
+ });
172
+ const dbMs = performance.now() - dbStart;
173
+ // Phase 3+4: Discord.js login + startDiscordBot
174
+ // In the real cli.ts flow, login happens first (line 2077), then
175
+ // startDiscordBot is called with the already-logged-in client (line 2130).
176
+ // startDiscordBot calls login() again internally (line 1069) which is
177
+ // a no-op on already-connected clients. We measure them together since
178
+ // that's the real critical path.
179
+ const loginStart = performance.now();
180
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
181
+ // Don't pre-login — let startDiscordBot handle login internally.
182
+ // This avoids the double-login overhead that inflates measurements.
183
+ const loginMs = Math.round(performance.now() - loginStart);
184
+ const botStart = performance.now();
185
+ await startDiscordBot({
186
+ token: discord.botToken,
187
+ appId: discord.botUserId,
188
+ discordClient: botClient,
189
+ });
190
+ const botMs = performance.now() - botStart;
191
+ // Phase 5: OpenCode server startup (biggest bottleneck)
192
+ const opencodeStart = performance.now();
193
+ const opencodeResult = await initializeOpencodeForDirectory(directories.projectDirectory);
194
+ if (opencodeResult instanceof Error) {
195
+ throw opencodeResult;
196
+ }
197
+ const opencodeMs = performance.now() - opencodeStart;
198
+ const totalMs = performance.now() - totalStart;
199
+ const timings = {
200
+ hranaServerMs: Math.round(hranaMs),
201
+ databaseInitMs: Math.round(dbMs),
202
+ discordLoginMs: Math.round(loginMs),
203
+ startDiscordBotMs: Math.round(botMs),
204
+ opencodeServerMs: Math.round(opencodeMs),
205
+ totalMs: Math.round(totalMs),
206
+ };
207
+ // Print timings for CI/local visibility
208
+ console.log('\n┌─────────────────────────────────────────────┐');
209
+ console.log('│ Kimaki Startup Time Breakdown │');
210
+ console.log('├─────────────────────────────────────────────┤');
211
+ console.log(`│ Hrana server: ${String(timings.hranaServerMs).padStart(6)} ms │`);
212
+ console.log(`│ Database init: ${String(timings.databaseInitMs).padStart(6)} ms │`);
213
+ console.log(`│ Discord.js login: ${String(timings.discordLoginMs).padStart(6)} ms │`);
214
+ console.log(`│ startDiscordBot: ${String(timings.startDiscordBotMs).padStart(6)} ms │`);
215
+ console.log(`│ OpenCode server: ${String(timings.opencodeServerMs).padStart(6)} ms │`);
216
+ console.log('├─────────────────────────────────────────────┤');
217
+ console.log(`│ TOTAL: ${String(timings.totalMs).padStart(6)} ms │`);
218
+ console.log('└─────────────────────────────────────────────┘\n');
219
+ // Sanity assertions — these are baselines, not targets yet.
220
+ // Each phase should complete (no infinite hang).
221
+ expect(timings.hranaServerMs).toBeLessThan(5_000);
222
+ expect(timings.databaseInitMs).toBeLessThan(5_000);
223
+ expect(timings.discordLoginMs).toBeLessThan(10_000);
224
+ expect(timings.startDiscordBotMs).toBeLessThan(5_000);
225
+ expect(timings.opencodeServerMs).toBeLessThan(30_000);
226
+ expect(timings.totalMs).toBeLessThan(60_000);
227
+ // Verify the bot is actually functional by sending a message
228
+ // and getting a response (validates the full pipeline works)
229
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
230
+ content: 'startup-test ping',
231
+ });
232
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
233
+ timeout: 10_000,
234
+ });
235
+ const reply = await discord.thread(thread.id).waitForBotReply({
236
+ timeout: 30_000,
237
+ });
238
+ expect(reply.content.length).toBeGreaterThan(0);
239
+ expect(thread.id.length).toBeGreaterThan(0);
240
+ }, 120_000);
241
+ test('measures parallel startup (discord + opencode simultaneously)', async () => {
242
+ // This test reuses the infrastructure from test 1 (hrana, db already up)
243
+ // to measure what happens when we run Discord login + OpenCode in parallel.
244
+ // In a fresh cold start, hrana+db init would add ~50ms on top.
245
+ // Stop opencode server from test 1 so we get a fresh measurement
246
+ await stopOpencodeServer().catch(() => { });
247
+ // Destroy and recreate bot client for a clean login measurement
248
+ if (botClient) {
249
+ botClient.destroy();
250
+ botClient = null;
251
+ }
252
+ // ── Parallel phase: Discord login + OpenCode server simultaneously ──
253
+ const parallelStart = performance.now();
254
+ const [discordResult, opencodeResult] = await Promise.all([
255
+ // Discord path: create client, login, start bot
256
+ (async () => {
257
+ const loginStart = performance.now();
258
+ const client = createDiscordJsClient({ restUrl: discord.restUrl });
259
+ await startDiscordBot({
260
+ token: discord.botToken,
261
+ appId: discord.botUserId,
262
+ discordClient: client,
263
+ });
264
+ return {
265
+ client,
266
+ totalMs: Math.round(performance.now() - loginStart),
267
+ };
268
+ })(),
269
+ // OpenCode path: spawn server + wait for health
270
+ (async () => {
271
+ const start = performance.now();
272
+ const result = await initializeOpencodeForDirectory(directories.projectDirectory);
273
+ if (result instanceof Error) {
274
+ throw result;
275
+ }
276
+ return { ms: Math.round(performance.now() - start) };
277
+ })(),
278
+ ]);
279
+ const parallelMs = Math.round(performance.now() - parallelStart);
280
+ botClient = discordResult.client;
281
+ console.log('\n┌─────────────────────────────────────────────┐');
282
+ console.log('│ Parallel Startup Time Breakdown │');
283
+ console.log('├─────────────────────────────────────────────┤');
284
+ console.log(`│ Discord login+bot: ${String(discordResult.totalMs).padStart(6)} ms │`);
285
+ console.log(`│ OpenCode server: ${String(opencodeResult.ms).padStart(6)} ms │`);
286
+ console.log('├─────────────────────────────────────────────┤');
287
+ console.log(`│ PARALLEL TOTAL: ${String(parallelMs).padStart(6)} ms │`);
288
+ console.log(`│ (vs sequential: ${String(discordResult.totalMs + opencodeResult.ms).padStart(6)} ms) │`);
289
+ console.log('└─────────────────────────────────────────────┘\n');
290
+ // Parallel total should be dominated by the slower path,
291
+ // not the sum of both.
292
+ const maxSingle = Math.max(discordResult.totalMs, opencodeResult.ms);
293
+ expect(parallelMs).toBeLessThan(maxSingle + 500);
294
+ }, 120_000);
295
+ });
package/dist/store.js CHANGED
@@ -10,6 +10,7 @@ export const store = createStore(() => ({
10
10
  critiqueEnabled: true,
11
11
  verboseOpencodeServer: false,
12
12
  discordBaseUrl: 'https://discord.com',
13
+ gatewayToken: null,
13
14
  registeredUserCommands: [],
14
15
  threads: new Map(),
15
16
  test: { deterministicTranscription: null },