@otto-assistant/bridge 0.4.102 → 0.4.103
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 +1 -0
- package/dist/anthropic-auth-plugin.js +22 -1
- package/dist/anthropic-auth-state.js +31 -0
- package/dist/btw-prefix-detection.js +17 -0
- package/dist/btw-prefix-detection.test.js +63 -0
- package/dist/cli.js +101 -15
- package/dist/commands/agent.js +21 -2
- package/dist/commands/ask-question.js +50 -4
- package/dist/commands/ask-question.test.js +92 -0
- package/dist/commands/btw.js +71 -66
- package/dist/commands/new-worktree.js +92 -35
- package/dist/commands/queue.js +17 -0
- package/dist/commands/worktrees.js +196 -139
- package/dist/context-awareness-plugin.js +16 -8
- package/dist/context-awareness-plugin.test.js +4 -2
- package/dist/discord-bot.js +35 -2
- package/dist/discord-command-registration.js +9 -2
- package/dist/memory-overview-plugin.js +3 -1
- package/dist/opencode.js +9 -0
- package/dist/queue-question-select-drain.e2e.test.js +135 -10
- package/dist/session-handler/thread-runtime-state.js +27 -0
- package/dist/session-handler/thread-session-runtime.js +58 -28
- package/dist/session-title-rename.test.js +12 -0
- package/dist/skill-filter.js +31 -0
- package/dist/skill-filter.test.js +65 -0
- package/dist/store.js +2 -0
- package/dist/system-message.js +12 -3
- package/dist/system-message.test.js +10 -6
- package/dist/thread-message-queue.e2e.test.js +109 -0
- package/dist/worktree-lifecycle.e2e.test.js +4 -1
- package/dist/worktrees.js +106 -12
- package/dist/worktrees.test.js +232 -6
- package/package.json +2 -2
- package/skills/goke/SKILL.md +13 -619
- package/skills/new-skill/SKILL.md +34 -10
- package/skills/npm-package/SKILL.md +336 -2
- package/skills/profano/SKILL.md +24 -0
- package/skills/zele/SKILL.md +50 -21
- package/src/agent-model.e2e.test.ts +1 -0
- package/src/anthropic-auth-plugin.ts +24 -4
- package/src/anthropic-auth-state.ts +45 -0
- package/src/btw-prefix-detection.test.ts +73 -0
- package/src/btw-prefix-detection.ts +23 -0
- package/src/cli.ts +138 -46
- package/src/commands/agent.ts +24 -2
- package/src/commands/ask-question.test.ts +111 -0
- package/src/commands/ask-question.ts +69 -4
- package/src/commands/btw.ts +105 -85
- package/src/commands/new-worktree.ts +107 -40
- package/src/commands/queue.ts +22 -0
- package/src/commands/worktrees.ts +246 -154
- package/src/context-awareness-plugin.test.ts +4 -2
- package/src/context-awareness-plugin.ts +16 -8
- package/src/discord-bot.ts +40 -2
- package/src/discord-command-registration.ts +12 -2
- package/src/memory-overview-plugin.ts +3 -1
- package/src/opencode.ts +9 -0
- package/src/queue-question-select-drain.e2e.test.ts +174 -10
- package/src/session-handler/thread-runtime-state.ts +36 -1
- package/src/session-handler/thread-session-runtime.ts +72 -32
- package/src/session-title-rename.test.ts +18 -0
- package/src/skill-filter.test.ts +83 -0
- package/src/skill-filter.ts +42 -0
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +10 -6
- package/src/system-message.ts +12 -3
- package/src/thread-message-queue.e2e.test.ts +126 -0
- package/src/worktree-lifecycle.e2e.test.ts +6 -1
- package/src/worktrees.test.ts +274 -9
- package/src/worktrees.ts +144 -23
package/src/discord-bot.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import {
|
|
20
20
|
stopOpencodeServer,
|
|
21
21
|
} from './opencode.js'
|
|
22
|
-
import {
|
|
22
|
+
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js'
|
|
23
23
|
import { validateWorktreeDirectory, git } from './worktrees.js'
|
|
24
24
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
|
|
25
25
|
import {
|
|
@@ -43,7 +43,9 @@ import {
|
|
|
43
43
|
getTextAttachments,
|
|
44
44
|
resolveMentions,
|
|
45
45
|
} from './message-formatting.js'
|
|
46
|
+
import { extractBtwPrefix } from './btw-prefix-detection.js'
|
|
46
47
|
import { isVoiceAttachment } from './voice-attachment.js'
|
|
48
|
+
import { forkSessionToBtwThread } from './commands/btw.js'
|
|
47
49
|
import {
|
|
48
50
|
preprocessExistingThreadMessage,
|
|
49
51
|
preprocessNewThreadMessage,
|
|
@@ -620,6 +622,40 @@ export async function startDiscordBot({
|
|
|
620
622
|
}
|
|
621
623
|
}
|
|
622
624
|
|
|
625
|
+
// Raw `btw ` mirrors /btw for fast side-question forks from Discord.
|
|
626
|
+
// Keep this at ingress instead of preprocess because it must create a
|
|
627
|
+
// new thread/runtime, not just transform the current prompt.
|
|
628
|
+
// Voice-transcribed `btw` still goes through normal preprocessing.
|
|
629
|
+
const btwShortcut =
|
|
630
|
+
projectDirectory && worktreeInfo?.status !== 'pending'
|
|
631
|
+
? extractBtwPrefix(message.content || '')
|
|
632
|
+
: null
|
|
633
|
+
if (btwShortcut && projectDirectory) {
|
|
634
|
+
const result = await forkSessionToBtwThread({
|
|
635
|
+
sourceThread: thread,
|
|
636
|
+
projectDirectory,
|
|
637
|
+
prompt: btwShortcut.prompt,
|
|
638
|
+
userId: message.author.id,
|
|
639
|
+
username:
|
|
640
|
+
message.member?.displayName || message.author.displayName,
|
|
641
|
+
appId: currentAppId,
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
if (result instanceof Error) {
|
|
645
|
+
await message.reply({
|
|
646
|
+
content: result.message,
|
|
647
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
648
|
+
})
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
await message.reply({
|
|
653
|
+
content: `Session forked! Continue in ${result.thread.toString()}`,
|
|
654
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
655
|
+
})
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
623
659
|
const hasVoiceAttachment = message.attachments.some((attachment) => {
|
|
624
660
|
return isVoiceAttachment(attachment)
|
|
625
661
|
})
|
|
@@ -817,7 +853,9 @@ export async function startDiscordBot({
|
|
|
817
853
|
// and the first message's preprocess callback awaits it before resolving.
|
|
818
854
|
let worktreePromise: Promise<string | Error> | undefined
|
|
819
855
|
if (shouldUseWorktrees) {
|
|
820
|
-
|
|
856
|
+
// Auto-derived from thread name -- compress long slugs so the
|
|
857
|
+
// folder path stays short and the agent doesn't reuse old worktrees.
|
|
858
|
+
const worktreeName = formatAutoWorktreeName(
|
|
821
859
|
hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50),
|
|
822
860
|
)
|
|
823
861
|
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
|
|
@@ -182,7 +182,7 @@ export async function registerCommands({
|
|
|
182
182
|
new SlashCommandBuilder()
|
|
183
183
|
.setName('new-worktree')
|
|
184
184
|
.setDescription(
|
|
185
|
-
truncateCommandDescription('Create a git worktree
|
|
185
|
+
truncateCommandDescription('Create a git worktree from the current HEAD by default. Optionally pick a base branch.'),
|
|
186
186
|
)
|
|
187
187
|
.addStringOption((option) => {
|
|
188
188
|
option
|
|
@@ -198,7 +198,7 @@ export async function registerCommands({
|
|
|
198
198
|
option
|
|
199
199
|
.setName('base-branch')
|
|
200
200
|
.setDescription(
|
|
201
|
-
truncateCommandDescription('Branch to create the worktree from (default: HEAD)'),
|
|
201
|
+
truncateCommandDescription('Branch to create the worktree from (default: current HEAD)'),
|
|
202
202
|
)
|
|
203
203
|
.setRequired(false)
|
|
204
204
|
.setAutocomplete(true)
|
|
@@ -384,6 +384,16 @@ export async function registerCommands({
|
|
|
384
384
|
new SlashCommandBuilder()
|
|
385
385
|
.setName('clear-queue')
|
|
386
386
|
.setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
|
|
387
|
+
.addIntegerOption((option) => {
|
|
388
|
+
option
|
|
389
|
+
.setName('position')
|
|
390
|
+
.setDescription(
|
|
391
|
+
truncateCommandDescription('1-based queued message position to clear (default: all)'),
|
|
392
|
+
)
|
|
393
|
+
.setMinValue(1)
|
|
394
|
+
|
|
395
|
+
return option
|
|
396
|
+
})
|
|
387
397
|
.setDMPermission(false)
|
|
388
398
|
.toJSON(),
|
|
389
399
|
new SlashCommandBuilder()
|
|
@@ -33,7 +33,9 @@ function createSessionState(): SessionState {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function buildMemoryOverviewReminder({ condensed }: { condensed: string }): string {
|
|
36
|
-
|
|
36
|
+
// Trailing newline so this synthetic part does not fuse with the next text
|
|
37
|
+
// part when the model concatenates message parts.
|
|
38
|
+
return `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>\n`
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
async function freezeMemoryOverview({
|
package/src/opencode.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
prependPathEntry,
|
|
65
65
|
selectResolvedCommand,
|
|
66
66
|
} from './opencode-command.js'
|
|
67
|
+
import { computeSkillPermission } from './skill-filter.js'
|
|
67
68
|
|
|
68
69
|
const opencodeLogger = createLogger(LogPrefix.OPENCODE)
|
|
69
70
|
|
|
@@ -549,6 +550,13 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
|
|
|
549
550
|
// OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
|
|
550
551
|
// causing issue #90 (project permissions not being respected).
|
|
551
552
|
const isDev = import.meta.url.endsWith('.ts') || import.meta.url.endsWith('.tsx')
|
|
553
|
+
// Skill whitelist/blacklist from --enable-skill / --disable-skill CLI flags.
|
|
554
|
+
// Applied as opencode permission.skill rules so every agent inherits the
|
|
555
|
+
// filter via Permission.merge(defaults, agentRules, user).
|
|
556
|
+
const skillPermission = computeSkillPermission({
|
|
557
|
+
enabledSkills: store.getState().enabledSkills,
|
|
558
|
+
disabledSkills: store.getState().disabledSkills,
|
|
559
|
+
})
|
|
552
560
|
const opencodeConfig = {
|
|
553
561
|
$schema: 'https://opencode.ai/config.json',
|
|
554
562
|
lsp: false,
|
|
@@ -564,6 +572,7 @@ async function startSingleServer(): Promise<ServerStartError | SingleServer> {
|
|
|
564
572
|
bash: 'allow',
|
|
565
573
|
external_directory: externalDirectoryPermissions,
|
|
566
574
|
webfetch: 'allow',
|
|
575
|
+
...(skillPermission && { skill: skillPermission }),
|
|
567
576
|
},
|
|
568
577
|
agent: {
|
|
569
578
|
explore: {
|
|
@@ -38,6 +38,37 @@ async function waitForPendingQuestion({
|
|
|
38
38
|
throw new Error('Timed out waiting for pending question context')
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
async function expectNoBotMessageContaining({
|
|
42
|
+
discord,
|
|
43
|
+
threadId,
|
|
44
|
+
text,
|
|
45
|
+
timeout,
|
|
46
|
+
}: {
|
|
47
|
+
discord: Parameters<typeof waitForBotMessageContaining>[0]['discord']
|
|
48
|
+
threadId: string
|
|
49
|
+
text: string
|
|
50
|
+
timeout: number
|
|
51
|
+
}): Promise<void> {
|
|
52
|
+
const start = Date.now()
|
|
53
|
+
while (Date.now() - start < timeout) {
|
|
54
|
+
const messages = await discord.thread(threadId).getMessages()
|
|
55
|
+
const match = messages.find((message) => {
|
|
56
|
+
return (
|
|
57
|
+
message.author.id === discord.botUserId
|
|
58
|
+
&& message.content.includes(text)
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
if (match) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Unexpected bot message containing ${JSON.stringify(text)} while it should still be queued`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
await new Promise<void>((resolve) => {
|
|
67
|
+
setTimeout(resolve, 20)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
41
72
|
describe('queue drain after question select answer', () => {
|
|
42
73
|
const ctx = setupQueueAdvancedSuite({
|
|
43
74
|
channelId: TEXT_CHANNEL_ID,
|
|
@@ -75,16 +106,10 @@ describe('queue drain after question select answer', () => {
|
|
|
75
106
|
|
|
76
107
|
// Get the pending question context hash from the internal map.
|
|
77
108
|
// By this point the question message is visible so the context must exist.
|
|
78
|
-
const pending = (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return entry ? { contextHash: entry[0] } : null
|
|
83
|
-
})()
|
|
84
|
-
expect(pending).toBeTruthy()
|
|
85
|
-
if (!pending) {
|
|
86
|
-
throw new Error('Expected pending question context')
|
|
87
|
-
}
|
|
109
|
+
const pending = await waitForPendingQuestion({
|
|
110
|
+
threadId: thread.id,
|
|
111
|
+
timeoutMs: 8_000,
|
|
112
|
+
})
|
|
88
113
|
const questionMsg = questionMessages.find((m) => {
|
|
89
114
|
return m.content.includes('How to proceed?')
|
|
90
115
|
})!
|
|
@@ -149,4 +174,143 @@ describe('queue drain after question select answer', () => {
|
|
|
149
174
|
},
|
|
150
175
|
20_000,
|
|
151
176
|
)
|
|
177
|
+
|
|
178
|
+
test(
|
|
179
|
+
'only the first queued message is handed off after dropdown answer',
|
|
180
|
+
async () => {
|
|
181
|
+
const marker = 'QUESTION_SELECT_QUEUE_MARKER second-test'
|
|
182
|
+
|
|
183
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
184
|
+
content: marker,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
188
|
+
timeout: 8_000,
|
|
189
|
+
predicate: (t) => {
|
|
190
|
+
return t.name === marker
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const th = ctx.discord.thread(thread.id)
|
|
195
|
+
|
|
196
|
+
const questionMessages = await waitForBotMessageContaining({
|
|
197
|
+
discord: ctx.discord,
|
|
198
|
+
threadId: thread.id,
|
|
199
|
+
text: 'How to proceed?',
|
|
200
|
+
timeout: 12_000,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const pending = await waitForPendingQuestion({
|
|
204
|
+
threadId: thread.id,
|
|
205
|
+
timeoutMs: 8_000,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const questionMsg = questionMessages.find((message) => {
|
|
209
|
+
return message.content.includes('How to proceed?')
|
|
210
|
+
})
|
|
211
|
+
expect(questionMsg).toBeTruthy()
|
|
212
|
+
if (!questionMsg) {
|
|
213
|
+
throw new Error('Expected question message')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const firstQueuedPrompt = 'SLOW_ABORT_MARKER run long response'
|
|
217
|
+
const secondQueuedPrompt = 'Reply with exactly: post-question-second'
|
|
218
|
+
|
|
219
|
+
const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
|
|
220
|
+
.runSlashCommand({
|
|
221
|
+
name: 'queue',
|
|
222
|
+
options: [{ name: 'message', type: 3, value: firstQueuedPrompt }],
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await th.waitForInteractionAck({
|
|
226
|
+
interactionId: firstQueueInteractionId,
|
|
227
|
+
timeout: 8_000,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
|
|
231
|
+
.runSlashCommand({
|
|
232
|
+
name: 'queue',
|
|
233
|
+
options: [{ name: 'message', type: 3, value: secondQueuedPrompt }],
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await th.waitForInteractionAck({
|
|
237
|
+
interactionId: secondQueueInteractionId,
|
|
238
|
+
timeout: 8_000,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
242
|
+
messageId: questionMsg.id,
|
|
243
|
+
customId: `ask_question:${pending.contextHash}:0`,
|
|
244
|
+
values: ['0'],
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
await th.waitForInteractionAck({
|
|
248
|
+
interactionId: interaction.id,
|
|
249
|
+
timeout: 8_000,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await waitForBotMessageContaining({
|
|
253
|
+
discord: ctx.discord,
|
|
254
|
+
threadId: thread.id,
|
|
255
|
+
text: `» **question-select-tester:** ${firstQueuedPrompt}`,
|
|
256
|
+
timeout: 8_000,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await expectNoBotMessageContaining({
|
|
260
|
+
discord: ctx.discord,
|
|
261
|
+
threadId: thread.id,
|
|
262
|
+
text: `» **question-select-tester:** ${secondQueuedPrompt}`,
|
|
263
|
+
timeout: 200,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await waitForFooterMessage({
|
|
267
|
+
discord: ctx.discord,
|
|
268
|
+
threadId: thread.id,
|
|
269
|
+
timeout: 8_000,
|
|
270
|
+
afterMessageIncludes: `» **question-select-tester:** ${firstQueuedPrompt}`,
|
|
271
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
await waitForBotMessageContaining({
|
|
275
|
+
discord: ctx.discord,
|
|
276
|
+
threadId: thread.id,
|
|
277
|
+
text: `» **question-select-tester:** ${secondQueuedPrompt}`,
|
|
278
|
+
timeout: 8_000,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
await waitForFooterMessage({
|
|
282
|
+
discord: ctx.discord,
|
|
283
|
+
threadId: thread.id,
|
|
284
|
+
timeout: 8_000,
|
|
285
|
+
afterMessageIncludes: `» **question-select-tester:** ${secondQueuedPrompt}`,
|
|
286
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const timeline = await th.text({ showInteractions: true })
|
|
290
|
+
expect(timeline).toMatchInlineSnapshot(`
|
|
291
|
+
"--- from: user (question-select-tester)
|
|
292
|
+
QUESTION_SELECT_QUEUE_MARKER second-test
|
|
293
|
+
--- from: assistant (TestBot)
|
|
294
|
+
**Select action**
|
|
295
|
+
How to proceed?
|
|
296
|
+
✓ _Alpha_
|
|
297
|
+
[user interaction]
|
|
298
|
+
Queued message (position 1)
|
|
299
|
+
[user interaction]
|
|
300
|
+
Queued message (position 2)
|
|
301
|
+
[user selects dropdown: 0]
|
|
302
|
+
» **question-select-tester:** SLOW_ABORT_MARKER run long response
|
|
303
|
+
⬥ slow-response-started
|
|
304
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
305
|
+
» **question-select-tester:** Reply with exactly: post-question-second
|
|
306
|
+
⬥ ok
|
|
307
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
308
|
+
`)
|
|
309
|
+
expect(timeline).toContain(`» **question-select-tester:** ${firstQueuedPrompt}`)
|
|
310
|
+
expect(timeline).toContain('⬥ slow-response-started')
|
|
311
|
+
expect(timeline).toContain(`» **question-select-tester:** ${secondQueuedPrompt}`)
|
|
312
|
+
expect(timeline).toContain('⬥ ok')
|
|
313
|
+
},
|
|
314
|
+
20_000,
|
|
315
|
+
)
|
|
152
316
|
})
|
|
@@ -80,7 +80,8 @@ export type ThreadRunState = {
|
|
|
80
80
|
// FIFO queue of pending inputs waiting for kimaki-local dispatch.
|
|
81
81
|
// Normal user messages default to opencode queue mode; this queue is
|
|
82
82
|
// for explicit local-queue flows (for example /queue).
|
|
83
|
-
// Changes: enqueueItem (append), dequeueItem (head removal),
|
|
83
|
+
// Changes: enqueueItem (append), dequeueItem (head removal),
|
|
84
|
+
// clearQueueItems, removeQueueItemAtPosition.
|
|
84
85
|
// Read by: runtime queue gating, hasQueue helpers, /queue command display.
|
|
85
86
|
queueItems: QueuedMessage[]
|
|
86
87
|
|
|
@@ -201,6 +202,40 @@ export function clearQueueItems(threadId: string): void {
|
|
|
201
202
|
updateThread(threadId, (t) => ({ ...t, queueItems: [] }))
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
export function removeQueueItemAtPosition(
|
|
206
|
+
threadId: string,
|
|
207
|
+
position: number,
|
|
208
|
+
): QueuedMessage | undefined {
|
|
209
|
+
if (position < 1) {
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let removedItem: QueuedMessage | undefined
|
|
214
|
+
store.setState((s) => {
|
|
215
|
+
const t = s.threads.get(threadId)
|
|
216
|
+
if (!t) {
|
|
217
|
+
return s
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const index = position - 1
|
|
221
|
+
const removed = t.queueItems[index]
|
|
222
|
+
if (!removed) {
|
|
223
|
+
return s
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
removedItem = removed
|
|
227
|
+
const newThreads = new Map(s.threads)
|
|
228
|
+
newThreads.set(threadId, {
|
|
229
|
+
...t,
|
|
230
|
+
queueItems: t.queueItems.filter((_, itemIndex) => {
|
|
231
|
+
return itemIndex !== index
|
|
232
|
+
}),
|
|
233
|
+
})
|
|
234
|
+
return { threads: newThreads }
|
|
235
|
+
})
|
|
236
|
+
return removedItem
|
|
237
|
+
}
|
|
238
|
+
|
|
204
239
|
// ── Queries ──────────────────────────────────────────────────────
|
|
205
240
|
|
|
206
241
|
export function getThreadState(threadId: string): ThreadRunState | undefined {
|
|
@@ -404,11 +404,14 @@ export function isEssentialToolPart(part: Part): boolean {
|
|
|
404
404
|
const DISCORD_THREAD_NAME_MAX = 100
|
|
405
405
|
const WORKTREE_THREAD_PREFIX = '⬦ '
|
|
406
406
|
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
407
|
+
// Prefixes that should survive OpenCode session title renames.
|
|
408
|
+
// When a thread starts with one of these, the rename preserves it.
|
|
409
|
+
const PRESERVED_THREAD_PREFIXES: string[] = [
|
|
410
|
+
WORKTREE_THREAD_PREFIX,
|
|
411
|
+
'btw: ',
|
|
412
|
+
'Fork: ',
|
|
413
|
+
]
|
|
414
|
+
|
|
412
415
|
export function deriveThreadNameFromSessionTitle({
|
|
413
416
|
sessionTitle,
|
|
414
417
|
currentName,
|
|
@@ -423,9 +426,11 @@ export function deriveThreadNameFromSessionTitle({
|
|
|
423
426
|
if (/^new session\s*-/i.test(trimmed)) {
|
|
424
427
|
return undefined
|
|
425
428
|
}
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
+
const matchedPrefix =
|
|
430
|
+
PRESERVED_THREAD_PREFIXES.find((p) => {
|
|
431
|
+
return currentName.startsWith(p)
|
|
432
|
+
}) ?? ''
|
|
433
|
+
const candidate = `${matchedPrefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX)
|
|
429
434
|
if (candidate === currentName) {
|
|
430
435
|
return undefined
|
|
431
436
|
}
|
|
@@ -2069,6 +2074,8 @@ export class ThreadSessionRuntime {
|
|
|
2069
2074
|
}
|
|
2070
2075
|
|
|
2071
2076
|
private async handleMainPart(part: Part): Promise<void> {
|
|
2077
|
+
const sessionId = this.state?.sessionId
|
|
2078
|
+
|
|
2072
2079
|
if (part.type === 'step-start') {
|
|
2073
2080
|
this.ensureTypingNow()
|
|
2074
2081
|
return
|
|
@@ -2084,13 +2091,42 @@ export class ThreadSessionRuntime {
|
|
|
2084
2091
|
|
|
2085
2092
|
// Track task tool spawning subtask sessions
|
|
2086
2093
|
if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
|
|
2087
|
-
const description =
|
|
2088
|
-
|
|
2089
|
-
|
|
2094
|
+
const description =
|
|
2095
|
+
typeof part.state.input?.description === 'string'
|
|
2096
|
+
? part.state.input.description
|
|
2097
|
+
: ''
|
|
2098
|
+
const agent =
|
|
2099
|
+
typeof part.state.input?.subagent_type === 'string'
|
|
2100
|
+
? part.state.input.subagent_type
|
|
2101
|
+
: 'task'
|
|
2102
|
+
const childSessionId =
|
|
2103
|
+
typeof part.state.metadata?.sessionId === 'string'
|
|
2104
|
+
? part.state.metadata.sessionId
|
|
2105
|
+
: ''
|
|
2090
2106
|
if (description && childSessionId) {
|
|
2091
2107
|
if ((await this.getVerbosity()) !== 'text_only') {
|
|
2092
2108
|
const taskDisplay = `┣ ${agent} **${description}**`
|
|
2093
|
-
|
|
2109
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
2110
|
+
const newIds = new Set(t.sentPartIds)
|
|
2111
|
+
newIds.add(part.id)
|
|
2112
|
+
return { ...t, sentPartIds: newIds }
|
|
2113
|
+
})
|
|
2114
|
+
const sendResult = await errore.tryAsync(() => {
|
|
2115
|
+
return sendThreadMessage(this.thread, taskDisplay + '\n\n')
|
|
2116
|
+
})
|
|
2117
|
+
if (sendResult instanceof Error) {
|
|
2118
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
2119
|
+
const newIds = new Set(t.sentPartIds)
|
|
2120
|
+
newIds.delete(part.id)
|
|
2121
|
+
return { ...t, sentPartIds: newIds }
|
|
2122
|
+
})
|
|
2123
|
+
discordLogger.error(
|
|
2124
|
+
`ERROR: Failed to send task part ${part.id}:`,
|
|
2125
|
+
sendResult,
|
|
2126
|
+
)
|
|
2127
|
+
return
|
|
2128
|
+
}
|
|
2129
|
+
await setPartMessage(part.id, sendResult.id, this.thread.id)
|
|
2094
2130
|
}
|
|
2095
2131
|
}
|
|
2096
2132
|
}
|
|
@@ -2594,8 +2630,9 @@ export class ThreadSessionRuntime {
|
|
|
2594
2630
|
|
|
2595
2631
|
// When a question is answered and the local queue has items, the model may
|
|
2596
2632
|
// continue the same run without ever reaching the local-queue idle gate.
|
|
2597
|
-
// Hand the queued
|
|
2598
|
-
//
|
|
2633
|
+
// Hand off only the next queued item to OpenCode immediately so the queue
|
|
2634
|
+
// resumes, but keep later items local so their `» user:` indicators still
|
|
2635
|
+
// appear one-by-one when they actually become active.
|
|
2599
2636
|
if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
|
|
2600
2637
|
logger.log(
|
|
2601
2638
|
`[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`,
|
|
@@ -2614,8 +2651,8 @@ export class ThreadSessionRuntime {
|
|
|
2614
2651
|
}
|
|
2615
2652
|
|
|
2616
2653
|
// Detached helper promise for the "question answered while local queue has
|
|
2617
|
-
// items" flow. Prevents starting two overlapping
|
|
2618
|
-
//
|
|
2654
|
+
// items" flow. Prevents starting two overlapping single-item handoffs when
|
|
2655
|
+
// multiple question replies land close together.
|
|
2619
2656
|
private questionReplyQueueHandoffPromise: Promise<void> | null = null
|
|
2620
2657
|
|
|
2621
2658
|
private async handoffQueuedItemsAfterQuestionReply({
|
|
@@ -2633,24 +2670,22 @@ export class ThreadSessionRuntime {
|
|
|
2633
2670
|
return
|
|
2634
2671
|
}
|
|
2635
2672
|
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
}
|
|
2641
|
-
|
|
2642
|
-
const displayText = next.command
|
|
2643
|
-
? `/${next.command.name}`
|
|
2644
|
-
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
|
|
2645
|
-
if (displayText.trim()) {
|
|
2646
|
-
await sendThreadMessage(
|
|
2647
|
-
this.thread,
|
|
2648
|
-
`» **${next.username}:** ${displayText}`,
|
|
2649
|
-
)
|
|
2650
|
-
}
|
|
2673
|
+
const next = threadState.dequeueItem(this.threadId)
|
|
2674
|
+
if (!next) {
|
|
2675
|
+
return
|
|
2676
|
+
}
|
|
2651
2677
|
|
|
2652
|
-
|
|
2678
|
+
const displayText = next.command
|
|
2679
|
+
? `/${next.command.name}`
|
|
2680
|
+
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
|
|
2681
|
+
if (displayText.trim()) {
|
|
2682
|
+
await sendThreadMessage(
|
|
2683
|
+
this.thread,
|
|
2684
|
+
`» **${next.username}:** ${displayText}`,
|
|
2685
|
+
)
|
|
2653
2686
|
}
|
|
2687
|
+
|
|
2688
|
+
await this.submitViaOpencodeQueue(next)
|
|
2654
2689
|
}
|
|
2655
2690
|
|
|
2656
2691
|
private async handleSessionStatus(properties: {
|
|
@@ -3398,6 +3433,11 @@ export class ThreadSessionRuntime {
|
|
|
3398
3433
|
threadState.clearQueueItems(this.threadId)
|
|
3399
3434
|
}
|
|
3400
3435
|
|
|
3436
|
+
/** Remove a queued message by its 1-based position. */
|
|
3437
|
+
removeQueuePosition(position: number): threadState.QueuedMessage | undefined {
|
|
3438
|
+
return threadState.removeQueueItemAtPosition(this.threadId, position)
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3401
3441
|
// ── Queue Drain ─────────────────────────────────────────────
|
|
3402
3442
|
|
|
3403
3443
|
/**
|
|
@@ -95,6 +95,24 @@ describe('deriveThreadNameFromSessionTitle', () => {
|
|
|
95
95
|
).toMatchInlineSnapshot(`undefined`)
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
test('preserves btw: prefix from current name', () => {
|
|
99
|
+
expect(
|
|
100
|
+
deriveThreadNameFromSessionTitle({
|
|
101
|
+
sessionTitle: 'Side question about auth',
|
|
102
|
+
currentName: 'btw: why is auth broken',
|
|
103
|
+
}),
|
|
104
|
+
).toMatchInlineSnapshot(`"btw: Side question about auth"`)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('preserves Fork: prefix from current name', () => {
|
|
108
|
+
expect(
|
|
109
|
+
deriveThreadNameFromSessionTitle({
|
|
110
|
+
sessionTitle: 'Forked task title',
|
|
111
|
+
currentName: 'Fork: old session title',
|
|
112
|
+
}),
|
|
113
|
+
).toMatchInlineSnapshot(`"Fork: Forked task title"`)
|
|
114
|
+
})
|
|
115
|
+
|
|
98
116
|
test('returns undefined for null/undefined title', () => {
|
|
99
117
|
expect(
|
|
100
118
|
deriveThreadNameFromSessionTitle({
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
import { computeSkillPermission } from './skill-filter.js'
|
|
3
|
+
|
|
4
|
+
describe('computeSkillPermission', () => {
|
|
5
|
+
test('empty inputs returns undefined (no filtering)', () => {
|
|
6
|
+
expect(
|
|
7
|
+
computeSkillPermission({ enabledSkills: [], disabledSkills: [] }),
|
|
8
|
+
).toMatchInlineSnapshot(`undefined`)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('whitelist single skill', () => {
|
|
12
|
+
expect(
|
|
13
|
+
computeSkillPermission({
|
|
14
|
+
enabledSkills: ['npm-package'],
|
|
15
|
+
disabledSkills: [],
|
|
16
|
+
}),
|
|
17
|
+
).toMatchInlineSnapshot(`
|
|
18
|
+
{
|
|
19
|
+
"*": "deny",
|
|
20
|
+
"npm-package": "allow",
|
|
21
|
+
}
|
|
22
|
+
`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('whitelist multiple skills', () => {
|
|
26
|
+
expect(
|
|
27
|
+
computeSkillPermission({
|
|
28
|
+
enabledSkills: ['npm-package', 'playwriter', 'errore'],
|
|
29
|
+
disabledSkills: [],
|
|
30
|
+
}),
|
|
31
|
+
).toMatchInlineSnapshot(`
|
|
32
|
+
{
|
|
33
|
+
"*": "deny",
|
|
34
|
+
"errore": "allow",
|
|
35
|
+
"npm-package": "allow",
|
|
36
|
+
"playwriter": "allow",
|
|
37
|
+
}
|
|
38
|
+
`)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('blacklist single skill', () => {
|
|
42
|
+
expect(
|
|
43
|
+
computeSkillPermission({
|
|
44
|
+
enabledSkills: [],
|
|
45
|
+
disabledSkills: ['jitter'],
|
|
46
|
+
}),
|
|
47
|
+
).toMatchInlineSnapshot(`
|
|
48
|
+
{
|
|
49
|
+
"jitter": "deny",
|
|
50
|
+
}
|
|
51
|
+
`)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('blacklist multiple skills', () => {
|
|
55
|
+
expect(
|
|
56
|
+
computeSkillPermission({
|
|
57
|
+
enabledSkills: [],
|
|
58
|
+
disabledSkills: ['jitter', 'termcast'],
|
|
59
|
+
}),
|
|
60
|
+
).toMatchInlineSnapshot(`
|
|
61
|
+
{
|
|
62
|
+
"jitter": "deny",
|
|
63
|
+
"termcast": "deny",
|
|
64
|
+
}
|
|
65
|
+
`)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('whitelist takes precedence when both are set (cli.ts is expected to reject this upstream)', () => {
|
|
69
|
+
// cli.ts validates mutual exclusion before reaching this helper. This
|
|
70
|
+
// test documents the defensive behavior if both arrays ever leak through.
|
|
71
|
+
expect(
|
|
72
|
+
computeSkillPermission({
|
|
73
|
+
enabledSkills: ['npm-package'],
|
|
74
|
+
disabledSkills: ['jitter'],
|
|
75
|
+
}),
|
|
76
|
+
).toMatchInlineSnapshot(`
|
|
77
|
+
{
|
|
78
|
+
"*": "deny",
|
|
79
|
+
"npm-package": "allow",
|
|
80
|
+
}
|
|
81
|
+
`)
|
|
82
|
+
})
|
|
83
|
+
})
|