kimaki 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) 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 +19 -1
  7. package/dist/commands/action-buttons.js +2 -0
  8. package/dist/commands/ask-question.js +2 -0
  9. package/dist/commands/compact.js +2 -5
  10. package/dist/commands/file-upload.js +5 -1
  11. package/dist/commands/model-variant.js +22 -17
  12. package/dist/commands/model.js +42 -14
  13. package/dist/commands/new-worktree.js +107 -59
  14. package/dist/commands/permissions.js +13 -3
  15. package/dist/config.js +8 -0
  16. package/dist/context-awareness-plugin.js +9 -4
  17. package/dist/discord-bot.js +50 -35
  18. package/dist/message-finish-field.e2e.test.js +1 -0
  19. package/dist/openai-auth-plugin.js +16 -16
  20. package/dist/openai-auth-state.js +1 -1
  21. package/dist/opencode-command.js +25 -1
  22. package/dist/opencode-command.test.js +64 -2
  23. package/dist/opencode-interrupt-plugin.js +192 -343
  24. package/dist/opencode-interrupt-plugin.test.js +168 -381
  25. package/dist/opencode.js +44 -0
  26. package/dist/plugin-opencode-client.js +43 -0
  27. package/dist/queue-advanced-action-buttons.e2e.test.js +1 -0
  28. package/dist/queue-advanced-footer.e2e.test.js +8 -1
  29. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  30. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
  31. package/dist/queue-question-select-drain.e2e.test.js +2 -0
  32. package/dist/session-handler/event-stream-state.js +3 -1
  33. package/dist/session-handler/event-stream-state.test.js +67 -1
  34. package/dist/session-handler/global-event-listener.js +179 -0
  35. package/dist/session-handler/thread-runtime-state.js +0 -1
  36. package/dist/session-handler/thread-session-runtime.js +33 -220
  37. package/dist/store.js +1 -0
  38. package/dist/subagent-rate-limit-plugin.js +12 -12
  39. package/dist/system-message.js +4 -4
  40. package/dist/system-message.test.js +5 -3
  41. package/dist/thread-message-queue.e2e.test.js +6 -22
  42. package/dist/undo-redo.e2e.test.js +1 -0
  43. package/dist/voice-message.e2e.test.js +1 -1
  44. package/dist/voice.js +3 -2
  45. package/dist/worktree-lifecycle.e2e.test.js +130 -50
  46. package/package.json +6 -6
  47. package/skills/holocron/SKILL.md +192 -14
  48. package/skills/new-skill/SKILL.md +7 -7
  49. package/skills/sigillo/SKILL.md +4 -4
  50. package/skills/spiceflow/SKILL.md +12 -4
  51. package/skills/strada/SKILL.md +236 -0
  52. package/skills/termcast/SKILL.md +2 -0
  53. package/skills/tuistory/SKILL.md +38 -2
  54. package/src/anthropic-auth-plugin.ts +17 -16
  55. package/src/anthropic-auth-state.test.ts +2 -2
  56. package/src/anthropic-auth-state.ts +4 -4
  57. package/src/channel-reference-permissions.e2e.test.ts +2 -0
  58. package/src/cli-parsing.test.ts +1 -1
  59. package/src/cli.ts +25 -1
  60. package/src/commands/action-buttons.ts +6 -0
  61. package/src/commands/ask-question.ts +6 -0
  62. package/src/commands/compact.ts +2 -5
  63. package/src/commands/file-upload.ts +9 -1
  64. package/src/commands/model-variant.ts +22 -17
  65. package/src/commands/model.ts +53 -15
  66. package/src/commands/new-worktree.ts +136 -81
  67. package/src/commands/permissions.ts +14 -3
  68. package/src/config.ts +9 -0
  69. package/src/context-awareness-plugin.ts +15 -8
  70. package/src/discord-bot.ts +63 -37
  71. package/src/message-finish-field.e2e.test.ts +1 -0
  72. package/src/openai-auth-plugin.ts +18 -17
  73. package/src/openai-auth-state.ts +4 -4
  74. package/src/opencode-command.test.ts +81 -1
  75. package/src/opencode-command.ts +26 -1
  76. package/src/opencode-interrupt-plugin.test.ts +201 -520
  77. package/src/opencode-interrupt-plugin.ts +213 -429
  78. package/src/opencode.ts +67 -0
  79. package/src/plugin-opencode-client.ts +60 -0
  80. package/src/queue-advanced-action-buttons.e2e.test.ts +1 -0
  81. package/src/queue-advanced-footer.e2e.test.ts +8 -1
  82. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  83. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
  84. package/src/queue-question-select-drain.e2e.test.ts +2 -0
  85. package/src/session-handler/event-stream-state.test.ts +72 -2
  86. package/src/session-handler/event-stream-state.ts +3 -1
  87. package/src/session-handler/global-event-listener.ts +224 -0
  88. package/src/session-handler/thread-runtime-state.ts +0 -8
  89. package/src/session-handler/thread-session-runtime.ts +41 -276
  90. package/src/store.ts +10 -0
  91. package/src/subagent-rate-limit-plugin.ts +13 -12
  92. package/src/system-message.test.ts +5 -3
  93. package/src/system-message.ts +8 -4
  94. package/src/thread-message-queue.e2e.test.ts +6 -24
  95. package/src/undo-redo.e2e.test.ts +1 -0
  96. package/src/voice-message.e2e.test.ts +1 -1
  97. package/src/voice.ts +3 -2
  98. package/src/worktree-lifecycle.e2e.test.ts +138 -53
@@ -4,6 +4,7 @@
4
4
  import { ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags, } from 'discord.js';
5
5
  import crypto from 'node:crypto';
6
6
  import { getOpencodeClient } from '../opencode.js';
7
+ import { getPermissionTimeoutMs } from '../config.js';
7
8
  import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
8
9
  import { createLogger, LogPrefix } from '../logger.js';
9
10
  const logger = createLogger(LogPrefix.PERMISSIONS);
@@ -59,7 +60,7 @@ export function compactPermissionPatterns(patterns) {
59
60
  }
60
61
  // Store pending permission contexts by hash.
61
62
  // TTL prevents unbounded growth if user never clicks a permission button.
62
- const PERMISSION_CONTEXT_TTL_MS = 10 * 60 * 1000;
63
+ // Configurable via --permission-timeout-minutes CLI flag (default: 10 minutes).
63
64
  export const pendingPermissionContexts = new Map();
64
65
  // Atomic take: removes context from Map and returns it. Only the first caller
65
66
  // (TTL expiry or button click) wins, preventing duplicate permission replies.
@@ -90,6 +91,9 @@ export async function showPermissionButtons({ thread, permission, directory, per
90
91
  // Auto-reject on TTL expiry so the OpenCode session doesn't hang forever
91
92
  // waiting for a permission reply that will never come. Uses atomic take
92
93
  // so only one of TTL-expiry or button-click can win.
94
+ // With continue_loop_on_deny enabled in opencode config, the model sees
95
+ // this as a tool error and continues (tries alternatives or explains).
96
+ const ttlMs = getPermissionTimeoutMs();
93
97
  setTimeout(async () => {
94
98
  const ctx = takePendingPermissionContext(contextHash);
95
99
  if (!ctx) {
@@ -100,21 +104,27 @@ export async function showPermissionButtons({ thread, permission, directory, per
100
104
  const requestIds = ctx.requestIds.length > 0
101
105
  ? ctx.requestIds
102
106
  : [ctx.permission.id];
107
+ const userId = ctx.thread.ownerId;
108
+ const timeoutFeedback = `Permission timed out — the user did not respond. They are probably away and not watching the session. ` +
109
+ `If this tool call is necessary for the core goal of this session, stop and mention the user with <@${userId}> asking them to grant permission. ` +
110
+ `If not, continue normally — work around it, skip the tool, or use an alternative approach.`;
103
111
  await Promise.all(requestIds.map((requestId) => {
104
112
  return client.permission.reply({
105
113
  requestID: requestId,
106
114
  directory: ctx.permissionDirectory,
107
115
  reply: 'reject',
116
+ message: timeoutFeedback,
108
117
  });
109
118
  })).catch((error) => {
110
119
  logger.error('Failed to auto-reject expired permission:', error);
111
120
  });
121
+ const minutes = Math.round(ttlMs / 60_000);
112
122
  updatePermissionMessage({
113
123
  context: ctx,
114
- status: '_Permission expired after 10 minutes and was rejected._',
124
+ status: `_Permission expired after ${minutes} minute${minutes !== 1 ? 's' : ''} and was rejected._`,
115
125
  });
116
126
  }
117
- }, PERMISSION_CONTEXT_TTL_MS).unref();
127
+ }, ttlMs).unref();
118
128
  const patternStr = compactPermissionPatterns(permission.patterns).join(', ');
119
129
  // Build 3 buttons for permission actions
120
130
  const acceptButton = new ButtonBuilder()
package/dist/config.js CHANGED
@@ -62,6 +62,14 @@ export function setProjectsDir(dir) {
62
62
  }
63
63
  store.setState({ projectsDir: resolvedDir });
64
64
  }
65
+ /**
66
+ * Get the permission button timeout in milliseconds.
67
+ * How long permission buttons remain active before auto-rejecting.
68
+ * Defaults to 10 minutes (600000ms).
69
+ */
70
+ export function getPermissionTimeoutMs() {
71
+ return store.getState().permissionTimeoutMs;
72
+ }
65
73
  const DEFAULT_LOCK_PORT = 29988;
66
74
  /**
67
75
  * Derive a lock port from the data directory path.
@@ -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
@@ -569,7 +569,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
569
569
  });
570
570
  // Notify when a voice message was queued instead of sent immediately
571
571
  if (enqueueResult.queued && enqueueResult.position) {
572
- await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}`);
572
+ await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update it in queue`);
573
573
  }
574
574
  }
575
575
  if (channel.type === ChannelType.GuildText) {
@@ -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,
@@ -780,8 +777,16 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
780
777
  // If the edit removed the queue suffix, remove the item from the queue.
781
778
  // If the suffix is still present, update the prompt.
782
779
  const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
783
- if (result.found) {
784
- discordLogger.log(`[MESSAGE_EDIT] ${result.removed ? 'Removed' : 'Updated'} queued message ${message.id} in thread ${channel.id}`);
780
+ if (result.found && channel.isThread()) {
781
+ const displayName = message.member?.displayName ?? message.author.displayName;
782
+ if (result.removed) {
783
+ discordLogger.log(`[MESSAGE_EDIT] Removed queued message ${message.id} in thread ${channel.id}`);
784
+ await sendThreadMessage(channel, `⬦ **${displayName}** removed message from queue`);
785
+ }
786
+ else {
787
+ discordLogger.log(`[MESSAGE_EDIT] Updated queued message ${message.id} in thread ${channel.id}`);
788
+ await sendThreadMessage(channel, `⬦ **${displayName}** edited queued message`);
789
+ }
785
790
  }
786
791
  }
787
792
  catch (error) {
@@ -925,11 +930,24 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
925
930
  }
926
931
  discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
927
932
  const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
933
+ const sessionDirectory = await (async () => {
934
+ if (cwdDirectory) {
935
+ return cwdDirectory;
936
+ }
937
+ if (!worktreePromise) {
938
+ return projectDirectory;
939
+ }
940
+ const result = await worktreePromise;
941
+ if (result instanceof Error) {
942
+ return projectDirectory;
943
+ }
944
+ return result;
945
+ })();
928
946
  const runtime = getOrCreateRuntime({
929
947
  threadId: thread.id,
930
948
  thread,
931
949
  projectDirectory,
932
- sdkDirectory: projectDirectory,
950
+ sdkDirectory: sessionDirectory,
933
951
  channelId: parent.id,
934
952
  appId: currentAppId,
935
953
  });
@@ -950,23 +968,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
950
968
  }
951
969
  : undefined,
952
970
  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
971
  const permissionRules = await getChannelReferencePermissionRules({
971
972
  message: starterMessage,
972
973
  });
@@ -1017,6 +1018,20 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1017
1018
  startHeapMonitor();
1018
1019
  const stopTaskRunner = startTaskRunner({ token });
1019
1020
  const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
1021
+ // Prevent discord.js from permanently killing the REST token on 401.
1022
+ // @discordjs/rest calls setToken(null) whenever it receives a 401 response.
1023
+ // The gateway proxy now returns 503 for stale-DB rejections (not 401), but
1024
+ // this guard stays as defense-in-depth for any other transient 401 source.
1025
+ // Allows null through when Client.destroy() is running (it sets client.token
1026
+ // = null before calling rest.setToken(null)).
1027
+ const originalSetToken = discordClient.rest.setToken.bind(discordClient.rest);
1028
+ discordClient.rest.setToken = (newToken) => {
1029
+ if (!newToken && discordClient.token !== null) {
1030
+ discordLogger.warn('[REST] Blocked token nullification from 401 response');
1031
+ return discordClient.rest;
1032
+ }
1033
+ return originalSetToken(newToken);
1034
+ };
1020
1035
  const handleShutdown = async (signal, { skipExit = false } = {}) => {
1021
1036
  discordLogger.log(`Received ${signal}, cleaning up...`);
1022
1037
  if (global.shuttingDown) {
@@ -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 = [
@@ -1,6 +1,9 @@
1
1
  // Regression tests for Windows OpenCode command resolution and spawn args.
2
- import { describe, expect, test } from 'vitest';
3
- import { getSpawnCommandAndArgs, selectResolvedCommand, splitCommandLookupOutput, } from './opencode-command.js';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
+ import { ensureKimakiCommandShim, getSpawnCommandAndArgs, sanitizeShimExecArgv, selectResolvedCommand, splitCommandLookupOutput, } from './opencode-command.js';
4
7
  describe('splitCommandLookupOutput', () => {
5
8
  test('splits windows command lookup output into trimmed lines', () => {
6
9
  expect(splitCommandLookupOutput('C:\\Program Files\\nodejs\\opencode\r\nC:\\Program Files\\nodejs\\opencode.cmd\r\n')).toEqual([
@@ -46,3 +49,62 @@ describe('getSpawnCommandAndArgs', () => {
46
49
  });
47
50
  });
48
51
  });
52
+ describe('sanitizeShimExecArgv', () => {
53
+ test('strips --env-file=value single-arg form', () => {
54
+ expect(sanitizeShimExecArgv([
55
+ '--require',
56
+ '/abs/tsx/preflight.cjs',
57
+ '--env-file=.env',
58
+ '--import',
59
+ 'file:///abs/tsx/loader.mjs',
60
+ ])).toEqual([
61
+ '--require',
62
+ '/abs/tsx/preflight.cjs',
63
+ '--import',
64
+ 'file:///abs/tsx/loader.mjs',
65
+ ]);
66
+ });
67
+ test('strips --env-file value two-arg form and its value', () => {
68
+ expect(sanitizeShimExecArgv(['--env-file', '.env', '--require', '/abs/preflight.cjs'])).toEqual(['--require', '/abs/preflight.cjs']);
69
+ });
70
+ test('strips --env-file-if-exists in both forms', () => {
71
+ expect(sanitizeShimExecArgv([
72
+ '--env-file-if-exists=.env',
73
+ '--env-file-if-exists',
74
+ '/abs/.env',
75
+ '--enable-source-maps',
76
+ ])).toEqual(['--enable-source-maps']);
77
+ });
78
+ test('leaves unrelated flags untouched', () => {
79
+ expect(sanitizeShimExecArgv(['--enable-source-maps', '--max-old-space-size=4096'])).toEqual(['--enable-source-maps', '--max-old-space-size=4096']);
80
+ });
81
+ });
82
+ describe('ensureKimakiCommandShim', () => {
83
+ let tempDir;
84
+ beforeEach(() => {
85
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-shim-test-'));
86
+ });
87
+ afterEach(() => {
88
+ fs.rmSync(tempDir, { recursive: true, force: true });
89
+ });
90
+ test('generated posix shim does not contain a relative --env-file flag', () => {
91
+ const result = ensureKimakiCommandShim({
92
+ dataDir: tempDir,
93
+ execPath: '/usr/bin/node',
94
+ execArgv: [
95
+ '--require',
96
+ '/abs/tsx/preflight.cjs',
97
+ '--env-file=.env',
98
+ '--import',
99
+ 'file:///abs/tsx/loader.mjs',
100
+ ],
101
+ entryScript: '/abs/cli/src/cli',
102
+ platform: 'linux',
103
+ });
104
+ expect(result).not.toBeInstanceOf(Error);
105
+ const shimContent = fs.readFileSync(path.join(tempDir, 'bin', 'kimaki'), 'utf8');
106
+ expect(shimContent).not.toContain('--env-file');
107
+ expect(shimContent).toContain('/abs/tsx/preflight.cjs');
108
+ expect(shimContent).toContain('/abs/cli/src/cli');
109
+ });
110
+ });