kimaki 0.25.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 (223) hide show
  1. package/dist/agent-model.e2e.test.js +157 -122
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/anthropic-account-identity.js +1 -0
  4. package/dist/anthropic-auth-plugin.js +1 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bin.js +73 -19
  7. package/dist/cli-commands/maintenance.js +46 -7
  8. package/dist/cli-commands/send.js +37 -3
  9. package/dist/cli-commands/session.js +183 -8
  10. package/dist/cli-commands/task.js +39 -8
  11. package/dist/cli-parsing.test.js +59 -3
  12. package/dist/cli-runner.js +94 -74
  13. package/dist/cli-runner.test.js +37 -1
  14. package/dist/cli-send-thread.e2e.test.js +1 -1
  15. package/dist/cli.js +32 -0
  16. package/dist/commands/abort.js +5 -1
  17. package/dist/commands/agent.js +93 -82
  18. package/dist/commands/ask-question.js +21 -32
  19. package/dist/commands/ask-question.test.js +43 -1
  20. package/dist/commands/login.js +6 -0
  21. package/dist/commands/merge-worktree.js +36 -10
  22. package/dist/commands/model-variant.js +7 -1
  23. package/dist/commands/model.js +32 -8
  24. package/dist/commands/multioauth.js +170 -1
  25. package/dist/commands/new-worktree.js +170 -102
  26. package/dist/commands/tasks.js +6 -0
  27. package/dist/commands/unset-model.js +5 -20
  28. package/dist/commands/user-command.js +6 -8
  29. package/dist/commands/worktrees.js +5 -13
  30. package/dist/config.js +16 -1
  31. package/dist/database.js +189 -3
  32. package/dist/db.js +22 -0
  33. package/dist/db.test.js +167 -1
  34. package/dist/discord-bot.js +41 -33
  35. package/dist/discord-command-registration.js +9 -1
  36. package/dist/discord-utils.js +13 -5
  37. package/dist/discord-utils.test.js +17 -1
  38. package/dist/errors.js +5 -0
  39. package/dist/exec-async.js +17 -5
  40. package/dist/external-directory-permissions.e2e.test.js +2 -2
  41. package/dist/file-edit-log.js +197 -0
  42. package/dist/file-edit-log.test.js +258 -0
  43. package/dist/format-tables.js +52 -0
  44. package/dist/format-tables.test.js +93 -0
  45. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  46. package/dist/gateway-proxy.e2e.test.js +5 -5
  47. package/dist/git-worktree-core.js +182 -17
  48. package/dist/ipc-tools-plugin.js +67 -6
  49. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  50. package/dist/kimaki-opencode-plugin.js +9 -3
  51. package/dist/kimaki-workspace-adaptor.js +33 -10
  52. package/dist/logger.js +10 -1
  53. package/dist/markdown.js +3 -1
  54. package/dist/markdown.test.js +11 -3
  55. package/dist/message-formatting.js +95 -5
  56. package/dist/message-formatting.test.js +207 -2
  57. package/dist/oauth-rotation-shared.js +17 -1
  58. package/dist/openai-auth-plugin.js +1 -0
  59. package/dist/openai-auth-state.js +21 -0
  60. package/dist/opencode-command.test.js +60 -0
  61. package/dist/opencode.js +53 -12
  62. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  63. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  64. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  65. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  66. package/dist/queue-advanced-e2e-setup.js +78 -5
  67. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  68. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  69. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  70. package/dist/queue-advanced-question.e2e.test.js +31 -26
  71. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  72. package/dist/queue-delete-message.e2e.test.js +1 -1
  73. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  74. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  75. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  76. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  77. package/dist/schema.js +53 -0
  78. package/dist/session-handler/event-stream-state.js +364 -0
  79. package/dist/session-handler/event-stream-state.test.js +844 -1
  80. package/dist/session-handler/model-utils.js +159 -5
  81. package/dist/session-handler/model-utils.test.js +345 -0
  82. package/dist/session-handler/thread-session-runtime.js +288 -165
  83. package/dist/session-sleep.test.js +224 -0
  84. package/dist/session-title-rename.test.js +7 -39
  85. package/dist/sleep.e2e.test.js +491 -0
  86. package/dist/startup-time.e2e.test.js +2 -1
  87. package/dist/store.js +3 -0
  88. package/dist/system-message.js +76 -9
  89. package/dist/system-message.test.js +103 -9
  90. package/dist/task-runner.js +235 -14
  91. package/dist/task-schedule.js +68 -0
  92. package/dist/task-schedule.test.js +219 -2
  93. package/dist/thread-message-queue.e2e.test.js +10 -169
  94. package/dist/undo-redo.e2e.test.js +2 -2
  95. package/dist/voice-attachment.js +23 -1
  96. package/dist/voice-message.e2e.test.js +10 -10
  97. package/dist/voice.test.js +55 -29
  98. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  99. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  100. package/dist/worktrees.js +226 -443
  101. package/dist/worktrees.test.js +435 -58
  102. package/dist/xai-auth-plugin.js +238 -0
  103. package/dist/xai-auth-state.js +234 -0
  104. package/dist/xai-auth-state.test.js +183 -0
  105. package/package.json +9 -7
  106. package/skills/errore/SKILL.md +18 -0
  107. package/skills/goke/SKILL.md +150 -10
  108. package/skills/holocron/SKILL.md +37 -0
  109. package/skills/npm-package/SKILL.md +55 -35
  110. package/skills/profano/SKILL.md +20 -3
  111. package/skills/spiceflow/SKILL.md +63 -4
  112. package/skills/strada/SKILL.md +39 -0
  113. package/skills/zele/SKILL.md +19 -0
  114. package/src/agent-model.e2e.test.ts +194 -152
  115. package/src/analytics.test.ts +30 -1
  116. package/src/analytics.ts +1 -0
  117. package/src/anthropic-account-identity.ts +1 -0
  118. package/src/anthropic-auth-plugin.ts +1 -0
  119. package/src/anthropic-auth-state.ts +1 -0
  120. package/src/bin.ts +78 -26
  121. package/src/cli-commands/maintenance.ts +59 -13
  122. package/src/cli-commands/send.ts +47 -3
  123. package/src/cli-commands/session.ts +203 -9
  124. package/src/cli-commands/task.ts +45 -20
  125. package/src/cli-parsing.test.ts +69 -3
  126. package/src/cli-runner.test.ts +65 -1
  127. package/src/cli-runner.ts +116 -93
  128. package/src/cli-send-thread.e2e.test.ts +1 -1
  129. package/src/cli.ts +53 -0
  130. package/src/commands/abort.ts +6 -1
  131. package/src/commands/agent.ts +126 -90
  132. package/src/commands/ask-question.test.ts +48 -1
  133. package/src/commands/ask-question.ts +21 -32
  134. package/src/commands/login.ts +6 -0
  135. package/src/commands/merge-worktree.ts +43 -12
  136. package/src/commands/model-variant.ts +8 -1
  137. package/src/commands/model.ts +43 -8
  138. package/src/commands/multioauth.ts +196 -2
  139. package/src/commands/new-worktree.ts +138 -55
  140. package/src/commands/tasks.ts +6 -0
  141. package/src/commands/unset-model.ts +5 -21
  142. package/src/commands/user-command.ts +6 -6
  143. package/src/commands/worktrees.ts +5 -12
  144. package/src/config.ts +18 -1
  145. package/src/database.ts +319 -3
  146. package/src/db.test.ts +188 -0
  147. package/src/db.ts +26 -0
  148. package/src/discord-bot.ts +42 -30
  149. package/src/discord-command-registration.ts +15 -1
  150. package/src/discord-utils.test.ts +19 -0
  151. package/src/discord-utils.ts +21 -6
  152. package/src/errors.ts +5 -0
  153. package/src/exec-async.ts +20 -11
  154. package/src/external-directory-permissions.e2e.test.ts +2 -2
  155. package/src/file-edit-log.test.ts +287 -0
  156. package/src/file-edit-log.ts +283 -0
  157. package/src/format-tables.test.ts +101 -0
  158. package/src/format-tables.ts +75 -0
  159. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  160. package/src/gateway-proxy.e2e.test.ts +5 -5
  161. package/src/git-worktree-core.ts +247 -20
  162. package/src/ipc-tools-plugin.ts +79 -8
  163. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  164. package/src/kimaki-opencode-plugin.ts +9 -3
  165. package/src/kimaki-workspace-adaptor.ts +40 -11
  166. package/src/logger.ts +13 -4
  167. package/src/markdown.test.ts +13 -3
  168. package/src/markdown.ts +4 -1
  169. package/src/message-formatting.test.ts +271 -2
  170. package/src/message-formatting.ts +110 -5
  171. package/src/oauth-rotation-shared.ts +16 -1
  172. package/src/openai-auth-plugin.ts +1 -0
  173. package/src/openai-auth-state.ts +20 -0
  174. package/src/opencode-command.test.ts +81 -0
  175. package/src/opencode.ts +71 -11
  176. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  177. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  178. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  179. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  180. package/src/queue-advanced-e2e-setup.ts +83 -4
  181. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  182. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  183. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  184. package/src/queue-advanced-question.e2e.test.ts +35 -29
  185. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  186. package/src/queue-delete-message.e2e.test.ts +1 -1
  187. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  188. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  189. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  190. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  191. package/src/schema.sql +29 -1
  192. package/src/schema.ts +56 -0
  193. package/src/session-handler/event-stream-state.test.ts +920 -0
  194. package/src/session-handler/event-stream-state.ts +546 -0
  195. package/src/session-handler/model-utils.test.ts +415 -0
  196. package/src/session-handler/model-utils.ts +250 -5
  197. package/src/session-handler/thread-session-runtime.ts +346 -200
  198. package/src/session-sleep.test.ts +294 -0
  199. package/src/session-title-rename.test.ts +9 -48
  200. package/src/sleep.e2e.test.ts +562 -0
  201. package/src/startup-time.e2e.test.ts +5 -1
  202. package/src/store.ts +21 -0
  203. package/src/system-message.test.ts +114 -9
  204. package/src/system-message.ts +85 -10
  205. package/src/task-runner.ts +304 -13
  206. package/src/task-schedule.test.ts +248 -2
  207. package/src/task-schedule.ts +118 -2
  208. package/src/thread-message-queue.e2e.test.ts +10 -202
  209. package/src/undo-redo.e2e.test.ts +2 -2
  210. package/src/voice-attachment.ts +29 -1
  211. package/src/voice-message.e2e.test.ts +10 -10
  212. package/src/voice.test.ts +63 -29
  213. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  214. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  215. package/src/worktrees.test.ts +505 -64
  216. package/src/worktrees.ts +257 -603
  217. package/src/xai-auth-plugin.ts +303 -0
  218. package/src/xai-auth-state.test.ts +224 -0
  219. package/src/xai-auth-state.ts +294 -0
  220. package/dist/message-finish-field.e2e.test.js +0 -169
  221. package/dist/subagent-rate-limit-plugin.js +0 -173
  222. package/src/message-finish-field.e2e.test.ts +0 -196
  223. package/src/subagent-rate-limit-plugin.ts +0 -218
@@ -60,13 +60,21 @@ cli
60
60
  }
61
61
  });
62
62
  cli
63
- .command('worktree merge', 'Merge worktree branch into default branch using worktrunk-style pipeline')
63
+ .command('merge-worktree', 'Merge the current worktree into a target branch')
64
64
  .option('-d, --directory <path>', 'Worktree directory (defaults to cwd)')
65
65
  .option('-m, --main-repo <path>', 'Main repository directory (auto-detected from worktree)')
66
66
  .option('-n, --name <name>', 'Worktree/branch name (auto-detected from branch)')
67
+ .option('--strategy [strategy]', z.enum(['rebase', 'squash'])
68
+ .default('rebase')
69
+ .describe('Keep commits or squash them into one'))
70
+ .option('--target-branch [branch]', z.string()
71
+ .optional()
72
+ .describe('Branch to merge into (defaults to origin/HEAD or main)'))
73
+ .option('--thread [threadId]', z.string().optional().describe('Discord thread to mark as merged'))
74
+ .option('--thread-name [name]', z.string().optional().describe('Discord thread name after merging'))
67
75
  .action(async (options) => {
68
76
  try {
69
- const { mergeWorktree } = await import('../worktrees.js');
77
+ const { formatMergeWorktreeError, mergeWorktree } = await import('../worktrees.js');
70
78
  const worktreeDir = path.resolve(options.directory || '.');
71
79
  // Auto-detect main repo: find the main worktree's toplevel.
72
80
  // For linked worktrees, --git-common-dir points to the shared .git,
@@ -77,14 +85,20 @@ cli
77
85
  if (!mainRepoDir) {
78
86
  try {
79
87
  // `git worktree list --porcelain` first line is always the main worktree
80
- const { stdout } = await execAsync(`git -C "${worktreeDir}" worktree list --porcelain`);
88
+ const { stdout } = await execAsync({
89
+ command: 'git',
90
+ args: ['-C', worktreeDir, 'worktree', 'list', '--porcelain'],
91
+ });
81
92
  const firstLine = stdout.split('\n')[0] || '';
82
93
  // Format: "worktree /path/to/main"
83
94
  mainRepoDir = firstLine.replace(/^worktree\s+/, '').trim();
84
95
  }
85
96
  catch {
86
97
  // Fallback: derive from git common dir
87
- const { stdout: commonDir } = await execAsync(`git -C "${worktreeDir}" rev-parse --git-common-dir`);
98
+ const { stdout: commonDir } = await execAsync({
99
+ command: 'git',
100
+ args: ['-C', worktreeDir, 'rev-parse', '--git-common-dir'],
101
+ });
88
102
  const resolved = path.isAbsolute(commonDir.trim())
89
103
  ? commonDir.trim()
90
104
  : path.resolve(worktreeDir, commonDir.trim());
@@ -95,7 +109,10 @@ cli
95
109
  let worktreeName = options.name;
96
110
  if (!worktreeName) {
97
111
  try {
98
- const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
112
+ const { stdout } = await execAsync({
113
+ command: 'git',
114
+ args: ['-C', worktreeDir, 'symbolic-ref', '--short', 'HEAD'],
115
+ });
99
116
  worktreeName = stdout.trim();
100
117
  }
101
118
  catch {
@@ -110,18 +127,40 @@ cli
110
127
  worktreeDir,
111
128
  mainRepoDir,
112
129
  worktreeName,
130
+ strategy: options.strategy,
131
+ targetBranch: options.targetBranch,
113
132
  onProgress: (msg) => {
114
133
  cliLogger.log(msg);
115
134
  },
116
135
  });
117
136
  if (result instanceof Error) {
118
- cliLogger.error(`Merge failed: ${result.message}`);
137
+ cliLogger.error(formatMergeWorktreeError(result, { maxLength: 8000 }));
119
138
  if (result instanceof RebaseConflictError) {
120
139
  cliLogger.log('Resolve the rebase conflicts, then run this command again.');
121
140
  }
122
141
  process.exit(1);
123
142
  }
124
- cliLogger.log(`Merged ${result.branchName} into ${result.defaultBranch} @ ${result.shortSha} (${result.commitCount} commit${result.commitCount === 1 ? '' : 's'})`);
143
+ const action = options.strategy === 'squash' ? 'Squashed' : 'Merged';
144
+ cliLogger.log(`${action} ${result.branchName} into ${result.defaultBranch} @ ${result.shortSha} (${result.commitCount} source commit${result.commitCount === 1 ? '' : 's'})`);
145
+ if (options.thread && options.threadName) {
146
+ const credentials = await resolveBotCredentials().catch((e) => {
147
+ return new Error('Failed to resolve Discord credentials', { cause: e });
148
+ });
149
+ if (credentials instanceof Error) {
150
+ cliLogger.warn(`Could not update the thread title: ${credentials.message}`);
151
+ }
152
+ else {
153
+ const rest = createDiscordRest(credentials.token);
154
+ const renameResult = await rest
155
+ .patch(Routes.channel(options.thread), {
156
+ body: { name: options.threadName },
157
+ })
158
+ .catch((e) => new Error('Discord rejected the thread rename', { cause: e }));
159
+ if (renameResult instanceof Error) {
160
+ cliLogger.warn(`Could not update the thread title: ${renameResult.message}`);
161
+ }
162
+ }
163
+ }
125
164
  process.exit(0);
126
165
  }
127
166
  catch (error) {
@@ -29,6 +29,7 @@ import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '
29
29
  import { upgrade, getCurrentVersion } from '../upgrade.js';
30
30
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
31
31
  import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
32
+ import { validateCliModelOption } from '../session-handler/model-utils.js';
32
33
  const cliLogger = createLogger(LogPrefix.CLI);
33
34
  const cli = goke();
34
35
  cli
@@ -52,6 +53,8 @@ cli
52
53
  .option('-f, --file <path>', z.array(z.string()).describe('Local file to attach (repeatable). Images, text files, PDFs, etc. ' +
53
54
  'Examples: --file screenshot.png --file report.pdf'))
54
55
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
56
+ .option('--pre-run <command>', 'Run a shell command in the project before starting a scheduled task')
57
+ .option('--allow-concurrency', 'Allow concurrent sessions from the same scheduled task')
55
58
  .option('--thread <threadId>', 'Post prompt to an existing thread')
56
59
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
57
60
  .option('--parent-session <sessionId>', 'Parent OpenCode session ID for newly created child sessions')
@@ -67,6 +70,10 @@ cli
67
70
  const { project: projectPath } = options;
68
71
  const sendAt = options.sendAt;
69
72
  const existingThreadMode = Boolean(threadId || sessionId);
73
+ if ((options.preRun || options.allowConcurrency) && !sendAt) {
74
+ cliLogger.error('--pre-run and --allow-concurrency require --send-at');
75
+ process.exit(EXIT_NO_RESTART);
76
+ }
70
77
  if (threadId && sessionId) {
71
78
  cliLogger.error('Use either --thread or --session, not both');
72
79
  process.exit(EXIT_NO_RESTART);
@@ -83,6 +90,13 @@ cli
83
90
  cliLogger.error('Prompt is required. Use --prompt <prompt>');
84
91
  process.exit(EXIT_NO_RESTART);
85
92
  }
93
+ const earlyModelCheck = await validateCliModelOption({
94
+ model: options.model,
95
+ });
96
+ if (earlyModelCheck instanceof Error) {
97
+ cliLogger.error(earlyModelCheck.message);
98
+ process.exit(EXIT_NO_RESTART);
99
+ }
86
100
  const filePaths = options.file?.length
87
101
  ? options.file.map((f) => path.resolve(f))
88
102
  : undefined;
@@ -329,6 +343,14 @@ cli
329
343
  // We only require it for features that genuinely need a local directory
330
344
  // (scheduled tasks and --wait).
331
345
  const channelConfig = await getChannelDirectory(threadData.parent_id);
346
+ const threadModelCheck = await validateCliModelOption({
347
+ model: options.model,
348
+ directory: channelConfig?.directory,
349
+ });
350
+ if (threadModelCheck instanceof Error) {
351
+ cliLogger.error(threadModelCheck.message);
352
+ process.exit(EXIT_NO_RESTART);
353
+ }
332
354
  // Guard early: fail before sending the message if a feature that
333
355
  // needs local project directory mapping is requested.
334
356
  if (!channelConfig && (parsedSchedule || options.wait)) {
@@ -348,6 +370,8 @@ cli
348
370
  permissions: options.permission?.length ? options.permission : null,
349
371
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
350
372
  parentSessionId: options.parentSession || null,
373
+ preRunCommand: options.preRun || null,
374
+ allowConcurrency: Boolean(options.allowConcurrency),
351
375
  };
352
376
  const taskId = await createScheduledTask({
353
377
  scheduleKind: parsedSchedule.scheduleKind,
@@ -387,7 +411,7 @@ cli
387
411
  // detection can find the command on its own line.
388
412
  const prefixedPrompt = `» **kimaki-cli:**\n${prompt}`;
389
413
  if (threadTargetUser) {
390
- cliLogger.log(`Adding user ${threadTargetUser.username} to thread...`);
414
+ cliLogger.log(`Adding user ${threadTargetUser.username || threadTargetUser.id} to thread...`);
391
415
  const addMemberResult = await ensureThreadMember({
392
416
  rest,
393
417
  threadId: targetThreadId,
@@ -438,6 +462,14 @@ cli
438
462
  // (--send-at, --wait, --cwd).
439
463
  const channelConfig = await getChannelDirectory(channelData.id);
440
464
  const projectDirectory = channelConfig?.directory;
465
+ const channelModelCheck = await validateCliModelOption({
466
+ model: options.model,
467
+ directory: projectDirectory,
468
+ });
469
+ if (channelModelCheck instanceof Error) {
470
+ cliLogger.error(channelModelCheck.message);
471
+ process.exit(EXIT_NO_RESTART);
472
+ }
441
473
  // Features that require a local project directory mapping
442
474
  const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd);
443
475
  if (!channelConfig && needsProjectDirectory) {
@@ -509,6 +541,8 @@ cli
509
541
  permissions: options.permission?.length ? options.permission : null,
510
542
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
511
543
  parentSessionId: options.parentSession || null,
544
+ preRunCommand: options.preRun || null,
545
+ allowConcurrency: Boolean(options.allowConcurrency),
512
546
  };
513
547
  const taskId = await createScheduledTask({
514
548
  scheduleKind: parsedSchedule.scheduleKind,
@@ -535,8 +569,8 @@ cli
535
569
  ...(worktreeName && { worktree: worktreeName }),
536
570
  ...(resolvedCwd && { cwd: resolvedCwd }),
537
571
  ...(resolvedUser && {
538
- username: resolvedUser.username,
539
572
  userId: resolvedUser.id,
573
+ ...(resolvedUser.username && { username: resolvedUser.username }),
540
574
  }),
541
575
  ...(options.agent && { agent: options.agent }),
542
576
  ...(options.model && { model: options.model }),
@@ -574,7 +608,7 @@ cli
574
608
  cliLogger.log('Thread created!');
575
609
  // Add user to thread if specified
576
610
  if (resolvedUser) {
577
- cliLogger.log(`Adding user ${resolvedUser.username} to thread...`);
611
+ cliLogger.log(`Adding user ${resolvedUser.username || resolvedUser.id} to thread...`);
578
612
  await rest.put(Routes.threadMembers(threadData.id, resolvedUser.id));
579
613
  }
580
614
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
@@ -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
@@ -40,7 +41,7 @@ cli
40
41
  cliLogger.log('No scheduled tasks found');
41
42
  process.exit(0);
42
43
  }
43
- console.log('id | status | message | channelId | userId | projectName | folderName | agent | model | timeRemaining | firesAt | cron');
44
+ console.log('id | status | message | channelId | userId | projectName | folderName | agent | model | preRun | allowConcurrency | timeRemaining | firesAt | cron');
44
45
  tasks.forEach((task) => {
45
46
  // Surfacing userId makes it obvious which tasks will never show up in
46
47
  // the user's Discord sidebar (no thread member is ever added for them).
@@ -49,6 +50,10 @@ cli
49
50
  const userId = payload instanceof Error ? '?' : payload.userId || '-';
50
51
  const agent = payload instanceof Error ? '?' : payload.agent || '-';
51
52
  const model = payload instanceof Error ? '?' : payload.model || '-';
53
+ const preRun = payload instanceof Error ? '?' : payload.preRunCommand || '-';
54
+ const allowConcurrency = payload instanceof Error
55
+ ? '?'
56
+ : String(payload.allowConcurrency);
52
57
  const projectDirectory = task.project_directory || '';
53
58
  const projectName = projectDirectory
54
59
  ? path.basename(projectDirectory)
@@ -60,7 +65,7 @@ cli
60
65
  ? task.run_at.toISOString()
61
66
  : '-';
62
67
  const cronValue = task.schedule_kind === 'cron' ? task.cron_expr || '-' : '-';
63
- console.log(`${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${userId} | ${projectName} | ${folderName} | ${agent} | ${model} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`);
68
+ console.log(`${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${userId} | ${projectName} | ${folderName} | ${agent} | ${model} | ${preRun} | ${allowConcurrency} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`);
64
69
  });
65
70
  process.exit(0);
66
71
  }
@@ -98,7 +103,7 @@ cli
98
103
  async function resolveTaskUser({ user, channelId, }) {
99
104
  const directUserId = getDiscordUserIdFromUserOption(user);
100
105
  if (directUserId) {
101
- return { id: directUserId, username: directUserId };
106
+ return { id: directUserId };
102
107
  }
103
108
  if (!channelId) {
104
109
  return new Error(`Cannot look up username "${user}": task has no channel to resolve a guild from. Pass a Discord user ID instead.`);
@@ -128,18 +133,24 @@ cli
128
133
  .option('--send-at <sendAt>', 'New schedule (UTC ISO date or cron expression)')
129
134
  .option('--agent <agent>', 'Agent for the scheduled session (empty string clears)')
130
135
  .option('--model <model>', 'Model for the scheduled session, format provider/model (empty string clears)')
136
+ .option('--pre-run <command>', 'New pre-run command (empty string clears)')
137
+ .option('--allow-concurrency <enabled>', z.enum(['true', 'false']).describe('Allow concurrent sessions: true or false'))
131
138
  .option('-u, --user <user>', 'Discord user ID, mention, or username added to the task thread')
132
139
  .action(async (id, options) => {
133
140
  try {
134
141
  const trimmedPrompt = options.prompt === undefined ? undefined : options.prompt.trim();
135
142
  const hasAgent = options.agent !== undefined;
136
143
  const hasModel = options.model !== undefined;
144
+ const hasPreRun = options.preRun !== undefined;
145
+ const hasAllowConcurrency = options.allowConcurrency !== undefined;
137
146
  if (!trimmedPrompt &&
138
147
  !options.sendAt &&
139
148
  !options.user &&
140
149
  !hasAgent &&
141
- !hasModel) {
142
- cliLogger.error('Provide at least --prompt, --send-at, --user, --agent or --model');
150
+ !hasModel &&
151
+ !hasPreRun &&
152
+ !hasAllowConcurrency) {
153
+ cliLogger.error('Provide at least --prompt, --send-at, --user, --agent, --model, --pre-run or --allow-concurrency');
143
154
  process.exit(EXIT_NO_RESTART);
144
155
  }
145
156
  if (trimmedPrompt !== undefined && trimmedPrompt.length === 0) {
@@ -181,15 +192,30 @@ cli
181
192
  process.exit(EXIT_NO_RESTART);
182
193
  }
183
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
+ }
184
206
  // Match send --model/--agent: empty string clears the override (null).
185
207
  const updatedPayload = {
186
208
  ...existingPayload,
187
209
  prompt: newPrompt,
188
210
  ...(resolvedUser
189
- ? { userId: resolvedUser.id, username: resolvedUser.username }
211
+ ? { userId: resolvedUser.id, username: resolvedUser.username || null }
190
212
  : {}),
191
213
  ...(hasAgent ? { agent: options.agent.trim() || null } : {}),
192
- ...(hasModel ? { model: options.model.trim() || null } : {}),
214
+ ...(hasModel ? { model: nextModel } : {}),
215
+ ...(hasPreRun ? { preRunCommand: options.preRun.trim() || null } : {}),
216
+ ...(hasAllowConcurrency
217
+ ? { allowConcurrency: options.allowConcurrency === 'true' }
218
+ : {}),
193
219
  };
194
220
  const updateData = {
195
221
  taskId,
@@ -220,7 +246,7 @@ cli
220
246
  }
221
247
  const parts = [`Updated task ${taskId}`];
222
248
  if (resolvedUser) {
223
- parts.push(`user ${resolvedUser.username} will be added to the thread`);
249
+ parts.push(`user ${resolvedUser.username || resolvedUser.id} will be added to the thread`);
224
250
  }
225
251
  if (hasAgent) {
226
252
  parts.push(`agent=${updatedPayload.agent || '-'}`);
@@ -228,6 +254,11 @@ cli
228
254
  if (hasModel) {
229
255
  parts.push(`model=${updatedPayload.model || '-'}`);
230
256
  }
257
+ if (hasPreRun)
258
+ parts.push(`preRun=${updatedPayload.preRunCommand || '-'}`);
259
+ if (hasAllowConcurrency) {
260
+ parts.push(`allowConcurrency=${updatedPayload.allowConcurrency}`);
261
+ }
231
262
  cliLogger.log(parts.join(' | '));
232
263
  process.exit(0);
233
264
  }