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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: profano
3
- description: CLI tool to analyze V8 .cpuprofile files and print top functions by self-time or total-time in the terminal. ALWAYS load this skill when CPU profiling JavaScript or TypeScript programs (Node, Vitest, Bun, Chrome DevTools exports) — it shows how to generate .cpuprofile files and how to inspect them from the terminal without opening Chrome DevTools.
3
+ description: CLI tool to analyze V8 .cpuprofile files and Chrome Performance traces and print top functions by self-time or total-time in the terminal. ALWAYS load this skill when CPU profiling JavaScript or TypeScript programs (Node, Vitest, Bun, Chrome DevTools exports) — it shows how to generate .cpuprofile files and how to inspect them from the terminal without opening Chrome DevTools.
4
4
  ---
5
5
 
6
6
  # profano
7
7
 
8
- `profano` reads V8 `.cpuprofile` files and prints the heaviest functions as a table sorted by self-time or total (inclusive) time.
8
+ `profano` reads V8 `.cpuprofile` files and Chrome Performance traces (`Trace-*.json`) and prints the heaviest functions as a table sorted by self-time or total (inclusive) time.
9
9
 
10
10
  Every time you use profano, you MUST fetch the latest README and read it in full:
11
11
 
@@ -13,4 +13,6 @@ Every time you use profano, you MUST fetch the latest README and read it in full
13
13
  curl -s https://raw.githubusercontent.com/remorses/profano/main/README.md # NEVER pipe to head/tail, read in full
14
14
  ```
15
15
 
16
- The README covers generating `.cpuprofile` files (Node, Vitest, Bun, Chrome DevTools, browser pages via playwriter, React component profiling), all CLI options, and how to read the output columns.
16
+ The README covers generating `.cpuprofile` files (Node, Vitest, Bun, Chrome Performance traces, browser pages via playwriter, React component profiling), all CLI options, and how to read the output columns.
17
+
18
+ Chrome Performance **Save profile** writes a trace JSON. Pass it to `profano Trace.json` directly. Do not convert it first.
@@ -1,10 +1,27 @@
1
1
  ---
2
2
  name: spiceflow
3
- description: 'Spiceflow is a super simple, fast, and type-safe API and React Server Components framework for TypeScript. Works on Node.js, Bun, and Cloudflare Workers. Use this skill whenever working with spiceflow to get the latest docs and API reference.'
3
+ description: 'Spiceflow is a super simple, fast, and type-safe API and React Server Components framework for TypeScript. Works on Node.js, Bun, and Cloudflare Workers. ALWAYS load this skill BEFORE writing or editing ANY spiceflow code, including one-line changes: routes, pages, layouts, loaders, server actions, forms, ErrorBoundary, redirects, cookies, navigation, Link, router, ProgressBar, the typed fetch client, middleware, and OpenAPI. A project uses spiceflow if anything imports from "spiceflow" or "spiceflow/react", or constructs new Spiceflow(). The API is small but very opinionated and the opinions are NOT guessable; writing spiceflow code from memory or from what looks like Next.js produces wrong code every time.'
4
4
  ---
5
5
 
6
6
  # Spiceflow
7
7
 
8
+ ## When to load this skill
9
+
10
+ **ALWAYS, before you touch ANY spiceflow code.** Not "when stuck", not "for big changes", not "if the task looks unusual" — every single time, including one-line edits.
11
+
12
+ A project uses spiceflow if anything imports from `spiceflow` or `spiceflow/react`, or constructs `new Spiceflow()`. That covers:
13
+
14
+ - routes (`.get`, `.post`, `.route`), pages, layouts, loaders
15
+ - server actions, forms, `ErrorBoundary`, `parseFormData`, `useActionState`
16
+ - redirects, cookies, navigation, `Link`, `router`, `ProgressBar`
17
+ - the typed fetch client, middleware, tracing, OpenAPI
18
+
19
+ The API surface is small but **very opinionated**, and the opinions are NOT guessable. Every framework has its own answer for forms, pending state, error display, and redirects; spiceflow's answers are frequently different from Next.js and from React defaults. Writing spiceflow code from memory, or from what "looks like Next.js", produces wrong code every time.
20
+
21
+ If you already started editing spiceflow code without loading this skill, stop, load it, read the README, and re-check what you wrote.
22
+
23
+ ## Read the full README first
24
+
8
25
  Every time you work with spiceflow, you MUST fetch the **entire** README from the main branch. The README is the primary documentation and every section matters. You MUST read it completely, from start to finish, with no truncation. Partial reads cause you to miss critical API details, conventions, and patterns that lead to wrong implementations.
9
26
 
10
27
  ```bash
@@ -133,6 +150,12 @@ export async function deleteProject(id: string) {
133
150
 
134
151
  Never assume a server action is only reachable through your own UI. Treat every server action like a public API endpoint.
135
152
 
153
+ ## Never `router.refresh()` after server actions
154
+
155
+ Successful server actions re-run matching loaders and reconcile the current page. Do not call `router.refresh()` afterward. Use it only when data changes outside a server action.
156
+
157
+ `router.refresh()` is fire-and-forget. Do not await a custom refresh or navigation commit helper inside a React form action because the transition can deadlock.
158
+
136
159
  ## Router usage in app entry handlers
137
160
 
138
161
  `router` from `spiceflow/react` is typed from the globally registered `typeof app`. Do **not** use `router` inside `.loader()`, `.get()`, `.post()`, or `.route()` handlers in the same file that initializes `export const app = new Spiceflow()`. Those handlers feed return types back into `typeof app` through loader data or typed API responses, so `router.href()` can create recursive circular TypeScript errors such as TS7022.
@@ -237,13 +237,13 @@ function createAgentFile({
237
237
  }: {
238
238
  projectDirectory: string
239
239
  agentName: string
240
- model: string
240
+ model?: string
241
241
  }) {
242
242
  const agentDir = path.join(projectDirectory, '.opencode', 'agent')
243
243
  fs.mkdirSync(agentDir, { recursive: true })
244
244
  const content = [
245
245
  '---',
246
- `model: ${model}`,
246
+ ...(model ? [`model: ${model}`] : []),
247
247
  'mode: primary',
248
248
  `description: Test agent with custom model`,
249
249
  '---',
@@ -369,6 +369,10 @@ describe('agent model resolution', () => {
369
369
  agentName: 'plan',
370
370
  model: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
371
371
  })
372
+ createAgentFile({
373
+ projectDirectory: directories.projectDirectory,
374
+ agentName: 'plain',
375
+ })
372
376
 
373
377
  const dbPath = path.join(directories.dataDir, 'discord-sessions.db')
374
378
  const hranaResult = await startHranaServer({ dbPath })
@@ -395,7 +399,7 @@ describe('agent model resolution', () => {
395
399
 
396
400
  // Register quick agent slash commands so /plan-agent and /test-agent-agent
397
401
  // are resolvable by handleQuickAgentCommand via guild.commands.fetch().
398
- const agentCommands = ['test-agent', 'plan'].map((agentName) => {
402
+ const agentCommands = ['test-agent', 'plan', 'plain'].map((agentName) => {
399
403
  return new SlashCommandBuilder()
400
404
  .setName(`${agentName}-agent`)
401
405
  .setDescription(
@@ -1252,7 +1256,7 @@ describe('agent model resolution', () => {
1252
1256
  ⬥ ok
1253
1257
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1254
1258
  Switched to **plan** agent for this session (was **test-agent**)
1255
- Model: *deterministic-provider/plan-model-v2*
1259
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
1256
1260
  The agent will change on the next message.
1257
1261
  --- from: user (agent-model-tester)
1258
1262
  Reply with exactly: after-switch-msg
@@ -1276,4 +1280,168 @@ describe('agent model resolution', () => {
1276
1280
  },
1277
1281
  20_000,
1278
1282
  )
1283
+
1284
+ test(
1285
+ '/plan-agent on the same agent refreshes a stale session model',
1286
+ async () => {
1287
+ await setChannelAgent(TEXT_CHANNEL_ID, 'plan')
1288
+
1289
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1290
+ content: 'Reply with exactly: refresh-agent-model-msg',
1291
+ })
1292
+
1293
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1294
+ timeout: 4_000,
1295
+ predicate: (t) => {
1296
+ return t.name === 'Reply with exactly: refresh-agent-model-msg'
1297
+ },
1298
+ })
1299
+
1300
+ await waitForFooterMessage({
1301
+ discord,
1302
+ threadId: thread.id,
1303
+ timeout: 4_000,
1304
+ afterMessageIncludes: 'ok',
1305
+ afterAuthorId: discord.botUserId,
1306
+ })
1307
+
1308
+ const sessionId = await getThreadSession(thread.id)
1309
+ expect(sessionId).toBeDefined()
1310
+ if (!sessionId) throw new Error('Expected session')
1311
+
1312
+ await setSessionModel({
1313
+ sessionId,
1314
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
1315
+ })
1316
+
1317
+ const th = discord.thread(thread.id)
1318
+ const { id: interactionId } = await th
1319
+ .user(TEST_USER_ID)
1320
+ .runSlashCommand({ name: 'plan-agent' })
1321
+
1322
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 })
1323
+
1324
+ expect(await th.text()).toMatchInlineSnapshot(`
1325
+ "--- from: user (agent-model-tester)
1326
+ Reply with exactly: refresh-agent-model-msg
1327
+ --- from: assistant (TestBot)
1328
+ *using deterministic-provider/plan-model-v2 ⋅ plan*
1329
+ ⬥ ok
1330
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***
1331
+ Using **plan** agent for this session
1332
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
1333
+ The agent will change on the next message."
1334
+ `)
1335
+ expect(await th.text()).not.toContain('Already using')
1336
+ expect(await getSessionModel(sessionId)).toBeUndefined()
1337
+ },
1338
+ 20_000,
1339
+ )
1340
+
1341
+ test(
1342
+ '/plan-agent shows the agent model when a channel model is also set',
1343
+ async () => {
1344
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent')
1345
+ await setChannelModel({
1346
+ channelId: TEXT_CHANNEL_ID,
1347
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
1348
+ })
1349
+
1350
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1351
+ content: 'Reply with exactly: channel-vs-agent-msg',
1352
+ })
1353
+
1354
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1355
+ timeout: 4_000,
1356
+ predicate: (t) => {
1357
+ return t.name === 'Reply with exactly: channel-vs-agent-msg'
1358
+ },
1359
+ })
1360
+
1361
+ await waitForFooterMessage({
1362
+ discord,
1363
+ threadId: thread.id,
1364
+ timeout: 4_000,
1365
+ afterMessageIncludes: 'ok',
1366
+ afterAuthorId: discord.botUserId,
1367
+ })
1368
+
1369
+ const th = discord.thread(thread.id)
1370
+ const { id: interactionId } = await th
1371
+ .user(TEST_USER_ID)
1372
+ .runSlashCommand({ name: 'plan-agent' })
1373
+
1374
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 })
1375
+
1376
+ const threadText = await th.text()
1377
+ expect(threadText).toMatchInlineSnapshot(`
1378
+ "--- from: user (agent-model-tester)
1379
+ Reply with exactly: channel-vs-agent-msg
1380
+ --- from: assistant (TestBot)
1381
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1382
+ ⬥ ok
1383
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1384
+ Switched to **plan** agent for this session (was **test-agent**)
1385
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
1386
+ The agent will change on the next message."
1387
+ `)
1388
+ expect(threadText).toContain(
1389
+ `Model: *${PROVIDER_NAME}/${PLAN_AGENT_MODEL}* (agent "plan")`,
1390
+ )
1391
+ expect(threadText).not.toContain('channel override')
1392
+ },
1393
+ 20_000,
1394
+ )
1395
+
1396
+ test(
1397
+ '/plain-agent tells the user to clear a channel model override',
1398
+ async () => {
1399
+ const db = await getDb()
1400
+ await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID))
1401
+ await setChannelModel({
1402
+ channelId: TEXT_CHANNEL_ID,
1403
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
1404
+ })
1405
+
1406
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1407
+ content: 'Reply with exactly: plain-agent-override-msg',
1408
+ })
1409
+
1410
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1411
+ timeout: 4_000,
1412
+ predicate: (t) => {
1413
+ return t.name === 'Reply with exactly: plain-agent-override-msg'
1414
+ },
1415
+ })
1416
+
1417
+ await waitForFooterMessage({
1418
+ discord,
1419
+ threadId: thread.id,
1420
+ timeout: 4_000,
1421
+ afterMessageIncludes: 'ok',
1422
+ afterAuthorId: discord.botUserId,
1423
+ })
1424
+
1425
+ const th = discord.thread(thread.id)
1426
+ const { id: interactionId } = await th
1427
+ .user(TEST_USER_ID)
1428
+ .runSlashCommand({ name: 'plain-agent' })
1429
+
1430
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 })
1431
+
1432
+ expect(await th.text()).toMatchInlineSnapshot(`
1433
+ "--- from: user (agent-model-tester)
1434
+ Reply with exactly: plain-agent-override-msg
1435
+ --- from: assistant (TestBot)
1436
+ *using deterministic-provider/channel-model-v2*
1437
+ ⬥ ok
1438
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*
1439
+ Switched to **plain** agent for this session
1440
+ Model: *deterministic-provider/channel-model-v2* (channel override)
1441
+ This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
1442
+ The agent will change on the next message."
1443
+ `)
1444
+ },
1445
+ 20_000,
1446
+ )
1279
1447
  })
@@ -118,7 +118,7 @@ describe('analytics', () => {
118
118
  })
119
119
  })
120
120
 
121
- it('emits project_registered session_created turn_completed shapes', () => {
121
+ it('emits project_registered session_created turn_completed tokens_used shapes', () => {
122
122
  const captured = _enableAnalyticsTestCapture({
123
123
  installId: '33333333-3333-4333-8333-333333333333',
124
124
  botMode: 'self_hosted',
@@ -136,11 +136,25 @@ describe('analytics', () => {
136
136
  trackEvent('turn_completed', {
137
137
  duration_sec: 42,
138
138
  })
139
+ trackEvent('tokens_used', {
140
+ tokens_input: 100,
141
+ tokens_output: 20,
142
+ tokens_reasoning: 5,
143
+ tokens_cache_read: 10,
144
+ tokens_cache_write: 2,
145
+ tokens_total: 137,
146
+ cost: 0,
147
+ assistant_message_count: 2,
148
+ is_subagent: false,
149
+ model: 'gpt-5.3-codex',
150
+ provider: 'openai',
151
+ })
139
152
 
140
153
  expect(captured.map((e) => e.name)).toEqual([
141
154
  'project_registered',
142
155
  'session_created',
143
156
  'turn_completed',
157
+ 'tokens_used',
144
158
  ])
145
159
  expect(captured[0]!.properties).toMatchObject({
146
160
  project_kind: 'user',
@@ -156,5 +170,20 @@ describe('analytics', () => {
156
170
  expect(captured[2]!.properties).toMatchObject({
157
171
  duration_sec: 42,
158
172
  })
173
+ expect(captured[3]!.properties).toMatchObject({
174
+ tokens_input: 100,
175
+ tokens_output: 20,
176
+ tokens_reasoning: 5,
177
+ tokens_cache_read: 10,
178
+ tokens_cache_write: 2,
179
+ tokens_total: 137,
180
+ cost: 0,
181
+ assistant_message_count: 2,
182
+ is_subagent: false,
183
+ model: 'gpt-5.3-codex',
184
+ provider: 'openai',
185
+ install_id: '33333333-3333-4333-8333-333333333333',
186
+ schema_version: 1,
187
+ })
159
188
  })
160
189
  })
package/src/analytics.ts CHANGED
@@ -46,6 +46,7 @@ export type AnalyticsEventName =
46
46
  | 'session_created'
47
47
  | 'turn_started'
48
48
  | 'turn_completed'
49
+ | 'tokens_used'
49
50
 
50
51
  export type AnalyticsBotMode = 'gateway' | 'self_hosted'
51
52
 
@@ -111,6 +111,14 @@ cli
111
111
  '--send-at <schedule>',
112
112
  'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)',
113
113
  )
114
+ .option(
115
+ '--pre-run <command>',
116
+ 'Run a shell command in the project before starting a scheduled task',
117
+ )
118
+ .option(
119
+ '--allow-concurrency',
120
+ 'Allow concurrent sessions from the same scheduled task',
121
+ )
114
122
  .option('--thread <threadId>', 'Post prompt to an existing thread')
115
123
  .option(
116
124
  '--session <sessionId>',
@@ -143,6 +151,11 @@ cli
143
151
 
144
152
  const existingThreadMode = Boolean(threadId || sessionId)
145
153
 
154
+ if ((options.preRun || options.allowConcurrency) && !sendAt) {
155
+ cliLogger.error('--pre-run and --allow-concurrency require --send-at')
156
+ process.exit(EXIT_NO_RESTART)
157
+ }
158
+
146
159
  if (threadId && sessionId) {
147
160
  cliLogger.error('Use either --thread or --session, not both')
148
161
  process.exit(EXIT_NO_RESTART)
@@ -495,6 +508,8 @@ cli
495
508
  permissions: options.permission?.length ? options.permission : null,
496
509
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
497
510
  parentSessionId: options.parentSession || null,
511
+ preRunCommand: options.preRun || null,
512
+ allowConcurrency: Boolean(options.allowConcurrency),
498
513
  }
499
514
  const taskId = await createScheduledTask({
500
515
  scheduleKind: parsedSchedule.scheduleKind,
@@ -542,7 +557,7 @@ cli
542
557
 
543
558
  if (threadTargetUser) {
544
559
  cliLogger.log(
545
- `Adding user ${threadTargetUser.username} to thread...`,
560
+ `Adding user ${threadTargetUser.username || threadTargetUser.id} to thread...`,
546
561
  )
547
562
  const addMemberResult = await ensureThreadMember({
548
563
  rest,
@@ -691,6 +706,8 @@ cli
691
706
  permissions: options.permission?.length ? options.permission : null,
692
707
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
693
708
  parentSessionId: options.parentSession || null,
709
+ preRunCommand: options.preRun || null,
710
+ allowConcurrency: Boolean(options.allowConcurrency),
694
711
  }
695
712
  const taskId = await createScheduledTask({
696
713
  scheduleKind: parsedSchedule.scheduleKind,
@@ -722,8 +739,8 @@ cli
722
739
  ...(worktreeName && { worktree: worktreeName }),
723
740
  ...(resolvedCwd && { cwd: resolvedCwd }),
724
741
  ...(resolvedUser && {
725
- username: resolvedUser.username,
726
742
  userId: resolvedUser.id,
743
+ ...(resolvedUser.username && { username: resolvedUser.username }),
727
744
  }),
728
745
  ...(options.agent && { agent: options.agent }),
729
746
  ...(options.model && { model: options.model }),
@@ -773,7 +790,9 @@ cli
773
790
 
774
791
  // Add user to thread if specified
775
792
  if (resolvedUser) {
776
- cliLogger.log(`Adding user ${resolvedUser.username} to thread...`)
793
+ cliLogger.log(
794
+ `Adding user ${resolvedUser.username || resolvedUser.id} to thread...`,
795
+ )
777
796
  await rest.put(Routes.threadMembers(threadData.id, resolvedUser.id))
778
797
  }
779
798
 
@@ -60,7 +60,7 @@ cli
60
60
  }
61
61
 
62
62
  console.log(
63
- 'id | status | message | channelId | userId | projectName | folderName | agent | model | timeRemaining | firesAt | cron',
63
+ 'id | status | message | channelId | userId | projectName | folderName | agent | model | preRun | allowConcurrency | timeRemaining | firesAt | cron',
64
64
  )
65
65
 
66
66
  tasks.forEach((task) => {
@@ -71,6 +71,10 @@ cli
71
71
  const userId = payload instanceof Error ? '?' : payload.userId || '-'
72
72
  const agent = payload instanceof Error ? '?' : payload.agent || '-'
73
73
  const model = payload instanceof Error ? '?' : payload.model || '-'
74
+ const preRun = payload instanceof Error ? '?' : payload.preRunCommand || '-'
75
+ const allowConcurrency = payload instanceof Error
76
+ ? '?'
77
+ : String(payload.allowConcurrency)
74
78
  const projectDirectory = task.project_directory || ''
75
79
  const projectName = projectDirectory
76
80
  ? path.basename(projectDirectory)
@@ -86,7 +90,7 @@ cli
86
90
  task.schedule_kind === 'cron' ? task.cron_expr || '-' : '-'
87
91
 
88
92
  console.log(
89
- `${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${userId} | ${projectName} | ${folderName} | ${agent} | ${model} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`,
93
+ `${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${userId} | ${projectName} | ${folderName} | ${agent} | ${model} | ${preRun} | ${allowConcurrency} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`,
90
94
  )
91
95
  })
92
96
 
@@ -137,10 +141,10 @@ async function resolveTaskUser({
137
141
  }: {
138
142
  user: string
139
143
  channelId: string | null
140
- }): Promise<{ id: string; username: string } | Error> {
144
+ }): Promise<{ id: string; username?: string } | Error> {
141
145
  const directUserId = getDiscordUserIdFromUserOption(user)
142
146
  if (directUserId) {
143
- return { id: directUserId, username: directUserId }
147
+ return { id: directUserId }
144
148
  }
145
149
 
146
150
  if (!channelId) {
@@ -181,36 +185,35 @@ cli
181
185
  '--model <model>',
182
186
  'Model for the scheduled session, format provider/model (empty string clears)',
183
187
  )
188
+ .option('--pre-run <command>', 'New pre-run command (empty string clears)')
189
+ .option(
190
+ '--allow-concurrency <enabled>',
191
+ z.enum(['true', 'false']).describe('Allow concurrent sessions: true or false'),
192
+ )
184
193
  .option(
185
194
  '-u, --user <user>',
186
195
  'Discord user ID, mention, or username added to the task thread',
187
196
  )
188
- .action(
189
- async (
190
- id: string,
191
- options: {
192
- prompt?: string
193
- sendAt?: string
194
- user?: string
195
- agent?: string
196
- model?: string
197
- },
198
- ) => {
197
+ .action(async (id, options) => {
199
198
  try {
200
199
  const trimmedPrompt =
201
200
  options.prompt === undefined ? undefined : options.prompt.trim()
202
201
  const hasAgent = options.agent !== undefined
203
202
  const hasModel = options.model !== undefined
203
+ const hasPreRun = options.preRun !== undefined
204
+ const hasAllowConcurrency = options.allowConcurrency !== undefined
204
205
 
205
206
  if (
206
207
  !trimmedPrompt &&
207
208
  !options.sendAt &&
208
209
  !options.user &&
209
210
  !hasAgent &&
210
- !hasModel
211
+ !hasModel &&
212
+ !hasPreRun &&
213
+ !hasAllowConcurrency
211
214
  ) {
212
215
  cliLogger.error(
213
- 'Provide at least --prompt, --send-at, --user, --agent or --model',
216
+ 'Provide at least --prompt, --send-at, --user, --agent, --model, --pre-run or --allow-concurrency',
214
217
  )
215
218
  process.exit(EXIT_NO_RESTART)
216
219
  }
@@ -265,10 +268,14 @@ cli
265
268
  ...existingPayload,
266
269
  prompt: newPrompt,
267
270
  ...(resolvedUser
268
- ? { userId: resolvedUser.id, username: resolvedUser.username }
271
+ ? { userId: resolvedUser.id, username: resolvedUser.username || null }
269
272
  : {}),
270
273
  ...(hasAgent ? { agent: options.agent!.trim() || null } : {}),
271
274
  ...(hasModel ? { model: options.model!.trim() || null } : {}),
275
+ ...(hasPreRun ? { preRunCommand: options.preRun!.trim() || null } : {}),
276
+ ...(hasAllowConcurrency
277
+ ? { allowConcurrency: options.allowConcurrency === 'true' }
278
+ : {}),
272
279
  }
273
280
 
274
281
  const updateData: Parameters<typeof updateScheduledTask>[0] = {
@@ -303,7 +310,9 @@ cli
303
310
 
304
311
  const parts: string[] = [`Updated task ${taskId}`]
305
312
  if (resolvedUser) {
306
- parts.push(`user ${resolvedUser.username} will be added to the thread`)
313
+ parts.push(
314
+ `user ${resolvedUser.username || resolvedUser.id} will be added to the thread`,
315
+ )
307
316
  }
308
317
  if (hasAgent) {
309
318
  parts.push(`agent=${updatedPayload.agent || '-'}`)
@@ -311,6 +320,10 @@ cli
311
320
  if (hasModel) {
312
321
  parts.push(`model=${updatedPayload.model || '-'}`)
313
322
  }
323
+ if (hasPreRun) parts.push(`preRun=${updatedPayload.preRunCommand || '-'}`)
324
+ if (hasAllowConcurrency) {
325
+ parts.push(`allowConcurrency=${updatedPayload.allowConcurrency}`)
326
+ }
314
327
  cliLogger.log(parts.join(' | '))
315
328
  process.exit(0)
316
329
  } catch (error) {
@@ -1,5 +1,20 @@
1
1
  import { describe, expect, test } from 'vitest'
2
- import { getOpenUrlCommand, isTransientNetworkError } from './cli-runner.js'
2
+ import { REST } from 'discord.js'
3
+ import {
4
+ getOpenUrlCommand,
5
+ isTransientNetworkError,
6
+ resolveDiscordUserOption,
7
+ } from './cli-runner.js'
8
+
9
+ test('raw Discord user ID does not invent a username', async () => {
10
+ const user = await resolveDiscordUserOption({
11
+ user: '535922349652836367',
12
+ guildId: '1422625037164351591',
13
+ rest: new REST(),
14
+ })
15
+
16
+ expect(user).toEqual({ id: '535922349652836367' })
17
+ })
3
18
 
4
19
  describe('getOpenUrlCommand', () => {
5
20
  const installUrl = 'https://kimaki.dev/discord-install?clientId=abc&clientSecret=def'