kimaki 0.28.0 → 0.30.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 (179) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bash-tool-schema-plugin.js +18 -0
  7. package/dist/bash-tool-schema-plugin.test.js +20 -1
  8. package/dist/bin.js +7 -3
  9. package/dist/bot-lifecycle.test.js +206 -0
  10. package/dist/cache-drift-plugin.js +25 -18
  11. package/dist/cache-rewrite.js +39 -0
  12. package/dist/cache-rewrite.test.js +41 -0
  13. package/dist/channel-management.js +113 -51
  14. package/dist/cli-commands/misc.js +4 -1
  15. package/dist/cli-commands/project.js +7 -4
  16. package/dist/cli-commands/session.js +118 -36
  17. package/dist/cli-commands/thread.js +63 -0
  18. package/dist/cli-parsing.test.js +3 -3
  19. package/dist/cli-runner.js +11 -1
  20. package/dist/cli-send-thread.e2e.test.js +2 -2
  21. package/dist/cli.js +6 -4
  22. package/dist/commands/ask-question.js +72 -23
  23. package/dist/commands/queue.js +84 -36
  24. package/dist/commands/queue.test.js +15 -0
  25. package/dist/commands/restart-opencode-server.js +7 -1
  26. package/dist/commands/session-id.js +5 -5
  27. package/dist/database.js +149 -6
  28. package/dist/db.js +34 -0
  29. package/dist/db.test.js +199 -1
  30. package/dist/default-channel-provisioning.test.js +2 -1
  31. package/dist/discord-bot.js +220 -146
  32. package/dist/discord-command-registration.js +1 -1
  33. package/dist/external-directory-permissions.e2e.test.js +3 -3
  34. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  35. package/dist/forum-sync/watchers.js +15 -0
  36. package/dist/gateway-proxy.e2e.test.js +9 -9
  37. package/dist/guild-categories.test.js +225 -0
  38. package/dist/hrana-server.js +68 -14
  39. package/dist/interaction-handler.js +5 -1
  40. package/dist/kimaki-opencode-plugin.js +2 -0
  41. package/dist/kimaki-opencode-plugin.test.js +28 -0
  42. package/dist/list-channel-threads.js +163 -0
  43. package/dist/list-channel-threads.test.js +80 -0
  44. package/dist/markdown.js +164 -102
  45. package/dist/markdown.test.js +88 -20
  46. package/dist/message-formatting.js +29 -18
  47. package/dist/message-formatting.test.js +111 -3
  48. package/dist/opencode.js +27 -12
  49. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  50. package/dist/question-abort-resume.e2e.test.js +100 -0
  51. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  52. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  53. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  54. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  55. package/dist/queue-advanced-e2e-setup.js +63 -0
  56. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  57. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  58. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  59. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  60. package/dist/queue-delete-message.e2e.test.js +4 -4
  61. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  62. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  63. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  64. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  65. package/dist/schema.js +21 -0
  66. package/dist/session-handler/event-stream-state.js +240 -6
  67. package/dist/session-handler/event-stream-state.test.js +1116 -130
  68. package/dist/session-handler/thread-runtime-state.js +4 -0
  69. package/dist/session-handler/thread-session-runtime.js +499 -89
  70. package/dist/sleep.e2e.test.js +7 -7
  71. package/dist/store.js +1 -1
  72. package/dist/system-message.js +64 -23
  73. package/dist/system-message.test.js +69 -23
  74. package/dist/task-id-plugin.js +16 -0
  75. package/dist/task-runner.js +7 -0
  76. package/dist/task-schedule.test.js +1 -1
  77. package/dist/test-utils.js +5 -3
  78. package/dist/thread-message-queue.e2e.test.js +30 -31
  79. package/dist/undo-redo.e2e.test.js +3 -3
  80. package/dist/voice-message.e2e.test.js +64 -26
  81. package/dist/voice-routing-input.e2e.test.js +4 -4
  82. package/dist/voice-routing-question.e2e.test.js +4 -4
  83. package/dist/voice.js +107 -21
  84. package/dist/voice.test.js +52 -1
  85. package/dist/wait-session.js +24 -2
  86. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  87. package/package.json +7 -7
  88. package/skills/egaki/SKILL.md +14 -0
  89. package/skills/holocron/SKILL.md +125 -42
  90. package/skills/opensrc/SKILL.md +20 -3
  91. package/skills/spiceflow/SKILL.md +53 -172
  92. package/skills/strada/SKILL.md +61 -232
  93. package/src/agent-model.e2e.test.ts +33 -33
  94. package/src/anthropic-account-identity.ts +1 -1
  95. package/src/anthropic-auth-plugin.test.ts +37 -2
  96. package/src/anthropic-auth-plugin.ts +9 -7
  97. package/src/anthropic-auth-state.ts +1 -0
  98. package/src/bash-tool-schema-plugin.test.ts +29 -0
  99. package/src/bash-tool-schema-plugin.ts +31 -0
  100. package/src/bin.ts +7 -3
  101. package/src/bot-lifecycle.test.ts +252 -0
  102. package/src/cache-drift-plugin.ts +34 -24
  103. package/src/cache-rewrite.test.ts +49 -0
  104. package/src/cache-rewrite.ts +96 -0
  105. package/src/channel-management.ts +144 -65
  106. package/src/cli-commands/misc.ts +4 -1
  107. package/src/cli-commands/project.ts +9 -4
  108. package/src/cli-commands/session.ts +167 -38
  109. package/src/cli-commands/thread.ts +86 -0
  110. package/src/cli-parsing.test.ts +3 -3
  111. package/src/cli-runner.ts +11 -0
  112. package/src/cli-send-thread.e2e.test.ts +2 -2
  113. package/src/cli.ts +8 -6
  114. package/src/commands/ask-question.ts +88 -25
  115. package/src/commands/queue.test.ts +19 -0
  116. package/src/commands/queue.ts +111 -45
  117. package/src/commands/restart-opencode-server.ts +12 -1
  118. package/src/commands/session-id.ts +5 -5
  119. package/src/database.ts +219 -6
  120. package/src/db.test.ts +236 -0
  121. package/src/db.ts +38 -0
  122. package/src/default-channel-provisioning.test.ts +2 -1
  123. package/src/discord-bot.ts +272 -185
  124. package/src/discord-command-registration.ts +3 -1
  125. package/src/external-directory-permissions.e2e.test.ts +3 -3
  126. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  127. package/src/forum-sync/watchers.ts +20 -0
  128. package/src/gateway-proxy.e2e.test.ts +9 -9
  129. package/src/guild-categories.test.ts +260 -0
  130. package/src/hrana-server.ts +96 -16
  131. package/src/interaction-handler.ts +7 -0
  132. package/src/kimaki-opencode-plugin.test.ts +34 -0
  133. package/src/kimaki-opencode-plugin.ts +2 -0
  134. package/src/list-channel-threads.test.ts +90 -0
  135. package/src/list-channel-threads.ts +252 -0
  136. package/src/markdown.test.ts +108 -22
  137. package/src/markdown.ts +216 -103
  138. package/src/message-formatting.test.ts +129 -2
  139. package/src/message-formatting.ts +28 -14
  140. package/src/opencode.ts +28 -13
  141. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  142. package/src/question-abort-resume.e2e.test.ts +121 -0
  143. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  144. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  145. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  146. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  147. package/src/queue-advanced-e2e-setup.ts +65 -0
  148. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  149. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  150. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  151. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  152. package/src/queue-delete-message.e2e.test.ts +4 -4
  153. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  154. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  155. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  156. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  157. package/src/schema.sql +16 -0
  158. package/src/schema.ts +23 -0
  159. package/src/session-handler/event-stream-state.test.ts +1184 -133
  160. package/src/session-handler/event-stream-state.ts +322 -8
  161. package/src/session-handler/thread-runtime-state.ts +5 -0
  162. package/src/session-handler/thread-session-runtime.ts +597 -95
  163. package/src/sleep.e2e.test.ts +7 -7
  164. package/src/store.ts +2 -2
  165. package/src/system-message.test.ts +70 -23
  166. package/src/system-message.ts +67 -22
  167. package/src/task-id-plugin.ts +17 -0
  168. package/src/task-runner.ts +8 -0
  169. package/src/task-schedule.test.ts +1 -1
  170. package/src/test-utils.ts +5 -3
  171. package/src/thread-message-queue.e2e.test.ts +30 -31
  172. package/src/undo-redo.e2e.test.ts +3 -3
  173. package/src/voice-message.e2e.test.ts +70 -26
  174. package/src/voice-routing-input.e2e.test.ts +4 -4
  175. package/src/voice-routing-question.e2e.test.ts +4 -4
  176. package/src/voice.test.ts +56 -0
  177. package/src/voice.ts +135 -25
  178. package/src/wait-session.ts +25 -2
  179. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -54,45 +54,92 @@ async function resolveSessionDirectoryFromDatabase({ sessionId, }) {
54
54
  }
55
55
  return new Error(`Session is not linked to a Kimaki thread in the local database: ${sessionId}`);
56
56
  }
57
+ // Total token footprint of a session (input + output + reasoning + cache).
58
+ function getSessionTokenTotal(tokens) {
59
+ return (tokens.input +
60
+ tokens.output +
61
+ tokens.reasoning +
62
+ tokens.cache.read +
63
+ tokens.cache.write);
64
+ }
65
+ // Compact token count: 1234 -> "1k", 200000 -> "200k", 1_500_000 -> "1.5M".
66
+ function formatTokenCount(count) {
67
+ if (count >= 1_000_000) {
68
+ return `${(count / 1_000_000).toFixed(1)}M`;
69
+ }
70
+ if (count >= 1_000) {
71
+ return `${Math.round(count / 1_000)}k`;
72
+ }
73
+ return String(count);
74
+ }
57
75
  cli
58
76
  .command('session list', 'List all OpenCode sessions, marking which were started via Kimaki')
59
77
  .option('--project <path>', 'Project directory to list sessions for (defaults to cwd)')
78
+ .option('--all', 'List sessions across every locally registered project')
60
79
  .option('--active', 'Only list active sessions; exits 1 when none remain')
61
80
  .option('--exclude <sessionId>', 'Exclude one session ID from the results')
62
81
  .option('--json', 'Output as JSON')
63
82
  .action(async (options) => {
64
83
  try {
65
- const projectDirectory = path.resolve(options.project || '.');
66
84
  await initDatabase();
67
- cliLogger.log('Connecting to OpenCode server...');
68
- const getClient = await initializeOpencodeForDirectory(projectDirectory);
69
- if (getClient instanceof Error) {
70
- cliLogger.error('Failed to connect to OpenCode:', getClient.message);
85
+ if (options.all && options.project) {
86
+ cliLogger.error('Use either --all or --project, not both');
71
87
  process.exit(EXIT_NO_RESTART);
72
88
  }
73
- const sessionsResponse = await getClient().session.list();
74
- const sessions = sessionsResponse.data || [];
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');
89
+ const projectDirectories = options.all
90
+ ? Array.from(new Set((await getAllTextChannelDirectories()).map((directory) => path.resolve(directory))))
91
+ : [path.resolve(options.project || '.')];
92
+ if (projectDirectories.length === 0) {
93
+ cliLogger.error('No registered project directories found. Add a project with `kimaki project add`.');
94
+ process.exit(EXIT_NO_RESTART);
95
+ }
96
+ const gathered = [];
97
+ for (const projectDirectory of projectDirectories) {
98
+ cliLogger.log(`Connecting to OpenCode server for ${projectDirectory}...`);
99
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
100
+ if (getClient instanceof Error) {
101
+ if (options.all) {
102
+ cliLogger.warn(`Skipping ${projectDirectory}: failed to connect to OpenCode: ${getClient.message}`);
103
+ continue;
104
+ }
105
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message);
83
106
  process.exit(EXIT_NO_RESTART);
84
107
  }
85
- return response.data || {};
86
- })();
87
- const selectedSessions = sessions.filter((session) => {
88
- if (session.id === options.exclude)
108
+ const client = getClient();
109
+ const [sessionsResponse, statusResponse, questionsResponse] = await Promise.all([
110
+ client.session.list(),
111
+ client.session.status({ directory: projectDirectory }).catch(() => null),
112
+ client.question.list({ directory: projectDirectory }).catch(() => null),
113
+ ]);
114
+ const statuses = statusResponse?.data || {};
115
+ const sessionsWithPendingQuestion = new Set((questionsResponse?.data || []).map((request) => request.sessionID));
116
+ for (const session of sessionsResponse.data || []) {
117
+ const status = statuses[session.id];
118
+ const isBusy = Boolean(status && status.type !== 'idle');
119
+ // Only relabel to showing-question when the session is actually busy.
120
+ // An orphaned question left in the list after an abort (session idle)
121
+ // must not hide or relabel an otherwise-idle session.
122
+ gathered.push({
123
+ session,
124
+ projectDirectory,
125
+ status: isBusy && sessionsWithPendingQuestion.has(session.id)
126
+ ? 'showing-question'
127
+ : isBusy
128
+ ? 'busy'
129
+ : 'idle',
130
+ });
131
+ }
132
+ }
133
+ const selected = gathered
134
+ .filter((entry) => {
135
+ if (entry.session.id === options.exclude)
89
136
  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) {
137
+ if (options.active && entry.status !== 'busy')
138
+ return false;
139
+ return true;
140
+ })
141
+ .sort((a, b) => b.session.time.updated - a.session.time.updated);
142
+ if (selected.length === 0) {
96
143
  if (options.json)
97
144
  console.log('[]');
98
145
  else
@@ -107,15 +154,26 @@ cli
107
154
  const sessionToThread = new Map(threadSessions
108
155
  .filter((row) => row.session_id !== '')
109
156
  .map((row) => [row.session_id, row.thread_id]));
110
- const sessionStartSources = await getSessionStartSourcesBySessionIds(selectedSessions.map((session) => session.id));
157
+ const sessionStartSources = await getSessionStartSourcesBySessionIds(selected.map((entry) => entry.session.id));
111
158
  const scheduleModeLabel = ({ scheduleKind, }) => {
112
159
  if (scheduleKind === 'at') {
113
160
  return 'delay';
114
161
  }
115
162
  return 'cron';
116
163
  };
164
+ // Token footprint straight from the session object (no message fetch).
165
+ // Session.tokens is a per-session aggregate, so it is reported as a token
166
+ // count rather than a context-window percentage.
167
+ const contextInfo = (entry) => {
168
+ const tokens = entry.session.tokens;
169
+ if (!tokens)
170
+ return null;
171
+ const total = getSessionTokenTotal(tokens);
172
+ return total > 0 ? total : null;
173
+ };
117
174
  if (options.json) {
118
- const output = selectedSessions.map((session) => {
175
+ const output = selected.map((entry) => {
176
+ const session = entry.session;
119
177
  const startSource = sessionStartSources.get(session.id);
120
178
  const startedBy = startSource
121
179
  ? `scheduled-${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}`
@@ -127,15 +185,18 @@ cli
127
185
  updated: new Date(session.time.updated).toISOString(),
128
186
  source: sessionToThread.has(session.id) ? 'kimaki' : 'opencode',
129
187
  threadId: sessionToThread.get(session.id) || null,
188
+ status: entry.status,
189
+ model: session.model?.id || null,
190
+ tokens: contextInfo(entry),
130
191
  startedBy,
131
192
  scheduledTaskId: startSource?.scheduled_task_id || null,
132
- status: options.active ? statuses?.[session.id]?.type || 'busy' : undefined,
133
193
  };
134
194
  });
135
195
  console.log(JSON.stringify(output, null, 2));
136
196
  process.exit(0);
137
197
  }
138
- for (const session of selectedSessions) {
198
+ for (const entry of selected) {
199
+ const session = entry.session;
139
200
  const threadId = sessionToThread.get(session.id);
140
201
  const startSource = sessionStartSources.get(session.id);
141
202
  const source = threadId ? '(kimaki)' : '(opencode)';
@@ -144,10 +205,15 @@ cli
144
205
  : '';
145
206
  const updatedAt = new Date(session.time.updated).toISOString();
146
207
  const threadInfo = threadId ? ` | thread: ${threadId}` : '';
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}`);
208
+ const statusLabel = entry.status === 'busy'
209
+ ? 'working'
210
+ : entry.status === 'showing-question'
211
+ ? 'showing-question'
212
+ : 'idle';
213
+ const statusInfo = ` | status: ${statusLabel}`;
214
+ const tokens = contextInfo(entry);
215
+ const tokensText = tokens ? ` | tokens: ${formatTokenCount(tokens)}` : '';
216
+ console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${statusInfo}${tokensText}${threadInfo}${startedBy}`);
151
217
  }
152
218
  process.exit(0);
153
219
  }
@@ -230,9 +296,18 @@ cli
230
296
  }
231
297
  });
232
298
  cli
233
- .command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
299
+ .command('session read <sessionId>', dedent `
300
+ Read a session conversation as markdown (pipe to file to grep).
301
+
302
+ Thinking is omitted by default. Tool inputs are truncated. Use
303
+ \`--thinking\` and \`--verbose\` for the full dump.
304
+ `)
234
305
  .option('--project <path>', 'Project directory (defaults to cwd)')
235
306
  .option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
307
+ .option('--thinking', 'Include reasoning / thinking parts')
308
+ .option('--tool-input-max-chars <n>', z.number().default(80).describe('Max characters for compact tool input'))
309
+ .example('kimaki session read ses_xxx > ./tmp/session.md')
310
+ .example('kimaki session read ses_xxx --thinking --verbose')
236
311
  .action(async (sessionId, options) => {
237
312
  try {
238
313
  const projectDirectory = path.resolve(options.project || '.');
@@ -246,7 +321,12 @@ cli
246
321
  // Try current project first (fast path)
247
322
  const compactTools = !options.verbose;
248
323
  const markdown = new ShareMarkdown(getClient());
249
- const result = await markdown.generate({ sessionID: sessionId, compactTools });
324
+ const result = await markdown.generate({
325
+ sessionID: sessionId,
326
+ compactTools,
327
+ includeThinking: options.thinking,
328
+ toolInputMaxChars: options.toolInputMaxChars,
329
+ });
250
330
  if (!(result instanceof Error)) {
251
331
  process.stdout.write(result);
252
332
  process.exit(0);
@@ -280,6 +360,8 @@ cli
280
360
  const otherResult = await otherMarkdown.generate({
281
361
  sessionID: sessionId,
282
362
  compactTools,
363
+ includeThinking: options.thinking,
364
+ toolInputMaxChars: options.toolInputMaxChars,
283
365
  });
284
366
  if (!(otherResult instanceof Error)) {
285
367
  process.stdout.write(otherResult);
@@ -295,7 +377,7 @@ cli
295
377
  }
296
378
  });
297
379
  cli
298
- .command('session wait <sessionId>', 'Wait for a session to finish, then print its conversation as markdown')
380
+ .command('session wait <sessionId>', 'Wait until a session finishes or pauses for a user question, then print its conversation as markdown')
299
381
  .action(async (sessionId) => {
300
382
  try {
301
383
  await initDatabase();
@@ -0,0 +1,63 @@
1
+ import { goke } from 'goke';
2
+ import { z } from 'zod';
3
+ import { createLogger, LogPrefix } from '../logger.js';
4
+ import { initDatabase } from '../discord-bot.js';
5
+ import { createDiscordRest } from '../discord-urls.js';
6
+ import { EXIT_NO_RESTART, resolveBotCredentials } from '../cli-runner.js';
7
+ import { listDiscordChannelThreads } from '../list-channel-threads.js';
8
+ const cliLogger = createLogger(LogPrefix.CLI);
9
+ const cli = goke();
10
+ cli
11
+ .command('thread list', 'List Discord threads in a channel. Use this to find a thread on another computer, then send with --thread.')
12
+ .option('-c, --channel <channelId>', 'Discord channel ID')
13
+ .option('--json', 'Output as JSON')
14
+ .option('--limit <n>', z.number().default(50).describe('Maximum threads to show'))
15
+ .example('kimaki thread list --channel 123456789012345678')
16
+ .example('kimaki thread list --channel 123456789012345678 --json')
17
+ .action(async (options, { console, process }) => {
18
+ try {
19
+ if (!options.channel) {
20
+ cliLogger.error('Channel ID is required. Use --channel <channelId>. Find remote channels with `kimaki project list --all --json`.');
21
+ process.exit(EXIT_NO_RESTART);
22
+ }
23
+ await initDatabase();
24
+ const { token: botToken } = await resolveBotCredentials({});
25
+ const rest = createDiscordRest(botToken);
26
+ const listed = await listDiscordChannelThreads({
27
+ rest,
28
+ channelId: options.channel,
29
+ });
30
+ if (!listed.ok) {
31
+ cliLogger.error(listed.error.message);
32
+ process.exit(EXIT_NO_RESTART);
33
+ return;
34
+ }
35
+ const limited = listed.threads.slice(0, options.limit);
36
+ if (options.json) {
37
+ console.log(JSON.stringify(limited.map((thread) => ({
38
+ thread_id: thread.id,
39
+ name: thread.name,
40
+ channel_id: thread.parentId,
41
+ guild_id: thread.guildId,
42
+ archived: thread.archived,
43
+ archive_state: thread.archiveState,
44
+ last_message_id: thread.lastMessageId,
45
+ })), null, 2));
46
+ process.exit(0);
47
+ }
48
+ if (limited.length === 0) {
49
+ console.log(`No threads found in channel ${options.channel}`);
50
+ process.exit(0);
51
+ }
52
+ for (const thread of limited) {
53
+ const archivedTag = thread.archived ? ' [archived]' : '';
54
+ console.log(`${thread.id} | ${thread.name}${archivedTag}`);
55
+ }
56
+ process.exit(0);
57
+ }
58
+ catch (error) {
59
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
60
+ process.exit(EXIT_NO_RESTART);
61
+ }
62
+ });
63
+ export default cli;
@@ -50,7 +50,7 @@ async function parseRootBotOptions(argv) {
50
50
  const script = [
51
51
  "import { goke } from 'goke'",
52
52
  'const cli = goke(\'kimaki\')',
53
- "cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--skip-footer-mentions', 'Do not mention the thread creator in final footers')",
53
+ "cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--enable-footer-mentions', 'Mention the thread creator in final footers')",
54
54
  `const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
55
55
  'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
56
56
  ].join(';');
@@ -213,13 +213,13 @@ describe('goke CLI ID parsing', () => {
213
213
  'node',
214
214
  'kimaki',
215
215
  '--no-analytics',
216
- '--skip-footer-mentions',
216
+ '--enable-footer-mentions',
217
217
  ]);
218
218
  expect(result.options).toMatchInlineSnapshot(`
219
219
  {
220
220
  "--": [],
221
+ "enableFooterMentions": true,
221
222
  "noAnalytics": true,
222
- "skipFooterMentions": true,
223
223
  }
224
224
  `);
225
225
  });
@@ -2,7 +2,7 @@
2
2
  // Keeps cli.ts focused on command composition while preserving the bot onboarding flow.
3
3
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, select, spinner, } from '@clack/prompts';
4
4
  import { deduplicateByKey, generateBotInstallUrl, generateDiscordInstallUrlForBot, KIMAKI_GATEWAY_APP_ID, KIMAKI_WEBSITE_URL, abbreviatePath, } from './utils.js';
5
- import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
5
+ import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, registerBotLifecycleHandlers, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
6
6
  import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getDb, } from './database.js';
7
7
  import * as orm from 'drizzle-orm';
8
8
  import * as dbSchema from './schema.js';
@@ -1175,6 +1175,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1175
1175
  cliLogger.error('Failed to start hrana server:', hranaResult.message);
1176
1176
  process.exit(EXIT_NO_RESTART);
1177
1177
  }
1178
+ // We own the lock port from here on. Stop signals must release it in every
1179
+ // startup phase (Discord login, channel reconcile), not only after ready.
1180
+ registerBotLifecycleHandlers();
1178
1181
  // Initialize database (connects to hrana server via HTTP)
1179
1182
  await initDatabase();
1180
1183
  const { appId, token, credentialSource, isGatewayMode, installerDiscordUserId } = await resolveCredentials({
@@ -1316,6 +1319,10 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1316
1319
  }
1317
1320
  process.exit(EXIT_NO_RESTART);
1318
1321
  }
1322
+ // Stop signal during Discord login: shutdown owns the process now.
1323
+ if (global.shuttingDown) {
1324
+ return;
1325
+ }
1319
1326
  await setBotToken(appId, token);
1320
1327
  // In gateway mode the bot only sees guilds the user has installed
1321
1328
  // it in. Zero guilds means the install URL callback never completed or the
@@ -1351,6 +1358,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
1351
1358
  // Start bot immediately — channel sync happens in the background.
1352
1359
  cliLogger.log('Starting Discord bot...');
1353
1360
  await startDiscordBot({ token, appId, discordClient, useWorktrees });
1361
+ if (global.shuttingDown) {
1362
+ return;
1363
+ }
1354
1364
  cliLogger.log('Discord bot is running!');
1355
1365
  // Background channel sync + role reconciliation + default channel creation.
1356
1366
  // Never blocks ready state.
@@ -319,9 +319,9 @@ describe('kimaki send --channel thread creation', () => {
319
319
  "--- from: user (cli-send-tester)
320
320
  thread empty content seed
321
321
  --- from: assistant (TestBot)
322
- > *using deterministic-provider/deterministic-v2*
322
+ -# *using deterministic-provider/deterministic-v2*
323
323
  > caught-by-model
324
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000830>
324
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
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
@@ -20,6 +20,7 @@ import projectCommands from './cli-commands/project.js';
20
20
  import sendCommands from './cli-commands/send.js';
21
21
  import sessionCommands from './cli-commands/session.js';
22
22
  import taskCommands from './cli-commands/task.js';
23
+ import threadCommands from './cli-commands/thread.js';
23
24
  import userCommands from './cli-commands/user.js';
24
25
  import { EXIT_NO_RESTART, printDiscordInstallUrlAndExit, run, } from './cli-runner.js';
25
26
  const cliLogger = createLogger(LogPrefix.CLI);
@@ -38,7 +39,7 @@ cli
38
39
  .option('--verbosity <level>', 'Default verbosity for all channels (tools_and_text, text_and_essential_tools, or text_only)')
39
40
  .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
40
41
  .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')
42
+ .option('--enable-footer-mentions', 'Mention the thread creator in final session footers so they get a notification')
42
43
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
43
44
  .option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
44
45
  .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')
@@ -175,7 +176,7 @@ cli
175
176
  }),
176
177
  ...(options.mentionMode && { defaultMentionMode: true }),
177
178
  ...(options.noCritique && { critiqueEnabled: false }),
178
- ...(options.skipFooterMentions && { footerMentionsEnabled: false }),
179
+ ...(options.enableFooterMentions && { footerMentionsEnabled: true }),
179
180
  ...(options.allowAllUsers && { allowAllUsers: true }),
180
181
  ...(options.restrictDirectories && { restrictExternalDirectories: true }),
181
182
  ...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
@@ -211,8 +212,8 @@ cli
211
212
  if (options.noCritique) {
212
213
  cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
213
214
  }
214
- if (options.skipFooterMentions) {
215
- cliLogger.log('Footer mentions disabled: final session footers will not mention thread creators');
215
+ if (options.enableFooterMentions) {
216
+ cliLogger.log('Footer mentions enabled: final session footers will mention thread creators');
216
217
  }
217
218
  if (options.noAutoUpgrade) {
218
219
  cliLogger.log('Auto-upgrade disabled: kimaki will not check for updates on startup');
@@ -258,6 +259,7 @@ cli.use(miscCommands);
258
259
  cli.use(sendCommands);
259
260
  cli.use(taskCommands);
260
261
  cli.use(projectCommands);
262
+ cli.use(threadCommands);
261
263
  cli.use(userCommands);
262
264
  cli.use(sessionCommands);
263
265
  cli.use(maintenanceCommands);
@@ -8,6 +8,7 @@ import { getOpencodeClient } from '../opencode.js';
8
8
  import { DiscordOperationError } from '../errors.js';
9
9
  import { createLogger, LogPrefix } from '../logger.js';
10
10
  import { QUEUE_PREFIX } from '../message-formatting.js';
11
+ import { getRuntime } from '../session-handler/thread-session-runtime.js';
11
12
  const logger = createLogger(LogPrefix.ASK_QUESTION);
12
13
  // Store pending question contexts by hash.
13
14
  // No TTL on purpose: a question can only be answered by the user, so expiring it
@@ -185,41 +186,89 @@ export async function handleAskQuestionSelectMenu(interaction) {
185
186
  });
186
187
  const username = interaction.user.globalName || interaction.user.username;
187
188
  await sendThreadMessage(context.thread, `${QUEUE_PREFIX}**${username}:** ${answeredText}`);
188
- // Check if all questions are answered
189
- if (areAllQuestionsAnswered(context)) {
190
- // All questions answered - send result back to session
191
- await submitQuestionAnswers(context);
189
+ // Check if all questions are answered. Claim the context synchronously
190
+ // (delete before awaiting submit) so two concurrent final selections for a
191
+ // multi-question tool cannot both submit and double-resume the session.
192
+ if (areAllQuestionsAnswered(context)
193
+ && pendingQuestionContexts.get(contextHash) === context) {
192
194
  deletePendingQuestionContextsForRequest({
193
195
  threadId: context.thread.id,
194
196
  requestId: context.requestId,
195
197
  });
198
+ await submitQuestionAnswers(context);
199
+ }
200
+ }
201
+ /**
202
+ * Format collected answers as a plain-text summary the model can read when
203
+ * the run is resumed after an abort (e.g. `"Which option?"="Alpha"`).
204
+ *
205
+ * Known limitation: if the user had queued items via /queue during the pending
206
+ * question AND aborted the run from another opencode client, the first queued
207
+ * item may have already been handed off to opencode and can lose its ordering
208
+ * on resume. That rare combination needs a queue-handoff redesign; the common
209
+ * (no-queue) abort case is handled correctly.
210
+ */
211
+ function formatQuestionAnswersText(context) {
212
+ const parts = context.questions.map((q, i) => {
213
+ const answer = (context.answers[i] || []).join(', ') || 'Unanswered';
214
+ return `"${q.question}"="${answer}"`;
215
+ });
216
+ return `Answers to your previous questions: ${parts.join(', ')}`;
217
+ }
218
+ /** Resume the session by feeding the answers back as a new user prompt. */
219
+ async function resumeSessionWithAnswers(context) {
220
+ const runtime = getRuntime(context.thread.id);
221
+ const resumed = await runtime?.resumeWithText({
222
+ text: formatQuestionAnswersText(context),
223
+ });
224
+ if (!resumed) {
225
+ await sendThreadMessage(context.thread, '✗ Failed to submit answers: session is no longer active');
196
226
  }
197
227
  }
198
228
  /**
199
229
  * Submit all collected answers back to the OpenCode session.
200
- * Uses the question.reply API to provide answers to the waiting tool.
230
+ *
231
+ * The decision is based on whether the session still has a live run:
232
+ *
233
+ * - Busy: a live run is parked on the question. Reply so it continues.
234
+ * - Idle: the run was aborted (from this or another opencode client). Replying
235
+ * would resolve a dead run and the session would never continue, so resume it
236
+ * with the answers as a fresh prompt instead.
237
+ *
238
+ * Note: `question.list` is not a reliable signal here. After an abort the
239
+ * question request can stay in the pending list but orphaned (no run awaiting
240
+ * it), so a reply would succeed yet the session would still not continue. The
241
+ * session busy state, derived from the event stream, is the accurate signal.
242
+ * The reply `.error` check is a safety net for the case where the request was
243
+ * actually removed while the session still looked busy.
201
244
  */
202
245
  async function submitQuestionAnswers(context) {
203
- try {
204
- const client = getOpencodeClient(context.directory);
205
- if (!client) {
206
- throw new Error('OpenCode server not found for directory');
207
- }
208
- // Build answers array: each element is an array of selected labels for that question
209
- const answers = context.questions.map((_, i) => {
210
- return context.answers[i] || [];
211
- });
212
- await client.question.reply({
213
- requestID: context.requestId,
214
- directory: context.directory,
215
- answers,
216
- });
217
- logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
246
+ const client = getOpencodeClient(context.directory);
247
+ if (!client) {
248
+ logger.error('OpenCode server not found for directory');
249
+ return;
218
250
  }
219
- catch (error) {
220
- logger.error('Failed to submit answers:', error);
221
- await sendThreadMessage(context.thread, `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`);
251
+ const runtime = getRuntime(context.thread.id);
252
+ if (runtime && !runtime.isBusy()) {
253
+ logger.log(`Session ${context.sessionId} idle; resuming with answers for question ${context.requestId}`);
254
+ await resumeSessionWithAnswers(context);
255
+ return;
256
+ }
257
+ const answers = context.questions.map((_, i) => {
258
+ return context.answers[i] || [];
259
+ });
260
+ // throwOnError is off on this client, so failures come back in `.error`.
261
+ const replyResult = await client.question
262
+ .reply({ requestID: context.requestId, directory: context.directory, answers })
263
+ .catch((error) => ({ error }));
264
+ if (!replyResult.error) {
265
+ logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
266
+ return;
222
267
  }
268
+ // Reply failed (the request was removed while the session still looked busy).
269
+ // Resume so the answer still reaches the model.
270
+ logger.log(`Reply failed for question ${context.requestId}; resuming session ${context.sessionId} with answers`);
271
+ await resumeSessionWithAnswers(context);
223
272
  }
224
273
  /**
225
274
  * Check if a tool part is an AskUserQuestion tool.