kimaki 0.6.0 → 0.7.1

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 (72) hide show
  1. package/dist/anthropic-auth-plugin.js +54 -13
  2. package/dist/bundled-skills.js +37 -0
  3. package/dist/cli.js +3 -3
  4. package/dist/commands/add-dir.js +1 -1
  5. package/dist/commands/add-dir.test.js +32 -28
  6. package/dist/commands/btw.js +2 -2
  7. package/dist/commands/fork-subagent.js +177 -0
  8. package/dist/commands/fork.js +71 -29
  9. package/dist/discord-command-registration.js +7 -2
  10. package/dist/format-tables.js +197 -8
  11. package/dist/format-tables.test.js +172 -2
  12. package/dist/hrana-server.js +12 -24
  13. package/dist/interaction-handler.js +9 -1
  14. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +12 -5
  15. package/dist/kimaki-opencode-plugin.js +0 -1
  16. package/dist/message-formatting.js +3 -1
  17. package/dist/message-formatting.test.js +43 -1
  18. package/dist/message-preprocessing.js +5 -4
  19. package/dist/message-preprocessing.test.js +35 -0
  20. package/dist/onboarding-tutorial.js +6 -15
  21. package/dist/opencode.js +13 -1
  22. package/dist/orphan-opencode-sweep.test.js +80 -0
  23. package/dist/proxy-ws-preload.cjs +85 -0
  24. package/dist/session-handler/event-stream-state.js +29 -1
  25. package/dist/session-handler/event-stream-state.test.js +70 -1
  26. package/dist/store.js +1 -1
  27. package/dist/system-message.js +77 -30
  28. package/dist/system-message.test.js +88 -32
  29. package/dist/thread-message-queue.e2e.test.js +2 -2
  30. package/dist/tools.js +16 -24
  31. package/dist/voice.js +10 -1
  32. package/package.json +4 -3
  33. package/skills/batch/SKILL.md +1 -1
  34. package/skills/goke/SKILL.md +1 -1
  35. package/skills/new-skill/SKILL.md +1 -1
  36. package/skills/npm-package/SKILL.md +62 -23
  37. package/skills/profano/SKILL.md +5 -13
  38. package/skills/sigillo/SKILL.md +101 -0
  39. package/skills/spiceflow/SKILL.md +16 -2
  40. package/skills/tuistory/SKILL.md +60 -212
  41. package/skills/zele/SKILL.md +32 -124
  42. package/src/anthropic-auth-plugin.ts +68 -15
  43. package/src/cli.ts +3 -3
  44. package/src/commands/add-dir.test.ts +35 -28
  45. package/src/commands/add-dir.ts +1 -1
  46. package/src/commands/btw.ts +2 -2
  47. package/src/commands/fork-subagent.ts +263 -0
  48. package/src/commands/fork.ts +105 -40
  49. package/src/discord-command-registration.ts +7 -2
  50. package/src/format-tables.test.ts +188 -8
  51. package/src/format-tables.ts +282 -9
  52. package/src/hrana-server.ts +12 -27
  53. package/src/interaction-handler.ts +17 -1
  54. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +13 -5
  55. package/src/kimaki-opencode-plugin.ts +0 -1
  56. package/src/message-formatting.test.ts +46 -1
  57. package/src/message-formatting.ts +3 -1
  58. package/src/message-preprocessing.ts +5 -4
  59. package/src/onboarding-tutorial.ts +6 -15
  60. package/src/opencode.ts +18 -1
  61. package/src/session-handler/event-stream-state.test.ts +74 -0
  62. package/src/session-handler/event-stream-state.ts +54 -2
  63. package/src/store.ts +1 -1
  64. package/src/system-message.test.ts +103 -44
  65. package/src/system-message.ts +77 -30
  66. package/src/thread-message-queue.e2e.test.ts +2 -2
  67. package/src/tools.ts +26 -41
  68. package/src/voice.ts +11 -0
  69. package/skills/jitter/dist/jitter-utils.js +0 -620
  70. package/src/bash-tool.test.ts +0 -103
  71. package/src/bash-tool.ts +0 -287
  72. package/src/system-prompt-drift-plugin.ts +0 -354
@@ -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,21 +480,59 @@ function toClaudeCodeToolName(name) {
477
480
  */
478
481
  function sanitizeAnthropicSystemText(text, onError) {
479
482
  const startIdx = text.indexOf(OPENCODE_IDENTITY);
480
- if (startIdx === -1)
481
- return text;
482
- // Keep the marker aligned with the current OpenCode Anthropic prompt.
483
- const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
484
- if (endIdx === -1) {
485
- onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
486
- return text;
483
+ if (startIdx !== -1) {
484
+ // Main session path: strip from OpenCode identity to the Anthropic prompt marker.
485
+ // Keep the marker aligned with the current OpenCode Anthropic prompt.
486
+ const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
487
+ if (endIdx === -1) {
488
+ onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
489
+ return text;
490
+ }
491
+ return replaceBlockWithCompactEnv(text, startIdx, endIdx);
492
+ }
493
+ // Subagent path: opencode appends "You are powered by the model named ..."
494
+ // followed by an <env> block. Strip from that line through </env>.
495
+ const subagentIdx = text.indexOf(SUBAGENT_MODEL_IDENTITY);
496
+ if (subagentIdx !== -1) {
497
+ const envCloseTag = "</env>";
498
+ const envCloseIdx = text.indexOf(envCloseTag, subagentIdx);
499
+ if (envCloseIdx === -1) {
500
+ onError?.("sanitizeAnthropicSystemText: could not find </env> after subagent model identity");
501
+ return text;
502
+ }
503
+ const endIdx = envCloseIdx + envCloseTag.length;
504
+ // Skip trailing newline so the join is clean
505
+ const afterEnd = text[endIdx] === "\n" ? endIdx + 1 : endIdx;
506
+ return replaceBlockWithCompactEnv(text, subagentIdx, afterEnd);
487
507
  }
488
- // Re-inject the process working directory that was inside the stripped block.
489
- const envContext = `\n<environment>\n<cwd>${process.cwd()}</cwd>\n</environment>\n\n`;
490
- const result = text.slice(0, startIdx) +
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) {
527
+ const strippedBlock = text.slice(startIdx, endIdx);
528
+ const cwdMatch = strippedBlock.match(/Working directory:\s*(.+)/)?.[1]?.trim() ||
529
+ strippedBlock.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
530
+ const cwd = cwdMatch || process.cwd();
531
+ const envContext = `\n<environment>\n<cwd>${cwd}</cwd>\n</environment>\n` +
532
+ `Read, write, and edit files under ${cwd}.\n\n`;
533
+ return (text.slice(0, startIdx) +
491
534
  envContext +
492
- text.slice(endIdx);
493
- return result;
494
- // return result.replace(/\bopencode\b/gi, "openc0de");
535
+ text.slice(endIdx));
495
536
  }
496
537
  function mapSystemTextPart(part, onError) {
497
538
  if (typeof part === "string") {
@@ -0,0 +1,37 @@
1
+ // Bundled Kimaki skills path helpers.
2
+ // The canonical tracked skills live at the repository root in /skills.
3
+ // Build and publish scripts copy them into cli/skills so the npm package ships
4
+ // the same files. Prefer the repo-root directory during local development and
5
+ // fall back to the packaged cli/skills directory when running from npm.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ function getCliDir() {
10
+ const currentFilePath = fileURLToPath(import.meta.url);
11
+ return path.resolve(path.dirname(currentFilePath), '..');
12
+ }
13
+ export function resolvePackagedBundledSkillsDir() {
14
+ return path.join(getCliDir(), 'skills');
15
+ }
16
+ export function resolveBundledSkillsDir() {
17
+ const repoSkillsDir = path.resolve(getCliDir(), '..', 'skills');
18
+ if (fs.existsSync(repoSkillsDir)) {
19
+ return repoSkillsDir;
20
+ }
21
+ return resolvePackagedBundledSkillsDir();
22
+ }
23
+ export function listBundledSkillNames() {
24
+ try {
25
+ return fs
26
+ .readdirSync(resolveBundledSkillsDir(), { withFileTypes: true })
27
+ .filter((entry) => {
28
+ return entry.isDirectory();
29
+ })
30
+ .map((entry) => {
31
+ return entry.name;
32
+ });
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
package/dist/cli.js CHANGED
@@ -1291,11 +1291,11 @@ cli
1291
1291
  .option('--enable-skill <name>', z
1292
1292
  .array(z.string())
1293
1293
  .optional()
1294
- .describe('Whitelist a built-in skill by name. Only the listed skills are injected into the model (all others are hidden via an opencode permission.skill deny-all rule). Repeatable: pass --enable-skill multiple times. Mutually exclusive with --disable-skill. See https://github.com/remorses/kimaki/tree/main/cli/skills for available skills.'))
1294
+ .describe('Whitelist a built-in skill by name. Only the listed skills are injected into the model (all others are hidden via an opencode permission.skill deny-all rule). Repeatable: pass --enable-skill multiple times. Mutually exclusive with --disable-skill. See https://github.com/remorses/kimaki/tree/main/skills for available skills.'))
1295
1295
  .option('--disable-skill <name>', z
1296
1296
  .array(z.string())
1297
1297
  .optional()
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/cli/skills for available skills.'))
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
1300
  // Guard: only one kimaki bot process can run at a time (they share a lock
1301
1301
  // port). Running `kimaki` here would kill the already-running bot process
@@ -2788,7 +2788,7 @@ cli
2788
2788
  });
2789
2789
  });
2790
2790
  cli
2791
- .command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 30 minutes. Runs until Ctrl+C. Use tmux to run in background.')
2791
+ .command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 30 minutes. Runs until Ctrl+C. For background usage, start with bunx tuistory --help, then run it in a tuistory session.')
2792
2792
  .action(async () => {
2793
2793
  const { startScreenshare } = await import('./commands/screenshare.js');
2794
2794
  try {
@@ -71,7 +71,7 @@ export async function handleAddDirCommand({ command, }) {
71
71
  });
72
72
  return;
73
73
  }
74
- const requestedDirectory = command.options.getString('directory', true);
74
+ const requestedDirectory = command.options.getString('directory') ?? ALL_DIRECTORIES_PATTERN;
75
75
  const resolvedPattern = resolveDirectoryPermissionPattern({
76
76
  input: requestedDirectory,
77
77
  workingDirectory: resolvedDirectories.workingDirectory,
@@ -1,6 +1,7 @@
1
1
  // Tests for /add-dir permission helpers.
2
2
  import { describe, expect, test } from 'vitest';
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { buildAddDirPermissionRules, resolveDirectoryPermissionPattern, } from './add-dir.js';
6
7
  import { buildExternalDirectoryPermissionRules, buildSessionPermissions, } from '../opencode.js';
@@ -16,6 +17,10 @@ describe('resolveDirectoryPermissionPattern', () => {
16
17
  expect(result).toBe(nested.replaceAll('\\', '/'));
17
18
  });
18
19
  test('supports allowing every directory with *', () => {
20
+ expect(resolveDirectoryPermissionPattern({
21
+ input: ' * ',
22
+ workingDirectory: '/repo',
23
+ })).toBe('*');
19
24
  expect(buildAddDirPermissionRules({
20
25
  resolvedPattern: '*',
21
26
  })).toMatchInlineSnapshot(`
@@ -85,38 +90,37 @@ describe('resolveDirectoryPermissionPattern', () => {
85
90
  `);
86
91
  });
87
92
  test('pre-allows common toolchain caches under home with ~ patterns', () => {
93
+ const home = os.homedir().replaceAll('\\', '/');
88
94
  expect(buildSessionPermissions({
89
95
  directory: '/Users/me/project',
90
96
  }).filter((rule) => {
91
97
  return [
92
- '~/.cache/zig',
93
- '~/.cargo',
94
- '~/.cache/go-build',
95
- '~/go/pkg',
98
+ `${home}/.cache/zig`,
99
+ `${home}/.cargo`,
100
+ `${home}/.cache/go-build`,
101
+ `${home}/go/pkg`,
96
102
  ].includes(rule.pattern);
97
- })).toMatchInlineSnapshot(`
98
- [
99
- {
100
- "action": "allow",
101
- "pattern": "~/.cache/zig",
102
- "permission": "external_directory",
103
- },
104
- {
105
- "action": "allow",
106
- "pattern": "~/.cargo",
107
- "permission": "external_directory",
108
- },
109
- {
110
- "action": "allow",
111
- "pattern": "~/.cache/go-build",
112
- "permission": "external_directory",
113
- },
114
- {
115
- "action": "allow",
116
- "pattern": "~/go/pkg",
117
- "permission": "external_directory",
118
- },
119
- ]
120
- `);
103
+ })).toEqual([
104
+ {
105
+ permission: 'external_directory',
106
+ pattern: `${home}/.cache/zig`,
107
+ action: 'allow',
108
+ },
109
+ {
110
+ permission: 'external_directory',
111
+ pattern: `${home}/.cargo`,
112
+ action: 'allow',
113
+ },
114
+ {
115
+ permission: 'external_directory',
116
+ pattern: `${home}/.cache/go-build`,
117
+ action: 'allow',
118
+ },
119
+ {
120
+ permission: 'external_directory',
121
+ pattern: `${home}/go/pkg`,
122
+ action: 'allow',
123
+ },
124
+ ]);
121
125
  });
122
126
  });
@@ -4,10 +4,10 @@
4
4
  // dispatches the user's prompt so the forked session starts working right away.
5
5
  import { ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
6
6
  import { getThreadSession, setThreadSession } from '../database.js';
7
- import { initializeOpencodeForDirectory } from '../opencode.js';
8
7
  import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
9
8
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
10
9
  import { createLogger, LogPrefix } from '../logger.js';
10
+ import { initializeOpencodeForDirectory } from '../opencode.js';
11
11
  const logger = createLogger(LogPrefix.FORK);
12
12
  export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
13
13
  const sessionId = await getThreadSession(sourceThread.id);
@@ -52,7 +52,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
52
52
  thread,
53
53
  projectDirectory,
54
54
  sdkDirectory: projectDirectory,
55
- channelId: textChannel.id,
55
+ channelId: sourceThread.parentId || sourceThread.id,
56
56
  appId,
57
57
  });
58
58
  await runtime.enqueueIncoming({
@@ -0,0 +1,177 @@
1
+ // /fork-subagent command - Fork a subagent task session into a new thread.
2
+ import { ActionRowBuilder, MessageFlags, StringSelectMenuBuilder, ThreadAutoArchiveDuration, } from 'discord.js';
3
+ import { getSessionEventSnapshot, getThreadSession, setThreadSession, } from '../database.js';
4
+ import { resolveTextChannel, resolveWorkingDirectory, sendThreadMessage, } from '../discord-utils.js';
5
+ import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
6
+ import { initializeOpencodeForDirectory } from '../opencode.js';
7
+ import { getDerivedSubagentSessions, } from '../session-handler/event-stream-state.js';
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
+ import { getThreadChannel, parsePersistedEventRows, } from './fork.js';
10
+ const forkLogger = createLogger(LogPrefix.FORK);
11
+ function truncateLabelPart(text, maxLength) {
12
+ if (text.length <= maxLength) {
13
+ return text;
14
+ }
15
+ if (maxLength <= 1) {
16
+ return text.slice(0, maxLength);
17
+ }
18
+ return `${text.slice(0, maxLength - 1)}…`;
19
+ }
20
+ function getSubagentOptionLabel({ subagentType, description, }) {
21
+ const agent = truncateLabelPart(subagentType || 'task', 24);
22
+ const cleanedDescription = description?.trim() || 'No description';
23
+ const descriptionBudget = Math.max(1, 100 - agent.length - 3);
24
+ const truncatedDescription = truncateLabelPart(cleanedDescription, descriptionBudget);
25
+ return `${agent} · ${truncatedDescription}`;
26
+ }
27
+ export async function handleForkSubagentCommand(interaction) {
28
+ const threadChannel = getThreadChannel(interaction.channel);
29
+ if (threadChannel instanceof Error) {
30
+ await interaction.reply({
31
+ content: threadChannel.message,
32
+ flags: MessageFlags.Ephemeral,
33
+ });
34
+ return;
35
+ }
36
+ const resolved = await resolveWorkingDirectory({
37
+ channel: threadChannel,
38
+ });
39
+ if (!resolved) {
40
+ await interaction.reply({
41
+ content: 'Could not determine project directory for this channel',
42
+ flags: MessageFlags.Ephemeral,
43
+ });
44
+ return;
45
+ }
46
+ const sessionId = await getThreadSession(threadChannel.id);
47
+ if (!sessionId) {
48
+ await interaction.reply({
49
+ content: 'No active session in this thread',
50
+ flags: MessageFlags.Ephemeral,
51
+ });
52
+ return;
53
+ }
54
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
55
+ const rows = await getSessionEventSnapshot({ sessionId });
56
+ const events = parsePersistedEventRows({ rows });
57
+ const subagentSessions = getDerivedSubagentSessions({
58
+ events,
59
+ mainSessionId: sessionId,
60
+ }).slice(0, 25);
61
+ if (subagentSessions.length === 0) {
62
+ await interaction.editReply({
63
+ content: 'No subagent task sessions found in this thread',
64
+ });
65
+ return;
66
+ }
67
+ const options = subagentSessions.map((subagentSession) => ({
68
+ label: getSubagentOptionLabel({
69
+ subagentType: subagentSession.subagentType,
70
+ description: subagentSession.description,
71
+ }),
72
+ value: subagentSession.childSessionId,
73
+ description: new Date(subagentSession.timestamp).toLocaleString().slice(0, 100),
74
+ }));
75
+ const selectMenu = new StringSelectMenuBuilder()
76
+ .setCustomId(`fork_subagent_select:${sessionId}`)
77
+ .setPlaceholder('Select a subagent session to fork')
78
+ .addOptions(options);
79
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
80
+ await interaction.editReply({
81
+ content: '**Fork Subagent Session**\nSelect a subagent task session to fork into a new thread:',
82
+ components: [actionRow],
83
+ });
84
+ }
85
+ export async function handleForkSubagentSelectMenu(interaction) {
86
+ const customId = interaction.customId;
87
+ if (!customId.startsWith('fork_subagent_select:')) {
88
+ return;
89
+ }
90
+ const [, parentSessionId] = customId.split(':');
91
+ if (!parentSessionId) {
92
+ await interaction.reply({
93
+ content: 'Invalid selection data',
94
+ flags: MessageFlags.Ephemeral,
95
+ });
96
+ return;
97
+ }
98
+ const selectedSessionId = interaction.values[0];
99
+ if (!selectedSessionId) {
100
+ await interaction.reply({
101
+ content: 'No subagent session selected',
102
+ flags: MessageFlags.Ephemeral,
103
+ });
104
+ return;
105
+ }
106
+ await interaction.deferReply();
107
+ const threadChannel = getThreadChannel(interaction.channel);
108
+ if (threadChannel instanceof Error) {
109
+ await interaction.editReply(threadChannel.message);
110
+ return;
111
+ }
112
+ const resolved = await resolveWorkingDirectory({
113
+ channel: threadChannel,
114
+ });
115
+ if (!resolved) {
116
+ await interaction.editReply('Could not determine project directory for this channel');
117
+ return;
118
+ }
119
+ const rows = await getSessionEventSnapshot({ sessionId: parentSessionId });
120
+ const events = parsePersistedEventRows({ rows });
121
+ const selectedSubagent = getDerivedSubagentSessions({
122
+ events,
123
+ mainSessionId: parentSessionId,
124
+ }).find((candidate) => {
125
+ return candidate.childSessionId === selectedSessionId;
126
+ });
127
+ const getClient = await initializeOpencodeForDirectory(resolved.projectDirectory);
128
+ if (getClient instanceof Error) {
129
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`);
130
+ return;
131
+ }
132
+ const forkResponse = await getClient().session.fork({
133
+ sessionID: selectedSessionId,
134
+ });
135
+ if (!forkResponse.data) {
136
+ await interaction.editReply('Failed to fork session');
137
+ return;
138
+ }
139
+ const textChannel = await resolveTextChannel(threadChannel);
140
+ if (!textChannel) {
141
+ await interaction.editReply('Could not resolve parent text channel');
142
+ return;
143
+ }
144
+ const forkedSession = forkResponse.data;
145
+ const forkedThread = await textChannel.threads.create({
146
+ name: `Fork: ${selectedSubagent?.description || selectedSubagent?.subagentType || 'subagent session'}`.slice(0, 100),
147
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
148
+ reason: `Forked subagent session ${selectedSessionId}`,
149
+ });
150
+ await setThreadSession(forkedThread.id, forkedSession.id);
151
+ await forkedThread.members.add(interaction.user.id);
152
+ forkLogger.log(`Created forked subagent session ${forkedSession.id} in thread ${forkedThread.id} from ${selectedSessionId}`);
153
+ const agentLabel = selectedSubagent?.subagentType || 'task';
154
+ const descriptionLabel = selectedSubagent?.description || 'No description';
155
+ await sendThreadMessage(forkedThread, `**Forked subagent session created!**\nAgent: \`${agentLabel}\`\nTask: ${descriptionLabel}\nFrom: \`${selectedSessionId}\`\nNew session: \`${forkedSession.id}\``);
156
+ try {
157
+ const messagesResponse = await getClient().session.messages({
158
+ sessionID: forkedSession.id,
159
+ });
160
+ if (messagesResponse.data) {
161
+ const { chunks } = collectSessionChunks({
162
+ messages: messagesResponse.data,
163
+ limit: 30,
164
+ });
165
+ const batched = batchChunksForDiscord(chunks);
166
+ for (const batch of batched) {
167
+ await sendThreadMessage(forkedThread, batch.content);
168
+ }
169
+ }
170
+ }
171
+ catch (error) {
172
+ forkLogger.error('Error replaying forked subagent history:', error);
173
+ await sendThreadMessage(forkedThread, 'Failed to load session messages, but the session is connected and ready to continue.');
174
+ }
175
+ await sendThreadMessage(forkedThread, 'You can now continue the conversation from this point.');
176
+ await interaction.editReply(`Subagent session forked! Continue in ${forkedThread.toString()}`);
177
+ }
@@ -3,34 +3,78 @@ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectM
3
3
  import { getThreadSession, setThreadSession, setPartMessagesBatch, } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
6
- import { collectSessionChunks, batchChunksForDiscord } from '../message-formatting.js';
6
+ import { collectSessionChunks, batchChunksForDiscord, } from '../message-formatting.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
8
  import * as errore from 'errore';
9
9
  const sessionLogger = createLogger(LogPrefix.SESSION);
10
10
  const forkLogger = createLogger(LogPrefix.FORK);
11
- export async function handleForkCommand(interaction) {
12
- const channel = interaction.channel;
11
+ function isTruthy(value) {
12
+ return Boolean(value);
13
+ }
14
+ function getThreadChannelFromCommand(interaction) {
15
+ return getThreadChannel(interaction.channel);
16
+ }
17
+ function getThreadChannel(channel) {
13
18
  if (!channel) {
14
- await interaction.reply({
15
- content: 'This command can only be used in a channel',
16
- flags: MessageFlags.Ephemeral,
19
+ return new Error('This command can only be used in a channel');
20
+ }
21
+ if (channel.type !== ChannelType.PublicThread
22
+ && channel.type !== ChannelType.PrivateThread
23
+ && channel.type !== ChannelType.AnnouncementThread) {
24
+ return new Error('This command can only be used in a thread with an active session');
25
+ }
26
+ return channel;
27
+ }
28
+ function parsePersistedEventRows({ rows, }) {
29
+ return rows.flatMap((row) => {
30
+ const parsed = errore.try({
31
+ try: () => {
32
+ return JSON.parse(row.event_json);
33
+ },
34
+ catch: (error) => {
35
+ return new Error('Failed to parse persisted event JSON', {
36
+ cause: error,
37
+ });
38
+ },
17
39
  });
18
- return;
40
+ if (parsed instanceof Error) {
41
+ forkLogger.warn(`[fork] Skipping invalid persisted event row ${row.id}: ${parsed.message}`);
42
+ return [];
43
+ }
44
+ return [{
45
+ event: parsed,
46
+ timestamp: Number(row.timestamp),
47
+ eventIndex: Number(row.event_index),
48
+ }];
49
+ });
50
+ }
51
+ function truncateLabelPart(text, maxLength) {
52
+ if (text.length <= maxLength) {
53
+ return text;
19
54
  }
20
- const isThread = [
21
- ChannelType.PublicThread,
22
- ChannelType.PrivateThread,
23
- ChannelType.AnnouncementThread,
24
- ].includes(channel.type);
25
- if (!isThread) {
55
+ if (maxLength <= 1) {
56
+ return text.slice(0, maxLength);
57
+ }
58
+ return `${text.slice(0, maxLength - 1)}…`;
59
+ }
60
+ function getSubagentOptionLabel({ subagentType, description, }) {
61
+ const agent = truncateLabelPart(subagentType || 'task', 24);
62
+ const cleanedDescription = description?.trim() || 'No description';
63
+ const descriptionBudget = Math.max(1, 100 - agent.length - 3);
64
+ const truncatedDescription = truncateLabelPart(cleanedDescription, descriptionBudget);
65
+ return `${agent} · ${truncatedDescription}`;
66
+ }
67
+ export async function handleForkCommand(interaction) {
68
+ const threadChannel = getThreadChannelFromCommand(interaction);
69
+ if (threadChannel instanceof Error) {
26
70
  await interaction.reply({
27
- content: 'This command can only be used in a thread with an active session',
71
+ content: threadChannel.message,
28
72
  flags: MessageFlags.Ephemeral,
29
73
  });
30
74
  return;
31
75
  }
32
76
  const resolved = await resolveWorkingDirectory({
33
- channel: channel,
77
+ channel: threadChannel,
34
78
  });
35
79
  if (!resolved) {
36
80
  await interaction.reply({
@@ -40,7 +84,7 @@ export async function handleForkCommand(interaction) {
40
84
  return;
41
85
  }
42
86
  const { projectDirectory } = resolved;
43
- const sessionId = await getThreadSession(channel.id);
87
+ const sessionId = await getThreadSession(threadChannel.id);
44
88
  if (!sessionId) {
45
89
  await interaction.reply({
46
90
  content: 'No active session in this thread',
@@ -79,7 +123,9 @@ export async function handleForkCommand(interaction) {
79
123
  // injected by the opencode plugin — they clutter the dropdown preview.
80
124
  const options = recentMessages
81
125
  .map((m, index) => {
82
- const textPart = m.parts.find((p) => p.type === 'text' && !p.synthetic);
126
+ const textPart = m.parts.find((p) => {
127
+ return p.type === 'text' && !p.synthetic && typeof p.text === 'string';
128
+ });
83
129
  if (!textPart?.text) {
84
130
  return null;
85
131
  }
@@ -93,7 +139,7 @@ export async function handleForkCommand(interaction) {
93
139
  .slice(0, 50),
94
140
  };
95
141
  })
96
- .filter((o) => o !== null);
142
+ .filter(isTruthy);
97
143
  const selectMenu = new StringSelectMenuBuilder()
98
144
  // Discord component custom_id max length is 100 chars.
99
145
  // Avoid embedding long directory paths (or base64 of them) in the custom ID.
@@ -136,9 +182,9 @@ export async function handleForkSelectMenu(interaction) {
136
182
  return;
137
183
  }
138
184
  await interaction.deferReply();
139
- const threadChannel = interaction.channel;
140
- if (!threadChannel) {
141
- await interaction.editReply('Could not access thread channel');
185
+ const threadChannel = getThreadChannel(interaction.channel);
186
+ if (threadChannel instanceof Error) {
187
+ await interaction.editReply(threadChannel.message);
142
188
  return;
143
189
  }
144
190
  const resolved = await resolveWorkingDirectory({
@@ -164,14 +210,9 @@ export async function handleForkSelectMenu(interaction) {
164
210
  return;
165
211
  }
166
212
  const forkedSession = forkResponse.data;
167
- const parentChannel = interaction.channel;
168
- if (!parentChannel ||
169
- ![
170
- ChannelType.PublicThread,
171
- ChannelType.PrivateThread,
172
- ChannelType.AnnouncementThread,
173
- ].includes(parentChannel.type)) {
174
- await interaction.editReply('Could not access parent channel');
213
+ const parentChannel = getThreadChannel(interaction.channel);
214
+ if (parentChannel instanceof Error) {
215
+ await interaction.editReply(parentChannel.message);
175
216
  return;
176
217
  }
177
218
  const textChannel = await resolveTextChannel(parentChannel);
@@ -218,3 +259,4 @@ export async function handleForkSelectMenu(interaction) {
218
259
  await interaction.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
219
260
  }
220
261
  }
262
+ export { getThreadChannel, parsePersistedEventRows };
@@ -206,7 +206,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
206
206
  option
207
207
  .setName('directory')
208
208
  .setDescription(truncateCommandDescription('Directory to allow, resolved from the current worktree. Use * for all folders'))
209
- .setRequired(true);
209
+ .setRequired(false);
210
210
  return option;
211
211
  })
212
212
  .setDMPermission(false)
@@ -236,6 +236,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
236
236
  .setDescription(truncateCommandDescription('Fork the session from a past user message'))
237
237
  .setDMPermission(false)
238
238
  .toJSON(),
239
+ new SlashCommandBuilder()
240
+ .setName('fork-subagent')
241
+ .setDescription(truncateCommandDescription('Fork a subagent task session into a new thread'))
242
+ .setDMPermission(false)
243
+ .toJSON(),
239
244
  new SlashCommandBuilder()
240
245
  .setName('btw')
241
246
  .setDescription(truncateCommandDescription('Ask something without polluting or blocking the current session'))
@@ -255,7 +260,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
255
260
  .toJSON(),
256
261
  new SlashCommandBuilder()
257
262
  .setName('model-variant')
258
- .setDescription(truncateCommandDescription('Quickly change the thinking level variant for the current model'))
263
+ .setDescription(truncateCommandDescription('Change thinking level for current model. Tied to the model; lost when you switch models'))
259
264
  .setDMPermission(false)
260
265
  .toJSON(),
261
266
  new SlashCommandBuilder()