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.
Files changed (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. 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
- if (compacted.properties.info.role !== 'user') {
915
- return compacted
916
- }
917
- delete compacted.properties.info.system
918
- delete compacted.properties.info.summary
919
- delete compacted.properties.info.tools
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
- // Push into bounded event buffer for waitForEvent() consumers.
1175
- this.appendEventToBuffer(event)
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
- if (this.hasPendingInteractiveUi()) {
2965
- return
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
- const sessionPermissions = buildSessionPermissions({
3462
- directory: this.sdkDirectory,
3463
- originalRepoDirectory,
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
- await sendThreadMessage(this.thread, footerText, { flags: NOTIFY_MESSAGE_FLAGS })
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
  )