kimaki 0.17.1 → 0.19.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/bin.js +5 -2
- package/dist/cache-drift-plugin.js +176 -0
- package/dist/cli-commands/bot.js +26 -0
- package/dist/cli-commands/send.js +34 -10
- package/dist/cli-commands/session.js +8 -5
- package/dist/cli-runner.js +70 -18
- package/dist/cli-runner.test.js +23 -0
- package/dist/cli.js +5 -0
- package/dist/commands/ask-question.js +13 -3
- package/dist/commands/ask-question.test.js +18 -2
- package/dist/commands/merge-worktree.js +11 -10
- package/dist/commands/new-worktree.js +55 -25
- package/dist/commands/worktrees.js +73 -67
- package/dist/database.js +54 -0
- package/dist/db.js +13 -0
- package/dist/db.test.js +12 -11
- package/dist/discord-bot.js +59 -14
- package/dist/discord-utils.js +9 -8
- package/dist/git-worktree-core.js +296 -0
- package/dist/image-utils.js +1 -1
- package/dist/interaction-handler.js +66 -0
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-workspace-adaptor.js +95 -0
- package/dist/markdown.js +59 -4
- package/dist/markdown.test.js +73 -2
- package/dist/opencode.js +1 -0
- package/dist/schema.js +16 -0
- package/dist/session-handler/thread-session-runtime.js +15 -15
- package/dist/store.js +1 -0
- package/dist/system-message.js +12 -0
- package/dist/system-message.test.js +12 -0
- package/dist/voice-handler.js +8 -0
- package/dist/worktree-lifecycle.e2e.test.js +187 -21
- package/dist/worktrees.js +13 -61
- package/dist/worktrees.test.js +17 -0
- package/package.json +3 -3
- package/skills/playwriter/SKILL.md +1 -1
- package/skills/sigillo/SKILL.md +36 -2
- package/src/bin.ts +8 -2
- package/src/cache-drift-plugin.ts +239 -0
- package/src/cli-commands/bot.ts +34 -0
- package/src/cli-commands/send.ts +37 -13
- package/src/cli-commands/session.ts +9 -6
- package/src/cli-runner.test.ts +27 -0
- package/src/cli-runner.ts +83 -20
- package/src/cli.ts +8 -0
- package/src/commands/ask-question.test.ts +20 -1
- package/src/commands/ask-question.ts +25 -7
- package/src/commands/merge-worktree.ts +12 -12
- package/src/commands/new-worktree.ts +66 -27
- package/src/commands/worktrees.ts +84 -86
- package/src/database.ts +90 -0
- package/src/db.test.ts +12 -11
- package/src/db.ts +14 -0
- package/src/discord-bot.ts +76 -18
- package/src/discord-utils.ts +9 -8
- package/src/git-worktree-core.ts +389 -0
- package/src/image-utils.ts +5 -4
- package/src/interaction-handler.ts +77 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/kimaki-workspace-adaptor.ts +115 -0
- package/src/markdown.test.ts +89 -2
- package/src/markdown.ts +63 -4
- package/src/opencode.ts +2 -1
- package/src/schema.sql +13 -0
- package/src/schema.ts +18 -0
- package/src/session-handler/thread-session-runtime.ts +15 -15
- package/src/store.ts +8 -0
- package/src/system-message.test.ts +12 -0
- package/src/system-message.ts +12 -0
- package/src/voice-handler.ts +11 -0
- package/src/worktree-lifecycle.e2e.test.ts +229 -24
- package/src/worktrees.test.ts +19 -0
- package/src/worktrees.ts +13 -75
package/dist/bin.js
CHANGED
|
@@ -69,8 +69,11 @@ else {
|
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// Progressive backoff: 2s, 4s, 8s, 16s, capped at 30s.
|
|
73
|
+
// Prevents hammering DNS/gateway during sustained network outages.
|
|
74
|
+
const delay = Math.min(RESTART_DELAY_MS * 2 ** (restartTimestamps.length - 1), 30_000);
|
|
75
|
+
console.error(`[kimaki] Process exited with ${reason}, restarting in ${(delay / 1000).toFixed(0)}s...`);
|
|
76
|
+
setTimeout(start, delay);
|
|
74
77
|
});
|
|
75
78
|
}
|
|
76
79
|
// Forward signals to child so graceful shutdown and heap snapshots work.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// OpenCode plugin that detects system prompt drift across turns.
|
|
2
|
+
// When the system prompt changes between user messages, it logs a warning
|
|
3
|
+
// with addition/deletion line counts so you know context cache was discarded.
|
|
4
|
+
// This helps spot plugins that mutate the system prompt unexpectedly.
|
|
5
|
+
import { diffLines } from 'diff';
|
|
6
|
+
import * as errore from 'errore';
|
|
7
|
+
import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath } from './plugin-logger.js';
|
|
8
|
+
import { initSentry, notifyError } from './sentry.js';
|
|
9
|
+
const logger = createPluginLogger('OPENCODE');
|
|
10
|
+
function normalizeSystemPrompt({ system }) {
|
|
11
|
+
return system.join('\n');
|
|
12
|
+
}
|
|
13
|
+
function buildTurnContext({ input, directory, }) {
|
|
14
|
+
const model = input.model
|
|
15
|
+
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
16
|
+
: undefined;
|
|
17
|
+
return {
|
|
18
|
+
agent: input.agent,
|
|
19
|
+
model,
|
|
20
|
+
directory,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function shouldSuppressDriftWarning({ previousContext, currentContext, }) {
|
|
24
|
+
if (!previousContext || !currentContext)
|
|
25
|
+
return false;
|
|
26
|
+
return (previousContext.agent !== currentContext.agent
|
|
27
|
+
|| previousContext.model !== currentContext.model
|
|
28
|
+
|| previousContext.directory !== currentContext.directory);
|
|
29
|
+
}
|
|
30
|
+
function countDiffLines({ beforeText, afterText, }) {
|
|
31
|
+
const changes = diffLines(beforeText, afterText);
|
|
32
|
+
let additions = 0;
|
|
33
|
+
let deletions = 0;
|
|
34
|
+
for (const change of changes) {
|
|
35
|
+
if (change.added)
|
|
36
|
+
additions += change.count ?? 0;
|
|
37
|
+
if (change.removed)
|
|
38
|
+
deletions += change.count ?? 0;
|
|
39
|
+
}
|
|
40
|
+
return { additions, deletions };
|
|
41
|
+
}
|
|
42
|
+
function getOrCreateSessionState({ sessions, sessionId, }) {
|
|
43
|
+
const existing = sessions.get(sessionId);
|
|
44
|
+
if (existing)
|
|
45
|
+
return existing;
|
|
46
|
+
const state = {
|
|
47
|
+
userTurnCount: 0,
|
|
48
|
+
previousTurnPrompt: undefined,
|
|
49
|
+
latestTurnPrompt: undefined,
|
|
50
|
+
latestTurnPromptTurn: 0,
|
|
51
|
+
comparedTurn: 0,
|
|
52
|
+
previousTurnContext: undefined,
|
|
53
|
+
currentTurnContext: undefined,
|
|
54
|
+
pendingCompareTimeout: undefined,
|
|
55
|
+
};
|
|
56
|
+
sessions.set(sessionId, state);
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
function handleSystemTransform({ input, output, sessions, }) {
|
|
60
|
+
const sessionId = input.sessionID;
|
|
61
|
+
if (!sessionId)
|
|
62
|
+
return;
|
|
63
|
+
const currentPrompt = normalizeSystemPrompt({ system: output.system });
|
|
64
|
+
const state = getOrCreateSessionState({ sessions, sessionId });
|
|
65
|
+
const currentTurn = state.userTurnCount;
|
|
66
|
+
state.latestTurnPrompt = currentPrompt;
|
|
67
|
+
state.latestTurnPromptTurn = currentTurn;
|
|
68
|
+
if (currentTurn <= 1)
|
|
69
|
+
return;
|
|
70
|
+
if (state.comparedTurn === currentTurn)
|
|
71
|
+
return;
|
|
72
|
+
const previousPrompt = state.previousTurnPrompt;
|
|
73
|
+
state.comparedTurn = currentTurn;
|
|
74
|
+
if (!previousPrompt || previousPrompt === currentPrompt)
|
|
75
|
+
return;
|
|
76
|
+
if (shouldSuppressDriftWarning({
|
|
77
|
+
previousContext: state.previousTurnContext,
|
|
78
|
+
currentContext: state.currentTurnContext,
|
|
79
|
+
})) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const { additions, deletions } = countDiffLines({
|
|
83
|
+
beforeText: previousPrompt,
|
|
84
|
+
afterText: currentPrompt,
|
|
85
|
+
});
|
|
86
|
+
logger.warn(`[cache-drift] context cache discarded for session ${sessionId}: `
|
|
87
|
+
+ `system prompt changed since previous message (+${additions} / -${deletions} lines)`);
|
|
88
|
+
}
|
|
89
|
+
function getDeletedSessionId({ event }) {
|
|
90
|
+
if (event.type !== 'session.deleted')
|
|
91
|
+
return undefined;
|
|
92
|
+
const sessionInfo = event.properties?.info;
|
|
93
|
+
if (!sessionInfo || typeof sessionInfo !== 'object')
|
|
94
|
+
return undefined;
|
|
95
|
+
const id = sessionInfo.id;
|
|
96
|
+
return typeof id === 'string' ? id : undefined;
|
|
97
|
+
}
|
|
98
|
+
const cacheDriftPlugin = async ({ directory }) => {
|
|
99
|
+
initSentry();
|
|
100
|
+
const dataDir = process.env.KIMAKI_DATA_DIR;
|
|
101
|
+
if (dataDir) {
|
|
102
|
+
setPluginLogFilePath(dataDir);
|
|
103
|
+
}
|
|
104
|
+
const sessions = new Map();
|
|
105
|
+
return {
|
|
106
|
+
'chat.message': async (input) => {
|
|
107
|
+
const sessionId = input.sessionID;
|
|
108
|
+
if (!sessionId)
|
|
109
|
+
return;
|
|
110
|
+
const state = getOrCreateSessionState({ sessions, sessionId });
|
|
111
|
+
if (state.userTurnCount > 0
|
|
112
|
+
&& state.latestTurnPromptTurn === state.userTurnCount) {
|
|
113
|
+
state.previousTurnPrompt = state.latestTurnPrompt;
|
|
114
|
+
state.previousTurnContext = state.currentTurnContext;
|
|
115
|
+
}
|
|
116
|
+
state.currentTurnContext = buildTurnContext({ input, directory });
|
|
117
|
+
state.userTurnCount += 1;
|
|
118
|
+
},
|
|
119
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
120
|
+
const result = errore.try({
|
|
121
|
+
try: () => {
|
|
122
|
+
const sessionId = input.sessionID;
|
|
123
|
+
if (!sessionId)
|
|
124
|
+
return;
|
|
125
|
+
const state = getOrCreateSessionState({ sessions, sessionId });
|
|
126
|
+
if (state.pendingCompareTimeout) {
|
|
127
|
+
clearTimeout(state.pendingCompareTimeout);
|
|
128
|
+
}
|
|
129
|
+
// Delay one tick so other system-transform hooks can finish mutating
|
|
130
|
+
// output.system before we snapshot it for drift detection.
|
|
131
|
+
state.pendingCompareTimeout = setTimeout(() => {
|
|
132
|
+
state.pendingCompareTimeout = undefined;
|
|
133
|
+
try {
|
|
134
|
+
handleSystemTransform({ input, output, sessions });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
logger.warn(`[cache-drift] ${formatPluginErrorWithStack(err)}`);
|
|
138
|
+
void notifyError(err, 'cache drift plugin transform hook failed');
|
|
139
|
+
}
|
|
140
|
+
}, 0);
|
|
141
|
+
},
|
|
142
|
+
catch: (error) => {
|
|
143
|
+
return new Error('cache drift transform hook failed', { cause: error });
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
if (result instanceof Error) {
|
|
147
|
+
logger.warn(`[cache-drift] ${formatPluginErrorWithStack(result)}`);
|
|
148
|
+
void notifyError(result, 'cache drift plugin transform hook failed');
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
event: async ({ event }) => {
|
|
152
|
+
const result = errore.try({
|
|
153
|
+
try: () => {
|
|
154
|
+
if (event.type !== 'session.deleted')
|
|
155
|
+
return;
|
|
156
|
+
const deletedSessionId = getDeletedSessionId({ event });
|
|
157
|
+
if (!deletedSessionId)
|
|
158
|
+
return;
|
|
159
|
+
const state = sessions.get(deletedSessionId);
|
|
160
|
+
if (state?.pendingCompareTimeout) {
|
|
161
|
+
clearTimeout(state.pendingCompareTimeout);
|
|
162
|
+
}
|
|
163
|
+
sessions.delete(deletedSessionId);
|
|
164
|
+
},
|
|
165
|
+
catch: (error) => {
|
|
166
|
+
return new Error('cache drift event hook failed', { cause: error });
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
if (result instanceof Error) {
|
|
170
|
+
logger.warn(`[cache-drift] ${formatPluginErrorWithStack(result)}`);
|
|
171
|
+
void notifyError(result, 'cache drift plugin event hook failed');
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
export { cacheDriftPlugin };
|
package/dist/cli-commands/bot.js
CHANGED
|
@@ -185,6 +185,32 @@ cli
|
|
|
185
185
|
process.exit(EXIT_NO_RESTART);
|
|
186
186
|
}
|
|
187
187
|
});
|
|
188
|
+
cli
|
|
189
|
+
.command('bot token', 'Print the bot token for use in CI and automation (KIMAKI_BOT_TOKEN)')
|
|
190
|
+
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
191
|
+
.action(async (options) => {
|
|
192
|
+
try {
|
|
193
|
+
if (options.dataDir) {
|
|
194
|
+
setDataDir(options.dataDir);
|
|
195
|
+
}
|
|
196
|
+
initLogFile(getDataDir());
|
|
197
|
+
await initDatabase();
|
|
198
|
+
const botRow = await getBotTokenWithMode();
|
|
199
|
+
if (!botRow) {
|
|
200
|
+
cliLogger.error('No bot configured. Run `kimaki` first.');
|
|
201
|
+
process.exit(EXIT_NO_RESTART);
|
|
202
|
+
}
|
|
203
|
+
// Print the token to stdout so it can be captured by scripts.
|
|
204
|
+
// Use process.stdout.write to avoid extra newline from console.log
|
|
205
|
+
// when piping to other commands.
|
|
206
|
+
process.stdout.write(botRow.token + '\n');
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
|
|
211
|
+
process.exit(EXIT_NO_RESTART);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
188
214
|
cli
|
|
189
215
|
.command('bot status clear', 'Clear the bot presence/status')
|
|
190
216
|
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// Terminal send command for creating Discord threads and scheduling prompts.
|
|
2
|
+
// Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
|
|
3
|
+
// The local SQLite database (channel_directories) is NOT required for the basic
|
|
4
|
+
// flow: post message → create thread → remote bot picks it up. The local project
|
|
5
|
+
// directory mapping is only needed for --send-at, --wait, and --cwd.
|
|
2
6
|
import { goke } from 'goke';
|
|
3
7
|
import { z } from 'zod';
|
|
4
8
|
import { note } from '@clack/prompts';
|
|
@@ -271,9 +275,18 @@ cli
|
|
|
271
275
|
if (!threadData.parent_id) {
|
|
272
276
|
throw new Error(`Thread has no parent channel: ${targetThreadId}`);
|
|
273
277
|
}
|
|
278
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
279
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
280
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
281
|
+
// We only require it for features that genuinely need a local directory
|
|
282
|
+
// (scheduled tasks and --wait).
|
|
274
283
|
const channelConfig = await getChannelDirectory(threadData.parent_id);
|
|
275
|
-
if
|
|
276
|
-
|
|
284
|
+
// Guard early: fail before sending the message if a feature that
|
|
285
|
+
// needs local project directory mapping is requested.
|
|
286
|
+
if (!channelConfig && (parsedSchedule || options.wait)) {
|
|
287
|
+
const flag = parsedSchedule ? '--send-at' : '--wait';
|
|
288
|
+
throw new Error('Thread parent channel is not configured with a project directory. ' +
|
|
289
|
+
`${flag} requires a local project mapping. Run the bot first to sync channel data.`);
|
|
277
290
|
}
|
|
278
291
|
if (parsedSchedule) {
|
|
279
292
|
const payload = {
|
|
@@ -298,6 +311,7 @@ cli
|
|
|
298
311
|
channelId: threadData.parent_id,
|
|
299
312
|
threadId: targetThreadId,
|
|
300
313
|
sessionId: sessionId || undefined,
|
|
314
|
+
// channelConfig is guaranteed: early guard threw if missing with --send-at
|
|
301
315
|
projectDirectory: channelConfig.directory,
|
|
302
316
|
});
|
|
303
317
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
|
|
@@ -337,6 +351,8 @@ cli
|
|
|
337
351
|
process.stdout.write(`Session: ${existingSessionId}\n`);
|
|
338
352
|
process.stdout.write(`${threadUrl}\n`);
|
|
339
353
|
if (options.wait) {
|
|
354
|
+
// channelConfig is guaranteed here: early guard above already
|
|
355
|
+
// threw if channelConfig is missing when --wait is used.
|
|
340
356
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
341
357
|
await waitAndOutputSession({
|
|
342
358
|
threadId: targetThreadId,
|
|
@@ -352,17 +368,25 @@ cli
|
|
|
352
368
|
}
|
|
353
369
|
// Get channel info to extract directory from topic
|
|
354
370
|
const channelData = (await rest.get(Routes.channel(channelId)));
|
|
371
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
372
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
373
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
374
|
+
// We only require it for features that genuinely need a local directory
|
|
375
|
+
// (--send-at, --wait, --cwd).
|
|
355
376
|
const channelConfig = await getChannelDirectory(channelData.id);
|
|
356
|
-
if (!channelConfig && !notifyOnly) {
|
|
357
|
-
cliLogger.log('Channel not configured');
|
|
358
|
-
throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
|
|
359
|
-
}
|
|
360
377
|
const projectDirectory = channelConfig?.directory;
|
|
378
|
+
// Features that require a local project directory mapping
|
|
379
|
+
const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd);
|
|
380
|
+
if (!channelConfig && needsProjectDirectory) {
|
|
381
|
+
throw new Error(`Channel #${channelData.name} is not configured with a project directory. ` +
|
|
382
|
+
`${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
|
|
383
|
+
'Run the bot first to sync channel data.');
|
|
384
|
+
}
|
|
361
385
|
// Validate --cwd is inside the project or an existing git worktree.
|
|
362
386
|
let resolvedCwd;
|
|
363
387
|
if (options.cwd) {
|
|
364
|
-
// projectDirectory is guaranteed here:
|
|
365
|
-
//
|
|
388
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
389
|
+
// already threw if channelConfig is missing when --cwd is used.
|
|
366
390
|
const cwdResult = await resolveSessionWorkingDirectory({
|
|
367
391
|
projectDirectory: projectDirectory,
|
|
368
392
|
candidatePath: options.cwd,
|
|
@@ -510,8 +534,8 @@ cli
|
|
|
510
534
|
process.stdout.write(`Session: ${newSessionId}\n`);
|
|
511
535
|
process.stdout.write(`${threadUrl}\n`);
|
|
512
536
|
if (options.wait) {
|
|
513
|
-
// projectDirectory is guaranteed here:
|
|
514
|
-
//
|
|
537
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
538
|
+
// already threw if channelConfig is missing when --wait is used.
|
|
515
539
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
516
540
|
await waitAndOutputSession({
|
|
517
541
|
threadId: threadData.id,
|
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
import { spawn, execSync } from 'node:child_process';
|
|
12
12
|
import { createLogger, LogPrefix, initLogFile } from '../logger.js';
|
|
13
13
|
import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
|
|
14
|
-
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory,
|
|
14
|
+
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getThreadWorktreeOrWorkspace } from '../database.js';
|
|
15
15
|
import { ShareMarkdown } from '../markdown.js';
|
|
16
16
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
|
|
17
17
|
import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
|
|
@@ -29,9 +29,9 @@ const cli = goke();
|
|
|
29
29
|
async function resolveSessionDirectoryFromDatabase({ sessionId, }) {
|
|
30
30
|
const threadId = await getThreadIdBySessionId(sessionId);
|
|
31
31
|
if (threadId) {
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
32
|
+
const workspace = await getThreadWorktreeOrWorkspace(threadId);
|
|
33
|
+
if (workspace?.status === 'ready' && workspace.workspace_directory) {
|
|
34
|
+
return workspace.workspace_directory;
|
|
35
35
|
}
|
|
36
36
|
const { token: botToken } = await resolveBotCredentials({});
|
|
37
37
|
const rest = createDiscordRest(botToken);
|
|
@@ -126,6 +126,7 @@ cli
|
|
|
126
126
|
cli
|
|
127
127
|
.command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
|
|
128
128
|
.option('--project <path>', 'Project directory (defaults to cwd)')
|
|
129
|
+
.option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
|
|
129
130
|
.action(async (sessionId, options) => {
|
|
130
131
|
try {
|
|
131
132
|
const projectDirectory = path.resolve(options.project || '.');
|
|
@@ -137,8 +138,9 @@ cli
|
|
|
137
138
|
process.exit(EXIT_NO_RESTART);
|
|
138
139
|
}
|
|
139
140
|
// Try current project first (fast path)
|
|
141
|
+
const compactTools = !options.verbose;
|
|
140
142
|
const markdown = new ShareMarkdown(getClient());
|
|
141
|
-
const result = await markdown.generate({ sessionID: sessionId });
|
|
143
|
+
const result = await markdown.generate({ sessionID: sessionId, compactTools });
|
|
142
144
|
if (!(result instanceof Error)) {
|
|
143
145
|
process.stdout.write(result);
|
|
144
146
|
process.exit(0);
|
|
@@ -171,6 +173,7 @@ cli
|
|
|
171
173
|
const otherMarkdown = new ShareMarkdown(otherClient());
|
|
172
174
|
const otherResult = await otherMarkdown.generate({
|
|
173
175
|
sessionID: sessionId,
|
|
176
|
+
compactTools,
|
|
174
177
|
});
|
|
175
178
|
if (!(otherResult instanceof Error)) {
|
|
176
179
|
process.stdout.write(otherResult);
|
package/dist/cli-runner.js
CHANGED
|
@@ -37,6 +37,27 @@ export const KIMAKI_GATEWAY_PROXY_URL = process.env.KIMAKI_GATEWAY_PROXY_URL ||
|
|
|
37
37
|
export const KIMAKI_GATEWAY_PROXY_REST_BASE_URL = getGatewayProxyRestBaseUrl({
|
|
38
38
|
gatewayUrl: KIMAKI_GATEWAY_PROXY_URL,
|
|
39
39
|
});
|
|
40
|
+
export function getOpenUrlCommand(url, platform = process.platform) {
|
|
41
|
+
if (platform === 'darwin') {
|
|
42
|
+
return { command: 'open', args: [url] };
|
|
43
|
+
}
|
|
44
|
+
if (platform === 'win32') {
|
|
45
|
+
return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] };
|
|
46
|
+
}
|
|
47
|
+
return { command: 'xdg-open', args: [url] };
|
|
48
|
+
}
|
|
49
|
+
function openUrlInDefaultBrowser(url) {
|
|
50
|
+
const { command, args } = getOpenUrlCommand(url);
|
|
51
|
+
const child = spawn(command, args, {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: 'ignore',
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
});
|
|
56
|
+
child.on('error', (error) => {
|
|
57
|
+
cliLogger.warn('Failed to open install URL:', error instanceof Error ? error.message : String(error));
|
|
58
|
+
});
|
|
59
|
+
child.unref();
|
|
60
|
+
}
|
|
40
61
|
// Strip bracketed paste escape sequences from terminal input.
|
|
41
62
|
// iTerm2 and other terminals wrap pasted content with \x1b[200~ and \x1b[201~
|
|
42
63
|
// which can cause validation to fail on macOS. See: https://github.com/remorses/kimaki/issues/18
|
|
@@ -79,11 +100,23 @@ export function appIdFromToken(token) {
|
|
|
79
100
|
// but don't run the interactive wizard.
|
|
80
101
|
// In gateway mode, also sets store.discordBaseUrl so REST calls
|
|
81
102
|
// are routed through the gateway-proxy REST endpoint.
|
|
103
|
+
//
|
|
104
|
+
// Priority: KIMAKI_BOT_TOKEN env var takes precedence over saved DB
|
|
105
|
+
// credentials. This lets CI and cross-instance commands override the
|
|
106
|
+
// local bot identity. If the env token looks like a gateway credential
|
|
107
|
+
// (clientId:clientSecret), the gateway proxy REST URL is set automatically.
|
|
82
108
|
export async function resolveBotCredentials({ appIdOverride } = {}) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN;
|
|
110
|
+
if (envToken) {
|
|
111
|
+
const isGatewayToken = envToken.includes(':');
|
|
112
|
+
if (isGatewayToken) {
|
|
113
|
+
// Gateway tokens need REST calls routed through the proxy, not discord.com
|
|
114
|
+
store.setState({ discordBaseUrl: KIMAKI_GATEWAY_PROXY_REST_BASE_URL });
|
|
115
|
+
}
|
|
116
|
+
const appId = appIdOverride || appIdFromToken(envToken);
|
|
117
|
+
return { token: envToken, appId };
|
|
118
|
+
}
|
|
119
|
+
// Fall back to saved credentials in the database
|
|
87
120
|
const botRow = await getBotTokenWithMode().catch((e) => {
|
|
88
121
|
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
89
122
|
return null;
|
|
@@ -91,12 +124,6 @@ export async function resolveBotCredentials({ appIdOverride } = {}) {
|
|
|
91
124
|
if (botRow) {
|
|
92
125
|
return { token: botRow.token, appId: appIdOverride || botRow.appId };
|
|
93
126
|
}
|
|
94
|
-
// Fall back to env var for CI/headless deployments with no database
|
|
95
|
-
const envToken = process.env.KIMAKI_BOT_TOKEN;
|
|
96
|
-
if (envToken) {
|
|
97
|
-
const appId = appIdOverride || appIdFromToken(envToken);
|
|
98
|
-
return { token: envToken, appId };
|
|
99
|
-
}
|
|
100
127
|
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
|
|
101
128
|
process.exit(EXIT_NO_RESTART);
|
|
102
129
|
}
|
|
@@ -281,6 +308,28 @@ function readErrorField(error, key) {
|
|
|
281
308
|
}
|
|
282
309
|
return undefined;
|
|
283
310
|
}
|
|
311
|
+
/** Transient network errors that may resolve on retry (DNS down, gateway unreachable). */
|
|
312
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
313
|
+
'ENOTFOUND',
|
|
314
|
+
'ECONNREFUSED',
|
|
315
|
+
'ETIMEDOUT',
|
|
316
|
+
'ECONNRESET',
|
|
317
|
+
'EAI_AGAIN',
|
|
318
|
+
'EPIPE',
|
|
319
|
+
'EHOSTUNREACH',
|
|
320
|
+
'ENETUNREACH',
|
|
321
|
+
]);
|
|
322
|
+
export function isTransientNetworkError(error) {
|
|
323
|
+
if (!(error instanceof Error))
|
|
324
|
+
return false;
|
|
325
|
+
const code = error.code;
|
|
326
|
+
if (code && TRANSIENT_ERROR_CODES.has(code))
|
|
327
|
+
return true;
|
|
328
|
+
// discord.js wraps errors in cause chains
|
|
329
|
+
if (error.cause instanceof Error)
|
|
330
|
+
return isTransientNetworkError(error.cause);
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
284
333
|
export function isDiscordMemberLookupUnavailable(error) {
|
|
285
334
|
const status = readErrorField(error, 'status');
|
|
286
335
|
if (status === 403) {
|
|
@@ -827,13 +876,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
827
876
|
if (isInteractive) {
|
|
828
877
|
note(`Open this URL to install the Kimaki bot in your Discord server:\n\n${oauthUrl}\n\nDo not share this URL with anyone — it contains your credentials.\n\nIf you don't have a server, create one first (+ button in the Discord sidebar).`, 'Install Bot');
|
|
829
878
|
// Open URL in default browser
|
|
830
|
-
|
|
831
|
-
const openCmd = process.platform === 'darwin'
|
|
832
|
-
? 'open'
|
|
833
|
-
: process.platform === 'win32'
|
|
834
|
-
? 'start'
|
|
835
|
-
: 'xdg-open';
|
|
836
|
-
exec(`${openCmd} "${oauthUrl}"`);
|
|
879
|
+
openUrlInDefaultBrowser(oauthUrl);
|
|
837
880
|
}
|
|
838
881
|
else {
|
|
839
882
|
// Non-TTY: emit structured JSON so the host process can show the URL to the user.
|
|
@@ -1011,7 +1054,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1011
1054
|
possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
|
|
1012
1055
|
}),
|
|
1013
1056
|
]);
|
|
1014
|
-
|
|
1057
|
+
if (store.getState().autoUpgradeEnabled) {
|
|
1058
|
+
void backgroundUpgradeKimaki();
|
|
1059
|
+
}
|
|
1015
1060
|
// Start in-process Hrana server before database init. Required for the bot
|
|
1016
1061
|
// process because it serves as both the DB server and the single-instance
|
|
1017
1062
|
// lock (binds the fixed lock port). Without it, IPC and lock enforcement
|
|
@@ -1152,6 +1197,13 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1152
1197
|
catch (error) {
|
|
1153
1198
|
cliLogger.log('Failed to connect to Discord', discordClient.ws.gateway);
|
|
1154
1199
|
cliLogger.error('Error: ' + (error instanceof Error ? error.stack : String(error)));
|
|
1200
|
+
// Transient network errors (DNS down, gateway unreachable) should allow
|
|
1201
|
+
// the bin.ts wrapper to restart us after a delay. Only truly fatal errors
|
|
1202
|
+
// (bad token, invalid intent, etc.) should use EXIT_NO_RESTART.
|
|
1203
|
+
if (isTransientNetworkError(error)) {
|
|
1204
|
+
cliLogger.error('Transient network error, exiting for wrapper restart...');
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1155
1207
|
process.exit(EXIT_NO_RESTART);
|
|
1156
1208
|
}
|
|
1157
1209
|
await setBotToken(appId, token);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { getOpenUrlCommand } from './cli-runner.js';
|
|
3
|
+
describe('getOpenUrlCommand', () => {
|
|
4
|
+
const installUrl = 'https://kimaki.dev/discord-install?clientId=abc&clientSecret=def';
|
|
5
|
+
test('uses a shell-free opener on Windows', () => {
|
|
6
|
+
expect(getOpenUrlCommand(installUrl, 'win32')).toEqual({
|
|
7
|
+
command: 'rundll32.exe',
|
|
8
|
+
args: ['url.dll,FileProtocolHandler', installUrl],
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
test('uses open on macOS', () => {
|
|
12
|
+
expect(getOpenUrlCommand(installUrl, 'darwin')).toEqual({
|
|
13
|
+
command: 'open',
|
|
14
|
+
args: [installUrl],
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
test('uses xdg-open on Linux', () => {
|
|
18
|
+
expect(getOpenUrlCommand(installUrl, 'linux')).toEqual({
|
|
19
|
+
command: 'xdg-open',
|
|
20
|
+
args: [installUrl],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -43,6 +43,7 @@ cli
|
|
|
43
43
|
.option('--permission-timeout-minutes <minutes>', 'Permission prompt timeout in minutes before auto-rejecting (default: 10)')
|
|
44
44
|
.option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
|
|
45
45
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
46
|
+
.option('--no-auto-upgrade', 'Disable background auto-upgrade on startup')
|
|
46
47
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
47
48
|
.option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
|
|
48
49
|
.option('--allow-mention <type>', z
|
|
@@ -156,6 +157,7 @@ cli
|
|
|
156
157
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
157
158
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
158
159
|
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
160
|
+
...(options.noAutoUpgrade && { autoUpgradeEnabled: false }),
|
|
159
161
|
...(options.disableSync && { syncEnabled: false }),
|
|
160
162
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
161
163
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
@@ -182,6 +184,9 @@ cli
|
|
|
182
184
|
if (options.noCritique) {
|
|
183
185
|
cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
|
|
184
186
|
}
|
|
187
|
+
if (options.noAutoUpgrade) {
|
|
188
|
+
cliLogger.log('Auto-upgrade disabled: kimaki will not check for updates on startup');
|
|
189
|
+
}
|
|
185
190
|
if (options.disableSync) {
|
|
186
191
|
cliLogger.log('Background sync disabled: external OpenCode sessions will not appear in Discord');
|
|
187
192
|
}
|
|
@@ -11,6 +11,14 @@ const logger = createLogger(LogPrefix.ASK_QUESTION);
|
|
|
11
11
|
// TTL prevents unbounded growth if user never answers a question.
|
|
12
12
|
const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
13
13
|
export const pendingQuestionContexts = new Map();
|
|
14
|
+
export function areAllQuestionsAnswered({ totalQuestions, answers, }) {
|
|
15
|
+
for (let i = 0; i < totalQuestions; i++) {
|
|
16
|
+
if (!answers[i]) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
14
22
|
export function findPendingQuestionContextForRequest({ threadId, requestId, }) {
|
|
15
23
|
for (const [contextHash, context] of pendingQuestionContexts) {
|
|
16
24
|
if (context.thread.id !== threadId) {
|
|
@@ -64,7 +72,6 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
|
|
|
64
72
|
questions: input.questions,
|
|
65
73
|
answers: {},
|
|
66
74
|
totalQuestions: input.questions.length,
|
|
67
|
-
answeredCount: 0,
|
|
68
75
|
contextHash,
|
|
69
76
|
};
|
|
70
77
|
pendingQuestionContexts.set(contextHash, context);
|
|
@@ -163,6 +170,10 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
163
170
|
logger.error(`Question index ${questionIndex} not found in context`);
|
|
164
171
|
return;
|
|
165
172
|
}
|
|
173
|
+
if (context.answers[questionIndex]) {
|
|
174
|
+
logger.log(`Ignored duplicate answer for question ${context.requestId} index ${questionIndex}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
166
177
|
// Check if "other" was selected
|
|
167
178
|
if (selectedValues.includes('other')) {
|
|
168
179
|
// User wants to provide custom answer
|
|
@@ -176,7 +187,6 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
176
187
|
return question.options[optIdx]?.label || `Option ${optIdx + 1}`;
|
|
177
188
|
});
|
|
178
189
|
}
|
|
179
|
-
context.answeredCount++;
|
|
180
190
|
// Update this question's message: show answer and remove dropdown
|
|
181
191
|
const answeredText = context.answers[questionIndex].join(', ');
|
|
182
192
|
await interaction.editReply({
|
|
@@ -186,7 +196,7 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
186
196
|
const username = interaction.user.globalName || interaction.user.username;
|
|
187
197
|
await sendThreadMessage(context.thread, `» **${username}:** ${answeredText}`);
|
|
188
198
|
// Check if all questions are answered
|
|
189
|
-
if (context
|
|
199
|
+
if (areAllQuestionsAnswered(context)) {
|
|
190
200
|
// All questions answered - send result back to session
|
|
191
201
|
await submitQuestionAnswers(context);
|
|
192
202
|
deletePendingQuestionContextsForRequest({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests AskUserQuestion request deduplication and cleanup helpers.
|
|
2
2
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
3
|
-
import { deletePendingQuestionContextsForRequest, pendingQuestionContexts, showAskUserQuestionDropdowns, } from './ask-question.js';
|
|
3
|
+
import { areAllQuestionsAnswered, deletePendingQuestionContextsForRequest, pendingQuestionContexts, showAskUserQuestionDropdowns, } from './ask-question.js';
|
|
4
4
|
function createFakeThread() {
|
|
5
5
|
const send = vi.fn(async () => {
|
|
6
6
|
return { id: 'msg-1' };
|
|
@@ -69,7 +69,6 @@ describe('ask-question', () => {
|
|
|
69
69
|
}],
|
|
70
70
|
answers: {},
|
|
71
71
|
totalQuestions: 1,
|
|
72
|
-
answeredCount: 0,
|
|
73
72
|
contextHash: 'ctx-1',
|
|
74
73
|
};
|
|
75
74
|
pendingQuestionContexts.set('ctx-1', baseContext);
|
|
@@ -89,4 +88,21 @@ describe('ask-question', () => {
|
|
|
89
88
|
expect(removed).toBe(2);
|
|
90
89
|
expect([...pendingQuestionContexts.keys()]).toEqual(['ctx-3']);
|
|
91
90
|
});
|
|
91
|
+
test('requires every question to have an answer', () => {
|
|
92
|
+
expect(areAllQuestionsAnswered({
|
|
93
|
+
totalQuestions: 3,
|
|
94
|
+
answers: {
|
|
95
|
+
0: ['Alpha'],
|
|
96
|
+
2: ['Gamma'],
|
|
97
|
+
},
|
|
98
|
+
})).toBe(false);
|
|
99
|
+
expect(areAllQuestionsAnswered({
|
|
100
|
+
totalQuestions: 3,
|
|
101
|
+
answers: {
|
|
102
|
+
0: ['Alpha'],
|
|
103
|
+
1: ['Beta'],
|
|
104
|
+
2: ['Gamma'],
|
|
105
|
+
},
|
|
106
|
+
})).toBe(true);
|
|
107
|
+
});
|
|
92
108
|
});
|