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
@@ -21,7 +21,7 @@ import { showAskUserQuestionDropdowns, pendingQuestionContexts, cancelPendingQue
21
21
  import { showActionButtons, waitForQueuedActionButtonsRequest, pendingActionButtonContexts, cancelPendingActionButtons, } from '../commands/action-buttons.js';
22
22
  import { pendingFileUploadContexts, cancelPendingFileUpload, } from '../commands/file-upload.js';
23
23
  import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from '../commands/model.js';
24
- import { getOpencodeSystemMessage, } from '../system-message.js';
24
+ import { getOpencodePromptContext, getOpencodeSystemMessage, } from '../system-message.js';
25
25
  import { resolveValidatedAgentPreference } from './agent-utils.js';
26
26
  import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
27
27
  import { doesLatestUserTurnHaveNaturalCompletion, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
@@ -36,6 +36,7 @@ import { notifyError } from '../sentry.js';
36
36
  import { createDebouncedProcessFlush } from '../debounced-process-flush.js';
37
37
  import { cancelHtmlActionsForThread } from '../html-actions.js';
38
38
  import { createDebouncedTimeout } from '../debounce-timeout.js';
39
+ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js';
39
40
  const logger = createLogger(LogPrefix.SESSION);
40
41
  const discordLogger = createLogger(LogPrefix.DISCORD);
41
42
  const DETERMINISTIC_CONTEXT_LIMIT = 100_000;
@@ -226,6 +227,58 @@ export function isEssentialToolPart(part) {
226
227
  }
227
228
  return true;
228
229
  }
230
+ // ── Thread title derivation ──────────────────────────────────────
231
+ const DISCORD_THREAD_NAME_MAX = 100;
232
+ const WORKTREE_THREAD_PREFIX = '⬦ ';
233
+ // Pure derivation: given an OpenCode session title and the current thread name,
234
+ // return the new thread name to apply, or undefined when no rename is needed.
235
+ // - Skips placeholder titles ("New Session - ...") to match external-sync.
236
+ // - Preserves worktree prefix when the current name carries it.
237
+ // - Returns undefined when the candidate matches currentName already.
238
+ export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, }) {
239
+ const trimmed = sessionTitle?.trim();
240
+ if (!trimmed) {
241
+ return undefined;
242
+ }
243
+ if (/^new session\s*-/i.test(trimmed)) {
244
+ return undefined;
245
+ }
246
+ const hasWorktreePrefix = currentName.startsWith(WORKTREE_THREAD_PREFIX);
247
+ const prefix = hasWorktreePrefix ? WORKTREE_THREAD_PREFIX : '';
248
+ const candidate = `${prefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX);
249
+ if (candidate === currentName) {
250
+ return undefined;
251
+ }
252
+ return candidate;
253
+ }
254
+ // Rewrite `{ prompt: "/build foo" }` → `{ prompt: "", command: { name, arguments }, mode: "local-queue" }`
255
+ // when the prompt's leading token matches a registered opencode command.
256
+ // Skip if a command is already set or there's no prompt to inspect.
257
+ function maybeConvertLeadingCommand(input) {
258
+ if (input.command)
259
+ return input;
260
+ if (!input.prompt)
261
+ return input;
262
+ const extracted = extractLeadingOpencodeCommand(input.prompt);
263
+ if (!extracted)
264
+ return input;
265
+ return {
266
+ ...input,
267
+ prompt: '',
268
+ command: extracted.command,
269
+ mode: 'local-queue',
270
+ };
271
+ }
272
+ function getWorktreePromptKey(worktree) {
273
+ if (!worktree) {
274
+ return null;
275
+ }
276
+ return [
277
+ worktree.worktreeDirectory,
278
+ worktree.branch,
279
+ worktree.mainRepoDirectory,
280
+ ].join('::');
281
+ }
229
282
  // ── Runtime class ────────────────────────────────────────────────
230
283
  export class ThreadSessionRuntime {
231
284
  threadId;
@@ -255,11 +308,19 @@ export class ThreadSessionRuntime {
255
308
  // Notification throttles for retry/context notices.
256
309
  lastDisplayedContextPercentage = 0;
257
310
  lastRateLimitDisplayTime = 0;
311
+ // Last OpenCode-generated session title we successfully applied to the
312
+ // Discord thread name. Used to dedupe repeated session.updated events so
313
+ // we only call thread.setName() once per distinct title. Discord rate-limits
314
+ // channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
315
+ // retrying. Not persisted — worst case on restart we re-apply the same title
316
+ // once (which is a no-op via deriveThreadNameFromSessionTitle).
317
+ appliedOpencodeTitle;
258
318
  // Part output buffering (write-side cache, not domain state)
259
319
  partBuffer = new Map();
260
320
  // Derivable cache (perf optimization for provider.list API call)
261
321
  modelContextLimit;
262
322
  modelContextLimitKey;
323
+ lastPromptWorktreeKey;
263
324
  // Bounded buffer of recent SSE events with timestamps.
264
325
  // Used by waitForEvent() to scan for specific events that arrived
265
326
  // after a given point in time (e.g. wait for session.idle after abort).
@@ -312,6 +373,12 @@ export class ThreadSessionRuntime {
312
373
  },
313
374
  });
314
375
  }
376
+ consumeWorktreePromptChange(worktree) {
377
+ const nextKey = getWorktreePromptKey(worktree);
378
+ const changed = this.lastPromptWorktreeKey !== nextKey;
379
+ this.lastPromptWorktreeKey = nextKey;
380
+ return changed;
381
+ }
315
382
  // Read own state from global store
316
383
  get state() {
317
384
  return threadState.getThreadState(this.threadId);
@@ -948,6 +1015,9 @@ export class ThreadSessionRuntime {
948
1015
  case 'session.status':
949
1016
  await this.handleSessionStatus(event.properties);
950
1017
  break;
1018
+ case 'session.updated':
1019
+ await this.handleSessionUpdated(event.properties.info);
1020
+ break;
951
1021
  case 'tui.toast.show':
952
1022
  await this.handleTuiToast(event.properties);
953
1023
  break;
@@ -1895,6 +1965,60 @@ export class ThreadSessionRuntime {
1895
1965
  discordLogger.error('Failed to send retry notice:', retryResult);
1896
1966
  }
1897
1967
  }
1968
+ // Rename the Discord thread to match the OpenCode-generated session title.
1969
+ //
1970
+ // Discord rate-limits channel/thread renames heavily — reported as ~2 per
1971
+ // 10 minutes per thread (discord/discord-api-docs#1900, discordjs/discord.js#6651)
1972
+ // and discord.js setName() can block silently on the 3rd attempt. We therefore:
1973
+ // - rename at most once per distinct title (deduped via appliedOpencodeTitle)
1974
+ // - race setName() against an AbortSignal.timeout() so a throttled call never
1975
+ // blocks the event loop
1976
+ // - fail soft (log + continue) on timeout, 429, or any other error
1977
+ async handleSessionUpdated(info) {
1978
+ // Only act on the main session for this thread
1979
+ if (info.id !== this.state?.sessionId) {
1980
+ return;
1981
+ }
1982
+ const desiredName = deriveThreadNameFromSessionTitle({
1983
+ sessionTitle: info.title,
1984
+ currentName: this.thread.name,
1985
+ });
1986
+ if (!desiredName) {
1987
+ return;
1988
+ }
1989
+ const normalizedTitle = info.title.trim();
1990
+ if (this.appliedOpencodeTitle === normalizedTitle) {
1991
+ return;
1992
+ }
1993
+ // Mark before the call so concurrent session.updated events don't stack
1994
+ // rename attempts. On failure we keep the mark — a retry won't help
1995
+ // because the failure is almost always a rate limit.
1996
+ this.appliedOpencodeTitle = normalizedTitle;
1997
+ const RENAME_TIMEOUT_MS = 3000;
1998
+ const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS);
1999
+ const renameResult = await Promise.race([
2000
+ errore.tryAsync({
2001
+ try: () => this.thread.setName(desiredName),
2002
+ catch: (e) => new Error('Failed to rename thread from OpenCode title', {
2003
+ cause: e,
2004
+ }),
2005
+ }),
2006
+ new Promise((resolve) => {
2007
+ timeoutSignal.addEventListener('abort', () => {
2008
+ resolve('timeout');
2009
+ });
2010
+ }),
2011
+ ]);
2012
+ if (renameResult === 'timeout') {
2013
+ logger.warn(`[TITLE] setName timed out after ${RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`);
2014
+ return;
2015
+ }
2016
+ if (renameResult instanceof Error) {
2017
+ logger.warn(`[TITLE] Could not rename thread ${this.threadId}: ${renameResult.message}`);
2018
+ return;
2019
+ }
2020
+ logger.log(`[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`);
2021
+ }
1898
2022
  async handleTuiToast(properties) {
1899
2023
  if (properties.variant === 'warning') {
1900
2024
  return;
@@ -2069,18 +2193,7 @@ export class ThreadSessionRuntime {
2069
2193
  .join('\n');
2070
2194
  return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`;
2071
2195
  })();
2072
- let syntheticContext = '';
2073
- if (input.username) {
2074
- const msgAttr = input.sourceMessageId ? ` message-id="${input.sourceMessageId}"` : '';
2075
- const thrAttr = input.sourceThreadId ? ` thread-id="${input.sourceThreadId}"` : '';
2076
- syntheticContext += `<discord-user name="${input.username}"${msgAttr}${thrAttr} />`;
2077
- }
2078
- const parts = [
2079
- { type: 'text', text: promptWithImagePaths },
2080
- { type: 'text', text: syntheticContext, synthetic: true },
2081
- ...images,
2082
- ];
2083
- // ── Worktree + channel topic for system message ─────────
2196
+ // ── Worktree + channel topic for per-turn prompt context ──
2084
2197
  const worktreeInfo = await getThreadWorktree(this.thread.id);
2085
2198
  const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
2086
2199
  ? {
@@ -2107,6 +2220,22 @@ export class ThreadSessionRuntime {
2107
2220
  }
2108
2221
  return fetched.topic?.trim() || undefined;
2109
2222
  })();
2223
+ const worktreeChanged = this.consumeWorktreePromptChange(worktree);
2224
+ const syntheticContext = getOpencodePromptContext({
2225
+ username: input.username,
2226
+ userId: input.userId,
2227
+ sourceMessageId: input.sourceMessageId,
2228
+ sourceThreadId: input.sourceThreadId,
2229
+ repliedMessage: input.repliedMessage,
2230
+ worktree,
2231
+ currentAgent: resolvedAgent,
2232
+ worktreeChanged,
2233
+ });
2234
+ const parts = [
2235
+ { type: 'text', text: promptWithImagePaths },
2236
+ { type: 'text', text: syntheticContext, synthetic: true },
2237
+ ...images,
2238
+ ];
2110
2239
  const request = {
2111
2240
  sessionID: session.id,
2112
2241
  directory: this.sdkDirectory,
@@ -2116,12 +2245,9 @@ export class ThreadSessionRuntime {
2116
2245
  channelId,
2117
2246
  guildId: this.thread.guildId,
2118
2247
  threadId: this.thread.id,
2119
- worktree,
2120
2248
  channelTopic,
2121
- username: input.username,
2122
- userId: input.userId,
2123
2249
  agents: availableAgents,
2124
- currentAgent: resolvedAgent,
2250
+ username: this.state?.sessionUsername || input.username,
2125
2251
  }),
2126
2252
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
2127
2253
  ...(modelField ? { model: modelField } : {}),
@@ -2184,6 +2310,7 @@ export class ThreadSessionRuntime {
2184
2310
  injectionGuardPatterns: input.injectionGuardPatterns,
2185
2311
  sourceMessageId: input.sourceMessageId,
2186
2312
  sourceThreadId: input.sourceThreadId,
2313
+ repliedMessage: input.repliedMessage,
2187
2314
  sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
2188
2315
  sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
2189
2316
  };
@@ -2219,11 +2346,18 @@ export class ThreadSessionRuntime {
2219
2346
  * discord-bot.ts.
2220
2347
  */
2221
2348
  async enqueueIncoming(input) {
2349
+ threadState.setSessionUsername(this.threadId, input.username);
2222
2350
  // When a preprocessor is provided, we must resolve it inside
2223
2351
  // dispatchAction before we know the final mode for routing.
2224
2352
  if (input.preprocess) {
2225
2353
  return this.enqueueWithPreprocess(input);
2226
2354
  }
2355
+ // If the prompt starts with `/cmdname ...` (and no explicit command is
2356
+ // already set), rewrite it into a command invocation so it goes through
2357
+ // opencode's session.command API instead of being sent to the model as
2358
+ // plain text. Covers Discord chat messages, /new-session, /queue, CLI
2359
+ // `kimaki send --prompt`, and scheduled tasks — all funnel through here.
2360
+ input = maybeConvertLeadingCommand(input);
2227
2361
  if (input.mode === 'local-queue') {
2228
2362
  return this.enqueueViaLocalQueue(input);
2229
2363
  }
@@ -2264,13 +2398,17 @@ export class ThreadSessionRuntime {
2264
2398
  resolveOuter({ queued: false });
2265
2399
  return;
2266
2400
  }
2267
- const resolvedInput = {
2401
+ const resolvedInput = maybeConvertLeadingCommand({
2268
2402
  ...input,
2269
2403
  prompt: result.prompt,
2270
2404
  images: result.images,
2271
2405
  mode: result.mode,
2406
+ // Voice transcription can extract an agent name — apply it only if
2407
+ // no explicit agent was already set (CLI --agent flag wins).
2408
+ agent: input.agent || result.agent,
2409
+ repliedMessage: result.repliedMessage,
2272
2410
  preprocess: undefined,
2273
- };
2411
+ });
2274
2412
  const hasPromptText = resolvedInput.prompt.trim().length > 0;
2275
2413
  const hasImages = (resolvedInput.images?.length || 0) > 0;
2276
2414
  if (!hasPromptText && !hasImages && !resolvedInput.command) {
@@ -2612,18 +2750,7 @@ export class ThreadSessionRuntime {
2612
2750
  .join('\n');
2613
2751
  return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`;
2614
2752
  })();
2615
- let syntheticContext = '';
2616
- if (input.username) {
2617
- const msgAttr = input.sourceMessageId ? ` message-id="${input.sourceMessageId}"` : '';
2618
- const thrAttr = input.sourceThreadId ? ` thread-id="${input.sourceThreadId}"` : '';
2619
- syntheticContext += `<discord-user name="${input.username}"${msgAttr}${thrAttr} />`;
2620
- }
2621
- const parts = [
2622
- { type: 'text', text: promptWithImagePaths },
2623
- { type: 'text', text: syntheticContext, synthetic: true },
2624
- ...images,
2625
- ];
2626
- // ── Worktree info for system message ──────────────────────
2753
+ // ── Worktree info for per-turn prompt context ─────────────
2627
2754
  const worktreeInfo = await getThreadWorktree(this.thread.id);
2628
2755
  const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
2629
2756
  ? {
@@ -2650,6 +2777,22 @@ export class ThreadSessionRuntime {
2650
2777
  }
2651
2778
  return fetched.topic?.trim() || undefined;
2652
2779
  })();
2780
+ const worktreeChanged = this.consumeWorktreePromptChange(worktree);
2781
+ const syntheticContext = getOpencodePromptContext({
2782
+ username: input.username,
2783
+ userId: input.userId,
2784
+ sourceMessageId: input.sourceMessageId,
2785
+ sourceThreadId: input.sourceThreadId,
2786
+ repliedMessage: input.repliedMessage,
2787
+ worktree,
2788
+ currentAgent: earlyAgentPreference,
2789
+ worktreeChanged,
2790
+ });
2791
+ const parts = [
2792
+ { type: 'text', text: promptWithImagePaths },
2793
+ { type: 'text', text: syntheticContext, synthetic: true },
2794
+ ...images,
2795
+ ];
2653
2796
  const variantField = earlyThinkingValue
2654
2797
  ? { variant: earlyThinkingValue }
2655
2798
  : {};
@@ -2678,15 +2821,19 @@ export class ThreadSessionRuntime {
2678
2821
  // session.command() only accepts FilePart in parts, not text parts.
2679
2822
  // Append <discord-user /> tag to arguments so external sync can
2680
2823
  // detect this message came from Discord (same tag as promptAsync).
2681
- const discordTag = input.username
2682
- ? `\n<discord-user name="${input.username}" />`
2683
- : '';
2824
+ const discordTag = getOpencodePromptContext({
2825
+ username: input.username,
2826
+ userId: input.userId,
2827
+ sourceMessageId: input.sourceMessageId,
2828
+ sourceThreadId: input.sourceThreadId,
2829
+ repliedMessage: input.repliedMessage,
2830
+ });
2684
2831
  const commandResponse = await errore.tryAsync(() => {
2685
2832
  return getClient().session.command({
2686
2833
  sessionID: session.id,
2687
2834
  directory: this.sdkDirectory,
2688
2835
  command: queuedCommand.name,
2689
- arguments: queuedCommand.arguments + discordTag,
2836
+ arguments: queuedCommand.arguments + (discordTag ? `\n${discordTag}` : ''),
2690
2837
  agent: earlyAgentPreference,
2691
2838
  ...variantField,
2692
2839
  }, { signal: commandSignal });
@@ -2752,12 +2899,9 @@ export class ThreadSessionRuntime {
2752
2899
  channelId,
2753
2900
  guildId: this.thread.guildId,
2754
2901
  threadId: this.thread.id,
2755
- worktree,
2756
2902
  channelTopic,
2757
- username: input.username,
2758
- userId: input.userId,
2759
2903
  agents: earlyAvailableAgents,
2760
- currentAgent: earlyAgentPreference,
2904
+ username: this.state?.sessionUsername || input.username,
2761
2905
  }),
2762
2906
  model: earlyModelParam,
2763
2907
  agent: earlyAgentPreference,
@@ -2869,13 +3013,21 @@ export class ThreadSessionRuntime {
2869
3013
  await this.hydrateSessionEventsFromDatabase({ sessionId: session.id });
2870
3014
  // Store session start source for scheduled tasks
2871
3015
  if (createdNewSession && sessionStartScheduleKind) {
2872
- await errore.tryAsync(() => {
2873
- return setSessionStartSource({
2874
- sessionId: session.id,
2875
- scheduleKind: sessionStartScheduleKind,
2876
- scheduledTaskId: sessionStartScheduledTaskId,
2877
- });
3016
+ const sessionStartSourceResult = await errore.tryAsync({
3017
+ try: () => {
3018
+ return setSessionStartSource({
3019
+ sessionId: session.id,
3020
+ scheduleKind: sessionStartScheduleKind,
3021
+ scheduledTaskId: sessionStartScheduledTaskId,
3022
+ });
3023
+ },
3024
+ catch: (e) => new Error('Failed to persist scheduled session start source', {
3025
+ cause: e,
3026
+ }),
2878
3027
  });
3028
+ if (sessionStartSourceResult instanceof Error) {
3029
+ logger.warn(`[SESSION START SOURCE] ${sessionStartSourceResult.message}`);
3030
+ }
2879
3031
  }
2880
3032
  // Store agent preference if provided
2881
3033
  if (agent && createdNewSession) {
@@ -0,0 +1,80 @@
1
+ // Unit tests for deriveThreadNameFromSessionTitle — the pure helper that
2
+ // decides whether (and how) to rename a Discord thread based on an
3
+ // OpenCode session title. Kept focused and deterministic; no Discord mocks.
4
+ import { describe, test, expect } from 'vitest';
5
+ import { deriveThreadNameFromSessionTitle } from './session-handler/thread-session-runtime.js';
6
+ describe('deriveThreadNameFromSessionTitle', () => {
7
+ test('returns trimmed title for plain thread', () => {
8
+ expect(deriveThreadNameFromSessionTitle({
9
+ sessionTitle: ' Fix auth bug ',
10
+ currentName: 'fix the auth',
11
+ })).toMatchInlineSnapshot(`"Fix auth bug"`);
12
+ });
13
+ test('preserves worktree prefix from current name', () => {
14
+ expect(deriveThreadNameFromSessionTitle({
15
+ sessionTitle: 'Refactor queue',
16
+ currentName: '⬦ refactor queue old',
17
+ })).toMatchInlineSnapshot(`"⬦ Refactor queue"`);
18
+ });
19
+ test('ignores placeholder "New Session -" titles', () => {
20
+ expect(deriveThreadNameFromSessionTitle({
21
+ sessionTitle: 'New Session - 2025-01-02',
22
+ currentName: 'whatever',
23
+ })).toMatchInlineSnapshot(`undefined`);
24
+ });
25
+ test('ignores case-insensitive placeholder titles', () => {
26
+ expect(deriveThreadNameFromSessionTitle({
27
+ sessionTitle: 'new session -abc',
28
+ currentName: 'whatever',
29
+ })).toMatchInlineSnapshot(`undefined`);
30
+ });
31
+ test('returns undefined when candidate already matches current name', () => {
32
+ expect(deriveThreadNameFromSessionTitle({
33
+ sessionTitle: 'Fix auth bug',
34
+ currentName: 'Fix auth bug',
35
+ })).toMatchInlineSnapshot(`undefined`);
36
+ });
37
+ test('returns undefined when candidate (with worktree prefix) already matches', () => {
38
+ expect(deriveThreadNameFromSessionTitle({
39
+ sessionTitle: 'Refactor queue',
40
+ currentName: '⬦ Refactor queue',
41
+ })).toMatchInlineSnapshot(`undefined`);
42
+ });
43
+ test('truncates to 100 chars including worktree prefix', () => {
44
+ const result = deriveThreadNameFromSessionTitle({
45
+ sessionTitle: 'x'.repeat(200),
46
+ currentName: '⬦ seed',
47
+ });
48
+ expect(result?.length).toMatchInlineSnapshot(`100`);
49
+ expect(result?.startsWith('⬦ ')).toMatchInlineSnapshot(`true`);
50
+ });
51
+ test('truncates to 100 chars without prefix', () => {
52
+ const result = deriveThreadNameFromSessionTitle({
53
+ sessionTitle: 'y'.repeat(200),
54
+ currentName: 'seed',
55
+ });
56
+ expect(result?.length).toMatchInlineSnapshot(`100`);
57
+ });
58
+ test('returns undefined for empty string', () => {
59
+ expect(deriveThreadNameFromSessionTitle({
60
+ sessionTitle: '',
61
+ currentName: 'seed',
62
+ })).toMatchInlineSnapshot(`undefined`);
63
+ });
64
+ test('returns undefined for whitespace-only title', () => {
65
+ expect(deriveThreadNameFromSessionTitle({
66
+ sessionTitle: ' ',
67
+ currentName: 'seed',
68
+ })).toMatchInlineSnapshot(`undefined`);
69
+ });
70
+ test('returns undefined for null/undefined title', () => {
71
+ expect(deriveThreadNameFromSessionTitle({
72
+ sessionTitle: null,
73
+ currentName: 'seed',
74
+ })).toMatchInlineSnapshot(`undefined`);
75
+ expect(deriveThreadNameFromSessionTitle({
76
+ sessionTitle: undefined,
77
+ currentName: 'seed',
78
+ })).toMatchInlineSnapshot(`undefined`);
79
+ });
80
+ });
package/dist/store.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Centralized zustand/vanilla store for global bot state.
2
2
  // Replaces scattered module-level `let` variables, process.env mutations,
3
3
  // and mutable arrays with a single immutable state atom.
4
- // See discord/skills/zustand-centralized-state/SKILL.md for the pattern.
4
+ // See cli/skills/zustand-centralized-state/SKILL.md for the pattern.
5
5
  import { createStore } from 'zustand/vanilla';
6
6
  export const store = createStore(() => ({
7
7
  dataDir: null,
@@ -9,7 +9,6 @@ export const store = createStore(() => ({
9
9
  defaultVerbosity: 'text_and_essential_tools',
10
10
  defaultMentionMode: false,
11
11
  critiqueEnabled: true,
12
- verboseOpencodeServer: false,
13
12
  discordBaseUrl: 'https://discord.com',
14
13
  gatewayToken: null,
15
14
  registeredUserCommands: [],