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.
Files changed (114) hide show
  1. package/dist/agent-model.e2e.test.js +80 -2
  2. package/dist/anthropic-auth-plugin.js +246 -195
  3. package/dist/anthropic-auth-plugin.test.js +125 -0
  4. package/dist/anthropic-auth-state.js +231 -0
  5. package/dist/bin.js +6 -3
  6. package/dist/cli-parsing.test.js +23 -0
  7. package/dist/cli-send-thread.e2e.test.js +2 -2
  8. package/dist/cli.js +72 -46
  9. package/dist/commands/merge-worktree.js +6 -3
  10. package/dist/commands/new-worktree.js +18 -7
  11. package/dist/commands/worktrees.js +71 -7
  12. package/dist/context-awareness-plugin.js +52 -50
  13. package/dist/context-awareness-plugin.test.js +68 -1
  14. package/dist/discord-bot.js +126 -54
  15. package/dist/discord-utils.test.js +19 -0
  16. package/dist/errors.js +0 -5
  17. package/dist/exec-async.js +26 -0
  18. package/dist/external-opencode-sync.js +33 -72
  19. package/dist/forum-sync/config.js +2 -2
  20. package/dist/forum-sync/markdown.js +4 -8
  21. package/dist/hrana-server.js +11 -3
  22. package/dist/image-optimizer-plugin.js +153 -0
  23. package/dist/ipc-tools-plugin.js +11 -4
  24. package/dist/kimaki-opencode-plugin.js +1 -0
  25. package/dist/logger.js +0 -1
  26. package/dist/markdown.js +2 -2
  27. package/dist/message-preprocessing.js +100 -16
  28. package/dist/onboarding-tutorial.js +1 -1
  29. package/dist/opencode-command-detection.js +70 -0
  30. package/dist/opencode-command-detection.test.js +210 -0
  31. package/dist/opencode-interrupt-plugin.js +64 -8
  32. package/dist/opencode-interrupt-plugin.test.js +23 -39
  33. package/dist/opencode.js +16 -20
  34. package/dist/pkce.js +23 -0
  35. package/dist/plugin-logger.js +59 -0
  36. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  37. package/dist/queue-advanced-question.e2e.test.js +127 -42
  38. package/dist/sentry.js +7 -114
  39. package/dist/session-handler/event-stream-state.js +1 -1
  40. package/dist/session-handler/thread-runtime-state.js +9 -0
  41. package/dist/session-handler/thread-session-runtime.js +197 -45
  42. package/dist/session-title-rename.test.js +80 -0
  43. package/dist/store.js +1 -2
  44. package/dist/system-message.js +105 -49
  45. package/dist/system-message.test.js +598 -15
  46. package/dist/task-runner.js +7 -4
  47. package/dist/task-schedule.js +2 -0
  48. package/dist/thread-message-queue.e2e.test.js +18 -11
  49. package/dist/unnest-code-blocks.js +11 -1
  50. package/dist/unnest-code-blocks.test.js +32 -0
  51. package/dist/voice-handler.js +15 -5
  52. package/dist/voice.js +53 -23
  53. package/dist/voice.test.js +2 -0
  54. package/dist/worktrees.js +111 -120
  55. package/package.json +15 -19
  56. package/skills/lintcn/SKILL.md +6 -1
  57. package/skills/new-skill/SKILL.md +211 -0
  58. package/skills/npm-package/SKILL.md +3 -2
  59. package/skills/spiceflow/SKILL.md +1 -1
  60. package/skills/usecomputer/SKILL.md +174 -249
  61. package/src/agent-model.e2e.test.ts +95 -2
  62. package/src/anthropic-auth-plugin.test.ts +159 -0
  63. package/src/anthropic-auth-plugin.ts +474 -403
  64. package/src/anthropic-auth-state.ts +282 -0
  65. package/src/bin.ts +6 -3
  66. package/src/cli-parsing.test.ts +32 -0
  67. package/src/cli-send-thread.e2e.test.ts +2 -2
  68. package/src/cli.ts +93 -62
  69. package/src/commands/merge-worktree.ts +8 -3
  70. package/src/commands/new-worktree.ts +22 -10
  71. package/src/commands/worktrees.ts +86 -5
  72. package/src/context-awareness-plugin.test.ts +77 -1
  73. package/src/context-awareness-plugin.ts +85 -64
  74. package/src/discord-bot.ts +135 -56
  75. package/src/discord-utils.test.ts +21 -0
  76. package/src/errors.ts +0 -6
  77. package/src/exec-async.ts +35 -0
  78. package/src/external-opencode-sync.ts +39 -85
  79. package/src/forum-sync/config.ts +2 -2
  80. package/src/forum-sync/markdown.ts +5 -9
  81. package/src/hrana-server.ts +15 -3
  82. package/src/image-optimizer-plugin.ts +194 -0
  83. package/src/ipc-tools-plugin.ts +16 -8
  84. package/src/kimaki-opencode-plugin.ts +1 -0
  85. package/src/logger.ts +0 -1
  86. package/src/markdown.ts +2 -2
  87. package/src/message-preprocessing.ts +117 -16
  88. package/src/onboarding-tutorial.ts +1 -1
  89. package/src/opencode-command-detection.test.ts +268 -0
  90. package/src/opencode-command-detection.ts +79 -0
  91. package/src/opencode-interrupt-plugin.test.ts +93 -50
  92. package/src/opencode-interrupt-plugin.ts +86 -9
  93. package/src/opencode.ts +16 -22
  94. package/src/plugin-logger.ts +68 -0
  95. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  96. package/src/queue-advanced-question.e2e.test.ts +243 -158
  97. package/src/sentry.ts +7 -120
  98. package/src/session-handler/event-stream-state.ts +1 -1
  99. package/src/session-handler/thread-runtime-state.ts +17 -0
  100. package/src/session-handler/thread-session-runtime.ts +232 -46
  101. package/src/session-title-rename.test.ts +112 -0
  102. package/src/store.ts +3 -8
  103. package/src/system-message.test.ts +612 -0
  104. package/src/system-message.ts +136 -63
  105. package/src/task-runner.ts +7 -4
  106. package/src/task-schedule.ts +3 -0
  107. package/src/thread-message-queue.e2e.test.ts +22 -11
  108. package/src/undici.d.ts +12 -0
  109. package/src/unnest-code-blocks.test.ts +34 -0
  110. package/src/unnest-code-blocks.ts +18 -1
  111. package/src/voice-handler.ts +18 -4
  112. package/src/voice.test.ts +2 -0
  113. package/src/voice.ts +68 -23
  114. 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
- // Keep the queued user message execution context across abort+resume.
197
- // Without this, OpenCode re-resolves model defaults and can ignore
198
- // /model session overrides (issue #77).
199
- const resumeBody = { parts: [] };
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
- resumeBody.agent = currentPending.agent;
255
+ replayBody.agent = currentPending.agent;
202
256
  }
203
257
  if (currentPending.model) {
204
- resumeBody.model = currentPending.model;
258
+ replayBody.model = currentPending.model;
205
259
  }
206
260
  await ctx.client.session.promptAsync({
207
261
  path: { id: sessionID },
208
- body: resumeBody,
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: { parts: [] },
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
- // Reproduces production bug from ses_33bb324aaffeQuvMZeixQ9x11N:
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. BUG: plugin sent promptAsync({parts:[]}) which creates a NEW empty
353
- // user message in opencode. The user's actual message (userMsgID) was
354
- // cleared from the prompt queue by abort() and is never processed.
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
- { path: { id: sessionID }, body: { parts: [] } },
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: { parts: [] },
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: { parts: [] },
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 = ['serve', '--port', port.toString()];
318
- if (store.getState().verboseOpencodeServer) {
319
- serveArgs.push('--print-logs', '--log-level', 'DEBUG');
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
- if (store.getState().verboseOpencodeServer) {
477
- for (const line of lines) {
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
- if (store.getState().verboseOpencodeServer) {
496
- for (const line of lines) {
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
- // When verbose mode is enabled, also dump startup logs so plugin loading
565
- // errors and other startup output are visible in kimaki.log.
566
- if (store.getState().verboseOpencodeServer) {
567
- for (const line of logBuffer) {
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*"