kimaki 0.22.0 → 0.23.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/anthropic-auth-plugin.js +78 -33
- package/dist/anthropic-auth-state.js +62 -27
- package/dist/anthropic-auth-state.test.js +55 -2
- package/dist/channel-management.js +29 -6
- package/dist/cli-commands/project.js +6 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/new-worktree.js +9 -3
- package/dist/commands/queue.js +12 -2
- package/dist/database.js +7 -8
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +9 -0
- package/dist/hrana-server.js +19 -0
- package/dist/oauth-rotation-shared.js +21 -0
- package/dist/opencode.js +73 -4
- package/dist/schema.js +3 -0
- package/dist/session-handler/global-event-listener.js +38 -6
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime.js +19 -3
- package/dist/system-message.js +34 -20
- package/dist/system-message.test.js +34 -20
- package/dist/task-runner.js +8 -4
- package/dist/worktree-lifecycle.e2e.test.js +16 -2
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +11 -8
- package/src/anthropic-auth-plugin.ts +82 -35
- package/src/anthropic-auth-state.test.ts +73 -1
- package/src/anthropic-auth-state.ts +85 -25
- package/src/channel-management.ts +35 -6
- package/src/cli-commands/project.ts +6 -1
- package/src/commands/login.ts +1 -1
- package/src/commands/new-worktree.ts +14 -3
- package/src/commands/queue.ts +13 -2
- package/src/database.ts +8 -9
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +12 -0
- package/src/hrana-server.ts +19 -0
- package/src/oauth-rotation-shared.ts +22 -0
- package/src/opencode.ts +90 -6
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/global-event-listener.ts +38 -6
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +25 -3
- package/src/system-message.test.ts +34 -20
- package/src/system-message.ts +34 -20
- package/src/task-runner.ts +8 -4
- package/src/worktree-lifecycle.e2e.test.ts +18 -2
package/dist/opencode.js
CHANGED
|
@@ -22,7 +22,7 @@ import readline from 'node:readline';
|
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
import { createOpencodeClient, } from '@opencode-ai/sdk/v2';
|
|
25
|
-
import { restartGlobalEventListener } from './session-handler/global-event-listener.js';
|
|
25
|
+
import { restartGlobalEventListener, waitForGlobalEventListener, } from './session-handler/global-event-listener.js';
|
|
26
26
|
import { getDataDir, getLockPort, } from './config.js';
|
|
27
27
|
import { store } from './store.js';
|
|
28
28
|
import { getHranaUrl } from './hrana-server.js';
|
|
@@ -195,6 +195,10 @@ function killSingleServerProcessNow({ reason, }) {
|
|
|
195
195
|
if (!singleServer) {
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
|
+
// Never kill a server we didn't spawn (discovered from another process)
|
|
199
|
+
if (singleServer.discovered || !singleServer.process) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
198
202
|
const serverProcess = singleServer.process;
|
|
199
203
|
const pid = serverProcess.pid;
|
|
200
204
|
if (!pid || serverProcess.killed) {
|
|
@@ -375,16 +379,70 @@ function ensureOpencodeHomeDirectories({ directories, }) {
|
|
|
375
379
|
fs.mkdirSync(directory, { recursive: true });
|
|
376
380
|
});
|
|
377
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Try to discover an OpenCode server already running in the bot process.
|
|
384
|
+
* Queries the hrana server on the lock port for the OpenCode server port,
|
|
385
|
+
* then verifies the server is healthy. Returns null if no server found.
|
|
386
|
+
*/
|
|
387
|
+
async function discoverExistingServer() {
|
|
388
|
+
const lockPort = getLockPort();
|
|
389
|
+
try {
|
|
390
|
+
const portResponse = await requestHealthcheck({
|
|
391
|
+
url: `http://127.0.0.1:${lockPort}/kimaki/opencode-port`,
|
|
392
|
+
timeoutMs: 2000,
|
|
393
|
+
});
|
|
394
|
+
if (portResponse.status !== 200) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
const parsed = JSON.parse(portResponse.body);
|
|
398
|
+
const port = parsed?.port;
|
|
399
|
+
if (typeof port !== 'number') {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
// Verify the OpenCode server is actually healthy
|
|
403
|
+
const healthResponse = await requestHealthcheck({
|
|
404
|
+
url: `http://127.0.0.1:${port}/api/health`,
|
|
405
|
+
timeoutMs: 2000,
|
|
406
|
+
});
|
|
407
|
+
if (healthResponse.status >= 500) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
opencodeLogger.log(`Discovered existing OpenCode server on port ${port} via hrana lock port ${lockPort}`);
|
|
411
|
+
return {
|
|
412
|
+
process: null,
|
|
413
|
+
port,
|
|
414
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
415
|
+
discovered: true,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// Connection refused or other network error — no bot running
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
378
423
|
async function ensureSingleServer({ directory, } = {}) {
|
|
379
424
|
const startupDirectory = directory || preferredStartupDirectory || undefined;
|
|
380
|
-
if (singleServer && !singleServer.process
|
|
425
|
+
if (singleServer && !singleServer.process?.killed) {
|
|
381
426
|
return singleServer;
|
|
382
427
|
}
|
|
383
|
-
// Deduplicate concurrent startup attempts
|
|
428
|
+
// Deduplicate concurrent startup attempts (covers both discovery and spawn)
|
|
384
429
|
if (startingServer) {
|
|
385
430
|
return startingServer;
|
|
386
431
|
}
|
|
387
|
-
|
|
432
|
+
// Wrap discovery + spawn in a single shared promise so concurrent callers
|
|
433
|
+
// don't each run discoverExistingServer() and then each spawn a server.
|
|
434
|
+
startingServer = (async () => {
|
|
435
|
+
// Try to discover an already-running server from the bot process via
|
|
436
|
+
// the hrana server's /kimaki/opencode-port endpoint. This lets CLI
|
|
437
|
+
// subcommands (kimaki session list, archive, wait, etc.) reuse the
|
|
438
|
+
// bot's OpenCode server instead of spawning a redundant one.
|
|
439
|
+
const discovered = await discoverExistingServer();
|
|
440
|
+
if (discovered) {
|
|
441
|
+
singleServer = discovered;
|
|
442
|
+
return discovered;
|
|
443
|
+
}
|
|
444
|
+
return startSingleServer({ directory: startupDirectory });
|
|
445
|
+
})();
|
|
388
446
|
try {
|
|
389
447
|
return await startingServer;
|
|
390
448
|
}
|
|
@@ -579,6 +637,7 @@ async function startSingleServer({ directory, } = {}) {
|
|
|
579
637
|
OPENCODE_PORT: port.toString(),
|
|
580
638
|
KIMAKI: '1',
|
|
581
639
|
OPENCODE_EXPERIMENTAL_WORKSPACES: 'true',
|
|
640
|
+
OPENCODE_ENABLE_EXA: '1',
|
|
582
641
|
KIMAKI_DATA_DIR: getDataDir(),
|
|
583
642
|
KIMAKI_LOCK_PORT: getLockPort().toString(),
|
|
584
643
|
KIMAKI_PARENT_LOCK_PORT: getLockPort().toString(),
|
|
@@ -991,6 +1050,14 @@ export async function stopOpencodeServer() {
|
|
|
991
1050
|
return false;
|
|
992
1051
|
}
|
|
993
1052
|
const server = singleServer;
|
|
1053
|
+
// For discovered servers (from another process), just clear local state
|
|
1054
|
+
// without killing the process we don't own.
|
|
1055
|
+
if (server.discovered || !server.process) {
|
|
1056
|
+
singleServer = null;
|
|
1057
|
+
clientCache.clear();
|
|
1058
|
+
serverRetryCount = 0;
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
994
1061
|
opencodeLogger.log(`Stopping opencode server (pid: ${server.process.pid}, port: ${server.port})`);
|
|
995
1062
|
if (!server.process.killed) {
|
|
996
1063
|
const killResult = errore.try(() => {
|
|
@@ -1032,5 +1099,7 @@ export async function restartOpencodeServer() {
|
|
|
1032
1099
|
const result = await ensureSingleServer();
|
|
1033
1100
|
if (result instanceof Error)
|
|
1034
1101
|
return result;
|
|
1102
|
+
restartGlobalEventListener();
|
|
1103
|
+
await waitForGlobalEventListener();
|
|
1035
1104
|
return true;
|
|
1036
1105
|
}
|
package/dist/schema.js
CHANGED
|
@@ -59,6 +59,9 @@ export const channel_directories = sqliteCore.sqliteTable('channel_directories',
|
|
|
59
59
|
channel_id: sqliteCore.text('channel_id').primaryKey().notNull(),
|
|
60
60
|
directory: sqliteCore.text('directory').notNull(),
|
|
61
61
|
channel_type: sqliteCore.text('channel_type', { enum: ['text', 'voice'] }).notNull(),
|
|
62
|
+
// Guild that owns this channel. Used to scope the default-channel tombstone
|
|
63
|
+
// check so multi-guild setups don't cross-block each other.
|
|
64
|
+
guild_id: sqliteCore.text('guild_id'),
|
|
62
65
|
created_at: datetime('created_at').default(orm.sql `CURRENT_TIMESTAMP`),
|
|
63
66
|
});
|
|
64
67
|
export const bot_api_keys = sqliteCore.sqliteTable('bot_api_keys', {
|
|
@@ -18,14 +18,26 @@ function isAbortError(err) {
|
|
|
18
18
|
return true;
|
|
19
19
|
return false;
|
|
20
20
|
}
|
|
21
|
-
function delay(ms) {
|
|
22
|
-
return new Promise((resolve) =>
|
|
21
|
+
function delay(ms, signal) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
resolve();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const timeout = setTimeout(resolve, ms);
|
|
28
|
+
signal.addEventListener('abort', () => {
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
resolve();
|
|
31
|
+
}, { once: true });
|
|
32
|
+
});
|
|
23
33
|
}
|
|
24
34
|
// ── State ──────────────────────────────────────────────────────
|
|
25
35
|
const callbacks = new Map();
|
|
26
36
|
let loopRunning = false;
|
|
27
37
|
let disposed = false;
|
|
28
38
|
let controller = null;
|
|
39
|
+
let connected = false;
|
|
40
|
+
const connectionWaiters = new Set();
|
|
29
41
|
// ── Public API ─────────────────────────────────────────────────
|
|
30
42
|
/**
|
|
31
43
|
* Register a thread runtime to receive global events. Every event from the
|
|
@@ -53,6 +65,7 @@ export function unregisterEventListener(threadId) {
|
|
|
53
65
|
export function disposeGlobalEventListener() {
|
|
54
66
|
disposed = true;
|
|
55
67
|
loopRunning = false;
|
|
68
|
+
connected = false;
|
|
56
69
|
controller?.abort();
|
|
57
70
|
controller = null;
|
|
58
71
|
callbacks.clear();
|
|
@@ -64,8 +77,18 @@ export function disposeGlobalEventListener() {
|
|
|
64
77
|
export function restartGlobalEventListener() {
|
|
65
78
|
if (disposed)
|
|
66
79
|
return;
|
|
80
|
+
connected = false;
|
|
67
81
|
controller?.abort();
|
|
68
82
|
}
|
|
83
|
+
/** Wait until the event stream is connected before starting event-producing work. */
|
|
84
|
+
export function waitForGlobalEventListener() {
|
|
85
|
+
if (callbacks.size === 0 || connected)
|
|
86
|
+
return Promise.resolve();
|
|
87
|
+
ensureListenerRunning();
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
connectionWaiters.add(resolve);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
69
92
|
// ── Internals ──────────────────────────────────────────────────
|
|
70
93
|
// Lazy subscription to opencode server lifecycle. Deferred to avoid
|
|
71
94
|
// circular import: global-event-listener imports opencode.ts which
|
|
@@ -128,7 +151,7 @@ async function runEventLoop() {
|
|
|
128
151
|
return;
|
|
129
152
|
}
|
|
130
153
|
logger.warn(`[GLOBAL LISTENER] No OpenCode server available, retrying in ${backoffMs}ms`);
|
|
131
|
-
await delay(backoffMs);
|
|
154
|
+
await delay(backoffMs, signal);
|
|
132
155
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
|
133
156
|
continue;
|
|
134
157
|
}
|
|
@@ -143,11 +166,15 @@ async function runEventLoop() {
|
|
|
143
166
|
continue;
|
|
144
167
|
}
|
|
145
168
|
logger.warn(`[GLOBAL LISTENER] Subscribe failed, retrying in ${backoffMs}ms:`, subscribeResult.message);
|
|
146
|
-
await delay(backoffMs);
|
|
169
|
+
await delay(backoffMs, signal);
|
|
147
170
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
|
148
171
|
continue;
|
|
149
172
|
}
|
|
150
173
|
const events = subscribeResult.stream;
|
|
174
|
+
connected = true;
|
|
175
|
+
for (const resolve of connectionWaiters)
|
|
176
|
+
resolve();
|
|
177
|
+
connectionWaiters.clear();
|
|
151
178
|
logger.log('[GLOBAL LISTENER] Connected to global event stream');
|
|
152
179
|
let receivedAnyEvent = false;
|
|
153
180
|
const iterResult = await (async () => {
|
|
@@ -157,6 +184,7 @@ async function runEventLoop() {
|
|
|
157
184
|
}
|
|
158
185
|
})()
|
|
159
186
|
.catch((e) => new OpenCodeSdkError({ operation: 'event.iterate', cause: e }));
|
|
187
|
+
connected = false;
|
|
160
188
|
if (receivedAnyEvent) {
|
|
161
189
|
backoffMs = 500;
|
|
162
190
|
}
|
|
@@ -168,12 +196,16 @@ async function runEventLoop() {
|
|
|
168
196
|
continue;
|
|
169
197
|
}
|
|
170
198
|
logger.warn(`[GLOBAL LISTENER] Stream broke, reconnecting in ${backoffMs}ms:`, iterResult.message);
|
|
171
|
-
await delay(backoffMs);
|
|
199
|
+
await delay(backoffMs, signal);
|
|
172
200
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
|
173
201
|
}
|
|
174
202
|
else {
|
|
203
|
+
if (signal.aborted) {
|
|
204
|
+
backoffMs = 500;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
175
207
|
logger.log(`[GLOBAL LISTENER] Stream ended normally, reconnecting in ${backoffMs}ms`);
|
|
176
|
-
await delay(backoffMs);
|
|
208
|
+
await delay(backoffMs, signal);
|
|
177
209
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
|
178
210
|
}
|
|
179
211
|
}
|
|
@@ -110,7 +110,9 @@ export function dequeueItem(threadId) {
|
|
|
110
110
|
return next;
|
|
111
111
|
}
|
|
112
112
|
export function clearQueueItems(threadId) {
|
|
113
|
+
const items = store.getState().threads.get(threadId)?.queueItems.slice() ?? [];
|
|
113
114
|
updateThread(threadId, (t) => ({ ...t, queueItems: [] }));
|
|
115
|
+
return items;
|
|
114
116
|
}
|
|
115
117
|
export function removeQueueItemAtPosition(threadId, position) {
|
|
116
118
|
if (position < 1) {
|
|
@@ -13,7 +13,7 @@ import * as errore from 'errore';
|
|
|
13
13
|
import * as threadState from './thread-runtime-state.js';
|
|
14
14
|
import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissions, parsePermissionRules, writeInjectionGuardConfig, extractSdkErrorMessage, } from '../opencode.js';
|
|
15
15
|
import { isAbortError } from '../utils.js';
|
|
16
|
-
import { registerEventListener, unregisterEventListener, } from './global-event-listener.js';
|
|
16
|
+
import { registerEventListener, unregisterEventListener, waitForGlobalEventListener, } from './global-event-listener.js';
|
|
17
17
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
18
18
|
import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
19
19
|
import { formatPart } from '../message-formatting.js';
|
|
@@ -1663,6 +1663,20 @@ export class ThreadSessionRuntime {
|
|
|
1663
1663
|
force: true,
|
|
1664
1664
|
repulseTyping: false,
|
|
1665
1665
|
});
|
|
1666
|
+
// Skip footer if model produced no visible output (no text, no tool calls,
|
|
1667
|
+
// just step-start/step-finish lifecycle parts). This happens when the model
|
|
1668
|
+
// decides not to respond.
|
|
1669
|
+
const hasVisibleOutput = assistantMessageIds.some((msgId) => {
|
|
1670
|
+
const parts = this.getBufferedParts(msgId);
|
|
1671
|
+
return parts.some((part) => part.type !== 'step-start' && part.type !== 'step-finish');
|
|
1672
|
+
});
|
|
1673
|
+
if (!hasVisibleOutput) {
|
|
1674
|
+
this.stopTyping();
|
|
1675
|
+
this.resetPerRunState();
|
|
1676
|
+
this.clearBufferedPartsForMessages(assistantMessageIds);
|
|
1677
|
+
logger.log(`[ASSISTANT COMPLETED] no visible output, skipping footer for message ${completedMessageId} sessionId=${sessionId}`);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1666
1680
|
this.stopTyping();
|
|
1667
1681
|
const turnStartTime = getCurrentTurnStartTime({
|
|
1668
1682
|
events: this.eventBuffer,
|
|
@@ -2281,6 +2295,7 @@ export class ThreadSessionRuntime {
|
|
|
2281
2295
|
...variantField,
|
|
2282
2296
|
...(input.noReply ? { noReply: true } : {}),
|
|
2283
2297
|
};
|
|
2298
|
+
await waitForGlobalEventListener();
|
|
2284
2299
|
const promptResult = await getClient().session.promptAsync(request)
|
|
2285
2300
|
.catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }));
|
|
2286
2301
|
if (promptResult instanceof Error || promptResult.error) {
|
|
@@ -2591,9 +2606,9 @@ export class ThreadSessionRuntime {
|
|
|
2591
2606
|
? SILENT_MESSAGE_FLAGS
|
|
2592
2607
|
: NOTIFY_MESSAGE_FLAGS;
|
|
2593
2608
|
}
|
|
2594
|
-
/** Clear all queued messages. */
|
|
2609
|
+
/** Clear all queued messages. Returns the removed items. */
|
|
2595
2610
|
clearQueue() {
|
|
2596
|
-
threadState.clearQueueItems(this.threadId);
|
|
2611
|
+
return threadState.clearQueueItems(this.threadId);
|
|
2597
2612
|
}
|
|
2598
2613
|
/** Remove a queued message by its 1-based position. */
|
|
2599
2614
|
removeQueuePosition(position) {
|
|
@@ -2987,6 +3002,7 @@ export class ThreadSessionRuntime {
|
|
|
2987
3002
|
logger.log(`[DISPATCH] Successfully ran command for session ${session.id}`);
|
|
2988
3003
|
return;
|
|
2989
3004
|
}
|
|
3005
|
+
await waitForGlobalEventListener();
|
|
2990
3006
|
const promptResponse = await getClient().session.promptAsync({
|
|
2991
3007
|
sessionID: session.id,
|
|
2992
3008
|
directory: this.sdkDirectory,
|
package/dist/system-message.js
CHANGED
|
@@ -211,7 +211,7 @@ ${escapePromptText(repliedMessage.text)}
|
|
|
211
211
|
: []),
|
|
212
212
|
...(worktree && worktreeChanged
|
|
213
213
|
? [
|
|
214
|
-
`<system-reminder>\nThis session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.\n- New worktree path (new cwd / pwd, edit files here): ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo path (previous folder, DO NOT TOUCH): ${worktree.mainRepoDirectory}\n- To find the base branch (the branch this worktree was created from): \`git -C ${worktree.mainRepoDirectory} symbolic-ref --short HEAD\`\n- To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`\nYou MUST read, write, and edit files only under the new worktree path ${worktree.worktreeDirectory}. You MUST NOT read, write, or edit any files under the main repo path ${worktree.mainRepoDirectory} — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default.
|
|
214
|
+
`<system-reminder>\nThis session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.\n- New worktree path (new cwd / pwd, edit files here): ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo path (previous folder, DO NOT TOUCH): ${worktree.mainRepoDirectory}\n- To find the base branch (the branch this worktree was created from): \`git -C ${worktree.mainRepoDirectory} symbolic-ref --short HEAD\`\n- To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`\nYou MUST read, write, and edit files only under the new worktree path ${worktree.worktreeDirectory}. You MUST NOT read, write, or edit any files under the main repo path ${worktree.mainRepoDirectory} — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. To merge this worktree into the main branch, run \`kimaki merge-worktree\`. If it reports rebase conflicts, resolve them and rerun until it succeeds.\n</system-reminder>`,
|
|
215
215
|
]
|
|
216
216
|
: []),
|
|
217
217
|
];
|
|
@@ -447,18 +447,30 @@ When the user specifies a time without a timezone, ask them to confirm their tim
|
|
|
447
447
|
|
|
448
448
|
\`--wait\` is incompatible with \`--send-at\` because scheduled tasks run in the future.
|
|
449
449
|
|
|
450
|
-
|
|
450
|
+
Keep scheduled task prompts **short**. The prompt text becomes the first message in the Discord thread, so long prompts clutter the channel. Instead of inlining the full task description in \`--prompt\`, write a markdown file in the project's \`tasks/\` folder and reference it:
|
|
451
451
|
|
|
452
|
-
|
|
452
|
+
\`\`\`bash
|
|
453
|
+
kimaki send --channel ${channelId} --prompt 'Read tasks/weekly-test-suite.md and follow instructions' --send-at '0 9 * * 1' --agent <current_agent>${parentSessionArg}${userArg}
|
|
454
|
+
\`\`\`
|
|
455
|
+
|
|
456
|
+
The task file should contain all the detail: goal, constraints, expected output, completion criteria. Use this frontmatter format:
|
|
457
|
+
|
|
458
|
+
\`\`\`yaml
|
|
459
|
+
---
|
|
460
|
+
title: Weekly test suite
|
|
461
|
+
description: >
|
|
462
|
+
Managed by kimaki scheduled task. Do not move or delete this file
|
|
463
|
+
without also updating the kimaki task (kimaki task list / kimaki task edit).
|
|
464
|
+
---
|
|
465
|
+
\`\`\`
|
|
466
|
+
|
|
467
|
+
For simple reminders and notifications (\`--notify-only\`), inline the prompt directly since there is no AI session to read files.
|
|
453
468
|
|
|
454
|
-
Notification strategy
|
|
469
|
+
Notification strategy:
|
|
455
470
|
- NEVER use \`@username\` (e.g. \`@Tommy\`) directly in task prompts. The prompt text becomes the first message in the thread, so a raw \`@\` mention triggers an actual Discord ping every time the task fires. Instead, wrap it in inline code like \`\\\`@Tommy\\\`\`, or use Discord user ID mentions like \`<@USER_ID>\` only in the body of the prompt where the agent will process it, not in the opening line.
|
|
456
|
-
-
|
|
457
|
-
-
|
|
458
|
-
-
|
|
459
|
-
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
460
|
-
- If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
|
|
461
|
-
- Example no-op cleanup command: \`kimaki session archive ${archiveTarget}\`
|
|
471
|
+
- If a task needs user attention, add "mention the user via Discord user ID when task requires user review" in the task md file.
|
|
472
|
+
- With \`--user\`, the user is added to the thread and receives thread-level notifications.
|
|
473
|
+
- If a scheduled task completes with no actionable result, archive the session: \`kimaki session archive ${archiveTarget}\`
|
|
462
474
|
|
|
463
475
|
Manage scheduled tasks with:
|
|
464
476
|
|
|
@@ -468,20 +480,22 @@ kimaki task delete <id>
|
|
|
468
480
|
|
|
469
481
|
\`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
|
|
470
482
|
|
|
483
|
+
**Never duplicate tasks to run more frequently.** If a task should run twice a day (morning and evening), edit the existing task's cron expression instead of creating a second task. Cron supports comma-separated hours:
|
|
484
|
+
|
|
485
|
+
\`\`\`bash
|
|
486
|
+
# runs at 9:00 UTC and 18:00 UTC every day
|
|
487
|
+
kimaki task edit <id> --send-at '0 9,18 * * *'
|
|
488
|
+
\`\`\`
|
|
489
|
+
|
|
471
490
|
Use case patterns:
|
|
472
|
-
- Reminder flows: create deadline reminders
|
|
473
|
-
- Proactive reminders: when you encounter time-sensitive information
|
|
474
|
-
- Weekly QA:
|
|
475
|
-
-
|
|
476
|
-
- Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
|
|
477
|
-
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive ${archiveTarget}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
478
|
-
- Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
|
|
491
|
+
- Reminder flows: create deadline reminders with one-time \`--send-at\` and \`--notify-only\`; mention only if action is required.
|
|
492
|
+
- Proactive reminders: when you encounter time-sensitive information (API key expiration, certificate renewal, trial ending), schedule a \`--notify-only\` reminder before the deadline. Always tell the user you scheduled the reminder so they know.
|
|
493
|
+
- Weekly QA / recurring maintenance: write the full task spec in \`tasks/\` and schedule a short prompt pointing to it.
|
|
494
|
+
- Thread reminders: when the user says "remind me about this in 2 hours", use \`--send-at\` with \`--thread\` to resurface the current thread:
|
|
479
495
|
|
|
480
496
|
kimaki send ${sendToSelfTarget} --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
|
|
481
497
|
|
|
482
|
-
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
|
|
483
|
-
|
|
484
|
-
Scheduled tasks can maintain project memory by reading and updating an md file in the repository (for example \`docs/automation-notes.md\`) on each run.
|
|
498
|
+
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
|
|
485
499
|
|
|
486
500
|
Worktrees are useful for handing off parallel tasks that need to be isolated from each other (each session works on its own branch).
|
|
487
501
|
|
|
@@ -264,18 +264,30 @@ describe('system-message', () => {
|
|
|
264
264
|
|
|
265
265
|
\`--wait\` is incompatible with \`--send-at\` because scheduled tasks run in the future.
|
|
266
266
|
|
|
267
|
-
|
|
267
|
+
Keep scheduled task prompts **short**. The prompt text becomes the first message in the Discord thread, so long prompts clutter the channel. Instead of inlining the full task description in \`--prompt\`, write a markdown file in the project's \`tasks/\` folder and reference it:
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
\`\`\`bash
|
|
270
|
+
kimaki send --channel chan_123 --prompt 'Read tasks/weekly-test-suite.md and follow instructions' --send-at '0 9 * * 1' --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
|
|
271
|
+
\`\`\`
|
|
272
|
+
|
|
273
|
+
The task file should contain all the detail: goal, constraints, expected output, completion criteria. Use this frontmatter format:
|
|
274
|
+
|
|
275
|
+
\`\`\`yaml
|
|
276
|
+
---
|
|
277
|
+
title: Weekly test suite
|
|
278
|
+
description: >
|
|
279
|
+
Managed by kimaki scheduled task. Do not move or delete this file
|
|
280
|
+
without also updating the kimaki task (kimaki task list / kimaki task edit).
|
|
281
|
+
---
|
|
282
|
+
\`\`\`
|
|
283
|
+
|
|
284
|
+
For simple reminders and notifications (\`--notify-only\`), inline the prompt directly since there is no AI session to read files.
|
|
270
285
|
|
|
271
|
-
Notification strategy
|
|
286
|
+
Notification strategy:
|
|
272
287
|
- NEVER use \`@username\` (e.g. \`@Tommy\`) directly in task prompts. The prompt text becomes the first message in the thread, so a raw \`@\` mention triggers an actual Discord ping every time the task fires. Instead, wrap it in inline code like \`\\\`@Tommy\\\`\`, or use Discord user ID mentions like \`<@USER_ID>\` only in the body of the prompt where the agent will process it, not in the opening line.
|
|
273
|
-
-
|
|
274
|
-
-
|
|
275
|
-
-
|
|
276
|
-
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
277
|
-
- If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
|
|
278
|
-
- Example no-op cleanup command: \`kimaki session archive thread_123 (or --session ses_123)\`
|
|
288
|
+
- If a task needs user attention, add "mention the user via Discord user ID when task requires user review" in the task md file.
|
|
289
|
+
- With \`--user\`, the user is added to the thread and receives thread-level notifications.
|
|
290
|
+
- If a scheduled task completes with no actionable result, archive the session: \`kimaki session archive thread_123 (or --session ses_123)\`
|
|
279
291
|
|
|
280
292
|
Manage scheduled tasks with:
|
|
281
293
|
|
|
@@ -285,20 +297,22 @@ describe('system-message', () => {
|
|
|
285
297
|
|
|
286
298
|
\`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
|
|
287
299
|
|
|
300
|
+
**Never duplicate tasks to run more frequently.** If a task should run twice a day (morning and evening), edit the existing task's cron expression instead of creating a second task. Cron supports comma-separated hours:
|
|
301
|
+
|
|
302
|
+
\`\`\`bash
|
|
303
|
+
# runs at 9:00 UTC and 18:00 UTC every day
|
|
304
|
+
kimaki task edit <id> --send-at '0 9,18 * * *'
|
|
305
|
+
\`\`\`
|
|
306
|
+
|
|
288
307
|
Use case patterns:
|
|
289
|
-
- Reminder flows: create deadline reminders
|
|
290
|
-
- Proactive reminders: when you encounter time-sensitive information
|
|
291
|
-
- Weekly QA:
|
|
292
|
-
-
|
|
293
|
-
- Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
|
|
294
|
-
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive thread_123 (or --session ses_123)\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
295
|
-
- Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
|
|
308
|
+
- Reminder flows: create deadline reminders with one-time \`--send-at\` and \`--notify-only\`; mention only if action is required.
|
|
309
|
+
- Proactive reminders: when you encounter time-sensitive information (API key expiration, certificate renewal, trial ending), schedule a \`--notify-only\` reminder before the deadline. Always tell the user you scheduled the reminder so they know.
|
|
310
|
+
- Weekly QA / recurring maintenance: write the full task spec in \`tasks/\` and schedule a short prompt pointing to it.
|
|
311
|
+
- Thread reminders: when the user says "remind me about this in 2 hours", use \`--send-at\` with \`--thread\` to resurface the current thread:
|
|
296
312
|
|
|
297
313
|
kimaki send --thread thread_123 (or --session ses_123) --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
|
|
298
314
|
|
|
299
|
-
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
|
|
300
|
-
|
|
301
|
-
Scheduled tasks can maintain project memory by reading and updating an md file in the repository (for example \`docs/automation-notes.md\`) on each run.
|
|
315
|
+
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
|
|
302
316
|
|
|
303
317
|
Worktrees are useful for handing off parallel tasks that need to be isolated from each other (each session works on its own branch).
|
|
304
318
|
|
|
@@ -777,7 +791,7 @@ describe('system-message', () => {
|
|
|
777
791
|
- Main repo path (previous folder, DO NOT TOUCH): /repo
|
|
778
792
|
- To find the base branch (the branch this worktree was created from): \`git -C /repo symbolic-ref --short HEAD\`
|
|
779
793
|
- To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`
|
|
780
|
-
You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default.
|
|
794
|
+
You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. To merge this worktree into the main branch, run \`kimaki merge-worktree\`. If it reports rebase conflicts, resolve them and rerun until it succeeds.
|
|
781
795
|
</system-reminder>
|
|
782
796
|
"
|
|
783
797
|
`);
|
package/dist/task-runner.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Routes } from 'discord.js';
|
|
3
3
|
import { createDiscordRest } from './discord-urls.js';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
-
import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, getScheduledTask, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed,
|
|
5
|
+
import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, getScheduledTask, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, } from './database.js';
|
|
6
6
|
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
7
7
|
import { notifyError } from './sentry.js';
|
|
8
8
|
import { getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
|
|
@@ -23,7 +23,10 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
|
|
|
23
23
|
const marker = {
|
|
24
24
|
start: true,
|
|
25
25
|
scheduledKind: task.schedule_kind,
|
|
26
|
-
scheduledTaskId
|
|
26
|
+
// Only include scheduledTaskId for cron tasks. One-shot tasks are deleted
|
|
27
|
+
// after execution, so the ID would be stale by the time the bot processes
|
|
28
|
+
// the Discord event and tries to insert a session_start_sources row.
|
|
29
|
+
...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
|
|
27
30
|
...(payload.agent ? { agent: payload.agent } : {}),
|
|
28
31
|
...(payload.model ? { model: payload.model } : {}),
|
|
29
32
|
...(payload.username ? { username: payload.username } : {}),
|
|
@@ -59,7 +62,8 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
|
|
|
59
62
|
: {
|
|
60
63
|
start: true,
|
|
61
64
|
scheduledKind: task.schedule_kind,
|
|
62
|
-
scheduledTaskId
|
|
65
|
+
// Only include scheduledTaskId for cron tasks (see thread variant comment)
|
|
66
|
+
...(task.schedule_kind === 'cron' ? { scheduledTaskId: task.id } : {}),
|
|
63
67
|
...(payload.worktreeName ? { worktree: payload.worktreeName } : {}),
|
|
64
68
|
...(payload.cwd ? { cwd: payload.cwd } : {}),
|
|
65
69
|
...(payload.agent ? { agent: payload.agent } : {}),
|
|
@@ -149,7 +153,7 @@ async function executeScheduledTask({ rest, task, }) {
|
|
|
149
153
|
}
|
|
150
154
|
async function finalizeSuccessfulTask({ task, completedAt, }) {
|
|
151
155
|
if (task.schedule_kind === 'at') {
|
|
152
|
-
await
|
|
156
|
+
await deleteScheduledTask(task.id);
|
|
153
157
|
return;
|
|
154
158
|
}
|
|
155
159
|
if (!task.cron_expr) {
|
|
@@ -36,6 +36,7 @@ function normalizeWorktreeLifecycleText(text) {
|
|
|
36
36
|
.replaceAll(CHANNEL_WORKTREE_NAME, 'CHANNEL_WORKTREE_NAME')
|
|
37
37
|
.replaceAll(AUTO_WORKTREE_SUFFIX, 'AUTO_WORKTREE_NAME')
|
|
38
38
|
.replaceAll(WORKTREE_NAME, 'WORKTREE_NAME')
|
|
39
|
+
.replace(/opencode\/kimaki-rply-wth-exctly-snd-at-wt-[a-z0-9]+/g, 'AUTO_WORKTREE_BRANCH')
|
|
39
40
|
.replaceAll(WORKTREE_SUFFIX, 'SUFFIX')
|
|
40
41
|
.replace(/ses_[a-zA-Z0-9]+/g, 'ses_TEST')
|
|
41
42
|
.replace(/<#\d+>/g, '<#THREAD_ID>')
|
|
@@ -350,11 +351,24 @@ describe('worktree lifecycle', () => {
|
|
|
350
351
|
expect(sourceSessionAfterFork).toBe(sessionBefore);
|
|
351
352
|
const worktreeSession = await getThreadSession(worktreeThread.id);
|
|
352
353
|
expect(worktreeSession).toBeTruthy();
|
|
354
|
+
if (!worktreeSession)
|
|
355
|
+
throw new Error('Worktree session was not persisted');
|
|
353
356
|
expect(worktreeSession).not.toBe(sessionBefore);
|
|
354
357
|
await expect(getThreadWorktreeOrWorkspace(thread.id)).resolves.toBeUndefined();
|
|
355
358
|
const worktreeInfo = await getThreadWorktreeOrWorkspace(worktreeThread.id);
|
|
356
359
|
expect(worktreeInfo?.status).toBe('ready');
|
|
357
360
|
expect(worktreeInfo?.workspace_directory).toContain(WORKTREE_NAME);
|
|
361
|
+
const worktreeDirectory = worktreeInfo?.workspace_directory;
|
|
362
|
+
if (!worktreeDirectory)
|
|
363
|
+
throw new Error('Worktree directory was not persisted');
|
|
364
|
+
const getWorktreeClient = await initializeOpencodeForDirectory(worktreeDirectory);
|
|
365
|
+
if (getWorktreeClient instanceof Error)
|
|
366
|
+
throw getWorktreeClient;
|
|
367
|
+
const worktreeSessionResponse = await getWorktreeClient().session.get({
|
|
368
|
+
sessionID: worktreeSession,
|
|
369
|
+
directory: worktreeDirectory,
|
|
370
|
+
});
|
|
371
|
+
expect(worktreeSessionResponse.data?.directory).toBe(worktreeDirectory);
|
|
358
372
|
const runtimeAfter = getRuntime(thread.id);
|
|
359
373
|
expect(runtimeAfter).toBe(runtimeBefore);
|
|
360
374
|
expect(runtimeAfter.sdkDirectory).toBe(directories.projectDirectory);
|
|
@@ -683,9 +697,9 @@ describe('worktree lifecycle', () => {
|
|
|
683
697
|
» **kimaki-cli:**
|
|
684
698
|
Reply with exactly: send-auto-wt-SUFFIX
|
|
685
699
|
[embed]
|
|
686
|
-
🌳 **Worktree:
|
|
700
|
+
🌳 **Worktree: AUTO_WORKTREE_BRANCH**
|
|
687
701
|
📁 \`/tmp/worktrees/WORKTREE_NAME\`
|
|
688
|
-
🌿 Branch: \`
|
|
702
|
+
🌿 Branch: \`AUTO_WORKTREE_BRANCH\`
|
|
689
703
|
*using deterministic-provider/deterministic-v2*
|
|
690
704
|
⬥ ok"
|
|
691
705
|
`);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.23.0",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"exports": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"lintcn": "^0.7.1",
|
|
32
32
|
"tsx": "^4.20.5",
|
|
33
33
|
"undici": "^8.0.2",
|
|
34
|
+
"db": "^0.0.0",
|
|
34
35
|
"discord-digital-twin": "^0.1.1",
|
|
35
|
-
"opencode-deterministic-provider": "^0.0.1",
|
|
36
36
|
"opencode-cached-provider": "^0.0.1",
|
|
37
|
-
"
|
|
37
|
+
"opencode-deterministic-provider": "^0.0.1"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@ai-sdk/google": "^3.0.53",
|
|
@@ -70,9 +70,9 @@
|
|
|
70
70
|
"zod": "^4.3.6",
|
|
71
71
|
"zustand": "^5.0.11",
|
|
72
72
|
"errore": "^0.14.1",
|
|
73
|
-
"traforo": "^0.7.
|
|
74
|
-
"
|
|
75
|
-
"
|
|
73
|
+
"traforo": "^0.7.2",
|
|
74
|
+
"libsqlproxy": "^0.1.0",
|
|
75
|
+
"opencode-injection-guard": "^0.2.1"
|
|
76
76
|
},
|
|
77
77
|
"optionalDependencies": {
|
|
78
78
|
"@snazzah/davey": "^0.1.10",
|