kimaki 0.26.0 → 0.27.0

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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -7,7 +7,7 @@
7
7
  // Never use console.log/console.error — plugins must be silent.
8
8
  import crypto from 'node:crypto';
9
9
  import path from 'node:path';
10
- import { createWorktreeCore, removeWorktreeCore } from './git-worktree-core.js';
10
+ import { createWorktreeCore, KIMAKI_WORKTREE_ADAPTER_TYPE, removeWorktreeFromOwnRepository, } from './git-worktree-core.js';
11
11
  /**
12
12
  * Compute the on-disk directory for a managed worktree.
13
13
  * Mirrors getManagedWorktreeDirectory from worktrees.ts but reads KIMAKI_DATA_DIR
@@ -29,14 +29,36 @@ function computeWorktreeDirectory({ projectDirectory, branchName, }) {
29
29
  .replaceAll('/', '-');
30
30
  return path.join(dataDir, 'worktrees', projectHash, withoutPrefix);
31
31
  }
32
- function createKimakiWorktreeAdaptor(projectDirectory) {
32
+ function getWorktreeIdentity(info) {
33
+ if (!info.extra || typeof info.extra !== 'object') {
34
+ return new Error('Kimaki worktree identity is missing');
35
+ }
36
+ const identity = {};
37
+ Object.assign(identity, info.extra);
38
+ if (typeof identity.projectDirectory !== 'string' ||
39
+ !path.isAbsolute(identity.projectDirectory)) {
40
+ return new Error('Kimaki worktree project directory must be absolute');
41
+ }
42
+ if (typeof identity.baseCommit !== 'string' ||
43
+ !/^[0-9a-f]{40}$/i.test(identity.baseCommit)) {
44
+ return new Error('Kimaki worktree base commit must be a full commit SHA');
45
+ }
46
+ return {
47
+ projectDirectory: identity.projectDirectory,
48
+ baseCommit: identity.baseCommit,
49
+ };
50
+ }
51
+ function createKimakiWorktreeAdaptor() {
33
52
  return {
34
53
  name: 'Kimaki Worktree',
35
54
  description: 'Create a git worktree managed by Kimaki',
36
55
  configure(info) {
56
+ const identity = getWorktreeIdentity(info);
57
+ if (identity instanceof Error)
58
+ throw identity;
37
59
  const branchName = info.branch || info.name;
38
60
  const directory = computeWorktreeDirectory({
39
- projectDirectory,
61
+ projectDirectory: identity.projectDirectory,
40
62
  branchName,
41
63
  });
42
64
  if (directory instanceof Error) {
@@ -53,12 +75,14 @@ function createKimakiWorktreeAdaptor(projectDirectory) {
53
75
  if (!info.directory) {
54
76
  throw new Error('Workspace directory not set — configure() likely failed');
55
77
  }
56
- const baseBranch = info.extra?.baseBranch || undefined;
78
+ const identity = getWorktreeIdentity(info);
79
+ if (identity instanceof Error)
80
+ throw identity;
57
81
  const result = await createWorktreeCore({
58
- projectDirectory,
82
+ projectDirectory: identity.projectDirectory,
59
83
  targetDirectory: info.directory,
60
84
  branchName: info.branch || info.name,
61
- baseBranch,
85
+ baseCommit: identity.baseCommit,
62
86
  // Silent log — plugin must not write to stdout/stderr
63
87
  });
64
88
  if (result instanceof Error) {
@@ -68,8 +92,7 @@ function createKimakiWorktreeAdaptor(projectDirectory) {
68
92
  async remove(info) {
69
93
  if (!info.directory)
70
94
  return;
71
- const result = await removeWorktreeCore({
72
- projectDirectory,
95
+ const result = await removeWorktreeFromOwnRepository({
73
96
  worktreeDirectory: info.directory,
74
97
  branchName: info.branch || '',
75
98
  });
@@ -89,7 +112,7 @@ function createKimakiWorktreeAdaptor(projectDirectory) {
89
112
  * Plugin entrypoint — registers the kimaki-worktree adaptor.
90
113
  * Called by OpenCode's plugin loader.
91
114
  */
92
- export const kimakiWorkspaceAdaptorPlugin = async ({ directory, experimental_workspace, }) => {
93
- experimental_workspace.register('kimaki-worktree', createKimakiWorktreeAdaptor(directory));
115
+ export const kimakiWorkspaceAdaptorPlugin = async ({ experimental_workspace, }) => {
116
+ experimental_workspace.register(KIMAKI_WORKTREE_ADAPTER_TYPE, createKimakiWorktreeAdaptor());
94
117
  return {};
95
118
  };
package/dist/logger.js CHANGED
@@ -95,7 +95,16 @@ function formatArg(arg) {
95
95
  }
96
96
  export function formatErrorWithStack(error) {
97
97
  if (error instanceof Error) {
98
- return sanitizeSensitiveText(error.stack ?? `${error.name}: ${error.message}`, { redactPaths: false });
98
+ const seen = new Set();
99
+ const parts = [];
100
+ let current = error;
101
+ while (current instanceof Error && !seen.has(current)) {
102
+ seen.add(current);
103
+ const formatted = sanitizeSensitiveText(current.stack ?? `${current.name}: ${current.message}`, { redactPaths: false });
104
+ parts.push(parts.length === 0 ? formatted : `Caused by: ${formatted}`);
105
+ current = current.cause;
106
+ }
107
+ return parts.join('\n');
99
108
  }
100
109
  if (typeof error === 'string') {
101
110
  return sanitizeSensitiveText(error, { redactPaths: false });
package/dist/markdown.js CHANGED
@@ -104,7 +104,9 @@ export class ShareMarkdown {
104
104
  }
105
105
  }
106
106
  else if (message.role === 'assistant') {
107
- lines.push(`### 🤖 Assistant (${message.modelID || 'unknown model'})`);
107
+ const modelId = [message.providerID, message.modelID].filter(Boolean).join('/') ||
108
+ 'unknown model';
109
+ lines.push(`### 🤖 Assistant (${modelId})`);
108
110
  lines.push('');
109
111
  // Filter and process parts
110
112
  const filteredParts = parts.filter((part) => {
@@ -102,6 +102,10 @@ beforeAll(async () => {
102
102
  await client.session.promptAsync({
103
103
  sessionID,
104
104
  directory: directories.projectDirectory,
105
+ model: {
106
+ providerID: 'deterministic-provider',
107
+ modelID: 'deterministic-v2',
108
+ },
105
109
  parts: [{ type: 'text', text: 'hello markdown test' }],
106
110
  });
107
111
  // Wait for assistant text parts to be fully written (not just message existence).
@@ -139,6 +143,10 @@ beforeAll(async () => {
139
143
  await client.session.promptAsync({
140
144
  sessionID: toolSessionID,
141
145
  directory: directories.projectDirectory,
146
+ model: {
147
+ providerID: 'deterministic-provider',
148
+ modelID: 'deterministic-v2',
149
+ },
142
150
  parts: [{ type: 'text', text: 'use a tool please' }],
143
151
  });
144
152
  // Wait for tool execution to complete
@@ -200,7 +208,7 @@ test('generate markdown with system info', async () => {
200
208
  expect(markdown).toContain('## Conversation');
201
209
  expect(markdown).toContain('### 👤 User');
202
210
  expect(markdown).toContain('hello markdown test');
203
- expect(markdown).toContain('### 🤖 Assistant');
211
+ expect(markdown).toContain('### 🤖 Assistant (deterministic-provider/deterministic-v2)');
204
212
  expect(markdown).toContain('Hello! This is a deterministic markdown test response.');
205
213
  expect(markdown).toContain('**Started using deterministic-provider/deterministic-v2**');
206
214
  const normalized = normalizeMarkdown(markdown);
@@ -220,7 +228,7 @@ test('generate markdown with system info', async () => {
220
228
  hello markdown test
221
229
 
222
230
 
223
- ### 🤖 Assistant (deterministic-v2)
231
+ ### 🤖 Assistant (deterministic-provider/deterministic-v2)
224
232
 
225
233
  **Started using deterministic-provider/deterministic-v2**
226
234
 
@@ -253,7 +261,7 @@ test('generate markdown without system info', async () => {
253
261
  hello markdown test
254
262
 
255
263
 
256
- ### 🤖 Assistant (deterministic-v2)
264
+ ### 🤖 Assistant (deterministic-provider/deterministic-v2)
257
265
 
258
266
  **Started using deterministic-provider/deterministic-v2**
259
267
 
@@ -444,6 +444,21 @@ export function getToolSummaryText(part) {
444
444
  const prompt = part.state.input?.prompt || '';
445
445
  return prompt ? `*${escapeInlineMarkdown(prompt.slice(0, 60))}*` : '';
446
446
  }
447
+ // LESSON: tool parts are sent to Discord at `running` status (see
448
+ // shouldSendPart in thread-session-runtime), so `state.title` from the tool
449
+ // result is never rendered here. Everything shown must come from state.input.
450
+ // `until` is already absolute; `duration` is relative, so say "for 2h" —
451
+ // never "until 2h".
452
+ if (part.tool.endsWith('kimaki_sleep')) {
453
+ const until = part.state.input?.until || '';
454
+ const duration = part.state.input?.duration || '';
455
+ const reason = part.state.input?.reason || '';
456
+ const when = until ? `until ${until}` : duration ? `for ${duration}` : '';
457
+ const reasonText = reason ? `_${escapeInlineMarkdown(reason)}_` : '';
458
+ return [when && escapeInlineMarkdown(when), reasonText]
459
+ .filter(Boolean)
460
+ .join(' ');
461
+ }
447
462
  if (!part.state.input)
448
463
  return '';
449
464
  const inputFields = Object.entries(part.state.input)
@@ -478,15 +493,11 @@ export function formatTodoList(part) {
478
493
  return `${num} **${escapeInlineMarkdown(content)}**`;
479
494
  }
480
495
  export function formatTaskToolTitle(part) {
481
- if (part.tool !== 'task' || part.state.status === 'pending')
482
- return '';
483
- const childSessionId = part.state.metadata?.sessionId;
484
- if (typeof childSessionId !== 'string' || !childSessionId)
496
+ // Running only. The child session can be created later when many tasks queue.
497
+ if (part.tool !== 'task' || part.state.status !== 'running')
485
498
  return '';
486
499
  const description = part.state.input?.description;
487
- const stateTitle = part.state.status === 'running' || part.state.status === 'completed'
488
- ? part.state.title
489
- : undefined;
500
+ const stateTitle = part.state.title;
490
501
  const title = typeof description === 'string' && description
491
502
  ? description
492
503
  : typeof stateTitle === 'string'
@@ -46,24 +46,71 @@ describe('formatPart', () => {
46
46
  });
47
47
  });
48
48
  describe('formatTaskToolTitle', () => {
49
- test('uses the completed task title when OpenAI omits the description', () => {
50
- const part = {
49
+ function taskPart({ status, input = {}, title = '', sessionId, }) {
50
+ const base = {
51
51
  id: 'prt_task',
52
52
  type: 'tool',
53
53
  tool: 'task',
54
54
  callID: 'call_task',
55
55
  sessionID: 'ses_parent',
56
56
  messageID: 'msg_assistant',
57
+ };
58
+ if (status === 'completed') {
59
+ return {
60
+ ...base,
61
+ state: {
62
+ status,
63
+ input,
64
+ output: '',
65
+ title,
66
+ metadata: sessionId ? { sessionId } : {},
67
+ time: { start: 1, end: 2 },
68
+ },
69
+ };
70
+ }
71
+ return {
72
+ ...base,
57
73
  state: {
58
- status: 'completed',
59
- input: { subagent_type: 'general' },
60
- output: '',
61
- title: 'Classify pending changes',
62
- metadata: { sessionId: 'ses_child' },
63
- time: { start: 1, end: 2 },
74
+ status,
75
+ input,
76
+ title,
77
+ metadata: sessionId ? { sessionId } : {},
78
+ time: { start: 1 },
64
79
  },
65
80
  };
66
- expect(formatTaskToolTitle(part)).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`);
81
+ }
82
+ test('uses the running task title when OpenAI omits the description', () => {
83
+ expect(formatTaskToolTitle(taskPart({
84
+ status: 'running',
85
+ input: { subagent_type: 'general' },
86
+ title: 'Classify pending changes',
87
+ sessionId: 'ses_child',
88
+ }))).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`);
89
+ });
90
+ test('prefers input description on running parts', () => {
91
+ expect(formatTaskToolTitle(taskPart({
92
+ status: 'running',
93
+ input: { description: 'inspect repo', subagent_type: 'explore' },
94
+ title: 'ignored title',
95
+ sessionId: 'ses_child',
96
+ }))).toMatchInlineSnapshot(`"┣ explore **inspect repo**"`);
97
+ });
98
+ test('does not format completed parts so Discord does not post the line at the end', () => {
99
+ expect(formatTaskToolTitle(taskPart({
100
+ status: 'completed',
101
+ input: { subagent_type: 'general' },
102
+ title: 'Classify pending changes',
103
+ sessionId: 'ses_child',
104
+ }))).toBe('');
105
+ });
106
+ test('shows queued task calls before the child session exists', () => {
107
+ expect(formatTaskToolTitle(taskPart({
108
+ status: 'running',
109
+ input: {
110
+ description: 'audit customer pages',
111
+ subagent_type: 'general',
112
+ },
113
+ }))).toMatchInlineSnapshot(`"┣ general **audit customer pages**"`);
67
114
  });
68
115
  });
69
116
  describe('formatBashToolTitle', () => {
@@ -1,3 +1,13 @@
1
+ /**
2
+ * LEGACY. Superseded by @subrouter/opencode, registered alongside the kimaki
3
+ * plugin in opencode.ts. This whole rotation path only cycles accounts inside
4
+ * one provider; subrouter also fails over to a different provider when every
5
+ * account of one is exhausted.
6
+ *
7
+ * Kept because opencode ships no Claude Pro/Max auth of its own, so deleting
8
+ * it would strand anyone still picking `anthropic/*` directly. To move to
9
+ * subrouter, log in again through /login and pick Subrouter.
10
+ */
1
11
  /**
2
12
  * Shared utilities for multi-provider OAuth account rotation.
3
13
  * Used by both anthropic-auth-state.ts and openai-auth-state.ts to avoid
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth rotation plugin for OpenCode.
3
4
  *
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth account store and rotation.
3
4
  * Mirrors anthropic-auth-state.ts but for OpenAI/Codex OAuth accounts.
@@ -1,5 +1,6 @@
1
1
  // Regression tests for Windows OpenCode command resolution and spawn args.
2
2
  import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
@@ -26,6 +27,65 @@ describe('selectResolvedCommand', () => {
26
27
  })).toBe('/usr/local/bin/opencode');
27
28
  });
28
29
  });
30
+ describe('buildOpencodeServeArgs', () => {
31
+ test('always passes --hostname so opencode.json cannot bind 0.0.0.0', async () => {
32
+ const { buildOpencodeServeArgs } = await import('./opencode.js');
33
+ expect(buildOpencodeServeArgs({ port: 4096 })).toEqual([
34
+ 'serve',
35
+ '--port',
36
+ '4096',
37
+ '--hostname',
38
+ '127.0.0.1',
39
+ '--print-logs',
40
+ '--log-level',
41
+ 'WARN',
42
+ ]);
43
+ });
44
+ test('passes --hostname when set', async () => {
45
+ const { buildOpencodeServeArgs } = await import('./opencode.js');
46
+ expect(buildOpencodeServeArgs({ port: 4096, hostname: '0.0.0.0' })).toEqual([
47
+ 'serve',
48
+ '--port',
49
+ '4096',
50
+ '--hostname',
51
+ '0.0.0.0',
52
+ '--print-logs',
53
+ '--log-level',
54
+ 'WARN',
55
+ ]);
56
+ });
57
+ });
58
+ describe('published runtime artifacts', () => {
59
+ test('lists @subrouter/opencode as a runtime dependency', () => {
60
+ const pkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf8'));
61
+ expect(pkg.dependencies?.['@subrouter/opencode']).toMatch(/^(workspace:\^|\^)/);
62
+ });
63
+ });
64
+ describe('resolveSubrouterPluginSpec', () => {
65
+ test('uses npm package identity in production for OpenCode deduplication', async () => {
66
+ const { resolveSubrouterPluginSpec } = await import('./opencode.js');
67
+ const require = createRequire(import.meta.url);
68
+ const packageJsonPath = require.resolve('@subrouter/opencode/package.json');
69
+ const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
70
+ expect(resolveSubrouterPluginSpec({ isDev: false })).toBe(`@subrouter/opencode@${version}`);
71
+ });
72
+ test('loads workspace source directly in development', async () => {
73
+ const { resolveSubrouterPluginSpec } = await import('./opencode.js');
74
+ expect(resolveSubrouterPluginSpec({ isDev: true })).toMatch(/^file:.*\/subrouter\/opencode\/dist\/index\.js$/);
75
+ });
76
+ });
77
+ describe('publicOpencodeBindRequiresPassword', () => {
78
+ test('allows loopback without a password', async () => {
79
+ const { publicOpencodeBindRequiresPassword } = await import('./opencode.js');
80
+ expect(publicOpencodeBindRequiresPassword({ hostname: null })).toBe(false);
81
+ expect(publicOpencodeBindRequiresPassword({ hostname: '127.0.0.1' })).toBe(false);
82
+ expect(publicOpencodeBindRequiresPassword({ hostname: 'localhost' })).toBe(false);
83
+ });
84
+ test('requires a password for 0.0.0.0', async () => {
85
+ const { publicOpencodeBindRequiresPassword } = await import('./opencode.js');
86
+ expect(publicOpencodeBindRequiresPassword({ hostname: '0.0.0.0' })).toBe(true);
87
+ });
88
+ });
29
89
  describe('getSpawnCommandAndArgs', () => {
30
90
  test('wraps windows cmd shims through cmd.exe without double-quoting by node', () => {
31
91
  expect(getSpawnCommandAndArgs({
package/dist/opencode.js CHANGED
@@ -40,13 +40,26 @@ import net from 'node:net';
40
40
  import os from 'node:os';
41
41
  import path from 'node:path';
42
42
  import readline from 'node:readline';
43
- import { fileURLToPath } from 'node:url';
43
+ import { createRequire } from 'node:module';
44
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
46
  import { createOpencodeClient, } from '@opencode-ai/sdk/v2';
46
47
  import { restartGlobalEventListener, waitForGlobalEventListener, } from './session-handler/global-event-listener.js';
47
- import { getDataDir, getLockPort, getRestrictExternalDirectories, } from './config.js';
48
+ import { getDataDir, getLockPort, getRestrictExternalDirectories, getOpencodeHostname, getOpencodePort, } from './config.js';
48
49
  import { store } from './store.js';
49
50
  import { getHranaUrl } from './hrana-server.js';
51
+ export function resolveSubrouterPluginSpec({ isDev }) {
52
+ const require = createRequire(import.meta.url);
53
+ const entry = require.resolve('@subrouter/opencode');
54
+ if (isDev)
55
+ return pathToFileURL(entry).href;
56
+ const packageJsonPath = require.resolve('@subrouter/opencode/package.json');
57
+ const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
58
+ if (typeof version !== 'string' || !version) {
59
+ throw new Error(`Missing @subrouter/opencode version in ${packageJsonPath}`);
60
+ }
61
+ return `@subrouter/opencode@${version}`;
62
+ }
50
63
  import * as errore from 'errore';
51
64
  import { createLogger, LogPrefix } from './logger.js';
52
65
  import { notifyError } from './sentry.js';
@@ -66,6 +79,26 @@ export function getOpencodeServerAuthHeaders() {
66
79
  const encoded = Buffer.from(`${username}:${serverPassword}`).toString('base64');
67
80
  return { Authorization: `Basic ${encoded}` };
68
81
  }
82
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1']);
83
+ export function publicOpencodeBindRequiresPassword({ hostname, }) {
84
+ if (!hostname)
85
+ return false;
86
+ return !LOOPBACK_HOSTNAMES.has(hostname);
87
+ }
88
+ // Always pass --hostname so opencode.json server.hostname / mdns cannot bind 0.0.0.0.
89
+ const DEFAULT_OPENCODE_HOSTNAME = '127.0.0.1';
90
+ export function buildOpencodeServeArgs({ port, hostname, }) {
91
+ return [
92
+ 'serve',
93
+ '--port',
94
+ port.toString(),
95
+ '--hostname',
96
+ hostname || DEFAULT_OPENCODE_HOSTNAME,
97
+ '--print-logs',
98
+ '--log-level',
99
+ 'WARN',
100
+ ];
101
+ }
69
102
  // Tracks directories that have been initialized, to avoid repeated log spam
70
103
  // from the external sync polling loop.
71
104
  const initializedDirectories = new Set();
@@ -89,6 +122,7 @@ export async function requestHealthcheck({ url, timeoutMs = 2000, }) {
89
122
  method: 'GET',
90
123
  headers: {
91
124
  connection: 'close',
125
+ ...getOpencodeServerAuthHeaders(),
92
126
  },
93
127
  }, (res) => {
94
128
  const chunks = [];
@@ -473,15 +507,17 @@ async function ensureSingleServer({ directory, } = {}) {
473
507
  }
474
508
  async function startSingleServer({ directory, } = {}) {
475
509
  ensureProcessCleanupHandlersRegistered();
476
- const port = await getOpenPort();
477
- const serveArgs = [
478
- 'serve',
479
- '--port',
480
- port.toString(),
481
- '--print-logs',
482
- '--log-level',
483
- 'WARN',
484
- ];
510
+ const configuredPort = getOpencodePort();
511
+ const port = configuredPort ?? (await getOpenPort());
512
+ const hostname = getOpencodeHostname() ?? DEFAULT_OPENCODE_HOSTNAME;
513
+ if (publicOpencodeBindRequiresPassword({ hostname }) &&
514
+ !process.env.OPENCODE_SERVER_PASSWORD) {
515
+ return new ServerStartError({
516
+ port,
517
+ reason: `OPENCODE_SERVER_PASSWORD is required when --opencode-hostname is ${hostname}`,
518
+ });
519
+ }
520
+ const serveArgs = buildOpencodeServeArgs({ port, hostname });
485
521
  const { command: spawnCommand, args: spawnArgs, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
486
522
  resolvedCommand: resolveOpencodeCommand(),
487
523
  baseArgs: serveArgs,
@@ -552,6 +588,9 @@ async function startSingleServer({ directory, } = {}) {
552
588
  formatter: false,
553
589
  plugin: [
554
590
  new URL(isDev ? './kimaki-opencode-plugin.ts' : './kimaki-opencode-plugin.js', import.meta.url).href,
591
+ // npm identity lets opencode dedupe a user-installed copy by package
592
+ // name. Development still loads this workspace's built package directly.
593
+ resolveSubrouterPluginSpec({ isDev }),
555
594
  ],
556
595
  permission: {
557
596
  edit: 'allow',
@@ -639,6 +678,8 @@ async function startSingleServer({ directory, } = {}) {
639
678
  OPENCODE_CONFIG: opencodeConfigPath,
640
679
  OPENCODE_PORT: port.toString(),
641
680
  KIMAKI: '1',
681
+ // The browser is not on this machine, so no localhost callback fires.
682
+ SUBROUTER_MANUAL_OAUTH: '1',
642
683
  OPENCODE_EXPERIMENTAL_WORKSPACES: 'true',
643
684
  OPENCODE_ENABLE_EXA: '1',
644
685
  KIMAKI_DATA_DIR: getDataDir(),
@@ -661,7 +702,7 @@ async function startSingleServer({ directory, } = {}) {
661
702
  const logBuffer = [];
662
703
  const startupStderrTail = [];
663
704
  let serverReady = false;
664
- logBuffer.push(`Spawned opencode serve --port ${port} (pid: ${serverProcess.pid})`);
705
+ logBuffer.push(`Spawned opencode ${serveArgs.join(' ')} (pid: ${serverProcess.pid})`);
665
706
  const stdoutReader = subscribeToProcessLogStream({
666
707
  stream: serverProcess.stdout,
667
708
  onLine: (line) => {
@@ -0,0 +1,89 @@
1
+ // E2e test: aborting a run must kill the pending question dropdown.
2
+ // Questions have no TTL, so abort is the only thing that clears them while the
3
+ // thread stays alive. Without this, a stale dropdown keeps accepting clicks and
4
+ // answers a question whose run is already dead.
5
+ import { describe, test, expect } from 'vitest';
6
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
7
+ import { waitForBotMessageContaining } from './test-utils.js';
8
+ import { pendingQuestionContexts } from './commands/ask-question.js';
9
+ import { getRuntime } from './session-handler/thread-session-runtime.js';
10
+ const TEXT_CHANNEL_ID = '200000000000001031';
11
+ describe('abort clears pending question dropdown', () => {
12
+ const ctx = setupQueueAdvancedSuite({
13
+ channelId: TEXT_CHANNEL_ID,
14
+ channelName: 'qa-question-abort',
15
+ dirName: 'qa-question-abort',
16
+ username: 'question-abort-tester',
17
+ });
18
+ test('dropdown stops accepting answers after abort', async () => {
19
+ const marker = 'QUESTION_SELECT_QUEUE_MARKER abort-test';
20
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
21
+ content: marker,
22
+ });
23
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
24
+ timeout: 8_000,
25
+ predicate: (t) => {
26
+ return t.name === marker;
27
+ },
28
+ });
29
+ const th = ctx.discord.thread(thread.id);
30
+ const questionMessages = await waitForBotMessageContaining({
31
+ discord: ctx.discord,
32
+ threadId: thread.id,
33
+ text: 'How to proceed?',
34
+ timeout: 12_000,
35
+ });
36
+ const questionMsg = questionMessages.find((message) => {
37
+ return message.content.includes('How to proceed?');
38
+ });
39
+ if (!questionMsg) {
40
+ throw new Error('Expected question message');
41
+ }
42
+ const pendingEntry = [...pendingQuestionContexts.entries()].find(([, context]) => {
43
+ return context.thread.id === thread.id;
44
+ });
45
+ if (!pendingEntry) {
46
+ throw new Error('Expected pending question context');
47
+ }
48
+ const contextHash = pendingEntry[0];
49
+ const runtime = getRuntime(thread.id);
50
+ if (!runtime) {
51
+ throw new Error('Expected runtime for question abort test');
52
+ }
53
+ runtime.abortActiveRun('test-question-abort');
54
+ // Click the now-dead dropdown. The answer echo must never appear.
55
+ const interaction = await th.user(TEST_USER_ID).selectMenu({
56
+ messageId: questionMsg.id,
57
+ customId: `ask_question:${contextHash}:0`,
58
+ values: ['0'],
59
+ });
60
+ await th.waitForInteractionAck({
61
+ interactionId: interaction.id,
62
+ timeout: 8_000,
63
+ });
64
+ for (let i = 0; i < 10; i++) {
65
+ const messages = await th.getMessages();
66
+ const answered = messages.find((message) => {
67
+ return message.content.includes('» **question-abort-tester:** Alpha');
68
+ });
69
+ if (answered) {
70
+ throw new Error('Dropdown was still answerable after abort');
71
+ }
72
+ await new Promise((resolve) => {
73
+ setTimeout(resolve, 20);
74
+ });
75
+ }
76
+ const timeline = await th.text({ showInteractions: true });
77
+ expect(timeline).toMatchInlineSnapshot(`
78
+ "--- from: user (question-abort-tester)
79
+ QUESTION_SELECT_QUEUE_MARKER abort-test
80
+ --- from: assistant (TestBot)
81
+ *using deterministic-provider/deterministic-v2*
82
+ **Select action**
83
+ How to proceed?
84
+ [user selects dropdown: 0]
85
+ This question has expired. Please ask the AI again."
86
+ `);
87
+ expect(pendingQuestionContexts.size).toBe(0);
88
+ }, 20_000);
89
+ });
@@ -0,0 +1,52 @@
1
+ // E2e: text that precedes a question tool must post before the dropdown.
2
+ // OpenCode can emit question.asked before the text part's time.end; Kimaki
3
+ // must wait so the plan is not hidden under the question UI.
4
+ import { describe, test, expect } from 'vitest';
5
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
6
+ import { waitForBotMessageContaining } from './test-utils.js';
7
+ const TEXT_CHANNEL_ID = '200000000000001042';
8
+ describe('question text before dropdown', () => {
9
+ const ctx = setupQueueAdvancedSuite({
10
+ channelId: TEXT_CHANNEL_ID,
11
+ channelName: 'qa-question-text-order',
12
+ dirName: 'qa-question-text-order',
13
+ username: 'question-text-tester',
14
+ });
15
+ test('posts plan text before the question dropdown', async () => {
16
+ const marker = 'QUESTION_AFTER_TEXT_MARKER';
17
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
18
+ content: marker,
19
+ });
20
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
21
+ timeout: 8_000,
22
+ predicate: (t) => {
23
+ return t.name === marker;
24
+ },
25
+ });
26
+ const th = ctx.discord.thread(thread.id);
27
+ await waitForBotMessageContaining({
28
+ discord: ctx.discord,
29
+ threadId: thread.id,
30
+ text: 'What next?',
31
+ timeout: 8_000,
32
+ });
33
+ expect(await th.text()).toMatchInlineSnapshot(`
34
+ "--- from: user (question-text-tester)
35
+ QUESTION_AFTER_TEXT_MARKER
36
+ --- from: assistant (TestBot)
37
+ *using deterministic-provider/deterministic-v2*
38
+ ⬥ PLAN_TEXT_BEFORE_QUESTION
39
+ **Next step**
40
+ What next?"
41
+ `);
42
+ const messages = await th.getMessages();
43
+ const planIndex = messages.findIndex((message) => {
44
+ return message.content.includes('PLAN_TEXT_BEFORE_QUESTION');
45
+ });
46
+ const questionIndex = messages.findIndex((message) => {
47
+ return message.content.includes('What next?');
48
+ });
49
+ expect(planIndex).toBeGreaterThanOrEqual(0);
50
+ expect(questionIndex).toBeGreaterThan(planIndex);
51
+ }, 15_000);
52
+ });
@@ -157,7 +157,7 @@ e2eTest('queue advanced: abort and retry', () => {
157
157
  --- from: assistant (TestBot)
158
158
  *using deterministic-provider/deterministic-v2*
159
159
  ⬥ ok
160
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
160
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
161
161
  --- from: user (queue-advanced-tester)
162
162
  SLOW_ABORT_MARKER run long response"
163
163
  `);