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
package/dist/opencode-plugin.js
CHANGED
|
@@ -1,390 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// (main entry) but their index.d.ts uses `export * from "./tool"` which
|
|
13
|
-
// doesn't re-export the tool function under nodenext resolution because
|
|
14
|
-
// tool is a merged function+namespace declaration.
|
|
15
|
-
function tool(input) {
|
|
16
|
-
return input;
|
|
17
|
-
}
|
|
18
|
-
import * as errore from 'errore';
|
|
19
|
-
import { getPrisma, createIpcRequest, getIpcRequestById } from './database.js';
|
|
20
|
-
import { setDataDir } from './config.js';
|
|
21
|
-
import { createLogger, formatErrorWithStack, LogPrefix, setLogFilePath, } from './logger.js';
|
|
22
|
-
import { initSentry, notifyError } from './sentry.js';
|
|
23
|
-
import { execAsync } from './worktrees.js';
|
|
24
|
-
const logger = createLogger(LogPrefix.OPENCODE);
|
|
25
|
-
// condenseMemoryMd lives in condense-memory.ts — must NOT be exported from
|
|
26
|
-
// this file because OpenCode's plugin loader calls every exported function
|
|
27
|
-
// as a plugin initializer, which would crash marked's Lexer with non-string input.
|
|
28
|
-
import { condenseMemoryMd } from './condense-memory.js';
|
|
29
|
-
const FILE_UPLOAD_TIMEOUT_MS = 6 * 60 * 1000;
|
|
30
|
-
const DEFAULT_FILE_UPLOAD_MAX_FILES = 5;
|
|
31
|
-
const ACTION_BUTTON_TIMEOUT_MS = 30 * 1000;
|
|
32
|
-
async function resolveGitState({ directory, }) {
|
|
33
|
-
const branchResult = await errore.tryAsync(() => {
|
|
34
|
-
return execAsync('git symbolic-ref --short HEAD', { cwd: directory });
|
|
35
|
-
});
|
|
36
|
-
if (!(branchResult instanceof Error)) {
|
|
37
|
-
const branch = branchResult.stdout.trim();
|
|
38
|
-
if (branch) {
|
|
39
|
-
return {
|
|
40
|
-
key: `branch:${branch}`,
|
|
41
|
-
kind: 'branch',
|
|
42
|
-
label: branch,
|
|
43
|
-
warning: null,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
const shaResult = await errore.tryAsync(() => {
|
|
48
|
-
return execAsync('git rev-parse --short HEAD', { cwd: directory });
|
|
49
|
-
});
|
|
50
|
-
if (shaResult instanceof Error) {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
const shortSha = shaResult.stdout.trim();
|
|
54
|
-
if (!shortSha) {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
const superprojectResult = await errore.tryAsync(() => {
|
|
58
|
-
return execAsync('git rev-parse --show-superproject-working-tree', {
|
|
59
|
-
cwd: directory,
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
const superproject = superprojectResult instanceof Error ? '' : superprojectResult.stdout.trim();
|
|
63
|
-
if (superproject) {
|
|
64
|
-
return {
|
|
65
|
-
key: `detached-submodule:${shortSha}`,
|
|
66
|
-
kind: 'detached-submodule',
|
|
67
|
-
label: `detached submodule @ ${shortSha}`,
|
|
68
|
-
warning: `\n[warning: submodule is in detached HEAD at ${shortSha}. ` +
|
|
69
|
-
'create or switch to a branch before committing.]',
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
key: `detached-head:${shortSha}`,
|
|
74
|
-
kind: 'detached-head',
|
|
75
|
-
label: `detached HEAD @ ${shortSha}`,
|
|
76
|
-
warning: `\n[warning: repository is in detached HEAD at ${shortSha}. ` +
|
|
77
|
-
'create or switch to a branch before committing.]',
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
const kimakiPlugin = async ({ directory }) => {
|
|
81
|
-
// Initialize Sentry in the plugin process (runs inside OpenCode server, not bot)
|
|
82
|
-
initSentry();
|
|
83
|
-
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
84
|
-
if (dataDir) {
|
|
85
|
-
setDataDir(dataDir);
|
|
86
|
-
// Append to the same log file the bot process created (no truncation)
|
|
87
|
-
setLogFilePath(dataDir);
|
|
88
|
-
}
|
|
89
|
-
// Per-session state for synthetic part injection
|
|
90
|
-
const sessionGitStates = new Map();
|
|
91
|
-
const sessionLastMessageTime = new Map();
|
|
92
|
-
// Track whether we've already injected MEMORY.md contents for each session
|
|
93
|
-
const sessionMemoryInjected = new Set();
|
|
94
|
-
return {
|
|
95
|
-
tool: {
|
|
96
|
-
kimaki_file_upload: tool({
|
|
97
|
-
description: 'Prompt the Discord user to upload files using a native file picker modal. ' +
|
|
98
|
-
'The user sees a button, clicks it, and gets a file upload dialog. ' +
|
|
99
|
-
'Returns the local file paths of downloaded files in the project directory. ' +
|
|
100
|
-
'Use this when you need the user to provide files (images, documents, configs, etc.). ' +
|
|
101
|
-
'IMPORTANT: Always call this tool last in your message, after all text parts.',
|
|
102
|
-
args: {
|
|
103
|
-
prompt: z
|
|
104
|
-
.string()
|
|
105
|
-
.describe('Message shown to the user explaining what files to upload'),
|
|
106
|
-
maxFiles: z
|
|
107
|
-
.number()
|
|
108
|
-
.min(1)
|
|
109
|
-
.max(10)
|
|
110
|
-
.optional()
|
|
111
|
-
.describe('Maximum number of files the user can upload (1-10, default 5)'),
|
|
112
|
-
},
|
|
113
|
-
async execute({ prompt, maxFiles }, context) {
|
|
114
|
-
const prisma = await getPrisma();
|
|
115
|
-
const row = await prisma.thread_sessions.findFirst({
|
|
116
|
-
where: { session_id: context.sessionID },
|
|
117
|
-
select: { thread_id: true },
|
|
118
|
-
});
|
|
119
|
-
if (!row?.thread_id) {
|
|
120
|
-
return 'Could not find thread for current session';
|
|
121
|
-
}
|
|
122
|
-
// Insert IPC request for the bot to pick up via polling
|
|
123
|
-
const ipcRow = await createIpcRequest({
|
|
124
|
-
type: 'file_upload',
|
|
125
|
-
sessionId: context.sessionID,
|
|
126
|
-
threadId: row.thread_id,
|
|
127
|
-
payload: JSON.stringify({
|
|
128
|
-
prompt,
|
|
129
|
-
maxFiles: maxFiles || DEFAULT_FILE_UPLOAD_MAX_FILES,
|
|
130
|
-
directory: context.directory,
|
|
131
|
-
}),
|
|
132
|
-
});
|
|
133
|
-
// Poll for response from the bot process
|
|
134
|
-
const deadline = Date.now() + FILE_UPLOAD_TIMEOUT_MS;
|
|
135
|
-
const POLL_INTERVAL_MS = 300;
|
|
136
|
-
while (Date.now() < deadline) {
|
|
137
|
-
await new Promise((resolve) => {
|
|
138
|
-
setTimeout(resolve, POLL_INTERVAL_MS);
|
|
139
|
-
});
|
|
140
|
-
const updated = await getIpcRequestById({ id: ipcRow.id });
|
|
141
|
-
if (!updated || updated.status === 'cancelled') {
|
|
142
|
-
return 'File upload was cancelled';
|
|
143
|
-
}
|
|
144
|
-
if (updated.response) {
|
|
145
|
-
const parsed = JSON.parse(updated.response);
|
|
146
|
-
if (parsed.error) {
|
|
147
|
-
return `File upload failed: ${parsed.error}`;
|
|
148
|
-
}
|
|
149
|
-
const filePaths = parsed.filePaths || [];
|
|
150
|
-
if (filePaths.length === 0) {
|
|
151
|
-
return 'No files were uploaded (user may have cancelled or sent a new message)';
|
|
152
|
-
}
|
|
153
|
-
return `Files uploaded successfully:\n${filePaths.join('\n')}`;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return 'File upload timed out - user did not upload files within the time limit';
|
|
157
|
-
},
|
|
158
|
-
}),
|
|
159
|
-
kimaki_action_buttons: tool({
|
|
160
|
-
description: dedent `
|
|
161
|
-
Show action buttons in the current Discord thread for quick confirmations.
|
|
162
|
-
Use this when the user can respond by clicking one of up to 3 buttons.
|
|
163
|
-
Prefer a single button whenever possible.
|
|
164
|
-
Default color is white (same visual style as permission deny button).
|
|
165
|
-
If you need more than 3 options, use the question tool instead.
|
|
166
|
-
IMPORTANT: Always call this tool last in your message, after all text parts.
|
|
167
|
-
|
|
168
|
-
Examples:
|
|
169
|
-
- buttons: [{"label":"Yes, proceed"}]
|
|
170
|
-
- buttons: [{"label":"Approve","color":"green"}]
|
|
171
|
-
- buttons: [
|
|
172
|
-
{"label":"Confirm","color":"blue"},
|
|
173
|
-
{"label":"Cancel","color":"white"}
|
|
174
|
-
]
|
|
175
|
-
`,
|
|
176
|
-
args: {
|
|
177
|
-
buttons: z
|
|
178
|
-
.array(z.object({
|
|
179
|
-
label: z
|
|
180
|
-
.string()
|
|
181
|
-
.min(1)
|
|
182
|
-
.max(80)
|
|
183
|
-
.describe('Button label shown to the user (1-80 chars)'),
|
|
184
|
-
color: z
|
|
185
|
-
.enum(['white', 'blue', 'green', 'red'])
|
|
186
|
-
.optional()
|
|
187
|
-
.describe('Optional button color. white is default and preferred for most confirmations.'),
|
|
188
|
-
}))
|
|
189
|
-
.min(1)
|
|
190
|
-
.max(3)
|
|
191
|
-
.describe('Array of 1-3 action buttons. Prefer one button whenever possible.'),
|
|
192
|
-
},
|
|
193
|
-
async execute({ buttons }, context) {
|
|
194
|
-
const prisma = await getPrisma();
|
|
195
|
-
const row = await prisma.thread_sessions.findFirst({
|
|
196
|
-
where: { session_id: context.sessionID },
|
|
197
|
-
select: { thread_id: true },
|
|
198
|
-
});
|
|
199
|
-
if (!row?.thread_id) {
|
|
200
|
-
return 'Could not find thread for current session';
|
|
201
|
-
}
|
|
202
|
-
// Insert IPC request for the bot to pick up via polling
|
|
203
|
-
const ipcRow = await createIpcRequest({
|
|
204
|
-
type: 'action_buttons',
|
|
205
|
-
sessionId: context.sessionID,
|
|
206
|
-
threadId: row.thread_id,
|
|
207
|
-
payload: JSON.stringify({
|
|
208
|
-
buttons,
|
|
209
|
-
directory: context.directory,
|
|
210
|
-
}),
|
|
211
|
-
});
|
|
212
|
-
// Wait for bot to acknowledge (status changes from pending to processing/completed)
|
|
213
|
-
const deadline = Date.now() + ACTION_BUTTON_TIMEOUT_MS;
|
|
214
|
-
const POLL_INTERVAL_MS = 200;
|
|
215
|
-
while (Date.now() < deadline) {
|
|
216
|
-
await new Promise((resolve) => {
|
|
217
|
-
setTimeout(resolve, POLL_INTERVAL_MS);
|
|
218
|
-
});
|
|
219
|
-
const updated = await getIpcRequestById({ id: ipcRow.id });
|
|
220
|
-
if (!updated || updated.status === 'cancelled') {
|
|
221
|
-
return 'Action button request was cancelled';
|
|
222
|
-
}
|
|
223
|
-
if (updated.response) {
|
|
224
|
-
const parsed = JSON.parse(updated.response);
|
|
225
|
-
if (parsed.error) {
|
|
226
|
-
return `Action button request failed: ${parsed.error}`;
|
|
227
|
-
}
|
|
228
|
-
return `Action button(s) shown: ${buttons.map((button) => button.label).join(', ')}`;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return 'Action button request timed out';
|
|
232
|
-
},
|
|
233
|
-
}),
|
|
234
|
-
},
|
|
235
|
-
// Inject synthetic parts for branch changes and idle-time gaps.
|
|
236
|
-
// Synthetic parts are hidden from the TUI but sent to the model,
|
|
237
|
-
// keeping it aware of context changes without cluttering the UI.
|
|
238
|
-
'chat.message': async (input, output) => {
|
|
239
|
-
const hookResult = await errore.tryAsync({
|
|
240
|
-
try: async () => {
|
|
241
|
-
const now = Date.now();
|
|
242
|
-
const first = output.parts.find((part) => {
|
|
243
|
-
if (part.type !== 'text') {
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
return part.synthetic !== true;
|
|
247
|
-
});
|
|
248
|
-
if (!first || first.type !== 'text' || first.text.trim().length === 0) {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
const { sessionID } = input;
|
|
252
|
-
const messageID = first.messageID;
|
|
253
|
-
// -- Branch / detached HEAD detection --
|
|
254
|
-
// Resolved early but injected last so it appears at the end of parts.
|
|
255
|
-
const gitState = await resolveGitState({ directory });
|
|
256
|
-
// -- MEMORY.md injection --
|
|
257
|
-
// On the first user message in a session, read MEMORY.md from the
|
|
258
|
-
// project root and inject a condensed table of contents (headings
|
|
259
|
-
// with line numbers, bodies collapsed to ...). The agent can use
|
|
260
|
-
// Read with offset/limit to drill into specific sections.
|
|
261
|
-
if (!sessionMemoryInjected.has(sessionID)) {
|
|
262
|
-
sessionMemoryInjected.add(sessionID);
|
|
263
|
-
const memoryPath = path.join(directory, 'MEMORY.md');
|
|
264
|
-
const memoryContent = await fs.promises
|
|
265
|
-
.readFile(memoryPath, 'utf-8')
|
|
266
|
-
.catch(() => null);
|
|
267
|
-
if (memoryContent) {
|
|
268
|
-
const condensed = condenseMemoryMd(memoryContent);
|
|
269
|
-
output.parts.push({
|
|
270
|
-
id: `prt_${crypto.randomUUID()}`,
|
|
271
|
-
sessionID,
|
|
272
|
-
messageID,
|
|
273
|
-
type: 'text',
|
|
274
|
-
text: `<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, make headings detailed and descriptive since they are the only thing visible in this prompt. You can update MEMORY.md to store learnings, tips, insights that will help prevent same mistakes, and context worth preserving across sessions.</system-reminder>`,
|
|
275
|
-
synthetic: true,
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
// -- Time since last message --
|
|
280
|
-
// If more than 10 minutes passed since the last user message in this session,
|
|
281
|
-
// inject current time context so the model is aware of the gap.
|
|
282
|
-
const lastTime = sessionLastMessageTime.get(sessionID);
|
|
283
|
-
sessionLastMessageTime.set(sessionID, now);
|
|
284
|
-
if (lastTime) {
|
|
285
|
-
const elapsed = now - lastTime;
|
|
286
|
-
const TEN_MINUTES = 10 * 60 * 1000;
|
|
287
|
-
if (elapsed >= TEN_MINUTES) {
|
|
288
|
-
const totalMinutes = Math.floor(elapsed / 60_000);
|
|
289
|
-
const hours = Math.floor(totalMinutes / 60);
|
|
290
|
-
const minutes = totalMinutes % 60;
|
|
291
|
-
const elapsedStr = hours > 0 ? `${hours}h ${minutes}m` : `${totalMinutes}m`;
|
|
292
|
-
const utcStr = new Date(now)
|
|
293
|
-
.toISOString()
|
|
294
|
-
.replace('T', ' ')
|
|
295
|
-
.replace(/\.\d+Z$/, ' UTC');
|
|
296
|
-
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
297
|
-
const localStr = new Date(now).toLocaleString('en-US', {
|
|
298
|
-
timeZone: localTz,
|
|
299
|
-
year: 'numeric',
|
|
300
|
-
month: '2-digit',
|
|
301
|
-
day: '2-digit',
|
|
302
|
-
hour: '2-digit',
|
|
303
|
-
minute: '2-digit',
|
|
304
|
-
hour12: false,
|
|
305
|
-
});
|
|
306
|
-
output.parts.push({
|
|
307
|
-
id: `prt_${crypto.randomUUID()}`,
|
|
308
|
-
sessionID,
|
|
309
|
-
messageID,
|
|
310
|
-
type: 'text',
|
|
311
|
-
text: `[${elapsedStr} since last message | UTC: ${utcStr} | Local (${localTz}): ${localStr}]`,
|
|
312
|
-
synthetic: true,
|
|
313
|
-
});
|
|
314
|
-
// -- Memory save reminder on idle gap --
|
|
315
|
-
// When the user comes back after a long break, remind the model
|
|
316
|
-
// to save any important context from the previous conversation.
|
|
317
|
-
output.parts.push({
|
|
318
|
-
id: `prt_${crypto.randomUUID()}`,
|
|
319
|
-
sessionID,
|
|
320
|
-
messageID,
|
|
321
|
-
type: 'text',
|
|
322
|
-
text: '<system-reminder>Long gap since last message. If the previous conversation had important learnings, tips, insights that will help prevent same mistakes, or context worth preserving, update MEMORY.md before starting the new task.</system-reminder>',
|
|
323
|
-
synthetic: true,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
// -- Branch injection (last synthetic part) --
|
|
328
|
-
// Placed last so branch context appears at the end of all injected parts.
|
|
329
|
-
if (gitState) {
|
|
330
|
-
const previousState = sessionGitStates.get(sessionID);
|
|
331
|
-
if (!previousState || previousState.key !== gitState.key) {
|
|
332
|
-
const info = (() => {
|
|
333
|
-
if (gitState.warning) {
|
|
334
|
-
return gitState.warning;
|
|
335
|
-
}
|
|
336
|
-
if (previousState?.kind === 'branch') {
|
|
337
|
-
return `\n[current git branch is ${gitState.label}]`;
|
|
338
|
-
}
|
|
339
|
-
return `\n[current git branch is ${gitState.label}]`;
|
|
340
|
-
})();
|
|
341
|
-
sessionGitStates.set(sessionID, gitState);
|
|
342
|
-
output.parts.push({
|
|
343
|
-
id: `prt_${crypto.randomUUID()}`,
|
|
344
|
-
sessionID,
|
|
345
|
-
messageID,
|
|
346
|
-
type: 'text',
|
|
347
|
-
text: info,
|
|
348
|
-
synthetic: true,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
},
|
|
353
|
-
catch: (error) => {
|
|
354
|
-
return new Error('chat.message hook failed', { cause: error });
|
|
355
|
-
},
|
|
356
|
-
});
|
|
357
|
-
if (hookResult instanceof Error) {
|
|
358
|
-
logger.warn(`[opencode-plugin chat.message] ${formatErrorWithStack(hookResult)}`);
|
|
359
|
-
void notifyError(hookResult, 'opencode-plugin chat.message hook failed');
|
|
360
|
-
}
|
|
361
|
-
},
|
|
362
|
-
// Clean up per-session tracking state when sessions are deleted
|
|
363
|
-
event: async ({ event }) => {
|
|
364
|
-
const cleanupResult = await errore.tryAsync({
|
|
365
|
-
try: async () => {
|
|
366
|
-
if (event.type !== 'session.deleted') {
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
const id = event.properties?.info?.id;
|
|
370
|
-
if (!id) {
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
sessionGitStates.delete(id);
|
|
374
|
-
sessionLastMessageTime.delete(id);
|
|
375
|
-
sessionMemoryInjected.delete(id);
|
|
376
|
-
},
|
|
377
|
-
catch: (error) => {
|
|
378
|
-
return new Error('event hook failed', { cause: error });
|
|
379
|
-
},
|
|
380
|
-
});
|
|
381
|
-
if (cleanupResult instanceof Error) {
|
|
382
|
-
logger.warn(`[opencode-plugin event] ${formatErrorWithStack(cleanupResult)}`);
|
|
383
|
-
void notifyError(cleanupResult, 'opencode-plugin event hook failed');
|
|
384
|
-
}
|
|
385
|
-
},
|
|
386
|
-
};
|
|
387
|
-
};
|
|
388
|
-
export { kimakiPlugin };
|
|
1
|
+
// OpenCode plugin entry point for Kimaki Discord bot.
|
|
2
|
+
// Each export is treated as a separate plugin by OpenCode's plugin loader.
|
|
3
|
+
// CRITICAL: never export utility functions from this file — only plugin
|
|
4
|
+
// initializer functions. OpenCode calls every export as a plugin.
|
|
5
|
+
//
|
|
6
|
+
// Plugins are split into focused modules:
|
|
7
|
+
// - ipc-tools-plugin: file upload + action buttons (IPC-based Discord tools)
|
|
8
|
+
// - context-awareness-plugin: branch, pwd, memory, time gap, onboarding tutorial
|
|
9
|
+
// - opencode-interrupt-plugin: interrupt queued messages at step boundaries
|
|
10
|
+
export { ipcToolsPlugin } from './ipc-tools-plugin.js';
|
|
11
|
+
export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
389
12
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
|
|
390
|
-
export {
|
|
13
|
+
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
|
package/dist/opencode.js
CHANGED
|
@@ -69,7 +69,8 @@ function pushStartupStderrTail({ stderrTail, chunk, }) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
function buildStartupTimeoutReason({ maxAttempts, stderrTail, }) {
|
|
72
|
-
const
|
|
72
|
+
const timeoutSeconds = Math.round((maxAttempts * 100) / 1000);
|
|
73
|
+
const baseReason = `Server did not start after ${timeoutSeconds} seconds`;
|
|
73
74
|
if (stderrTail.length === 0) {
|
|
74
75
|
return baseReason;
|
|
75
76
|
}
|
|
@@ -253,7 +254,7 @@ async function getOpenPort() {
|
|
|
253
254
|
server.on('error', reject);
|
|
254
255
|
});
|
|
255
256
|
}
|
|
256
|
-
async function waitForServer({ port, maxAttempts =
|
|
257
|
+
async function waitForServer({ port, maxAttempts = 300, startupStderrTail, }) {
|
|
257
258
|
const endpoint = `http://127.0.0.1:${port}/api/health`;
|
|
258
259
|
for (let i = 0; i < maxAttempts; i++) {
|
|
259
260
|
const response = await errore.tryAsync({
|
|
@@ -261,8 +262,10 @@ async function waitForServer({ port, maxAttempts = 30, startupStderrTail, }) {
|
|
|
261
262
|
catch: (e) => new FetchError({ url: endpoint, cause: e }),
|
|
262
263
|
});
|
|
263
264
|
if (response instanceof Error) {
|
|
264
|
-
// Connection refused or other transient errors - continue polling
|
|
265
|
-
|
|
265
|
+
// Connection refused or other transient errors - continue polling.
|
|
266
|
+
// Use 100ms interval instead of 1s so we detect readiness faster.
|
|
267
|
+
// Critical for scale-to-zero cold starts where every ms matters.
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
266
269
|
continue;
|
|
267
270
|
}
|
|
268
271
|
if (response.status < 500) {
|
|
@@ -273,7 +276,7 @@ async function waitForServer({ port, maxAttempts = 30, startupStderrTail, }) {
|
|
|
273
276
|
if (body.includes('BunInstallFailedError')) {
|
|
274
277
|
return new ServerStartError({ port, reason: body.slice(0, 200) });
|
|
275
278
|
}
|
|
276
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
277
280
|
}
|
|
278
281
|
return new ServerStartError({
|
|
279
282
|
port,
|
|
@@ -357,6 +360,7 @@ async function startSingleServer() {
|
|
|
357
360
|
if (kimakiShimDirectory instanceof Error) {
|
|
358
361
|
opencodeLogger.warn(kimakiShimDirectory.message);
|
|
359
362
|
}
|
|
363
|
+
const gatewayToken = store.getState().gatewayToken;
|
|
360
364
|
const serverProcess = spawn(spawnCommand, spawnArgs, {
|
|
361
365
|
stdio: 'pipe',
|
|
362
366
|
detached: false,
|
|
@@ -402,8 +406,10 @@ async function startSingleServer() {
|
|
|
402
406
|
},
|
|
403
407
|
}),
|
|
404
408
|
OPENCODE_PORT: port.toString(),
|
|
409
|
+
KIMAKI: '1',
|
|
405
410
|
KIMAKI_DATA_DIR: getDataDir(),
|
|
406
411
|
KIMAKI_LOCK_PORT: getLockPort().toString(),
|
|
412
|
+
...(gatewayToken && { KIMAKI_DB_AUTH_TOKEN: gatewayToken }),
|
|
407
413
|
// Guard: prevents agents from running `kimaki` root command inside
|
|
408
414
|
// an OpenCode session, which would steal the lock port and break the bot.
|
|
409
415
|
KIMAKI_OPENCODE_PROCESS: '1',
|
|
@@ -651,6 +657,54 @@ export function buildSessionPermissions({ directory, originalRepoDirectory, }) {
|
|
|
651
657
|
}
|
|
652
658
|
return rules;
|
|
653
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Parse raw permission strings into PermissionRuleset entries.
|
|
662
|
+
*
|
|
663
|
+
* Accepted formats:
|
|
664
|
+
* "tool:action" → { permission: tool, pattern: "*", action }
|
|
665
|
+
* "tool:pattern:action" → { permission: tool, pattern, action }
|
|
666
|
+
*
|
|
667
|
+
* The action must be one of "allow", "deny", "ask" (case-insensitive).
|
|
668
|
+
* Parts are trimmed to tolerate whitespace from YAML deserialization.
|
|
669
|
+
* Invalid entries are silently skipped (bad user input shouldn't crash the bot).
|
|
670
|
+
* If `raw` is not an array, returns empty (defensive against malformed YAML markers).
|
|
671
|
+
*/
|
|
672
|
+
export function parsePermissionRules(raw) {
|
|
673
|
+
if (!Array.isArray(raw)) {
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
const validActions = new Set(['allow', 'deny', 'ask']);
|
|
677
|
+
return raw.flatMap((entry) => {
|
|
678
|
+
if (typeof entry !== 'string') {
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
const parts = entry.split(':').map((s) => {
|
|
682
|
+
return s.trim();
|
|
683
|
+
});
|
|
684
|
+
if (parts.length === 2) {
|
|
685
|
+
const [permission, rawAction] = parts;
|
|
686
|
+
const action = rawAction.toLowerCase();
|
|
687
|
+
if (!permission || !validActions.has(action)) {
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
return [{ permission, pattern: '*', action: action }];
|
|
691
|
+
}
|
|
692
|
+
if (parts.length >= 3) {
|
|
693
|
+
// Last segment is the action, first segment is the permission,
|
|
694
|
+
// everything in between is the pattern (may contain colons in theory,
|
|
695
|
+
// but unlikely for tool patterns).
|
|
696
|
+
const permission = parts[0];
|
|
697
|
+
const rawAction = parts[parts.length - 1];
|
|
698
|
+
const action = rawAction.toLowerCase();
|
|
699
|
+
const pattern = parts.slice(1, -1).join(':');
|
|
700
|
+
if (!permission || !pattern || !validActions.has(action)) {
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
return [{ permission, pattern, action: action }];
|
|
704
|
+
}
|
|
705
|
+
return [];
|
|
706
|
+
});
|
|
707
|
+
}
|
|
654
708
|
// ── Public helpers ───────────────────────────────────────────────
|
|
655
709
|
// These helpers expose the single shared server and directory-scoped clients.
|
|
656
710
|
export function getOpencodeServerPort(_directory) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Tests for parsePermissionRules() from opencode.ts
|
|
2
|
+
import { describe, test, expect } from 'vitest';
|
|
3
|
+
import { parsePermissionRules } from './opencode.js';
|
|
4
|
+
describe('parsePermissionRules', () => {
|
|
5
|
+
test('simple tool:action format', () => {
|
|
6
|
+
expect(parsePermissionRules(['bash:deny'])).toMatchInlineSnapshot(`
|
|
7
|
+
[
|
|
8
|
+
{
|
|
9
|
+
"action": "deny",
|
|
10
|
+
"pattern": "*",
|
|
11
|
+
"permission": "bash",
|
|
12
|
+
},
|
|
13
|
+
]
|
|
14
|
+
`);
|
|
15
|
+
});
|
|
16
|
+
test('multiple rules', () => {
|
|
17
|
+
expect(parsePermissionRules(['bash:deny', 'edit:deny', 'read:allow'])).toMatchInlineSnapshot(`
|
|
18
|
+
[
|
|
19
|
+
{
|
|
20
|
+
"action": "deny",
|
|
21
|
+
"pattern": "*",
|
|
22
|
+
"permission": "bash",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"action": "deny",
|
|
26
|
+
"pattern": "*",
|
|
27
|
+
"permission": "edit",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"action": "allow",
|
|
31
|
+
"pattern": "*",
|
|
32
|
+
"permission": "read",
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
`);
|
|
36
|
+
});
|
|
37
|
+
test('tool:pattern:action format', () => {
|
|
38
|
+
expect(parsePermissionRules(['bash:git *:allow'])).toMatchInlineSnapshot(`
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
"action": "allow",
|
|
42
|
+
"pattern": "git *",
|
|
43
|
+
"permission": "bash",
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
`);
|
|
47
|
+
});
|
|
48
|
+
test('wildcard permission', () => {
|
|
49
|
+
expect(parsePermissionRules(['*:deny'])).toMatchInlineSnapshot(`
|
|
50
|
+
[
|
|
51
|
+
{
|
|
52
|
+
"action": "deny",
|
|
53
|
+
"pattern": "*",
|
|
54
|
+
"permission": "*",
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
`);
|
|
58
|
+
});
|
|
59
|
+
test('case-insensitive action', () => {
|
|
60
|
+
expect(parsePermissionRules(['bash:DENY', 'edit:Allow'])).toMatchInlineSnapshot(`
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
"action": "deny",
|
|
64
|
+
"pattern": "*",
|
|
65
|
+
"permission": "bash",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"action": "allow",
|
|
69
|
+
"pattern": "*",
|
|
70
|
+
"permission": "edit",
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
`);
|
|
74
|
+
});
|
|
75
|
+
test('trims whitespace', () => {
|
|
76
|
+
expect(parsePermissionRules([' bash : deny '])).toMatchInlineSnapshot(`
|
|
77
|
+
[
|
|
78
|
+
{
|
|
79
|
+
"action": "deny",
|
|
80
|
+
"pattern": "*",
|
|
81
|
+
"permission": "bash",
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
`);
|
|
85
|
+
});
|
|
86
|
+
test('skips invalid entries', () => {
|
|
87
|
+
expect(parsePermissionRules(['', 'bash', 'bash:invalid', ':deny'])).toMatchInlineSnapshot(`[]`);
|
|
88
|
+
});
|
|
89
|
+
test('handles non-array input defensively', () => {
|
|
90
|
+
expect(parsePermissionRules(undefined)).toMatchInlineSnapshot(`[]`);
|
|
91
|
+
expect(parsePermissionRules(null)).toMatchInlineSnapshot(`[]`);
|
|
92
|
+
expect(parsePermissionRules('bash:deny')).toMatchInlineSnapshot(`[]`);
|
|
93
|
+
expect(parsePermissionRules(123)).toMatchInlineSnapshot(`[]`);
|
|
94
|
+
});
|
|
95
|
+
test('handles non-string array items', () => {
|
|
96
|
+
expect(parsePermissionRules([123, null, 'bash:deny'])).toMatchInlineSnapshot(`
|
|
97
|
+
[
|
|
98
|
+
{
|
|
99
|
+
"action": "deny",
|
|
100
|
+
"pattern": "*",
|
|
101
|
+
"permission": "bash",
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
`);
|
|
105
|
+
});
|
|
106
|
+
test('ask action', () => {
|
|
107
|
+
expect(parsePermissionRules(['webfetch:ask'])).toMatchInlineSnapshot(`
|
|
108
|
+
[
|
|
109
|
+
{
|
|
110
|
+
"action": "ask",
|
|
111
|
+
"pattern": "*",
|
|
112
|
+
"permission": "webfetch",
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
`);
|
|
116
|
+
});
|
|
117
|
+
});
|