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.
- package/dist/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
package/dist/commands/model.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
+
}
|
package/dist/commands/resume.js
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
83
|
-
logger.log(`Session ${sessionId} reverted message ${
|
|
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
|
-
|
|
138
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
153
|
-
logger.log(`Session ${sessionId}
|
|
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);
|