kimaki 0.19.0 → 0.20.0
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.
- package/dist/cli-commands/project.js +85 -87
- package/dist/commands/agent.js +51 -4
- package/dist/discord-bot.js +24 -10
- package/dist/message-formatting.js +46 -16
- package/dist/message-formatting.test.js +39 -1
- package/dist/session-handler/thread-session-runtime.js +40 -19
- package/dist/store.js +1 -0
- package/package.json +3 -3
- package/skills/goke/SKILL.md +17 -0
- package/skills/holocron/SKILL.md +66 -1
- package/skills/zele/SKILL.md +13 -0
- package/src/cli-commands/project.ts +99 -87
- package/src/commands/agent.ts +64 -3
- package/src/discord-bot.ts +29 -10
- package/src/message-formatting.test.ts +60 -1
- package/src/message-formatting.ts +58 -15
- package/src/session-handler/thread-session-runtime.ts +49 -18
- package/src/store.ts +8 -0
|
@@ -56,74 +56,7 @@ cli
|
|
|
56
56
|
void client.login(botToken);
|
|
57
57
|
});
|
|
58
58
|
cliLogger.log('Finding guild...');
|
|
59
|
-
|
|
60
|
-
let guild;
|
|
61
|
-
if (options.guild) {
|
|
62
|
-
const guildId = String(options.guild);
|
|
63
|
-
const foundGuild = client.guilds.cache.get(guildId);
|
|
64
|
-
if (!foundGuild) {
|
|
65
|
-
cliLogger.log('Guild not found');
|
|
66
|
-
cliLogger.error(`Guild not found: ${guildId}`);
|
|
67
|
-
void client.destroy();
|
|
68
|
-
process.exit(EXIT_NO_RESTART);
|
|
69
|
-
}
|
|
70
|
-
guild = foundGuild;
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
|
|
74
|
-
where: { channel_type: 'text' },
|
|
75
|
-
orderBy: { created_at: 'desc' },
|
|
76
|
-
columns: { channel_id: true },
|
|
77
|
-
}).then((row) => row?.channel_id);
|
|
78
|
-
if (existingChannelId) {
|
|
79
|
-
try {
|
|
80
|
-
const ch = await client.channels.fetch(existingChannelId);
|
|
81
|
-
if (ch && !ch.isDMBased()) {
|
|
82
|
-
guild = ch.guild;
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
throw new Error('Channel has no guild');
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
|
|
90
|
-
let firstGuild = client.guilds.cache.first();
|
|
91
|
-
if (!firstGuild) {
|
|
92
|
-
// Cache might be empty, try fetching guilds from API
|
|
93
|
-
const fetched = await client.guilds.fetch();
|
|
94
|
-
const firstOAuth2Guild = fetched.first();
|
|
95
|
-
if (firstOAuth2Guild) {
|
|
96
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (!firstGuild) {
|
|
100
|
-
cliLogger.log('No guild found');
|
|
101
|
-
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
102
|
-
void client.destroy();
|
|
103
|
-
process.exit(EXIT_NO_RESTART);
|
|
104
|
-
}
|
|
105
|
-
guild = firstGuild;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
let firstGuild = client.guilds.cache.first();
|
|
110
|
-
if (!firstGuild) {
|
|
111
|
-
// Cache might be empty, try fetching guilds from API
|
|
112
|
-
const fetched = await client.guilds.fetch();
|
|
113
|
-
const firstOAuth2Guild = fetched.first();
|
|
114
|
-
if (firstOAuth2Guild) {
|
|
115
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!firstGuild) {
|
|
119
|
-
cliLogger.log('No guild found');
|
|
120
|
-
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
121
|
-
void client.destroy();
|
|
122
|
-
process.exit(EXIT_NO_RESTART);
|
|
123
|
-
}
|
|
124
|
-
guild = firstGuild;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
59
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
|
|
127
60
|
// Check if channel already exists in this guild
|
|
128
61
|
cliLogger.log('Checking for existing channel...');
|
|
129
62
|
try {
|
|
@@ -353,25 +286,7 @@ cli
|
|
|
353
286
|
client.once(Events.Error, reject);
|
|
354
287
|
client.login(botToken).catch(reject);
|
|
355
288
|
});
|
|
356
|
-
|
|
357
|
-
if (options.guild) {
|
|
358
|
-
const found = client.guilds.cache.get(options.guild);
|
|
359
|
-
if (!found) {
|
|
360
|
-
cliLogger.error(`Guild not found: ${options.guild}`);
|
|
361
|
-
void client.destroy();
|
|
362
|
-
process.exit(EXIT_NO_RESTART);
|
|
363
|
-
}
|
|
364
|
-
guild = found;
|
|
365
|
-
}
|
|
366
|
-
else {
|
|
367
|
-
const first = client.guilds.cache.first();
|
|
368
|
-
if (!first) {
|
|
369
|
-
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
370
|
-
void client.destroy();
|
|
371
|
-
process.exit(EXIT_NO_RESTART);
|
|
372
|
-
}
|
|
373
|
-
guild = first;
|
|
374
|
-
}
|
|
289
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
|
|
375
290
|
const { textChannelId, channelName } = await createProjectChannels({
|
|
376
291
|
guild,
|
|
377
292
|
projectDirectory,
|
|
@@ -383,4 +298,87 @@ cli
|
|
|
383
298
|
cliLogger.log(channelUrl);
|
|
384
299
|
process.exit(0);
|
|
385
300
|
});
|
|
301
|
+
// Resolve the guild for project add/create commands. In gateway mode the
|
|
302
|
+
// guild cache only contains authorized guilds, so picking from cache is safe.
|
|
303
|
+
// The old approach fetched an existing channel to infer the guild, but that
|
|
304
|
+
// breaks when the channel belongs to a different guild (e.g. old self-hosted
|
|
305
|
+
// bot channels) and the gateway proxy rejects the REST call. This led to a
|
|
306
|
+
// non-deterministic fallback that picked the wrong guild.
|
|
307
|
+
async function resolveGuildForProjectCommand({ client, guildIdOverride }) {
|
|
308
|
+
if (guildIdOverride) {
|
|
309
|
+
const found = client.guilds.cache.get(guildIdOverride);
|
|
310
|
+
if (!found) {
|
|
311
|
+
cliLogger.error(`Guild not found: ${guildIdOverride}`);
|
|
312
|
+
void client.destroy();
|
|
313
|
+
process.exit(EXIT_NO_RESTART);
|
|
314
|
+
}
|
|
315
|
+
return found;
|
|
316
|
+
}
|
|
317
|
+
// Try existing channel lookup to find the guild the user already has channels in.
|
|
318
|
+
// This handles multi-guild setups where we want to add to the same guild.
|
|
319
|
+
const db = await getDb();
|
|
320
|
+
const existingChannels = await db.query.channel_directories.findMany({
|
|
321
|
+
where: { channel_type: 'text' },
|
|
322
|
+
orderBy: { created_at: 'desc' },
|
|
323
|
+
columns: { channel_id: true },
|
|
324
|
+
limit: 20,
|
|
325
|
+
});
|
|
326
|
+
// Log available guilds for debugging guild selection issues
|
|
327
|
+
const cachedGuilds = Array.from(client.guilds.cache.values());
|
|
328
|
+
cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`);
|
|
329
|
+
// When multiple guilds are available, find which guild has the most
|
|
330
|
+
// existing channels. The user's main guild will have far more channels
|
|
331
|
+
// than a test/demo guild.
|
|
332
|
+
const guildHits = new Map();
|
|
333
|
+
for (const row of existingChannels) {
|
|
334
|
+
try {
|
|
335
|
+
const ch = await client.channels.fetch(row.channel_id);
|
|
336
|
+
if (ch && !ch.isDMBased()) {
|
|
337
|
+
const entry = guildHits.get(ch.guild.id);
|
|
338
|
+
if (entry) {
|
|
339
|
+
entry.count++;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// Channel might be in a different guild (gateway proxy rejects) or deleted, skip
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (guildHits.size > 0) {
|
|
351
|
+
// Pick the guild with the most channels
|
|
352
|
+
const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0];
|
|
353
|
+
cliLogger.debug(`Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`);
|
|
354
|
+
cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`);
|
|
355
|
+
return best.guild;
|
|
356
|
+
}
|
|
357
|
+
cliLogger.debug('Could not resolve guild from existing channels, falling back to cache');
|
|
358
|
+
// If only one guild in cache, use it directly (common case).
|
|
359
|
+
// If multiple guilds, error out and ask the user to specify --guild
|
|
360
|
+
// since we can't determine which one to use.
|
|
361
|
+
if (cachedGuilds.length === 1) {
|
|
362
|
+
return cachedGuilds[0];
|
|
363
|
+
}
|
|
364
|
+
if (cachedGuilds.length > 1) {
|
|
365
|
+
cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
|
|
366
|
+
void client.destroy();
|
|
367
|
+
process.exit(EXIT_NO_RESTART);
|
|
368
|
+
}
|
|
369
|
+
// Cache empty, try fetching
|
|
370
|
+
const fetched = await client.guilds.fetch();
|
|
371
|
+
if (fetched.size === 1) {
|
|
372
|
+
const firstOAuth2Guild = fetched.first();
|
|
373
|
+
return await client.guilds.fetch(firstOAuth2Guild.id);
|
|
374
|
+
}
|
|
375
|
+
if (fetched.size > 1) {
|
|
376
|
+
cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
|
|
377
|
+
void client.destroy();
|
|
378
|
+
process.exit(EXIT_NO_RESTART);
|
|
379
|
+
}
|
|
380
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
381
|
+
void client.destroy();
|
|
382
|
+
process.exit(EXIT_NO_RESTART);
|
|
383
|
+
}
|
|
386
384
|
export default cli;
|
package/dist/commands/agent.js
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
// the prompt is sent with that agent and the session keeps that agent afterwards.
|
|
5
5
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
|
-
import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
|
|
7
|
+
import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, getChannelWorktreesEnabled, } from '../database.js';
|
|
8
8
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
9
9
|
import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
10
10
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
11
11
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
12
12
|
import { getCurrentModelInfo } from './model.js';
|
|
13
|
+
import { isGitRepositoryRoot } from '../worktrees.js';
|
|
14
|
+
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
|
|
15
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
16
|
+
import { store } from '../store.js';
|
|
13
17
|
const agentLogger = createLogger(LogPrefix.AGENT);
|
|
14
18
|
const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
15
19
|
const pendingAgentContexts = new Map();
|
|
@@ -429,22 +433,65 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
|
|
|
429
433
|
return;
|
|
430
434
|
}
|
|
431
435
|
await command.deferReply();
|
|
436
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting),
|
|
437
|
+
// mirroring the logic in discord-bot.ts message handler.
|
|
438
|
+
const wantsWorktrees = store.getState().useWorktrees ||
|
|
439
|
+
(await getChannelWorktreesEnabled(channel.id));
|
|
440
|
+
const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
|
|
441
|
+
if (wantsWorktrees && !shouldUseWorktrees) {
|
|
442
|
+
agentLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
|
|
443
|
+
}
|
|
444
|
+
const baseThreadName = prompt.slice(0, 80);
|
|
445
|
+
const threadName = shouldUseWorktrees
|
|
446
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
447
|
+
: baseThreadName;
|
|
432
448
|
const starterMessage = await channel.send({
|
|
433
449
|
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
434
450
|
flags: SILENT_MESSAGE_FLAGS,
|
|
435
451
|
});
|
|
436
452
|
const thread = await starterMessage.startThread({
|
|
437
|
-
name:
|
|
453
|
+
name: threadName.slice(0, 80),
|
|
438
454
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
439
455
|
reason: `${resolvedAgentName} agent prompt`,
|
|
440
456
|
});
|
|
441
457
|
await thread.members.add(command.user.id);
|
|
442
|
-
|
|
458
|
+
// Create worktree in background if enabled, same as discord-bot.ts
|
|
459
|
+
let worktreePromise;
|
|
460
|
+
if (shouldUseWorktrees) {
|
|
461
|
+
const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
|
|
462
|
+
agentLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
463
|
+
const worktreeStatusMessage = await thread
|
|
464
|
+
.send({
|
|
465
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
466
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
467
|
+
})
|
|
468
|
+
.catch(() => undefined);
|
|
469
|
+
worktreePromise = createWorktreeInBackground({
|
|
470
|
+
thread,
|
|
471
|
+
starterMessage: worktreeStatusMessage,
|
|
472
|
+
worktreeName,
|
|
473
|
+
projectDirectory,
|
|
474
|
+
rest: command.client.rest,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
const sessionDirectory = await (async () => {
|
|
478
|
+
if (!worktreePromise)
|
|
479
|
+
return projectDirectory;
|
|
480
|
+
const result = await worktreePromise;
|
|
481
|
+
if (result instanceof Error)
|
|
482
|
+
return projectDirectory;
|
|
483
|
+
return result;
|
|
484
|
+
})();
|
|
485
|
+
await command
|
|
486
|
+
.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
|
|
487
|
+
.catch(() => {
|
|
488
|
+
agentLogger.warn('[AGENT] Failed to edit quick-agent reply, continuing session');
|
|
489
|
+
});
|
|
443
490
|
const runtime = getOrCreateRuntime({
|
|
444
491
|
threadId: thread.id,
|
|
445
492
|
thread,
|
|
446
493
|
projectDirectory,
|
|
447
|
-
sdkDirectory:
|
|
494
|
+
sdkDirectory: sessionDirectory,
|
|
448
495
|
channelId: channel.id,
|
|
449
496
|
appId,
|
|
450
497
|
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -173,6 +173,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
173
173
|
if (!discordClient) {
|
|
174
174
|
discordClient = await createDiscordClient();
|
|
175
175
|
}
|
|
176
|
+
store.setState({ useWorktrees: Boolean(useWorktrees) });
|
|
176
177
|
let currentAppId = appId;
|
|
177
178
|
const setupHandlers = async (c) => {
|
|
178
179
|
discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
|
|
@@ -332,15 +333,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
332
333
|
return;
|
|
333
334
|
}
|
|
334
335
|
}
|
|
335
|
-
//
|
|
336
|
-
//
|
|
336
|
+
// Detect messages that start with a mention of another user (not the bot).
|
|
337
|
+
// In channels these are fully ignored. In threads they are added to the
|
|
338
|
+
// session context without triggering the AI (noReply), so the agent sees
|
|
339
|
+
// user-to-user conversation on the next real turn.
|
|
337
340
|
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (mentionedUserId !== discordClient.user?.id) {
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
341
|
+
const isLeadingMentionToOtherUser = leadingMentionMatch &&
|
|
342
|
+
leadingMentionMatch[1] !== discordClient.user?.id;
|
|
344
343
|
if (message.partial) {
|
|
345
344
|
discordLogger.log(`Fetching partial message ${message.id}`);
|
|
346
345
|
const fetched = await message.fetch()
|
|
@@ -354,6 +353,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
354
353
|
// When mention mode is enabled, users without Kimaki role can message
|
|
355
354
|
// without getting a permission error - we just silently ignore.
|
|
356
355
|
const channel = message.channel;
|
|
356
|
+
// In text channels, messages starting with a mention to another user
|
|
357
|
+
// are fully ignored before any permission or mention-mode checks.
|
|
358
|
+
// This prevents permission-error replies for user-to-user conversation.
|
|
359
|
+
if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
357
362
|
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
358
363
|
const mentionModeEnabled = await getChannelMentionMode(channel.id);
|
|
359
364
|
if (mentionModeEnabled) {
|
|
@@ -435,6 +440,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
435
440
|
discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
|
|
436
441
|
return;
|
|
437
442
|
}
|
|
443
|
+
// Context-only messages (user-to-user replies) can't be stored without
|
|
444
|
+
// an existing session. Skip early to avoid creating a runtime or running
|
|
445
|
+
// preprocessing for nothing.
|
|
446
|
+
if (isLeadingMentionToOtherUser && !hasExistingSession) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
438
449
|
const parent = thread.parent;
|
|
439
450
|
let projectDirectory;
|
|
440
451
|
if (parent) {
|
|
@@ -509,7 +520,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
509
520
|
const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
510
521
|
? extractBtwSuffix(message.content || '')
|
|
511
522
|
: null;
|
|
512
|
-
if (btwResult?.forceBtw && projectDirectory) {
|
|
523
|
+
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
513
524
|
const result = await forkSessionToBtwThread({
|
|
514
525
|
sourceThread: thread,
|
|
515
526
|
projectDirectory,
|
|
@@ -559,7 +570,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
559
570
|
appId: currentAppId,
|
|
560
571
|
});
|
|
561
572
|
// Cancel interactive UI when a real user sends a message.
|
|
562
|
-
|
|
573
|
+
// Context-only messages (user-to-user replies) should not interrupt
|
|
574
|
+
// the active run or dismiss pending UI.
|
|
575
|
+
if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
|
|
563
576
|
cancelPendingActionButtons(thread.id);
|
|
564
577
|
cancelHtmlActionsForThread(thread.id);
|
|
565
578
|
const dismissedPermission = await cancelPendingPermission(thread.id);
|
|
@@ -594,6 +607,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
594
607
|
model: cliInjectedModel,
|
|
595
608
|
permissions: cliInjectedPermissions,
|
|
596
609
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
610
|
+
noReply: isLeadingMentionToOtherUser || undefined,
|
|
597
611
|
sessionStartSource: sessionStartSource
|
|
598
612
|
? {
|
|
599
613
|
scheduleKind: sessionStartSource.scheduleKind,
|
|
@@ -266,6 +266,42 @@ export async function getFileAttachments(message) {
|
|
|
266
266
|
return results.filter((r) => r !== null);
|
|
267
267
|
}
|
|
268
268
|
const MAX_BASH_COMMAND_INLINE_LENGTH = 100;
|
|
269
|
+
/**
|
|
270
|
+
* Format the inline title for a bash tool part. Handles three cases:
|
|
271
|
+
* 1. Short single-line command → show full command
|
|
272
|
+
* 2. Long/multiline command with description → show description
|
|
273
|
+
* 3. Long/multiline command without description → truncate first line of command
|
|
274
|
+
*
|
|
275
|
+
* The description field was removed from the bash tool schema in newer opencode
|
|
276
|
+
* versions, so case 3 is the common path now. Without this fallback, long commands
|
|
277
|
+
* would render as just "┣ bash" with no context.
|
|
278
|
+
*/
|
|
279
|
+
export function formatBashToolTitle({ command, description, stateTitle, }) {
|
|
280
|
+
if (!command && !description && !stateTitle)
|
|
281
|
+
return '';
|
|
282
|
+
const isSingleLine = !command.includes('\n');
|
|
283
|
+
// Find first non-empty line to handle commands with leading blank lines
|
|
284
|
+
const firstMeaningfulLine = command
|
|
285
|
+
.split('\n')
|
|
286
|
+
.find((line) => line.trim().length > 0)
|
|
287
|
+
?.trimStart() ?? '';
|
|
288
|
+
if (command && isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
|
|
289
|
+
return ` _${escapeInlineMarkdown(command)}_`;
|
|
290
|
+
}
|
|
291
|
+
if (description) {
|
|
292
|
+
return ` _${escapeInlineMarkdown(description)}_`;
|
|
293
|
+
}
|
|
294
|
+
if (firstMeaningfulLine.length > 0) {
|
|
295
|
+
const truncated = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
|
|
296
|
+
? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH) + '…'
|
|
297
|
+
: firstMeaningfulLine;
|
|
298
|
+
return ` _${escapeInlineMarkdown(truncated)}_`;
|
|
299
|
+
}
|
|
300
|
+
if (stateTitle) {
|
|
301
|
+
return ` _${escapeInlineMarkdown(stateTitle)}_`;
|
|
302
|
+
}
|
|
303
|
+
return '';
|
|
304
|
+
}
|
|
269
305
|
export function getToolSummaryText(part) {
|
|
270
306
|
if (part.type !== 'tool')
|
|
271
307
|
return '';
|
|
@@ -449,12 +485,10 @@ export function formatPart(part, prefix) {
|
|
|
449
485
|
}
|
|
450
486
|
const command = part.state.input?.command || '';
|
|
451
487
|
const description = part.state.input?.description || '';
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
? ` _${escapeInlineMarkdown(description)}_`
|
|
457
|
-
: '';
|
|
488
|
+
const toolTitle = formatBashToolTitle({
|
|
489
|
+
command,
|
|
490
|
+
description,
|
|
491
|
+
});
|
|
458
492
|
return `┣ ${pfx}bash${toolTitle}`;
|
|
459
493
|
}
|
|
460
494
|
const summaryText = getToolSummaryText(part);
|
|
@@ -466,16 +500,12 @@ export function formatPart(part, prefix) {
|
|
|
466
500
|
else if (part.tool === 'bash') {
|
|
467
501
|
const command = part.state.input?.command || '';
|
|
468
502
|
const description = part.state.input?.description || '';
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
else if (stateTitle) {
|
|
477
|
-
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
|
|
478
|
-
}
|
|
503
|
+
const formatted = formatBashToolTitle({
|
|
504
|
+
command,
|
|
505
|
+
description,
|
|
506
|
+
stateTitle,
|
|
507
|
+
});
|
|
508
|
+
toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted;
|
|
479
509
|
}
|
|
480
510
|
else if (stateTitle) {
|
|
481
511
|
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from 'vitest';
|
|
2
|
-
import { formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
|
|
2
|
+
import { formatBashToolTitle, formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
|
|
3
3
|
describe('formatPart', () => {
|
|
4
4
|
test('callout text does not get ⬥ prefix', () => {
|
|
5
5
|
const part = {
|
|
@@ -42,6 +42,44 @@ describe('formatPart', () => {
|
|
|
42
42
|
`);
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
describe('formatBashToolTitle', () => {
|
|
46
|
+
test('short single-line command shown in full', () => {
|
|
47
|
+
expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
|
|
48
|
+
});
|
|
49
|
+
test('multiline command without description truncates to first line', () => {
|
|
50
|
+
expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello_"`);
|
|
51
|
+
});
|
|
52
|
+
test('long single-line command is truncated with ellipsis', () => {
|
|
53
|
+
const longCommand = 'a'.repeat(150);
|
|
54
|
+
const result = formatBashToolTitle({ command: longCommand });
|
|
55
|
+
expect(result).toContain('…');
|
|
56
|
+
expect(result.length).toBeLessThan(150);
|
|
57
|
+
});
|
|
58
|
+
test('description is preferred over truncated command when present', () => {
|
|
59
|
+
expect(formatBashToolTitle({
|
|
60
|
+
command: 'echo hello\necho world',
|
|
61
|
+
description: 'Print greeting',
|
|
62
|
+
})).toMatchInlineSnapshot(`" _Print greeting_"`);
|
|
63
|
+
});
|
|
64
|
+
test('stateTitle used as last resort', () => {
|
|
65
|
+
expect(formatBashToolTitle({ command: '', stateTitle: 'Running tests' })).toMatchInlineSnapshot(`" _Running tests_"`);
|
|
66
|
+
});
|
|
67
|
+
test('empty inputs return empty string', () => {
|
|
68
|
+
expect(formatBashToolTitle({ command: '' })).toBe('');
|
|
69
|
+
});
|
|
70
|
+
test('leading blank line skipped, uses first meaningful line', () => {
|
|
71
|
+
expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test_"`);
|
|
72
|
+
});
|
|
73
|
+
test('whitespace-only first line skipped', () => {
|
|
74
|
+
expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test_"`);
|
|
75
|
+
});
|
|
76
|
+
test('no description field (new opencode) with multiline command', () => {
|
|
77
|
+
// This is the exact scenario that was broken: opencode removed `description`
|
|
78
|
+
// from the bash tool schema, so multiline commands rendered as just "┣ bash"
|
|
79
|
+
const command = 'git diff HEAD~1 --stat && git log --oneline -5';
|
|
80
|
+
expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5_"`);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
45
83
|
describe('formatTodoList', () => {
|
|
46
84
|
test('formats active todo with monospace numbers', () => {
|
|
47
85
|
const part = {
|
|
@@ -190,23 +190,23 @@ function getTokenTotal(tokens) {
|
|
|
190
190
|
tokens.cache.read +
|
|
191
191
|
tokens.cache.write);
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Built-in read-only tools that are hidden in default verbosity mode.
|
|
195
|
+
* Any tool NOT in this list is considered "essential" and shown,
|
|
196
|
+
* which means custom tools, MCP tools, and plugin tools are visible by default.
|
|
197
|
+
*/
|
|
198
|
+
const HIDDEN_READONLY_TOOLS = [
|
|
199
|
+
'read',
|
|
200
|
+
'glob',
|
|
201
|
+
'grep',
|
|
202
|
+
'describe-media',
|
|
203
|
+
'todoread',
|
|
204
|
+
];
|
|
193
205
|
/** Check if a tool part is "essential" (shown in text-and-essential-tools mode). */
|
|
194
206
|
export function isEssentialToolName(toolName) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
'apply_patch',
|
|
199
|
-
'bash',
|
|
200
|
-
'webfetch',
|
|
201
|
-
'websearch',
|
|
202
|
-
'googlesearch',
|
|
203
|
-
'codesearch',
|
|
204
|
-
'task',
|
|
205
|
-
'todowrite',
|
|
206
|
-
'skill',
|
|
207
|
-
];
|
|
208
|
-
// Also match any MCP tool that contains these names
|
|
209
|
-
return essentialTools.some((name) => {
|
|
207
|
+
// Hide known read-only built-in tools; show everything else
|
|
208
|
+
// (custom tools, MCP tools, plugin tools are visible by default)
|
|
209
|
+
return !HIDDEN_READONLY_TOOLS.some((name) => {
|
|
210
210
|
return toolName === name || toolName.endsWith(`_${name}`);
|
|
211
211
|
});
|
|
212
212
|
}
|
|
@@ -2062,6 +2062,15 @@ export class ThreadSessionRuntime {
|
|
|
2062
2062
|
skippedBySessionGuard = true;
|
|
2063
2063
|
return;
|
|
2064
2064
|
}
|
|
2065
|
+
// Context-only messages (noReply) should not create a new session.
|
|
2066
|
+
// If there is no existing session, silently skip.
|
|
2067
|
+
if (input.noReply) {
|
|
2068
|
+
const existingSessionId = this.state?.sessionId || await getThreadSession(this.thread.id) || undefined;
|
|
2069
|
+
if (!existingSessionId) {
|
|
2070
|
+
logger.log(`[INGRESS] Skipping noReply message for thread ${this.threadId}: no existing session`);
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2065
2074
|
// Helper: stop typing and drain queued local messages on error.
|
|
2066
2075
|
const cleanupOnError = async (errorMessage) => {
|
|
2067
2076
|
this.stopTyping();
|
|
@@ -2269,6 +2278,7 @@ export class ThreadSessionRuntime {
|
|
|
2269
2278
|
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
|
2270
2279
|
...(modelField ? { model: modelField } : {}),
|
|
2271
2280
|
...variantField,
|
|
2281
|
+
...(input.noReply ? { noReply: true } : {}),
|
|
2272
2282
|
};
|
|
2273
2283
|
const promptResult = await getClient().session.promptAsync(request)
|
|
2274
2284
|
.catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }));
|
|
@@ -2284,7 +2294,10 @@ export class ThreadSessionRuntime {
|
|
|
2284
2294
|
return;
|
|
2285
2295
|
}
|
|
2286
2296
|
logger.log(`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`);
|
|
2287
|
-
|
|
2297
|
+
// noReply messages don't trigger the agent loop, so don't mark as busy
|
|
2298
|
+
if (!input.noReply) {
|
|
2299
|
+
this.markQueueDispatchBusy(session.id);
|
|
2300
|
+
}
|
|
2288
2301
|
});
|
|
2289
2302
|
if (skippedBySessionGuard) {
|
|
2290
2303
|
return { queued: false };
|
|
@@ -2426,9 +2439,17 @@ export class ThreadSessionRuntime {
|
|
|
2426
2439
|
// Route with the resolved mode through normal paths.
|
|
2427
2440
|
// Await the enqueue so session state (ensureSession, setThreadSession)
|
|
2428
2441
|
// is persisted before the next message's preprocessing reads it.
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2442
|
+
// noReply messages always go through the opencode path so the flag
|
|
2443
|
+
// reaches promptAsync; local queue doesn't support noReply.
|
|
2444
|
+
const enqueueResult = resolvedInput.noReply
|
|
2445
|
+
? await this.submitViaOpencodeQueue({
|
|
2446
|
+
...resolvedInput,
|
|
2447
|
+
mode: 'opencode',
|
|
2448
|
+
command: undefined,
|
|
2449
|
+
})
|
|
2450
|
+
: (resolvedInput.mode === 'local-queue' || resolvedInput.command)
|
|
2451
|
+
? await this.enqueueViaLocalQueue(resolvedInput)
|
|
2452
|
+
: await this.submitViaOpencodeQueue(resolvedInput);
|
|
2432
2453
|
resolveOuter(enqueueResult);
|
|
2433
2454
|
}
|
|
2434
2455
|
catch (err) {
|
package/dist/store.js
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.20.0",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"tsx": "^4.20.5",
|
|
26
26
|
"undici": "^8.0.2",
|
|
27
27
|
"discord-digital-twin": "^0.1.0",
|
|
28
|
-
"opencode-cached-provider": "^0.0.1",
|
|
29
28
|
"opencode-deterministic-provider": "^0.0.1",
|
|
29
|
+
"opencode-cached-provider": "^0.0.1",
|
|
30
30
|
"db": "^0.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
"yaml": "^2.8.3",
|
|
63
63
|
"zod": "^4.3.6",
|
|
64
64
|
"zustand": "^5.0.11",
|
|
65
|
-
"errore": "^0.14.1",
|
|
66
65
|
"libsqlproxy": "^0.1.0",
|
|
67
66
|
"opencode-injection-guard": "^0.2.1",
|
|
67
|
+
"errore": "^0.14.1",
|
|
68
68
|
"traforo": "^0.7.1"
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
package/skills/goke/SKILL.md
CHANGED
|
@@ -178,6 +178,23 @@ import { openInBrowser } from 'goke'
|
|
|
178
178
|
await openInBrowser('https://example.com/dashboard')
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
+
## Command Descriptions and Examples
|
|
182
|
+
|
|
183
|
+
**Use backtick formatting in descriptions** for flags and command references (e.g. `` `--status` ``, `` `mycli deploy` ``). Plain text flag names won't render as code in generated docs.
|
|
184
|
+
|
|
185
|
+
**Use `.example()` for usage examples, not the description string.** `generateDocs()` auto-wraps `.example()` strings in fenced `` ```sh `` code blocks. Examples in the description render as plain text without syntax highlighting.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
cli.command(
|
|
189
|
+
'query <sql>',
|
|
190
|
+
dedent`
|
|
191
|
+
Run a SQL query. Add \`--json\` for the raw JSON envelope.
|
|
192
|
+
`,
|
|
193
|
+
)
|
|
194
|
+
.example('mycli query "SELECT * FROM users" -p my-app')
|
|
195
|
+
.example('mycli query "SELECT * FROM users FORMAT CSV" > out.csv')
|
|
196
|
+
```
|
|
197
|
+
|
|
181
198
|
## Command Naming Conventions
|
|
182
199
|
|
|
183
200
|
**ALWAYS read existing commands before adding a new one.** Scan the CLI for option names, verbs, and noun patterns already in use. New commands must stay consistent with what exists.
|