kimaki 0.7.1 → 0.8.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 (57) hide show
  1. package/dist/agent-model.e2e.test.js +7 -0
  2. package/dist/cli-send-thread.e2e.test.js +4 -2
  3. package/dist/cli.js +11 -5
  4. package/dist/commands/add-dir.js +57 -10
  5. package/dist/commands/last-sessions.js +120 -0
  6. package/dist/commands/permissions.js +46 -7
  7. package/dist/discord-command-registration.js +5 -0
  8. package/dist/gateway-proxy.e2e.test.js +4 -1
  9. package/dist/interaction-handler.js +7 -0
  10. package/dist/logger.js +19 -20
  11. package/dist/opencode.js +1 -0
  12. package/dist/queue-advanced-abort.e2e.test.js +2 -1
  13. package/dist/queue-advanced-action-buttons.e2e.test.js +2 -0
  14. package/dist/queue-advanced-footer.e2e.test.js +7 -0
  15. package/dist/queue-advanced-model-switch.e2e.test.js +1 -0
  16. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
  17. package/dist/queue-advanced-typing-interrupt.e2e.test.js +1 -0
  18. package/dist/queue-drain-after-interactive-ui.e2e.test.js +1 -0
  19. package/dist/queue-interrupt-drain.e2e.test.js +1 -0
  20. package/dist/queue-question-select-drain.e2e.test.js +2 -0
  21. package/dist/runtime-lifecycle.e2e.test.js +6 -4
  22. package/dist/session-handler/thread-session-runtime.js +32 -2
  23. package/dist/system-message.js +43 -29
  24. package/dist/system-message.test.js +47 -29
  25. package/dist/thread-message-queue.e2e.test.js +8 -1
  26. package/dist/undo-redo.e2e.test.js +1 -0
  27. package/dist/voice-message.e2e.test.js +8 -0
  28. package/package.json +7 -8
  29. package/skills/new-skill/SKILL.md +34 -20
  30. package/skills/readme.md +20 -0
  31. package/src/agent-model.e2e.test.ts +7 -0
  32. package/src/cli-send-thread.e2e.test.ts +4 -2
  33. package/src/cli.ts +13 -5
  34. package/src/commands/add-dir.ts +85 -14
  35. package/src/commands/last-sessions.ts +167 -0
  36. package/src/commands/permissions.ts +62 -13
  37. package/src/discord-command-registration.ts +5 -0
  38. package/src/gateway-proxy.e2e.test.ts +4 -1
  39. package/src/interaction-handler.ts +8 -0
  40. package/src/logger.ts +46 -35
  41. package/src/opencode.ts +1 -0
  42. package/src/queue-advanced-abort.e2e.test.ts +2 -1
  43. package/src/queue-advanced-action-buttons.e2e.test.ts +2 -0
  44. package/src/queue-advanced-footer.e2e.test.ts +7 -0
  45. package/src/queue-advanced-model-switch.e2e.test.ts +1 -0
  46. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
  47. package/src/queue-advanced-typing-interrupt.e2e.test.ts +1 -0
  48. package/src/queue-drain-after-interactive-ui.e2e.test.ts +1 -0
  49. package/src/queue-interrupt-drain.e2e.test.ts +1 -0
  50. package/src/queue-question-select-drain.e2e.test.ts +2 -0
  51. package/src/runtime-lifecycle.e2e.test.ts +6 -4
  52. package/src/session-handler/thread-session-runtime.ts +48 -2
  53. package/src/system-message.test.ts +47 -29
  54. package/src/system-message.ts +43 -29
  55. package/src/thread-message-queue.e2e.test.ts +8 -1
  56. package/src/undo-redo.e2e.test.ts +1 -0
  57. package/src/voice-message.e2e.test.ts +8 -0
@@ -329,6 +329,7 @@ describe('agent model resolution', () => {
329
329
  "--- from: user (agent-model-tester)
330
330
  Reply with exactly: agent-model-check
331
331
  --- from: assistant (TestBot)
332
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
332
333
  ⬥ ok
333
334
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
334
335
  `);
@@ -372,6 +373,7 @@ describe('agent model resolution', () => {
372
373
  "--- from: user (agent-model-tester)
373
374
  Reply with exactly: system-context-check
374
375
  --- from: assistant (TestBot)
376
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
375
377
  ⬥ system-context-ok
376
378
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
377
379
  `);
@@ -461,6 +463,7 @@ describe('agent model resolution', () => {
461
463
  "--- from: user (agent-model-tester)
462
464
  Reply with exactly: channel-model-check
463
465
  --- from: assistant (TestBot)
466
+ *using deterministic-provider/channel-model-v2*
464
467
  ⬥ ok
465
468
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
466
469
  `);
@@ -516,6 +519,7 @@ describe('agent model resolution', () => {
516
519
  "--- from: user (agent-model-tester)
517
520
  Reply with exactly: variant-check
518
521
  --- from: assistant (TestBot)
522
+ *using deterministic-provider/channel-model-v2*
519
523
  ⬥ ok
520
524
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
521
525
  `);
@@ -583,6 +587,7 @@ describe('agent model resolution', () => {
583
587
  "--- from: user (agent-model-tester)
584
588
  Reply with exactly: first-thread-msg
585
589
  --- from: assistant (TestBot)
590
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
586
591
  ⬥ ok
587
592
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
588
593
  --- from: user (agent-model-tester)
@@ -665,6 +670,7 @@ describe('agent model resolution', () => {
665
670
  "--- from: user (agent-model-tester)
666
671
  Reply with exactly: default-thread-msg
667
672
  --- from: assistant (TestBot)
673
+ *using deterministic-provider/deterministic-v2*
668
674
  ⬥ ok
669
675
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
670
676
  --- from: user (agent-model-tester)
@@ -731,6 +737,7 @@ describe('agent model resolution', () => {
731
737
  "--- from: user (agent-model-tester)
732
738
  Reply with exactly: switch-in-thread-msg
733
739
  --- from: assistant (TestBot)
740
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
734
741
  ⬥ ok
735
742
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
736
743
  Switched to **plan** agent for this session (was **test-agent**)
@@ -243,12 +243,14 @@ describe('kimaki send --channel thread creation', () => {
243
243
  body: { name: 'cmd-detection-test', auto_archive_duration: 1440 },
244
244
  }));
245
245
  await botClient.rest.put(Routes.threadMembers(threadData.id, TEST_USER_ID));
246
- // Wait for any bot reply AFTER the starter message
246
+ // Wait for the command detection result AFTER the starter message.
247
+ // New-session model banners are also bot replies, so waiting for any
248
+ // message can return before the command result is visible.
247
249
  await waitForBotMessageContaining({
248
250
  discord,
249
251
  threadId: threadData.id,
250
252
  userId: discord.botUserId,
251
- text: '',
253
+ text: 'Command not found: "hello-test"',
252
254
  afterMessageId: starterMessage.id,
253
255
  timeout: 4_000,
254
256
  });
package/dist/cli.js CHANGED
@@ -1297,15 +1297,21 @@ cli
1297
1297
  .optional()
1298
1298
  .describe('Blacklist a built-in skill by name. Listed skills are hidden from the model. Repeatable: pass --disable-skill multiple times. Mutually exclusive with --enable-skill. See https://github.com/remorses/kimaki/tree/main/skills for available skills.'))
1299
1299
  .action(async (options) => {
1300
- // Guard: only one kimaki bot process can run at a time (they share a lock
1301
- // port). Running `kimaki` here would kill the already-running bot process
1302
- // and take over the lock port, breaking all active Discord sessions.
1303
- if (process.env.KIMAKI_OPENCODE_PROCESS) {
1300
+ // Guard: only one kimaki bot process can run per lock port. Agents may run
1301
+ // a second dev bot only when they explicitly choose a different lock port.
1302
+ const parentLockPort = process.env.KIMAKI_PARENT_LOCK_PORT;
1303
+ const currentLockPort = process.env.KIMAKI_LOCK_PORT;
1304
+ const usesDifferentLockPort = currentLockPort !== parentLockPort;
1305
+ if (process.env.KIMAKI_OPENCODE_PROCESS && !usesDifferentLockPort) {
1304
1306
  cliLogger.error('Cannot run `kimaki` inside an OpenCode session — it would kill the already-running bot process.\n' +
1305
1307
  'Only one kimaki bot can run at a time (they share a lock port).\n' +
1306
- 'Use `kimaki send`, `kimaki session`, or other subcommands instead.');
1308
+ 'Set KIMAKI_LOCK_PORT to a different port for an isolated dev process, or use `kimaki send`, `kimaki session`, and other subcommands instead.');
1307
1309
  process.exit(EXIT_NO_RESTART);
1308
1310
  }
1311
+ if (process.env.KIMAKI_OPENCODE_PROCESS && usesDifferentLockPort) {
1312
+ delete process.env['KIMAKI_DB_URL'];
1313
+ delete process.env['KIMAKI_DB_AUTH_TOKEN'];
1314
+ }
1309
1315
  try {
1310
1316
  // Set data directory early, before any database access
1311
1317
  if (options.dataDir) {
@@ -1,7 +1,7 @@
1
1
  // /add-dir command - Expand the current session's external_directory permissions.
2
2
  // Resolves the requested directory against the active working directory, then
3
3
  // updates the current session permission rules via OpenCode.
4
- import { ChannelType, MessageFlags, } from 'discord.js';
4
+ import { MessageFlags, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { getThreadSession } from '../database.js';
@@ -10,6 +10,46 @@ import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils
10
10
  import { createLogger, LogPrefix } from '../logger.js';
11
11
  const logger = createLogger(LogPrefix.PERMISSIONS);
12
12
  const ALL_DIRECTORIES_PATTERN = '*';
13
+ async function waitForSessionIdle({ client, sessionId, directory, timeoutMs = 2_000, }) {
14
+ const deadline = Date.now() + timeoutMs;
15
+ while (Date.now() < deadline) {
16
+ const statusResponse = await client.session.status({ directory });
17
+ const sessionStatus = statusResponse.data?.[sessionId];
18
+ if (!sessionStatus || sessionStatus.type === 'idle') {
19
+ return;
20
+ }
21
+ await new Promise((resolve) => {
22
+ setTimeout(resolve, 50);
23
+ });
24
+ }
25
+ }
26
+ async function restartSessionIfBusy({ client, sessionId, directory, }) {
27
+ const statusResponse = await client.session.status({ directory });
28
+ if (statusResponse.error) {
29
+ return new Error('Failed to check session status');
30
+ }
31
+ const sessionStatus = statusResponse.data?.[sessionId];
32
+ if (!sessionStatus || sessionStatus.type === 'idle') {
33
+ return false;
34
+ }
35
+ const abortResponse = await client.session.abort({
36
+ sessionID: sessionId,
37
+ directory,
38
+ });
39
+ if (abortResponse.error) {
40
+ return new Error('Failed to abort in-progress session');
41
+ }
42
+ await waitForSessionIdle({ client, sessionId, directory });
43
+ const resumeResponse = await client.session.promptAsync({
44
+ sessionID: sessionId,
45
+ directory,
46
+ parts: [],
47
+ });
48
+ if (resumeResponse.error) {
49
+ return new Error('Failed to resume session');
50
+ }
51
+ return true;
52
+ }
13
53
  export function resolveDirectoryPermissionPattern({ input, workingDirectory, }) {
14
54
  const trimmedInput = input.trim();
15
55
  if (!trimmedInput) {
@@ -49,12 +89,7 @@ export async function handleAddDirCommand({ command, }) {
49
89
  });
50
90
  return;
51
91
  }
52
- const isThread = [
53
- ChannelType.PublicThread,
54
- ChannelType.PrivateThread,
55
- ChannelType.AnnouncementThread,
56
- ].includes(channel.type);
57
- if (!isThread) {
92
+ if (!channel.isThread()) {
58
93
  await command.reply({
59
94
  content: 'This command can only be used in a thread with an active session',
60
95
  flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
@@ -62,7 +97,7 @@ export async function handleAddDirCommand({ command, }) {
62
97
  return;
63
98
  }
64
99
  const resolvedDirectories = await resolveWorkingDirectory({
65
- channel: channel,
100
+ channel,
66
101
  });
67
102
  if (!resolvedDirectories) {
68
103
  await command.reply({
@@ -111,9 +146,21 @@ export async function handleAddDirCommand({ command, }) {
111
146
  await command.editReply('Failed to update session permissions');
112
147
  return;
113
148
  }
149
+ const restarted = await restartSessionIfBusy({
150
+ client,
151
+ sessionId,
152
+ directory: resolvedDirectories.workingDirectory,
153
+ });
154
+ if (restarted instanceof Error) {
155
+ await command.editReply(`Updated session permissions, but ${restarted.message.toLowerCase()}`);
156
+ return;
157
+ }
158
+ const restartSuffix = restarted
159
+ ? '. Restarted the in-progress session so the change applies now'
160
+ : '';
114
161
  await command.editReply(resolvedPattern === ALL_DIRECTORIES_PATTERN
115
- ? 'Updated session permissions: all external directories are now allowed'
116
- : `Updated session permissions: allowed \`${resolvedPattern}\``);
162
+ ? `Updated session permissions: all external directories are now allowed${restartSuffix}`
163
+ : `Updated session permissions: allowed \`${resolvedPattern}\`${restartSuffix}`);
117
164
  }
118
165
  catch (error) {
119
166
  logger.error('[ADD-DIR] Failed to update session permissions:', error);
@@ -0,0 +1,120 @@
1
+ // /last-sessions command — list the 20 most recently active sessions across
2
+ // all projects, sorted by last activity. Renders a markdown table with
3
+ // clickable thread links and project names via Discord CV2 components.
4
+ import { ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'discord.js';
5
+ import path from 'node:path';
6
+ import { getPrisma } from '../db.js';
7
+ import { getChannelDirectory } from '../database.js';
8
+ import { splitTablesFromMarkdown } from '../format-tables.js';
9
+ import { formatTimeAgo } from './worktrees.js';
10
+ const MAX_ROWS = 20;
11
+ async function fetchRecentSessions({ client, }) {
12
+ const prisma = await getPrisma();
13
+ // Fetch all thread sessions with their most recent event timestamp.
14
+ // Prisma doesn't support ORDER BY aggregated subquery, so we fetch all
15
+ // sessions with their latest event and sort in JS.
16
+ const sessions = await prisma.thread_sessions.findMany({
17
+ select: {
18
+ thread_id: true,
19
+ session_id: true,
20
+ created_at: true,
21
+ session_events: {
22
+ orderBy: { timestamp: 'desc' },
23
+ take: 1,
24
+ select: { timestamp: true },
25
+ },
26
+ },
27
+ });
28
+ // Build rows with resolved last-active timestamp
29
+ const withTimestamp = sessions.map((s) => {
30
+ const latestEventTs = s.session_events[0]?.timestamp;
31
+ const lastActive = latestEventTs
32
+ ? new Date(Number(latestEventTs))
33
+ : s.created_at ?? new Date(0);
34
+ return {
35
+ threadId: s.thread_id,
36
+ sessionId: s.session_id,
37
+ lastActive,
38
+ };
39
+ });
40
+ // Sort by last active descending, take top N
41
+ withTimestamp.sort((a, b) => {
42
+ return b.lastActive.getTime() - a.lastActive.getTime();
43
+ });
44
+ const top = withTimestamp.slice(0, MAX_ROWS);
45
+ // Resolve project names via Discord thread parent channel
46
+ const channelDirCache = new Map();
47
+ const rows = await Promise.all(top.map(async (row) => {
48
+ let projectName;
49
+ try {
50
+ const channel = await client.channels.fetch(row.threadId);
51
+ const parentId = channel && 'parentId' in channel ? channel.parentId : undefined;
52
+ if (parentId) {
53
+ if (!channelDirCache.has(parentId)) {
54
+ const dir = await getChannelDirectory(parentId);
55
+ channelDirCache.set(parentId, dir ? path.basename(dir.directory) : undefined);
56
+ }
57
+ projectName = channelDirCache.get(parentId);
58
+ }
59
+ }
60
+ catch {
61
+ // Thread may have been deleted or is inaccessible
62
+ }
63
+ return {
64
+ threadId: row.threadId,
65
+ sessionId: row.sessionId,
66
+ lastActive: row.lastActive,
67
+ projectName,
68
+ };
69
+ }));
70
+ return rows;
71
+ }
72
+ function buildSessionTable({ rows }) {
73
+ const header = '| Project | Thread | Last Active |';
74
+ const separator = '|---|---|---|';
75
+ const tableRows = rows.map((row) => {
76
+ const project = row.projectName ?? 'unknown';
77
+ const thread = `<#${row.threadId}>`;
78
+ const lastActive = formatTimeAgo(row.lastActive);
79
+ return `| ${project} | ${thread} | ${lastActive} |`;
80
+ });
81
+ return [header, separator, ...tableRows].join('\n');
82
+ }
83
+ export async function handleLastSessionsCommand({ command, }) {
84
+ if (!command.guildId) {
85
+ await command.reply({
86
+ content: 'This command can only be used in a server.',
87
+ flags: MessageFlags.Ephemeral,
88
+ });
89
+ return;
90
+ }
91
+ await command.deferReply({ flags: MessageFlags.Ephemeral });
92
+ const rows = await fetchRecentSessions({ client: command.client });
93
+ if (rows.length === 0) {
94
+ const textDisplay = {
95
+ type: ComponentType.TextDisplay,
96
+ content: 'No sessions found.',
97
+ };
98
+ await command.editReply({
99
+ components: [textDisplay],
100
+ flags: MessageFlags.IsComponentsV2,
101
+ });
102
+ return;
103
+ }
104
+ const tableMarkdown = buildSessionTable({ rows });
105
+ const segments = splitTablesFromMarkdown(tableMarkdown);
106
+ const components = segments.flatMap((segment) => {
107
+ if (segment.type === 'components') {
108
+ return segment.components;
109
+ }
110
+ const textDisplay = {
111
+ type: ComponentType.TextDisplay,
112
+ content: segment.text,
113
+ };
114
+ return [textDisplay];
115
+ });
116
+ await command.editReply({
117
+ components,
118
+ flags: MessageFlags.IsComponentsV2,
119
+ });
120
+ }
@@ -7,6 +7,28 @@ import { getOpencodeClient } from '../opencode.js';
7
7
  import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
8
8
  import { createLogger, LogPrefix } from '../logger.js';
9
9
  const logger = createLogger(LogPrefix.PERMISSIONS);
10
+ async function resumeSessionIfIdleAfterPermission({ client, sessionId, directory, }) {
11
+ await new Promise((resolve) => {
12
+ setTimeout(resolve, 100);
13
+ });
14
+ const statusResponse = await client.session.status({ directory });
15
+ if (statusResponse.error) {
16
+ return new Error('Failed to check session status');
17
+ }
18
+ const sessionStatus = statusResponse.data?.[sessionId];
19
+ if (!sessionStatus || sessionStatus.type !== 'idle') {
20
+ return false;
21
+ }
22
+ const resumeResponse = await client.session.promptAsync({
23
+ sessionID: sessionId,
24
+ directory,
25
+ parts: [],
26
+ });
27
+ if (resumeResponse.error) {
28
+ return new Error('Failed to resume session');
29
+ }
30
+ return true;
31
+ }
10
32
  function wildcardMatch({ value, pattern, }) {
11
33
  let escapedPattern = pattern
12
34
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
@@ -87,6 +109,10 @@ export async function showPermissionButtons({ thread, permission, directory, per
87
109
  })).catch((error) => {
88
110
  logger.error('Failed to auto-reject expired permission:', error);
89
111
  });
112
+ updatePermissionMessage({
113
+ context: ctx,
114
+ status: '_Permission expired after 10 minutes and was rejected._',
115
+ });
90
116
  }
91
117
  }, PERMISSION_CONTEXT_TTL_MS).unref();
92
118
  const patternStr = compactPermissionPatterns(permission.patterns).join(', ');
@@ -206,10 +232,16 @@ export async function handlePermissionButton(interaction) {
206
232
  return;
207
233
  }
208
234
  const response = actionPart.replace('permission_', '');
235
+ if (response !== 'once' && response !== 'always' && response !== 'reject') {
236
+ return;
237
+ }
209
238
  // Atomic take: if TTL already expired and auto-rejected, context is gone.
210
239
  const context = takePendingPermissionContext(contextHash);
211
240
  if (!context) {
212
- await interaction.update({ components: [] });
241
+ await interaction.update({
242
+ content: '_Permission expired and was already rejected. Send a new message to continue._',
243
+ components: [],
244
+ });
213
245
  return;
214
246
  }
215
247
  await interaction.deferUpdate();
@@ -228,6 +260,19 @@ export async function handlePermissionButton(interaction) {
228
260
  reply: response,
229
261
  });
230
262
  }));
263
+ if (response !== 'reject') {
264
+ const resumed = await resumeSessionIfIdleAfterPermission({
265
+ client: permClient,
266
+ sessionId: context.permission.sessionID,
267
+ directory: context.permissionDirectory,
268
+ });
269
+ if (resumed instanceof Error) {
270
+ logger.error('Failed to resume idle session after permission:', resumed);
271
+ }
272
+ if (resumed === true) {
273
+ logger.log(`Resumed idle session after permission ${context.permission.id}`);
274
+ }
275
+ }
231
276
  // Context already removed by takePendingPermissionContext above.
232
277
  // Update message: show result and remove dropdown
233
278
  const resultText = (() => {
@@ -266,9 +311,3 @@ export function addPermissionRequestToContext({ contextHash, requestId, }) {
266
311
  pendingPermissionContexts.set(contextHash, context);
267
312
  return true;
268
313
  }
269
- /**
270
- * Clean up a pending permission context (e.g., on auto-reject).
271
- */
272
- export function cleanupPermissionContext(contextHash) {
273
- pendingPermissionContexts.delete(contextHash);
274
- }
@@ -151,6 +151,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
151
151
  .setDescription(truncateCommandDescription('List all active worktree sessions'))
152
152
  .setDMPermission(false)
153
153
  .toJSON(),
154
+ new SlashCommandBuilder()
155
+ .setName('last-sessions')
156
+ .setDescription(truncateCommandDescription('List the 20 most recently active sessions across all projects'))
157
+ .setDMPermission(false)
158
+ .toJSON(),
154
159
  new SlashCommandBuilder()
155
160
  .setName('tasks')
156
161
  .setDescription(truncateCommandDescription('List scheduled tasks created via send --send-at'))
@@ -323,6 +323,7 @@ describeIf('gateway-proxy e2e', () => {
323
323
  "--- from: user (proxy-tester)
324
324
  hello from gateway proxy test
325
325
  --- from: assistant (TestBot)
326
+ *using deterministic-provider/deterministic-v2*
326
327
  ⬥ gateway-proxy-reply
327
328
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
328
329
  `);
@@ -349,6 +350,7 @@ describeIf('gateway-proxy e2e', () => {
349
350
  "--- from: user (proxy-tester)
350
351
  hello from gateway proxy test
351
352
  --- from: assistant (TestBot)
353
+ *using deterministic-provider/deterministic-v2*
352
354
  ⬥ gateway-proxy-reply
353
355
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
354
356
  --- from: user (proxy-tester)
@@ -380,6 +382,7 @@ describeIf('gateway-proxy e2e', () => {
380
382
  "--- from: user (proxy-tester)
381
383
  hello from gateway proxy test
382
384
  --- from: assistant (TestBot)
385
+ *using deterministic-provider/deterministic-v2*
383
386
  ⬥ gateway-proxy-reply
384
387
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
385
388
  --- from: user (proxy-tester)
@@ -414,7 +417,7 @@ describeIf('gateway-proxy e2e', () => {
414
417
  "--- from: user (proxy-tester)
415
418
  second message through proxy
416
419
  --- from: assistant (TestBot)
417
- gateway-proxy-reply"
420
+ *using deterministic-provider/deterministic-v2*"
418
421
  `);
419
422
  expect(reply).toBeDefined();
420
423
  expect(reply.content.trim().length).toBeGreaterThan(0);
@@ -8,6 +8,7 @@ import { handleMergeWorktreeCommand, handleMergeWorktreeAutocomplete, } from './
8
8
  import { handleToggleWorktreesCommand } from './commands/worktree-settings.js';
9
9
  import { handleWorktreesCommand } from './commands/worktrees.js';
10
10
  import { handleTasksCommand } from './commands/tasks.js';
11
+ import { handleLastSessionsCommand } from './commands/last-sessions.js';
11
12
  import { handleResumeCommand, handleResumeAutocomplete, } from './commands/resume.js';
12
13
  import { handleAddProjectCommand, handleAddProjectAutocomplete, } from './commands/add-project.js';
13
14
  import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './commands/remove-project.js';
@@ -122,6 +123,12 @@ export function registerInteractionHandler({ discordClient, appId, }) {
122
123
  appId,
123
124
  });
124
125
  return;
126
+ case 'last-sessions':
127
+ await handleLastSessionsCommand({
128
+ command: interaction,
129
+ appId,
130
+ });
131
+ return;
125
132
  case 'resume':
126
133
  await handleResumeCommand({ command: interaction, appId });
127
134
  return;
package/dist/logger.js CHANGED
@@ -1,6 +1,6 @@
1
- // Prefixed logging utility using @clack/prompts for consistent visual style.
2
- // All log methods use clack's log.message() with appropriate symbols to prevent
3
- // output interleaving from concurrent async operations.
1
+ // Prefixed logging utility using @clack/prompts for consistent stderr diagnostics and file logs.
2
+ // Never write logger output to stdout because many CLI subcommands print
3
+ // machine-readable data there, for example `kimaki project list --json`.
4
4
  import { log as clackLog } from '@clack/prompts';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
@@ -106,7 +106,7 @@ export function formatErrorWithStack(error) {
106
106
  redactPaths: false,
107
107
  });
108
108
  }
109
- function writeToFile(level, prefix, args) {
109
+ function writeToFile({ level, prefix, args, }) {
110
110
  const timestamp = new Date().toISOString();
111
111
  const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`;
112
112
  if (!logFilePath) {
@@ -118,13 +118,10 @@ function getTimestamp() {
118
118
  const now = new Date();
119
119
  return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
120
120
  }
121
- function padPrefix(prefix) {
122
- return prefix.padEnd(MAX_PREFIX_LENGTH);
123
- }
124
- function formatMessage(timestamp, prefix, args) {
121
+ function formatMessage({ timestamp, prefix, args, }) {
125
122
  return [pc.dim(timestamp), prefix, ...args.map(formatArg)].join(' ');
126
123
  }
127
- const noSpacing = { spacing: 0 };
124
+ const stderrLogOptions = { output: process.stderr, spacing: 0 };
128
125
  // Suppress clack terminal output during vitest runs to avoid flooding
129
126
  // test output with hundreds of log lines. File logging still works.
130
127
  // Set KIMAKI_TEST_LOGS=1 when rerunning a failing test to see all
@@ -132,39 +129,41 @@ const noSpacing = { spacing: 0 };
132
129
  const isVitest = !!process.env['KIMAKI_VITEST'];
133
130
  const showTestLogs = isVitest && !!process.env['KIMAKI_TEST_LOGS'];
134
131
  export function createLogger(prefix) {
135
- const paddedPrefix = padPrefix(prefix);
132
+ const paddedPrefix = prefix.padEnd(MAX_PREFIX_LENGTH);
136
133
  const suppressConsole = isVitest && !showTestLogs;
137
134
  const log = (...args) => {
138
- writeToFile('LOG', prefix, args);
135
+ writeToFile({ level: 'LOG', prefix, args });
139
136
  if (suppressConsole) {
140
137
  return;
141
138
  }
142
- clackLog.message(formatMessage(getTimestamp(), pc.cyan(paddedPrefix), args), {
143
- ...noSpacing,
144
- });
139
+ clackLog.message(formatMessage({ timestamp: getTimestamp(), prefix: pc.cyan(paddedPrefix), args }), stderrLogOptions);
145
140
  };
146
141
  return {
147
142
  log,
148
143
  error: (...args) => {
149
- writeToFile('ERROR', prefix, args);
144
+ writeToFile({ level: 'ERROR', prefix, args });
150
145
  if (suppressConsole) {
151
146
  return;
152
147
  }
153
- clackLog.error(formatMessage(getTimestamp(), pc.red(paddedPrefix), args), noSpacing);
148
+ clackLog.error(formatMessage({ timestamp: getTimestamp(), prefix: pc.red(paddedPrefix), args }), stderrLogOptions);
154
149
  },
155
150
  warn: (...args) => {
156
- writeToFile('WARN', prefix, args);
151
+ writeToFile({ level: 'WARN', prefix, args });
157
152
  if (suppressConsole) {
158
153
  return;
159
154
  }
160
- clackLog.warn(formatMessage(getTimestamp(), pc.yellow(paddedPrefix), args), noSpacing);
155
+ clackLog.warn(formatMessage({
156
+ timestamp: getTimestamp(),
157
+ prefix: pc.yellow(paddedPrefix),
158
+ args,
159
+ }), stderrLogOptions);
161
160
  },
162
161
  info: (...args) => {
163
- writeToFile('INFO', prefix, args);
162
+ writeToFile({ level: 'INFO', prefix, args });
164
163
  if (suppressConsole) {
165
164
  return;
166
165
  }
167
- clackLog.info(formatMessage(getTimestamp(), pc.blue(paddedPrefix), args), noSpacing);
166
+ clackLog.info(formatMessage({ timestamp: getTimestamp(), prefix: pc.blue(paddedPrefix), args }), stderrLogOptions);
168
167
  },
169
168
  debug: log,
170
169
  };
package/dist/opencode.js CHANGED
@@ -521,6 +521,7 @@ async function startSingleServer({ directory, } = {}) {
521
521
  KIMAKI: '1',
522
522
  KIMAKI_DATA_DIR: getDataDir(),
523
523
  KIMAKI_LOCK_PORT: getLockPort().toString(),
524
+ KIMAKI_PARENT_LOCK_PORT: getLockPort().toString(),
524
525
  ...(gatewayToken && { KIMAKI_DB_AUTH_TOKEN: gatewayToken }),
525
526
  // Guard: prevents agents from running `kimaki` root command inside
526
527
  // an OpenCode session, which would steal the lock port and break the bot.
@@ -155,6 +155,7 @@ e2eTest('queue advanced: abort and retry', () => {
155
155
  "--- from: user (queue-advanced-tester)
156
156
  Reply with exactly: abort-no-footer-setup
157
157
  --- from: assistant (TestBot)
158
+ *using deterministic-provider/deterministic-v2*
158
159
  ⬥ ok
159
160
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
160
161
  --- from: user (queue-advanced-tester)
@@ -285,7 +286,7 @@ e2eTest('queue advanced: abort and retry', () => {
285
286
  "--- from: user (queue-advanced-tester)
286
287
  Reply with exactly: force-abort-setup
287
288
  --- from: assistant (TestBot)
288
- ok
289
+ *using deterministic-provider/deterministic-v2*
289
290
  --- from: user (queue-advanced-tester)
290
291
  SLOW_ABORT_MARKER run long response"
291
292
  `);
@@ -122,6 +122,7 @@ describe('queue advanced: action buttons', () => {
122
122
  "--- from: user (queue-action-tester)
123
123
  Reply with exactly: action-button-setup
124
124
  --- from: assistant (TestBot)
125
+ *using deterministic-provider/deterministic-v2*
125
126
  ⬥ ok
126
127
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
127
128
  **Action Required**
@@ -193,6 +194,7 @@ describe('queue advanced: action buttons', () => {
193
194
  "--- from: user (queue-action-tester)
194
195
  Reply with exactly: action-button-dismiss-setup
195
196
  --- from: assistant (TestBot)
197
+ *using deterministic-provider/deterministic-v2*
196
198
  ⬥ ok
197
199
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
198
200
  **Action Required**
@@ -33,6 +33,7 @@ e2eTest('queue advanced: footer emission', () => {
33
33
  "--- from: user (queue-advanced-tester)
34
34
  Reply with exactly: footer-check
35
35
  --- from: assistant (TestBot)
36
+ *using deterministic-provider/deterministic-v2*
36
37
  ⬥ ok
37
38
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
38
39
  `);
@@ -89,6 +90,7 @@ e2eTest('queue advanced: footer emission', () => {
89
90
  "--- from: user (queue-advanced-tester)
90
91
  Reply with exactly: footer-multi-setup
91
92
  --- from: assistant (TestBot)
93
+ *using deterministic-provider/deterministic-v2*
92
94
  ⬥ ok
93
95
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
94
96
  --- from: user (queue-advanced-tester)
@@ -185,6 +187,7 @@ e2eTest('queue advanced: footer emission', () => {
185
187
  "--- from: user (queue-advanced-tester)
186
188
  Reply with exactly: interrupt-footer-setup
187
189
  --- from: assistant (TestBot)
190
+ *using deterministic-provider/deterministic-v2*
188
191
  ⬥ ok
189
192
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
190
193
  --- from: user (queue-advanced-tester)
@@ -265,6 +268,7 @@ e2eTest('queue advanced: footer emission', () => {
265
268
  "--- from: user (queue-advanced-tester)
266
269
  Reply with exactly: plugin-timeout-setup
267
270
  --- from: assistant (TestBot)
271
+ *using deterministic-provider/deterministic-v2*
268
272
  ⬥ ok
269
273
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
270
274
  --- from: user (queue-advanced-tester)
@@ -357,6 +361,7 @@ e2eTest('queue advanced: footer emission', () => {
357
361
  "--- from: user (queue-advanced-tester)
358
362
  TOOL_CALL_FOOTER_MARKER
359
363
  --- from: assistant (TestBot)
364
+ *using deterministic-provider/deterministic-v2*
360
365
  ⬥ running tool
361
366
  ⬥ ok
362
367
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
@@ -411,6 +416,7 @@ e2eTest('queue advanced: footer emission', () => {
411
416
  "--- from: user (queue-advanced-tester)
412
417
  MULTI_TOOL_FOOTER_MARKER
413
418
  --- from: assistant (TestBot)
419
+ *using deterministic-provider/deterministic-v2*
414
420
  ⬥ investigating the issue
415
421
  ⬥ all done, fixed 3 files
416
422
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
@@ -467,6 +473,7 @@ e2eTest('queue advanced: footer emission', () => {
467
473
  "--- from: user (queue-advanced-tester)
468
474
  MULTI_STEP_CHAIN_MARKER
469
475
  --- from: assistant (TestBot)
476
+ *using deterministic-provider/deterministic-v2*
470
477
  ⬥ chain step 1: reading config
471
478
  ⬥ chain step 2: analyzing results
472
479
  ⬥ chain step 3: applying fix