kimaki 0.13.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 (69) 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/channel-reference-permissions.e2e.test.js +2 -0
  5. package/dist/cli-parsing.test.js +1 -1
  6. package/dist/cli.js +1 -1
  7. package/dist/commands/compact.js +2 -5
  8. package/dist/commands/model-variant.js +1 -1
  9. package/dist/commands/model.js +1 -1
  10. package/dist/commands/new-worktree.js +107 -59
  11. package/dist/context-awareness-plugin.js +9 -4
  12. package/dist/discord-bot.js +25 -32
  13. package/dist/message-finish-field.e2e.test.js +1 -0
  14. package/dist/openai-auth-plugin.js +16 -16
  15. package/dist/openai-auth-state.js +1 -1
  16. package/dist/opencode-command.js +25 -1
  17. package/dist/opencode-command.test.js +64 -2
  18. package/dist/opencode-interrupt-plugin.js +184 -341
  19. package/dist/opencode-interrupt-plugin.test.js +168 -381
  20. package/dist/opencode.js +22 -0
  21. package/dist/plugin-opencode-client.js +43 -0
  22. package/dist/queue-advanced-footer.e2e.test.js +8 -1
  23. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  24. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
  25. package/dist/session-handler/event-stream-state.js +3 -1
  26. package/dist/session-handler/event-stream-state.test.js +67 -1
  27. package/dist/session-handler/thread-session-runtime.js +11 -60
  28. package/dist/subagent-rate-limit-plugin.js +12 -12
  29. package/dist/thread-message-queue.e2e.test.js +2 -20
  30. package/dist/undo-redo.e2e.test.js +1 -0
  31. package/dist/voice.js +3 -2
  32. package/dist/worktree-lifecycle.e2e.test.js +130 -50
  33. package/package.json +7 -7
  34. package/skills/holocron/SKILL.md +176 -14
  35. package/skills/sigillo/SKILL.md +4 -4
  36. package/skills/spiceflow/SKILL.md +12 -4
  37. package/skills/tuistory/SKILL.md +38 -2
  38. package/src/anthropic-auth-plugin.ts +17 -16
  39. package/src/anthropic-auth-state.test.ts +2 -2
  40. package/src/anthropic-auth-state.ts +4 -4
  41. package/src/channel-reference-permissions.e2e.test.ts +2 -0
  42. package/src/cli-parsing.test.ts +1 -1
  43. package/src/cli.ts +1 -1
  44. package/src/commands/compact.ts +2 -5
  45. package/src/commands/model-variant.ts +1 -1
  46. package/src/commands/model.ts +1 -1
  47. package/src/commands/new-worktree.ts +136 -81
  48. package/src/context-awareness-plugin.ts +15 -8
  49. package/src/discord-bot.ts +27 -32
  50. package/src/message-finish-field.e2e.test.ts +1 -0
  51. package/src/openai-auth-plugin.ts +18 -17
  52. package/src/openai-auth-state.ts +4 -4
  53. package/src/opencode-command.test.ts +81 -1
  54. package/src/opencode-command.ts +26 -1
  55. package/src/opencode-interrupt-plugin.test.ts +201 -520
  56. package/src/opencode-interrupt-plugin.ts +206 -428
  57. package/src/opencode.ts +43 -0
  58. package/src/plugin-opencode-client.ts +60 -0
  59. package/src/queue-advanced-footer.e2e.test.ts +8 -1
  60. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  61. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
  62. package/src/session-handler/event-stream-state.test.ts +72 -2
  63. package/src/session-handler/event-stream-state.ts +3 -1
  64. package/src/session-handler/thread-session-runtime.ts +16 -78
  65. package/src/subagent-rate-limit-plugin.ts +13 -12
  66. package/src/thread-message-queue.e2e.test.ts +2 -22
  67. package/src/undo-redo.e2e.test.ts +1 -0
  68. package/src/voice.ts +3 -2
  69. 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',
@@ -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)}`, {
package/dist/cli.js CHANGED
@@ -207,4 +207,4 @@ cli.use(sessionCommands);
207
207
  cli.use(maintenanceCommands);
208
208
  cli.version(getCurrentVersion());
209
209
  cli.help();
210
- cli.parse();
210
+ void cli.parse();
@@ -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/docs/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/docs/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
  });
@@ -18,6 +18,7 @@ import crypto from 'node:crypto';
18
18
  import * as errore from 'errore';
19
19
  import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath, } from './plugin-logger.js';
20
20
  import { setDataDir } from './config.js';
21
+ import { createPluginClient } from './plugin-opencode-client.js';
21
22
  import { initSentry, notifyError } from './sentry.js';
22
23
  import { execAsync } from './exec-async.js';
23
24
  import { ONBOARDING_TUTORIAL_INSTRUCTIONS, TUTORIAL_WELCOME_TEXT, } from './onboarding-tutorial.js';
@@ -145,7 +146,7 @@ async function resolveGitState({ directory, }) {
145
146
  async function resolveSessionDirectory({ client, sessionID, state, }) {
146
147
  const previousDirectory = state.resolvedDirectory;
147
148
  const result = await errore.tryAsync(() => {
148
- return client.session.get({ path: { id: sessionID } });
149
+ return client.session.get({ sessionID });
149
150
  });
150
151
  if (result instanceof Error || !result.data?.directory) {
151
152
  return {
@@ -160,13 +161,16 @@ async function resolveSessionDirectory({ client, sessionID, state, }) {
160
161
  };
161
162
  }
162
163
  // ── Plugin ───────────────────────────────────────────────────────
163
- const contextAwarenessPlugin = async ({ directory, client }) => {
164
+ const contextAwarenessPlugin = async ({ directory, serverUrl }) => {
164
165
  initSentry();
165
166
  const dataDir = process.env.KIMAKI_DATA_DIR;
166
167
  if (dataDir) {
167
168
  setDataDir(dataDir);
168
169
  setPluginLogFilePath(dataDir);
169
170
  }
171
+ // Build our own v2 client. The plugin-provided ctx.client (v1) does not
172
+ // reliably make REST calls from inside the plugin process.
173
+ const client = createPluginClient({ serverUrl, directory });
170
174
  // Single Map for all per-session state. One entry per session, one
171
175
  // delete on cleanup — no parallel Maps that can drift out of sync.
172
176
  const sessions = new Map();
@@ -225,8 +229,9 @@ const contextAwarenessPlugin = async ({ directory, client }) => {
225
229
  const messageID = first.messageID;
226
230
  const latestAssistantMessageResult = await errore.tryAsync(() => {
227
231
  return client.session.messages({
228
- path: { id: sessionID },
229
- query: { directory, limit: 20 },
232
+ sessionID,
233
+ directory,
234
+ limit: 20,
230
235
  });
231
236
  });
232
237
  const latestAssistantMessage = latestAssistantMessageResult instanceof Error
@@ -687,11 +687,21 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
687
687
  rest: discordClient.rest,
688
688
  });
689
689
  }
690
+ const sessionDirectory = await (async () => {
691
+ if (!worktreePromise) {
692
+ return projectDirectory;
693
+ }
694
+ const result = await worktreePromise;
695
+ if (result instanceof Error) {
696
+ return projectDirectory;
697
+ }
698
+ return result;
699
+ })();
690
700
  const channelRuntime = getOrCreateRuntime({
691
701
  threadId: thread.id,
692
702
  thread,
693
703
  projectDirectory,
694
- sdkDirectory: projectDirectory,
704
+ sdkDirectory: sessionDirectory,
695
705
  channelId: channel.id,
696
706
  appId: currentAppId,
697
707
  });
@@ -703,19 +713,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
703
713
  sourceThreadId: thread.id,
704
714
  appId: currentAppId,
705
715
  preprocess: async () => {
706
- // Wait for worktree creation + install before preprocessing.
707
- // Follow-up messages queue behind this in the preprocess chain.
708
- let sessionDirectory = projectDirectory;
709
- if (worktreePromise) {
710
- const result = await worktreePromise;
711
- if (!(result instanceof Error)) {
712
- sessionDirectory = result;
713
- channelRuntime.handleDirectoryChanged({
714
- oldDirectory: projectDirectory,
715
- newDirectory: sessionDirectory,
716
- });
717
- }
718
- }
719
716
  return preprocessNewThreadMessage({
720
717
  message,
721
718
  thread,
@@ -925,11 +922,24 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
925
922
  }
926
923
  discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
927
924
  const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
925
+ const sessionDirectory = await (async () => {
926
+ if (cwdDirectory) {
927
+ return cwdDirectory;
928
+ }
929
+ if (!worktreePromise) {
930
+ return projectDirectory;
931
+ }
932
+ const result = await worktreePromise;
933
+ if (result instanceof Error) {
934
+ return projectDirectory;
935
+ }
936
+ return result;
937
+ })();
928
938
  const runtime = getOrCreateRuntime({
929
939
  threadId: thread.id,
930
940
  thread,
931
941
  projectDirectory,
932
- sdkDirectory: projectDirectory,
942
+ sdkDirectory: sessionDirectory,
933
943
  channelId: parent.id,
934
944
  appId: currentAppId,
935
945
  });
@@ -950,23 +960,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
950
960
  }
951
961
  : undefined,
952
962
  preprocess: async () => {
953
- // Wait for worktree creation + install before starting session.
954
- if (worktreePromise) {
955
- const result = await worktreePromise;
956
- if (!(result instanceof Error)) {
957
- runtime.handleDirectoryChanged({
958
- oldDirectory: projectDirectory,
959
- newDirectory: result,
960
- });
961
- }
962
- }
963
- // --cwd: switch sdkDirectory to the existing worktree path
964
- if (cwdDirectory) {
965
- runtime.handleDirectoryChanged({
966
- oldDirectory: projectDirectory,
967
- newDirectory: cwdDirectory,
968
- });
969
- }
970
963
  const permissionRules = await getChannelReferencePermissionRules({
971
964
  message: starterMessage,
972
965
  });
@@ -150,6 +150,7 @@ test('tool-call step has finish="tool-calls", follow-up has finish="stop"', asyn
150
150
  "partTypes": [
151
151
  "step-start",
152
152
  "text",
153
+ "tool",
153
154
  "step-finish",
154
155
  ],
155
156
  },
@@ -14,6 +14,7 @@
14
14
  * Account management is done via `kimaki multioauth openai` CLI commands.
15
15
  */
16
16
  import { createPluginLogger, appendToastSessionMarker } from './plugin-logger.js';
17
+ import { createPluginClient } from './plugin-opencode-client.js';
17
18
  import { isRateLimitRetryMessage, isTokenRefreshError, isOAuthStored, readJson, authFilePath } from './oauth-rotation-shared.js';
18
19
  import { detectAndRememberNewOpenAIAccount, loadOpenAIAccountStore, rotateOpenAIAccount, } from './openai-auth-state.js';
19
20
  const log = createPluginLogger('openai-rotation');
@@ -30,7 +31,7 @@ function isRetryStatusEvent(event) {
30
31
  // the last message in the session to find the model.
31
32
  async function isOpenAISession(client, sessionID) {
32
33
  try {
33
- const res = await client.session.messages({ path: { id: sessionID } });
34
+ const res = await client.session.messages({ sessionID });
34
35
  const lastMessage = res.data?.filter((m) => m.info).at(-1)?.info;
35
36
  if (!lastMessage)
36
37
  return false;
@@ -45,8 +46,11 @@ async function isOpenAISession(client, sessionID) {
45
46
  // Throttle login detection to avoid spamming auth.json reads
46
47
  let lastLoginCheckMs = 0;
47
48
  const LOGIN_CHECK_INTERVAL_MS = 30_000;
48
- const openaiRotationPlugin = async ({ client }) => {
49
+ const openaiRotationPlugin = async ({ serverUrl, directory }) => {
49
50
  log.info('OpenAI rotation plugin loaded');
51
+ // Build our own v2 client. The plugin-provided ctx.client (v1) does not
52
+ // reliably make REST calls from inside the plugin process.
53
+ const client = createPluginClient({ serverUrl, directory });
50
54
  return {
51
55
  'chat.headers': async (input, output) => {
52
56
  if (input.model.providerID !== 'openai')
@@ -69,13 +73,11 @@ const openaiRotationPlugin = async ({ client }) => {
69
73
  const count = store?.accounts.length ?? 1;
70
74
  client.tui
71
75
  .showToast({
72
- body: {
73
- message: appendToastSessionMarker({
74
- message: `OpenAI account ${label} added to rotation pool (${count} account${count === 1 ? '' : 's'})`,
75
- sessionId: event.properties.sessionID,
76
- }),
77
- variant: 'info',
78
- },
76
+ message: appendToastSessionMarker({
77
+ message: `OpenAI account ${label} added to rotation pool (${count} account${count === 1 ? '' : 's'})`,
78
+ sessionId: event.properties.sessionID,
79
+ }),
80
+ variant: 'info',
79
81
  })
80
82
  .catch(() => { });
81
83
  }
@@ -106,13 +108,11 @@ const openaiRotationPlugin = async ({ client }) => {
106
108
  if (result) {
107
109
  client.tui
108
110
  .showToast({
109
- body: {
110
- message: appendToastSessionMarker({
111
- message: `Switching OpenAI from ${result.fromLabel} to ${result.toLabel}`,
112
- sessionId: sessionID,
113
- }),
114
- variant: 'info',
115
- },
111
+ message: appendToastSessionMarker({
112
+ message: `Switching OpenAI from ${result.fromLabel} to ${result.toLabel}`,
113
+ sessionId: sessionID,
114
+ }),
115
+ variant: 'info',
116
116
  })
117
117
  .catch(() => { });
118
118
  }
@@ -92,7 +92,7 @@ async function writeOpenAIAuthFile(auth) {
92
92
  }
93
93
  export async function setOpenAIAuth(auth, client) {
94
94
  await writeOpenAIAuthFile(auth);
95
- await client.auth.set({ path: { id: 'openai' }, body: auth });
95
+ await client.auth.set({ providerID: 'openai', auth });
96
96
  }
97
97
  // --- Remember new login ---
98
98
  export async function rememberOpenAIOAuth(auth, identity) {
@@ -62,12 +62,36 @@ export function getSpawnCommandAndArgs({ resolvedCommand, baseArgs, platform, })
62
62
  windowsVerbatimArguments: true,
63
63
  };
64
64
  }
65
+ // Remove flags from the parent process's execArgv that must not leak into the
66
+ // relocatable kimaki shim. The shim runs from arbitrary working directories
67
+ // (it is on PATH for opencode child processes), so a relative `--env-file=.env`
68
+ // would make node abort with ".env: not found" whenever the cwd has no .env.
69
+ // The shim does not need to re-load env files at all: the env vars the bot
70
+ // cares about are already in the inherited process environment. We strip both
71
+ // `--env-file`/`--env-file-if-exists` forms: `--env-file=value` (single arg)
72
+ // and `--env-file value` (two args).
73
+ export function sanitizeShimExecArgv(execArgv) {
74
+ const sanitized = [];
75
+ for (let index = 0; index < execArgv.length; index++) {
76
+ const arg = execArgv[index];
77
+ if (arg === '--env-file' || arg === '--env-file-if-exists') {
78
+ // Skip this flag and its separate value argument, if present.
79
+ index++;
80
+ continue;
81
+ }
82
+ if (arg.startsWith('--env-file=') || arg.startsWith('--env-file-if-exists=')) {
83
+ continue;
84
+ }
85
+ sanitized.push(arg);
86
+ }
87
+ return sanitized;
88
+ }
65
89
  export function ensureKimakiCommandShim({ dataDir, execPath, execArgv, entryScript, platform, }) {
66
90
  const effectivePlatform = platform || process.platform;
67
91
  const shimDirectory = path.join(dataDir, 'bin');
68
92
  try {
69
93
  fs.mkdirSync(shimDirectory, { recursive: true });
70
- const launcherArgs = [...execArgv, entryScript];
94
+ const launcherArgs = [...sanitizeShimExecArgv(execArgv), entryScript];
71
95
  if (effectivePlatform === 'win32') {
72
96
  const shimPath = path.join(shimDirectory, 'kimaki.cmd');
73
97
  const shimContent = [