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
package/skills/profano/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
})
|
package/src/analytics.test.ts
CHANGED
|
@@ -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
package/src/cli-commands/send.ts
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/cli-commands/task.ts
CHANGED
|
@@ -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
|
|
144
|
+
}): Promise<{ id: string; username?: string } | Error> {
|
|
141
145
|
const directUserId = getDiscordUserIdFromUserOption(user)
|
|
142
146
|
if (directUserId) {
|
|
143
|
-
return { id: 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 --
|
|
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(
|
|
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) {
|
package/src/cli-runner.test.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest'
|
|
2
|
-
import {
|
|
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'
|