kimaki 0.25.0 → 0.27.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 +157 -122
- package/dist/analytics.test.js +30 -1
- package/dist/anthropic-account-identity.js +1 -0
- package/dist/anthropic-auth-plugin.js +1 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/send.js +37 -3
- package/dist/cli-commands/session.js +183 -8
- package/dist/cli-commands/task.js +39 -8
- package/dist/cli-parsing.test.js +59 -3
- package/dist/cli-runner.js +94 -74
- package/dist/cli-runner.test.js +37 -1
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/agent.js +93 -82
- package/dist/commands/ask-question.js +21 -32
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +36 -10
- package/dist/commands/model-variant.js +7 -1
- package/dist/commands/model.js +32 -8
- package/dist/commands/multioauth.js +170 -1
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/database.js +189 -3
- package/dist/db.js +22 -0
- package/dist/db.test.js +167 -1
- package/dist/discord-bot.js +41 -33
- package/dist/discord-command-registration.js +9 -1
- package/dist/discord-utils.js +13 -5
- package/dist/discord-utils.test.js +17 -1
- package/dist/errors.js +5 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/git-worktree-core.js +182 -17
- package/dist/ipc-tools-plugin.js +67 -6
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +10 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +95 -5
- package/dist/message-formatting.test.js +207 -2
- package/dist/oauth-rotation-shared.js +17 -1
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +21 -0
- package/dist/opencode-command.test.js +60 -0
- package/dist/opencode.js +53 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +40 -246
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
- package/dist/queue-advanced-question.e2e.test.js +31 -26
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
- package/dist/queue-delete-message.e2e.test.js +1 -1
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +112 -2
- package/dist/queue-question-select-drain.e2e.test.js +30 -128
- package/dist/runtime-lifecycle.e2e.test.js +10 -37
- package/dist/schema.js +53 -0
- package/dist/session-handler/event-stream-state.js +364 -0
- package/dist/session-handler/event-stream-state.test.js +844 -1
- package/dist/session-handler/model-utils.js +159 -5
- package/dist/session-handler/model-utils.test.js +345 -0
- package/dist/session-handler/thread-session-runtime.js +288 -165
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +7 -39
- package/dist/sleep.e2e.test.js +491 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +76 -9
- package/dist/system-message.test.js +103 -9
- package/dist/task-runner.js +235 -14
- package/dist/task-schedule.js +68 -0
- package/dist/task-schedule.test.js +219 -2
- package/dist/thread-message-queue.e2e.test.js +10 -169
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-message.e2e.test.js +10 -10
- package/dist/voice.test.js +55 -29
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +29 -10
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +238 -0
- package/dist/xai-auth-state.js +234 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +9 -7
- package/skills/errore/SKILL.md +18 -0
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +37 -0
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +63 -4
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +194 -152
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/anthropic-account-identity.ts +1 -0
- package/src/anthropic-auth-plugin.ts +1 -0
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/send.ts +47 -3
- package/src/cli-commands/session.ts +203 -9
- package/src/cli-commands/task.ts +45 -20
- package/src/cli-parsing.test.ts +69 -3
- package/src/cli-runner.test.ts +65 -1
- package/src/cli-runner.ts +116 -93
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/agent.ts +126 -90
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +21 -32
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +43 -12
- package/src/commands/model-variant.ts +8 -1
- package/src/commands/model.ts +43 -8
- package/src/commands/multioauth.ts +196 -2
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/database.ts +319 -3
- package/src/db.test.ts +188 -0
- package/src/db.ts +26 -0
- package/src/discord-bot.ts +42 -30
- package/src/discord-command-registration.ts +15 -1
- package/src/discord-utils.test.ts +19 -0
- package/src/discord-utils.ts +21 -6
- package/src/errors.ts +5 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/git-worktree-core.ts +247 -20
- package/src/ipc-tools-plugin.ts +79 -8
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +13 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +271 -2
- package/src/message-formatting.ts +110 -5
- package/src/oauth-rotation-shared.ts +16 -1
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +20 -0
- package/src/opencode-command.test.ts +81 -0
- package/src/opencode.ts +71 -11
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +47 -294
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
- package/src/queue-advanced-question.e2e.test.ts +35 -29
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
- package/src/queue-delete-message.e2e.test.ts +1 -1
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +123 -2
- package/src/queue-question-select-drain.e2e.test.ts +32 -157
- package/src/runtime-lifecycle.e2e.test.ts +10 -41
- package/src/schema.sql +29 -1
- package/src/schema.ts +56 -0
- package/src/session-handler/event-stream-state.test.ts +920 -0
- package/src/session-handler/event-stream-state.ts +546 -0
- package/src/session-handler/model-utils.test.ts +415 -0
- package/src/session-handler/model-utils.ts +250 -5
- package/src/session-handler/thread-session-runtime.ts +346 -200
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +9 -48
- package/src/sleep.e2e.test.ts +562 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +21 -0
- package/src/system-message.test.ts +114 -9
- package/src/system-message.ts +85 -10
- package/src/task-runner.ts +304 -13
- package/src/task-schedule.test.ts +248 -2
- package/src/task-schedule.ts +118 -2
- package/src/thread-message-queue.e2e.test.ts +10 -202
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-attachment.ts +29 -1
- package/src/voice-message.e2e.test.ts +10 -10
- package/src/voice.test.ts +63 -29
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +29 -9
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +303 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +294 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -173
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -218
|
@@ -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({
|
|
@@ -398,7 +402,7 @@ describe('agent model resolution', () => {
|
|
|
398
402
|
--- from: assistant (TestBot)
|
|
399
403
|
*using deterministic-provider/agent-model-v2 ⋅ test-agent*
|
|
400
404
|
⬥ ok
|
|
401
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
|
|
405
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
|
|
402
406
|
`);
|
|
403
407
|
expect(footerMessage).toBeDefined();
|
|
404
408
|
if (!footerMessage) {
|
|
@@ -442,7 +446,7 @@ describe('agent model resolution', () => {
|
|
|
442
446
|
--- from: assistant (TestBot)
|
|
443
447
|
*using deterministic-provider/agent-model-v2 ⋅ test-agent*
|
|
444
448
|
⬥ system-context-ok
|
|
445
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
|
|
449
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
|
|
446
450
|
`);
|
|
447
451
|
}, 15_000);
|
|
448
452
|
test('session.command path includes kimaki system prompt on first message', async () => {
|
|
@@ -548,7 +552,7 @@ describe('agent model resolution', () => {
|
|
|
548
552
|
--- from: assistant (TestBot)
|
|
549
553
|
*using deterministic-provider/channel-model-v2*
|
|
550
554
|
⬥ ok
|
|
551
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
|
|
555
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
|
|
552
556
|
`);
|
|
553
557
|
expect(footerMessage).toBeDefined();
|
|
554
558
|
if (!footerMessage) {
|
|
@@ -561,40 +565,30 @@ describe('agent model resolution', () => {
|
|
|
561
565
|
expect(footerMessage.content).toContain(CHANNEL_MODEL);
|
|
562
566
|
expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
|
|
563
567
|
}, 15_000);
|
|
564
|
-
test('channel model
|
|
565
|
-
// Clear channel agent so model resolution falls through to channel model
|
|
568
|
+
test('unsupported channel model variant falls back to the selected model', async () => {
|
|
566
569
|
const db = await getDb();
|
|
567
570
|
await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
|
|
568
|
-
// Set channel model with a variant (thinking level)
|
|
569
|
-
// The deterministic provider doesn't support thinking, so the variant
|
|
570
|
-
// is resolved but silently dropped (no matching thinking values).
|
|
571
|
-
// This test verifies the variant cascade code path runs without crashing
|
|
572
|
-
// and the correct model still appears in the footer.
|
|
573
571
|
await setChannelModel({
|
|
574
572
|
channelId: TEXT_CHANNEL_ID,
|
|
575
573
|
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
576
574
|
variant: 'high',
|
|
577
575
|
});
|
|
576
|
+
const prompt = 'Reply with exactly: variant-check';
|
|
578
577
|
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
579
|
-
content:
|
|
578
|
+
content: prompt,
|
|
580
579
|
});
|
|
581
580
|
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
582
581
|
timeout: 4_000,
|
|
583
|
-
predicate: (
|
|
584
|
-
return t.name === 'Reply with exactly: variant-check';
|
|
585
|
-
},
|
|
582
|
+
predicate: (candidate) => candidate.name === prompt,
|
|
586
583
|
});
|
|
587
|
-
await
|
|
584
|
+
const messages = await waitForFooterMessage({
|
|
588
585
|
discord,
|
|
589
586
|
threadId: thread.id,
|
|
590
|
-
userId: TEST_USER_ID,
|
|
591
|
-
text: '*project',
|
|
592
587
|
timeout: 4_000,
|
|
593
588
|
});
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
message.content.startsWith('*'));
|
|
589
|
+
const footer = messages.find((message) => {
|
|
590
|
+
return message.author.id === discord.botUserId
|
|
591
|
+
&& message.content.startsWith('*');
|
|
598
592
|
});
|
|
599
593
|
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
600
594
|
"--- from: user (agent-model-tester)
|
|
@@ -602,18 +596,10 @@ describe('agent model resolution', () => {
|
|
|
602
596
|
--- from: assistant (TestBot)
|
|
603
597
|
*using deterministic-provider/channel-model-v2*
|
|
604
598
|
⬥ ok
|
|
605
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
|
|
599
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
|
|
606
600
|
`);
|
|
607
|
-
expect(
|
|
608
|
-
|
|
609
|
-
throw new Error(`Expected footer message but none found. Bot messages: ${messages
|
|
610
|
-
.filter((m) => m.author.id === discord.botUserId)
|
|
611
|
-
.map((m) => m.content.slice(0, 150))
|
|
612
|
-
.join(' | ')}`);
|
|
613
|
-
}
|
|
614
|
-
// Footer should still contain the channel model (variant doesn't crash)
|
|
615
|
-
expect(footerMessage.content).toContain(CHANNEL_MODEL);
|
|
616
|
-
expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
|
|
601
|
+
expect(footer?.content).toContain(CHANNEL_MODEL);
|
|
602
|
+
expect(footer?.content).not.toContain(DEFAULT_MODEL);
|
|
617
603
|
}, 15_000);
|
|
618
604
|
test('/btw fork keeps source session model when channel model differs', async () => {
|
|
619
605
|
const db = await getDb();
|
|
@@ -677,7 +663,7 @@ describe('agent model resolution', () => {
|
|
|
677
663
|
Reusing context from <#SOURCE_THREAD> to answer prompt...
|
|
678
664
|
Reply with exactly: btw-model-check
|
|
679
665
|
⬥ ok
|
|
680
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
|
|
666
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2* <@200000000000000920>"
|
|
681
667
|
`);
|
|
682
668
|
expect(forkedSessionModel).toMatchInlineSnapshot(`
|
|
683
669
|
{
|
|
@@ -742,12 +728,12 @@ describe('agent model resolution', () => {
|
|
|
742
728
|
--- from: assistant (TestBot)
|
|
743
729
|
*using deterministic-provider/agent-model-v2 ⋅ test-agent*
|
|
744
730
|
⬥ ok
|
|
745
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
731
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
|
|
746
732
|
--- from: user (agent-model-tester)
|
|
747
733
|
Reply with exactly: second-thread-msg
|
|
748
734
|
--- from: assistant (TestBot)
|
|
749
735
|
⬥ ok
|
|
750
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
|
|
736
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
|
|
751
737
|
`);
|
|
752
738
|
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
753
739
|
const secondFooter = [...secondMessages]
|
|
@@ -761,85 +747,6 @@ describe('agent model resolution', () => {
|
|
|
761
747
|
expect(secondFooter.content).toContain(AGENT_MODEL);
|
|
762
748
|
expect(secondFooter.content).not.toContain(PLAN_AGENT_MODEL);
|
|
763
749
|
}, 20_000);
|
|
764
|
-
test('thread created with no agent keeps default model after channel agent is set', async () => {
|
|
765
|
-
// Clear any channel agent — thread starts with default (no agent)
|
|
766
|
-
const db = await getDb();
|
|
767
|
-
await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
|
|
768
|
-
// Also clear channel model so we get the pure default
|
|
769
|
-
await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
|
|
770
|
-
// 1. Send a message to create a thread (no channel agent set)
|
|
771
|
-
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
772
|
-
content: 'Reply with exactly: default-thread-msg',
|
|
773
|
-
});
|
|
774
|
-
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
775
|
-
timeout: 4_000,
|
|
776
|
-
predicate: (t) => {
|
|
777
|
-
return t.name === 'Reply with exactly: default-thread-msg';
|
|
778
|
-
},
|
|
779
|
-
});
|
|
780
|
-
// Wait for footer — should show the default model
|
|
781
|
-
await waitForFooterMessage({
|
|
782
|
-
discord,
|
|
783
|
-
threadId: thread.id,
|
|
784
|
-
timeout: 4_000,
|
|
785
|
-
afterMessageIncludes: 'ok',
|
|
786
|
-
afterAuthorId: discord.botUserId,
|
|
787
|
-
});
|
|
788
|
-
const firstMessages = await discord.thread(thread.id).getMessages();
|
|
789
|
-
const firstFooter = firstMessages.find((m) => {
|
|
790
|
-
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
791
|
-
});
|
|
792
|
-
expect(firstFooter).toBeDefined();
|
|
793
|
-
// First run uses the default model (no agent set)
|
|
794
|
-
expect(firstFooter.content).toContain(DEFAULT_MODEL);
|
|
795
|
-
expect(firstFooter.content).not.toContain(AGENT_MODEL);
|
|
796
|
-
// 2. Set channel agent to test-agent via /test-agent-agent in the CHANNEL
|
|
797
|
-
const { id: interactionId } = await discord
|
|
798
|
-
.channel(TEXT_CHANNEL_ID)
|
|
799
|
-
.user(TEST_USER_ID)
|
|
800
|
-
.runSlashCommand({ name: 'test-agent-agent' });
|
|
801
|
-
await discord
|
|
802
|
-
.channel(TEXT_CHANNEL_ID)
|
|
803
|
-
.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
804
|
-
// 3. Send a second message in the EXISTING thread
|
|
805
|
-
await discord
|
|
806
|
-
.thread(thread.id)
|
|
807
|
-
.user(TEST_USER_ID)
|
|
808
|
-
.sendMessage({
|
|
809
|
-
content: 'Reply with exactly: default-second-msg',
|
|
810
|
-
});
|
|
811
|
-
await waitForFooterMessage({
|
|
812
|
-
discord,
|
|
813
|
-
threadId: thread.id,
|
|
814
|
-
timeout: 4_000,
|
|
815
|
-
afterMessageIncludes: 'default-second-msg',
|
|
816
|
-
afterAuthorId: TEST_USER_ID,
|
|
817
|
-
});
|
|
818
|
-
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
819
|
-
"--- from: user (agent-model-tester)
|
|
820
|
-
Reply with exactly: default-thread-msg
|
|
821
|
-
--- from: assistant (TestBot)
|
|
822
|
-
*using deterministic-provider/deterministic-v2*
|
|
823
|
-
⬥ ok
|
|
824
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
825
|
-
--- from: user (agent-model-tester)
|
|
826
|
-
Reply with exactly: default-second-msg
|
|
827
|
-
--- from: assistant (TestBot)
|
|
828
|
-
⬥ ok
|
|
829
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
830
|
-
`);
|
|
831
|
-
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
832
|
-
const secondFooter = [...secondMessages]
|
|
833
|
-
.reverse()
|
|
834
|
-
.find((m) => {
|
|
835
|
-
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
836
|
-
});
|
|
837
|
-
expect(secondFooter).toBeDefined();
|
|
838
|
-
// The existing thread should still use the DEFAULT model,
|
|
839
|
-
// NOT the test-agent's model (AGENT_MODEL)
|
|
840
|
-
expect(secondFooter.content).toContain(DEFAULT_MODEL);
|
|
841
|
-
expect(secondFooter.content).not.toContain(AGENT_MODEL);
|
|
842
|
-
}, 20_000);
|
|
843
750
|
test('/plan-agent with prompt starts a session with the plan agent', async () => {
|
|
844
751
|
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
845
752
|
const prompt = 'Reply with exactly: inline-plan-agent-msg';
|
|
@@ -875,7 +782,7 @@ describe('agent model resolution', () => {
|
|
|
875
782
|
» **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
|
|
876
783
|
*using deterministic-provider/plan-model-v2 ⋅ plan*
|
|
877
784
|
⬥ ok
|
|
878
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
785
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
|
|
879
786
|
`);
|
|
880
787
|
}, 20_000);
|
|
881
788
|
test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
|
|
@@ -920,10 +827,10 @@ describe('agent model resolution', () => {
|
|
|
920
827
|
--- from: assistant (TestBot)
|
|
921
828
|
*using deterministic-provider/agent-model-v2 ⋅ test-agent*
|
|
922
829
|
⬥ ok
|
|
923
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
830
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
|
|
924
831
|
» **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
|
|
925
832
|
⬥ ok
|
|
926
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
833
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
|
|
927
834
|
`);
|
|
928
835
|
}, 20_000);
|
|
929
836
|
test('/plan-agent inside a thread switches the model for that thread', async () => {
|
|
@@ -974,15 +881,15 @@ describe('agent model resolution', () => {
|
|
|
974
881
|
--- from: assistant (TestBot)
|
|
975
882
|
*using deterministic-provider/agent-model-v2 ⋅ test-agent*
|
|
976
883
|
⬥ ok
|
|
977
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
884
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
|
|
978
885
|
Switched to **plan** agent for this session (was **test-agent**)
|
|
979
|
-
Model: *deterministic-provider/plan-model-v2*
|
|
886
|
+
Model: *deterministic-provider/plan-model-v2* (agent "plan")
|
|
980
887
|
The agent will change on the next message.
|
|
981
888
|
--- from: user (agent-model-tester)
|
|
982
889
|
Reply with exactly: after-switch-msg
|
|
983
890
|
--- from: assistant (TestBot)
|
|
984
891
|
⬥ ok
|
|
985
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
892
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
|
|
986
893
|
`);
|
|
987
894
|
const secondFooter = [...(await discord.thread(thread.id).getMessages())]
|
|
988
895
|
.reverse()
|
|
@@ -994,4 +901,132 @@ describe('agent model resolution', () => {
|
|
|
994
901
|
expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
|
|
995
902
|
expect(secondFooter.content).not.toContain(AGENT_MODEL);
|
|
996
903
|
}, 20_000);
|
|
904
|
+
test('/plan-agent on the same agent refreshes a stale session model', async () => {
|
|
905
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'plan');
|
|
906
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
907
|
+
content: 'Reply with exactly: refresh-agent-model-msg',
|
|
908
|
+
});
|
|
909
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
910
|
+
timeout: 4_000,
|
|
911
|
+
predicate: (t) => {
|
|
912
|
+
return t.name === 'Reply with exactly: refresh-agent-model-msg';
|
|
913
|
+
},
|
|
914
|
+
});
|
|
915
|
+
await waitForFooterMessage({
|
|
916
|
+
discord,
|
|
917
|
+
threadId: thread.id,
|
|
918
|
+
timeout: 4_000,
|
|
919
|
+
afterMessageIncludes: 'ok',
|
|
920
|
+
afterAuthorId: discord.botUserId,
|
|
921
|
+
});
|
|
922
|
+
const sessionId = await getThreadSession(thread.id);
|
|
923
|
+
expect(sessionId).toBeDefined();
|
|
924
|
+
if (!sessionId)
|
|
925
|
+
throw new Error('Expected session');
|
|
926
|
+
await setSessionModel({
|
|
927
|
+
sessionId,
|
|
928
|
+
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
929
|
+
});
|
|
930
|
+
const th = discord.thread(thread.id);
|
|
931
|
+
const { id: interactionId } = await th
|
|
932
|
+
.user(TEST_USER_ID)
|
|
933
|
+
.runSlashCommand({ name: 'plan-agent' });
|
|
934
|
+
await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
935
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
936
|
+
"--- from: user (agent-model-tester)
|
|
937
|
+
Reply with exactly: refresh-agent-model-msg
|
|
938
|
+
--- from: assistant (TestBot)
|
|
939
|
+
*using deterministic-provider/plan-model-v2 ⋅ plan*
|
|
940
|
+
⬥ ok
|
|
941
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>
|
|
942
|
+
Using **plan** agent for this session
|
|
943
|
+
Model: *deterministic-provider/plan-model-v2* (agent "plan")
|
|
944
|
+
The agent will change on the next message."
|
|
945
|
+
`);
|
|
946
|
+
expect(await th.text()).not.toContain('Already using');
|
|
947
|
+
expect(await getSessionModel(sessionId)).toBeUndefined();
|
|
948
|
+
}, 20_000);
|
|
949
|
+
test('/plan-agent shows the agent model when a channel model is also set', async () => {
|
|
950
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
951
|
+
await setChannelModel({
|
|
952
|
+
channelId: TEXT_CHANNEL_ID,
|
|
953
|
+
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
954
|
+
});
|
|
955
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
956
|
+
content: 'Reply with exactly: channel-vs-agent-msg',
|
|
957
|
+
});
|
|
958
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
959
|
+
timeout: 4_000,
|
|
960
|
+
predicate: (t) => {
|
|
961
|
+
return t.name === 'Reply with exactly: channel-vs-agent-msg';
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
await waitForFooterMessage({
|
|
965
|
+
discord,
|
|
966
|
+
threadId: thread.id,
|
|
967
|
+
timeout: 4_000,
|
|
968
|
+
afterMessageIncludes: 'ok',
|
|
969
|
+
afterAuthorId: discord.botUserId,
|
|
970
|
+
});
|
|
971
|
+
const th = discord.thread(thread.id);
|
|
972
|
+
const { id: interactionId } = await th
|
|
973
|
+
.user(TEST_USER_ID)
|
|
974
|
+
.runSlashCommand({ name: 'plan-agent' });
|
|
975
|
+
await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
976
|
+
const threadText = await th.text();
|
|
977
|
+
expect(threadText).toMatchInlineSnapshot(`
|
|
978
|
+
"--- from: user (agent-model-tester)
|
|
979
|
+
Reply with exactly: channel-vs-agent-msg
|
|
980
|
+
--- from: assistant (TestBot)
|
|
981
|
+
*using deterministic-provider/agent-model-v2 ⋅ test-agent*
|
|
982
|
+
⬥ ok
|
|
983
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
|
|
984
|
+
Switched to **plan** agent for this session (was **test-agent**)
|
|
985
|
+
Model: *deterministic-provider/plan-model-v2* (agent "plan")
|
|
986
|
+
The agent will change on the next message."
|
|
987
|
+
`);
|
|
988
|
+
expect(threadText).toContain(`Model: *${PROVIDER_NAME}/${PLAN_AGENT_MODEL}* (agent "plan")`);
|
|
989
|
+
expect(threadText).not.toContain('channel override');
|
|
990
|
+
}, 20_000);
|
|
991
|
+
test('/plain-agent tells the user to clear a channel model override', async () => {
|
|
992
|
+
const db = await getDb();
|
|
993
|
+
await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
|
|
994
|
+
await setChannelModel({
|
|
995
|
+
channelId: TEXT_CHANNEL_ID,
|
|
996
|
+
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
997
|
+
});
|
|
998
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
999
|
+
content: 'Reply with exactly: plain-agent-override-msg',
|
|
1000
|
+
});
|
|
1001
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
1002
|
+
timeout: 4_000,
|
|
1003
|
+
predicate: (t) => {
|
|
1004
|
+
return t.name === 'Reply with exactly: plain-agent-override-msg';
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
await waitForFooterMessage({
|
|
1008
|
+
discord,
|
|
1009
|
+
threadId: thread.id,
|
|
1010
|
+
timeout: 4_000,
|
|
1011
|
+
afterMessageIncludes: 'ok',
|
|
1012
|
+
afterAuthorId: discord.botUserId,
|
|
1013
|
+
});
|
|
1014
|
+
const th = discord.thread(thread.id);
|
|
1015
|
+
const { id: interactionId } = await th
|
|
1016
|
+
.user(TEST_USER_ID)
|
|
1017
|
+
.runSlashCommand({ name: 'plain-agent' });
|
|
1018
|
+
await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
1019
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
1020
|
+
"--- from: user (agent-model-tester)
|
|
1021
|
+
Reply with exactly: plain-agent-override-msg
|
|
1022
|
+
--- from: assistant (TestBot)
|
|
1023
|
+
*using deterministic-provider/channel-model-v2*
|
|
1024
|
+
⬥ ok
|
|
1025
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>
|
|
1026
|
+
Switched to **plain** agent for this session
|
|
1027
|
+
Model: *deterministic-provider/channel-model-v2* (channel override)
|
|
1028
|
+
This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
|
|
1029
|
+
The agent will change on the next message."
|
|
1030
|
+
`);
|
|
1031
|
+
}, 20_000);
|
|
997
1032
|
});
|
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
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
|
|
1
2
|
// Helpers for extracting and normalizing Anthropic OAuth account identity.
|
|
2
3
|
const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer']);
|
|
3
4
|
const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub'];
|
package/dist/bin.js
CHANGED
|
@@ -32,14 +32,57 @@ else {
|
|
|
32
32
|
console.error('no subcommand detected. kimaki will automatically restart on crash');
|
|
33
33
|
console.error();
|
|
34
34
|
const EXIT_NO_RESTART = 64;
|
|
35
|
+
// Keep in sync with EXIT_TEMPFAIL in cli-runner.ts. Network-down login
|
|
36
|
+
// failures use this so a long outage cannot trip the crash-loop detector.
|
|
37
|
+
const EXIT_TEMPFAIL = 75;
|
|
35
38
|
const MAX_RAPID_RESTARTS = 5;
|
|
36
39
|
const RAPID_RESTART_WINDOW_MS = 60_000;
|
|
37
40
|
const RESTART_DELAY_MS = 2_000;
|
|
41
|
+
const CHILD_EXIT_DEADLINE_MS = 15_000;
|
|
38
42
|
const restartTimestamps = [];
|
|
43
|
+
let tempFailAttempts = 0;
|
|
39
44
|
let child = null;
|
|
40
45
|
// Track when we forwarded a termination signal so we don't restart after graceful shutdown
|
|
41
46
|
let shutdownRequested = false;
|
|
47
|
+
let forceKillTimer = null;
|
|
48
|
+
let scheduledRestart = null;
|
|
49
|
+
function clearForceKillTimer() {
|
|
50
|
+
if (!forceKillTimer)
|
|
51
|
+
return;
|
|
52
|
+
clearTimeout(forceKillTimer);
|
|
53
|
+
forceKillTimer = null;
|
|
54
|
+
}
|
|
55
|
+
function clearScheduledRestart() {
|
|
56
|
+
if (!scheduledRestart)
|
|
57
|
+
return;
|
|
58
|
+
clearTimeout(scheduledRestart);
|
|
59
|
+
scheduledRestart = null;
|
|
60
|
+
}
|
|
61
|
+
function killChild(signal) {
|
|
62
|
+
const target = child;
|
|
63
|
+
if (!target) {
|
|
64
|
+
if (shutdownRequested) {
|
|
65
|
+
clearScheduledRestart();
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const sent = target.kill(signal);
|
|
71
|
+
if (!sent || forceKillTimer)
|
|
72
|
+
return;
|
|
73
|
+
forceKillTimer = setTimeout(() => {
|
|
74
|
+
forceKillTimer = null;
|
|
75
|
+
if (child !== target)
|
|
76
|
+
return;
|
|
77
|
+
console.error(`[kimaki] Child did not exit within ${CHILD_EXIT_DEADLINE_MS / 1000}s, force-killing it`);
|
|
78
|
+
target.kill('SIGKILL');
|
|
79
|
+
}, CHILD_EXIT_DEADLINE_MS);
|
|
80
|
+
forceKillTimer.unref();
|
|
81
|
+
}
|
|
42
82
|
function start() {
|
|
83
|
+
if (shutdownRequested)
|
|
84
|
+
return;
|
|
85
|
+
scheduledRestart = null;
|
|
43
86
|
if (!fs.existsSync(HEAP_SNAPSHOT_DIR)) {
|
|
44
87
|
fs.mkdirSync(HEAP_SNAPSHOT_DIR, { recursive: true });
|
|
45
88
|
}
|
|
@@ -48,32 +91,42 @@ else {
|
|
|
48
91
|
`--diagnostic-dir=${HEAP_SNAPSHOT_DIR}`,
|
|
49
92
|
];
|
|
50
93
|
const args = [...heapArgs, ...process.execArgv, ...process.argv.slice(1)];
|
|
51
|
-
|
|
94
|
+
const currentChild = spawn(process.argv[0], args, {
|
|
52
95
|
stdio: 'inherit',
|
|
53
96
|
env: { ...process.env, __KIMAKI_CHILD: '1' },
|
|
54
97
|
});
|
|
55
|
-
child
|
|
98
|
+
child = currentChild;
|
|
99
|
+
currentChild.on('exit', (code, signal) => {
|
|
100
|
+
if (child === currentChild)
|
|
101
|
+
child = null;
|
|
102
|
+
clearForceKillTimer();
|
|
56
103
|
if (code === 0 || code === EXIT_NO_RESTART || shutdownRequested) {
|
|
57
104
|
process.exit(code ?? 0);
|
|
58
105
|
return;
|
|
59
106
|
}
|
|
60
107
|
const now = Date.now();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
restartTimestamps
|
|
64
|
-
restartTimestamps.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
108
|
+
const isTempFail = code === EXIT_TEMPFAIL;
|
|
109
|
+
if (!isTempFail) {
|
|
110
|
+
restartTimestamps.push(now);
|
|
111
|
+
while (restartTimestamps.length > 0 &&
|
|
112
|
+
restartTimestamps[0] < now - RAPID_RESTART_WINDOW_MS) {
|
|
113
|
+
restartTimestamps.shift();
|
|
114
|
+
}
|
|
115
|
+
if (restartTimestamps.length > MAX_RAPID_RESTARTS) {
|
|
116
|
+
console.error(`[kimaki] Crash loop detected (${MAX_RAPID_RESTARTS} crashes in ${RAPID_RESTART_WINDOW_MS / 1000}s), exiting`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
70
120
|
}
|
|
71
121
|
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
|
72
122
|
// Progressive backoff: 2s, 4s, 8s, 16s, capped at 30s.
|
|
73
123
|
// Prevents hammering DNS/gateway during sustained network outages.
|
|
74
|
-
const
|
|
124
|
+
const backoffStep = isTempFail
|
|
125
|
+
? Math.min(tempFailAttempts++, 4)
|
|
126
|
+
: restartTimestamps.length - 1;
|
|
127
|
+
const delay = Math.min(RESTART_DELAY_MS * 2 ** backoffStep, 30_000);
|
|
75
128
|
console.error(`[kimaki] Process exited with ${reason}, restarting in ${(delay / 1000).toFixed(0)}s...`);
|
|
76
|
-
setTimeout(start, delay);
|
|
129
|
+
scheduledRestart = setTimeout(start, delay);
|
|
77
130
|
});
|
|
78
131
|
}
|
|
79
132
|
// Forward signals to child so graceful shutdown and heap snapshots work.
|
|
@@ -81,13 +134,14 @@ else {
|
|
|
81
134
|
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
82
135
|
process.on(sig, () => {
|
|
83
136
|
shutdownRequested = true;
|
|
84
|
-
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
for (const sig of ['SIGUSR1', 'SIGUSR2']) {
|
|
88
|
-
process.on(sig, () => {
|
|
89
|
-
child?.kill(sig);
|
|
137
|
+
killChild(sig);
|
|
90
138
|
});
|
|
91
139
|
}
|
|
140
|
+
process.on('SIGUSR1', () => {
|
|
141
|
+
child?.kill('SIGUSR1');
|
|
142
|
+
});
|
|
143
|
+
process.on('SIGUSR2', () => {
|
|
144
|
+
killChild('SIGUSR2');
|
|
145
|
+
});
|
|
92
146
|
start();
|
|
93
147
|
}
|