kimaki 0.26.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 +25 -122
- 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 +24 -0
- package/dist/cli-commands/session.js +183 -8
- package/dist/cli-commands/task.js +13 -1
- package/dist/cli-parsing.test.js +59 -3
- package/dist/cli-runner.js +28 -4
- package/dist/cli-runner.test.js +27 -0
- 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 +6 -8
- 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 +14 -8
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- 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 +124 -3
- package/dist/db.js +22 -0
- package/dist/db.test.js +167 -1
- package/dist/discord-bot.js +34 -32
- 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 +63 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +7 -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 +18 -7
- package/dist/message-formatting.test.js +56 -9
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -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 +34 -0
- package/dist/session-handler/event-stream-state.js +267 -50
- package/dist/session-handler/event-stream-state.test.js +415 -8
- 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 +252 -182
- 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 +59 -6
- package/dist/system-message.test.js +76 -6
- package/dist/task-runner.js +94 -4
- package/dist/task-schedule.js +56 -0
- package/dist/task-schedule.test.js +71 -1
- 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 +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- 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 +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +25 -151
- 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 +25 -0
- package/src/cli-commands/session.ts +203 -9
- package/src/cli-commands/task.ts +13 -1
- package/src/cli-parsing.test.ts +69 -3
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +28 -4
- 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 +6 -6
- 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 +18 -8
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- 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 +195 -3
- package/src/db.test.ts +188 -0
- package/src/db.ts +26 -0
- package/src/discord-bot.ts +35 -29
- 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 +75 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +7 -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 +86 -12
- package/src/message-formatting.ts +19 -7
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -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 +14 -1
- package/src/schema.ts +36 -0
- package/src/session-handler/event-stream-state.test.ts +437 -7
- package/src/session-handler/event-stream-state.ts +389 -53
- package/src/session-handler/model-utils.test.ts +415 -0
- package/src/session-handler/model-utils.ts +249 -5
- package/src/session-handler/thread-session-runtime.ts +300 -215
- 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 +86 -6
- package/src/system-message.ts +66 -7
- package/src/task-runner.ts +136 -2
- package/src/task-schedule.test.ts +76 -0
- package/src/task-schedule.ts +95 -0
- 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 +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
package/dist/db.js
CHANGED
|
@@ -89,6 +89,20 @@ async function initializeDb() {
|
|
|
89
89
|
dbInstance = db;
|
|
90
90
|
return db;
|
|
91
91
|
}
|
|
92
|
+
// CREATE TABLE IF NOT EXISTS cannot drop columns. The first two session_sleeps
|
|
93
|
+
// shapes kept thread_id NOT NULL and posted_at. Inserts now omit thread_id, so
|
|
94
|
+
// existing DBs fail with SQLITE_CONSTRAINT_NOTNULL until those columns go away.
|
|
95
|
+
async function dropLegacySessionSleepsColumns(client) {
|
|
96
|
+
const info = await client.execute('PRAGMA table_info(session_sleeps)');
|
|
97
|
+
const columns = new Set(info.rows.map((row) => String(row.name)));
|
|
98
|
+
if (columns.has('thread_id')) {
|
|
99
|
+
await client.execute('ALTER TABLE session_sleeps DROP COLUMN thread_id');
|
|
100
|
+
}
|
|
101
|
+
if (columns.has('posted_at')) {
|
|
102
|
+
await client.execute('ALTER TABLE session_sleeps DROP COLUMN posted_at');
|
|
103
|
+
}
|
|
104
|
+
await client.execute("UPDATE session_sleeps SET status = 'planned' WHERE status = 'posted'");
|
|
105
|
+
}
|
|
92
106
|
async function migrateSchema({ db, client, }) {
|
|
93
107
|
const schemaPath = path.join(__dirname, '../src/schema.sql');
|
|
94
108
|
const sql = fs.readFileSync(schemaPath, 'utf-8');
|
|
@@ -120,7 +134,12 @@ async function migrateSchema({ db, client, }) {
|
|
|
120
134
|
"ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
|
|
121
135
|
'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
|
|
122
136
|
'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
|
|
137
|
+
'ALTER TABLE thread_sessions ADD COLUMN updated_at DATETIME',
|
|
123
138
|
'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
|
|
139
|
+
// First session_sleeps shape had thread_id only. Later ticks query these.
|
|
140
|
+
'ALTER TABLE session_sleeps ADD COLUMN delivery_id TEXT',
|
|
141
|
+
'ALTER TABLE session_sleeps ADD COLUMN attempts INTEGER DEFAULT 0',
|
|
142
|
+
'ALTER TABLE session_sleeps ADD COLUMN last_attempt_at DATETIME',
|
|
124
143
|
];
|
|
125
144
|
for (const stmt of alterStatements) {
|
|
126
145
|
await client.execute(stmt).catch(() => undefined);
|
|
@@ -140,10 +159,13 @@ async function migrateSchema({ db, client, }) {
|
|
|
140
159
|
"UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
|
|
141
160
|
"UPDATE bot_tokens SET proxy_url = REPLACE(proxy_url, 'discord-gateway.kimaki.xyz', 'discord-gateway.kimaki.dev') WHERE bot_mode = 'gateway' AND proxy_url LIKE '%discord-gateway.kimaki.xyz%'",
|
|
142
161
|
"UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
|
|
162
|
+
"UPDATE session_sleeps SET delivery_id = lower(hex(randomblob(16))) WHERE delivery_id IS NULL",
|
|
163
|
+
'UPDATE session_sleeps SET attempts = 0 WHERE attempts IS NULL',
|
|
143
164
|
];
|
|
144
165
|
for (const stmt of migrationStatements) {
|
|
145
166
|
await client.execute(stmt).catch(() => undefined);
|
|
146
167
|
}
|
|
168
|
+
await dropLegacySessionSleepsColumns(client);
|
|
147
169
|
// Migrate legacy thread_worktrees rows into thread_workspaces.
|
|
148
170
|
// Rows that already exist in thread_workspaces (by thread_id) are skipped.
|
|
149
171
|
await client.execute(`
|
package/dist/db.test.js
CHANGED
|
@@ -7,7 +7,8 @@ import { afterAll, describe, expect, test } from 'vitest';
|
|
|
7
7
|
import { closeDb, getDb } from './db.js';
|
|
8
8
|
import * as orm from 'drizzle-orm';
|
|
9
9
|
import * as schema from './schema.js';
|
|
10
|
-
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, setSessionModel, upsertSessionSleep, } from './database.js';
|
|
11
|
+
import { createClient } from '@libsql/client';
|
|
11
12
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
12
13
|
import { chooseLockPort } from './test-utils.js';
|
|
13
14
|
import { copyCurrentSessionModel } from './commands/model.js';
|
|
@@ -15,6 +16,171 @@ afterAll(async () => {
|
|
|
15
16
|
await closeDb();
|
|
16
17
|
});
|
|
17
18
|
describe('getDb', () => {
|
|
19
|
+
test('schema.sql creates every drizzle table', () => {
|
|
20
|
+
const schemaTs = fs.readFileSync(path.join(import.meta.dirname, 'schema.ts'), 'utf8');
|
|
21
|
+
const schemaSql = fs.readFileSync(path.join(import.meta.dirname, 'schema.sql'), 'utf8');
|
|
22
|
+
const tablesFromTs = [
|
|
23
|
+
...schemaTs.matchAll(/sqliteTable\('([^']+)'/g),
|
|
24
|
+
].map((match) => match[1]);
|
|
25
|
+
const tablesFromSql = [
|
|
26
|
+
...schemaSql.matchAll(/CREATE TABLE IF NOT EXISTS `([^`]+)`/g),
|
|
27
|
+
].map((match) => match[1]);
|
|
28
|
+
expect(new Set(tablesFromSql)).toEqual(new Set(tablesFromTs));
|
|
29
|
+
expect(tablesFromSql).toContain('session_sleeps');
|
|
30
|
+
});
|
|
31
|
+
test('adds session_sleeps delivery columns on databases created before that schema', async () => {
|
|
32
|
+
await closeDb();
|
|
33
|
+
const previousDbUrl = process.env['KIMAKI_DB_URL'];
|
|
34
|
+
const dbPath = path.join(process.cwd(), `tmp/test-db-legacy-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
|
|
35
|
+
try {
|
|
36
|
+
const client = createClient({ url: `file:${dbPath}` });
|
|
37
|
+
await client.execute(`
|
|
38
|
+
CREATE TABLE thread_sessions (
|
|
39
|
+
thread_id text PRIMARY KEY,
|
|
40
|
+
session_id text NOT NULL
|
|
41
|
+
)
|
|
42
|
+
`);
|
|
43
|
+
await client.execute(`
|
|
44
|
+
CREATE TABLE session_sleeps (
|
|
45
|
+
session_id text PRIMARY KEY,
|
|
46
|
+
thread_id text NOT NULL,
|
|
47
|
+
wake_at datetime NOT NULL,
|
|
48
|
+
reason text,
|
|
49
|
+
status text DEFAULT 'planned' NOT NULL,
|
|
50
|
+
created_at datetime DEFAULT CURRENT_TIMESTAMP
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
await client.execute(`
|
|
54
|
+
INSERT INTO thread_sessions (thread_id, session_id)
|
|
55
|
+
VALUES ('thr-legacy', 'ses-legacy')
|
|
56
|
+
`);
|
|
57
|
+
await client.execute(`
|
|
58
|
+
INSERT INTO session_sleeps (session_id, thread_id, wake_at, status)
|
|
59
|
+
VALUES ('ses-legacy', 'thr-legacy', '2020-01-01T00:00:00.000Z', 'planned')
|
|
60
|
+
`);
|
|
61
|
+
client.close();
|
|
62
|
+
process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
|
|
63
|
+
await getDb();
|
|
64
|
+
const due = await getDueSessionSleeps({
|
|
65
|
+
now: new Date('2026-08-21T00:00:00Z'),
|
|
66
|
+
retryAfterMs: 30_000,
|
|
67
|
+
limit: 10,
|
|
68
|
+
});
|
|
69
|
+
const legacy = due.find((row) => row.session_id === 'ses-legacy');
|
|
70
|
+
expect(legacy?.delivery_id).toBeTruthy();
|
|
71
|
+
expect(legacy?.attempts).toBe(0);
|
|
72
|
+
// The first table required thread_id. After the column was dropped from
|
|
73
|
+
// the schema, inserts that omit it must still work on existing databases.
|
|
74
|
+
await upsertSessionSleep({
|
|
75
|
+
sessionId: 'ses-legacy-new',
|
|
76
|
+
wakeAt: new Date('2026-08-26T00:00:00Z'),
|
|
77
|
+
reason: 'check the reply',
|
|
78
|
+
});
|
|
79
|
+
const created = await getSessionSleep({ sessionId: 'ses-legacy-new' });
|
|
80
|
+
expect(created?.status).toBe('planned');
|
|
81
|
+
expect(created?.reason).toBe('check the reply');
|
|
82
|
+
expect(created?.delivery_id).toBeTruthy();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await closeDb();
|
|
86
|
+
if (previousDbUrl === undefined) {
|
|
87
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
process.env['KIMAKI_DB_URL'] = previousDbUrl;
|
|
91
|
+
}
|
|
92
|
+
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
93
|
+
try {
|
|
94
|
+
fs.unlinkSync(file);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Test cleanup best effort.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test('rebuilds session_sleeps that still have posted_at from the intermediate schema', async () => {
|
|
103
|
+
await closeDb();
|
|
104
|
+
const previousDbUrl = process.env['KIMAKI_DB_URL'];
|
|
105
|
+
const dbPath = path.join(process.cwd(), `tmp/test-db-posted-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
|
|
106
|
+
try {
|
|
107
|
+
const client = createClient({ url: `file:${dbPath}` });
|
|
108
|
+
await client.execute(`
|
|
109
|
+
CREATE TABLE thread_sessions (
|
|
110
|
+
thread_id text PRIMARY KEY,
|
|
111
|
+
session_id text NOT NULL
|
|
112
|
+
)
|
|
113
|
+
`);
|
|
114
|
+
await client.execute(`
|
|
115
|
+
CREATE TABLE session_sleeps (
|
|
116
|
+
session_id text PRIMARY KEY,
|
|
117
|
+
thread_id text NOT NULL,
|
|
118
|
+
wake_at datetime NOT NULL,
|
|
119
|
+
reason text,
|
|
120
|
+
status text DEFAULT 'planned' NOT NULL,
|
|
121
|
+
delivery_id text NOT NULL,
|
|
122
|
+
attempts integer DEFAULT 0 NOT NULL,
|
|
123
|
+
last_attempt_at datetime,
|
|
124
|
+
posted_at datetime,
|
|
125
|
+
created_at datetime DEFAULT CURRENT_TIMESTAMP
|
|
126
|
+
)
|
|
127
|
+
`);
|
|
128
|
+
await client.execute(`
|
|
129
|
+
INSERT INTO thread_sessions (thread_id, session_id)
|
|
130
|
+
VALUES ('thr-posted', 'ses-posted')
|
|
131
|
+
`);
|
|
132
|
+
await client.execute(`
|
|
133
|
+
INSERT INTO session_sleeps (
|
|
134
|
+
session_id, thread_id, wake_at, status, delivery_id, posted_at
|
|
135
|
+
)
|
|
136
|
+
VALUES (
|
|
137
|
+
'ses-posted',
|
|
138
|
+
'thr-posted',
|
|
139
|
+
'2020-01-01T00:00:00.000Z',
|
|
140
|
+
'posted',
|
|
141
|
+
'del-posted',
|
|
142
|
+
'2020-01-01T00:00:01.000Z'
|
|
143
|
+
)
|
|
144
|
+
`);
|
|
145
|
+
client.close();
|
|
146
|
+
process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
|
|
147
|
+
await getDb();
|
|
148
|
+
const due = await getDueSessionSleeps({
|
|
149
|
+
now: new Date('2026-08-21T00:00:00Z'),
|
|
150
|
+
retryAfterMs: 30_000,
|
|
151
|
+
limit: 10,
|
|
152
|
+
});
|
|
153
|
+
const posted = due.find((row) => row.session_id === 'ses-posted');
|
|
154
|
+
expect(posted?.status).toBe('planned');
|
|
155
|
+
expect(posted?.delivery_id).toBe('del-posted');
|
|
156
|
+
await upsertSessionSleep({
|
|
157
|
+
sessionId: 'ses-posted',
|
|
158
|
+
wakeAt: new Date('2026-08-26T00:00:00Z'),
|
|
159
|
+
reason: 'retry after schema change',
|
|
160
|
+
});
|
|
161
|
+
const updated = await getSessionSleep({ sessionId: 'ses-posted' });
|
|
162
|
+
expect(updated?.status).toBe('planned');
|
|
163
|
+
expect(updated?.reason).toBe('retry after schema change');
|
|
164
|
+
expect(updated?.delivery_id).not.toBe('del-posted');
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await closeDb();
|
|
168
|
+
if (previousDbUrl === undefined) {
|
|
169
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
process.env['KIMAKI_DB_URL'] = previousDbUrl;
|
|
173
|
+
}
|
|
174
|
+
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(file);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Test cleanup best effort.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
18
184
|
test('creates sqlite file and migrates schema automatically', async () => {
|
|
19
185
|
const db = await getDb();
|
|
20
186
|
const [session] = await db.insert(schema.thread_sessions)
|
package/dist/discord-bot.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// suppresses these when shuttingDown is already set, and we removeAllListeners()
|
|
9
9
|
// before destroying the client to prevent late events from becoming uncaught.
|
|
10
10
|
import { DiscordOperationError } from './errors.js';
|
|
11
|
-
import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
|
|
11
|
+
import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, consumeSessionSleepWake, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
|
|
12
12
|
import { stopOpencodeServer, } from './opencode.js';
|
|
13
13
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
14
14
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
@@ -277,8 +277,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
277
277
|
discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
|
|
278
278
|
if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
279
279
|
discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
|
|
280
|
-
// Self-restart: cleanup, then
|
|
281
|
-
// restarts us. Without the wrapper this
|
|
280
|
+
// Self-restart: cleanup, then SIGKILL so the bin.ts wrapper
|
|
281
|
+
// restarts us. Without the wrapper this dies after logging a warning.
|
|
282
282
|
void selfRestart('gateway-reconnect-limit');
|
|
283
283
|
}
|
|
284
284
|
});
|
|
@@ -613,6 +613,21 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
613
613
|
}
|
|
614
614
|
void cancelPendingFileUpload(thread.id);
|
|
615
615
|
}
|
|
616
|
+
// A sleep wake only becomes a turn if it can still claim its own row.
|
|
617
|
+
// The claim fails when the user cancelled the sleep while the wake was
|
|
618
|
+
// being posted, so the superseded wake is dropped instead of starting a
|
|
619
|
+
// turn the user already replaced. Cancellation for every other kind of
|
|
620
|
+
// message happens inside runtime.enqueueIncoming().
|
|
621
|
+
const isSleepWake = Boolean(promptMarker?.sleepWake);
|
|
622
|
+
if (isSleepWake) {
|
|
623
|
+
const claimedWake = promptMarker?.sleepId
|
|
624
|
+
? await consumeSessionSleepWake({ deliveryId: promptMarker.sleepId })
|
|
625
|
+
: false;
|
|
626
|
+
if (!claimedWake) {
|
|
627
|
+
discordLogger.log(`[SLEEP] ignoring superseded wake in thread ${thread.id}`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
616
631
|
// Expensive pre-processing (voice transcription, context fetch,
|
|
617
632
|
// attachment download) runs inside the runtime's serialized
|
|
618
633
|
// preprocess chain, preserving Discord arrival order without
|
|
@@ -631,6 +646,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
631
646
|
permissions: cliInjectedPermissions,
|
|
632
647
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
633
648
|
parentSessionId: cliInjectedParentSessionId,
|
|
649
|
+
isSleepWake: isSleepWake || undefined,
|
|
634
650
|
noReply: isLeadingMentionToOtherUser || undefined,
|
|
635
651
|
sessionStartSource: sessionStartSource
|
|
636
652
|
? {
|
|
@@ -771,15 +787,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
771
787
|
rest: discordClient.rest,
|
|
772
788
|
});
|
|
773
789
|
}
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const result = await worktreePromise;
|
|
779
|
-
if (result instanceof Error)
|
|
780
|
-
return projectDirectory;
|
|
781
|
-
return result;
|
|
782
|
-
})();
|
|
790
|
+
const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
|
|
791
|
+
if (worktreeResult instanceof Error)
|
|
792
|
+
return;
|
|
793
|
+
const sessionDirectory = worktreeResult;
|
|
783
794
|
const channelRuntime = getOrCreateRuntime({
|
|
784
795
|
threadId: thread.id,
|
|
785
796
|
thread,
|
|
@@ -1055,18 +1066,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1055
1066
|
}
|
|
1056
1067
|
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
|
|
1057
1068
|
const botThreadStartSource = parseSessionStartSourceFromMarker(marker);
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
if (!worktreePromise) {
|
|
1063
|
-
return projectDirectory;
|
|
1064
|
-
}
|
|
1065
|
-
const result = await worktreePromise;
|
|
1066
|
-
if (result instanceof Error)
|
|
1067
|
-
return projectDirectory;
|
|
1068
|
-
return result;
|
|
1069
|
-
})();
|
|
1069
|
+
const worktreeResult = worktreePromise ? await worktreePromise : undefined;
|
|
1070
|
+
if (worktreeResult instanceof Error)
|
|
1071
|
+
return;
|
|
1072
|
+
const sessionDirectory = cwdDirectory ?? worktreeResult ?? projectDirectory;
|
|
1070
1073
|
const runtime = getOrCreateRuntime({
|
|
1071
1074
|
threadId: thread.id,
|
|
1072
1075
|
thread,
|
|
@@ -1244,13 +1247,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1244
1247
|
discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
|
|
1245
1248
|
});
|
|
1246
1249
|
});
|
|
1247
|
-
// Self-restart:
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
// for auto-restart support.
|
|
1252
|
-
// unreliable: process.exit(0) could kill the child before it started,
|
|
1253
|
-
// there was no backoff between restarts, and spawn failures were silent.
|
|
1250
|
+
// Self-restart: die so the bin.ts wrapper restarts us with exponential
|
|
1251
|
+
// backoff and crash-loop detection. process.exit() can hang joining native
|
|
1252
|
+
// worker threads after Discord gateway failures, so SIGKILL instead.
|
|
1253
|
+
// When running without the wrapper (e.g. `tsx src/cli.ts`), the process
|
|
1254
|
+
// just dies — use `tsx src/bin.ts` for auto-restart support.
|
|
1254
1255
|
let selfRestarting = false;
|
|
1255
1256
|
async function selfRestart(reason) {
|
|
1256
1257
|
if (selfRestarting) {
|
|
@@ -1259,6 +1260,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1259
1260
|
}
|
|
1260
1261
|
selfRestarting = true;
|
|
1261
1262
|
discordLogger.log(`Self-restarting (reason: ${reason})...`);
|
|
1263
|
+
setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000);
|
|
1262
1264
|
try {
|
|
1263
1265
|
await handleShutdown(reason, { skipExit: true });
|
|
1264
1266
|
}
|
|
@@ -1268,7 +1270,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1268
1270
|
if (!process.env.__KIMAKI_CHILD) {
|
|
1269
1271
|
discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
|
|
1270
1272
|
}
|
|
1271
|
-
process.
|
|
1273
|
+
process.kill(process.pid, 'SIGKILL');
|
|
1272
1274
|
}
|
|
1273
1275
|
process.on('SIGUSR2', () => {
|
|
1274
1276
|
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
|
@@ -98,7 +98,15 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
98
98
|
.toJSON(),
|
|
99
99
|
new SlashCommandBuilder()
|
|
100
100
|
.setName('merge-worktree')
|
|
101
|
-
.setDescription(truncateCommandDescription('
|
|
101
|
+
.setDescription(truncateCommandDescription('Merge worktree commits into the default branch'))
|
|
102
|
+
.addStringOption((option) => {
|
|
103
|
+
option
|
|
104
|
+
.setName('strategy')
|
|
105
|
+
.setDescription(truncateCommandDescription('How to merge commits (default: keep commits)'))
|
|
106
|
+
.setRequired(false)
|
|
107
|
+
.addChoices({ name: 'Keep commits (rebase)', value: 'rebase' }, { name: 'Squash into one commit', value: 'squash' });
|
|
108
|
+
return option;
|
|
109
|
+
})
|
|
102
110
|
.addStringOption((option) => {
|
|
103
111
|
option
|
|
104
112
|
.setName('target-branch')
|
package/dist/discord-utils.js
CHANGED
|
@@ -168,11 +168,6 @@ export async function archiveThread({ rest, threadId, parentChannelId, sessionId
|
|
|
168
168
|
if (updateResult instanceof Error) {
|
|
169
169
|
discordLogger.warn(`[archive-thread] ${updateResult.message}`);
|
|
170
170
|
}
|
|
171
|
-
const abortResult = await client.session.abort({ sessionID: sessionId })
|
|
172
|
-
.catch((e) => new Error('Failed to abort session', { cause: e }));
|
|
173
|
-
if (abortResult instanceof Error) {
|
|
174
|
-
discordLogger.warn(`[archive-thread] ${abortResult.message}`);
|
|
175
|
-
}
|
|
176
171
|
}
|
|
177
172
|
if (archiveDelay > 0) {
|
|
178
173
|
await new Promise((resolve) => {
|
|
@@ -185,6 +180,19 @@ export async function archiveThread({ rest, threadId, parentChannelId, sessionId
|
|
|
185
180
|
body: { archived: true },
|
|
186
181
|
});
|
|
187
182
|
}
|
|
183
|
+
export const DISCORD_THREAD_RENAME_TIMEOUT_MS = 3000;
|
|
184
|
+
// Discord rename can hang on the 3rd call (~2 per 10 min). Race so it never blocks.
|
|
185
|
+
export async function raceDiscordRename({ rename, timeoutMs = DISCORD_THREAD_RENAME_TIMEOUT_MS, }) {
|
|
186
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
187
|
+
return Promise.race([
|
|
188
|
+
rename,
|
|
189
|
+
new Promise((resolve) => {
|
|
190
|
+
timeoutSignal.addEventListener('abort', () => {
|
|
191
|
+
resolve('timeout');
|
|
192
|
+
});
|
|
193
|
+
}),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
188
196
|
/**
|
|
189
197
|
* Add a user as a thread member so the thread shows up in their Discord left
|
|
190
198
|
* sidebar. Discord only lists threads you are a member of, so a scheduled
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PermissionsBitField } from 'discord.js';
|
|
2
2
|
import { afterEach, describe, expect, test } from 'vitest';
|
|
3
|
-
import { hasKimakiAdminPermission, hasKimakiBotPermission, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
3
|
+
import { hasKimakiAdminPermission, hasKimakiBotPermission, raceDiscordRename, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
4
4
|
import { store } from './store.js';
|
|
5
5
|
describe('splitMarkdownForDiscord', () => {
|
|
6
6
|
test('never returns chunks over the max length with code fences', () => {
|
|
@@ -248,3 +248,19 @@ describe('resolveGuildMessageMember', () => {
|
|
|
248
248
|
await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
|
+
describe('raceDiscordRename', () => {
|
|
252
|
+
test('returns timeout when rename hangs', async () => {
|
|
253
|
+
const result = await raceDiscordRename({
|
|
254
|
+
rename: new Promise(() => { }),
|
|
255
|
+
timeoutMs: 20,
|
|
256
|
+
});
|
|
257
|
+
expect(result).toBe('timeout');
|
|
258
|
+
});
|
|
259
|
+
test('returns rename result when it finishes first', async () => {
|
|
260
|
+
const result = await raceDiscordRename({
|
|
261
|
+
rename: Promise.resolve('ok'),
|
|
262
|
+
timeoutMs: 1000,
|
|
263
|
+
});
|
|
264
|
+
expect(result).toBe('ok');
|
|
265
|
+
});
|
|
266
|
+
});
|
package/dist/errors.js
CHANGED
|
@@ -109,6 +109,11 @@ export class OpenCodeSdkError extends errore.createTaggedError({
|
|
|
109
109
|
message: 'OpenCode SDK call failed: $operation',
|
|
110
110
|
}) {
|
|
111
111
|
}
|
|
112
|
+
export class InvalidModelError extends errore.createTaggedError({
|
|
113
|
+
name: 'InvalidModelError',
|
|
114
|
+
message: 'Invalid model "$model": $reason',
|
|
115
|
+
}) {
|
|
116
|
+
}
|
|
112
117
|
export class FilesystemOperationError extends errore.createTaggedError({
|
|
113
118
|
name: 'FilesystemOperationError',
|
|
114
119
|
message: 'Filesystem operation failed: $operation',
|
package/dist/exec-async.js
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
|
-
import { exec } from 'node:child_process';
|
|
1
|
+
import { exec, execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const DEFAULT_EXEC_TIMEOUT_MS = 10_000;
|
|
4
4
|
const _execAsync = promisify(exec);
|
|
5
|
+
const _execFileAsync = promisify(execFile);
|
|
5
6
|
export function execAsync(command, options) {
|
|
6
7
|
const timeoutMs = options?.timeout || DEFAULT_EXEC_TIMEOUT_MS;
|
|
7
|
-
const
|
|
8
|
+
const childPromise = typeof command === 'string'
|
|
9
|
+
? _execAsync(command, options)
|
|
10
|
+
: _execFileAsync(command.command, command.args, options);
|
|
11
|
+
const execPromise = childPromise.then(({ stdout, stderr }) => {
|
|
12
|
+
return {
|
|
13
|
+
stdout: typeof stdout === 'string' ? stdout : stdout.toString(),
|
|
14
|
+
stderr: typeof stderr === 'string' ? stderr : stderr.toString(),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
const commandLabel = typeof command === 'string'
|
|
18
|
+
? command
|
|
19
|
+
: [command.command, ...command.args].join(' ');
|
|
8
20
|
let timer;
|
|
9
21
|
const timeoutPromise = new Promise((_, reject) => {
|
|
10
22
|
timer = setTimeout(() => {
|
|
11
|
-
const pid =
|
|
23
|
+
const pid = childPromise.child?.pid;
|
|
12
24
|
if (pid) {
|
|
13
25
|
try {
|
|
14
26
|
process.kill(-pid, 'SIGTERM');
|
|
15
27
|
}
|
|
16
28
|
catch {
|
|
17
|
-
|
|
29
|
+
childPromise.child?.kill('SIGTERM');
|
|
18
30
|
}
|
|
19
31
|
}
|
|
20
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms: ${
|
|
32
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${commandLabel}`));
|
|
21
33
|
}, timeoutMs);
|
|
22
34
|
});
|
|
23
35
|
return Promise.race([execPromise, timeoutPromise]).finally(() => {
|
|
@@ -68,14 +68,14 @@ describe('external directory permissions', () => {
|
|
|
68
68
|
⬥ reading external directory
|
|
69
69
|
┣ read *probe.txt*
|
|
70
70
|
⬥ external-directory-probe-done
|
|
71
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
71
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
|
|
72
72
|
--- from: user (external-directory-tester)
|
|
73
73
|
EXTERNAL_DIRECTORY_PROBE_MARKER followup
|
|
74
74
|
--- from: assistant (TestBot)
|
|
75
75
|
⬥ reading external directory
|
|
76
76
|
┣ read *probe.txt*
|
|
77
77
|
⬥ external-directory-probe-done
|
|
78
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
78
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
79
79
|
`);
|
|
80
80
|
expect(text).not.toContain('Permission Required');
|
|
81
81
|
});
|