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.
- package/dist/agent-model.e2e.test.js +135 -3
- package/dist/analytics.test.js +30 -1
- package/dist/cli-commands/send.js +13 -3
- package/dist/cli-commands/task.js +26 -7
- package/dist/cli-runner.js +66 -70
- package/dist/cli-runner.test.js +10 -1
- package/dist/commands/agent.js +87 -74
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +7 -1
- package/dist/ipc-tools-plugin.js +4 -3
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/message-formatting.js +84 -5
- package/dist/message-formatting.test.js +160 -2
- package/dist/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -0
- package/dist/schema.js +19 -0
- package/dist/session-handler/event-stream-state.js +147 -0
- package/dist/session-handler/event-stream-state.test.js +437 -1
- package/dist/session-handler/thread-session-runtime.js +90 -37
- package/dist/subagent-rate-limit-plugin.js +13 -2
- package/dist/system-message.js +18 -4
- package/dist/system-message.test.js +28 -4
- package/dist/task-runner.js +143 -12
- package/dist/task-schedule.js +12 -0
- package/dist/task-schedule.test.js +149 -2
- package/dist/xai-auth-plugin.js +237 -0
- package/dist/xai-auth-state.js +233 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +6 -6
- package/skills/profano/SKILL.md +5 -3
- package/skills/spiceflow/SKILL.md +24 -1
- package/src/agent-model.e2e.test.ts +172 -4
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/cli-commands/send.ts +22 -3
- package/src/cli-commands/task.ts +32 -19
- package/src/cli-runner.test.ts +16 -1
- package/src/cli-runner.ts +88 -89
- package/src/commands/agent.ts +120 -84
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -1
- package/src/ipc-tools-plugin.ts +4 -3
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/message-formatting.test.ts +197 -2
- package/src/message-formatting.ts +98 -5
- package/src/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -0
- package/src/schema.sql +15 -0
- package/src/schema.ts +20 -0
- package/src/session-handler/event-stream-state.test.ts +490 -0
- package/src/session-handler/event-stream-state.ts +210 -0
- package/src/session-handler/model-utils.ts +1 -0
- package/src/session-handler/thread-session-runtime.ts +105 -44
- package/src/subagent-rate-limit-plugin.ts +12 -2
- package/src/system-message.test.ts +29 -4
- package/src/system-message.ts +20 -4
- package/src/task-runner.ts +168 -11
- package/src/task-schedule.test.ts +172 -2
- package/src/task-schedule.ts +23 -2
- package/src/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- 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
|
});
|
package/dist/analytics.test.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
434
|
+
return { id: directUserId };
|
|
439
435
|
}
|
|
440
436
|
cliLogger.log(`Searching for user "${user}" in guild...`);
|
|
441
437
|
const searchResult = await rest
|
package/dist/cli-runner.test.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import {
|
|
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', () => {
|