kimaki 0.4.90 → 0.4.91
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 +80 -2
- package/dist/anthropic-auth-plugin.js +246 -195
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +6 -3
- package/dist/cli-parsing.test.js +23 -0
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +72 -46
- package/dist/commands/merge-worktree.js +6 -3
- package/dist/commands/new-worktree.js +18 -7
- package/dist/commands/worktrees.js +71 -7
- package/dist/context-awareness-plugin.js +52 -50
- package/dist/context-awareness-plugin.test.js +68 -1
- package/dist/discord-bot.js +126 -54
- package/dist/discord-utils.test.js +19 -0
- package/dist/errors.js +0 -5
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +33 -72
- package/dist/forum-sync/config.js +2 -2
- package/dist/forum-sync/markdown.js +4 -8
- package/dist/hrana-server.js +11 -3
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/ipc-tools-plugin.js +11 -4
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +0 -1
- package/dist/markdown.js +2 -2
- package/dist/message-preprocessing.js +100 -16
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/opencode-command-detection.js +70 -0
- package/dist/opencode-command-detection.test.js +210 -0
- package/dist/opencode-interrupt-plugin.js +64 -8
- package/dist/opencode-interrupt-plugin.test.js +23 -39
- package/dist/opencode.js +16 -20
- package/dist/pkce.js +23 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/queue-advanced-question.e2e.test.js +127 -42
- package/dist/sentry.js +7 -114
- package/dist/session-handler/event-stream-state.js +1 -1
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +197 -45
- package/dist/session-title-rename.test.js +80 -0
- package/dist/store.js +1 -2
- package/dist/system-message.js +105 -49
- package/dist/system-message.test.js +598 -15
- package/dist/task-runner.js +7 -4
- package/dist/task-schedule.js +2 -0
- package/dist/thread-message-queue.e2e.test.js +18 -11
- package/dist/unnest-code-blocks.js +11 -1
- package/dist/unnest-code-blocks.test.js +32 -0
- package/dist/voice-handler.js +15 -5
- package/dist/voice.js +53 -23
- package/dist/voice.test.js +2 -0
- package/dist/worktrees.js +111 -120
- package/package.json +15 -19
- package/skills/lintcn/SKILL.md +6 -1
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +3 -2
- package/skills/spiceflow/SKILL.md +1 -1
- package/skills/usecomputer/SKILL.md +174 -249
- package/src/agent-model.e2e.test.ts +95 -2
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +474 -403
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +6 -3
- package/src/cli-parsing.test.ts +32 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +93 -62
- package/src/commands/merge-worktree.ts +8 -3
- package/src/commands/new-worktree.ts +22 -10
- package/src/commands/worktrees.ts +86 -5
- package/src/context-awareness-plugin.test.ts +77 -1
- package/src/context-awareness-plugin.ts +85 -64
- package/src/discord-bot.ts +135 -56
- package/src/discord-utils.test.ts +21 -0
- package/src/errors.ts +0 -6
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +39 -85
- package/src/forum-sync/config.ts +2 -2
- package/src/forum-sync/markdown.ts +5 -9
- package/src/hrana-server.ts +15 -3
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/ipc-tools-plugin.ts +16 -8
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +0 -1
- package/src/markdown.ts +2 -2
- package/src/message-preprocessing.ts +117 -16
- package/src/onboarding-tutorial.ts +1 -1
- package/src/opencode-command-detection.test.ts +268 -0
- package/src/opencode-command-detection.ts +79 -0
- package/src/opencode-interrupt-plugin.test.ts +93 -50
- package/src/opencode-interrupt-plugin.ts +86 -9
- package/src/opencode.ts +16 -22
- package/src/plugin-logger.ts +68 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/queue-advanced-question.e2e.test.ts +243 -158
- package/src/sentry.ts +7 -120
- package/src/session-handler/event-stream-state.ts +1 -1
- package/src/session-handler/thread-runtime-state.ts +17 -0
- package/src/session-handler/thread-session-runtime.ts +232 -46
- package/src/session-title-rename.test.ts +112 -0
- package/src/store.ts +3 -8
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +136 -63
- package/src/task-runner.ts +7 -4
- package/src/task-schedule.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +22 -11
- package/src/undici.d.ts +12 -0
- package/src/unnest-code-blocks.test.ts +34 -0
- package/src/unnest-code-blocks.ts +18 -1
- package/src/voice-handler.ts +18 -4
- package/src/voice.test.ts +2 -0
- package/src/voice.ts +68 -23
- package/src/worktrees.ts +152 -156
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { extractLeadingOpencodeCommand } from './opencode-command-detection.js';
|
|
3
|
+
const fixtures = [
|
|
4
|
+
{
|
|
5
|
+
name: 'build',
|
|
6
|
+
discordCommandName: 'build-cmd',
|
|
7
|
+
description: 'build the project',
|
|
8
|
+
source: 'command',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: 'namespace:foo',
|
|
12
|
+
discordCommandName: 'namespace-foo-cmd',
|
|
13
|
+
description: 'namespaced',
|
|
14
|
+
source: 'command',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'review',
|
|
18
|
+
discordCommandName: 'review-skill',
|
|
19
|
+
description: 'review skill',
|
|
20
|
+
source: 'skill',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'plan',
|
|
24
|
+
discordCommandName: 'plan-mcp-prompt',
|
|
25
|
+
description: 'plan via mcp',
|
|
26
|
+
source: 'mcp',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
describe('extractLeadingOpencodeCommand', () => {
|
|
30
|
+
test('plain /build with args', () => {
|
|
31
|
+
expect(extractLeadingOpencodeCommand('/build foo bar', fixtures)).toMatchInlineSnapshot(`
|
|
32
|
+
{
|
|
33
|
+
"command": {
|
|
34
|
+
"arguments": "foo bar",
|
|
35
|
+
"name": "build",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
`);
|
|
39
|
+
});
|
|
40
|
+
test('plain /build no args', () => {
|
|
41
|
+
expect(extractLeadingOpencodeCommand('/build', fixtures))
|
|
42
|
+
.toMatchInlineSnapshot(`
|
|
43
|
+
{
|
|
44
|
+
"command": {
|
|
45
|
+
"arguments": "",
|
|
46
|
+
"name": "build",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
`);
|
|
50
|
+
});
|
|
51
|
+
test('/build-cmd suffix resolves to build', () => {
|
|
52
|
+
expect(extractLeadingOpencodeCommand('/build-cmd hello world', fixtures)).toMatchInlineSnapshot(`
|
|
53
|
+
{
|
|
54
|
+
"command": {
|
|
55
|
+
"arguments": "hello world",
|
|
56
|
+
"name": "build",
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
`);
|
|
60
|
+
});
|
|
61
|
+
test('-skill suffix', () => {
|
|
62
|
+
expect(extractLeadingOpencodeCommand('/review-skill a b', fixtures)).toMatchInlineSnapshot(`
|
|
63
|
+
{
|
|
64
|
+
"command": {
|
|
65
|
+
"arguments": "a b",
|
|
66
|
+
"name": "review",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
`);
|
|
70
|
+
});
|
|
71
|
+
test('-mcp-prompt suffix', () => {
|
|
72
|
+
expect(extractLeadingOpencodeCommand('/plan-mcp-prompt go', fixtures)).toMatchInlineSnapshot(`
|
|
73
|
+
{
|
|
74
|
+
"command": {
|
|
75
|
+
"arguments": "go",
|
|
76
|
+
"name": "plan",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
`);
|
|
80
|
+
});
|
|
81
|
+
test('original namespaced name with colon', () => {
|
|
82
|
+
expect(extractLeadingOpencodeCommand('/namespace:foo arg', fixtures)).toMatchInlineSnapshot(`
|
|
83
|
+
{
|
|
84
|
+
"command": {
|
|
85
|
+
"arguments": "arg",
|
|
86
|
+
"name": "namespace:foo",
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
`);
|
|
90
|
+
});
|
|
91
|
+
test('discord-sanitized namespaced name', () => {
|
|
92
|
+
expect(extractLeadingOpencodeCommand('/namespace-foo-cmd arg', fixtures)).toMatchInlineSnapshot(`
|
|
93
|
+
{
|
|
94
|
+
"command": {
|
|
95
|
+
"arguments": "arg",
|
|
96
|
+
"name": "namespace:foo",
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
`);
|
|
100
|
+
});
|
|
101
|
+
test('kimaki-cli prefix on its own line', () => {
|
|
102
|
+
expect(extractLeadingOpencodeCommand('» **kimaki-cli:**\n/build foo bar', fixtures)).toMatchInlineSnapshot(`
|
|
103
|
+
{
|
|
104
|
+
"command": {
|
|
105
|
+
"arguments": "foo bar",
|
|
106
|
+
"name": "build",
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
`);
|
|
110
|
+
});
|
|
111
|
+
test('queue-style user prefix on its own line', () => {
|
|
112
|
+
expect(extractLeadingOpencodeCommand('» **Tommy:**\n/build hey', fixtures)).toMatchInlineSnapshot(`
|
|
113
|
+
{
|
|
114
|
+
"command": {
|
|
115
|
+
"arguments": "hey",
|
|
116
|
+
"name": "build",
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
`);
|
|
120
|
+
});
|
|
121
|
+
test('username containing asterisk on its own line', () => {
|
|
122
|
+
expect(extractLeadingOpencodeCommand('» **A*B:**\n/build hi', fixtures)).toMatchInlineSnapshot(`
|
|
123
|
+
{
|
|
124
|
+
"command": {
|
|
125
|
+
"arguments": "hi",
|
|
126
|
+
"name": "build",
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
`);
|
|
130
|
+
});
|
|
131
|
+
test('Context from thread wrapping still detects command', () => {
|
|
132
|
+
const wrapped = 'Context from thread:\nsome starter text\n\nUser request:\n/build foo';
|
|
133
|
+
expect(extractLeadingOpencodeCommand(wrapped, fixtures))
|
|
134
|
+
.toMatchInlineSnapshot(`
|
|
135
|
+
{
|
|
136
|
+
"command": {
|
|
137
|
+
"arguments": "foo",
|
|
138
|
+
"name": "build",
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
`);
|
|
142
|
+
});
|
|
143
|
+
test('unknown command returns null', () => {
|
|
144
|
+
expect(extractLeadingOpencodeCommand('/nothing here', fixtures)).toMatchInlineSnapshot(`null`);
|
|
145
|
+
});
|
|
146
|
+
test('no leading slash on any line returns null', () => {
|
|
147
|
+
expect(extractLeadingOpencodeCommand('hello /build\nmore text', fixtures)).toMatchInlineSnapshot(`null`);
|
|
148
|
+
});
|
|
149
|
+
test('just slash returns null', () => {
|
|
150
|
+
expect(extractLeadingOpencodeCommand('/', fixtures)).toMatchInlineSnapshot(`null`);
|
|
151
|
+
});
|
|
152
|
+
test('empty string returns null', () => {
|
|
153
|
+
expect(extractLeadingOpencodeCommand('', fixtures)).toMatchInlineSnapshot(`null`);
|
|
154
|
+
});
|
|
155
|
+
test('empty registry returns null even for known-looking commands', () => {
|
|
156
|
+
expect(extractLeadingOpencodeCommand('/build foo', [])).toMatchInlineSnapshot(`null`);
|
|
157
|
+
});
|
|
158
|
+
test('leading whitespace before slash still matches', () => {
|
|
159
|
+
expect(extractLeadingOpencodeCommand(' /build foo', fixtures)).toMatchInlineSnapshot(`
|
|
160
|
+
{
|
|
161
|
+
"command": {
|
|
162
|
+
"arguments": "foo",
|
|
163
|
+
"name": "build",
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
`);
|
|
167
|
+
});
|
|
168
|
+
test('first matching line wins', () => {
|
|
169
|
+
const prompt = 'noise line\n/build first args\n/review second args';
|
|
170
|
+
expect(extractLeadingOpencodeCommand(prompt, fixtures))
|
|
171
|
+
.toMatchInlineSnapshot(`
|
|
172
|
+
{
|
|
173
|
+
"command": {
|
|
174
|
+
"arguments": "first args",
|
|
175
|
+
"name": "build",
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
`);
|
|
179
|
+
});
|
|
180
|
+
test('unknown command on one line, known on next', () => {
|
|
181
|
+
const prompt = '/unknown foo\n/build bar';
|
|
182
|
+
expect(extractLeadingOpencodeCommand(prompt, fixtures))
|
|
183
|
+
.toMatchInlineSnapshot(`
|
|
184
|
+
{
|
|
185
|
+
"command": {
|
|
186
|
+
"arguments": "bar",
|
|
187
|
+
"name": "build",
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
`);
|
|
191
|
+
});
|
|
192
|
+
test('suffix strip does not clobber a command whose name happens to end in -cmd', () => {
|
|
193
|
+
const custom = [
|
|
194
|
+
{
|
|
195
|
+
name: 'deploy-cmd',
|
|
196
|
+
discordCommandName: 'deploy-cmd-cmd',
|
|
197
|
+
description: '',
|
|
198
|
+
source: 'command',
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
expect(extractLeadingOpencodeCommand('/deploy-cmd now', custom)).toMatchInlineSnapshot(`
|
|
202
|
+
{
|
|
203
|
+
"command": {
|
|
204
|
+
"arguments": "now",
|
|
205
|
+
"name": "deploy-cmd",
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
`);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -8,6 +8,53 @@
|
|
|
8
8
|
// (createInterruptState). The plugin hooks only interact with the returned
|
|
9
9
|
// API — they cannot directly touch Maps/Sets or break invariants like
|
|
10
10
|
// forgetting to clear a timer.
|
|
11
|
+
function toPromptParts(parts) {
|
|
12
|
+
return parts.reduce((acc, part) => {
|
|
13
|
+
if (part.type === 'text') {
|
|
14
|
+
acc.push({
|
|
15
|
+
id: part.id,
|
|
16
|
+
type: 'text',
|
|
17
|
+
text: part.text,
|
|
18
|
+
synthetic: part.synthetic,
|
|
19
|
+
ignored: part.ignored,
|
|
20
|
+
time: part.time,
|
|
21
|
+
metadata: part.metadata,
|
|
22
|
+
});
|
|
23
|
+
return acc;
|
|
24
|
+
}
|
|
25
|
+
if (part.type === 'file') {
|
|
26
|
+
acc.push({
|
|
27
|
+
id: part.id,
|
|
28
|
+
type: 'file',
|
|
29
|
+
mime: part.mime,
|
|
30
|
+
filename: part.filename,
|
|
31
|
+
url: part.url,
|
|
32
|
+
source: part.source,
|
|
33
|
+
});
|
|
34
|
+
return acc;
|
|
35
|
+
}
|
|
36
|
+
if (part.type === 'agent') {
|
|
37
|
+
acc.push({
|
|
38
|
+
id: part.id,
|
|
39
|
+
type: 'agent',
|
|
40
|
+
name: part.name,
|
|
41
|
+
source: part.source,
|
|
42
|
+
});
|
|
43
|
+
return acc;
|
|
44
|
+
}
|
|
45
|
+
if (part.type === 'subtask') {
|
|
46
|
+
acc.push({
|
|
47
|
+
id: part.id,
|
|
48
|
+
type: 'subtask',
|
|
49
|
+
prompt: part.prompt,
|
|
50
|
+
description: part.description,
|
|
51
|
+
agent: part.agent,
|
|
52
|
+
});
|
|
53
|
+
return acc;
|
|
54
|
+
}
|
|
55
|
+
return acc;
|
|
56
|
+
}, []);
|
|
57
|
+
}
|
|
11
58
|
const DEFAULT_INTERRUPT_STEP_TIMEOUT_MS = 3_000;
|
|
12
59
|
function getInterruptStepTimeoutMsFromEnv() {
|
|
13
60
|
const raw = process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'];
|
|
@@ -89,7 +136,7 @@ function createInterruptState() {
|
|
|
89
136
|
},
|
|
90
137
|
// Schedule a timeout to interrupt a pending message. Cleans up any
|
|
91
138
|
// existing timer for the same messageID before setting a new one.
|
|
92
|
-
schedulePending({ messageID, sessionID, delayMs, onTimeout, }) {
|
|
139
|
+
schedulePending({ messageID, sessionID, parts, delayMs, onTimeout, }) {
|
|
93
140
|
const existing = pendingByMessageId.get(messageID);
|
|
94
141
|
if (existing) {
|
|
95
142
|
clearTimeout(existing.timer);
|
|
@@ -100,6 +147,7 @@ function createInterruptState() {
|
|
|
100
147
|
started: false,
|
|
101
148
|
timer,
|
|
102
149
|
abortAfterStepMessageID: latestAssistantMessageIDBySession.get(sessionID),
|
|
150
|
+
parts,
|
|
103
151
|
agent: undefined,
|
|
104
152
|
model: undefined,
|
|
105
153
|
});
|
|
@@ -159,6 +207,7 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
159
207
|
state.schedulePending({
|
|
160
208
|
messageID,
|
|
161
209
|
sessionID,
|
|
210
|
+
parts: pending.parts,
|
|
162
211
|
delayMs: 200,
|
|
163
212
|
onTimeout: () => {
|
|
164
213
|
void interruptPendingMessage(messageID);
|
|
@@ -193,19 +242,24 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
193
242
|
state.clearPending(messageID);
|
|
194
243
|
return;
|
|
195
244
|
}
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
245
|
+
// Resubmit the original queued user message after abort.
|
|
246
|
+
// session.abort() clears OpenCode's internal prompt queue, so resuming
|
|
247
|
+
// with an empty parts array can silently drop the user's message.
|
|
248
|
+
// Keep the original messageID + parts and preserve agent/model context so
|
|
249
|
+
// session overrides (issue #77) survive the abort + replay path.
|
|
250
|
+
const replayBody = {
|
|
251
|
+
messageID,
|
|
252
|
+
parts: currentPending.parts,
|
|
253
|
+
};
|
|
200
254
|
if (currentPending.agent) {
|
|
201
|
-
|
|
255
|
+
replayBody.agent = currentPending.agent;
|
|
202
256
|
}
|
|
203
257
|
if (currentPending.model) {
|
|
204
|
-
|
|
258
|
+
replayBody.model = currentPending.model;
|
|
205
259
|
}
|
|
206
260
|
await ctx.client.session.promptAsync({
|
|
207
261
|
path: { id: sessionID },
|
|
208
|
-
body:
|
|
262
|
+
body: replayBody,
|
|
209
263
|
});
|
|
210
264
|
state.clearPending(messageID);
|
|
211
265
|
const nextPending = state.getNextPendingForSession(sessionID);
|
|
@@ -215,6 +269,7 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
215
269
|
state.schedulePending({
|
|
216
270
|
messageID: nextPending.messageID,
|
|
217
271
|
sessionID,
|
|
272
|
+
parts: nextPending.pending.parts,
|
|
218
273
|
delayMs: 50,
|
|
219
274
|
onTimeout: () => {
|
|
220
275
|
void interruptPendingMessage(nextPending.messageID);
|
|
@@ -288,6 +343,7 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
|
|
|
288
343
|
state.schedulePending({
|
|
289
344
|
messageID,
|
|
290
345
|
sessionID,
|
|
346
|
+
parts: toPromptParts(output.parts),
|
|
291
347
|
delayMs: interruptStepTimeoutMs,
|
|
292
348
|
onTimeout: () => {
|
|
293
349
|
void interruptPendingMessage(messageID);
|
|
@@ -230,7 +230,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
230
230
|
expect(promptAsyncCalls).toEqual([
|
|
231
231
|
{
|
|
232
232
|
path: { id: REAL_RATE_LIMIT_CASE.sessionID },
|
|
233
|
-
body: {
|
|
233
|
+
body: {
|
|
234
|
+
messageID: REAL_RATE_LIMIT_CASE.queuedMessageID,
|
|
235
|
+
parts: [{ type: 'text', text: 'user message' }],
|
|
236
|
+
},
|
|
234
237
|
},
|
|
235
238
|
]);
|
|
236
239
|
});
|
|
@@ -287,25 +290,7 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
287
290
|
expect(abortCalls).toEqual([]);
|
|
288
291
|
expect(promptAsyncCalls).toEqual([]);
|
|
289
292
|
});
|
|
290
|
-
|
|
291
|
-
//
|
|
292
|
-
// Timeline:
|
|
293
|
-
// 1. Session is busy streaming response to firstMsg
|
|
294
|
-
// 2. User sends userMsg (queued via promptAsync in opencode)
|
|
295
|
-
// 3. 3s timeout fires - no assistant started on userMsg
|
|
296
|
-
// 4. Plugin aborts session → session goes idle
|
|
297
|
-
// 5. Plugin sends promptAsync({parts:[]}) → opencode creates NEW empty
|
|
298
|
-
// user message and processes THAT instead of userMsg
|
|
299
|
-
// 6. userMsg is silently lost — no assistant ever responds to it
|
|
300
|
-
//
|
|
301
|
-
// Root cause: session.abort() clears opencode's internal prompt queue.
|
|
302
|
-
// The empty promptAsync({parts:[]}) is supposed to "resume" but instead
|
|
303
|
-
// creates a separate message. The user's actual message is gone.
|
|
304
|
-
//
|
|
305
|
-
// This is a unit-level repro — it proves the plugin clears the user
|
|
306
|
-
// message from tracking without any assistant acknowledgement. A full
|
|
307
|
-
// e2e test is needed to prove the message is lost in Discord.
|
|
308
|
-
test.todo('BUG REPRO: user message dropped after abort because promptAsync({parts:[]}) replaces it', async () => {
|
|
293
|
+
test('abort recovery replays the original queued user message', async () => {
|
|
309
294
|
process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'] = '20';
|
|
310
295
|
const abortCalls = [];
|
|
311
296
|
const promptAsyncCalls = [];
|
|
@@ -349,25 +334,18 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
349
334
|
await delay({ ms: 20 });
|
|
350
335
|
// 5. Verify plugin aborted the session
|
|
351
336
|
expect(abortCalls).toEqual([{ path: { id: sessionID } }]);
|
|
352
|
-
// 6.
|
|
353
|
-
//
|
|
354
|
-
//
|
|
337
|
+
// 6. Recovery should replay the queued message itself, not an empty
|
|
338
|
+
// resume prompt. This preserves the original messageID + parts after
|
|
339
|
+
// session.abort() clears OpenCode's internal prompt queue.
|
|
355
340
|
expect(promptAsyncCalls).toEqual([
|
|
356
|
-
{
|
|
341
|
+
{
|
|
342
|
+
path: { id: sessionID },
|
|
343
|
+
body: {
|
|
344
|
+
messageID: userMsgID,
|
|
345
|
+
parts: [{ type: 'text', text: 'user message' }],
|
|
346
|
+
},
|
|
347
|
+
},
|
|
357
348
|
]);
|
|
358
|
-
// 7. Verify the plugin cleared userMsgID from pending tracking.
|
|
359
|
-
// Re-registering it via chatHook succeeds (doesn't hit the dedup guard
|
|
360
|
-
// at line 225), proving the plugin considers it "handled" even though
|
|
361
|
-
// no assistant message.updated with parentID=userMsgID was ever received.
|
|
362
|
-
//
|
|
363
|
-
// In production this means the user's message is silently lost:
|
|
364
|
-
// - opencode processed the empty prompt instead
|
|
365
|
-
// - the bot thinks the message was dispatched (promptAsync returned OK)
|
|
366
|
-
// - nobody re-sends the user's actual message
|
|
367
|
-
let reRegisteredWithoutDedup = false;
|
|
368
|
-
await chatHook({ sessionID, messageID: userMsgID }, createChatOutput({ sessionID, messageID: userMsgID }));
|
|
369
|
-
reRegisteredWithoutDedup = true;
|
|
370
|
-
expect(reRegisteredWithoutDedup).toBe(true);
|
|
371
349
|
});
|
|
372
350
|
test('real sleep interrupt trace still recovers queued interrupt message', async () => {
|
|
373
351
|
process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'] = '20';
|
|
@@ -414,7 +392,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
414
392
|
expect(promptAsyncCalls).toEqual([
|
|
415
393
|
{
|
|
416
394
|
path: { id: REAL_SLEEP_INTERRUPT_CASE.sessionID },
|
|
417
|
-
body: {
|
|
395
|
+
body: {
|
|
396
|
+
messageID: REAL_SLEEP_INTERRUPT_CASE.interruptingMessageID,
|
|
397
|
+
parts: [{ type: 'text', text: 'user message' }],
|
|
398
|
+
},
|
|
418
399
|
},
|
|
419
400
|
]);
|
|
420
401
|
});
|
|
@@ -467,7 +448,10 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
|
|
|
467
448
|
expect(promptAsyncCalls).toEqual([
|
|
468
449
|
{
|
|
469
450
|
path: { id: sessionID },
|
|
470
|
-
body: {
|
|
451
|
+
body: {
|
|
452
|
+
messageID: queuedMessageID,
|
|
453
|
+
parts: [{ type: 'text', text: 'user message' }],
|
|
454
|
+
},
|
|
471
455
|
},
|
|
472
456
|
]);
|
|
473
457
|
});
|
package/dist/opencode.js
CHANGED
|
@@ -314,10 +314,14 @@ async function ensureSingleServer() {
|
|
|
314
314
|
async function startSingleServer() {
|
|
315
315
|
ensureProcessCleanupHandlersRegistered();
|
|
316
316
|
const port = await getOpenPort();
|
|
317
|
-
const serveArgs = [
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
317
|
+
const serveArgs = [
|
|
318
|
+
'serve',
|
|
319
|
+
'--port',
|
|
320
|
+
port.toString(),
|
|
321
|
+
'--print-logs',
|
|
322
|
+
'--log-level',
|
|
323
|
+
'WARN',
|
|
324
|
+
];
|
|
321
325
|
const { command: spawnCommand, args: spawnArgs, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
|
|
322
326
|
resolvedCommand: resolveOpencodeCommand(),
|
|
323
327
|
baseArgs: serveArgs,
|
|
@@ -460,7 +464,6 @@ async function startSingleServer() {
|
|
|
460
464
|
});
|
|
461
465
|
startingServerProcess = serverProcess;
|
|
462
466
|
// Buffer logs until we know if server started successfully.
|
|
463
|
-
// Once ready, switch to forwarding if --verbose-opencode-server is set.
|
|
464
467
|
const logBuffer = [];
|
|
465
468
|
const startupStderrTail = [];
|
|
466
469
|
let serverReady = false;
|
|
@@ -473,10 +476,8 @@ async function startSingleServer() {
|
|
|
473
476
|
logBuffer.push(...lines.map((line) => `[stdout] ${line}`));
|
|
474
477
|
return;
|
|
475
478
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
opencodeLogger.log(`[server:${port}] ${line}`);
|
|
479
|
-
}
|
|
479
|
+
for (const line of lines) {
|
|
480
|
+
opencodeLogger.log(line);
|
|
480
481
|
}
|
|
481
482
|
}
|
|
482
483
|
catch (error) {
|
|
@@ -492,10 +493,8 @@ async function startSingleServer() {
|
|
|
492
493
|
pushStartupStderrTail({ stderrTail: startupStderrTail, chunk });
|
|
493
494
|
return;
|
|
494
495
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
opencodeLogger.error(`[server:${port}] ${line}`);
|
|
498
|
-
}
|
|
496
|
+
for (const line of lines) {
|
|
497
|
+
opencodeLogger.error(line);
|
|
499
498
|
}
|
|
500
499
|
}
|
|
501
500
|
catch (error) {
|
|
@@ -561,12 +560,10 @@ async function startSingleServer() {
|
|
|
561
560
|
}
|
|
562
561
|
serverReady = true;
|
|
563
562
|
opencodeLogger.log(`Server ready on port ${port}`);
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
opencodeLogger.log(`[server:${port}:startup] ${line}`);
|
|
569
|
-
}
|
|
563
|
+
// Always dump startup logs so plugin loading errors and other startup output
|
|
564
|
+
// are visible in kimaki.log.
|
|
565
|
+
for (const line of logBuffer) {
|
|
566
|
+
opencodeLogger.log(line);
|
|
570
567
|
}
|
|
571
568
|
const server = {
|
|
572
569
|
process: serverProcess,
|
|
@@ -628,7 +625,6 @@ export async function initializeOpencodeForDirectory(directory, _options) {
|
|
|
628
625
|
}
|
|
629
626
|
if (!initializedDirectories.has(directory)) {
|
|
630
627
|
initializedDirectories.add(directory);
|
|
631
|
-
opencodeLogger.log(`Using shared server on port ${server.port} for directory: ${directory}`);
|
|
632
628
|
}
|
|
633
629
|
return () => {
|
|
634
630
|
if (!singleServer) {
|
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (Proof Key for Code Exchange) utilities using Web Crypto API.
|
|
3
|
+
* Zero-dependency replacement for @openauthjs/openauth/pkce.
|
|
4
|
+
* Works in Node.js 20+ and browsers.
|
|
5
|
+
*
|
|
6
|
+
* Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
|
|
7
|
+
*/
|
|
8
|
+
function base64urlEncode(bytes) {
|
|
9
|
+
let binary = '';
|
|
10
|
+
for (const byte of bytes) {
|
|
11
|
+
binary += String.fromCharCode(byte);
|
|
12
|
+
}
|
|
13
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
14
|
+
}
|
|
15
|
+
export async function generatePKCE() {
|
|
16
|
+
const verifierBytes = new Uint8Array(32);
|
|
17
|
+
crypto.getRandomValues(verifierBytes);
|
|
18
|
+
const verifier = base64urlEncode(verifierBytes);
|
|
19
|
+
const data = new TextEncoder().encode(verifier);
|
|
20
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
21
|
+
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
|
|
22
|
+
return { verifier, challenge };
|
|
23
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import util from 'node:util';
|
|
4
|
+
import { sanitizeSensitiveText, sanitizeUnknownValue } from './privacy-sanitizer.js';
|
|
5
|
+
let pluginLogFilePath = null;
|
|
6
|
+
export function setPluginLogFilePath(dataDir) {
|
|
7
|
+
pluginLogFilePath = path.join(dataDir, 'kimaki.log');
|
|
8
|
+
}
|
|
9
|
+
function formatArg(arg) {
|
|
10
|
+
if (typeof arg === 'string') {
|
|
11
|
+
return sanitizeSensitiveText(arg, { redactPaths: false });
|
|
12
|
+
}
|
|
13
|
+
const safeArg = sanitizeUnknownValue(arg, { redactPaths: false });
|
|
14
|
+
return util.inspect(safeArg, { colors: false, depth: 4 });
|
|
15
|
+
}
|
|
16
|
+
export function formatPluginErrorWithStack(error) {
|
|
17
|
+
if (error instanceof Error) {
|
|
18
|
+
return sanitizeSensitiveText(error.stack ?? `${error.name}: ${error.message}`, { redactPaths: false });
|
|
19
|
+
}
|
|
20
|
+
if (typeof error === 'string') {
|
|
21
|
+
return sanitizeSensitiveText(error, { redactPaths: false });
|
|
22
|
+
}
|
|
23
|
+
const safeError = sanitizeUnknownValue(error, { redactPaths: false });
|
|
24
|
+
return sanitizeSensitiveText(util.inspect(safeError, { colors: false, depth: 4 }), {
|
|
25
|
+
redactPaths: false,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function writeToFile(level, prefix, args) {
|
|
29
|
+
if (!pluginLogFilePath) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const timestamp = new Date().toISOString();
|
|
33
|
+
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`;
|
|
34
|
+
try {
|
|
35
|
+
fs.appendFileSync(pluginLogFilePath, message);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Plugin logging must never break the OpenCode plugin process.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function createPluginLogger(prefix) {
|
|
42
|
+
return {
|
|
43
|
+
log: (...args) => {
|
|
44
|
+
writeToFile('LOG', prefix, args);
|
|
45
|
+
},
|
|
46
|
+
info: (...args) => {
|
|
47
|
+
writeToFile('INFO', prefix, args);
|
|
48
|
+
},
|
|
49
|
+
warn: (...args) => {
|
|
50
|
+
writeToFile('WARN', prefix, args);
|
|
51
|
+
},
|
|
52
|
+
error: (...args) => {
|
|
53
|
+
writeToFile('ERROR', prefix, args);
|
|
54
|
+
},
|
|
55
|
+
debug: (...args) => {
|
|
56
|
+
writeToFile('DEBUG', prefix, args);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -85,12 +85,12 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
85
85
|
"--- from: user (queue-permission-tester)
|
|
86
86
|
PERMISSION_TYPING_MARKER
|
|
87
87
|
--- from: assistant (TestBot)
|
|
88
|
+
⬥ requesting external read permission
|
|
88
89
|
⚠️ **Permission Required**
|
|
89
90
|
**Type:** \`external_directory\`
|
|
90
91
|
Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)
|
|
91
92
|
**Pattern:** \`/Users/morse/*\`
|
|
92
93
|
✅ Permission **accepted**
|
|
93
|
-
⬥ requesting external read permission
|
|
94
94
|
[user clicks button]
|
|
95
95
|
⬥ permission-flow-done
|
|
96
96
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|