kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -9,6 +9,7 @@ import { getRuntime } from '../session-handler/thread-session-runtime.js';
9
9
  import { getThinkingValuesForModel } from '../thinking-utils.js';
10
10
  import { createLogger, LogPrefix } from '../logger.js';
11
11
  import * as errore from 'errore';
12
+ import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
12
13
  const modelLogger = createLogger(LogPrefix.MODEL);
13
14
  // Store context by hash to avoid customId length limits (Discord max: 100 chars).
14
15
  // Entries are TTL'd to prevent unbounded growth when users open /model and never
@@ -291,6 +292,7 @@ export async function handleModelCommand({ interaction, appId, }) {
291
292
  return `\n**Variant:** \`${cascadeVariant}\``;
292
293
  })();
293
294
  // Store context with a short hash key to avoid customId length limits.
295
+ const providerSelectHeader = `**Set Model Preference**\n${currentModelText}${variantText}\nSelect a provider:`;
294
296
  const context = {
295
297
  dir: projectDirectory,
296
298
  channelId: targetChannelId,
@@ -298,12 +300,12 @@ export async function handleModelCommand({ interaction, appId, }) {
298
300
  isThread: isThread,
299
301
  thread: isThread ? channel : undefined,
300
302
  appId,
303
+ providerSelectHeader,
301
304
  };
302
305
  const contextHash = crypto.randomBytes(8).toString('hex');
303
306
  setModelContext(contextHash, context);
304
- const options = [...availableProviders]
307
+ const allProviderOptions = [...availableProviders]
305
308
  .sort((a, b) => a.name.localeCompare(b.name))
306
- .slice(0, 25)
307
309
  .map((provider) => {
308
310
  const modelCount = Object.keys(provider.models || {}).length;
309
311
  return {
@@ -312,13 +314,17 @@ export async function handleModelCommand({ interaction, appId, }) {
312
314
  description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
313
315
  };
314
316
  });
317
+ const { options } = buildPaginatedOptions({
318
+ allOptions: allProviderOptions,
319
+ page: 0,
320
+ });
315
321
  const selectMenu = new StringSelectMenuBuilder()
316
322
  .setCustomId(`model_provider:${contextHash}`)
317
323
  .setPlaceholder('Select a provider')
318
324
  .addOptions(options);
319
325
  const actionRow = new ActionRowBuilder().addComponents(selectMenu);
320
326
  await interaction.editReply({
321
- content: `**Set Model Preference**\n${currentModelText}${variantText}\nSelect a provider:`,
327
+ content: providerSelectHeader,
322
328
  components: [actionRow],
323
329
  });
324
330
  }
@@ -357,6 +363,45 @@ export async function handleProviderSelectMenu(interaction) {
357
363
  });
358
364
  return;
359
365
  }
366
+ // Handle pagination nav — re-render the same provider select with new page
367
+ const providerNavPage = parsePaginationValue(selectedProviderId);
368
+ if (providerNavPage !== undefined) {
369
+ context.providerPage = providerNavPage;
370
+ setModelContext(contextHash, context);
371
+ const getClient = await initializeOpencodeForDirectory(context.dir);
372
+ if (getClient instanceof Error) {
373
+ await interaction.editReply({ content: getClient.message, components: [] });
374
+ return;
375
+ }
376
+ const providersResponse = await getClient().provider.list({ directory: context.dir });
377
+ if (!providersResponse.data) {
378
+ await interaction.editReply({ content: 'Failed to fetch providers', components: [] });
379
+ return;
380
+ }
381
+ const { all: allProviders, connected } = providersResponse.data;
382
+ const availableProviders = allProviders.filter((p) => connected.includes(p.id));
383
+ const allProviderOptions = [...availableProviders]
384
+ .sort((a, b) => a.name.localeCompare(b.name))
385
+ .map((p) => {
386
+ const modelCount = Object.keys(p.models || {}).length;
387
+ return {
388
+ label: p.name.slice(0, 100),
389
+ value: p.id,
390
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
391
+ };
392
+ });
393
+ const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage });
394
+ const selectMenu = new StringSelectMenuBuilder()
395
+ .setCustomId(`model_provider:${contextHash}`)
396
+ .setPlaceholder('Select a provider')
397
+ .addOptions(options);
398
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
399
+ await interaction.editReply({
400
+ content: context.providerSelectHeader || `**Set Model Preference**\nSelect a provider:`,
401
+ components: [actionRow],
402
+ });
403
+ return;
404
+ }
360
405
  try {
361
406
  const getClient = await initializeOpencodeForDirectory(context.dir);
362
407
  if (getClient instanceof Error) {
@@ -398,13 +443,12 @@ export async function handleProviderSelectMenu(interaction) {
398
443
  });
399
444
  return;
400
445
  }
401
- // Take first 25 models (most recent since sorted descending)
402
- const recentModels = models.slice(0, 25);
403
446
  // Update context with provider info and reuse the same hash
404
447
  context.providerId = selectedProviderId;
405
448
  context.providerName = provider.name;
449
+ context.modelPage = 0;
406
450
  setModelContext(contextHash, context);
407
- const options = recentModels.map((model) => {
451
+ const allModelOptions = models.map((model) => {
408
452
  const dateStr = model.releaseDate
409
453
  ? new Date(model.releaseDate).toLocaleDateString()
410
454
  : 'Unknown date';
@@ -414,6 +458,10 @@ export async function handleProviderSelectMenu(interaction) {
414
458
  description: dateStr.slice(0, 100),
415
459
  };
416
460
  });
461
+ const { options } = buildPaginatedOptions({
462
+ allOptions: allModelOptions,
463
+ page: 0,
464
+ });
417
465
  const selectMenu = new StringSelectMenuBuilder()
418
466
  .setCustomId(`model_select:${contextHash}`)
419
467
  .setPlaceholder('Select a model')
@@ -460,6 +508,43 @@ export async function handleModelSelectMenu(interaction) {
460
508
  });
461
509
  return;
462
510
  }
511
+ // Handle pagination nav — re-render the same model select with new page
512
+ const modelNavPage = parsePaginationValue(selectedModelId);
513
+ if (modelNavPage !== undefined) {
514
+ context.modelPage = modelNavPage;
515
+ setModelContext(contextHash, context);
516
+ const getClient = await initializeOpencodeForDirectory(context.dir);
517
+ if (getClient instanceof Error) {
518
+ await interaction.editReply({ content: getClient.message, components: [] });
519
+ return;
520
+ }
521
+ const providersResponse = await getClient().provider.list({ directory: context.dir });
522
+ const provider = providersResponse.data?.all.find((p) => p.id === context.providerId);
523
+ if (!provider) {
524
+ await interaction.editReply({ content: 'Provider not found', components: [] });
525
+ return;
526
+ }
527
+ const allModelOptions = Object.entries(provider.models || {})
528
+ .map(([modelId, model]) => ({
529
+ label: model.name.slice(0, 100),
530
+ value: modelId,
531
+ description: (model.release_date
532
+ ? new Date(model.release_date).toLocaleDateString()
533
+ : 'Unknown date').slice(0, 100),
534
+ }))
535
+ .sort((a, b) => a.label.localeCompare(b.label));
536
+ const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage });
537
+ const selectMenu = new StringSelectMenuBuilder()
538
+ .setCustomId(`model_select:${contextHash}`)
539
+ .setPlaceholder('Select a model')
540
+ .addOptions(options);
541
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
542
+ await interaction.editReply({
543
+ content: `**Set Model Preference**\nProvider: **${context.providerName}**\nSelect a model:`,
544
+ components: [actionRow],
545
+ });
546
+ return;
547
+ }
463
548
  // Build full model ID: provider_id/model_id
464
549
  const fullModelId = `${context.providerId}/${selectedModelId}`;
465
550
  try {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reusable paginated select menu helpers for Discord StringSelectMenuBuilder.
3
+ * Discord caps select menus at 25 options. This module slices a full options
4
+ * list into pages of PAGE_SIZE real items and appends "← Previous page" /
5
+ * "Next page →" sentinel options so the user can navigate. Handlers detect
6
+ * sentinel values via parsePaginationValue() and re-render the same select
7
+ * with the new page — reusing the same customId, no new interaction handlers.
8
+ */
9
+ const NAV_PREFIX = '__page_nav:';
10
+ /** 23 real items per page, leaving room for up to 2 nav sentinels (prev + next). */
11
+ const PAGE_SIZE = 23;
12
+ /**
13
+ * Build the options array for a single page, with prev/next nav sentinels.
14
+ * If allOptions fits in 25 items, returns them all with no nav items.
15
+ */
16
+ export function buildPaginatedOptions({ allOptions, page, }) {
17
+ // No pagination needed — everything fits in one Discord select
18
+ if (allOptions.length <= 25) {
19
+ return { options: allOptions, totalPages: 1 };
20
+ }
21
+ const totalPages = Math.ceil(allOptions.length / PAGE_SIZE);
22
+ const safePage = Math.max(0, Math.min(page, totalPages - 1));
23
+ const start = safePage * PAGE_SIZE;
24
+ const slice = allOptions.slice(start, start + PAGE_SIZE);
25
+ const result = [];
26
+ if (safePage > 0) {
27
+ result.push({
28
+ label: `← Previous page (${safePage}/${totalPages})`,
29
+ value: `${NAV_PREFIX}${safePage - 1}`,
30
+ description: 'Go to previous page',
31
+ });
32
+ }
33
+ result.push(...slice);
34
+ if (safePage < totalPages - 1) {
35
+ result.push({
36
+ label: `Next page → (${safePage + 2}/${totalPages})`,
37
+ value: `${NAV_PREFIX}${safePage + 1}`,
38
+ description: 'Go to next page',
39
+ });
40
+ }
41
+ return { options: result, totalPages };
42
+ }
43
+ /**
44
+ * Check if a selected value is a pagination nav sentinel.
45
+ * Returns the target page number if so, undefined otherwise.
46
+ */
47
+ export function parsePaginationValue(value) {
48
+ if (!value.startsWith(NAV_PREFIX)) {
49
+ return undefined;
50
+ }
51
+ const pageStr = value.slice(NAV_PREFIX.length);
52
+ const page = Number(pageStr);
53
+ if (Number.isNaN(page)) {
54
+ return undefined;
55
+ }
56
+ return page;
57
+ }
@@ -3,7 +3,7 @@ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
3
  import fs from 'node:fs';
4
4
  import { getChannelDirectory, setThreadSession, setPartMessagesBatch, getAllThreadSessionIds, } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
- import { sendThreadMessage, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js';
6
+ import { sendThreadMessage, resolveProjectDirectoryFromAutocomplete, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
7
7
  import { collectLastAssistantParts } from '../message-formatting.js';
8
8
  import { createLogger, LogPrefix } from '../logger.js';
9
9
  import * as errore from 'errore';
@@ -90,7 +90,7 @@ export async function handleResumeCommand({ command, }) {
90
90
  }
91
91
  catch (sendError) {
92
92
  logger.error('[RESUME] Error sending messages to thread:', sendError);
93
- await sendThreadMessage(thread, `Failed to load message history, but session is connected. You can still send new messages.`);
93
+ await sendThreadMessage(thread, `Failed to load message history, but session is connected. You can still send new messages.`, { flags: NOTIFY_MESSAGE_FLAGS });
94
94
  }
95
95
  }
96
96
  catch (error) {
@@ -0,0 +1,205 @@
1
+ // /tasks command — list all scheduled tasks sorted by next run time.
2
+ // Renders a markdown table that the CV2 pipeline auto-formats for Discord,
3
+ // including HTML-backed action buttons for cancellable tasks.
4
+ import { ButtonInteraction, ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'discord.js';
5
+ import { cancelScheduledTask, listScheduledTasks, } from '../database.js';
6
+ import { splitTablesFromMarkdown } from '../format-tables.js';
7
+ import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
8
+ import { formatTimeAgo } from './worktrees.js';
9
+ function formatTimeUntil(date) {
10
+ const diffMs = date.getTime() - Date.now();
11
+ if (diffMs <= 0) {
12
+ return 'due now';
13
+ }
14
+ const totalSeconds = Math.floor(diffMs / 1000);
15
+ if (totalSeconds < 60) {
16
+ return `in ${totalSeconds}s`;
17
+ }
18
+ const totalMinutes = Math.floor(totalSeconds / 60);
19
+ if (totalMinutes < 60) {
20
+ return `in ${totalMinutes}m`;
21
+ }
22
+ const hours = Math.floor(totalMinutes / 60);
23
+ const minutes = totalMinutes % 60;
24
+ if (hours < 24) {
25
+ return minutes > 0 ? `in ${hours}h ${minutes}m` : `in ${hours}h`;
26
+ }
27
+ const days = Math.floor(hours / 24);
28
+ const remainingHours = hours % 24;
29
+ return remainingHours > 0 ? `in ${days}d ${remainingHours}h` : `in ${days}d`;
30
+ }
31
+ function scheduleLabel(task) {
32
+ if (task.schedule_kind === 'cron') {
33
+ return task.cron_expr || 'cron';
34
+ }
35
+ return 'one-time';
36
+ }
37
+ function canCancelTask(task) {
38
+ return task.status === 'planned' || task.status === 'running';
39
+ }
40
+ // Escape pipe chars and collapse whitespace so free-text fields don't break
41
+ // GFM table column alignment.
42
+ function sanitizeTableCell(value) {
43
+ return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim();
44
+ }
45
+ function buildCancelButtonHtml({ buttonId }) {
46
+ return `<button id="${buttonId}" variant="secondary">Delete</button>`;
47
+ }
48
+ function buildActionCell(task) {
49
+ if (!canCancelTask(task)) {
50
+ return '-';
51
+ }
52
+ return buildCancelButtonHtml({ buttonId: `cancel-task-${task.id}` });
53
+ }
54
+ // Cap rows to avoid exceeding Discord's 40-component CV2 limit.
55
+ // Each cancellable row renders as text + action row + button (~4 components),
56
+ // so 10 rows is a safe ceiling.
57
+ const MAX_TASK_ROWS = 10;
58
+ function buildTaskTable({ tasks, }) {
59
+ const header = '| ID | Status | Prompt | Schedule | Next Run | Action |';
60
+ const separator = '|---|---|---|---|---|---|';
61
+ const rows = tasks.map((task) => {
62
+ const id = String(task.id);
63
+ const status = task.status;
64
+ const prompt = sanitizeTableCell(task.prompt_preview.length > 240
65
+ ? task.prompt_preview.slice(0, 237) + '...'
66
+ : task.prompt_preview);
67
+ const schedule = sanitizeTableCell(scheduleLabel(task));
68
+ const nextRun = (() => {
69
+ if (task.status === 'completed' ||
70
+ task.status === 'cancelled' ||
71
+ task.status === 'failed') {
72
+ return task.last_run_at ? formatTimeAgo(task.last_run_at) : '-';
73
+ }
74
+ return formatTimeUntil(task.next_run_at);
75
+ })();
76
+ const action = buildActionCell(task);
77
+ return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${action} |`;
78
+ });
79
+ return [header, separator, ...rows].join('\n');
80
+ }
81
+ function getTasksActionOwnerKey({ userId, channelId, }) {
82
+ return `tasks:${userId}:${channelId}`;
83
+ }
84
+ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, editReply, }) {
85
+ const ownerKey = getTasksActionOwnerKey({ userId, channelId });
86
+ cancelHtmlActionsForOwner(ownerKey);
87
+ const statuses = showAll
88
+ ? undefined
89
+ : ['planned', 'running'];
90
+ const allTasks = await listScheduledTasks({ statuses });
91
+ if (allTasks.length === 0) {
92
+ const message = notice
93
+ ? `${notice}\n\nNo scheduled tasks found.`
94
+ : 'No scheduled tasks found.';
95
+ const textDisplay = {
96
+ type: ComponentType.TextDisplay,
97
+ content: message,
98
+ };
99
+ await editReply({
100
+ components: [textDisplay],
101
+ flags: MessageFlags.IsComponentsV2,
102
+ });
103
+ return;
104
+ }
105
+ const tasks = allTasks.slice(0, MAX_TASK_ROWS);
106
+ const truncatedNotice = allTasks.length > MAX_TASK_ROWS
107
+ ? `Showing ${MAX_TASK_ROWS}/${allTasks.length} tasks. Use \`kimaki task list\` for full list.`
108
+ : undefined;
109
+ const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n');
110
+ const cancellableTasksByButtonId = new Map();
111
+ tasks.forEach((task) => {
112
+ if (!canCancelTask(task)) {
113
+ return;
114
+ }
115
+ cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
116
+ });
117
+ const tableMarkdown = buildTaskTable({ tasks });
118
+ const markdown = combinedNotice
119
+ ? `${combinedNotice}\n\n${tableMarkdown}`
120
+ : tableMarkdown;
121
+ const segments = splitTablesFromMarkdown(markdown, {
122
+ resolveButtonCustomId: ({ button }) => {
123
+ const task = cancellableTasksByButtonId.get(button.id);
124
+ if (!task) {
125
+ return new Error(`No task registered for button ${button.id}`);
126
+ }
127
+ const actionId = registerHtmlAction({
128
+ ownerKey,
129
+ threadId: String(task.id),
130
+ run: async ({ interaction }) => {
131
+ await handleCancelTaskAction({
132
+ interaction,
133
+ taskId: task.id,
134
+ showAll,
135
+ });
136
+ },
137
+ });
138
+ return buildHtmlActionCustomId(actionId);
139
+ },
140
+ });
141
+ const components = segments.flatMap((segment) => {
142
+ if (segment.type === 'components') {
143
+ return segment.components;
144
+ }
145
+ const textDisplay = {
146
+ type: ComponentType.TextDisplay,
147
+ content: segment.text,
148
+ };
149
+ return [textDisplay];
150
+ });
151
+ await editReply({
152
+ components,
153
+ flags: MessageFlags.IsComponentsV2,
154
+ });
155
+ }
156
+ async function handleCancelTaskAction({ interaction, taskId, showAll, }) {
157
+ const guildId = interaction.guildId;
158
+ if (!guildId) {
159
+ await interaction.editReply({
160
+ components: [
161
+ {
162
+ type: ComponentType.TextDisplay,
163
+ content: 'This action can only be used in a server.',
164
+ },
165
+ ],
166
+ flags: MessageFlags.IsComponentsV2,
167
+ });
168
+ return;
169
+ }
170
+ const cancelled = await cancelScheduledTask(taskId);
171
+ const notice = cancelled
172
+ ? `Cancelled task #${taskId}.`
173
+ : `Task #${taskId} not found or already finalized.`;
174
+ await renderTasksReply({
175
+ guildId,
176
+ userId: interaction.user.id,
177
+ channelId: interaction.channelId,
178
+ showAll,
179
+ notice,
180
+ editReply: (options) => {
181
+ return interaction.editReply(options);
182
+ },
183
+ });
184
+ }
185
+ export async function handleTasksCommand({ command, }) {
186
+ const guildId = command.guildId;
187
+ if (!guildId) {
188
+ await command.reply({
189
+ content: 'This command can only be used in a server.',
190
+ flags: MessageFlags.Ephemeral,
191
+ });
192
+ return;
193
+ }
194
+ const showAll = command.options.getBoolean('all') ?? false;
195
+ await command.deferReply({ flags: MessageFlags.Ephemeral });
196
+ await renderTasksReply({
197
+ guildId,
198
+ userId: command.user.id,
199
+ channelId: command.channelId,
200
+ showAll,
201
+ editReply: (options) => {
202
+ return command.editReply(options);
203
+ },
204
+ });
205
+ }
@@ -52,25 +52,49 @@ export async function handleUndoCommand({ command, }) {
52
52
  return;
53
53
  }
54
54
  try {
55
- // Fetch messages to find the last assistant message
56
- const messagesResponse = await getClient().session.messages({
55
+ const client = getClient();
56
+ // Fetch session to check existing revert state
57
+ const sessionResponse = await client.session.get({
57
58
  sessionID: sessionId,
58
59
  });
60
+ if (sessionResponse.error) {
61
+ await command.editReply(`Failed to undo: ${JSON.stringify(sessionResponse.error)}`);
62
+ return;
63
+ }
64
+ const messagesResponse = await client.session.messages({
65
+ sessionID: sessionId,
66
+ });
67
+ if (messagesResponse.error) {
68
+ await command.editReply(`Failed to undo: ${JSON.stringify(messagesResponse.error)}`);
69
+ return;
70
+ }
59
71
  if (!messagesResponse.data || messagesResponse.data.length === 0) {
60
72
  await command.editReply('No messages to undo');
61
73
  return;
62
74
  }
63
- // Find the last assistant message
64
- const lastAssistantMessage = [...messagesResponse.data]
65
- .reverse()
66
- .find((m) => m.info.role === 'assistant');
67
- if (!lastAssistantMessage) {
68
- await command.editReply('No assistant message to undo');
75
+ // Follow the same approach as the OpenCode TUI (use-session-commands.tsx):
76
+ // find the last user message that is before the current revert point
77
+ // (or the last user message if no revert is active). This matches the
78
+ // TUI's `findLast(userMessages(), (x) => !revert || x.id < revert)`.
79
+ const currentRevert = sessionResponse.data?.revert?.messageID;
80
+ const userMessages = messagesResponse.data.filter((m) => {
81
+ return m.info.role === 'user';
82
+ });
83
+ const targetUserMessage = [...userMessages].reverse().find((m) => {
84
+ return !currentRevert || m.info.id < currentRevert;
85
+ });
86
+ if (!targetUserMessage) {
87
+ await command.editReply('No messages to undo');
69
88
  return;
70
89
  }
71
- const response = await getClient().session.revert({
90
+ // session.revert() reverts filesystem patches (file edits, writes) and
91
+ // marks the session with revert.messageID. Messages are NOT deleted — they
92
+ // get cleaned up automatically on the next promptAsync() call via
93
+ // SessionRevert.cleanup(). The model only sees messages before the revert
94
+ // point when processing the next prompt.
95
+ const response = await client.session.revert({
72
96
  sessionID: sessionId,
73
- messageID: lastAssistantMessage.info.id,
97
+ messageID: targetUserMessage.info.id,
74
98
  });
75
99
  if (response.error) {
76
100
  await command.editReply(`Failed to undo: ${JSON.stringify(response.error)}`);
@@ -79,8 +103,8 @@ export async function handleUndoCommand({ command, }) {
79
103
  const diffInfo = response.data?.revert?.diff
80
104
  ? `\n\`\`\`diff\n${response.data.revert.diff.slice(0, 1500)}\n\`\`\``
81
105
  : '';
82
- await command.editReply(`⏪ **Undone** - reverted last assistant message${diffInfo}`);
83
- logger.log(`Session ${sessionId} reverted message ${lastAssistantMessage.info.id}`);
106
+ await command.editReply(`Undone - reverted last assistant message${diffInfo}`);
107
+ logger.log(`Session ${sessionId} reverted to before user message ${targetUserMessage.info.id}`);
84
108
  }
85
109
  catch (error) {
86
110
  logger.error('[UNDO] Error:', error);
@@ -134,23 +158,61 @@ export async function handleRedoCommand({ command, }) {
134
158
  return;
135
159
  }
136
160
  try {
137
- // Check if session has reverted state
138
- const sessionResponse = await getClient().session.get({
161
+ const client = getClient();
162
+ // Fetch session to check existing revert state
163
+ const sessionResponse = await client.session.get({
139
164
  sessionID: sessionId,
140
165
  });
141
- if (!sessionResponse.data?.revert) {
166
+ if (sessionResponse.error) {
167
+ await command.editReply(`Failed to redo: ${JSON.stringify(sessionResponse.error)}`);
168
+ return;
169
+ }
170
+ const revertMessageID = sessionResponse.data?.revert?.messageID;
171
+ if (!revertMessageID) {
142
172
  await command.editReply('Nothing to redo - no previous undo found');
143
173
  return;
144
174
  }
145
- const response = await getClient().session.unrevert({
175
+ // Follow the same approach as the OpenCode TUI (use-session-commands.tsx):
176
+ // find the next user message after the current revert point. If one exists,
177
+ // move the revert cursor forward to it (one step redo). If none exists,
178
+ // fully unrevert — we're at the end of the message history.
179
+ const messagesResponse = await client.session.messages({
180
+ sessionID: sessionId,
181
+ });
182
+ if (messagesResponse.error) {
183
+ await command.editReply(`Failed to redo: ${JSON.stringify(messagesResponse.error)}`);
184
+ return;
185
+ }
186
+ const userMessages = (messagesResponse.data ?? []).filter((m) => {
187
+ return m.info.role === 'user';
188
+ });
189
+ const nextMessage = userMessages.find((m) => {
190
+ return m.info.id > revertMessageID;
191
+ });
192
+ if (!nextMessage) {
193
+ // No more messages after revert point — fully unrevert
194
+ const response = await client.session.unrevert({
195
+ sessionID: sessionId,
196
+ });
197
+ if (response.error) {
198
+ await command.editReply(`Failed to redo: ${JSON.stringify(response.error)}`);
199
+ return;
200
+ }
201
+ await command.editReply('Restored - session fully back to previous state');
202
+ logger.log(`Session ${sessionId} unrevert completed`);
203
+ return;
204
+ }
205
+ // Move revert cursor forward one step to the next user message
206
+ const response = await client.session.revert({
146
207
  sessionID: sessionId,
208
+ messageID: nextMessage.info.id,
147
209
  });
148
210
  if (response.error) {
149
211
  await command.editReply(`Failed to redo: ${JSON.stringify(response.error)}`);
150
212
  return;
151
213
  }
152
- await command.editReply(`⏩ **Restored** - session back to previous state`);
153
- logger.log(`Session ${sessionId} unrevert completed`);
214
+ await command.editReply('Restored one step forward');
215
+ logger.log(`Session ${sessionId} redo: moved revert to ${nextMessage.info.id}`);
154
216
  }
155
217
  catch (error) {
156
218
  logger.error('[REDO] Error:', error);