kimaki 0.25.0 → 0.26.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 (70) hide show
  1. package/dist/agent-model.e2e.test.js +135 -3
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/cli-commands/send.js +13 -3
  4. package/dist/cli-commands/task.js +26 -7
  5. package/dist/cli-runner.js +66 -70
  6. package/dist/cli-runner.test.js +10 -1
  7. package/dist/commands/agent.js +87 -74
  8. package/dist/commands/model.js +18 -0
  9. package/dist/commands/multioauth.js +169 -1
  10. package/dist/commands/tasks.js +6 -0
  11. package/dist/commands/unset-model.js +5 -20
  12. package/dist/database.js +65 -0
  13. package/dist/discord-bot.js +7 -1
  14. package/dist/ipc-tools-plugin.js +4 -3
  15. package/dist/kimaki-opencode-plugin.js +2 -0
  16. package/dist/message-formatting.js +84 -5
  17. package/dist/message-formatting.test.js +160 -2
  18. package/dist/oauth-rotation-shared.js +7 -1
  19. package/dist/openai-auth-state.js +20 -0
  20. package/dist/schema.js +19 -0
  21. package/dist/session-handler/event-stream-state.js +147 -0
  22. package/dist/session-handler/event-stream-state.test.js +437 -1
  23. package/dist/session-handler/thread-session-runtime.js +90 -37
  24. package/dist/subagent-rate-limit-plugin.js +13 -2
  25. package/dist/system-message.js +18 -4
  26. package/dist/system-message.test.js +28 -4
  27. package/dist/task-runner.js +143 -12
  28. package/dist/task-schedule.js +12 -0
  29. package/dist/task-schedule.test.js +149 -2
  30. package/dist/xai-auth-plugin.js +237 -0
  31. package/dist/xai-auth-state.js +233 -0
  32. package/dist/xai-auth-state.test.js +183 -0
  33. package/package.json +6 -6
  34. package/skills/profano/SKILL.md +5 -3
  35. package/skills/spiceflow/SKILL.md +24 -1
  36. package/src/agent-model.e2e.test.ts +172 -4
  37. package/src/analytics.test.ts +30 -1
  38. package/src/analytics.ts +1 -0
  39. package/src/cli-commands/send.ts +22 -3
  40. package/src/cli-commands/task.ts +32 -19
  41. package/src/cli-runner.test.ts +16 -1
  42. package/src/cli-runner.ts +88 -89
  43. package/src/commands/agent.ts +120 -84
  44. package/src/commands/model.ts +25 -0
  45. package/src/commands/multioauth.ts +195 -2
  46. package/src/commands/tasks.ts +6 -0
  47. package/src/commands/unset-model.ts +5 -21
  48. package/src/database.ts +124 -0
  49. package/src/discord-bot.ts +7 -1
  50. package/src/ipc-tools-plugin.ts +4 -3
  51. package/src/kimaki-opencode-plugin.ts +2 -0
  52. package/src/message-formatting.test.ts +197 -2
  53. package/src/message-formatting.ts +98 -5
  54. package/src/oauth-rotation-shared.ts +6 -1
  55. package/src/openai-auth-state.ts +19 -0
  56. package/src/schema.sql +15 -0
  57. package/src/schema.ts +20 -0
  58. package/src/session-handler/event-stream-state.test.ts +490 -0
  59. package/src/session-handler/event-stream-state.ts +210 -0
  60. package/src/session-handler/model-utils.ts +1 -0
  61. package/src/session-handler/thread-session-runtime.ts +105 -44
  62. package/src/subagent-rate-limit-plugin.ts +12 -2
  63. package/src/system-message.test.ts +29 -4
  64. package/src/system-message.ts +20 -4
  65. package/src/task-runner.ts +168 -11
  66. package/src/task-schedule.test.ts +172 -2
  67. package/src/task-schedule.ts +23 -2
  68. package/src/xai-auth-plugin.ts +302 -0
  69. package/src/xai-auth-state.test.ts +224 -0
  70. package/src/xai-auth-state.ts +293 -0
@@ -189,7 +189,7 @@ function createAgentFile({ projectDirectory, agentName, model, }) {
189
189
  fs.mkdirSync(agentDir, { recursive: true });
190
190
  const content = [
191
191
  '---',
192
- `model: ${model}`,
192
+ ...(model ? [`model: ${model}`] : []),
193
193
  'mode: primary',
194
194
  `description: Test agent with custom model`,
195
195
  '---',
@@ -289,6 +289,10 @@ describe('agent model resolution', () => {
289
289
  agentName: 'plan',
290
290
  model: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
291
291
  });
292
+ createAgentFile({
293
+ projectDirectory: directories.projectDirectory,
294
+ agentName: 'plain',
295
+ });
292
296
  const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
293
297
  const hranaResult = await startHranaServer({ dbPath });
294
298
  if (hranaResult instanceof Error) {
@@ -311,7 +315,7 @@ describe('agent model resolution', () => {
311
315
  });
312
316
  // Register quick agent slash commands so /plan-agent and /test-agent-agent
313
317
  // are resolvable by handleQuickAgentCommand via guild.commands.fetch().
314
- const agentCommands = ['test-agent', 'plan'].map((agentName) => {
318
+ const agentCommands = ['test-agent', 'plan', 'plain'].map((agentName) => {
315
319
  return new SlashCommandBuilder()
316
320
  .setName(`${agentName}-agent`)
317
321
  .setDescription(buildQuickAgentCommandDescription({
@@ -976,7 +980,7 @@ describe('agent model resolution', () => {
976
980
  ⬥ ok
977
981
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
978
982
  Switched to **plan** agent for this session (was **test-agent**)
979
- Model: *deterministic-provider/plan-model-v2*
983
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
980
984
  The agent will change on the next message.
981
985
  --- from: user (agent-model-tester)
982
986
  Reply with exactly: after-switch-msg
@@ -994,4 +998,132 @@ describe('agent model resolution', () => {
994
998
  expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
995
999
  expect(secondFooter.content).not.toContain(AGENT_MODEL);
996
1000
  }, 20_000);
1001
+ test('/plan-agent on the same agent refreshes a stale session model', async () => {
1002
+ await setChannelAgent(TEXT_CHANNEL_ID, 'plan');
1003
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1004
+ content: 'Reply with exactly: refresh-agent-model-msg',
1005
+ });
1006
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1007
+ timeout: 4_000,
1008
+ predicate: (t) => {
1009
+ return t.name === 'Reply with exactly: refresh-agent-model-msg';
1010
+ },
1011
+ });
1012
+ await waitForFooterMessage({
1013
+ discord,
1014
+ threadId: thread.id,
1015
+ timeout: 4_000,
1016
+ afterMessageIncludes: 'ok',
1017
+ afterAuthorId: discord.botUserId,
1018
+ });
1019
+ const sessionId = await getThreadSession(thread.id);
1020
+ expect(sessionId).toBeDefined();
1021
+ if (!sessionId)
1022
+ throw new Error('Expected session');
1023
+ await setSessionModel({
1024
+ sessionId,
1025
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
1026
+ });
1027
+ const th = discord.thread(thread.id);
1028
+ const { id: interactionId } = await th
1029
+ .user(TEST_USER_ID)
1030
+ .runSlashCommand({ name: 'plan-agent' });
1031
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
1032
+ expect(await th.text()).toMatchInlineSnapshot(`
1033
+ "--- from: user (agent-model-tester)
1034
+ Reply with exactly: refresh-agent-model-msg
1035
+ --- from: assistant (TestBot)
1036
+ *using deterministic-provider/plan-model-v2 ⋅ plan*
1037
+ ⬥ ok
1038
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***
1039
+ Using **plan** agent for this session
1040
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
1041
+ The agent will change on the next message."
1042
+ `);
1043
+ expect(await th.text()).not.toContain('Already using');
1044
+ expect(await getSessionModel(sessionId)).toBeUndefined();
1045
+ }, 20_000);
1046
+ test('/plan-agent shows the agent model when a channel model is also set', async () => {
1047
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
1048
+ await setChannelModel({
1049
+ channelId: TEXT_CHANNEL_ID,
1050
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
1051
+ });
1052
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1053
+ content: 'Reply with exactly: channel-vs-agent-msg',
1054
+ });
1055
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1056
+ timeout: 4_000,
1057
+ predicate: (t) => {
1058
+ return t.name === 'Reply with exactly: channel-vs-agent-msg';
1059
+ },
1060
+ });
1061
+ await waitForFooterMessage({
1062
+ discord,
1063
+ threadId: thread.id,
1064
+ timeout: 4_000,
1065
+ afterMessageIncludes: 'ok',
1066
+ afterAuthorId: discord.botUserId,
1067
+ });
1068
+ const th = discord.thread(thread.id);
1069
+ const { id: interactionId } = await th
1070
+ .user(TEST_USER_ID)
1071
+ .runSlashCommand({ name: 'plan-agent' });
1072
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
1073
+ const threadText = await th.text();
1074
+ expect(threadText).toMatchInlineSnapshot(`
1075
+ "--- from: user (agent-model-tester)
1076
+ Reply with exactly: channel-vs-agent-msg
1077
+ --- from: assistant (TestBot)
1078
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1079
+ ⬥ ok
1080
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1081
+ Switched to **plan** agent for this session (was **test-agent**)
1082
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
1083
+ The agent will change on the next message."
1084
+ `);
1085
+ expect(threadText).toContain(`Model: *${PROVIDER_NAME}/${PLAN_AGENT_MODEL}* (agent "plan")`);
1086
+ expect(threadText).not.toContain('channel override');
1087
+ }, 20_000);
1088
+ test('/plain-agent tells the user to clear a channel model override', async () => {
1089
+ const db = await getDb();
1090
+ await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
1091
+ await setChannelModel({
1092
+ channelId: TEXT_CHANNEL_ID,
1093
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
1094
+ });
1095
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1096
+ content: 'Reply with exactly: plain-agent-override-msg',
1097
+ });
1098
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1099
+ timeout: 4_000,
1100
+ predicate: (t) => {
1101
+ return t.name === 'Reply with exactly: plain-agent-override-msg';
1102
+ },
1103
+ });
1104
+ await waitForFooterMessage({
1105
+ discord,
1106
+ threadId: thread.id,
1107
+ timeout: 4_000,
1108
+ afterMessageIncludes: 'ok',
1109
+ afterAuthorId: discord.botUserId,
1110
+ });
1111
+ const th = discord.thread(thread.id);
1112
+ const { id: interactionId } = await th
1113
+ .user(TEST_USER_ID)
1114
+ .runSlashCommand({ name: 'plain-agent' });
1115
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
1116
+ expect(await th.text()).toMatchInlineSnapshot(`
1117
+ "--- from: user (agent-model-tester)
1118
+ Reply with exactly: plain-agent-override-msg
1119
+ --- from: assistant (TestBot)
1120
+ *using deterministic-provider/channel-model-v2*
1121
+ ⬥ ok
1122
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*
1123
+ Switched to **plain** agent for this session
1124
+ Model: *deterministic-provider/channel-model-v2* (channel override)
1125
+ This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
1126
+ The agent will change on the next message."
1127
+ `);
1128
+ }, 20_000);
997
1129
  });
@@ -90,7 +90,7 @@ describe('analytics', () => {
90
90
  bot_mode: 'gateway',
91
91
  });
92
92
  });
93
- it('emits project_registered session_created turn_completed shapes', () => {
93
+ it('emits project_registered session_created turn_completed tokens_used shapes', () => {
94
94
  const captured = _enableAnalyticsTestCapture({
95
95
  installId: '33333333-3333-4333-8333-333333333333',
96
96
  botMode: 'self_hosted',
@@ -107,10 +107,24 @@ describe('analytics', () => {
107
107
  trackEvent('turn_completed', {
108
108
  duration_sec: 42,
109
109
  });
110
+ trackEvent('tokens_used', {
111
+ tokens_input: 100,
112
+ tokens_output: 20,
113
+ tokens_reasoning: 5,
114
+ tokens_cache_read: 10,
115
+ tokens_cache_write: 2,
116
+ tokens_total: 137,
117
+ cost: 0,
118
+ assistant_message_count: 2,
119
+ is_subagent: false,
120
+ model: 'gpt-5.3-codex',
121
+ provider: 'openai',
122
+ });
110
123
  expect(captured.map((e) => e.name)).toEqual([
111
124
  'project_registered',
112
125
  'session_created',
113
126
  'turn_completed',
127
+ 'tokens_used',
114
128
  ]);
115
129
  expect(captured[0].properties).toMatchObject({
116
130
  project_kind: 'user',
@@ -126,5 +140,20 @@ describe('analytics', () => {
126
140
  expect(captured[2].properties).toMatchObject({
127
141
  duration_sec: 42,
128
142
  });
143
+ expect(captured[3].properties).toMatchObject({
144
+ tokens_input: 100,
145
+ tokens_output: 20,
146
+ tokens_reasoning: 5,
147
+ tokens_cache_read: 10,
148
+ tokens_cache_write: 2,
149
+ tokens_total: 137,
150
+ cost: 0,
151
+ assistant_message_count: 2,
152
+ is_subagent: false,
153
+ model: 'gpt-5.3-codex',
154
+ provider: 'openai',
155
+ install_id: '33333333-3333-4333-8333-333333333333',
156
+ schema_version: 1,
157
+ });
129
158
  });
130
159
  });
@@ -52,6 +52,8 @@ cli
52
52
  .option('-f, --file <path>', z.array(z.string()).describe('Local file to attach (repeatable). Images, text files, PDFs, etc. ' +
53
53
  'Examples: --file screenshot.png --file report.pdf'))
54
54
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
55
+ .option('--pre-run <command>', 'Run a shell command in the project before starting a scheduled task')
56
+ .option('--allow-concurrency', 'Allow concurrent sessions from the same scheduled task')
55
57
  .option('--thread <threadId>', 'Post prompt to an existing thread')
56
58
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
57
59
  .option('--parent-session <sessionId>', 'Parent OpenCode session ID for newly created child sessions')
@@ -67,6 +69,10 @@ cli
67
69
  const { project: projectPath } = options;
68
70
  const sendAt = options.sendAt;
69
71
  const existingThreadMode = Boolean(threadId || sessionId);
72
+ if ((options.preRun || options.allowConcurrency) && !sendAt) {
73
+ cliLogger.error('--pre-run and --allow-concurrency require --send-at');
74
+ process.exit(EXIT_NO_RESTART);
75
+ }
70
76
  if (threadId && sessionId) {
71
77
  cliLogger.error('Use either --thread or --session, not both');
72
78
  process.exit(EXIT_NO_RESTART);
@@ -348,6 +354,8 @@ cli
348
354
  permissions: options.permission?.length ? options.permission : null,
349
355
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
350
356
  parentSessionId: options.parentSession || null,
357
+ preRunCommand: options.preRun || null,
358
+ allowConcurrency: Boolean(options.allowConcurrency),
351
359
  };
352
360
  const taskId = await createScheduledTask({
353
361
  scheduleKind: parsedSchedule.scheduleKind,
@@ -387,7 +395,7 @@ cli
387
395
  // detection can find the command on its own line.
388
396
  const prefixedPrompt = `» **kimaki-cli:**\n${prompt}`;
389
397
  if (threadTargetUser) {
390
- cliLogger.log(`Adding user ${threadTargetUser.username} to thread...`);
398
+ cliLogger.log(`Adding user ${threadTargetUser.username || threadTargetUser.id} to thread...`);
391
399
  const addMemberResult = await ensureThreadMember({
392
400
  rest,
393
401
  threadId: targetThreadId,
@@ -509,6 +517,8 @@ cli
509
517
  permissions: options.permission?.length ? options.permission : null,
510
518
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
511
519
  parentSessionId: options.parentSession || null,
520
+ preRunCommand: options.preRun || null,
521
+ allowConcurrency: Boolean(options.allowConcurrency),
512
522
  };
513
523
  const taskId = await createScheduledTask({
514
524
  scheduleKind: parsedSchedule.scheduleKind,
@@ -535,8 +545,8 @@ cli
535
545
  ...(worktreeName && { worktree: worktreeName }),
536
546
  ...(resolvedCwd && { cwd: resolvedCwd }),
537
547
  ...(resolvedUser && {
538
- username: resolvedUser.username,
539
548
  userId: resolvedUser.id,
549
+ ...(resolvedUser.username && { username: resolvedUser.username }),
540
550
  }),
541
551
  ...(options.agent && { agent: options.agent }),
542
552
  ...(options.model && { model: options.model }),
@@ -574,7 +584,7 @@ cli
574
584
  cliLogger.log('Thread created!');
575
585
  // Add user to thread if specified
576
586
  if (resolvedUser) {
577
- cliLogger.log(`Adding user ${resolvedUser.username} to thread...`);
587
+ cliLogger.log(`Adding user ${resolvedUser.username || resolvedUser.id} to thread...`);
578
588
  await rest.put(Routes.threadMembers(threadData.id, resolvedUser.id));
579
589
  }
580
590
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
@@ -40,7 +40,7 @@ cli
40
40
  cliLogger.log('No scheduled tasks found');
41
41
  process.exit(0);
42
42
  }
43
- console.log('id | status | message | channelId | userId | projectName | folderName | agent | model | timeRemaining | firesAt | cron');
43
+ console.log('id | status | message | channelId | userId | projectName | folderName | agent | model | preRun | allowConcurrency | timeRemaining | firesAt | cron');
44
44
  tasks.forEach((task) => {
45
45
  // Surfacing userId makes it obvious which tasks will never show up in
46
46
  // the user's Discord sidebar (no thread member is ever added for them).
@@ -49,6 +49,10 @@ cli
49
49
  const userId = payload instanceof Error ? '?' : payload.userId || '-';
50
50
  const agent = payload instanceof Error ? '?' : payload.agent || '-';
51
51
  const model = payload instanceof Error ? '?' : payload.model || '-';
52
+ const preRun = payload instanceof Error ? '?' : payload.preRunCommand || '-';
53
+ const allowConcurrency = payload instanceof Error
54
+ ? '?'
55
+ : String(payload.allowConcurrency);
52
56
  const projectDirectory = task.project_directory || '';
53
57
  const projectName = projectDirectory
54
58
  ? path.basename(projectDirectory)
@@ -60,7 +64,7 @@ cli
60
64
  ? task.run_at.toISOString()
61
65
  : '-';
62
66
  const cronValue = task.schedule_kind === 'cron' ? task.cron_expr || '-' : '-';
63
- console.log(`${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${userId} | ${projectName} | ${folderName} | ${agent} | ${model} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`);
67
+ console.log(`${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${userId} | ${projectName} | ${folderName} | ${agent} | ${model} | ${preRun} | ${allowConcurrency} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`);
64
68
  });
65
69
  process.exit(0);
66
70
  }
@@ -98,7 +102,7 @@ cli
98
102
  async function resolveTaskUser({ user, channelId, }) {
99
103
  const directUserId = getDiscordUserIdFromUserOption(user);
100
104
  if (directUserId) {
101
- return { id: directUserId, username: directUserId };
105
+ return { id: directUserId };
102
106
  }
103
107
  if (!channelId) {
104
108
  return new Error(`Cannot look up username "${user}": task has no channel to resolve a guild from. Pass a Discord user ID instead.`);
@@ -128,18 +132,24 @@ cli
128
132
  .option('--send-at <sendAt>', 'New schedule (UTC ISO date or cron expression)')
129
133
  .option('--agent <agent>', 'Agent for the scheduled session (empty string clears)')
130
134
  .option('--model <model>', 'Model for the scheduled session, format provider/model (empty string clears)')
135
+ .option('--pre-run <command>', 'New pre-run command (empty string clears)')
136
+ .option('--allow-concurrency <enabled>', z.enum(['true', 'false']).describe('Allow concurrent sessions: true or false'))
131
137
  .option('-u, --user <user>', 'Discord user ID, mention, or username added to the task thread')
132
138
  .action(async (id, options) => {
133
139
  try {
134
140
  const trimmedPrompt = options.prompt === undefined ? undefined : options.prompt.trim();
135
141
  const hasAgent = options.agent !== undefined;
136
142
  const hasModel = options.model !== undefined;
143
+ const hasPreRun = options.preRun !== undefined;
144
+ const hasAllowConcurrency = options.allowConcurrency !== undefined;
137
145
  if (!trimmedPrompt &&
138
146
  !options.sendAt &&
139
147
  !options.user &&
140
148
  !hasAgent &&
141
- !hasModel) {
142
- cliLogger.error('Provide at least --prompt, --send-at, --user, --agent or --model');
149
+ !hasModel &&
150
+ !hasPreRun &&
151
+ !hasAllowConcurrency) {
152
+ cliLogger.error('Provide at least --prompt, --send-at, --user, --agent, --model, --pre-run or --allow-concurrency');
143
153
  process.exit(EXIT_NO_RESTART);
144
154
  }
145
155
  if (trimmedPrompt !== undefined && trimmedPrompt.length === 0) {
@@ -186,10 +196,14 @@ cli
186
196
  ...existingPayload,
187
197
  prompt: newPrompt,
188
198
  ...(resolvedUser
189
- ? { userId: resolvedUser.id, username: resolvedUser.username }
199
+ ? { userId: resolvedUser.id, username: resolvedUser.username || null }
190
200
  : {}),
191
201
  ...(hasAgent ? { agent: options.agent.trim() || null } : {}),
192
202
  ...(hasModel ? { model: options.model.trim() || null } : {}),
203
+ ...(hasPreRun ? { preRunCommand: options.preRun.trim() || null } : {}),
204
+ ...(hasAllowConcurrency
205
+ ? { allowConcurrency: options.allowConcurrency === 'true' }
206
+ : {}),
193
207
  };
194
208
  const updateData = {
195
209
  taskId,
@@ -220,7 +234,7 @@ cli
220
234
  }
221
235
  const parts = [`Updated task ${taskId}`];
222
236
  if (resolvedUser) {
223
- parts.push(`user ${resolvedUser.username} will be added to the thread`);
237
+ parts.push(`user ${resolvedUser.username || resolvedUser.id} will be added to the thread`);
224
238
  }
225
239
  if (hasAgent) {
226
240
  parts.push(`agent=${updatedPayload.agent || '-'}`);
@@ -228,6 +242,11 @@ cli
228
242
  if (hasModel) {
229
243
  parts.push(`model=${updatedPayload.model || '-'}`);
230
244
  }
245
+ if (hasPreRun)
246
+ parts.push(`preRun=${updatedPayload.preRunCommand || '-'}`);
247
+ if (hasAllowConcurrency) {
248
+ parts.push(`allowConcurrency=${updatedPayload.allowConcurrency}`);
249
+ }
231
250
  cliLogger.log(parts.join(' | '));
232
251
  process.exit(0);
233
252
  }
@@ -12,7 +12,6 @@ import { discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInt
12
12
  import crypto from 'node:crypto';
13
13
  import path from 'node:path';
14
14
  import fs from 'node:fs';
15
- import os from 'node:os';
16
15
  import { spawn } from 'node:child_process';
17
16
  import { createLogger, LogPrefix } from './logger.js';
18
17
  import { notifyError } from './sentry.js';
@@ -130,6 +129,42 @@ export async function resolveBotCredentials({ appIdOverride } = {}) {
130
129
  process.exit(EXIT_NO_RESTART);
131
130
  }
132
131
  export { isThreadChannelType };
132
+ /** Wrap long lines so prompt.md is readable in Discord's attachment preview. */
133
+ function wrapPromptAttachmentText(prompt) {
134
+ return prompt
135
+ .split('\n')
136
+ .flatMap((line) => {
137
+ if (line.length <= 120) {
138
+ return [line];
139
+ }
140
+ const wrapped = [];
141
+ let remaining = line;
142
+ const maxCol = 120;
143
+ // Only soft-break at a space if it's reasonably close to maxCol,
144
+ // otherwise hard-break to avoid tiny fragments from early spaces
145
+ const minSoftBreak = 90;
146
+ while (remaining.length > maxCol) {
147
+ const lastSpace = remaining.lastIndexOf(' ', maxCol);
148
+ const useSoftBreak = lastSpace >= minSoftBreak;
149
+ const breakAt = useSoftBreak ? lastSpace : maxCol;
150
+ wrapped.push(remaining.slice(0, breakAt));
151
+ // Only consume the separator space on soft breaks
152
+ remaining = useSoftBreak
153
+ ? remaining.slice(breakAt + 1)
154
+ : remaining.slice(breakAt);
155
+ }
156
+ if (remaining.length > 0) {
157
+ wrapped.push(remaining);
158
+ }
159
+ return wrapped;
160
+ })
161
+ .join('\n');
162
+ }
163
+ function promptAttachmentBlob(text) {
164
+ return new Blob([new Uint8Array(Buffer.from(text, 'utf8'))], {
165
+ type: 'text/markdown',
166
+ });
167
+ }
133
168
  export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, splitInsteadOfAttach, files, }) {
134
169
  const discordMaxLength = 2000;
135
170
  // When files are provided, always use multipart FormData upload
@@ -145,22 +180,23 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
145
180
  }
146
181
  }
147
182
  // When prompt exceeds Discord's limit, attach it as prompt.md alongside
148
- // user files so nothing is silently lost.
183
+ // user files so nothing is silently lost. Build prompt.md from memory so
184
+ // parallel kimaki send processes never share or unlink a temp path.
149
185
  const isLongPrompt = prompt.length > discordMaxLength;
150
186
  const content = isLongPrompt
151
187
  ? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
152
188
  : prompt;
153
- // Build attachment metadata: user files + optional prompt.md
154
189
  const allFiles = files.map((file) => ({
155
- filePath: file,
190
+ data: new Uint8Array(fs.readFileSync(file)),
156
191
  filename: path.basename(file),
157
192
  mimeType: mime.getType(file) || 'application/octet-stream',
158
193
  }));
159
194
  if (isLongPrompt) {
160
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'));
161
- const tmpFile = path.join(tmpDir, 'prompt.md');
162
- fs.writeFileSync(tmpFile, prompt);
163
- allFiles.push({ filePath: tmpFile, filename: 'prompt.md', mimeType: 'text/markdown' });
195
+ allFiles.push({
196
+ data: new Uint8Array(Buffer.from(prompt, 'utf8')),
197
+ filename: 'prompt.md',
198
+ mimeType: 'text/markdown',
199
+ });
164
200
  }
165
201
  const attachments = allFiles.map((f, index) => ({
166
202
  id: index,
@@ -174,8 +210,7 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
174
210
  allowed_mentions: { parse: store.getState().allowedMentions },
175
211
  }));
176
212
  for (const [index, f] of allFiles.entries()) {
177
- const buffer = fs.readFileSync(f.filePath);
178
- formData.append(`files[${index}]`, new Blob([buffer], { type: f.mimeType }), f.filename);
213
+ formData.append(`files[${index}]`, new Blob([f.data], { type: f.mimeType }), f.filename);
179
214
  }
180
215
  const response = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
181
216
  method: 'POST',
@@ -226,66 +261,27 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
226
261
  }
227
262
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
228
263
  const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
229
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'));
230
- const tmpFile = path.join(tmpDir, 'prompt.md');
231
- // Wrap long lines so the file is readable in Discord's preview
232
- // (Discord doesn't wrap text in file attachments)
233
- const wrappedPrompt = prompt
234
- .split('\n')
235
- .flatMap((line) => {
236
- if (line.length <= 120) {
237
- return [line];
238
- }
239
- const wrapped = [];
240
- let remaining = line;
241
- const maxCol = 120;
242
- // Only soft-break at a space if it's reasonably close to maxCol,
243
- // otherwise hard-break to avoid tiny fragments from early spaces
244
- const minSoftBreak = 90;
245
- while (remaining.length > maxCol) {
246
- const lastSpace = remaining.lastIndexOf(' ', maxCol);
247
- const useSoftBreak = lastSpace >= minSoftBreak;
248
- const breakAt = useSoftBreak ? lastSpace : maxCol;
249
- wrapped.push(remaining.slice(0, breakAt));
250
- // Only consume the separator space on soft breaks
251
- remaining = useSoftBreak
252
- ? remaining.slice(breakAt + 1)
253
- : remaining.slice(breakAt);
254
- }
255
- if (remaining.length > 0) {
256
- wrapped.push(remaining);
257
- }
258
- return wrapped;
259
- })
260
- .join('\n');
261
- fs.writeFileSync(tmpFile, wrappedPrompt);
262
- try {
263
- const formData = new FormData();
264
- formData.append('payload_json', JSON.stringify({
265
- content: summaryContent,
266
- attachments: [{ id: 0, filename: 'prompt.md' }],
267
- embeds,
268
- allowed_mentions: { parse: store.getState().allowedMentions },
269
- }));
270
- const buffer = fs.readFileSync(tmpFile);
271
- formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
272
- const starterMessageResponse = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
273
- method: 'POST',
274
- headers: {
275
- Authorization: `Bot ${botToken}`,
276
- },
277
- body: formData,
278
- });
279
- if (!starterMessageResponse.ok) {
280
- const error = await starterMessageResponse.text();
281
- throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
282
- }
283
- return (await starterMessageResponse.json());
284
- }
285
- finally {
286
- fs.unlinkSync(tmpFile);
287
- fs.rmdirSync(tmpDir);
264
+ // In-memory Blob only — no temp file. Parallel send must never share a path.
265
+ const formData = new FormData();
266
+ formData.append('payload_json', JSON.stringify({
267
+ content: summaryContent,
268
+ attachments: [{ id: 0, filename: 'prompt.md' }],
269
+ embeds,
270
+ allowed_mentions: { parse: store.getState().allowedMentions },
271
+ }));
272
+ formData.append('files[0]', promptAttachmentBlob(wrapPromptAttachmentText(prompt)), 'prompt.md');
273
+ const starterMessageResponse = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
274
+ method: 'POST',
275
+ headers: {
276
+ Authorization: `Bot ${botToken}`,
277
+ },
278
+ body: formData,
279
+ });
280
+ if (!starterMessageResponse.ok) {
281
+ const error = await starterMessageResponse.text();
282
+ throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
288
283
  }
284
+ return (await starterMessageResponse.json());
289
285
  }
290
286
  export function formatRelativeTime(target) {
291
287
  const diffMs = target.getTime() - Date.now();
@@ -435,7 +431,7 @@ export async function resolveDiscordUserOption({ user, guildId, rest, }) {
435
431
  const directUserId = getDiscordUserIdFromUserOption(user);
436
432
  if (directUserId) {
437
433
  cliLogger.log(`Using Discord user ID: ${directUserId}`);
438
- return { id: directUserId, username: directUserId };
434
+ return { id: directUserId };
439
435
  }
440
436
  cliLogger.log(`Searching for user "${user}" in guild...`);
441
437
  const searchResult = await rest
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { getOpenUrlCommand, isTransientNetworkError } from './cli-runner.js';
2
+ import { REST } from 'discord.js';
3
+ import { getOpenUrlCommand, isTransientNetworkError, resolveDiscordUserOption, } from './cli-runner.js';
4
+ test('raw Discord user ID does not invent a username', async () => {
5
+ const user = await resolveDiscordUserOption({
6
+ user: '535922349652836367',
7
+ guildId: '1422625037164351591',
8
+ rest: new REST(),
9
+ });
10
+ expect(user).toEqual({ id: '535922349652836367' });
11
+ });
3
12
  describe('getOpenUrlCommand', () => {
4
13
  const installUrl = 'https://kimaki.dev/discord-install?clientId=abc&clientSecret=def';
5
14
  test('uses a shell-free opener on Windows', () => {