kimaki 0.28.0 → 0.30.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/agent-model.e2e.test.js +33 -33
- package/dist/anthropic-account-identity.js +1 -1
- package/dist/anthropic-auth-plugin.js +5 -5
- package/dist/anthropic-auth-plugin.test.js +30 -2
- package/dist/anthropic-auth-state.js +1 -1
- package/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/bin.js +7 -3
- package/dist/bot-lifecycle.test.js +206 -0
- package/dist/cache-drift-plugin.js +25 -18
- package/dist/cache-rewrite.js +39 -0
- package/dist/cache-rewrite.test.js +41 -0
- package/dist/channel-management.js +113 -51
- package/dist/cli-commands/misc.js +4 -1
- package/dist/cli-commands/project.js +7 -4
- package/dist/cli-commands/session.js +118 -36
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli-parsing.test.js +3 -3
- package/dist/cli-runner.js +11 -1
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +6 -4
- package/dist/commands/ask-question.js +72 -23
- package/dist/commands/queue.js +84 -36
- package/dist/commands/queue.test.js +15 -0
- package/dist/commands/restart-opencode-server.js +7 -1
- package/dist/commands/session-id.js +5 -5
- package/dist/database.js +149 -6
- package/dist/db.js +34 -0
- package/dist/db.test.js +199 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +220 -146
- package/dist/discord-command-registration.js +1 -1
- package/dist/external-directory-permissions.e2e.test.js +3 -3
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/forum-sync/watchers.js +15 -0
- package/dist/gateway-proxy.e2e.test.js +9 -9
- package/dist/guild-categories.test.js +225 -0
- package/dist/hrana-server.js +68 -14
- package/dist/interaction-handler.js +5 -1
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-opencode-plugin.test.js +28 -0
- package/dist/list-channel-threads.js +163 -0
- package/dist/list-channel-threads.test.js +80 -0
- package/dist/markdown.js +164 -102
- package/dist/markdown.test.js +88 -20
- package/dist/message-formatting.js +29 -18
- package/dist/message-formatting.test.js +111 -3
- package/dist/opencode.js +27 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-resume.e2e.test.js +100 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/question-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +3 -3
- package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
- package/dist/queue-advanced-e2e-setup.js +63 -0
- package/dist/queue-advanced-footer.e2e.test.js +18 -18
- package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +4 -4
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
- package/dist/queue-interrupt-drain.e2e.test.js +10 -10
- package/dist/queue-question-select-drain.e2e.test.js +5 -5
- package/dist/runtime-lifecycle.e2e.test.js +95 -11
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +240 -6
- package/dist/session-handler/event-stream-state.test.js +1116 -130
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +499 -89
- package/dist/sleep.e2e.test.js +7 -7
- package/dist/store.js +1 -1
- package/dist/system-message.js +64 -23
- package/dist/system-message.test.js +69 -23
- package/dist/task-id-plugin.js +16 -0
- package/dist/task-runner.js +7 -0
- package/dist/task-schedule.test.js +1 -1
- package/dist/test-utils.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +30 -31
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-message.e2e.test.js +64 -26
- package/dist/voice-routing-input.e2e.test.js +4 -4
- package/dist/voice-routing-question.e2e.test.js +4 -4
- package/dist/voice.js +107 -21
- package/dist/voice.test.js +52 -1
- package/dist/wait-session.js +24 -2
- package/dist/worktree-lifecycle.e2e.test.js +8 -8
- package/package.json +7 -7
- package/skills/egaki/SKILL.md +14 -0
- package/skills/holocron/SKILL.md +125 -42
- package/skills/opensrc/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +53 -172
- package/skills/strada/SKILL.md +61 -232
- package/src/agent-model.e2e.test.ts +33 -33
- package/src/anthropic-account-identity.ts +1 -1
- package/src/anthropic-auth-plugin.test.ts +37 -2
- package/src/anthropic-auth-plugin.ts +9 -7
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/bin.ts +7 -3
- package/src/bot-lifecycle.test.ts +252 -0
- package/src/cache-drift-plugin.ts +34 -24
- package/src/cache-rewrite.test.ts +49 -0
- package/src/cache-rewrite.ts +96 -0
- package/src/channel-management.ts +144 -65
- package/src/cli-commands/misc.ts +4 -1
- package/src/cli-commands/project.ts +9 -4
- package/src/cli-commands/session.ts +167 -38
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli-parsing.test.ts +3 -3
- package/src/cli-runner.ts +11 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +8 -6
- package/src/commands/ask-question.ts +88 -25
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +111 -45
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +219 -6
- package/src/db.test.ts +236 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +272 -185
- package/src/discord-command-registration.ts +3 -1
- package/src/external-directory-permissions.e2e.test.ts +3 -3
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/forum-sync/watchers.ts +20 -0
- package/src/gateway-proxy.e2e.test.ts +9 -9
- package/src/guild-categories.test.ts +260 -0
- package/src/hrana-server.ts +96 -16
- package/src/interaction-handler.ts +7 -0
- package/src/kimaki-opencode-plugin.test.ts +34 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/list-channel-threads.test.ts +90 -0
- package/src/list-channel-threads.ts +252 -0
- package/src/markdown.test.ts +108 -22
- package/src/markdown.ts +216 -103
- package/src/message-formatting.test.ts +129 -2
- package/src/message-formatting.ts +28 -14
- package/src/opencode.ts +28 -13
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-resume.e2e.test.ts +121 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/question-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +3 -3
- package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
- package/src/queue-advanced-e2e-setup.ts +65 -0
- package/src/queue-advanced-footer.e2e.test.ts +18 -18
- package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +4 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
- package/src/queue-interrupt-drain.e2e.test.ts +10 -10
- package/src/queue-question-select-drain.e2e.test.ts +5 -5
- package/src/runtime-lifecycle.e2e.test.ts +104 -10
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +1184 -133
- package/src/session-handler/event-stream-state.ts +322 -8
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +597 -95
- package/src/sleep.e2e.test.ts +7 -7
- package/src/store.ts +2 -2
- package/src/system-message.test.ts +70 -23
- package/src/system-message.ts +67 -22
- package/src/task-id-plugin.ts +17 -0
- package/src/task-runner.ts +8 -0
- package/src/task-schedule.test.ts +1 -1
- package/src/test-utils.ts +5 -3
- package/src/thread-message-queue.e2e.test.ts +30 -31
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-message.e2e.test.ts +70 -26
- package/src/voice-routing-input.e2e.test.ts +4 -4
- package/src/voice-routing-question.e2e.test.ts +4 -4
- package/src/voice.test.ts +56 -0
- package/src/voice.ts +135 -25
- package/src/wait-session.ts +25 -2
- package/src/worktree-lifecycle.e2e.test.ts +8 -8
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { goke } from 'goke'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { createLogger, LogPrefix } from '../logger.js'
|
|
4
|
+
import { initDatabase } from '../discord-bot.js'
|
|
5
|
+
import { createDiscordRest } from '../discord-urls.js'
|
|
6
|
+
import { EXIT_NO_RESTART, resolveBotCredentials } from '../cli-runner.js'
|
|
7
|
+
import { listDiscordChannelThreads } from '../list-channel-threads.js'
|
|
8
|
+
|
|
9
|
+
const cliLogger = createLogger(LogPrefix.CLI)
|
|
10
|
+
const cli = goke()
|
|
11
|
+
|
|
12
|
+
cli
|
|
13
|
+
.command(
|
|
14
|
+
'thread list',
|
|
15
|
+
'List Discord threads in a channel. Use this to find a thread on another computer, then send with --thread.',
|
|
16
|
+
)
|
|
17
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
18
|
+
.option('--json', 'Output as JSON')
|
|
19
|
+
.option(
|
|
20
|
+
'--limit <n>',
|
|
21
|
+
z.number().default(50).describe('Maximum threads to show'),
|
|
22
|
+
)
|
|
23
|
+
.example('kimaki thread list --channel 123456789012345678')
|
|
24
|
+
.example('kimaki thread list --channel 123456789012345678 --json')
|
|
25
|
+
.action(async (options, { console, process }) => {
|
|
26
|
+
try {
|
|
27
|
+
if (!options.channel) {
|
|
28
|
+
cliLogger.error(
|
|
29
|
+
'Channel ID is required. Use --channel <channelId>. Find remote channels with `kimaki project list --all --json`.',
|
|
30
|
+
)
|
|
31
|
+
process.exit(EXIT_NO_RESTART)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await initDatabase()
|
|
35
|
+
const { token: botToken } = await resolveBotCredentials({})
|
|
36
|
+
const rest = createDiscordRest(botToken)
|
|
37
|
+
const listed = await listDiscordChannelThreads({
|
|
38
|
+
rest,
|
|
39
|
+
channelId: options.channel,
|
|
40
|
+
})
|
|
41
|
+
if (!listed.ok) {
|
|
42
|
+
cliLogger.error(listed.error.message)
|
|
43
|
+
process.exit(EXIT_NO_RESTART)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const limited = listed.threads.slice(0, options.limit)
|
|
48
|
+
if (options.json) {
|
|
49
|
+
console.log(
|
|
50
|
+
JSON.stringify(
|
|
51
|
+
limited.map((thread) => ({
|
|
52
|
+
thread_id: thread.id,
|
|
53
|
+
name: thread.name,
|
|
54
|
+
channel_id: thread.parentId,
|
|
55
|
+
guild_id: thread.guildId,
|
|
56
|
+
archived: thread.archived,
|
|
57
|
+
archive_state: thread.archiveState,
|
|
58
|
+
last_message_id: thread.lastMessageId,
|
|
59
|
+
})),
|
|
60
|
+
null,
|
|
61
|
+
2,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (limited.length === 0) {
|
|
68
|
+
console.log(`No threads found in channel ${options.channel}`)
|
|
69
|
+
process.exit(0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const thread of limited) {
|
|
73
|
+
const archivedTag = thread.archived ? ' [archived]' : ''
|
|
74
|
+
console.log(`${thread.id} | ${thread.name}${archivedTag}`)
|
|
75
|
+
}
|
|
76
|
+
process.exit(0)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
cliLogger.error(
|
|
79
|
+
'Error:',
|
|
80
|
+
error instanceof Error ? error.stack : String(error),
|
|
81
|
+
)
|
|
82
|
+
process.exit(EXIT_NO_RESTART)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export default cli
|
package/src/cli-parsing.test.ts
CHANGED
|
@@ -58,7 +58,7 @@ async function parseRootBotOptions(argv: string[]) {
|
|
|
58
58
|
const script = [
|
|
59
59
|
"import { goke } from 'goke'",
|
|
60
60
|
'const cli = goke(\'kimaki\')',
|
|
61
|
-
"cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--
|
|
61
|
+
"cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--enable-footer-mentions', 'Mention the thread creator in final footers')",
|
|
62
62
|
`const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
|
|
63
63
|
'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
|
|
64
64
|
].join(';')
|
|
@@ -291,14 +291,14 @@ describe('goke CLI ID parsing', () => {
|
|
|
291
291
|
'node',
|
|
292
292
|
'kimaki',
|
|
293
293
|
'--no-analytics',
|
|
294
|
-
'--
|
|
294
|
+
'--enable-footer-mentions',
|
|
295
295
|
])
|
|
296
296
|
|
|
297
297
|
expect(result.options).toMatchInlineSnapshot(`
|
|
298
298
|
{
|
|
299
299
|
"--": [],
|
|
300
|
+
"enableFooterMentions": true,
|
|
300
301
|
"noAnalytics": true,
|
|
301
|
-
"skipFooterMentions": true,
|
|
302
302
|
}
|
|
303
303
|
`)
|
|
304
304
|
})
|
package/src/cli-runner.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
initDatabase,
|
|
29
29
|
getChannelDirectory,
|
|
30
30
|
startDiscordBot,
|
|
31
|
+
registerBotLifecycleHandlers,
|
|
31
32
|
initializeOpencodeForDirectory,
|
|
32
33
|
assertCompatibleOpencodeVersion,
|
|
33
34
|
createProjectChannels,
|
|
@@ -1638,6 +1639,9 @@ export async function run({
|
|
|
1638
1639
|
cliLogger.error('Failed to start hrana server:', hranaResult.message)
|
|
1639
1640
|
process.exit(EXIT_NO_RESTART)
|
|
1640
1641
|
}
|
|
1642
|
+
// We own the lock port from here on. Stop signals must release it in every
|
|
1643
|
+
// startup phase (Discord login, channel reconcile), not only after ready.
|
|
1644
|
+
registerBotLifecycleHandlers()
|
|
1641
1645
|
|
|
1642
1646
|
// Initialize database (connects to hrana server via HTTP)
|
|
1643
1647
|
await initDatabase()
|
|
@@ -1803,6 +1807,10 @@ export async function run({
|
|
|
1803
1807
|
}
|
|
1804
1808
|
process.exit(EXIT_NO_RESTART)
|
|
1805
1809
|
}
|
|
1810
|
+
// Stop signal during Discord login: shutdown owns the process now.
|
|
1811
|
+
if (global.shuttingDown) {
|
|
1812
|
+
return
|
|
1813
|
+
}
|
|
1806
1814
|
await setBotToken(appId, token)
|
|
1807
1815
|
|
|
1808
1816
|
// In gateway mode the bot only sees guilds the user has installed
|
|
@@ -1842,6 +1850,9 @@ export async function run({
|
|
|
1842
1850
|
// Start bot immediately — channel sync happens in the background.
|
|
1843
1851
|
cliLogger.log('Starting Discord bot...')
|
|
1844
1852
|
await startDiscordBot({ token, appId, discordClient, useWorktrees })
|
|
1853
|
+
if (global.shuttingDown) {
|
|
1854
|
+
return
|
|
1855
|
+
}
|
|
1845
1856
|
cliLogger.log('Discord bot is running!')
|
|
1846
1857
|
|
|
1847
1858
|
// Background channel sync + role reconciliation + default channel creation.
|
|
@@ -410,9 +410,9 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
410
410
|
"--- from: user (cli-send-tester)
|
|
411
411
|
thread empty content seed
|
|
412
412
|
--- from: assistant (TestBot)
|
|
413
|
-
|
|
413
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
414
414
|
> caught-by-model
|
|
415
|
-
|
|
415
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
416
416
|
--- from: user (cli-send-tester)
|
|
417
417
|
--- from: assistant (TestBot)
|
|
418
418
|
I can see you sent a message, but Discord did not include its text.
|
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,7 @@ import projectCommands from './cli-commands/project.js'
|
|
|
25
25
|
import sendCommands from './cli-commands/send.js'
|
|
26
26
|
import sessionCommands from './cli-commands/session.js'
|
|
27
27
|
import taskCommands from './cli-commands/task.js'
|
|
28
|
+
import threadCommands from './cli-commands/thread.js'
|
|
28
29
|
import userCommands from './cli-commands/user.js'
|
|
29
30
|
import {
|
|
30
31
|
EXIT_NO_RESTART,
|
|
@@ -75,8 +76,8 @@ cli
|
|
|
75
76
|
'Disable automatic diff upload to critique.work in system prompts',
|
|
76
77
|
)
|
|
77
78
|
.option(
|
|
78
|
-
'--
|
|
79
|
-
'
|
|
79
|
+
'--enable-footer-mentions',
|
|
80
|
+
'Mention the thread creator in final session footers so they get a notification',
|
|
80
81
|
)
|
|
81
82
|
.option(
|
|
82
83
|
'--auto-restart',
|
|
@@ -158,7 +159,7 @@ cli
|
|
|
158
159
|
verbosity?: string
|
|
159
160
|
mentionMode?: boolean
|
|
160
161
|
noCritique?: boolean
|
|
161
|
-
|
|
162
|
+
enableFooterMentions?: boolean
|
|
162
163
|
allowAllUsers?: boolean
|
|
163
164
|
restrictDirectories?: boolean
|
|
164
165
|
permissionTimeoutMinutes?: string
|
|
@@ -314,7 +315,7 @@ cli
|
|
|
314
315
|
}),
|
|
315
316
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
316
317
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
317
|
-
...(options.
|
|
318
|
+
...(options.enableFooterMentions && { footerMentionsEnabled: true }),
|
|
318
319
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
319
320
|
...(options.restrictDirectories && { restrictExternalDirectories: true }),
|
|
320
321
|
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
@@ -365,9 +366,9 @@ cli
|
|
|
365
366
|
'Critique disabled: diffs will not be auto-uploaded to critique.work',
|
|
366
367
|
)
|
|
367
368
|
}
|
|
368
|
-
if (options.
|
|
369
|
+
if (options.enableFooterMentions) {
|
|
369
370
|
cliLogger.log(
|
|
370
|
-
'Footer mentions
|
|
371
|
+
'Footer mentions enabled: final session footers will mention thread creators',
|
|
371
372
|
)
|
|
372
373
|
}
|
|
373
374
|
if (options.noAutoUpgrade) {
|
|
@@ -423,6 +424,7 @@ cli.use(miscCommands)
|
|
|
423
424
|
cli.use(sendCommands)
|
|
424
425
|
cli.use(taskCommands)
|
|
425
426
|
cli.use(projectCommands)
|
|
427
|
+
cli.use(threadCommands)
|
|
426
428
|
cli.use(userCommands)
|
|
427
429
|
cli.use(sessionCommands)
|
|
428
430
|
cli.use(maintenanceCommands)
|
|
@@ -15,6 +15,7 @@ import { getOpencodeClient } from '../opencode.js'
|
|
|
15
15
|
import { DiscordOperationError } from '../errors.js'
|
|
16
16
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
17
17
|
import { QUEUE_PREFIX } from '../message-formatting.js'
|
|
18
|
+
import { getRuntime } from '../session-handler/thread-session-runtime.js'
|
|
18
19
|
|
|
19
20
|
const logger = createLogger(LogPrefix.ASK_QUESTION)
|
|
20
21
|
|
|
@@ -298,51 +299,113 @@ export async function handleAskQuestionSelectMenu(
|
|
|
298
299
|
`${QUEUE_PREFIX}**${username}:** ${answeredText}`,
|
|
299
300
|
)
|
|
300
301
|
|
|
301
|
-
// Check if all questions are answered
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
302
|
+
// Check if all questions are answered. Claim the context synchronously
|
|
303
|
+
// (delete before awaiting submit) so two concurrent final selections for a
|
|
304
|
+
// multi-question tool cannot both submit and double-resume the session.
|
|
305
|
+
if (
|
|
306
|
+
areAllQuestionsAnswered(context)
|
|
307
|
+
&& pendingQuestionContexts.get(contextHash) === context
|
|
308
|
+
) {
|
|
305
309
|
deletePendingQuestionContextsForRequest({
|
|
306
310
|
threadId: context.thread.id,
|
|
307
311
|
requestId: context.requestId,
|
|
308
312
|
})
|
|
313
|
+
await submitQuestionAnswers(context)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Format collected answers as a plain-text summary the model can read when
|
|
319
|
+
* the run is resumed after an abort (e.g. `"Which option?"="Alpha"`).
|
|
320
|
+
*
|
|
321
|
+
* Known limitation: if the user had queued items via /queue during the pending
|
|
322
|
+
* question AND aborted the run from another opencode client, the first queued
|
|
323
|
+
* item may have already been handed off to opencode and can lose its ordering
|
|
324
|
+
* on resume. That rare combination needs a queue-handoff redesign; the common
|
|
325
|
+
* (no-queue) abort case is handled correctly.
|
|
326
|
+
*/
|
|
327
|
+
function formatQuestionAnswersText(context: PendingQuestionContext): string {
|
|
328
|
+
const parts = context.questions.map((q, i) => {
|
|
329
|
+
const answer = (context.answers[i] || []).join(', ') || 'Unanswered'
|
|
330
|
+
return `"${q.question}"="${answer}"`
|
|
331
|
+
})
|
|
332
|
+
return `Answers to your previous questions: ${parts.join(', ')}`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Resume the session by feeding the answers back as a new user prompt. */
|
|
336
|
+
async function resumeSessionWithAnswers(
|
|
337
|
+
context: PendingQuestionContext,
|
|
338
|
+
): Promise<void> {
|
|
339
|
+
const runtime = getRuntime(context.thread.id)
|
|
340
|
+
const resumed = await runtime?.resumeWithText({
|
|
341
|
+
text: formatQuestionAnswersText(context),
|
|
342
|
+
})
|
|
343
|
+
if (!resumed) {
|
|
344
|
+
await sendThreadMessage(
|
|
345
|
+
context.thread,
|
|
346
|
+
'✗ Failed to submit answers: session is no longer active',
|
|
347
|
+
)
|
|
309
348
|
}
|
|
310
349
|
}
|
|
311
350
|
|
|
312
351
|
/**
|
|
313
352
|
* Submit all collected answers back to the OpenCode session.
|
|
314
|
-
*
|
|
353
|
+
*
|
|
354
|
+
* The decision is based on whether the session still has a live run:
|
|
355
|
+
*
|
|
356
|
+
* - Busy: a live run is parked on the question. Reply so it continues.
|
|
357
|
+
* - Idle: the run was aborted (from this or another opencode client). Replying
|
|
358
|
+
* would resolve a dead run and the session would never continue, so resume it
|
|
359
|
+
* with the answers as a fresh prompt instead.
|
|
360
|
+
*
|
|
361
|
+
* Note: `question.list` is not a reliable signal here. After an abort the
|
|
362
|
+
* question request can stay in the pending list but orphaned (no run awaiting
|
|
363
|
+
* it), so a reply would succeed yet the session would still not continue. The
|
|
364
|
+
* session busy state, derived from the event stream, is the accurate signal.
|
|
365
|
+
* The reply `.error` check is a safety net for the case where the request was
|
|
366
|
+
* actually removed while the session still looked busy.
|
|
315
367
|
*/
|
|
316
368
|
async function submitQuestionAnswers(
|
|
317
369
|
context: PendingQuestionContext,
|
|
318
370
|
): Promise<void> {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
371
|
+
const client = getOpencodeClient(context.directory)
|
|
372
|
+
if (!client) {
|
|
373
|
+
logger.error('OpenCode server not found for directory')
|
|
374
|
+
return
|
|
375
|
+
}
|
|
324
376
|
|
|
325
|
-
|
|
326
|
-
const answers = context.questions.map((_, i) => {
|
|
327
|
-
return context.answers[i] || []
|
|
328
|
-
})
|
|
377
|
+
const runtime = getRuntime(context.thread.id)
|
|
329
378
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
379
|
+
if (runtime && !runtime.isBusy()) {
|
|
380
|
+
logger.log(
|
|
381
|
+
`Session ${context.sessionId} idle; resuming with answers for question ${context.requestId}`,
|
|
382
|
+
)
|
|
383
|
+
await resumeSessionWithAnswers(context)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const answers = context.questions.map((_, i) => {
|
|
388
|
+
return context.answers[i] || []
|
|
389
|
+
})
|
|
335
390
|
|
|
391
|
+
// throwOnError is off on this client, so failures come back in `.error`.
|
|
392
|
+
const replyResult = await client.question
|
|
393
|
+
.reply({ requestID: context.requestId, directory: context.directory, answers })
|
|
394
|
+
.catch((error) => ({ error }))
|
|
395
|
+
|
|
396
|
+
if (!replyResult.error) {
|
|
336
397
|
logger.log(
|
|
337
398
|
`Submitted answers for question ${context.requestId} in session ${context.sessionId}`,
|
|
338
399
|
)
|
|
339
|
-
|
|
340
|
-
logger.error('Failed to submit answers:', error)
|
|
341
|
-
await sendThreadMessage(
|
|
342
|
-
context.thread,
|
|
343
|
-
`✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
344
|
-
)
|
|
400
|
+
return
|
|
345
401
|
}
|
|
402
|
+
|
|
403
|
+
// Reply failed (the request was removed while the session still looked busy).
|
|
404
|
+
// Resume so the answer still reaches the model.
|
|
405
|
+
logger.log(
|
|
406
|
+
`Reply failed for question ${context.requestId}; resuming session ${context.sessionId} with answers`,
|
|
407
|
+
)
|
|
408
|
+
await resumeSessionWithAnswers(context)
|
|
346
409
|
}
|
|
347
410
|
|
|
348
411
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildQueueRemoveCustomId,
|
|
4
|
+
parseQueueRemoveCustomId,
|
|
5
|
+
} from './queue.js'
|
|
6
|
+
|
|
7
|
+
describe('queue remove custom id', () => {
|
|
8
|
+
test('round-trips thread and queue ids under Discord custom_id limit', () => {
|
|
9
|
+
const customId = buildQueueRemoveCustomId({
|
|
10
|
+
threadId: '1550106184055398531',
|
|
11
|
+
queueId: 'a1b2c3d4e5f60789',
|
|
12
|
+
})
|
|
13
|
+
expect(customId.length).toBeLessThanOrEqual(100)
|
|
14
|
+
expect(parseQueueRemoveCustomId(customId)).toEqual({
|
|
15
|
+
threadId: '1550106184055398531',
|
|
16
|
+
queueId: 'a1b2c3d4e5f60789',
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
})
|
package/src/commands/queue.ts
CHANGED
|
@@ -7,10 +7,11 @@ import {
|
|
|
7
7
|
ButtonStyle,
|
|
8
8
|
ChannelType,
|
|
9
9
|
MessageFlags,
|
|
10
|
+
type ButtonInteraction,
|
|
10
11
|
type ThreadChannel,
|
|
11
12
|
} from 'discord.js'
|
|
12
13
|
import type { AutocompleteContext, CommandContext } from './types.js'
|
|
13
|
-
import { getThreadSession } from '../database.js'
|
|
14
|
+
import { deleteThreadQueueItem, getThreadSession } from '../database.js'
|
|
14
15
|
import {
|
|
15
16
|
resolveWorkingDirectory,
|
|
16
17
|
SILENT_MESSAGE_FLAGS,
|
|
@@ -19,61 +20,128 @@ import {
|
|
|
19
20
|
getOrCreateRuntime,
|
|
20
21
|
getRuntime,
|
|
21
22
|
} from '../session-handler/thread-session-runtime.js'
|
|
22
|
-
import {
|
|
23
|
-
buildHtmlActionCustomId,
|
|
24
|
-
registerHtmlAction,
|
|
25
|
-
} from '../html-actions.js'
|
|
26
23
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
27
|
-
import { QUEUE_PREFIX } from '../message-formatting.js'
|
|
24
|
+
import { asSystemLine, QUEUE_PREFIX } from '../message-formatting.js'
|
|
28
25
|
import { store } from '../store.js'
|
|
29
26
|
|
|
30
27
|
const logger = createLogger(LogPrefix.QUEUE)
|
|
31
28
|
|
|
29
|
+
export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:'
|
|
30
|
+
|
|
31
|
+
async function deleteThreadQueueItemWithoutRuntime(queueId: string) {
|
|
32
|
+
const row = await deleteThreadQueueItem(queueId).catch((error) => {
|
|
33
|
+
logger.error(
|
|
34
|
+
`[QUEUE] Failed to delete persisted queue item ${queueId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
35
|
+
)
|
|
36
|
+
return undefined
|
|
37
|
+
})
|
|
38
|
+
if (!row) {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(row.payload_json) as {
|
|
43
|
+
prompt?: string
|
|
44
|
+
username?: string
|
|
45
|
+
command?: { name: string }
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
|
|
49
|
+
username: typeof parsed.username === 'string' ? parsed.username : '',
|
|
50
|
+
command: parsed.command,
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return { prompt: '', username: '' }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildQueueRemoveCustomId({
|
|
58
|
+
threadId,
|
|
59
|
+
queueId,
|
|
60
|
+
}: {
|
|
61
|
+
threadId: string
|
|
62
|
+
queueId: string
|
|
63
|
+
}): string {
|
|
64
|
+
return `${QUEUE_REMOVE_CUSTOM_ID_PREFIX}${threadId}:${queueId}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseQueueRemoveCustomId(customId: string): {
|
|
68
|
+
threadId: string
|
|
69
|
+
queueId: string
|
|
70
|
+
} | undefined {
|
|
71
|
+
if (!customId.startsWith(QUEUE_REMOVE_CUSTOM_ID_PREFIX)) {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
const rest = customId.slice(QUEUE_REMOVE_CUSTOM_ID_PREFIX.length)
|
|
75
|
+
const separator = rest.indexOf(':')
|
|
76
|
+
if (separator <= 0 || separator >= rest.length - 1) {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
threadId: rest.slice(0, separator),
|
|
81
|
+
queueId: rest.slice(separator + 1),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
32
85
|
function buildQueueRemoveRow({
|
|
33
86
|
threadId,
|
|
34
87
|
queueId,
|
|
35
|
-
position,
|
|
36
88
|
}: {
|
|
37
89
|
threadId: string
|
|
38
90
|
queueId: string
|
|
39
|
-
position?: number
|
|
40
91
|
}) {
|
|
41
|
-
const actionId = registerHtmlAction({
|
|
42
|
-
ownerKey: `queue-remove:${threadId}:${queueId}`,
|
|
43
|
-
threadId,
|
|
44
|
-
run: async ({ interaction }) => {
|
|
45
|
-
const removed = getRuntime(threadId)?.removeQueueItemById(queueId)
|
|
46
|
-
if (!removed) {
|
|
47
|
-
await interaction.editReply({
|
|
48
|
-
content: position
|
|
49
|
-
? `Queued message at position ${position} is no longer in the queue`
|
|
50
|
-
: 'Queued message is no longer in the queue',
|
|
51
|
-
components: [],
|
|
52
|
-
})
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const label = removed.command
|
|
57
|
-
? `/${removed.command.name}`
|
|
58
|
-
: removed.prompt.slice(0, 120)
|
|
59
|
-
await interaction.editReply({
|
|
60
|
-
content: `Removed queued message${position ? ` (was position ${position})` : ''}: ${label}`,
|
|
61
|
-
components: [],
|
|
62
|
-
})
|
|
63
|
-
logger.log(
|
|
64
|
-
`[QUEUE] User ${interaction.user.displayName} removed queued item ${queueId} in thread ${threadId}`,
|
|
65
|
-
)
|
|
66
|
-
},
|
|
67
|
-
})
|
|
68
|
-
|
|
69
92
|
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
70
93
|
new ButtonBuilder()
|
|
71
|
-
.setCustomId(
|
|
94
|
+
.setCustomId(buildQueueRemoveCustomId({ threadId, queueId }))
|
|
72
95
|
.setLabel('Remove from queue')
|
|
73
96
|
.setStyle(ButtonStyle.Secondary),
|
|
74
97
|
)
|
|
75
98
|
}
|
|
76
99
|
|
|
100
|
+
export async function handleQueueRemoveButton(
|
|
101
|
+
interaction: ButtonInteraction,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const parsed = parseQueueRemoveCustomId(interaction.customId)
|
|
104
|
+
if (!parsed) {
|
|
105
|
+
await interaction.reply({
|
|
106
|
+
content: 'Invalid queue remove button',
|
|
107
|
+
flags: MessageFlags.Ephemeral,
|
|
108
|
+
})
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (interaction.channelId && interaction.channelId !== parsed.threadId) {
|
|
113
|
+
await interaction.reply({
|
|
114
|
+
content: 'This queue button does not belong to this thread',
|
|
115
|
+
flags: MessageFlags.Ephemeral,
|
|
116
|
+
})
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await interaction.deferUpdate()
|
|
121
|
+
const runtime = getRuntime(parsed.threadId)
|
|
122
|
+
const removed = runtime
|
|
123
|
+
? await runtime.removeQueueItemById(parsed.queueId)
|
|
124
|
+
: await deleteThreadQueueItemWithoutRuntime(parsed.queueId)
|
|
125
|
+
if (!removed) {
|
|
126
|
+
await interaction.editReply({
|
|
127
|
+
content: asSystemLine('Queued message is no longer in the queue'),
|
|
128
|
+
components: [],
|
|
129
|
+
})
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const label = removed.command
|
|
134
|
+
? `/${removed.command.name}`
|
|
135
|
+
: removed.prompt.slice(0, 120)
|
|
136
|
+
await interaction.editReply({
|
|
137
|
+
content: asSystemLine(`Removed queued message: ${label}`),
|
|
138
|
+
components: [],
|
|
139
|
+
})
|
|
140
|
+
logger.log(
|
|
141
|
+
`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
77
145
|
export async function handleQueueCommand({
|
|
78
146
|
command,
|
|
79
147
|
appId,
|
|
@@ -143,14 +211,13 @@ export async function handleQueueCommand({
|
|
|
143
211
|
})
|
|
144
212
|
|
|
145
213
|
if (enqueueResult.queued && enqueueResult.queueId) {
|
|
146
|
-
const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`
|
|
214
|
+
const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
|
|
147
215
|
await command.reply({
|
|
148
216
|
content: responseText,
|
|
149
217
|
components: [
|
|
150
218
|
buildQueueRemoveRow({
|
|
151
219
|
threadId: thread.id,
|
|
152
220
|
queueId: enqueueResult.queueId,
|
|
153
|
-
position: enqueueResult.position,
|
|
154
221
|
}),
|
|
155
222
|
],
|
|
156
223
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -205,7 +272,7 @@ export async function handleClearQueueCommand({
|
|
|
205
272
|
}
|
|
206
273
|
|
|
207
274
|
if (position !== undefined) {
|
|
208
|
-
const removed = runtime?.removeQueuePosition(position)
|
|
275
|
+
const removed = await runtime?.removeQueuePosition(position)
|
|
209
276
|
if (!removed) {
|
|
210
277
|
await command.reply({
|
|
211
278
|
content: `No queued message at position ${position}`,
|
|
@@ -215,7 +282,7 @@ export async function handleClearQueueCommand({
|
|
|
215
282
|
}
|
|
216
283
|
|
|
217
284
|
await command.reply({
|
|
218
|
-
content: `Cleared queued message at position ${position}
|
|
285
|
+
content: asSystemLine(`Cleared queued message at position ${position}`),
|
|
219
286
|
flags: SILENT_MESSAGE_FLAGS,
|
|
220
287
|
})
|
|
221
288
|
|
|
@@ -225,7 +292,7 @@ export async function handleClearQueueCommand({
|
|
|
225
292
|
return
|
|
226
293
|
}
|
|
227
294
|
|
|
228
|
-
const cleared = runtime?.clearQueue() ?? []
|
|
295
|
+
const cleared = await runtime?.clearQueue() ?? []
|
|
229
296
|
|
|
230
297
|
const lines = cleared.map((item, i) => {
|
|
231
298
|
const label = item.command
|
|
@@ -239,7 +306,7 @@ export async function handleClearQueueCommand({
|
|
|
239
306
|
}
|
|
240
307
|
|
|
241
308
|
await command.reply({
|
|
242
|
-
content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}
|
|
309
|
+
content: asSystemLine(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
|
|
243
310
|
flags: SILENT_MESSAGE_FLAGS,
|
|
244
311
|
})
|
|
245
312
|
|
|
@@ -335,14 +402,13 @@ export async function handleQueueCommandCommand({
|
|
|
335
402
|
})
|
|
336
403
|
|
|
337
404
|
if (enqueueResult.queued && enqueueResult.queueId) {
|
|
338
|
-
const responseText = `Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`
|
|
405
|
+
const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
|
|
339
406
|
await command.reply({
|
|
340
407
|
content: responseText,
|
|
341
408
|
components: [
|
|
342
409
|
buildQueueRemoveRow({
|
|
343
410
|
threadId: thread.id,
|
|
344
411
|
queueId: enqueueResult.queueId,
|
|
345
|
-
position: enqueueResult.position,
|
|
346
412
|
}),
|
|
347
413
|
],
|
|
348
414
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
SILENT_MESSAGE_FLAGS,
|
|
21
21
|
} from '../discord-utils.js'
|
|
22
22
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
disposeRuntimesForDirectory,
|
|
25
|
+
restorePersistedLocalQueues,
|
|
26
|
+
} from '../session-handler/thread-session-runtime.js'
|
|
24
27
|
import { registerCommands, type AgentInfo } from '../discord-command-registration.js'
|
|
25
28
|
|
|
26
29
|
const logger = createLogger(LogPrefix.OPENCODE)
|
|
@@ -104,6 +107,14 @@ export async function handleRestartOpencodeServerCommand({
|
|
|
104
107
|
content: `Opencode server **restarted** successfully${abortMsg}. Re-registering slash commands...`,
|
|
105
108
|
})
|
|
106
109
|
logger.log('[RESTART] Shared opencode server restarted')
|
|
110
|
+
await restorePersistedLocalQueues({
|
|
111
|
+
discordClient: command.client,
|
|
112
|
+
appId,
|
|
113
|
+
}).catch((error) => {
|
|
114
|
+
logger.warn(
|
|
115
|
+
`[RESTART] Failed to restore persisted queues: ${error instanceof Error ? error.message : String(error)}`,
|
|
116
|
+
)
|
|
117
|
+
})
|
|
107
118
|
|
|
108
119
|
// Re-register Discord slash commands after restart so new/changed
|
|
109
120
|
// commands, agents, and plugins are picked up immediately.
|