kimaki 0.7.0 → 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 (66) hide show
  1. package/dist/agent-model.e2e.test.js +7 -0
  2. package/dist/anthropic-auth-plugin.js +52 -18
  3. package/dist/cli-send-thread.e2e.test.js +4 -2
  4. package/dist/cli.js +11 -5
  5. package/dist/commands/add-dir.js +57 -10
  6. package/dist/commands/last-sessions.js +120 -0
  7. package/dist/commands/permissions.js +46 -7
  8. package/dist/discord-command-registration.js +5 -0
  9. package/dist/format-tables.test.js +19 -0
  10. package/dist/gateway-proxy.e2e.test.js +4 -1
  11. package/dist/interaction-handler.js +7 -0
  12. package/dist/logger.js +19 -20
  13. package/dist/message-formatting.js +3 -1
  14. package/dist/message-formatting.test.js +43 -1
  15. package/dist/opencode.js +1 -0
  16. package/dist/proxy-ws-preload.cjs +85 -0
  17. package/dist/queue-advanced-abort.e2e.test.js +2 -1
  18. package/dist/queue-advanced-action-buttons.e2e.test.js +2 -0
  19. package/dist/queue-advanced-footer.e2e.test.js +7 -0
  20. package/dist/queue-advanced-model-switch.e2e.test.js +1 -0
  21. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
  22. package/dist/queue-advanced-typing-interrupt.e2e.test.js +1 -0
  23. package/dist/queue-drain-after-interactive-ui.e2e.test.js +1 -0
  24. package/dist/queue-interrupt-drain.e2e.test.js +1 -0
  25. package/dist/queue-question-select-drain.e2e.test.js +2 -0
  26. package/dist/runtime-lifecycle.e2e.test.js +6 -4
  27. package/dist/session-handler/thread-session-runtime.js +32 -2
  28. package/dist/system-message.js +43 -29
  29. package/dist/system-message.test.js +47 -29
  30. package/dist/thread-message-queue.e2e.test.js +8 -1
  31. package/dist/undo-redo.e2e.test.js +1 -0
  32. package/dist/voice-message.e2e.test.js +8 -0
  33. package/package.json +9 -10
  34. package/skills/new-skill/SKILL.md +34 -20
  35. package/skills/readme.md +20 -0
  36. package/src/agent-model.e2e.test.ts +7 -0
  37. package/src/anthropic-auth-plugin.ts +64 -20
  38. package/src/cli-send-thread.e2e.test.ts +4 -2
  39. package/src/cli.ts +13 -5
  40. package/src/commands/add-dir.ts +85 -14
  41. package/src/commands/last-sessions.ts +167 -0
  42. package/src/commands/permissions.ts +62 -13
  43. package/src/discord-command-registration.ts +5 -0
  44. package/src/format-tables.test.ts +20 -0
  45. package/src/gateway-proxy.e2e.test.ts +4 -1
  46. package/src/interaction-handler.ts +8 -0
  47. package/src/logger.ts +46 -35
  48. package/src/message-formatting.test.ts +46 -1
  49. package/src/message-formatting.ts +3 -1
  50. package/src/opencode.ts +1 -0
  51. package/src/queue-advanced-abort.e2e.test.ts +2 -1
  52. package/src/queue-advanced-action-buttons.e2e.test.ts +2 -0
  53. package/src/queue-advanced-footer.e2e.test.ts +7 -0
  54. package/src/queue-advanced-model-switch.e2e.test.ts +1 -0
  55. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
  56. package/src/queue-advanced-typing-interrupt.e2e.test.ts +1 -0
  57. package/src/queue-drain-after-interactive-ui.e2e.test.ts +1 -0
  58. package/src/queue-interrupt-drain.e2e.test.ts +1 -0
  59. package/src/queue-question-select-drain.e2e.test.ts +2 -0
  60. package/src/runtime-lifecycle.e2e.test.ts +6 -4
  61. package/src/session-handler/thread-session-runtime.ts +48 -2
  62. package/src/system-message.test.ts +47 -29
  63. package/src/system-message.ts +43 -29
  64. package/src/thread-message-queue.e2e.test.ts +8 -1
  65. package/src/undo-redo.e2e.test.ts +1 -0
  66. 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**)
@@ -65,6 +65,9 @@ const CLAUDE_CODE_VERSION = "2.1.75";
65
65
  const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
66
66
  const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
67
67
  const ANTHROPIC_PROMPT_MARKER = "Skills provide specialized instructions";
68
+ // Subagent prompts don't contain OPENCODE_IDENTITY; opencode appends this
69
+ // line + an <env> block instead. We strip from here to </env> inclusive.
70
+ const SUBAGENT_MODEL_IDENTITY = "You are powered by the model named";
68
71
  const CLAUDE_CODE_BETA = "claude-code-20250219";
69
72
  const OAUTH_BETA = "oauth-2025-04-20";
70
73
  const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
@@ -477,28 +480,59 @@ function toClaudeCodeToolName(name) {
477
480
  */
478
481
  function sanitizeAnthropicSystemText(text, onError) {
479
482
  const startIdx = text.indexOf(OPENCODE_IDENTITY);
480
- if (startIdx === -1)
481
- return text;
482
- // Keep the marker aligned with the current OpenCode Anthropic prompt.
483
- const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
484
- if (endIdx === -1) {
485
- onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
486
- return text;
483
+ if (startIdx !== -1) {
484
+ // Main session path: strip from OpenCode identity to the Anthropic prompt marker.
485
+ // Keep the marker aligned with the current OpenCode Anthropic prompt.
486
+ const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
487
+ if (endIdx === -1) {
488
+ onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
489
+ return text;
490
+ }
491
+ return replaceBlockWithCompactEnv(text, startIdx, endIdx);
492
+ }
493
+ // Subagent path: opencode appends "You are powered by the model named ..."
494
+ // followed by an <env> block. Strip from that line through </env>.
495
+ const subagentIdx = text.indexOf(SUBAGENT_MODEL_IDENTITY);
496
+ if (subagentIdx !== -1) {
497
+ const envCloseTag = "</env>";
498
+ const envCloseIdx = text.indexOf(envCloseTag, subagentIdx);
499
+ if (envCloseIdx === -1) {
500
+ onError?.("sanitizeAnthropicSystemText: could not find </env> after subagent model identity");
501
+ return text;
502
+ }
503
+ const endIdx = envCloseIdx + envCloseTag.length;
504
+ // Skip trailing newline so the join is clean
505
+ const afterEnd = text[endIdx] === "\n" ? endIdx + 1 : endIdx;
506
+ return replaceBlockWithCompactEnv(text, subagentIdx, afterEnd);
487
507
  }
488
- // Extract the cwd from the block we're about to strip. OpenCode's system
489
- // prompt embeds <environment><cwd>/path</cwd></environment> in the identity
490
- // block. We preserve the per-session cwd instead of falling back to
491
- // process.cwd() which is the opencode server's cwd and wrong for
492
- // multi-session/worktree setups where each session has a different directory.
508
+ return text;
509
+ }
510
+ // Extract cwd from the block being stripped and replace it with a compact
511
+ // <environment> tag. Shared by both main-session and subagent paths.
512
+ // Source: anomalyco/opencode packages/opencode/src/session/system.ts
513
+ // OpenCode's system prompt format (as of 2025):
514
+ // <env>
515
+ // Working directory: ${Instance.directory}
516
+ // Workspace root folder: ${Instance.worktree}
517
+ // Is directory a git repo: yes/no
518
+ // Platform: ${process.platform}
519
+ // Today's date: ${new Date().toDateString()}
520
+ // </env>
521
+ // Older format used <environment><cwd>/path</cwd></environment>.
522
+ // We try both patterns to stay compatible across opencode versions.
523
+ // We preserve the per-session directory instead of falling back to
524
+ // process.cwd() which is the opencode server's cwd and wrong for
525
+ // multi-session/worktree setups where each session has a different directory.
526
+ function replaceBlockWithCompactEnv(text, startIdx, endIdx) {
493
527
  const strippedBlock = text.slice(startIdx, endIdx);
494
- const cwdMatch = strippedBlock.match(/<cwd>([^<]+)<\/cwd>/);
495
- const cwd = cwdMatch?.[1] || process.cwd();
528
+ const cwdMatch = strippedBlock.match(/Working directory:\s*(.+)/)?.[1]?.trim() ||
529
+ strippedBlock.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
530
+ const cwd = cwdMatch || process.cwd();
496
531
  const envContext = `\n<environment>\n<cwd>${cwd}</cwd>\n</environment>\n` +
497
- `Read, write, and edit files under <cwd>.\n\n`;
498
- const result = text.slice(0, startIdx) +
532
+ `Read, write, and edit files under ${cwd}.\n\n`;
533
+ return (text.slice(0, startIdx) +
499
534
  envContext +
500
- text.slice(endIdx);
501
- return result;
535
+ text.slice(endIdx));
502
536
  }
503
537
  function mapSystemTextPart(part, onError) {
504
538
  if (typeof part === "string") {
@@ -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'))
@@ -439,6 +439,25 @@ Read this first.
439
439
  ]
440
440
  `);
441
441
  });
442
+ test('renders callout that was prefixed with ⬥ as plain text (regression)', () => {
443
+ // Before the fix, formatPart would add ⬥ prefix to callout lines,
444
+ // breaking the callout parser. Now formatPart skips the prefix for callouts.
445
+ const result = splitTablesFromMarkdown(`⬥ <callout accent="#ef4444">
446
+ ## Top priority
447
+ - **Stripe dispute** deadline
448
+ </callout>`);
449
+ expect(result).toMatchInlineSnapshot(`
450
+ [
451
+ {
452
+ "text": "⬥ <callout accent="#ef4444">
453
+ ## Top priority
454
+ - **Stripe dispute** deadline
455
+ </callout>",
456
+ "type": "text",
457
+ },
458
+ ]
459
+ `);
460
+ });
442
461
  test('falls back to plain text when a callout is not closed', () => {
443
462
  const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
444
463
  ## Important
@@ -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;