kimaki 0.12.0 → 0.13.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 (91) hide show
  1. package/dist/anthropic-auth-plugin.js +15 -15
  2. package/dist/anthropic-auth-state.js +1 -1
  3. package/dist/anthropic-auth-state.test.js +2 -2
  4. package/dist/btw-prefix-detection.js +13 -15
  5. package/dist/btw-prefix-detection.test.js +60 -30
  6. package/dist/channel-reference-permissions.e2e.test.js +2 -0
  7. package/dist/cli-parsing.test.js +1 -1
  8. package/dist/cli-runner.js +8 -2
  9. package/dist/cli.js +6 -1
  10. package/dist/commands/abort.js +1 -1
  11. package/dist/commands/compact.js +2 -5
  12. package/dist/commands/model-variant.js +1 -1
  13. package/dist/commands/model.js +1 -1
  14. package/dist/commands/new-worktree.js +107 -59
  15. package/dist/commands/restart-opencode-server.js +1 -1
  16. package/dist/commands/undo-redo.js +2 -2
  17. package/dist/commands/upgrade.js +1 -2
  18. package/dist/context-awareness-plugin.js +9 -4
  19. package/dist/discord-bot.js +80 -42
  20. package/dist/message-finish-field.e2e.test.js +1 -0
  21. package/dist/message-preprocessing.js +1 -1
  22. package/dist/openai-auth-plugin.js +16 -16
  23. package/dist/openai-auth-state.js +1 -1
  24. package/dist/opencode-command.js +25 -1
  25. package/dist/opencode-command.test.js +64 -2
  26. package/dist/opencode-interrupt-plugin.js +184 -329
  27. package/dist/opencode-interrupt-plugin.test.js +168 -362
  28. package/dist/opencode.js +22 -0
  29. package/dist/plugin-opencode-client.js +43 -0
  30. package/dist/queue-advanced-footer.e2e.test.js +8 -1
  31. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  32. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
  33. package/dist/session-handler/agent-utils.js +9 -9
  34. package/dist/session-handler/event-stream-state.js +3 -1
  35. package/dist/session-handler/event-stream-state.test.js +67 -1
  36. package/dist/session-handler/thread-runtime-state.js +29 -0
  37. package/dist/session-handler/thread-session-runtime.js +51 -66
  38. package/dist/store.js +1 -0
  39. package/dist/subagent-rate-limit-plugin.js +12 -12
  40. package/dist/thread-message-queue.e2e.test.js +200 -21
  41. package/dist/undo-redo.e2e.test.js +1 -0
  42. package/dist/voice.js +3 -2
  43. package/dist/worktree-lifecycle.e2e.test.js +130 -50
  44. package/package.json +7 -7
  45. package/skills/holocron/SKILL.md +594 -0
  46. package/skills/sigillo/SKILL.md +4 -4
  47. package/skills/spiceflow/SKILL.md +12 -4
  48. package/skills/tuistory/SKILL.md +38 -2
  49. package/src/anthropic-auth-plugin.ts +17 -16
  50. package/src/anthropic-auth-state.test.ts +2 -2
  51. package/src/anthropic-auth-state.ts +4 -4
  52. package/src/btw-prefix-detection.test.ts +61 -30
  53. package/src/btw-prefix-detection.ts +15 -19
  54. package/src/channel-reference-permissions.e2e.test.ts +2 -0
  55. package/src/cli-parsing.test.ts +1 -1
  56. package/src/cli-runner.ts +8 -2
  57. package/src/cli.ts +12 -1
  58. package/src/commands/abort.ts +1 -1
  59. package/src/commands/compact.ts +2 -5
  60. package/src/commands/model-variant.ts +1 -1
  61. package/src/commands/model.ts +1 -1
  62. package/src/commands/new-worktree.ts +136 -81
  63. package/src/commands/restart-opencode-server.ts +1 -1
  64. package/src/commands/undo-redo.ts +2 -2
  65. package/src/commands/upgrade.ts +1 -2
  66. package/src/context-awareness-plugin.ts +15 -8
  67. package/src/discord-bot.ts +92 -41
  68. package/src/message-finish-field.e2e.test.ts +1 -0
  69. package/src/message-preprocessing.ts +1 -1
  70. package/src/openai-auth-plugin.ts +18 -17
  71. package/src/openai-auth-state.ts +4 -4
  72. package/src/opencode-command.test.ts +81 -1
  73. package/src/opencode-command.ts +26 -1
  74. package/src/opencode-interrupt-plugin.test.ts +201 -496
  75. package/src/opencode-interrupt-plugin.ts +205 -415
  76. package/src/opencode.ts +43 -0
  77. package/src/plugin-opencode-client.ts +60 -0
  78. package/src/queue-advanced-footer.e2e.test.ts +8 -1
  79. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  80. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
  81. package/src/session-handler/agent-utils.ts +11 -11
  82. package/src/session-handler/event-stream-state.test.ts +72 -2
  83. package/src/session-handler/event-stream-state.ts +3 -1
  84. package/src/session-handler/thread-runtime-state.ts +35 -0
  85. package/src/session-handler/thread-session-runtime.ts +72 -84
  86. package/src/store.ts +8 -0
  87. package/src/subagent-rate-limit-plugin.ts +13 -12
  88. package/src/thread-message-queue.e2e.test.ts +229 -23
  89. package/src/undo-redo.e2e.test.ts +1 -0
  90. package/src/voice.ts +3 -2
  91. package/src/worktree-lifecycle.e2e.test.ts +138 -53
@@ -23,6 +23,7 @@
23
23
  * - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
24
24
  */
25
25
  import { appendToastSessionMarker } from "./plugin-logger.js";
26
+ import { createPluginClient } from "./plugin-opencode-client.js";
26
27
  import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
27
28
  import { extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
28
29
  // PKCE (Proof Key for Code Exchange) using Web Crypto API.
@@ -766,7 +767,10 @@ async function getFreshOAuth(getAuth, client) {
766
767
  pendingRefresh.delete(auth.refresh);
767
768
  });
768
769
  }
769
- const AnthropicAuthPlugin = async ({ client }) => {
770
+ const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
771
+ // Build our own v2 client. The plugin-provided ctx.client (v1) does not
772
+ // reliably make REST calls from inside the plugin process.
773
+ const client = createPluginClient({ serverUrl, directory });
770
774
  return {
771
775
  "chat.headers": async (input, output) => {
772
776
  if (input.model.providerID !== "anthropic") {
@@ -816,13 +820,11 @@ const AnthropicAuthPlugin = async ({ client }) => {
816
820
  const rewritten = rewriteRequestPayload(originalBody, (msg) => {
817
821
  client.tui
818
822
  .showToast({
819
- body: {
820
- message: appendToastSessionMarker({
821
- message: msg,
822
- sessionId,
823
- }),
824
- variant: "error",
825
- },
823
+ message: appendToastSessionMarker({
824
+ message: msg,
825
+ sessionId,
826
+ }),
827
+ variant: "error",
826
828
  })
827
829
  .catch(() => { });
828
830
  });
@@ -859,13 +861,11 @@ const AnthropicAuthPlugin = async ({ client }) => {
859
861
  // Show toast notification so Discord thread shows the rotation
860
862
  client.tui
861
863
  .showToast({
862
- body: {
863
- message: appendToastSessionMarker({
864
- message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
865
- sessionId,
866
- }),
867
- variant: "info",
868
- },
864
+ message: appendToastSessionMarker({
865
+ message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
866
+ sessionId,
867
+ }),
868
+ variant: "info",
869
869
  })
870
870
  .catch(() => { });
871
871
  const retryAuth = await getFreshOAuth(getAuth, client);
@@ -55,7 +55,7 @@ async function writeAnthropicAuthFile(auth) {
55
55
  }
56
56
  export async function setAnthropicAuth(auth, client) {
57
57
  await writeAnthropicAuthFile(auth);
58
- await client.auth.set({ path: { id: 'anthropic' }, body: auth });
58
+ await client.auth.set({ providerID: 'anthropic', auth });
59
59
  }
60
60
  // --- Current account ---
61
61
  export async function getCurrentAnthropicAccount() {
@@ -97,8 +97,8 @@ describe('rotateAnthropicAccount', () => {
97
97
  expect(authJson.anthropic?.refresh).toBe('refresh-second');
98
98
  expect(authSetCalls).toEqual([
99
99
  {
100
- path: { id: 'anthropic' },
101
- body: {
100
+ providerID: 'anthropic',
101
+ auth: {
102
102
  type: 'oauth',
103
103
  refresh: 'refresh-second',
104
104
  access: 'access-second',
@@ -1,17 +1,15 @@
1
- // Detects the raw `btw ` Discord message shortcut used to fork a side-question
2
- // thread without invoking the /btw slash command UI.
3
- export function extractBtwPrefix(content) {
4
- if (!content) {
5
- return null;
1
+ // Detects `. btw` suffix at the end of a Discord message, identical pattern
2
+ // to the queue suffix. When present the suffix is stripped and the remaining
3
+ // message is forked to a new btw thread via /btw.
4
+ //
5
+ // Supported forms:
6
+ // - punctuation + btw: ". btw", "! btw", ". btw.", "!btw."
7
+ // - btw as its own final line: "text\nbtw"
8
+ // Non-matches: "btw fix this" (start only), "hello btw" (no punctuation)
9
+ const BTW_SUFFIX_RE = /(?:[.!?,;:])\s*btw\.?\s*$|\n\s*btw\.?\s*$/i;
10
+ export function extractBtwSuffix(content) {
11
+ if (!BTW_SUFFIX_RE.test(content)) {
12
+ return { prompt: content, forceBtw: false };
6
13
  }
7
- // Match "btw" followed by whitespace or punctuation (. , : ; ! ?) then the prompt
8
- const match = content.match(/^\s*btw[.,;:!?\s]\s*([\s\S]+)$/i);
9
- if (!match) {
10
- return null;
11
- }
12
- const prompt = match[1]?.trim();
13
- if (!prompt) {
14
- return null;
15
- }
16
- return { prompt };
14
+ return { prompt: content.replace(BTW_SUFFIX_RE, '').trimEnd(), forceBtw: true };
17
15
  }
@@ -1,63 +1,93 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { extractBtwPrefix } from './btw-prefix-detection.js';
3
- describe('extractBtwPrefix', () => {
4
- test('matches lowercase prefix', () => {
5
- expect(extractBtwPrefix('btw fix this')).toMatchInlineSnapshot(`
2
+ import { extractBtwSuffix } from './btw-prefix-detection.js';
3
+ describe('extractBtwSuffix', () => {
4
+ test('matches after period', () => {
5
+ expect(extractBtwSuffix('fix the bug. btw')).toMatchInlineSnapshot(`
6
6
  {
7
- "prompt": "fix this",
7
+ "forceBtw": true,
8
+ "prompt": "fix the bug",
8
9
  }
9
10
  `);
10
11
  });
11
- test('matches uppercase prefix', () => {
12
- expect(extractBtwPrefix('BTW check this')).toMatchInlineSnapshot(`
12
+ test('matches after exclamation', () => {
13
+ expect(extractBtwSuffix('done! btw')).toMatchInlineSnapshot(`
13
14
  {
14
- "prompt": "check this",
15
+ "forceBtw": true,
16
+ "prompt": "done",
15
17
  }
16
18
  `);
17
19
  });
18
- test('keeps multiline content', () => {
19
- expect(extractBtwPrefix(' btw first line\nsecond line ')).toMatchInlineSnapshot(`
20
+ test('matches after comma', () => {
21
+ expect(extractBtwSuffix('sure, btw')).toMatchInlineSnapshot(`
20
22
  {
21
- "prompt": "first line
22
- second line",
23
+ "forceBtw": true,
24
+ "prompt": "sure",
23
25
  }
24
26
  `);
25
27
  });
26
- test('matches dot separator', () => {
27
- expect(extractBtwPrefix('btw. fix this')).toMatchInlineSnapshot(`
28
+ test('matches after newline', () => {
29
+ expect(extractBtwSuffix('fix the bug\nbtw')).toMatchInlineSnapshot(`
28
30
  {
29
- "prompt": "fix this",
31
+ "forceBtw": true,
32
+ "prompt": "fix the bug",
30
33
  }
31
34
  `);
32
35
  });
33
- test('matches comma separator', () => {
34
- expect(extractBtwPrefix('btw, fix this')).toMatchInlineSnapshot(`
36
+ test('matches with trailing dot', () => {
37
+ expect(extractBtwSuffix('fix the bug. btw.')).toMatchInlineSnapshot(`
35
38
  {
36
- "prompt": "fix this",
39
+ "forceBtw": true,
40
+ "prompt": "fix the bug",
37
41
  }
38
42
  `);
39
43
  });
40
- test('matches colon separator', () => {
41
- expect(extractBtwPrefix('btw: fix this')).toMatchInlineSnapshot(`
44
+ test('case insensitive', () => {
45
+ expect(extractBtwSuffix('done. BTW')).toMatchInlineSnapshot(`
42
46
  {
43
- "prompt": "fix this",
47
+ "forceBtw": true,
48
+ "prompt": "done",
44
49
  }
45
50
  `);
46
51
  });
47
- test('matches punctuation without trailing space', () => {
48
- expect(extractBtwPrefix('btw.fix this')).toMatchInlineSnapshot(`
52
+ test('no space between punctuation and btw', () => {
53
+ expect(extractBtwSuffix('done.btw')).toMatchInlineSnapshot(`
49
54
  {
50
- "prompt": "fix this",
55
+ "forceBtw": true,
56
+ "prompt": "done",
51
57
  }
52
58
  `);
53
59
  });
54
- test('does not match without separating whitespace', () => {
55
- expect(extractBtwPrefix('btwfix this')).toMatchInlineSnapshot(`null`);
60
+ test('does not match at start of message', () => {
61
+ expect(extractBtwSuffix('btw fix this')).toMatchInlineSnapshot(`
62
+ {
63
+ "forceBtw": false,
64
+ "prompt": "btw fix this",
65
+ }
66
+ `);
56
67
  });
57
- test('does not match mid-message', () => {
58
- expect(extractBtwPrefix('hello btw fix this')).toMatchInlineSnapshot(`null`);
68
+ test('does not match mid-message without punctuation', () => {
69
+ expect(extractBtwSuffix('hello btw')).toMatchInlineSnapshot(`
70
+ {
71
+ "forceBtw": false,
72
+ "prompt": "hello btw",
73
+ }
74
+ `);
59
75
  });
60
- test('does not match empty payload', () => {
61
- expect(extractBtwPrefix('btw ')).toMatchInlineSnapshot(`null`);
76
+ test('does not match empty content', () => {
77
+ expect(extractBtwSuffix('')).toMatchInlineSnapshot(`
78
+ {
79
+ "forceBtw": false,
80
+ "prompt": "",
81
+ }
82
+ `);
83
+ });
84
+ test('multiline message with btw at end', () => {
85
+ expect(extractBtwSuffix('first line\nsecond line. btw')).toMatchInlineSnapshot(`
86
+ {
87
+ "forceBtw": true,
88
+ "prompt": "first line
89
+ second line",
90
+ }
91
+ `);
62
92
  });
63
93
  });
@@ -71,12 +71,14 @@ describe('channel reference permissions', () => {
71
71
  --- from: assistant (TestBot)
72
72
  *using deterministic-provider/deterministic-v2*
73
73
  ⬥ reading referenced channel directory
74
+ ┣ read *allowed.txt*
74
75
  ⬥ channel-reference-read-done
75
76
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
76
77
  --- from: user (channel-reference-tester)
77
78
  Use <#200000000000001022> CHANNEL_REFERENCE_PERMISSION_MARKER followup
78
79
  --- from: assistant (TestBot)
79
80
  ⬥ reading referenced channel directory
81
+ ┣ read *allowed.txt*
80
82
  ⬥ channel-reference-read-done
81
83
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
82
84
  `);
@@ -15,7 +15,7 @@ async function parseWithGoke(argv) {
15
15
  "cli.command('multioauth anthropic remove <indexOrEmail>', 'Remove stored Anthropic account')",
16
16
  "cli.command('multioauth openai list', 'List stored OpenAI accounts')",
17
17
  "cli.command('multioauth openai remove <indexOrEmail>', 'Remove stored OpenAI account')",
18
- `const result = cli.parse(${JSON.stringify(argv)}, { run: false })`,
18
+ `const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
19
19
  'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
20
20
  ].join(';');
21
21
  const { stdout } = await execAsync(`node --input-type=module -e ${JSON.stringify(script)}`, {
@@ -111,7 +111,11 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
111
111
  const discordMaxLength = 2000;
112
112
  if (prompt.length <= discordMaxLength) {
113
113
  return (await rest.post(Routes.channelMessages(channelId), {
114
- body: { content: prompt, embeds },
114
+ body: {
115
+ content: prompt,
116
+ embeds,
117
+ allowed_mentions: { parse: store.getState().allowedMentions },
118
+ },
115
119
  }));
116
120
  }
117
121
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
@@ -158,6 +162,7 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
158
162
  content: summaryContent,
159
163
  attachments: [{ id: 0, filename: 'prompt.md' }],
160
164
  embeds,
165
+ allowed_mentions: { parse: store.getState().allowedMentions },
161
166
  }));
162
167
  const buffer = fs.readFileSync(tmpFile);
163
168
  formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
@@ -509,6 +514,7 @@ export async function ensureCommandAvailable({ name, envPathKey, installUnix, in
509
514
  }
510
515
  // Run opencode upgrade in the background so the user always has the latest version.
511
516
  // Spawn caffeinate on macOS to prevent system sleep while bot is running.
517
+ // Uses -s to also prevent sleep on lid close (AC power only, not battery).
512
518
  // Uses -w to watch the parent PID so caffeinate self-terminates if kimaki
513
519
  // exits for any reason (SIGTERM, crash, process.exit, supervisor stop).
514
520
  export function startCaffeinate() {
@@ -516,7 +522,7 @@ export function startCaffeinate() {
516
522
  return;
517
523
  }
518
524
  try {
519
- const proc = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
525
+ const proc = spawn('caffeinate', ['-s', '-w', String(process.pid)], {
520
526
  stdio: 'ignore',
521
527
  detached: false,
522
528
  });
package/dist/cli.js CHANGED
@@ -44,6 +44,10 @@ cli
44
44
  .option('--no-sentry', 'Disable Sentry error reporting')
45
45
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
46
46
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
47
+ .option('--allow-mention <type>', z
48
+ .array(z.enum(['users', 'roles', 'everyone']))
49
+ .optional()
50
+ .describe('Which mention types the bot can trigger (users, roles, everyone). Repeatable. Default: users only.'))
47
51
  .option('--enable-skill <name>', z
48
52
  .array(z.string())
49
53
  .optional()
@@ -140,6 +144,7 @@ cli
140
144
  ...(options.disableSync && { syncEnabled: false }),
141
145
  ...(enabledSkills.length > 0 && { enabledSkills }),
142
146
  ...(disabledSkills.length > 0 && { disabledSkills }),
147
+ ...(options.allowMention && { allowedMentions: options.allowMention }),
143
148
  });
144
149
  if (enabledSkills.length > 0) {
145
150
  cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
@@ -202,4 +207,4 @@ cli.use(sessionCommands);
202
207
  cli.use(maintenanceCommands);
203
208
  cli.version(getCurrentVersion());
204
209
  cli.help();
205
- cli.parse();
210
+ void cli.parse();
@@ -27,7 +27,7 @@ export async function handleAbortCommand({ command, }) {
27
27
  });
28
28
  return;
29
29
  }
30
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
30
+ await command.deferReply();
31
31
  const resolved = await resolveWorkingDirectory({
32
32
  channel: channel,
33
33
  });
@@ -1,7 +1,7 @@
1
1
  // /compact command - Trigger context compaction (summarization) for the current session.
2
2
  import { ChannelType, MessageFlags, } from 'discord.js';
3
3
  import { getThreadSession } from '../database.js';
4
- import { initializeOpencodeForDirectory, getOpencodeClient, } from '../opencode.js';
4
+ import { initializeOpencodeForDirectory, getOpencodeClient, extractSdkErrorMessage, } from '../opencode.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { createLogger, LogPrefix } from '../logger.js';
7
7
  const logger = createLogger(LogPrefix.COMPACT);
@@ -97,10 +97,7 @@ export async function handleCompactCommand({ command, }) {
97
97
  });
98
98
  if (result.error) {
99
99
  logger.error('[COMPACT] Error:', result.error);
100
- const errorData = result.error.data;
101
- const errorMessage = errorData && typeof errorData === 'object' && 'message' in errorData
102
- ? String(errorData.message || 'Unknown error')
103
- : 'Unknown error';
100
+ const errorMessage = extractSdkErrorMessage(result.error);
104
101
  await command.editReply({
105
102
  content: `Failed to compact: ${errorMessage}`,
106
103
  });
@@ -292,7 +292,7 @@ export async function handleVariantScopeSelectMenu(interaction) {
292
292
  async function applyVariant({ interaction, context, variant, scope, contextHash, }) {
293
293
  const modelId = context.modelId;
294
294
  const variantSuffix = variant ? ` (${variant})` : '';
295
- const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
295
+ const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/getting-started/model-switching) in .opencode/agent/ for one-command model switching_';
296
296
  try {
297
297
  if (scope === 'session') {
298
298
  if (!context.sessionId) {
@@ -728,7 +728,7 @@ export async function handleModelScopeSelectMenu(interaction) {
728
728
  const modelDisplay = modelId.split('/')[1] || modelId;
729
729
  const variant = context.selectedVariant ?? null;
730
730
  const variantSuffix = variant ? ` (${variant})` : '';
731
- const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
731
+ const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/getting-started/model-switching) in .opencode/agent/ for one-command model switching_';
732
732
  try {
733
733
  if (selectedScope === 'session') {
734
734
  if (!context.sessionId) {
@@ -3,12 +3,13 @@
3
3
  // Creates thread immediately, then worktree in background so user can type
4
4
  import { ChannelType, REST, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
- import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadWorktree, getThreadSession, } from '../database.js';
7
- import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
6
+ import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
7
+ import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
8
8
  import { createLogger, LogPrefix } from '../logger.js';
9
9
  import { notifyError } from '../sentry.js';
10
10
  import { createWorktreeWithSubmodules, execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
11
- import { buildExternalDirectoryPermissionRules, getOpencodeClient, initializeOpencodeForDirectory, } from '../opencode.js';
11
+ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
12
+ import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
12
13
  import { WORKTREE_PREFIX } from './merge-worktree.js';
13
14
  import * as errore from 'errore';
14
15
  const logger = createLogger(LogPrefix.WORKTREE);
@@ -187,10 +188,6 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
187
188
  threadId: thread.id,
188
189
  worktreeDirectory: worktreeResult.directory,
189
190
  });
190
- await denyPreviousCheckoutForExistingSession({
191
- threadId: thread.id,
192
- projectDirectory,
193
- });
194
191
  // React with tree emoji to mark as worktree thread
195
192
  await reactToThread({
196
193
  rest,
@@ -210,41 +207,6 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
210
207
  },
211
208
  });
212
209
  }
213
- async function denyPreviousCheckoutForExistingSession({ threadId, projectDirectory, }) {
214
- const sessionId = await getThreadSession(threadId);
215
- if (!sessionId) {
216
- return;
217
- }
218
- const initializeResult = await initializeOpencodeForDirectory(projectDirectory);
219
- if (initializeResult instanceof Error) {
220
- logger.warn(`[WORKTREE] Failed to initialize OpenCode before denying previous checkout for thread ${threadId}: ${initializeResult.message}`);
221
- return;
222
- }
223
- const client = getOpencodeClient(projectDirectory);
224
- if (!client) {
225
- logger.warn(`[WORKTREE] Missing OpenCode client for previous checkout deny update in thread ${threadId}`);
226
- return;
227
- }
228
- const updateResult = await errore.tryAsync({
229
- try: async () => {
230
- await client.session.update({
231
- sessionID: sessionId,
232
- permission: buildExternalDirectoryPermissionRules({
233
- resolvedPattern: projectDirectory.replaceAll('\\', '/'),
234
- action: 'deny',
235
- }),
236
- });
237
- },
238
- catch: (e) => new Error('Failed to deny previous checkout for existing session', {
239
- cause: e,
240
- }),
241
- });
242
- if (updateResult instanceof Error) {
243
- logger.warn(`[WORKTREE] Failed to deny previous checkout for existing session in thread ${threadId}: ${updateResult.message}`);
244
- return;
245
- }
246
- logger.log(`[WORKTREE] Denied previous checkout for existing session ${sessionId} in thread ${threadId}`);
247
- }
248
210
  async function findExistingWorktreePath({ projectDirectory, worktreeName, }) {
249
211
  const listResult = await errore.tryAsync({
250
212
  try: () => execAsync('git worktree list --porcelain', { cwd: projectDirectory }),
@@ -268,7 +230,7 @@ async function findExistingWorktreePath({ projectDirectory, worktreeName, }) {
268
230
  }
269
231
  return undefined;
270
232
  }
271
- export async function handleNewWorktreeCommand({ command, }) {
233
+ export async function handleNewWorktreeCommand({ command, appId, }) {
272
234
  await command.deferReply();
273
235
  const channel = command.channel;
274
236
  if (!channel) {
@@ -281,6 +243,7 @@ export async function handleNewWorktreeCommand({ command, }) {
281
243
  await handleWorktreeInThread({
282
244
  command,
283
245
  thread: channel,
246
+ appId,
284
247
  });
285
248
  return;
286
249
  }
@@ -351,7 +314,7 @@ export async function handleNewWorktreeCommand({ command, }) {
351
314
  const { thread, starterMessage } = result;
352
315
  await command.editReply(`Creating worktree in ${thread.toString()}`);
353
316
  // Create worktree in background (don't await)
354
- createWorktreeInBackground({
317
+ void createWorktreeInBackground({
355
318
  thread,
356
319
  starterMessage,
357
320
  worktreeName,
@@ -365,14 +328,10 @@ export async function handleNewWorktreeCommand({ command, }) {
365
328
  }
366
329
  /**
367
330
  * Handle /new-worktree when called inside an existing thread.
368
- * Attaches a worktree to the current thread, using thread name if no name provided.
331
+ * Creates a separate worktree thread, using the source thread name if no name
332
+ * is provided. The source thread stays bound to its original directory.
369
333
  */
370
- async function handleWorktreeInThread({ command, thread, }) {
371
- // Error if thread already has a worktree
372
- if (await getThreadWorktree(thread.id)) {
373
- await command.editReply('This thread already has a worktree attached.');
374
- return;
375
- }
334
+ async function handleWorktreeInThread({ command, thread, appId, }) {
376
335
  // Get worktree name from parameter or derive from thread name
377
336
  const rawName = command.options.getString('name');
378
337
  const rawBaseBranch = command.options.getString('base-branch') || undefined;
@@ -414,20 +373,109 @@ async function handleWorktreeInThread({ command, thread, }) {
414
373
  await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``);
415
374
  return;
416
375
  }
417
- // Send status message in thread
418
- const statusMessage = await thread.send({
419
- content: worktreeCreatingMessage(worktreeName),
420
- flags: SILENT_MESSAGE_FLAGS,
376
+ const textChannel = await resolveTextChannel(thread);
377
+ if (!textChannel) {
378
+ await command.editReply('Could not resolve parent text channel');
379
+ return;
380
+ }
381
+ const threadResult = await errore.tryAsync({
382
+ try: async () => {
383
+ const worktreeThread = await textChannel.threads.create({
384
+ name: `${WORKTREE_PREFIX}worktree: ${worktreeName}`.slice(0, 100),
385
+ autoArchiveDuration: 1440,
386
+ reason: `Worktree fork from thread ${thread.id}`,
387
+ });
388
+ await worktreeThread.members.add(command.user.id);
389
+ const statusMessage = await worktreeThread.send({
390
+ content: worktreeCreatingMessage(worktreeName),
391
+ flags: SILENT_MESSAGE_FLAGS,
392
+ });
393
+ return { worktreeThread, statusMessage };
394
+ },
395
+ catch: (e) => new WorktreeError('Failed to create worktree thread', { cause: e }),
421
396
  });
422
- await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`);
423
- createWorktreeInBackground({
424
- thread,
397
+ if (threadResult instanceof Error) {
398
+ await command.editReply(threadResult.message);
399
+ return;
400
+ }
401
+ const { worktreeThread, statusMessage } = threadResult;
402
+ await command.editReply(`Creating worktree in ${worktreeThread.toString()}`);
403
+ void createWorktreeInBackground({
404
+ thread: worktreeThread,
425
405
  starterMessage: statusMessage,
426
406
  worktreeName,
427
407
  projectDirectory,
428
408
  baseBranch,
429
409
  rest: command.client.rest,
430
- }).catch((e) => {
410
+ })
411
+ .then(async (result) => {
412
+ if (result instanceof Error) {
413
+ return;
414
+ }
415
+ const sourceSessionId = await getThreadSession(thread.id);
416
+ if (!sourceSessionId) {
417
+ await sendThreadMessage(worktreeThread, 'Worktree is ready. Send a message here to start a fresh session in this checkout.');
418
+ return;
419
+ }
420
+ const getClient = await initializeOpencodeForDirectory(result, {
421
+ originalRepoDirectory: projectDirectory,
422
+ channelId: parent.id,
423
+ });
424
+ if (getClient instanceof Error) {
425
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to initialize OpenCode for context reuse: ${getClient.message}`);
426
+ return;
427
+ }
428
+ const forkResponse = await errore.tryAsync(() => {
429
+ return getClient().session.fork({
430
+ sessionID: sourceSessionId,
431
+ directory: result,
432
+ });
433
+ });
434
+ if (forkResponse instanceof Error) {
435
+ logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
436
+ void notifyError(forkResponse, 'Failed to fork session into worktree');
437
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${forkResponse.message}`);
438
+ return;
439
+ }
440
+ const forkedSession = forkResponse.data;
441
+ if (!forkedSession) {
442
+ const error = new Error('OpenCode did not return a forked session');
443
+ logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', error);
444
+ void notifyError(error, 'Failed to fork session into worktree');
445
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
446
+ return;
447
+ }
448
+ const permissionResponse = await errore.tryAsync(() => {
449
+ return getClient().session.update({
450
+ sessionID: forkedSession.id,
451
+ directory: result,
452
+ permission: buildSessionPermissions({
453
+ directory: result,
454
+ originalRepoDirectory: projectDirectory,
455
+ }),
456
+ });
457
+ });
458
+ if (permissionResponse instanceof Error || permissionResponse.error) {
459
+ const error = permissionResponse instanceof Error
460
+ ? permissionResponse
461
+ : new Error('OpenCode rejected forked session permission update');
462
+ logger.error('[NEW-WORKTREE] Failed to update forked session permissions:', error);
463
+ void notifyError(error, 'Failed to update forked session permissions');
464
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to update forked session permissions: ${error.message}`);
465
+ return;
466
+ }
467
+ await setThreadSession(worktreeThread.id, forkedSession.id);
468
+ getOrCreateRuntime({
469
+ threadId: worktreeThread.id,
470
+ thread: worktreeThread,
471
+ projectDirectory,
472
+ sdkDirectory: result,
473
+ channelId: parent.id,
474
+ appId,
475
+ });
476
+ await sendThreadMessage(worktreeThread, `Reusing context from <#${thread.id}> in worktree session \`${forkedSession.id}\`.`);
477
+ })
478
+ .catch((e) => {
431
479
  logger.error('[NEW-WORKTREE] Background error:', e);
432
480
  void notifyError(e, 'Background worktree creation failed (in-thread)');
433
481
  });
@@ -46,7 +46,7 @@ export async function handleRestartOpencodeServerCommand({ command, appId, }) {
46
46
  }
47
47
  const { projectDirectory } = resolved;
48
48
  // Defer reply since restart may take a moment
49
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
49
+ await command.deferReply();
50
50
  // Dispose all runtimes for this directory/channel scope.
51
51
  // disposeRuntimesForDirectory aborts active runs, kills listeners, and
52
52
  // removes runtimes from the registry. Scoped by channelId so runtimes
@@ -58,7 +58,7 @@ export async function handleUndoCommand({ command, }) {
58
58
  });
59
59
  return;
60
60
  }
61
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
61
+ await command.deferReply();
62
62
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
63
63
  if (getClient instanceof Error) {
64
64
  await command.editReply(`Failed to undo: ${getClient.message}`);
@@ -209,7 +209,7 @@ export async function handleRedoCommand({ command, }) {
209
209
  });
210
210
  return;
211
211
  }
212
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
212
+ await command.deferReply();
213
213
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
214
214
  if (getClient instanceof Error) {
215
215
  await command.editReply(`Failed to redo: ${getClient.message}`);