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
package/skills/holocron/SKILL.md
CHANGED
|
@@ -43,6 +43,7 @@ When the user asks about a specific workflow, fetch the matching page directly:
|
|
|
43
43
|
- **Node deploy**: https://holocron.so/docs/deploy/node.md
|
|
44
44
|
- **Holocron deploy**: https://holocron.so/docs/deploy/holocron.md
|
|
45
45
|
- **AI assistant docs**: https://holocron.so/docs/ai/assistant.md
|
|
46
|
+
- **Bleed**: https://holocron.so/docs/customize/bleed.md
|
|
46
47
|
|
|
47
48
|
```bash
|
|
48
49
|
curl -fsSL https://holocron.so/docs/quickstart.md
|
|
@@ -326,6 +327,36 @@ sidebarTitle: Auth Providers
|
|
|
326
327
|
below the title and in search engine snippets. Write a single sentence that
|
|
327
328
|
summarizes the page content. Longer descriptions get truncated with an ellipsis.
|
|
328
329
|
|
|
330
|
+
## Page titles (MUST follow)
|
|
331
|
+
|
|
332
|
+
Holocron renders the browser `<title>` as `{page title} — {site name}`. Two
|
|
333
|
+
frontmatter fields control what appears where:
|
|
334
|
+
|
|
335
|
+
- **`title`** — browser tab, Google results, OG image, page heading. Write for SEO.
|
|
336
|
+
- **`sidebarTitle`** — sidebar navigation only. Write for scannability (short label).
|
|
337
|
+
|
|
338
|
+
**Always set both.** `title` is the full descriptive phrase; `sidebarTitle` is
|
|
339
|
+
the short sidebar label. Without `sidebarTitle`, the full title shows in the
|
|
340
|
+
sidebar and often wraps or looks verbose.
|
|
341
|
+
|
|
342
|
+
```mdx
|
|
343
|
+
---
|
|
344
|
+
title: Open-source browser automation for AI agents
|
|
345
|
+
sidebarTitle: Home
|
|
346
|
+
description: Automate any browser with a simple TypeScript API.
|
|
347
|
+
---
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Browser tab: `Open-source browser automation for AI agents — Playwriter`
|
|
351
|
+
Sidebar: `Home`
|
|
352
|
+
|
|
353
|
+
**Rules:**
|
|
354
|
+
|
|
355
|
+
- **Never set `title` identical to the site name.** `Playwriter — Playwriter` is redundant.
|
|
356
|
+
- **Every `title` MUST be descriptive, not a generic label.** `Introduction`, `Getting Started`, `Overview` say nothing in a browser tab. Write what the page covers.
|
|
357
|
+
- **Every `title` MUST be unique across the site.**
|
|
358
|
+
- **`index.mdx` MUST use `sidebarTitle: Home`** (or `Overview`) and a descriptive `title`.
|
|
359
|
+
|
|
329
360
|
## Custom entry (mounting docs inside a Spiceflow app)
|
|
330
361
|
|
|
331
362
|
When the site mounts Holocron inside an existing **Spiceflow** app (the `entry`
|
|
@@ -416,6 +447,12 @@ below the main content to accommodate the aside height. Keep aside callouts
|
|
|
416
447
|
short, and only place them in sections that have at least a few paragraphs of
|
|
417
448
|
body text.
|
|
418
449
|
|
|
450
|
+
## Moving or renaming a page
|
|
451
|
+
|
|
452
|
+
When moving or renaming a page, **always add a redirect** from the old slug to
|
|
453
|
+
the new one so existing links and bookmarks don't break. Update internal links
|
|
454
|
+
in other pages and the slug in `docs.json` navigation to match.
|
|
455
|
+
|
|
419
456
|
## New pages and navigation
|
|
420
457
|
|
|
421
458
|
After creating a new `.mdx` or `.md` page, add its slug to `docs.jsonc`
|
|
@@ -569,7 +606,7 @@ Use ASCII diagrams frequently in MDX pages. Always use the **`diagram`** languag
|
|
|
569
606
|
- Never exceed 94 characters per line.
|
|
570
607
|
- Prefer a **varied, organic layout**. Mix plain text labels, boxes for major components, and directional arrows.
|
|
571
608
|
- All connections must use **directional arrows**. Never use plain lines without an arrowhead.
|
|
572
|
-
- Verify alignment by
|
|
609
|
+
- Verify alignment by running `npx -y @holocron.so/cli diagrams fix <file>` (see dedicated section below).
|
|
573
610
|
|
|
574
611
|
````mdx
|
|
575
612
|
```diagram
|
|
@@ -590,6 +627,23 @@ docs.jsonc ───►│ Vite Plugin │──────► Build Output
|
|
|
590
627
|
```
|
|
591
628
|
````
|
|
592
629
|
|
|
630
|
+
## Always run `diagrams fix` after editing diagrams
|
|
631
|
+
|
|
632
|
+
LLMs cannot count characters reliably. Every time you create or edit a diagram in an MDX page, you **must** run the alignment fixer before committing. This is not optional.
|
|
633
|
+
|
|
634
|
+
```bash
|
|
635
|
+
npx -y @holocron.so/cli diagrams fix path/to/file.mdx
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
The command:
|
|
639
|
+
- Fixes box alignment (padding, border widths, junctions) in-place
|
|
640
|
+
- Reports how many lines were changed
|
|
641
|
+
- Exits with code 1 if any lines exceed max width (94 cols by default)
|
|
642
|
+
|
|
643
|
+
If the output says "No changes", the diagram was already correct. If it reports width violations, shorten the offending lines manually and re-run.
|
|
644
|
+
|
|
645
|
+
**Do not skip this step.** Even if the diagram looks correct in your editor, run the command. Off-by-one padding errors are invisible to LLMs but obvious to humans in monospace fonts.
|
|
646
|
+
|
|
593
647
|
## OpenAPI summaries
|
|
594
648
|
|
|
595
649
|
OpenAPI operation `summary` fields become sidebar titles. The sidebar is only
|
|
@@ -601,6 +655,15 @@ characters. Use the same concise style as page `sidebarTitle` fields.
|
|
|
601
655
|
- Never repeat the tag/group name in the summary. If the tag is `Deploy`, the summary should not start with "Deploy".
|
|
602
656
|
- If the summary reads well as a sidebar label at 230px, it is short enough.
|
|
603
657
|
|
|
658
|
+
## Bleed — extending content past the prose column
|
|
659
|
+
|
|
660
|
+
Always wrap **YouTube embeds, videos, and large images** in `<div className='bleed'>`
|
|
661
|
+
so they extend into both page margins instead of sitting at narrow prose width.
|
|
662
|
+
`<Frame>` also accepts `className='bleed'`. Containers like Callout, Card, and
|
|
663
|
+
Accordion apply `no-bleed` automatically to keep descendants inside their frame.
|
|
664
|
+
|
|
665
|
+
See https://holocron.so/docs/customize/bleed.md for the full reference.
|
|
666
|
+
|
|
604
667
|
## Agent rules
|
|
605
668
|
|
|
606
669
|
- **Always place MDX pages inside the Holocron `pagesDir`.** Before creating or
|
|
@@ -614,5 +677,7 @@ characters. Use the same concise style as page `sidebarTitle` fields.
|
|
|
614
677
|
- All ASCII diagrams in MDX pages must use the `diagram` language hint on the
|
|
615
678
|
code fence (` ```diagram `). This renders them with proper styling on the
|
|
616
679
|
website instead of plain monospace.
|
|
680
|
+
- After creating or editing any diagram, run `npx -y @holocron.so/cli diagrams fix <file>`
|
|
681
|
+
to auto-fix alignment. LLMs cannot count characters reliably.
|
|
617
682
|
- Never pipe curl or `--help` output through `head`, `tail`, or any truncation
|
|
618
683
|
command. Always read the full output.
|
package/skills/zele/SKILL.md
CHANGED
|
@@ -47,3 +47,16 @@ The README and `zele --help` output are the source of truth for commands, option
|
|
|
47
47
|
```
|
|
48
48
|
5. **Google-only features** (labels, Gmail filters, `zele cal *`, full profile) fail on IMAP accounts with a clear error. Check `zele whoami` output for account type before using them.
|
|
49
49
|
6. **Headless Google login** requires a tmux wrapper because `zele login` is interactive. See the README "Remote / headless login" section for the exact pattern.
|
|
50
|
+
7. **Waiting for emails** with `zele mail watch`. It polls for new emails matching a filter and exits as soon as one arrives. Use this to wait for replies, verification codes, or any expected email:
|
|
51
|
+
```bash
|
|
52
|
+
# wait for a reply from alice (no timeout, blocks until match)
|
|
53
|
+
zele mail watch --filter "is:unread from:alice@example.com"
|
|
54
|
+
|
|
55
|
+
# wait for a verification code with a 5-minute timeout
|
|
56
|
+
zele mail watch --filter "is:unread subject:verification" --timeout 300
|
|
57
|
+
|
|
58
|
+
# send an email then wait for the reply
|
|
59
|
+
zele mail send --to bob@example.com --subject "Question" --body "Hey, can you check this?"
|
|
60
|
+
zele mail watch --filter "is:unread from:bob@example.com subject:Re:Question" --timeout 600
|
|
61
|
+
```
|
|
62
|
+
If the matched email wasn't the expected one, call `zele mail watch` again with a more specific filter. Exit code 0 means a match was found, exit code 1 means timeout.
|
|
@@ -5,7 +5,7 @@ import { note } from '@clack/prompts'
|
|
|
5
5
|
import YAML from 'yaml'
|
|
6
6
|
import * as errore from 'errore'
|
|
7
7
|
import type { OpencodeClient, Event as OpenCodeEvent } from '@opencode-ai/sdk/v2'
|
|
8
|
-
import { Events, ActivityType, type PresenceStatusData, type Guild, Routes } from 'discord.js'
|
|
8
|
+
import { Events, ActivityType, type PresenceStatusData, type Guild, type Client, Routes } from 'discord.js'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import fs from 'node:fs'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
@@ -98,74 +98,7 @@ cli
|
|
|
98
98
|
|
|
99
99
|
cliLogger.log('Finding guild...')
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
let guild: Guild
|
|
103
|
-
if (options.guild) {
|
|
104
|
-
const guildId = String(options.guild)
|
|
105
|
-
const foundGuild = client.guilds.cache.get(guildId)
|
|
106
|
-
if (!foundGuild) {
|
|
107
|
-
cliLogger.log('Guild not found')
|
|
108
|
-
cliLogger.error(`Guild not found: ${guildId}`)
|
|
109
|
-
void client.destroy()
|
|
110
|
-
process.exit(EXIT_NO_RESTART)
|
|
111
|
-
}
|
|
112
|
-
guild = foundGuild
|
|
113
|
-
} else {
|
|
114
|
-
const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
|
|
115
|
-
where: { channel_type: 'text' },
|
|
116
|
-
orderBy: { created_at: 'desc' },
|
|
117
|
-
columns: { channel_id: true },
|
|
118
|
-
}).then((row) => row?.channel_id)
|
|
119
|
-
|
|
120
|
-
if (existingChannelId) {
|
|
121
|
-
try {
|
|
122
|
-
const ch = await client.channels.fetch(existingChannelId)
|
|
123
|
-
if (ch && !ch.isDMBased()) {
|
|
124
|
-
guild = ch.guild
|
|
125
|
-
} else {
|
|
126
|
-
throw new Error('Channel has no guild')
|
|
127
|
-
}
|
|
128
|
-
} catch (error) {
|
|
129
|
-
cliLogger.debug(
|
|
130
|
-
'Failed to fetch existing channel while selecting guild:',
|
|
131
|
-
error instanceof Error ? error.stack : String(error),
|
|
132
|
-
)
|
|
133
|
-
let firstGuild = client.guilds.cache.first()
|
|
134
|
-
if (!firstGuild) {
|
|
135
|
-
// Cache might be empty, try fetching guilds from API
|
|
136
|
-
const fetched = await client.guilds.fetch()
|
|
137
|
-
const firstOAuth2Guild = fetched.first()
|
|
138
|
-
if (firstOAuth2Guild) {
|
|
139
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (!firstGuild) {
|
|
143
|
-
cliLogger.log('No guild found')
|
|
144
|
-
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
145
|
-
void client.destroy()
|
|
146
|
-
process.exit(EXIT_NO_RESTART)
|
|
147
|
-
}
|
|
148
|
-
guild = firstGuild
|
|
149
|
-
}
|
|
150
|
-
} else {
|
|
151
|
-
let firstGuild = client.guilds.cache.first()
|
|
152
|
-
if (!firstGuild) {
|
|
153
|
-
// Cache might be empty, try fetching guilds from API
|
|
154
|
-
const fetched = await client.guilds.fetch()
|
|
155
|
-
const firstOAuth2Guild = fetched.first()
|
|
156
|
-
if (firstOAuth2Guild) {
|
|
157
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (!firstGuild) {
|
|
161
|
-
cliLogger.log('No guild found')
|
|
162
|
-
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
163
|
-
void client.destroy()
|
|
164
|
-
process.exit(EXIT_NO_RESTART)
|
|
165
|
-
}
|
|
166
|
-
guild = firstGuild
|
|
167
|
-
}
|
|
168
|
-
}
|
|
101
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
|
|
169
102
|
|
|
170
103
|
// Check if channel already exists in this guild
|
|
171
104
|
cliLogger.log('Checking for existing channel...')
|
|
@@ -466,24 +399,7 @@ cli
|
|
|
466
399
|
client.login(botToken).catch(reject)
|
|
467
400
|
})
|
|
468
401
|
|
|
469
|
-
|
|
470
|
-
if (options.guild) {
|
|
471
|
-
const found = client.guilds.cache.get(options.guild)
|
|
472
|
-
if (!found) {
|
|
473
|
-
cliLogger.error(`Guild not found: ${options.guild}`)
|
|
474
|
-
void client.destroy()
|
|
475
|
-
process.exit(EXIT_NO_RESTART)
|
|
476
|
-
}
|
|
477
|
-
guild = found
|
|
478
|
-
} else {
|
|
479
|
-
const first = client.guilds.cache.first()
|
|
480
|
-
if (!first) {
|
|
481
|
-
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
482
|
-
void client.destroy()
|
|
483
|
-
process.exit(EXIT_NO_RESTART)
|
|
484
|
-
}
|
|
485
|
-
guild = first
|
|
486
|
-
}
|
|
402
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
|
|
487
403
|
|
|
488
404
|
const { textChannelId, channelName } = await createProjectChannels({
|
|
489
405
|
guild,
|
|
@@ -505,4 +421,100 @@ cli
|
|
|
505
421
|
})
|
|
506
422
|
|
|
507
423
|
|
|
424
|
+
// Resolve the guild for project add/create commands. In gateway mode the
|
|
425
|
+
// guild cache only contains authorized guilds, so picking from cache is safe.
|
|
426
|
+
// The old approach fetched an existing channel to infer the guild, but that
|
|
427
|
+
// breaks when the channel belongs to a different guild (e.g. old self-hosted
|
|
428
|
+
// bot channels) and the gateway proxy rejects the REST call. This led to a
|
|
429
|
+
// non-deterministic fallback that picked the wrong guild.
|
|
430
|
+
async function resolveGuildForProjectCommand({ client, guildIdOverride }: { client: Client; guildIdOverride?: string }): Promise<Guild> {
|
|
431
|
+
if (guildIdOverride) {
|
|
432
|
+
const found = client.guilds.cache.get(guildIdOverride)
|
|
433
|
+
if (!found) {
|
|
434
|
+
cliLogger.error(`Guild not found: ${guildIdOverride}`)
|
|
435
|
+
void client.destroy()
|
|
436
|
+
process.exit(EXIT_NO_RESTART)
|
|
437
|
+
}
|
|
438
|
+
return found
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Try existing channel lookup to find the guild the user already has channels in.
|
|
442
|
+
// This handles multi-guild setups where we want to add to the same guild.
|
|
443
|
+
const db = await getDb()
|
|
444
|
+
const existingChannels = await db.query.channel_directories.findMany({
|
|
445
|
+
where: { channel_type: 'text' },
|
|
446
|
+
orderBy: { created_at: 'desc' },
|
|
447
|
+
columns: { channel_id: true },
|
|
448
|
+
limit: 20,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
// Log available guilds for debugging guild selection issues
|
|
452
|
+
const cachedGuilds = Array.from(client.guilds.cache.values())
|
|
453
|
+
cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`)
|
|
454
|
+
|
|
455
|
+
// When multiple guilds are available, find which guild has the most
|
|
456
|
+
// existing channels. The user's main guild will have far more channels
|
|
457
|
+
// than a test/demo guild.
|
|
458
|
+
const guildHits = new Map<string, { guild: Guild; count: number }>()
|
|
459
|
+
for (const row of existingChannels) {
|
|
460
|
+
try {
|
|
461
|
+
const ch = await client.channels.fetch(row.channel_id)
|
|
462
|
+
if (ch && !ch.isDMBased()) {
|
|
463
|
+
const entry = guildHits.get(ch.guild.id)
|
|
464
|
+
if (entry) {
|
|
465
|
+
entry.count++
|
|
466
|
+
} else {
|
|
467
|
+
guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 })
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
// Channel might be in a different guild (gateway proxy rejects) or deleted, skip
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (guildHits.size > 0) {
|
|
476
|
+
// Pick the guild with the most channels
|
|
477
|
+
const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0]!
|
|
478
|
+
cliLogger.debug(
|
|
479
|
+
`Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`,
|
|
480
|
+
)
|
|
481
|
+
cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`)
|
|
482
|
+
return best.guild
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
cliLogger.debug('Could not resolve guild from existing channels, falling back to cache')
|
|
486
|
+
|
|
487
|
+
// If only one guild in cache, use it directly (common case).
|
|
488
|
+
// If multiple guilds, error out and ask the user to specify --guild
|
|
489
|
+
// since we can't determine which one to use.
|
|
490
|
+
if (cachedGuilds.length === 1) {
|
|
491
|
+
return cachedGuilds[0]!
|
|
492
|
+
}
|
|
493
|
+
if (cachedGuilds.length > 1) {
|
|
494
|
+
cliLogger.error(
|
|
495
|
+
`Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
|
|
496
|
+
)
|
|
497
|
+
void client.destroy()
|
|
498
|
+
process.exit(EXIT_NO_RESTART)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Cache empty, try fetching
|
|
502
|
+
const fetched = await client.guilds.fetch()
|
|
503
|
+
if (fetched.size === 1) {
|
|
504
|
+
const firstOAuth2Guild = fetched.first()!
|
|
505
|
+
return await client.guilds.fetch(firstOAuth2Guild.id)
|
|
506
|
+
}
|
|
507
|
+
if (fetched.size > 1) {
|
|
508
|
+
cliLogger.error(
|
|
509
|
+
`Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
|
|
510
|
+
)
|
|
511
|
+
void client.destroy()
|
|
512
|
+
process.exit(EXIT_NO_RESTART)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
516
|
+
void client.destroy()
|
|
517
|
+
process.exit(EXIT_NO_RESTART)
|
|
518
|
+
}
|
|
519
|
+
|
|
508
520
|
export default cli
|
package/src/commands/agent.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getThreadSession,
|
|
22
22
|
getSessionAgent,
|
|
23
23
|
getChannelAgent,
|
|
24
|
+
getChannelWorktreesEnabled,
|
|
24
25
|
} from '../database.js'
|
|
25
26
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
26
27
|
import {
|
|
@@ -32,6 +33,14 @@ import {
|
|
|
32
33
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
|
|
33
34
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
34
35
|
import { getCurrentModelInfo } from './model.js'
|
|
36
|
+
import { isGitRepositoryRoot } from '../worktrees.js'
|
|
37
|
+
import {
|
|
38
|
+
formatAutoWorktreeName,
|
|
39
|
+
createWorktreeInBackground,
|
|
40
|
+
worktreeCreatingMessage,
|
|
41
|
+
} from './new-worktree.js'
|
|
42
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js'
|
|
43
|
+
import { store } from '../store.js'
|
|
35
44
|
|
|
36
45
|
const agentLogger = createLogger(LogPrefix.AGENT)
|
|
37
46
|
|
|
@@ -597,26 +606,78 @@ async function handleQuickAgentWithPrompt({
|
|
|
597
606
|
|
|
598
607
|
await command.deferReply()
|
|
599
608
|
|
|
609
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting),
|
|
610
|
+
// mirroring the logic in discord-bot.ts message handler.
|
|
611
|
+
const wantsWorktrees =
|
|
612
|
+
store.getState().useWorktrees ||
|
|
613
|
+
(await getChannelWorktreesEnabled(channel.id))
|
|
614
|
+
const shouldUseWorktrees =
|
|
615
|
+
wantsWorktrees && (await isGitRepositoryRoot(projectDirectory))
|
|
616
|
+
|
|
617
|
+
if (wantsWorktrees && !shouldUseWorktrees) {
|
|
618
|
+
agentLogger.warn(
|
|
619
|
+
`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`,
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const baseThreadName = prompt.slice(0, 80)
|
|
624
|
+
const threadName = shouldUseWorktrees
|
|
625
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
626
|
+
: baseThreadName
|
|
627
|
+
|
|
600
628
|
const starterMessage = await channel.send({
|
|
601
629
|
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
602
630
|
flags: SILENT_MESSAGE_FLAGS,
|
|
603
631
|
})
|
|
604
632
|
|
|
605
633
|
const thread = await starterMessage.startThread({
|
|
606
|
-
name:
|
|
634
|
+
name: threadName.slice(0, 80),
|
|
607
635
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
608
636
|
reason: `${resolvedAgentName} agent prompt`,
|
|
609
637
|
})
|
|
610
638
|
|
|
611
639
|
await thread.members.add(command.user.id)
|
|
612
640
|
|
|
613
|
-
|
|
641
|
+
// Create worktree in background if enabled, same as discord-bot.ts
|
|
642
|
+
let worktreePromise: Promise<string | Error> | undefined
|
|
643
|
+
if (shouldUseWorktrees) {
|
|
644
|
+
const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50))
|
|
645
|
+
agentLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
|
|
646
|
+
|
|
647
|
+
const worktreeStatusMessage = await thread
|
|
648
|
+
.send({
|
|
649
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
650
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
651
|
+
})
|
|
652
|
+
.catch(() => undefined)
|
|
653
|
+
|
|
654
|
+
worktreePromise = createWorktreeInBackground({
|
|
655
|
+
thread,
|
|
656
|
+
starterMessage: worktreeStatusMessage,
|
|
657
|
+
worktreeName,
|
|
658
|
+
projectDirectory,
|
|
659
|
+
rest: command.client.rest,
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const sessionDirectory = await (async () => {
|
|
664
|
+
if (!worktreePromise) return projectDirectory
|
|
665
|
+
const result = await worktreePromise
|
|
666
|
+
if (result instanceof Error) return projectDirectory
|
|
667
|
+
return result
|
|
668
|
+
})()
|
|
669
|
+
|
|
670
|
+
await command
|
|
671
|
+
.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
|
|
672
|
+
.catch(() => {
|
|
673
|
+
agentLogger.warn('[AGENT] Failed to edit quick-agent reply, continuing session')
|
|
674
|
+
})
|
|
614
675
|
|
|
615
676
|
const runtime = getOrCreateRuntime({
|
|
616
677
|
threadId: thread.id,
|
|
617
678
|
thread,
|
|
618
679
|
projectDirectory,
|
|
619
|
-
sdkDirectory:
|
|
680
|
+
sdkDirectory: sessionDirectory,
|
|
620
681
|
channelId: channel.id,
|
|
621
682
|
appId,
|
|
622
683
|
})
|
package/src/discord-bot.ts
CHANGED
|
@@ -297,6 +297,8 @@ export async function startDiscordBot({
|
|
|
297
297
|
discordClient = await createDiscordClient()
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
store.setState({ useWorktrees: Boolean(useWorktrees) })
|
|
301
|
+
|
|
300
302
|
let currentAppId: string | undefined = appId
|
|
301
303
|
|
|
302
304
|
const setupHandlers = async (c: Client<true>) => {
|
|
@@ -503,15 +505,14 @@ export async function startDiscordBot({
|
|
|
503
505
|
}
|
|
504
506
|
}
|
|
505
507
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
+
// Detect messages that start with a mention of another user (not the bot).
|
|
509
|
+
// In channels these are fully ignored. In threads they are added to the
|
|
510
|
+
// session context without triggering the AI (noReply), so the agent sees
|
|
511
|
+
// user-to-user conversation on the next real turn.
|
|
508
512
|
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
return
|
|
513
|
-
}
|
|
514
|
-
}
|
|
513
|
+
const isLeadingMentionToOtherUser =
|
|
514
|
+
leadingMentionMatch &&
|
|
515
|
+
leadingMentionMatch[1] !== discordClient.user?.id
|
|
515
516
|
|
|
516
517
|
if (message.partial) {
|
|
517
518
|
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
@@ -530,6 +531,14 @@ export async function startDiscordBot({
|
|
|
530
531
|
// When mention mode is enabled, users without Kimaki role can message
|
|
531
532
|
// without getting a permission error - we just silently ignore.
|
|
532
533
|
const channel = message.channel
|
|
534
|
+
|
|
535
|
+
// In text channels, messages starting with a mention to another user
|
|
536
|
+
// are fully ignored before any permission or mention-mode checks.
|
|
537
|
+
// This prevents permission-error replies for user-to-user conversation.
|
|
538
|
+
if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
533
542
|
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
534
543
|
const mentionModeEnabled = await getChannelMentionMode(channel.id)
|
|
535
544
|
if (mentionModeEnabled) {
|
|
@@ -629,6 +638,13 @@ export async function startDiscordBot({
|
|
|
629
638
|
return
|
|
630
639
|
}
|
|
631
640
|
|
|
641
|
+
// Context-only messages (user-to-user replies) can't be stored without
|
|
642
|
+
// an existing session. Skip early to avoid creating a runtime or running
|
|
643
|
+
// preprocessing for nothing.
|
|
644
|
+
if (isLeadingMentionToOtherUser && !hasExistingSession) {
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
632
648
|
const parent = thread.parent as TextChannel | null
|
|
633
649
|
let projectDirectory: string | undefined
|
|
634
650
|
if (parent) {
|
|
@@ -713,7 +729,7 @@ export async function startDiscordBot({
|
|
|
713
729
|
projectDirectory && worktreeInfo?.status !== 'pending'
|
|
714
730
|
? extractBtwSuffix(message.content || '')
|
|
715
731
|
: null
|
|
716
|
-
if (btwResult?.forceBtw && projectDirectory) {
|
|
732
|
+
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
717
733
|
const result = await forkSessionToBtwThread({
|
|
718
734
|
sourceThread: thread,
|
|
719
735
|
projectDirectory,
|
|
@@ -775,7 +791,9 @@ export async function startDiscordBot({
|
|
|
775
791
|
})
|
|
776
792
|
|
|
777
793
|
// Cancel interactive UI when a real user sends a message.
|
|
778
|
-
|
|
794
|
+
// Context-only messages (user-to-user replies) should not interrupt
|
|
795
|
+
// the active run or dismiss pending UI.
|
|
796
|
+
if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
|
|
779
797
|
cancelPendingActionButtons(thread.id)
|
|
780
798
|
cancelHtmlActionsForThread(thread.id)
|
|
781
799
|
const dismissedPermission = await cancelPendingPermission(thread.id)
|
|
@@ -812,6 +830,7 @@ export async function startDiscordBot({
|
|
|
812
830
|
model: cliInjectedModel,
|
|
813
831
|
permissions: cliInjectedPermissions,
|
|
814
832
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
833
|
+
noReply: isLeadingMentionToOtherUser || undefined,
|
|
815
834
|
sessionStartSource: sessionStartSource
|
|
816
835
|
? {
|
|
817
836
|
scheduleKind: sessionStartSource.scheduleKind,
|
|
@@ -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
|
import type { Collection, Embed, Message, MessageSnapshot, Poll } from 'discord.js'
|
|
4
4
|
import type { Part } from '@opencode-ai/sdk/v2'
|
|
5
5
|
|
|
@@ -48,6 +48,65 @@ describe('formatPart', () => {
|
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
+
describe('formatBashToolTitle', () => {
|
|
52
|
+
test('short single-line command shown in full', () => {
|
|
53
|
+
expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('multiline command without description truncates to first line', () => {
|
|
57
|
+
expect(
|
|
58
|
+
formatBashToolTitle({ command: 'echo hello\necho world\necho done' }),
|
|
59
|
+
).toMatchInlineSnapshot(`" _echo hello_"`)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('long single-line command is truncated with ellipsis', () => {
|
|
63
|
+
const longCommand = 'a'.repeat(150)
|
|
64
|
+
const result = formatBashToolTitle({ command: longCommand })
|
|
65
|
+
expect(result).toContain('…')
|
|
66
|
+
expect(result.length).toBeLessThan(150)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('description is preferred over truncated command when present', () => {
|
|
70
|
+
expect(
|
|
71
|
+
formatBashToolTitle({
|
|
72
|
+
command: 'echo hello\necho world',
|
|
73
|
+
description: 'Print greeting',
|
|
74
|
+
}),
|
|
75
|
+
).toMatchInlineSnapshot(`" _Print greeting_"`)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('stateTitle used as last resort', () => {
|
|
79
|
+
expect(
|
|
80
|
+
formatBashToolTitle({ command: '', stateTitle: 'Running tests' }),
|
|
81
|
+
).toMatchInlineSnapshot(`" _Running tests_"`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('empty inputs return empty string', () => {
|
|
85
|
+
expect(formatBashToolTitle({ command: '' })).toBe('')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('leading blank line skipped, uses first meaningful line', () => {
|
|
89
|
+
expect(
|
|
90
|
+
formatBashToolTitle({ command: '\npnpm test\npnpm build' }),
|
|
91
|
+
).toMatchInlineSnapshot(`" _pnpm test_"`)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('whitespace-only first line skipped', () => {
|
|
95
|
+
expect(
|
|
96
|
+
formatBashToolTitle({ command: ' \npnpm test' }),
|
|
97
|
+
).toMatchInlineSnapshot(`" _pnpm test_"`)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('no description field (new opencode) with multiline command', () => {
|
|
101
|
+
// This is the exact scenario that was broken: opencode removed `description`
|
|
102
|
+
// from the bash tool schema, so multiline commands rendered as just "┣ bash"
|
|
103
|
+
const command = 'git diff HEAD~1 --stat && git log --oneline -5'
|
|
104
|
+
expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(
|
|
105
|
+
`" _git diff HEAD\\~1 --stat && git log --oneline -5_"`,
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
51
110
|
describe('formatTodoList', () => {
|
|
52
111
|
test('formats active todo with monospace numbers', () => {
|
|
53
112
|
const part: Part = {
|