kimaki 0.4.78 → 0.4.80
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/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Tests for parsePermissionRules() from opencode.ts
|
|
2
|
+
import { describe, test, expect } from 'vitest'
|
|
3
|
+
import { parsePermissionRules } from './opencode.js'
|
|
4
|
+
|
|
5
|
+
describe('parsePermissionRules', () => {
|
|
6
|
+
test('simple tool:action format', () => {
|
|
7
|
+
expect(parsePermissionRules(['bash:deny'])).toMatchInlineSnapshot(`
|
|
8
|
+
[
|
|
9
|
+
{
|
|
10
|
+
"action": "deny",
|
|
11
|
+
"pattern": "*",
|
|
12
|
+
"permission": "bash",
|
|
13
|
+
},
|
|
14
|
+
]
|
|
15
|
+
`)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('multiple rules', () => {
|
|
19
|
+
expect(parsePermissionRules(['bash:deny', 'edit:deny', 'read:allow'])).toMatchInlineSnapshot(`
|
|
20
|
+
[
|
|
21
|
+
{
|
|
22
|
+
"action": "deny",
|
|
23
|
+
"pattern": "*",
|
|
24
|
+
"permission": "bash",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"action": "deny",
|
|
28
|
+
"pattern": "*",
|
|
29
|
+
"permission": "edit",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"action": "allow",
|
|
33
|
+
"pattern": "*",
|
|
34
|
+
"permission": "read",
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('tool:pattern:action format', () => {
|
|
41
|
+
expect(parsePermissionRules(['bash:git *:allow'])).toMatchInlineSnapshot(`
|
|
42
|
+
[
|
|
43
|
+
{
|
|
44
|
+
"action": "allow",
|
|
45
|
+
"pattern": "git *",
|
|
46
|
+
"permission": "bash",
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('wildcard permission', () => {
|
|
53
|
+
expect(parsePermissionRules(['*:deny'])).toMatchInlineSnapshot(`
|
|
54
|
+
[
|
|
55
|
+
{
|
|
56
|
+
"action": "deny",
|
|
57
|
+
"pattern": "*",
|
|
58
|
+
"permission": "*",
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
`)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('case-insensitive action', () => {
|
|
65
|
+
expect(parsePermissionRules(['bash:DENY', 'edit:Allow'])).toMatchInlineSnapshot(`
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
"action": "deny",
|
|
69
|
+
"pattern": "*",
|
|
70
|
+
"permission": "bash",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"action": "allow",
|
|
74
|
+
"pattern": "*",
|
|
75
|
+
"permission": "edit",
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
`)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('trims whitespace', () => {
|
|
82
|
+
expect(parsePermissionRules([' bash : deny '])).toMatchInlineSnapshot(`
|
|
83
|
+
[
|
|
84
|
+
{
|
|
85
|
+
"action": "deny",
|
|
86
|
+
"pattern": "*",
|
|
87
|
+
"permission": "bash",
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
`)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('skips invalid entries', () => {
|
|
94
|
+
expect(parsePermissionRules(['', 'bash', 'bash:invalid', ':deny'])).toMatchInlineSnapshot(`[]`)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('handles non-array input defensively', () => {
|
|
98
|
+
expect(parsePermissionRules(undefined)).toMatchInlineSnapshot(`[]`)
|
|
99
|
+
expect(parsePermissionRules(null)).toMatchInlineSnapshot(`[]`)
|
|
100
|
+
expect(parsePermissionRules('bash:deny')).toMatchInlineSnapshot(`[]`)
|
|
101
|
+
expect(parsePermissionRules(123)).toMatchInlineSnapshot(`[]`)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('handles non-string array items', () => {
|
|
105
|
+
expect(parsePermissionRules([123, null, 'bash:deny'])).toMatchInlineSnapshot(`
|
|
106
|
+
[
|
|
107
|
+
{
|
|
108
|
+
"action": "deny",
|
|
109
|
+
"pattern": "*",
|
|
110
|
+
"permission": "bash",
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('ask action', () => {
|
|
117
|
+
expect(parsePermissionRules(['webfetch:ask'])).toMatchInlineSnapshot(`
|
|
118
|
+
[
|
|
119
|
+
{
|
|
120
|
+
"action": "ask",
|
|
121
|
+
"pattern": "*",
|
|
122
|
+
"permission": "webfetch",
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
`)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// E2e test: queued messages must drain immediately when the session is idle,
|
|
2
|
+
// even if action buttons are still pending. The isSessionBusy check is
|
|
3
|
+
// sufficient — hasPendingInteractiveUi() should NOT block queue drain.
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
setupQueueAdvancedSuite,
|
|
8
|
+
TEST_USER_ID,
|
|
9
|
+
} from './queue-advanced-e2e-setup.js'
|
|
10
|
+
import {
|
|
11
|
+
waitForBotMessageContaining,
|
|
12
|
+
waitForFooterMessage,
|
|
13
|
+
} from './test-utils.js'
|
|
14
|
+
import { getThreadSession } from './database.js'
|
|
15
|
+
import {
|
|
16
|
+
pendingActionButtonContexts,
|
|
17
|
+
showActionButtons,
|
|
18
|
+
} from './commands/action-buttons.js'
|
|
19
|
+
|
|
20
|
+
const TEXT_CHANNEL_ID = '200000000000001020'
|
|
21
|
+
|
|
22
|
+
describe('queue drain with pending interactive UI', () => {
|
|
23
|
+
const ctx = setupQueueAdvancedSuite({
|
|
24
|
+
channelId: TEXT_CHANNEL_ID,
|
|
25
|
+
channelName: 'qa-drain-interactive-ui',
|
|
26
|
+
dirName: 'qa-drain-interactive-ui',
|
|
27
|
+
username: 'drain-ui-tester',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test(
|
|
31
|
+
'queued message drains immediately while action buttons are still pending',
|
|
32
|
+
async () => {
|
|
33
|
+
// 1. Create a thread with a first completed reply
|
|
34
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
35
|
+
content: 'Reply with exactly: drain-button-setup',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
39
|
+
timeout: 4_000,
|
|
40
|
+
predicate: (t) => {
|
|
41
|
+
return t.name === 'Reply with exactly: drain-button-setup'
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const th = ctx.discord.thread(thread.id)
|
|
46
|
+
|
|
47
|
+
await waitForBotMessageContaining({
|
|
48
|
+
discord: ctx.discord,
|
|
49
|
+
threadId: thread.id,
|
|
50
|
+
userId: TEST_USER_ID,
|
|
51
|
+
text: 'ok',
|
|
52
|
+
timeout: 4_000,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
await waitForFooterMessage({
|
|
56
|
+
discord: ctx.discord,
|
|
57
|
+
threadId: thread.id,
|
|
58
|
+
timeout: 4_000,
|
|
59
|
+
afterMessageIncludes: 'ok',
|
|
60
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// 2. Show action buttons (session is idle, buttons are pending)
|
|
64
|
+
const currentSessionId = await getThreadSession(thread.id)
|
|
65
|
+
if (!currentSessionId) {
|
|
66
|
+
throw new Error('Expected thread session id')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const channel = await ctx.botClient.channels.fetch(thread.id)
|
|
70
|
+
if (!channel || !channel.isThread()) {
|
|
71
|
+
throw new Error('Expected Discord thread channel')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await showActionButtons({
|
|
75
|
+
thread: channel,
|
|
76
|
+
sessionId: currentSessionId,
|
|
77
|
+
directory: ctx.directories.projectDirectory,
|
|
78
|
+
buttons: [{ label: 'Pending button', color: 'white' }],
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Verify buttons are pending
|
|
82
|
+
const start = Date.now()
|
|
83
|
+
while (Date.now() - start < 4_000) {
|
|
84
|
+
const entry = [...pendingActionButtonContexts.entries()].find(([, context]) => {
|
|
85
|
+
return context.thread.id === thread.id && Boolean(context.messageId)
|
|
86
|
+
})
|
|
87
|
+
if (entry) {
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
await new Promise<void>((resolve) => {
|
|
91
|
+
setTimeout(resolve, 100)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
expect(
|
|
95
|
+
[...pendingActionButtonContexts.values()].some((c) => {
|
|
96
|
+
return c.thread.id === thread.id
|
|
97
|
+
}),
|
|
98
|
+
).toBe(true)
|
|
99
|
+
|
|
100
|
+
// 3. Queue a message via /queue while buttons are still pending.
|
|
101
|
+
// The queue should drain immediately because session is idle.
|
|
102
|
+
// Currently FAILS: hasPendingInteractiveUi() blocks tryDrainQueue().
|
|
103
|
+
const { id: queueInteractionId } = await th.user(TEST_USER_ID)
|
|
104
|
+
.runSlashCommand({
|
|
105
|
+
name: 'queue',
|
|
106
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: post-button-drain' }],
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const queueAck = await th.waitForInteractionAck({
|
|
110
|
+
interactionId: queueInteractionId,
|
|
111
|
+
timeout: 4_000,
|
|
112
|
+
})
|
|
113
|
+
if (!queueAck.messageId) {
|
|
114
|
+
throw new Error('Expected /queue response message id')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 4. Queued message should dispatch immediately (not stay "Queued").
|
|
118
|
+
// The dispatch indicator should appear quickly.
|
|
119
|
+
await waitForBotMessageContaining({
|
|
120
|
+
discord: ctx.discord,
|
|
121
|
+
threadId: thread.id,
|
|
122
|
+
text: '» **drain-ui-tester:** Reply with exactly: post-button-drain',
|
|
123
|
+
timeout: 4_000,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// 5. Wait for the footer after the drained message completes
|
|
127
|
+
await waitForFooterMessage({
|
|
128
|
+
discord: ctx.discord,
|
|
129
|
+
threadId: thread.id,
|
|
130
|
+
timeout: 4_000,
|
|
131
|
+
afterMessageIncludes: '» **drain-ui-tester:**',
|
|
132
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const timeline = await th.text({ showInteractions: true })
|
|
136
|
+
expect(timeline).toMatchInlineSnapshot(`
|
|
137
|
+
"--- from: user (drain-ui-tester)
|
|
138
|
+
Reply with exactly: drain-button-setup
|
|
139
|
+
--- from: assistant (TestBot)
|
|
140
|
+
⬥ ok
|
|
141
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
142
|
+
**Action Required**
|
|
143
|
+
[user interaction]
|
|
144
|
+
» **drain-ui-tester:** Reply with exactly: post-button-drain
|
|
145
|
+
⬥ ok
|
|
146
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
147
|
+
`)
|
|
148
|
+
},
|
|
149
|
+
20_000,
|
|
150
|
+
)
|
|
151
|
+
})
|
|
@@ -36,9 +36,12 @@ export type QueuedMessage = {
|
|
|
36
36
|
command?: { name: string; arguments: string }
|
|
37
37
|
// First-dispatch-only overrides — used when creating a new session.
|
|
38
38
|
// Subsequent queue drains ignore these since the session already exists.
|
|
39
|
-
// Set by --agent/--model flags on kimaki send or slash commands.
|
|
39
|
+
// Set by --agent/--model/--permission flags on kimaki send or slash commands.
|
|
40
40
|
agent?: string
|
|
41
41
|
model?: string
|
|
42
|
+
// Raw permission rule strings ("tool:action" or "tool:pattern:action").
|
|
43
|
+
// Parsed and merged into session permissions on creation.
|
|
44
|
+
permissions?: string[]
|
|
42
45
|
// Tracking fields for scheduled tasks. Stored in the DB via
|
|
43
46
|
// setSessionStartSource() after the session is created, so the session
|
|
44
47
|
// list can show which sessions were started by scheduled tasks.
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getOpencodeClient,
|
|
25
25
|
initializeOpencodeForDirectory,
|
|
26
26
|
buildSessionPermissions,
|
|
27
|
+
parsePermissionRules,
|
|
27
28
|
subscribeOpencodeServerLifecycle,
|
|
28
29
|
} from '../opencode.js'
|
|
29
30
|
import { isAbortError } from '../utils.js'
|
|
@@ -426,6 +427,14 @@ export type IngressInput = {
|
|
|
426
427
|
// First-dispatch-only overrides (used when creating a new session)
|
|
427
428
|
agent?: string
|
|
428
429
|
model?: string
|
|
430
|
+
/**
|
|
431
|
+
* Raw permission rule strings from --permission flag ("tool:action" or
|
|
432
|
+
* "tool:pattern:action"). Parsed into PermissionRuleset entries by
|
|
433
|
+
* parsePermissionRules() and appended after buildSessionPermissions()
|
|
434
|
+
* so they win via opencode's findLast() evaluation. Only used on
|
|
435
|
+
* session creation (first dispatch).
|
|
436
|
+
*/
|
|
437
|
+
permissions?: string[]
|
|
429
438
|
sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }
|
|
430
439
|
/** Optional guard for retries: skip enqueue when session has changed. */
|
|
431
440
|
expectedSessionId?: string
|
|
@@ -911,12 +920,16 @@ export class ThreadSessionRuntime {
|
|
|
911
920
|
const compacted = structuredClone(event)
|
|
912
921
|
|
|
913
922
|
if (compacted.type === 'message.updated') {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
923
|
+
// Strip heavy fields from ALL roles. Derivation only needs lightweight
|
|
924
|
+
// metadata (id, role, sessionID, parentID, time, finish, error, modelID,
|
|
925
|
+
// providerID, mode, tokens). The parts array on assistant messages grows
|
|
926
|
+
// with every tool call and was the primary OOM vector — 1000 buffer entries
|
|
927
|
+
// each carrying the full cumulative parts array reached 4GB+.
|
|
928
|
+
const info = compacted.properties.info as Record<string, unknown>
|
|
929
|
+
delete info.system
|
|
930
|
+
delete info.summary
|
|
931
|
+
delete info.tools
|
|
932
|
+
delete info.parts
|
|
920
933
|
return compacted
|
|
921
934
|
}
|
|
922
935
|
|
|
@@ -1171,8 +1184,16 @@ export class ThreadSessionRuntime {
|
|
|
1171
1184
|
// Subtask sessions also bypass — they're tracked in subtaskSessions.
|
|
1172
1185
|
|
|
1173
1186
|
private async handleEvent(event: OpenCodeEvent): Promise<void> {
|
|
1174
|
-
//
|
|
1175
|
-
|
|
1187
|
+
// Skip message.part.delta from the event buffer — no derivation function
|
|
1188
|
+
// (isSessionBusy, doesLatestUserTurnHaveNaturalCompletion, waitForEvent,
|
|
1189
|
+
// etc.) uses them. During long streaming responses they flood the 1000-slot
|
|
1190
|
+
// buffer, evicting session.status busy events that isSessionBusy needs,
|
|
1191
|
+
// causing tryDrainQueue to drain the local queue while the session is
|
|
1192
|
+
// actually still busy. This was the root cause of "? queue" messages
|
|
1193
|
+
// interrupting instead of queuing.
|
|
1194
|
+
if (event.type !== 'message.part.delta') {
|
|
1195
|
+
this.appendEventToBuffer(event)
|
|
1196
|
+
}
|
|
1176
1197
|
|
|
1177
1198
|
const sessionId = this.state?.sessionId
|
|
1178
1199
|
|
|
@@ -1903,6 +1924,7 @@ export class ThreadSessionRuntime {
|
|
|
1903
1924
|
await sendThreadMessage(
|
|
1904
1925
|
this.thread,
|
|
1905
1926
|
`Failed to show action buttons: ${showResult.message}`,
|
|
1927
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
1906
1928
|
)
|
|
1907
1929
|
}
|
|
1908
1930
|
},
|
|
@@ -2164,6 +2186,7 @@ export class ThreadSessionRuntime {
|
|
|
2164
2186
|
await sendThreadMessage(
|
|
2165
2187
|
this.thread,
|
|
2166
2188
|
`✗ opencode session error: ${errorMessage}`,
|
|
2189
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
2167
2190
|
)
|
|
2168
2191
|
await this.persistEventBufferDebounced.flush()
|
|
2169
2192
|
|
|
@@ -2457,7 +2480,9 @@ export class ThreadSessionRuntime {
|
|
|
2457
2480
|
// Helper: stop typing and drain queued local messages on error.
|
|
2458
2481
|
const cleanupOnError = async (errorMessage: string) => {
|
|
2459
2482
|
this.stopTyping()
|
|
2460
|
-
await sendThreadMessage(this.thread, errorMessage
|
|
2483
|
+
await sendThreadMessage(this.thread, errorMessage, {
|
|
2484
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
2485
|
+
})
|
|
2461
2486
|
await this.tryDrainQueue({ showIndicator: true })
|
|
2462
2487
|
}
|
|
2463
2488
|
|
|
@@ -2465,6 +2490,7 @@ export class ThreadSessionRuntime {
|
|
|
2465
2490
|
const sessionResult = await this.ensureSession({
|
|
2466
2491
|
prompt: input.prompt,
|
|
2467
2492
|
agent: input.agent,
|
|
2493
|
+
permissions: input.permissions,
|
|
2468
2494
|
sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
|
|
2469
2495
|
sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
|
|
2470
2496
|
})
|
|
@@ -2720,6 +2746,7 @@ export class ThreadSessionRuntime {
|
|
|
2720
2746
|
command: input.command,
|
|
2721
2747
|
agent: input.agent,
|
|
2722
2748
|
model: input.model,
|
|
2749
|
+
permissions: input.permissions,
|
|
2723
2750
|
sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
|
|
2724
2751
|
sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
|
|
2725
2752
|
}
|
|
@@ -2736,7 +2763,6 @@ export class ThreadSessionRuntime {
|
|
|
2736
2763
|
const willDrainNow = stateAfterEnqueue
|
|
2737
2764
|
? (
|
|
2738
2765
|
stateAfterEnqueue.queueItems.length > 0
|
|
2739
|
-
&& !this.hasPendingInteractiveUi()
|
|
2740
2766
|
&& !this.isMainSessionBusy()
|
|
2741
2767
|
)
|
|
2742
2768
|
: false
|
|
@@ -2819,6 +2845,16 @@ export class ThreadSessionRuntime {
|
|
|
2819
2845
|
preprocess: undefined,
|
|
2820
2846
|
}
|
|
2821
2847
|
|
|
2848
|
+
const hasPromptText = resolvedInput.prompt.trim().length > 0
|
|
2849
|
+
const hasImages = (resolvedInput.images?.length || 0) > 0
|
|
2850
|
+
if (!hasPromptText && !hasImages && !resolvedInput.command) {
|
|
2851
|
+
logger.warn(
|
|
2852
|
+
`[INGRESS] Skipping empty preprocessed input threadId=${this.threadId}`,
|
|
2853
|
+
)
|
|
2854
|
+
resolveOuter({ queued: false })
|
|
2855
|
+
return
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2822
2858
|
// Route with the resolved mode through normal paths.
|
|
2823
2859
|
// Await the enqueue so session state (ensureSession, setThreadSession)
|
|
2824
2860
|
// is persisted before the next message's preprocessing reads it.
|
|
@@ -2961,9 +2997,11 @@ export class ThreadSessionRuntime {
|
|
|
2961
2997
|
if (thread.queueItems.length === 0) {
|
|
2962
2998
|
return
|
|
2963
2999
|
}
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
3000
|
+
// Interactive UI (action buttons, questions, permissions) does NOT block
|
|
3001
|
+
// queue drain. The isSessionBusy check is sufficient: questions and
|
|
3002
|
+
// permissions keep the OpenCode session busy, so drain is naturally
|
|
3003
|
+
// blocked. Action buttons are fire-and-forget (session already idle),
|
|
3004
|
+
// so queued messages should dispatch immediately.
|
|
2967
3005
|
|
|
2968
3006
|
const sessionBusy = thread.sessionId
|
|
2969
3007
|
? isSessionBusy({ events: this.eventBuffer, sessionId: thread.sessionId })
|
|
@@ -3030,6 +3068,7 @@ export class ThreadSessionRuntime {
|
|
|
3030
3068
|
const sessionResult = await this.ensureSession({
|
|
3031
3069
|
prompt: input.prompt,
|
|
3032
3070
|
agent: input.agent,
|
|
3071
|
+
permissions: input.permissions,
|
|
3033
3072
|
sessionStartScheduleKind: input.sessionStartScheduleKind,
|
|
3034
3073
|
sessionStartScheduledTaskId: input.sessionStartScheduledTaskId,
|
|
3035
3074
|
})
|
|
@@ -3038,6 +3077,7 @@ export class ThreadSessionRuntime {
|
|
|
3038
3077
|
await sendThreadMessage(
|
|
3039
3078
|
this.thread,
|
|
3040
3079
|
`✗ ${sessionResult.message}`,
|
|
3080
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3041
3081
|
)
|
|
3042
3082
|
// Show indicator: this dispatch failed, so the next queued message
|
|
3043
3083
|
// has been waiting — the user needs to see which one is starting.
|
|
@@ -3084,6 +3124,7 @@ export class ThreadSessionRuntime {
|
|
|
3084
3124
|
await sendThreadMessage(
|
|
3085
3125
|
this.thread,
|
|
3086
3126
|
`Failed to resolve agent: ${earlyAgentResult.message}`,
|
|
3127
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3087
3128
|
)
|
|
3088
3129
|
// Show indicator: dispatch failed mid-setup, next queued message was waiting.
|
|
3089
3130
|
await this.tryDrainQueue({ showIndicator: true })
|
|
@@ -3124,6 +3165,7 @@ export class ThreadSessionRuntime {
|
|
|
3124
3165
|
await sendThreadMessage(
|
|
3125
3166
|
this.thread,
|
|
3126
3167
|
`Failed to resolve model: ${earlyModelResult.message}`,
|
|
3168
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3127
3169
|
)
|
|
3128
3170
|
// Show indicator: dispatch failed mid-setup, next queued message was waiting.
|
|
3129
3171
|
await this.tryDrainQueue({ showIndicator: true })
|
|
@@ -3284,6 +3326,7 @@ export class ThreadSessionRuntime {
|
|
|
3284
3326
|
await sendThreadMessage(
|
|
3285
3327
|
this.thread,
|
|
3286
3328
|
'✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.',
|
|
3329
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3287
3330
|
)
|
|
3288
3331
|
await this.dispatchAction(() => {
|
|
3289
3332
|
return this.tryDrainQueue({ showIndicator: true })
|
|
@@ -3308,6 +3351,7 @@ export class ThreadSessionRuntime {
|
|
|
3308
3351
|
await sendThreadMessage(
|
|
3309
3352
|
this.thread,
|
|
3310
3353
|
`✗ Unexpected bot Error: ${commandResponse.message}`,
|
|
3354
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3311
3355
|
)
|
|
3312
3356
|
await this.dispatchAction(() => {
|
|
3313
3357
|
return this.tryDrainQueue({ showIndicator: true })
|
|
@@ -3328,7 +3372,9 @@ export class ThreadSessionRuntime {
|
|
|
3328
3372
|
logger.error(`[DISPATCH] ${apiError.message}`)
|
|
3329
3373
|
void notifyError(apiError, 'OpenCode API error during command')
|
|
3330
3374
|
this.stopTyping()
|
|
3331
|
-
await sendThreadMessage(this.thread, `✗ ${apiError.message}
|
|
3375
|
+
await sendThreadMessage(this.thread, `✗ ${apiError.message}`, {
|
|
3376
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
3377
|
+
})
|
|
3332
3378
|
await this.dispatchAction(() => {
|
|
3333
3379
|
return this.tryDrainQueue({ showIndicator: true })
|
|
3334
3380
|
})
|
|
@@ -3375,7 +3421,9 @@ export class ThreadSessionRuntime {
|
|
|
3375
3421
|
logger.error(`[DISPATCH] Prompt API call failed: ${errorMessage}`)
|
|
3376
3422
|
void notifyError(errorObject, 'OpenCode API error during local queue prompt')
|
|
3377
3423
|
this.stopTyping()
|
|
3378
|
-
await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}
|
|
3424
|
+
await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}`, {
|
|
3425
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
3426
|
+
})
|
|
3379
3427
|
await this.dispatchAction(() => {
|
|
3380
3428
|
return this.tryDrainQueue({ showIndicator: true })
|
|
3381
3429
|
})
|
|
@@ -3393,11 +3441,14 @@ export class ThreadSessionRuntime {
|
|
|
3393
3441
|
private async ensureSession({
|
|
3394
3442
|
prompt,
|
|
3395
3443
|
agent,
|
|
3444
|
+
permissions,
|
|
3396
3445
|
sessionStartScheduleKind,
|
|
3397
3446
|
sessionStartScheduledTaskId,
|
|
3398
3447
|
}: {
|
|
3399
3448
|
prompt: string
|
|
3400
3449
|
agent?: string
|
|
3450
|
+
/** Raw "tool:action" strings from --permission flag */
|
|
3451
|
+
permissions?: string[]
|
|
3401
3452
|
sessionStartScheduleKind?: 'at' | 'cron'
|
|
3402
3453
|
sessionStartScheduledTaskId?: number
|
|
3403
3454
|
}): Promise<
|
|
@@ -3458,10 +3509,15 @@ export class ThreadSessionRuntime {
|
|
|
3458
3509
|
// access its own project directory (and worktree origin if applicable)
|
|
3459
3510
|
// without prompts. These override the server-level 'ask' default via
|
|
3460
3511
|
// opencode's findLast() rule evaluation.
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3512
|
+
// CLI --permission rules are appended after base rules so they win
|
|
3513
|
+
// via opencode's findLast() evaluation.
|
|
3514
|
+
const sessionPermissions = [
|
|
3515
|
+
...buildSessionPermissions({
|
|
3516
|
+
directory: this.sdkDirectory,
|
|
3517
|
+
originalRepoDirectory,
|
|
3518
|
+
}),
|
|
3519
|
+
...parsePermissionRules(permissions ?? []),
|
|
3520
|
+
]
|
|
3465
3521
|
const sessionResponse = await getClient().session.create({
|
|
3466
3522
|
title: sessionTitle,
|
|
3467
3523
|
directory: this.sdkDirectory,
|
|
@@ -3620,7 +3676,13 @@ export class ThreadSessionRuntime {
|
|
|
3620
3676
|
const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`
|
|
3621
3677
|
this.stopTyping()
|
|
3622
3678
|
|
|
3623
|
-
|
|
3679
|
+
// Skip notification if there's a queued message next — the user only
|
|
3680
|
+
// needs to be notified when the entire queue finishes.
|
|
3681
|
+
const queuedNext =
|
|
3682
|
+
(threadState.getThreadState(this.threadId)?.queueItems.length ?? 0) > 0
|
|
3683
|
+
await sendThreadMessage(this.thread, footerText, {
|
|
3684
|
+
flags: queuedNext ? SILENT_MESSAGE_FLAGS : NOTIFY_MESSAGE_FLAGS,
|
|
3685
|
+
})
|
|
3624
3686
|
logger.log(
|
|
3625
3687
|
`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`,
|
|
3626
3688
|
)
|