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.
- package/dist/agent-model.e2e.test.js +7 -0
- package/dist/anthropic-auth-plugin.js +52 -18
- package/dist/cli-send-thread.e2e.test.js +4 -2
- package/dist/cli.js +11 -5
- package/dist/commands/add-dir.js +57 -10
- package/dist/commands/last-sessions.js +120 -0
- package/dist/commands/permissions.js +46 -7
- package/dist/discord-command-registration.js +5 -0
- package/dist/format-tables.test.js +19 -0
- package/dist/gateway-proxy.e2e.test.js +4 -1
- package/dist/interaction-handler.js +7 -0
- package/dist/logger.js +19 -20
- package/dist/message-formatting.js +3 -1
- package/dist/message-formatting.test.js +43 -1
- package/dist/opencode.js +1 -0
- package/dist/proxy-ws-preload.cjs +85 -0
- package/dist/queue-advanced-abort.e2e.test.js +2 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +2 -0
- package/dist/queue-advanced-footer.e2e.test.js +7 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +1 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +1 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +1 -0
- package/dist/queue-interrupt-drain.e2e.test.js +1 -0
- package/dist/queue-question-select-drain.e2e.test.js +2 -0
- package/dist/runtime-lifecycle.e2e.test.js +6 -4
- package/dist/session-handler/thread-session-runtime.js +32 -2
- package/dist/system-message.js +43 -29
- package/dist/system-message.test.js +47 -29
- package/dist/thread-message-queue.e2e.test.js +8 -1
- package/dist/undo-redo.e2e.test.js +1 -0
- package/dist/voice-message.e2e.test.js +8 -0
- package/package.json +9 -10
- package/skills/new-skill/SKILL.md +34 -20
- package/skills/readme.md +20 -0
- package/src/agent-model.e2e.test.ts +7 -0
- package/src/anthropic-auth-plugin.ts +64 -20
- package/src/cli-send-thread.e2e.test.ts +4 -2
- package/src/cli.ts +13 -5
- package/src/commands/add-dir.ts +85 -14
- package/src/commands/last-sessions.ts +167 -0
- package/src/commands/permissions.ts +62 -13
- package/src/discord-command-registration.ts +5 -0
- package/src/format-tables.test.ts +20 -0
- package/src/gateway-proxy.e2e.test.ts +4 -1
- package/src/interaction-handler.ts +8 -0
- package/src/logger.ts +46 -35
- package/src/message-formatting.test.ts +46 -1
- package/src/message-formatting.ts +3 -1
- package/src/opencode.ts +1 -0
- package/src/queue-advanced-abort.e2e.test.ts +2 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +2 -0
- package/src/queue-advanced-footer.e2e.test.ts +7 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +1 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +1 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +1 -0
- package/src/queue-interrupt-drain.e2e.test.ts +1 -0
- package/src/queue-question-select-drain.e2e.test.ts +2 -0
- package/src/runtime-lifecycle.e2e.test.ts +6 -4
- package/src/session-handler/thread-session-runtime.ts +48 -2
- package/src/system-message.test.ts +47 -29
- package/src/system-message.ts +43 -29
- package/src/thread-message-queue.e2e.test.ts +8 -1
- package/src/undo-redo.e2e.test.ts +1 -0
- 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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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(
|
|
495
|
-
|
|
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
|
|
498
|
-
|
|
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
|
|
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
|
|
1301
|
-
//
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
'
|
|
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) {
|
package/dist/commands/add-dir.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
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({
|
|
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
|
-
|
|
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;
|