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
@@ -1,6 +1,7 @@
1
1
  // Session inspection and archival terminal commands.
2
2
  import { goke } from 'goke';
3
3
  import { z } from 'zod';
4
+ import dedent from 'string-dedent';
4
5
  import { note } from '@clack/prompts';
5
6
  import YAML from 'yaml';
6
7
  import * as errore from 'errore';
@@ -15,10 +16,13 @@ import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessi
15
16
  import { ShareMarkdown } from '../markdown.js';
16
17
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
17
18
  import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
19
+ import { formatTimeAgo } from '../commands/worktrees.js';
20
+ import { editorsForFile, loadFileEditEvents } from '../file-edit-log.js';
18
21
  import { WORKTREE_PREFIX } from '../commands/merge-worktree.js';
19
22
  import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-event-log.js';
20
23
  import { createDiscordRest } from '../discord-urls.js';
21
24
  import { archiveThread, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
25
+ import { OpenCodeSdkError } from '../errors.js';
22
26
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js';
23
27
  import { execAsync, validateWorktreeDirectory } from '../worktrees.js';
24
28
  import { upgrade, getCurrentVersion } from '../upgrade.js';
@@ -53,6 +57,8 @@ async function resolveSessionDirectoryFromDatabase({ sessionId, }) {
53
57
  cli
54
58
  .command('session list', 'List all OpenCode sessions, marking which were started via Kimaki')
55
59
  .option('--project <path>', 'Project directory to list sessions for (defaults to cwd)')
60
+ .option('--active', 'Only list active sessions; exits 1 when none remain')
61
+ .option('--exclude <sessionId>', 'Exclude one session ID from the results')
56
62
  .option('--json', 'Output as JSON')
57
63
  .action(async (options) => {
58
64
  try {
@@ -66,9 +72,32 @@ cli
66
72
  }
67
73
  const sessionsResponse = await getClient().session.list();
68
74
  const sessions = sessionsResponse.data || [];
69
- if (sessions.length === 0) {
70
- cliLogger.log('No sessions found');
71
- process.exit(0);
75
+ const statuses = await (async () => {
76
+ if (!options.active)
77
+ return null;
78
+ const response = await getClient().session.status({
79
+ directory: projectDirectory,
80
+ });
81
+ if (response.error) {
82
+ cliLogger.error('Failed to list active sessions');
83
+ process.exit(EXIT_NO_RESTART);
84
+ }
85
+ return response.data || {};
86
+ })();
87
+ const selectedSessions = sessions.filter((session) => {
88
+ if (session.id === options.exclude)
89
+ return false;
90
+ if (!options.active)
91
+ return true;
92
+ const status = statuses?.[session.id];
93
+ return Boolean(status && status.type !== 'idle');
94
+ });
95
+ if (selectedSessions.length === 0) {
96
+ if (options.json)
97
+ console.log('[]');
98
+ else
99
+ cliLogger.log(options.active ? 'No active sessions found' : 'No sessions found');
100
+ process.exit(options.active ? 1 : 0);
72
101
  }
73
102
  // Look up which sessions were started via kimaki (have a thread mapping)
74
103
  const db = await getDb();
@@ -78,7 +107,7 @@ cli
78
107
  const sessionToThread = new Map(threadSessions
79
108
  .filter((row) => row.session_id !== '')
80
109
  .map((row) => [row.session_id, row.thread_id]));
81
- const sessionStartSources = await getSessionStartSourcesBySessionIds(sessions.map((session) => session.id));
110
+ const sessionStartSources = await getSessionStartSourcesBySessionIds(selectedSessions.map((session) => session.id));
82
111
  const scheduleModeLabel = ({ scheduleKind, }) => {
83
112
  if (scheduleKind === 'at') {
84
113
  return 'delay';
@@ -86,7 +115,7 @@ cli
86
115
  return 'cron';
87
116
  };
88
117
  if (options.json) {
89
- const output = sessions.map((session) => {
118
+ const output = selectedSessions.map((session) => {
90
119
  const startSource = sessionStartSources.get(session.id);
91
120
  const startedBy = startSource
92
121
  ? `scheduled-${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}`
@@ -100,12 +129,13 @@ cli
100
129
  threadId: sessionToThread.get(session.id) || null,
101
130
  startedBy,
102
131
  scheduledTaskId: startSource?.scheduled_task_id || null,
132
+ status: options.active ? statuses?.[session.id]?.type || 'busy' : undefined,
103
133
  };
104
134
  });
105
135
  console.log(JSON.stringify(output, null, 2));
106
136
  process.exit(0);
107
137
  }
108
- for (const session of sessions) {
138
+ for (const session of selectedSessions) {
109
139
  const threadId = sessionToThread.get(session.id);
110
140
  const startSource = sessionStartSources.get(session.id);
111
141
  const source = threadId ? '(kimaki)' : '(opencode)';
@@ -114,7 +144,10 @@ cli
114
144
  : '';
115
145
  const updatedAt = new Date(session.time.updated).toISOString();
116
146
  const threadInfo = threadId ? ` | thread: ${threadId}` : '';
117
- console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${threadInfo}${startedBy}`);
147
+ const statusInfo = options.active
148
+ ? ` | status: ${statuses?.[session.id]?.type || 'busy'}`
149
+ : '';
150
+ console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${threadInfo}${startedBy}${statusInfo}`);
118
151
  }
119
152
  process.exit(0);
120
153
  }
@@ -123,6 +156,79 @@ cli
123
156
  process.exit(EXIT_NO_RESTART);
124
157
  }
125
158
  });
159
+ cli
160
+ .command('session editors <file>', dedent `
161
+ List sessions that last edited a file, newest first.
162
+
163
+ Use this before a commit in another session so the \`Session:\` line
164
+ uses the session that actually edited the file.
165
+ `)
166
+ .option('--json', 'Output as JSON')
167
+ .option('--limit <n>', z.number().default(20).describe('Max sessions to show'))
168
+ .example('kimaki session editors src/cli.ts')
169
+ .example('kimaki session editors src/cli.ts --json')
170
+ .action(async (file, options, { console, process }) => {
171
+ try {
172
+ const cwd = process.cwd;
173
+ const loaded = loadFileEditEvents({ dataDir: getDataDir() });
174
+ if (loaded instanceof Error) {
175
+ console.error(loaded.message);
176
+ process.exit(EXIT_NO_RESTART);
177
+ return;
178
+ }
179
+ const editors = editorsForFile({
180
+ events: loaded,
181
+ filePath: file,
182
+ cwd,
183
+ }).slice(0, options.limit);
184
+ if (editors.length === 0) {
185
+ console.error(`No recorded editors for ${path.resolve(cwd, file)}`);
186
+ process.exit(1);
187
+ return;
188
+ }
189
+ const titles = new Map();
190
+ try {
191
+ await initDatabase();
192
+ const db = await getDb();
193
+ const sessionRows = await db.query.thread_sessions.findMany({
194
+ columns: { session_id: true, last_synced_name: true },
195
+ where: { session_id: { in: editors.map((editor) => editor.sessionId) } },
196
+ orderBy: { updated_at: 'desc' },
197
+ });
198
+ for (const row of sessionRows) {
199
+ if (!titles.has(row.session_id) && row.last_synced_name) {
200
+ titles.set(row.session_id, row.last_synced_name);
201
+ }
202
+ }
203
+ }
204
+ catch (error) {
205
+ console.error(error instanceof Error ? error.message : String(error));
206
+ }
207
+ const rows = editors.map((editor) => {
208
+ const title = titles.get(editor.sessionId) || '-';
209
+ const editedAt = new Date(editor.at);
210
+ return {
211
+ sessionId: editor.sessionId,
212
+ title,
213
+ editedAt: editedAt.toISOString(),
214
+ ago: formatTimeAgo(editedAt),
215
+ };
216
+ });
217
+ if (options.json) {
218
+ console.log(JSON.stringify(rows, null, 2));
219
+ process.exit(0);
220
+ return;
221
+ }
222
+ for (const row of rows) {
223
+ console.log(`${row.sessionId} | ${row.title} | ${row.ago}`);
224
+ }
225
+ process.exit(0);
226
+ }
227
+ catch (error) {
228
+ console.error(error instanceof Error ? error.stack : String(error));
229
+ process.exit(EXIT_NO_RESTART);
230
+ }
231
+ });
126
232
  cli
127
233
  .command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
128
234
  .option('--project <path>', 'Project directory (defaults to cwd)')
@@ -430,7 +536,7 @@ cli
430
536
  process.exit(0);
431
537
  });
432
538
  cli
433
- .command('session archive [threadId]', 'Archive a Discord thread and stop its mapped OpenCode session')
539
+ .command('session archive [threadId]', 'Archive a Discord thread without stopping its mapped OpenCode session')
434
540
  .option('--session <sessionId>', 'Resolve thread from an OpenCode session ID')
435
541
  .action(async (threadIdArg, options) => {
436
542
  try {
@@ -544,6 +650,75 @@ cli
544
650
  process.exit(EXIT_NO_RESTART);
545
651
  }
546
652
  });
653
+ cli
654
+ .command('session title <title>', 'Update the OpenCode session title. Discord thread name follows automatically.')
655
+ .option('--session <sessionId>', 'OpenCode session ID')
656
+ .option('--thread <threadId>', 'Discord thread ID')
657
+ .example("kimaki session title 'Fix queue draining' --session ses_xxx")
658
+ .action(async (title, options) => {
659
+ try {
660
+ await initDatabase();
661
+ const trimmedTitle = title.trim();
662
+ if (!trimmedTitle) {
663
+ cliLogger.error('Title must not be empty');
664
+ process.exit(EXIT_NO_RESTART);
665
+ }
666
+ if (options.session && options.thread) {
667
+ cliLogger.error('Use either --session or --thread, not both');
668
+ process.exit(EXIT_NO_RESTART);
669
+ }
670
+ if (!options.session && !options.thread) {
671
+ cliLogger.error('Provide --session <sessionId> or --thread <threadId>');
672
+ process.exit(EXIT_NO_RESTART);
673
+ }
674
+ const sessionId = await (async () => {
675
+ if (options.session)
676
+ return options.session;
677
+ const threadId = options.thread;
678
+ if (!threadId)
679
+ return null;
680
+ return getThreadSession(threadId);
681
+ })();
682
+ if (!sessionId) {
683
+ cliLogger.error(options.thread
684
+ ? `No OpenCode session found for thread: ${options.thread}`
685
+ : 'Provide --session <sessionId> or --thread <threadId>');
686
+ process.exit(EXIT_NO_RESTART);
687
+ }
688
+ const directory = await resolveSessionDirectoryFromDatabase({
689
+ sessionId,
690
+ });
691
+ if (directory instanceof Error) {
692
+ cliLogger.error(directory.message);
693
+ process.exit(EXIT_NO_RESTART);
694
+ }
695
+ const serverResult = await initializeOpencodeForDirectory(directory);
696
+ if (serverResult instanceof Error) {
697
+ cliLogger.error(`Failed to initialize OpenCode: ${serverResult.message}`);
698
+ process.exit(EXIT_NO_RESTART);
699
+ }
700
+ const updateResult = await serverResult()
701
+ .session.update({
702
+ sessionID: sessionId,
703
+ title: trimmedTitle,
704
+ })
705
+ .catch((e) => new OpenCodeSdkError({ operation: 'session.update', cause: e }));
706
+ if (updateResult instanceof Error) {
707
+ cliLogger.error(updateResult.message);
708
+ process.exit(EXIT_NO_RESTART);
709
+ }
710
+ if (updateResult.error) {
711
+ cliLogger.error('OpenCode rejected the session title update');
712
+ process.exit(EXIT_NO_RESTART);
713
+ }
714
+ note(`Updated OpenCode title: ${trimmedTitle}`, 'Title updated');
715
+ process.exit(0);
716
+ }
717
+ catch (error) {
718
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
719
+ process.exit(EXIT_NO_RESTART);
720
+ }
721
+ });
547
722
  cli
548
723
  .command('session discord-url <sessionId>', 'Print the Discord thread URL for a session')
549
724
  .option('--json', 'Output as JSON')
@@ -24,6 +24,7 @@ import { execAsync, validateWorktreeDirectory } from '../worktrees.js';
24
24
  import { upgrade, getCurrentVersion } from '../upgrade.js';
25
25
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
26
26
  import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, getDiscordUserIdFromUserOption, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
27
+ import { validateCliModelOption } from '../session-handler/model-utils.js';
27
28
  const cliLogger = createLogger(LogPrefix.CLI);
28
29
  const cli = goke();
29
30
  cli
@@ -191,6 +192,17 @@ cli
191
192
  process.exit(EXIT_NO_RESTART);
192
193
  }
193
194
  const newPrompt = trimmedPrompt ?? existingPayload.prompt;
195
+ const nextModel = hasModel ? options.model.trim() || null : existingPayload.model;
196
+ if (nextModel) {
197
+ const modelCheck = await validateCliModelOption({
198
+ model: nextModel,
199
+ directory: task.project_directory ?? undefined,
200
+ });
201
+ if (modelCheck instanceof Error) {
202
+ cliLogger.error(modelCheck.message);
203
+ process.exit(EXIT_NO_RESTART);
204
+ }
205
+ }
194
206
  // Match send --model/--agent: empty string clears the override (null).
195
207
  const updatedPayload = {
196
208
  ...existingPayload,
@@ -199,7 +211,7 @@ cli
199
211
  ? { userId: resolvedUser.id, username: resolvedUser.username || null }
200
212
  : {}),
201
213
  ...(hasAgent ? { agent: options.agent.trim() || null } : {}),
202
- ...(hasModel ? { model: options.model.trim() || null } : {}),
214
+ ...(hasModel ? { model: nextModel } : {}),
203
215
  ...(hasPreRun ? { preRunCommand: options.preRun.trim() || null } : {}),
204
216
  ...(hasAllowConcurrency
205
217
  ? { allowConcurrency: options.allowConcurrency === 'true' }
@@ -1,12 +1,14 @@
1
1
  // Regression tests for CLI argument parsing around Discord ID string preservation.
2
2
  import { describe, expect, test } from 'vitest';
3
3
  import { execAsync } from './exec-async.js';
4
+ import maintenanceCommands from './cli-commands/maintenance.js';
4
5
  async function parseWithGoke(argv) {
5
6
  const script = [
6
7
  "import { goke } from 'goke'",
7
8
  'const cli = goke(\'kimaki\')',
8
9
  "cli.command('send', 'Send a message').option('-c, --channel <channelId>', 'Discord channel ID').option('--thread <threadId>', 'Thread ID').option('--session <sessionId>', 'Session ID').option('--send-at <schedule>', 'Schedule')",
9
10
  "cli.command('session archive <threadId>', 'Archive a thread')",
11
+ "cli.command('session title <title>', 'Update title').option('--session <sessionId>', 'Session ID').option('--thread <threadId>', 'Thread ID')",
10
12
  "cli.command('session search <query>', 'Search sessions').option('--channel <channelId>', 'Discord channel ID').option('--project <path>', 'Project path')",
11
13
  "cli.command('session export-events-jsonl', 'Export in-memory events to JSONL').option('--session <sessionId>', 'Session ID').option('--out <file>', 'Output path')",
12
14
  "cli.command('add-project', 'Add a project').option('-g, --guild <guildId>', 'Discord guild/server ID')",
@@ -47,7 +49,7 @@ async function parseRootBotOptions(argv) {
47
49
  const script = [
48
50
  "import { goke } from 'goke'",
49
51
  'const cli = goke(\'kimaki\')',
50
- "cli.command('', 'bot').option('--no-analytics', 'Disable analytics')",
52
+ "cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--skip-footer-mentions', 'Do not mention the thread creator in final footers')",
51
53
  `const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
52
54
  'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
53
55
  ].join(';');
@@ -78,6 +80,23 @@ describe('goke CLI ID parsing', () => {
78
80
  expect(result.options.guild).toBe(guildId);
79
81
  expect(typeof result.options.guild).toBe('string');
80
82
  });
83
+ test('keeps session title and session ID as strings', async () => {
84
+ const sessionId = '001111222233334444';
85
+ const title = 'Fix queue draining';
86
+ const result = await parseWithGoke([
87
+ 'node',
88
+ 'kimaki',
89
+ 'session',
90
+ 'title',
91
+ title,
92
+ '--session',
93
+ sessionId,
94
+ ]);
95
+ expect(result.args[0]).toBe(title);
96
+ expect(typeof result.args[0]).toBe('string');
97
+ expect(result.options.session).toBe(sessionId);
98
+ expect(typeof result.options.session).toBe('string');
99
+ });
81
100
  test('keeps session archive thread ID as string', async () => {
82
101
  const threadId = '0098765432109876543';
83
102
  const result = await parseWithGoke(['node', 'kimaki', 'session', 'archive', threadId]);
@@ -136,12 +155,49 @@ describe('goke CLI ID parsing', () => {
136
155
  expect(stdout).toContain('send');
137
156
  expect(stdout).toContain('multioauth');
138
157
  });
139
- test('parses --no-analytics as a boolean flag on the root bot command', async () => {
158
+ test('merge-worktree parses strategy and target branch', async () => {
159
+ const result = await maintenanceCommands.parse([
160
+ 'node',
161
+ 'kimaki',
162
+ 'merge-worktree',
163
+ '--strategy',
164
+ 'squash',
165
+ '--target-branch',
166
+ 'release',
167
+ '--thread',
168
+ '123456789012345678',
169
+ '--thread-name',
170
+ 'feature work',
171
+ ], { run: false });
172
+ expect({
173
+ command: maintenanceCommands.matchedCommandName,
174
+ strategy: result.options.strategy,
175
+ targetBranch: result.options.targetBranch,
176
+ thread: result.options.thread,
177
+ threadName: result.options.threadName,
178
+ }).toMatchInlineSnapshot(`
179
+ {
180
+ "command": "merge-worktree",
181
+ "strategy": "squash",
182
+ "targetBranch": "release",
183
+ "thread": "123456789012345678",
184
+ "threadName": "feature work",
185
+ }
186
+ `);
187
+ });
188
+ test('parses root bot boolean flags', async () => {
140
189
  const result = await parseRootBotOptions([
141
190
  'node',
142
191
  'kimaki',
143
192
  '--no-analytics',
193
+ '--skip-footer-mentions',
144
194
  ]);
145
- expect(result.options.noAnalytics).toBe(true);
195
+ expect(result.options).toMatchInlineSnapshot(`
196
+ {
197
+ "--": [],
198
+ "noAnalytics": true,
199
+ "skipFooterMentions": true,
200
+ }
201
+ `);
146
202
  });
147
203
  });
@@ -312,6 +312,10 @@ export function formatTaskScheduleLine(schedule) {
312
312
  return `cron "${schedule.cronExpr}" (${schedule.timezone}) next ${schedule.nextRunAt.toISOString()}`;
313
313
  }
314
314
  export const EXIT_NO_RESTART = 64;
315
+ // Temporary failure (sysexits EX_TEMPFAIL). Wrapper retries forever with
316
+ // backoff and does not count this toward the crash-loop detector. Used when
317
+ // Discord login fails because the network is down.
318
+ export const EXIT_TEMPFAIL = 75;
315
319
  export function isGuildMemberSearchResult(value) {
316
320
  if (!value) {
317
321
  return false;
@@ -378,12 +382,28 @@ const TRANSIENT_ERROR_CODES = new Set([
378
382
  'DEPTH_ZERO_SELF_SIGNED_CERT',
379
383
  'SELF_SIGNED_CERT_IN_CHAIN',
380
384
  'ERR_TLS_CERT_ALTNAME_INVALID',
385
+ // undici fetch/WebSocket connect failures when the network is down.
386
+ // Without these, Discord login exits EXIT_NO_RESTART and the wrapper never
387
+ // comes back after a connect timeout to discord-gateway.kimaki.dev.
388
+ 'UND_ERR_CONNECT_TIMEOUT',
389
+ 'UND_ERR_HEADERS_TIMEOUT',
390
+ 'UND_ERR_BODY_TIMEOUT',
391
+ 'UND_ERR_SOCKET',
392
+ ]);
393
+ const TRANSIENT_ERROR_NAMES = new Set([
394
+ 'ConnectTimeoutError',
395
+ 'HeadersTimeoutError',
396
+ 'BodyTimeoutError',
397
+ 'SocketError',
381
398
  ]);
382
399
  const TRANSIENT_ERROR_MESSAGE_PATTERNS = [
383
400
  /unable to verify the first certificate/i,
384
401
  /certificate has expired/i,
385
402
  /self[- ]signed certificate/i,
386
403
  /unable to get local issuer certificate/i,
404
+ /connect timeout error/i,
405
+ /headers timeout error/i,
406
+ /body timeout error/i,
387
407
  ];
388
408
  export function isTransientNetworkError(error) {
389
409
  if (!(error instanceof Error))
@@ -391,6 +411,8 @@ export function isTransientNetworkError(error) {
391
411
  const code = error.code;
392
412
  if (code && TRANSIENT_ERROR_CODES.has(code))
393
413
  return true;
414
+ if (TRANSIENT_ERROR_NAMES.has(error.name))
415
+ return true;
394
416
  // Fallback when code is stripped by wrappers (discord.js sometimes rethrows by message only).
395
417
  if (TRANSIENT_ERROR_MESSAGE_PATTERNS.some((pattern) => pattern.test(error.message))) {
396
418
  return true;
@@ -1271,12 +1293,14 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1271
1293
  catch (error) {
1272
1294
  cliLogger.log('Failed to connect to Discord', discordClient.ws.gateway);
1273
1295
  cliLogger.error('Error: ' + (error instanceof Error ? error.stack : String(error)));
1274
- // Transient network errors (DNS down, gateway unreachable) should allow
1275
- // the bin.ts wrapper to restart us after a delay. Only truly fatal errors
1276
- // (bad token, invalid intent, etc.) should use EXIT_NO_RESTART.
1296
+ // Transient network errors (DNS down, gateway unreachable, connect
1297
+ // timeout) should allow the bin.ts wrapper to restart us after a delay.
1298
+ // EXIT_TEMPFAIL skips the crash-loop detector so a long outage cannot
1299
+ // permanently stop the bot. Only truly fatal errors (bad token, invalid
1300
+ // intent, etc.) should use EXIT_NO_RESTART.
1277
1301
  if (isTransientNetworkError(error)) {
1278
1302
  cliLogger.error('Transient network error, exiting for wrapper restart...');
1279
- process.exit(1);
1303
+ process.exit(EXIT_TEMPFAIL);
1280
1304
  }
1281
1305
  process.exit(EXIT_NO_RESTART);
1282
1306
  }
@@ -56,4 +56,31 @@ describe('isTransientNetworkError', () => {
56
56
  });
57
57
  expect(isTransientNetworkError(error)).toBe(true);
58
58
  });
59
+ test('treats undici connect timeouts as transient', () => {
60
+ const error = Object.assign(new Error('Connect Timeout Error (attempted address: discord-gateway.kimaki.dev:443, timeout: 10000ms)'), {
61
+ name: 'ConnectTimeoutError',
62
+ code: 'UND_ERR_CONNECT_TIMEOUT',
63
+ });
64
+ expect(isTransientNetworkError(error)).toBe(true);
65
+ });
66
+ test('matches undici connect timeouts by message when code is missing', () => {
67
+ expect(isTransientNetworkError(new Error('Connect Timeout Error (attempted address: discord-gateway.kimaki.dev:443, timeout: 10000ms)'))).toBe(true);
68
+ });
69
+ test('walks cause chains for nested undici connect timeouts', () => {
70
+ const cause = Object.assign(new Error('Connect Timeout Error (attempted address: discord-gateway.kimaki.dev:443, timeout: 10000ms)'), {
71
+ name: 'ConnectTimeoutError',
72
+ code: 'UND_ERR_CONNECT_TIMEOUT',
73
+ });
74
+ expect(isTransientNetworkError(new Error('Failed to connect to Discord', { cause }))).toBe(true);
75
+ });
76
+ test('treats other undici timeout and socket codes as transient', () => {
77
+ for (const [name, code] of [
78
+ ['HeadersTimeoutError', 'UND_ERR_HEADERS_TIMEOUT'],
79
+ ['BodyTimeoutError', 'UND_ERR_BODY_TIMEOUT'],
80
+ ['SocketError', 'UND_ERR_SOCKET'],
81
+ ]) {
82
+ const error = Object.assign(new Error(name), { name, code });
83
+ expect(isTransientNetworkError(error)).toBe(true);
84
+ }
85
+ });
59
86
  });
@@ -321,7 +321,7 @@ describe('kimaki send --channel thread creation', () => {
321
321
  --- from: assistant (TestBot)
322
322
  *using deterministic-provider/deterministic-v2*
323
323
  ⬥ caught-by-model
324
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
324
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000830>
325
325
  --- from: user (cli-send-tester)
326
326
  --- from: assistant (TestBot)
327
327
  I can see you sent a message, but Discord did not include its text.
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './lo
11
11
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir, } from './config.js';
12
12
  import { getCurrentVersion } from './upgrade.js';
13
13
  import { store } from './store.js';
14
+ import { publicOpencodeBindRequiresPassword } from './opencode.js';
14
15
  import multioauthCommands from './commands/multioauth.js';
15
16
  import botCommands from './cli-commands/bot.js';
16
17
  import maintenanceCommands from './cli-commands/maintenance.js';
@@ -37,6 +38,7 @@ cli
37
38
  .option('--verbosity <level>', 'Default verbosity for all channels (tools_and_text, text_and_essential_tools, or text_only)')
38
39
  .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
39
40
  .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
41
+ .option('--skip-footer-mentions', 'Do not mention the thread creator in final session footers')
40
42
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
41
43
  .option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
42
44
  .option('--restrict-directories', 'Only allow the agent to access the session working directory and a few known-safe paths. Any other folder asks for permission. By default every directory is allowed and you protect folders with deny/ask rules in opencode.json')
@@ -44,6 +46,8 @@ cli
44
46
  .option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
45
47
  .option('--no-analytics', 'Disable anonymous product analytics (Strada). Same as KIMAKI_STRADA_ENABLED=0')
46
48
  .option('--no-auto-upgrade', 'Disable background auto-upgrade on startup')
49
+ .option('--opencode-hostname <host>', 'Hostname the OpenCode server listens on (default: 127.0.0.1). Use 0.0.0.0 to expose it on a VPS')
50
+ .option('--opencode-port <port>', 'Port the OpenCode server listens on (default: a random free port)')
47
51
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
48
52
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
49
53
  .option('--allow-mention <type>', z
@@ -149,12 +153,29 @@ cli
149
153
  }
150
154
  return parsed * 60_000;
151
155
  })();
156
+ const opencodeHostname = options.opencodeHostname?.trim() || undefined;
157
+ const opencodePort = (() => {
158
+ if (!options.opencodePort)
159
+ return undefined;
160
+ const parsed = Number(options.opencodePort);
161
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
162
+ cliLogger.error(`Invalid --opencode-port: ${options.opencodePort}. Must be an integer between 1 and 65535.`);
163
+ process.exit(EXIT_NO_RESTART);
164
+ }
165
+ return parsed;
166
+ })();
167
+ if (publicOpencodeBindRequiresPassword({ hostname: opencodeHostname }) &&
168
+ !process.env.OPENCODE_SERVER_PASSWORD) {
169
+ cliLogger.error(`OPENCODE_SERVER_PASSWORD is required when --opencode-hostname is ${opencodeHostname}. Binding the OpenCode server on a public address without a password lets anyone control the agent.`);
170
+ process.exit(EXIT_NO_RESTART);
171
+ }
152
172
  store.setState({
153
173
  ...(defaultVerbosity && {
154
174
  defaultVerbosity,
155
175
  }),
156
176
  ...(options.mentionMode && { defaultMentionMode: true }),
157
177
  ...(options.noCritique && { critiqueEnabled: false }),
178
+ ...(options.skipFooterMentions && { footerMentionsEnabled: false }),
158
179
  ...(options.allowAllUsers && { allowAllUsers: true }),
159
180
  ...(options.restrictDirectories && { restrictExternalDirectories: true }),
160
181
  ...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
@@ -163,6 +184,8 @@ cli
163
184
  ...(enabledSkills.length > 0 && { enabledSkills }),
164
185
  ...(disabledSkills.length > 0 && { disabledSkills }),
165
186
  ...(options.allowMention && { allowedMentions: options.allowMention }),
187
+ ...(opencodeHostname && { opencodeHostname }),
188
+ ...(opencodePort !== undefined && { opencodePort }),
166
189
  });
167
190
  if (enabledSkills.length > 0) {
168
191
  cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
@@ -188,6 +211,9 @@ cli
188
211
  if (options.noCritique) {
189
212
  cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
190
213
  }
214
+ if (options.skipFooterMentions) {
215
+ cliLogger.log('Footer mentions disabled: final session footers will not mention thread creators');
216
+ }
191
217
  if (options.noAutoUpgrade) {
192
218
  cliLogger.log('Auto-upgrade disabled: kimaki will not check for updates on startup');
193
219
  }
@@ -198,6 +224,12 @@ cli
198
224
  process.env.KIMAKI_STRADA_ENABLED = '0';
199
225
  cliLogger.log('Anonymous product analytics disabled (--no-analytics)');
200
226
  }
227
+ if (opencodeHostname) {
228
+ cliLogger.log(`OpenCode server hostname: ${opencodeHostname}`);
229
+ }
230
+ if (opencodePort !== undefined) {
231
+ cliLogger.log(`OpenCode server port: ${opencodePort}`);
232
+ }
201
233
  if (options.installUrl) {
202
234
  await printDiscordInstallUrlAndExit({
203
235
  gateway: options.gateway,
@@ -1,6 +1,6 @@
1
1
  // /abort command - Abort the current OpenCode request in this thread.
2
2
  import { ChannelType, MessageFlags, } from 'discord.js';
3
- import { getThreadSession } from '../database.js';
3
+ import { cancelSessionSleepForThread, getThreadSession } from '../database.js';
4
4
  import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
@@ -41,6 +41,10 @@ export async function handleAbortCommand({ command, }) {
41
41
  await command.editReply('No active session in this thread');
42
42
  return;
43
43
  }
44
+ // Aborting is an explicit "stop waiting" from the user, and it never goes
45
+ // through enqueueIncoming, so cancel any pending sleep here too. Otherwise the
46
+ // wake would still fire later and restart a session the user just stopped.
47
+ await cancelSessionSleepForThread({ threadId: channel.id });
44
48
  // abortActiveRun delegates to session.abort(), run settlement stays event-driven.
45
49
  const runtime = getRuntime(channel.id);
46
50
  if (runtime) {
@@ -487,14 +487,12 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
487
487
  rest: command.client.rest,
488
488
  });
489
489
  }
490
- const sessionDirectory = await (async () => {
491
- if (!worktreePromise)
492
- return projectDirectory;
493
- const result = await worktreePromise;
494
- if (result instanceof Error)
495
- return projectDirectory;
496
- return result;
497
- })();
490
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
491
+ if (worktreeResult instanceof Error) {
492
+ await command.editReply(`Worktree creation failed: ${worktreeResult.message}`);
493
+ return;
494
+ }
495
+ const sessionDirectory = worktreeResult;
498
496
  await command
499
497
  .editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
500
498
  .catch(() => {